自动机器学习实战-全-

自动机器学习实战(全)

原文:Automated Machine Learning in Action

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

自动机器学习(AutoML)的目标是使机器学习(ML)对每个人可访问,包括医生、土木工程师、材料科学家和小企业主,以及统计学家和计算机科学家。这个长期愿景与微软 Office 的愿景非常相似——使普通用户能够轻松创建文档和准备报告——以及智能手机中的相机,方便在任何时间、任何地点拍照。尽管机器学习社区已经投入了大量研发努力来追求这一目标,但通过与领域专家和数据科学家的合作,我们确定,揭示 AutoML 背后的魔法,包括基本概念、算法和工具,有着很高的需求。

首先,我们想分享几个让我们走到这一步的步骤。(好吧,如果你想的话,现在可以跳到我们的主要内容,但嘿,谁不喜欢一个好故事呢?)

我们在多年前就开始了数据科学和机器学习的旅程,并从那时起一直在从头研究和发展机器学习算法和系统。在早期,我们像许多人一样,被复杂的方程式、不稳定的结果和难以理解的超参数组合所折磨。后来,越来越多的先进算法被开发出来,开源实现也变得可用。不幸的是,训练一个有效的机器学习/深度学习模型仍然非常像炼金术,成为一名有能力的炼金术士需要多年的训练……是的,我们是有证书的炼金术士。

这些年来,我们遇到了许多想要尝试被称为机器学习的神奇工具的领域专家,因为它在许多任务上的卓越性能(或者简单地说,因为每个人都谈论它)。不出所料,它在许多数据集上表现良好,并改进了传统的基于规则或启发式的方法。在与许多具有相似任务的人反复合作(分类、聚类和预测)之后,我们不仅厌倦了应用 ML 工具,而且强烈地感觉到我们可以为所有人民主化 ML。AutoML,从这里开始!

自那以后,我们一直在进行一个名为“数据驱动模型发现”(D3M)的项目,该项目由 DARPA 支持,并启动了开源项目 AutoKeras。我们很高兴看到许多人对我们开发的软件感兴趣,他们为我们开发的工具提供了大量的积极和尖锐的反馈。同时,我们有幸结识并与其他在类似问题上工作的杰出研究人员和工程师合作。一切都在正确的方向上发展!

随着我们与越来越多的数据科学家和机器学习工程师合作,我们的愿景也在不断演变。最初,我们只想帮助人们通过几行代码快速利用机器学习,但随着我们面临越来越多的下游任务和问题,我们逐渐意识到,要实现这一目标还有很长的路要走。最紧迫的是,许多从业者都在开发自己的 AutoML 系统,这些系统能够很好地解决他们自己的内部小规模问题,例如自动异常检测、自动推荐系统和自动特征工程。我们的目标随后变成了让机器学习对每个人来说都触手可及。哎呀!这似乎和我们的原始计划一样!为了更好地实现这一目标,我们决定花大量时间撰写这本书,帮助您更好地使用和轻松开发 AutoML 工具。

我们希望您喜欢这本书,并期待您的反馈!

致谢

我们想感谢所有在我们撰写这本书期间帮助我们的人,没有他们这本书是不可能完成的。名单上的第一人是 François Chollet。他不仅为我们书籍的内容提供了宝贵的指导和反馈,还对 KerasTuner 和 AutoKeras 的设计和实现做出了重大贡献,使得这些库的使用变得如此愉快。我们还非常感激他在 Keras 上的出色工作,这为超参数调整和 AutoML 工作奠定了坚实的基础。

感谢所有为 KerasTuner 和 AutoKeras 开源项目做出贡献的开源贡献者,他们提供了宝贵的反馈,甚至代码贡献,使这些开源库变得如此易于使用。尽管我们没有全部见过你们,但你们的代码成为了这个庞大生态系统不可或缺的一部分,帮助了成千上万(甚至可能是数百万)的人。

我们衷心感谢德克萨斯 A&M 大学 DATA 实验室的同事们,在撰写这本书的过程中给予了我们帮助。我们特别感谢 Yi-Wei Chen,他帮助我们撰写了第九章的示例,使这本书更加完善。

致所有审稿人:Alain Couniot、Amaresh Rajasekharan、Andrei Paleyes、David Cronkite、Dewayne Cushman、Didier Garcia、Dimitris Polychronopoulos、Dipkumar Patel、Gaurav Kumar Leekha、Harsh Raval、Howard Bandy、Ignacio Ruiz、Ioannis Atsonios、Lucian Mircea Sasu、Manish Jain、Marco Carnini、Nick Vazquez、Omar El Malak、Pablo Roccatagliata、Richard Tobias、Richard Vaughan、Romit Singhai、Satej Kumar Sahu、Sean Settle、Sergio Govoni、Sheik Uduman Ali M、Shreesha Jagadeesh、Stanley Anozie、Steve D Sussman、Thomas Joseph Heiman、Venkatesh Rajagopal、Viton Vitanis、Vivek Krishnan、Walter Alexander Mata López、Xiangbo Mao 和 Zachery Beyel,您们细致的审稿和建议给了我们继续完善这本书的动力。

最后,没有 Manning Publications 的杰出人士,这本书是无法完成的。我们特别感谢我们的编辑 Toni Arritola 和 Rachel Head,他们提供了宝贵的评论,并在修订我们的稿件上勤奋工作。他们使本书易于阅读,并教会了我们如何写一本好书。我们还要感谢生产团队中的 Paul Wells、Andy Marinkovich 和 Keri Hales,以及我们的技术校对员 Karsten Strøbaek 和 Ninoslav Čerkez。没有你们,这本书是无法写成的。

关于本书

《自动机器学习实战》旨在帮助您学习 AutoML 的基本概念,并采用 AutoML 技术来解决机器学习任务,在实践中改进机器学习流程,借助如 AutoKeras 和 KerasTuner 等高级 AutoML 工具包。本书从关注 AutoML 的元素及其与机器学习的联系开始,然后逐渐引导您了解与 AutoML 问题合作的无形方面——从那些需要最少机器学习经验的问题到那些允许最灵活定制的那些。

适合阅读本书的人群

本书旨在为希望学习 AutoML 基础知识并采用 AutoML 技术的学生、教师、实践者和研究人员提供系统性的指导。我们的意图是避免繁重的数学公式和符号,而是从用户和开发者的角度,通过具体的用法示例和代码设计片段来介绍 AutoML 概念和技术。

本书组织结构:路线图

本书分为三个主要部分,共涵盖九个章节。第一部分介绍了机器学习的核心概念和一些流行模型,以帮助读者理解基本机器学习构建块,并获取学习 AutoML 的知识。对于那些没有太多经验解决机器学习问题的人来说,务必阅读本书的这一部分,以便为学习 AutoML 做好准备。

  • 第一章介绍了自动机器学习的定义、核心思想和概念。

  • 第二章通过几个具体的机器学习问题解决实例,帮助您理解机器学习构建块,并获取学习 AutoML 的知识。

  • 第三章介绍了深度学习的基本构建块,并作为通往更好地理解本书第二部分介绍的生成和调整深度学习方法的 AutoML 方法的垫脚石。

第二部分解释了如何采用 AutoML 来解决机器学习问题,并在实践中改进机器学习解决方案。

  • 第四章教您如何使用 AutoML 为监督学习问题创建一个端到端的深度学习解决方案。

  • 第五章讨论了如何根据您的需求自定义 AutoML 搜索空间,并自动发现针对不同类型任务的某些深度学习解决方案。

  • 第六章深入探讨了 AutoML 搜索空间的定制。分层设计为你调整无监督学习模型和优化算法提供了更大的灵活性。

第三部分从搜索方法和加速策略的角度探讨了某些高级 AutoML 设计和配置。

  • 第七章讨论了如何实现一个顺序搜索方法来探索 AutoML 搜索空间。

  • 第八章介绍了各种技术,以有限的计算资源加速搜索过程。

  • 第九章回顾了我们所涵盖的核心概念,并为你提供了一份资源列表和策略,以扩展你的 AutoML 视野并保持与最新技术的同步。

关于代码

本书包含许多源代码示例,既有编号列表,也有与普通文本混排。在这两种情况下,源代码都使用固定宽度字体格式化,如这样,以将其与普通文本区分开来。有时代码也会加粗,以突出显示与章节中先前步骤相比有所改变的代码,例如当新功能添加到现有代码行时。

在许多情况下,原始源代码已被重新格式化;我们添加了换行并重新调整了缩进,以适应书中的可用页面空间。在极少数情况下,即使这样也不够,列表中还包括了行续行标记(➥)。此外,当代码在文本中描述时,源代码中的注释通常也会从列表中删除。代码注释伴随着许多列表,突出显示重要概念。

你可以从本书的 liveBook(在线)版本中获取可执行的代码片段,网址为livebook.manning.com/book/automated-machine-learning-in-action。本书中示例的完整代码可以从 Manning 网站下载,网址为mng.bz/y48p

随着本书中使用的技术和开源库的持续发展和演变,本书中的源代码示例可能会在未来发生变化。请参考我们的 GitHub 仓库(mng.bz/M2ZQ),作为代码示例的最新真实来源。

liveBook 讨论论坛

购买 Automated Machine Learning in Action 包括免费访问 liveBook,Manning 的在线阅读平台。使用 liveBook 的独家讨论功能,您可以在全球范围内或针对特定章节或段落附加评论。为自己做笔记、提问和回答技术问题以及从作者和其他用户那里获得帮助都非常简单。要访问论坛,请访问 livebook.manning.com/book/automated-machine-learning-in-action/discussion。您还可以在 livebook.manning.com/discussion 上了解更多关于 Manning 论坛和行为准则的信息。

Manning 对读者的承诺是提供一个场所,让读者之间以及读者与作者之间可以进行有意义的对话。这不是对作者参与特定数量承诺的承诺,作者对论坛的贡献仍然是自愿的(且未付费)。我们建议您尝试向作者提出一些挑战性的问题,以免他们的兴趣转移!只要本书有售,论坛和以前讨论的存档将从出版社的网站提供访问。

其他在线资源

查看以下资源,以获得关于本书涵盖主题的额外帮助:

  • GitHub 页面 (mng.bz/aDEj) 提供了一个提交有关我们书籍的问题或提供评论的绝佳场所。

  • AutoKeras 的 GitHub 讨论区 (mng.bz/g4ve) 同样是一个提问和帮助他人的绝佳场所。帮助他人是学习的好方法!

关于作者

01-Song

宋庆泉博士是领英 AI 基金团队的一名机器学习和相关性工程师。他在德克萨斯 A&M 大学获得了计算机科学博士学位。他的研究兴趣包括自动化机器学习、动态数据分析、张量分解,以及它们在推荐系统和社交网络中的应用。他是 AutoKeras 的作者之一。他的论文已在 KDD、NeurIPS、数据发现知识交易(TKDD)等主要数据挖掘和机器学习场所发表。

02-Jin

金海峰博士是谷歌 Keras 团队的一名软件工程师。他是 AutoKeras 的创造者,也是 KerasTuner 项目的负责人。他还是 Keras 和 TensorFlow 的贡献者。他在德克萨斯 A&M 大学获得了计算机科学博士学位。他的研究兴趣集中在机器学习和 AutoML。

03-Hu

夏“本”胡博士是莱斯大学计算机科学系的副教授。胡博士在包括 NeurIPS、ICLR、KDD、WWW、IJCAI 和 AAAI 在内的几个主要学术场合发表了 100 多篇论文。他团队开发的开源软件包 AutoKeras 已成为 GitHub 上使用最广泛的自动深度学习系统(拥有超过 8,000 颗星和 1,000 次分支)。此外,他在深度协同过滤、异常检测和知识图谱方面的工作分别被纳入 TensorFlow 软件包、苹果生产系统和必应生产系统。他的论文在 WWW、WSDM 和 ICDM 等场合获得了数个最佳论文(候选人)奖项。他是 NSF 职业生涯奖和 ACM SIGKDD 新星奖的获得者。他的工作被引用超过 10,000 次,h 指数为 43。他是 2020 年 WSDM 会议的共同大会主席。

关于封面插图

《自动机器学习实战》的封面图是“阿拉贡人”,或称来自阿拉贡的人,取自雅克·格拉塞·德·圣索沃尔所著的书籍,该书于 1797 年出版。每一幅插图都是手工精心绘制和着色的。

在那些日子里,人们通过他们的服饰就能轻易地识别出他们居住的地方以及他们的职业或社会地位。曼宁通过基于几个世纪前丰富多样的地域文化的书封面来庆祝当今计算机行业的创新精神和主动性,这些文化通过如这一系列图片被重新带回生活。

第一部分 AutoML 基础

书的前三章为您介绍了一些机器学习的基本概念和模型,帮助您理解机器学习的基本构建块,并为学习 AutoML 打下基础。您将在第一章中开始对 AutoML、其概念以及它与通用机器学习的联系有所了解。您将学习 AutoML 的研究价值和实际效益。第二章介绍了用于解决机器学习问题的经典机器学习流程。考虑到深度学习模型在人工智能社区以及更广泛的领域中的普及,在第三章中,我们通过三种流行类型的模型示例,涵盖了深度学习的基本知识。我们并未涉及复杂的深度学习概念,而是仅介绍了基本构建块和三种应用于不同数据格式的经典模型。如果您在机器学习、深度学习以及如何使用 Python 应用它们方面没有太多经验,您应该绝对先完整阅读第一部分,然后再继续学习第二部分中 AutoML 的实际应用。您还可以在附录 B 中找到更多示例,以便在阅读本书的这一部分后,对基本机器学习流程有更深入的了解。

1 从机器学习到自动化机器学习

本章涵盖

  • 定义并介绍机器学习的基本概念

  • 描述自动化机器学习的动机和高级概念

人工智能(AI),它渗透到日常生活的许多方面,近年来已被广泛研究。它试图通过允许计算设备像人类一样感知环境来自动化任务。作为人工智能的一个分支,机器学习(ML)使计算机能够通过自我探索数据来执行任务。它允许计算机学习,因此它可以完成我们不知道如何命令它去做的事情。但是,入门的门槛很高:学习涉及的技术和积累应用所需的经验的成本意味着没有太多专业知识的从业者难以使用机器学习。将机器学习技术从象牙塔中取出并使其对更多人可及,正成为研究和工业界的关键焦点。为此,自动化机器学习(AutoML)已成为一个主流的研究领域。其目标是模拟人类专家如何解决机器学习问题,并自动发现给定问题的最佳机器学习解决方案,从而让没有丰富经验的从业者能够访问现成的机器学习技术。除了对新手有益外,AutoML 还将减轻专家和数据科学家设计和配置机器学习模型的负担。作为一个前沿话题,它对大多数人来说都是新的,其当前的能力常常被大众媒体夸大。为了让你对 AutoML 有一个大致的了解,本章提供了一些背景知识,介绍了基本概念,并指导你了解其研究价值和实际效益。让我们从一个玩具示例开始。

1.1 自动化机器学习的一瞥

假设你想要设计一个机器学习模型来识别图像中的手写数字。该机器学习模型将图像作为输入,并输出每个图像中相应的数字(见图 1.1)。

01-01

图 1.1 使用机器学习模型识别手写数字

如果您不熟悉机器学习,让我们用一个具有 Python 风格的程序示例来说明我们通常如何在实践中实现这个目标。我们以一个从类中实例化的机器学习模型作为对象,如列表 1.1 所示。这个类对应于我们希望在模型中使用的特定类型的机器学习算法(一系列过程)。¹ 要实例化一个模型,除了选择要使用的算法类之外,我们还需要向算法提供一些历史数据和参数(arg1 和 arg2)。这里使用的历史数据包括手写数字的图像,其标签(对应的数字)已经已知。这有助于机器(或机器学习算法)进行学习过程——即学习如何识别图像中的数字,类似于如何训练一个孩子从图片中识别物体。(您将在后面的章节中看到这个过程的细节。)这里的参数用于控制算法,指导它如何进行这个过程。生成的机器学习模型将能够使用下一个列表中的第二行代码预测以前未见过的图像中的数字(见图 1.1)。

列表 1.1 简化的机器学习过程

ml_model = MachineLearningAlgorithm1(
    arg1=..., arg2=..., data=historical_images)                     ❶
digits=[model.predict_image_digit(image) for image in new_images]   ❷

❶ 创建机器学习模型

❷ 使用机器学习模型进行预测

如您从代码中看到的,除了我们可能需要自己准备的数据集之外,我们还需要根据我们的先验知识提供以下两个东西来处理任务:

  • 要使用的机器学习算法(或方法);即 MachineLearningAlgorithm1

  • 算法的参数

在实践中,选择算法和配置其参数可能很困难。让我们以算法选择为例。作为一个初学者,典型的方法是收集一些学习资料,探索一些相关任务的代码,并确定一组您可能能够用于当前任务的机器学习算法。然后,您可以在您的历史数据上逐一尝试它们(正如我们在列表 1.1 中所做的那样),并根据它们在识别图像中的数字时的性能选择最佳的一个。这个重复的过程将在下一个代码示例中总结。

列表 1.2 选择机器学习算法的简单方法

ml_algorithm_pool = [
    MachineLearningAlgorithm1,                  ❶
    MachineLearningAlgorithm2,                  ❶
    ...,                                        ❶
    MachineLearningAlgorithmN,                  ❶
]
for ml_algorithm in ml_algorithm_pool:          ❷
    model = ml_algorithm(                       ❸
        arg1=..., arg2=...,                     ❸
        data=historical_images)                 ❸
    result = evaluate(model)                    ❸
    push result into the result_pool
    push model into the model_pool
best_ml_model = pick_the_best(result_pool,
                              ml_model_pool)    ❹
return best_ml_model

❶ 要测试的机器学习算法池

❷ 遍历所有候选的机器学习算法

❸ 根据每个机器学习算法实例化和评估机器学习模型

❹ 根据性能选择最佳机器学习模型

这个过程看起来直观,但如果您没有太多的机器学习知识或经验,可能需要花费数小时或数天。原因有以下几点。首先,收集一组可行的机器学习算法可能具有挑战性。您可能需要探索文献,识别最先进的算法,并学习如何实现它们。其次,可行的机器学习算法数量可能非常大。逐一尝试可能不是一个好的选择,甚至可能成为障碍。第三,每个算法都有自己的参数。正确配置它们需要专业知识、经验和甚至一些运气。

是否有更好的方法来做这件事?是否有可能让机器自动为你完成?如果你面临过类似的问题,并希望以更节省劳动力的方式采用机器学习(ML),AutoML 可能就是你要找的工具。简单来说,AutoML 模仿了前面伪代码中描述的手动过程。它试图自动化选择和配置机器学习算法的重复和繁琐的过程,并可能让你访问许多高级算法,即使你不知道它们的存在。以下两行伪代码说明了如何使用 AutoML 算法生成机器学习解决方案:

automl_model = AutoMLAlgorithm()
best_ml_model = automl_model.generate_model(data=historical_images)

从 AutoML 算法创建一个 AutoML 模型对象意味着你甚至不需要提供要测试的机器学习算法池,你只需将数据输入其中,就可以生成所需的模型。

但你如何选择一个 AutoML 算法?它将选择哪些机器学习算法?它是如何评估它们并选择一个模型的?在继续之前,我将给你一些关于机器学习的背景知识,这样你就能更好地理解 AutoML 自动化的内容以及如何在实践中使用它来节省时间和精力。这里的重点将放在你需要了解的内容上,以便学习和使用 AutoML。如果你想了解更多关于这些算法的信息,我建议参考其他机器学习书籍,例如 Peter Harrington 的《机器学习实战》(Manning, 2012)和 François Chollet 的《Python 深度学习》(2nd ed.,Manning, 2021)。对于已经熟悉机器学习基础知识的读者,下一节将作为一个复习,确保我们对一些术语有共同的理解,并为以下对 AutoML 的介绍提供更好的动机。

1.2 开始学习机器学习

本节简要介绍了机器学习——它是什么,机器学习算法中的关键组件,以及如何根据选定的算法和数据输入创建机器学习模型。学习这些基础知识对于理解下一节中介绍的 AutoML 概念至关重要。

1.2.1 什么是机器学习?

在机器学习出现之前,人工智能研究中的主导范式是符号 AI,计算机只能根据人类明确输入的预定义规则处理数据。机器学习的出现通过使知识能够从数据中隐式学习而革命性地改变了编程范式。例如,假设你希望机器能够自动识别苹果和香蕉的图像。在符号 AI 中,你需要向 AI 方法提供与推理过程相关的人类可读规则,可能指定颜色和形状等特征。相比之下,机器学习算法接受一系列图像及其相应的标签(“香蕉”或“苹果”),并输出学习到的规则,这些规则可以用来预测未标记的图像(见图 1.2)。

01-02

图 1.2 符号 AI 和机器学习的比较

机器学习的基本目标是自动化泛化。自动化意味着 ML 算法在提供的数据上训练,以自动从数据中提取规则(或模式)。它模仿人类思维,并允许机器通过与提供给它的历史数据进行交互来提高自身,这我们称之为训练学习。这些规则随后被用来在新数据上执行重复的预测,而不需要人为干预。例如,在图 1.2 中,ML 算法与提供的苹果和香蕉图像进行交互,并在训练过程中提取一个颜色规则,使其能够通过训练过程识别它们。这些规则可以帮助机器在没有人类监督的情况下对新图像进行分类,这我们称之为对新数据泛化。泛化的能力是评估 ML 算法好坏的重要标准。在这种情况下,假设一个黄色苹果的图像被输入到 ML 算法中——颜色规则将无法使其正确判断它是苹果还是香蕉。一个学习并应用形状特征进行预测的 ML 算法可能会提供更好的预测。

1.2.2 机器学习过程

ML 算法通过接触具有已知输出的示例来学习规则。这些规则预期能够使它将输入转换为有意义的输出,例如将手写数字的图像转换为相应的数字。因此,学习的目标也可以被视为使数据转换成为可能。学习过程通常需要以下两个组件:

  • 数据输入——要输入到 ML 算法中的目标任务的实例数据,例如,在图像识别问题中(见图 1.2),一组苹果和香蕉图像及其相应的标签

  • 学习算法——一种基于数据输入推导模型的数学过程,它包含以下四个要素:

    • 一个具有从数据中学习的一组参数的 ML 模型

    • 一种测量模型性能(如预测准确度)的测量方法,使用当前参数

    • 一种更新模型的方法,我们称之为优化方法

    • 一个停止标准,以确定学习过程何时应该停止

在模型参数初始化后,² 学习算法可以通过根据测量结果迭代地修改参数来更新模型,直到达到停止标准。这个测量结果被称为训练阶段的损失函数(或目标函数),它衡量模型预测与真实目标之间的差异。这个过程如图 1.3 所示。

01-03

图 1.3 训练 ML 模型的过程

让我们通过一个例子来帮助你更好地理解学习过程。想象一下,我们有一组在二维空间中的数据点(见图 1.4)。每个点要么是黑色,要么是白色。我们希望构建一个机器学习模型,每当有一个新的点到来时,可以根据点的位置判断这个点是黑色还是白色。实现这个目标的一个直接方法是根据手头的数据点在二维空间中绘制一条水平线,将其分割成两部分。这条线可以被视为一个机器学习模型。其参数是水平位置,可以通过提供的数据点进行更新和学习。结合图 1.3 中介绍的学习过程,所需组件可以总结如下:

  • 数据输入是一组由它们在二维空间中的位置描述的黑白点。

  • 学习算法由以下四个选定的组件组成:

    • 机器学习模型——一条可以表示为 y = a 的水平线,其中 a 是可以通过算法更新的参数。

    • 准确度测量——根据模型正确标记的点所占的百分比。

    • 优化方法——通过一定的距离上下移动线。这个距离可以与每次迭代的测量值相关。它将一直移动,直到满足停止标准。

    • 停止标准——当测量值为 100%时停止,这意味着所有手头的点都根据当前线被正确标记。

01-04

图 1.4 学习过程的一个示例:学习一条水平线来分割黑白点

在图 1.4 所示的例子中,学习算法经过两次迭代就达到了期望的线,这条线正确地分割了所有输入点。但在实际应用中,这个标准可能并不总是满足。它取决于输入数据的分布、选定的模型类型以及模型是如何被测量和更新的。我们通常需要选择不同的组件并尝试不同的组合来调整学习过程,以获得预期的机器学习解决方案。此外,即使学习到的模型能够正确标记所有训练输入,也不能保证它在未见过的数据上表现良好。换句话说,模型泛化能力可能不佳(我们将在下一节中进一步讨论这个问题)。仔细选择组件和调整学习过程非常重要。

1.2.3 超参数调整

我们如何选择合适的组件来调整学习过程,以便我们能够推导出预期的模型?为了回答这个问题,我们需要介绍一个称为 超参数 的概念,并阐明这些参数与我们之前讨论的参数之间的关系如下:

  • 参数是机器学习算法在学习过程中可以更新的变量。它们用于捕捉数据中的规则。例如,水平线的位置是我们之前示例(图 1.4)中唯一的参数,用于帮助分类点。它通过优化方法在训练过程中进行调整,以捕捉分割不同颜色点的位置规则。通过调整参数,我们可以得到一个可以准确预测给定输入数据输出的机器学习模型。

  • 超参数也是参数,但它们是在学习过程开始之前我们为算法预先定义的,并且在学习过程中它们的值保持固定。这些包括测量、优化方法、学习速度、停止标准等等。一个机器学习算法通常有多个超参数。它们的不同组合对学习过程有不同的影响,从而产生具有不同性能的机器学习模型。我们也可以将算法类型(或机器学习模型类型)视为超参数,因为我们自己选择它,并且在学习过程中它是固定的。

为机器学习算法选择最佳超参数组合的过程称为超参数调整,通常通过迭代过程来完成。在每次迭代中,我们选择一组超参数来使用,以便使用训练数据集学习一个机器学习模型。图 1.5 中的机器学习算法块表示图 1.3 中描述的学习过程。通过在称为验证集的单独数据集上评估每个学习到的模型,然后我们可以选择最好的一个作为最终模型。我们可以使用另一个称为测试集的数据集来评估该模型的泛化能力,这标志着整个机器学习工作流程的结束。

01-05

图 1.5 经典机器学习工作流程

通常,在机器学习工作流程中,我们将有三个数据集。每个数据集与其他两个都不同,如下所述:

  • 训练集在学习过程中用于使用固定的超参数组合训练模型。

  • 验证集在调整过程中用于评估训练模型以选择最佳超参数。

  • 测试集在调整过程之后用于最终测试。它只在最终模型选定后使用一次,不应用于训练或调整机器学习算法。

训练集和测试集的理解相对简单。我们想要有一个额外的验证数据集的原因是避免在调整阶段将算法暴露于所有训练数据中——这增强了最终模型对未见数据的泛化能力。如果我们没有验证集,调整阶段选出的最佳模型将是专注于从训练数据中提取任何细微特征以不断增加训练准确率,而不关心任何未见数据集的模型。这种情况很可能会导致最终测试集(包含不同的数据)表现不佳。当模型在测试集(或验证集)上的表现不如训练集时,这被称为过拟合。这是机器学习中一个众所周知的问题,通常发生在模型的学习能力太强且训练数据集规模有限的情况下。例如,假设你想要根据前三个数字作为训练数据来预测一个数列的第四个数:a[1] = 1, a[2] = 2, a[3] = 3, a[4] = ? (a[4]在这里是验证集;a[5]及以后的是测试集。)如果正确的解是a[4] = 4,一个简单的模型a[i] = i会给出正确答案。如果你使用三次多项式来拟合这个数列,一个完美的训练数据解将是a[i] = i³ - 6i² + 12i - 6,这将预测a[4]为 10。验证过程使得模型在评估期间能够更好地反映其泛化能力,从而选择出更好的模型。

注意:过拟合是机器学习中研究的重要问题之一。除了在调整过程中进行验证之外,我们还有许多其他方法来解决这个问题,例如增加数据集、在训练过程中向模型添加正则化以约束其学习能力,等等。我们在这里不会深入探讨这个问题。要了解更多关于这个主题的信息,请参阅 Chollet 的《用 Python 进行深度学习》。

1.2.4 应用机器学习的障碍

在这个阶段,你应该对机器学习(ML)是什么以及它是如何进行的有一个基本的了解。尽管你可以利用许多成熟的机器学习工具包,但在实践中你仍然可能会遇到困难。本节描述了其中的一些挑战——目的不是让你望而却步,而是为随后描述的自动化机器学习(AutoML)技术提供背景。你可能会遇到的障碍包括以下内容:

  • 学习机器学习技术的成本——我们已经介绍了基础知识,但在将机器学习应用于实际问题时,还需要更多的知识。例如,你需要考虑如何将你的问题表述为机器学习问题,你可以为你的问题使用哪些机器学习算法以及它们是如何工作的,如何清理和预处理数据以符合输入机器学习算法的预期格式,应该选择哪些评估标准用于模型训练和超参数调整,等等。所有这些问题都需要提前回答,这样做可能需要大量的时间投入。

  • 实施复杂性—即使拥有必要的知识和经验,在选定机器学习算法后实施工作流程也是一个复杂任务。随着更高级算法的采用,实施和调试所需的时间将会增加。

  • 理论与实践之间的差距—学习过程可能难以解释,性能高度依赖于数据。此外,机器学习中使用的数据集通常复杂且噪声大,难以解释、清理和控制。这意味着调整过程通常比分析过程更经验性。即使是机器学习专家有时也无法达到预期的结果。

这些困难在很大程度上阻碍了机器学习向经验有限的人的普及,并相应地增加了机器学习专家的负担。这促使机器学习研究人员和实践者寻求降低障碍、规避不必要的程序、减轻手动算法设计和调整负担的解决方案——AutoML。

1.3 AutoML:自动化的自动化

AutoML 的目标是让机器模仿人类如何设计、调整和应用机器学习算法,以便我们更容易地采用机器学习(见图 1.6)。因为机器学习的一个关键特性是自动化,所以 AutoML 可以被视为自动化的自动化。

01-06

图 1.6 AutoML 的主要目标:将人类从机器学习算法设计和调整的循环中解放出来

为了帮助您理解 AutoML 是如何工作的,让我们首先回顾其关键组件。

1.3.1 AutoML 的三个关键组件

这里是对第 1.1 节中引入的伪代码的回顾:

ml_algorithm_pool = [
    MachineLearningAlgorithm1,
    MachineLearningAlgorithm2,
    ...,
    MachineLearningAlgorithmN,
]
for ml_algorithm in ml_algorithm_pool:
    model = ml_algorithm(arg1=..., arg2=..., data=historical_images)
    result = evaluate(model)
    push result into the result_pool
    push model into the model_pool
best_ml_model = pick_the_best(result_pool, ml_model_pool)
return best_ml_model

这段伪代码可以被视为一个简单的自动化机器学习(AutoML)算法,它接受一组机器学习算法作为输入,逐一评估它们,并输出从最佳算法中学习到的模型。每个 AutoML 算法都包含以下三个核心组件(见图 1.7):

  • 搜索空间—一组超参数,以及从中选择的每个超参数的范围。每个超参数的范围可以根据用户的需求和知识来定义。例如,搜索空间可以是一个机器学习算法的集合,如伪代码所示。在这种情况下,我们将机器学习算法的类型视为需要选择的超参数。搜索空间也可以是特定机器学习算法的超参数,例如机器学习模型的结构。搜索空间的设计高度依赖于任务,因为我们可能需要为不同的任务采用不同的机器学习算法。它也非常个性化且具有针对性,取决于用户的兴趣、专业知识和经验水平。在定义一个大的搜索空间带来的便利性和花费在识别一个好的模型(或在一个有限的时间内可以达到的模型性能)之间的权衡总是存在的。对于初学者来说,定义一个足够通用以适用于任何任务或情况的广泛搜索空间可能会很有吸引力,例如包含所有机器学习算法的搜索空间——但所涉及的时间和计算成本使得这成为一个较差的解决方案。我们将在本书的第二部分更详细地讨论这些考虑因素,其中你将学习如何根据额外的要求在不同的场景中自定义你的搜索空间。

  • 搜索策略—从搜索空间中选择最佳超参数集的策略。由于 AutoML 通常是一个迭代试错的过程,该策略通常按顺序选择搜索空间中的超参数并评估其性能。它可能遍历搜索空间中的所有超参数(如伪代码所示),或者根据迄今为止已评估的超参数调整搜索策略,以提高后续试验的效率。一个更好的搜索策略可以帮助你在相同的时间内实现更好的机器学习解决方案。它还可能通过减少搜索时间和计算成本,允许你使用更大的搜索空间。本书的第三部分将介绍如何采用、比较和实现不同的搜索算法。

  • 性能评估策略—评估由所选超参数实例化的特定机器学习算法性能的方法。评估标准通常与手动调优中使用的相同,例如从所选机器学习算法中学习到的模型的验证性能。在本书中,我们讨论了在采用 AutoML 解决不同类型的机器学习任务时采用的不同评估策略。

01-07

图 1.7 AutoML 过程

为了促进 AutoML 算法的采用,AutoML 工具包通常将这三个组件打包在一起,并提供一些具有默认搜索空间和搜索算法的通用应用程序编程接口(API),这样您就不必担心自己选择它们。对于最终用户来说,在最简单的情况下,您要获得最终模型所需做的只是提供数据,如这里所示——您甚至不需要将数据分成训练集和验证集:

automl_model = AutoMLAlgorithm()
best_ml_model = automl_model.generate_model(data=...)

但由于不同用户可能有不同的用例和机器学习专业知识水平,他们可能需要设计自己的搜索空间、评估策略,甚至搜索策略。因此,现有的 AutoML 系统通常也提供具有可配置参数的 API,以便您可以根据需要自定义不同的组件。从最简单的到最可配置的(图 1.8),提供了广泛的选择。

01-08

图 1.8 AutoML API 的范围

可用的 API 范围允许您选择最适合您用例的一个。本书将教会您如何在一个高级 AutoML 工具包AutoKeras中为不同的 AutoML 应用选择正确的 API。您还将学习如何借助 KerasTuner 创建自己的 AutoML 算法。

1.3.2 我们能否实现完全自动化?

AutoML 领域已经发展了三十年,有行业和开源社区的参与。已经看到了许多成功的实施和有希望的发展,如这里所述:

  • 许多公司内部工具和开源平台已被开发出来,以帮助进行机器学习模型的超参数调整和模型选择(例如 Google Vizier、Facebook Ax 等)。

  • 在许多 Kaggle 数据科学竞赛中,观察到执行接近人类水平的 AutoML 解决方案。

  • 已经开发了大量的开源机器学习包,用于改进超参数调整和机器学习管道创建,例如 Auto-sklearn、AutoKeras 等。

  • 商业 AutoML 产品正在帮助许多公司,无论大小,在生产中采用机器学习。例如,迪士尼成功使用了 Google Cloud AutoML 为其在线商店开发机器学习解决方案,而没有雇佣一支机器学习工程师团队(blog.google/products/google-cloud/cloud-automl-making-ai-accessible-every-business/)。

  • 计算机科学以外的领域的学者,如医学、神经生物学和经济学,也在利用 AutoML 的力量。他们现在可以将新的机器学习解决方案应用于特定领域的问题,如医学图像分割³、基因组研究⁴和动物识别与保护⁵,而无需经历机器学习和编程的漫长学习曲线。

我们仍在探索 AutoML 的全部功能,以民主化机器学习技术,使不同领域更多的人能够访问。尽管到目前为止已经看到了许多 AutoML 的成功应用,但我们仍然有许多挑战和限制需要进一步探索和解决,包括以下内容:

  • 构建 AutoML 系统的难度——与构建机器学习系统相比,从头开始构建 AutoML 系统是一个更复杂且涉及更多的过程。

  • 收集和清理数据的自动化——AutoML 仍然需要人们来收集、清理和标记数据。在实践中,这些过程通常比机器学习算法的设计更复杂,至少到目前为止,它们不能通过 AutoML 自动化。为了使 AutoML 今天能够工作,它必须被赋予一个明确的任务和目标,并使用高质量的数据库。

  • 选择和调整 AutoML 算法的成本——“没有免费午餐”定理告诉我们,没有一种万能的 AutoML 算法可以适用于任何超参数调整问题。你在选择和调整机器学习算法上节省的努力可能会被分摊,甚至可能被选择和调整 AutoML 算法所需付出的努力所超过。

  • 资源成本——从时间和计算资源的角度来看,AutoML 是一个相对昂贵的流程。现有的 AutoML 系统通常需要尝试比人类专家更多的超参数才能达到可比的结果。

  • 人机交互的成本——解释 AutoML 的解决方案和调整过程可能并不容易。随着这些系统变得更加复杂,人类参与调整过程和理解最终模型是如何实现的将变得越来越困难。

AutoML 仍处于发展的早期阶段,其持续进步将严重依赖于来自不同领域的学者、开发者和实践者的参与。尽管你有一天可能会为这一努力做出贡献,但本书的目标更为谦逊。它主要针对那些在机器学习方面专业知识有限,或者有一定经验但希望节省在创建机器学习解决方案上所花费的努力的从业者。本书将教你如何用不超过五行代码自动解决机器学习问题。它将逐步介绍更复杂的 AutoML 解决方案,适用于更复杂的情况和数据类型,例如图像、文本等。为了帮助你入门,在下一章中,我们将更深入地探讨机器学习的根本原理,并探索机器学习项目的端到端流程。这将有助于你在后续章节中更好地理解和利用 AutoML 技术。

摘要

  • 机器学习是指计算机通过自动与数据交互来修改其处理过程的能力,而不需要被明确编程。

  • 机器学习过程可以描述为一个迭代算法过程,根据数据输入和某些测量来调整机器学习模型的参数。当模型能够提供预期的输出,或者达到用户定义的某些特定标准时,这个过程停止。

  • 调整机器学习算法中的超参数允许你调整学习过程,并选择适合当前机器学习问题的组件。

  • AutoML 旨在从设计和应用机器学习模型的经验中学习,并自动化调整过程,从而减轻数据科学家的负担,并使没有丰富经验的专业人士能够使用现成的机器学习技术。

  • 一个 AutoML 算法由三个关键组件组成:搜索空间、搜索策略和评估策略。不同的 AutoML 系统提供不同级别的 API,这些 API 要么为你配置这些组件,要么允许你根据你的用例自定义它们。

  • AutoML 包含许多未解决的问题,阻碍了它达到最高的期望。实现真正的自动机器学习将是困难的。我们应该保持乐观,但也要注意避免夸大 AutoML 当前的能力。


(1.) 许多知名的机器学习包提供了对应于机器学习算法的这些类,例如 scikit-learn。

(2.) 参数值可能随机初始化,或者按照某种策略分配,例如使用预热启动,在这种情况下,你从一些由类似模型学习到的现有参数开始。

(3.) 汪宇,等,“NAS-Unet:用于医学图像分割的神经架构搜索”,IEEE Access 7 (2019): 44247-44257。

(4.) 刘登辉,等,“AutoGenome:基因组研究的 AutoML 工具”,bioRxiv (2019): 842526。

(5.) 刘尧,罗泽,等,“基于 AutoML 的保护区物种识别”,计算机系统与应用 28 (2019): 147-153。

2 机器学习项目的端到端管道

本章涵盖

  • 熟悉进行机器学习项目的端到端管道

  • 为机器学习模型准备数据(数据收集和预处理)

  • 生成和选择特征以增强机器学习算法的性能

  • 构建线性回归和决策树模型

  • 使用网格搜索微调机器学习模型

现在第一章已经设定了场景,是时候熟悉机器学习(ML)和自动化机器学习(AutoML)的基本概念了。因为自动化机器学习建立在机器学习的基础上,学习机器学习的基本原理将帮助你更好地理解和利用自动化机器学习技术。这在设计自动化机器学习算法中的搜索空间时尤为重要,这决定了要使用的机器学习组件及其超参数的范围。在本章中,我们将通过一个具体的例子来解决问题。这将帮助你更深入地理解构建机器学习管道的整体过程,尤其是如果你在机器学习项目中经验不足的话。你还将学习一种调整机器学习模型超参数的简单方法。这可以被视为自动化机器学习最简单的应用之一,展示了它如何帮助你找到更好的机器学习解决方案。本书的第二部分将介绍更高级的自动化机器学习任务和解决方案。

注意:本节及后续章节中包含的所有代码片段均以 Python 编写,以 Jupyter 笔记本的形式呈现。它们都是由 Jupyter Notebook(jupyter.org)生成的,这是一个开源的 Web 应用程序,提供交互式代码设计、数据处理和可视化、叙述文本等功能。它在机器学习和数据科学社区中非常受欢迎。如果你不熟悉环境设置或没有足够的硬件资源,你还可以在 Google Colaboratory(colab.research.google.com/)中运行代码,这是一个免费的 Jupyter 笔记本环境,任何人都可以在其中运行机器学习实验。有关在 Google Colaboratory(简称 Colab)中设置环境的详细说明请参阅附录 A。笔记本可在github.com/datamllab/automl-in-action-notebooks找到。

2.1 端到端管道概述

机器学习管道是一系列用于执行机器学习项目的步骤。这些步骤依次为:

  • 问题界定和数据收集——将问题界定为机器学习问题并收集所需的数据。

  • 数据预处理和特征工程—将数据处理成适合输入到机器学习算法的格式。选择或生成与目标输出相关的特征,以提高算法的性能。这一步骤通常通过首先探索数据集来了解其特征来完成。操作应适应你考虑的具体机器学习算法。

  • 机器学习算法选择—根据你对问题的先验知识和经验,选择适合你想要测试的任务的机器学习算法。

  • 模型训练和评估—将选定的机器学习算法(或算法)应用于训练数据,以训练机器学习模型,并在验证数据集上评估其性能。

  • 超参数调整—通过迭代调整模型的超参数来尝试实现更好的性能。

  • 服务部署和模型监控—部署最终的机器学习解决方案,并监控其性能,以便你可以持续维护和改进管道。

如你所见,机器学习项目是一个人机交互的过程。从问题定义和数据处理开始,管道涉及多个数据处理步骤,这些步骤通常异步发生(见图 2.1)。本书的其余部分将重点关注服务部署和监控之前的步骤。要了解更多关于部署和提供模型的信息,请参考杰夫·史密斯(Manning,2018 年)的《机器学习系统》或道格·哈奇顿和理查德·尼科尔(Manning,2019 年)的《商业机器学习》等参考资料。

02-01

图 2.1 全端机器学习项目管道

让我们开始解决一个实际问题,以便你熟悉管道中的每个组件。我们在这里探索的问题是预测一个住宅区的平均房价,给定房屋的特征,如位置和房间数量。我们使用的数据是 R.凯利·佩斯和罗纳德·巴里 1997 年文章“稀疏空间自回归”中提到的加利福尼亚住房数据集,该数据集是通过 1990 年的人口普查收集的。这是一个在许多实际机器学习书籍中作为入门问题使用的代表性问题,因为数据规模小,数据准备简单。

注意选择合适的问题进行工作是困难的。这取决于多个因素,例如您的业务需求和研究目标。在真正投入问题之前,问问自己你期望实现哪些解决方案,以及它们将如何惠及你的下游应用,以及是否已有工作已经满足了这一需求。这将帮助你决定这个问题是否值得投资。

2.2 定义问题和组装数据集

在任何机器学习项目中,你需要做的第一件事是定义问题和收集相应的数据。定义问题需要你指定机器学习模型的输入和输出。在加利福尼亚住宅问题中,输入是描述住宅区块的特征集合。

在这个数据集中,一个住宅区块是一群平均有 1,425 人居住在地理上紧凑区域的人群。特征包括住宅区块中每栋房子的平均房间数、区块中心的纬度和经度等。输出应该是区块的平均房价。我们试图训练一个机器学习模型,给定已知中位数的住宅区块,并基于其特征预测未知房价。返回的预测值也被称为模型的目标(或注释)。通常,任何旨在根据现有注释示例学习数据输入和目标之间关系的问题都被称为监督学习问题。这是机器学习研究最广泛的分支,也是本书余下的主要关注点。

我们可以根据目标值的类型进一步将监督学习问题分类为不同的类别。例如,任何具有连续目标值的监督学习问题都可以归类为回归问题。因为价格是一个连续变量,所以预测加利福尼亚房价本质上是一个回归问题。如果监督学习问题中的目标值是有限类别的离散值,我们称该问题为分类问题。你可以在附录 B 中找到一些分类问题的例子,我们也会在下一章中探讨它们。

在定义问题之后,下一步是收集数据。因为加利福尼亚住宅数据集是使用最广泛的机器学习数据集之一,你可以通过流行的机器学习库 scikit-learn 轻松访问它。然而,需要注意的是,在现实生活中,发现和获取数据集是一项非同寻常的活动,可能需要额外的技能,例如对结构化查询语言(SQL)的了解,这超出了本书的范围。(有关更多信息,请参阅 Jeff Smith 的书籍《机器学习系统》)。以下代码将加载我们问题的数据集。

列表 2.1 加载加利福尼亚住宅数据集

from sklearn.datasets import fetch_california_housing   ❶

house_dataset = fetch_california_housing()              ❷

❶ 从 scikit-learn 库导入数据集加载函数

❷ 加载加利福尼亚住宅数据集

原始数据是一个包含数据点的字典,这些数据点以实例-特征矩阵的格式进行格式化。每个数据点都是一个由矩阵中的一行特征描述的住宅区块。它们的标签以向量的格式进行格式化。该字典还包含特征名称和描述,这些描述说明了特征的含义和数据集的创建信息,如下所示:

>>> house_dataset.keys()
dict_keys(['data', 'target', 'feature_names', 'DESCR'])

在加载原始数据集之后,我们提取数据点和将它们转换为 DataFrame,这是 pandas 库的基本结构。pandas 是 Python 中进行数据分析和操作的有力工具。如图 2.2 所示,目标以 Series 对象的格式表示;它是一个带有标签“MedPrice”的向量,代表该住宅区的中位房价(以百万美元为单位)。

列表 2.2 提取数据样本和目标

import pandas as pd                                                         ❶
data = pd.DataFrame(house_dataset.data, columns=house_dataset.feature_names)❷

target = pd.Series(house_dataset.target, name = 'MedPrice')                 ❸

❶ 导入 pandas 包

❷ 将带有其名称的特征提取到 DataFrame 中

❸ 将目标提取为名为 "MedPrice" 的 Series 对象

让我们打印出数据的前五个样本(如图 2.2 所示)。第一行表示特征名称,详细信息可以在 scikit-learn.org/ stable/datasets.html 找到。例如,“AveRooms”特征表示一个住宅区内的平均房间数。我们也可以以同样的方式检查目标数据的值,如下所示:

>>> data.head(5)

02-02

图 2.2 加利福尼亚住房数据集的前五个样本的特征

在进行数据预处理步骤之前,我们首先进行数据拆分,以分离训练数据和测试集。正如你在上一章所学,这样做的主要目的是避免在用于进行分析和训练模型的数据上测试你的模型。拆分数据到训练集和测试集的代码如下所示。

列表 2.3 将数据拆分为训练集和测试集

from sklearn.model_selection import train_test_split    ❶

X_train, X_test, y_train, y_test = train_test_split(
    data, target,
    test_size=0.2,
    random_state=42)                                    ❷

❶ 从 scikit-learn 导入数据拆分函数

❷ 随机将 20% 的数据拆分为测试集

我们将 20% 的数据随机拆分为测试集。现在让我们快速检查拆分情况。查看完整数据集,你会发现它包含 20,640 个数据点。每个住宅区的特征数量为八个。训练集包含 16,512 个样本,测试集包含 4,128 个样本,如以下代码片段所示:

>>> (data.shape, target.shape), (X_train.shape, y_train.shape), (X_test.shape, y_test.shape)
(((20640, 8), (20640,)), ((16512, 8), (16512,)), ((4128, 8), (4128,)))

在你得到最终的机器学习解决方案之前,不要触碰测试集中的目标数据。否则,包括数据准备和模型训练在内的所有分析可能会过度拟合测试数据,导致最终解决方案在部署时在未见过的数据上表现不佳。在数据预处理和特征工程中,将测试集中的特征与训练特征相结合是可行的,正如我们将在以下章节中所做的那样。当数据集大小较小时,聚合特征信息可能很有帮助。

2.3 数据预处理

我们下一步要做的是进行一些预处理,将数据转换成适合输入到机器学习算法的格式。这个过程通常涉及基于先前的假设或对数据的疑问进行的某些探索性数据分析(EDA)。EDA 可以帮助我们熟悉数据集,并对其有更深入的了解,以便更好地准备数据。常见的问题包括以下内容:

  • 每个特征中的值的数据类型是什么?它们是字符串或其他可以在管道后续步骤中使用的对象,还是需要转换?

  • 每个特征有多少个不同的值?它们是数值、分类值还是其他类型?

  • 每个特征的尺度及基本统计信息是什么?通过可视化值的分布或它们之间的相关性,我们能获得一些洞察吗?

  • 数据中是否存在缺失值?如果是,我们需要移除它们还是填充它们?

在实践中,不同的数据通常需要根据其格式和特征、我们关注的问题、选定的机器学习模型等因素定制数据预处理技术。这通常是一个启发式、经验的过程,导致提出了各种临时操作。

在这个例子中,我们将使用前面提到的四个问题作为初步数据预处理的依据。更多示例可以在附录 B 中找到。我们首先关注的问题是特征值的类型。在这个例子中,所有特征及其目标都是浮点值,可以直接输入到机器学习算法中,无需进一步操作,如下所示:

>>> data.dtypes
MedInc        float64
HouseAge      float64
AveRooms      float64
AveBedrms     float64
Population    float64
AveOccup      float64
Latitude      float64
Longitude     float64
dtype: object

>>> target.dtypes
dtype('float64')

我们关注的第二点是特征中不同值的数量。计算不同值可以帮助我们区分特征类型,以便我们可以设计定制的策略来处理它们。这也有助于我们移除任何冗余特征。例如,如果所有数据样本的特征值都相同,那么这个特征就不能为预测提供任何有用的信息。也有可能每个数据点的特征值都是唯一的,但我们确信这些值对分类没有帮助。这通常是数据点的 ID 特征的情况,如果它只表示数据样本的顺序。在列表 2.4 中,我们可以看到在这个数据集中,没有特征值对所有点都是相同的,也没有特征值是每个数据点都唯一的。尽管其中一些特征具有大量不同的值,例如“MedInc”、“AveRooms”和“AveBedrms”,因为这些是数值特征,其值对于比较住宅区和预测价格是有用的,所以我们不应该移除它们。

列表 2.4 检查每个特征中唯一值的数量

>>> data.nunique()
MedInc        12928
HouseAge         52
AveRooms      19392
AveBedrms     14233
Population     3888
AveOccup      18841
Latitude        862
Longitude       844
dtype: int64

我们可以进一步显示一些特征的基本统计信息以获得更多见解(如图 2.3 所示)。例如,一个住宅区的平均人口为 1,425,但在这个数据集中人口最密集的住宅区有超过 35,000 居民,而人口最稀疏的住宅区仅有 3 人。

02-03

图 2.3 加利福尼亚住宅数据特征统计

在现实世界的应用中,数据中的缺失值是一个关键挑战。这个问题可能在数据收集或传输过程中引入,也可能由损坏、未能正确加载数据等原因引起。如果处理不当,缺失值可能会影响机器学习解决方案的性能,甚至导致程序崩溃。用替代值替换数据中的缺失和无效值的过程称为插补

下面的列表检查我们的训练和测试数据集中是否存在缺失值。

列表 2.5 检查训练和测试集中的缺失值

train_data = X_train.copy()                             ❶

train_data['MedPrice'] = y_train                        ❷

print(f'-- check for missing values in training data -- {training_data.isnull().any()}')                   ❸

print(f'-- check for missing values in training data -- {Xtest.isnull().any()}')                           ❹

❶ 将训练数据复制以避免就地更改

❷ 通过添加一个名为'MedPrice'的目标列将特征和目标合并

❸ 检查训练集是否存在缺失值

❹ 检查测试集是否存在缺失值

下面的结果显示数据集中没有缺失值,因此我们可以继续我们的分析,无需进一步考虑这个问题(你将在第三章中看到一个处理缺失值的例子):

-- check for missing values in training data --
MedInc        False
HouseAge      False
AveRooms      False
AveBedrms     False
Population    False
AveOccup      False
Latitude      False
Longitude     False
MedPrice      False
dtype: bool

-- check for missing values in test data --
MedInc        False
HouseAge      False
AveRooms      False
AveBedrms     False
Population    False
AveOccup      False
Latitude      False
Longitude     False
dtype: bool

为了简单起见,我们在这里不会进行任何额外的数据预处理。你通常会想采取一些其他常见步骤,例如检查异常值,这些是可能影响机器学习模型训练的远离中心的点,如果它们存在于你的数据中,则应将其移除。此外,现实世界的数据集通常不如本例中使用的格式好。附录 B 提供了处理不同数据类型的预处理技术示例;如果你不熟悉这个主题,我建议你在继续下一章之前先浏览那些示例。接下来,我们将继续进行特征工程步骤,这通常与数据预处理同时进行。

2.4 特征工程

与数据预处理不同,数据预处理侧重于将原始数据转换为有用的或高效的格式,特征工程旨在生成和选择一组良好的特征以提升机器学习算法的性能。它通常依赖于特定的领域知识,并迭代进行以下两个步骤:

  • 特征生成旨在通过转换现有特征来生成新特征。这可以在单个特征上完成,例如,通过将分类特征替换为每个类别的频率计数来获得可测量的数值特征,或者可以在多个特征上完成。例如,通过计算不同职业的男性和女性员工的数量,我们可能得到一个更有助于分析不同行业招聘公平性的特征。

  • 特征选择旨在选择现有特征中最有用的子集,以提高机器学习算法的效率和准确性。

特征选择和生成通常以迭代方式进行,利用某些度量(如生成的特征与目标之间的相关性)的即时反馈,或者基于在评估数据集上训练的机器学习模型的性能的延迟反馈。

在列表 2.6 中,我们通过使用皮尔逊相关系数来测量每个特征与目标之间的相关性,进行简单的特征选择。皮尔逊相关系数衡量两个变量(特征和目标)之间的线性相关性。其值范围从-1 到 1,其中-1 和 1 分别表示完美的负线性关系和完美的正线性关系。系数为 0 表示不存在关系。

列表 2.6 绘制皮尔逊相关系数矩阵

import matplotlib.pyplot as plt                       ❶
import seaborn as sns                                 ❷
%matplotlib inline                                    ❸

plt.figure(figsize=(30,10))                           ❹

correlation_matrix = train_data.corr().round(2)       ❺
sns.heatmap(data=correlation_matrix, square= True,
            annot=True, cmap='Blues')                 ❻

❶ 导入用于通用绘图配置的库

❷ 导入 seaborn 库以绘制热图

❸ 在 Jupyter 笔记本中美化图形显示

❹ 设置图形大小

❺ 计算皮尔逊相关系数矩阵

❻ 绘制所有特征与目标之间的相关性图

我们将关注矩阵的最后一行(见图 2.4),它显示了目标房价与每个特征之间的成对相关性。然后我们将讨论我们选择的两个特征。

02-04

图 2.4 所有特征与目标之间的皮尔逊相关系数矩阵

基于系数矩阵和以下假设,我们选择前两个相关性最高的特征:

  • MedInc—这个特征表示一个街区内房屋的中等收入,它与目标值显示出高度的正线性相关性。这与直觉相符,即收入较高的人更有可能住在房价较高的街区(正相关)。

  • AveRooms—这个特征表示每个街区房屋的平均房间数。房间数较多的房屋更有可能价格更高(正相关)。

为了简化,这里我们只选择两个特征作为示例。特征选择在列表 2.7 中实现。也可以通过选择计算出的皮尔逊相关系数的阈值(例如 0.5)来自动化特征选择,而不是基于视觉检查。要选择多少个特征是一个需要我们仔细决定的超参数。我们可以尝试不同的特征组合来训练我们的机器学习模型,并通过试错法选择最佳的一个。

列表 2.7 特征选择

selected_feature_set = ['MedInc', 'AveRooms',]        ❶
sub_train_data = train_data[
    selected_feature_set + ['MedPrice']]              ❷

X_train = sub_train_data.drop(['MedPrice'], axis=1)   ❸
X_test = X_test[selected_feature_set]                 ❹

❶ 选定的特征集

❷ 提取新的训练特征

❸ 在 X_train 中删除目标并仅保留训练特征

❹ 为测试数据选择相同的特征集

在选择了两个特征之后,我们可以绘制散点图来显示它们之间的成对相关性以及与目标的相关性。它们的分布可以通过以下代码使用直方图来共同展示:

sns.pairplot(sub_train_data, height=3.5, plot_kws={'alpha': 0.4})

散点图显示“MedInc”特征与目标“MedPrice”之间存在强烈的正相关。由于特征和异常值之间的比例差异,“AveRooms”特征与“MedPrice”之间的相关性相对不那么明显(见图 2.5)。

02-05

图 2.5 选定特征与目标之间的成对关系

使用皮尔逊相关系数来选择特征很容易,但在实践中可能并不总是有效。它忽略了特征与目标之间的非线性关系,以及特征之间的相关性。此外,对于值不是序数的分类特征,特征与目标之间的相关性可能没有意义。随着越来越多的特征工程技术的提出,决定如何选择最佳方法已经成为一个痛点。这引出了 AutoML 中的一个重要话题——自动特征选择和转换,但我们将在本书的第二部分讨论这个问题,现在我们继续解决当前的问题。

现在我们已经准备好了训练数据并选择了特征,我们就可以选择用于使用预处理数据训练机器学习模型的算法了。(在实践中,你还可以在数据预处理和特征工程步骤之前选择机器学习算法,以追求更定制化的数据准备过程。)

2.5 机器学习算法选择

记住,对于每个机器学习算法,我们都有四个核心组件需要选择:要训练的机器学习模型、衡量模型效力的指标、基于该指标更新模型参数的优化方法,以及终止更新过程的停止标准。因为我们的主要焦点不是优化,所以我们只会简要地讨论每个选定模型的优化方法和停止标准。

对于这个例子,我们将使用两个简单、经典的模型。第一个是线性回归模型,第二个是决策树模型。我们将首先回顾线性回归模型背后的核心思想以及创建、训练和评估它的过程。我们将使用整个训练集来训练模型,并在测试集上评估它,而不会进一步将训练数据分成训练集和验证集进行超参数调整。我们将在介绍决策树模型后讨论超参数调整步骤。

2.5.1 构建线性回归模型

线性回归是监督机器学习中最简单的模型之一,可能是你首先了解的机器学习模型。它试图通过计算数据点的特征加权和来预测目标值:02-05-EQ01,其中m是特征的数量。在当前示例中m为 2,因为我们只选择了两个特征:“MedInc”和“AveRooms”。w[i]是从数据中学习到的参数(或权重),其中w[0]被称为截距02-05-EQ02被称为特征x[i]系数。参数基于训练数据学习,以捕捉特征和目标之间的线性关系。使用 scikit-learn 构建线性回归模型的代码如下:

from sklearn.linear_model import LinearRegression

linear_regressor = LinearRegression()

为了学习权重,我们需要选择一个优化方法和一个指标来衡量它们的性能。均方误差(MSE)是回归问题中广泛使用的损失函数和评估指标——它衡量模型预测与目标之间的平均平方差异。在训练阶段,我们将使用 MSE 作为损失函数来学习模型,在测试阶段,我们将使用评估指标来衡量模型在测试集上的预测能力。为了帮助您理解其计算方式,提供了一个代码示例在列表 2.8 中。在训练阶段,true_target_values 是训练数据集中所有目标值(房屋的实际价格)的列表,predictions 是模型预测的所有房价。

列表 2.8 计算 MSE

def mean_squared_error(predictions, true_target_values):
    mse = 0                                                              ❶
    for prediction, target_value in zip(predictions, true_target_values):
        mse += (prediction - target_value) ** 2                          ❷
    mse /= len(predictions)                                              ❸
    return mse

❶ 将总和初始化为零

❷ 求平方误差之和

❸ 计算平方误差之和

图 2.6 是线性回归模型单变量(或特征)的简单示意图。学习过程旨在找到最佳斜率和截距,以最小化平方误差的平均值,这由数据点和回归线之间的虚线表示。

02-06

图 2.6 线性回归单特征示意图

在 scikit-learn 的帮助下,我们可以通过调用 fit 函数并输入训练数据来轻松优化权重。默认情况下,MSE(均方误差)用作损失函数,如下所示:

linear_regressor.fit(X_train, y_train)

我们可以使用以下代码打印出学习到的权重。

列表 2.9 显示学习到的参数

>>> coeffcients = pd.DataFrame(
>>> coefficients = pd.DataFrame(
...    linear_regression.conf_,
...    X_train.coluns,
...    columns=['Coefficient'])                                  ❶

>>> print(f'Intercept: {linear_regressor.intercept_:.2f}\n')     ❷
>>> print(coeffcients)                                           ❸

Learned intercept: 0.60

--The coefficient value learned by the linear regression model--
       Coefficient
MedInc           0.44
AveRooms        -0.04

❶ 将系数值转换为 DataFrame

❷ 打印截距值

❸ 打印系数

学习到的系数表明,“MedInc”特征和目标确实存在正线性相关性。而“AveRooms”特征具有负相关性,这与我们的预期相反。这可能是以下两个可能因素之一:

  • 训练数据中的异常值(一些高价住宅区房间较少)正在影响训练过程。

  • 我们选择的两个特征是正线性相关的。这是因为它们共享一些对预测目标有用的共同信息。由于“MedInc”已经覆盖了“AveRooms”提供的一些信息,因此“AveRooms”的影响减小,导致轻微的负相关性。

理想情况下,线性回归的良好特征集应彼此之间只有弱相关性,但与目标变量高度相关。在这个阶段,我们可以通过迭代进行特征选择和模型训练,尝试不同的特征组合,并选择一个良好的组合。我将把这个过程留给你作为练习尝试,我们将直接进入测试阶段。测试集上学习模型的均方误差(MSE)可以通过以下代码计算和打印。

列表 2.10 测试线性回归模型

>>> from sklearn.metrics import mean_squared_error       ❶
>>> y_pred_test = linear_regressor.predict(X_test)       ❷
>>> print(f'Test MSE: {mean_squared_error(y_test, y_pred_test):.2f}')

Test MSE: 0.70

❶ 导入评估指标

❷ 预测测试数据的目标值

测试 MSE 为 0.70,这意味着平均而言,模型预测与测试数据真实目标之间的平方差为 0.70。MSE 的值越低越好;理想情况下,你希望这个值尽可能接近 0。接下来,我们将尝试决策树模型,并比较两种模型的性能。

2.5.2 构建决策树模型

决策树的关键思想是根据一系列(通常是二元的)条件将数据分割成不同的组,如图 2.7 所示。决策树中的每个非叶节点都是一个条件,它将每个数据样本放置在子节点之一中。每个叶节点有一个特定的值作为预测。每个数据样本将从树的根(顶部)导航到叶节点之一,为我们提供该样本的预测。例如,假设我们有一所房子,其 MedInc=5,AveRooms=3。我们将从根节点开始,通过 No 路径和 Yes 路径,直到我们达到一个值为$260,000 的叶节点,这是这所房子的预测价格。

02-07

图 2.7 使用决策树模型预测值

树的分割和每个叶子节点中的预测都是基于训练数据学习的。构建决策树的典型过程如列表 2.11 所示。树是递归构建的。在每次递归中,我们找到当前节点数据集的最佳分割。在每个分割节点中,节点给出的预测值等于落入此节点的所有训练样本的平均值。最佳分割定义为最小化两个子节点中所有样本预测值与目标值之间均方误差的分割。当满足退出条件时,递归将停止。我们可以有多种方式来定义这个条件。例如,我们可以预先定义树的最大深度,当达到这个深度时,递归停止。我们也可以基于算法的停止标准。例如,我们可以将递归的退出条件定义为“如果落入当前节点的训练样本数量少于五个,则停止。”

列表 2.11 构建决策树模型

decision_tree_root = construct_subtree(training_data)

def construct_subtree(data):
    if exit_condition(data):
        return LeafNode(get_predicted_value(data))       ❶
    condition = get_split_condition(data)                ❷
    node = Node(condition)                               ❸
    left_data, right_data = condition.split_data(data)   ❹
    node.left = construct_subtree(left_data)             ❺
    node.right = construct_subtree(right_data)           ❻
    return node

❶ 如果满足退出条件,则计算预测值

❷ 从数据中获取最佳分割条件

❸ 创建一个新的节点,包含条件

❹ 根据条件将数据分成两部分

❺ 递归构建左子树

❻ 递归构建右子树

使用 scikit-learn 构建决策树很容易。训练和测试的代码如列表 2.12 所示。max_depth 参数是一个超参数,在训练过程中约束树模型的深度。它将在达到此最大深度或当前节点包含少于两个样本(默认停止标准)时停止增长。

列表 2.12 使用 scikit-learn 构建决策树模型

from sklearn.tree import DecisionTreeRegressor

tree_regressor = DecisionTreeRegressor(max_depth=3,
                                       random_state=42)   ❶
tree_regressor.fit(X_train, y_train)

y_pred_train = tree_regressor.predict(X_train)
y_pred_test = tree_regressor.predict(X_test)

❶ 创建决策树回归器

让我们打印出训练集和测试集的 MSE 结果,如下所示。它们之间的差异表明存在少量过拟合:

>>> print(f'Train MSE: {mean_squared_error(y_train, y_pred_train):.2f}')

>>> print(f'Test MSE: {mean_squared_error(y_test, y_pred_test):.2f}')

Train MSE: 0.68
Test MSE: 0.71

与线性回归模型相比,当前的决策树模型在测试集上的表现略差。但值得注意的是,我们不应仅基于这些测试结果选择模型。正确的模型选择和超参数调整方法是通过验证过程在单独的验证集上尝试不同的模型。我们在这里直接评估测试集的原因是为了让您熟悉训练和测试过程。我们还可以使用以下代码可视化学习到的树模型,以获得更直观的理解。

列表 2.13 可视化决策树

from sklearn.externals.six import StringIO
import sklearn.tree as tree
import pydotplus

from IPython.display import Image
dot_data = StringIO()
tree.export_graphviz(tree_regressor,
                     out_file=dot_data,
                     class_names=['MedPrice'],            ❶
                     feature_names=selected_feature_set,
                     filled=True,                         ❷
                     rounded=True)                        ❸
graph = pydotplus.graph_from_dot_data(dot_data.getvalue())
Image(graph.create_png())

❶ 目标名称

❷ 是否用颜色填充方框

❸ 是否圆化方框的角

学习到的树是一个深度为三的平衡二叉树,如图 2.8 所示。除了叶节点外,它们没有分裂条件,每个节点传达四个信息:分裂条件,它决定了样本应该根据哪个特征落入哪个子节点;落入当前节点的训练样本数量;它们的目标值的平均值;以及落入当前节点的所有样本的均方误差(MSE)。每个节点的 MSE 是基于真实目标和树给出的预测计算的,即目标的平均值。

02-08

图 2.8 学习到的决策树可视化

我们现在已经创建了两个机器学习模型,并且知道在测试数据集上,决策树模型的表现略逊于回归模型。现在的问题是,在不接触测试集的情况下,我们是否能够改进决策树模型,使其在最终测试中比我们的线性回归模型表现更好。这引入了机器学习流程中的一个重要步骤:超参数调整和模型选择。

2.6 精调机器学习模型:网格搜索简介

在开始之前就知道机器学习算法的最佳超参数,例如决策树模型的 max_depth,通常是不可能的。因此,超参数调整是一个非常关键的步骤——它允许你选择最佳组件来构建你的机器学习算法,并提高你创建的算法的性能。调整通常是一个试错的过程。你预先定义一组候选的超参数组合,并通过将它们应用于训练数据,使用验证过程并评估结果来选择最佳组合。为了帮助你更好地理解调整过程,让我们尝试通过调整其 max_depth 超参数来改进决策树模型。

我们首先应该做的是构建一个验证集,这样我们就可以根据不同模型的验证性能进行比较。始终记住,在超参数调整过程中,你不应该接触测试集。模型只应在调整完成后一次接触这些数据。有许多方法可以将训练数据分割并进行模型验证。在这里我们将使用交叉验证;这是一种广泛用于模型验证的技术,尤其是在数据集大小较小时。交叉验证平均了多轮模型训练和评估的结果。在每一轮中,数据集被随机分割成两个互补的子集(训练集和验证集)。在不同的轮次中,每个数据点都有平等的机会被分配到训练集或验证集中。交叉验证的主要方法分为以下两组:

  • 使用穷尽性交叉验证方法,你将在所有可能的方式上训练和评估模型,以组成你将数据分成的两个集合。例如,假设你决定使用数据集的 80%进行训练,20%进行验证。在这种情况下,你需要穷尽这两组数据点中所有可能的组合,并平均所有这些分区上模型的训练和测试结果。穷尽性交叉验证的一个代表性例子是留一法交叉验证,其中每个例子都是一个单独的测试集,而所有其余的则形成相应的训练集。给定N个样本,你将会有N个分区,用于N次训练和评估你的候选模型。

  • 使用非穷尽性交叉验证方法,正如其名所示,你不必穷尽每个分区所有可能的情况。两个代表性的例子是留出法k 折交叉验证。留出法简单地将原始训练数据随机分为两组。一组是新训练集,另一组是验证集。人们通常将这种方法视为简单的验证而不是交叉验证,因为它通常只涉及一次运行,并且个体数据点既不用于训练也不用于验证。k 折交叉验证将原始训练数据分为k个等分的子集。每个子集依次用作验证集,其余的则是当时相应的训练集(见图 2.9)。给定N个样本,N折交叉验证等同于留一法交叉验证。

02-09

图 2.9 三折交叉验证

让我们尝试使用五折交叉验证来调整 max_depth 超参数。在列表 2.14 中,我们使用 scikit-learn 库中的 KFold 交叉验证器生成交叉验证集,并遍历 max_depth 超参数的所有候选值来生成树模型并进行交叉验证。这种搜索策略被称为网格搜索,它是寻找最佳超参数的最简单的 AutoML 方法之一。主要思想是遍历候选超参数集中值的所有组合,并根据评估结果选择最佳组合。因为我们只有一个候选超参数需要调整,所以它变成了对所有可能值进行简单循环的过程。

列表 2.14 生成交叉验证集,调整 max_depth

import numpy as np
from sklearn.model_selection import KFold

kf = KFold(n_splits = 5)                          ❶

cv_sets = []
for train_index, test_index in kf.split(X_train):
    cv_sets.append((X_train.iloc[train_index],
                    y_train.iloc[train_index],
                    X_train.iloc[test_index],
                    y_train.iloc[test_index]))

max_depths = list(range(1, 11))                   ❷

for max_depth in max_depths:
    cv_results = []
    regressor = DecisionTreeRegressor(max_depth=max_depth, random_state=42)

    for x_tr, y_tr, x_te, y_te in cv_sets:        ❸
        regressor.fit(x_tr, y_tr)
        cv_results.append(mean_squared_error(regressor.predict(x_te) , y_te))
    print(f'Tree depth: {max_depth}, Avg. MSE: {np.mean(cv_results)}')

❶ 创建一个五折交叉验证对象用于数据分区

❷ 为 max_depth 超参数构建候选值列表

❸ 遍历所有交叉验证集并平均验证结果

从以下评估结果中,我们可以观察到 max_depth=6 给出了最低的均方误差(MSE),如下所示:

Tree depth: 1, Avg. MSE: 0.9167053334390705
Tree depth: 2, Avg. MSE: 0.7383634845663015
Tree depth: 3, Avg. MSE: 0.68854467373395
Tree depth: 4, Avg. MSE: 0.6388802215441052
Tree depth: 5, Avg. MSE: 0.6229559075742178
Tree depth: 6, Avg. MSE: 0.6181574550660847
Tree depth: 7, Avg. MSE: 0.6315191091236836
Tree depth: 8, Avg. MSE: 0.6531981343523263
Tree depth: 9, Avg. MSE: 0.6782896327438639
Tree depth: 10, Avg. MSE: 0.7025407934796457

我们可以使用相同的技巧来选择其他超参数的值,甚至模型类型。例如,我们可以使用相同的验证集对线性回归和决策树模型进行交叉验证,并选择交叉验证结果更好的一个。

有时候你可能需要调整更多的超参数,这使得使用简单的 for 循环来完成这项任务变得困难。scikit-learn 提供了一个内置的类,称为 GridSearchCV,这使得这项任务更加方便。你提供它超参数的搜索空间作为一个字典,你想要调整的模型,以及一个评分函数来衡量模型的性能。例如,在这个问题中,搜索空间是一个只有一个键的字典,即 max_depth,其值是一个包含其候选值的列表。评分函数可以通过 make_scorer 函数从性能指标转换而来。例如,我们可以将 MSE 转换为如列表 2.15 所示的评分函数。需要注意的是,scikit-learn 中的 GridSearchCV 默认假设更高的分数更好。因为我们想要找到具有最小 MSE 的模型,所以在定义网格搜索的评分函数时,我们应该在 make_scorer 函数中将 greater_is_better 设置为 False。

列表 2.15 对 max_depth 超参数的网格搜索

from sklearn.model_selection import GridSearchCV
from sklearn.metrics import make_scorer

regressor = DecisionTreeRegressor(random_state=42)    ❶

hps = {'max_depth':list(range(1, 11))}                ❷

scoring_fnc = make_scorer(mean_squared_error,
                          greater_is_better=False)    ❸

grid_search = GridSearchCV(estimator=regressor, param_grid=hps,
                           scoring=scoring_fnc,
                           cv=5)                      ❹

grid_search = grid_search.fit(X_train, y_train)       ❺

❶ 构建决策树回归器

❷ 创建一个字典作为超参数 max_depth 的搜索空间

❸ 定义评分函数

❹ 创建具有五折交叉验证的网格搜索交叉验证对象

❺ 将网格搜索对象拟合到训练数据以找到最佳模型

我们可以使用以下代码检索交叉验证结果,并绘制 MSE 与 max_depth 的关系。

列表 2.16 绘制网格搜索交叉验证结果

cvres = grid_search.cv_results_                             ❶
for mean_score, params in zip(cvres['mean_test_score'], cvres['params']):
    print(-mean_score, params)

plt.plot(hps['max_depth'], -cvres['mean_test_score'])       ❷
plt.title('MSE change with hyperparameter tree max depth')  ❷
plt.xlabel('max_depth')                                     ❷
plt.ylabel('MSE')                                           ❷
plt.show()

❶ 获取交叉验证结果

❷ 通过增加 max_depth 绘制 MSE 曲线

在图 2.10 中,我们可以看到 MSE 随着 max_depth 的增加先下降后上升。这是因为随着树深度的增加,模型获得了更多的灵活性,更好地能够细化分区。这将有助于模型更好地拟合训练数据,但最终它将开始过拟合。在我们的例子中,这发生在 max_depth>6 时。模型在 max_depth=6 时达到最佳性能。

02-10

图 2.10 随 max_depth 增加的五折交叉验证结果的变异

你可能会想知道交叉验证是否能够准确地反映模型在未见示例上的泛化能力。我们可以通过绘制具有不同 max_depth 值的 10 个模型的交叉验证 MSE 曲线和测试 MSE 曲线来验证这一点,如下所示:

>>> test_results = []
>>> for max_depth in hps['max_depth']:
...    tmp_results = []
...    regressor = DecisionTreeRegressor(max_depth=max_depth,
...                                      random_state=42)
...    regressor.fit(X_train, y_train)
...    test_results.append(mean_squared_error(
...                        regressor.predict(X_test) , y_test))
...    print(f'Tree depth: {max_depth}, Test MSE: {test_results[-1]}')

>>> plt.plot(hps['max_depth'], -cvres['mean_test_score'])
>>> plt.plot(hps['max_depth'], test_results)
>>> plt.title('Comparison of the changing curve of the CV results 
➥and real test results')
>>> plt.legend(['CV', 'Test'])
>>> plt.xlabel('max_depth')
>>> plt.ylabel('MSE')
>>> plt.show()

Tree depth: 1, Test MSE: 0.9441349708215667
Tree depth: 2, Test MSE: 0.7542635096031615
Tree depth: 3, Test MSE: 0.7063353387614023
Tree depth: 4, Test MSE: 0.6624543803195595
Tree depth: 5, Test MSE: 0.6455716785858321
Tree depth: 6, Test MSE: 0.6422136569733781
Tree depth: 7, Test MSE: 0.6423777285754818
Tree depth: 8, Test MSE: 0.6528185531960586
Tree depth: 9, Test MSE: 0.6751884166016034
Tree depth: 10, Test MSE: 0.7124031319320459

根据测试均方误差(MSE)值和图 2.11,我们可以观察到验证结果完美地选择了对应最佳测试结果的 max_depth,并且两条曲线大致对齐。请注意,这样做只是为了说明交叉验证的有效性——在实际应用中,你绝不应该使用测试曲线来选择模型!

02-11

图 2.11 随着 max_depth 增加的交叉验证结果曲线和测试结果曲线的比较

到目前为止,我们一直使用加利福尼亚房价预测问题来展示在部署机器学习解决方案之前机器学习管道中的通用步骤。我们使用的数据是以表格格式结构化的,行代表实例,列表示它们的特征。这类数据通常被称为表格数据结构化数据。除了表格数据外,你可能在不同的机器学习应用中遇到许多其他类型的数据,这些数据需要通过在机器学习管道中选择定制组件来处理。附录 B 提供了三个更多示例,展示了如何处理图像、文本和表格数据以进行分类任务。所有这些都使用了经典的数据准备方法和机器学习模型。如果你不熟悉这些问题,我建议你在继续下一章之前先看看它们。这些示例还展示了如何使用 scikit-learn 中的网格搜索方法在机器学习管道中联合调整多个超参数。本书的第二部分将讨论更高级的超参数调整选项。

摘要

  • 在机器学习项目中,首要任务是将问题表述为机器学习问题,并组装用于的数据库集。

  • 探索和准备数据集非常重要。从数据中提取有用的模式可以提高最终机器学习解决方案的性能。

  • 在选择机器学习模型的过程中,应该尝试不同的模型并评估它们的相对性能。

  • 使用适合你模型的正确超参数对于机器学习解决方案的最终性能至关重要。网格搜索是一种简单的自动化机器学习(AutoML)方法,可用于超参数调整和模型选择。

3 深度学习概述

本章涵盖

  • 深度学习模型构建和训练的基本原理

  • 使用多层感知器对表格数据进行回归

  • 使用多层感知器和卷积神经网络对图像数据进行分类

  • 使用循环神经网络对文本数据进行分类

深度学习,作为机器学习的一个子领域,已经成为人工智能社区乃至更广泛的领域的热门话题。它推动了众多领域中的应用,并且与许多早期介绍的传统模型相比,取得了优越的性能。本章将介绍深度学习的基本构建块,并展示如何将三种流行的模型类型应用于解决不同数据类型上的监督学习任务。本章还将作为垫脚石,帮助您更好地理解书中第二部分介绍的用于生成和调整深度学习方法的 AutoML 方法。

3.1 什么是深度学习?

“深度学习”中的“深度”指的是依次添加的,如图 3.1 所示。堆叠在一起的层数称为深度学习模型的深度。例如,图 3.1 中模型的深度是四层。您可以将层视为一系列操作,用于转换特征,例如用矩阵乘以特征。层共同训练以执行为我们进行的转换,而不是我们逐个执行。我们将每个层的输出称为原始输入的表示(或嵌入)。例如,图 3.1 左侧的猫图像是模型的输入。图像的像素值可以被视为图像的原始表示。模型的第 1 层将图像作为输入并输出五个不同的图像,这些是原始图像的转换表示。最后,第 4 层的输出是一个向量,表示预测的图像标签是“猫”。

03-01

图 3.1 用于动物分类的深度学习模型

深度学习模型通常统称为神经网络,因为它们主要基于人工神经网络(ANNs),这是一种从大脑的生物结构中得到启发而松散构建的模型。

在实践中应用深度学习遵循第二章中介绍的相同的机器学习流程。但以下两个特性将深度学习模型与之前介绍的模型区分开来:

  • 模型结构减少了特征工程的工作量,例如在图像中进行主成分分析(PCA)以降低维度。通过层学习到的特征变换可以产生类似的效果,您将看到。

  • 深度模型比“较浅”的模型(如前几章中介绍的传统的模型,例如决策树和线性回归模型)或最多两层神经网络的模型引入了更多的学习参数和需要调整的超参数。

深度学习模型在广泛的问题上表现出高性能,尤其是在大量数据的问题上。本章将通过带有代码示例的三个典型深度学习应用来指导你理解这些区别以及如何在实践中应用深度学习。在我们进入示例之前,让我们快速了解一下你将使用的实现深度学习的工具。

3.2 TensorFlow 和 Keras

TensorFlow 是一个开源的机器学习平台。它拥有一个全面、灵活的工具和库生态系统,研究人员和开发者可以使用它来构建和部署机器学习驱动的应用程序。TensorFlow 实现了一套全面的数学运算,可以在不同的硬件上运行,包括 CPU、GPU 和 张量处理单元(TPU)用于深度模型训练。训练可以扩展到多台机器上的多个 GPU,并且训练好的模型可以在各种环境中部署,如网页和嵌入式系统。

注意,TPU 是专门为 张量 计算设计的专用硬件。GPU 和 TPU 在深度学习中被广泛使用,以促进模型训练和推理速度。

张量 是深度学习中应用最广泛的数据类型,是一个 n 维数组。张量是向量和矩阵的推广,可以具有超过两个维度:向量是一维张量,矩阵是二维张量。在实践中,一个 RGB 图像可以被视为一个三维张量(颜色通道 × 高度 × 宽度),而视频可以被视为一个四维张量,额外的维度是时间(或帧)维度。

Keras 是一个 Python 库,它通过封装 TensorFlow 的功能,提供了一套更简单的 API 来构建和训练机器学习模型。它极大地减少了构建深度学习算法所需的工作量,并且得到了社区的广泛认可。Keras 最初作为一个独立的 Python 包出现,但后来已被集成到 TensorFlow 包中,作为一个高级 API,便于深度学习模型的定制、扩展和部署。从现在开始,我们将主要使用 TensorFlow 中的 Keras API 来实现所有的深度学习工作流程。

3.3 使用多层感知器进行加利福尼亚房价预测

我们将要解决的第一个问题是我们在第二章中研究过的问题:加利福尼亚房价预测问题。这是一个回归问题,目标是根据八个特征(例如,该区域房屋的平均房间数)预测一个住宅区的平均房价。我们将遵循创建机器学习管道的典型流程来进行分析,但我会跳过重复的部分,并在深度学习的背景下强调不同的部分。

3.3.1 组装和准备数据

我们遵循之前使用 scikit-learn 库收集数据并为其深度学习模型准备数据的过程。第一步是加载加利福尼亚住房数据集,并将 20% 的数据分割出来用于测试,如下面的列表所示。

列表 3.1 加载和分割加利福尼亚住房数据集

from sklearn.datasets import fetch_california_housing

house_dataset = fetch_california_housing()    ❶

from sklearn.model_selection import train_test_split
train_data, test_data, train_targets, test_targets = train_test_split(
    data, target,
    test_size=0.2,
    random_state=42)                          ❷

❶ 加载数据集

❷ 将 20% 的数据分割出来用于测试

正如我们之前看到的,训练集和测试集中的特征矩阵的形状分别是 (16512, 8) 和 (4128, 8),如下所示:

>>> train_data.shape, test_data.shape
((16512, 8), (4128, 8))

因为所有数据集的特征都是数值特征,没有缺失值,正如我们在第 2.3 节所学,数据已经适合输入到神经网络中。然而,不同的特征有不同的尺度。这在实践中可能是一个问题,会导致训练过程变得非常缓慢。在最坏的情况下,训练可能不会收敛,这意味着优化损失或网络的权重没有稳定在最优值周围的误差范围内。通常,当权重收敛时,神经网络的训练会停止。否则,我们考虑训练失败,产生的模型通常无法很好地工作。为了处理不同的特征尺度,特征归一化是一个好主意。我们通过从特征均值中减去并除以它们的标准差来实现这一点,如下面的列表所示。

列表 3.2 在训练和测试数据上执行特征归一化

def norm(x, mean, std):                            ❶
    return (x - mean) / std

mean = train_data.mean(axis=0)                     ❷
std = train_data.std(axis=0)                       ❷

normed_train_data = norm(train_data, mean, std)    ❸
normed_test_data = norm(test_data, mean, std)      ❸

❶ 定义一个函数来进行特征归一化

❷ 计算每个特征的均值和标准差

❸ 归一化训练和测试数据

注意,我们使用为训练数据计算的均值和标准差来归一化测试数据,以下两个原因:

  • 我们假设训练和测试数据遵循相同的分布。

  • 测试数据可能没有足够多的实例来计算可靠的均值和标准差值。

在这个例子中,这种归一化将是唯一进行的特征工程。它也可以用浅层模型来完成,但我们没有在第二章中这样做,因为线性回归和决策树模型的优化算法不会从它那里获得太多好处。要了解更多信息,请参阅 Joel Grus 的 Data Science from Scratch,第 2 版(O'Reilly,2019)。

我们现在准备好创建深度学习算法。

3.3.2 构建多层感知器

为了实现深度学习算法,让我们首先在 TensorFlow 中导入 Keras,如下所示:

from tensorflow import keras

作为快速回顾,构建机器学习算法需要你指定四个组件:模型类型、用于衡量当前模型质量的度量、用于更新模型权重的优化方法以及用于终止更新过程的停止标准。我们将从第一个组件开始,并实例化一个三层神经网络。我们使用以下代码构建网络,网络结构如图 3.2 所示。

03-02

图 3.2 三层网络

列表 3.3 创建一个三层神经网络

from tensorflow import keras
from tensorflow.keras import layers

model = keras.Sequential([
    layers.Dense(64, activation='relu', input_shape=[8]),
    layers.Dense(64, activation='relu'),
    layers.Dense(1)
])                     ❶

❶ 使用 Keras API 创建一个多层感知器模型

这三层都是同一类型,被称为 全连接密集层。前两层,离输入更近,也被称为 隐藏层。最后一层,用于生成预测的房价,被称为 输出层。密集层的输入和输出都是张量 (n 维数组)。keras.Sequential 表示我们选择的模型是一个由多个层顺序堆叠构建的 Keras 模型。由多个密集层组成的顺序模型被称为 多层感知器 (MLP)。

为了更好地理解代码,让我们深入探究一下密集层是如何工作的。密集层可以表示为 输出 = 激活函数(点积(输入, 权重矩阵) + 偏置)。它由以下三个操作组成:

  • 张量-矩阵点积—这是矩阵-矩阵乘法的推广。张量-矩阵点积会将输入张量与一个矩阵(通常称为 核矩阵)相乘,将其转换为一个新张量,其最后一个维度与原始张量不同。在定义层时,我们应该明确定义这个最后一个维度的形状,或者这个层的 单元数。例如,在这个问题中,每个输入的住房块实例是一个包含八个元素的 一维向量。第一个密集层有 64 个单元。它将创建一个 8×64 的权重矩阵(可以学习),将每个输入向量转换为一个长度为 64 的新向量(张量)。如果每个输入样本是一个大小为 3×10×10 的三维张量,通过使用相同的代码定义密集层,我们将创建一个 20×64 的权重矩阵,将输入张量转换为一个 3×10×64 的张量。具体的计算是以矩阵-矩阵乘法的方式进行,这意味着输入将被分成多个矩阵,并与权重矩阵相乘。我们使用一个玩具示例在图 3.3 中说明计算过程。

  • 偏置加法操作—在执行点积之后,我们向每个实例添加一个偏置权重。偏置权重是一个与点积后实例表示形状相同的张量。例如,在这个问题中,第一个密集层将创建一个形状为 64 的可学习偏置向量。

  • 激活操作——选定的激活函数定义了一个激活操作。因为神经网络的原始概念是受神经生物学启发的,所以每一层输出的表示(张量)中的每个元素被称为一个神经元。引入激活函数是为了近似细胞外场对神经元的影响。从神经网络的视角来看,我们通常选择激活函数作为应用于每个神经元的非线性映射函数,以在每个层定义的转换中引入非线性。如果我们使用线性激活,堆叠多个线性层将导致一个仅包含线性变换的约束表示空间。因此,产生的神经网络将不会是一个通用逼近变换。一些常用的激活函数包括 ReLU(修正线性单元)、sigmoid 和 tanh(双曲正切)。它们的形状如图 3.4 所示。

03-03

图 3.3 张量点积:一个三维张量乘以一个二维矩阵

03-04

图 3.4 三种常见的激活函数:从左到右,ReLU、sigmoid 和 tanh

当实例化一个密集层时,你需要指定其输出形状(单元数)。如果是第一层,你还需要提供输入形状。对于其余的层,这不需要,因为它们的输入形状可以从前一层输出中自动确定。

当将 MLP 模型应用于数据集时,我们可以一次输入一个数据点,或者一次提供一批实例。在任何时候输入到网络中的数据点数量称为批次大小。在将数据输入到网络之前,不需要指定模型。通过神经网络传递输入以实现输出的流程称为前向传递。现在让我们尝试创建的模型,从训练数据中切片一个包含五个数据点的示例批次,并执行如下一代码列表所示的前向传递。

列表 3.4 使用示例数据批次尝试模型

>>> example_batch = normed_train_data[:5]           ❶
>>> example_result = model.predict(example_batch)   ❷
>>> example_result
array([[-0.06166808],
       [-0.12472008],
       [-0.01898661],
       [-0.1598819 ],
       [ 0.17510001]], dtype=float32)

❶ 切片前五个归一化数据点

❷ 将数据批次输入模型进行预测

由于我们没有训练网络中的权重,预测是基于每层的随机初始化权重计算的。Keras API 提供了一种方便的方法,可以使用 summary 函数可视化定义的模型中每层的输出形状和权重,如下所示:

>>> model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                Output Shape              Param #
=================================================================
dense (Dense)               (None, 64)                576
_________________________________________________________________
dense_1 (Dense)             (None, 64)                4160
_________________________________________________________________
dense_2 (Dense)             (None, 1)                 65
=================================================================
Total params: 4,801
Trainable params: 4,801
Nontrainable params: 0
_________________________________________________________________

模型可以接受任何大小的数据批次。每个层的输出形状的第一个维度是 None,因为我们没有预先定义每个批次中的数据点数量。模型将在数据被输入后识别每个批次中的数据点数量。参数包括张量-矩阵点积中的权重矩阵和偏置向量。例如,第一层有 8 * 64 + 64 = 576 个参数。再次强调,这些参数在训练之前都是随机初始化的,因此它们不能将特征转换为正确的预测。但是,检查权重和输出形状的练习可以帮助我们调试模型结构。现在让我们开始训练网络并测试其性能。

3.3.3 训练和测试神经网络

训练网络需要一套完整的深度学习算法,并选择以下三个剩余组件:

  • 优化器—用于更新神经网络权重的优化方法。

  • 损失函数—一个用于衡量神经网络性能并在训练过程中指导优化器的函数。它通常衡量真实值和预测值之间的差异。例如,均方误差(在第 2.5 节中介绍)是回归任务的可接受损失函数。在训练过程中,优化器将尝试更新权重以实现最小损失。

  • 指标—在训练和测试过程中要监控的统计数据,例如分类任务的准确率。这些数据不会影响训练过程,但将被计算并记录在训练集和验证集上。这些值用作辅助信息,以分析和调整设计的算法。

深度学习中常用的优化器是什么,它是如何工作的?

训练神经网络最广泛使用的优化方法被称为随机梯度下降(SGD)。以下图示说明了原始的 SGD 优化器是如何工作的。在这个图中,y轴是神经网络的损失值,x轴表示要更新的网络权重。为了说明目的,我们这里只考虑一个权重。应用 SGD 的目标是更新这个权重,以找到曲线中的全局最优点,其y值对应于损失函数的最小值。这个目标可能并不总是能达到,这取决于超参数和网络复杂性。我们可能最终识别出一个次优点,如图中的局部最优点。

03-04-unnumb

随机梯度下降在 1-D 损失曲线(一个可学习参数)下的示意图

在每次迭代中,SGD 使用训练数据的一个子集通过求损失函数的导数来计算参数的梯度。然后通过将梯度加到权重上来更新权重。通常,梯度不是直接加到权重上,而是乘以一个称为学习率的值,这是一个控制更新速率的超参数。此过程的伪代码如下所示。

列表 3.5 随机梯度下降的伪代码

for i in range(num_iterations):
    gradients = calculate_gradients(loss, data_subset)
    weights = weights - gradients * learning_rate

图中的每个箭头表示在迭代中权重的变化方向。其长度是梯度学习率的值。如果权重从t[0]开始,它最终会到达t[2],这是一个局部最优。然而,从t'*[0]初始化的权重在右侧达到了全局最优。正如这个例子所示,初始化值和选择的学习率可以导致不同的优化结果。像 RMSprop 和 Adam 这样的原始 SGD 优化器的变体试图提高优化效果和效率,这就是为什么你会在我们的代码实现中看到它们被引入。

在这个例子中,我们只有一个权重需要更新。但是,由于神经网络通常具有多层,每层有多个权重,计算梯度可能会变得非常复杂。计算所有权重梯度的最常见方法称为反向传播。它将层视为一系列组合函数(张量运算)的链,并应用链式法则来计算每层中权重的梯度。后续层的梯度可以用来计算前一层梯度,从而使它们最终从神经网络的最后一层反向传递到第一层。

使用以下一行代码可以设置深度学习算法的损失函数、优化器和指标:

model.compile(loss='mse', optimizer='rmsprop', metrics=['mae', 'mse'])

编译方法配置模型以进行训练。我们使用均方误差(MSE)作为损失函数来衡量神经网络在训练过程中的性能,正如我们在第二章中为线性回归模型所做的那样。选择的优化器是 RMSprop,它是 SGD 的一种变体。我们应用两个指标,平均绝对误差(MAE)和 MSE,来评估模型。我们还可以自定义优化器的配置,例如学习率,如下所示:

optimizer = tf.keras.optimizers.RMSprop(0.01)
model.compile(loss='mse', optimizer=optimizer, metrics=['mae', 'mse'])

在为训练准备网络后,我们可以使用 fit 方法如下向其提供数据:

model.fit(normed_train_data,
          train_targets,
          epochs=300,
          batch_size=1024,
          verbose=1)

训练过程将根据预定义的停止标准终止,例如训练的epochs数量。因为输入被分成批次(这里每个批次 1,024 个),在这种情况下,一个 epoch 意味着将所有批次输入神经网络并更新其权重一次。例如,如果我们有 100 个训练示例,批大小为 1,那么一个 epoch 等于模型更新的 100 次迭代。

回顾第一章中介绍的机器学习的一般学习过程,训练神经网络的对应工作流程如图 3.5 所示。给定一批输入数据,损失函数将通过将当前网络的预测与目标进行比较来衡量当前网络的预测准确性。优化器将根据损失函数的反馈来更新网络中每一层的权重。

03-05

图 3.5 训练神经网络的流程(从通用机器学习训练流程转换而来)

为了测试训练好的网络,我们可以调用 evaluate 函数。我们的模型经过 300 轮训练后,在测试集上达到 MSE 0.34,如下代码片段所示:

>>> loss, mae, mse = model.evaluate(normed_test_data, test_targets, verbose=0)
>>> mse
0.34268078

我们已经成功训练并测试了我们的第一个深度学习模型。下一步是调整超参数,看看我们是否可以提高其性能。

3.3.4 调整训练轮数

调整深度学习算法对于提高其性能至关重要,但这个过程通常既耗时又昂贵,因为深度神经网络包含许多超参数(层数、层类型、单元数等)。学习过程通常被视为一个黑盒,现有文献中很少发现理论保证。在这里,我们以使用留出验证集调整训练轮数(epochs)的简单例子为例。更复杂的超参数调整,如层数和层中单元数,将在后续章节中介绍,使用高级的 AutoML 工具。

如列表 3.6 所示,我们将 20% 的数据分割出来作为验证集,并使用留出交叉验证来确定训练轮数(epochs)。我们训练神经网络以高轮数进行,将训练历史保存到 pandas DataFrame 中,并按轮数绘制训练和验证集的 MSE 曲线。

列表 3.6 使用留出交叉验证验证 MLP 模型

def build_model():                                          ❶
    model = keras.Sequential([
        layers.Dense(64, activation='relu',
                     input_shape=[normed_train_data.shape[1]]),
        layers.Dense(64, activation='relu'),
        layers.Dense(1)
    ])
    model.compile(loss='mse', optimizer='rmsprop', metrics=['mae', 'mse'])
    return model

model = build_model()                                       ❷

EPOCHS=500
history = model.fit(normed_train_data, train_targets,
                    validation_split=0.2,
                    epochs=EPOCHS, batch_size=1024,
                    verbose=1)                              ❸

import pandas as pd
hist = pd.DataFrame(history.history)                        ❹

hist['epoch'] = history.epoch                               ❺

import matplotlib.pyplot as plt                             ❻
plt.plot(hist['epoch'], hist['mse'], label='train mse')     ❻
plt.plot(hist['epoch'], hist['val_mse'], label='val mse')   ❻
plt.xlabel('Epochs')                                        ❻
plt.ylabel('MSE')                                           ❻
plt.title('Training and Validation MSE by Epoch')           ❻
plt.legend()                                                ❻
plt.show()                                                  ❻

❶ 创建一个函数来帮助反复构建新的编译模型

❷ 创建一个新的编译模型

❸ 在训练过程中留出 20% 的数据用于验证

❹ 将历史数据检索到 DataFrame 中——history.history 是一个字典,包含每个轮次在训练和验证集上的损失、平均绝对误差(MAE)和 MSE 结果。

❺ 为绘图目的向 DataFrame 添加一个轮数列

❻ 绘制训练和验证 MSE 曲线

训练和验证均方误差(MSE)曲线显示在图 3.6 中。

03-06

图 3.6 按轮数显示的训练和验证 MSE

由于尺度变化和轮次间的波动,这些曲线的解释并不容易,尤其是在轮数较多的情况下。使用列表 3.7 中的代码,我们可以将 y-轴限制在 0.5 以下,以便放大并使用高斯平滑来平滑训练和验证曲线(参见 Shapiro 和 Stockman 的 Computer Vision,Prentice Hall,2001,第 137、150 页)。

列表 3.7 平滑准确率曲线

import numpy as np
def smooth_curve(values, std=5):      ❶
    width = std * 4
    x = np.linspace(-width, width, 2 * width + 1)
    kernel = np.exp(-(x / 5) ** 2)

    values = np.array(values)
    weights = np.ones_like(values)

    smoothed_values = np.convolve(values, kernel, mode='same')
    smoothed_weights = np.convolve(weights, kernel, mode='same')
    return smoothed_values / smoothed_weights
plt.plot(hist['epoch'], smooth_curve(hist['mse']), label = 'train mse')
plt.plot(hist['epoch'], smooth_curve(hist['val_mse']), label = 'val mse')
plt.xlabel('Epochs')
plt.ylabel('MSE')
plt.ylim((0, 0.5))
plt.title('Training and Validation MSE by Epoch (smoothed)')
plt.legend()
plt.show()

❶ 使用高斯平滑函数对一系列值进行平滑处理

调整后的图示如图 3.7 所示。它显示在 500 个轮次期间,训练 MSE 持续下降。相比之下,验证 MSE 在约 150 个轮次后显示出增加的波动趋势。这意味着网络在约 150 个轮次后倾向于过拟合训练数据。

03-07

图 3.7 每个轮次的训练和验证 MSE(平滑),y 轴限制在 0.5 以下

在选择了最佳轮数后,我们现在可以在完整的数据集(训练集和验证集)上重新训练模型并进行测试,如下面的代码列表所示。

列表 3.8 在完整训练集上重新训练最终模型

model = build_model()
model.fit(normed_train_data, train_targets, epochs=150, batch_size=1024, verbose=1)

测试集的最终均方误差(MSE)为 0.31,这比我们用训练了 300 个轮次的模型所达到的结果要好,如下所示:

>>> loss, mae, mse = model.evaluate(normed_test_data, test_targets, verbose=0)
>>> mse
0.30648965

在这个例子中,我们只训练了模型一次以选择最佳的轮数。调整其他超参数可能需要一些尝试和错误。例如,你可以使用网格搜索方法来调整网络的深度——即构建多个具有不同层数的 MLP,并在相同的训练和验证集分割上尝试它们,以选择最佳的网络深度。我们将在本书的第二部分介绍更方便的实现方法。

我们现在已经构建了我们第一个深度神经网络,一个 MLP,来解决表格数据回归问题。在接下来的两个部分中,你将了解另外两种深度学习模型,你可以使用它们分别解决图像数据和文本数据的分类问题。

3.4 使用卷积神经网络对手写数字进行分类

在本节中,我们将探讨一种新的模型,即卷积神经网络(CNN),它是计算机视觉应用中占主导地位的深度学习模型。我们将通过手写数字分类来解释其工作原理,并构建一个 MLP 网络进行比较。

3.4.1 组装和准备数据集

让我们先收集数据集并做一些准备工作。在第一章中,我们使用了 scikit-learn 附带的数据集,其中包含 1797 张 8×8 像素的手写数字图像。在这个例子中,我们将使用一个类似但更大的数据集,称为MNIST,它通常用作深度学习的入门套件。它包含由国家标准与技术研究院(NIST)收集的 60,000 张训练图像和 10,000 张测试图像。每个图像的大小为 28×28,并标记为 0 到 9 的数字。该数据集可以使用 Keras API 组装,如下面的列表所示。

列表 3.9 使用 TensorFlow Keras API 加载 MNIST 数据集

from tensorflow.keras.datasets import mnist

(train_images, train_labels), (test_images, test_labels) =  
➥mnist.load_data()

加载的数据已经分为训练集和测试集。图像和标签以 NumPy 数组的形式存在。让我们在下一个列表中查看训练和测试数据。

列表 3.10 探索训练和测试数据的形状

>>> train_images.shape, test_images.shape
((60000, 28, 28), (10000, 28, 28))
>>> len(train_labels), len(test_labels)
(60000, 10000)
>>> train_labels, test_labels
(array([5, 0, 4, ..., 5, 6, 8], dtype=uint8),
 array([7, 2, 1, ..., 4, 5, 6], dtype=uint8))

我们也可以使用以下代码可视化一个样本图像。

列表 3.11 可视化训练图像

import matplotlib.pyplot as plt

plt.figure()
plt.imshow(train_images[0])
plt.colorbar()
plt.title('Label is {label}'.format(label=train_labels[0]))
plt.show()

在图 3.8 中,我们可以看到图像中每个像素的值范围从 0 到 255,图像的标签是 5。

03-08

图 3.8 MNIST 中的一个训练样本

与我们在上一个问题中进行的归一化类似,我们可以通过调整像素值的范围到 0 到 1 之间来归一化图像。在列表 3.12 中,我们使用了一种称为最小-最大缩放的归一化方法。这种方法通过将像素值除以最大可能值(255)和最小可能值(0)之间的差值来缩放像素值。我们在这里和上一个例子中使用归一化方法在深度学习中相当常见,以提高学习算法的有效性。

列表 3.12 缩放图像

train_images = train_images / 255.0
test_images = test_images / 255.0

在准备数据之后,我们可以开始构建网络。在我们创建第一个 CNN 之前,让我们构建一个 MLP,我们可以将其用作基准模型进行比较。

3.4.2 使用 MLP 解决问题

我们知道 MLP 模型由多个密集(全连接)层组成,其中每一层的点积应用于输入张量的最后一个轴和核矩阵。为了确保第一层使用图像的所有特征来计算点积,我们可以首先使用 Keras 的 Flatten 层将每个 28×28 的图像转换为 1×784 的向量。我们将它与两个 Dense 层堆叠以创建一个联合管道。图 3.9 提供了 Flatten 层的视觉说明。

03-09

图 3.9 使用 Keras Flatten 层将 2-D 图像重塑为 1-D 向量

构建 MLP 的代码如下所示。

列表 3.13 为 MNIST 手写数字分类构建 MLP

from tensorflow import keras
from tensorflow.keras import layers

mlp_model = keras.Sequential([
    keras.layers.Flatten(input_shape=train_images.shape[1:]),
    keras.layers.Dense(128, activation='relu'),
    keras.layers.Dense(10),
    keras.layers.Softmax()
])

展平层接受图像的形状作为输入,并且没有需要学习的权重。通过比较这个 MLP 与我们之前构建的回归任务中的 MLP,你可能已经注意到了以下主要区别:

  • 在先前的模型中,我们将输出层的单元数设置为 1,以输出预测的房价。在这个多类分类问题中,最后一个密集层的单元数是 10,以与分类目的的类别数(0-9)相匹配。最后一个密集层输出的 10 个值称为logits

  • 我们在最后一个密集层中不应用激活函数,而是在最后添加一个名为 Softmax 层的层,该层应用 softmax 函数 将 10 个 logits 转换为输入属于每个类的概率,以执行最终预测。图像将被分配给具有最高转换概率的类别。softmax 函数可以表示为 03-09-EQ01,其中 c 是类别的数量。y[i] 表示 logits。Softmax 层没有需要学习的参数,其输入和输出的形状相同。Softmax 层也可以被视为最后一个密集层的激活函数,这允许我们更简洁地编写模型规范,如下所示。

列表 3.14 具有 softmax 激活输出密集层的相同 MLP 结构

mlp_model = keras.Sequential([
    keras.layers.Flatten(input_shape=train_images.shape[1:]),
    keras.layers.Dense(128, activation='relu'),
    keras.layers.Dense(10, activation='softmax')
])

我们现在可以通过指定损失函数、优化器和一些要检索的评估指标来编译用于训练的模型,如下所示。

列表 3.15 编译 MLP 模型

mlp_model.compile(optimizer='adam',
                  loss=tf.keras.losses.SparseCategoricalCrossentropy(),
                  metrics=['accuracy'])

我们使用分类准确率作为评估指标。选择 adam 优化器,它是 sgd 优化器的一种变体。adam 和 rmsprop 都是常用的优化方案,你可以尝试不同的方案,根据其性能选择最合适的一个。损失函数是一种交叉熵损失,它衡量两个离散概率分布之间的距离。在这里,它衡量的是属于每个 10 个类别的图像预测概率与真实概率之间的差异。真实概率将为图像的正确标签为 1,其他所有标签为 0。请注意,我们在列表 3.15 中使用 SparseCategoricalCrossentropy,它要求输入标签为整数(本例中的 0-9)。如果你想使用真实标签的 one-hot 表示——例如,标签 2 的表示为[0, 1, 0, 0, 0, 0, 0, 0, 0, 0]——你应该使用 tf.keras.losses.CategoricalCrossentropy。

让我们拟合网络并检查其性能。在这里,我们以 64 张图像每个训练批次输入网络的方式训练网络五个周期(周期数和批量大小可以调整):

>>> mlp_model.fit(train_images, train_labels,
...     epochs=5, batch_size=64, verbose=0)
>>> test_loss, test_acc = mlp_model.evaluate(
...     test_images, test_labels, verbose=0)
>>> test_acc
0.9757

测试准确率为 97.57%,这意味着 MLP 网络能够正确分类大约 98 张中的 100 张图像,这还不错。

3.4.3 使用 CNN 解决问题

在本节中,我们介绍一个卷积神经网络(CNN)模型来解决该问题。CNN 的核心思想是提取一些局部模式,例如图像中的边缘、弧线和纹理,并逐层将这些模式逐渐浓缩成更复杂的模式,例如 弧线、边缘轮胎车灯汽车

为了实现这个目标,除了密集层之外,一个简单的 CNN 通常还包含两种其他类型的层:卷积层池化层。让我们首先构建 CNN,然后通过查看它们输入和输出张量的形状来检查这两个层。创建简单 CNN 的代码如下。

列表 3.16 构建简单的 CNN 模型

def build_cnn():
    model = keras.Sequential([
        keras.layers.Conv2D(32, (3, 3), activation='relu',
                            input_shape=(28, 28, 1)),
        keras.layers.MaxPooling2D((2, 2)),
        keras.layers.Conv2D(64, (3, 3), activation='relu'),
        keras.layers.MaxPooling2D((2, 2)),
        keras.layers.Conv2D(64, (3, 3), activation='relu'),
        keras.layers.Flatten(),
        keras.layers.Dense(64, activation='relu'),
        keras.layers.Dense(10, activation='softmax')
    ])                                                        ❶

    model.compile(optimizer='adam',
        loss=tf.keras.losses.SparseCategoricalCrossentropy(),
        metrics=['accuracy'])                                 ❷
    return model

cnn_model = build_cnn()

❶ 构建 CNN 模型结构

❷ 编译模型以进行训练

在 Flatten 层之后,网络结构与简单的 MLP 相同,除了单元数量不同。前五个层由三个卷积层和两个池化层交替组成。它们都是二维层,旨在提取图像的空间特征。在介绍具体操作之前,让我们在这里显示每个层的输入和输出形状:

>>> cnn_model.summary()
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
conv2d (Conv2D)              (None, 26, 26, 32)        320
_________________________________________________________________
max_pooling2d (MaxPooling2D) (None, 13, 13, 32)        0
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 11, 11, 64)        18496
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 5, 5, 64)          0
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 3, 3, 64)          36928
_________________________________________________________________
flatten_1 (Flatten)          (None, 576)               0
_________________________________________________________________
dense_2 (Dense)              (None, 64)                36928
_________________________________________________________________
dense_3 (Dense)              (None, 10)                650
=================================================================
Total params: 93,322
Trainable params: 93,322
Nontrainable params: 0
_________________________________________________________________

无论第一个批量大小维度如何,卷积层都接受三个维度的输入(高度、宽度、通道)并输出一个三维张量,我们通常称之为特征图。前两个维度是空间维度,表示图像的大小(MNIST 图像为 28×28)。最后一个维度表示特征图中的通道数。对于原始输入图像,通道维度是颜色通道的数量。例如,RGB 图像有三个通道:红色、蓝色和绿色。像 MNIST 数据集中的图像这样的灰度图像只有一个通道。

卷积层是如何工作的

在实例化卷积层时,我们需要指定两个主要参数。第一个是滤波器(或核)的数量,它决定了输出特征图中的通道数(最后一个维度的尺寸)。第二个参数是每个滤波器的大小。滤波器是一个可训练的张量,它试图在输入特征图中发现某种模式。它扫描输入特征图,并输出一个矩阵,指示目标特征出现在输入中的位置。通过聚合不同滤波器的输出矩阵,我们得到卷积层的输出特征图。输出特征图中的每个通道都是由一个独特的滤波器生成的。以列表 3.16 中的第一个卷积层为例,我们将其通道数设置为 32,每个滤波器的大小为 3×3×1。每个滤波器将生成一个 26×26 的矩阵(我稍后会解释原因)。因为我们有 32 个滤波器,所以它们将生成 32 个 26×26 的矩阵,这些矩阵共同构成了形状为(26, 26, 32)的输出特征图。卷积层中的可训练权重数量等于滤波器中元素数量的总和加上通道数(偏置向量的长度)。例如,在第一个卷积层中,有 3 * 3 * 32 + 32 = 320 个参数需要学习。

现在我们来看一个三维滤波器如何将一个三维输入特征图转换成另一个三维特征图,以及如何计算输出特征图的形状。我们将使用一个 4×4×1 的输入特征图和大小为 3×3 的滤波器作为例子(见图 3.10)。滤波器会逐步遍历特征图。我们还可以指定步长,称为步长。在这里,我们假设步长为 1,这意味着一个 3×3×1 的滤波器必须水平或垂直移动两步才能通过 4×4×1 特征图。在每一步中,它将覆盖与自身大小相同的输入特征图区域并执行卷积操作(这就是卷积层名称的由来)。

03-10

图 3.10 4×4×1 特征图与 3×3×1 滤波器进行步长为 1 的卷积操作,生成 2×2×1 特征图

从数学上讲,在每一步中,它首先在覆盖的特征图和滤波器之间进行逐元素乘法。这将产生一个与滤波器大小相同的三个维度的张量。然后,它将张量中的所有元素加起来得到一个单一值。通过遍历输入特征图,我们将得到一个矩阵(或者如果我们考虑通道轴,则是一个三维张量)。这个矩阵的大小由滤波器大小和步长共同决定。通常,输出特征图的大小等于步长可以采取的有效步数的数量。例如,在图 3.11 中,我们将得到一个 2×2 的矩阵,因为步长可以在高度和宽度维度上采取两个有效的步数。同样,在 MNIST 示例中,每个 3×3 滤波器可以在每个维度上移动 26 步,将 28×28 的图像转换成 26×26 的特征图。

如果我们在定义 Conv2D 层时将步长设置为(2,2),则每个滤波器将沿着每个维度移动两步。假设我们将滤波器大小定义为(3, 3)并有一个 4×4 的输入特征图。滤波器在垂直和水平方向上无法移动两步,边界上的一些像素无法覆盖并考虑,如图 3.11 所示。如果我们将步长设置为(1, 1),滤波器将能够沿着每个维度移动两步。然而,边界上的像素在较少的步数中被考虑,这被称为边界效应

03-11

图 3.11 无填充的卷积

为了帮助过滤器实现特征图的全覆盖,我们可以通过在其边缘添加零来扩展输入特征的范围。这种填充将根据需要添加行和列,以确保像素被同等考虑(在卷积层和池化层中)。例如,如图 3.12 所示,在每个边缘添加一列或一行允许 3×3 的过滤器在步长为(1, 1)时保持 4×4 输入特征图的形状。填充特征图中的每个像素在卷积操作中将被考虑相同数量的步骤。

03-12

图 3.12 比较了带零填充和不带零填充的输入特征图的输出维度

池化层的工作原理

现在让我们来谈谈池化。图 3.13 展示了池化层是如何工作的。这类层用于在空间维度上减小特征图的大小,以达到以下两个目的:

  • 为了降低计算复杂度和后续层(尤其是权重大小与输入大小相对应的全连接层)中需要学习的参数数量。

  • 在减小特征图尺寸的同时,保持尺度、旋转和平移不变性。在图 3.13 中,图像的每个小块在通过池化层后不会进行缩放或旋转。它们被聚合成一个粗略的图像,保持了原始图像的意义。

03-13

图 3.13 池化可以在一定程度上保持图像属性的不变性。

池化层执行与卷积层类似的操作,但没有用于学习的任何过滤器(核)。卷积操作被替换为特定的硬编码操作,例如我们在第一个 CNN 中定义的 MaxPooling2D 层中的最大操作。当实例化一个池化层时,我们需要指定我们想要使用哪种类型,例如我们例子中的 MaxPooling2D,并定义一个池化大小(类似于核大小)以识别应用池化操作的特征图区域。池化层的步长必须与池化大小相同,这意味着每个维度的池化大小应该是该维度大小的因子。如果不是,则必须在之前应用填充操作;这是通过在用 Keras API 实例化池化层时指定 padding='valid'参数来完成的。

应用池化层会将输入特征图划分为与用户指定的池化大小相同的多个块。在每个块中,它将应用池化操作。例如,MaxPooling2D 层将选择特征图每个块中的最大值(见图 3.14)。

03-14

图 3.14 在 4×4 特征图上应用池化大小为(2, 2)的最大池化

实际上,池化层通常与卷积层或密集层交替使用,以逐步减小特征图的大小。

训练和测试 CNN

在将 MNIST 数据输入我们的 CNN 之前,我们需要为原始图像添加一个额外的通道维度,如图所示:

>>> train_images_4d = train_images[..., tf.newaxis]
>>> test_images_4d = test_images[..., tf.newaxis]
>>> train_images_4d.shape, test_images_4d.shape
((60000, 28, 28, 1), (10000, 28, 28, 1))

编译和训练 CNN 模型与 MLP 没有不同。通过检查性能,如图所示,我们可以看到简单的 CNN 模型达到了 99.02%的准确率,将错误率降低了 40%以上:

>>> cnn_model.fit(train_images_4d, train_labels,
...               epochs=5, batch_size=64, verbose=1)
>>> test_loss, test_acc = cnn_model.evaluate(
...     test_images_4d, test_labels, verbose=0)
>>> test_acc
0.9902

此外,尽管 CNN 的层数比我们设计的 MLP 多,但由于池化层减少了特征图的大小,总参数数量比 MLP 少。

同样,我们可以通过调整超参数(如滤波器大小、步长大小、池化大小、卷积和池化层的数量和组合、学习率、优化器等)来调整 CNN 模型。这里有很多选项,但您应该能够通过编写一个简单的循环函数,尝试不同的超参数值,并使用交叉验证比较它们的性能来手动调整它们。我将在本书的第二部分介绍 AutoML 方法,以帮助您更方便地完成这项工作。

3.5 使用循环神经网络进行 IMDB 评论分类

在本章的最后一个例子中,我将向您展示如何使用经典的用于序列数据的深度学习模型——循环神经网络(RNN)来解决文本分类问题。我们将使用的数据集是 IMDB 电影评论数据集。目标是预测用户撰写的评论是正面还是负面。

3.5.1 准备数据

与 MNIST 数据集类似,IMDB 数据集也可以使用 Keras 加载,如下所示。

列表 3.17 加载 IMDB 数据

from tensorflow.keras.datasets import imdb

max_words = 10000          ❶

(train_data, train_labels), (test_data, test_labels) = imdb.load_data(
    num_words=max_words)   ❶

❶ 加载数据并仅保留出现频率最高的 num_words 个单词

此代码将评论加载到 train_data 和 test_data 中,并将标签(正面或负面)加载到 train_labels 和 test_labels 中。数据集已经通过标记化从原始文本评论转换为整数列表。标记化过程首先将每个评论分割成单词列表,然后根据单词-整数映射字典为每个单词分配一个整数。这些整数没有特殊含义,但为可以输入网络的单词提供了数值表示。标签是表示每个评论是正面还是负面的布尔值。让我们检查以下数据:

>>> train_data.shape
(25000,)
>>> train_labels.shape
(25000,)
>>> train_data[0]
[1, 14, 22, 16, 43, 530, 973, 1622, 1385, 65, 458, 4468, 66, 3941, 4,
➥ 173, 36, 256, 5, 25, 100, 43, 838, 112, 50, 670, 2, 9, 35, ...]
>>> len(train_data[0])
218
>>> len(train_data[1])
189
>>> train_labels[:2]
[1 0]

由于评论可能具有不同的长度,如这个输出所示,我们将序列填充到相同的长度以将其格式化为矩阵。填充操作如图 3.15 所示。我们首先选择一个最大长度(max_len),所有序列都将转换为这个长度。如果序列短于 max_len,我们在末尾添加零;如果它更长,我们截断多余的长度。

03-15

图 3.15 展示了如何将序列填充到相同的长度。

在这个例子中,我们选择最大长度为 100,并使用 Keras 实现,如列表 3.18 所示。

列表 3.18 截断和填充数据以达到相同的长度

from tensorflow.keras.preprocessing import sequence

max_len = 100

train_data = sequence.pad_sequences(train_data, maxlen=max_len)
test_data = sequence.pad_sequences(test_data, maxlen=max_len)

返回的填充训练数据被分组成一个矩阵,其形状为(25000, 100),如下所示:

>>> train_data.shape
(25000, 100)

矩阵中的每个整数只是单词的数值表示,因此它们对网络学习没有具体含义。为了向网络提供有意义的输入,我们使用一种称为词嵌入的技术为每个单词创建一个可学习的向量,我们称之为嵌入向量。该方法将为每个单词随机初始化一个嵌入向量。这些向量作为网络的输入,并与网络的权重一起学习。词嵌入提供了一种将人类语言映射到几何空间的方法,当有足够的训练数据时非常强大。如果您没有大量数据集,可以使用为其他数据集学习到的嵌入向量(预训练词嵌入)作为初始化,以帮助算法更好地、更快地学习特定任务的嵌入。

因为嵌入向量是可学习的参数,所以词嵌入方法被封装为 Keras 中的一个层,命名为 Embedding。它可以与 RNN 一起堆叠。我们可以通过创建一个 Keras 顺序模型并添加一个嵌入层作为第一层来实现整个流程,如下所示。

列表 3.19 向模型添加嵌入层

from tensorflow.keras.layers import Embedding
from tensorflow.keras import Sequential

max_words = 10000
embedding_dim = 32

model = Sequential()                             ❶
model.add(Embedding(max_words, embedding_dim))   ❷

❶ 创建一个 Keras 顺序模型对象

❷ 在顺序模型中添加嵌入层

max_words 参数定义了词汇表大小,或输入数据中可能包含的最大单词数量。这里的嵌入维度(32)表示每个词嵌入向量的长度。嵌入层的输出张量形状为(batch_size, max_len, embedding_dim),其中 max_len 是我们之前使用的填充序列的长度。现在,每个评论序列都是一个由一组词嵌入向量组成的矩阵。

3.5.2 构建 RNN

在嵌入层之后,我们构建一个用于分类的 RNN。RNN 处理序列输入,这些输入格式化为向量。它一次取一个嵌入向量以及一个 状态向量,为下一步使用生成一个新的状态向量(见图 3.16)。你可以把状态向量看作 RNN 的记忆:它提取并记住序列中前面的单词的信息,以考虑同一序列中单词之间的序列相关性。实际上,图中的每个 RNN 单元都是包含由某些可学习权重矩阵定义的特定转换的相同单元的副本。在第一步中,RNN 没有前面的单词可以记住。它以第一个词嵌入向量和初始状态(通常是空的零向量)作为输入。第一步的输出是第二步要输入的状态。对于其余的步骤,RNN 将前一步的输出和当前输入作为输入,输出下一步的状态。对于最后一步,输出状态是我们将用于分类的最终输出。

03-16

图 3.16 基本循环神经网络架构

我们可以使用以下列表中的 Python 代码来说明这个过程。返回的状态是 RNN 的最终输出。

列表 3.20 RNN 的伪代码

state = [0] * 32                       ❶
for i in range(100):                   ❷
    state = rnn(embedding[i], state)   ❷
return state

❶ 设置循环状态的数量

❷ 递归生成新的状态

有时,我们可能还需要收集如图 3.17 所示的每个步骤的输出。我们可以收集不仅最后一个状态向量作为输出,还可以收集所有状态向量。因为现在的输出是一个向量序列,我们可以通过堆叠多个 RNN 层来使 RNN 深度化,并使用一个层的输出作为下一层的输入。值得注意的是,每个输出向量的维度不必与输入向量的维度相同。

03-17

图 3.17 具有多个向量输出的循环神经网络

我们还可以将多个 RNN 链堆叠成一个多层 RNN 模型(见图 3.18)。每个 RNN 层的输出状态将被收集作为后续层的输入。

03-18

图 3.18 多层循环神经网络具有多个向量输出

为了实现 RNN,我们可以使用 Keras 中的 SimpleRNN 类。在列表 3.21 中,我们堆叠了四个 RNN 层来形成一个多层 RNN。每个 SimpleRNN 创建一个 RNN 链(而不是单个 RNN 单元)。前三个 RNN 层中的输出状态被收集作为后续层的输入。第四个 RNN 层的输出状态被输入到密集层进行最终分类。

列表 3.21 创建 RNN 模型

from tensorflow.keras.layers import SimpleRNN
from tensorflow.keras.layers import Dense

model.add(SimpleRNN(units=embedding_dim,
                    return_sequences=True))  ❶
model.add(SimpleRNN(units=embedding_dim,
                    return_sequences=True))  ❶
model.add(SimpleRNN(units=embedding_dim,
                    return_sequences=True))  ❶
model.add(SimpleRNN(units=embedding_dim))    ❶
model.add(Dense(1, activation='sigmoid'))    ❷

❶ 堆叠四个 RNN 层

❷ 添加一个密集层以生成最终的分类概率

单元参数定义了每个输出向量(或状态向量)的长度,这与输入向量的长度相同。return_sequences 参数控制是否收集 RNN 的所有输出向量或仅收集最终输出。默认设置为 False。最后一层是一个具有 sigmoid 激活函数的密集层,将长度为 32 的状态向量映射到单个值(单元),表示评论属于正面类别的概率。

3.5.3 训练和验证 RNN

我们可以使用与 MNIST 示例类似的过程来编译和训练模型。我们选择二元交叉熵作为损失函数,如下一列表所示,它是二元分类交叉熵损失的特殊情况。

列表 3.22 添加分类层

model.compile(optimizer='adam', metrics=['acc'], loss='binary_crossentropy')
model.fit(train_data,
          train_labels,
          epochs=2,
          batch_size=128)

为了说明目的,我们仅用 128 个批次的规模训练了两个时期。训练好的 RNN 模型可以像这样在测试集上进行评估:

>>> model.evaluate(test_data, test_labels)
782/782 [==============================] - 28s 35ms/step - 
➥ loss: 0.3684 - acc: 0.8402
[0.36835795640945435, 0.8402000069618225]

再次,我们将跳过 RNN 的调整,并将这部分内容留到本书的下一部分,借助 AutoML 工具包来完成。

摘要

  • 深度学习模型由多个层堆叠而成,用于提炼输入数据并生成层次化的表示。它们可以通过迭代过程联合训练,通过正向传递数据,确定输出中的损失(错误),并通过反向传播使用选定的优化方法更新每层的参数。

  • TensorFlow 和 Keras 帮助我们轻松实现深度学习模型。你现在应该能够实现三种经典的深度学习模型,包括用于表格数据分类的多层感知器(MLPs)、用于图像分类的卷积神经网络(CNNs)和用于文本分类的循环神经网络(RNNs)。

  • 编译和训练深度学习模型需要指定损失函数、优化器、检索指标和停止标准(例如训练的时期数)。

  • 深度学习模型通常比经典机器学习模型需要更少的数据预处理和特征工程。然而,这些算法通常有多个超参数需要调整,例如层数、层类型、每层的具体配置以及优化方法中的超参数。

第二部分:实践中的 AutoML

前几章提供了机器学习的基本介绍,不同类型的机器学习模型以及处理机器学习问题的流程。你还看到了一种用于超参数调整的最直观的 AutoML 方法:使用网格搜索在 scikit-learn 工具包的帮助下调整机器学习管道。

从第四章开始,你将学习如何使用 AutoML 来解决机器学习问题并改进机器学习解决方案。在接下来的两章中,我们主要关注生成深度学习解决方案,考虑到深度学习模型的重要性以及设计和调整它们的复杂性。借助高级 AutoML 工具包:AutoKeras 和 KerasTuner,你将能够为不同的机器学习任务创建深度学习解决方案。第六章介绍了一种通用的解决方案来自定义整个 AutoML 搜索空间,这为你设计搜索空间以调整无监督学习模型和优化算法提供了更大的灵活性。

4 自动生成端到端 ML 解决方案

本章节涵盖了

  • AutoKeras 简介

  • 自动分类和回归

  • 使用 AutoML 解决多输入和多输出问题

本章节首先教您如何创建一个端到端的深度学习解决方案,而无需选择或调整任何深度学习算法。这可以通过尽可能少的五行代码实现,这比第三章中介绍实现深度学习管道的过程要简单得多。然后,您将学习如何使用 AutoML 在图像、文本和表格数据上执行分类和回归,正如我们在前面的章节中所做的那样。我们还将探索几个更复杂的场景,包括具有多种类型输入的任务,例如图像和文本,以及具有多个目标的任务,例如回归响应和分类标签的联合预测。

4.1 准备 AutoML 工具包:AutoKeras

在开始解决实际问题之前,让我们首先探索我们的主要 AutoML 工具——AutoKeras。AutoKeras 是一个专注于自动生成深度学习解决方案的 Python 库。要安装 AutoKeras,您可以在命令行中简单地运行 pip install autokeras,或者在 Jupyter 笔记本中运行!pip install autokeras。关于包安装的更详细讨论见附录 A。

AutoKeras 建立在 TensorFlow 后端(tensorflow.org)、TensorFlow Keras API(keras.io)和 KerasTuner 库(keras.io/keras_tuner/)之上。这四个组件展示了深度学习软件的完整范围。从用户的角度来看,如图 4.1 所示,TensorFlow 是最可配置的,但也是最复杂的;AutoKeras 位于光谱的另一端,是最简单的。右侧的组件是基于左侧的组件开发的,它们提供了更高级和封装的自动化,但可定制性较低。

04-01

图 4.1 Keras 生态系统

注意,“更多可配置性”在这里意味着用户可以在 API 中指定更多参数,这为自定义 ML 管道和 AutoML 算法(主要是搜索空间)提供了更大的灵活性。具有更多 ML 专业知识的用户可以使用像 TensorFlow 和 Keras API 这样的底层库来实现更个性化的解决方案,以满足他们的需求。这些库允许用户逐层自定义他们的深度学习模型。另一方面,那些对 ML 了解较少,希望在建模和调整上节省一些精力,并且不介意牺牲一些灵活性的用户,可能希望使用像 KerasTuner 和 AutoKeras 这样的高级库。

在本书中,我们将重点关注使用 AutoKeras 解决深度学习问题,并在第七章简要介绍 KerasTuner。与 AutoKeras 相比,KerasTuner 可以应用于更广泛的机器学习问题(超出深度学习的范围),并且在搜索空间设计和搜索算法选择方面更加灵活。相应地,它需要更多关于要调整的机器学习流水线和 AutoML 算法的知识。如果您对探索传统深度学习和 TensorFlow 和 Keras 的底层功能感兴趣,François Chollet 的书籍《Python 深度学习》(第 2 版,Manning,2021 年)提供了更详细的介绍。

AutoKeras 定位为 Keras 生态系统中最顶层的库。它提供了最高级别的自动化。如图 4.2 所示,它提供了以下三个级别的 API——即一组任务 API、输入/输出(IO)API 和功能 API——以覆盖在现实世界应用中应用 AutoML 的不同场景:

  • 任务 API 帮助您为针对目标机器学习任务生成端到端的深度学习解决方案,例如图像分类。这些是 AutoKeras 中最直接的 API,因为它们允许您通过仅一步操作(输入数据)来实现所需的机器学习解决方案。在 AutoKeras 的最新版本中,六个不同的任务 API 支持六个不同的任务,包括图像、文本和结构化数据的分类和回归。

  • 现实世界的问题可能有多个输入或输出。例如,我们可以使用视觉和声学信息来检测视频中的动作。我们可能还希望预测多个输出,例如使用客户的消费记录来预测他们的购物兴趣和收入水平。为了解决这些任务,我们可以使用 AutoKeras 的 IO API。您将在第 4.4 节中看到两个示例。

  • 功能 API 主要面向希望根据自身需求定制搜索空间的进阶用户。它类似于我们在第三章中使用的 TensorFlow Keras 功能 API,允许我们通过连接一些 AutoKeras 构建块来构建深度学习流水线。一个构建块通常代表由多个 Keras 层(如 CNN)组成的特定深度学习模型,这意味着我们不需要逐层指定这些模型。每个块的超参数搜索空间也是为我们设计和设置的,这样我们就可以专注于我们关心的超参数,而无需担心其他方面。

04-02

图 4.2 AutoKeras API 的不同级别

本章将重点关注任务 API 和 IO API。这两个 API 都允许您在不自定义搜索空间的情况下生成端到端解决方案。功能 API 将在下一章讨论。

4.2 自动化图像分类

理想情况下,给定一个机器学习问题和相应的数据,我们期望 AutoML 算法能够在最小的人力或配置下提供令人满意的机器学习解决方案。在本节中,我们以 MNIST 数据集的图像分类问题为例,介绍如何仅通过以下两个步骤实现这一目标:

  • 选择适合当前问题的 AutoKeras 任务 API。

  • 将数据输入到所选 API 中。

你将能够创建图像分类器,而无需创建任何深度学习模型或接触 AutoML 算法。在下一节中,我们将讨论更多与不同任务和数据类型相关联的示例。

4.2.1 使用五行代码解决问题

记住,我们在上一章中通过构建 TensorFlow 及其 Keras API 的 CNN 来解决图像分类问题。以下五个步骤是深度学习工作流程的一部分:

  1. 使用 TensorFlow 加载数据集和测试数据集。

  2. 通过归一化预处理图像。

  3. 构建神经网络。

  4. 编译和训练神经网络。

  5. 在测试数据上评估流水线。

实现此过程需要选择深度学习算法中的每个组件。您需要定义整个流水线的超参数,并逐层构建网络。即使有这个过程,也不总是容易获得期望的结果,因为没有保证您会在第一次尝试时设置所有超参数都合适。在实现阶段,在单独的验证集上调整超参数需要额外的努力,并且是一个试错过程。借助 AutoML,您可以一次解决所有问题。让我们使用 AutoKeras 自动生成一个用于分类 MNIST 数字的深度学习模型。整个问题可以用尽可能少的五行代码来解决,如下所示。

列表 4.1 使用 AutoKeras 任务 API 的多类图像分类

from tensorflow.keras.datasets import mnist

(x_train, y_train), (x_test, y_test) =
➥ mnist.load_data()                                ❶
import autokeras as ak

clf = ak.ImageClassifier(max_trials=2)             ❷

clf.fit(x_train, y_train, epochs=3, verbose=2)     ❸

❶ 加载数据

❷ 初始化 AutoKeras ImageClassifier

❸ 将训练数据输入到 ImageClassifier 中

在加载数据集之后,您要获取最终解决方案的唯一事情就是初始化 API 并将训练数据输入到初始化的 ImageClassifier 对象中。拟合是一个迭代过程,使用以下三个步骤:

  1. 根据 AutoML 搜索算法从搜索空间中选择一个深度学习流水线(由预处理方法、CNN 模型和用 Keras 实现的训练算法组成)。对于每个机器学习任务,AutoKeras 在其相应的任务 API 中集成了一个定制的搜索空间和特定于任务的搜索算法。在使用 API 时,您无需指定它们。在本例中,因为我们正在处理图像分类问题,所以 AutoKeras 中的 ImageClassifier 将自动用一系列由不同的图像预处理方法和 CNN 组成的深度学习流水线填充搜索空间。

  2. 训练选定的管道并评估它以获取其分类准确率。默认情况下,20% 的训练数据将被分割为验证集。验证损失或准确率将用于比较所有选定管道的性能。

  3. 更新 AutoML 搜索算法。一些 AutoML 算法可以从先前探索的管道的性能中学习,以使它们的后续探索更加高效。这一步可能不是必需的,因为一些 AutoML 算法,如网格搜索,不需要更新。

这个迭代过程模仿了手动调整,但去除了人为因素,让 AutoML 算法来完成选择。调整迭代的次数由你想要进行的试验次数决定——即你想要 AutoML 算法在搜索空间中探索多少个管道。在初始化 ImageClassifier 时,你可以在 max_trials 参数中设置这个值。所有试验完成后,迄今为止找到的最佳管道将再次使用完整的训练数据集进行训练,以实现最终解决方案(见图 4.3)。

04-03

图 4.3 AutoKeras 任务 API 的 AutoML 流程

调用 ImageClassifier 的 fit() 方法与调用 Keras 模型的 fit() 方法相同。所有用于拟合单个 Keras 模型的参数都可以无缝地应用于此处,以控制每个选定管道的训练过程,例如训练轮数。所有试验和最佳管道的模型权重都将保存到磁盘上,以便于后续评估和使用。

最终解决方案的评估也与评估 Keras 模型类似。拟合完成后,我们可以通过调用 evaluate() 方法来测试最佳管道。它将首先使用最佳管道中包含的预处理方法对测试图像进行预处理,然后将处理后的数据输入到模型中。如以下代码示例所示,本例中最佳管道的评估准确率为 98.74%,考虑到我们只进行了两次试验(探索了两个管道),这个结果还不错:

>>> test_loss, test_acc = clf.evaluate(x_test, y_test, verbose=0)
>>> print('Test accuracy: ', test_acc)
Test accuracy:  0.9873999953269958

你也可以通过调用 predict() 函数来获取测试图像的预测标签,如下所示:

>>> predicted_y = clf.predict(x_test)
>>> print(predicted_y)
[[7]
 [2]
 [1]
 ...
 [4]
 [5]
 [6]]

找到的最佳管道可以导出为 Keras 模型。本例中实现的最佳模型可以如下导出并打印出来:

>>> best_model = clf.export_model()
>>> best_model.summary()

Model: 'model'
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
input_1 (InputLayer)         [(None, 28, 28, 1)]       0
_________________________________________________________________
normalization (Normalization (None, 28, 28, 1)         3
_________________________________________________________________
conv2d (Conv2D)              (None, 26, 26, 32)        320
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 24, 24, 64)        18496
_________________________________________________________________
max_pooling2d (MaxPooling2D) (None, 12, 12, 64)        0
_________________________________________________________________
dropout (Dropout)            (None, 12, 12, 64)        0
_________________________________________________________________
flatten (Flatten)            (None, 9216)              0
_________________________________________________________________
dropout_1 (Dropout)          (None, 9216)              0
_________________________________________________________________
dense (Dense)                (None, 10)                92170
_________________________________________________________________
classification_head_1 (Softm (None, 10)                0
=================================================================
Total params: 110,989
Trainable params: 110,986
Nontrainable params: 3
_________________________________________________________________

模型在输入层之后堆叠了一个归一化层、两个卷积层和一个池化层。dropout 层用于在每个训练迭代(数据批次)期间随机将部分输入张量元素设置为 0,从而有效地将它们排除在考虑之外。它仅在训练过程中应用,以帮助克服网络的过拟合问题。

为什么 dropout 层可以减轻过拟合

dropout 层可以在以下三个原因下减轻训练神经网络时的过拟合:

  • 它减少了神经元之间相关性的复杂性。在训练过程中,dropout 层会屏蔽一些神经元;只有未屏蔽的神经元在每次前向传递和反向传播迭代中才会被交互和更新。

  • 它平均了子网络的效果。在某种程度上,dropout 可以被视为一种集成策略,在训练过程中平均从整个网络随机选择的多个子网络的预测。

  • 它在训练过程中引入了额外的随机性,这有助于网络层适应不同的输入条件。这将提高其在测试期间对未见案例的泛化能力。

要了解 dropout 层的详细信息,请参阅 François Chollet 的书籍《使用 Python 进行深度学习》,第 2 版(Manning,2021 年)。

导出的 Keras 模型可以通过提供保存的路径保存到磁盘,并用于进一步的使用。如前所述,导出的模型不包含预处理输入图像的开头重塑层。在使用加载的模型进行预测之前,我们需要首先将输入图像扩展为 3-D 图像,如下面的列表所示。

列表 4.2 保存和加载最佳模型

from tensorflow.keras.models import load_model

best_model.save('model_autokeras')             ❶

loaded_model = load_model('model_autokeras')   ❷

predicted_y = loaded_model.predict(
    tf.expand_dims(x_test, -1))                ❸

❶ 在 model_autokeras 文件夹中保存模型

❷ 加载模型

❸ 使用加载的模型进行预测

正如这个示例所示,与使用 TensorFlow Keras API 创建的传统深度学习解决方案相比,使用 AutoML 和 AutoKeras 任务 API 可以让我们在数据准备、算法配置和知识获取上花费更少的精力来处理机器学习任务。为了总结这个示例并进一步展示 AutoML 的灵活性,让我们看看一些额外的用例。

4.2.2 处理不同的数据格式

在实践中,我们可能拥有不同格式的数据。AutoML 应该能够自动适应并处理不同的格式,而无需额外的手动预处理。以下是一些示例:

  • 图像可能或可能没有明确指定的通道维度。我们期望 AutoML API 可以直接处理这两种情况,而(正如我们在上一章中看到的)为了将 MNIST 图像输入到 Keras 模型中,我们需要手动为每个图像添加一个额外的通道维度,以在通道维度不存在时转换它们的形状。

  • 图像的标签可以是字符串、整数,甚至可以准备成 one-hot 编码格式(0 和 1 的向量)。

  • 图像和标签的数据结构可能因用于加载数据集的包而异。它们可能格式化为 NumPy 数组 (np.ndarray)、pandas DataFrame (pd.DataFrame) 或 TensorFlow Datasets (tf.data.Dataset)。

为了减轻数据准备的压力,AutoML 库通常提供处理不同数据格式的灵活性。例如,所有刚刚描述的案例都可以由 ImageClassifier 处理。测试留给读者作为练习。

4.2.3 配置调优过程

除了数据格式,您可能还想通过指定您希望用作验证集的数据量、您希望收集用于探索管道的评估指标、您希望如何比较管道(例如,通过比较验证集上的准确率或损失)等方式来配置搜索过程。

列表 4.3 提供了一个示例。训练管道的损失函数定义为类别交叉熵损失。选定的评估指标是准确率。搜索目标设置为验证准确率,因此最佳管道将是验证集上分类准确率最高的管道。最后,我们预留出 15%的训练数据用于调整过程的验证集。

列表 4.3 定制调整过程的配置

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

import autokeras as ak

clf = ak.ImageClassifier(max_trials=2,

                         loss='categorical_crossentropy',  ❶

                         metrics=['accuracy'],             ❷

                         objective='val_accuracy')         ❸

clf.fit(x_train, y_train,
        validation_split=0.15,                             ❹
        epochs=3, verbose=2)

❶ 使用类别交叉熵损失进行分类

❷ 使用准确率作为评估指标

❸ 将验证准确率设置为调整目标

❹ 将 15%的数据分出用于验证

我们可能希望使用自定义评估指标而不是默认指标来比较管道的性能。例如,我们可以创建自己的指标函数,并将其作为 AutoML 过程中的评估目标封装。由于我们正在调整使用 TensorFlow Keras 构建的深度学习模型,因此目标指标的创建应遵循 Keras 指标函数的创建,该函数接受真实响应(分类问题中的标签)和一批数据点的模型预测作为输入,并输出指标值,如下所示。

列表 4.4 创建定制的 Keras 评估指标函数

def my_metric(y_true, y_pred):
    correct_labels = tf.cast(y_true == y_pred, tf.float32)   ❶
    return tf.reduce_mean(correct_labels, axis=-1)           ❷

❶ 将模型预测与真实标签进行比较

❷ 计算此批次数据中的预测准确率

注意,可以为 ImageClassifier 添加多个指标,这些指标将在评估期间全部计算。然而,对于 ImageClassifier,仅设置一个目标——用于选择最佳模型的指标。对于其他任务 API 也是如此。

要在 AutoML 过程中将自定义的指标作为指标和目标,我们首先应该将函数作为其中一个指标传递,这样它就会在评估过程中为每个选定的管道计算。然后我们可以将其封装为一个搜索目标,该目标由搜索算法用于比较管道的性能。封装目标需要 KerasTuner 库中的 Objective 类(见列表 4.5)。实例化一个目标需要提供两个参数。第一个参数指定用作搜索目标的指标名称(本例中的 val_my_metric)。因为我们想将管道的验证准确率作为目标,所以我们应该在函数(或指标)名称中添加一个 val_ 前缀。第二个参数(方向)指示指标的大值(direction='max')或小值(direction='min')更好。在本例中,我们想找到一个最大化自定义准确率的管道,因此我们将方向设置为'max'。

列表 4.5 将自定义指标传递给 AutoML 过程

import keras_tuner

clf = ak.ImageClassifier(
    seed=42,
    max_trials=2,
    loss='categorical_crossentropy',
    objective=keras_tuner.Objective(
        'val_my_metric', direction='max'),   ❶
    metrics=[my_metric],                     ❷
)

clf.fit(x_train, y_train,
        validation_split=0.15,
        epochs=3)

❶ 将自定义指标函数封装在 KerasTuner Objective 中,并将其传递给 AutoKeras

❷ 将自定义指标作为其中一个指标包含在内

值得指出的是,任务 API 没有直接提供用于选择搜索算法和配置搜索空间的参数,因为它们的目的是简化整个机器学习(ML)工作流程,尽可能减轻你的负担。让我们看看使用 AutoKeras 任务 API 的更多示例。在本章的最后部分,你将学习一个更通用的 AutoML 解决方案,用于处理具有多个输入和输出的任务。

4.3 四个监督学习问题的端到端 AutoML 解决方案

在本节中,我们将使用 AutoML,借助不同的 AutoKeras 任务 API,为四个更多的监督机器学习(ML)问题生成端到端解决方案。我们将解决的问题如下:

  • 使用 20 个新闻组数据集进行文本分类

  • 使用泰坦尼克号数据集进行结构化数据分类

  • 使用加利福尼亚住房数据集进行结构化数据回归

  • 使用合成数据集进行多标签图像分类

我们已经在上一章中用传统的机器学习(ML)方法解决了前三个问题;在这里,我们用 AutoML 重新处理这些问题,以便让你熟悉 AutoKeras 的任务 API 的使用。

在进入问题之前,让我们快速回顾一下传统机器学习(ML)方法和 AutoML 方法之间的区别。传统的机器学习(ML)方法为每个问题创建一个模型。因此,你必须指定模型的详细信息。AutoML 方法搜索不同类型的模型,这样你就不必指定所有细节,但将模型的一部分留给搜索。这种差异导致 AutoML 的 API 与传统方法不同。AutoML API 更简洁,因为它们将详细配置留给搜索。

最后一个问题是一个更复杂的图像分类场景,其中每个图像都与多个标签相关(例如,包含猫和狗的图像)。您可能不知道如何使用传统的机器学习来解决它,但借助 AutoML 和 AutoKeras 任务 API 的帮助,您只需要在 ImageClassifier 中更改一个参数,所有事情都会一次性完成。

4.3.1 使用 20 个新闻组数据集进行文本分类

第一个例子是使用 scikit-learn 库获取的 20 个新闻组数据集进行文本分类的问题。目标是根据文档的主题将其分类到 20 个新闻组之一。如果您不熟悉文本分类的基本机器学习过程,我们建议您查看附录 B。我们在那里提供了有关文本数据预处理和两种概率分类模型的更多详细信息。这里我们使用 AutoML 来处理 AutoKeras 中的 TextClassifier 任务 API 的问题。

我们首先使用 scikit-learn 内置的数据加载器 fetch_20newsgroups 下载数据。它已经被分为训练集和测试集,以便于使用。如下所示,我们只加载了 20 个新闻组类别中的两个(rec.autos 和 rec.motorcycles),以使搜索过程更快。

列表 4.6 加载 20 个新闻组数据集

import numpy as np
from sklearn.datasets import fetch_20newsgroups
categories = ['rec.autos', 'rec.motorcycles']

news_train = fetch_20newsgroups(subset='train',
                                shuffle=True,
                                random_state=42,
                                categories=categories)       ❶
news_test = fetch_20newsgroups(subset='test',
                               shuffle=True,
                               random_state=42,
                               categories=categories)        ❶

doc_train, label_train = \
    np.array(news_train.data), np.array(news_train.target)   ❷
doc_test, label_test =  \
    np.array(news_test.data), np.array(news_test.target)     ❷

❶ 加载训练和测试数据集

❷ 将数据集格式化为 NumPy 数组

让我们探索训练和测试数据集。每个文档都已格式化为如这里所示的术语字符串:

>>> print('The number of documents for training: {}.'.format(len(doc_train)))
>>> print('The number of documents for testing: {}.'.format(len(doc_test)))
>>> type(doc_train[0]), doc_train[0]

The number of documents for training: 1192.
The number of documents for testing: 794.

(numpy.str_,
 'From: gregl@zimmer.CSUFresno.EDU (Greg Lewis)\nSubject: Re:
➥ WARNING.....(please read)...\nKeywords: BRICK, TRUCK, DANGER\
➥ nNntp-Posting-Host: zimmer.csufresno.edu\nOrganization: CSU 
➥ Fresno\nLines: 33\n\nIn article <1qh336INNfl5@CS.UTK.EDU>
➥ larose@austin.cs.utk.edu (Brian LaRose) writes:\n>This just a 
➥ warning to EVERYBODY on the net.  Watch out for\n>folks standing
➥ NEXT to the road or on overpasses. They can\n>cause
➥ SERIOUS HARM to you and your car. \n>\n>(just a cliff-notes version
➥ of my story follows)\n>\n>10pm last night, I was
➥ travelling on the interstate here in\n>knoxville, I was taking an
➥ offramp exit to another interstate\n>and my wife suddenly
➥ screamed and something LARGE hit the side\n>of my truck.  We slowed
➥ down, but after looking back to see the\n>vandals standing 
➥ there, we drove on to the police station.\n>\n>She did get a 
➥ good look at the guy and saw him 'cock his arm' with\n>something
➥ the size of a cinderblock, BUT I never saw him.
➥ We are \n>VERY lucky the truck sits up high on the road; if it
➥ would have hit\n>her window, it would have killed her. 
➥ \n>\n>The police are looking for the guy, but in all likelyhood he is
➥ gone. \nStuff deleted...\n\nI am sorry to report that in
➥ Southern California it was a sick sport\nfor a while to drop concrete 
➥ blocks from the overpasses onto the\nfreeway. Several persons
➥ were killed when said blocks came through\ntheir
➥ windshields. Many overpass bridges are now fenced, and they\nhave
➥ made it illegal to loiter on such bridges (as if that
➥ would stop\nsuch people). Yet many bridges are NOT fenced.
➥ I always look up at a\nbridge while I still have time to take
➥ evasive action even though this\n*sport* has not reached us
➥ here in Fresno.\n_______________________________________________________
➥ ____________\nGreg_Lewis@csufresno.edu\nPhotojournalism sequence,
➥ Department of Journalism\nCSU Fresno, Fresno, CA 93740\n')

在加载数据后,我们可以直接将这些原始文档输入 API,而无需任何进一步的前处理,例如将文档转换为数值向量。在列表 4.7 中,我们设置了要搜索和比较的管道数量为三个。我们没有指定每个管道的训练轮数。TextClassifier API 将默认为每个管道训练,最多 1,000 轮,并在验证损失在连续 10 轮中没有改善时停止训练,以最小化训练时间并避免过拟合。

列表 4.7 使用 AutoKeras 任务 API 进行文本分类

import autokeras as ak

clf = ak.TextClassifier(max_trials=3)       ❶

clf.fit(doc_train, label_train, verbose=2)  ❷

❶ 初始化 AutoKeras TextClassifier

❷ 将训练数据输入 TextClassifier

在三次试验中找到的最佳管道在最终的测试集上实现了 96.1%的准确率,如下所示:

>>> test_loss, test_acc = clf.evaluate(doc_test, label_test, verbose=0)
>>> print('Test accuracy: ', test_acc)
 0.9609571695327759

我们可以通过将文档输入 predict()函数来获取预测标签,并通过调用 export_model()方法导出最佳模型。由于这个过程与前面的例子相同,这里不再重复。

4.3.2 使用泰坦尼克号数据集进行结构化数据分类

在本例中,我们将使用泰坦尼克号数据集来自动生成一个针对结构化数据分类任务的机器学习解决方案。该数据集包含分类特征,或字符串类型,以及数值特征。一些特征也存在缺失值,因此在它们输入神经网络之前需要额外的预处理。数据集的更多细节以及预处理它的经典方法在附录 B 中介绍。当使用 AutoML 解决分类问题时,正如我们在这里所做的那样,您无需担心这些手动预处理步骤。

结构化数据通常以表格格式保存并存储在 CSV 文件中。您也可以将这些原始 CSV 文件作为输入提供,而无需将它们加载到 NumPy 数组或 pandas DataFrame 中。

我们使用一个真实的结构化数据集,即泰坦尼克号数据集。数据集的特征是泰坦尼克号乘客的档案。预测目标是乘客是否在事故中幸存。

我们可以使用图 4.8 所示的代码下载数据集。我们有两个文件需要下载——训练数据和测试数据。我们使用 tf.keras.utils 中的 get_file(...) 函数,该函数从 URL 下载 CSV 文件。第一个参数是用于保存文件的本地文件名。第二个参数是下载文件的 URL。该函数返回文件在本地保存的位置的路径。

列表 4.8 下载泰坦尼克号数据集

import tensorflow as tf

TRAIN_DATA_URL = 'https:/ /storage.googleapis.com/tf-datasets/titanic/train.csv'
TEST_DATA_URL = 'https:/ /storage.googleapis.com/tf-datasets/titanic/eval.csv'

train_file_path = tf.keras.utils.get_file('train.csv',
                                          TRAIN_DATA_URL)
test_file_path = tf.keras.utils.get_file('eval.csv',
                                         TEST_DATA_URL)

训练 CSV 文件的前五行如图 4.4 所示。第一行给出了目标响应(survived)和九个特征的名称。接下来的四行表示四位乘客及其相应的特征。缺失值被标记为“unknown”,例如第一位乘客的甲板特征。训练集中共有 627 名乘客。测试 CSV 文件具有相同的格式,包含 264 名乘客的数据。

04-04

图 4.4 泰坦尼克号训练 CSV 文件的前五行

为了解决结构化数据分类问题,我们可以使用 AutoKeras 中的 StructuredDataClassifier API。我们使用训练 CSV 文件的路径来拟合一个初始化的 StructuredDataClassifier 对象。它将自动加载数据并进行预处理。目标标签列(survived)的名称应作为参数提供,如以下列表所示。

列表 4.9 使用 AutoKeras 任务 API 进行结构化数据分类

import autokeras as ak

clf = ak.StructuredDataClassifier(max_trials=10)
clf.fit(x=train_file_path,   ❶
        y='survived',        ❷
        verbose=2)

❶ 训练 CSV 文件的路径

❷ 目标标签列的名称

StructuredDataClassifier 将从训练 CSV 文件的标题中加载每个特征的名称,并自动推断特征类型(分类或数值)。您也可以在初始化 StructuredDataClassifier 时显式指定它们,如以下列表所示。

列表 4.10 向 AutoKeras API 提供特征信息

clf = ak.StructuredDataClassifier(
    column_names=[                        ❶
        'sex',
        'age',
        'n_siblings_spouses',
        'parch',
        'fare',
        'class',
        'deck',
        'embark_town',
        'alone'],
    column_types={'sex': 'categorical',   ❷
                  'fare': 'numerical'},
    max_trials=10,
)
clf.fit(x=train_file_path,
        y='survived',
        verbose=2)

❶ 指定特征名称

❷ 指定两个特征的数据类型

要使用最佳发现的管道进行预测,我们可以将测试 CSV 文件的路径传递给 predict()方法。测试文件中应提供从训练文件中采用的所有特征列。同样,我们可以通过提供测试 CSV 文件的路径来使用 evaluate()方法评估最佳管道,如以下所示。

列表 4.11 使用 AutoKeras 测试结构化数据分类器

>>> predicted_y = clf.predict(test_file_path)        ❶
>>> print(predicted_y[:5])

[[0]
 [0]
 [1]
 [0]
 [0]]

>>> test_loss, test_acc = clf.evaluate(test_file_path,
...                                    'survived',
...                                    verbose=0)    ❷
>>> print('Test accuracy: ', test_acc)

Test accuracy:  0.780303

❶ 从 CSV 文件获取测试数据的预测

❷ 评估分类器

我们现在已经使用 AutoKeras 的任务 API 解决了三种不同类型数据的分类任务。接下来,我们将探讨一个回归问题。

4.3.3 使用加利福尼亚住房数据集进行结构化数据回归

在这个例子中,我们将使用 AutoML 来解决结构化数据回归问题。与结构化数据分类问题相比,唯一的区别在于在 AutoKeras 中选择任务 API。我们首先从 scikit-learn 获取数据集,并将 20%的数据分割出来作为测试集,如下一列表所示。

列表 4.12 加载和分割加利福尼亚住房数据集

from sklearn.datasets import fetch_california_housing
house_dataset = fetch_california_housing()                                  ❶

import pandas as pd
data = pd.DataFrame(house_dataset.data, columns=house_dataset.feature_names)❷
target = pd.Series(house_dataset.target, name = 'MEDV')

from sklearn.model_selection import train_test_split
train_data, test_data, train_targets, test_targets = \
    train_test_split(data, target,
                     test_size=0.2,
                     random_state=42)                                       ❸

❶ 获取数据集

❷ 将特征打包到 pandas DataFrame 中

❸ 分割 20%的数据用于测试

然后我们使用 AutoKeras 的 StructuredDataRegressor API 来执行回归任务,如列表 4.13 所示。在这里我们使用更大的批量大小(1024)以提高每个管道的训练速度。在 10 次试验中发现的最佳管道的最终测试 MSE 为 0.31。与第三章中使用传统机器学习方法得到的结果 0.34 相比,AutoML 的结果显著更好。

注意:此代码示例可能需要很长时间才能运行。

列表 4.13 使用 AutoKeras 任务 API 进行结构化数据回归

>>> import autokeras as ak

>>> regressor = ak.StructuredDataRegressor(max_trials=10)   ❶

>>> regressor.fit(x=train_data, y=train_targets,
...               batch_size=1024, verbose=2)               ❶

>>> test_loss, test_mse = regressor.evaluate(
...     test_data, test_targets, verbose=0)                 ❷

>>> print('Test MSE: ', test_mse)

Test MSE:  0.31036660075187683

❶ 使用训练数据拟合 API

❷ 测试最终的回归器

除了 StructuredDataRegressor 之外,AutoKeras 还提供了 ImageRegressor 和 TextRegressor API,分别用于图像和文本数据的回归任务。它们的使用方式相同,这允许您从这个例子中推断其他情况。

4.3.4 多标签图像分类

我们的最后一个例子是一个多标签分类问题。我们已经探讨了多类分类的一些例子,例如在 MNIST 数据集中对手写数字进行分类以及将新闻组分配到相关主题。在这些情况下,每个实例只能属于一个类别,这意味着所有类别都是互斥的。但在现实世界中,一个样本可能具有多个标签。例如,一个场景的图像可能包含山脉和河流,而一篇新闻文档可能涉及政治和经济主题。在多标签分类中,一个样本可以与多个标签相关联,这通过一组布尔变量(实例是否属于标签)来指示。目标是分配样本到所有可能的标签。

这可能看起来不是多类分类的简单扩展。但借助 AutoML 的帮助,特别是 AutoKeras 中的任务 API,您不必自己学习、选择和实现定制的管道。您只需更改一个参数,就可以一次性解决问题。我们将使用图像分类 API(ImageClassifier)作为示例,并使用 scikit-learn 库构建一个合成多标签图像分类数据集。在下一条列表中,我们创建了 100 个样本,每个样本有 64 个特征。总共有三个类别。每个样本应属于至少一个类别,最多三个类别。每个样本的平均标签数设置为两个(n_labels=2)。

列表 4.14 使用 AutoKeras 创建合成多标签图像分类数据集

from sklearn.datasets import make_multilabel_classification

X, Y = make_multilabel_classification(n_samples=100,
                                      n_features=64,
                                      n_classes=3,
                                      n_labels=2,
                                      allow_unlabeled=False,
                                      random_state=1)         ❶

X = X.reshape((100, 8, 8))                                    ❷

x_train, x_test, y_train, y_test = \
            X[:80], X[80:], Y[:80], Y[80:]                    ❸

❶ 创建合成数据集

❷ 将特征格式化为 100 个 8×8 的合成图像

❸ 将 20%的数据分割为测试集

接下来,我们使用 ImageClassifier,但这次我们将参数 multi_label 设置为 True,如列表 4.15 所示。类似的方法可以用来检索预测结果和测试准确率。

列表 4.15 使用 AutoKeras 任务 API 进行多标签分类

>>> clf = ak.ImageClassifier(max_trials=10, multi_label=True)
>>> clf.fit(x_train, y_train, epochs=3, verbose=2)              ❶
>>> test_loss, test_acc = clf.evaluate(x_test,
                                       y_test,
                                       verbose=0)               ❷

>>> predicted_y = clf.predict(x_test)                           ❸
>>> print(f'The prediction shape is : {predicted_y.shape}')
>>> print(f'The predicted labels of the first five instances are:\n 
    {predicted_y[:5, :]}')

The prediction shape is: (20, 3)

❶ 调整 AutoML 算法

❷ 测试最终模型

❸ 获取预测标签

如前述代码所示,每个实例的预测是一个向量,其长度与类别的数量相同。向量中的值只能是 1 或 0,表示实例是否属于相应的类别。它与 one-hot 编码向量类似,但向量中有多个 1。因此,它被称为多-hot 编码

您还可以使用带有 multi_label 参数的 StructuredDataClassifier 和 TextClassifier。

对于回归问题,如果实例的目标响应是一个向量而不是单个值,您不必显式更改任何参数;API 将自动推断数据中是否存在单个或多个回归响应。

您现在已经看到了如何使用 AutoKeras 的任务 API 来处理不同数据类型的分类和回归问题。它们对 ML 知识有限的用户非常友好,并且便于推导端到端的深度学习解决方案。但这种方法有以下两个局限性:

  • 您无法使用提供的参数更改搜索空间和搜索算法。您以一定的灵活性、可定制性和可扩展性为代价换取便利性。

  • 当数据集大小或试验次数很大时,运行时间可能会非常慢。

缓解这些问题并适应更复杂的情况需要您对想要使用的机器学习模型和 AutoML 算法有更深入的了解。您将在下一章学习如何设计自己的搜索空间,而定制搜索算法和加速 AutoML 过程的话题将在本书的第三部分讨论。但在我们到达那里之前,让我们先处理两个比之前的例子稍微复杂一点的情况。它们不需要您了解想要使用的模型,但确实需要更多对搜索空间的定制。下一节的目标是介绍一个更通用的解决方案,它可以适应不同的数据类型和监督学习任务,而无需在 AutoKeras 的不同 API 之间切换。

4.4 处理具有多个输入或输出的任务

一个机器学习任务可能涉及从不同资源收集的多个输入,我们称之为不同的数据模态。例如,图像可以与标签和其他文本描述相关联,视频可以包含对分类有用的视觉和声学信息(以及元数据)。多个输入可以增强信息资源。它们可以相互受益和补偿,以帮助训练更好的机器学习模型。这种方法被称为多输入学习多模态学习。同样,我们可能希望有多个输出对应于我们同时解决的不同任务(回归或分类)。这被称为多输出学习多任务学习

本节将探讨我们如何使用 AutoKeras IO API 来处理具有多个输入或输出的任务。与针对不同特定数据类型和任务的多个变体的任务 API 不同,IO API 提供了一个相当通用的解决方案——只有一个 API 类,名为 AutoModel——但需要额外的配置来指定输入和输出的类型。实际上,所有任务 API 的类都继承自 AutoModel 类,因此您可以使用 IO API 来解决所有之前的任务。在这里,我们将探索关注三个场景的示例:多类分类、多输入学习和多输出学习。

4.4.1 使用 AutoKeras IO API 进行自动图像分类

我们将首先使用 IO API 来处理使用 MNIST 数据集的简单图像分类任务。目标是介绍 IO API 的基本配置,以便我们可以检查更高级的场景。

我们像往常一样使用 TensorFlow 加载数据,并构建一个 AutoModel 对象来解决问题(参见列表 4.16)。使用 IO API(AutoModel)与图像分类任务 API(ImageClassifier)之间的主要区别在于初始化。当使用任务 API 时,因为每个 API 都是针对特定问题(分类或回归)和数据类型(图像、文本或结构化数据)定制的,所以我们除了指定搜索空间中要探索的试验(管道)数量之外,不需要指定任何内容。然而,IO API 可以泛化到所有类型的数据和任务,因此我们需要在初始化时提供有关数据类型和任务类型的信息,以便它可以选择适当的损失函数、指标、搜索空间和搜索目标。在本例中,我们的输入是图像,任务是分类任务。因此,当初始化 AutoModel 时,我们向其 inputs 参数提供 ak.ImageInput(),这是 AutoKeras 用于图像数据的占位符,并将它的 outputs 参数设置为 ak.ClassificationHead(),表示任务是分类任务。我们还指定了每个管道的训练损失函数和评估指标。如果这是一个多标签分类任务,我们将 multi_label 参数设置为 True。

注意:此代码示例可能需要很长时间才能运行。

列表 4.16 使用 AutoKeras IO API 进行 MNIST 图像分类

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

io_model = ak.AutoModel(
    inputs = ak.ImageInput(),                          ❶
    outputs = ak.ClassificationHead(
        loss='categorical_crossentropy',
        metrics=['accuracy']),
        multi_label=False),                            ❷
    objective='val_loss',                              ❸
    tuner='random',                                    ❹
    max_trials=3)
io_model.fit(x_train, y_train, epochs=10, verbose=2)   ❺

❶ 指定输入数据类型

❷ 指定任务类型和训练配置

❸ 选择搜索目标

❹ 选择搜索算法

❺ 使用准备好的数据拟合模型

为了控制搜索过程,我们可以设置用于比较不同管道性能的搜索目标(在本例中为验证损失)。与任务 API 一样,你可以创建自定义评估指标和目标来比较管道的性能并选择最佳候选者(此处不再赘述)。IO API 还提供了一个名为 tuner 的额外参数,你可以在初始化时设置。tuner定义了一个搜索算法,用于探索和选择搜索空间中的不同管道。例如,本例中使用的'random' tuner 随机选择搜索空间中的管道:它通过为每个超参数随机选择一个值来构建每个试验的管道。tuner 还控制每个构建的管道的训练和评估过程,以便搜索过程可以顺利地进行。AutoKeras 提供了与当前 AutoML 领域中几种最流行的搜索算法相对应的 tuner。你将在第七章中了解更多关于 tuner 的信息。

我们可以使用与任务 API 相同的方式使用 IO API 的其他方法,如下面的列表所示。只要你知道如何使用其中一个任务 API,就应该能够使用它们。

列表 4.17 使用 IO API 进行导出、测试和评估

best_model = io_model.export_model()                ❶

predicted_y = io_model.predict(x_test)              ❷

test_loss, test_acc = io_model.evaluate(x_test,
                                        y_test,
                                        verbose=0)  ❸

❶ 导出 AutoKeras 找到的最佳模型

❷ 在测试数据上做出预测

❸ 评估模型的性能

这个示例展示了如何使用 IO API 进行多类图像分类任务,但很容易从中推断出其他用例。例如,如果数据类型是结构化数据或文本数据,可以将 ak.ImageInput()更改为 ak.StructuredDataInput()或 ak.TextInput()。如果任务是回归任务,可以将 ak.ClassificationHead()更改为 ak.RegressionHead(),并且损失和度量也可以相应地更改。接下来,我们将探讨一个更复杂的情况。

4.4.2 自动多输入学习

在传统机器学习中处理多个输入的典型流程结构如图 4.5 所示。流程首先对每个输入源应用数据特定的操作,例如对图像进行归一化和卷积层,对文本进行数值嵌入等。然后它将所有处理后的数据合并以生成分类或回归输出。这种结构也可以用于搜索空间中的所有流程以进行 AutoML,我们可以利用 AutoKeras 的 IO API 来指定输入和输出头。

04-05

图 4.5 多输入学习流程结构

在列表 4.18 中,我们创建了一个包含图像和结构化数据的合成多输入分类数据集。图像是三维的,形状为(32, 32, 3)。每个三维图像都与结构化数据中的一行向量相关联,该向量表示其属性(图像的合成描述)。目标标签有五个类别,我们将 20%的数据作为验证集分割出来。

列表 4.18 创建合成多输入分类数据集

import numpy as np
num_instances = 100

image_data = np.random.rand(num_instances, 32, 32, 3).astype(np.float32) ❶
image_train, image_test = image_data[:80], image_data[80:]               ❶

structured_data = np.random.rand(
    num_instances, 20). astype(np.float32)                               ❷
structured_train = structured_data[:80]
structured_test = structured_data[80:]                                   ❷

classification_target = np.random.randint(
    5, size=num_instances)                                               ❸
target_train, target_test = classification_target[:80],
    classification_target[80:]                                           ❸

❶ 生成图像数据

❷ 生成结构化数据

❸ 生成五个类别的分类标签

配置 IO API 以适应多个输入相当直观——我们只需要在初始化 AutoModel 对象时输入一个占位符列表,如列表 4.19 所示。这些占位符可以具有相同的类型,例如两个结构化数据占位符,或者不同的类型。它们的数量与输入(模态)的数量相匹配。在拟合和评估阶段,我们需要按照相应的占位符顺序提供数据。

列表 4.19 使用 AutoKeras IO API 执行多输入分类

import autokeras as ak

multi_input_clf = ak.AutoModel(
    inputs=[ak.ImageInput(), ak.StructuredDataInput()],   ❶
    outputs=ak.ClassificationHead(),
    max_trials=3,
)

multi_input_clf.fit(
    [image_train, structured_train],                      ❷
    target_train,
    epochs=10,
)

test_loss, test_acc = multi_input_clf.evaluate(           ❸
    [image_test, structured_test],
    target_test,
)

❶ 定义多个输入

❷ 将多个输入馈送到 AutoModel

❸ 使用测试集评估找到的最佳流程

训练过程和搜索算法的配置与上一个示例相同。

4.4.3 自动多输出学习

我们还可以使用 IO API 来处理多个输出。这种情况通常发生在我们想要联合处理多个任务时,例如预测一个人的年龄和性别。多输出学习(或多任务学习)的常见管道结构如图 4.6 所示。我们在这里使用不同的头来表示不同的输出目标,但头也可以具有相同的类型。例如,如果我们把具有 N 个标签的多标签分类视为 N 个二进制分类任务的组合,我们可以形成一个具有 N 个 ClassificationHeads 的多输出学习问题。数据输入也可以包含多个输入,如前一个示例中讨论的那样。

04-06

图 4.6 多输出学习管道结构

在列表 4.20 中,我们生成一个包含多个输入和多个输出的合成数据集,作为具有多个输入的多任务学习的通用示例。输入包括图像和结构化数据,输出涵盖分类和回归响应。当初始化 AutoModel 时,我们输入一个与提供的数据类型相对应的输入占位符列表,以及一个与不同输出目标相对应的头列表。完整的实现如下所示。

列表 4.20 使用 AutoKeras IO API 的多输出学习

import numpy as np
import autokeras as ak

num_instances = 100

image_data = np.random.rand(                                 ❶
    num_instances, 32, 32, 3). astype(np.float32)            ❶
image_train, image_test = image_data[:80], image_data[80:]
structured_data = np.random.rand(                            ❶
    num_instances, 20). astype(np.float32)                   ❶
structured_train, structured_test = 
    structured_data[:80], structured_data[80:]

classification_target = np.random.randint(
    5, size=num_instances)                                   ❷
clf_target_train, clf_target_test =                          ❷
    classification_target[:80], classification_target[80:]   ❷
regression_target = np.random.rand(                          ❷
    num_instances, 1). astype(np.float32)                    ❷
reg_target_train, reg_target_test =                          ❷
    regression_target[:80], regression_target[80:]           ❷

multi_output_learner = ak.AutoModel(
    inputs=[ak.ImageInput(), ak.StructuredDataInput()],
    outputs=[ak.ClassificationHead(), ak.RegressionHead()],  ❸
    max_trials=3,
)
multi_output_learner.fit(
    [image_train, structured_train],
    [clf_target_train, reg_target_train],
    epochs=10,
    verbose=2
)

❶ 生成两个输入源

❷ 生成两个目标响应

❸ 指定多个输出目标

让我们显示最佳模型,看看它的样子,如下所示。

列表 4.21 显示最佳模型

best_model = multi_modal_clf.export_model()
tf.keras.utils.plot_model(best_model, show_shapes=True, expand_nested=True)

在图 4.7 中,我们可以看到模型具有连接两个输入源并从上到下生成两个输出目标的交叉结构。图像输入由 CNN 分支处理,MLP 分支处理结构化数据输入。我们使用分类编码层将结构化数据中的分类特征转换为数值向量,以供 MLP 使用。对于每个实例,两个分支的输出表示分别是长度为 800 和 32 的两个向量。它们被连接起来生成分类和回归预测。

04-07

图 4.7 最佳搜索的多任务模型

如这三个示例所示,与任务 API(以定制性换取便利性)不同,IO API 提供了在搜索空间中定义管道输入和输出的灵活性。它还允许选择搜索算法。然而,到目前为止,我们对搜索空间或如何定制它知之甚少。我们将在下一章探讨搜索空间设计的话题。

到目前为止,我们已经学习了如何使用 AutoKeras 通过 AutoML 技术解决问题。然而,AutoKeras 确实存在一些局限性。首先,自动根据推理时间和模型大小选择模型比较困难,这对于机器学习模型的最终部署可能很重要。其次,AutoKeras 的另一个局限性,也是 AutoML 的局限性,是它无法考虑任何关于数据集内容的知识。例如,它无法理解 Titanic 数据集中每一列的含义。因此,它可能无法设计出比那些对问题有更深入理解的专家更好的模型。第三,AutoKeras 更多的是为用户提供简洁且易于学习的 API,并生成性能良好的模型,而不是生成击败所有最先进解决方案的最佳模型。

摘要

  • AutoML 允许您通过直接输入数据集来创建针对不同机器学习任务的端到端 ML 解决方案。您可以使用 AutoKeras 的任务 API 在 Python 中实现这一点。

  • 要将 AutoML 应用于 AutoKeras 的不同任务,您需要明确当前任务的数据类型和学习范式,例如多类或多标签分类,以便您可以选择相应的任务 API 和设置。

  • 使用 AutoKeras 的 IO API,您可以处理具有多个输入和数据类型的任务。您还可以定义不同的头部,以生成多任务学习中的多个输出。

  • 搜索空间通常针对不同的任务进行定制。AutoKeras 为每个任务提供默认的搜索空间,以节省您在搜索空间设计上的精力。为了针对个性化用例自定义搜索空间,您需要使用将在下一章中介绍的函数式 API。

5 通过创建 AutoML 管道来定制搜索空间

本章节涵盖

  • 理解 AutoML 管道

  • 定制序列和图结构化的 AutoML 管道

  • 使用定制 AutoML 管道进行自动超参数调整和模型选择

  • 定制 AutoML 管道中的 AutoML 块

在第四章中,我们使用 AutoKeras 解决了各种问题,而没有自定义搜索空间。为了回顾,在 AutoML 中,搜索空间是一组具有特定超参数值的模型池,这些模型可能由调优算法构建和选择。在实践中,你可能想使用特定的 ML 算法或数据预处理方法来解决问题,例如使用 MLP 进行回归任务。设计和调整特定的 ML 组件需要定制搜索空间,仅调整相关的超参数,同时固定其他一些参数。

本章节介绍了如何根据您的需求定制搜索空间,并自动发现针对不同类型任务的某些深度学习解决方案。限制搜索空间还可以减少您的搜索时间,让您在更少的尝试中实现更好的结果。您将学习如何通过创建序列和图结构化的 AutoML 管道来定制搜索空间。我将向您展示如何使用 AutoKeras 功能 API 实现 AutoML 管道,以及如何使用 AutoKeras 内置的块进行自动超参数调整和模型选择。您还将学习当现有的块不能满足您的需求时,如何自定义自己的构建块。

5.1 使用序列 AutoML 管道

一个 ML 管道由一系列 ML 组件组成,例如数据预处理方法、用于执行 ML 任务的 ML 算法等。序列 AutoML 管道表征了序列 ML 管道的搜索空间。它由一系列组成,每个块代表一个或多个 ML 组件,以及它们的超参数搜索空间。通过在每个块中选择一个组件并固定其超参数,AutoML 管道将实例化一个 ML 管道,并在数据集上进行训练和评估。一个从序列 AutoML 管道创建的搜索空间中选择深度学习管道的示例如图 5.1 所示。

05-01

图 5.1 使用序列 AutoML 管道实例化深度学习管道

这可以被视为一个双层搜索空间,因为在每次迭代中,搜索算法首先选择要使用的模型类型和预处理方法,然后选择它们适当的超参数。如果我们只在管道中有一个关注的模型和一种处理方法,我们只需要执行一步选择适当的超参数。因此,我们可以将我们在实践中主要针对的 AutoML 问题分为以下两类:

  • 自动化超参数调整(一般定义)—模型类型和预处理方法是固定的。我们只想调整管道中每个指定 ML 组件的超参数。在这种情况下,AutoML 管道中的 AutoML 模块将每个只包含一个 ML 模型或预处理方法。搜索空间将只包括每个固定组件的相关超参数。例如,假设我们想应用 MLP 来解决回归问题,并想调整模型超参数,如层数和单元数。在这种情况下,我们可以通过创建只包含 MLP 的 AutoML 算法模块来限制搜索空间。搜索空间将是 MLP 模型中可行的层数和单元数。

  • 自动化管道搜索—在某些情况下,我们可能事先不知道应该采用哪种模型或数据准备方法。我们希望搜索不仅包括合适的模型和预处理方法,还包括它们的超参数。在这种情况下,一个或多个 AutoML 模块将包含多个组件。例如,我们可能想要探索 CNN 和 MLP 模型,看看哪一个更适合我们的任务,并为每个模型找到最佳的超参数。为此,我们可以为每个模型架构包含模块。预处理方法可以是固定的,也可以与模型一起选择和调整。

另一类 AutoML 问题,特别是对于服务浅层模型非常有用,是自动化特征工程。它的目的是自动发现基于某些特征选择标准(如第二章中介绍的皮尔逊相关系数)的有信息和判别性特征,以学习 ML 模型。自动化特征工程通常涉及一个迭代特征生成和特征选择过程,类似于手动特征工程的方式。由于深度学习算法具有在无需大量特征工程操作的情况下提取和学习的天然才能,因此让我们首先关注本章中深度学习算法及其数据准备方法的调整,然后在第六章中简要介绍自动化特征工程和浅层模型的调整。

在接下来的两节中,我们将探讨如何使用 AutoKeras 功能 API 创建一个顺序 AutoML 管道,以解决深度学习环境中的自动化超参数调整和自动化管道搜索。之后,我们将介绍将顺序 AutoML 管道扩展到更通用的图结构管道。除了使用 AutoKeras 的内置 AutoML 模块之外,你还将学习如何在本章的最后部分自定义自己的模块。

注意:AutoML 任务的分类可能比我们这里描述的更复杂,并且可能被分类为不同的类别。最广泛使用的分类方法包括自动数据预处理、自动特征工程、自动模型选择和自动超参数调整。

在这本书中,我们考虑了自动超参数调整的更广义定义,其中我们将机器学习模型类型和数据预处理方法视为特殊超参数。这将统一自动数据预处理、自动模型选择和自动超参数调整为一个类别:自动超参数调整,正如我们在前面的章节中所描述的。当然,我们也可以有自动管道调整,以调整整个机器学习工作流程,正如我们之前所介绍的。特别是,一些工作还明确地将选择和调整深度学习算法的子领域作为自动深度学习,考虑到设计神经网络架构的复杂性(或者我们称之为神经架构搜索)。

5.2 创建用于自动超参数调整的顺序 AutoML 管道

在本节中,我将向您展示如何创建一个 AutoML 管道以进行自动超参数调整。使用 AutoKeras 功能 API 创建 AutoML 管道与第三章中介绍的用 Keras 功能 API 构建神经网络非常相似。唯一的区别是 Keras 层被 AutoKeras 的内置 AutoML 块所取代。每个块包含一个或多个深度学习模型(或预处理方法)以及它们超参数的默认搜索空间。您还可以修改每个超参数的搜索空间。为了构建网络,我们通过按顺序连接它们的输入和输出堆叠多个 Keras 层。相应地,为了形成一个顺序 AutoML 管道,我们选择 AutoKeras 块并逐个连接它们,如图 5.2 所示。

05-02

图 5.2 使用 AutoKeras 功能 API 创建的顺序 AutoML 管道

管道应该从一个表示数据类型(如图像或文本)的输入占位符开始,并以一个对应于我们想要解决的问题的任务(如分类或回归)的输出头结束。两个中间块是表征预处理方法和深度学习模型搜索空间的 AutoML 块。让我们更详细地看看管道中的组件(块),如下所述:

  • 输入节点是管道张量输入的占位符,例如图像输入(ImageInput)、文本输入(TextInput)或结构化数据输入(StructuredDataInput)(如第四章所述)。您还可以使用 AutoKeras 中的 Input 类定义一个通用的张量输入。输入节点接受多种格式的数据,如 NumPy 数组、pandas DataFrame 和 TensorFlow 数据集。它还将自动执行某些预处理操作,例如,如果图像没有通道维度,则扩展图像的维度。输入节点没有可以设置或调整的超参数。

  • 预处理块定义了在输入上执行额外的预处理操作(即如果如前所述,某些操作已经由输入节点执行),例如图像归一化、文本嵌入等。根据操作,我们可能有一些超参数需要调整,例如,如果执行文本嵌入,则用于将文本文档转换为它们的向量表示的词汇表的最大大小。在这个块中,没有通过反向传播训练的权重。

  • 网络块是 AutoKeras 中最重要的一种 AutoML 块。每个块代表一组具有相同结构的神经网络模型。例如,在本节中您将看到的 ConvBlock,包含一组卷积神经网络(CNNs)。每个 CNN 由卷积层和池化层组成。层数和类型被视为超参数。您可以选择一个或多个网络块来创建基于当前任务的管道,并根据您的需求指定其超参数的搜索空间。与预处理块不同,在指定网络块中的超参数后,将通过网络反向传播训练权重。

  • 输出头是一个用于生成最终输出的特定任务组件,例如在第四章讨论 IO API 时引入的分类头(ClassificationHead)和回归头(RegressionHead)。它将每个实例的表示重塑为向量,并应用密集层将其转换为目标输出的大小。例如,如果头是分类头且问题是多类分类问题,那么从密集层输出的每个实例将是一个长度为 10 的向量,对应于十个标签。每个头还指定了损失函数和度量标准,以帮助编译从搜索空间中选择的每个深度学习管道进行训练。

在本节的其余部分,我们将通过使用顺序 AutoML 管道,逐步介绍两个超参数调整示例。这些示例还将介绍 AutoKeras 中可以用于创建 AutoML 管道的几个内置 AutoML 块。

5.2.1 调整结构化数据回归的 MLP

我们的首要任务是调整一个多层感知器(MLP)的网络结构,以解决结构化数据的回归问题。在第三章中,我们通过使用 Keras 创建了一个 MLP 来解决加利福尼亚房价预测问题。我们在训练过程中通过观察训练集和验证集的均方误差(MSE)曲线来调整训练的轮数。在这里,我们将使用自动机器学习(AutoML)来调整 MLP 的结构超参数:层数和每层的单元数。一个直观的方法是创建多个具有不同层数和单元数的 MLP,对它们进行训练,并根据验证集的 MSE 选择最佳的一个。这个过程可以通过创建一个顺序的 AutoML 管道来完成,而不需要手动创建和探索多个 MLP。

要创建 AutoML 管道,我们可以利用 AutoKeras 的两个内置 AutoML 模块,如下所示:

  • 归一化是一个预处理块,它通过减去特征的平均值并除以它们的标准差来执行特征归一化。我们在第三章中使用这个操作来归一化加利福尼亚房价数据的特征。这个块有助于预处理 MLP 的数据。它不包含任何需要调整的超参数。

  • DenseBlock 是一个网络块,它形成了一个具有 MLP 结构的模型搜索空间。与最简单的 MLP 不同,它只堆叠了具有特定激活函数的密集层,DenseBlock 中的每个“层”(或单元)是三个 Keras 层的组合:一个密集层、一个 dropout 层以帮助减轻过拟合问题,以及一个批归一化层,该层将一批实例的输入张量归一化到均值为 0 和标准差为 1。批归一化层被添加在一个没有激活函数的密集层和一个 ReLU 激活层之间。是否使用批归一化层是一个需要调整的超参数。dropout 层被添加在最后(如图 5.3 所示)。密集层的数量、每个密集层的单元数以及 dropout 率(范围从 0 到 1)也是在这个块中需要调整的超参数,除非它们被固定。层数的默认选择范围在 1 到 3 之间,单元数的默认选择列表为[16, 32, 64, 128, 256, 512, 1024]。

05-04

图 5.3 DenseBlock 中的一个单元

如列表 5.1 所示,我们将两个块堆叠起来形成一个结构化数据回归管道,其结构如图 5.2 所示。我们将使用此管道来寻找加利福尼亚房价预测问题的良好 MLP 结构。输出头被定义为 RegressionHead,它通过对其输入应用线性变换来生成最终预测。默认情况下,输出头中在最终线性变换之前存在一个 dropout 层。我们通过将 dropout 率固定为 0 来简化它。我们还通过将 use_batchnorm 参数设置为 False 来移除批归一化层。除了 DenseBlock 中的两个超参数(层数和单元数)之外,搜索空间还包含两个优化算法的超参数,即算法的类型和学习率。通过将它们与 MLP 结构联合调整,我们可以为不同的管道实现更精确的性能,这使得我们更容易比较和选择它们。最后一行将试验次数设置为 10,这意味着我们从搜索空间中选择总共 10 个不同的管道,并从中选择最佳的一个。

列表 5.1 使用 MLP 为结构化数据回归创建 AutoML 管道

input_node = ak.StructuredDataInput()                       ❶
output_node = ak.Normalization()(input_node)                ❷
output_node = ak.DenseBlock(use_batchnorm=False,
                            dropout=0.0)(output_node)       ❸
output_node = ak.RegressionHead(dropout=0.0)(output_node)   ❹
auto_model = ak.AutoModel(inputs=input_node,
                          outputs=output_node,
                          max_trials=10)                    ❺

❶ 为结构化数据创建输入占位符

❷ 在输入节点上方堆叠一个归一化预处理块

❸ 添加用于调整 MLP 结构的 AutoML 块

❹ 在管道末尾添加回归输出头

❺ 形成 AutoML 管道并定义搜索试验次数

现在我们使用 scikit-learn 加载数据,并通过将其输入到其中(如列表 5.2 所示)来进行搜索过程。批大小和训练的最大轮数分别固定为 1,024 和 150,以帮助减少搜索时间。一般来说,较大的批大小和较少的轮数将减少训练网络所需的时间。这是一种在 AutoML 工作中加速搜索速度的朴素方法——不能保证每个探索的管道都能在 150 个轮次内收敛——但我们假设这已经足够长,可以给出它们性能的指示,并允许我们区分它们。在第八章中还将介绍更多加速速度的方法,即使不利用每个管道的评估性能。

列表 5.2 调整 MLP 以进行结构化数据回归

import pandas as pd
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split

house_dataset = fetch_california_housing()                            ❶

data = pd.DataFrame(house_dataset.data,
                    columns=house_dataset.feature_names)              ❶
target = pd.Series(house_dataset.target, name='MEDV')                 ❶

train_data, test_data, train_targets, test_targets = 
➥ train_test_split(data, target, test_size=0.2, random_state=42)     ❶

auto_model.fit(train_data, train_targets,
               batch_size=1024, epochs=150)                           ❷

❶ 加载数据集,将其打包到 pandas DataFrame 中,并分割出 20% 用于测试

❷ 使用数据拟合 AutoML 管道

如列表 5.3 所示,最佳 MLP 的最终测试 MSE 为 0.28——优于我们在第三章中设计的模型(MSE = 0.31)。我们可以使用 results_summary() 方法显示 MLP 的超参数;它有两个层,分别有 32 和 512 个单元。其在搜索过程中的验证 MSE 为 0.29。

列表 5.3 评估最佳深度学习管道

>>> test_loss, test_acc = auto_model.evaluate(test_data,
...                                           test_targets,
...                                           verbose=0)    ❶
>>> print('Test accuracy: ', test_acc)
Test accuracy:  0.2801434397697449
>>> auto_model.tuner.results_summary(num_trials=1)          ❷
Results summary
Results in ./auto_model
Showing 1 best trials
Objective(name='val_loss', direction='min')
Trial summary
Hyperparameters:
dense_block_1/num_layers: 2
dense_block_1/units_0: 32
dense_block_1/dropout: 0.0
dense_block_1/units_1: 512
regression_head_1/dropout: 0.0
optimizer: adam
learning_rate: 0.001
Score: 0.2891707420349121
>>> best_model = auto_model.export_model()                  ❸
>>> tf.keras.utils.plot_model(best_model,
...                           show_shapes=True,
...                           expand_nested=True)           ❹

❶ 评估最佳 MLP

❷ 总结搜索过程中的最佳试验

❸ 导出最佳 MLP

❹ 可视化最佳 MLP

由于导出的最佳模型是一个 Keras 模型,你可以像这里所示一样轻松地保存和加载你的最佳模型:

from tensorflow import keras
best_model.save('saved_model')
best_model = keras.models.load_model('saved_model')

我们导出最佳 MLP 并可视化其结构,如图 5.4 所示。它的每一层都可以与顺序 AutoML 管道中的相应组件相关联。例如,具有 ReLU 激活的两个密集层是从 DenseBlock 中定义的搜索空间中选择的。

05-04

图 5.4 最佳发现的 MLP 结构和 AutoML 管道中的相应组件

由于我们使用默认的层数搜索空间([1, 2, 3])和 DenseBlock 中的单元数([16, 32, 64, 128, 256, 512, 1024]),因此不同的 MLP 结构总数为 7 + 7² + 7³ = 399。与我们所进行的 10 次试验相比,这是一个相当大的搜索空间,这意味着我们可能没有测试到许多可能性。如果我们考虑优化算法(默认有三种选择)和学习率(默认有六种选择),搜索空间将更大。并且通过扩展,通过仅测试 10 个选项,我们相对不太可能从所有可能性中找到最好的一个。为了帮助约束搜索空间,我们可以手动固定一些超参数或限制它们的范围。例如,我们可以借鉴过去调整 MLP 的经验来约束层数和单元数。以下是一些我们可以基于我们的更改的假设:

  • 由于本例中的数据集较小,具有较少层的 MLP 应该有足够的容量来学习数据并避免过拟合。

  • 具有三角形或菱形结构的 MLP 通常比具有矩形结构的 MLP 表现更好。以三层 MLP 为例,具有三角形结构的 MLP 中的单元可以是[32, 64, 128]或[128, 64, 32]。具有菱形和矩形结构的两个 MLP 分别可以有单元[32, 64, 32]和[32, 32, 32]。这些三种类型的结构的示意图如图 5.5 所示。

05-05

图 5.5 三种 MLP 结构

在这个例子中,我们可以将层数固定为两层,并将两个层的单元数限制为分别从[128, 256, 512, 1024]和[16, 32, 64]中选择。这将有助于形成一个倒三角形 MLP 结构的搜索空间。可以通过连接两个 DenseBlock 并定义每层的单元选择来实现这些约束,如列表 5.4 所示。KerasTuner 提供了一个超参数模块(简称 hp)来帮助创建连续和离散超参数的搜索空间。例如,由于单元数是一个离散超参数,我们可以使用模块中的 hyperparameters.Choice 类来指定该超参数的可能值的列表。你将在第六章中看到这个类在设计和自己的 AutoML 块时的更多用途。

列表 5.4 调整 MLP 的搜索空间

>>> from keras_tuner.engine import hyperparameters as hp

>>> input_node = ak.StructuredDataInput()
>>> output_node = ak.Normalization()(input_node)
>>> output_node = ak.DenseBlock(
...     num_layers=1, num_units=hp.Choice('num_units', 512, 1024]),     ❶
...     use_batchnorm=False,
...     dropout=0.0)(output_node)
>>> output_node = ak.DenseBlock(
...     num_layers=1, num_units=hp.Choice('num_units', [16, 32, 64]),   ❶
...     use_batchnorm=False,
...     dropout=0.0)(output_node)
>>> output_node = ak.RegressionHead()(output_node)
>>> auto_model = ak.AutoModel(inputs=input_node, outputs=output_node,
...                           max_trials=10, overwrite=True, seed=42)

>>> auto_model.fit(train_data, train_targets, batch_size=1024, epochs=150)

>>> test_loss, test_acc = auto_model.evaluate(
...     test_data, test_targets, verbose=0)
>>> print('Test accuracy: ', test_acc)
Test accuracy:  0.2712092995643616

❶ 在密集层中自定义单元超参数的搜索空间

新的搜索空间只有 12 种不同的 MLP 结构。我们使用类似的方法来搜索、检索和评估最佳的 MLP;这次,在 10 次试验中发现的最佳 MLP 在测试中达到了 0.27 的均方误差,这优于在更大搜索空间中发现的先前 MLP。

注意:搜索空间的构建在 AutoML 的成功中起着至关重要的作用。一个好的搜索空间可以帮助你在更短的时间内发现一个有希望的管道。设计一个好的搜索空间甚至可能比设计一个好的搜索算法更重要,因为它提供了廉价的约束以加速搜索过程。然而,这通常需要先验知识以及对模型以及耦合的搜索算法的理解,这与 AutoML 的最终目标(节省人力)相悖。如果你没有先验知识可以依赖,你可以从一个大的搜索空间开始,并通过试错法逐渐减小其大小。这个想法也激励了一些高级 AutoML 算法,这些算法旨在逐步调整搜索空间或将其缩小到一个更精细的区域。我们将在第七章介绍一些代表性的例子。

现在你已经看到了如何调整 MLPs 以适应结构化数据回归任务,让我们看看另一个例子:调整 CNN 以适应图像分类任务。

5.2.2 调整 CNN 以进行图像分类

在这个例子中,我们将使用序列 AutoML 管道调整 CNN,以使用 MNIST 数据集解决图像分类问题。在第三章中,我们创建了一个 CNN,并展示了它在这一任务上的性能优于 MLP 网络。但我们没有探讨如何设置和调整 CNN 中的超参数,例如卷积层中的过滤器数量。现在,让我们构建一个 AutoML 管道来改进 CNN 结构,以实现更好的分类精度。

我们在 AutoKeras 中使用 ConvBlock 调整 CNN 的三个主要超参数:过滤器的数量、卷积层的数量以及卷积层的核大小。ConvBlock 按顺序堆叠多个卷积块(或卷积单元)。每个卷积块按顺序堆叠多个卷积层、一个最大池化层和一个 dropout 层(见图 5.6)。

05-06

图 5.6 ConvBlock 中每个卷积块的结构

所有的卷积块都有相同数量的卷积层,但每一层可以包含不同数量的过滤器。ConvBlock 的搜索空间具有以下七个超参数:

  • 卷积块的数量

  • 每个块中的卷积层数量——这在所有卷积块中都是相同的。

  • 卷积层的类型——每个卷积层可以是两种类型之一:它可以是一个常规的 2-D 卷积层,如第三章中介绍的那样,或者是一个可分离卷积层,它比常规卷积层包含更少的权重,但可能实现相当的性能。在下一节讨论 XceptionBlock 时,将提供关于此层类型的更详细解释。

  • 卷积层中的滤波器数量——每个块的每个层的滤波器数量可能不同。

  • 卷积层的核大小——最大池化层的核大小设置为核大小减一。一旦在试验中为 ConvBlock 选择核大小,它将应用于该 ConvBlock 中所有单元的所有池化层和卷积层。

  • 是否在每个单元中应用最大池化层——一旦为试验选择,它将应用于 ConvBlock 中的每个单元。

  • 是否在每个单元中应用 dropout 层——一旦为试验选择,它将应用于 ConvBlock 中的每个单元。

为了使这个例子简单,我们将通过将块的数量固定为两个来限制搜索空间,如列表 5.5 所示。我们不应用 dropout 层或使用可分离卷积层。要调整的超参数包括块中的层数、核大小以及每层的滤波器数量。默认情况下,它们分别从列表[1, 2]、[3, 5, 7]和[16, 32, 64, 128, 256, 512]中选择。不考虑优化器和学习率,在这个搜索空间中有 5,292 种不同的 CNN 结构。

注意:此代码示例可能需要很长时间才能运行。

列表 5.5 使用 AutoKeras 功能 API 进行 MNIST 分类

>>> import autokeras as ak
>>> from tensorflow.keras.datasets import mnist

>>> (x_train, y_train), (x_test, y_test) = 
➥ mnist.load_data()                                                      ❶

>>> input_node = ak.ImageInput()                                          ❷
>>> output_node = ak.Normalization()(input_node)                          ❸
>>> output_node = ak.ConvBlock(
...     num_blocks=2,
...     max_pooling=True,
...     separable=False,
...     dropout=0.0)(output_node)                                         ❹
>>> output_node = ak.ClassificationHead(dropout=0.0)(output_node)         ❺

>>> auto_model = ak.AutoModel(
...     inputs=input_node,
...     outputs=output_node,
...     max_trials=10,
...     overwrite=True,
...     seed=42)                                                          ❻

>>> auto_model.fit(x_train, y_train, epochs=3)                            ❼
>>> test_loss, test_acc = auto_model.evaluate(x_test, y_test, verbose=0)  ❽
>>> print('Test accuracy: ', test_acc)
Test accuracy:  0.9937999844551086

>>> best_model = auto_model.export_model()                                ❾
>>> best_model.summary()                                                  ❾
Model: 'functional_1'
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
input_1 (InputLayer)         [(None, 28, 28)]          0
_________________________________________________________________
cast_to_float32 (CastToFloat (None, 28, 28)            0
_________________________________________________________________
expand_last_dim (ExpandLastD (None, 28, 28, 1)         0
_________________________________________________________________
normalization (Normalization (None, 28, 28, 1)         3
_________________________________________________________________
conv2d (Conv2D)              (None, 24, 24, 128)       3328
_________________________________________________________________
conv2d_1 (Conv2D)            (None, 20, 20, 16)        51216
_________________________________________________________________
max_pooling2d (MaxPooling2D) (None, 5, 5, 16)          0
_________________________________________________________________
conv2d_2 (Conv2D)            (None, 5, 5, 16)          6416
_________________________________________________________________
conv2d_3 (Conv2D)            (None, 5, 5, 512)         205312
_________________________________________________________________
max_pooling2d_1 (MaxPooling2 (None, 2, 2, 512)         0
_________________________________________________________________
flatten (Flatten)            (None, 2048)              0
_________________________________________________________________
dense (Dense)                (None, 10)                20490
_________________________________________________________________
classification_head_1 (Softm (None, 10)                0
=================================================================
Total params: 286,765
Trainable params: 286,762
Nontrainable params: 3
_________________________________________________________________

❶ 加载 MNIST 数据

❷ 创建输入节点

❸ 添加一个归一化预处理块

❹ 将 ConvBlock 堆叠以创建 CNN 的搜索空间

❺ 使用分类头最终确定管道

❻ 将管道封装为 AutoModel

❼ 通过将训练数据拟合到管道来执行搜索过程

❽ 在测试集上评估最佳卷积神经网络

❾ 导出最佳卷积神经网络并打印其结构

最佳卷积神经网络在测试集上实现了 99.38%的准确率,这降低了我们在第三章中手动设计的 CNN 的错误率超过 30%。然而,网络的尺寸更大,主要是由于滤波器数量众多。为了发现更小的架构,我们可以限制搜索空间中的层数和滤波器数量。有可能找到具有与这里构建的 CNN 相当性能的更小架构;我将把这个作为练习留给你尝试。

5.3 使用超块进行自动管道搜索

在本节中,我们将讨论 AutoML 应用中经常遇到的另一个场景:选择在深度学习管道中使用最佳类型的组件(模型或预处理程序)。这比上一节中仅调整特定类型模型的超参数更为复杂,因为不同的模型和预处理程序可能包含不同的操作和独特的超参数。它要求我们共同选择预处理程序和模型的组合及其耦合的超参数。例如,在图像分类中,除了我们之前使用的原始 CNN 之外,还提出了许多高级模型,如 ResNet、Xception 等。即使你已经听说过这些模型,你可能也不知道它们是如何工作的,它们最适合哪些任务,或者如何调整它们。你还需要决定合适的预处理方法,例如选择是否使用归一化。在这里,我们将通过一些图像分类示例来展示如何自动选择模型和预处理方法。

5.3.1 图像分类的自动模型选择

第三章中介绍的 CNN 模型,它递归地堆叠卷积和池化层,是最简单的 CNN 架构,通常称为普通 CNN。现有工作提出了多种高级变体,试图提高 CNN 的运行性能和准确性。其中最强大的两种是 ResNet(残差网络)¹和 Xception²架构。由于没有模型在所有情况下都表现最佳,因此根据任务和手头的数据集选择模型及其超参数非常重要。我们将首先查看这两个模型及其超参数的调整方法,然后我会向你展示如何使用 AutoML 管道进行联合模型选择和超参数调整。

ResNet

在 ResNet 中,多个小的神经网络块(或细胞)被堆叠在一起以构建一个完整的神经网络。其块结构类似于 AutoKeras 的 ConvBlock 中的卷积块,但增加了一种特殊连接,称为跳跃连接,它以逐元素的方式将块的输入张量添加到其输出张量中(见图 5.7)。确保有效的跳跃连接,细胞的输入和输出大小应该相同。加法的结果将作为下一个细胞的输入张量。这对于构建更深的网络是有帮助的,因为它避免了梯度消失的问题,在反向传播过程中,由于使用链式法则计算复合层变换的导数,第一层的权重更新梯度变得越来越小。这些“消失”的梯度无法更新早期层的权重,这阻碍了更深网络的形成。你可以在 Chollet 的《Python 深度学习》一书中了解更多相关信息。

05-07

图 5.7 ResNet 的子结构

我们可以创建具有不同细胞结构或不同数量细胞的多种 ResNet。一些传统架构包括 ResNet-18 和 ResNet-50,其中名称中的数字表示从所有堆叠细胞中累积的总层数。为了实现 ResNet 并调整其结构,我们可以使用 AutoKeras 内置的 ResNetBlock 构建一个顺序 AutoML 流程,如下所示。ResNetBlock 包含经典 ResNet 结构的搜索空间,这些结构是预定义的,并包含在 Keras API 中 (keras.io/api/applications/resnet/)。

列表 5.6 创建用于图像分类的 ResNet AutoML 流程

input_node = ak.ImageInput()
output_node = ak.Normalization()(input_node)
output_node = ak.ResNetBlock()(output_node)
output_node = ak.ClassificationHead()(output_node)

Xception

Xception 是一种使用可分离卷积层来提高网络性能的 CNN 架构。正如在前面关于 ConvBlock 的简短讨论中提到的,可分离卷积层比常规卷积层包含更少的权重,但在许多任务上能够实现可比的性能。它使用来自两个可分离层的权重生成常规卷积层的滤波器(权重),然后像标准卷积层一样使用其生成的滤波器。以下列表显示了这是如何工作的。我们使用一个 3×3 大小的 2-D 平方权重矩阵和一个长度为 16 的向量,通过张量积生成一个 3×3×16 大小的常规 3-D 卷积滤波器。

列表 5.7 使用可分离卷积层生成权重

import numpy as np
kernel_size = 3
num_filters = 16

sep_conv_weight_1 = np.random.rand(kernel_size,
                                   kernel_size)                 ❶

sep_conv_weight_2 = np.random.rand(num_filters)                 ❷

sep_conv_filters = np.zeros(shape=(kernel_size,
                                   kernel_size,
                                   num_filters))                ❸

for i in range(kernel_size):
    for j in range(kernel_size):
        for k in range(num_filters):
            sep_conv_filters[i][j][k] = sep_conv_weight_1[i][j] 
➥ * sep_conv_weight_2[k]                                        ❹

❶ 初始化一个变量来表示我们要带来的内容。

❷ 可分离卷积层的权重向量

❸ 使用卷积层的权重初始化一个数组

❹ 使用张量积计算卷积层的权重

如图 5.8 所示,Xception 使用两种类型的神经网络细胞。它们与 ResNet 细胞相似,但具有可分离卷积层。第一种类型的细胞使用一个核大小等于 1 的卷积层来处理输入,然后再将其添加到可分离卷积层的输出中。

05-08

图 5.8 两种类型的 Xception 细胞

原始的 Xception 架构如图 5.9 所示。它包含开头的常规卷积层,中间的不同类型细胞,以及末尾的一些可分离卷积层。通过堆叠不同数量的细胞或选择不同的超参数,如层的滤波器数量或核大小,可以生成 Xception 架构的不同变体。

05-09

图 5.9 Xception 架构

我们可以使用 AutoKeras 中的 XceptionBlock 来帮助构建一个 AutoML 管道,以搜索一个好的 Xception 结构,如下一列表所示。它涵盖了 Chollet 描述的原版 Xception 架构(arxiv.org/abs/1610.02357)以及 TensorFlow Keras API 中包含的许多变体(keras.io/api/applications/xception/)。

列表 5.8 创建用于图像分类的 Xception AutoML 管道

input_node = ak.ImageInput()
output_node = ak.Normalization()(input_node)
output_node = ak.XceptionBlock()(output_node)
output_node = ak.ClassificationHead()(output_node)

除了 ResNet 和 Xception 之外,还有许多其他基于普通 CNN 的流行变体。我建议您根据自己的兴趣探索可用的模型。对于实际应用,您可以直接应用 AutoML 管道,以节省学习它们的工作原理和调整它们所花费的努力。

图像分类的联合模型选择和超参数调整

“没有免费午餐”定理³告诉我们,没有任何一个模型在任何情况下都是最佳选择。“我应该为我的任务使用哪种机器学习模型?”是一个常见的问题。因为我们知道如何设计一个 AutoML 管道来调整特定类型的模型,例如一个普通的卷积神经网络(CNN),一个直接的选择是逐一调整不同类型的模型,为每个模型找到最佳的超参数集,并从中选择表现最好的一个。这是一个可行的解决方案,但它并不优雅,因为它需要我们创建多个 AutoML 管道。我们期望能够创建一个单一的 AutoML 管道,一步解决问题。这要求 AutoML 管道涵盖所有相关的模型类型以及它们独特的超参数。在每次搜索尝试中,搜索算法可以先选择一个模型,然后选择其超参数来生成一个管道。我们可以使用 AutoKeras 中的 ImageBlock 来实现这样的 AutoML 管道(见图 5.10),它是一种“超块”,将几个低级 AutoML 块组合在一起:ConvBlock、ResNetBlock 和 XceptionBlock。您可以使用 block_type 超参数来选择要使用的块类型,或者如果未指定,它将自动调整。它还包括归一化和图像增强预处理块(我们将在下一节中更多地讨论预处理方法)。

05-10

图 5.10 AutoKeras 中的 ImageBlock

搜索空间中的模型数量相当大,因为它包括了所有包含的块类型。通常需要许多搜索试验才能找到一个好的模型。此外,一些模型,如 ResNet-152,比我们之前设计的 vanilla CNN 大得多。这些模型可能需要更多的训练 epoch 来达到良好的准确率,并确保公平的比较和选择。这两个因素都导致了更长的搜索过程。此外,大模型的大小在搜索过程中会导致内存消耗增加,这可能会阻止我们使用大型数据集或批次大小(为了减少搜索成本)。这些都是 AutoML 研究和应用中的关键障碍。我们在这里不会深入探讨这些问题,而是将使用少量搜索试验和训练 epoch 作为一种变通方法来帮助您了解模型选择过程。我们将在第八章中更多地讨论如何加速搜索过程并减少内存消耗。

在列表 5.9 中,我们实现了一个 AutoML 管道,用于选择一个合适的模型(vanilla CNN、ResNet 或 Xception)用于在 MNIST 数据集上进行图像分类。我们进行了 10 次试验。每个模型使用 32 个批次的默认大小(AutoKeras 的默认批次大小)进行训练,共三个 epoch。如果批次大小过大,AutoKeras 会自动将其减小以避免内存溢出。

列表 5.9 使用 ImageBlock 选择图像分类模型

>>> import timeit
>>> import autokeras as ak

>>> input_node = ak.ImageInput()
>>> output_node = ak.ImageBlock(
...     normalize=True,                                  ❶
...     augment=False,                                   ❶
...     )(input_node)
>>> output_node = ak.ClassificationHead(dropout=0.0)(output_node)
>>> auto_model = ak.AutoModel(
...     inputs=input_node,
...     outputs=output_node,
...     max_trials=10,
...     overwrite=True,
...     seed=42)

>>> start_time = timeit.default_timer()                  ❷
>>> auto_model.fit(x_train, y_train, epochs=3,
...                batch_size=32)                        ❷
>>> stop_time = timeit.default_timer()                   ❷
>>> print(f'total time: {round(stop_time - start_time, 2)} seconds.')
Total time: 4008.61 seconds.

>>> auto_model.tuner.results_summary(num_trials=1)       ❸
Results summary
Results in ./auto_model
Showing 1 best trials
Objective(name='val_loss', direction='min')
Trial summary
Hyperparameters:
image_block_1/block_type: xception
classification_head_1/spatial_reduction_1/reduction_type: global_avg
optimizer: adam
learning_rate: 0.001
image_block_1/xception_block_1/pretrained: False
image_block_1/xception_block_1/imagenet_size: False
Score: 0.06062331795692444
>>> test_loss, test_acc = auto_model.evaluate(x_test,
...     y_test,
...     verbose=0)                                       ❹
>>> print('Accuracy: {accuracy}%'.format(accuracy=round(test_acc*100,2)))
Accuracy: 98.57%

❶ 修复包含在 ImageBlock 中的两个预处理块

❷ 执行搜索并标记总时间

❸ 总结最佳找到的管道

❹ 评估最佳模型

从搜索结果中我们可以看到,在一个单独的 GPU(NVIDIA 2080 Titan)上完成 10 次试验需要超过一个小时,这非常长。最佳模型是一个 Xception 模型,但其性能并不如我们之前使用相同数量的搜索试验和相同的模型训练设置找到的带有 ConvBlock 的 vanilla CNN。这表明,虽然扩大搜索空间可以让我们从不同的模型中进行选择,但它可能需要更多的资源,例如使用更多试验的更多搜索时间或更多计算资源,来找到一个好的架构。尽管它可以节省调优的努力,但 AutoML 的便利性和其成本之间的权衡绝不能被忽视,并且仍然是 AutoML 领域的一个活跃的研究领域。

5.3.2 自动选择图像预处理方法

除了模型选择之外,我们还可能想要选择合适的数据预处理方法,以更好地为我们的模型准备数据并提高它们的性能。例如,在深度学习应用中,处理小型数据集是一种常见情况。从不足的训练数据中学习可能会引入过拟合的高风险,尤其是在我们有一个较大的模型时,导致模型对新数据泛化不良。这个问题可以通过以下两种主要方法来缓解:

  • 在模型或学习算法方面,我们可以使用一种称为正则化的技术。我们已经看到了一些例子,例如使用 dropout 层,通过减少层数或神经元数量来限制模型大小,以及使用更少的训练轮次。

  • 在数据方面,我们可能能够收集更多数据或使用数据增强方法来调整现有数据集中的实例,以生成新的实例。数据增强为 ML 模型提供了更大的实例池来学习,这可以提高它们的性能。例如,对于图像数据集,每个图像在传递到神经网络之前可能会水平翻转或旋转一定角度。我们可以使用许多此类操作来调整图像,并且我们可以随机对不同的图像应用不同的操作以实现更多样化的训练数据。在不同的轮次中,我们也可以对同一图像应用不同的操作。在图 5.11 中,你可以看到以这种方式生成的某些图像。第一幅是原始图像,其他九幅图像都是使用数据增强技术生成的。正如你所见,内容始终相同,但大小、位置等已经发生了变化。

05-11

图 5.11 图像增强

由于许多正则化技术都与模型结构的选择相关,因此在前几节中介绍的 AutoML 方法已经能够发现其中的一些,以减轻过拟合的问题。实际上,将 AutoML 管道扩展以调整和选择合适的数据增强方法也很简单——即使用 AutoML 模块来选择和评估各种数据增强方法。ImageBlock 还允许我们在多种数据预处理方法中进行选择,例如决定是否使用归一化和/或数据增强方法来准备数据。

让我们用一个图像分类示例来说明如何自动选择 ResNet 模型的预处理方法。我们决定是否使用数据增强和归一化方法。我们在 5.10 列表中使用的数据集是 CIFAR-10 数据集的一个子集,包含 60,000 张 32×32×3 大小的 RGB 图像。训练集中的 50,000 张图像属于 10 个类别,例如“鸟”、“猫”、“狗”等(每个类别 5,000 张图像)。为了简化问题,我们将只使用两个类别的图像,“飞机”和“汽车”。子采样数据集的前九张图像在图 5.12 中进行了可视化。

05-12

图 5.12 CIFAR-10 数据集中“飞机”和“汽车”类的前九张图像

列表 5.10 加载和可视化 CIFAR-10 数据集的子集

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

>>> airplane_automobile_indices_train = \                   ❷
...     (y_train[:, 0]==0) | (y_train[:, 0]==1)             ❷
>>> airplane_automobile_indices_test = \                    ❷
...     (y_test[:, 0]==0) | (y_test[:, 0]==1)               ❷
>>> x_train = x_train[airplane_automobile_indices_train]    ❷
>>> y_train = y_train[airplane_automobile_indices_train]    ❷
>>> x_test = x_test[airplane_automobile_indices_test]       ❷
>>> y_test = y_test[airplane_automobile_indices_test]       ❷
>>> print('Training image shape:', x_train.shape)
>>> print('Training label shape:', y_train.shape)
>>> print('First five training labels:', y_train[:5])
Training image shape: (10000, 32, 32, 3)
Training label shape: (10000, 1)
First five training labels: [[1]
 [1]
 [0]
 [0]
 [1]]

>>> from matplotlib import pyplot as plt
>>> for i in range(9):                                      ❸
...     plt.subplot(330 + 1 + i)                            ❸
...     plt.imshow(x_train[i])                              ❸
>>> plt.show()                                              ❸

❶ 加载 CIFAR-10 数据集

❷ 选择属于“飞机”和“汽车”类别的图像

❸ 绘制前九张图像

让我们首先创建一个 AutoML 管道来选择 ResNet 模型的数据增强方法。该管道的结构与我们在上一节中为调整单个 ResNet 模型所构建的顺序 AutoML 管道相同。唯一的区别是我们将一个 ImageAugmentation AutoML 模块添加到归一化预处理模块和网络模块之间,如列表 5.11 所示。AutoKeras 中的 ImageAugmentation 模块没有通过反向传播更新的参数,但它包含多个可以与管道中的其他超参数一起联合选择的图像变换操作。我们固定 ResNet 的类型以缩小搜索空间。'v2'在这里表示版本 2 的搜索空间,包括三种 ResNet 结构。⁴ 增强方法将与结构和其他超参数(如优化方法和学习率)一起选择。

注意:此代码示例可能需要很长时间才能运行。

列表 5.11 选择 ResNet 模型的图像预处理方法

input_node = ak.ImageInput()
output_node = ak.Normalization()(input_node)
output_node = ak.ImageAugmentation()(output_node)
output_node = ak.ResNetBlock(version='v2')(output_node)
output_node = ak.ClassificationHead(dropout=0.0)(output_node)
auto_model = ak.AutoModel(
    inputs=input_node,
    outputs=output_node,
    overwrite=True,
    max_trials=10)
auto_model.fit(x_train, y_train, epochs=10)

如前所述,AutoKeras 中的图像超模块(ImageBlock)也包含预处理方法,因此我们可以用它来选择数据增强方法。如果我们设置 normalize 参数为 None,它还可以决定是否使用归一化,如下面的列表所示。将 normalize 设置为 True(或 False)将确定我们想要使用(或不想使用)归一化方法。

注意:此代码示例可能需要很长时间才能运行。

列表 5.12 选择 ResNet 模型的增强和归一化方法

input_node = ak.ImageInput()
output_node = ak.ImageBlock(

    normalize=None,           ❶
    augment=None,             ❶

    block_type='resnet',      ❷
    )(input_node)
output_node = ak.ClassificationHead(dropout=0.0)(output_node)
auto_model = ak.AutoModel(
    inputs=input_node,
    outputs=output_node,
    overwrite=True,
    max_trials=10)
auto_model.fit(x_train, y_train, epochs=10)

❶ 不指定是否要使用归一化和数据增强方法;让它们自动搜索

❷ 只搜索 ResNet 架构

因为搜索和模型评估过程与前面介绍的所有示例相同,所以我们在此不再重复。

你现在知道了如何使用 AutoML 管道对一个单一类型的模型进行超参数调整,以及如何使用 ImageBlock 进行模型选择。尽管我们以图像分类任务为例,但这个过程可以推广到文本和结构化数据用例。例如,为了联合选择文本或结构化数据分类或回归任务中的模型和预处理程序,你可以在 AutoKeras 中使用 TextBlock 或 StructuredDataBlock 来创建 AutoML 管道,并将输入节点从 ImageInput 更改为 TextInput 或 StructuredDataInput。TextBlock 和 StructuredDataBlock 都涵盖了相应数据类型的某些代表性模型和预处理程序。有关这些和其他 AutoML 块更详细的信息,请参阅autokeras.com/tutorial/overview/。对于实际应用,你可以选择与你想调整或选择的神经网络类型相关的选项,并使用超参数的默认搜索空间来创建你的 AutoML 管道,或者根据你的需求自定义超参数。在下一节中,我们将转向一个稍微复杂一些的用例:设计超出仅仅顺序堆叠块的图结构化 AutoML 管道。

5.4 设计图结构化的 AutoML 管道

在许多应用中,我们的需求超出了顺序深度学习模型(顺序堆叠层)。例如,对于多输入和多输出分类问题,我们可能需要为不同的输入使用不同的层和预处理组件,并创建不同的头部来生成不同的输出。我们可能需要在将图像输入到 ResNet 模型之前使用归一化方法对图像进行预处理,并在将具有分类特征的结构化数据输入到 MLPs 之前将其编码为数值。然后我们可以合并来自模型的输出以生成针对不同目标的输出。在其他情况下,我们可能希望利用多个深度学习模型的组合力量,例如使用 ResNet 和 Xception 网络一起进行图像分类。不同的模型可以从数据中学习不同的特征表示,并且结合这些表示可能会增强它们的预测能力。在这些场景中调整模型需要超出顺序管道到图结构化 管道,其中每个块可以从多个块接收输入(见图 5.13)。该管道是一个有向无环图(DAG),其中的节点是上一节中引入的 AutoML 块。它们的顺序表示块之间的输入/输出连接,这也表示了数据流。

05-13

图 5.13 图结构化的 AutoML 管道

在本节中,我们将简要介绍如何通过创建图结构管道来调整具有多个输入和输出的模型。我们想要调整的模型具有交叉结构,如图 5.13 左侧所示。我们将首先创建一个与第四章中使用的类似的合成数据集,其中包含合成图像和结构化数据。我们有两个目标:一个分类目标和回归响应。创建数据集的代码在列表 5.13 中给出。我们生成了 1,000 个合成实例,其中 800 个用于训练和验证,200 个保留用于测试。每个实例包含一个大小为 32×32×3 的图像和三个分类特征。输出包括一个分类标签(五个类别之一)和一个回归响应。

列表 5.13 创建具有多个输入和输出的合成数据集

>>> import numpy as np

>>> num_instances = 1000

>>> image_data = np.random.rand(num_instances, 32, 32, 3).astype(np.float32)❶
>>> image_train, image_test = image_data[:800], 
➥ image_data[800:]                                                         ❶

>>> structured_data = np.random.choice(
...     ['a', 'b', 'c', 'd', 'e'], size=(num_instances, 3))                 ❷
>>> structured_train, structured_test = 
➥ structured_data[:800], structured_data[800:]                             ❷

>>> classification_target = np.random.randint( 
...     5, size=num_instances)                                              ❸
>>> clf_target_train, clf_target_test = 
➥ classification_target[:800], classification_target[800:]                 ❸

>>> regression_target = np.random.rand(
...     num_instances, 1).astype(np.float32)                                ❹
>>> reg_target_train, reg_target_test = 
➥ regression_target[:800], regression_target[800:]                         ❹

>>> structured_train[:5]                                                    ❺
array([['b', 'b', 'e'],
       ['e', 'e', 'b'],
       ['c', 'c', 'c'],
       ['c', 'b', 'd'],
       ['c', 'c', 'a']], dtype='<U1')

❶ 生成图像数据

❷ 生成具有三个分类特征的结构化数据

❸ 为五个类别生成分类标签

❹ 生成回归目标

❺ 显示前五个实例的分类特征

管道的创建应遵循图中节点的拓扑顺序,这意味着我们应该遵循数据流——我们首先创建管道前端的 AutoML 块,以便它们的输出可以输入到后续出现的块中。你可以想象一个数据实例如何从输入到输出,以便逐个设置 AutoML 管道的块。图 5.13 左侧所示创建 AutoML 管道的代码在列表 5.14 中给出。在堆叠 AutoML 块以处理每种类型的数据后,两个分支的输出通过一个合并块组合起来以生成两个响应。如果它们的维度相同,该块将逐元素添加两个输出。否则,它将输入张量重塑为向量,然后进行连接。如果在初始化期间未指定,则将在搜索过程中调整要使用的特定合并操作。

列表 5.14 使用图结构 AutoML 管道调整模型

import autokeras as ak

input_node1 = ak.ImageInput()                        ❶
branch1 = ak.Normalization()(input_node1)            ❶
branch1 = ak.ConvBlock()(branch1)                    ❶

input_node2 = ak.StructuredDataInput()               ❷
branch2 = ak.CategoricalToNumerical()(input_node2)   ❷
branch2 = ak.DenseBlock()(branch2)                   ❷

merge_node = ak.Merge()([branch1, branch2])          ❸
output_node1 = ak.ClassificationHead()(merge_node)   ❸
output_node2 = ak.RegressionHead()(merge_node)       ❸

auto_model = ak.AutoModel(
    inputs=[input_node1, input_node2],
    outputs=[output_node1, output_node2],
    max_trials=3,
    overwrite=True,
    seed=42)                                         ❹

auto_model.fit(
    [image_train, structured_train],
    [clf_target_train, reg_target_train],
    epochs=3,
)                                                    ❺

best_model = auto_model.export_model()               ❻
tf.keras.utils.plot_model( 
    best_model,show_shapes=True, expand_nested=True) ❻

❶ 在图像分支中堆叠两个块

❷ 在结构化数据分支中堆叠两个块

❸ 合并两个块

❹ 生成具有多个输入和输出的图结构 AutoML 管道

❺ 将数据输入到 AutoML 管道中

❻ 绘制最佳模型

在三次试验中找到的最佳架构在图 5.14 中进行了可视化。我已经将发现的深度网络中的每个组件与 AutoML 管道中的相应元素进行了注释。它们的超参数被选用来形成 AutoML 块的搜索空间。例如,使用两个密集层来处理结构化数据,并且有两个卷积单元,每个单元包含两个卷积层和一个最大池化层来编码图像。

05-14

图 5.14 为具有多个输入和输出的任务识别的最佳模型

我们可以创建更复杂的图结构流水线来调整更复杂的架构,并且我们可以使用超块来帮助我们选择不同的模型。

虽然使用 AutoKeras 内置的块来创建这些流水线很方便,但你可能会发现它们并不能涵盖你了解的所有模型,或者无法支持你想要调整的所有超参数。这引发了一个问题:我们是否可以创建自己的 AutoML 块来选择和调整我们关心的神经网络或预处理方法?最后一节的目标是介绍如何自定义自己的 AutoML 块来构建 AutoML 流水线。

5.5 设计自定义 AutoML 块

使用 AutoKeras 的内置块可能并不总是能满足你的超参数调整和模型选择需求。可能存在数百甚至数千个神经网络超出了这些块定义的搜索空间范围。例如,假设你想调整你自己创建的或从文献中找到的用于图像分类的 CNN(卷积神经网络)。它不包含任何内置 AutoKeras 块的搜索空间中,包括 ConvBlock、ResNetBlock 等。你需要一个新的 AutoML 块,其搜索空间由与这个新的 CNN 模型具有相同结构但具有不同超参数(如不同数量的单元或层)的模型组成。有了这个新的 AutoML 块,你可以调整新神经网络的超参数,并将其与其他由现有 AutoKeras 块覆盖的模型进行比较。

本节将指导你如何创建自己的 AutoML 块来进行超参数调整和模型选择。我们将首先创建一个自定义块来调整 MLP(多层感知器)而无需使用内置的 MLP 块(DenseBlock)。然后我们将探讨如何自定义超块来进行模型选择。本节的目标不是教你如何设计高级神经网络,而是展示一种创建搜索空间(AutoML 块)的一般方法,这样你就可以在已知如何使用 TensorFlow 和 Keras 创建它们的情况下,从不同类型的神经网络中进行调整和选择。

5.5.1 使用自定义 MLP 块调整 MLP

在 5.2 节中,我们使用内置的 AutoKeras 块(DenseBlock)为回归任务选择了一个好的 MLP 结构。本节将向你展示如何自己实现一个 MLP 块以实现相同的目标。这将使你熟悉创建定义深度学习模型搜索空间的 AutoML 块的基本操作,并且可以推广到创建用于调整更复杂架构的 AutoML 块。

自定义用于调整单元数量的块

第三章向您展示了如何使用 TensorFlow 和 Keras 创建一个 MLP。我们从指定输入形状的输入节点开始。然后我们创建多个密集层并将它们按顺序逐层堆叠。最后,这些层被组合成一个 MLP 模型,通过 tensorflow.keras.Model 类(或我们之前使用的 tensorflow.keras.Sequential 类)进行训练和测试。列表 5.15 创建了一个三层 MLP,其两个隐藏层各有 32 个单元。我们忽略了模型编译的部分,只实现了模型结构的创建。调用 build 函数将返回一个具有此处定义结构的 MLP 模型。

列表 5.15 Keras 中的 MLP 实现

from tensorflow import keras
from tensorflow.keras import layers

def build_mlp():
    input_node = keras.Input(shape=(20,))                                  ❶

    output_node = layers.Dense(units=32, activation='relu')(input_node)    ❷
    output_node = layers.Dense(units=32, activation='relu')(output_node)   ❷
    output_node = layers.Dense(units=1, activation='sigmoid')(output_node) ❸

    model = keras.Model(input_node, output_node)                           ❹
    return model

mlp_model = build_mlp()

❶ 定义网络的输入维度

❷ 堆叠两个具有 32 个单元和 ReLU 激活的隐藏密集层

❸ 添加一个具有 sigmoid 激活的密集分类层

❹ 将层组合成一个 Keras 模型

假设我们想要调整两个隐藏层中的单元数。单元超参数应该是一个整数,因此为了创建一个有限的搜索空间,我们将假设其值为 32 的倍数且小于 512。这给我们提供了一个以下值的搜索空间:[32, 64, ..., 512]。为了创建一个定义此搜索空间的 AutoML 块,我们需要做三件事。首先,在列表 5.16 中,我们创建了一个扩展基本 AutoKeras Block 类的类(ak.Block)。我们将使用它来调整我们的 MLP,因此我们将它命名为 MlpBlock。扩展基本 Block 类确保我们可以使用定制的块通过将其与其他 AutoKeras 组件连接起来来创建一个 AutoML 管道。它包含一个要重写的 build()函数,这可以帮助我们在搜索过程中定义搜索空间并实例化 MLP 模型。

列表 5.16 调整具有两个隐藏层的 MLP 的单元数

import autokeras as ak              ❶

class MlpBlock(ak.Block):           ❷

    def build(self, ...):           ❸
        raise NotImplementedError

❶ 导入 autokeras 包以自定义 AutoML 块

❷ 开始实现一个扩展基本 AutoKeras Block 类的类

❸ 需要实现的函数

接下来,我们实现 build 函数。这是 AutoML 块的核心部分。它有两个作用:定义我们想要调整的超参数的搜索空间,并在搜索过程中每次被搜索算法调用时构建一个 MLP。实现它非常类似于编写一个构建 MLP 模型的函数(参见列表 5.15),但有以下主要变化:

  • 因为这个 AutoML 块将与其他块连接起来形成一个 AutoML 管道,所以它应该接受一个或多个先前块的输出作为其输入,并输出一个张量,可以作为输入馈送到其他块,而不是一个完整的 Keras 模型进行训练和评估。

  • 由于使用 AutoML 块来定义相关超参数的搜索空间,因此我们应该为每个想要调整的超参数定义一个搜索空间。这些超参数不会预先分配固定值,例如列表 5.15 中的 units=32,而是在每个搜索试验中动态地从搜索空间(由搜索算法)中分配一个值。

为了反映第一个变化,在列表 5.17 中,我们移除了 Keras 输入初始化器(keras.Input())并直接将前一个 AutoML 块的输出节点(或张量)列表馈送到 build()函数。由于输入可以是张量列表,我们使用展平操作(tf.nest.flatten())将它们组合成一个可以直接馈送到密集层的单个张量。我们只留下块中的两个隐藏层进行调整,并返回输出而不添加分类层或将其包装到 Keras 模型中。

列表 5.17 使用相同数量的单元调整两个隐藏层

import autokeras as ak
import tensorflow as tf
from tensorflow.keras import layers

class MlpBlock(ak.Block):                               ❶

  def build(self, hp, inputs):                          ❷

    input_node = tf.nest.flatten(inputs)[0]             ❸

    units = hp.Int(name='units', min_value=32,
                   max_value=512, step=32)              ❹

    output_node = layers.Dense(
        units=units, activation='relu')(input_node)     ❺
    output_node = layers.Dense(
        units=units, activation='relu')(output_node)    ❺

    return output_node                                  ❻

❶ 实现一个扩展 Block 类的类

❷ 覆盖构建函数

❸ 从输入中获取输入节点,这些输入可能是一个节点列表或单个节点

❹ 声明单元数量为一个整型超参数

❺ 两个层使用相同数量的单元

❻ build 函数的返回值应该是输出节点。

为了定义单元超参数的搜索空间并动态地为其分配值,我们使用 KerasTuner 中的一个模块,即 keras_tuner.engine.hyperparameters。它包含用于创建不同类型超参数(整数、浮点、布尔)的搜索空间的不同类,以及一个名为 HyperParameters(简称 hp)的容器,它包含有关所有相关超参数搜索空间的信息。hp 容器具有对应于不同搜索空间创建类的不同方法。例如,hp.Choice 方法对应于我们在第五章中使用的 keras_tuner.engine.hyperparameters.Choice 类。在这里,我们使用 hp.Int 为单元超参数定义一个整数值的搜索空间(参见列表 5.17 中的第四个注释)。它创建了一个值列表([32, 64, ..., 512]),类似于 hp.Choice 方法,但更方便:您不需要逐个列出搜索空间中的每个值,但可以定义一个步长值来自动生成它们(本例中为 32,因为值是 32 的倍数)。max_value 和 min_value 参数限制了搜索空间的范围。name 参数为搜索空间中的超参数提供了一个参考。

在列表 5.18 中,我们假设两个隐藏层具有相同数量的单元。我们也可以分别调整每个隐藏层中的单元数量,如下面的列表所示。在这种情况下,我们为每个超参数分配不同的名称并将它们分配给相应的层。

列表 5.18 分别调整两个隐藏层中的单元数量

import autokeras as ak
import tensorflow as tf
from tensorflow.keras import layers

class MlpBlock(ak.Block):
  def build(self, hp, inputs):
    input_node = tf.nest.flatten(inputs)[0]
    units_1 = hp.Int(name='units_1', min_value=32,
                     max_value=512, step=32)          ❶
    units_2 = hp.Int(name='units_2', min_value=32,
                     max_value=512, step=32)          ❶
    output_node = layers.Dense(units=units_1,
        activation='relu')(input_node)                ❷
    output_node = layers.Dense(units=units_2,
        activation='relu')(output_node)               ❷
    return output_node

❶ 为每一层的单元数创建单独的搜索空间

❷ 将单元分配给相应的层

我们不在 build()函数中创建 hp 容器,而是将其作为输入传递给 build()函数。容器是所有 AutoML 块的全球容器。它扮演以下两个角色:

  • 搜索空间容器—hp 容器包含每个 AutoML 块中创建的所有超参数搜索空间的信息。因此,一旦完整的 AutoML 管道被创建,它将包含整个管道的完整搜索空间细节。

  • 当前超参数值容器—在搜索过程中,在每次试验中,容器将跟踪搜索算法提供的超参数值,并根据它们的名称将它们分配给每个块中相应的超参数。在这个例子中,hp 容器将提供由搜索算法选择的固定单元值,以帮助我们构建 MlpBlock 中的两个隐藏层。

虽然看起来我们在 build()函数中为每个超参数(如 units=hp.Int(...))分配了一个搜索空间,但在搜索过程中,该函数总是会创建具有固定超参数值的层。这些值默认设置或由搜索算法选择。这意味着当调用 hp.Int()方法时,它总是会返回一个固定值,搜索空间的定义保存在 hp 容器中。这也说明了为什么超参数的名称应该不同:这样容器在保存它们的搜索空间时可以区分它们,并在每次试验构建模型时将正确的值分配给它们。

为调整不同类型的超参数定制一个块

在上一个示例中,我们调整了一个整型超参数,但在 MLP 中可能存在许多不同类型的超参数。例如,是否应该使用 dropout 层(如第四章所述,有助于避免过拟合)可以被视为具有布尔(真/假)值的超参数。dropout 层中的 dropout 率是一个浮点值。调整不同类型超参数的关键点是选择正确的搜索空间创建方法。我们在表 5.1 中列出了几个示例。直观上,创建搜索空间的方法与超参数的值类型相符合。请记住,尽管 hp 容器方法,如 hp.Choice()和 hp.Int()定义了一个搜索空间,但它们总是会返回一个值。

表 5.1 MLP 模式中的不同超参数类型

超参数 类型 搜索空间示例 搜索空间创建方法
层中的单元数量 整数 [10, 30, 100, 200] / [10, 20, 30, 40] hp.Choice, hp.Int
层数数量 整数 [1, 2, 3, 4] hp.Choice, hp.Int
是否使用 dropout 层 布尔 [True, False] hp.Boolean
Dropout 率 浮点数 介于 0.1 和 0.2 之间的任何实数值 hp.Float

调整单个超参数很简单。但如果我们想同时搜索层数以及每层的单元数呢?这两个超参数相互依赖,因为层数决定了如果我们假设隐藏层可以有不同数量的单元,我们想要调整多少个“单元”超参数。我们可以使用 hp.Choice() 方法创建层数的搜索空间,如列表 5.19 所示。返回值将决定 MlpBlock 中将有多少层,并可用于 for 循环以帮助创建层并设置每层单元数的搜索空间。请注意,我们为每层的单元超参数使用不同的名称,以便在搜索算法中区分它们。

列表 5.19 选择层数和每层的单元数

import autokeras as ak
import tensorflow as tf
from tensorflow.keras import layers

class MlpBlock(ak.Block):
    def build(self, hp, inputs):
        output_node = tf.nest.flatten(inputs)[0]
        for i in range(hp.Choice('num_layers', [1, 2, 3])):    ❶
            output_node = layers.Dense(units=hp.Int(
                'units_' + str(i),                             ❷
                min_value=32,
                max_value=512,
                step=32),
            activation='relu')(output_node)
    return output_node

❶ 将层数定义为超参数

❷ 动态地为每一层生成一个新的超参数,同时确保超参数名称不重复

我们可以通过添加更多超参数来完善我们的 MlpBlock,如列表 5.20 所示。例如,是否使用 dropout 层在实践中值得探索。因为它是一个真或假的选项,我们可以使用 hp.Boolean() 来调整这个超参数。如前所述,我们还可以调整 dropout 率,这决定了在训练过程中要忽略的神经元百分比。这是一个浮点值,因此我们将使用 hp.Float() 来调整这个超参数。

列表 5.20 调整更多类型的超参数

import autokeras as ak
import tensorflow as tf
from tensorflow.keras import layers

class MlpBlock(ak.Block):
    def build(self, hp, inputs):
        output_node = tf.nest.flatten(inputs)[0]
        for i in range(hp.Choice('num_layers', [1, 2, 3])):
            output_node = layers.Dense(units=hp.Int('units_' + str(i),
                min_value=32,
                max_value=512,
                step=32),
            activation='relu')(output_node)
        if hp.Boolean('dropout'):                   ❶
            output_node = layers.Dropout(rate=hp.Float('dropout_rate',
                min_value=0,
                max_value=1))                       ❷
    return output_node

❶ 使用 hp.Boolean 来决定是否使用 dropout 层

❷ 使用 hp.Float 来决定 dropout 率

当使用 hp.Float() 时,请注意,我们没有像在 hp.Int() 中那样指定 step 参数。搜索算法将在连续范围内选择一个浮点值。

使用自定义块创建 AutoML 流程

现在你已经知道如何编写一个神经网络块来定义一个自定义的搜索空间。下一步是将它与其他组件(输入节点、输出头部和其他块)连接起来,以创建一个完整的 AutoML 流程来调整相关的超参数。

在连接之前确保块不包含实现错误,你可以编写一个简单的测试来查看它是否正确构建,如下一列表所示。输入可以是一个单一的 Keras 输入节点或节点列表。为了测试目的,创建了一个 hp 容器。你还可以在 build() 函数中插入一些打印语句(或断言)以打印出(或断言)一些中间输出,以便更好地测试块。

列表 5.21 测试神经网络块

import keras_tuner as kt
hp = kt.HyperParameters()
inputs = tf.keras.Input(shape=(20,))
MlpBlock().build(hp, inputs)

如果块的 build() 函数运行顺利且没有错误,我们可以像使用 AutoKeras 中第四章介绍的任何内置块一样使用这个块。让我们用它来调整一个用于结构化数据回归任务的 MLP,并使用合成数据集。在下面的列表中,我们首先随机生成一个包含 20 个特征、100 个训练实例和 100 个测试实例的表格数据集。然后,我们将 MlpBlock 与输入节点和输出回归头连接起来,创建一个完整的 AutoML 管道,并初始化 AutoModel 以在训练数据集上执行搜索过程。

列表 5.22 使用自定义块构建和拟合模型

>>> import numpy as np                                  ❶
>>> x_train = np.random.rand(100, 20)                   ❶
>>> y_train = np.random.rand(100, 1)                    ❶
>>> x_test = np.random.rand(100, 20)                    ❶

>>> input_node = ak.StructuredDataInput()
>>> output_node = MlpBlock()(input_node)                ❷
>>> output_node = ak.RegressionHead()(output_node)      ❸
>>> auto_model = ak.AutoModel(input_node, output_node,
...     max_trials=3, overwrite=True)
>>> auto_model.fit(x_train, y_train, epochs=1)

❶ 生成用于回归的合成结构化数据

❷ 将输入节点传递给自定义块

❸ 将自定义块的输出节点传递给回归头

我们还可以打印出创建的 AutoML 管道的搜索空间,如列表 5.23 所示。正如你所见,它包含七个超参数。其中四个是我们设计在 MlpBlock 中的:层数、单元数、是否使用 dropout 层以及 dropout 率(mlp_block_1/dropout_rate)。其余三个是回归头中的 dropout 率、优化算法以及优化算法的学习率。

列表 5.23 打印搜索空间的摘要

>>> auto_model.tuner.search_space_summary()
Search space summary
Default search space size: 7
mlp_block_1/num_layers (Choice)
{'default': 1, 'conditions': [], 'values': [1, 2, 3], 'ordered': True}
mlp_block_1/units_0 (Int)
{'default': None, 'conditions': [], 'min_value': 32, 
➥ 'max_value': 512, 'step': 32, 'sampling': None}
mlp_block_1/dropout (Boolean)
{'default': False, 'conditions': []}
regression_head_1/dropout (Choice)
{'default': 0, 'conditions': [], 'values': [0.0, 0.25, 0.5], 
➥ 'ordered': True}
optimizer (Choice)
{'default': 'adam', 'conditions': [], 'values': ['adam', 'sgd',
➥ 'adam_weight_decay'], 'ordered': False}
learning_rate (Choice)
{'default': 0.001, 'conditions': [], 'values': [0.1, 0.01, 0.001, 
➥ 0.0001, 2e-05, 1e-05], 'ordered': True}
mlp_block_1/dropout_rate (Float)
{'default': 0.0, 'conditions': [], 'min_value': 0.0, 'max_value': 1.0, 
➥ 'step': None, 'sampling': None}

设计 MLP 块的过程可以推广到设计用于调整任何可以直接用 Keras 层构建的神经网络架构的 AutoML 块。如果你满足以下两个条件,你可能想要创建一个自定义块来调整你的架构:

  • AutoKeras 中没有内置的块可以直接用来构建你的 AutoML 管道。

  • 你知道如何通过堆叠 Keras 层来创建网络架构。

5.5.2 设计用于模型选择的超块

除了调整单个模型的超参数之外,你可能还想要实现一个用于模型选择的 AutoML 块。在本节中,你将学习如何实现自己的超块,就像我们在第四章中用于在不同模型之间进行选择的那样。因为不同的模型也可能有不同的超参数,这实际上是一个联合超参数调整和模型选择任务,需要分层搜索空间来展示每个模型及其独特超参数之间的关系。我们将首先查看一个简单的模型选择案例,其中每个模型都没有需要调整的超参数,然后学习如何处理联合超参数调整和模型选择。

在不同的 DenseNet 模型之间进行选择

DenseNet 是一种广泛使用的卷积神经网络(CNN)。它通过堆叠多个 DenseNet 单元来构建一个完整的神经网络。DenseNet 的基本单元如图 5.15 所示。在该单元中,每个卷积层的输入是同一单元内所有先前卷积层的输出张量以及单元的输入张量的拼接。张量在其最后一个维度上进行拼接。例如,一个形状为 (32, 32, 3) 的张量和一个形状为 (32, 32, 16) 的张量可以拼接成 (32, 32, 19)。可以通过应用额外的池化层来减少结果张量前两个维度的大小。堆叠不同数量的单元或不同的单元结构(例如,每个单元中卷积层的数量不同或卷积层中的过滤器数量不同)可以产生不同的 DenseNet 版本。有关 DenseNet 的更多详细信息,请参阅 Ferlitsch 的著作 深度学习设计模式

05-15

图 5.15 DenseNet 单元

由于 DenseNets 在图像相关应用中非常流行,因此有几个代表性的在 Keras Applications 中实现,这是一个 TensorFlow Keras 模块,它收集了各种广泛使用的深度学习模型。它包含实例化三种 DenseNet 版本(DenseNet121、DenseNet169 和 DenseNet201)的函数,这些版本是通过堆叠不同数量的 DenseNet 单元(每个单元包含两个卷积层)创建的。名称中的数字表示最终模型包含的层数。我们可以直接调用这些函数来使用这些模型,而无需自己逐层实现。例如,要创建一个 DenseNet121,我们可以调用函数 tf.keras.applications.DenseNet121,如图 5.24 列所示。该函数返回一个 Keras 模型。我们可以传递一个 NumPy 数组给它以查看其输出形状,或者直接调用 model.summary() 来查看模型的详细信息。例如,该模型可以接受 100 个形状为 32×32×3 的合成图像。输出是一个形状为 (100, 1, 1, 1024) 的张量(见 5.24 列)。如果我们想使网络不包含分类头,而只包含神经网络的卷积层,我们可以在参数中使用 include_top=False。默认情况下,网络层的权重使用在大数据集上预训练的权重初始化,以提高在当前数据集上的训练速度和准确性。

列表 5.24 使用 Keras Applications 中的函数构建 DenseNet 模型

import tensorflow as tf
import numpy as np

model = tf.keras.applications.DenseNet121(           ❶
    include_top=False,                               ❷
    weights=None)                                    ❸
print(model(np.random.rand(100, 32, 32, 3)).shape)   ❹

❶ 创建 DenseNet 模型的函数

❷ 此参数表示只使用网络的卷积部分,而不使用分类头。

❸ 此参数表示不使用任何预训练权重。

❹ 将合成输入馈送到创建的 DenseNet121 模型,并打印输出形状

考虑到 Keras Applications 中可用的 DenseNet 的不同版本,你可能想知道如何确定哪个版本最适合当前任务。你可以通过使用 hp.Choice() 函数实现一个自定义的 AutoML 块来选择最佳模型。在列表 5.25 中,我们实现了一个 DenseNetBlock 来在 DenseNet 模型的三个版本之间进行选择。请注意,我们不应直接将 Keras Applications 中的模型传递给 hp.Choice() 函数,因为它们是不可序列化的;我们应该始终在传递给 Choice() 的列表中使用 Python 的基本数据类型,如字符串、布尔值和数值类型。因此,我们在这里使用字符串来表示 DenseNet 模型的三个版本,并使用 if 语句来判断选择并创建模型。将输入张量输入到创建的模型中,将产生一个输出张量,该张量可以被输入到其他 AutoML 块或分类/回归头部。

列表 5.25 实现一个 DenseNet 块

import autokeras as ak
import tensorflow as tf

class DenseNet(ak.Block):
    def build(self, hp, inputs):
        version = hp.Choice(
            'version', ['DenseNet121', 'DenseNet169', 'DenseNet201'])   ❶
        if version == 'DenseNet121':
            dense_net_func = tf.keras.applications.DenseNet121
        elif version == 'DenseNet169':
            dense_net_func = tf.keras.applications.DenseNet169
        elif version == 'DenseNet201':
            dense_net_func = tf.keras.applications.DenseNet201
    return dense_net_func(include_top=False,
        weights=None)(inputs)                                           ❷

❶ 使用字符串作为模型选择的超参数值,因为函数不可序列化

❷ 获取模型并使用输入张量调用它

由于 Keras Applications 中包含的架构是固定的,我们无法直接调整它们的超参数(例如,调整 DenseNet121 的细胞结构)。但是,假设除了选择最佳模型架构之外,我们还想调整细胞结构。在这种情况下,我们将需要自己定义每个 DenseNet 模型,一层一层地定义,并指定相关超参数的搜索空间,就像我们对 MlpBlock 所做的那样。创建多个 AutoML 块是可行的,每个块定义一种 DenseNet 模型的搜索空间。在这种情况下,我们可以创建一个超块来在不同的 AutoML 块之间进行选择,并在所选块中调整超参数。下一节将展示如何创建一个超块来进行联合模型选择和超参数调整,利用现有的 AutoML 块。

在 DenseNet 和 ResNet 之间进行选择

我们创建了一个 DenseNetBlock 来在不同的 DenseNet 架构中进行搜索。我们还知道在 AutoKeras 中内置了一些块,可以用于图像分类,例如我们在 5.3 节中用于调整 ResNet 的 ResNetBlock。假设我们想要选择最佳模型,在 DenseNet 和 ResNet 架构之间进行选择。如列表 5.26 所示,我们可以利用这些现有块创建一个超块来在两种模型类型之间进行选择,类似于我们创建一个正常的 AutoML 块。每个块的名字可以作为字符串输入到 hp.Choice 函数中,以定义模型选择的搜索空间。然而,因为超块也是一个 AutoML 块,它不应该直接返回一个选定的 AutoML 块,而应该返回由 Keras 层处理过的输出张量,这些张量可以用于其他 AutoML 块。这要求我们调用每个选定的 AutoML 块的 build()函数来返回其输出。换句话说,我们的超块(SelectionBlock)应该在构建搜索空间时将创建其他块的方法作为子例程调用。这也帮助 hp 容器在搜索过程中收集每个块中定义的所有搜索空间。

列表 5.26 创建模型选择块

class SelectionBlock(ak.Block):
    def build(self, hp, inputs):
        if hp.Choice('model_type', 
➥                ['densenet', 'resnet']) == 'densenet':    ❶
            outputs = DenseNetBlock().build(hp, inputs)
        else:
            outputs = ak.ResNetBlock().build(hp, inputs)
        return outputs

❶ 定义用于模型选择的 model_type 超参数

这里使用的 model_type 超参数被称为条件超参数,这意味着在子例程中选择的超参数取决于我们选择哪个模型。例如,当 SelectionBlock 中的 model_type 超参数的值为'densenet'时,才会选择 DenseNetBlock 中的超参数。

条件超参数的一个问题是它们可能会对调整算法造成问题。如果我们没有明确地告诉调整算法有关条件超参数,它会导致搜索错误事物的冗余,并可能影响搜索性能。例如,当 model_type 的值为'reset'时,调整算法可能想要找到一个 DenseNet 版本的优化值,即使改变 DenseNet 版本也不会影响模型。为了声明这种超参数之间的关联,我们使用 hp.conditional_scope()方法通知调整算法依赖关系。任何在条件作用域下定义的超参数将在条件满足时才被视为活动状态。例如,在列表 5.27 中,hp.conditional_scope('model_type', ['densenet'])设置了条件,即 model_type 超参数的值应该是'densenet'以激活作用域。

列表 5.27 创建具有条件作用域的模型选择块

class SelectionBlock(ak.Block):
    def build(self, hp, inputs):
        if hp.Choice('model_type', ['densenet', 'resnet']) == 'densenet':
            with hp.conditional_scope('model_type', ['densenet']):        ❶
                outputs = DenseNetBlock().build(hp, inputs)               ❷
        else:
            with hp.conditional_scope('model_type', ['resnet']):
                outputs = ak.ResNetBlock().build(hp, inputs)
        return outputs

❶ 仅当模型超参数的值为'densenet'时激活作用域

❷ DenseNet 的 build 函数中的所有超参数都处于此作用域之下。

现在我们已经创建了我们的超块,我们可以构建一个完整的 AutoML 流水线,用于联合模型选择和超参数调整。在列表 5.28 中,我们将一个 ImageInput 节点传递给我们的 SelectionBlock,并通过与 ClassificationHead 连接来选择 CIFAR-10 数据集上图像分类任务搜索空间中的最佳模型。我们还可以通过调用 search_space_summary()函数打印一个摘要来查看整个搜索空间。

列表 5.28 构建模型和进行搜索

input_node = ak.ImageInput()
output_node = SelectionBlock()(input_node)
output_node = ak.ClassificationHead()(output_node)
auto_model = ak.AutoModel(input_node, output_node,
                          max_trials=5, overwrite=True)

from tensorflow.keras.datasets import cifar10

(x_train, y_train), (x_test, y_test) = cifar10.load_data()
auto_model.fit(x_train[:100], y_train[:100], epochs=1)       ❶

auto_model.tuner.search_space_summary()                       ❷

❶ 将 epoch 数设置为 1 以加快演示速度

❷ 打印出创建的 AutoML 流水线的搜索空间

现在你已经知道了如何创建自己的自定义 AutoML 块和超块,用于调整分类和回归任务上的深度学习模型,在下一章中,我们将转换方向,探讨在不连接一系列这些块的情况下定义搜索空间。这将为你提供一种更灵活的方式来设计适用于更广泛 AutoML 任务的搜索空间,例如调整无监督学习模型,调整优化算法或损失函数,以及联合选择深度学习和浅层模型。

摘要

  • AutoML 流水线可以被视为 ML 流水线的搜索空间。你可以通过堆叠四个组件来使用 AutoKeras 功能 API 创建流水线:输入节点、预处理块、网络块和输出头。

  • AutoKeras 包含多个内置的预处理块和网络块。每个预处理块代表一种特定的预处理方法及其超参数的搜索空间。每个网络块代表一种特定的模型类型,例如 MLP 或 CNN,以及模型超参数的默认搜索空间,例如 MLP 中的层数和单元数。你可以使用这些块来构建 AutoML 流水线,并通过自定义它们的搜索空间来调整相关的超参数,同时固定其他参数。

  • 超块是一种 AutoML 块,它允许在多种模型和预处理方法之间进行选择。AutoKeras 包含三个超块,用于图像、文本和结构化数据,以帮助你创建用于联合模型选择和超参数调整的 AutoML 流水线。

  • AutoML 流水线可以从顺序结构泛化到图结构,以调整具有多个输入和输出的模型或包含预处理方法或模型集成(ensemble)的流水线。你可以遵循数据流,使用 AutoKeras 功能 API 创建流水线,依次设置每个 AutoML 块,基于它们在图中的出现顺序。

  • 你可以创建一个包含你自己的模型搜索空间的自定义 AutoML 块,并将其与 AutoKeras 中的内置块连接起来。你还可以在 AutoML 块中设置一个条件搜索空间以进行模型选择。


(1.) 请参阅 Kaiming He 等人撰写的“用于图像识别的深度残差学习”,可在arxiv.org/pdf/1512.03385.pdf找到。

(2.) 请参阅 François Chollet 撰写的“Xception: 基于深度学习的深度可分离卷积”,可在arxiv.org/pdf/1610.02357.pdf找到。

(3.) 由 David Wolpert 在“学习算法之间缺乏先验区分”一文中描述,可在mng.bz/xvde找到。

(4.) 请参阅 Kaiming He 等人撰写的“深度残差网络中的恒等映射”,可在arxiv.org/abs/1603.05027找到。

6 使用完全自定义的搜索空间进行 AutoML

本章涵盖

  • 在不连接 AutoML 块的情况下自定义整个 AutoML 搜索空间

  • 调整自动编码器模型以进行无监督学习任务

  • 使用预处理管道调整浅层模型

  • 通过自定义调整器控制 AutoML 过程

  • 在深度学习和浅层模型之间进行联合调整和选择

  • 超参数调整超出 Keras 和 scikit-learn 模型

本章介绍了在不连接 AutoML 块的情况下以分层方式自定义整个 AutoML 搜索空间,这为你设计调整无监督学习模型和优化算法的搜索空间提供了更大的灵活性。我们介绍了如何使用预处理管道调整浅层模型,包括特征工程步骤。你还将学习如何控制模型训练和评估过程,以进行深度学习模型和浅层模型的联合调整和选择。这允许你使用不同 ML 库实现的不同训练和评估程序来调整模型。

6.1 以分层方式自定义搜索空间

在第五章中,你学习了如何通过指定搜索空间使用 AutoML 块进行超参数调整和模型选择。你还知道如果内置块不符合你的需求,如何创建自己的 AutoML 块。然而,你可能遇到一些难以通过连接 AutoML 块解决的场景,或者这种方法根本不是最佳选择,例如以下情况:

  • 调整超出分类和回归任务的模型——尽管这些可能是机器学习中最广泛研究的问题,但你可能会遇到这些领域之外的任务。它们甚至可能不是监督学习任务,其中存在预先存在的响应供你学习。

  • 自定义优化算法的搜索空间(例如,调整学习率或批量大小)和损失函数

除了这些场景之外,如果你需要自定义管道中的所有块,可能觉得通过连接 AutoML 块来创建 AutoML 管道是多余的。为什么不直接在一个 build()函数中创建管道,就像你使用 TensorFlow Keras 实现神经网络一样,而不是进行两步操作(将它们包装成不同的 AutoKeras AutoML 块并将它们连接起来)?

在本节中,您将了解如何以分层的方式完全自定义搜索空间,而无需连接 AutoML 块。这种方法可能需要更多的代码,但它为定制超出监督学习之外的调整任务的搜索空间提供了额外的灵活性。如果您没有合适的内置 AutoML 块可以使用,这种方法还可以减少创建和连接多个自定义块以定义搜索空间的负担。为了实现这一点,我们将使用 KerasTuner,这是一个最初为选择和调整 TensorFlow Keras 模型以及更多内容而提出的 AutoML 库。我们在前两章中使用了它的超参数模块来定制部分搜索空间,在这里我们将使用它从头开始构建搜索空间。我们将从一个用于回归任务的 MLP 模型开始调整,这涉及到使用自定义搜索空间调整优化算法和数据预处理方法。然后我们将使用 KerasTuner 来调整用于无监督学习任务的自动编码器模型。

6.1.1 使用 KerasTuner 调整 MLP 的回归

让我们先解决调整 MLP 用于回归的问题。我们将首先调整隐藏层中的单元数量,并逐步在搜索空间中添加更多超参数。

使用 KerasTuner 调整 MLP 模型的搜索空间与使用 Keras 构建一个 MLP 模型几乎相同。我们需要一个 build() 函数,它可以生成一个 MLP 模型并定义我们关心的超参数的搜索空间,例如单元数量。您已经看到如何在自定义 AutoML 块时使用 hp 容器来定义搜索空间,这里的流程是相同的。然而,由于我们在这个案例中的目的不是创建一个 AutoML 块并将其与其他块连接起来以形成搜索空间,我们应该在一个 build() 函数中创建深度学习管道的每个组件。换句话说,除了网络架构之外,我们还需要创建输入节点,设置输出回归层,选择优化算法,并编译模型以进行训练。列表 6.1 展示了如何使用 KerasTuner 实现调整单元数量的 build() 函数。除了使用 hp 容器来定义单元超参数的搜索空间之外,其余部分与构建用于回归的 Keras 模型相同,这是您在第三章中学到的。

列表 6.1 使用 KerasTuner 实现调整 MLP 单元的搜索空间

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers

def build_model(hp):                                         ❶
    input_node = keras.Input(shape=(20,))
    units = hp.Int('units', min_value=32,
                    max_value=512, step=32)                  ❷
    output_node = layers.Dense(units=units, activation='relu')(input_node)
    output_node = layers.Dense(units=units, activation='relu')(output_node)
    output_node = layers.Dense(units=1, activation='sigmoid')(output_node)
    model = keras.Model(input_node, output_node)

    optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3)
    model.compile(                                           ❸
        optimizer=optimizer,
        loss='mse',
        metrics=['mae'])
    return model                                             ❹

❶ 创建一个 build 函数,其输入是一个 hp 容器实例

❷ 定义两个隐藏层中单元数量的搜索空间

❸ 使用 Adam 优化算法和 MSE 损失编译模型,并计算模型的 MAE 指标

❹ 返回的模型是一个 Keras 模型。

在定义搜索空间之后,我们仍然需要一个搜索方法来帮助执行搜索过程。回顾第五章的内容,在创建 AutoML 管道时,我们将模块封装在 AutoModel 对象中,然后设置其 tuner 参数(或使用默认值,'greedy')来选择一个搜索方法进行调优。在这里,因为我们没有 AutoModel 对象,所以我们直接从 KerasTuner 中选择一个 tuner 来完成这个任务。一个调优器不仅指定了搜索方法,还帮助安排每个搜索试验中选定模型的训练和评估,以确保顺序搜索过程可以顺利执行。我们将在本章后面更详细地讨论这一点。调优器的名称与我们要使用的搜索方法相匹配。例如,我们可以通过选择 RandomSearch 调优器来使用随机搜索方法,如列表 6.2 所示。

在初始化过程中,我们向其提供搜索空间(build() 函数),并设置在搜索过程中想要探索的模型数量(max_trial=5)。objective 参数指定了我们要用于比较模型和优化搜索方法的指标(或损失函数)(对于随机搜索方法,没有需要优化的内容)。在这个例子中,在每个试验中,随机调优器将基于随机选择的单元值构建一个模型,训练该模型并评估它。它将根据我们设定的目标返回最佳模型,即验证集的平均绝对误差(MAE)(由 val_ 前缀表示)。如果没有指定验证集,它将随机从训练集中分割出来。我们可以通过设置 executions_per_trial 参数来指定每个模型想要训练和评估的次数,以减少评估过程中的随机性。

列表 6.2 初始化调优器

from keras_tuner import RandomSearch

tuner = RandomSearch(              ❶
    build_model,                   ❷
    objective='val_mae',           ❸
    max_trials=5,                  ❹
    executions_per_trial=3,        ❺
    directory='my_dir',            ❻
    project_name='helloworld')     ❼

❶ 初始化随机搜索调优器

❷ 传递 build_model 函数

❸ 在选择模型时设置优化目标

❹ 尝试的不同超参数值集的总数

❺ 一个超参数值集的运行次数

❻ 保存结果的目录

❼ 项目名称

在我们开始搜索过程之前,让我们使用 summarize_search_space() 方法打印搜索空间的摘要,以确保调优器已按预期接收它。

列表 6.3 打印搜索空间摘要

>>> tuner.search_space_summary()     ❶

Search space summary
Default search space size: 1
units (Int)
{'default': None, 'conditions': [], 'min_value': 32, 'max_value': 
➥ 512, 'step': 32, 'sampling': None}

❶ 打印搜索空间摘要

我们可以看到创建的搜索空间包含一个超参数(units)。它是一个整数,应该从 32 到 512 的范围内选择,步长为 32。

现在,让我们创建一个合成回归数据集,并调用调优器的 search() 方法来启动搜索过程。搜索结果在列表 6.4 中显示。search() 函数的参数控制每个模型的训练和评估,对应于 Keras 模型 fit() 方法支持的参数(tf.keras.Model.fit())。例如,在这种情况下,每个选定的 MLP 将训练一个 epoch。

列表 6.4 运行搜索

>>> import numpy as np                          ❶
>>> x_train = np.random.rand(100, 20)           ❶
>>> y_train = np.random.rand(100, 1)            ❶
>>> x_val = np.random.rand(20, 20)              ❶
>>> y_val = np.random.rand(20, 1)               ❶

>> tuner.search(x_train, y_train, epochs=1,
...     validation_data=(x_val, y_val))         ❷

Trial 5 Complete [00h 00m 02s]
val_mae: 0.2220905969540278

Best val_mae So Far: 0.2120091120402018
Total elapsed time: 00h 00m 11s
INFO:tensorflow:Oracle triggered exit

❶ 随机创建一个具有每个实例 20 个特征的合成回归数据集

❷ 运行搜索,这可能需要一段时间

我们可以使用 results_summary()函数打印五个最佳模型及其评估结果,如下一列表所示。这里的最佳模型在每个密集层中有 218 个单元。在搜索过程中,它在验证集上的 MAE 为 0.212。

列表 6.5 打印五个最佳模型及其每个模型的 MAE

>> tuner.results_summary(5)

Results summary
Results in my_dir/helloworld
Showing 10 best trials
Objective(name='val_mae', direction='min')
Trial summary
Hyperparameters:
units: 288
Score: 0.2120091120402018
Trial summary
Hyperparameters:
units: 128
Score: 0.2220905969540278
Trial summary
Hyperparameters:
units: 320
Score: 0.22237977385520935
Trial summary
Hyperparameters:
units: 256
Score: 0.22893168032169342
Trial summary
Hyperparameters:
units: 192
Score: 0.23000877102216086

在搜索之后,我们可能想要导出最佳模型以保存它供将来使用。如列表 6.6 所示,这样做很简单。导出的模型是一个 Keras 模型,我们可以通过调用 summary()函数来打印其架构。

列表 6.6 总结和导出模型

from tensorflow import keras
best_models = tuner.get_best_models(num_models=2)  ❶
best_model = best_models[0]                        ❷
best_model.save('path_to_best_model')              ❸
best_model = keras.models.load_model(
    'path_to_best_model')                          ❹
print(best_model.predict(x_val))                   ❺
best_model.summary()                                ❻

❶ 指定函数应返回两个模型的列表

❷ 从返回的列表中获取最佳模型

❸ 将模型保存到磁盘

❹ 从磁盘加载模型

❺ 使用模型进行预测

❻ 打印模型架构的摘要

联合调整优化函数

在 build()函数中自定义整个流程的一个好处是我们可以调整优化函数。因为我们需要自己编译网络以确保它可以被训练,我们可以完全控制使用哪种优化方法,以及其超参数,例如学习率。例如,我们可以通过 hp.Choice()方法从深度学习中广泛使用的两个优化器中选择,Adam 和 Adadelta。所选优化方法的学习率可以在 1e-5 和 0.1 之间采样。而且因为实际上我们经常在对数尺度上选择学习率,例如 0.1 或 0.01,我们可以将采样参数设置为'log',以将等概率分配给每个数量级范围,如下一代码列表所示。

列表 6.7 联合调整单元和优化方法

def build_model(hp):
    input_node = keras.Input(shape=(20,))
    units = hp.Int('units', min_value=32, max_value=512, step=32)
    output_node = layers.Dense(units=units, activation='relu')(input_node)
    output_node = layers.Dense(units=units, activation='relu')(output_node)
    output_node = layers.Dense(units=1, activation='sigmoid')(output_node)
    model = keras.Model(input_node, output_node)
    optimizer_name = hp.Choice('optimizer', ['adam', 'adadelta'])
    learning_rate = hp.Float('learning_rate', min_value=1e-5, max_value=0.1,
                             sampling='log')         ❶
    if optimizer_name == 'adam':
        optimizer = tf.keras.optimizers.Adam(learning_rate=learning_rate)
    else:
        optimizer = tf.keras.optimizers.Adadelta(learning_rate=learning_rate)
    model.compile(
        optimizer=optimizer,                         ❷
        loss='mse',                                  ❸
        metrics=['mae'])                             ❹
    return model                                     ❺

tuner = RandomSearch(
    build_model,
    objective='val_mae',
    max_trials=5,
    executions_per_trial=3,
    directory='my_dir',
    project_name='helloworld')

❶ 在对数尺度上进行均匀随机采样

❷ 定义优化器的搜索空间

❸ 编译模型以设置损失

❹ 我们使用的指标是 MAE。

❺ 函数应返回一个 Keras 模型。

搜索过程与之前的示例相同,这里不再重复。不出所料,我们可以以相同的方式调整损失函数,因此我们不会进一步详细说明。

调整模型训练过程

除了模型构建过程之外,在模型训练过程中,你可能还需要调整一些超参数。例如,你可能想要调整数据的批量大小以及是否对数据进行洗牌。你可以在 Keras 模型的 model.fit()方法的参数中指定这些。然而,在之前的代码示例中,我们只为构建和编译模型定义了搜索空间,而不是为训练模型定义。

要调整模型训练过程,我们可以使用 HyperModel 类,它提供了一种面向对象的方式来定义搜索空间。你可以覆盖 HyperModel.build(hp),这与前面展示的 build_model() 函数相同。你需要覆盖 HyperModel.fit() 来调整模型训练。在方法参数中,你可以访问 hp,我们刚刚构建的模型,以及传递给 Tuner.search() 的所有 **kwargs。它应该返回训练历史,这是 model.fit() 的返回值。接下来的列表是一个定义搜索空间以调整是否打乱数据集以及调整批次大小,除了其他超参数的示例。

列表 6.8 调整模型训练过程

import keras_tuner as kt

class Regressor(kt.HyperModel):

    def build(self, hp):
        input_node = keras.Input(shape=(20,))
        units = hp.Int('units', min_value=32, max_value=512, step=32)
        output_node = layers.Dense(units=units, activation='relu')(input_node)
        output_node = layers.Dense(units=units, activation='relu')(output_node)
        output_node = layers.Dense(units=1, activation='sigmoid')(output_node)
        model = keras.Model(input_node, output_node)
        optimizer_name = hp.Choice('optimizer', ['adam', 'adadelta'])
        learning_rate = hp.Float('learning_rate', min_value=1e-5, max_value=0.1,
                                 sampling='log')
        if optimizer_name == 'adam':
            optimizer = tf.keras.optimizers.Adam(learning_rate=learning_rate)
        else:
            optimizer = tf.keras.optimizers.Adadelta(learning_rate=learning_rate)
        model.compile(
            optimizer=optimizer,
            loss='mse',
            metrics=['mae'])
        return model
    def fit(self, hp, model, **kwargs):         ❶
        return model.fit(                       ❷
            batch_size=hp.Int('batch_size'),    ❸
            shuffle=hp.Boolean('shuffle'),      ❹
            **kwargs)

tuner = RandomSearch(
    build_model,
    objective='val_mae',
    max_trials=5,
    executions_per_trial=3,
    directory='my_dir',
    project_name='helloworld')

❶ 调整模型训练

❷ 返回 model.fit() 的返回值

❸ 调整批次大小

❹ 调整是否打乱数据集

注意,我们没有在 fit() 中调整训练轮数(模型将在最佳轮次保存,以目标值为准,这里是指 'val_mae')。

到目前为止,我们已经学习了如何在模型构建、编译和拟合过程中定义和调整超参数。

调整数据预处理方法

有时你可能想在工作流程中包含一些预处理步骤——例如,在将特征输入神经网络之前对特征进行归一化。在本节中,我们首先将看到如何轻松地将预处理步骤包含到你的模型中。然后,我们将看到如何调整涉及这些步骤的超参数。

与你在第三章中学到的创建归一化函数的方式不同,你可以使用 Keras 预处理层来完成这个任务。例如,你可以在密集层之前堆叠一个归一化层,如列表 6.9 所示。请注意,这里的预处理层有特殊处理:你需要在整个数据集上调用一个层适配函数(layer.adapt())。神经网络是在数据批次上训练和评估的,但预处理方法通常需要从整个数据集中获取一些统计信息,例如特征的均值和方差,以预处理批次。调用适配函数将帮助预处理层收集这些统计信息。

列表 6.9 使用 Keras 归一化预处理层

from tensorflow.keras.layers.experimental.preprocessing import Normalization

layer = Normalization(input_shape=(20,))     ❶
layer.adapt(x_train)                         ❷

model = tf.keras.Sequential([layer, tf.keras.layers.Dense(1)])
model.compile(optimizer='adam', loss='mse')
model.fit(x_train, y_train)

❶ 初始化归一化层

❷ 将归一化层适配到数据以获取均值和方差

我们有多种使用预处理层的方式。在之前的例子中,我们将其放入 Sequential 模型中。你也可以将其作为一个独立的步骤使用。你可以用它来预处理 NumPy 数组。对于 NumPy 数组,你只需调用层即可获取处理后的数据,如下面的代码示例所示:

normalized_x_train = layer(x_train)

然而,你的数据可能并不总是以 NumPy 数组的格式存在。它可能是 tf.data.Dataset 的格式,这是一种更通用的格式。它可以由一个小 NumPy 数组或甚至是从本地或远程存储流式传输的大数据集创建。下面的代码示例展示了如何将 NumPy 数组转换为 tf.data.Dataset,并使用我们通过调用 Dataset.map(layer)创建的层进行归一化。

列表 6.10 归一化 tf.data.Dataset

dataset_x_train = tf.data.Dataset.from_tensor_slices(x_train).batch(32)
normalized_dataset = dataset_x_train.map(layer)

现在我们已经学习了如何使用预处理层进行数据预处理。让我们看看我们是否可以有一个布尔超参数来调整是否使用这个归一化步骤。

由于数据预处理需要访问数据集,我们将在 HyperModel.fit()中调整这一步,该函数具有从 Tuner.search()传递的参数中的数据集,如下所示。这次,我们不会将这些有用的参数留在**kwargs 中,而是会明确地将 x 和 y 放入方法签名中。

列表 6.11 在搜索空间中使用预处理层

from keras_tuner import HyperModel

class Regressor(HyperModel):

    def build(self, hp):
          model = tf.keras.Sequential()
          model.add(tf.keras.layers.Dense(1))
          model.compile(optimizer='adam', loss='mse')
          return model

      def fit(self, hp, model, x, y, **kwargs):
          if hp.Boolean('normalize'):                   ❶
              layer = Normalization(input_shape=(20,))
              layer.adapt(x)
              x = layer(x)                              ❷
          return model.fit(x=x, y=y, **kwargs)

❶ 指定是否使用归一化层

❷ 用归一化后的数据替换 x

在实现搜索空间后,我们可以将其输入到一个调优器中,就像我们对 build_model()函数所做的那样,以搜索一个好的模型,如下所示。

列表 6.12 在具有预处理层的空间中进行搜索

tuner = RandomSearch(hypermodel,
                     objective='val_loss', max_trials=2)       ❶
tuner.search(x_train, y_train, validation_data=(x_val, y_val))

❶ 将类实例传递给调优器

在完成这个示例后,你可能想知道为什么,因为层状搜索空间设计可以解决块状搜索空间设计可以解决的问题,甚至具有更多的灵活性,我们可能还想通过连接 AutoML 块来构建 AutoML 管道。原因是,与层状搜索空间设计相比,使用 AutoKeras 的内置块可以使搜索空间创建不那么费力(特别是如果你不擅长以层状方式创建搜索空间)。实现难度和灵活性之间的权衡将决定哪种搜索空间创建方法最适合你和你手头的任务。

6.1.2 调整自动编码器模型以进行无监督学习

我们迄今为止所做的一切示例都是监督学习任务,但机器学习应用并不全是关于预测预存在的响应。与监督学习相反,一种称为无监督学习的机器学习范式旨在在没有人类监督或参与的情况下,在特征中找到隐藏或未检测到的模式。一个典型的无监督学习任务是降维。目标是学习数据实例的压缩表示(例如,具有少量元素的向量),以去除数据中的噪声或不重要信息。

自动编码器是一种经典的神经网络类型,用于降维。它通常应用于图像,以学习每个图像的低维向量表示。自动编码器的架构如图 6.1 所示。

06-01

图 6.1 自动编码器的架构

如您所见,自动编码器的输入和输出都是图像。网络由两部分组成,即 编码器解码器。编码器用于将图像压缩到低维向量表示,而解码器则试图根据编码向量重建原始图像。编码器和解码器通常具有不对称的结构。它们都可以使用经典网络,例如 MLP 或 CNN。例如,如果每个输入实例是一个长度为 64 的向量,一个简单的编码器可以是一个单层 MLP,每个密集层有 32 个单元。解码器也可以是一个单层 MLP,密集层有 64 个单元,以解码每个输入到原始大小。

在这个任务中,因为我们没有预先存在的响应来指导学习过程,所以我们使用原始图像本身作为响应。在训练过程中,重建图像与原始图像之间的差异将是损失指标,以帮助更新网络的权重。我们可以使用回归损失函数,如均方误差(MSE)来衡量差异。

在回归任务中,均方误差(MSE)衡量预测值和真实值之间的差异。在这个图像重建任务中,预测值是重建的图像,而真实值是原始图像。如果神经网络能够成功重建图像,这意味着图像的所有重要信息都存储在向量表示中。

学习到的向量表示有许多潜在用途:例如,它们可以用来可视化图像在 2-D 或 3-D 空间中的分布,或者我们可以使用它们来重建图像,以从原始版本中去除噪声。

使用 Keras 实现自动编码器

让我们通过一个例子来展示自动编码器是如何工作的。然后我们将探讨如何创建搜索空间来调整它。在这里,我们将要完成的工作是学习一个能够将图像压缩到低维向量并随后重建图像的自动编码器。

我们将使用一个名为 Fashion MNIST 的基准深度学习数据集,该数据集包含训练数据集中的 60,000 张图像和测试数据集中的 10,000 张图像。所有图像都是灰度的,大小为 28×28,图像内容都是服装。我们可以通过 Keras 数据集 API 加载数据,并将图像的像素值归一化到 0 到 1 的范围,以便于网络优化,如下面的列表所示。

列表 6.13 加载 Fashion MNIST 数据

from tensorflow.keras.datasets import fashion_mnist

(x_train, _), (x_test, _) = fashion_mnist.load_data()

x_train = x_train.astype('float32') / 255\.   ❶
x_test = x_test.astype('float32') / 255\.     ❶

❶ 将图像中的值归一化到 [0, 1] 范围内

你应该已经知道如何单独实现编码器和解码器,因为它们都是常规网络(CNN 或 MLP)。问题是如何将两个网络组合起来并编译以进行训练和预测。实现此类复合模型的一种常见方法是重写 tf.keras.Model 类。当我们子类化 Model 类时,我们应该在 init() 函数中定义所有自动编码器层,以便在初始化自动编码器实例时创建它们。下一步是实现名为 call 的函数中的正向传播。这个函数将接受一批输入图像并输出重构的图像。我们通常希望单独调用编码器以提取它提供的表示,因此单独实现编码器和解码器网络的前向传播是一个好主意。这也是我们为什么要通过子类化 Model 类来实现自动编码器,而不是将层分组在 tf.keras.Model 或 tf.keras.Sequential 对象中的原因。

列表 6.14 展示了我们在本例中使用的自动编码器的实现。编码器和解码器网络都是单层 MLP 网络,其单元数量由可自定义的超参数 latent_dim 指定。latent_dim 超参数还表示编码表示向量的长度。在正向传播过程中,每个图像将被展平成一个长度为 784 的向量,然后编码成一个长度为 latent_dim 的表示向量。解码器网络将表示解码为一个大小为 784 的向量,并将图像重塑为原始大小(28×28)。

列表 6.14 通过子类化 Keras Model 类实现自动编码器

import tensorflow as tf
from tensorflow.keras.models import Model
from tensorflow.keras import layers, losses

class AutoencoderModel(Model):                                       ❶
    def __init__(self, latent_dim):                                  ❷
        super().__init__()
        self.latent_dim = latent_dim
        self.encoder_layer = layers.Dense(latent_dim,
                                          activation='relu')         ❸
        self.decoder_layer = layers.Dense(784, activation='sigmoid')

    def encode(self, encoder_input):
        encoder_output = layers.Flatten()(encoder_input)             ❹
        encoder_output = self.encoder_layer(encoder_output)          ❺
        return encoder_output

    def decode(self, decoder_input):
        decoder_output = decoder_input
        decoder_output = self.decoder_layer(decoder_output)          ❻
        decoder_output = layers.Reshape
➥ (28, 28))(decoder_output)                                         ❼
        return decoder_output

    def call(self, x):
        return self.decode(self.encode(x))                           ❽

❶ 重写 Model 类

❷ 初始化器应创建所有层实例。

❸ 编码层应输出表示向量。

❹ 将 28×28 图像展平成一个长度为 784 的向量。

❺ 使用全连接层对图像进行编码

❻ 使用全连接层解码表示向量

❷ 将图像重塑回 28×28

❽ call 函数定义了神经网络应首先编码输入,然后解码它。

接下来,我们将创建一个自动编码器模型,将图像编码为长度为 64 的向量。模型编译、训练和拟合过程与 MLP 的回归相同。唯一的区别是我们使用图像作为特征和响应,如这里所示。

列表 6.15 使用 Fashion MNIST 对自动编码器模型进行拟合

tf.random.set_seed(5)                               ❶
np.random.seed(5)                                   ❷
autoencoder = AutoencoderModel(64)                  ❸
autoencoder.compile(optimizer='adam', loss='mse')   ❹
autoencoder.fit(x_train, x_train,
                epochs=10,
                shuffle=True,
                validation_data=(x_test, x_test))   ❹

autoencoder.evaluate(x_test, x_test)                ❺

❶ 设置 TensorFlow 随机种子

❷ 设置 NumPy 随机种子

❸ 创建自动编码器

❹ 编译和拟合自动编码器——图像既作为特征也作为目标响应。

❺ 在测试图像上评估自动编码器

自动编码器训练完成后,我们可以通过调用我们实现的 encode() 函数来使用它将图像编码为向量。如以下列表所示,测试图像的编码向量长度为 64,正如预期的那样。

列表 6.16 使用自动编码器编码图像

>>> autoencoder.encode(x_test[:1])
<tf.Tensor: shape=(1, 64), dtype=float32, numpy=
array([[3.931118  , 1.0182608 , 6.5596466 , 2.8951719 , 1.5840771 ,
        2.3559608 , 2.0955124 , 4.485343  , 1.34939   , 3.600976  ,
        3.5480025 , 1.0803885 , 3.5926101 , 2.34089   , 0\.        ,
        1.3521026 , 1.5423647 , 3.7132359 , 2.2019305 , 1.3938735 ,
        0.9601332 , 2.3903034 , 1.4392244 , 2.155833  , 4.196291  ,
        3.8109841 , 3.2413573 , 1.1022317 , 2.7478027 , 0\.        ,
        6.3407483 , 2.5890563 , 1.905628  , 0.61499554, 1.7429417 ,
        0.59232974, 2.5122235 , 1.4705787 , 1.5797877 , 2.3179786 ,
        0.19336838, 1.6040547 , 1.8269951 , 2.1929228 , 3.5982947 ,
        2.1040354 , 3.4453387 , 3.405629  , 3.6934092 , 2.5358922 ,
        2.8133378 , 4.46262   , 2.0303524 , 3.7909238 , 2.4032137 ,
        2.2115898 , 2.5821419 , 1.4490023 , 2.3869803 , 0\.        ,
        3.246771  , 1.1970178 , 0.5150778 , 0.7152041 ]], dtype=float32)>

我们还可以使用以下代码可视化原始和重建的图像。

列表 6.17 可视化重建的图像

import matplotlib.pyplot as plt

def show_images(model, images):
    encoded_imgs = model.encode(images).numpy()        ❶
    decoded_imgs = model.decode(encoded_imgs).numpy()  ❶

    n = 10                                             ❷
    plt.figure(figsize=(20, 4))                        ❷
    for i in range(n):
        ax = plt.subplot(2, n, i + 1)                  ❸
        plt.imshow(images[i])                          ❸
        plt.title('original')                          ❸
        plt.gray()
        ax.get_xaxis().set_visible(False)
        ax.get_yaxis().set_visible(False)

        ax = plt.subplot(2, n, i + 1 + n)              ❹
        plt.imshow(decoded_imgs[i])                    ❹
        plt.title('reconstructed')                     ❹
        plt.gray()
        ax.get_xaxis().set_visible(False)
        ax.get_yaxis().set_visible(False)
    plt.show()

show_images(autoencoder, x_test)

❶ 获取编码和解码的测试图像——我们也可以调用 call 函数使其成为一个单步过程。

❷ 控制显示图像的数量和大小

❸ 在第一行显示前 10 个原始图像

❹ 在第二行显示前 10 个重建图像

如图 6.2 所示,重建的图像与原始图像非常接近,除了某些轻微的模糊和一些缺失的细节(例如,衬衫上的图案看起来模糊)。这意味着 64 维度的表示几乎保留了我们需要重建原始图像的所有信息。我们可以使用这个自动编码器来压缩大型图像数据集以节省内存。

06-02

图 6.2 自动编码器重建的图像

下一步是创建一个搜索空间,在 KerasTuner 的帮助下微调自动编码器。

6.2 调整自动编码器模型

你已经知道如何通过创建一个模型构建函数以分层方式定义 MLP 的搜索空间。因为自动编码器模型是在类中创建的,并且层是在类的 init() 函数中初始化的,所以我们可以直接在初始化时设置这些层的搜索空间。为了创建搜索空间,我们将 hp 容器的一个实例传递给 init() 函数。它的方法可以用来定义搜索空间。例如,我们可以使用 hp.Int() 来定义编码器(或解码器)中层的数量选择空间,并使用 hp.Choice() 来调整每层中的单元数量,如以下列表所示。因为在我们创建自动编码器之前层的数量是不确定的,所以在实现编码和解码网络的正向传递时,我们应该遍历所有层。

列表 6.18 建立一个类来定义自动编码器模型的搜索空间

import keras_tuner
from tensorflow import keras
from keras_tuner import RandomSearch

class AutoencoderBlock(keras.Model):
    def __init__(self, latent_dim, hp):                    ❶
        super().__init__()
        self.latent_dim = latent_dim
        self.encoder_layers = []                           ❷
        for i in range(hp.Int('encoder_layers',            ❸
                              min_value=0,
                              max_value=2,
                              step=1,
                              default=0)):
        self.encoder_layers.append(
            layers.Dense(units=hp.Choice(
                'encoder_layers_{i}'.format(i=i),          ❹
                [64, 128, 256]),
                activation='relu'))
        self.encoder_layers.append(layers.Dense(latent_dim, activation='relu'))
        self.decoder_layers = []                           ❺
        for i in range(hp.Int('decoder_layers',            ❻
                              min_value=0,
                              max_value=2,
                              step=1,
                              default=0)):
        self.decoder_layers.append(
            layers.Dense(units=hp.Choice(
                'decoder_layers_{i}'.format(i=i),          ❼
                [64, 128, 256]),
                activation='relu'))
        self.decoder_layers.append(layers.Dense(784, activation='sigmoid'))

    def encode(self, encoder_input):
        encoder_output = layers.Flatten()(encoder_input)
        for layer in self.encoder_layers:                  ❽
            encoder_output = layer(encoder_output)
        return encoder_output

    def decode(self, decoder_input):
        decoder_output = decoder_input
        for layer in self.decoder_layers:                  ❾
            decoder_output = layer(decoder_output)
        decoder_output = layers.Reshape((28, 28))(decoder_output)
        return decoder_output

    def call(self, x):
        return self.decode(self.encode(x))

❶ 将 hp 添加到初始化器的参数中

❷ 编码层的列表

❸ 使用 hp 来决定编码层的数量

❹ 使用 hp 来选择每个编码层的单元数量

❺ 解码层的列表

❻ 使用 hp 来决定解码层的数量

❼ 使用 hp 来选择每个解码层的单元数量

❽ 通过编码层进行正向循环

❾ 通过解码层进行正向循环

为了遵循实现 Keras 模型的最佳实践,我们再次应该在 init() 函数中初始化所有层,并在 call() 函数中使用它们。使用 hp 容器,我们可以获取构建模型所需的所有超参数的值并将它们记录下来。

下一步遵循 KerasTuner 的正常使用方法。在列表 6.19 中,我们创建了一个 build_model()函数来返回我们定义的自动编码器模型,并将其输入到一个初始化的调优器中,以继续搜索过程。值得注意的是,自动编码器的初始化需要一个额外的输入(一个 hp 容器实例)。我们还可以与相同的 hp 容器一起调整优化函数,就像我们在 MLP 示例中所做的那样。

列表 6.19 运行自动编码器的搜索

def build_model(hp):
    latent_dim = 20
    autoencoder = AutoencoderBlock(latent_dim, hp)      ❶
    autoencoder.compile(optimizer='adam', loss='mse')   ❷
    return autoencoder                                  ❸

tuner = RandomSearch(
    build_model,
    objective='val_loss',
    max_trials=10,
    overwrite=True,                                     ❹
    directory='my_dir',                                 ❺
    project_name='helloworld')

tuner.search(x_train, x_train,
             epochs=10,
             validation_data=(x_test, x_test))          ❻

❶ 初始化模型并传入 hp 实例

❷ 编译模型

❸ 返回模型

❹ 在开始之前清除工作目录以移除任何先前结果

❺ 工作目录

❻ 验证数据是评估模型所必需的。

调优器执行 10 次试验,每次训练一个自动编码器 10 个 epoch。我们可以选择最佳模型,并使用以下代码可视化前 10 个重建图像。

列表 6.20 评估结果

autoencoder = tuner.get_best_models(num_models=1)[0]
tuner.results_summary(1)
autoencoder.evaluate(x_test, x_test)

show_images(autoencoder, x_test)

通过调整自动编码器中的超参数,我们已经使一些重建图像在视觉上更加清晰,例如第九张图片中显示的翻转(见图 6.3)。

06-03

图 6.3 调优后的自动编码器重建图像

6.3 使用不同搜索方法调整浅层模型

在第二章中,你学习了如何使用网格搜索调整传统(或浅层)机器学习模型。附录 B 还涵盖了在机器学习管道中调整多个组件的几个更复杂的示例。所有这些示例都利用了 scikit-learn 库中内置的调优器(GridSearchCV)。现在你已经看到了 KerasTuner 在选择和调整深度学习模型方面的强大功能,你可能想知道是否也可以使用 KerasTuner 调整浅层模型。与使用 GridSearchCV 相比,使用 KerasTuner 进行此操作有以下两个关键优势:

  • 它为你提供了一个更直接的方式来执行模型选择,无需分别调整每个模型并手动比较。借助由超参数类定义的条件范围,你可以以与深度学习模型相同的方式选择不同的浅层模型。我们将在下一个示例中介绍如何做到这一点。

  • 它为你提供了更多可供选择的搜索方法。KerasTuner 包含几种高级搜索方法。选择不同的方法可能会导致不同的调优结果,正如你将在下一个示例中看到的那样。

实际上,从深度学习模型和浅层模型池中选择也是可行的。然而,由于深度学习模型通常以与浅层模型不同的方式创建和训练(部分原因是由于各种库在实现上的差异,考虑到它们不同的模型架构),在搜索过程中对它们的训练和评估需要不同的处理。您将在下一节中学习如何创建自己的调谐器,并使用个性化的训练和评估策略来适应更广泛的搜索空间。现在,我们将从一个示例开始,展示我们如何在不同浅层模型之间进行选择。

6.3.1 选择和调整浅层模型

在本节中,我们将处理一个图像分类问题,使用 scikit-learn 库附带的手写数字数据集。该数据集包含 1797 个 0 到 9 的手写数字的 8×8 灰度图像。我们可以使用 scikit-learn 库中的内置函数 load_digits()加载该数据集,并将其 20%的数据分割出来作为测试集,如下所示。

列表 6.21 加载数字数据

from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

digits = load_digits()                                    ❶

images, labels = digits.images, digits.target             ❷

X = images.reshape((n_samples, -1))                       ❸

X_train, X_test, y_train, y_test = train_test_split(
    X, labels, test_size=0.2, shuffle=False)              ❹

❶ 加载数字数据集

❷ 分别存储图像及其相应的目标数字

❸ 将图像重塑为向量

❹ 将 20%的数据分割出来作为最终的测试集

在附录 B 中,我们介绍了如何创建一个 SVM 模型来分类图像,并使用网格搜索调整其超参数。您还可以尝试一些不同的浅层分类模型,如决策树,并从中选择最佳模型。类似于在第 6.1.2 节中介绍的深度学习模型的选择,您可以使用 KerasTuner 通过为模型类型设置条件超参数来在不同浅层模型之间进行选择。在列表 6.22 中,我们创建了一个搜索空间,用于在 SVM 和随机森林(如果您不熟悉随机森林模型,请参阅附录 B)之间进行选择。模型选择是通过一个名为'model_type'的超参数来完成的。在搜索过程的每次试验中,通过选择特定的'model_type',例如 svm,将搜索空间缩小到所选模型的条件范围,并创建相应的模型。模型选择可以与每个模型的超参数调整一起进行。

列表 6.22 创建浅层模型选择的搜索空间

from sklearn.svm import SVC
from sklearn.ensemble import RandomForestClassifier
from keras_tuner.engine import hyperparameters as hp

def build_model(hp):
    model_type = hp.Choice('model_type',
                           ['svm', 'random_forest'])     ❶
    if model_type == 'svm':
        with hp.conditional_scope('model_type', 'svm'):  ❷
            model = SVC(
                C=hp.Float('C', 1e-3, 10, sampling='linear', default=1), 
                kernel=hp.Choice('kernel_type',
                                 ['linear', 'rbf'], 
                                 default='linear'), 
                random_state=42)
    elif model_type == 'random_forest':
        with hp.conditional_scope('model_type',
                                  'random_forest'):      ❸
            model =  RandomForestClassifier(
                n_estimators=hp.Int('n_estimators', 10, 200, step=10),
                max_depth=hp.Int('max_depth', 3, 10))
    else:
        raise ValueError('Unrecognized model_type')
    return model

❶ 选择分类器类型

❷ 如果选择,调整 SVM 分类器

❸ 如果选择,调整随机森林分类器

正如我们在上一节中所做的那样,我们使用随机搜索进行模型选择和超参数调优。然而,这里的一个重要区别是,我们并没有直接使用 RandomSearch 类,而是使用了一个名为 SklearnTuner 的特定调优器类,该类专门用于调优 scikit-learn 模型。原因是 KerasTuner 中的调优器在搜索过程中控制实例化模型的训练和评估。由于 scikit-learn 模型的训练和测试方式与使用 TensorFlow Keras 实现的深度学习模型不同,我们可以使用不同的调优器来适应模型训练中的差异。我们将在下一节中介绍如何构建一个通用的调优器,它能够处理这两种情况。

尽管模型训练和评估存在差异,但选择超参数的方法适用于所有情况。在 KerasTuner 中,这被称为Oracle。它将决定在每个试验中尝试的超参数,并在需要时将先前选择超参数的评估结果作为其更新的输入(见图 6.4)。由于调优器仅触及超参数和评估结果,不同模型(深度或浅层)的评估过程差异不会影响它。

06-04

图 6.4 KerasTuner 中调优器及其 Oracle 的结构

要使用随机搜索方法来调优使用 scikit-learn 库创建的模型,我们可以创建一个 SklearnTuner,并将其 Oracle(搜索方法)设置为 RandomSearch(参见列表 6.23)。我们可以设置在搜索过程中想要执行的最大试验次数,并使用 kt.Objective 设置搜索方法的目标准则,该准则用于比较不同的模型。我们在这里使用的评分目标代表每个模型的评估准确度分数。max 参数意味着分数越大,模型越好。如果目标是 MSE,我们将使用'min',因为在那种情况下,分数越小越好。调优器的评估策略由 scikit-learn 中的 KFold 模块定义,设置为三折评估。

列表 6.23 使用随机搜索选择和调优 scikit-learn 模型

from sklearn.model_selection import KFold
import sklearn.pipeline

random_tuner = kt.tuners.SklearnTuner(            ❶
    oracle=kt.oracles.RandomSearch(               ❷
        objective=kt.Objective('score', 'max'),
        max_trials=30,
        seed=42),

    hypermodel=build_model,                       ❸
    cv=KFold(3, shuffle=True, random_state=42),   ❹
    overwrite=True,                               ❺
    project_name='random_tuner')                  ❻

random_tuner.search(X_train, y_train)             ❼

❶ 创建一个用于调优 scikit-learn 模型的调优器

❷ 选择随机搜索方法并指定其参数

❸ 将搜索空间传递给调优器

❹ 使用三折交叉验证评估每个模型

❺ 如果存在,则覆盖先前的项目

❻ 将此调优项目命名为 'random_tuner'

❼ 通过向调优器提供训练数据来执行搜索过程

现在,让我们获取最佳发现的模型,并在测试数据上对其进行评估,如下所示。

列表 6.24 查看使用随机搜索的结果

>>> random_tuner.results_summary(1)                                       ❶
Results summary
Results in ./random_tuner
Showing 1 best trials
Objective(name='score', direction='max')
Trial summary
Hyperparameters:
model_type: svm
C: 2.242630562998417
kernel_type: rbf
Score: 0.9693806541405707

>>> from sklearn.metrics import accuracy_score
>>> best_model = random_tuner.get_best_models(1)[0]                       ❷
>>> best_model.fit(X_train, y_train)                                      ❸
>>> y_pred_test = best_model.predict(X_test)                              ❹
>>> test_acc = accuracy_score(y_test, y_pred_test)                        ❹
>>> print(f'Prediction accuracy on test set: {test_acc * 100:.2f}. %')    ❹

Prediction accuracy on test set: 95.83 %

❶ 显示随机搜索结果

❷ 获取最佳模型

❸ 在整个训练数据集上重新训练最佳模型

❹ 评估最佳发现的模型

在 30 次试验中找到的最佳模型是一个使用 RBF 核和正则化参数C = 2.24 的 SVM 模型。通过在全部训练集上重新训练最佳模型,我们达到了最终的测试准确率 95.83%。

6.3.2 调整浅层模型管道

您可能在管道中有多组件,并希望联合选择和调整它们。例如,假设您想创建一个包含两个组件的管道:一个用于降低图像维度的 PCA 组件和一个用于分类预处理图像的 SVM 分类器。这可以通过堆叠组件形成顺序的 scikit-learn 管道来完成(参见附录 B)。然后您可以选择一个模型,同时调整管道中的其他组件,如下列 6.25 所示。

列表 6.25 选择和调整 scikit-learn 管道

from keras_tuner.engine import hyperparameters as hp
from sklearn.decomposition import PCA
from sklearn.svm import SVC
from sklearn.ensemble import RandomForestClassifier
from sklearn.pipeline import Pipeline

def build_pipeline(hp):

    n_components=hp.Choice('n_components', [2, 5, 10], default=5)  ❶
    pca = PCA(n_components=n_components)                           ❶

    model_type = hp.Choice('model_type',
                           ['svm', 'random_forest'])               ❷
    if model_type == 'svm':
        with hp.conditional_scope('model_type', 'svm'):
            model = SVC(
                C=hp.Float('C', 1e-3, 10, sampling='linear', default=1),
                kernel=hp.Choice('kernel_type', 
                                 ['linear', 'rbf'], 
                                 default='linear'),
                random_state=42)
    elif model_type == 'random_forest':
        with hp.conditional_scope('model_type', 'random_forest'):
            model = RandomForestClassifier(
                n_estimators=hp.Int('n_estimators', 10, 200, step=10),
                max_depth=hp.Int('max_depth', 3, 10))
    else:
        raise ValueError('Unrecognized model_type')

    pipeline = Pipeline([                                          ❸
        ('pca', pca),
        ('clf', model)
    ])

    return pipeline

tuner = kt.tuners.SklearnTuner(                                    ❹
        oracle=kt.oracles.RandomSearch(
            objective=kt.Objective('score', 'max'),
            max_trials=30),
        hypermodel=build_pipeline,
        overwrite=True)

tuner.search(X_train, y_train)                                     ❹

❶ 选择 PCA 的超参数

❷ 选择模型类型

❸ 使用选定的超参数实例化 scikit-learn 管道

❹ 使用随机搜索方法进行搜索

我们以与列表 6.24 相同的方式评估管道并检索最佳管道,因此我们在此不再详细说明。

6.3.3 尝试不同的搜索方法

如本节开头所述,使用 KerasTuner 进行调优的一个关键好处是,它使您能够轻松地在(或实现)不同的搜索方法之间切换。这可以通过更改到您偏好的 oracle 来实现。例如,我们可以将随机搜索方法更改为一些更高级的方法,例如贝叶斯优化方法,如下面的列表所示。如果您对这个方法不熟悉,现在不用担心;我们将在第七章中更多地讨论它。

列表 6.26 使用贝叶斯优化调整 scikit-learn 模型

bo_tuner = kt.tuners.SklearnTuner(
    oracle=kt.oracles.BayesianOptimization(     ❶
        objective=kt.Objective('score', 'max'),
        max_trials=30,
        seed=42),
    hypermodel=build_model,
    cv=KFold(3, shuffle=True, random_state=42),
    overwrite=True,
    project_name='bo_tuner')

bo_tuner.search(X_train, y_train)               ❷

❶ 将 oracle 设置为 BayesianOptimization

❷ 使用贝叶斯优化方法进行搜索

不同的搜索方法通常适合不同的搜索空间。例如,贝叶斯优化方法通常更适合搜索具有连续值的超参数。在实践中,您可以尝试不同的方法,并选择最佳发现的模型。

6.3.4 自动特征工程

在本节中,我们将介绍如何进行自动特征工程。在介绍自动特征工程之前,我们首先将了解什么是特征工程。它是机器学习中的一个重要步骤,可能会提高模型的表现,并且对于结构化数据特别有效。

例如,我们可能有一个结构化数据集,它是一个包含多个列作为特征和一列作为预测目标的表格。在直接将这些特征输入到机器学习模型之前,我们可以进行特征工程,这包括特征生成特征选择。我们可以根据现有的特征列创建更多的特征列,这被称为特征生成。我们也可以删除一些无用的特征,这被称为特征选择。

为了展示特征工程是如何工作的,我们再次使用第四章中使用的泰坦尼克号数据集。数据集的特征是泰坦尼克号乘客的档案,预测目标是乘客是否在事故中幸存。使用以下代码下载数据集。

列表 6.27 下载泰坦尼克号数据集

import tensorflow as tf

TRAIN_DATA_URL = 'https:/ /storage.googleapis.com/tf-datasets/titanic/train.csv'
TEST_DATA_URL = 'https:/ /storage.googleapis.com/tf-datasets/titanic/eval.csv'
train_file_path = tf.keras.utils.get_file(
    'train.csv', TRAIN_DATA_URL)
test_file_path = tf.keras.utils.get_file(
    'eval.csv', TEST_DATA_URL)

下载 CSV 文件后,我们可以使用 read_csv()函数将它们加载到 pandas DataFrame 中,如下所示。我们将从 DataFrame 中弹出目标列以单独使用。

列表 6.28 使用 Pandas 加载下载的 CSV 文件

import pandas as pd

x_train = pd.read_csv(train_file_path)   ❶
y_train = x_train.pop('survived')        ❷
y_train = pd.DataFrame(y_train)          ❸

x_test = pd.read_csv(test_file_path)
y_test = x_test.pop('survived')

x_train.head()                           ❹

❶ 将 CSV 文件加载到 pandas DataFrame 中

❷ 弹出目标列作为 y_train

❸ 将弹出的列从 Series 转换为 DataFrame

❹ 打印数据的前几行

训练数据的打印内容如图 6.5 所示。

06-05

图 6.5 泰坦尼克号数据集的前几行

如您所见,一些特征是分类的,而其他特征是数值的。我们需要将它们放入不同的组,并为它们使用不同的编码方法。

我们将年龄和乘客支付的船票费用设置为数值数据。我们将用它们的中位数替换缺失值或 NaN 值。然后,我们将它们归一化到 0 到 1 的范围内。

我们将兄弟姐妹和配偶的数量以及乘客的等级作为分类特征。因为它们没有很多不同的类别,我们将使用独热编码来编码它们。独热编码通常不用于具有太多不同值的分类特征,因为这会在编码后创建太多的列。

其余的特征,如乘客的性别和是否在甲板上,也是分类特征,但我们将对它们使用序数编码,将不同的字符串值编码为不同的整数值。

对于这两种类型的分类特征,在编码之前,我们还需要用常数值替换缺失值。我们只是为了方便使用“None”字符串。

在这里,我们使用 sklearn.pipeline.Pipeline 为每种类型的列构建一个用于这些转换的管道,其代码如下所示。

列表 6.29 构建用于清理和编码列的管道

from sklearn.preprocessing import OrdinalEncoder, OneHotEncoder, StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline

numerical_columns = ['age', 'fare']                            ❶
one_hot_columns = ['n_siblings_spouses', 'class']              ❶
int_columns = [                                                ❶
    'sex', 'parch', 'deck', 'embark_town', 'alone']            ❶

numerical_transformer = Pipeline(steps=[                       ❷
    ('imputer', SimpleImputer(strategy='median')),             ❸
    ('normalizer', StandardScaler())                           ❹
])

one_hot_transformer = Pipeline(steps=[                         ❺
    ('imputer', SimpleImputer(
        strategy='constant', fill_value='None')),              ❻
    ('one_hot_encoder', OneHotEncoder(
        handle_unknown='ignore'))                              ❼
])

int_transformer = Pipeline(steps=[                             ❽
    ('imputer', SimpleImputer(
        strategy='constant', fill_value='None')),              ❾
    ('label_encoder', OrdinalEncoder(
        handle_unknown='use_encoded_value', unknown_value=-1)) ❿
])

❶ 不同类型列的名称列表

❷ 数字列的管道

❸ 用中位数替换缺失值

❹ 将值缩放到 0 到 1 的范围内

❺ 独热编码列的管道

❻ 用'None'替换缺失值

❼ 对列进行独热编码。使用 handle_unknow='ignore'时,在推理过程中如果遇到任何未知值,它不会引发错误,其编码将为全零。

❽ 原序编码列的管道

❾ 用'None'替换缺失值

❿ 将值编码为整数。对于未知值,它将使用-1。

到目前为止,我们已经完成了数据的清理。在这些管道之后,所有列都是数值型的,无论是浮点数还是整数。下一步是特征生成。

我们将要介绍的第一种特征生成技术是组合不同的分类列。你可以将其视为简单地连接两个选定分类列的字符串。连接的字符串是新列的值。这种技术可能有助于机器学习模型发现两个选定列之间的某些相关性。

例如,对于列表 6.30 中显示的表格,表格的第一列只包含 A 和 B 的值,而表格的第二列只包含 0 和 1 的值。使用前面描述的技术生成的新列将包含四个不同的值:A0、A1、B0、B1。我们可以使用序数编码器将它们编码为 0、1、2、3。

列表 6.30 通过组合现有列生成新列

A  1  A1  1
A  0  A0  0
A  1  A1  1
B  0  B0  2
B  1  B1  3
B  1  B1  3

我们将此技术实现为一个 SklearnTransformer,它可以作为管道的一部分,如列表 6.31 所示。fit() 函数应该从训练数据中学习特征生成的信息。我们的 fit() 函数生成新列并拟合 OrdinalEncoder。transform() 函数应该转换数据并返回转换后的数据。我们的 transform() 函数生成新列并用 OrdinalEncoder 进行编码。

列表 6.31 将分类特征组合以生成新特征

from sklearn.base import BaseEstimator, TransformerMixin

class CategoricalCombination(
    BaseEstimator, TransformerMixin):                              ❶
    def __init__(self, name_a, name_b):                            ❷
        self.name_a = name_a
        self.name_b = name_b
        self.encoder = OrdinalEncoder(
            handle_unknown='use_encoded_value', unknown_value=-1)  ❸

    def fit(self, x, y=None, **kwargs):
        temp_column = x[self.name_a].astype(str) +
            x[self.name_b].astype(str)                             ❹
        self.encoder.fit(temp_column.to_frame())                   ❺
        return self

    def transform(self, x, **kwargs):
        temp_column = x[self.name_a].astype(str) + 
            x[self.name_b].astype(str)                             ❻
        temp_column = self.encoder.transform(
            temp_column.to_frame())                                ❼
        return temp_column

按要求扩展两个类以实现 SklearnTransformer

初始化器接受两个列名。

准备一个 OrdinalEncoder 对新生成的特征进行编码

将列连接起来构建新列

使用新列拟合编码器

将列连接起来构建新列

使用编码器对新的列进行编码

使用这个 CategoricalCombination,我们现在可以轻松地生成一个新列,该列结合了两个现有列的分类数据,如列表 6.32 所示。

列表 6.32 使用 CategoricalCombination 生成新特征

>>> temp_data = pd.DataFrame({
...     '1': ['A', 'A', 'A', 'B', 'B', 'B'],
...     '2': [1, 0, 1, 0, 1, 1]
... })
>>> print(temp_data.head(6))                         ❶
   1  2
0  A  1
1  A  0
2  A  1
3  B  0
4  B  1
5  B  1
>>> transformer = CategoricalCombination('1', '2')   ❷
>>> print(transformer.fit_transform(temp_data))      ❸
[[1.]
 [0.]
 [1.]
 [2.]
 [3.]
 [3.]]

打印原始列

初始化转换器

打印新生成的列

如输出所示,我们使用的数据和新生成列中的值与之前示例中使用的相同。

生成新特征的下一个技术是使用数值特征和分类特征来生成一个新的数值特征。例如,给定列表 6.33 中的表格,我们首先需要将数据行分成不同的组。在这个例子中,数据被分成了三个组:(A 1, A 1),(B 1, B 0),和 (C 1, C -1)。同一组中的行在第一列具有相同的值。其次,我们需要计算不同组中数值的平均值。在这个例子中,我们将得到三个值:组 A 的值为 1,组 B 的值为 0.5,组 C 的值为 0。最后一步是生成一个与分类列相同的新列,并用相应的平均值替换分类值。这就像使用平均值作为分类值的编码。A 被替换为 0.5,B 被替换为 0.5,C 被替换为 0。你也可以将其视为使用数值特征来编码分类特征。

列表 6.33 使用数值和分类列生成新列

A  1  1
A  1  1
B  1  0.5
B  0  0.5
C  1  0
C -1  0

为了实现这一技术,我们实现了一个新的 SklearnTransformer,如列表 6.34 所示。初始化器也接受两个列名,分别是分类列和数值列的列名。在 fit() 函数中,我们根据不同的分类值计算不同组的平均值。在 transform() 函数中,我们需要用数值列替换分类值并返回该值。

列表 6.34 使用数值和分类特征生成新特征

class MeanEncoder(BaseEstimator, TransformerMixin):                             ❶
    def __init__(
        self, categorical_name, numerical_name):                                ❷
        self.categorical_name = categorical_name
        self.numerical_name = numerical_name
        self.means = None

    def fit(self, x, y=None, **kwargs):
        self.mean = x.groupby(self.categorical_name)[self.numerical_name].mean()❸
        return self

    def transform(self, x, **kwargs):
        return x[self.categorical_name].map(
          self.mean).to_frame()                                                 ❹

❶ 扩展了 Transformer 所需的类

❷ 初始化器接受两个列名。

❸ fit 函数根据分类列中的值对行进行分组,并计算每个组中数值的平均值。

❹ 将分类值替换为平均值

MeanEncoder 实现,我们可以进行另一个快速测试以查看它是否按预期工作,如下面的列表所示。我们使用的数据与列表 6.33 中的示例相同。

列表 6.35 使用数值和分类特征生成新特征

>>> temp_data = pd.DataFrame({                        ❶
...     'a': ['A', 'A', 'B', 'B', 'C', 'C'],
...     'b': [1, 1, 1, 0, 1, -1]
... })
>>> print(temp_data.head(6))
   a  b
0  A  1
1  A  1
2  B  1
3  B  0
4  C  1
5  C -1
>>> encoder = MeanEncoder('a', 'b')                   ❷
>>> print(encoder.fit_transform(temp_data).head(6))   ❸
     a
0  1.0
1  1.0
2  0.5
3  0.5
4  0.0
5  0.0

❶ 准备一些示例数据

❷ 初始化 MeanEncoder

❸ 转换数据

如打印结果所示,新生成的列与之前示例中预期的相同。

现在我们已经拥有了所有需要的特征工程模块。我们需要将它们组合成一个单一的管道,包括特征编码管道和用于特征生成的转换器。我们将使用 SklearnColumnTransformer,它仅用于在输入机器学习模型之前转换特征。代码在下一个代码列表中显示。ColumnTransformer 接受一个参数,即转换器的列表,为三个元组的列表,一个字符串作为步骤的名称,一个 Transformer 或 Pipeline 实例,以及一个列表,包含将被转换器使用的列名。

列表 6.36 将特征编码和生成合并到管道中

from sklearn.compose import ColumnTransformer

column_transformer = ColumnTransformer(transformers=[
  ('numerical', numerical_transformer, numerical_columns),  ❶
  ('one_hot', one_hot_transformer, one_hot_columns),        ❶
  ('int', int_transformer, int_columns),                    ❶
  ('categorical_combination', CategoricalCombination(
      'sex', 'class'), ['sex', 'class']),                   ❷
  ('mean', MeanEncoder(
      'embark_town', 'age'), ['embark_town', 'age'])        ❸
])

❶ 不同类型列的预处理步骤

❷ 将两个分类列合并以生成新列

❸ 使用年龄列计算平均值以对登船城镇列进行编码

到目前为止,我们已经完成了特征生成部分。下一步是特征选择。特征选择通常使用一些指标来评估每个特征,选择对任务最有用的特征,并丢弃其余的列。用于特征选择的典型指标称为互信息,它是信息理论中的一个重要概念。它衡量两个变量之间的依赖性。每个特征可以看作是一个变量,相应列中的值可以看作是该变量的样本。如果目标列高度依赖于一个特征,这两个列之间的互信息就会很高,这意味着它是一个很好的保留列。如果两个变量相互独立,互信息就会接近于零。

我们使用 sklearn.feature_selection.SelectKBest 来实现这个特征选择步骤,它可以帮助我们根据给定的指标选择 k 个最佳特征。例如,为了选择与目标列具有最高互信息的八个顶级特征,我们可以使用 SelectKBest(mutual_info_classif, k=8)。它也可以是管道步骤之一。

在所有这些特征预处理、特征生成和特征选择步骤准备就绪后,我们构建了一个完整的端到端管道,如列表 6.37 所示,它使用支持向量机作为最终分类模型,该模型是通过 sklearn.svm.SVC 实现的。

列表 6.37 构建整体管道

from sklearn.svm import SVC
from sklearn.feature_selection import SelectKBest
from sklearn.feature_selection import mutual_info_classif

pipeline = Pipeline(steps=[                                          ❶
    ('preprocessing', column_transformer),                           ❷
    ('feature_selection', SelectKBest(mutual_info_classif, k=8)),    ❸
    ('model', SVC()),                                                ❹
])
pipeline.fit(x_train, y_train)                                        ❺

❶ 初始化最终的端到端管道

❷ 预处理和特征生成转换器

❸ 选择与目标列具有最高互信息的八个顶级特征的特征选择步骤

❹ 支持向量机分类模型

❺ 使用训练数据拟合模型

使用训练数据训练的管道,我们可以使用以下代码使用测试数据对其进行评估。

列表 6.38 使用测试数据评估管道

from sklearn.metrics import accuracy_score

y_pred = pipeline.predict(x_test)                     ❶
print('Accuracy: ', accuracy_score(y_test, y_pred))   ❷

❶ 预测测试数据的目标

❷ 打印准确度分数

它显示准确度分数为 0.74。

我们已经展示了一个特征工程示例。在这个过程中,我们可以执行许多不同的部分,例如,我们选择的组合分类列,我们选择的编码数值和分类列,或者在特征选择过程中要保留的列数。如果我们将这些决策定义为超参数,调整这些超参数的过程将是自动化特征工程的过程。

我们可以以下方式定义超参数。首先,我们生成两个分类列的所有可能组合以及数值列和分类列的所有可能组合。代码如下所示。

列表 6.39 生成所有可能的列组合

import numpy as np

mean_column_pairs = []                                                     ❶
for int_col in int_columns:                                                ❷
    for num_col in numerical_columns:                                      ❸
        mean_column_pairs.append((int_col, num_col))                       ❹

cat_column_pairs = []                                                      ❺
for index1 in range(len(int_columns)):                                     ❻
    for index2 in range(index1 + 1, len(int_columns)):                     ❼
        cat_column_pairs.append((int_columns[index1], int_columns[index2]))❽

mean_column_pairs = np.array(mean_column_pairs)
cat_column_pairs = np.array(cat_column_pairs)

❶ 所有可能的数值和分类列对的列表

❷ 遍历有序编码的分类列

❸ 遍历数值列

❹ 将分类和数值列对添加到列表中

❺ 所有可能的分类列对的列表

❻ 遍历所有有序编码的分类列

❽ 遍历剩余的有序编码分类列

❽ 将分类列对添加到列表中

其次,我们将为每一对使用布尔超参数来控制是否使用这两个列生成新特征。定义这些超参数的代码看起来如下所示。

列表 6.40 使用每个对的布尔超参数

transformers = []
for index, (col1, col2) in enumerate(cat_column_pairs):   ❶
    if not hp.Boolean('combine_{i}'.format(i=index)):     ❷
        continue
    col1 = str(col1)
    col2 = str(col2)
    transformers.append((                                 ❸
        col1 + col2,                                      ❸
        CategoricalCombination(col1, col2),               ❸
        [col1, col2]))                                    ❸

❶ 枚举所有对及其索引

❷ 使用布尔超参数控制每个对是否生成新特征

❸ 将三个转换器元组添加到列表中,该列表将被 ColumnTransformer 使用

现在我们准备将所有这些组合在一起以构建整个搜索空间。代码在列表 6.41 中展示。首先,正如前面展示的特征工程示例中,我们构建三个管道以预处理和编码三种类型的特征。其次,正如列表 6.40 中所示,我们为每个分类特征的每一对定义布尔超参数以生成新特征。我们也为每个数值和分类特征对做同样的事情。最后,我们定义一个用于保留列数的超参数。然后,我们将所有这些步骤放入一个单独的整体管道中并返回它。

列表 6.41 自动特征工程的搜索空间

import keras_tuner as kt

def build_model(hp):
    numerical_transformer = Pipeline(steps=[                 ❶
        ('imputer', SimpleImputer(strategy='median')),
        ('normalizer', StandardScaler())
    ])

    one_hot_transformer = Pipeline(steps=[                   ❷
        ('imputer', SimpleImputer(strategy='constant', fill_value='None')),
        ('one_hot_encoder', OneHotEncoder(handle_unknown='ignore'))
    ])

    int_transformer = Pipeline(steps=[                       ❸
        ('imputer', SimpleImputer(strategy='constant', fill_value='None')),
        ('label_encoder', OrdinalEncoder(
            handle_unknown='use_encoded_value', unknown_value=-1))
    ])

    transformers = [                                         ❹
        ('numerical', numerical_transformer, numerical_columns),
        ('one_hot', one_hot_transformer, one_hot_columns),
        ('int', int_transformer, int_columns),
    ]
    for index, (col1, col2) in enumerate(
        cat_column_pairs):                                   ❺
        if not hp.Boolean('combine_{i}'.format(i=index)):    ❺
            continue                                         ❺
        col1 = str(col1)                                     ❺
        col2 = str(col2)                                     ❺
        transformers.append((                                ❺
            col1 + col2,                                     ❺
            CategoricalCombination(col1, col2),              ❺
            [col1, col2]))                                   ❺

    for index, (col1, col2) in enumerate(
        mean_column_pairs):                                  ❻
        if not hp.Boolean('mean_{i}'.format(i=index)):       ❻
          continue                                           ❻
        col1 = str(col1)                                     ❻
        col2 = str(col2)                                     ❻
        transformers.append((                                ❻
            col1 + col2,                                     ❻
            MeanEncoder(col1, col2),                         ❻
            [col1, col2]))                                   ❻
    print(transformers)                                      ❻
    pipeline = Pipeline(steps=[                              ❼
        ('preprocessing', ColumnTransformer(
            transformers=transformers)),                     ❽
        ('impute', SimpleImputer(strategy='median')),        ❾
        ('model_selection', SelectKBest(                     ❿
            mutual_info_classif,                             ⓫
            k=hp.Int('best_k',                               ⓬
                     5,                                      ⓭
                     13 + len(transformers) - 3))),          ⓮
        ('model', SVC()),
    ])

    return pipeline

❶ 数值特征的预处理管道

❷ 对要独热编码的分类特征进行预处理的管道

❸ 对要有序编码的分类特征进行预处理的管道

❹ 将预处理管道放入列表中,稍后由 ColumnTransformer 使用

❺ 枚举分类特征对以定义布尔超参数以生成新特征

❻ 枚举分类和数值特征对以定义布尔超参数以生成新特征

❻ 初始化整体管道

❽ 将 ColumnTransformer 作为管道的第一步初始化以预处理数据和生成新特征

❾ 再次填充数据以避免在特征生成过程中出现任何缺失值

❽ 初始化特征选择器以选择前 k 个特征

⓫ 使用互信息作为特征选择的度量

⓬ 定义 k 值的超参数

⓭ 至少选择五个特征

⓮ 选择最多所有特征(预处理和编码后的 13 个特征加上新生成的特征,这与变换器列表中的变换器数量相同,除了三个预处理管道)

为了确保搜索空间能够正确构建模型,我们可以在列表 6.42 中使用以下代码作为快速单元测试。

列表 6.42 搜索空间的快速单元测试

build_model(kt.HyperParameters()).fit(x_train, y_train)

最后,我们可以开始搜索最佳模型,如下所示。

列表 6.43 搜索最佳自动特征工程模型

from sklearn import metrics
import sklearn

tuner = kt.SklearnTuner(
    kt.oracles.RandomSearchOracle(                         ❶
        objective=kt.Objective('score', 'max'),
        max_trials=10,
    ),
    build_model,
    scoring=metrics.make_scorer(metrics.accuracy_score),   ❷
    overwrite=True,
)
tuner.search(x_train, y_train)

❶ 使用随机搜索算法进行搜索

❷ 使用准确率作为指标

通过自动特征工程,我们实现了更好的准确率得分,达到了 0.81。

6.4 通过自定义调优器控制 AutoML 过程

在本节中,让我们深入了解调优器对象,并学习如何自定义它以控制 AutoML 过程并启用使用不同库实现的模型的调优。控制 AutoML 过程意味着控制 AutoML 的几个步骤的循环:在每个试验中根据搜索方法(占卜者)选择的超参数实例化 ML 管道,训练和评估管道的性能,记录评估结果,并在需要时提供结果以更新占卜者(见图 6.6)。您在前几节中已经看到了两种类型的调优器,对应于不同模型的调优:用于调优深度学习模型(特别是使用 TensorFlow Keras 实现)的随机搜索调优器,以及用于调优使用 scikit-learn 库实现的浅层模型的 SklearnTuner,其中您可以选择不同的搜索方法。我们选择不同调优器的主要原因是因为深度学习和浅层模型的训练和评估实现上的差异。这对于进行 AutoML 来说是一个相当实际的问题,因为很难找到一个包含您可能想要使用的所有可能 ML 模型的包。尽管 KerasTuner 通过使用 hp 容器定义 build()函数提供了一种自定义搜索空间的通用方法,但训练、评估、保存和加载这些模型可能仍然需要不同的处理。这可以通过定义自己的调优器来解决。

06-06

图 6.6 AutoML 过程中的搜索循环

在本节的剩余部分,我们将介绍如何分别自定义调优器以调优 scikit-learn 模型和 TensorFlow Keras 模型,以便您熟悉基本调优器设计。在两个示例之后,您将学习如何设计一个调优器以联合选择和调优深度学习和浅层模型。您还将通过一个额外的示例学习如何调优模型,该模型是一个使用 LightGBM 库实现的梯度提升决策树(GBDT)模型。

6.4.1 创建用于调优 scikit-learn 模型的调优器

让我们先学习如何设计一个用于调整使用 scikit-learn 库实现的浅层模型的调谐器。我们将处理在 6.2.1 节中使用的数字分类问题,并将使用相同的代码来创建搜索空间、执行模型选择和调谐。唯一的区别是我们自定义我们的调谐器,而不是使用内置的 scikit-learn 模型调谐器(kt.tuners.SklearnTuner)。我们命名的调谐器 ShallowTuner 应该扩展 KerasTuner 中的调谐器类(Tuner)。

覆盖 Tuner 类让我们完全控制搜索空间、模型构建、训练、保存和评估过程。在进入实际示例之前,我们先来看一个基本示例,以了解其工作原理。在列表 6.44 中,我们尝试通过将 x 定义为超参数来找到使 y=x*x+1 最小的 x 的值。是的,通过子类化 Tuner 或 Tuner 的任何子类,如 RandomSearch,你可以将 KerasTuner 作为黑盒优化工具用于任何事物。为此,我们只需要覆盖 Tuner.run_trial(),定义超参数,并返回目标函数值。默认情况下,返回的值将被最小化。Tuner.run_trial() 只是一次运行实验并返回评估结果。

列表 6.44 一个用于子类化 Tuner 的基本示例

import keras_tuner as kt

class MyTuner(kt.RandomSearch):                    ❶
    def run_trial(self, trial, *args, **kwargs):
        hp = trial.hyperparameters                 ❷
        x = hp.Float('x', -1.0, 1.0)
        return x * x + 1

tuner = MyTuner(max_trials=20)
tuner.search()
tuner.results_summary()

❶ 扩展 RandomSearch 类

❷ 从试验中获取超参数对象

在这个示例中,我们没有使用超模型。我们也没有指定一个目标。这些都是在 Tuner.run_trial() 中使用的。如果你不使用它们,就没有必要指定它们。对于 Tuner.run_trial() 的返回值,它支持不同的格式。更常见的是使用字典,我们将在下一个示例中展示。

然而,当我们实现 ShallowTuner 时,我们需要覆盖更多函数,因为我们希望在搜索过程中保存模型,并在搜索结束后加载最佳模型。一般来说,为了自定义调谐器,需要实现以下五个函数:

  • 初始化函数 (__init__()) — 通过提供搜索方法(占位符)和定义的模型构建函数或类(超模型),我们在前面的章节中学习到的,来初始化调谐器,以表征搜索空间并在每个试验中构建模型。占位符和超模型将被保存为调谐器的属性(self.oracle 和 self.hypermodel)。

  • 搜索函数 (search()) — 当被调用时,启动整个迭代式 AutoML 搜索过程。在每次搜索迭代中,它将首先初始化一个名为 trial 的对象,该对象存储当前试验中的所有元信息,例如搜索方法选择的超参数和当前试验的状态,以帮助跟踪试验是否已开始或完成。然后搜索函数将调用下一个介绍的核心函数以追求搜索过程。

  • 核心函数 (run_trial()) — 实现我们在图 6.6 中描述的单个搜索循环。

  • 保存功能(save_model())—保存生成的模型。

  • 加载功能(load_model())—在搜索过程完成后,如果需要重新训练,则加载模型。

我们 ShallowTuner 的代码显示在列表 6.45 中。初始化函数和搜索函数在此可以忽略,因为它们仅调用从 Tuner 扩展的相应函数,并且没有任何专门的操作。

列表 6.45 定制用于调整 scikit-learn 模型的 tuner

import os
import pickle
import tensorflow as tf
import keras_tuner as kt

class ShallowTuner(kt.Tuner):
    def __init__(self, oracle, hypermodel, **kwargs):
        super(ShallowTuner, self).__init__(
            oracle=oracle, hypermodel=hypermodel, **kwargs)   ❶

    def search(self, X, y, validation_data):
        return super(ShallowTuner, self).search(
            X, y, validation_data)                            ❷
    def run_trial(self, trial, X, y, validation_data):
        model = self.hypermodel.build(trial.hyperparameters)  ❸
        model.fit(X, y)                                       ❸
        X_val, y_val = validation_data                        ❸
        eval_score = model.score(X_val, y_val)                ❸
        self.save_model(trial.trial_id, model)                ❹
        return {'score': eval_score}                          ❺

    def save_model(self, trial_id, model):                    ❻
        fname = os.path.join(self.get_trial_dir(trial_id), 'model.pickle')
        with tf.io.gfile.GFile(fname, 'wb') as f:
            pickle.dump(model, f)

    def load_model(self, trial):                              ❼
        fname = os.path.join(
            self.get_trial_dir(trial.trial_id), 'model.pickle')
        with tf.io.gfile.GFile(fname, 'rb') as f:
            return pickle.load(f)

❶ 初始化 tuner

❷ 执行 AutoML 搜索过程

❸ 在当前试验中构建、训练和评估模型

❹ 将模型保存到磁盘

❺ 返回评估结果

❻ 使用 pickle 包的模型保存函数

❽ 使用 pickle 包的模型加载函数

由于 AutoML 过程是一个循环过程,自定义 tuner 中的核心函数(run_trial())在搜索函数(在基于 Tuner 的实现中)中被反复调用。其输入包含用于训练和评估在它中实例化的模型的训练和评估数据(X 为训练特征,y 为训练响应,validation_data 为测试数据)。一个 trial 对象包含当前试验中占卜者返回的所有超参数以及一些帮助总结结果的元数据,例如随机生成的此试验的 ID(trial.trial_id)。

深入研究 run_trial()函数,我们可以看到它首先基于由占卜者选择的超参数构建模型。trial.hyperparameters 是一个超参数容器,有助于创建当前模型。然后,模型被训练和评估。model.score()函数采用 scikit-learn 模型的默认评估标准。您也可以在此处实现自己的评估方法,例如交叉验证。在这种情况下,验证数据也可以从参数中移除,因为交叉验证将自动将部分训练数据(X 和 y)作为验证数据分割。评估结果(以度量名称为键的字典)返回以更新占卜者。

通过调用 save_model()函数将模型保存到磁盘,以便将来使用。此过程严格遵循图 6.6 中描述的搜索循环。为了帮助保存和加载搜索过程中发现的模型,我们还需要实现 save_model()和 load_model()函数。在 run_trial()函数中调用的 save_model()函数以试验的唯一 ID 和训练的 scikit-learn 模型作为输入,并使用 pickle 包保存模型。load_model()函数在搜索过程完成后使用。它有助于从磁盘检索最佳模型。它以包含此试验的所有元信息(如试验 ID、超参数、模型准确度)的试验对象作为输入,并返回在相应试验中选择的训练模型。

我们遵循第 6.2.1 节中介绍的相同过程来加载数据和创建搜索空间。值得注意的是,我们在训练数据上进行了额外的拆分以获得验证数据集,因为我们的自定义调谐器需要验证数据的输入。我们没有为内置的 scikit-learn 调谐器这样做,因为它在 run_trial()函数中实现了交叉验证来评估每个选定的模型。这里的搜索空间仍然是用于 SVM 模型和随机森林模型的联合模型选择和超参数调整。执行此 AutoML 任务的代码如下所示。

列表 6.46 调整 scikit-learn 模型以进行数字分类

from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split

digits = load_digits()                                          ❶

images, labels = digits.images, digits.target                   ❶

X = images.reshape((n_samples, -1))                             ❶

X_train, X_test, y_train, y_test = train_test_split(
    X, labels, test_size=0.2, shuffle=False)                    ❷

X_train, X_val, y_train, y_val = train_test_split(
    X_train, y_train, test_size=0.2, shuffle=False)             ❷

from sklearn.svm import SVC
from sklearn.ensemble import RandomForestClassifier
from keras_tuner.engine import hyperparameters as hp

def build_model(hp):                                            ❸
    model_type = hp.Choice('model_type', ['svm', 'random_forest'])
    if model_type == 'svm':
        with hp.conditional_scope('model_type', 'svm'):
            model = SVC(
                C=hp.Float('C', 1e-3, 10, sampling='linear', default=1),
                kernel=hp.Choice('kernel_type', 
                                 ['linear', 'rbf'], 
                                 default='linear'),
                random_state=42)
    elif model_type == 'random_forest':
        with hp.conditional_scope('model_type', 'random_forest'):
            model =  RandomForestClassifier(
                n_estimators=hp.Int('n_estimators', 10, 200, step=10),
                max_depth=hp.Int('max_depth', 3, 10))
    else:
        raise ValueError('Unrecognized model_type')
    return model

my_sklearn_tuner = ShallowTuner(                               ❹
    oracle=kt.oracles.RandomSearch(
        objective=kt.Objective('score', 'max'),
        max_trials=10,
        seed=42),
    hypermodel=build_model,
    overwrite=True,
    project_name='my_sklearn_tuner')

my_sklearn_tuner.search(
    X_train, y_train, validation_data=(X_val, y_val))          ❺

❶ 加载数字数据集

❷ 将数据集分为训练集、验证集和测试集

❸ 为模型选择和超参数调整创建搜索空间

❹ 初始化自定义调谐器

❺ 通过提供训练集和验证集来执行搜索过程

您还可以使用自定义调谐器来调整 scikit-learn 管道,就像我们在第 6.2.2 节中所做的那样。我们在这里不会进一步详细说明,将其留作您的练习。

6.4.2 创建用于调整 Keras 模型的调谐器

在第二个示例中,让我们创建一个自定义调谐器来调整使用 TensorFlow Keras 实现的深度学习模型。我们使用了一个内置的调谐器来调整 Keras 模型:随机搜索调谐器,它是硬编码的随机搜索方法(oracle 是随机搜索 oracle,不可更改)。现在我们创建一个可以选择不同搜索方法的自定义调谐器。遵循我们在上一个示例中执行的相同步骤,我们通过扩展基本调谐器类创建了一个名为 DeepTuner 的调谐器。正如我们之前提到的,如果初始化函数和搜索函数与基本调谐器相比没有任何专门的操作,我们可以忽略它们。因此,我们在这里只为 DeepTuner 实现了三个函数:run_trial()、save_model()和 load_model()函数。

与前一个示例相比,主要区别在于我们在 run_trial()函数中如何评估模型以及如何保存和加载这些 Keras 模型(参见列表 6.47)。聚焦到 run_trial()函数,我们仍然可以通过调用超模型的 build()函数来构建 Keras 模型,就像我们在前一个示例中所做的那样。然后我们调用实例化 Keras 模型的 fit()函数来训练它。值得注意的是,训练深度学习模型可能需要额外的超参数,如批大小和 epoch 数量,以帮助控制优化算法。我们可以通过**fit_kwargs 参数传递这些超参数(正如我们将在调用调优器的 search()函数时看到的那样)。或者,为了更自动化,可以使用超参数容器设置其搜索空间(就像我们在列表 6.47 中对 batch_size 所做的那样)以与其他超参数一起调整。模型训练完成后,我们可以使用验证数据对其进行评估,并使用评估结果来更新算子。具体来说,你可能想知道当我们有多个评估指标时如何更新算子。例如,默认情况下,Keras 的 evaluate()函数将返回评估损失值和分类准确率(在这个例子中,假设我们已经创建了一个用于分类数字的模型搜索空间)。解决这个问题的直接方法是将所有指标保存在一个字典中,其中指标名称作为键,并将所有这些指标都提供给算子。在初始化调优器时,我们可以通知调优器我们想要使用哪个特定指标来比较模型并更新算子。由于 TensorFlow Keras 提供了保存和加载模型的方法,我们可以采用这些方法来实现 save_model()和 load_model()函数。

列表 6.47 为调整 Keras 模型自定义调优器

class DeepTuner(kt.Tuner):

    def run_trial(self, trial, X, y, validation_data, **fit_kwargs):
        model = self.hypermodel.build(trial.hyperparameters)

        model.fit(X, y, batch_size=trial.hyperparameters.Choice(
            'batch_size', [16, 32]), **fit_kwargs)                 ❶

        X_val, y_val = validation_data
        eval_scores = model.evaluate(X_val, y_val)
        self.save_model(trial.trial_id, model)
        return {name: value for name, value in zip(
            model.metrics_names,
            eval_scores)}                                          ❷

    def save_model(self, trial_id, model, step=0):
        fname = os.path.join(self.get_trial_dir(trial_id), 'model')
        model.save(fname)

    def load_model(self, trial):
        fname = os.path.join(self.get_trial_dir(
            trial.trial_id), 'model')
        model = tf.keras.models.load_model(fname)
        return model

❶ 使用可调整的批大小训练模型

❷ 返回评估结果

接下来,我们使用自定义调优器来调整用于数字分类的 MLP。如列表 6.48 所示,我们在 build_model()函数中创建一个 MLP 的搜索空间,并使用它来初始化一个 DeepTuner 对象。正如我们所见,我们自行选择了一个算子,这是随机搜索调优器无法做到的。目标被指定为分类准确率,这将是比较模型和用于在 run_trial()函数中更新算子的指标。通过调用搜索函数,我们可以执行搜索过程。从搜索函数传递到 run_trial()函数(通过**fit_kwargs)的 epoch 数量将用于控制每个选定的 MLP 的训练 epoch 数。

列表 6.48 使用自定义调优器对数字分类的 MLP 进行调优

import keras_tuner as kt

def build_model(hp):                                             ❶
    model = tf.keras.Sequential()
    model.add(tf.keras.Input(shape=(64,)))
    for i in range(hp.Int('num_layers', min_value=1, max_value=4)):
        model.add(tf.keras.layers.Dense(hp.Int(
            'units_{i}'.format(i=i), min_value=32, max_value=128, step=32), 
                activation='relu'))
    model.add(tf.keras.layers.Dense(10, activation='softmax'))
    model.compile(loss='sparse_categorical_crossentropy', 
        metrics=['accuracy'])
    return model
my_keras_tuner = DeepTuner(
    oracle=kt.oracles.RandomSearch(
        objective=kt.Objective('accuracy', 'max'),               ❷
        max_trials=10,
        seed=42),
    hypermodel=build_model,
    overwrite=True,
    project_name='my_keras_tuner')

my_keras_tuner.search(
    X_train, y_train, validation_data=(X_val, y_val), epochs=10) ❸

❶ 为调整 MLP 创建搜索空间

❷ 使用分类准确率作为模型比较和算子更新的目标

❸ 执行搜索过程

从两个调优器设计的例子中,我们可以发现,只要我们知道如何训练、评估、保存和加载任何机器学习库实现的模型,我们就应该能够编写一个调优器来控制、选择和调整这些模型。通过定制调优器,我们可以完全控制自动化机器学习过程,并允许更广泛的搜索空间,例如调整批量大小和调整来自不同库的模型。在接下来的两个例子中,我们将进一步展示调优器设计在扩大不同库中调整模型搜索空间方面的好处。

6.4.3 深度学习和浅层模型之间的联合调整和选择

尽管深度学习模型最近在许多机器学习任务中已经显示出其突出地位,但它们并不是普遍最优的解决方案。在许多情况下,我们事先不知道使用深度学习模型是否会优于浅层模型,尤其是在数据集较小的情况下。你可能会在某一点上遇到这种情况。下一个例子将展示如何在浅层模型和深度学习模型之间进行联合模型选择和调整。我们将前两个例子中使用的搜索空间合并为一个统一的搜索空间。该搜索空间包含三种类型的模型结构——支持向量机(SVM)、随机森林和多层感知器(MLP),每种都有其指定的超参数空间。为了进一步阐明搜索空间层次结构,我们为每种类型模型下的超参数设置了条件范围,如下所示。

列表 6.49 创建既深又浅的模型搜索空间

from sklearn.svm import SVC
from sklearn.ensemble import RandomForestClassifier

def build_model(hp):
    model_type = hp.Choice('model_type', ['svm', 'random_forest', 'mlp'], 
        default='mlp')                                                    ❶
    if model_type == 'svm':
        with hp.conditional_scope('model_type', 'svm'):                   ❷
            model = SVC(
                C=hp.Float('C', 1e-3, 10, sampling='linear', default=1),
                kernel=hp.Choice('kernel_type', ['linear', 'rbf'], 
                   default='linear'),
                    random_state=42)
    elif model_type == 'random_forest':
        with hp.conditional_scope('model_type', 'random_forest'):         ❷
            model = RandomForestClassifier(
                n_estimators=hp.Int('n_estimators', 10, 200, step=10),
                max_depth=hp.Int('max_depth', 3, 10))
    elif model_type == 'mlp':
        with hp.conditional_scope('model_type', 'mlp'):                   ❷
            model = tf.keras.Sequential()
            model.add(tf.keras.Input(shape=(64,)))
            for i in range(hp.Int('num_layers', min_value=1, max_value=4)):
                model.add(tf.keras.layers.Dense(hp.Int(
                    f'units_{i}', min_value=32, max_value=128, 
                        step=32), activation='relu'))
            model.add(tf.keras.layers.Dense(10, activation='softmax'))
            model.compile(
                loss='sparse_categorical_crossentropy', metrics=['accuracy'])
    else:
        raise ValueError('Unrecognized model_type')
    return model

❶ 选择是否使用浅层模型或 MLP

❷ 为每种类型的模型设置条件超参数范围

现在是创建调优器的时候了。遵循前两个例子,一个可行的方法是将两个调优器合并为一个。因为深度学习模型和浅层模型的创建方式相同(通过在每个试验中调用带有超参数的 build()函数),我们可以设置一个模型判别器来判断在每个试验中创建的模型是浅层模型还是深度模型。每当得到深度学习模型(Keras 模型)时,我们就在 DeepTuner 中进行训练、评估和保存。相反,如果创建的是浅层模型(scikit-learn),我们将遵循 ShallowTuner 中实现的步骤。我们在列表 6.50 中实现了调优器。

如你所见,run_trial()、save_model() 和 load_model() 函数都可以决定模型是否为 Keras 模型。为确保预言机对不同模型进行相同类型的评估,我们仅保留深度学习模型的分类准确率。这里的一个棘手点是,不同的模型以不同的方式保存和加载。在训练期间,我们可以直接使用基于模型类型的定制保存方法保存一个模型。然而,在加载模型时,我们没有模型来预先选择相应的加载方法。为了解决这个问题,我们在初始化函数(trial_id_to_type)中预先定义了一个属性来记录每个试验的模型类型。它是一个将试验 ID 映射到相应模型类型(Keras 或 scikit-learn)的字典,因此当加载模型时,我们可以根据试验 ID 选择相应的加载方法。

列表 6.50 自定义调优器以调优深度和浅层模型

import pickle
import os
import tensorflow as tf
import keras_tuner as kt

class ShallowDeepTuner(kt.Tuner):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.trial_id_to_type = {}                                    ❶

    def run_trial(
        self, trial, x, y, validation_data, epochs=None, **fit_kwargs):
            model = self.hypermodel.build(trial.hyperparameters)
            x_val, y_val = validation_data
            if isinstance(model, tf.keras.Model):                     ❷
                model.fit(
                    x, y, validation_data=validation_data,
                    batch_size=trial.hyperparameters.Choice(
                        'batch_size', [16, 32]),
                        epochs=epochs,
                        **fit_kwargs)
                accuracy = {name: value for name, value in zip(
                    model.metrics_names,
                    model.evaluate(x_val, y_val))}['accuracy']        ❸
                self.trial_id_to_type[trial.trial_id] = 'keras'       ❹
            else:
                model = self.hypermodel.build(trial.hyperparameters)
                model.fit(x, y)
                accuracy = model.score(x_val, y_val)
                self.trial_id_to_type[trial.trial_id] = 'sklearn'     ❹
            self.save_model(trial.trial_id, model)
            return {'accuracy': accuracy}

    def save_model(self, trial_id, model):
        fname = os.path.join(self.get_trial_dir(trial_id), 'model')
        if isinstance(model, tf.keras.Model):
            model.save(fname)
        else:
            with tf.io.gfile.GFile(fname, 'wb') as f:
                pickle.dump(model, f)

    def load_model(self, trial):
        fname = os.path.join(self.get_trial_dir(trial.trial_id), 'model')
        if self.trial_id_to_type[trial.trial_id] == 'keras':          ❺
            model = tf.keras.models.load_model(fname)
        else:
            with tf.io.gfile.GFile(fname, 'rb') as f:
                model = pickle.load(f)
        return model

❶ 为每个试验添加一个属性以记录所选模型的类型

❷ 检查模型训练的模型类型

❸ 仅检索 Keras 模型的准确率

❹ 记录模型类型

❺ 检查模型加载的模型类型

剩下的工作就是实例化调优器,并使用它来探索深度和浅层模型的混合搜索空间(参见列表 6.51)。我们在这里选择随机搜索方法,并将比较模型的目标设置为准确率,这与我们在调优器的 run_trial() 函数中指定的目标一致。我们进行 30 次试验,迄今为止探索的最佳模型是一个 SVM 分类器。

列表 6.51 使用自定义调优器探索混合搜索空间

>>> random_tuner = ShallowDeepTuner(
...     oracle=kt.oracles.RandomSearch(
...         objective=kt.Objective('accuracy', 'max'),   ❶
...         max_trials=30,
...         seed=42),
...     hypermodel=build_model,
...     overwrite=True,
...     project_name='random_tuner')

>>> random_tuner.search(
...     x_train, y_train, validation_data=(x_val, y_val), epochs=10)

>>> best_model = random_tuner.get_best_models(1)[0]      ❷
>>> print(type(best_model))

<class 'sklearn.svm._classes.SVC'>

❶ 将目标设置为分类准确率

❷ 检索最佳模型

在最后一个示例中,我们将对未使用 TensorFlow Keras 和 scikit-learn API 实现的模型进行调优。这可以帮助你将所学的 AutoML 技术推广到更广泛的模型和库中,这些模型和库可能是你在实践中想要使用的。

6.4.4 超参数调优超出 Keras 和 scikit-learn 模型

例如,我们使用 LightGBM 库(lightgbm.readthedocs.io/en/latest/),这是一个基于树的梯度提升框架。它包含几个代表性的基于树的机器学习算法,如 GBDT 算法和随机森林算法,你可能之前已经见过(更多关于这些算法的细节请见附录 B)。学习如何调整这些算法需要我们事先了解如何应用它们。具体来说,你需要知道如何实例化算法并使用它来训练模型。你还应该知道如何评估学习到的模型、保存它,并在需要时将其加载回来进行预测。在这里,我们针对加利福尼亚房价预测任务进行工作,并使用 GBDT 算法(通过 boosting_type 参数指定)训练了一个回归模型,如列表 6.52 所示。该算法将按顺序创建并添加决策树到最终的 GBDT 模型中。我们训练了一个包含最多 10 棵树和每棵树 31 个叶子的 GBDT 模型。这里的学习率是当新树添加到集成模型中时对预测校正的加权因子。这里的最佳迭代次数表示达到最佳性能的集成模型中的树的数量。我们建议您查阅官方网站以获取此库的详细使用信息:mng.bz/q2jJ。我们评估了训练好的模型,保存了它,并重新加载以检查模型是否正确保存。

列表 6.52 在 LightGBM 库中应用 GBDT 模型进行回归

import pandas as pd
from sklearn.datasets import fetch_california_housing
house_dataset = fetch_california_housing()
data = pd.DataFrame(house_dataset.data, columns=house_dataset.feature_names)
target = pd.Series(house_dataset.target, name = 'MEDV')

from sklearn.model_selection import train_test_split
X_train, X_test, y_train, y_test = train_test_split(
    data, target, test_size=0.2, random_state=42)
X_train, X_val, y_train, y_val = train_test_split(
    X_train, y_train, test_size=0.2, shuffle=False)

!pip install lightgbm -q                                     ❶
import lightgbm as lgb                                       ❶
from sklearn.metrics import mean_squared_error

gbdt_model = lgb.LGBMRegressor(
    boosting_type='gbdt',
    num_leaves=31,
    learning_rate=0.05,
    n_estimators=10
)                                                            ❷

validation_data = (X_val, y_val)
gbdt_model.fit(X_train, y_train,
        eval_set=[validation_data],
        eval_metric='mse',
        early_stopping_rounds=5)                             ❷

y_pred_gbdt = gbdt_model.predict(
    X_test, num_iteration=gbdt_model.best_iteration_)        ❸ 
test_mse_1 = mean_squared_error(y_test, y_pred_gbdt)         ❸
print('The GBDT prediction MSE on test set: {}'.format(test_mse_1))

fname = 'gbdt_model.txt'                                     ❹
gbdt_model.booster_.save_model(                              ❹
    fname, num_iteration=gbdt_model.best_iteration_)         ❹
gbdt_model_2 = lgb.Booster(model_file=fname)                 ❹
gbdt_model_2.predict(X_test)                                 ❹
test_mse_2 = mean_squared_error(y_test, y_pred_gbdt)         ❹
print('The reloaded GBDT prediction MSE on test set: {}'.format(test_mse_2))

❶ 安装并导入 LightGBM 包

❷ 创建并拟合 GBDT 模型

❸ 评估学习到的 GBDT 模型

❹ 保存、加载和重新评估模型

如下所示,GBDT 模型的均方误差(MSE)约为 0.75,模型保存和加载方法能够成功地将学习到的模型保存到 .txt 文件中:

>>> The GBDT prediction MSE on test set: 0.7514642734431766
>>> The reloaded GBDT prediction MSE on test set: 0.7514642734431766

在列表 6.53 中,我们尝试使用之前章节中学到的 AutoML 技术调整 LightGBM 中实现的 GBDT 算法。假设我们想要调整最大树的数量、每棵树的最大叶子数以及 GBDT 算法的学习率。因为我们知道如何实例化单个 GBDT 回归器,我们可以利用 hp 容器并在模型构建函数中指定这些相关超参数的搜索空间,就像我们为调整 scikit-learn 和 Keras 模型所做的那样。

列表 6.53 为选择和调整 LightGBM 模型创建搜索空间

def build_model(hp):
    model = lgb.LGBMRegressor(
        boosting_type='gbdt',
        num_leaves=hp.Choice('num_leaves', [15, 31, 63], default=31),   ❶
        learning_rate=hp.Float(                                         ❶
            'learning_rate', 1e-3, 10, sampling='log', default=1),      ❶
        n_estimators=hp.Int('n_estimators', 10, 200, step=10)           ❶
    )

    return model

❶ 描述相关超参数的搜索空间

按照前例中介绍的自定义调谐器的方法,我们创建了一个 LightGBMTuner,它扩展了 KerasTuner 的基本调谐器,并实现了执行搜索试验、保存训练模型和从磁盘加载模型这三个核心功能。从列表 6.54 中的代码可以看出,除了包括使用所选超参数构建模型和或 acle 更新在内的几个 AutoML 步骤外,结合这三个功能的代码与我们在 LightGBM 中实现单个 GBDT 算法以进行回归任务的代码相同。

列表 6.54 自定义用于调整 LightGBM 模型的调谐器

import os
import pickle
import tensorflow as tf
import keras_tuner as kt
import lightgbm as lgb
from sklearn.metrics import mean_squared_error

class LightGBMTuner(kt.Tuner):

    def run_trial(self, trial, X, y, validation_data):
        model = self.hypermodel.build(
            trial.hyperparameters)                                        ❶
        model.fit(
            X_train, y_train,
            eval_set=[validation_data],
            eval_metric='mse',
            early_stopping_rounds=5)                                      ❶
        X_val, y_val = validation_data
        y_pred = model.predict(X_val, num_iteration=model.best_iteration_)❷
        eval_mse = mean_squared_error(y_val, y_pred)                      ❷
        self.save_model(trial.trial_id, model)                            ❸
        return {'mse': eval_mse}                                          ❹
    def save_model(self, trial_id, model, step=0):
        fname = os.path.join(self.get_trial_dir(trial_id), 'model.txt')
        model.booster_.save_model(fname, num_iteration=model.best_iteration_)

    def load_model(self, trial):
        fname = os.path.join(self.get_trial_dir(trial.trial_id), 'model.txt')
        model = lgb.Booster(model_file=fname)
        return model

❶ 构建并拟合一个 GBDT 模型

❷ 评估学习到的 GBDT 模型

❸ 返回模型评估的均方误差 (MSE)

❹ 将学习到的 GBDT 模型保存到磁盘

我们使用创建的模型构建函数和自定义调谐器来调整使用 LightGBM 库实现的 GBDT 算法中的三个超参数。通过进行 10 次试验来探索搜索空间,与初始模型相比,最佳发现的模型的测试 MSE 大幅降低,这证明了我们的调整策略的有效性(见列表 6.55)。最佳模型的超参数可以通过调用从基本调谐器继承的 result_summary() 函数打印出来。

列表 6.55 执行超参数调整并评估最佳模型

>>> my_lightgbm_tuner = LightGBMTuner(
...     oracle=kt.oracles.RandomSearch(
...         objective=kt.Objective('mse', 'min'),
...         max_trials=10,
...         seed=42),
...     hypermodel=build_model,
...     overwrite=True,
...     project_name='my_lightgbm_tuner')
>>> my_lightgbm_tuner.search(X_train, y_train, validation_data=(X_val, y_val))

>>> from sklearn.metrics import mean_squared_error
>>> best_model = my_lightgbm_tuner.get_best_models(1)[0]
>>> y_pred_test = best_model.predict(X_test)
>>> test_mse = mean_squared_error(y_test, y_pred_test)
>>> print('The prediction MSE on test set: {}'.format(test_mse))

The prediction MSE on test set: 0.20391543433512713

>>> my_lightgbm_tuner.results_summary(1)    ❶
Results summary
Results in ./my_lightgbm_tuner
Showing 1 best trials
Objective(name='mse', direction='min')
Trial summary
Hyperparameters:
num_leaves: 31
learning_rate: 0.09504947970741313
n_estimators: 190
Score: 0.2202899505068673

❶ 打印最佳试验的超参数和评估信息

您现在已经学会了如何设计调谐器以扩展搜索空间到使用不同库实现的更广泛模型范围。在结束本章之前,我们想指出几个注意事项。

注意

  • 采用 KerasTuner 需要知道模型的实现方式。如果您想为不使用 Keras 或 scikit-learn 库实现的模型自定义调谐器,您还必须知道如何训练、评估、保存和加载模型。

  • 我们将 build_model() 函数中的模型实例化部分和调谐器中的模型训练部分分开。这种分离是可行的,因为得到了 Keras 和 scikit-learn API 的支持。然而,某些库可能不允许模型实例化和训练的分离。换句话说,您必须在一句话中用超参数实例化模型,并用训练数据拟合模型,例如 LightGBM 的默认训练 API (mng.bz/7Wve)。为了适应这种情况,一种简单的方法是使用 build_model() 函数返回超参数,而不是创建模型,并在调谐器的 run_trial() 函数中同时使用这些超参数实例化模型,利用覆盖您想要调整的模型的库的 API 进行模型训练。

  • KerasTuner 要求我们定义调优器中每个所选模型的性能评估。一些 AutoML 工具包在调优器外部定义模型训练和评估(目标)函数,并在搜索过程中将其输入到调优器中,例如 Ray Tune (docs.ray.io/en/latest/tune/index.html) 或 Hyperopt (github.com/hyperopt/hyperopt)。它们的通用 API 与 KerasTuner 实现相当相似,你可以从它们的官方网站了解更多详细信息。

  • 尽管我们通常需要为使用不同库实现的模型设计定制的调优器,但算子通常是通用的,可以在不同的调优器中使用,因为算子的输入和输出总是从 hp 容器中提取的超参数的数值表示。我们将在下一章中介绍更多细节。

摘要

  • 为了在调优和选择深度学习模型时获得额外的搜索空间设计灵活性,你可以通过分层的方式在一个build()函数中创建整个搜索空间。这种模型构建方式类似于创建 Keras 模型,除了你应该将相关超参数更改为可行值的空间。

  • 你可以使用与深度学习模型相同的方式,在 KerasTuner 中为浅层模型创建搜索空间。可以通过创建 scikit-learn 管道来联合选择和调优多个预处理方法和浅层模型。

  • 调优器包含一个搜索方法,并在搜索过程中组织所选管道的训练和评估。可以通过更改 KerasTuner 中的算子来选择不同的搜索方法。

  • 对于具有不同训练和评估策略或使用不同库实现的模型,你可能需要选择一个具有合适训练和评估策略的调优器,或者自定义自己的调优器。

  • 定制调优器的典型情况需要你指定三个函数:一个run_trial()函数用于处理单个 AutoML 循环(包括模型实例化、训练、评估、保存以及算子更新),一个模型保存函数和一个模型加载函数。

第三部分 AutoML 的高级主题

书的最后一部分将引导你探索一些实践中的高级 AutoML 设计和配置。你将在第七章学习如何自定义自己的搜索方法来探索超参数搜索空间并发现更好的超参数,以及在第八章中如何采用不同的策略来加速搜索过程,即使是在有限的计算资源下。第九章提供了一个快速回顾,总结了你应该从这本书中学到的内容,以及一个关于学习更多 AutoML 知识和保持该领域最新发展的资源与策略的简短列表。

7 自定义 AutoML 的搜索方法

本章涵盖

  • 理解顺序搜索方法

  • 自定义随机搜索方法

  • 为基于模型的搜索方法向量化超参数

  • 理解和实现贝叶斯优化搜索方法

  • 理解和实现进化搜索方法

在本章中,我们将探讨如何自定义顺序搜索方法以迭代探索超参数搜索空间并发现更好的超参数。您将学习如何实现不同的顺序搜索方法,以在每个试验中选择搜索空间中的管道。这些搜索方法分为以下两类:

  • 历史无关的顺序搜索方法在搜索过程中不能更新。例如,我们在第二章中讨论的网格搜索遍历候选超参数集中所有可能值的组合,在第六章中,我们使用随机搜索方法从搜索空间中随机选择超参数组合。这些是两种最典型的历史无关方法。一些其他高级随机搜索方法利用历史记录,例如使用 Sobol 序列的准随机搜索方法(mng.bz/6Z7A),但在这里我们将仅考虑经典的均匀随机搜索方法。

  • 历史依赖的顺序搜索方法,例如贝叶斯优化,能够通过利用先前结果来提高搜索的有效性。

7.1 顺序搜索方法

在第六章中,您学习了如何自定义调谐器以控制 AutoML 搜索循环(见图 7.1)。机器学习管道是通过迭代调用算子(搜索方法)生成的。从机器学习管道中学习的模型被评估,并将结果反馈给算子以更新它,以便它能更好地探索搜索空间。因为算子以顺序方式生成机器学习管道,所以我们称它为顺序搜索方法。它通常包括以下两个步骤:

  • 超参数采样——从搜索空间中采样超参数以创建机器学习管道。

  • 算子更新(可选)——更新搜索方法,利用现有模型和评估的历史记录。目标是增加在搜索空间中识别更好的机器学习管道的速度。这一步骤在不同搜索方法中有所不同,并且仅在历史依赖方法中发生。例如,网格搜索和随机搜索不考虑历史记录,因此在这些方法中,算子不需要在搜索过程中更新。

07-01

图 7.1 使用顺序搜索方法时的单个搜索循环

如前所述,如果 oracle 可以利用历史评估来更新自身并指导其从搜索空间中采样新的超参数,我们可以将顺序搜索方法分为两类:依赖于历史的方法和独立于历史的方法。根据更新的方式,依赖于历史的方法可以进一步分为以下两个主要类别:

  • 启发式方法—通常受到生物行为的启发。一个典型的例子是进化方法,它通过模拟动物种群在代际间的进化来生成新的样本。我们将在本章的最后部分介绍如何创建一个进化搜索方法。

  • 基于模型的方法—利用某些机器学习模型,如决策树模型,来预测搜索空间中哪些超参数是好的选择。使用先前超参数集的历史评估作为训练数据来训练机器学习模型。一个代表性的方法是我们在上一章中使用的贝叶斯优化方法。你将在第 7.3 节中学习如何实现这种方法。

这些方法可能是现有文献中最广泛使用的顺序搜索方法。然而,我们将从一种与历史无关的方法开始:随机搜索。我们将继续使用第六章中使用的加利福尼亚房价预测问题的 LightGBM 模型调优示例。代码将主要关注 oracle。加载数据和 tuner 类的实现代码保持不变,此处不再重复。完整的代码可以在本书的 GitHub 仓库中找到:mng.bz/oaep

7.2 使用随机搜索方法入门

在本节中,我们将介绍如何使用 KerasTuner 创建随机搜索方法来探索搜索空间并找到更好的超参数。随机搜索方法是 AutoML 中进行超参数调整的最简单和最传统的方法之一。标准的随机搜索方法随机探索搜索空间中的超参数组合。这种方法在大多数情况下已被经验证明比网格搜索方法更强大。

为什么随机搜索通常比网格搜索更好?

我们将通过一个例子来描述这一点。更多细节可以在 James Bergstra 和 Yoshua Bengio 的论文“Random Search for Hyper-Parameter Optimization”中找到(www.jmlr.org/papers/v13/bergstra12a.html)。假设我们有两个连续的超参数,xy,形成一个二维搜索空间。假设模型性能是一个与这些超参数相关的函数。更具体地说,它是由两个函数的加法函数组成的,每个函数都依赖于一个超参数:07-01-EQ01

不同的超参数对最终模型性能的影响不同,因此有些超参数的影响会比其他超参数小。假设超参数y相对于x具有边际效应,表示为 07-01-EQ02。在空间的两个边界(左侧和上方)上,我们提供了两个函数曲线,形成每个超参数及其函数的一维子空间。每个函数曲线的高度也可以理解为指示超参数对最终模型评估的重要性。如果我们使用网格搜索方法通过九次试验来探索搜索空间,它将搜索空间划分为桶,并采样一个网格点(见图 a),这给出了空间的均匀覆盖。在这种情况下,尽管超参数的重要性不同,但网格搜索为每个超参数的子空间提供了相等的覆盖,而随机搜索则提供了对超参数y(重要的超参数)子空间更彻底的覆盖,如图 b 所示。

07-01-unnumb

网格搜索与随机搜索的比较

KerasTuner 中的搜索方法实现为一个可以被调用的 Oracle 对象。在实现 Oracle 之前,我们需要理解 Oracle 函数与调优器函数之间的(参考逻辑)关系。

在搜索过程中调用的主要函数如列表 7.1 所示。调优器的 search()函数将在循环中调用两个主要函数。第一个,create_trial(),是 Oracle 的一个函数。它创建一个包含 Oracle 在当前试验中选择的超参数的试验对象,并将试验的状态设置为 RUNNING,意味着试验正在执行。超参数的采样是在一个名为 populate_space()的私有方法中完成的,这是我们需要实现的 Oracle 的主要函数。如果搜索方法是历史依赖的,我们将在采样之前根据评估结果更新它。试验对象创建后,它将携带超参数到调优器的主函数 run_trial(),正如我们在第六章所学,该函数用于实例化、训练、评估和保存当前试验中的 ML 模型。

列表 7.1 调优器函数与 Oracle 函数之间的参考逻辑

search (tuner)
    |-- create_trial (oracle)
        |-- populate_space (oracle)
    |-- run_trial (tuner)
        |-- instantiate, fit, evaluate model
        |-- save_model (tuner)
        |-- return the evaluation results

由于 KerasTuner 已经帮助我们封装了基 Oracle 类中的一些函数(例如 create_trial 函数),我们可以扩展基类并仅实现一个核心函数——populate_space()(该函数执行超参数采样并更新 Oracle)。

注意奥拉类包含一个 update_trial()函数,该函数使用 Tuner.run_trial()返回的值来更新奥拉。然而,不需要使用此函数来更新搜索方法。如果搜索方法需要根据历史评估进行更新,我们可以在进行超参数采样之前使用 populate_space()函数来处理这个问题。您将在 7.3 节中学习如何实现依赖历史的搜索方法。

由于随机搜索方法是历史无关的,populate_space()函数需要做的只是均匀随机采样超参数。我们使用基 Tuner 类的私有实用方法 _random_values 从搜索空间中生成随机样本。populate_space()函数的输出应该是一个包含试验状态和本次试验采样超参数值的字典。如果搜索空间为空或所有超参数都已固定,我们应该将试验状态设置为 STOPPED 以结束此试验。

列表 7.2 展示了如何实现随机搜索奥拉。尽管这可以忽略不计,但我们在这里包含初始化函数以供参考。您可以使用一些超参数来帮助控制搜索算法,例如随机种子,因此您可以添加这些超参数的属性。值得注意的是,搜索方法中的超参数不包含在搜索空间中,我们需要自己调整这些参数。它们被认为是超超参数,即用于控制超参数调整过程的超参数。我们将在接下来的章节中看到一些示例。

列表 7.2 随机搜索奥拉

class RandomSearchOracle(Oracle):

    def __init__(self, *args, **kwargs):                    ❶
        super().__init__(*args, **kwargs)

    def populate_space(self, trial_id):
        values = self._random_values()                      ❷
        if values is None:                                  ❸
            return {'status': <4> trial_lib.TrialStatus.STOPPED,
                    'values': None}
        return {'status': trial_lib.TrialStatus.RUNNING,    ❹
                'values': values}

❶ 奥拉(Oracle)的初始化函数

❷ 从搜索空间中随机采样的超参数值

❸ 检查采样到的超参数值是否有效

❹ 返回选定的超参数值和正确的试验状态

列表 7.3 展示了如何将随机搜索奥拉应用于调整使用 LightGBM 库实现的梯度提升决策树(GBDT)模型,以解决加利福尼亚房价预测任务。GBDT 模型按顺序构建多个树,并将每个新构建的树定位以解决先前树集成中的错误分类或弱预测。如果您不熟悉此模型,更多细节可以在附录 B 中找到。加载数据集和实现调整器的代码与上一章相同,因此我们不再展示。在这里,我们调整了 GBDT 模型的三个超参数:每棵树中的叶子数、树的数量(n_estimators)和学习率。经过 100 次搜索后,最佳发现的模型在测试集上实现了 0.2204 的均方误差(MSE)。

列表 7.3 使用自定义的随机搜索奥拉调整 GBDT 模型

def build_model(hp):
    model = lgb.LGBMRegressor(
        boosting_type='gbdt',
        num_leaves=hp.Int('num_leaves', 5, 50, step=1),
        learning_rate=hp.Float(
            'learning_rate', 1e-3, 1, sampling='log', default=0.01),
        n_estimators=hp.Int('n_estimators', 5, 50, step=1)
    )

    return model

>>> random_tuner = LightGBMTuner(
...     oracle=RandomSearchOracle(                         ❶
...         objective=kt.Objective('mse', 'min'),
...         max_trials=100,
...         seed=42),
...     hypermodel=build_model,
...     overwrite=True,
...     project_name='random_tuner')

>>> random_tuner.search(X_train, y_train, validation_data=(X_val, y_val))

>>> from sklearn.metrics import mean_squared_error         ❷
>>> best_model = random_tuner.get_best_models(1)[0]        ❷
>>> y_pred_test = best_model.predict(X_test)               ❷
>>> test_mse = mean_squared_error(y_test, y_pred_test)     ❷
>>> print(f'The prediction MSE on test set: {test_mse} ')

The prediction MSE on test set: 0.22039670222190072

❶ 为调整器提供自定义的随机搜索奥拉

❷ 检索并评估最佳发现的模型

为了展示搜索过程的样子,我们提取了所有搜索模型的评估性能,并按顺序绘制它们。模型按照试验完成的顺序记录在 oracle 的 end_order 属性中,在我们的例子中是 random_tuner.oracle.end_order。试验的完成顺序与开始顺序相同,因为我们在这个案例中没有进行并行试验。绘制搜索曲线的代码显示在列表 7.4 中。

列表 7.4 绘制搜索过程

import matplotlib.pyplot as plt

def plot_curve(x, y, xlabel, ylabel, title):
    plt.plot(x, y)
    plt.xlabel(xlabel)
    plt.ylabel(ylabel)
    plt.title(title)
    plt.show()

mse = [random_tuner.oracle.get_trial(trial_id).score for trial_id
➥ in random_tuner.oracle.end_order]
ids = list(range(len(mse)))
plot_curve(ids, mse, 'Trials in finishing order', 
    'Validation MSE', 'Searched results')

在图 7.2 中,我们可以看到在随机搜索过程中发现的模型评估结果波动很大。因为随机搜索不能考虑历史评估,所以后来发现的模型没有从先前结果中受益,并且不一定比早期的模型更好。

07-02

图 7.2 随机搜索过程中的模型评估结果

在下一节中,我们将介绍一种依赖于历史评估的顺序搜索方法,该方法可以利用历史评估来提高搜索效率。

7.3 自定义贝叶斯优化搜索方法

在本节中,我们介绍了一种基于模型的顺序搜索方法,称为贝叶斯优化。它被设计用来优化黑盒函数,这些函数没有解析形式的解。在 AutoML 的背景下,这种情况很常见,因为要优化的函数是模型评估性能。黑盒函数通常评估成本很高,这使得通过暴力随机采样和评估来找到全局最优解变得不切实际。由于模型训练和评估的成本,可能无法进行多次超参数搜索试验。贝叶斯优化方法解决这一挑战的关键思想与以下两个函数相关:

  • 我们训练一个称为代理函数(或代理模型)的函数来近似模型评估性能。从统计学的角度来看,这个代理函数是一个概率模型,它近似目标函数。我们根据我们对目标函数外观的信念(例如,稍后我们将使用高斯过程先验,这是最常用的先验)自行估计其先验分布。代理模型使用 ML 模型的历史评估进行训练,作为一种更便宜的方式来获取先前未见过的模型性能,尽管是近似的。这个过程与解决回归任务非常相似,其中每个模型都是一个实例。超参数是实例的特征,模型性能是目标。理论上,如果代理模型足够好,我们就不必对 ML 模型进行真实的训练和评估。但由于我们只有有限的学习数据(模型评估),在 AutoML 问题中这通常在实际上是不可能的。

  • 一旦我们有了代理模型,我们就可以采样一个新的超参数组合来创建一个用于评估的模型。为了进行采样,我们需要基于代理函数设计另一个函数,称为获取函数。此函数指定了比较 ML 模型(由超参数确定)的标准,以便我们可以选择最有希望的模型进行训练和评估。

如您所见,这两个函数对应于序列 AutoML 流程中的两个步骤。在更新步骤中,我们根据历史评估训练代理模型。在采样步骤中,我们使用获取函数来采样下一个要评估的模型。迭代这两个步骤将为我们提供额外的历史样本,以帮助训练一个更准确的代理模型。在本节的剩余部分,我们将提供一种贝叶斯优化搜索方法的逐步实现。在这个过程中,您将学习以下内容:

  • 如何向量化超参数以训练代理模型

  • 你应该选择哪种代理模型

  • 如何初始化训练初始代理模型的流程

  • 如何设计一个获取函数并根据它采样要评估的超参数

7.3.1 向量化超参数

由于贝叶斯优化搜索方法,就像其他基于模型的搜索方法一样,是根据访问过的样本来训练模型的,一个自然的问题是如何将超参数转换为模型可接受的特征。最常见的方法是将每个试验中选择的超参数编码为一个数值向量,表示在本试验中选择的 ML 管道的特征。将应用逆转换将采样步骤中选择的向量解码为原始超参数,以实例化 ML 管道。

让我们先实现一个用于向量化超参数的函数。我们将将其作为 Oracle 类的私有方法实现,命名为 _vectorize_trials。关键思想是逐个提取所有超参数并将它们连接成一个向量。在搜索过程中,所有试验都保存在 Oracle 类的名为 self.trials 的字典属性中。值和键分别代表试验对象及其唯一 ID。超参数保存在试验对象的属性中(trial.hyperparameters)。这是一个超参数容器,它包含试验中选定的超参数以及整个搜索空间结构。我们可以使用 trial.hyperparameters.values 检索每个试验中选定的超参数并将其放入字典中。然后,将试验中选定的超参数转换为向量就变成了将字典的值转换为向量的问题。如果我们的所有超参数值最初都是数值型的,例如学习率、单元数量和层数,我们可以直接逐个连接它们。然而,您需要注意以下问题:

  • 处理具有固定值的超参数—因为这些超参数不会影响模型之间的比较,我们可以明确地删除它们,这样搜索方法就不会考虑它们。这可以减轻搜索方法的负担并避免在搜索方法的更新中引入额外的噪声。

  • 处理不活跃的条件超参数—某些条件超参数可能不会在每次试验中被选中。例如,假设我们有一个名为'model_type'的超参数,用于在 MLP 和 CNN 之间进行选择。如果试验中选定的模型是 MLP,则 CNN 的超参数(如滤波器数量)将不会被选中和使用。这会导致转换后的向量长度不同,因此两个向量中相同位置的元素可能不对应于同一超参数。解决这个问题的简单方法是在向量中使用任何不活跃(未选中)的超参数的默认值。超参数容器提供了一个名为 is_active()的方法来检查超参数是否已被选中。如果超参数是活跃的,您可以附加其选中的值;如果不是,则提取保存在 hyperparameters.default 中的默认值并替换附加。

  • 处理不同尺度的超参数——超参数通常在不同的尺度上。例如,学习率通常小于 1,GBDT 模型中的树的数量可能大于 100。为了归一化超参数,我们可以使用累积概率将它们转换为 0 到 1 之间的值。图 7.3 显示了将离散搜索空间和连续搜索空间转换为相应的累积分布的两个示例。对于连续搜索空间,我们直接将其映射到 0 和 1 的区间。如果超参数在对数尺度上采样,将应用对数变换。对于离散搜索空间,我们假设每个值是均匀分布的,概率单位将根据空间中的值选择数量进行等分。我们使用每个概率桶中的中心值来表示每个值选择。

  • 处理模型类型等分类超参数——为了将分类超参数转换为数值特征,我们可以使用列表中特征的索引。例如,如果我们有四个要选择的模型[MLP, CNN, RNN, GBDT],列表可以转换为[0, 1, 2, 3],其中模型分别用 0, 1, 2, 3 表示。然后,该向量进一步根据将离散搜索空间转换为累积概率的机制归一化到 0 和 1。

07-03

图 7.3 基于累积概率归一化超参数值

列表 7.5 中的代码描述了向量化过程的细节。我们遍历所有现有的试验,将所有超参数转换为特征向量,并将对应模型的评估分数转换为一个响应向量。对于每个试验,我们忽略固定的超参数,遍历其余部分。如果一个超参数被检测为活动状态(在当前试验选择的管道中使用),我们将直接使用搜索方法选择的值。否则,使用默认值填充向量,使其与其他向量长度相同。为了归一化目的,向量中的值进一步替换为累积概率。如果一个试验完成,评估结果将附加到响应向量 y 中。因为对于某些指标,较小的值更好(例如 MSE),而对于其他指标,较大的值更好(例如分类准确率),我们将它们统一,使得所有情况下较大的值更好,通过将第一种类型指标的值乘以-1。

列表 7.5 将超参数编码为向量的私有方法

from keras_tuner.engine import hyperparameters as hp_module

class BayesianOptimizationOracle(oracle_module.Oracle):
    def _vectorize_trials(self):
        x, y = [], []
        for trial in self.trials.values():                              ❶
            trial_hps = trial.hyperparameters
            vector = []
            nonfixed_hp_space = [hp for hp in self.hyperparameters.space
                if not isinstance(hp, hp_module.Fixed)]                 ❷
            for hp in nonfixed_hp_space:
                if trial_hps.is_active(hp):                             ❸
                    trial_value = trial_hps.values[hp.name]
                else:
                    trial_value = hp.default                            ❹
                prob = hp_module.value_to_cumulative_prob(trial_value, hp)
                vector.append(prob)

            if trial.status == 'COMPLETED':
                score = trial.score
                if self.objective.direction == 'min':
                    score = -1 * score                                  ❺
            else:
                continue

            x.append(vector)
            y.append(score)

        x = np.array(x)
        y = np.array(y)
        return x, y

❶ 遍历所有试验

❷ 记录未固定的超参数

❸ 检测当前试验中是否选择了超参数

❹ 为未使用的超参数使用默认值

❺ 统一评估分数,使得较大的值始终更好

一旦我们得到了基于获取函数(稍后介绍)采样的向量格式的新的超参数集,我们需要将向量作为值输入到超参数容器中。逆变换过程简单,涉及以下步骤:

  1. 将向量中的累积概率转换为每个超参数的真实值。

  2. 将每个超参数的值输入到超参数容器中。

通过遍历搜索空间中的所有超参数,我们按照这两个步骤依次转换向量中的每个值。对于每个固定的超参数,默认值(以下列表中的 hp.value)被放入容器中。所有值都保存在超参数容器的字典(hps.values)中,并返回以帮助创建下一个试验。逆变换函数的实现介绍在列表 7.6 中。我们将在 populate_ 空间()函数中使用它来帮助转换由获取函数选择的向量。

列表 7.6 将向量解码为超参数的私有方法

class BayesianOptimizationOracle(oracle_module.Oracle):
    def _vector_to_values(self, vector):
        hps = hp_module.HyperParameters()        ❶
        vector_index = 0
        for hp in self.hyperparameters.space:
            hps.merge([hp])                      ❷
            if isinstance(hp, hp_module.Fixed):
                value = hp.value                 ❸
            else:
                prob = vector[vector_index]
                vector_index += 1
                value = hp_module.
➥ cumulative_prob_to_value(prob, hp)            ❹

            if hps.is_active(hp):
                hps.values[hp.name] = value      ❺
        return hps.values

❶ 创建一个空的超参数容器

❷ 将超参数合并到容器中

❸ 如果超参数被固定,则使用默认值

❹ 将累积概率转换回超参数值

❺ 将超参数的原始值放入容器中

超参数的编码应与搜索方法中采用的代理模型相匹配。在下一阶段,我们将使用高斯过程作为我们的代理模型,它接受向量输入,因此我们在这里采用向量表示。

注意:研究社区中的一些近期工作将超参数表示为树或图,其中树或图中的每个节点代表一个超参数,其叶子表示其条件超参数。这些结构擅长表示超参数之间的条件层次结构,我们可以使用一些基于树或图的先进搜索方法来直接遍历树或图以采样新的超参数组合。你可以在 Yi-Wei Chen 等人撰写的调查论文“自动机器学习技术”(ACM SIGKDD Explorations Newsletter, 2020)中找到更多内容。

7.3.2 基于历史模型评估更新代理函数

在 AutoML 的背景下,在给它任何数据之前,代理函数仅仅是一个先验,表示我们对真实超参数评估函数外观的主观信念。例如,一个常见的选择是高斯过程先验,如图 7.4(a)所示,它可以理解为由无限多个高斯随机变量组成的分布函数,描述了搜索空间中所有模型的评估性能。高斯过程由所有高斯变量的均值函数和协方差函数完全指定。中间的曲线是均值函数,表示所有高斯随机变量的平均值,我们可以用07-03-EQ03表示。x表示 AutoML 中的向量化超参数(在这里,我们只有一个超参数用于说明目的)。灰色范围表示高斯变量的标准差(STD),可以用07-04-EQ04表示。在这种情况下,每个纵向截面代表一个高斯分布。平均值近似于给定所选超参数x的 ML 管道的评估性能。方差(或 STD)表示近似的不确定性。因为变量之间存在相关性,为了完全描述高斯过程,我们需要定义一个协方差函数,07-04-EQ05(通常称为核函数),来模拟任意两个高斯变量之间的协方差,具体来说,07-04-EQ06。变量之间的协方差对于帮助预测给定未见过的超参数的分布非常重要。例如,如果所有变量都是独立的,这意味着在给定其超参数的任何两个 ML 管道的性能中不存在条件相关性。

07-04

图 7.4 高斯过程代理模型的更新

在这种情况下,我们有的噪声是白噪声(对应于高斯过程中的白核)。衡量 ML 管道性能的唯一方法是对它们进行逐个评估,并通过对每个模型进行多次评估来估计每个模型的噪声。这通常不是我们想要的,因为我们期望减少模型评估的数量,而在实践中通常不是这样,因为相似的超参数设置往往会生成性能更接近的 ML 管道。随着收集越来越多的数据(评估模型),预测的均值函数将穿过新的点,不确定性(STD)将降低,如图 7.4(b)所示。

核函数的选择取决于我们对目标函数平滑度的假设。它是一个超超参数,不包括在搜索空间中,应手动选择或调整。常见的核函数选择是 Matérn 核函数。它有一个参数 07-04-EQ07 用于设置函数的平滑度程度。我们通常将 07-04-EQ07 设置为 0.5、1.5 或 2.5,分别对应于我们假设函数应该是一、二或三次可微的。当 07-04-EQ07 接近无穷大时,Matérn 核函数就接近一个称为 平方指数核函数(也称为 径向基函数核,您可能在前一章的 SVM 模型中记得),这反映了目标函数是无限可微的。还有一些用于建模周期函数的核函数,例如 Exp-Sine-Squared 核函数。您可以在 Carl Edward Rasmussen 和 Christopher K. I. Williams 所著的《机器学习中的高斯过程》(MIT Press,2006 年)一书中了解更多关于不同核函数的信息。

要实现用于贝叶斯优化的高斯过程模型,我们可以使用 scikit-learn 库中的 gaussian_process 模块。在初始化预言者时,我们可以使用 Matérn 核函数创建一个高斯过程模型。alpha 参数用于指定在模型评估过程中引入的随机噪声量。我们通常将其设置为一个小数,并且根据经验,这足以并且对考虑环境噪声来说是好的。我们在 populate_space() 函数中实现了高斯过程模型的初始训练和顺序更新。一旦我们有了每一轮中搜索的新模型的评估,高斯过程模型就会更新。最初,我们随机采样几个模型进行评估,然后对高斯过程模型进行初始训练。如果未在 num_initial_points 属性中提供,则随机样本的数量定义为 2,以确保核函数可以实际应用。(根据经验,搜索空间中超参数数量的平方根是初始化高斯过程模型时使用的随机点的良好数量。)

一旦我们有了足够的随机样本,我们将超参数和评估向量化,并通过调用 fit() 函数来拟合高斯过程模型,如列表 7.7 所示。后来,在顺序搜索过程中,每当评估新的模型时,我们都会基于所有完成的试验重新拟合模型。在这里,我们只描述高斯过程模型的更新,而不展示基于获取函数的采样过程。您将在下一步中看到 populate_space() 函数的完整实现。

列表 7.7 在预言者中创建和更新高斯过程模型

from sklearn import exceptions
from sklearn import gaussian_process

class BayesianOptimizationOracle(oracle_module.Oracle):
    def __init__(self,
                 objective,
                 max_trials,
                 num_initial_points=None,
                 seed=None,
                 hyperparameters=None,
                 *args, **kwargs):
        super(BayesianOptimizationOracle, self).__init__(
            objective=objective,
            max_trials=max_trials,
            hyperparameters=hyperparameters,
            seed=seed,
            *args, **kwargs)
        self.num_initial_points = num_initial_points 
➥ or 2                                                            ❶
        self.seed = seed or random.randint(1, 1e4)
        self.gpr = self._make_gpr()                                ❷

    def _make_gpr(self):
        return gaussian_process.GaussianProcessRegressor(
            kernel=gaussian_process.kernels.Matern(nu=2.5),
            alpha=1e-4,
            normalize_y=True,
            random_state=self.seed)

    def populate_space(self, trial_id):

        if self._num_completed_trials() < self.num_initial_points:
            return self._random_populate_space()                   ❸

        x, y = self._vectorize_trials()                            ❹
        try:
            self.gpr.fit(x, y)                                     ❺
        except exceptions.ConvergenceWarning:
            raise e

❶ 如果未指定,则使用 2 作为初始随机点的数量

❷ 初始化高斯过程模型

❸ 对初始化高斯过程模型进行随机采样

❹ 向量化所有试验

❺ 根据完成的试验拟合高斯过程模型

拟合 n 个数据点的复杂度会是 O(n³),这相当耗时。这是高斯过程在基于模型的搜索方法中的主要缺点。不同的方法使用其他代理模型,例如基于树的模型(例如,随机森林)和神经网络,来克服这一点。您可以从 Frank Hutter、Lars Kotthoff 和 Joaquin Vanschoren 合著的《自动机器学习:方法、系统、挑战》(Springer Nature,2019 年)一书中了解更多关于用于自动机器学习中的贝叶斯优化的代理模型。

7.3.3 设计获取函数

一旦我们有了代理模型,我们需要一个获取函数来帮助采样下一个机器学习管道以进行评估,并创建一个封闭的顺序搜索循环。让我们首先介绍获取函数的设计标准,然后讨论如何根据该函数采样一个点。

获取函数的设计标准

一个好的获取函数应该衡量一个点对于采样的吸引力。吸引力是两个方面的权衡:利用探索。利用意味着我们希望发现代理模型预测为好的点。这将考虑到我们已经探索过的有希望的领域,但缺乏探索未知领域的能力。例如,在图 7.5 中,假设我们的真实目标函数曲线是 f(x),并且给定五个点,我们通过三个点拟合了一个高斯过程,其均值函数为 07-03-EQ03。超参数 x[a] 附近的区域比点 x[b] 附近的区域探索得更多,导致 x[b] 附近的 STD 比 x[a] 附近的 STD 大得多。如果我们只考虑预测的均值函数,充分利用利用能力,x[a] 比较好于 x[b]。然而,x[a] 在目标函数上比 x[b] 差。这需要一个获取函数来平衡利用(均值)和探索(方差)。

07-05

图 7.5 更新高斯过程代理模型

现在我们来看三种常用的获取函数及其实现。

上置信界

假设 y 的较大值更好,上置信界 (UCB) 通过将均值和 STD 函数相加以简单直接地平衡利用和探索: 07-05-EQ08,其中 07-05-EQ09 是用户指定的用于平衡两个术语之间权衡的正参数。如图 7.6 所示,曲线 g(x) 是当 07-05-EQ10 时的 UCB 获取函数。如果较小的值更可取,我们可以使用 07-05-EQ11

07-06

图 7.6 上置信界

实现该函数的示例代码如下所示。在这里,self.gpr 表示拟合的高斯过程回归器,x表示 ML 管道的超参数向量。

列表 7.8 计算上置信界

def upper_confidence_bound(x):
    x = x.reshape(1, -1)
    mu, sigma = self.gpr.predict(x, return_std=True)
    return mu + self.beta * sigma

改善概率

改善概率(PI)衡量一个样本实现比迄今为止找到的最佳样本更好的性能的概率。如图 7.7 所示,给定最佳点07-06-EQ12和相应的目标值07-06-EQ13,点x实现比07-06-EQ12更好的性能的概率等于由07-03-EQ0307-04-EQ04定义的高斯分布的阴影区域。我们可以借助正态分布的累积分布函数来计算这个概率。

07-07

图 7.7 改进概率

PI 的代码实现如列表 7.9 所示。如果我们假设不存在环境噪声,我们可以直接使用评估目标,或者我们可以设置一个噪声值(当使用 scikit-learn 创建回归器时 alpha 不等于 0)并使用基于噪声的高斯过程回归器给出的预测值选择最佳点。PI 的一个问题是它倾向于查询接近我们已评估的点,尤其是最佳点,导致对已探索区域的利用度高,探索能力低。

列表 7.9 计算改进概率

def _probability_of_improvement(x):
    x_history, _ = self._vectorize_trials()              ❶
    y_pred = self.gpr.predict(
        x_history, return_std=False)                     ❷
    y_best = max(yhat)   
    mu, sigma = self.gpr.predict(x, return_std=True)     ❸
    z = (mu - y_best) / (sigma+1E-9)                     ❹
    prob = norm.cdf(z)                                   ❹
    return prob

❶ 向量化所有试验

❷ 计算迄今为止找到的最佳代理分数

❸ 通过代理函数计算均值和标准差

❹ 计算改进概率

期望改进

期望改进(EI)通过使用改进幅度的量来加权计算改进概率,从而缓解了 PI 的问题(见图 7.8)。这相当于计算到目前为止找到的最优值的期望改进:07-07-EQ14

07-08

图 7.8 某点的期望改进

列表 7.10 提供了一个显式方程,用于利用正态分布的概率密度函数(norm.pdf)和累积分布函数(norm.pdf)来计算 EI。该方程的详细推导可以在 Donald R. Jones、Matthias Schonlau 和 William J. Welch 的论文“Efficient Global Optimization of Expensive Black-Box Functions”中找到(mng.bz/5KlO)。

列表 7.10 计算期望改进

def _expected_improvement(x):
    x_history, _ = self._vectorize_trials()
    y_pred = self.gpr.predict(x_history, return_std=False)
    y_best = max(yhat)
    mu, sigma = self.gpr.predict(x, return_std=True)
    z = (mu - y_best) / (sigma+1E-9)
    ei = (mu - y_best) * norm.cdf(z) + sigma * norm.pdf(z)
    return ei

为了增强 PI 和 EI 的探索能力,我们还可以将一个正参数添加到最优目标值中:(07-08-EQ15)。参数 07-08-EQ16 的值越大,探索量就越大。在实践中,UCB 和 EI 是现有 AutoML 库中最常用的获取函数类型。UCB 在平衡探索和利用方面相对更直接。

7.3.4 通过获取函数采样新的超参数

现在你已经知道了如何创建一个获取函数,现在是时候利用这个函数来采样下一个试验中评估的超参数值。目标是找到使获取函数达到最大值的超参数向量。这是一个约束优化问题,因为每个超参数都被定义的搜索空间所限制。一种用于最小化有边界约束的函数的流行优化方法是 L-BFGS-B 算法

注意:BFGS 是一种优化算法,它从一个初始向量开始,根据逆海森矩阵的估计迭代地将其优化到局部最优。我们可以使用不同的初始化向量多次优化以获得更好的局部最优。L-BFGS 通过近似优化它,以便在优化过程中使用的内存量可以限制。L-BFGS-B 进一步扩展了算法以处理搜索空间的边界框约束。更多细节可以在 D. C. Liu 和 J. Nocedal 的文章中找到,“关于大规模优化的有限内存方法”,Mathematical Programming 45, no. 3 (1989): 503-528 (doi:10.1007/BF01589116)。

该方法使用 scipy Python 工具包中的 optimize 模块实现,如列表 7.11 所示。我们首先实现一个名为 get_hp_bounds() 的函数来收集超参数的边界。因为我们已经根据累积概率对超参数进行了归一化,所以边界被设置为每个超参数的 0 到 1。我们在 x_seeds 中均匀生成 50 个不同的初始化向量,并使用 50 个不同的初始化向量优化获取函数 50 次。优化是在连续向量空间中进行的,并且可以使用 Oracle 基类中定义的 self._vector_to_values 函数将最优向量转换回原始超参数值。

列表 7.11 基于 UCB 获取函数的样本

from scipy import optimize as scipy_optimize

class BayesianOptimizationOracle(oracle_module.Oracle):
    def __init__(self,
                 objective,
                 max_trials,
                 beta=2.6,
                 num_initial_points=None,
                 seed=None,
                 hyperparameters=None,
                 *args, **kwargs):
        super(BayesianOptimizationOracle, self).__init__(
            objective=objective,
            max_trials=max_trials,
            hyperparameters=hyperparameters,
            seed=seed,
            *args, **kwargs)

        self.num_initial_points = num_initial_points or 2
        self.beta = beta
        self.seed = seed or random.randint(1, 1e4)
        self._random_state = np.random.RandomState(self.seed)
        self.gpr = self._make_gpr()

    def _make_gpr(self):
        return gaussian_process.GaussianProcessRegressor(
            kernel=gaussian_process.kernels.Matern(nu=2.5),
            alpha=1e-4,
            normalize_y=True,
            random_state=self.seed)
    def _get_hp_bounds(self):
        nonfixed_hp_space = [hp for hp in self.hyperparameters.space
            if not isinstance(hp, hp_module.Fixed)]
        bounds = []
        for hp in nonfixed_hp_space:
            bounds.append([0, 1])                                                 ❶
        return np.array(bounds)

    def populate_space(self, trial_id):

        if self._num_completed_trials() < self.num_initial_points:
            return self._random_populate_space()                                  ❷

        x, y = self._vectorize_trials()
        try:
            self.gpr.fit(x, y)
        except exceptions.ConvergenceWarning:
            raise e
        def _upper_confidence_bound(x):
            x = x.reshape(1, -1)
            mu, sigma = self.gpr.predict(x, return_std=True)
            return -1 * (mu + self.beta * sigma)                                  ❸

        optimal_val = float('inf')
        optimal_x = None
        num_restarts = 50                                                         ❹
        bounds = self._get_hp_bounds()
        x_seeds = self._random_state.uniform(bounds[:, 0], bounds[:, 1],
                                             size=(num_restarts, bounds.shape[0]))❹
        for x_try in x_seeds:
            result = scipy_optimize.minimize(_upper_confidence_bound,
                                             x0=x_try,
                                             bounds=bounds,
                                             method='L-BFGS-B')                   ❺
            if result.fun[0] < optimal_val:
                optimal_val = result.fun[0]
                optimal_x = result.x

        values = self._vector_to_values(optimal_x)                                ❻
        return {'status': trial_lib.TrialStatus.RUNNING,
                'values': values}

❶ 添加超参数的归一化边界

❷ 对训练初始高斯过程回归器的随机搜索

❸ 为了最小化的目的,UCB 得分的符号被反转。

❹ 在边界内均匀生成 50 个随机向量

❺ 最小化反转的获取函数

❻ 将最优向量映射到原始超参数值

通过结合向量化函数、采样函数和创建高斯过程回归器的函数,我们可以创建一个完整的贝叶斯优化算子,用于执行 AutoML 任务。接下来,我们将使用它来调整房价预测任务的 GBDT 模型。

7.3.5 使用贝叶斯优化方法调整 GBDT 模型

我们以与随机搜索部分相同的方式加载数据并分割数据,并使用定制的调整器来调整 GBDT 模型。唯一的区别是我们将随机搜索算子更改为贝叶斯优化算子,如下所示。最佳模型在最终测试集上的均方误差(MSE)为 0.2202。

列表 7.12 基于 UCB 获取函数的采样

>>> bo_tuner = LightGBMTuner(
...     oracle=BayesianOptimizationOracle(
...         objective=kt.Objective('mse', 'min'),
...         max_trials=100,
...         seed=42),              ❶
...     hypermodel=build_model,
...     overwrite=True,
...     project_name='bo_tuner')

>>> bo_tuner.search(X_train, y_train, validation_data=(X_val, y_val))

>>> from sklearn.metrics import mean_squared_error
>>> best_model = bo_tuner.get_best_models(1)[0]
>>> y_pred_test = best_model.predict(X_test)
>>> test_mse = mean_squared_error(y_test, y_pred_test)
>>> print('The prediction MSE on test set: {}'.format(test_mse))

The prediction MSE on test set: 0.2181461078854755

❶ 使用定制的贝叶斯优化搜索算子

让我们比较贝叶斯优化搜索与随机搜索的结果,以更好地理解这两种方法。我们按顺序提取了在搜索过程中发现的全部模型的评估性能。图 7.9(a)直接显示了在验证集上评估的发现模型的均方误差(MSE)。与随机搜索方法不同,贝叶斯优化搜索的模型性能随着搜索过程的继续而逐渐稳定。这是因为贝叶斯优化考虑了历史信息,并可以利用利用性进行搜索,因此后来发现的模型可能比早期发现的模型具有相当甚至更好的性能。图 7.9(b)显示了随着搜索过程的进行,迄今为止找到的最佳模型的性能。我们可以看到,随机搜索在开始时表现略好,但在后期变得较差。这是因为随机搜索在开始时比贝叶斯优化搜索能更好地探索搜索空间,但随着收集的历史数据量增加,这些信息可以被利用来改善搜索过程。

尽管在这个例子中贝叶斯优化方法优于随机搜索,但在实际应用中并不总是如此,尤其是在搜索空间较小且存在大量分类和条件超参数的情况下。我们应该根据搜索空间的大小、搜索迭代次数以及时间和资源限制(如你所经历的,纯贝叶斯优化方法运行速度远慢于随机搜索)来选择和调整不同的搜索方法。如果没有指定具体限制,贝叶斯优化搜索是一个不错的起点。

07-09

图 7.9 比较贝叶斯优化和随机搜索的结果

除了增加的复杂性之外,当应用贝叶斯优化时,你可能会遇到另一个问题,即局部最优问题。尽管我们在优化采样获取函数时尝试探索多个初始化点,但如果基于历史样本的代理模型拟合不佳,或者获取函数过于偏向于利用,那么仍然很可能会总是从局部区域采样。这样做会导致对局部区域的集中利用,而忽略探索其他区域,如果评估性能表面不是凸的。除了增加获取函数的探索偏好,例如通过减少 UCB 获取函数中的07-05-EQ09参数,我们还有以下两个常用的技巧来防止贝叶斯优化收敛到局部最优:

  • 多次进行贝叶斯优化搜索,并使用不同的随机种子来采样不同的随机点以拟合初始代理模型。

  • 以动态方式结合贝叶斯优化和随机搜索:在贝叶斯优化搜索的每几次迭代(比如说,五次)之后进行一次随机搜索迭代,交替进行两种方法。

此外,如果搜索迭代次数较多,并且你有时间,那么对每个发现的模型使用交叉验证而不是简单地在一个固定的验证集上评估每个模型,这是一个好习惯。这有助于防止搜索方法在验证集上过度拟合,并且对于任何搜索算法通常都是实际有用的。

7.3.6 恢复搜索过程和恢复搜索方法

由于 AutoML 过程通常相当长,并且可能会意外中断,我们可以添加两个辅助函数来帮助恢复搜索过程并恢复预言者(参见列表 7.13)。KerasTuner 的基本 Oracle 类提供了两个可以扩展的函数,用于记忆和重新加载历史试验和元数据以恢复预言者。首先,我们可以扩展 get_state()函数,该函数在搜索过程中记忆预言者历史试验的状态和参数。此函数将在每个搜索循环中被调用以保存试验和预言者的当前状态。为了实现它,我们首先需要调用基类的 get_state()函数以获取当前试验的状态字典,然后使用搜索方法的唯一超参数更新它。例如,我们可以在状态对象中保存随机种子、随机初始化试验的数量以及 UCB 获取函数中的利用-探索权衡参数。其次,为了重新加载预言者的状态,我们可以扩展 set_state()函数。该函数将访问从磁盘重新加载的先前状态,并检索有关所有历史试验和预言者参数的信息。例如,在贝叶斯优化预言者中,我们可以调用 set_state()函数来检索所有模型评估信息,并使用加载的状态字典逐个恢复预言者的属性。

列表 7.13 恢复预言者

class BayesianOptimizationOracle(oracle_module.Oracle):

    def get_state(self):
        state = super().get_state()
        state.update({
            'num_initial_points': self.num_initial_points,
            'beta': self.beta,
            'seed': self.seed,
        })                                            ❶
        return state

    def set_state(self, state):
        super().set_state(state)                      ❷
        self.num_initial_points = state[
➥ 'num_initial_points']                              ❸
        self.beta = state['beta']                     ❸
        self.seed = state['seed']                     ❸
        self._random_state = np.random.RandomState(   ❸
            self.seed)                                ❸
        self.gpr = self._make_gpr()                   ❸

❶ 在状态中保存特定于预言者的配置

❷ 重新加载历史状态

❸ 恢复贝叶斯优化预言者

在恢复搜索过程时,我们可以使用想要从中恢复的项目名称初始化 tuner,并以前述方式进行搜索。唯一的区别是在初始化期间将 overwrite 参数设置为 False,这样 tuner 将自动恢复与工作目录中现有项目(以下列表中的 bo_tuner)具有相同名称的搜索过程。我们已实现的 set_state()函数将随后被调用以帮助恢复预言者。

列表 7.14 恢复预言者

bo_tuner = LightGBMTuner(
    oracle=BayesianOptimizationOracle(
        objective=kt.Objective('mse', 'min'),
        max_trials=100,
        seed=42),
    hypermodel=build_model,
    overwrite=False,           ❶
    project_name='bo_tuner')   ❷

bo_tuner.search(X_train, y_train, validation_data=(X_val, y_val))

❶ 如果已存在,则不会覆盖命名项目

❷ 提供要恢复和/或保存搜索过程的项目名称

下一个部分将介绍另一种常用的基于历史的方法,它不需要选择代理模型或获取函数。

7.4 自定义进化搜索方法

进化搜索方法是一种受生物行为启发的启发式搜索方法。在 AutoML 中已被广泛使用的一种最流行的方法是基于种群的进化搜索方法,它通过以下四个步骤模拟生物种群的发展:

  1. 初始种群生成—随机生成一组初始机器学习管道并评估它们以形成初始种群。在开始之前,我们应该预先定义种群的大小。

  2. 父代选择—选择最适应的管道,称为父代,用于繁殖新的子管道(后代)以在下一个试验中进行评估。

  3. 交叉和变异—这些操作可以根据父代繁殖新的后代。交叉意味着我们交换两个父代管道的一些超参数以形成两个新的管道。变异意味着我们随机改变父代或从交叉操作生成的后代的一些超参数以引入一些变异。此操作模仿遗传变异中的“染色体中的调整”,以增强搜索过程中的探索能力。交叉和变异操作不必都执行。我们可以只用其中一个来生成新的后代。例如,我们不必结合两个管道,我们可以在每个试验中从种群中选择一个父代机器学习管道,并对其一个或多个超参数进行变异,以生成下一个要评估的管道。

  4. 生存者选择(种群再生)—在新的后代被评估后,此步骤通过用新的后代替换最不适应的管道来重新创建一组新的机器学习管道种群。

步骤 2 到 4 在搜索过程中迭代执行,以纳入新的评估,如图 7.10 所示。

07-10

图 7.10 基于人群的进化搜索生命周期

7.4.1 进化搜索方法中的选择策略

虽然交叉和变异步骤决定了我们如何从现有管道中创建新的后代,但在设计良好的进化方法中,两个选择步骤中选择的策略(父代选择和生存者选择)可能更为重要。选择步骤应在搜索过程中平衡利用和探索。这里的利用代表我们希望多么强烈地选择具有良好评估性能的管道作为父代。探索意味着引入更多的随机性来尝试未探索的区域,而不是仅仅关注最适应的管道。利用和探索之间的权衡也被称为进化方法文献中选择强度选择多样性之间的平衡。让我们看看三种流行的选择方法。

比例选择

比例选择(或 轮盘赌选择)中,我们根据概率分布选择一个个体。选择个体的概率与其适应度成正比。例如,如果我们想选择一个用于分类的机器学习管道,我们可以使用准确度来衡量每个管道的适应度。管道的准确度越高,我们应该分配给它的概率就越大。一个管道 i 可以以概率07-10-EQ17被选为父代,其中 f[i] 表示管道 i 的非负准确度,分母是种群中所有管道准确度的总和。在幸存者选择步骤中,我们可以使用迄今为止探索的所有管道的准确度总和,并采样多个个体(无重复)来形成下一次搜索循环的种群。尽管这是一个流行的方法,但它存在一些问题。值得注意的是,存在过早收敛的风险,因为如果某些管道的准确度远高于其他管道,它们倾向于被反复选择。

排名选择

排名选择 采用与比例选择相似的战略,但使用管道的适应度排名来计算概率。例如,假设我们在种群中有三个管道,其准确度排名为 1、2 和 3。我们可以分别赋予它们07-10-EQ1807-10-EQ1907-10-EQ20的概率,从它们中选择一个父代。概率的设计平衡了选择强度和多样性。这里的例子是一个线性排名选择策略,其中概率与个体的排名成正比。我们也可以使用非线性概率来增强利用或探索。例如,通过给排名更高的管道分配更高的比例概率,我们在选择过程中更倾向于利用而不是探索。

排名选择通常比比例选择表现更好,因为它通过将所有个体映射到统一尺度来避免比例选择中的尺度问题。例如,如果所有管道的准确度得分都很接近,排名选择仍然可以根据它们的排名来区分它们。此外,如果一个管道比其他所有管道都好,无论它的适应度相对于其他管道如何,它被选为父代或幸存者的概率不会改变。与比例搜索相比,这牺牲了一些选择强度,但在一般情况下提供了更稳健的选择强度和多样性平衡。

比赛选择

锦标赛选择 是一个两步选择过程。它首先随机选择一定数量的候选人,然后从他们中挑选出最佳者作为父代进行交叉和变异。如果我们给排名最后的 k 个个体分配 0 概率,其中 k 是锦标赛选择中用于比较的候选个体数量,那么它可以转换为一个特殊的排序选择类型。其余个体分配的概率为 07-10-EQ21,其中 r[i] 表示管道 i 在管道中的排名,p 是种群大小,而 07-10-EQ22 是二项式系数 (07-10-EQ23)。通过增加锦标赛选择中的候选人数,我们可以增加选择强度(利用)并减少选择多样性(探索),因为只有候选者中的最佳者将被选为父代。考虑两种额外情况,如果候选大小 (k) 为 1,则相当于从种群中随机选择一个个体。如果候选大小等于种群大小,选择强度达到最大,因此种群中的最佳个体将被选为父代。

除了模型评估性能外,我们还可以根据我们对最优模型的期望在选择过程中指定其他目标。例如,我们可以创建一个函数来考虑管道的准确性和复杂度度量(如每秒浮点运算次数,或 FLOPS),并使用函数值来为每个管道分配一个概率。对于那些对更多细节和其他选择策略感兴趣的人,请参阅 Dan Simon 所著的《进化优化算法》(Wiley,2013 年)。

7.4.2 老化进化搜索方法

在本节中,我们将实现一种名为 老化进化搜索 的进化搜索方法,该方法由 Google Brain 的研究人员在“Regularized Evolution for Image Classifier Architecture Search”(arxiv.org/abs/1802.01548)中提出。它最初是为了搜索最佳神经网络架构而提出的,但可以推广到各种 AutoML 任务。该方法使用锦标赛选择来选择父代管道进行繁殖,并使用启发式老化选择策略进行幸存者选择。管道的“年龄”(或试验)意味着搜索过程中的迭代次数。当试验出生(开始)时,我们将其定义为 0。当有 N 个更多试验被选择并执行后,年龄变为 N。结合基于种群的进化搜索方法的四个核心步骤,我们详细阐述老化进化搜索方法如下:

  1. 初始种群生成—随机采样一组机器学习管道并评估它们以形成初始种群。

  2. 父代选择——在每次搜索迭代中,根据锦标赛选择方法从种群中选择一个父代。

  3. 变异——随机选择父代的一个超参数,并将其值随机改变为另一个不同的值。如果生成的后代之前已经被探索过,我们将将其视为碰撞,并将重试变异步骤,直到选择一个有效的后代或达到最大碰撞次数。我们使用哈希字符串来表示试验中的超参数,以检查后代是否已经被探索过。

  4. 幸存者选择——在生成新的后代之后,我们保留最新的采样试验作为新种群。例如,假设我们的种群大小是 100。当试验 101 完成时,种群中的第一个(最旧的)试验将被移除,新的(最年轻的)试验将被添加进去。这就是为什么这种方法被称为老化进化方法。选择最新的试验作为幸存者应该增强利用能力,因为我们假设旧的试验将比最新的试验表现得更差。

该过程在图 7.11 中进行了可视化。我们可以看到,这种方法没有使用交叉操作;仅使用变异操作来生成新的后代。

07-11

图 7.11 老化进化搜索生命周期

我们需要预先定义两个主要的超超参数来控制算法:锦标赛选择策略中的种群大小和候选大小。它们有助于平衡探索和利用。如果我们使用较大的种群大小,更多的旧试验将作为幸存者被保留,并可能被选为父代以繁殖后代。这将增加探索能力,因为旧试验通常比新试验差,种群中的多样性也会增加。如果我们选择较大的候选大小,选择强度将增加,正如我们之前提到的,这增加了该方法利用能力。

列表 7.15 展示了老化进化算子的实现方式。我们创建一个列表来保存种群试验的 ID。随机初始化试验的数量应该大于种群大小,以便种群列表能够被填满。看看核心函数,populate_space()。一开始,试验是随机抽取来形成种群的。种群创建后,在每次搜索循环中,我们根据试验的结束顺序进行生存选择,以维持固定的种群大小。然后我们通过随机选择一组候选者并进行锦标赛选择,从中挑选出最佳者作为父代(best_candidate_trial)。我们使用 _mutate 函数变异父代试验的随机选择的超参数,并将后代的超参数值以及后代试验的状态返回并放入一个字典中。状态设置为 RUNNING,意味着试验准备就绪,可以进行评估。

列表 7.15 进化搜索算子

import random
import numpy as np
from keras_tuner.engine import hyperparameters as hp_module
from keras_tuner.engine import oracle as oracle_module
from keras_tuner.engine import trial as trial_lib

class EvolutionaryOracle(oracle_module.Oracle):
    def __init__(self,
                 objective,
                 max_trials,
                 num_initial_points=None,
                 population_size=20,
                 candidate_size=5,
                 seed=None,
                 hyperparameters=None,
                 *args, **kwargs):
        super().__init__(
            objective=objective,
            max_trials=max_trials,
            hyperparameters=hyperparameters,
            seed=seed,
            *args, **kwargs)
        self.population_size = population_size
        self.candidate_size = candidate_size
        self.num_initial_points = num_initial_points or self.population_size
        self.num_initial_points = max(self.num_initial_points, population_size)❶
        self.population_trial_ids = []                                         ❷
        self.seed = seed or random.randint(1, 1e4)
        self._seed_state = self.seed
        self._max_collisions = 100

    def _random_populate_space(self):
        values = self._random_values()
        if values is None:
            return {'status': trial_lib.TrialStatus.STOPPED,
                    'values': None}
        return {'status': trial_lib.TrialStatus.RUNNING,
                'values': values}

    def _num_completed_trials(self):
        return len([t for t in self.trials.values() if t.status == 'COMPLETED'])

    def populate_space(self, trial_id):

        if self._num_completed_trials() 
➥ < self.num_initial_points:                                                  ❸
            return self._random_populate_space()

        self.population_trial_ids = self.end_order[
➥ -self.population_size:]                                                     ❹

        candidate_indices = np.random.choice(                                  ❺
            self.population_size, self.candidate_size, replace=False
        )
        self.candidate_indices = candidate_indices
        candidate_trial_ids = list(
            map(self.population_trial_ids.__getitem__, candidate_indices)
        )

        candidate_scores = [self.trials[trial_id].score 
➥ for trial_id in candidate_trial_ids]                                        ❻
        best_candidate_trial_id =
➥ candidate_trial_ids[np.argmin(candidate_scores)]
        best_candidate_trial = self.trials[best_candidate_trial_id]

        values = self._mutate(best_candidate_trial)                            ❼

        if values is None:                                                     ❽
            return {'status': trial_lib.TrialStatus.STOPPED, 'values': None}

        return {'status': trial_lib.TrialStatus.RUNNING,
                'values': values}

❶ 确保随机初始化试验能够填满种群

❷ 一个列表用于保存种群试验的 ID

❸ 随机选择用于初始化种群的个体

❹ 基于试验年龄的生存选择

❺ 从种群中选择候选试验

❻ 根据性能获取最佳父代候选者

❼ 变异父代随机选择的超参数

❽ 如果后代无效(已经评估过)则停止试验

现在我们来看如何实现变异操作。

7.4.3 实现简单的变异操作

理想情况下,只要超参数没有被固定,我们就可以将其变异为其他值。然而,如果选定的超参数是一个条件超参数,改变它可能会影响其他超参数。例如,如果我们选择了模型类型超参数进行变异,并且其值从 MLP 变为决策树,原本不活跃的树深度超参数将变为活跃,我们需要为其分配一个特定的值(见图 7.12)。因此,我们需要检查变异超参数是否为条件超参数。如果是,我们需要为其后代超参数(图中由子节点表示的超参数)随机分配值。

因此,我们首先收集父试验(best_trial)中的非固定和活动超参数,并从中随机选择一个超参数进行变异。然后我们创建一个名为 hps 的超参数容器实例,用于保存新后代的超参数值。通过遍历搜索空间中的所有超参数,我们逐一生成它们的值并将它们输入到容器中。请注意,我们需要遍历搜索空间中的所有活动超参数,而不仅仅是处理父试验中的活动超参数,因为某些非活动超参数在条件超参数变异的情况下可能会变为活动状态(参见图 7.12)。

07-12

图 7.12 由于变异,一个非活动超参数可能变为活动状态

对于在父试验中活动但不是所选变异超参数的任何超参数,我们将它的原始值分配给它并继续变异操作。对于所选变异超参数,我们随机选择一个新的值。假设所选变异超参数是一个条件超参数。在其值变异后,其子代超参数,在后代中变为活动状态,也将被随机选择值分配。子代超参数是否活动由 hps.is_active(hp) 语句确定,如列表 7.16 所示。新后代生成后,我们通过从基 Oracle 类继承的哈希函数 (_compute_values_hash) 检查它是否已经被评估过。如果后代与之前的试验冲突,我们重复变异过程。我们继续这样做,直到生成一个有效的后代或达到最大冲突次数。最终后代的哈希值被收集到一个 Python 集合(self._tried_so_far)中,用于检查未来的试验。

列表 7.16 在父试验中变异超参数

def _mutate(self, best_trial):

        best_hps = best_trial.hyperparameters               ❶

        nonfixed_active_hps = [hp for hp in self.hyperparameters.space
            if not isinstance(hp, hp_module.Fixed) and 
➥ best_hps.is_active(hp)]                                  ❷

        hp_to_mutate = np.random.choice(
            nonfixed_active_hps, 1)[0]                      ❸

        collisions = 0
        while True:
            hps = hp_module.HyperParameters()

            for hp in self.hyperparameters.space:           ❹
                hps.merge([hp])

                if hps.is_active(hp):

                    if best_hps.is_active(hp.name) and hp.name != 
➥ hp_to_mutate.name:                                       ❺
                        hps.values[hp.name] = best_hps.values[hp.name]
                        continue
                    hps.values[hp.name] = 
➥ hp.random_sample(self._seed_state)                       ❻
                    self._seed_state += 1
            values = hps.values

            values_hash = self._compute_values_hash(
                values)                                     ❼
            if values_hash in self._tried_so_far:           ❽
                collisions += 1
                if collisions <= self._max_collisions:
                    continue
                return None
            self._tried_so_far.add(values_hash)
            break
        return values

❶ 从最佳试验中提取超参数

❷ 收集非固定和活动超参数

❸ 随机选择一个超参数进行变异

❹ 遍历搜索空间中的所有活动超参数

❺ 检查当前超参数是否需要变异

❻ 执行随机变异

❼ 为新后代生成哈希字符串

❽ 检查后代是否已经被评估

以下两点值得注意:

  • 判断一个超参数是否是条件超参数的后代,利用了 KerasTuner 的一个特性:后代超参数将始终出现在条件超参数之后(self.hyperparameters.space)。实际上,超参数搜索空间可以被视为一个图,其中每个节点代表一个超参数,而链接表示它们在机器学习管道中的出现顺序或它们的条件相关性。KerasTuner 使用图的拓扑顺序将超参数搜索空间保存在一个列表中。

  • 为了确保算法了解超参数之间的条件相关性,并在突变时检测条件超参数的后代是否活跃,我们需要在搜索空间中显式定义条件作用域,如第五章所述。以下列表展示了合成搜索空间的说明性定义,其中 conditional_choice 是一个条件超参数,其后代超参数是 child1_choice 和 child2_choice。在搜索过程中,其两个子代中的一个将根据其值而活跃。

列表 7.17 一个条件搜索空间

def build_model(hp):
    hp.Choice('conditional_choice', [1, 2, 3], default=2)
    with hp.conditional_scope(
        'conditional_choice', [1, 3]):                  ❶
        child1 = hp.Choice('child1_choice', [4, 5, 6])
    with hp.conditional_scope(
        'conditional_choice', 2):                       ❶
        child2 = hp.Choice('child2_choice', [7, 8, 9])

❶ 超参数的条件作用域

将先前学习的突变函数与采样函数相结合,我们完成了 Oracle 的核心实现。我们还可以添加帮助保存和恢复 Oracle 的函数。用于控制 Oracle 的超超参数保存在状态字典中,当在 set_state() 函数中恢复 Oracle 时,应将这些超超参数与种群列表一起重新初始化,如以下列表所示。

列表 7.18 帮助恢复进化搜索 Oracle

class EvolutionaryOracle(oracle_module.Oracle):

    def get_state(self):
        state = super(EvolutionaryOracle, self).get_state()
        state.update({
            'num_initial_points': self.num_initial_points,
            'population_size': self.population_size,                      ❶
            'candidate_size': self.candidate_size,                        ❶
        })
        return state

    def set_state(self, state):
        super(EvolutionaryOracle, self).set_state(state)
        self.num_initial_points = state['num_initial_points']
        self.population_size = state['population_size']
        self.candidate_size = state['candidate_size']
        self.population_trial_ids = self.end_order[-self.population_size:]❷

❶ 保存种群大小和候选大小

❷ 在 Oracle 恢复期间重新初始化种群列表

最后,让我们在之前章节中使用的相同回归任务(加利福尼亚房价预测)上评估老化进化搜索方法,并将其与随机搜索和贝叶斯优化搜索方法进行比较。

7.4.4 评估老化进化搜索方法

为了评估老化进化搜索方法,我们进行了 100 次试验,并将种群大小和候选大小分别设置为 20 和 5。这两个超参数通常基于你对搜索空间的感觉和经验调优结果主观设置。通常,如果搜索空间很大,我们使用较大的种群大小来累积足够的多样化试验以培育后代。大约 100 的种群大小应该足够处理大多数情况。在这里,我们使用了保守的选择(20),因为搜索空间中只包含三个超参数。使用种群大小的一半或四分之一作为候选大小,如我们在这里所做的那样,通常在经验上提供良好的性能。

在接下来的代码列表中,我们只列出调用不同搜索方法的代码。数据加载、搜索空间创建和调谐器定制的其余实现与之前使用的方法相同,可在 Jupyter 笔记本中找到,网址为 github.com/datamllab/automl-in-action-notebooks

列表 7.19 调用不同的搜索方法

evo_tuner_p20c5 = LightGBMTuner(
    oracle=EvolutionaryOracle(
        objective=kt.Objective('mse', 'min'),
        max_trials=100,
        population_size=20,
        candidate_size=5,
        seed=42),                        ❶
    hypermodel=build_model,
    overwrite=True,
    project_name='evo_tuner_p20c5')

evo_tuner_p20c5.search(X_train, y_train, validation_data=(X_val, y_val))

random_tuner = LightGBMTuner(
    oracle=kt.oracles.RandomSearch(
        objective=kt.Objective('mse', 'min'),
        max_trials=100,
        seed=42),                        ❷
    hypermodel=build_model,
    overwrite=True,
    project_name='random_tuner')

random_tuner.search(X_train, y_train, validation_data=(X_val, y_val))

bo_tuner = LightGBMTuner(
    oracle=kt.oracles.BayesianOptimization(
        objective=kt.Objective('mse', 'min'),
        max_trials=100,
        seed=42),                        ❸
    hypermodel=build_model,
    overwrite=True,
    project_name='bo_tuner')

bo_tuner.search(X_train, y_train, validation_data=(X_val, y_val))

❶ 使用老化进化方法进行搜索

❷ 使用 KerasTuner 内置的随机搜索方法

❸ 使用 KerasTuner 内置的贝叶斯优化搜索方法

图 7.13 展示了随着搜索过程的进行,三种方法各自找到的最佳模型的评估性能。我们可以看到,在三种方法中,贝叶斯优化方法表现最好。尽管进化方法在搜索过程中实现了更多的改进步骤,但与随机搜索相比,它的区分度较小。每一步的改进都很小,因为我们让突变在每个阶段只发生在单个超参数上,并且改进一直持续到搜索过程的后期。这意味着选择强度(利用能力)可以在早期阶段得到改善。

07-13

图 7.13 比较三种搜索方法的搜索结果

现在让我们将候选者数量增加到 20(这是一个极端的选择,因为我们的种群大小也是 20)以增强利用能力,看看会发生什么。在图 7.14 中,我们可以看到在早期阶段有更多的改进,这证实了通过增加候选者数量可以在开始时增强选择强度(利用能力)。尽管在这个例子中,最终结果改进不大(如果我们尝试进一步的试验,结果甚至可能比使用较小候选者数量获得的结果更差,因为缺乏探索能力),但这表明如果我们只考虑 100 次试验,较大的候选者数量可以帮助我们以更少的试验次数达到可比的结果。在实际应用中,你可以根据自己的容忍度和可用时间调整这些大小。如果你不介意花更多的时间更彻底地探索搜索空间,你可以选择较小的候选者数量(以及较大的种群大小)。如果你期望在较少的试验中实现一个适度的好模型,你可以使用较大的候选者数量(以及较小的种群大小)。

07-14

图 7.14 不同搜索方法的比较

在这个例子中,贝叶斯优化在所有搜索算法中表现最佳。这是因为我们试图调整的超参数要么是连续超参数,要么具有序数值。通常,如果我们的搜索空间主要由具有连续或序数值的超参数主导,我们更倾向于使用贝叶斯优化算法。如果超参数大多是分类的或者存在许多条件超参数,进化方法将是一个不错的选择。对于探索连续超参数,随机变异不是一个好的选择。一些可行的改进方案包括对某些超参数(如学习率)使用对数尺度,或者通过添加代理模型来结合进化方法和基于模型的方法,以帮助引导变异——也就是说,多次随机变异并基于代理模型选择最佳试验作为后代。当想要探索的试验数量与搜索空间大小相比太少时,随机搜索方法可以提供一个强大的基线。这在涉及设计和调整深度神经网络(神经架构搜索)的任务中是一个常见情况。

摘要

  • 顺序搜索方法迭代地从搜索空间中采样和评估超参数。它通常包括两个步骤:超参数采样和一个可选的更新步骤,以纳入历史评估。

  • 历史依赖性搜索方法可以利用评估过的超参数来更好地从搜索空间中进行采样。启发式方法和基于模型的方法是历史依赖性方法的两大类。

  • 贝叶斯优化方法是 AutoML 中最广泛使用的基于模型的方法。它使用代理模型来近似模型评估性能,并使用获取函数在从搜索空间中采样新超参数时平衡探索和利用。最常用的代理模型是高斯过程模型,一些流行的获取函数包括上置信界(UCB)、改进概率和期望改进。

  • 进化方法是一种启发式搜索方法,通过模拟动物种群在新一代中的进化来生成新的样本。它包括四个步骤:初始种群生成、父代选择、交叉和变异以及幸存者选择。老化进化搜索方法是一种流行的进化搜索方法,最初是为神经架构搜索提出的。它利用现有试验的年龄进行幸存者选择,并使用锦标赛选择进行父代选择。

8 扩展 AutoML

本章涵盖

  • 分批将大型数据集加载到内存中

  • 使用多个 GPU 加速搜索和训练

  • 使用 Hyperband 高效调度模型训练,以充分利用可用的计算资源

  • 使用预训练模型和 warm-start 加速搜索过程

本章介绍了各种大规模训练技术——例如,使用大型数据集在多个 GPU 上训练大型模型。对于一次性无法全部装入内存的大型数据集,我们将向您展示如何在训练过程中分批加载它们。我们还将介绍不同的并行化策略,以将训练和搜索过程分布到多个 GPU 上。此外,我们还将向您展示一些策略,利用高级搜索算法和搜索空间,在有限的计算资源下加速搜索过程。

8.1 处理大规模数据集

深度学习强大背后的一个重要因素是大量数据的可用性,用于训练模型。通常,数据集越大、越多样化,训练的模型性能越好。本书早期示例中使用的所有数据集都足够小,可以装入进行训练的机器的主内存中。然而,您可能没有——或者不需要——足够的内存来存储整个数据集。如果您使用 GPU,数据集将被分割成小批次,这些批次将分批加载到 GPU 中。这意味着您只需要 GPU 内存中的一个槽位来存储单个数据批次,当您加载新批次时,它将被覆盖。

因此,如果您想使用一个不适合您所使用机器主内存的大型数据集,而不是一次性尝试加载整个数据集,您可以一次加载一个或多个批次,覆盖之前的批次。然后,这些批次可以在训练过程中像往常一样加载到 GPU 内存中。

总结来说,我们有两种加载数据的方式。在两种情况下,GPU 都保留一个缓冲区,用于存储将被机器学习模型消耗的数据批次。在第一种情况下,我们将整个数据集加载到主内存中,然后分批将数据加载到 GPU 的内存中。在第二种情况下,主内存也用作缓冲区,从硬盘上分批加载数据,整个数据集都存储在硬盘中。两种加载数据方式的比较如图 8.1 所示。

08-01

图 8.1 不同加载数据的方式

在本节中,我们将向您展示如何使用第二种选项来加载不适合主内存的大型数据集,以搜索一个好的模型。首先,我们将介绍加载图像和文本分类数据,这可以通过 AutoKeras 中现有的数据加载 API 轻松完成。然后,我们将向您展示如何分批从磁盘加载任何数据集。

8.1.1 加载图像分类数据集

AutoKeras 中有一个方便的函数可以帮助您从磁盘加载图像分类数据。在这里,我们将使用 MNIST 数据集作为示例。要下载和提取数据集,您需要运行下一个代码示例中的命令。您可以直接在 Python 笔记本中运行它们,或者在您的本地 Linux 或 Unix 命令行终端中运行它们,无需使用 ! 符号。首先,我们使用 wget 命令下载 MNIST 数据集的压缩文件,该文件将给定的 URL 中的文件存储到当前目录中。然后,我们使用 tar 命令从压缩文件中提取文件。xzf 是提取文件最常用的配置,其中 x 表示提取,z 表示通过 gzip 过滤存档内容,f 表示从文件中读取内容:

!wget https:/ /github.com/datamllab/automl-in-action-notebooks/raw/master/data/mnist.tar.gz
!tar xzf mnist.tar.gz

提取后,我们可以看到图像根据它们的类别分组到不同的文件夹中。训练和测试目录各包含 10 个子目录,命名为 0 到 9,这些是图像的标签,如下所示:

train/
├─0/
│ ├─1.png
│ └─21.png
│   ...
├─1/
├─2/
└─3/
  ...
test/
├─0/
└─1/
  ...

我们现在可以使用内置的 AutoKeras 函数 image_dataset_from_directory() 来加载图像数据集。此函数返回一个包含数据的 tf.data.Dataset 对象。函数的第一个参数是数据目录的路径,在我们的例子中是 'test' 或 'train'。使用 image_size 指定图像大小为一个包含两个整数的元组,(高度,宽度),并使用 batch_size 指定数据集的批次大小。这些是函数的必需参数。

调用此函数会生成一个包含图像和标签的元组批次的批次。每个元组的第一个元素是形状 (batch_size, image_height, image_width, number_of_channels)。我们使用 color_mode 参数设置通道数,它可以是 'grayscale'、'rgb' 或 'rgba' 之一。相应的通道数是 1、3 和 4;MNIST 数据集由灰度图像组成,因此通道数为 1。

元组的第二个元素是标签。标签是字符串,与测试或训练目录中的目录名相同。在我们的例子中,它们是从 '0' 到 '9' 的数字。image_dataset_from_directory() 函数还接受一个 shuffle 参数,默认值为 True,这意味着它将不同目录中的不同类别的图像进行洗牌。要设置洗牌的随机种子,您可以使用 seed 参数。

我们首先加载测试数据,如下所示列表。这不需要分割数据,这意味着它比加载训练数据更容易。我们还将打印出有关加载的第一批数据的规格——这是加载数据集时的一种有用的调试方法。

列表 8.1 从磁盘加载测试数据

import os
import autokeras as ak

batch_size = 32
img_height = 28
img_width = 28

parent_dir = 'data'
test_data = ak.image_dataset_from_directory(
    os.path.join(parent_dir, 'test'),          ❶
    seed=123,
    color_mode='grayscale',
    image_size=(img_height, img_width),
    batch_size=batch_size,

for images, labels in test_data.take(1):       ❷
    print(images.shape, images.dtype)
    print(labels.shape, labels.dtype))

❶ 测试数据集的路径

❷ 返回只包含第一批数据的新数据集

如输出所示,这列出了具有 10 个不同类别标签的 10,000 张图像。加载的一批图像的形状为 (32, 28, 28, 1),类型为 float32。相应的标签形状为 (32,), 类型为字符串,如下所示:

Found 10000 files belonging to 10 classes.
(32, 28, 28, 1) <dtype: 'float32'>
(32,) <dtype: 'string'>

数据集已成功从磁盘加载到 tf.data.Dataset 对象中。然而,它不能直接用于 AutoML,因为我们至少需要两个 tf.data.Dataset 对象分别用于训练和验证。因此,我们需要一种有效的方法将数据集分割成不同的子集。

8.1.2 加载数据集的分割

我们有多种方法可以分割数据集。列表 8.2 展示了一种简单但效率低下的解决方案:它加载了整个训练数据集,并使用 take() 和 skip() 函数进行分割。如果你调用 dataset.take(n),它将返回一个包含前 n 批次的数据集。如果你调用 dataset.skip(n),它将返回一个包含第一 n 批次之后的所有批次的集合。

列表 8.2 加载和分割训练数据

all_train_data = ak.image_dataset_from_directory(
    os.path.join(parent_dir, 'train'),
    seed=123,
    color_mode='grayscale',
    image_size=(img_height, img_width),
    batch_size=batch_size,
)
train_data = all_train_data.take(int(60000 / batch_size * 0.8))
validation_data = all_train_data.skip(int(60000 / batch_size * 0.8))

这效率低下是因为 skip() 函数必须遍历前 n 批次,才能开始迭代。我们提出了一种更有效的解决方案,如列表 8.3 所示。在这里,我们两次调用 image_dataset_from_directory() 函数,分别获取训练集和验证集。在每次调用中,除了两个新的参数 validation_split 和 subset,控制数据集的分割外,其他一切保持与加载测试集时相同。validation_split 参数指定要包含在验证集中的批次百分比,应在 0 和 1 之间。其余的批次将包含在训练集中。subset 参数应该是 'training' 或 'validation',以指示函数应返回哪个集合。值得注意的是,seed 参数必须手动设置,以确保在两次函数调用中,数据的分割方式没有差异。

列表 8.3 从磁盘加载训练和验证数据

train_data = ak.image_dataset_from_directory(           ❶
    os.path.join(parent_dir, 'train'),                  ❷
    validation_split=0.2,
    subset='training',
    seed=123,
    color_mode='grayscale',
    image_size=(img_height, img_width),
    batch_size=batch_size,
)

validation_data = ak.image_dataset_from_directory(      ❸
    os.path.join(parent_dir, 'train'),                  ❹
    validation_split=0.2,
    subset='validation',
    seed=123,
    color_mode='grayscale',
    image_size=(img_height, img_width),
    batch_size=batch_size,

❶ 加载训练分割

❷ 训练数据目录的路径

❸ 加载验证分割

❹ 训练数据目录的路径

我们已经完成了数据加载指定部分:所有数据都被封装成 tf.data.Dataset 格式。接下来,我们将探讨一种可以进一步提高加载后数据集使用效率的简单技术,称为 预取

使用预取提高数据加载效率

由于读取效率低下,从磁盘加载数据可能需要一些时间。如果没有预取,程序只有在内存中的所有批次都被机器学习模型使用完毕后,才会开始从磁盘加载下一批数据到内存中。然后,模型训练或推理过程将暂停,等待下一批数据加载。显然,这种方法效率不高。

预取会在训练或推理并行进行的同时,提前从磁盘加载额外的数据批次到内存中,因此这个过程不会被暂停。两种方法之间的区别在图 8.2 的序列图中展示。涉及两个任务,即训练和加载。从上到下的线条显示了执行过程中某一时刻正在运行的任务。正如你所见,没有预取时,加载和训练任务永远不会同时运行;训练过程需要等待加载过程完成。另一方面,使用预取,它们可以并行运行以节省时间,从而使程序运行得更高效。

08-02

图 8.2 预取序列图

要为 tf.data.Dataset 启用预取功能,你可以调用其成员函数 prefetch()。唯一必需的参数是你希望它预取到内存中的批次数:例如,调用 dataset.prefetch(5) 会预先将五个批次加载到内存中。如果你不确定预取多少批次是理想的,可以使用 dataset.prefetch(tf.data.AUTOTUNE),这将自动为你调整这个数字。

使用预取进行训练、验证和测试的代码在列表 8.4 中展示。在这里,我们为训练集和验证集预取五个批次,并自动调整测试数据的批次数。

列表 8.4 使用预取提高加载效率

import tensorflow as tf

train_data = train_data.prefetch(5)
validation_data = validation_data.prefetch(5)
test_data = test_data.prefetch(tf.data.AUTOTUNE)

现在,让我们尝试使用这些数据训练一个简单的图像分类模型。代码将在下一列表中展示。正如你所见,与使用能够一次性加载到内存中的较小数据集没有区别:我们直接将训练集和验证集传递给 fit() 函数。

列表 8.5 从磁盘加载数据拟合图像分类器

clf = ak.ImageClassifier(overwrite=True, max_trials=1)
clf.fit(train_data, epochs=1, validation_data=validation_data)
print(clf.evaluate(test_data))

你现在知道如何从磁盘加载大型图像数据集。然而,我们可能会遇到其他类型的大型数据集,例如文本数据集,在许多情况下这些数据集也太大,无法放入主内存。接下来,我们将探讨如何从磁盘加载大型文本数据集。

8.1.3 加载文本分类数据集

加载大型文本分类数据集与加载大型图像分类数据集没有太大区别。同样,你可以使用 AutoKeras 中的一个内置函数 text_dataset_from_directory();在用法上唯一的区别是参数。

text_dataset_from_directory() 函数没有 image_size 和 color_mode 参数,因为它们仅与图像数据相关。相反,它接受一个名为 max_length 的新参数,这是每个文本实例中要保留的字符串的最大字符数。如果你没有指定 max_length,字符串将在被输入到模型之前保留其原始长度。

让我们以 IMDb 电影评论数据集为例,展示如何从磁盘加载文本数据。首先,我们使用以下命令下载原始文本文件。这两个命令将数据集作为压缩文件下载,并将文件提取到当前目录中。与下载图像数据集一样,我们可以在 notebook 中直接运行这些命令,或者在没有!符号的情况下在 Linux 或 Unix 终端本地运行它们:

!wget https:/ /github.com/datamllab/automl-in-action-notebooks/raw/master/data/imdb.tar.gz
!tar xzf imdb.tar.gz

提取后,您将得到一个名为 imdb 的目录,其中包含以下内容。如您所见,评论被分为训练集和测试集,每个集都按评论的两个类别组织,即正面和负面:

train/
├─pos/
│ ├─0_9.txt
│ └─10000_8.txt
│   ...
└─neg/
test/
├─pos/
└─neg/

接下来,我们可以以加载图像数据相同的方式加载文本数据。我们使用 text_dataset_from_directory()从 train 目录加载数据,并将其分为训练集和验证集。我们还使用预取功能来加快迭代过程,max_length=1000 限制加载字符串的最大长度。任何超过 1,000 个字符限制的字符将被丢弃。如下所示,在加载测试数据时,我们可以省略几个参数,因为它们仅对训练数据重要。

列表 8.6 从磁盘加载训练、验证和测试数据

train_data = ak.text_dataset_from_directory(        ❶
    'imdb/train',
    validation_split=0.2,
    subset='training',
    seed=123,
    max_length=1000,
    batch_size=32,
).prefetch(1000)

validation_data = ak.text_dataset_from_directory(   ❷
    'imdb/train',
    validation_split=0.2,
    subset='validation',
    seed=123,
    max_length=1000,
    batch_size=32,
).prefetch(1000)

test_data = ak.text_dataset_from_directory(         ❸
    'imdb/test',
    max_length=1000,
).prefetch(1000)

❶ 加载训练集

❷ 加载验证集

❸ 加载测试集

在加载数据后,我们现在可以使用一个简单的文本分类器来测试加载数据是否按预期工作,如下所示。

列表 8.7 从磁盘加载数据拟合文本分类器

clf = ak.TextClassifier(overwrite=True, max_trials=1)
clf.fit(train_data, epochs=2, validation_data=validation_data)
print(clf.evaluate(test_data))

到目前为止,我们已经向您展示了如何使用 AutoKeras 的内置函数从磁盘加载图像和文本分类数据,以及如何使用预取功能加快数据集的迭代速度。然而,我们需要一种更通用的方式来加载任何类型的数据集,而不仅仅是图像和文本。

8.1.4 一般处理大型数据集

在本节中,我们将介绍一种使用 tf.data.Dataset 的内部机制来加载太大而无法放入主内存的任意数据集的方法。我们将继续使用此格式来加载数据集以解决内存问题。然而,为了在数据类型上提供更多灵活性,我们将使用Python 生成器来遍历数据,并将生成器转换为 tf.data.Dataset。

首先,什么是 Python 生成器?从概念上讲,它是一个数据序列的迭代器。为了实现目的,它是一个使用 yield 提供要迭代的数据的 Python 函数。您可以使用 for 循环遍历该函数以获取所有数据,如下所示。在这里,我们定义了一个名为 generator()的 Python 生成器。它只是一个 Python 函数,它调用 yield 来提供数据列表中的所有元素。

列表 8.8 Python 生成器示例

data = [5, 8, 9, 3, 6]

def generator():         ❶
    for i in data:
        yield i

for x in generator():    ❷
    print(x)

❶ 定义生成器

❷ 使用 for 循环遍历生成器并打印其元素

生成器的输出,如下所示,是列表 8.8 中的数据列表元素:

5
8
9
3
6

tf.data.Dataset 类有一个名为 from_generator 的函数,它使用 Python 生成器构建一个新的实例。要使用它,我们只需提供生成器函数并使用 output_types 参数指定数据类型。让我们尝试将我们刚刚构建的 Python 生成器转换为 tf.data.Dataset 实例。代码将在下一个列表中展示。输出将与上一个示例相同。

列表 8.9 将 Python 生成器转换为 tf.data.Dataset

dataset = tf.data.Dataset.from_generator(
    generator,                             ❶
    output_types=tf.int32)                 ❷
for x in dataset:
    print(x.numpy())

❶ 指定生成器函数

❷ 指定输出类型为整数

现在您已经了解了 Python 生成器是什么以及如何使用它来构建 tf.data.Dataset 实例,让我们尝试使用这种方法加载一个真实的数据集。

使用 Python 生成器加载数据集

现在我们将使用以下步骤使用 Python 生成器加载 IMDb 数据集:

  1. 将所有文件路径和标签加载到 NumPy 数组中。对数组进行洗牌以混合来自不同目录的数据。

  2. 从洗牌后的 NumPy 数组构建一个生成器,通过读取文件的正文内容将文件路径转换为实际文本数据。

  3. 从生成器创建 tf.data.Dataset 实例。

在第一步中,我们遍历不同类别的所有文件(目录)。我们创建两个元素的元组:文件的路径和文件的标签。文件路径是后来在训练期间加载其内容所需的,标签信息由包含文件的目录指示,因此我们需要在遍历目录的过程中记录此信息。

然而,因为我们按目录逐个迭代所有文件,所以同一目录(即具有相同类标签)的所有文件在数组中相邻。因此,在使用文件进行训练之前,我们需要对它们进行洗牌以混合来自不同类别的数据。

我们首先创建一个 NumPy 数组而不是直接创建生成器来迭代文件的原因是,一旦文件路径和标签被加载到内存中,进行洗牌和分割就更容易了。此外,文件路径在内存中不会占用太多空间。

执行此过程的代码如下所示。load_data() 函数加载、洗牌并返回作为 NumPy 数组的数据。

列表 8.10 加载 IMDb 数据集文件名

import numpy as np

path = os.path.join(parent_dir, 'train')

def load_data(path):
    data = []
    for class_label in ['pos', 'neg']:                          ❶
        for file_name in os.listdir(
            os.path.join(path, class_label)):                   ❷
            data.append((os.path.join(
                path, class_label, file_name), class_label))    ❸
    data = np.array(data)
    np.random.shuffle(data)                                     ❹
    return data

all_train_np = load_data(os.path.join(parent_dir, 'train'))     ❺

❶ 枚举类标签

❷ 遍历每个类别的所有文件名

❸ 创建 (文件路径,类标签) 的元组

❹ 洗牌数据

❺ 加载训练数据

使用这个 NumPy 数组,我们可以创建一个迭代器,将数组中的每个元素转换为实际的文本数据。我们不是直接实现生成器,而是实现了一个名为 get_generator(data)的函数来返回一个生成器,因为我们可能需要为训练、验证和测试集使用不同的生成器。通过 get_generator(data),我们可以将相应的 NumPy 数组传递给函数,以动态地为该 NumPy 数组创建生成器。然后,我们可以创建生成器函数作为内部函数,并将此函数作为返回值返回。

在生成器函数中,我们将使用 for 循环遍历文件路径,读取每个文件的内容,并一起产生文本和标签。这样,生成器将生成实际文本数据,并带有标签,以便用于训练。get_generator()的代码如下所示。data_generator()函数将作为生成器函数返回。

列表 8.11:使用 Python 生成器加载 IMDb 数据集

def get_generator(data):
    def data_generator():                      ❶
        for file_path, class_label in data:    ❷
            text_file = open(file_path, 'r')   ❸
            text = text_file.read()            ❸
            text_file.close()
            yield text, class_label            ❹
    return data_generator                      ❺

❶ 生成器函数

❷ 遍历 NumPy 数组

❸ 使用文件路径读取文件内容

❹ 产生文本和类别标签

❺ 返回生成器函数

下一步是创建一个 tf.data.Dataset 实例。为了方便分割数据,让我们编写一个函数,np_to_dataset(),将 NumPy 数组转换为 tf.data.Dataset。这个函数将调用 get_generator()函数来获取生成器函数,并使用 tf.data.Dataset.from_generator()来获取 tf.data.Dataset 实例。

我们将 NumPy 数组分割两次,并调用 np_to_dataset()函数,分别用于训练集和验证集。这些包含 20,000 和 5,000 个实例。

在创建数据集时需要注意几个问题。在 from_generator()函数中,我们需要指定 output_types。因为文本和标签都是字符串类型,所以我们可以直接指定类型为 tf.string。

使用 AutoKeras 的数据集需要具有具体的形状。因此,我们使用 output_shapes 参数指定数据集的形状。此参数的值需要是 tf.TensorShape 的实例。我们可以通过将其初始化器传递形状列表来轻松创建它。

创建的数据集不能直接使用,因为其中的每个实例都是一个形状为(2,)的张量。然而,AutoKeras 使用的数据集应该是两个张量的元组,(x, y)。因此,我们需要调用数据集的 map()函数来更改其格式。

map()函数接受一个 lambda 函数作为其参数,该函数接受旧实例作为参数并返回新实例。然后我们可以返回原始张量的第一和第二维度作为一个包含两个元素的元组。这些步骤的代码如下。

列表 8.12:从生成器创建数据集

def np_to_dataset(data_np):                               ❶
    return tf.data.Dataset.from_generator(
        get_generator(data_np),                           ❷
        output_types=tf.string,
        output_shapes=tf.TensorShape([2]),
    ).map(                                                ❸
        lambda x: (x[0], x[1])
    ).batch(32).prefetch(5)                               ❹

train_data = np_to_dataset(all_train_np[:20000])          ❺
validation_data = np_to_dataset(all_train_np[20000:])     ❻

❶ 将 NumPy 数组转换为 tf.data.Dataset 的功能

❷ 数组的生成器函数

❸ 将数据从张量转换为元组

❹ 为数据集进行批量和集合预取

❺ 加载训练集

❻ 加载验证集

将所有这些过程实现为函数后,我们可以以相同的方式加载测试集,如下面的代码示例所示。

列表 8.13 使用 Python 生成器加载 IMDb 数据集的测试集

test_np = load_data(os.path.join(parent_dir, 'test'))
test_data = np_to_dataset(test_np)

在准备好训练集、验证集和测试集,并以 tf.data.Dataset 格式存储后,我们可以使用 AutoKeras 文本分类器来测试它是否按预期运行,如下所示。

列表 8.14 使用 Python 生成器拟合文本分类器

clf = ak.TextClassifier(overwrite=True, max_trials=1)
clf.fit(train_data, epochs=2, validation_data=validation_data)
print(clf.evaluate(test_data))

本例的主要目的是展示如何使用 Python 生成器构建数据集。现在你知道如何使用这种方法将任何格式的数据集加载到 tf.data.Dataset 中,这大大提高了你加载大型数据集的灵活性。

此外,这种方法不仅限于从磁盘加载数据:如果你的数据来自通过网络从远程机器获取或通过一些 Python 代码动态生成,你也可以使用 Python 生成器将其包装成数据集。

8.2 多 GPU 并行化

为了扩展机器学习和 AutoML 以支持大型模型和大型数据集,我们可以在多个 GPU 和多台机器上并行运行我们的程序。并行化通常用于加速训练和推理(数据并行)或加载一个非常大的模型,该模型无法适应单个 GPU 的内存(模型并行)。它有时也用于加速超参数调整过程(并行调整)。图 8.3 展示了这三种不同类型的并行化以及数据集和模型在每个情况下的内存分配差异。

此图显示了三种策略在三个 GPU 上的样子。在左侧是数据并行方法,它加速了大型数据集的训练过程。每个 GPU 都有相同模型的副本,但处理不同的数据批次。不同 GPU 上的权重更新定期同步。

图中中间部分是模型并行策略的示例,该策略主要用于无法包含在单个 GPU 内存中的大型模型或加速可以并行化的推理过程的模型。它将模型分解成多个部分,并将它们分配到不同的 GPU 上。在图 8.3 中,第一个 GPU 持有模型的前两层和训练数据。第二和第三个 GPU 持有其余的层和层的中间输出。在推理过程中,模型的一些部分可能并行运行以节省时间。

08-03

图 8.3 三种并行化类型

用于加速 AutoML 进程的并行调优策略如图所示。采用这种方法,具有不同超参数设置的模型被放置在不同的 GPU 上,并使用相同的训练数据集来训练它们。因此,超参数调优过程是并行运行的。

让我们更详细地看看这些策略。

8.2.1 数据并行

使用 TensorFlow,数据并行是通过 tf.distribute.Strategy 管理的,其子类,如 tf.distribute.MirroredStrategy,实现了不同的并行策略。你可以直接使用这些子类与 AutoKeras 和 KerasTuner 一起使用。

在 AutoKeras 中,AutoModel 类和所有任务 API 类(如 ImageClassifier 和 TextClassifier)的初始化器中都有一个名为 distribution_strategy 的参数。你可以传递一个 tf.distribute.MirroredStrategy(或其其他子类)的实例,以便在搜索过程中使用数据并行进行所有模型训练过程。下一个列表展示了使用 MNIST 数据集的示例。代码将在程序可见的所有 GPU 上以分布式方式运行。

列表 8.15 使用 AutoKeras 的数据并行

import tensorflow as tf
from tensorflow.keras.datasets import mnist
import autokeras as ak

(x_train, y_train), (x_test, y_test) = mnist.load_data()
clf = ak.ImageClassifier(
    overwrite=True,
    max_trials=1,
    distribution_strategy=tf.distribute.MirroredStrategy())   ❶
clf.fit(x_train, y_train, epochs=1)

❶ 用于训练的数据并行

对于 KerasTuner,所有调优器,即 Tuner 类的子类(BayesianOptimization、Hyperband 和 RandomSearch),它们的初始化器中也有这个 distribution_strategy 参数,并且其工作方式与 AutoKeras 相同。你可以传递一个 TensorFlow 分布式策略的实例,如 tf.distribute.MirroredStrategy,到该参数。模型将使用分布式策略进行训练。

AutoKeras 在底层使用这个 KerasTuner 功能。以下列表展示了使用 KerasTuner 进行数据并行的简单示例。在这里,我们为 MNIST 数据集设计了一个非常基本的搜索空间。

列表 8.16 使用 KerasTuner 的数据并行

import keras_tuner as kt

def build_model(hp):
    model = tf.keras.Sequential()
    model.add(tf.keras.layers.Flatten())
    model.add(tf.keras.layers.Dense(
        units=hp.Int('units', min_value=32, max_value=512, step=32),
        activation='relu'))
    model.add(tf.keras.layers.Dense(10, activation='softmax'))
    model.compile(optimizer='adam', loss='sparse_categorical_crossentropy')
    return model

tuner = kt.RandomSearch(
    build_model,
    objective='val_loss',
    max_trials=1,
    directory='my_dir',
    distribution_strategy=tf.distribute.
➥ MirroredStrategy(),                      ❶
    project_name='helloworld')

tuner.search(x_train, y_train,
             epochs=1,
             validation_data=(x_test, y_test))

❶ 用于训练的数据并行

程序可以使用所有可用的 GPU 来分割数据并聚合梯度以更新模型。除了数据并行之外,让我们看看其他类型的分布式策略如何帮助加速训练过程。

8.2.2 模型并行

如前所述,模型并行主要用于处理大型模型。对于太大而无法容纳在一个 GPU 内存中的模型,它提供了一种通过将模型拆分成多个部分并将它们分布到可用的处理单元上来确保所有计算仍然在 GPU 上高效完成的方法。它还允许你在推理期间将一些计算卸载到不同的 GPU 上以并行运行。

一个典型的例子是具有多个分支的模型。中间输出是两个独立层的输入,它们彼此之间没有任何依赖。图 8.4 展示了这样一个案例的例子。图中显示了总共四个 GPU:GPU 2 上的两个卷积层,GPU 3 可以在推理时并行运行,因为它们的输入不依赖于彼此的输出。

08-04

图 8.4 多个 GPU 上的多分支模型

另一个不那么常见的例子是将一层分割成多个部分以并行运行。例如,在一个卷积层中,每个滤波器独立工作。因此,它们可以被分割到多个 GPU 上运行。然后可以将输出汇总以形成整个卷积层的输出张量。

目前,流行的开源深度学习框架并没有很好地将模型并行性封装到简单的 API 中。在大多数情况下,模型足够小,可以适应单个 GPU 的内存。要实现具有模型并行的模型,需要学习如何使用 Mesh TensorFlow,本书不会详细介绍这一点。如果您感兴趣,可以在 GitHub 上查看 Mesh TensorFlow:github.com/tensorflow/mesh

8.2.3 并行调优

并行调优意味着在不同的设备上训练具有不同超参数的模型。例如,假设您在一台机器上拥有四个 GPU,并且您想在搜索空间中尝试八组不同的超参数。如果您并行运行四个模型,您只需要训练两个模型的时间来完成搜索。

特别地,并行调优还要求调优算法能够异步接收评估结果。如果不并行运行,调优算法将开始训练一个模型,并在开始训练另一个模型之前等待评估结果。然而,当并行运行时,搜索算法需要在收到任何评估结果之前开始训练多个模型,并且评估结果可能不会按照训练过程启动的顺序接收。搜索算法将在有可用设备时随时生成一个新模型进行训练。

让我们看看如何使用 KerasTuner 运行并行调优。它为此实现了一个首席/工人模型:在任何给定时间,只有一个首席进程正在运行,但存在多个工人进程。首席进程运行搜索算法。它管理工人以启动训练过程,并从他们那里收集评估结果。

在并行调优时遇到的一个问题是模型存储的位置。在不并行运行的情况下,搜索到的模型及其训练好的权重被保存在磁盘上,这样我们可以在搜索后加载最佳模型。在并行运行时,为了保存模型以便稍后加载,我们需要所有工作进程和主进程共享存储。如果我们在一台机器上使用多个 GPU,这不是问题,因为它们使用相同的存储。如果我们使用多台机器上的多个 GPU,我们可以将共享存储挂载到机器上或使用所有机器都可以访问的网络存储,例如 Google Cloud Storage 存储桶。

并行调优中主进程和工作进程的通信模式如图 8.5 所示。实线是控制流。主节点将不同的超参数集发送到不同的工作进程,这些工作进程使用给定的超参数构建和训练模型。完成后,工作进程将评估结果发送回主节点。图中的虚线是数据流。训练好的模型和结果被写入集中式存储,所有工作进程和主进程都可以访问。当用户调用主进程加载最佳模型时,主进程应从集中式存储中加载模型。训练和验证数据也应存储在所有工作进程都可以访问的集中式存储中。

08-05

图 8.5 并行调优的通信模式

现在,让我们看看如何在 KerasTuner 中启动这个并行训练。要启动主进程和工作进程,我们运行相同的 Python 脚本。KerasTuner 将使用环境变量来定义当前进程应该是主进程还是工作进程。KERASTUNER_TUNER_ID 环境变量用于指定不同进程的 ID。你可以将其设置为 'chief' 以指定主进程,对于工作进程则设置为 'tuner0'、'tuner1' 等等。

我们需要设置两个额外的环境变量,以便工作进程找到主进程的地址,从而可以报告评估结果:KERASTUNER_ORACLE_IP 和 KERASTUNER_ORACLE_PORT。这些指定了主服务运行在上的 IP 地址和端口号;它们需要为主进程和工作进程都设置。

总结来说,在运行脚本(run_tuning.py,我们很快就会看到)之前,我们需要设置三个环境变量。这里,我们提供了两个 shell 脚本以启动主进程和工作进程。首先,我们需要启动主进程。以下列表显示了指定环境变量并启动进程的命令。你可以打开一个终端来运行这些命令;终端将挂起并等待工作进程启动。

列表 8.17 启动主进程

export KERASTUNER_TUNER_ID='chief'         ❶
export KERASTUNER_ORACLE_IP='127.0.0.1'    ❷
export KERASTUNER_ORACLE_PORT='8000'       ❸
python run_tuning.py                       ❹

❶ 标记进程为主进程

❷ 主进程的 IP 地址

❸ 主进程的端口号

❹ 启动主进程

现在,打开另一个终端来启动工作者进程。你可以使用以下列表中的脚本完成此操作。它与前面的脚本非常相似;唯一的区别是 KERASTUNER_TUNER_ID,用于指定它作为工作者。

列表 8.18 启动工作者进程

export KERASTUNER_TUNER_ID='tuner0'        ❶
export KERASTUNER_ORACLE_IP='127.0.0.1'    ❷
export KERASTUNER_ORACLE_PORT='8000'       ❸
python run_tuning.py                       ❹

❶ 将进程标记为工作者

❷ 主控的 IP 地址

❸ 主控的端口

❹ 启动工作者进程

一旦启动工作者进程,调优就开始了。要启动其他工作者进程,使用相同的命令,但为每个指定不同的 KERASTUNER_TUNER_ID:例如,'tuner1'、'tuner2'等等。

run_tuning.py 中的 Python 代码启动进程。让我们看看这个脚本中有什么。以下列表展示了简单的 KerasTuner 示例。除了目录参数外,不需要任何特定配置,该参数必须指向所有工作者和主管都可以访问的目录。

列表 8.19 使用 KerasTuner 进行并行调优

import tensorflow as tf
from tensorflow.keras.datasets import mnist
import autokeras as ak
import keras_tuner as kt

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

def build_model(hp):
    model = tf.keras.Sequential()
    model.add(tf.keras.layers.Flatten())
    model.add(tf.keras.layers.Dense(
        units=hp.Int('units', min_value=32, max_value=512, step=32),
        activation='relu'))
    model.add(tf.keras.layers.Dense(10, activation='softmax'))
    model.compile(optimizer='adam', loss='sparse_categorical_crossentropy')
    return model

tuner = kt.RandomSearch(
    build_model,
    objective='val_loss',
    max_trials=1,
    directory='result_dir',      ❶
    project_name='helloworld')

tuner.search(x_train, y_train,
             epochs=1,
             validation_data=(x_test, y_test))

❶ 可供所有工作者和主管访问的目录

到目前为止,我们一直在讨论如何通过在更多 GPU 上运行来加速调优。然而,我们还有其他策略可以在算法层面上加速调优。

8.3 搜索加速策略

在本节中,我们将介绍一些用于搜索过程的加速策略。首先,我们将介绍一种名为Hyperband的模型调度技术。给定一定量的计算资源,这项技术可以将它们分配给训练不同模型的不同程度。而不是完全训练每个模型,它通过在早期阶段终止一些不太有前途的模型来节省时间。

接下来,我们将探讨如何使用预训练权重的模型来加速训练过程。最后,我们将介绍 AutoML 中广泛使用的一种技术,即使用一些好的模型预热启动搜索空间。这为调优器提供了一些指导,使其需要做更少的探索来找到一个相对性能较好的模型。

8.3.1 使用 Hyperband 进行模型调度

Hyperband¹是 AutoML 中广泛使用的模型调度算法。Hyperband 的核心思想不是试图模拟模型性能与超参数之间的关系,而是专注于搜索空间中更有前途的模型,而不是完全训练所有可能的模型,从而节省计算资源和时间。

这里有一个具体的例子。假设我们有四个不同的超参数集要尝试,每个集都将实例化为一个深度学习模型,并且每个模型都需要训练 40 个 epoch。我们首先训练这四个模型 20 个 epoch,然后丢弃在这个阶段表现最差的两个模型。现在我们剩下两个好的模型。然后我们再训练这些模型 20 个 epoch,使它们完全训练,并选择最好的一个。因此,我们节省了训练两个不太好的模型额外 20 个 epoch 所需的时间和资源。

要理解使用 Hyperband 的过程,我们首先需要了解其子过程,称为连续减半。连续减半涉及两个交替步骤的循环:第一步是通过删除不那么有希望的模型来减少剩余模型的数量,第二步是进一步训练剩余模型。您需要向连续减半算法提供以下四个参数:

  • models—所有待训练模型的列表。

  • max_epochs—完全训练一个模型所需的总 epoch 数。

  • start_epochs—第一轮中训练所有模型所需的 epoch 数。

  • factor—衡量我们希望多快减少模型数量并增加剩余模型的训练 epoch 数的指标;默认值为 3。例如,当 factor=3 时,每次循环通过,我们将剩余模型的数量减少到当前数量的三分之一,并将剩余模型进一步训练到已训练 epoch 数的三倍。

此过程会重复进行,直到所有剩余的模型都完全训练完毕。它将返回最佳模型及其验证损失。

在算法开始时,我们得到一个模型列表。我们将它们保存在 remaining_models 中,并在每一轮中删除一些模型。我们使用 n_models 记录初始的总模型数。我们使用 trained_epochs 记录剩余模型已训练的 epoch 数,这将在每一轮进一步训练中更新。target_epochs 代表当前轮次需要达到的目标 epoch 数,这也在每一轮结束时更新。我们在字典 eval_results 中记录评估结果,其键是模型,值是每个模型的验证损失。

初始化这些变量后,我们就可以开始外循环了,该循环会重复进行,直到所有剩余模型都完全训练完毕——换句话说,直到 trained_epochs 等于或大于 max_epochs。循环中的第一步是内循环,用于训练和评估 remaining_models 中的所有模型,并将结果记录在 eval_results 中。

然后我们丢弃表现最差的模型,通过指定的因子减少剩余模型的数量。在每一轮结束时,我们更新跟踪 epoch 的变量。

一旦外循环完成(当模型完全训练完毕时),我们可以获取最佳模型并返回该模型及其验证损失。以下列表展示了连续减半过程的伪代码。

列表 8.20 连续减半伪代码

import copy

def successive_halving(models, max_epochs, start_epochs, factor=3):
    remaining_models = copy.copy(models)
    n_models = len(models)
    trained_epochs = 0
    target_epochs = start_epochs
    eval_results = {}
    i = 0                                               ❶
    while trained_epochs < max_epochs:                  ❷
        for model in remaining_models:                  ❸
            model.fit(x_train, y_train,
                      epochs=target_epochs - trained_epochs)
            eval_results[model] = model.evaluate(x_val, y_val)
        remaining_models = sorted(remaining_models, key=lambda x:
➥ eval_results[x])[int(n_models / pow(factor, i))]     ❹
        trained_epochs = target_epochs                  ❺
        target_epochs = trained_epochs * factor         ❺
        i += 1                                          ❺
    best_model = min(remaining_models, key=lambda x: eval_results[x])
    return best_model, eval_results[best_model]

❶ 轮次计数器

❷ 外循环(继续进行,直到剩余模型完全训练)

❸ 内循环

❹ 减少模型数量

❺ 更新下一轮的变量

为了使连续减半有效,我们需要选择合适的模型探索数量和合适的 epoch 数来开始训练。如果我们开始时模型不足,算法的探索性将不足。如果我们开始时模型过多,算法在早期阶段将丢弃大量模型而未加以利用。因此,Hyperband 提出了一种方法来避免在连续减半过程中指定固定的模型数量。

为了避免这些问题,Hyperband 多次运行连续减半过程以平衡探索和利用,尝试不同的因子值。

要运行 Hyperband,我们需要指定两个参数,max_epochs 和 factor,它们与传递给连续减半算法的参数相同。

Hyperband 多次运行连续减半算法。每次迭代被称为一个 括号。我们使用 s_max 来指定要运行的括号数量,其值接近 log(max_epochs, factor)。

为了直观地理解在括号中生成多少模型,在第一个括号中,生成的模型数量等于 max_epochs。之后,在每个括号中,初始模型数量是前一次调用中数量的 1/factor(实际上,在实现中,模型数量大约是 pow(factor, s),但在后面的括号中调整为更大的数字)。Hyperband 还可以防止括号中模型过少;例如,括号不会从只有一个模型开始。不同的括号不共享传递给它们的模型,而是在每个括号的开始时随机生成新的模型。括号的 start_epochs 值将严格增加 factor 倍每次括号。

现在你已经理解了所有必要的参数,让我们看看 Hyperband 的伪代码,如下所示。

列表 8.21 Hyperband 伪代码

import math

def hyperband(max_epochs, factor=3):
    best_model = None
    best_model_loss = math.inf
    s_max = int(math.log(max_epochs, factor))                                     ❶
    for s in (s_max, -1, -1):                                                     ❷
        models = generate_models(math.ceil(pow(factor, s) * (s_max + 1) / (s + 1)))❸
        start_epochs = max_epochs / pow(factor, s)
        model, loss = successive_halving(models, max_epochs, start_epochs, factor)❹
        if loss < best_model_loss:                                                ❺
            best_model_loss = loss                                                ❺
            best_model = model                                                    ❺
    return model

❶ 指定括号的数目

❷ 遍历括号

❸ 生成新的模型

❹ 调用 successive_halving

❺ 更新最佳模型

为了更好地理解代码,让我们通过一个具体的例子来讲解。如果我们让 max_epochs 为 81,让 factor 为 3,s 将从 4 迭代到 0。括号中的模型数量将是 [81, 34, 15, 8, 5]。这大约是 pow(factor, s),但随着 pow(factor, s) 的减小,它被调整为更大的数字。

Hyperband 已经在 KerasTuner 中实现,作为其中一个调优器,名为 Hyperband,你可以直接使用。你可以在单个 GPU 或多个 GPU 上使用它进行并行调优。你可以在 Hyperband 类的初始化器中指定 factor 和 max_epochs。此外,你可以通过 hyperband_iterations 指定括号的数目,这对应于列表 8.21 中的 s_max 参数,以控制搜索的时间。以下列表显示了一个示例。

列表 8.22 使用 KerasTuner 运行 Hyperband

import tensorflow as tf
from tensorflow.keras.datasets import mnist
import autokeras as ak
import keras_tuner as kt

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

def build_model(hp):
    model = tf.keras.Sequential()
    model.add(tf.keras.layers.Flatten())
    model.add(tf.keras.layers.Dense(
        units=hp.Int('units', min_value=32, max_value=512, step=32),
        activation='relu'))
    model.add(tf.keras.layers.Dense(10, activation='softmax'))
    model.compile(optimizer='adam', loss='sparse_categorical_crossentropy')
    return model

tuner = kt.Hyperband(
    build_model,
    objective='val_loss',
    max_epochs=10,                 ❶
    factor=3,                      ❷
    hyperband_iterations=2,        ❸
    directory='result_dir',
    project_name='helloworld')

tuner.search(x_train, y_train,
             epochs=1,
             validation_data=(x_test, y_test))

❶ 指定最大 epoch 数为 10

❷ 指定减半剩余模型数量的因子为 3

❸ 指定括号的数量为 2

代码只是 KerasTuner 的正常用法,但使用了不同的调优类。

8.3.2 在搜索空间中使用预训练权重以实现更快的收敛

训练一个深度学习模型通常需要很长时间。我们可以使用预训练权重来加速训练过程,这使得模型能在更少的迭代次数中收敛。

注意:预训练权重是指已经训练好的模型的权重。

在某些情况下,你可能发现你的数据集不够大,无法训练出一个泛化良好的好模型。预训练权重也可以帮助这种情况:你可以下载并使用使用其他(更大、更全面)数据集训练的权重模型。模型学习到的特征应该可以泛化到新的数据集。当使用预训练权重时,你需要进一步使用你的数据集训练预训练模型。这个过程通常被称为一种迁移学习。

我们可以使用两种方式使用预训练权重。第一种方法相对简单:直接使用新数据集训练模型。通常,更小的学习率是首选的,因为较大的学习率可能会非常快地改变原始权重。第二种方法是冻结模型的大部分部分,只训练输出层。

例如,假设我们正在使用一个具有预训练权重的卷积神经网络进行分类任务。输出层是一个具有与类别数量相同神经元数量的全连接层。我们可以只保留模型的卷积部分,并丢弃任何全连接层。然后,我们将使用新初始化的权重添加的全连接层附加到卷积层上,并开始以冻结卷积层的方式训练模型,这样我们只更新模型中添加的全连接层(见图 8.6)。如果模型是不同类型的神经网络,它通常仍然可以分成特征学习部分(卷积层、循环层和变换器)和头部(通常是全连接层)。因此,我们仍然可以应用相同的方法来使用预训练权重。

08-06

图 8.6 使用预训练权重的两种方法

除了上面提到的两种方法,你还可以更灵活地选择冻结模型的哪一部分,保留哪一部分,以及向模型中添加多少层。例如,你可以冻结一些卷积层,而将其他层保持未冻结状态。你也可以保留一个或多个全连接层,并向模型中添加多个层。

使用预训练权重的一个要求是,训练数据和用于创建预训练模型的数据库必须是同一类型。例如,如果您的数据集由英语句子组成,预训练模型也需要是一个在英语句子上训练的自然语言模型。如果您的数据集由中文字符组成,使用此类模型的预训练权重获得的表现提升可能并不显著,甚至可能产生负面影响。

AutoKeras 已经在搜索空间中使用了一些预训练权重。对于一些与图像或文本相关的块,初始化器中有一个名为 pretrained 的布尔超参数,您可以使用它来指定是否为模型使用预训练权重。对于图像数据,这些包括 ResNetBlock、XceptionBlock 和 EfficientNetBlock,它们使用 ImageNet 数据集进行预训练。对于文本数据,BertBlock 使用来自维基百科的文本进行预训练。

要在 AutoKeras 中使用预训练权重,我们可以直接使用 AutoModel 将这些块连接起来形成一个搜索空间,如下所示列表。这是一个使用 CIFAR-10 数据集用预训练的 ResNet 进行图像分类的简单示例。在代码中,我们指定 ResNetBlock 的预训练参数为 True,以便它只搜索带有预训练权重的 ResNet 模型。

列表 8.23 在 AutoKeras 中使用预训练的 ResNets 进行图像分类

import tensorflow as tf
import autokeras as ak

(x_train, y_train), (x_test, y_test) = tf.keras.datasets.cifar10.load_data()
input_node = ak.ImageInput()
output_node = ak.Normalization()(input_node)
output_node = ak.ImageAugmentation()(output_node)
output_node = ak.ResNetBlock(pretrained=True)(output_node)    ❶
output_node = ak.ClassificationHead()(output_node)
model = ak.AutoModel(
    inputs=input_node, outputs=output_node, max_trials=2, overwrite=True)
model.fit(x_train, y_train, epochs=10)
model.evaluate(x_test, y_test)

❶ 为块使用预训练权重

要使用具有预训练权重的模型构建自己的搜索空间,您可以使用 Keras Applications,它包含了一组可用的预训练模型。它们可以在 tf.keras.applications 下导入;例如,您可以从 tf.keras.applications.ResNet50 导入一个 ResNet 模型。有关模型完整列表,请参阅 keras.io/api/applications/

要初始化预训练模型对象,您通常需要指定两个参数,include_top 和 weights。include_top 是一个布尔参数,指定是否包含预训练模型的分类头。weights 可以是 'imagenet' 或 None,指定是否使用 ImageNet 预训练权重或随机初始化的权重。以下是一个使用 ResNet 的示例列表。在这里,我们创建了一个不带全连接层的 ResNet,并使用 ImageNet 预训练权重。

列表 8.24 Keras 应用示例

import tensorflow as tf
resnet = tf.keras.applications.ResNet50(
    include_top=False,
    weights='imagenet')

您可以使用 Keras Applications 与 KerasTuner 一起构建预训练模型的搜索空间。例如,在这里我们构建了一个包含两个超参数的搜索空间:第一个超参数表示是否使用预训练权重,第二个表示是否冻结模型。

列表 8.25 使用 KerasTuner 预训练 ResNet

import tensorflow as tf
import keras_tuner as kt

def build_model(hp):
    if hp.Boolean('pretrained'):                      ❶
        weights = 'imagenet'
    else:
        weights = None
    resnet = tf.keras.applications.ResNet50(
        include_top=False,                            ❷
        weights=weights)                              ❸
    if hp.Boolean('freeze'):                          ❹
        resnet.trainable = False
    input_node = tf.keras.Input(shape=(32, 32, 3))    ❺
    output_node = resnet(input_node)
    output_node = tf.keras.layers.Dense(10, activation='softmax')(output_node)
    model = tf.keras.Model(inputs=input_node, outputs=output_node)
    model.compile(loss='sparse_categorical_crossentropy')
    return model

(x_train, y_train), (x_test, y_test) = tf.keras.datasets.cifar10.load_data()

tuner = kt.RandomSearch(
    build_model,
    objective='val_loss',
    max_trials=4,
    overwrite=True,
    directory='result_dir',
    project_name='pretrained')

tuner.search(x_train, y_train,
             epochs=1,
             validation_data=(x_test, y_test))

❶ 指示是否使用预训练权重的超参数

❷ 不包括分类头

❸ 指定是否使用 ImageNet 预训练权重或随机权重

❹ 冻结模型的超参数

❺ 构建并返回 Keras 模型

构建这样的搜索空间以确定是否使用预训练权重以及是否冻结模型,可以帮助你选择最佳解决方案。使用预训练权重和预训练模型通常是一种加速训练并使你的模型在有限的训练数据下具有良好的泛化能力的好方法。然而,只有通过实验,你才能知道预训练权重是否适合你的问题和数据集。

8.3.3 预热搜索空间

在 AutoML 和超参数调整中,如果没有预热启动,搜索算法对搜索空间没有任何先验知识。它不知道不同超参数的含义,也不知道哪些模型可能表现良好或不好。因此,它必须逐渐、逐个样本地探索一个庞大且未知的搜索空间,这并不非常高效。

预热搜索空间意味着我们在搜索开始之前手动挑选一些好的模型和超参数供搜索算法评估。这是一种将人类对不同模型性能的知识注入搜索过程的好方法。否则,搜索算法可能会在找到好的模型之前,花费大量时间在不好的模型和不好的超参数组合上,而这些好的模型数量远少于不好的模型!

使用预热启动,搜索算法可以通过利用启动模型,在有限的计算资源下快速找到一个好的模型。基于这个想法,我们可以使用贪婪策略来搜索空间。我们首先评估启动模型。然后选择最佳模型,并随机稍微修改其超参数值,以产生下一个要评估的模型。

这种贪婪策略已经在 AutoKeras 中实现,作为贪婪调优器。一些特定任务的调优器,如 ImageClassifierTuner 和 TextClassifierTuner,是贪婪调优器的子类。当使用任务 API,如 ImageClassifier 和 TextClassifier 时,这些是默认的调优器。它们提供了一系列预定义的超参数值,在探索搜索空间之前先尝试。因此,当你在 AutoKeras 中运行这些任务时,这些调优器比没有预热启动的调优器更有效率。

摘要

  • 当训练数据集太大而无法放入主内存时,我们可以分批将数据集加载到内存中进行训练和预测。

  • 数据并行通过在不同设备上保持模型同步副本并分割数据集以并行训练来加速模型训练。

  • 模型并行将大模型分割,将不同的层放在不同的设备上,并允许它们并行运行(使用相同的数据)。这也可以加速训练。

  • 并行调优涉及在不同设备上并行运行具有不同超参数设置的模型,以加快调优过程。

  • Hyperband 可以通过将有限的资源分配给没有前途的模型,并使用节省下来的资源为更有前途的模型提供支持,从而加速搜索过程。

  • 使用在大数据集上学习到的预训练权重可以允许在新的数据集上更快地收敛,并在新数据集较小时使模型更好地泛化。

  • 对搜索空间进行预热启动,给调优器提供了一个更好的空间概览,从而加快搜索过程。


(1.) 李丽沙,等,“Hyperband: 一种基于 Bandit 的新的超参数优化方法”,《机器学习研究杂志》 18,第 1 期(2017 年):6765-6816。

9 总结

本章涵盖

  • 本书的重要要点

  • AutoML 的开源工具包和商业平台

  • AutoML 的挑战和未来

  • 学习更多和在该领域工作的资源

我们几乎到达了本书的结尾。最后一章回顾了我们所涵盖的核心概念,同时旨在拓宽你的视野。我们将从快速回顾你应该从本书中汲取的内容开始。接下来,我们将概述一些流行的 AutoML 工具(包括开源和商业),这些工具位于 Keras 生态系统之外。了解当前 AutoML 社区中的其他标志性工具包将使你能够在阅读本书后根据你的兴趣进一步探索。最后,我们提供了一些关于 AutoML 领域的核心挑战和未来演变的推测性思考,这对于那些想要深入研究该领域基本研究的人来说将特别有趣。理解 AutoML 是一段旅程,完成本书只是第一步。在本章末尾,我们将为你提供一份关于学习更多 AutoML 和了解该领域最新发展的资源和方法简短列表。

9.1 复习关键概念

本节简要总结了本书的关键要点,以刷新你对所学内容的记忆。

9.1.1 AutoML 流程及其关键组件

AutoML 允许机器模仿人类设计、调整和应用机器学习算法的方式,以便我们更容易地采用机器学习。它旨在在给定机器学习问题时自动发现最佳机器学习解决方案,从而释放数据科学家手动调整的负担,并使没有丰富经验的专业人士能够访问现成的机器学习技术(见图 9.1)。

09-01

图 9.1 机器学习与 AutoML 对比

AutoML 的过程是迭代的,通常包括三个步骤(见图 9.2):

  1. 根据搜索策略从搜索空间中选择一个机器学习流程进行观察。搜索空间定义了我们想要调整的超参数集合以及每个超参数的取值范围。搜索策略探索搜索空间,并在每次迭代中选择一组超参数以实例化一个待评估的完整机器学习流程。

  2. 在训练数据集上训练所选的机器学习流程,并检索其在验证集上评估的性能。

  3. 如果搜索策略能够利用历史评估来加速发现更好流程的过程,则更新搜索策略。

09-02

图 9.2 经典顺序 AutoML 流程的搜索循环

因此,AutoML 的三个核心组件是搜索空间、搜索策略以及用于评估和比较所选管道的验证过程。搜索空间是需要你最多实现工作的部分,而其他两个的实现通常是内置模块,你可以从 AutoML 工具包中选择。

9.1.2 机器学习管道

正如我们在第二章中描述的,典型的机器学习工作流程可以总结如下:

  • 问题定义和数据收集—定义问题的目标,例如你想要预测哪些类型的事物或从数据中提取哪些类型的模式。指定问题所属的范式,例如监督学习、无监督学习等。确定一种可靠地衡量最终模型成功的方法,例如在图像分类问题中的预测准确率。在许多情况下,你可能需要特定领域的指标。收集数据以帮助训练和评估你的模型。

  • 数据预处理和特征工程—将数据处理成适合输入到机器学习算法的格式。移除冗余特征,并在需要的情况下选择或生成有用的特征,以帮助提高算法的性能。考虑你将如何评估你的模型,并将数据分割成训练集、验证集和测试集,以帮助后续的评估过程。

  • 机器学习算法选择—根据你的经验和问题先验知识选择合适的机器学习算法。你可能想要迭代尝试不同的机器学习算法,并在将其应用于最终测试集和部署之前选择最佳的一个。

  • 模型训练和评估—应用机器学习算法训练机器学习模型,并根据你预定义的度量在验证数据集上评估它。

  • 超参数调整—通过迭代调整其超参数来改进管道以实现更好的性能。为了避免过拟合,确保不要使用测试集来选择机器学习算法和调整超参数。

  • 服务部署和模型监控—部署最终的机器学习解决方案,并监控其性能以持续维护和改进管道。

9.1.3 AutoML 的分类

与机器学习工作流程相呼应,我们可以将 AutoML 分为以下三个类别:

  • 自动特征工程通常遵循特征生成和选择的迭代过程。它的目的是自动发现信息丰富且具有区分度的特征,以便根据预定义的选择标准学习最佳的机器学习模型。

  • 自动超参数调整旨在为机器学习管道中的一个或多个组件选择最优的超参数。通常,可调整的超参数可以包括机器学习管道中的任何超参数,例如模型类型、不同的数据预处理方法、优化算法的超参数等。

  • 自动流水线搜索旨在根据我们告诉 AutoML 系统执行的任务(如分类或回归)和输入数据,生成整个机器学习(ML)流水线。

在深度学习的背景下,我们通常关注上述最后两个,但自动特征工程也非常重要(特别是对于提高浅层模型的性能和学习速度)。

9.1.4 AutoML 的应用

AutoML 已被应用于设计调整机器学习(ML)流水线的各种机器学习任务。在不同情况下应用 AutoML 的主要差异在于搜索空间的设计和评估策略。搜索空间应包括所有适用于当前任务的机器学习流水线,例如用于图像分类的卷积神经网络(CNNs)、用于时间序列数据的循环神经网络(RNNs)等。为您的机器学习任务设计合适的搜索空间需要对该任务特定的机器学习模型有初步的了解,这种知识可以帮助缩小搜索范围以获得更好的搜索结果。评估策略应根据应用进行调整,以便它能提供有用的度量来比较机器学习流水线。例如,在分类任务中,我们可以使用分类准确率作为度量,在推荐任务中,我们可以使用曲线下面积(AUC)或归一化折现累积增益(NDCG)等。搜索方法通常无需修改即可适用。文献中研究的一些代表性 AutoML 应用包括以下内容:

  • 自动目标检测—目标检测是计算机视觉中的一个经典任务,旨在在图像和视频中检测特定类别的对象(如人类、家具或汽车)。自动目标检测试图生成多级目标特征的更好融合和更好的检测机器学习模型结构,以提高目标检测性能。

  • 自动语义分割—语义分割系统有两个基本组件:多尺度上下文模块和神经网络架构。分割任务对空间分辨率变化敏感。因此,AutoML 可以用来搜索每个层具有适当空间分辨率的不同结构。

  • 自动生成对抗网络—生成对抗网络的核心有两个组成部分:生成网络和判别网络。AutoML 可以用来搜索生成器和判别器的最佳网络结构。

  • 自动网络压缩—自动化机器学习(AutoML)可以搜索网络参数的最佳组合,包括层稀疏性、通道数量和位宽,以压缩神经网络,而不会降低准确度或延迟。

  • 自动图神经网络——AutoML 可以在节点分类任务中搜索适合图神经网络的图卷积组件,例如隐藏维度的数量、注意力头的数量以及各种类型的注意力、聚合和组合函数。

  • 自动损失函数搜索——机器学习中使用的最常见损失函数是交叉熵和 RMSE。除了这些之外,AutoML 还可以考虑不同计算机视觉任务中损失函数的类内/类间距离以及样本的难度水平。

  • 自动激活函数搜索——激活函数在深度神经网络中起着至关重要的作用。除了选择最佳现有激活函数外,AutoML 还可以搜索一组二进制和一元数学函数,以设计预定义函数结构的新激活函数。

  • 自动点击率(CTR)预测——CTR 预测是推荐系统中的一个重要任务。AutoML 可以设计有效的神经网络架构来捕捉显式和隐式特征交互,以实现更好的 CTR 预测。

其他任务包括计算机视觉领域的自动行人重识别、自动超分辨率和自动视频任务;自然语言处理领域的自动翻译、自动语言建模和自动关键词检测;以及一些特定于模型/算法/学习范式和任务无关的应用,例如自动无监督学习、自动强化学习、自动联邦学习等等。一般来说,AutoML 的可能应用领域与机器学习(ML)的可能性空间相吻合。无论你可以在哪里应用机器学习,你都可以应用 AutoML 来生成机器学习管道,或者通过更改其组件或调整其超参数来改进管道。

9.1.5 使用 AutoKeras 的自动深度学习

深度学习是机器学习的一个子领域,已成为人工智能社区乃至更广泛的领域的热门话题。它在广泛的应用空间中展现出有希望的性能和可能性。自动深度学习的目标是自动设计和调整深度学习管道。在最受欢迎的开源深度学习库 AutoKeras 的支持下,我们能够根据我们的需求和不同的 AutoKeras API 在以下场景中进行自动深度学习:

  • AutoKeras 的任务 API 可以帮助我们用尽可能少的代码行(如三行)生成针对目标机器学习任务(如图像分类)的端到端深度学习解决方案。这些是最直接的 AutoKeras API,因为它们允许我们在单步中实现所需的机器学习解决方案——即输入数据——而无需我们自己知道如何实现深度学习模型。在 AutoKeras 的最新版本中,有六个不同的任务 API 支持六个不同的任务,包括图像、文本和结构化数据的分类和回归。在开始之前,你应该清楚地知道根据你想要解决的机器学习问题选择哪个 API,并将原始数据准备成 AutoKeras 可接受的格式之一。

  • AutoKeras 的输入/输出(I/O)API 是处理多模态和多任务学习问题的更通用解决方案。它接受不同类型和数量的输入和输出,并在初始化时要求你明确指定它们的格式。

  • 功能 API 是 AutoKeras 最复杂的 API,专为希望根据需求定制搜索空间的先进用户设计。它类似于 TensorFlow Keras 功能 API,要求你通过连接一些 AutoKeras 构建块来实现 AutoML 管道。每个块代表由多个 Keras 层组成的特定深度学习模型(或数据预处理方法),例如 CNN,以及模型的超参数搜索空间。你还可以在每个构建块(或你自己的 AutoML 块)中指定搜索空间,并将它们与内置块连接起来,以选择和调整你自己的个性化深度神经网络。

图 9.3 展示了这些 API 各自的使用示例。

09-03

图 9.3 展示了使用 AutoKeras API 进行自动深度学习的示例。

AutoKeras 支持你对于监督学习问题(如分类和回归)的自动深度学习解决方案的需求,其内置块也节省了你创建搜索空间的努力。然而,如果你需要的内置块都不满足你的需求,或者你有一个复杂的 AutoML 应用程序,需要调整损失函数、选择浅层模型、为无监督学习问题设计模型等,我们建议你使用 Keras 生态系统中的其他 AutoML 工具包:KerasTuner。

9.1.6 使用 KerasTuner 进行完全个性化的 AutoML

KerasTuner 是一个用于选择和调整深度学习和浅层机器学习模型的库。除了 AutoKeras 可以解决的任务外,它还处理以下三个 AutoKeras 难以处理或引入额外负担的场景:

  • 搜索空间中的管道有不同的训练和评估策略,例如使用 scikit-learn 实现的浅层模型和使用 TensorFlow Keras 实现的深度学习模型。

  • 你需要执行除了监督学习任务之外的任务。

  • 在 AutoKeras 中没有内置的 AutoML 块适合使用。

如第六章所述,使用 KerasTuner 调整模型需要实现一个模型构建函数(或扩展 HyperModel 的类)来表征搜索空间,并初始化一个指定搜索方法的调节器对象,如下所示。

列表 9.1 使用随机搜索调整 KerasTuner 的 MLP 模型

import tensorflow as tf
from tensorflow import keras
from tensorflow.keras import layers
from keras_tuner import RandomSearch

def build_model(hp):                               ❶
    input_node = keras.Input(shape=(20,))
    units = hp.Int('units', min_value=32, max_value=512, step=32)
    output_node = layers.Dense(units=units, activation='relu')(input_node)
    output_node = layers.Dense(units=1, activation='sigmoid')(output_node)
    model = keras.Model(input_node, output_node)

    optimizer = tf.keras.optimizers.Adam(learning_rate=1e-3)
    model.compile(
        optimizer=optimizer,
        loss='mse',
        metrics=['mae'])
    return model

tuner = RandomSearch(                              ❷
    build_model,
    objective='val_mae',
    max_trials=5,
    executions_per_trial=3,
    directory='my_dir',
    project_name='helloworld')

❶ 创建模型构建函数,并指定搜索空间

❷ 定义随机搜索调节器

调节器包含一个搜索方法,并在搜索过程中组织所选管道的训练和评估。由于 KerasTuner 是为调整深度学习模型而设计的,因此内置的调节器(除 SklearnTuner 外)都是专门用于调整深度学习管道的。每个调节器都封装了深度学习管道的训练和评估过程,其名称表示特定的搜索方法:例如,RandomSearch 是一个采用随机搜索方法调整深度学习模型的调节器。

我们可以通过使用 SklearnTuner 以与深度学习模型相同的方式创建用于调整使用 scikit-learn 库实现的浅层模型的搜索空间,SklearnTuner 封装了 scikit-learn 模型或管道的训练和评估过程。在 KerasTuner 中,搜索方法被称为 oracle。因此,我们可以通过更改调节器中的 oracle 来选择不同的搜索方法。我们还可以自定义调节器来调整使用其他库(超出 Keras 和 scikit-learn)实现的模型。以下列出了自定义调节器的伪代码;它需要实现一个 run_trial()函数来执行当前试验,以及两个用于保存和加载评估模型的辅助函数。

列表 9.2 自定义调节器的模板

import tensorflow as tf
import keras_tuner as kt

class CustomTuner(kt.engine.base_tuner.BaseTuner):       ❶

    def run_trial(self, trial, data):                    ❷
        ...

    def save_model(self, trial_id, model, step=0):       ❸
        ...

    def load_model(self, trial):                         ❹
        ...
        return model

my_custom_tuner = CustomTuner(
    oracle=kt.oracles.RandomSearch(
        objective=...,
        max_trials=...,
        seed=...),
    hypermodel=build_model,
    overwrite=True,
    project_name='my_custom_tuner')
>>> my_custom_tuner.search(data)

❶ 构建和拟合 GBDT 模型

❷ 构建、训练、评估和保存当前试验中选定的模型,并在需要时更新 oracle

❸ 将模型保存到磁盘

❹ 模型加载函数

除了通过更改 oracle 来选择我们自定义调节器的不同搜索方法外,我们还可以通过自定义 oracle 类来实现自己的搜索技术,如下一节所示。

9.1.7 实现搜索技术

现有的 AutoML 搜索方法可以根据它们是否能够考虑历史搜索结果来提高性能,分为历史无关或历史相关。

随机搜索和网格搜索是两种代表性的不依赖历史信息的方法。启发式方法,如进化方法和基于模型的方法,如贝叶斯优化方法,是两种最广泛使用的依赖历史信息的方法。进化方法模拟了生物种群的发展。它随机初始化一个试验种群,并从种群中随机选择几个父代试验,根据超参数的变异和交叉操作生成后代。在评估新的后代试验后,根据某些选择策略(如排名选择)更新种群。贝叶斯优化采用一个代理模型,该模型使用历史评估的机器学习管道作为近似未见模型性能的更便宜的方法。一个获取函数将利用代理模型的近似来帮助采样下一个可用的试验。

设计一个依赖历史信息的搜索方法需要平衡利用和探索。利用意味着我们想要利用过去的经验,根据当前表现最佳的超参数的邻近性来选择下一个超参数,因为我们对这些点有信心。探索意味着我们想要探索搜索空间中更多未开发的区域,以避免陷入局部最优并错过全局最优。

实现一个依赖历史信息的搜索方法需要实现两个步骤:超参数采样和算法更新。搜索方法的采样和更新在 populate_space()函数中实现,如下所示。

列表 9.3 定制算子的模板(搜索方法)

class CustomOracle(Oracle):

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        ...                                 ❶

    def populate_space(self, trial_id):
        values = ...                        ❷
        ...                                 ❸
        if values is None:
            return {'status': trial_lib.TrialStatus.STOPPED,
                    'values': None}
        return {'status': trial_lib.TrialStatus.RUNNING,
                'values': values}

❶ 可以在这里放置搜索方法的额外初始化步骤

❷ 在当前试验中采样超参数值

❸ 基于搜索历史更新搜索方法

注意:在研究社区中,一些最近的研究进展集中在强化学习基于梯度的方法上,尤其是在自动深度学习的领域。我们将在本章末尾提供一些有用的学习材料的参考文献,以便在阅读本书之后,您可以进行一些进一步的探索,以跟上这些进展。

9.1.8 扩展 AutoML 过程

在实践中应用 AutoML 的最大挑战是数据可扩展性以及时间和空间复杂性。解决这些挑战的一种典型方法是通过采用并行化技术。正如我们在第八章中讨论的,存在以下三种类型的并行化策略(见图 9.4):

  • 数据并行性使得通过利用多台机器(或 CPU/GPU/TPU)来处理大型数据集成为可能。这种方法允许你在不同的机器上使用不同的数据批次训练相同模型的多个副本,并定期同步这些机器以更新模型权重。

  • 模型并行性主要用于无法包含在单个 GPU 内存中的大型模型,或者用于加速推理过程可以并行化的模型。它将模型分解成不同的部分,并将它们分配到不同的 GPU 上,以便整个模型可以适应可用的内存。在推理过程中,模型的一些部分可能并行运行以节省时间。

  • 并行调优用于加速 AutoML 过程。采用这种方法,你将具有不同超参数设置的模型放在不同的 GPU 上,并使用相同的训练数据集来训练它们,因此超参数调优过程是并行运行的。

09-04

图 9.4 三种并行类型

除了利用更多的硬件资源外,我们还可以通过以下方式从算法的角度加速搜索过程:

  • 使用基于保真度的技术——我们可以使用低保真度估计来大致比较不同机器学习管道的性能。一些典型的方法包括早期停止、对训练和评估搜索过程中发现的模型的数据进行子采样,以及直接采用高级调度方法,如 Hyperband。

  • 使用预训练权重和模型——我们可以使用预训练的权重并将它们(部分)共享到发现的机器学习模型中,以加速这些模型的训练。这在自动深度学习中特别有用。

  • 预热搜索空间——我们可以在搜索开始之前手动挑选一些好的模型和超参数来评估,并将一些人类先验知识注入到搜索算法中,让搜索算法基于这些信息进行构建。

9.2 AutoML 工具和平台

工具和平台的发展推动了 AutoML 领域的发展。我们在此介绍其中的一些。尽管不同工具的环境配置相当不同,其中一些还提供了 GUI 以实现更好的可视化和更简单的人机交互,但它们的 API 通常与 AutoKeras 和 KerasTuner 的 API 非常相似。它们都是围绕 AutoML 的三个组件构建的:搜索空间、搜索算法和评估标准。在前面章节的背景下,你应该能够通过探索它们的仓库和教程,无需陡峭的学习曲线就能适应这些工具。

9.2.1 开源 AutoML 工具

可用的开源 AutoML 工具包可以根据其核心焦点分为几个类别。以下是一些:

  • 自动特征工程工具

  • 自动超参数调整、模型选择和端到端自动化管道搜索工具

  • 自动深度学习工具

让我们看看一些代表性的例子。

FeatureTools 可能是当时最受欢迎的开源 Python 库,用于自动特征工程。它将特征工程操作抽象为原语,并将它们应用于从关系数据集和时间数据集中生成特征。

大多数现有的 AutoML 项目都集中在超参数调整或生成端到端机器学习管道。这个领域最早的项目之一是 Auto-WEKA,它建立在名为 Weka(Waikato Environment for Knowledge Analysis)的数据分析包之上。它通过贝叶斯优化方法进行超参数调整并生成机器学习管道,主要用于监督学习任务。另一个基于 scikit-learn 库构建的项目,名为 Auto-Sklearn,在许多 AutoML 竞赛中展示了有希望的性能。它也使用贝叶斯优化方法搜索并发现集成模型以提升性能。Auto-Sklearn 的最新版本有一个简洁的 API,类似于 AutoKeras 的任务 API,如下所示。其他一些流行的库包括 TPOT、Hyperopt、Microsoft NNI 以及 H2O AutoML 工具包的开源版本。

列表 9.4 比较 Auto-Sklearn 和 AutoKeras 任务 API

from autosklearn.classification import AutoSklearnClassifier
automl = AutoSklearnClassifier()                              ❶
automl.fit(X_train, y_train)
predictions = automl.predict(X_test)

clf = ak.StructuredDataClassifier()                           ❷
clf.fit(x_train, y_train, epochs=10)
predicted_y = clf.predict(x_test)

❶ 初始化 Auto-Sklearn 的自动分类学习器

❷ 初始化 AutoKeras 的自动分类学习器

近年来,大部分开发工作都集中在自动深度学习上。除了 AutoKeras 之外,亚马逊的研究人员还提出了一个名为 AutoGluon 的包,它建立在 Gluon 深度学习 API 之上。它针对 MXNet 和 PyTorch 用户,旨在在 AWS 云基础设施上易于使用。其他库,如 Auto-PyTorch,也提供了与 AutoKeras 类似 API 的神经架构搜索功能。

除了这里提到的工具之外,许多其他工具也提供了 AutoML 组件。例如,一个著名的机器学习分布式执行框架 Ray 有一个名为 Ray Tune 的模块,它收集了一系列开源 AutoML 搜索算法,并利用 Ray 框架实现分布式调整。Ludwig 工具箱允许您在不编写任何代码的情况下训练和评估深度学习模型,它还包括一个用于超参数调整和模型选择的 AutoML 模块。这些工具的总结见表 9.1。

表 9.1 选定的开源 AutoML 工具

核心任务 框架 URL
自动特征工程 FeatureTools www.featuretools.com
自动超参数调整或管道搜索 Hyperopt hyperopt.github.io/hyperopt/
Auto-WEKA www.cs.ubc.ca/labs/beta/Projects/autoweka/
Auto-Sklearn automl.github.io/auto-sklearn/master/
Ray Tune docs.ray.io/en/master/tune/index.html
KerasTuner keras.io/keras_tuner/
TPOT epistasislab.github.io/tpot/
微软 NNI nni.readthedocs.io
H2O AutoML 工具包 www.h2o.ai/products/h2o-automl/
洛德维希 github.com/ludwig-ai/ludwig
自动深度学习 AutoKeras autokeras.com
Auto-Gluon auto.gluon.ai/stable/index.html

9.2.2 商业自动机器学习平台

除了开源项目之外,许多公司,尤其是那些提供云服务的公司,也在探索自动机器学习的商业机会。以下是一些例子:

  • 谷歌 Cloud AutoML (cloud.google.com/automl) 提供了一个图形界面,可以根据其应用领域和数据结构自定义机器学习模型。谷歌的 AutoML 产品包括用于计算机视觉任务的 AutoML Vision、用于自然语言处理任务的 AutoML Natural Language、用于轻松构建和部署模型的 Vertex AI 以及更多。

  • 亚马逊 SageMaker Autopilot (aws.amazon.com/sagemaker/autopilot/) 主要专注于生成用于表格数据分类或回归的端到端机器学习管道。它可以帮助您通过简单地输入原始表格数据和目标,自动构建、训练和调整最佳机器学习模型,并且可以利用 AWS 的强大功能来处理大规模任务。您还可以一键部署模型到生产环境,并通过利用 Amazon SageMaker Studio 逐步改进它。

  • 微软 Azure AutoML (mng.bz/aDEm) 提供了针对两个不同用户群体的定制化体验。对于熟悉机器学习并且知道如何使用 Python 代码实现机器学习模型的用户,Azure 机器学习 Python SDK 可以是一个不错的选择,它使您能够快速、大规模地构建机器学习模型。对于没有机器学习编码经验的用户,Azure 机器学习 Studio (ml.azure.com) 是一个很好的选择;它提供了一个图形界面,通过几个简单的点击即可执行自动机器学习。

  • IBM Watson Studio AutoAI (www.ibm.com/cloud/watson-studio/autoai) 自动化 AI 生命周期的所有四个步骤:数据准备、特征工程、模型开发和超参数优化。您可以使用此工具管理整个生命周期,并通过一键部署模型。

除了之前提到的平台,许多初创公司也在努力,包括 DataRobot、4Paradigm、H2O.ai、Feature Labs、DarwinML 等(见表 9.2)。我们相信 AutoML 将继续在越来越多的产品中应用并显示出其优势,并将有助于民主化 ML 技术,使更多公司能够用于不同的工业应用。

表 9.2 选定的商业 AutoML 平台

公司 产品 用户示例
Google Google Cloud AutoML 迪士尼、ZSL、URBN
Amazon Amazon SageMaker Autopilot Amazon AWS
Microsoft Microsoft Azure AutoML Azure 机器学习、Power BI 和其他 Microsoft 产品
IBM IBM Watson Studio AutoAI IBM Cloud
DataRobot DataRobot 企业 AI 平台 Snowflake、Reltio、Alteryx、AWS、Databricks
4Paradigm 4Paradigm AutoML 平台 中国银行、PICC、知乎
H2O.ai H2O AutoML 平台 AWS、Databricks、IBM、NVIDIA
Feature Labs Feature Labs AutoML 平台 NASA、Monsanto、Kohl’s
DarwinML DarwinML AutoML 平台 Intelligence Qubic

9.3 AutoML 的挑战和未来

AutoML 仍然处于早期阶段,有巨大的可能性等待发现和限制需要解决。在本节中,我们分享了对当前 AutoML 领域主要挑战的看法,并展望了如何解决这些挑战。

9.3.1 衡量 AutoML 的性能

在进行 AutoML 之前,我们需要明确我们想要用来衡量其性能的目标。尽管在这本书的大部分内容中,我们使用了准确率指标,如图像分类准确率,来决定 AutoML 算法发现的模型是否良好,但 AutoML 不仅仅是提高模型准确率。在实践中,在应用 AutoML 时,我们可能需要或需要考虑许多目标。例如,我们可能希望选择一个具有较小模型尺寸的深度学习模型(限制内存消耗)或较慢的训练/推理速度,以便我们可以在边缘设备上部署它。在这种情况下,我们可能需要考虑搜索过程中的复杂度指标,如每秒浮点运算次数(FLOPS)。作为另一个例子,我们可能希望一个能够生成高度可解释和令人信服的结果的模型,而不仅仅是提供准确的预测。这在医疗应用中相当常见,其中可解释性和透明度非常重要。道德和伦理也指出,数据隐私和 ML 模型的预测公平性很重要,这导致了一些新的研究方向,如具有联邦学习的 AutoML 来增强这些目标。由于具体目标因案例而异,理想的 AutoML 系统应该能够考虑这些特定任务的要求,并且不同的 AutoML 算法应该进行更好的基准测试,以帮助没有太多 ML 背景的用户轻松选择最佳使用方案。

9.3.2 资源复杂性

资源消耗是当前 AutoML 领域的主要挑战之一。随着数据集和机器学习模型变得越来越庞大,终端用户往往难以采用 AutoML 来设计或调整他们自己的机器学习模型。由于资源限制,我们常常不得不妥协。尽管研究界最近的一些进展旨在提出一次性方法以避免迭代调整过程,但为了从机器学习算法和硬件设计两个方面降低 AutoML 的时间和空间复杂度,还有很多探索要做。

9.3.3 可解释性和透明性

AutoML 最终应该方便用户使用,减轻他们的负担。这要求 AutoML 系统以人为中心,意味着用户应以多种方式参与到搜索过程中。首先,AutoML 系统提供的结果应该是可解释的,以说服用户其有效性,并培养用户对 AutoML 解决方案在特定领域应用(如医疗应用)的信任。其次,用户应该能够调整搜索空间或目标以加速搜索过程,这需要可见性。第三,搜索方法应该对用户透明,以帮助他们更好地理解搜索过程,并确保数据隐私得到保护,预测是公平的。确保 AutoML 的可解释性和透明性还需要对不同的机器学习管道和 AutoML 搜索技术有更深入的理论理解。

9.3.4 可重复性和鲁棒性

机器学习的可重复性和机器学习模型的鲁棒性是机器学习社区的热门话题。这些问题在 AutoML 的背景下也同样重要——甚至更具挑战性,因为 AutoML 系统不仅可能控制多个机器学习管道的训练,还包含多个控制搜索算法的“超超参数”。单个种子差异可能导致单个机器学习管道的训练结果产生巨大差异,也可能导致超参数采样和搜索算法更新的巨大差异。此外,机器学习模型,尤其是深度学习模型,容易受到对抗性样本和人类感知干扰的影响。确保训练和评估机器学习模型以及超参数采样和搜索算法更新的鲁棒性对于保护 AutoML 系统至关重要。

9.3.5 泛化性和迁移性

在实际应用中,我们可能会有多个数据集和任务。一个由人类设计的模型通常适用于不同的数据集,甚至可以在多个任务之间迁移。我们期望 AutoML 解决方案也能推广到不同的机器学习应用中。还期望它具有终身学习能力,这意味着从先前 AutoML 任务中学习到的元知识可以被记住并应用于新任务,就像我们人类积累知识和经验一样。

9.3.6 民主化和商业化

AutoML 在推广高级机器学习技术方面发挥着重要作用,尤其是对于没有太多机器学习专业知识的用户来说。尽管开源社区在开发易于使用的 AutoML 解决方案方面加大了努力,但使用这些工具的学习曲线仍然很陡峭,需要初步的机器学习知识和对 AutoML 系统的理解。此外,由于从通用搜索空间生成 AutoML 解决方案不切实际,采用 AutoML 方法处理超出常见问题的机器学习任务通常需要额外的手动数据预处理工作和特定领域的搜索空间设计。将 AutoML 解决方案商业化并投入生产还需要更优化的系统设计。部署它们甚至可能比采用经典机器学习方法和手动设计和调整更复杂。

9.4 在快速发展的领域中保持最新

为了帮助您跟上快速发展的 AutoML 领域,在本节中,我们将向您推荐一些有用的资源,这些资源可以帮助您跟踪和学习 AutoML 技术领域的最新发展。一些研究小组和个人研究人员在其网站或 GitHub 页面上调查和整理了 AutoML 工具包和论文的最新进展,因此我们将从其中的一些开始(目前这些内容是定期更新的)。您可以通过以下资源快速搜索和检索 AutoML 文献中的材料:

  • 弗赖堡大学 AutoML 研究小组的网站,由弗兰克·胡特教授和马里乌斯·林道尔领导:www.automl.org。除了托管研究小组的项目外,该网站还基于分类如神经架构搜索(NAS)等,提供了精选的 AutoML 论文和其他资源的列表。

  • 由马克·林博士发起的 GitHub 网页,提供了一份精选的 AutoML 论文和其他资源的列表:github.com/hibayesian/awesome-automl-papers

  • 由韦恩·韦发起的 GitHub 网页,整理了一份 AutoML 相关文献和工具包的列表:github.com/windmaple/awesome-AutoML

除了这些参考网站,我们还推荐以下三个网站,你可以在这些网站上搜索最新的 AutoML 研究论文,或者在实际应用中练习一些代码和实现:

  • arXiv (arxiv.org) 是一个开放获取的科研论文预印本服务器。机器学习领域的科研人员经常在这里发布他们的发现或研究想法,甚至在他们的论文发表之前。尽管监控网站上大量的论文可能会让人感到压力山大,但这仍然是一个不错的渠道,让你能够跟踪 AutoML 领域的新发现。

  • Papers with Code (paperswithcode.com/) 精选了带有免费开源代码的机器学习论文。你可以浏览前沿的 AutoML 论文,以及探索用于学习和实践的学习示例代码和数据集。

  • Kaggle (kaggle.com) 是一个面向数据科学和机器学习实践者的在线社区,参与者可以发布数据集,组织比赛,并使用机器学习模型解决数据挑战。通过参与这些比赛并了解其他实践者在不同任务上的机器学习或 AutoML 解决方案,你将更深入地理解机器学习模型和 AutoML 技术。

AutoML 并非遥不可及,无疑,它是迈向通用人工智能的重要一步。长期来看,所有机器学习问题的完全自动化是可能的,但短期内不太可能实现。我们可能正处于对 AutoML 过度期望的顶峰,通往通用人工智能的道路仍然充满挑战。其发展高度依赖于来自多个不同领域的科研人员、开发者和实践者的参与。我们鼓励你继续你的 AutoML 学习之旅,包括使用、质疑和开发 AutoML,这可以是一段终身的旅程。尽管许多人认为,民主化人工智能可能会使人类专家变得无用,被人工智能代理所取代,但我们相信,机器的智慧永远不会完全超越人类的智慧,相反,我们将共同成长和学习。总的来说,我们只能根据我们今天所知道的情况来想象未来会带来什么——但当我们回顾过去,看看人们当时是如何想象他们的未来的,我们常常发现今天的现实已经超越了他们的最狂野的梦想。

摘要

  • 本章总结了你在本书中学到的核心概念。我们希望你已经从 AutoML 以及如何使用 AutoKeras 和 KerasTuner 来应用它中学到了一些东西,同时也对从 AutoML 视角看机器学习有了诱人的瞥见。

  • 越来越多的开源和商业 AutoML 工具包被提出,该领域的研究活跃。这些工具帮助将机器学习技术民主化到不同的研究领域和工业应用中。

  • AutoML 仍处于早期阶段,存在着巨大的可能性空间等待被发现,以及需要解决的局限性。我们鼓励您从提供的资源中学习,继续在这个领域进行探索,并且永远不要停止向理解 AutoML 奥秘迈进。

附录 A. 设置运行代码的环境

本附录提供了设置环境以运行本书中代码示例的说明。本书中所有的代码片段都是以 Jupyter Notebook 脚本或 notebooks 的形式提供的。Jupyter Notebook (jupyter.org/) 是一个开源的 Web 应用程序,在机器学习和数据科学社区中很受欢迎;它为包括 Python 在内的各种语言提供支持,包括交互式代码设计、数据处理和可视化、叙事文本等。与在命令行或 PyCharm 等 IDE 中运行独立的 Python 相比,它具有更平缓的学习曲线,有助于学习和开发。

本书展示的所有代码片段都可以从 github.com/datamllab/automl-in-action-notebooks 下载。要运行脚本,您有以下两种选择:

  • 使用在线服务或平台,如 Google Colaboratory(Colab)、AWS EC2 等。

  • 在您的本地工作站上安装 Jupyter Notebook。

我建议使用 Google Colab,因为它具有相对平缓的学习曲线,并且易于设置以运行机器学习实验。它还提供了免费的硬件资源(CPU 和 GPU),使得运行实验——特别是深度学习实验——变得更加容易。

我将首先提供设置和配置 Google Colab 环境的说明,然后介绍一些创建和运行笔记本的基本操作。我还会向您展示如何安装运行本书中笔记本所需的附加包以及如何配置运行时。在最后一节中,我将简要描述如何在您的本地机器上设置环境,以便您有更多运行代码的选项。

A.1 Google Colaboratory 入门

Google Colab 是一个免费的 Jupyter Notebook 环境,您可以使用它来运行机器学习实验。它完全在云端运行,无需安装。默认环境包含您将需要的多数机器学习包,如 TensorFlow,并且您可以免费(但有限)访问 GPU。

注:GPU,或图形处理单元,是一种用于广泛和高效图形和数学计算的单一芯片处理器。GPU 比 CPU 更快、更高效,因此非常适合训练机器学习模型。

要开始使用 Colab,请在浏览器中访问 colab.research.google.com/。您有以下几种选择:

  • 通过点击“New Notebook”(如图 A.1 所示)创建您自己的脚本。

  • 通过上传本地文件或从 GitHub 加载来使用现有脚本。

如果您点击“New Notebook”(如图 A.1 所示),将为您创建一个新的 Python 3 笔记本环境。

A-01

图 A.1 如果您想在 Google Colab 中创建一个新的笔记本,请点击“New Notebook”。

本书的所有代码都提供在 GitHub 上,因此你可以通过点击 GitHub 标签直接从那里加载笔记本(如图 A.2 所示)。

A-02

通过菜单栏选择 GitHub 来从 GitHub 仓库加载笔记本。

输入本书仓库的 URL,github.com/datamllab/automl-in-action-notebooks,然后点击搜索按钮(如图 A.3 所示)。

A-03

Figure A.3 输入本书的 GitHub URL,并点击搜索。

仓库菜单应设置为 datamllab/automl-in-action-notebooks,分支菜单设置为 master。在这些菜单下方,你会看到本书的所有 Jupyter 笔记本(如图 A.4 所示)。

A-04

Figure A.4 查看本书 GitHub 仓库中的笔记本

如果你选择之前图中所高亮的第一个 Jupyter 笔记本(2.2-House-Price-Prediction.ipynb),Colab 将自动配置一个带有 CPU 的 Python 3 环境,允许你运行笔记本(如图 A.5 所示)。

A-05

Figure A.5 加载的 2.2-House-Price-Prediction.ipynb 脚本

A.1.1 基本 Google Colab 笔记本操作

笔记本中的每一块代码或文本都称为一个单元格。你可以通过点击它来直接修改现有单元格中的代码或文本。要运行一个单元格,使用 Shift+Enter 键盘快捷键或点击单元格左侧的三角形运行单元格按钮(如图 A.6 所示)。

A-06

Figure A.6 点击运行单元格按钮来运行单元格中的代码。

要添加一个包含可执行 Python 代码的新单元格,你可以在工具栏中点击+ Code。例如,你可以添加一个 Python 代码单元格来导入 numpy 包,创建一个 numpy.ndarray 对象,然后运行它,如图 A.7 所示。

A-07

Figure A.7 创建和运行代码单元格

你还可以创建一个文本单元格,在其中你可以使用 Markdown 语法。为此,在工具栏中点击+ Text(如图 A.8 所示)。

A-08

Figure A.8 创建新的文本单元格

通过选择单元格并使用所选单元格右上角的工具栏,可以执行更多操作,如删除、复制和移动单元格(如图 A.9 所示)。

A-09

Figure A.9 其他单元格操作的工具栏

A.1.2 包和硬件配置

Google Colab 的默认环境包括本书所需的大部分库(如 NumPy、pandas 和 TensorFlow),因此你不需要在设置新环境后安装它们。如果你确实需要安装新的 Python 包,如 AutoKeras 或 KerasTuner,你可以使用 pip,如图所示:

!pip install $PACKAGE_NAME

例如,你可以按照图 A.10 所示安装 AutoKeras。感叹号(!)告诉 Colab 以 shell 命令而不是笔记本命令的方式运行一个命令。

A-10

Figure A.10 在 Colab 笔记本中使用 pip 安装 AutoKeras 包

在本书的仓库中的 requirements.txt 文件中提供了一个需要安装以运行本书代码的包列表(mng.bz/QWZ6)。您可以在运行任何脚本之前安装所有这些包,也可以根据需要逐个安装。

要更改运行时或调整硬件,请从主菜单中选择“运行时”>“更改运行时类型”(见图 A.11)。

A-11

图 A.11 配置运行时和硬件

您可以将 Python 版本从默认的“book”更改为 Python 3,并可以选择要使用的硬件加速器类型:GPU、TPU 或 None(默认 CPU),如图 A.12 所示。

A-12

图 A.12 在 Google Colab 中使用 GPU

这就是您需要了解的所有内容,以便在 Colab 上开始运行。接下来,我们将向您展示如何在您的本地机器上安装 Jupyter Notebook。

A.2 在本地 Ubuntu 系统上设置 Jupyter 笔记本环境

本节描述了如何在本地 Ubuntu/Debian 系统上设置 Jupyter 环境以运行本书中的脚本。以下为四个基本步骤:

  • 安装 Python 3。建议您使用 Python 3,因为 Python 2 已弃用,不再由 Python 软件基金会支持。

  • 使用 venv 命令创建一个 Python 3 虚拟环境,以便您更好地管理环境。

  • 克隆本书的仓库,并安装所有必需的 Python 包,例如 jupyter 和 autokeras。

  • 在提供的 Jupyter 笔记本上工作。

如果您选择此选项,我假设您有在 Python 3 环境中安装和工作的经验,因此我将仅介绍设置虚拟环境以运行 Jupyter 笔记本以及本节剩余步骤。如果您需要有关在本地设置 GPU 环境的详细信息,请参阅弗朗索瓦·肖莱特(François Chollet)所著的《Python 深度学习》(Manning,2021)一书。

A.2.1 创建 Python 3 虚拟环境

我建议使用 venv 来帮助您创建一个干净的 Python 3 环境。这将允许您运行一个虚拟 Python 环境,其中安装的包与您的系统 Python 包分开。首先,按照以下方式安装 venv 命令:

$ sudo apt-get install python3-venv

然后,创建一个名为 automl 的 Python 3 环境,如图所示:

$ python3 -m venv ~/automl

在运行任何与 Python 相关的命令之前,例如这样,您需要激活虚拟环境:

$ source ~/automl/bin/activate
(automl) ...$

设置完成后,您将在命令行开头看到(automl)前缀,表示虚拟环境已启动,如图中代码片段所示。您可以使用 deactivate 命令退出环境。

A.2.2 安装所需的 Python 包

一旦创建了虚拟环境,您就需要安装所需的包。在本书的仓库中的 requirements.txt 文件中提供了一个需要安装的包列表。您可以克隆整个仓库,并使用以下命令进行安装:

(automl) ...$ git clone https:/ /github.com/datamllab/automl-in-
➥ action-notebooks.git

(automl) ...$ cd automl-in-action-notebooks

(automl) .../automl-in-action-notebooks$ pip install -r requirements.txt

Jupyter Notebook 的包也包含在内,因此您无需单独安装。如果您需要安装其他包,可以使用以下 pip 命令:

(automl) .../automl-in-action-notebooks$ pip install $PACKAGE_NAME

A.2.3 设置 IPython 内核

您现在已安装了 Jupyter Notebook 应用程序以及运行本书示例所需的全部包,在虚拟环境中。在开始处理笔记本之前,您需要将虚拟环境与 Jupyter Notebook 连接起来,以便代码可以在特定的 Python 环境中执行。您可以通过将虚拟环境作为笔记本内核安装来完成此操作,如图所示:

(automl) .../automl-in-action-notebooks$ ipython kernel install 
➥ --user --name=automl

内核是一个计算引擎,用于帮助执行笔记本代码。IPython 内核用于执行 Python 代码。您可以在多个内核之间切换,这允许您为运行不同的代码拥有不同的环境。

A.2.4 在 Jupyter 笔记本上工作

要在 Jupyter 笔记本上工作,首先按照以下方式打开网络应用程序:

(automl) .../automl-in-action-notebooks$ jupyter notebook

这将在浏览器窗口中启动应用程序。您还可以在没有浏览器的情况下在远程服务器上运行 Jupyter 笔记本,可选地指定端口(—port=XXXX,默认端口号为 8888)。这将允许您使用以下链接在本地浏览器中启动应用程序:http:/ /localhost:XXXX。

(automl) .../automl-in-action-notebooks$ jupyter notebook 
➥ --no-browser --port=XXXX

这将为您提供如图 A.13 所示的链接,您可以使用它来在本地浏览器中打开 Jupyter 网络应用程序。

A-13

图 A.13 从命令行打开 Jupyter Notebook 网络应用程序

打开 Jupyter Notebook 并进入已下载仓库的文件夹后,您将看到该书的所有笔记本列表(如图 A.14 所示)。

A-14

图 A.14 在网络应用程序中查看笔记本

您可以打开一个笔记本,然后将内核设置为 automl,如图 A.15 所示。

A-15

图 A.15 设置 Jupyter 笔记本的内核

选定的内核将出现在右上角。您现在可以按需运行或修改笔记本。您还可以通过按 Cmd+Shift+P(在 Mac 上)或 Ctrl+Shift+P(在 Linux 或 Windows 上)来查看一些有用的快捷键。

附录 B. 三个示例:图像、文本和表格数据的分类

在第二章中,我们学习了如何构建一个端到端的机器学习管道来解决表格(结构化)数据的回归问题。本附录提供了三个额外的示例,旨在使你更熟悉机器学习管道。这些示例展示了使用各种经典机器学习模型解决涉及图像、文本和表格数据的分类问题。如果你不熟悉机器学习,你将学习如何使用定制的数据预处理方法为模型准备这些不同类型的数据。这里提出的问题也在书的第二部分使用自动化机器学习技术解决。我们将从一个图像分类问题开始。

B.1 图像分类:识别手写数字

我们的第一个问题是识别图像中的手写数字。遵循第二章中介绍的构建机器学习管道的工作流程,我们首先界定问题并组装数据集。

B.1.1 问题界定和数据组装

这是一个分类问题,因为我们假设每个图像中的数字只能是 0 到 9 范围内的整数。因此,识别数字等价于将图像分类到正确的数字类别中。由于我们有超过两种不同的数字类型,我们可以进一步将问题指定为多类分类问题。如果有两个目标类别,我们称之为二元分类问题。

我们将要处理的数据是 scikit-learn 库附带的手写数字数据集,包含 1,797 个大小为 8×8 的手写数字图像。我们可以使用 load_digits()函数加载它,该函数来自 scikit-learn,如下所示。这将返回图像及其相应的标签(数字),我们将它们分别存储。

列表 B.1 加载数字数据集

from sklearn.datasets import load_digits

digits = load_digits()                          ❶

images, labels = digits.images, digits.target   ❷

❶ 加载数字数据集

❷ 分别存储图像和相应的目标

加载的数据包含 1,797 个图像,分为一个形状为 1797×8×8 的三维数组。数组中的每个元素是一个介于 0 到 16 之间的整数,对应于图像中的一个像素。第二和第三维度分别是图像的高度和宽度,如下所示:

>>> images.shape, labels.shape
((1797, 8, 8), (1797,))

在实践中,你处理的图像可能具有不同的尺寸和分辨率,需要裁剪和调整大小操作以对齐它们。本例中的数据集已经处于良好状态,因此我们可以继续进行一些探索性数据分析(EDA),为机器学习模型做准备。

B.1.2 探索和准备数据

我们可以使用 B.2 列表中的代码可视化前 20 个样本,以了解数据的外观。前 20 个图像显示在图 B.1 中。

列表 B.2 可视化前 20 个数字图像及其标签

import matplotlib
import matplotlib.pyplot as plt

n = 20
_, axes = plt.subplots(2, 10, figsize=(10, 2))   ❶
plt.tight_layout()                               ❷
for i in range(n):                               ❸
    row, col = i // 10, i % 10
    axes[row, col].set_axis_off()
    axes[row, col].imshow(images[i,], cmap=plt.cm.gray_r, 
        interpolation='nearest')
    axes[row, col].set_title('Label: %i' % labels[i])

❶ 创建一个图和 20 个子图,布局为 2×10

❷ 自动调整子图参数以提供指定的填充

❸ 绘制前 20 个数字图像

B-01

图 B.1 前 20 位数字图像及其标签的可视化

许多分类算法不能直接应用于二维图像,因此接下来我们将每个图像重塑为一个向量,如下面的代码片段所示。重塑后的数据变成了一个形状为 1797×64 的二维数组。数组中的每一行向量代表一个图像:

>>> n_samples = len(digits.images)
>>> X = digits.images.reshape((n_samples, -1))
>>> X.shape
(1797, 64)

重新塑形的数据格式与我们在第二章中处理过的表格数据类似,但每个图像有 64 个特征(与加利福尼亚住房示例中的 8 个特征相比)。更多的特征通常意味着需要更多的计算资源和更多的时间用于模型训练。这也可能使得学习机器学习模型和从数据中提取有用的分类模式变得更加困难。我们有许多特征工程方法来处理这个问题,其中之一将在下一节中介绍。

B.1.3 使用主成分分析来压缩特征

在进行特征工程之前,让我们首先保留 20%的数据作为最终的测试集,如下所示。这将有助于防止过度拟合训练数据:

>>> from sklearn.model_selection import train_test_split
>>> X_train, X_test, y_train, y_test = train_test_split(
...     X, labels, test_size=0.2, shuffle=False)
>>> X_train.shape, X_test.shape
((1437, 64), (360, 64))

减少数据中特征数量的一个自然解决方案是选择其中的一部分。然而,图像特征(像素)通常没有实际意义,这使得很难制定关于选择哪些特征的选择假设。天真地选择一些特征可能会破坏一些图像并影响分类算法的性能。例如,假设数字可以出现在任何给定图像的左侧或右侧。无论它们出现在哪一侧,它们的标签都将相同。但如果你移除所有图像的左侧一半,一些图像将不再包含数字,导致丢失对分类有用的信息。

一种经典的方法是在尽可能保留信息的同时,减少图像中的特征数量,这种方法是主成分分析(PCA)。它试图通过拟合一个椭球到数据上(见图 B.2),并基于椭球的轴创建一个低维空间,将原始特征线性变换成更少的特征。这些轴由称为主成分的一组正交向量指出。基于这些成分,我们可以重新定义坐标空间,并用新的轴来表示每个数据点。每个数据点的坐标值是其新的特征。我们通过选择一些主成分来执行降维,使用它们指向的轴形成一个子空间,然后将数据投影到新的空间上。子空间的选择基于整个数据集中新特征的方差。图中的椭球轴长度表示了方差。为了在数据集中保留更多信息,我们选择对应于最大方差的主成分。我们可以根据它们可以保留多少整体特征方差来经验性地选择主成分的数量。

在图 B.2 中,我们可以看到,如果我们选择的主成分数量与原始特征数量相同,进行 PCA 变换就等同于旋转坐标并将数据映射到由两个主成分构成的新坐标系上。如果我们选择使用一个成分,它将点投影到椭圆的长轴上(图 B.2 中的第一个 PCA 维度)。

B-02

图 B.2 PCA 示例:具有两个特征的点云的两个主成分

对于数字图像,我们在这里选择 10 个特征作为示例,并使用 scikit-learn 实现 PCA,如下所示。

列表 B.3 在训练数据上应用 PCA

from sklearn.decomposition import PCA               ❶

n_components = 10
pca = PCA(n_components=n_components).fit(X_train)   ❷

X_train_pca = pca.transform(X_train)                ❸

❶ 导入 PCA 模型

❷ 使用训练数据拟合模型

❸ 将训练数据转换到低维空间

对于我们的训练数据,原始特征矩阵和转换后的特征矩阵的形状分别是(1437, 64)和(1437, 10),如图所示:

>>> X_train.shape, X_train_pca.shape
((1437, 64), (1437, 10))

要拟合 PCA 模型,我们只需要输入 X_train,而不需要提供目标标签。这与监督学习范式不同,在监督学习中,我们需要提供一些目标来训练机器学习模型。这种学习范式被称为 无监督学习。它的目的是直接从特征中找到未检测到的模式或学习隐藏的转换,而不需要人类监督,例如标签。PCA 等无监督学习模型对于 EDA 非常有帮助,使我们能够揭示数据中的模式。例如,回到我们的问题,我们可以将数据投影到由前两个主成分构成的两个维空间中,并用相应的标签进行着色,以可视化训练数据中的模式,如下所示(如图 B.3 所示):

plt.figure(figsize=(8, 6))
plt.scatter(X_train_pca[:, 0], X_train_pca[:, 1],
            c=y_train, edgecolor='none', alpha=0.5,
            cmap=plt.cm.get_cmap('Spectral', 10))
plt.xlabel('Component 1')
plt.ylabel('Component 2')
plt.title('PCA 2D Embedding')
plt.colorbar();

B-03

图 B.3 展示了 PCA 变换后 2 维空间中的训练数据可视化

可视化显示了训练图像之间的聚类模式。也就是说,同一数字类别的图像在这个投影的两个维空间中往往彼此更接近。现在我们已经压缩了特征,是时候选择一个机器学习模型来构建分类算法了。

B.1.4 使用支持向量机进行分类

本节介绍了最流行的分类模型之一,即 支持向量机(SVM),以及我们可以调整以改进其性能的两个超参数。SVM 的最简单版本是用于二分类的 线性 SVM。我们将以二分类为例,并假设数据有两个特征。线性 SVM 模型的主要思想是找到一个线来分隔两个类别的点,并最大化它们之间的间隔。位于间隔边界上的实例(例如,图 B.4(a) 中的实例 A 和 B)被称为 支持向量,因为它们“支撑”间隔的两个边界线。假设两个类别是可以直接分隔的,这意味着我们可以找到一个线确保同一类别的所有训练实例都位于同一侧。在这种情况下,我们称这个间隔为 硬间隔。否则,我们只能达到 软间隔,它可以容忍一些违规。超参数 C 可以用来决定这种容忍的强度。调整这个超参数对于线性 SVM 很有用,尤其是在两个类别几乎线性可分时。例如,我们可以减小 C 的值来增加容忍度,从而实现更大的间隔,如图 B.4(c) 所示。

B-04

图 B.4 展示了 SVM 对硬间隔分类(a)和具有不同超参数 C 值的软间隔分类(b 和 c)。这里的 C 值仅用于说明。

有时数据集甚至接近线性不可分(如图 B.5(a)所示)。在这种情况下,我们可以使用非线性支持向量机。非线性支持向量机的主要思想是通过将原始特征映射到更高维的特征空间来增加特征的数量,从而使实例可以变得更加线性可分(见图 B.5(b))。我们可以通过一种称为核技巧的数学技术隐式地进行这种转换。它应用一个称为核函数的函数,直接计算新特征空间中实例之间的相似性,而不需要显式创建新特征,从而大大提高了支持向量机算法的效率。

B-05

图 B.5 通过将其转换为 3-D 空间使线性不可分的 2-D 数据线性可分

支持向量机最初是为二元或双类分类设计的。为了将这种方法推广到多类分类,我们可以使用常见的一对多(OvO)方案。该方案从所有类中选择两个类,并在此对上构建一个二元支持向量机分类器。对每一对类重复此过程,将得到B-05-EQ01个分类器,其中c是类的数量。在测试阶段,所有二元支持向量机分类器都会被测试。每个分类器将当前示例分类为它所训练的两个类中的一个,这意味着每个示例将在所有类中收到B-05-EQ01票。样本最终所属的类是获得最多票的类。以下示例展示了 scikit-learn 实现的多类线性支持向量机分类器。核参数指定了要使用的核类型。

列表 B.4 构建和训练 SVM 分类器

from sklearn.svm import SVC                   ❶

clf = SVC(kernel='linear', random_state=42)   ❷

clf.fit(X_train, y_train)

❶ 导入 SVM 分类器模块

❷ 使用线性核创建支持向量分类器

我们使用准确度分数来评估我们的模型。预测精度定义为正确分类的样本数除以测试样本总数,如下所示:

>>> from sklearn.metrics import accuracy_score
>>> y_pred_test = clf.predict(X_test)

>>> acc = accuracy_score(y_test, y_pred_test)
>>> print('Prediction accuracy: {:.2f} %'.format(acc * 100))

Prediction accuracy: 93.06 %

可以使用混淆矩阵更全面地可视化分类精度,该矩阵显示每个类中正确和错误预测的数量,如下所示:

>>> from sklearn.metrics import plot_confusion_matrix
>>> disp = plot_confusion_matrix(clf, X_test, y_test)
>>> disp.figure_.suptitle('Confusion Matrix (linear SVM classifier)')
>>> plt.show()

混淆矩阵中的每一行对应一个真实标签,每一列对应一个预测标签。例如,图 B.6 中的第一行对应真实标签 0,第一列是预测标签 0。行中的元素表示具有该标签的实例被预测为每个可能标签的数量。每一行的总和等于测试集中具有该标签的实例总数。混淆矩阵的对角线表示每个类别正确预测的标签数量。在图 B.6 中,你可以看到创建的分类器在分类带有标签 3 的测试图像时表现最差:它将六个真实标签为 3 的图像错误地分类为 8。

B-06

图 B.6 线性 SVM 分类器的混淆矩阵

关于模型评估中常用的指标,如 F1 分数、精确度和召回率,你可以参考弗朗索瓦·肖莱特的《Python 深度学习》一书中的更多细节。

为了在数据处理和超参数调整方面更加方便,我们可以将应用 PCA 进行特征工程和 SVM 进行分类的步骤组合成一个集成管道。

B.1.5 使用 PCA 和 SVM 构建数据预处理管道(主成分分析)

scikit-learn 提供了一个简单的管道模块,我们可以用它来按顺序组装多个数据处理组件。以下列表显示了使用两个组件(PCA 后跟 SVM)构建顺序管道的代码。

列表 B.5 使用 PCA 和 SVM 构建 scikit-learn 管道

from sklearn.pipeline import Pipeline
image_clf = Pipeline([
    ('pca', PCA(n_components=10)),
    ('clf', SVC(kernel='linear', random_state=42)),])   ❶

image_clf.fit(X_train, y_train)                         ❷

y_pred_test = image_clf.predict(X_test)                 ❸

❶ 构建图像分类管道并为每个组件分配名称

❷ 训练管道

❸ 测试管道

如果我们检查测试准确率,如下所示,我们会看到它低于我们应用 SVM 而不使用 PCA 时的准确率:

>>> acc = accuracy_score(y_test, y_pred_test)
>>> print(f'The prediction accuracy: {acc * 100:.2f} %')
The prediction accuracy: 89.44 %

如我们之前所看到的,尽管 PCA 减少了特征数量,但它也可能移除一些对分类有用的区分信息。这很可能是这里发生的情况。我们不应该否认 PCA 在压缩我们数据中的特征方面的有用性,但在设计管道时,我们应始终考虑准确性和简单性之间的权衡。

现在,为了提高分类准确率,让我们尝试调整管道中的 PCA 和 SVM 模型。

B.1.6 联合调整管道中的多个组件

在本例中,我们有一个包含两个不同组件的 scikit-learn 管道,每个组件可能有多个超参数需要调整。例如,我们可以调整 SVM 分类器的 C 超参数和核类型。使用 scikit-learn 调整管道几乎与调整单个模型相同。唯一的区别是搜索空间中超参数名称的定义方式。为了区分不同管道组件中的超参数,我们给每个超参数的名称添加一个前缀,以指示该超参数属于哪个组件(如 ComponentName_HyperparameterName)。然后我们可以通过输入整个管道来对所有超参数进行网格搜索。

列表 B.6 联合调整三个超参数

>>> from sklearn.model_selection import GridSearchCV
>>> from sklearn.metrics import make_scorer
>>> hps = {
...     'pca__n_components': [2, 5, 10, 20],
...     'clf__C': [0.05, 0.1, 0.2, 0.5, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15],
... }                                                    ❶

>>> scoring_fnc = make_scorer(accuracy_score)            ❷

>>> grid_search = GridSearchCV(estimator=image_clf,
...                            param_grid=hps,
...                            scoring=scoring_fnc,
...                            cv=3,
...                            verbose=5,
...                            n_jobs=-1)                ❸

>>> grid_search = grid_search.fit(X_train, y_train)      ❹

Fitting 3 folds for each of 120 candidates, totalling 360 fits
[Parallel(n_jobs=-1)]: Using backend LokyBackend with 48 concurrent workers.
[Parallel(n_jobs=-1)]: Done  66 tasks      | elapsed:    1.5s
[Parallel(n_jobs=-1)]: Done 192 tasks      | elapsed:    2.1s
[Parallel(n_jobs=-1)]: Done 338 out of 360 | elapsed:    3.0s remaining:   0.2s
[Parallel(n_jobs=-1)]: Done 360 out of 360 | elapsed:    4.1s finished

❶ 创建一个字典作为超参数搜索空间

❷ 构建一个评分函数以进行性能估计

❸ 使用三折交叉验证创建整个管道的网格搜索对象

❹ 将网格搜索对象拟合到训练数据以搜索最佳模型

然后,我们可以打印最佳超参数组合并检索相应的管道进行最终测试。最终测试结果比我们使用先前管道获得的结果要好得多,符合我们的预期,如下所示:

>>> grid_search.best_params_
>>> best_pipeline = grid_search.best_estimator_

>>> print('The best combination of hyperparameters is:')

>>> for hp_name in sorted(hps.keys()):
...    print('%s: %r' % (hp_name, grid_search.best_params_[hp_name]))

The best combination of hyperparameters is:
clf__C: 3
clf__kernel: 'rbf'
pca__n_components: 20

>>> y_pred_train = best_pipeline.predict(X_train)
>>> y_pred_test = best_pipeline.predict(X_test)

>>> train_acc = accuracy_score(y_train, y_pred_train)
>>> test_acc = accuracy_score(y_test, y_pred_test)
>>> print(f'Prediction accuracy on training set: {train_acc * 100:.2f} %')
>>> print(f'Prediction accuracy on test set: {test_acc * 100:.2f} %')

Prediction accuracy on training set: 99.93 %
Prediction accuracy on test set: 96.67 %

在本例中,我们探索了一个经典图像分类问题,并学习了如何在 ML 管道中堆叠和联合调整不同的组件(模型)。在下一节中,我们将转向 ML 应用中的另一种重要数据类型——文本数据。

B.2 文本分类:对新闻组帖子进行主题分类

在本节中,我们专注于一个文本数据的分类示例。与图像数据和表格数据相比,在文本数据中,我们需要考虑特征(单词)之间的语义意义和更强的依赖关系。我们将使用 20 个新闻组数据集进行探索,该数据集可以通过 scikit-learn 库获取。它包含关于 20 个主题的 18,846 个新闻组帖子(qwone.com/~jason/20Newsgroups/)。你将学习如何对文本数据进行预处理,以便与使用统计原理进行分类的概率分类模型一起使用。

B.2.1 问题定义和数据组装

如同往常,我们首先定义问题并组装数据集。如下所示,这是一个多类分类问题,有 20 个类别代表不同的新闻组主题。我们的目标是预测一个先前未见过的帖子所属的主题。数据是通过 scikit-learn 库内置的数据加载器 fetch_20newsgroups 下载的。它已经被分为训练集和测试集,以便于使用。

列表 B.7 通过 scikit-learn 库加载 20 个新闻组数据集

from sklearn.datasets import fetch_20newsgroups

news_train = fetch_20newsgroups(subset='train', 
➥ shuffle=True, random_state=42)                ❶
news_test = fetch_20newsgroups(subset='test', 
➥ shuffle=True, random_state=42)                ❶

doc_train, label_train = news_train.data, 
➥ news_train.target                             ❷
doc_test, label_test =  news_test.data, 
➥ news_test.target                              ❷

❶ 分别加载训练数据和测试数据,并对每个中的数据进行洗牌

❷ 分别存储文本文档和相应的目标

快速检查显示,doc_train 和 doc_test 分别是 11,314 和 7,532 个文档的列表,如下所示:

>>> len(doc_train), len(doc_test)
(11314, 7532)

让我们打印一个样本文档,以了解原始文本特征的样子。每个文档都是一个包含字母、数字、标点和一些特殊字符的字符串,如下面的代码示例所示:

>>> type(doc_train[0]), doc_train[0]
(str,
 'From: lerxst@wam.umd.edu (where's my thing)\nSubject: WHAT car is this!?\nNntp-Posting-Host: rac3.wam.umd.edu\nOrganization: University of Maryland, College Park\nLines: 15\n\n I was wondering if anyone out there could enlighten me on this car I saw\nthe other day. It was a 2-door sports car, looked to be from the late 60s/\nearly 70s. It was called a Bricklin. The doors were really small. In addition,\nthe front bumper was separate from the rest of the body. This is \nall I know. If anyone can tellme a model name, engine specs, years\nof production, where this car is made, history, or whatever info you\nhave on this funky looking car, please e-mail.\n\nThanks,\n- IL\n   ---- brought to you by your neighborhood Lerxst ----\n\n\n\n\n')

原始文本不是我们可以直接输入到机器学习模型中的东西,所以让我们做一些数据准备,使其更整洁。

B.2.2 数据预处理和特征工程

当前的文本文档是以字符串格式存在的,所以我们将首先将这些字符串转换为数值向量,这样我们就可以将其输入到我们的机器学习算法中。

通常,所有单词、特殊字符和标点都可以作为文本文档的特征。我们可以将多个单词和/或字符组合成一个特征(称为“词”或“术语”)并共同处理。对数据集的特征进行数值编码的一种直观方法是收集语料库中所有文档中出现的所有唯一单词,并将每个文档转换为包含其包含的唯一单词数量的数值向量。向量中的每个元素表示该文档中相应单词的出现次数。这种转换方法称为“词袋”(BoW)方法(如图 B.7 所示)。

B-07

图 B.7 将文档转换为 BoW 表示

要实现这种方法,我们首先需要将每个文档划分为一组单词(标记)。这个过程称为标记化。标记化和 BoW 转换可以通过 scikit-learn 内置类 CountVectorizer 的单次调用一起完成,如下所示。

列表 B.8 将训练文档转换为 BoW 表示

from sklearn.feature_extraction.text import CountVectorizer

count_vec = CountVectorizer()                         ❶

X_train_counts = count_vec.fit_transform(doc_train)   ❷

❶ 构建一个 CountVectorizer 对象

❷ 将训练文本文档转换为标记计数的矩阵

通过打印转换后的训练文档的形状,如下所示,我们可以看到转换后的矩阵有 130,107 列,代表从训练文档中提取的 BoW:

>>> X_train_counts.shape
(11314, 130107)

BoW 方法帮助我们将我们的字符串文档集合转换为标记计数的矩阵,可以输入到机器学习算法中。然而,由于以下两个问题,文档中单词的出现次数可能不是直接使用的良好特征:

  • 文档的长度不同。在两个文档中发生相同次数的词可能在这两个文档中的重要性并不相同。

  • 一些词在整个文本语料库中可能具有非常高的出现频率,例如“the”和“a”。与一些低频词相比,它们在分类中携带的意义信息很少。

为了解决这些问题,我们通常使用一种称为 词频-逆文档频率 (TF-IDF) 的特征转换技术。其核心思想是通过计算一个词在文档中的频率(通常只是一个原始计数)并将其除以包含该词的语料库中的文档数量来评估该词在文档中的重要性。在文档中频繁出现但在整个语料库中较少出现的术语被认为比在文档中频繁出现且在大量其他文档中也频繁出现的词对该文档更重要。

这种转换可以通过 scikit-learn 中的 TfidfTransformer 类实现,如下所示:

from sklearn.feature_extraction.text import TfidfTransformer
tfidf_transformer = TfidfTransformer()
X_train_tfidf = tfidf_transformer.fit_transform(X_train_counts)

尽管 TF-IDF 具有优势,但它也存在一些问题,例如计算复杂度高以及有限的能力来捕捉词语之间的语义相似性。进一步的探索留给读者作为练习。另一种方法,使用 词嵌入,在第三章中介绍。

我们接下来的步骤是构建一个文本分类器,并用准备好的数据集对其进行训练。接下来的两节介绍了两种可以用来解决这个问题概率分类器。这两个都是基本的机器学习模型,在文本分类以及其他领域得到了广泛的应用。

B.2.3 使用逻辑回归模型构建文本分类器

我们将要探索的第一个模型是 逻辑回归 模型。从名称上看,你可能认为它是一个类似于第二章中介绍的线性回归模型,但实际上,它是为分类问题设计的。线性回归模型和逻辑回归模型之间的区别在于它们的输出。线性回归模型直接根据样本的特征输出预测的目标值,而逻辑回归模型输出样本的 logits。logit 定义为 B-07-EQ03,其中 p 是样本属于特定类别的概率。我们可以通过计算训练集中每个类别的标签频率来估计这个概率。在测试阶段,给定一个未见过的例子,我们可以根据其特征计算它属于每个类别的概率,并将其分配给概率最高的类别。

对于多类分类,我们可以使用 OvO 方案(类似于多类 SVM 模型),或者我们可以使用另一种称为 一对余 (OvR) 的方案。与需要为每一对类别构建分类器的 OvO 方案不同,OvR 方案需要构建 n 个分类器,其中 n 是类别的数量。每个分类器对应一个类别。然后它试图将每个类别的例子与剩余的 n-1 个类别区分开来。在测试阶段,我们将所有 n 个分类器应用于一个未见过的例子,以获取样本每个类别的概率。该例子被分配给概率最高的类别。

我们可以使用以下代码构建一个多类逻辑回归分类器,并使用预处理后的文档进行训练:

from sklearn.linear_model import LogisticRegression
lr_clf = LogisticRegression(multi_class='ovr', random_state=42)
lr_clf.fit(X_train_tfidf, label_train)

然后,我们将相同的转换器应用于测试数据,并如下评估学习到的逻辑回归分类器:

>>> from sklearn.metrics import accuracy_score

>>> X_test_counts = count_vec.transform(doc_test)
>>> X_test_tfidf = tfidf_transformer.transform(X_test_counts)
>>> label_pred_test = lr_clf.predict(X_test_tfidf)

>>> lr_acc = accuracy_score(label_test, label_pred_test)
>>> print(f'Test accuracy: {lr_acc * 100:.2f} %')

Test accuracy: 82.78 %

测试准确率为 82.78%。如果你对跨不同标签的更详细的分类结果感兴趣,也可以绘制混淆矩阵。

B.2.4 使用朴素贝叶斯模型构建文本分类器

另一个常用于文本分类的著名概率模型是朴素贝叶斯模型。它将贝叶斯定理应用于计算给定数据点属于每个类的概率。“朴素”在这里意味着我们假设所有特征都是相互独立的。例如,在这个新闻主题分类问题中,我们假设每个术语出现在特定主题类中的概率与其他术语独立。这个假设可能对你来说听起来不太合理,尤其是对于文本数据,但在实践中它通常工作得相当好。

在应用朴素贝叶斯模型时,我们需要在训练阶段执行以下两个操作:

  • 计算训练集中每个类中出现的每个术语的出现次数,并将其除以该类中术语的总数。这作为概率B-07-EQ4的估计。

  • 计算训练集中每个类出现的频率,这是B-07-EQ5的估计。

朴素贝叶斯模型还可以通过假设B-07-EQ6的概率分布为多项分布来处理多类分类。我们不会深入数学细节,只关注 scikit-learn 的实现。以下是使用预处理文本数据训练朴素贝叶斯模型的代码:

from sklearn.naive_bayes import MultinomialNB
nb_clf = MultinomialNB().fit(X_train_tfidf, label_train)

在测试阶段,我们可以根据文档包含的特征应用贝叶斯定理来计算文档属于每个类的概率。文档的最终标签被预测为概率最大的类。我们将相同的转换器 fit 应用于训练数据,并如下评估学习到的朴素贝叶斯分类器:

>>> X_test_counts = count_vec.transform(doc_test)
>>> X_test_tfidf = tfidf_transformer.transform(X_test_counts)

>>> label_pred_test = nb_clf.predict(X_test_tfidf)

>>> lr_acc = accuracy_score(label_test, label_pred_test)

>>> print(f'Test accuracy: {lr_acc * 100:.2f} %')
>>> Test accuracy: 77.39 %

使用 scikit-learn 中默认超参数的多项式朴素贝叶斯模型的最终测试准确率为 77.39%——略低于之前逻辑回归模型实现的 82.78%。让我们尝试通过调整一些关键超参数来提高它。

B.2.5 使用网格搜索调整文本分类流程

到目前为止,我们已经将以下三个数据处理组件引入到文本分类流程中:

  • BoW 转换器

  • TF-IDF 转换器

  • 分类器(逻辑回归模型/朴素贝叶斯模型)

我们可以使用在 B.1.6 节中介绍的相同过程来构建一个结合所有三个组件的顺序管道,以进行联合超参数调整。我们在这里选择多项式朴素贝叶斯分类器作为示例来构建 scikit-learn 管道,并选择以下三个超参数进行调整:

  • ngram_range CountVectorizer 操作中—CountVectorizer 计算文档中每个术语的出现次数以执行 BoW 转换。它还可以计算连续出现的 n 个术语的出现次数,这被称为 ngram。例如,在句子“我爱机器学习”中,1-grams(单语元)是“我”、“爱”、“机器”和“学习”;2-grams(双语元)是“我爱”、“爱机器”和“机器学习”。

  • use_idf—确定我们是否想要使用 TF-IDF 转换或 TF 转换。

  • alpha—多项式朴素贝叶斯分类器中的平滑超参数。朴素贝叶斯模型的一个问题是,如果在训练集中给定主题类中的任何文档中一个术语从未出现(B-07-EQ7),则在测试期间我们永远不会将包含此术语的文档分配给该类。为了减轻这个问题,我们通常引入一个平滑超参数,将这些术语分配给这些类的小概率,这样每个术语都有机会出现在每个类中。

我们可以使用以下代码设置整个管道并自定义三个超参数的搜索空间。

列表 B.9 创建用于文本分类的管道

text_clf = Pipeline([
    ('vect', CountVectorizer()),
    ('tfidf', TfidfTransformer()),
    ('clf', MultinomialNB()),])       ❶

hps = {
    'vect__ngram_range': [(1, 1), (1, 2)],
    'tfidf__use_idf': (True, False),
    'clf__alpha': (1, 1e-1, 1e-2),
}                                     ❷

❶ 定义管道

❷ 声明要搜索的超参数并定义它们的搜索空间

我们应用网格搜索并使用三折交叉验证进行模型选择,如下所示:

>>> scoring_fnc = make_scorer(accuracy_score)

>>> grid_search = GridSearchCV(estimator=text_clf,
...                            param_grid=hps,
...                            scoring=scoring_fnc,
...                            cv=3,
...                            verbose=5,
...                            n_jobs=-1)

>>> grid_search = grid_search.fit(doc_train, label_train)

Fitting 3 folds for each of 12 candidates, totalling 36 fits
[Parallel(n_jobs=-1)]: Using backend LokyBackend with 48 concurrent workers.
[Parallel(n_jobs=-1)]: Done   5 out of  36 | elapsed:   
➥ 7.1s remaining:   43.8s
[Parallel(n_jobs=-1)]: Done  13 out of  36 | elapsed:  
➥ 10.0s remaining:   17.6s
[Parallel(n_jobs=-1)]: Done  21 out of  36 | elapsed:   
➥ 18.2s remaining:   13.0s
[Parallel(n_jobs=-1)]: Done  29 out of  36 | elapsed:  
➥ 20.2s remaining:    4.9s
[Parallel(n_jobs=-1)]: Done  36 out of  36 | elapsed:   21.6s finished

然后,我们从 grid_search.cv_results_ 中检索每个管道(由三个超参数表示)的搜索结果。最佳管道可以如下获得:

>>> grid_search.best_params_
>>> best_pipeline = grid_search.best_estimator_

>>> for hp_name in sorted(hps.keys()):
...     print('%s: %r' % (hp_name, grid_search.best_params_[hp_name]))

clf__alpha: 0.01
tfidf__use_idf: True
vect__ngram_range: (1, 2)

如果我们在 GridSearchCV 对象中未指定 refit 超参数,则在 CV 后将自动在整个训练数据集上训练最佳管道。因此,我们可以直接在测试集上评估最佳管道的最终结果。如下所示,最终准确率为 83.44%,这比初始的 77.39% 有很大提升:

>>> test_acc = accuracy_score(label_test, label_pred_test)
>>> print(f'Test accuracy: {test_acc * 100:.2f} %')

Test accuracy: 83.44 %

既然你已经看到了如何处理图像数据和文本数据的分类任务,让我们回到表格数据,来处理一个相对更复杂的例子。

B.3 表格分类:识别泰坦尼克号幸存者

我们最后的例子将使用著名的泰坦尼克号 Kaggle 竞赛数据集,由 Michael A. Findlay 编辑。它包含了 1,309 名乘客的个人资料,例如姓名和性别(训练集中有 891 名,测试集中有 418 名)。我们的目标是识别幸存者。对于表格数据,你将学习如何预处理一个包含混合数据类型和缺失值的更粗糙的数据集,而不是有一个准备好的数值特征矩阵。这里描述的技术在许多处理表格数据集的 Kaggle 竞赛中很常见,并且至今仍广泛应用于实际应用中,即使在深度学习普遍的今天。

B.3.1 问题界定和数据组装

这里的问题是一个二元分类问题,其中目标标签表示乘客是否幸存(1)或未幸存(0)。我们从 OpenML 平台收集数据。¹ scikit-learn 提供了一个内置 API 来获取数据集。我们可以通过将 as_frame 选项设置为 True 来将获取的数据格式化为 DataFrame,如下面的列表所示。

列表 B.10 获取泰坦尼克号数据集

from sklearn.datasets import fetch_openml

titanic = fetch_openml(
    name='titanic', version=1, as_frame=True)              ❶

data, label = titanic.data.copy(), titanic.target.copy()   ❷

data.head(5)                                               ❸

❶ 从 OpenML 获取泰坦尼克号数据集的第一版本作为 DataFrame

❷ 深度复制数据以避免后续的就地数据处理操作

❸ 查看前五个乘客的特征

原始特征矩阵的前五个示例如图 B.8 所示。

B-08

图 B.8 泰坦尼克号数据的第一个五个样本的原始特征

通过查看前五个样本,我们可以观察到乘客有 13 个特征。这些特征具有不同的格式和数据类型。例如,姓名特征是字符串类型,性别特征是分类特征,年龄特征是数值特征。让我们进一步探索数据集,并为机器学习算法做准备。

B.3.2 数据预处理和特征工程

我们可以考虑以下三个典型程序来准备表格数据:

  • 恢复缺失数据。

  • 根据先验知识提取特征,例如根据乘客姓名中的头衔(先生、女士等)提取性别特征。

  • 将分类特征编码为数值类型。

首先,让我们检查缺失值。通过计算每个特征的缺失值数量,我们可以看到有七个特征存在缺失值,如下所示:年龄、船票、船舱、登船港口(embarked)、船只、身体和乘客的家园/目的地(home.dest):

>>> data.isnull().sum()
pclass          0
name            0
sex             0
age           263
sibsp           0
parch           0
ticket          0
fare            1
cabin        1014
embarked        2
boat          823
body         1188
home.dest     564
dtype: int64

考虑到我们只有 1,309 个数据点,船舱、船只、身体和 home.dest 特征中的缺失值数量相当大。不恰当地填充这些数据点可能会损害模型的性能,因此我们将删除这四个特征,并仅考虑填充剩余三个特征的缺失值,如下所示:

data = data.drop(['cabin', 'boat', 'body', 'home.dest'], axis=1)

我们可以使用许多技术来填充缺失数据,其中以下三种相当常见:

  • 根据合理的特征相关性手动外推特征——这通常需要领域专业知识和人力,但可能比其他选项更准确。例如,我们可以提取两个缺失登船特征的乘客,并检查他们的票价来猜测他们的登船港。

  • 使用统计信息——例如,我们可以使用平均值或中位数来估算缺失的票价。这种方法通常方便且高效,但可能会丢失一些用于分类的判别信息。

  • 使用机器学习模型根据其他特征估计值——例如,我们可以将年龄特征视为目标,并使用回归根据其他特征估算缺失值。这种方法可能非常强大,但可能会通过过度使用特征相关性而过度拟合数据。它还高度依赖于用于估算缺失数据的模型,并且可能受到模型偏差的影响。

由于票价和登船特征仅缺失少数值,让我们首先使用一些合理的特征相关性来估算它们。我们首先绘制一组乘客票价的箱线图,按他们的登船港(embarked)和舱位(pclass)分组,如下所示。

列表 B.11 按登船和 pclass 分组的票价箱线图

import seaborn as sns                                                       ❶
boxplot = sns.boxplot(
    x='embarked', y='fare', data=data,hue='pclass')                         ❷
boxplot.axhline(80)                                                         ❸
boxplot.set_title(
    'Boxplot of fare grouped by embarked and pclass')                       ❹
boxplot.text(
    x=2.6, y=80, s='fare = $80', size='medium', color='blue', weight='bold')❺

❶ 导入绘制箱线图的包

❷ 绘制箱线图

❸ 设置$80 票价水平线

❹ 添加标题

❺ 添加图表图例

从图 B.9 中,我们可以观察到具有相同登船港的乘客在票价特征上的分布相当不同,这与 pclass 特征高度相关。

B-09

图 B.9 按登船港和 pclass 特征分组的乘客票价箱线图

通过检查缺失登船特征的乘客,我们可以看到这两位乘客都是头等舱,并且为他们的票支付了$80(见图 B.10),如下所示:

>>> data[data['embarked'].isnull()]

B-10

图 B.10 缺失登船特征的乘客

图表上的$80 参考线表明,支付了$80 票价的头等舱乘客很可能是在 C 港登船的。因此,我们将这两位乘客缺失的登船特征填充为 C 港,如下所示:

>>> data['embarked'][[168, 284]] = 'C'

类似地,我们可以检查缺失票价的乘客(见图 B.11),如下所示:

>>> data[data['fare'].isnull()]

B-11

图 B.11 缺失票价特征的乘客

这位乘客乘坐三等舱并在 S 港登船。我们可以结合统计信息,通过提供同一舱位且在 S 港登船的乘客的中位票价来估算缺失的票价,如下所示:

>>> data['fare'][1225] = data.groupby(
...     ['embarked', 'pclass'])
...     .get_group(('S', 3))['fare'] 
...     .median()

对于最后一个缺失的特征(年龄),我们直接使用统计中位数来估算缺失值,如下所示:

>>> data['age'].fillna(data['age'].median(skipna=True), inplace=True)

在处理完所有缺失数据后,下一步是基于常识进行一些特征提取。这里我们以名称特征为例。这个特征乍一看似乎非常混乱且无用,但在仔细检查后我们可以看到,名字中包含头衔(先生、夫人、少爷等),这些可能反映了乘客的婚姻状况和潜在的社会地位。我们首先提取头衔,并按以下方式探索每个头衔的频率:

>>> data['title'] = data['name'].str.extract(' ([A-Za-z]+)\.', expand=False)
>>> data['title'].value_counts()
Mr          757
Miss        260
Mrs         197
Master       61
Rev           8
Dr            8
Col           4
Major         2
Ms            2
Mlle          2
Capt          1
Don           1
Mme           1
Countess      1
Dona          1
Lady          1
Jonkheer      1
Sir           1
Name: title, dtype: int64

由于一些标题在机器学习角度来看是相似的,例如“Ms”和“Miss”,我们可以首先统一它们。并且因为数据集中有几个标题出现频率很低,我们可以使用的一种常见做法是将这些罕见标题合并为一个类别以聚合信息。在下一个列表中,我们考虑出现次数少于八次的标题作为罕见标题。

列表 B.12 统一同义词标题和合并罕见标题

data['title'] = data['title'].replace('Mlle', 'Miss')     ❶
data['title'] = data['title'].replace('Ms', 'Miss')       ❶
data['title'] = data['title'].replace('Mme', 'Mrs')       ❶

data['title'] = data['title'].replace(
    ['Lady', 'Countess','Capt', 'Col','Don', 'Dr',
    'Major', 'Rev', 'Sir', 'Jonkheer', 'Dona'], 'Rare')   ❷

data = data.drop(['name'], axis=1)                        ❸

❶ 通过重新分配将具有相同意义的标题进行聚合

❷ 将剩余的罕见标题合并为一个

❸ 删除了原始名称列

在进行标题提取后,我们再次通过查看前五个示例(见图 B.12)来显示当前数据,如下所示:

>>> data.head(5)

B-12

图 B.12 在缺失数据插补和名称标题提取后,泰坦尼克号数据集的前五个样本

最后一步是将分类特征,包括性别、登船地点和头衔,转换为数值型。尽管一些机器学习库可以直接接受字符串值的分类特征,但我们将为此进行预处理以供说明。转换这些特征的一种直观方法是将其编码为整数。这种方法对于层级数量较少的分类特征(换句话说,类别较少)可以很好地工作,例如性别,但当层级数量较多时可能会影响模型的性能。这是因为我们在转换后的数值中引入了原始数据中可能不存在的序数关系。另一个流行的选项是独热编码,它没有这个问题,但可能会因为引入过多的新特征而损害效率。独热编码是通过将一个分类特征转换为N个二进制分类特征来完成的,其中N个新特征代表原始分类特征的N个层级。对于每个实例,我们将只设置N个新特征中的一个(它所属的类别)为 1;其他将被设置为 0。例如,女性和男性乘客的性别特征将被转换为[1, 0]和[0, 1],其中向量中的第一个元素表示乘客是女性,第二个元素表示乘客是男性。我们可以像以下这样对所有的分类特征进行转换。

列表 B.13 分类数据的独热编码

import pandas as pd
encode_col_list = ['sex', 'embarked', 'title']
for i in encode_col_list:
    data = pd.concat(
        [data, pd.get_dummies(data[i], prefix=i)],axis=1)  ❶
    data.drop(i, axis = 1, inplace=True)                   ❷
data.drop('ticket', axis = 1, inplace=True)                ❸

❶ 执行独热编码

❷ 移除了原始特征

❸ 删除了票务功能,因为它包含太多层级

注意,尽管票务特征也是一个分类特征,但我们直接将其删除,而没有对其进行编码。这是因为与总样本数相比,它包含太多的唯一类别,如下一代码片段所示:

>>> data['ticket'].describe()
count         1309
unique         929
top       CA. 2343
freq            11
Name: ticket, dtype: object

最终数据包含 15 个特征,如图 B.13 所示。

B-13

图 B.13 泰坦尼克号数据集前五个样本的最终特征

最后,我们将数据分为训练集和测试集,如下所示。分割编号 891 是基于 Kaggle 竞赛中数据集原始分割设置的:

>>> X_train, X_test, y_train, y_test = data[:891], data[891:], label[:891], label[891:]

现在我们已经完成了预处理,我们就可以在泰坦尼克号数据集上应用机器学习算法了。

B.3.3 构建基于树的分类器

在深度学习兴起之前,基于树的模型通常被认为是表格数据分类中最强大的模型。本节介绍了三种基于树的分类模型。第一个是一个常规决策树模型,类似于我们在第二章中使用的模型。其他的是“集成模型”,旨在利用多个决策树的集体力量以获得更好的分类精度。

在第二章的加利福尼亚房价预测示例中,我们创建了一个决策树模型来执行回归任务。我们也可以通过一些小的修改为分类问题构建一个决策树模型——在训练过程中,我们需要将树分裂标准从回归性能度量改为分类性能度量,并且我们需要改变预测的方式如下:

  • 在回归示例中,我们使用均方误差(MSE)作为分裂标准来衡量每个节点分裂的质量。这里我们可以使用一个称为“熵”的标准,它衡量特定节点分裂通过分类获得的实用信息增益。熵的数学定义超出了本书的范围。

  • 在回归示例中,我们使用与测试样本相同的叶子节点中训练样本的均值进行预测。这里我们使用那些训练样本的目标标签的众数。

使用 scikit-learn,实现决策树分类器非常简单,如下一列表所示。

列表 B.14 在泰坦尼克号数据集上创建一个决策树分类器

from sklearn.tree import DecisionTreeClassifier
dt_clf = DecisionTreeClassifier(
    criterion='entropy', random_state=42)    ❶

dt_clf.fit(X_train, y_train)

❶ 使用熵作为分裂标准创建一个决策树分类器

测试准确度计算如下:

>>> from sklearn.metrics import accuracy_score
>>> y_pred_test = dt_clf.predict(X_test)
>>> acc = accuracy_score(y_test, y_pred_test)
>>> print('Test accuracy: {:.2f} %'.format(acc * 100))

Test accuracy: 71.05 %

现在,让我们利用集成学习技术创建两个更高级的基于决策树的模型。正如谚语所说,三个臭皮匠顶个诸葛亮,群众的智慧在许多情况下不仅适用于人类,也适用于机器学习模型。集成学习是一个将多个机器学习模型聚合起来以实现更好预测的过程。我们将查看以下两个代表性的基于集成学习的树算法,这些算法可能是 Kaggle 竞赛中最受欢迎的模型:

  • 随机森林—这种方法同时构建多个决策树,并通过投票共同考虑它们的预测。我们可以选择要组装的树的数量,并控制单个树的超参数,例如分割标准和树的深度最大值。

  • 梯度提升决策树 (GBDT)—这种方法按顺序构建多个树,并将新构建的树定位在解决先前树集成中的错误分类或弱预测。我们可以控制要组装的树的数量,每个树对最终集成模型相对贡献(学习率超参数),以及单个树的超参数。

您可以从 sklearn.ensemble 模块导入这两个分类器。以下列表显示了如何将它们应用于泰坦尼克号数据集。

列表 B.15 将随机森林和 GBDT 算法应用于泰坦尼克号数据集

from sklearn.ensemble import RandomForestClassifier, GradientBoostingClassifier

rf_clf = RandomForestClassifier(
    n_estimators=100, random_state=42)                                    ❶
rf_clf.fit(X_train, y_train)                                              ❶
y_pred_test = rf_clf.predict(X_test)                                      ❶
acc_rf = accuracy_score(y_test, y_pred_test)                              ❶

gbdt_clf = GradientBoostingClassifier(n_estimators=100, random_state=42)  ❷
gbdt_clf.fit(X_train, y_train)                                            ❷
y_pred_test = gbdt_clf.predict(X_test)                                    ❷
acc_gbdt = accuracy_score(y_test, y_pred_test)                            ❷

❶ 训练和测试随机森林算法

❷ 训练和测试 GBDT 算法

最终的性能显示,这两个模型都比我们之前的决策树模型表现更好,后者实现了 71.05% 的准确率。获得的 GBDT 模型比随机森林模型略好,如下所示:

>>> print(f'Random forest test accuracy: {acc_rf * 100:.2f} %')
>>> print(f'GBDT test accuracy: {acc_gbdt * 100:.2f} %')

Random forest test accuracy: 72.01 %
GBDT test accuracy: 72.97 %

我们还可以使用网格搜索调整每个算法的超参数,类似于之前的示例。这项练习留给读者作为自我测试。


(1.) 由 Joaquin Vanschoren 创立的 OpenML 平台 (openml.org) 是一个在线平台,用于共享数据、代码、模型和实验,以使机器学习和数据分析变得简单、开放、易于访问和可重复。

posted @ 2025-11-24 09:17  绝不原创的飞龙  阅读(11)  评论(0)    收藏  举报