Python-深度学习第三版-一-

Python 深度学习第三版(一)

原文:deeplearningwithpython.io/chapters/

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:什么是深度学习?

deeplearningwithpython.io/chapters/chapter01_what-is-deep-learning

在过去的十年里,人工智能(AI)一直是媒体炒作的热点。机器学习、深度学习和人工智能在无数文章中都有提及,通常这些文章并非来自技术导向的出版物。我们被承诺一个智能聊天机器人、自动驾驶汽车和虚拟助手的未来——有时被描绘成一种阴暗的光景,有时被描绘成乌托邦,在那里人类的工作将变得稀缺,大部分经济活动将由机器人或人工智能代理处理。对于机器学习的从业者来说,能够识别信号中的噪音非常重要,这样你就可以区分出世界级的变化和过度炒作的公关稿。我们的未来处于危险之中,而你将扮演一个积极的角色:阅读完这本书后,你将成为那些能够开发这些人工智能系统的人之一。所以,让我们来探讨这些问题:深度学习已经取得了哪些成果?它有多重要?我们接下来将走向何方?你应该相信炒作吗?

人工智能、机器学习和深度学习

首先,我们需要明确当我们提到 AI 时我们在谈论什么。什么是人工智能、机器学习和深度学习(图 1.1)?它们之间是如何相互关联的?

图片

图 1.1:人工智能、机器学习和深度学习

人工智能

人工智能诞生于 20 世纪 50 年代,当时计算机科学领域的几位先驱开始质疑是否能够制造出能够“思考”的计算机——这是一个我们今天仍在探索的问题的深远影响。

尽管许多基本思想在过去的几年甚至几十年里一直在酝酿,但“人工智能”最终在 1956 年作为一个研究领域得以明确,当时约翰·麦卡锡(John McCarthy),当时是达特茅斯学院的一位年轻的数学助理教授,根据以下提案组织了一个夏季研讨会:

这项研究将基于以下猜想进行:学习的各个方面或任何其他智能特征原则上都可以被精确描述,以至于可以制造出能够模拟它的机器。我们将尝试找出如何让机器使用语言,形成抽象和概念,解决目前仅限于人类解决的问题,并自我改进。我们认为,如果一组精心挑选的科学家共同在一个夏天致力于这些问题,那么在这些问题中的一个或多个上可以取得重大进展。

夏季结束时,研讨会没有完全解决它最初要解决的问题,但仍然吸引了很多人参加,这些人后来成为了该领域的先驱,并引发了一场至今仍在进行的智力革命。

简而言之,人工智能可以被描述为自动化人类通常执行的知识任务的努力。因此,人工智能是一个涵盖机器学习和深度学习,但也包括许多可能不涉及学习的更广泛领域的通用领域。考虑一下,直到 20 世纪 80 年代,大多数人工智能教科书根本不提“学习”!早期的棋类程序,例如,只涉及程序员编写的硬编码规则,并不符合机器学习的标准。实际上,在相当长的一段时间里,大多数专家认为,通过程序员手工制作足够大的显式规则集来操作存储在显式数据库中的知识,就可以实现人类水平的人工智能。这种方法被称为符号人工智能。它是从 20 世纪 50 年代到 20 世纪 80 年代末人工智能的主导范式,并在 20 世纪 80 年代的专家系统热潮中达到了顶峰。

尽管符号人工智能被证明适合解决定义明确的逻辑问题,例如下棋,但它发现解决更复杂、模糊的问题,如图像分类、语音识别或自然语言翻译,是难以处理的。一种新的方法应运而生,以取代符号人工智能:机器学习

机器学习

在维多利亚时代的英国,洛夫莱斯女士是查尔斯·巴贝奇的朋友和合作者,他是分析机的发明者:已知的第一台通用机械计算机。尽管具有远见卓识,并且领先于时代,但分析机在 19 世纪 30 年代和 40 年代设计时并不是作为通用计算机而设计的,因为通用计算的概念尚未被发明。它仅仅是为了使用机械操作来自动化数学分析领域的某些计算——因此得名分析机。因此,它是早期尝试将数学运算编码为齿轮形式的智力后裔,例如帕斯卡计算器,或莱布尼茨的步进计算器,帕斯卡计算器的改进版。帕斯卡在 1642 年(当时 19 岁!)设计的帕斯卡计算器是世界上第一台机械计算器——它可以进行加法、减法、乘法,甚至除法。

1843 年,阿达·洛夫莱斯对分析机的发明发表了评论:

分析机根本不试图创造任何东西。它可以执行我们知道如何命令它执行的一切……它的职责是帮助我们提供我们已熟悉的东西。

即使有 182 年的历史视角,洛夫莱斯女士的观察仍然引人注目。一台通用计算机“创造”出任何东西,还是始终被束缚在执行我们人类完全理解的过程上?它能否具有任何原创思想?它能否从经验中学习?它能否表现出创造力?

她的评论后来被人工智能先驱艾伦·图灵在 1950 年的里程碑式论文《计算机机械与智能》中引用为“洛夫莱斯异议”,该论文介绍了图灵测试以及将塑造人工智能的关键概念。图灵当时持有——极具挑衅性——的观点,即计算机原则上可以模仿人类智能的所有方面。

通常让计算机做有用的工作的方式是让人类程序员写下规则——一个计算机程序——以将输入数据转换为适当的答案,就像洛夫莱斯女士为分析机编写逐步指令一样。机器学习则相反:机器查看输入数据和相应的答案,并找出应该遵循的规则(见图 1.2)。

图片

图 1.2:机器学习:一种新的编程范式

机器学习系统是通过训练而不是明确编程的。它被提供了许多与任务相关的示例,并从这些示例中找到统计结构,最终使系统能够制定自动化任务的规则。例如,如果你希望自动化标记假期照片的任务,你可以向机器学习系统提供许多已经被人类标记的图片示例,系统就会学习将特定图片与特定标签(如“风景”或“食物”)关联的统计规则。

尽管机器学习直到 20 世纪 90 年代才开始蓬勃发展,但它迅速成为人工智能最受欢迎且最成功的子领域,这一趋势是由更快的硬件和更大的数据集的可用性所驱动的。机器学习与数学统计学相关,但在几个重要方面与统计学有所不同——就像医学与化学相关,但不能简化为化学一样,因为医学处理的是其自身独特的系统及其独特的属性。与统计学不同,机器学习倾向于处理大型、复杂的数据集(例如包含数百万个图像的数据集,每个图像由数万个像素组成),对于这些数据集,传统的统计分析(如贝叶斯分析)将是不切实际的。因此,机器学习,尤其是深度学习,在数学理论上相对较少——可能太少——它本质上是一门工程学科。与理论物理学或数学不同,机器学习是一个非常注重实践、由经验发现驱动且深度依赖软件和硬件进步的领域。

从数据中学习规则和表示

要定义深度学习并理解深度学习与其他机器学习方法的区别,首先我们需要了解机器学习算法是如何工作的。我们之前提到,机器学习是通过发现规则来执行数据处理任务,给定预期的示例。因此,要进行机器学习,我们需要三样东西:

  • 输入数据点 — 例如,如果任务是语音识别,这些数据点可能是人们说话的音频文件。如果任务是图像标记,它们可能是图片。

  • 预期输出的例子 — 在语音识别任务中,这些可能是人类生成的音频文件的转录。在图像任务中,预期输出可能是“狗”、“猫”等标签。

  • 衡量算法是否做得好的方法 — 这是确定算法当前输出与预期输出之间距离所必需的。这种测量被用作反馈信号来调整算法的工作方式。这个调整步骤就是我们所说的学习

机器学习模型将输入数据转换成有意义的输出,这个过程是通过接触已知的输入和输出示例来“学习”的。因此,机器学习与深度学习的核心问题是有意义地转换数据:换句话说,学习手头输入数据的有用表示——这些表示使我们更接近预期的输出。

在我们继续之前,什么是表示?在本质上,它是一种不同的方式来看待数据,以表示或编码数据。例如,彩色图像可以编码在 RGB 格式(红-绿-蓝)或 HSV 格式(色调-饱和度-值)中:这些都是相同数据的两种不同表示。一些任务可能在一个表示下很难,但在另一个表示下可能变得容易。例如,“在图像中选择所有红色像素”在 RGB 格式下更简单,而“使图像不那么饱和”在 HSV 格式下更简单。机器学习模型都是关于为其输入数据找到适当的表示——对数据进行转换,使其更适合当前任务。

让我们具体说明。考虑一个 x 轴、一个 y 轴和一些在(x,y)系统中用坐标表示的点,如图 1.3 所示。

图片

图 1.3:一些样本数据

如您所见,我们有一些白色点和一些黑色点。假设我们想要开发一个算法,该算法可以接受一个点的(x,y)坐标,并输出该点可能是黑色还是白色。在这种情况下,

  • 输入是我们点的坐标。

  • 预期输出是我们点的颜色。

  • 一种衡量我们的算法是否做得好的方法可以是,例如,正确分类的点的百分比。

在这里,我们需要一种新的数据表示方法,能够清晰地分离出白点和黑点。在众多可能性中,我们可以使用的一种变换就是坐标变换,如图 1.4 所示。

图片

图 1.4:坐标变换

在这个新的坐标系中,我们点的坐标可以说是一种新的数据表示。而且这是一个很好的表示!使用这种表示,黑/白分类问题可以表达为一个简单的规则:“黑点满足 x > 0,”或者“白点满足 x < 0。”这种新的表示,结合这个简单的规则,巧妙地解决了分类问题。

在这个例子中,我们手动定义了坐标变换:我们利用人类智慧来提出我们自己的数据适当表示。对于如此简单的问题来说,这是可以的,但如果你要分类手写数字的图像,你能做到同样的事情吗?你能写下明确的、计算机可执行的图像变换,以阐明 6 和 8、1 和 7 之间的区别,以及各种不同手写的区别吗?

这在某种程度上是可能的。基于数字表示的规则,例如“计算封闭环的数量”或垂直和水平像素直方图,可以在区分手写数字方面做得相当不错。但手动找到这样的有用表示是件辛苦的工作,而且正如你可以想象的那样,由此产生的基于规则的系统将是脆弱的,维护起来将是一场噩梦。每次你遇到一个新的手写例子,它打破了你的精心设计的规则,你都必须添加新的数据变换和新的规则,同时考虑到它们与每个先前规则之间的相互作用。

你可能正在想,如果这个过程如此痛苦,我们能否自动化它?如果我们尝试系统地搜索不同集合的自动生成数据表示及其规则,并使用某些开发数据集中正确分类数字的百分比作为反馈来识别好的表示,我们会做什么?那么我们就会进行机器学习。在机器学习的背景下,“学习”描述了一个自动搜索过程,用于寻找产生某些数据有用表示的数据变换,这个过程由某些反馈信号引导——这些表示可以适用于更简单的规则来解决手头的任务。学习,在机器学习的上下文中,描述了一个自动搜索过程,用于寻找产生有用表示的数据变换,这个过程由某些反馈信号引导——这些表示可以适用于更简单的规则来解决手头的任务。

这些变换可以是坐标变化(就像我们在二维坐标分类示例中那样)或像素直方图和计数环(就像我们在数字分类示例中那样),但它们也可以是线性投影、平移和非线性操作(例如“选择所有 x > 0 的点”),等等。机器学习算法在寻找这些变换方面通常并不具有创造性;它们只是在预定义的操作集合中搜索,称为假设空间。例如,所有可能的坐标变化空间将是我们在二维坐标分类示例中的假设空间。

所以,这就是机器学习的简要概述:在预定义的可能空间内,通过反馈信号的引导,搜索一些输入数据的有用表示和规则。这个简单的想法使我们能够解决从自动驾驶到自然语言问答的广泛智力任务。

现在你已经理解了我们所说的学习是什么意思,让我们来看看是什么让深度学习变得特殊。

“深度学习”中的“深度”

深度学习是机器学习的一个特定子领域;它是对从数据中学习表示的新方法,强调学习越来越有意义的表示的连续层。在“深度学习”中的“深度”并不是指通过这种方法实现的任何更深入的理解;相反,它代表了这个连续层表示的想法。对数据模型贡献多少层被称为模型的深度。该领域的其他适当名称可以是分层表示学习层次表示学习。现代深度学习通常涉及数十甚至数百个连续的表示层,它们都是通过接触训练数据自动学习的。同时,其他机器学习方法倾向于只学习数据的一层或两层表示(例如,获取像素直方图然后应用分类规则);因此,它们有时被称为浅层学习

在深度学习中,这些分层表示是通过称为神经网络的模型来学习的,这些网络以实际的层堆叠在一起。术语神经网络是对神经生物学的引用,尽管深度学习的一些核心概念部分是通过从我们对大脑(特别是视觉皮层)的理解中汲取灵感而开发的,但深度学习模型并不是大脑的模型。没有证据表明大脑实现了与现代深度学习模型中使用的任何类似的学习机制。你可能会遇到一些流行科学文章宣称深度学习就像大脑一样工作,或者是以大脑为模型,但这并不是事实。对于新进入该领域的人来说,将深度学习视为与神经生物学有任何关联将会令人困惑且适得其反;你不需要那种“就像我们的心智”的神秘和神秘感,而且你最好忘记任何关于深度学习与生物学之间假设性联系的内容。就我们的目的而言,深度学习是从数据中学习表示的数学框架。

深度学习算法学习的表示是什么样的呢?让我们考察一个多层网络(见图 1.5)是如何将数字图像转换为识别其是哪个数字的。

图片

图 1.5:用于数字分类的深度神经网络

正如你在图 1.6 中看到的,网络将数字图像转换为与原始图像越来越不同且越来越有助于最终结果的表示。你可以将深度网络视为一个多阶段信息蒸馏过程,其中信息通过连续的过滤器,并越来越纯净(即,对某些任务有用)。

图片

图 1.6:数字分类模型学习的深度表示

因此,从技术上讲,深度学习就是这样一种多阶段学习数据表示的方法。这是一个简单的想法,但正如实际情况所证明的,非常简单的机制,如果足够规模,最终看起来就像魔术。

通过三个图解理解深度学习的工作原理

到目前为止,你知道机器学习是关于将输入(如图像)映射到目标(如标签“猫”),这是通过观察许多输入和目标的示例来完成的。你也知道,深度神经网络通过一系列简单数据变换(层)来实现输入到目标的映射,而这些数据变换是通过接触示例来学习的。现在让我们具体看看这种学习是如何发生的。

层对输入数据的处理规范存储在层的权重中,本质上是一系列数字。从技术角度讲,我们可以说层的实现是通过其权重参数化的(见图 1.7)。(权重有时也被称为层的参数。)在这个背景下,学习意味着找到网络中所有层权重的值,使得网络能够正确地将示例输入映射到其相关目标。但问题是:深度神经网络可能包含数百万个参数。找到所有这些参数的正确值可能是一项艰巨的任务,尤其是考虑到修改一个参数的值将影响所有其他参数的行为!

图片

图 1.7:神经网络通过其权重进行参数化。

要控制某物,首先你需要能够观察它。要控制神经网络的输出,你需要能够测量这个输出与预期之间的差距。这是网络损失函数的工作,有时也称为目标函数成本函数。损失函数接受网络的预测和真实目标(你希望网络输出的内容)并计算一个距离分数,捕捉网络在这个特定示例上的表现(见图 1.8)。

图片

图 1.8:损失函数衡量网络输出的质量。

深度学习的基本技巧是使用这个分数作为反馈信号来稍微调整权重的值,使其在当前示例中降低损失分数(见图 1.9)。这个调整是优化器的工作,它实现了所谓的反向传播算法:深度学习的核心算法。下一章将更详细地解释反向传播是如何工作的。

图片

图 1.9:损失分数被用作反馈信号来调整权重。

初始时,网络的权重被分配随机值,因此网络仅仅实现了一系列随机变换。自然地,其输出远远不是理想的,相应的损失分数也极高。但随着网络处理每个示例,权重都会在正确的方向上稍微调整,损失分数随之降低。这就是训练循环,重复足够多次(通常是数千个示例的数十次遍历),可以得到最小化损失函数的权重值。损失最小的网络是输出尽可能接近目标的网络:一个训练好的网络。再次强调,这是一个简单的机制,一旦扩展,最终看起来就像魔法。

深度学习与众不同的地方

深度神经网络有什么特别之处,使得它们成为公司投资和研究人员蜂拥而至的“正确”方法?我们还会在 20 年后继续使用深度神经网络吗?

深度学习有几个特性使其成为 AI 革命的正当理由,并且它将长期存在。我们可能在未来几十年内不会使用神经网络,但我们将使用的东西将直接继承自现代深度学习和其核心概念。这些重要特性可以大致分为三类:

  • 简单性 — 深度学习使问题解决变得更加容易,因为它自动化了机器学习工作流程中最关键的一步:特征工程。先前的机器学习技术——浅层学习——仅涉及将输入数据转换为一个或两个连续的表示空间,这对于大多数问题来说表达性不足。因此,人类不得不付出巨大的努力来使初始输入数据更适合这些方法:他们必须手动为他们的数据设计良好的表示。这被称为特征工程。另一方面,深度学习完全自动化了这一步骤:使用深度学习,你可以在一次遍历中学习所有特征,而无需自己设计。这极大地简化了机器学习工作流程,通常用单个简单、端到端的深度学习模型取代了复杂的多个阶段管道。

  • 可扩展性 — 深度学习非常适合在 GPU 或更专业的机器学习硬件上进行并行化,因此它可以充分利用摩尔定律。此外,深度学习模型通过迭代处理小批量数据来训练,这使得它们可以在任意大小的数据集上训练。(唯一的瓶颈是可用的并行计算能力,但由于摩尔定律,这是一个快速移动的障碍。)

  • 通用性和可重用性 — 与许多先前的机器学习方法不同,深度学习模型可以在不从头开始的情况下使用额外数据进行训练,这使得它们适用于持续在线学习——这对于非常大的生产模型来说是一个重要的特性。此外,训练好的深度学习模型可以重新部署,因此可以重复使用:这就是“基础模型”背后的重大理念——在大量数据上训练的大型模型,可以用于许多新的任务,只需少量重新训练,甚至无需重新训练。

生成式 AI 的时代

深度学习今天最著名的例子可能是最近一波生成式 AI 应用——如 ChatGPT、Gemini 和 Claude 等聊天机器人助手,以及 Midjourney 等图像生成服务。这些应用通过其能够对简单提示产生信息或甚至创造性的内容的能力,吸引了公众的想象力,模糊了人类与机器创造力的界限。

生成式 AI 由非常庞大的“基础模型”驱动,这些模型学会重建输入给它们的文本和图像内容——从噪声版本重建清晰图像,预测句子中的下一个单词,等等。这意味着图 1.8 中的目标是从输入本身提取的。这被称为自监督学习,它使得这些模型能够使用大量未标记的数据。摆脱了以前机器学习瓶颈的手动数据标注,解锁了前所未有的规模——这些基础模型中的一些拥有数百亿个参数,并在超过 1PB 的数据上进行训练,成本高达数百万美元。

这些基础模型作为人类知识的一种模糊数据库运行,使得它们适用于非常广泛的应用,而无需专门的编程或重新训练。因为它们已经记住了很多,它们可以通过提示来解决新问题——查询它们学习到的知识表示,并返回与提示最可能相关联的输出。

生成式 AI 直到 2022 年才进入主流意识,但它有着悠久的历史——最早的文本生成实验可以追溯到 20 世纪 90 年代。本书的第一版于 2017 年发布,其中已经有一个名为“生成式 AI”的章节,探讨了当时的文本生成和图像生成技术,并承诺“很快”,我们消费的大部分文化内容都将借助 AI 创造。

深度学习至今所取得的成就

在过去十年中,深度学习取得了不亚于技术革命的成就,从 2013 年到 2017 年,在感知任务上取得了显著成果,然后从 2017 年到 2022 年在自然语言处理任务上快速进步,最终从 2022 年到如今,涌现出一波变革性的生成式 AI 应用。

深度学习在极其具有挑战性的问题上实现了重大突破,这些问题长期以来一直困扰着机器:

  • 流畅且高度通用的聊天机器人,如 ChatGPT 和 Gemini

  • 如 GitHub Copilot 之类的编程助手

  • 真实感图像生成

  • 人类水平的图像分类

  • 人类水平的语音转录

  • 人类水平的手写转录和印刷文本转录

  • 显著改进的机器翻译

  • 显著改进的文本到语音转换

  • 人类水平的自动驾驶,截至 2025 年已在凤凰城、旧金山、洛杉矶和奥斯汀向公众部署

  • 改进的推荐系统,如 YouTube、Netflix 或 Spotify 所使用

  • 超人类水平的围棋、象棋和国际象棋游戏

我们仍在探索深度学习的全部潜力。我们已经成功地将它应用于各种问题,这些问题几年前还被认为是不可能解决的——自动转录梵蒂冈秘密档案中持有的数万份古代手稿,使用简单的智能手机在田间检测和分类植物疾病,协助肿瘤学家或放射科医生解读医学影像数据,预测自然灾害,如洪水、飓风甚至地震。随着每一个里程碑的达成,我们正越来越接近一个深度学习帮助我们从事每一个活动和每一个人类努力领域的时代——科学、医学、制造业、能源、交通、软件开发、农业甚至艺术创作。

警惕短期炒作

这看似不可阻挡的成功连锁反应引发了一波强烈的炒作热潮,其中一些多少有些根据,但大部分只是华丽的童话。在 2023 年初,OpenAI 发布 GPT-4 之后不久,许多专家声称“没有人需要再工作了”,或者大规模失业将在一年内到来,或者经济生产力将很快增长 10 倍到 100 倍。当然,两年后,这一切都没有发生——美国的失业率仍然很低,而生产力指标远未达到预期的爆炸性增长。不要误解:AI——特别是生成式 AI——的影响已经相当可观,并且增长速度非常快。截至 2025 年中,生成式 AI 每年产生数十亿美元的收益,这对于两年前还不存在的行业来说是非常令人印象深刻的!但这还没有在整体经济中造成太大的影响,与我们在其初期所受到的绝对不受约束的承诺相比相形见绌。

虽然关于由 AI 引发的失业和 100 倍生产力增长的讨论已经引起了焦虑,但 AI 炒作的另一个更引人注目的方面是,它宣称人类水平的一般智能(AGI)或甚至超越人类能力的“超级智能”即将到来。这些说法正在引发超越经济破坏的恐惧——人类物种本身可能面临被我们的数字创造物所取代的危险。

对于那些刚进入这个领域的人来说,可能会觉得是生成式 AI 的实用成功导致了近端 AGI(通用人工智能)的信念,但实际上是相反的。近端 AGI 的声明先于一切,并且它们极大地促进了生成式 AI 的兴起。早在 2013 年,科技精英们就担心 AGI 可能在几年内到来。当时,DeepMind(一家被谷歌收购的伦敦 AI 研究初创公司)被认为正在朝着这个目标前进。这种信念是 2015 年 OpenAI 成立的动力,它最初的目标是成为 DeepMind 的开源平衡力量。OpenAI 在启动生成式 AI 方面发挥了关键作用,因此,在一种奇特的反转中,是近端 AGI 的信念推动了生成式 AI 的崛起,而不是相反。2016 年,OpenAI 的招聘口号是它将在 2020 年实现 AGI!诚然,当时,科技行业只有少数人相信这样的乐观时间表。然而,到了 2023 年初,旧金山湾区的大多数工程师似乎都相信 AGI 将在接下来的几年内到来。

以健康的好奇心去审视这样的说法至关重要。尽管名字叫“人工智能”,但今天的“人工智能”更准确地描述为“认知自动化”——人类技能和知识的编码和操作。AI 擅长解决那些定义狭窄的问题或那些有大量精确示例的问题。它关于增强计算机的能力,而不是复制人类心智。

要明确的是,认知自动化极其有用。但智能——认知自主性——却是完全不同的生物。可以这样想:AI 就像一个卡通人物,而智能则像是一个活生生的人。无论卡通多么逼真,它也只能表演出它被绘制出来的场景。另一方面,一个活生生的人可以适应意外情况。

“如果卡通画得足够逼真,覆盖足够多的场景,那又有什么区别?”你可能会问。如果一个大型语言模型在被问及问题时能输出足够像人类的声音的回答,那么它是否拥有真正的认知自主性就无关紧要了吗?关键的区别在于适应性。智能是面对未知、适应它并从中学习的能力。自动化,即使在其最佳状态下,也只能处理它被训练或编程的情况。这就是为什么创建健壮的自动化如此具有挑战性——它需要考虑到每一个可能的场景。

因此,无需担心 AI 突然变得有自我意识并接管人类。今天的技术根本不是朝着这个方向发展。即使有显著的进步,AI 也将保持为一个复杂的工具,而不是一个有感知的生物。这就像期待一个更好的时钟能导致时间旅行——它们完全是不同的事物。

夏天可以变成冬天

过度膨胀的短期期望的危险在于,当技术不可避免地未能达到预期时,研究投资可能会枯竭,长时间地减缓进步。这种情况以前发生过。在过去,人工智能经历了两次高度乐观随后是失望和怀疑的周期,结果是资金短缺。这一切始于 20 世纪 60 年代的符号人工智能。在那些早期日子里,对人工智能的预测很高。符号人工智能方法最著名的先驱和倡导者之一是马文·明斯基,他在 1967 年声称:“在一代人之内……创造‘人工智能’的问题将基本得到解决。”三年后,在 1970 年,他做出了一个更精确的预测:“在三到八年之内,我们将拥有一个具有普通人类一般智能的机器。”到 2025 年,这样的成就似乎仍然遥不可及——如此遥远以至于我们无法预测它需要多长时间——但在 20 世纪 60 年代和早期 70 年代,一些专家认为它就在眼前(正如今天许多人所认为的那样)。几年后,随着这些高期望未能实现,研究人员和政府资金开始远离这个领域,标志着第一次人工智能寒冬(这个术语是参照核冬天而来的,因为这是冷战高潮之后不久)的开始。

这不会是最后一次。在 20 世纪 80 年代,一种新的符号人工智能观点,即专家系统,开始在大型公司中积聚势头。一些初步的成功故事引发了一波投资热潮,全球各地的公司开始建立自己的内部人工智能部门来开发专家系统。到 1985 年左右,公司每年在技术上的支出超过 10 亿美元;但到 20 世纪 90 年代初,这些系统已被证明维护成本高昂、难以扩展且应用范围有限,兴趣逐渐消退。于是开始了第二次人工智能寒冬。我们现在可能正在见证第三次人工智能炒作和失望的周期——我们仍然处于极度乐观的阶段。

我目前的观点是,我们不太可能看到像 20 世纪 90 年代那样全面退出人工智能研究的情况。如果真的有寒冬,它应该非常温和。人工智能已经证明了其改变世界的价值。然而,似乎不可避免的是,2023-2024 年的人工智能泡沫需要释放一些空气。目前,人工智能投资,主要在数据中心和 GPU 上,每年超过 1000 亿美元,而收入生成却远远落后,接近 100 亿美元。目前,人工智能正在被高管和投资者根据我们被告知它可能很快能够做到的事情来评判——其中许多将长期超出现有技术的范围。总会有所让步。但人工智能泡沫缩水时会发生什么仍然是个未知数。

人工智能的承诺

尽管我们可能对人工智能的短期期望不切实际,但长期前景看起来光明。我们只是在将深度学习应用于许多可能证明具有变革性的重要问题,例如医疗诊断和数字助手,才刚刚开始。

在 2017 年,在这本同一本书中,我写道:

目前,可能很难相信人工智能会对我们的世界产生重大影响,因为它尚未得到广泛部署——就像 1995 年,人们很难相信互联网未来的影响一样。当时,大多数人看不到互联网与他们有何关联,以及它将如何改变他们的生活。对于深度学习和人工智能来说,今天也是如此。但不要误解:人工智能正在到来。在不那么遥远的未来,人工智能将成为你的助手,甚至你的朋友;它将回答你的问题,帮助你教育孩子,并关注你的健康。它将把你的杂货送到门口,并把你从 A 点带到 B 点。它将成为你通往日益复杂和信息密集世界的接口。更重要的是,人工智能将通过协助人类科学家在所有科学领域的新突破性发现,帮助整个人类社会前进,从基因组学到数学。

快进到 2025 年,这些事情中的大多数要么已经实现,要么即将实现——而这只是开始:

  • 数以千万计的人正在每天使用像 ChatGPT、Gemini 和 Claude 这样的 AI 聊天机器人作为助手。事实上,问答和“教育你的孩子”(家庭作业辅助)已经成为这些聊天机器人的主要应用!对许多人来说,人工智能已经成为通往世界信息的首选接口。

  • 数十万人在与 AI“朋友”互动,例如在 Character.ai 等应用程序中。

  • 在凤凰城、旧金山、洛杉矶和奥斯汀等城市,完全自动驾驶已经大规模部署。

  • 人工智能正在迈出重大步伐,帮助加速科学发展。DeepMind 的 AlphaFold 模型正在帮助生物学家以前所未有的准确性预测蛋白质结构。著名数学家田纳西·陶(Terence Tao)认为,到 2026 年左右,如果使用得当,人工智能可能成为数学研究和其他领域的可靠合著者。

人工智能革命,曾经是一个遥远的愿景,现在正迅速在我们眼前展开。在这个过程中,我们可能会遇到一些挫折——就像 1998-1999 年互联网行业过度炒作,并在 2000 年代初遭受了投资枯竭的崩溃一样。但最终我们会到达那里。人工智能最终将应用于构成我们社会和日常生活的几乎每一个过程,就像今天的互联网一样。

不要相信短期炒作,但要对长期愿景保持信心。AI 发挥其真正潜力可能需要一段时间——这种潜力的全貌至今无人敢梦想——但 AI 正在到来,并以惊人的方式改变我们的世界。

脚注

  1. A. M. 图灵,“计算机与智能”,《心灵》59 卷,第 236 期(1950 年):433-460。 [↩]]

  2. 尽管图灵测试有时被解释为一种字面意义上的测试——人工智能领域应努力实现的目标——图灵只是将其视为关于认知本质的哲学讨论中的一个概念性工具。 [↩]

第二章:神经网络的数学基础

原文:deeplearningwithpython.io/chapters/chapter02_mathematical-building-blocks

理解深度学习需要熟悉许多简单的数学概念:张量张量运算微分梯度下降等等。我们本章的目标将是建立你对这些概念的理解,而不需要过于技术化。特别是,我们将避免使用数学符号,这可能会为没有数学背景的人设置不必要的障碍,而且对于解释事物来说也不是必要的。数学运算最精确、最明确描述是其可执行代码。

为了为介绍张量和梯度下降提供足够的背景,我们将以一个神经网络的实用示例开始本章。然后我们将逐一点评介绍的新概念。记住,这些概念对于你理解接下来章节中的实际示例是至关重要的!

在阅读完本章后,你将对深度学习背后的数学理论有一个直观的理解,并且你将准备好开始深入研究第三章中的现代深度学习框架。

首次了解神经网络

让我们来看一个具体的例子,这个神经网络使用机器学习库 Keras 来学习对手写数字进行分类。我们将在整本书中广泛使用 Keras。这是一个简单、高级的库,将使我们能够专注于我们想要覆盖的概念。

除非你已经具备使用 Keras 或类似库的经验,否则你不会立即理解这个第一个示例的所有内容。这没关系。在接下来的几个部分中,我们将回顾示例中的每个元素,并对其进行详细解释。所以,如果某些步骤看起来很随意或者对你来说像是魔术,请不要担心!我们必须从某个地方开始。

我们试图解决的问题是将灰度图像的手写数字(28 × 28 像素)分类到其 10 个类别(0 到 9)中。我们将使用 MNIST 数据集,这是机器学习社区中的经典数据集,几乎与该领域一样历史悠久,并且已经被深入研究。它是由美国国家标准与技术研究院(NIST)在 20 世纪 80 年代汇编的一套 60,000 张训练图像和 10,000 张测试图像。你可以把“解决”MNIST 看作是深度学习的“Hello World”——这是你用来验证你的算法是否按预期工作的方法。随着你成为机器学习从业者,你会在科学论文、博客文章等地方反复看到 MNIST。你可以在图 2.1 中看到一些 MNIST 样本。

图 2.1:MNIST 样本数字

MNIST 数据集在 Keras 中预先加载,以一组四个 NumPy 数组的形式存在。

from keras.datasets import mnist

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

列表 2.1:在 Keras 中加载 MNIST 数据集

train_imagestrain_labels构成了训练集,即模型将从中学习的数据。然后,模型将在测试集test_imagestest_labels上进行测试。图像被编码为 NumPy 数组,标签是一个从 0 到 9 的数字数组。图像和标签有一一对应的关系。

让我们看看训练数据:

>>> train_images.shape
(60000, 28, 28)
>>> len(train_labels)
60000
>>> train_labels
array([5, 0, 4, ..., 5, 6, 8], dtype=uint8)

下面是测试数据:

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

工作流程将如下进行。首先,我们将训练数据train_imagestrain_labels输入神经网络。然后,网络将学会将图像和标签关联起来。最后,我们将要求网络对test_images生成预测,并验证这些预测是否与test_labels中的标签匹配。

让我们构建网络——再次提醒,你不需要现在就理解这个例子中的所有内容。

import keras
from keras import layers

model = keras.Sequential(
    [
        layers.Dense(512, activation="relu"),
        layers.Dense(10, activation="softmax"),
    ]
) 

列表 2.2:网络架构

神经网络的核心构建块是。你可以将层视为数据的过滤器:一些数据进入,以更有用的形式输出。具体来说,层从输入的数据中提取表示——希望这些表示对于当前问题更有意义。大多数深度学习都是由将简单的层链接起来以实现一种形式的数据蒸馏。深度学习模型就像数据处理的筛子,由一系列越来越精细的数据过滤器——即层组成。

在这里,我们的模型由两个Dense层组成,这些层是密集连接的(也称为全连接)神经网络层。第二个(也是最后一个)层是一个 10 路softmax分类层,这意味着它将返回一个包含 10 个概率分数的数组(总和为 1)。每个分数将是当前数字图像属于我们 10 个数字类别之一的概率。

为了使模型准备好训练,我们需要在编译步骤中挑选三个更多的事物:

  • 损失函数——模型将如何衡量其在训练数据上的性能,从而如何能够引导自己走向正确的方向。

  • 优化器 — 模型将根据其看到的训练数据更新自己的机制,以改善其性能。

  • 在训练和测试期间要监控的指标 — 在这里,我们只关心准确率(正确分类的图像的比例)。

损失函数和优化器的确切目的将在接下来的两章中变得清晰。

model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
) 

列表 2.3:编译步骤

在训练之前,我们将通过将其重塑成模型期望的形状并缩放到所有值都在 [0, 1] 区间内来预处理数据。之前,我们的训练图像存储在一个形状为 (60000, 28, 28)、类型为 uint8 且值在 [0, 255] 区间内的数组中。我们将其转换为一个形状为 (60000, 28 * 28)、值在 01 之间的 float32 数组。

train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype("float32") / 255 

列表 2.4:准备图像数据

我们现在准备好训练模型了,在 Keras 中,这是通过调用模型的 fit() 方法来完成的——我们将模型拟合到其训练数据。

model.fit(train_images, train_labels, epochs=5, batch_size=128) 

列表 2.5:“拟合”模型

训练过程中显示了两个量:模型在训练数据上的损失和模型在训练数据上的准确率。我们很快就在训练数据上达到了 98.9%(0.989)的准确率。

现在我们已经训练了一个模型,我们可以用它来预测新数字的类别概率——那些不是训练数据一部分的图像,比如测试集中的图像。

>>> test_digits = test_images[0:10]
>>> predictions = model.predict(test_digits)
>>> predictions[0]
array([1.0726176e-10, 1.6918376e-10, 6.1314843e-08, 8.4106023e-06,
       2.9967067e-11, 3.0331331e-09, 8.3651971e-14, 9.9999106e-01,
       2.6657624e-08, 3.8127661e-07], dtype=float32)

列表 2.6:使用模型进行预测

那个数组中索引 i 的每个数字都对应于数字图像 test_digits[0] 属于类别 i 的概率。

这个第一个测试数字在索引 7 处具有最高的概率分数(0.99999106,几乎为 1),因此根据我们的模型,它必须是一个 7:

>>> predictions[0].argmax()
7
>>> predictions[0][7]
0.99999106

我们可以检查测试标签是否一致:

>>> test_labels[0]
7

平均来说,我们的模型在分类这些从未见过的数字方面有多好?让我们通过在整个测试集上计算平均准确率来检查。

>>> test_loss, test_acc = model.evaluate(test_images, test_labels)
>>> print(f"test_acc: {test_acc}")
test_acc: 0.9785

列表 2.7:在新数据上评估模型

测试集的准确率结果是 97.8%——这几乎是训练集错误率(98.9% 准确率)的两倍。训练准确率和测试准确率之间的差距是过拟合的一个例子:机器学习模型往往在新数据上的表现不如在训练数据上。过拟合是第五章的核心主题。

这就结束了我们的第一个例子。您刚刚看到了如何用不到 15 行 Python 代码构建和训练一个神经网络来分类手写数字。在本章和下一章中,我们将详细介绍我们刚刚预览的每个移动部件,并阐明幕后发生的事情。您将了解张量,即进入模型的数据存储对象;张量操作,即哪些层由它们组成;以及梯度下降,它允许您的模型从其训练示例中学习。

神经网络的表示

在上一个例子中,我们是从存储在多维 NumPy 数组中的数据开始的,这些数组也被称为tensors。一般来说,所有当前的机器学习系统都使用张量作为它们的基本数据结构。张量是该领域的基石——基础到以至于 TensorFlow 框架就是以它们的名称命名的。那么,什么是张量呢?

从本质上讲,张量是数据的容器 — 通常是指数值数据。因此,它是一个数字的容器。您可能已经熟悉矩阵,它们是 2 秩张量:张量是矩阵向任意维数的推广(请注意,在张量的上下文中,维度通常被称为)。

最初了解张量的细节可能有点抽象。但这是值得的 — 操作张量将是您所写的任何机器学习代码的基础。

标量(0 秩张量)

只包含一个数的张量被称为标量(或标量张量、0 阶张量或 0D 张量)。在 NumPy 中,一个float32float64数字是一个标量张量(或标量数组)。您可以通过ndim属性显示 NumPy 张量的轴数;标量张量有 0 个轴(ndim == 0)。张量的轴数也称为其。以下是一个 NumPy 标量:

>>> import numpy as np
>>> x = np.array(12)
>>> x
array(12)
>>> x.ndim
0

向量(1 秩张量)

数字数组被称为向量(或 1 秩张量或 1D 张量)。1 秩张量恰好有一个轴。以下是一个 NumPy 向量:

>>> x = np.array([12, 3, 6, 14, 7])
>>> x
array([12, 3, 6, 14, 7])
>>> x.ndim
1

这个向量有五个条目,因此被称为5 维向量。不要将 5 维向量与 5 维张量混淆!5 维向量只有一个轴,其轴上有五个维度,而 5 维张量有五个轴(并且每个轴上可能有任意数量的维度)。维度可以表示特定轴上的条目数(如我们的 5 维向量的情况)或张量中的轴数(如 5 维张量),有时可能会造成混淆。在后一种情况下,从技术上讲,更正确地谈论一个秩为 5 的张量(张量的秩是轴的数量),但模糊的符号5D 张量仍然很常见。

矩阵(2 秩张量)

向量数组是一个矩阵(或 2 秩张量或 2D 张量)。矩阵有两个轴(通常被称为)。您可以将矩阵可视化为数字的矩形网格。这是一个 NumPy 矩阵:

>>> x = np.array([[5, 78, 2, 34, 0],
...               [6, 79, 3, 35, 1],
...               [7, 80, 4, 36, 2]])
>>> x.ndim
2

第一轴的条目称为,第二轴的条目称为。在上一个例子中,[5, 78, 2, 34, 0]x的第一行,而[5, 6, 7]是第一列。

3 秩张量和更高秩的张量

如果您将这些矩阵打包到一个新的数组中,您将获得一个 3 秩张量(或 3D 张量),您可以将其可视化为数字的立方体。以下是一个 NumPy 3 秩张量:

>>> x = np.array([[[5, 78, 2, 34, 0],
...                [6, 79, 3, 35, 1],
...                [7, 80, 4, 36, 2]],
...               [[5, 78, 2, 34, 0],
...                [6, 79, 3, 35, 1],
...                [7, 80, 4, 36, 2]],
...               [[5, 78, 2, 34, 0],
...                [6, 79, 3, 35, 1],
...                [7, 80, 4, 36, 2]]])
>>> x.ndim
3

通过将 3 秩张量打包到数组中,您可以创建一个 4 秩张量,依此类推。在深度学习中,您通常操作秩为 0 到 4 的张量,尽管如果您处理视频数据,您可能达到 5。

关键属性

张量由三个关键属性定义:

  • 轴数(秩) — 例如,一个 3 秩张量有三个轴,而矩阵有两个轴。这也被称为 Python 库(如 NumPy、JAX、TensorFlow 和 PyTorch)中的张量的ndim

  • 形状 — 这是一个整数元组,描述了张量在每个轴上的维度数。例如,前面的矩阵示例的形状是(3, 5),而三阶张量示例的形状是(3, 3, 5)。一个向量有一个元素的形状,例如(5,),而一个标量有一个空的形状,()

  • 数据类型(在 Python 库中通常称为dtype — 这是张量中包含的数据类型;例如,张量的类型可以是float16float32float64uint8bool等等。在 TensorFlow 中,你也可能会遇到string类型的张量。

为了使这一点更加具体,让我们回顾一下 MNIST 示例中处理的数据。首先,我们加载 MNIST 数据集:

from keras.datasets import mnist

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

接下来,我们显示张量train_images的轴数,ndim属性:

>>> train_images.ndim
3

这是它的形状:

>>> train_images.shape
(60000, 28, 28)

这就是它的数据类型,dtype属性:

>>> train_images.dtype
uint8

因此,我们这里有一个 8 位整数的三阶张量。更确切地说,它是一个包含 60,000 个 28 × 28 整数的矩阵数组。每个这样的矩阵是一个灰度图像,其系数在 0 到 255 之间。

让我们使用 Matplotlib 库(标准科学 Python 套件的一部分)显示这个三阶张量中的第四个数字;参见图 2.2。

import matplotlib.pyplot as plt

digit = train_images[4]
plt.imshow(digit, cmap=plt.cm.binary)
plt.show() 

代码列表 2.8:显示第四位数字

图 2.2:数据集的第四个样本

自然地,相应的标签只是整数 9:

>>> train_labels[4]
9

在 NumPy 中操作张量

在前面的例子中,我们使用语法train_images[i]在第一个轴上选择了一个特定的数字。在张量中选择特定元素称为张量切片。让我们看看可以在 NumPy 数组上执行的张量切片操作。

以下示例选择了数字#10 到#100(不包括#100)并将它们放入形状为(90, 28, 28)的数组中:

>>> my_slice = train_images[10:100]
>>> my_slice.shape
(90, 28, 28)

这与以下更详细的表示法等价,它为每个张量轴上的切片指定了起始索引和停止索引。注意,:等价于选择整个轴:

>>> # Equivalent to the previous example
>>> my_slice = train_images[10:100, :, :]
>>> my_slice.shape
(90, 28, 28)
>>> # Also equivalent to the previous example
>>> my_slice = train_images[10:100, 0:28, 0:28]
>>> my_slice.shape
(90, 28, 28)

通常,您可以在每个张量轴上的任意两个索引之间选择切片。例如,要选择所有图像右下角的 14 × 14 像素,您将这样做:

my_slice = train_images[:, 14:, 14:] 

也可以使用负索引。与 Python 列表中的负索引类似,它们表示相对于当前轴末尾的位置。为了将图像裁剪为中间 14 × 14 像素的补丁,这样做:

my_slice = train_images[:, 7:-7, 7:-7] 

数据批次的观念

通常,在深度学习中遇到的所有数据张量的第一个轴(轴 0,因为索引从 0 开始)将是样本轴。在 MNIST 示例中,“样本”是数字的图像。

此外,深度学习模型不会一次性处理整个数据集;相反,它们将数据分成小的“批次”,即具有固定大小的样本组。具体来说,这是我们 MNIST 数字的一个批次,批次大小为 128:

batch = train_images[:128] 

这是下一个批次:

batch = train_images[128:256] 

以及第n个批次:

n = 3
batch = train_images[128 * n : 128 * (n + 1)] 

当考虑这样的批量张量时,第一个轴(轴 0)被称为批量轴(或批量维度)。当使用 Keras 和其他深度学习库时,你经常会遇到这个术语。

数据张量的现实世界示例

让我们通过一些与您稍后将要遇到的类似示例来具体说明数据张量。您将要操作的数据几乎总是属于以下类别之一:

  • 向量数据 — 形状为(样本, 特征)的二阶张量,其中每个样本是一个数值属性(“特征”)的向量

  • 时序数据或序列数据 — 形状为(样本, 时间步长, 特征)的三阶张量,其中每个样本是一个特征向量的序列(长度为时间步长

  • 图像 — 形状为(样本, 高度, 宽度, 通道)的四阶张量,其中每个样本是一个二维像素网格,每个像素由一个值向量(“通道”)表示

  • 视频 — 形状为(样本, 帧, 高度, 宽度, 通道)的五阶张量,其中每个样本是一个长度为的图像序列

向量数据

向量数据是最常见的案例之一。在这样的数据集中,每个单独的数据点都可以编码为一个向量,因此一批数据将被编码为一个二阶张量(即向量的数组),其中第一个轴是样本轴,第二个轴是特征轴

让我们看看两个例子:

  • 一个关于人群的精算数据集,其中我们考虑每个人的年龄、性别和收入。每个人可以表示为一个包含三个值的向量,因此整个包含 100,000 个人的数据集可以存储在一个形状为(100000, 3)的二阶张量中。

  • 一个文本文档数据集,其中我们通过每个文档中每个单词出现的次数(从 20,000 个常用单词的字典中)来表示每个文档。每个文档可以编码为一个包含 20,000 个值(字典中每个单词的一个计数)的向量,因此整个包含 500 个文档的数据集可以存储在一个形状为(500, 20000)的张量中。

时序数据或序列数据

当时间在你的数据(或序列顺序的概念)中很重要时,将数据存储在具有显式时间轴的三阶张量中是有意义的。每个样本可以编码为一个向量的序列(一个二阶张量),因此一批数据将被编码为一个三阶张量(参见图 2.3)。

图 2.3:一个三阶时序数据张量

时间轴始终是第二个轴(索引为 1 的轴),这是惯例。让我们看看几个例子:

  • 股票价格数据集 — 每分钟,我们存储股票的当前价格、过去一分钟内的最高价格和最低价格。因此,每分钟被编码为一个三维向量,整个交易日的编码是一个形状为(390, 3)的矩阵(交易日中有 390 分钟),250 天的数据可以存储在一个形状为(250, 390, 3)的三阶张量中。在这里,每个样本代表一天的数据。

  • 一个包含推文的数据库,其中我们将每个推文编码为从 128 个唯一字符的字母表中选取的 280 个字符的序列——在这个设置中,每个字符可以编码为一个大小为 128 的二进制向量(除了对应字符的索引处的 1 条记录之外,其余都是全零向量)。然后每个推文可以编码为一个形状为(280, 128)的二阶张量,一个包含 100 万个推文的数据库可以存储在一个形状为(1000000, 280, 128)的张量中。

图像数据

图像通常具有三个维度:高度、宽度和颜色深度。尽管灰度图像(如我们的 MNIST 数字)只有一个颜色通道,因此可以存储在二阶张量中,但按照惯例,图像张量总是三阶的,对于灰度图像有一个一维的颜色通道。因此,一个大小为 256 × 256 的 128 个灰度图像批次的张量形状可以是(128, 256, 256, 1),而一个 128 个彩色图像批次的张量形状可以是(128, 256, 256, 3)(见图 2.4)。

图 2.4:四阶图像数据张量

图像张量的形状有两种惯例:通道最后惯例(在 JAX、TensorFlow 以及大多数其他深度学习工具中是标准的)和通道首先惯例(在 PyTorch 中是标准的)。

通道最后惯例将颜色深度轴放在末尾:(样本, 高度, 宽度, 颜色深度)。同时,通道首先惯例将颜色深度轴放在批次轴之后:(样本, 颜色深度, 高度, 宽度)。使用通道首先惯例,前面的例子将变为(128, 1, 256, 256)(128, 3, 256, 256)。Keras API 提供了对这两种格式的支持。

视频数据

视频数据是少数几种需要五阶张量的真实世界数据之一。视频可以被理解为一系列帧,每一帧都是一个彩色图像。因为每一帧可以存储在一个三阶张量中(高度, 宽度, 颜色深度),所以一系列帧可以存储在一个四阶张量中(帧, 高度, 宽度, 颜色深度),因此不同视频的批次可以存储在一个形状为(样本, 帧, 高度, 宽度, 颜色深度)的五阶张量中。

例如,一个 60 秒、每秒 4 帧的 144 × 256 YouTube 视频剪辑将包含 240 帧。四个这样的视频剪辑的批次将存储在一个形状为(4, 240, 144, 256, 3)的张量中。这总共是 10,616,8320 个值!如果张量的dtypefloat32,那么每个值将占用 32 位,因此张量将代表 425 MB。太重了!你在现实生活中遇到的视频要轻得多,因为它们不是以float32存储的,并且通常被压缩了很大的比例(例如 MPEG 格式)。

神经网络齿轮:张量操作

就像任何计算机程序最终都可以归结为对二进制输入(ANDORNOR等)进行的一小套二进制操作一样,深度神经网络学习到的所有变换都可以归结为对数值数据张量应用的一小套张量操作(或张量函数)。例如,可以添加张量、乘以张量等。

在我们的初始示例中,我们通过将Dense层堆叠在一起来构建我们的模型。一个 Keras 层实例看起来像这样:

keras.layers.Dense(512, activation="relu") 

这个层可以解释为一个函数,它接受一个矩阵作为输入,并返回另一个矩阵——这是输入张量的新表示。具体来说,函数如下(其中W是一个矩阵,b是一个向量,都是层的属性):

output = relu(matmul(input, W) + b) 

让我们分解一下。这里我们有三个张量操作:

  • 输入张量与名为W的张量之间的张量积(matmul)。

  • 结果矩阵与向量b之间的加法(+)。

  • 一个relu操作:relu(x)max(x, 0)。"relu"代表“REctified Linear Unit"。

元素级操作

relu操作和加法是元素级操作:这些操作独立应用于考虑中的张量中的每个条目。这意味着这些操作非常适合大规模并行实现(矢量化实现,这个术语来自 1970-1990 期间的矢量处理器超级计算机架构)。如果你想要编写一个元素级操作的原始 Python 实现,你可以使用一个for循环,就像这个元素级relu操作的原始实现一样:

def naive_relu(x):
    # x is a rank-2 NumPy tensor.
    assert len(x.shape) == 2
    # Avoids overwriting the input tensor
    x = x.copy()
    for i in range(x.shape[0]):
        for j in range(x.shape[1]):
            x[i, j] = max(x[i, j], 0)
    return x 

你可以为加法做同样的事情:

def naive_add(x, y):
    # x and y are rank-2 NumPy tensors.
    assert len(x.shape) == 2
    assert x.shape == y.shape
    # Avoids overwriting the input tensor
    x = x.copy()
    for i in range(x.shape[0]):
        for j in range(x.shape[1]):
            x[i, j] += y[i, j]
    return x 

根据同样的原理,你可以进行元素级乘法、减法等操作。

在实践中,当处理 NumPy 数组时,这些操作也是作为优化良好的内置 NumPy 函数提供的,这些函数本身将繁重的工作委托给基本线性代数子程序(BLAS)实现。BLAS 是低级、高度并行、高效的张量操作例程,通常用 Fortran 或 C 实现。

因此,在 NumPy 中,你可以执行以下元素级操作,并且它会非常快:

import numpy as np

# Element-wise addition
z = x + y
# Element-wise relu
z = np.maximum(z, 0.0) 

让我们实际测量一下这个差异:

import time

x = np.random.random((20, 100))
y = np.random.random((20, 100))

t0 = time.time()
for _ in range(1000):
    z = x + y
    z = np.maximum(z, 0.0)
print("Took: {0:.2f} s".format(time.time() - t0)) 

这需要 0.02 秒。与此同时,原始版本需要惊人的 2.45 秒:

t0 = time.time()
for _ in range(1000):
    z = naive_add(x, y)
    z = naive_relu(z)
print("Took: {0:.2f} s".format(time.time() - t0)) 

同样,当在 GPU 上运行 JAX/TensorFlow/PyTorch 代码时,元素级操作通过完全矢量化 CUDA 实现执行,这可以最好地利用高度并行的 GPU 芯片架构。

广播

我们之前对naive_add的原始实现只支持相同形状的秩为 2 的张量相加。但在之前引入的Dense层中,我们添加了一个与向量相加的秩为 2 的张量。当两个要相加的张量的形状不同时,加法会发生什么?

当可能时,如果没有歧义,较小的张量将被广播以匹配较大张量的形状。广播包括两个步骤:

  • 轴(称为广播轴)被添加到较小的张量中,以匹配较大张量的ndim

  • 较小的张量会沿着这些新轴重复,以匹配较大张量的完整形状。

让我们来看一个具体的例子。考虑形状为(32, 10)X和形状为(10,)y

import numpy as np

# X is a random matrix with shape (32, 10).
X = np.random.random((32, 10))
# y is a random vector with shape (10,).
y = np.random.random((10,)) 

首先,我们向y添加一个空的第一轴,其形状变为(1, 10)

# The shape of y is now (1, 10).
y = np.expand_dims(y, axis=0) 

然后,我们将y重复 32 次在这个新轴上,这样我们最终得到一个形状为(32, 10)的张量Y,其中Y[i, :] == y对于irange(0, 32)中:

# Repeat y 32 times along axis 0 to obtain Y with shape (32, 10).
Y = np.tile(y, (32, 1)) 

在这一点上,我们可以添加XY,因为它们具有相同的形状。

在实现方面,没有创建新的秩为 2 的张量,因为这会非常低效。重复操作完全是虚拟的:它在算法级别发生,而不是在内存级别。但想象向量在新的轴上重复 32 次是一个有帮助的心智模型。以下是一个简单的实现示例:

def naive_add_matrix_and_vector(x, y):
    # x is a rank-2 NumPy tensor.
    assert len(x.shape) == 2
    # y is a NumPy vector.
    assert len(y.shape) == 1
    assert x.shape[1] == y.shape[0]
    # Avoids overwriting the input tensor
    x = x.copy()
    for i in range(x.shape[0]):
        for j in range(x.shape[1]):
            x[i, j] += y[j]
    return x 

通过广播,如果第一个张量的形状为(a, b, … n, n + 1, … m),而第二个张量的形状为(n, n + 1, … m),则通常可以应用两个张量的逐元素操作。广播将自动发生在轴a通过n - 1上。

以下示例通过广播将逐元素maximum操作应用于不同形状的两个张量:

import numpy as np

# x is a random tensor with shape (64, 3, 32, 10).
x = np.random.random((64, 3, 32, 10))
# y is a random tensor with shape (32, 10).
y = np.random.random((32, 10))
# The output z has shape (64, 3, 32, 10) like x.
z = np.maximum(x, y) 

张量积

张量积,也称为点积矩阵乘积(简称“矩阵乘法”),是最常见、最有用的张量运算之一。

在 NumPy 中,张量积是通过np.matmul函数实现的,而在 Keras 中,则是通过keras.ops.matmul函数。其简写是 Python 中的@运算符:

x = np.random.random((32,))
y = np.random.random((32,))

# Takes the product between x and y
z = np.matmul(x, y)
# This is equivalent.
z = x @ y 

在数学符号中,您会注意到操作使用点(•)来表示(因此得名“点积”):

z = x • y 

从数学的角度来看,matmul操作做什么?让我们从两个向量xy的乘积开始。它被计算如下:

def naive_vector_product(x, y):
    # x and y are NumPy vectors.
    assert len(x.shape) == 1
    assert len(y.shape) == 1
    assert x.shape[0] == y.shape[0]
    z = 0.0
    for i in range(x.shape[0]):
        z += x[i] * y[i]
    return z 

您会注意到两个向量的乘积是一个标量,并且只有具有相同元素数量的向量才能进行此操作兼容。

您还可以计算矩阵x和向量y之间的乘积,这将返回一个向量,其系数是yx的行的乘积。您可以这样实现它:

def naive_matrix_vector_product(x, y):
    # x is a NumPy matrix.
    assert len(x.shape) == 2
    # y is a NumPy vector.
    assert len(y.shape) == 1
    # The 1st dimension of x must equal the 0th dimension of y!
    assert x.shape[1] == y.shape[0]
    # This operation returns a vector of 0s with as many rows as x.
    z = np.zeros(x.shape[0])
    for i in range(x.shape[0]):
        for j in range(x.shape[1]):
            z[i] += x[i, j] * y[j]
    return z 

您还可以重用我们之前编写的代码,这突出了矩阵-向量乘积和向量乘积之间的关系:

def naive_matrix_vector_product(x, y):
    z = np.zeros(x.shape[0])
    for i in range(x.shape[0]):
        z[i] = naive_vector_product(x[i, :], y)
    return z 

注意,一旦两个张量中的任何一个的ndim大于 1,matmul就不再是对称的,也就是说matmul(x, y)不等于matmul(y, x)

当然,张量乘积可以推广到具有任意数量轴的张量。最常见的应用可能是两个矩阵之间的乘法。如果你可以取两个矩阵xy的乘积(matmul(x, y)),当且仅当x.shape[1] == y.shape[0]。结果是形状为(x.shape[0], y.shape[1])的矩阵,其中系数是x的行和y的列之间的向量乘积。以下是原始实现:

def naive_matrix_product(x, y):
    # x and y are NumPy matrices.
    assert len(x.shape) == 2
    assert len(y.shape) == 2
    # The 1st dimension of x must equal the 0th dimension of y!
    assert x.shape[1] == y.shape[0]
    # This operation returns a matrix of 0s with a specific shape.
    z = np.zeros((x.shape[0], y.shape[1]))
    # Iterates over the rows of x ...
    for i in range(x.shape[0]):
        # ... and over the columns of y.
        for j in range(y.shape[1]):
            row_x = x[i, :]
            column_y = y[:, j]
            z[i, j] = naive_vector_product(row_x, column_y)
    return z 

要理解向量乘积的形状兼容性,通过如图 2.5 所示的对齐输入和输出张量来可视化它们是有帮助的。

图 2.5:矩阵乘法框图

xyz被表示为矩形(系数的实心框)。因为x的行和y的列必须具有相同的大小,所以x的宽度必须与y的高度相匹配。如果你继续开发新的机器学习算法,你很可能会经常绘制这样的图表。

更一般地,你可以根据前面为二维情况概述的形状兼容性规则,对高维张量进行乘法运算:

(a, b, c, d) • (d,) -> (a, b, c)
(a, b, c, d) • (d, e) -> (a, b, c, e) 

等等。

张量重塑

需要理解的一种第三类张量操作是张量重塑。尽管它在我们第一个神经网络示例中的Dense层中没有使用,但我们使用它在我们预处理数字数据并将其输入到我们的模型之前:

train_images = train_images.reshape((60000, 28 * 28)) 

重塑张量意味着重新排列其行和列以匹配目标形状。自然地,重塑后的张量与初始张量具有相同数量的系数。重塑最好通过简单的例子来理解:

>>> x = np.array([[0., 1.],
...               [2., 3.],
...               [4., 5.]])
>>> x.shape
(3, 2)
>>> x = x.reshape((6, 1))
>>> x
array([[ 0.],
       [ 1.],
       [ 2.],
       [ 3.],
       [ 4.],
       [ 5.]])
>>> x = x.reshape((2, 3))
>>> x
array([[ 0.,  1.,  2.],
       [ 3.,  4.,  5.]])

常见的一种重塑特殊情况是转置。转置矩阵意味着交换其行和列,使得x[i, :]变为x[:, i]

>>> # Creates an all-zeros matrix of shape (300, 20)
>>> x = np.zeros((300, 20))
>>> x = np.transpose(x)
>>> x.shape
(20, 300)

张量操作的几何解释

因为张量操作中操作的张量内容可以解释为某些几何空间中点的坐标,所以所有张量操作都有几何解释。例如,让我们考虑加法。我们将从以下向量开始:

A = [0.5, 1] 

它是一个二维空间中的点(见图 2.6)。通常将向量表示为从原点到点的箭头,如图 2.7 所示。

图 2.6:二维空间中的点

图 2.7:一个二维空间中的点,以箭头形式表示

让我们考虑一个新的点B = [1, 0.25],我们将将其添加到前面的点上。这是通过将向量箭头链式连接起来在几何上完成的,结果位置是表示前两个向量之和的向量(见图 2.8)。正如你所看到的,将向量B添加到向量A表示将点A复制到新的位置,该位置与原始点A的距离和方向由向量B确定。如果你将相同的向量加法应用于平面上的点组(一个“对象”),你将创建整个对象在新的位置上的副本(见图 2.9)。因此,张量加法表示通过一定量的方向移动对象(不扭曲对象)的动作。

图 2.8:两个向量的几何和的解释

通常,基本的几何运算,如平移、旋转、缩放、倾斜等,都可以表示为张量运算。以下是一些例子:

  • 平移 — 正如你所看到的,向一个点添加一个向量将使该点在固定方向上移动固定距离。应用于一组点(例如一个 2D 对象),这被称为“平移”(见图 2.9)。

图 2.9:2D 平移作为向量加法

  • 旋转 — 通过与一个 2×2 矩阵R = [[cos(theta), -sin(theta)], [sin(theta), cos(theta)]]相乘,可以实现一个 2D 向量以角度 theta 逆时针旋转(见图 2.10)。

图 2.10:2D 旋转(逆时针)作为矩阵乘积

  • 缩放 — 通过与一个 2×2 矩阵S = [[horizontal_factor, 0], [0, vertical_factor]]相乘(注意这样的矩阵被称为“对角矩阵”,因为它只在“对角线”上有非零系数,从左上角到右下角),可以实现图像的垂直和水平缩放(见图 2.11)。

图 2.11:2D 缩放作为矩阵乘积

  • 线性变换 — 与任意矩阵相乘实现线性变换。注意,之前看到的缩放旋转按定义是线性变换。

  • 仿射变换 — 仿射变换(见图 2.12)是线性变换(通过矩阵乘法实现)和平移(通过向量加法实现)的组合。你可能已经注意到,这正是Dense层中实现的y = W @ x + b计算!没有激活函数的Dense层是一个仿射层。

图 2.12:平面上的仿射变换

  • 具有relu激活功能的Dense — 关于仿射变换的一个重要观察是,如果你反复应用许多次,最终仍然得到一个仿射变换(所以你一开始就可以只应用那个仿射变换)。让我们用两个来试一下:affine2(affine1(x)) = W2 @ (W1 @ x + b1) + b2 = (W2 @ W1) @ x + (W2 @ b1 + b2)。这是一个仿射变换,其中线性部分是矩阵W2 @ W1,平移部分是向量W2 @ b1 + b2。因此,一个完全由Dense层组成且没有激活的多层神经网络等价于一个单一的Dense层。这个“深度”神经网络实际上只是一个伪装的线性模型!这就是为什么我们需要激活函数,比如relu(如图 2.13 所示)。多亏了激活函数,一系列Dense层可以被用来实现非常复杂、非线性的几何变换,从而为你的深度神经网络提供非常丰富的假设空间。我们将在下一章更详细地介绍这个想法。

图片

图 2.13:仿射变换后跟relu激活

深度学习的几何解释

你刚刚了解到神经网络完全由张量操作链组成,而且所有这些张量操作都只是输入数据的简单几何变换。因此,你可以将神经网络解释为一个在多维空间中的非常复杂的几何变换,通过一系列简单的步骤实现。

在三维空间中,以下的心智图可能很有用。想象两张彩色纸:一张红色,一张蓝色。将一张放在另一张上面。现在将它们揉成一个小球。这个揉皱的纸球是你的输入数据,而每一张纸都是分类问题中的一个数据类别。神经网络的目的就是找出一个将纸球展开,使两个类别再次清晰可分的变换(见图 2.14)。通过深度学习,这将通过一系列简单的三维空间变换来实现,就像你可以用手指对纸球进行的一次次简单变换。

图片

图 2.14:展开复杂的数据流形

将纸团展开是机器学习的主要内容:在多维空间中找到复杂、高度折叠的数据流形的整洁表示(流形是一个连续的表面,就像我们皱巴巴的纸张)。到这一点,你应该对为什么深度学习在这方面表现出色有很好的直觉:它采用了一种将复杂几何变换逐步分解为一系列基本变换的方法,这基本上是人类展开纸团所遵循的策略。深度网络中的每一层都应用一种转换,稍微解开数据——而深度堆叠的层使得极其复杂的解开过程变得可行。

神经网络的引擎:基于梯度的优化

正如你在上一节中看到的,我们第一个模型示例中的每个神经网络层都按以下方式转换其输入数据:

output = relu(matmul(input, W) + b) 

在这个表达式中,Wb是层的属性张量。它们被称为层的权重可训练参数(分别对应kernelbias属性)。这些权重包含了模型从训练数据中学习到的信息。

初始时,这些权重矩阵填充了小的随机值(称为随机初始化)。当然,没有理由期望当Wb是随机的时,relu(matmul(input, W) + b)会产生任何有用的表示。结果表示是没有意义的——但它们是一个起点。接下来要做的是根据反馈信号逐渐调整这些权重。这种逐渐调整,也称为训练,基本上是机器学习所涉及的学习过程。

这发生在所谓的训练循环中,其工作原理如下。重复以下步骤,直到损失看起来足够低:

  1. 提取一批训练样本x和相应的目标y_true

  2. x上运行模型(称为前向传递)以获得预测y_pred

  3. 计算模型在批次上的损失,这是y_predy_true之间不匹配的度量。

  4. 以一种方式更新模型的所有权重,以略微减少这个批次上的损失。

最终,你将得到一个在训练数据上损失非常低的模型:预测y_pred和预期目标y_true之间的低不匹配。模型“学习”了将其输入映射到正确目标。从远处看,这可能看起来像魔法,但当你将其简化为基本步骤时,它实际上很简单。

第一步听起来很简单——它只是 I/O 代码。第二步和第三步仅仅是应用一些张量操作,所以你可以纯粹根据上一节学到的知识来实现这些步骤。困难的部分是第四步:更新模型的权重。给定模型中的一个权重系数,你如何计算该系数应该增加还是减少,以及增加或减少多少?

一个简单的方法是冻结模型中除考虑的单个标量系数外的所有权重,并尝试这个系数的不同值。假设系数的初始值为 0.3。在数据批次的前向传递后,模型在批次上的损失为 0.5。如果你将系数的值改为 0.35 并重新运行前向传递,损失增加到 0.6。但如果你将系数降低到 0.25,损失下降到 0.4。在这种情况下,似乎通过更新系数-0.05 可以有助于最小化损失。这需要为模型中的所有系数重复进行。

但这样的方法将非常低效,因为你需要为每个单独的系数(通常至少有几千个,甚至可能高达数十亿)计算两次前向传递(这是昂贵的)。幸运的是,有一个更好的方法:梯度下降

梯度下降是现代神经网络背后的优化技术。以下是它的核心内容。我们模型中使用的所有函数(例如matmul+)都以平滑和连续的方式转换它们的输入:例如,如果你看z = x + y,那么y的微小变化只会导致z的微小变化,如果你知道y变化的方向,你可以推断出z变化的方向。从数学上讲,你会说这些函数是可微的。如果你将这些函数链在一起,得到的更大的函数仍然是可微的。特别是,这适用于将模型的系数映射到模型在数据批次上的损失的函数:模型系数的微小变化会导致损失值的微小、可预测的变化。这使得你可以使用一个称为梯度的数学运算符来描述随着你将模型系数移动到不同方向时损失的变化。如果你计算这个梯度,你可以用它来移动系数(在一次更新中同时移动所有系数,而不是逐个移动)以减少损失。

如果你已经知道什么是可微的以及什么是梯度,你可以跳过接下来的两节。否则,以下内容将帮助你理解这些概念。

什么是导数?

考虑一个连续、平滑的函数f(x) = y,将一个数字x映射到一个新的数字y。我们可以用图 2.15 中的函数作为例子。

图片

图 2.15:一个连续、平滑的函数

因为函数是连续的,x的微小变化只能导致y的微小变化——这就是连续性的直觉。假设你通过一个小的因子epsilon_x增加x:这将导致y的微小epsilon_y变化,如图 2.16 所示。

图片

图 2.16:对于连续函数,x的微小变化导致y的微小变化。

此外,由于函数是平滑的(其曲线没有任何突然的角度),当epsilon_x足够小的时候,在某个点p附近,可以将f近似为一个斜率为a的线性函数,这样epsilon_y就变成了a * epsilon_x

f(x + epsilon_x) = y + a * epsilon_x 

显然,这种线性近似仅在x足够接近p时才有效。

斜率a被称为fp处的导数。如果a是负数,这意味着在p附近的x的微小增加将导致f(x)的减少,如图 2.17 所示,如果a是正数,则x的微小增加将导致f(x)的增加。此外,a的绝对值(导数的大小)告诉你这种增加或减少将发生得多快。

图 2.17:fp处的导数

对于每一个可导函数f(x)(可导意味着“可以求导”:例如,平滑、连续的函数可以求导),都存在一个导数函数f'(x),它将x的值映射到f在这些点的局部线性近似的斜率。例如,cos(x)的导数是-sin(x)f(x) = a * x的导数是f'(x) = a,等等。

能够求导函数是进行优化时一个非常强大的工具,优化是指寻找使f(x)的值最小化的x的值。如果你试图通过一个因子epsilon_x更新x以最小化f(x),并且你知道f的导数,那么你的工作就完成了:导数完全描述了随着x的变化f(x)如何演变。如果你想减少f(x)的值,你只需要将x稍微移动到导数的相反方向。

张量运算的导数:梯度

我们刚才看到的函数将标量值x转换成另一个标量值y:你可以在二维平面上将其绘制为曲线。现在,想象一个将标量元组(x, y)转换成标量值z的函数:这将是一个向量运算。你可以在三维空间(由坐标x, y, z索引)中将其绘制为二维表面。同样,你可以想象输入矩阵的函数,输入秩为 3 的张量的函数,等等。

只要描述它们的表面是连续且平滑的,导数的概念就可以应用于任何这样的函数。张量运算(或张量函数)的导数称为梯度。梯度只是将导数的概念推广到以张量作为输入的函数。记得对于标量函数,导数代表函数曲线的局部斜率吗?同样地,张量函数的梯度代表由函数描述的多维表面的曲率。它描述了当输入参数变化时函数输出的变化情况。

让我们来看一个基于机器学习的例子。考虑

  • 一个输入向量 x(数据集中的样本)。

  • 一个矩阵 W(模型的权重)。

  • 一个目标 y_true(模型应该学习与 x 关联的内容)。

  • 一个损失函数 loss(用来衡量模型当前预测与 y_true 之间的差距)。

你可以使用 W 来计算一个目标候选 y_pred,然后计算目标候选 y_pred 与目标 y_true 之间的损失或差异:

# We use the model weights W to make a prediction for x.
y_pred = matmul(x, W)
# We estimate how far off the prediction was.
loss_value = loss(y_pred, y_true) 

现在,我们想使用梯度来确定如何更新 W 以使 loss_value 更小。我们该如何做?

给定固定的输入 xy_true,前面的操作可以解释为一个将 W(模型的权重)的值映射到损失值的函数:

# f describes the curve (or high-dimensional surface) formed by loss
# values when W varies.
loss_value = f(W) 

假设当前 W 的值为 W0。那么 f 在点 W0 的导数是一个与 W 形状相同的张量 grad(loss_value, W0),其中每个系数 grad(loss_value, W0)[i, j] 指示了当修改 W0[i, j] 时观察到的 loss_value 变化的方向和大小。这个张量 grad(loss_value, W0) 是函数 f(W) = loss_valueW0 处的梯度,也称为“loss_value 关于 WW0 附近的梯度。”

具体来说,grad(loss_value, W0) 代表什么?你之前看到,一个单系数函数 f(x) 的导数可以解释为 f 的曲线的斜率。同样,grad(loss_value, W0) 可以解释为描述 loss_value = f(W)W0 附近 曲率 的张量。每个偏导数描述了 f 在特定方向上的曲率。

我们刚刚看到,对于一个函数 f(x),你可以通过将 x 向导数的相反方向移动一小段距离来减少 f(x) 的值。以类似的方式,对于一个张量 f(W) 的函数,你可以通过将 W 向梯度相反的方向移动来减少 loss_value = f(W),例如更新 W1 = W0 - step * grad(f(W0), W0),其中 step 是一个小的缩放因子。这意味着逆着曲率走,直观上应该会让你处于曲线的较低位置。请注意,缩放因子 step 是必需的,因为 grad(loss_value, W0) 只在接近 W0 时近似曲率,所以你不想离 W0 太远。

随机梯度下降。

对于一个可微函数,从理论上讲,可以找到它的最小值:已知函数的最小值是导数为 0 的点,所以你只需要找到所有导数变为 0 的点,并检查这些点中哪个函数值最低。

将此应用于神经网络,意味着通过解析方法找到权重值的组合,以产生最小的损失函数。这可以通过求解方程 grad(f(W), W) = 0 对于 W 来实现。这是一个 N 个变量的多项式方程,其中 N 是模型中的系数数量。虽然对于 N = 2N = 3,解决这样的方程是可能的,但对于实际神经网络来说,这样做是难以处理的,因为参数的数量永远不会少于几千,有时甚至达到数十亿。

相反,您可以使用本节开头概述的四个步骤算法:根据当前随机批次数据上的损失值逐渐修改参数。因为您正在处理一个可微分的函数,您可以计算其梯度,这为您实现第 4 步提供了一个有效的方法。如果您将权重更新为与梯度相反的方向,损失将每次都略有减少:

  1. 抽取一批训练样本 x 和相应的目标 y_true

  2. x 上运行模型以获得预测 y_pred(这被称为 正向传播)。

  3. 计算模型在批次上的损失,这是 y_predy_true 之间差异的度量。

  4. 计算损失相对于模型参数的梯度(这被称为 反向传播)。

  5. 将参数稍微向梯度的反方向移动——例如,W -= learning_rate * gradient——从而略微减少批次的损失。学习率(此处为 learning_rate)将是一个标量因子,调节梯度下降过程的“速度”。

足够简单!我们刚才描述的被称为 小批量随机梯度下降(mini-batch SGD)。术语 随机 指的是每个数据批次都是随机抽取的(随机random 的科学同义词)。图 2.18 展示了当模型只有一个参数且只有一个训练样本时,在 1D 中会发生什么。

图片

图 2.18:SGD 在 1D 损失曲线上下降(一个可学习的参数)

我们可以直观地看出,选择一个合理的 learning_rate 因子值是很重要的。如果它太小,沿着曲线的下降将需要许多迭代,并且可能会陷入局部最小值。如果 learning_rate 太大,您的更新可能会将您带到曲线上的完全随机位置。

注意,mini-batch SGD 算法的一个变体是每次迭代抽取一个样本和目标,而不是抽取一批数据。这将被称为 真实 SGD(与 小批量 SGD 相对)。或者,走向相反的极端,您可以在所有可用数据上运行每个步骤,这被称为 批量梯度下降。这样,每次更新将更加准确,但成本也更高。在这两个极端之间的有效折衷方案是使用合理大小的小批量。

虽然图 2.18 说明了在 1D 参数空间中的梯度下降,但在实践中,你会在高维空间中使用梯度下降:神经网络中的每个权重系数都是空间中的一个自由维度,可能有成千上万个,甚至更多。为了帮助你建立对损失表面的直观理解,你还可以可视化沿二维损失表面的梯度下降,如图 2.19 所示。但你不可能可视化神经网络实际训练过程的样子——你不能以对人类有意义的方式表示一个一百万维度的空间。因此,值得注意的是,通过这些低维表示建立起来的直觉在实践上可能并不总是准确的。这在历史上一直是深度学习研究领域的问题来源。

图片

图 2.19:沿二维损失表面的梯度下降(两个可学习参数)

此外,还存在多种 SGD 变体,这些变体在计算下一个权重更新时考虑了之前的权重更新,而不仅仅是查看当前梯度的值。例如,有带有动量的 SGD,以及 Adagrad、RMSprop 和几个其他变体。这些变体被称为优化方法优化器。特别是,动量的概念,它在许多这些变体中使用,值得你的注意。动量解决了 SGD 的两个问题:收敛速度和局部最小值。考虑图 2.20,它显示了损失作为模型参数的函数的曲线。

图片

图 2.20:局部最小值和全局最小值

如你所见,在某个参数值附近,存在一个局部最小值:在该点附近,向左移动会导致损失增加,向右移动也是如此。如果考虑的参数通过具有小学习率的 SGD 进行优化,那么优化过程会卡在局部最小值上,而不是达到全局最小值。

你可以通过使用动量来避免这些问题,这从物理学中汲取了灵感。在这里的一个有用的心理图像是将优化过程想象成一个沿着损失曲线滚动的小球。如果它有足够的动量,球就不会卡在沟壑中,最终会达到全局最小值。动量通过在每个步骤中不仅基于当前斜率值(当前加速度)而且还基于当前速度(由过去的加速度产生)来移动球来实现。在实践中,这意味着不仅基于当前的梯度值,还基于之前的参数更新来更新参数w,例如在这个简单的实现中:

past_velocity = 0.0
# Constant momentum factor
momentum = 0.1
# Optimization loop
while loss > 0.01:
    w, loss, gradient = get_current_parameters()
    velocity = past_velocity * momentum - learning_rate * gradient
    w = w + momentum * velocity - learning_rate * gradient
    past_velocity = velocity
    update_parameter(w) 

连续求导:反向传播算法

在之前讨论的算法中,我们随意假设由于函数是可微的,我们可以轻松地计算其梯度。但这真的正确吗?我们如何在实践中计算复杂表达式的梯度?在我们的两层网络示例中,我们如何得到关于权重的损失梯度?这就是反向传播算法发挥作用的地方。

链式法则

反向传播是一种使用简单操作(如加法、relu或张量积)的导数来轻松计算这些原子操作任意复杂组合的梯度的方法。关键的是,神经网络由许多链式连接的张量操作组成,每个操作都有一个简单且已知的导数。例如,我们第一个示例中的模型可以表示为一个由变量W1b1W2b2(分别属于第一和第二Dense层)参数化的函数,涉及原子操作matmulrelusoftmax+,以及我们的损失函数loss,所有这些都很容易微分:

loss_value = loss(
    y_true,
    softmax(matmul(relu(matmul(inputs, W1) + b1), W2) + b2),
) 

微积分告诉我们,可以使用以下称为链式法则的恒等式推导出这样的函数链。考虑两个函数fg,以及复合函数fg,使得y = fg(x) == f(g(x))

def fg(x):
    x1 = g(x)
    y = f(x1)
    return y 

链式法则指出grad(y, x) == grad(y, x1) * grad(x1, x)。这使你能够在知道fg的导数的情况下计算fg的导数。链式法则之所以得名,是因为当你添加更多的中间函数时,它开始看起来像一条链:

def fghj(x):
    x1 = j(x)
    x2 = h(x1)
    x3 = g(x2)
    y = f(x3)
    return y

grad(y, x) == grad(y, x3) * grad(x3, x2) * grad(x2, x1) * grad(x1, x) 

将链式法则应用于计算神经网络梯度值会产生一个称为反向传播的算法。让我们具体看看它是如何工作的。

基于计算图的自动微分

考虑到反向传播的一个有用方法是将其视为计算图。计算图是深度学习革命的核心数据结构。它是一个有向无环图,表示操作——在我们的例子中,是张量操作。例如,图 2.21 是我们第一个模型的图表示。

图 2.21

图 2.21:我们两层模型的计算图表示

计算图在计算机科学中是一个非常成功的抽象,因为它们使我们能够将计算视为数据:一个可计算的表达式被编码为一种机器可读的数据结构,可以用作另一个程序的输入或输出。例如,你可以想象一个程序,它接收一个计算图并返回一个新的计算图,该图实现了相同计算的大规模分布式版本——这意味着你可以分发任何计算,而无需自己编写分发逻辑。或者想象一个程序,它接收一个计算图并可以自动生成它所表示的表达式的导数。如果你的计算以显式的图数据结构表达,而不是,比如说,.py文件中的 ASCII 字符行,那么做这些事情会容易得多。

为了清晰地解释反向传播,让我们来看一个计算图的简单示例。我们将考虑图 2.21 中的简化版本,其中只有一个线性层,并且所有变量都是标量,如图 2.22 所示。我们将考虑两个标量变量wb,一个标量输入x,并对它们应用一些操作以组合成一个输出y。最后,我们将应用一个绝对值误差损失函数:loss_val = abs(y_true - y)。由于我们希望以最小化loss_val的方式更新wb,因此我们感兴趣的是计算grad(loss_val, b)grad(loss_val, w)

图 2.23

图 2.22:计算图的基本示例

让我们在图中为“输入节点”设置具体的值——也就是说,输入x、目标y_truewb(图 2.23)。我们将从上到下传播这些值到图中的所有节点,直到我们达到loss_val。这就是正向传播

图 2.24

图 2.23:运行正向传播

现在,让我们“反转”图:对于图中从AB的每条边,我们将创建一个从BA的相反边,并询问,“当A变化时,B变化多少?”也就是说,grad(B, A)是多少?我们将用这个值标注每个反转边(图 2.24)。这个反向图代表了反向传播

图 2.23

图 2.24:运行反向传播

我们有

  • grad(loss_val, x2) = 1,因为当x2变化一个量ε时,loss_val = abs(4 - x2)也变化相同的量。

  • grad(x2, x1) = 1,因为当x1变化一个量ε时,x2 = x1 + b = x1 + 1也变化相同的量。

  • grad(x2, b) = 1,因为当b变化一个量ε时,x2 = x1 + b = 6 + b也变化相同的量。

  • grad(x1, w) = 2,因为当w变化一个量ε时,x1 = x * w = 2 * w变化2 * epsilon

链式法则关于这个反向图的说法是,你可以通过沿着连接两个节点的路径乘以每个边的导数来获得一个节点相对于另一个节点的导数。例如,grad(loss_val, w) = grad(loss_val, x2) * grad(x2, x1) * grad(x1, w)

图 2.25:反向图中从loss_valw的路径

通过将链式法则应用于我们的图,我们得到了我们想要的结果:

  • grad(loss_val, w) = 1 * 1 * 2 = 2

  • grad(loss_val, b) = 1 * 1 = 1

有了这些,你刚刚见证了反向传播的实际应用!反向传播仅仅是将链式法则应用于计算图。除此之外没有其他内容。反向传播从最终的损失值开始,从顶层到底层反向工作,计算每个参数在损失值中的贡献。这就是“反向传播”这个名字的由来:我们在计算图的节点中“反向传播”不同节点的损失贡献。

现在,人们使用能够进行自动微分的现代框架来实现神经网络,例如 JAX、TensorFlow 和 PyTorch。自动微分是通过之前提出的计算图实现的。自动微分使得能够检索任意可微张量操作的任意组合的梯度,而无需进行任何额外的工作,除了写下正向传递。当我 2000 年代用 C 语言编写我的第一个神经网络时,我不得不手动编写梯度。现在,多亏了现代自动微分工具,你永远不需要自己实现反向传播。觉得自己很幸运吧!

回顾我们的第一个例子

你即将结束本章的学习,现在你应该对神经网络背后的工作原理有一个大致的了解。在章节开始时,神经网络是一个神秘的黑色盒子,而现在它已经变成了一个更清晰的图景,如图 2.26 所示:由层组成的模型将输入数据映射到预测。损失函数随后将这些预测与目标进行比较,产生损失值:衡量模型预测与预期匹配程度的一个指标。优化器使用这个损失值来更新模型的权重。

图 2.26:网络、层、损失函数和优化器之间的关系

让我们回到第一个例子,并回顾一下你在前几节中学到的内容。

这是输入数据:

(train_images, train_labels), (test_images, test_labels) = mnist.load_data()
train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype("float32") / 255 

现在你已经理解了输入图像存储在 NumPy 张量中,这里格式化为形状为(60000, 784)float32张量(训练数据)和形状为(10000, 784)的张量(测试数据)。

这就是我们的模型:

model = keras.Sequential(
    [
        layers.Dense(512, activation="relu"),
        layers.Dense(10, activation="softmax"),
    ]
) 

现在你已经明白这个模型由两个 Dense 层组成,每个层都对输入数据应用一些简单的张量操作,并且这些操作涉及到权重张量。权重张量是层的属性,是模型知识的持久化所在。

这是模型编译步骤:

model.compile(
    optimizer="adam",
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
) 

现在你已经明白 "sparse_categorical_crossentropy" 是用于学习权重张量的损失函数,作为学习的反馈信号,训练阶段将尝试最小化它。你还知道这种损失减少是通过小批量随机梯度下降实现的。具体使用梯度下降的规则由作为第一个参数传递的 "adam" 优化器定义。

最后,这是训练循环:

model.fit(
    train_images,
    train_labels,
    epochs=5,
    batch_size=128,
) 

现在你已经明白当你调用 fit 时会发生什么:模型将开始以 128 个样本的小批量迭代训练数据,总共迭代 5 次(每次遍历所有训练数据称为一个 epoch)。对于每个批量,模型将计算损失相对于权重的梯度(使用反向传播算法,该算法源于微积分中的链式法则)并将权重移动到减少该批量损失值的方向。

经过这 5 个 epoch 后,模型将执行 2,345 次梯度更新(每个 epoch 469 次),并且模型的损失将足够低,以至于模型能够以高精度对手写数字进行分类。

到目前为止,你已经了解了关于神经网络的大部分知识。让我们通过逐步重新实现第一个示例的简化版本来证明这一点,只使用低级操作。

从头开始重新实现我们的第一个示例

什么比从头开始实现一切更能证明对知识的全面、明确理解呢?当然,“从头开始”在这里是相对的:我们不会重新实现基本的张量操作,也不会实现反向传播。但我们会深入到这样的低级,以至于每个计算步骤都会被明确地表示出来。

如果你现在还不完全理解这个例子中的每一个细节,不要担心。下一章将更详细地介绍 Keras API。现在,只需尽量理解正在发生的事情的大致情况——这个例子的目的是通过具体的实现帮助你巩固对深度学习数学的理解。让我们开始吧!

一个简单的 Dense 类

你之前已经了解到 Dense 层实现了以下输入转换,其中 Wb 是模型参数,而 activation 是逐元素函数(通常是 relu):

output = activation(matmul(input, W) + b) 

让我们实现一个简单的 Python 类 NaiveDense,它创建两个 Keras 变量 Wb,并公开一个 __call__() 方法,该方法应用之前的转换:

# keras.ops is where you will find all the tensor operations you need.
import keras
from keras import ops

class NaiveDense:
    def __init__(self, input_size, output_size, activation=None):
        self.activation = activation
        self.W = keras.Variable(
            # Creates a matrix W of shape (input_size, output_size),
            # initialized with random values drawn from a uniform
            # distribution
            shape=(input_size, output_size), initializer="uniform"
        )
        # Creates a vector b of shape (output_size,), initialized with
        # zeros
        self.b = keras.Variable(shape=(output_size,), initializer="zeros")

    # Applies the forward pass
    def __call__(self, inputs):
        x = ops.matmul(inputs, self.W)
        x = x + self.b
        if self.activation is not None:
            x = self.activation(x)
        return x

    @property
    # The convenience method for retrieving the layer's weights
    def weights(self):
        return [self.W, self.b] 

一个简单的 Sequential 类

现在,让我们创建一个NaiveSequential类来连接这些层。它包装了一个层列表,并暴露了一个__call__()方法,该方法简单地按顺序调用输入的底层层。它还提供了一个weights属性,以便轻松跟踪层的参数:

class NaiveSequential:
    def __init__(self, layers):
        self.layers = layers

    def __call__(self, inputs):
        x = inputs
        for layer in self.layers:
            x = layer(x)
        return x

    @property
    def weights(self):
        weights = []
        for layer in self.layers:
            weights += layer.weights
        return weights 

使用这个NaiveDense类和这个NaiveSequential类,我们可以创建一个模拟的 Keras 模型:

model = NaiveSequential(
    [
        NaiveDense(input_size=28 * 28, output_size=512, activation=ops.relu),
        NaiveDense(input_size=512, output_size=10, activation=ops.softmax),
    ]
)
assert len(model.weights) == 4 

批量生成器

接下来,我们需要一种方法来迭代 MNIST 数据的小批量。这很简单:

import math

class BatchGenerator:
    def __init__(self, images, labels, batch_size=128):
        assert len(images) == len(labels)
        self.index = 0
        self.images = images
        self.labels = labels
        self.batch_size = batch_size
        self.num_batches = math.ceil(len(images) / batch_size)

    def next(self):
        images = self.images[self.index : self.index + self.batch_size]
        labels = self.labels[self.index : self.index + self.batch_size]
        self.index += self.batch_size
        return images, labels 

运行一个训练步骤

过程中最困难的部分是“训练步骤”:在运行一个数据批次后更新模型的权重。我们需要

  • 计算模型对批次中图像的预测

  • 根据实际标签计算这些预测的损失值

  • 计算损失相对于模型权重的梯度

  • 将权重沿着与梯度相反的方向移动一小步

def one_training_step(model, images_batch, labels_batch):
    # Runs the "forward pass"
    predictions = model(images_batch)
    loss = ops.sparse_categorical_crossentropy(labels_batch, predictions)
    average_loss = ops.mean(loss)
    # Computes the gradient of the loss with regard to the weights. The
    # output, gradients, is a list where each entry corresponds to a
    # weight from the model.weights list. We haven't defined this
    # function yet!
    gradients = get_gradients_of_loss_wrt_weights(loss, model.weights)
    # Updates the weights using the gradients. We haven't defined this
    # function yet!
    update_weights(gradients, model.weights)
    return loss 

列表 2.9:训练的单个步骤

权重更新步骤

如你所知,“权重更新”步骤(由update_weights()函数表示)的目的是将权重“稍微”移动到一个方向,以减少这个批次的损失。移动的幅度由“学习率”决定,通常是一个很小的量。实现这个update_weights()函数的最简单方法是从每个权重中减去gradient * learning_rate

learning_rate = 1e-3

def update_weights(gradients, weights):
    for g, w in zip(gradients, weights):
        # Assigns a new value to the variable, in place
        w.assign(w - g * learning_rate) 

在实践中,你几乎永远不会手动实现这样的权重更新步骤。相反,你会使用 Keras 中的一个Optimizer实例——就像这样:

from keras import optimizers

optimizer = optimizers.SGD(learning_rate=1e-3)

def update_weights(gradients, weights):
    optimizer.apply_gradients(zip(gradients, weights)) 

梯度计算

现在,我们只是还缺少一件事情:梯度计算(由列表 2.9 中的get_gradients_of_loss_wrt_weights()函数表示)。在前一节中,我们概述了如何使用链式法则来获得函数链的梯度,给定它们的各个导数,这个过程称为反向传播。我们可以在这里从头开始重新实现反向传播,但这会很繁琐,尤其是我们正在使用softmax操作和交叉熵损失,它们的导数相当冗长。

相反,我们可以依赖 Keras 支持的低级框架之一内置的自动微分机制,例如 TensorFlow、JAX 或 PyTorch。为了示例的目的,让我们在这里使用 TensorFlow。你将在下一章中了解更多关于 TensorFlow、JAX 和 PyTorch 的信息。

你可以通过tf.GradientTape对象使用 TensorFlow 的自动微分功能。它是一个 Python 作用域,会“记录”在其中运行的张量操作,以计算图的形式(有时称为tape)。然后,这个图可以用来检索任何标量值相对于任何一组输入值的梯度:

import tensorflow as tf

# Instantiates a scalar tensor with value 0
x = tf.zeros(shape=())
# Opens a GradientTape scope
with tf.GradientTape() as tape:
    # Inside the scope, applies some tensor operations to our variable
    y = 2 * x + 3
# Uses the tape to retrieve the gradient of the output y with respect
# to our variable x
grad_of_y_wrt_x = tape.gradient(y, x) 

让我们使用 TensorFlow 的GradientTape重写我们的one_training_step()函数(跳过需要单独的get_gradients_of_loss_wrt_weights()函数):

def one_training_step(model, images_batch, labels_batch):
    with tf.GradientTape() as tape:
        predictions = model(images_batch)
        loss = ops.sparse_categorical_crossentropy(labels_batch, predictions)
        average_loss = ops.mean(loss)
    gradients = tape.gradient(average_loss, model.weights)
    update_weights(gradients, model.weights)
    return average_loss 

现在我们已经准备好了每个批次的训练步骤,我们可以继续实现整个训练周期的实现。

完整的训练循环

一个训练周期简单地由重复训练数据集中每个批次的训练步骤组成,完整的训练循环只是重复一个周期:

def fit(model, images, labels, epochs, batch_size=128):
    for epoch_counter in range(epochs):
        print(f"Epoch {epoch_counter}")
        batch_generator = BatchGenerator(images, labels)
        for batch_counter in range(batch_generator.num_batches):
            images_batch, labels_batch = batch_generator.next()
            loss = one_training_step(model, images_batch, labels_batch)
            if batch_counter % 100 == 0:
                print(f"loss at batch {batch_counter}: {loss:.2f}") 

让我们试驾一下:

from keras.datasets import mnist

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

train_images = train_images.reshape((60000, 28 * 28))
train_images = train_images.astype("float32") / 255
test_images = test_images.reshape((10000, 28 * 28))
test_images = test_images.astype("float32") / 255

fit(model, train_images, train_labels, epochs=10, batch_size=128) 

评估模型

我们可以通过对其测试图像的预测取argmax并与其期望的标签进行比较来评估模型:

>>> predictions = model(test_images)
>>> predicted_labels = ops.argmax(predictions, axis=1)
>>> matches = predicted_labels == test_labels
>>> f"accuracy: {ops.mean(matches):.2f}"
accuracy: 0.83

所有工作都完成了!正如你所见,手动完成你可以在几行 Keras 代码中完成的事情需要相当多的工作。但是因为你已经经历了这些步骤,你现在应该对当你调用fit()时神经网络内部发生的事情有一个清晰的理解。拥有这种低级别的心理模型,将使你更好地利用 Keras API 的高级功能。

摘要

  • 张量是现代机器学习系统的基础。它们有各种dtyperankshape的口味。

  • 你可以通过张量操作(如加法、张量积或逐元素乘法)来操作数值张量,这些操作可以解释为编码几何变换。在深度学习中,一切都可以用几何解释。

  • 深度学习模型由一系列简单的张量操作组成,这些操作由权重参数化,而权重本身也是张量。模型的重量是其“知识”存储的地方。

  • 学习意味着找到一组模型权重的值,这组值可以最小化给定一组训练数据样本及其对应的目标的损失函数

  • 学习是通过随机抽取数据样本及其目标,并计算模型参数相对于批次损失的梯度来发生的。然后,模型参数会朝着梯度的反方向移动一点(移动的大小由学习率定义)。这被称为小批量梯度下降

  • 整个学习过程之所以成为可能,是因为神经网络中的所有张量操作都是可微分的,因此可以应用导数的链式法则来找到将当前参数和当前批次数据映射到梯度值的梯度函数。这被称为反向传播

  • 在未来的章节中你将频繁看到的两个关键概念是损失优化器。在开始将数据输入模型之前,你需要定义这两者:

    • 损失是在训练过程中你将尝试最小化的量,因此它应该代表你试图解决的问题的成功度量。

    • 优化器指定了损失梯度的确切使用方式来更新参数:例如,它可以是 RMSProp 优化器、带有动量的 SGD,等等。

第三章:TensorFlow、PyTorch、JAX 和 Keras 简介

深度学习与 Python

本章旨在为你提供开始实际进行深度学习所需的一切。首先,你将熟悉三个可以与 Keras 一起使用的流行深度学习框架:

然后,在第二章中与 Keras 的第一次接触基础上,我们将回顾神经网络的核心组件以及它们如何转换为 Keras API。

到本章结束时,你将准备好进入实际、现实世界的应用——这些应用将从第四章开始。

深度学习框架简史

在现实世界中,你不会像我们在第二章末尾那样从头编写底层代码。相反,你将使用一个框架。除了 Keras 之外,今天主要的深度学习框架还有 JAX、TensorFlow 和 PyTorch。这本书将教你了解所有这四个框架。

如果你刚开始接触深度学习,可能会觉得所有这些框架似乎一直都在这里。实际上,它们都相当新,Keras 是其中最古老的(于 2015 年 3 月推出)。然而,这些框架背后的理念有着悠久的历史——关于自动微分的第一篇论文发表于 1964 年^([1])。

所有这些框架都结合了三个关键特性:

  • 计算任意可微分函数的梯度的一种方法(自动微分)

  • 在 CPU 和 GPU(甚至可能在其他专门的深度学习硬件)上运行张量计算的方法

  • 在多个设备或多台计算机上分布计算的方法,例如一台计算机上的多个 GPU,甚至多台不同计算机上的多个 GPU

这三个简单特性共同解锁了所有现代深度学习。

该领域花了很长时间才为这三个问题开发出稳健的解决方案,并将这些解决方案打包成可重用的形式。自从 20 世纪 60 年代诞生以来,直到 2000 年代,自动微分在机器学习中没有实际应用——与神经网络打交道的人只是手动编写自己的梯度逻辑,通常是在 C++这样的语言中。同时,GPU 编程几乎是不可能的。

事情在 2000 年代末开始慢慢发生变化。首先,Python 及其生态系统在科学界逐渐流行起来,逐渐取代了 MATLAB 和 C++。其次,NVIDIA 在 2006 年发布了 CUDA,解锁了在消费级 GPU 上构建神经网络的可能性。最初的 CUDA 重点是物理模拟而不是机器学习,但这并没有阻止机器学习研究人员从 2009 年开始实施基于 CUDA 的神经网络。这些通常是单次实现的,在单个 GPU 上运行,没有任何自动微分。

第一个使自动微分和 GPU 计算能够训练深度学习模型的框架是 Theano,大约在 2009 年左右。Theano 是所有现代深度学习工具的概念先驱。它在 2013-2014 年开始在机器学习研究社区中获得良好的势头,这得益于 ImageNet 2012 竞赛的结果,它引发了全世界对深度学习的兴趣。大约与此同时,一些其他支持 GPU 的深度学习库开始在计算机视觉领域流行起来——特别是基于 Lua 的 Torch 7 和基于 C++的 Caffe。Keras 于 2015 年初作为由 Theano 驱动的更高级、更易于使用的深度学习库推出,并迅速获得了当时对深度学习感兴趣的几千人的青睐。

然后在 2015 年底,谷歌推出了 TensorFlow,它从 Theano 中汲取了许多关键思想,并增加了对大规模分布式计算的支持。TensorFlow 的发布是一个分水岭事件,它推动了深度学习在主流开发者中的普及。Keras 立即增加了对 TensorFlow 的支持。到 2016 年中,超过一半的 TensorFlow 用户都是通过 Keras 来使用的。

作为对 TensorFlow 的回应,Meta(当时名为 Facebook)大约一年后推出了 PyTorch,它借鉴了 Chainer(一个于 2015 年中推出的小众但创新的框架,现在已经不复存在)和 NumPy-Autograd 的思想,NumPy-Autograd 是由 Maclaurin 等人于 2014 年发布的一个仅适用于 CPU 的自动微分库。与此同时,谷歌发布了 TPU 作为 GPU 的替代品,同时发布了 XLA,这是一个高性能编译器,旨在使 TensorFlow 能够在 TPU 上运行。

几年后,在谷歌,NumPy-Autograd 的开发者之一 Matthew Johnson 发布了 JAX,作为一种使用 XLA 进行自动微分的新方法。JAX 因其简约的 API 和高可扩展性而迅速受到研究人员的青睐。今天,Keras、TensorFlow、PyTorch 和 JAX 是深度学习领域的顶级框架。

回顾这段混乱的历史,我们可以问,接下来会怎样?明天会出现一个新的框架吗?我们会转向新的编程语言或新的硬件平台吗?

如果问我,今天有三件事是确定的:

  • Python 已经赢了。它的机器学习和数据科学生态系统目前势头强劲。不会有一种全新的语言来取代它——至少在接下来的 15 年内不会。

  • 我们处于一个多框架的世界——所有四个框架都得到了很好的建立,在接下来的几年内不太可能有所改变。了解每个框架的点点滴滴是个不错的选择。然而,未来很可能会有框架流行起来,除了它们之外;苹果最近发布的 MLX 可能就是这样一个例子。在这种情况下,使用 Keras 是一个相当大的优势:你应该能够通过新的 Keras 后端在任何新的新兴框架上运行你的现有 Keras 模型。Keras 将继续像自 2015 年以来那样为机器学习开发者提供未来保障的稳定性——那时 TensorFlow、PyTorch 和 JAX 都还不存在。

  • 未来可能会出现新的芯片,与 NVIDIA 的 GPU 和 Google 的 TPUs 并行。例如,AMD 的 GPU 系列可能前景光明。但任何新的此类芯片都必须与现有框架协同工作才能获得市场认可。新硬件不太可能破坏你的工作流程。

这些框架之间是如何相互关联的

Keras、TensorFlow、PyTorch 和 JAX 并不都具有相同的特性集,也不能互换使用。它们有一些重叠,但在很大程度上,它们针对不同的用例扮演着不同的角色。最大的区别在于 Keras 和另外三个框架。Keras 是一个高级框架,而其他的是低级框架。想象一下建造一栋房子。Keras 就像一个预制建筑套件:它提供了一个简化的界面来设置和训练神经网络。相比之下,TensorFlow、PyTorch 和 JAX 就像建筑中使用的原材料。

正如你在前几章中看到的,训练神经网络围绕以下概念展开:

  • 首先,低级张量操作——所有现代机器学习的基础设施。这对应于 TensorFlow、PyTorch^([2])和 JAX 中找到的低级 API:

    • 张量,包括存储网络状态的特殊张量(变量

    • 张量操作,例如加法、relumatmul

    • 反向传播,一种计算数学表达式梯度的方法

  • 其次,高级深度学习概念——这对应于 Keras API:

    • ,它们被组合成一个模型

    • 一个损失函数,它定义了用于学习的反馈信号

    • 一个优化器,它决定了学习过程如何进行

    • 指标用于评估模型性能,例如准确率

    • 一个执行小批量随机梯度下降的训练循环

此外,Keras 的独特之处在于它不是一个完全独立的框架。它需要一个 后端引擎 来运行,(见图 3.4),就像预制房屋建造套件需要从某处获取建筑材料一样。TensorFlow、PyTorch 和 JAX 都可以用作 Keras 后端。此外,Keras 还可以在 NumPy 上运行,但由于 NumPy 不提供梯度 API,因此基于 NumPy 的 Keras 工作流程仅限于从模型进行预测——训练是不可能的。

现在您已经对所有这些框架是如何产生以及它们之间如何相互关联有了更清晰的理解,让我们深入了解与它们一起工作的感觉。我们将按时间顺序介绍它们:首先是 TensorFlow,然后是 PyTorch,最后是 JAX。

TensorFlow 简介

TensorFlow 是一个由 Google 主要开发的基于 Python 的开源机器学习框架。它的首次发布是在 2015 年 11 月,随后在 2017 年 2 月发布了 v1 版本,在 2019 年 10 月发布了 v2 版本。TensorFlow 在整个行业的生产级机器学习应用中被广泛使用。

重要的是要记住,TensorFlow 不仅仅是一个库。它实际上是一个平台,拥有一个庞大的组件生态系统,其中一些由 Google 开发,一些由第三方开发。例如,有 TFX 用于工业级机器学习工作流程管理,TF-Serving 用于生产部署,TF Optimization Toolkit 用于模型量化修剪,以及 TFLite 和 MediaPipe 用于移动应用部署。

这些组件共同覆盖了非常广泛的使用案例,从尖端研究到大规模生产应用。

TensorFlow 的第一步

在接下来的段落中,您将熟悉 TensorFlow 的所有基础知识。我们将介绍以下关键概念:

  • 张量和变量

  • TensorFlow 中的数值运算

  • 使用 GradientTape 计算梯度

  • 通过即时编译使 TensorFlow 函数快速运行

然后,我们将通过一个端到端示例结束介绍:一个纯 TensorFlow 实现的线性回归。

让我们让那些张量流动起来。

TensorFlow 中的张量和变量

要在 TensorFlow 中做任何事情,我们都需要一些张量。您可以通过几种不同的方式创建它们。

常数张量

张量需要用一些初始值来创建,因此常见的创建张量的方式是通过 tf.ones(相当于 np.ones)和 tf.zeros(相当于 np.zeros)。您还可以使用 tf.constant 从 Python 或 NumPy 值创建张量。

>>> import tensorflow as tf
>>> # Equivalent to np.ones(shape=(2, 1))
>>> tf.ones(shape=(2, 1))
tf.Tensor([[1.], [1.]], shape=(2, 1), dtype=float32)
>>> # Equivalent to np.zeros(shape=(2, 1))
>>> tf.zeros(shape=(2, 1))
tf.Tensor([[0.], [0.]], shape=(2, 1), dtype=float32)
>>> # Equivalent to np.array([1, 2, 3], dtype="float32")
>>> tf.constant([1, 2, 3], dtype="float32")
tf.Tensor([1., 2., 3.], shape=(3,), dtype=float32)

列表 3.1:全为 1 或全为 0 的张量

随机张量

您还可以通过 tf.random 子模块(相当于 np.random 子模块)的方法之一创建填充随机值的张量。

>>> # Tensor of random values drawn from a normal distribution with
>>> # mean 0 and standard deviation 1\. Equivalent to
>>> # np.random.normal(size=(3, 1), loc=0., scale=1.).
>>> x = tf.random.normal(shape=(3, 1), mean=0., stddev=1.)
>>> print(x)
tf.Tensor(
[[-0.14208166]
 [-0.95319825]
 [ 1.1096532 ]], shape=(3, 1), dtype=float32)
>>> # Tensor of random values drawn from a uniform distribution between
>>> # 0 and 1\. Equivalent to np.random.uniform(size=(3, 1), low=0.,
>>> # high=1.).
>>> x = tf.random.uniform(shape=(3, 1), minval=0., maxval=1.)
>>> print(x)
tf.Tensor(
[[0.33779848]
 [0.06692922]
 [0.7749394 ]], shape=(3, 1), dtype=float32)

列表 3.2:随机张量

张量赋值和 Variable 类

NumPy 数组与 TensorFlow 张量之间一个显著的区别是 TensorFlow 张量不可赋值:它们是常量。例如,在 NumPy 中,你可以这样做。

import numpy as np

x = np.ones(shape=(2, 2))
x[0, 0] = 0.0 

列表 3.3:NumPy 数组可赋值

尝试在 TensorFlow 中做同样的事情:你会得到一个错误,EagerTensor object does not support item assignment

x = tf.ones(shape=(2, 2))
# This will fail, as a tensor isn't assignable.
x[0, 0] = 0.0 

列表 3.4:TensorFlow 张量不可赋值

要训练一个模型,我们需要更新其状态,这是一个张量集合。如果张量不可赋值,我们该如何操作呢?这就是变量的用武之地。tf.Variable是用于在 TensorFlow 中管理可修改状态的类。

要创建一个变量,你需要提供一个初始值,例如一个随机张量。

>>> v = tf.Variable(initial_value=tf.random.normal(shape=(3, 1)))
>>> print(v)
array([[-0.75133973],
       [-0.4872893 ],
       [ 1.6626885 ]], dtype=float32)>

列表 3.5:创建tf.Variable

变量的状态可以通过其assign方法进行修改。

>>> v.assign(tf.ones((3, 1)))
array([[1.],
       [1.],
       [1.]], dtype=float32)>

列表 3.6:将值赋给Variable

赋值也适用于系数的子集。

>>> v[0, 0].assign(3.)
array([[3.],
       [1.],
       [1.]], dtype=float32)>

列表 3.7:将值赋给Variable的子集

类似地,assign_addassign_sub+=-=的有效等效操作。

>>> v.assign_add(tf.ones((3, 1)))
array([[2.],
       [2.],
       [2.]], dtype=float32)>

列表 3.8:使用assign_add

张量运算:在 TensorFlow 中进行数学运算

就像 NumPy 一样,TensorFlow 提供了一大批张量运算来表示数学公式。以下是一些示例。

a = tf.ones((2, 2))
# Takes the square, same as np.square
b = tf.square(a)
# Takes the square root, same as np.sqrt
c = tf.sqrt(a)
# Adds two tensors (element-wise)
d = b + c
# Takes the product of two tensors (see chapter 2), same as np.matmul
e = tf.matmul(a, b)
# Concatenates a and b along axis 0, same as np.concatenate
f = tf.concat((a, b), axis=0) 

列表 3.9:TensorFlow 中的几个基本数学运算

这里是第二章中看到的Dense层的等效形式:

def dense(inputs, W, b):
    return tf.nn.relu(tf.matmul(inputs, W) + b) 

TensorFlow 中的梯度:再次审视 GradientTape API

到目前为止,TensorFlow 看起来很像 NumPy。但这里有一个 NumPy 做不到的事情:检索任何可微表达式相对于其任何输入的梯度。只需打开GradientTape作用域,对一或多个输入张量进行一些计算,然后检索相对于输入的结果的梯度。

input_var = tf.Variable(initial_value=3.0)
with tf.GradientTape() as tape:
    result = tf.square(input_var)
gradient = tape.gradient(result, input_var) 

列表 3.10:使用GradientTape

这通常用于检索模型损失相对于其权重的梯度:gradients = tape.gradient(loss, weights)

在第二章中,你看到了GradientTape在单个输入或输入列表上的工作方式,以及输入可以是标量或高维张量。

到目前为止,你只看到了tape.gradient()中的输入张量是 TensorFlow 变量的情况。实际上,这些输入可以是任何任意的张量。然而,默认情况下,只有可训练变量被跟踪。对于常量张量,你必须手动将其标记为被跟踪,通过在它上面调用tape.watch()

input_const = tf.constant(3.0)
with tf.GradientTape() as tape:
    tape.watch(input_const)
    result = tf.square(input_const)
gradient = tape.gradient(result, input_const) 

列表 3.11:使用GradientTape与常量张量输入

为什么?因为预先存储计算任何事物相对于任何事物的梯度所需的信息会非常昂贵。为了避免浪费资源,带子需要知道要观察什么。默认情况下,可训练变量会被观察,因为计算损失相对于一系列可训练变量的梯度是梯度带最常见的用法。

梯度带是一个强大的实用工具,甚至能够计算 二阶梯度——即梯度的梯度。例如,一个物体相对于时间的位置的梯度是那个物体的速度,而二阶梯度是它的加速度。

如果你测量一个下落的苹果在垂直轴上的位置随时间的变化,并发现它符合 position(time) = 4.9 * time ** 2,那么它的加速度是多少?让我们使用两个嵌套梯度带来找出答案。

time = tf.Variable(0.0)
with tf.GradientTape() as outer_tape:
    with tf.GradientTape() as inner_tape:
        position = 4.9 * time**2
    speed = inner_tape.gradient(position, time)
# We use the outer tape to compute the gradient of the gradient from
# the inner tape. Naturally, the answer is 4.9 * 2 = 9.8.
acceleration = outer_tape.gradient(speed, time) 

列表 3.12:使用嵌套梯度带计算二阶梯度

使用编译来加速 TensorFlow 函数

你迄今为止编写的所有 TensorFlow 代码都一直在“贪婪地”执行。这意味着操作在 Python 运行时一个接一个地执行,就像任何 Python 代码或 NumPy 代码一样。贪婪执行对于调试来说很棒,但它通常非常慢。通常,并行化某些计算或“融合”操作——将两个连续的操作,如 matmul 后跟 relu,替换为单个更有效的操作,这样做可以完成相同的事情而不产生中间输出。

这可以通过 编译 来实现。编译的一般想法是将你用 Python 编写的某些函数提取出来,自动将它们重写为更快、更高效的“编译程序”,然后从 Python 运行时调用该程序。

编译的主要好处是性能提升。但也有一个缺点:你写的代码不再是执行中的代码,这可能会让调试体验变得痛苦。只有在你已经使用 Python 运行时调试了你的代码之后,才打开编译功能。

你可以通过将 tf.function 装饰器包裹在 TensorFlow 函数上来应用编译,如下所示:

@tf.function
def dense(inputs, W, b):
    return tf.nn.relu(tf.matmul(inputs, W) + b) 

当你这样做时,对 dense() 的任何调用都将被替换为调用一个实现了函数更优化版本的编译程序。第一次调用该函数会花费更长的时间,因为 TensorFlow 将会编译你的代码。这只会发生一次——所有后续对该函数的调用都将很快。

TensorFlow 有两种编译模式:

  • 首先,默认的一种,我们称之为“图模式”。任何用 @tf.function 装饰的函数都在图模式下运行。

  • 第二种,使用 XLA 的编译,XLA 是一个用于机器学习的高性能编译器(它代表加速线性代数)。你可以通过指定 jit_compile=True 来打开它,如下所示:

@tf.function(jit_compile=True)
def dense(inputs, W, b):
    return tf.nn.relu(tf.matmul(inputs, W) + b) 

通常情况下,使用 XLA 编译函数会比图模式运行得更快——尽管第一次执行函数需要更多时间,因为编译器有更多工作要做。

一个端到端的示例:纯 TensorFlow 中的线性分类器

你已经了解了张量、变量和张量操作,也知道如何计算梯度。这足以构建任何基于梯度下降的 TensorFlow 机器学习模型。让我们通过一个端到端的示例来确保一切都很清晰。

在机器学习面试中,你可能会被要求从头实现一个线性分类器:一个非常简单的任务,作为具有一些基本机器学习背景的候选人和没有背景的候选人之间的过滤器。让我们帮助你通过这个过滤器,并使用你新获得的 TensorFlow 知识来实现这样的线性分类器。

首先,让我们想出一些很好的线性可分合成数据来工作:2D 平面上的两类点。

import numpy as np

num_samples_per_class = 1000
negative_samples = np.random.multivariate_normal(
    # Generates the first class of points: 1,000 random 2D points with
    # specified "mean" and "covariance matrix." Intuitively, the
    # "covariance matrix" describes the shape of the point cloud, and
    # the "mean" describes its position in the plane. `cov=[[1,
    # 0.5],[0.5, 1]]` corresponds to "an oval-like point cloud oriented
    # from bottom left to top right."
    mean=[0, 3], cov=[[1, 0.5], [0.5, 1]], size=num_samples_per_class
)
positive_samples = np.random.multivariate_normal(
    # Generates the other class of points with a different mean and the
    # same covariance matrix (point cloud with a different position and
    # the same shape)
    mean=[3, 0], cov=[[1, 0.5], [0.5, 1]], size=num_samples_per_class
) 

列表 3.13:在 2D 平面上生成两个类别的随机点

negative_samplespositive_samples 都是形状为 (1000, 2) 的数组。让我们将它们堆叠成一个形状为 (2000, 2) 的单个数组。

inputs = np.vstack((negative_samples, positive_samples)).astype(np.float32) 

列表 3.14:将两个类别堆叠成一个形状为 (2000, 2) 的数组

让我们生成相应的目标标签,一个形状为 (2000, 1) 的 0 和 1 的数组,其中 targets[i, 0] 为 0 如果 inputs[i] 属于类别 0(反之亦然)。

targets = np.vstack(
    (
        np.zeros((num_samples_per_class, 1), dtype="float32"),
        np.ones((num_samples_per_class, 1), dtype="float32"),
    )
) 

列表 3.15:生成相应的目标(0 和 1)

让我们使用 Matplotlib,一个著名的 Python 数据可视化库(在 Colab 中预先安装,因此你不需要自己安装它)来绘制我们的数据,如图 3.1 所示。

import matplotlib.pyplot as plt

plt.scatter(inputs[:, 0], inputs[:, 1], c=targets[:, 0])
plt.show() 

列表 3.16:绘制两个点类

图片

图 3.1:我们的合成数据:2D 平面上的两类随机点

现在,让我们创建一个线性分类器,它可以学会分离这两个团块。线性分类器是一种仿射变换(prediction = matmul(input, W) + b),经过训练以最小化预测值与目标之间的差的平方。

如你所见,这实际上比第二章末尾的玩具双层神经网络端到端示例要简单得多。然而,这次,你应该能够逐行理解代码中的每一部分。

让我们创建变量 Wb,分别用随机值和零初始化。

# The inputs will be 2D points.
input_dim = 2
# The output predictions will be a single score per sample (close to 0
# if the sample is predicted to be in class 0, and close to 1 if the
# sample is predicted to be in class 1).
output_dim = 1
W = tf.Variable(initial_value=tf.random.uniform(shape=(input_dim, output_dim)))
b = tf.Variable(initial_value=tf.zeros(shape=(output_dim,))) 

列表 3.17:创建线性分类器变量

这是我们的前向传递函数。

def model(inputs, W, b):
    return tf.matmul(inputs, W) + b 

列表 3.18:前向传递函数

由于我们的线性分类器在 2D 输入上操作,W实际上只是两个标量系数:W = [[w1], [w2]]。同时,b是一个单一的标量系数。因此,对于给定的输入点[x, y],其预测值是prediction = [[w1], [w2]] • [x, y] + b = w1 * x + w2 * y + b

这里是我们的损失函数。

def mean_squared_error(targets, predictions):
    # per_sample_losses will be a tensor with the same shape as targets
    # and predictions, containing per-sample loss scores.
    per_sample_losses = tf.square(targets - predictions)
    # We need to average these per-sample loss scores into a single
    # scalar loss value: reduce_mean does this.
    return tf.reduce_mean(per_sample_losses) 

列表 3.19:均方误差损失函数

现在,我们转向训练步骤,它接收一些训练数据并更新权重Wb以最小化数据上的损失。

learning_rate = 0.1

# Wraps the function in a tf.function decorator to speed it up
@tf.function(jit_compile=True)
def training_step(inputs, targets, W, b):
    # Forward pass, inside of a gradient tape scope
    with tf.GradientTape() as tape:
        predictions = model(inputs, W, b)
        loss = mean_squared_error(predictions, targets)
    # Retrieves the gradient of the loss with regard to weights
    grad_loss_wrt_W, grad_loss_wrt_b = tape.gradient(loss, [W, b])
    # Updates the weights
    W.assign_sub(grad_loss_wrt_W * learning_rate)
    b.assign_sub(grad_loss_wrt_b * learning_rate)
    return loss 

列表 3.20:训练步骤函数

为了简化,我们将进行批量训练而不是小批量训练:我们将对整个数据集运行每个训练步骤(梯度计算和权重更新),而不是在小的数据批次上迭代。一方面,这意味着每个训练步骤将需要更长的时间来运行,因为我们一次计算 2,000 个样本的前向传递和梯度。另一方面,每个梯度更新将更有效地减少训练数据上的损失,因为它将包含所有训练样本的信息,而不是,比如说,只有 128 个随机样本。因此,我们将需要更少的训练步骤,并且我们应该使用比典型的小批量训练更大的学习率(我们将使用learning_rate = 0.1,如之前定义)。

for step in range(40):
    loss = training_step(inputs, targets, W, b)
    print(f"Loss at step {step}: {loss:.4f}") 

列表 3.21:批量训练循环

经过 40 步后,训练损失似乎稳定在 0.025 左右。让我们绘制我们的线性模型如何分类训练数据点,如图 3.2 所示。因为我们的目标是 0 和 1,所以给定输入点如果其预测值低于 0.5,则被分类为“0”,如果高于 0.5,则被分类为“1”:

predictions = model(inputs, W, b)
plt.scatter(inputs[:, 0], inputs[:, 1], c=predictions[:, 0] > 0.5)
plt.show() 

图像

图 3.2:我们的模型在训练输入上的预测:与训练目标非常相似

回想一下,给定点[x, y]的预测值仅仅是prediction == [[w1], [w2]] • [x, y] + b == w1 * x + w2 * y + b。因此,类别“0”定义为w1 * x + w2 * y + b < 0.5,类别“1”定义为w1 * x + w2 * y + b > 0.5。你会注意到你所看到的是二维平面上线的方程:w1 * x + w2 * y + b = 0.5。类别 1 在直线之上;类别 0 在直线之下。你可能习惯于看到格式为y = a * x + b的线方程;以同样的格式,我们的线变为y = - w1 / w2 * x + (0.5 - b) / w2

让我们绘制这条线,如图 3.3 所示:

# Generates 100 regularly spaced numbers between -1 and 4, which we
# will use to plot our line
x = np.linspace(-1, 4, 100)
# This is our line's equation.
y = -W[0] / W[1] * x + (0.5 - b) / W[1]
# Plots our line (`"-r"` means "plot it as a red line")
plt.plot(x, y, "-r")
# Plots our model's predictions on the same plot
plt.scatter(inputs[:, 0], inputs[:, 1], c=predictions[:, 0] > 0.5) 

图像

图 3.3:我们的模型,以线的形式可视化

这正是线性分类器的本质:找到一条线(或在更高维空间中的超平面)的参数,以整洁的方式将两类数据分开。

TensorFlow 方法独特之处

现在,你已经熟悉了所有支撑 TensorFlow 工作流程的基本 API,你即将深入探索更多框架——特别是 PyTorch 和 JAX。与使用其他任何框架相比,使用 TensorFlow 有何不同?你应该在什么情况下使用 TensorFlow,以及在什么情况下可以使用其他工具?

如果你们问我们,以下是 TensorFlow 的主要优点:

  • 多亏了图模式和 XLA 编译,它运行得很快。它通常比 PyTorch 和 NumPy 快得多,尽管 JAX 有时甚至更快。

  • 它的功能非常全面。在所有框架中独一无二,它支持字符串张量以及“不规则张量”(不同条目可能具有不同维度的张量——在不需要将它们填充到共享长度的情况下处理序列非常有用)。它还通过高性能的 tf.data API 提供出色的数据预处理支持。tf.data 非常出色,以至于 JAX 也推荐它用于数据预处理。无论你需要做什么,TensorFlow 都有相应的解决方案。

  • 它的生产部署生态系统在所有框架中最为成熟,尤其是在移动或浏览器部署方面。

然而,TensorFlow 也有一些明显的缺点:

  • 它有一个庞大的 API——这是功能全面的一个副作用。TensorFlow 包含了数千种不同的操作。

  • 它的数值 API 有时与 NumPy API 不一致,如果你已经熟悉 NumPy,那么它可能会让你觉得有点难以接近。

  • 流行的预训练模型共享平台 Hugging Face 对 TensorFlow 的支持较少,这意味着最新的生成式 AI 模型可能并不总是可在 TensorFlow 中找到。

现在,让我们转向 PyTorch。

PyTorch 简介

PyTorch 是由 Meta(前身为 Facebook)主要开发的一个基于 Python 的开源机器学习框架。它最初于 2016 年 9 月发布(作为对 TensorFlow 发布的回应),其 1.0 版本于 2018 年推出,其 2.0 版本于 2023 年推出。PyTorch 从现在已停用的 Chainer 框架继承了其编程风格,而 Chainer 框架本身又受到了 NumPy-Autograd 的启发。PyTorch 在机器学习研究社区中得到广泛应用。

与 TensorFlow 类似,PyTorch 也处于一个庞大的相关包生态系统中心,例如 torchvisiontorchaudio 或流行的模型共享平台 Hugging Face。

PyTorch API 比 TensorFlow 和 JAX 的 API 更高级:它包括层和优化器,就像 Keras 一样。当你使用 PyTorch 后端与 Keras 一起使用时,这些层和优化器与 Keras 工作流程兼容。

PyTorch 的第一步

在接下来的段落中,你将熟悉 PyTorch 的所有基础知识。我们将涵盖以下关键概念:

  • 张量和参数

  • PyTorch 中的数值操作

  • 使用 backward() 方法计算梯度

  • 使用 Module 类包装计算

  • 通过编译加速 PyTorch

我们将通过重新实现我们的端到端线性回归示例来结束对 PyTorch 的介绍。

PyTorch 中的张量和参数

PyTorch 的第一个问题在于,这个包的名字不是 pytorch。实际上,它的名字是 torch。你可以通过 pip install torch 来安装它,并通过 import torch 来导入它。

就像在 NumPy 和 TensorFlow 中一样,框架的核心对象是张量。首先,让我们来获取一些 PyTorch 张量。

常数张量

这里有一些常数张量。

>>> import torch
>>> # Unlike in other frameworks, the shape argument is named "size"
>>> # rather than "shape."
>>> torch.ones(size=(2, 1))
tensor([[1.], [1.]])
>>> torch.zeros(size=(2, 1))
tensor([[0.], [0.]])
>>> # Unlike in other frameworks, you cannot pass dtype="float32" as a
>>> # string. The dtype argument must be a torch dtype instance.
>>> torch.tensor([1, 2, 3], dtype=torch.float32)
tensor([1., 2., 3.])

列表 3.22:全一或全零张量

随机张量

随机张量创建类似于 NumPy 和 TensorFlow,但语法有所不同。考虑函数 normal:它不接受形状参数。相反,均值和标准差应该作为具有预期输出形状的 PyTorch 张量提供。

>>> # Equivalent to tf.random.normal(shape=(3, 1), mean=0., stddev=1.)
>>> torch.normal(
... mean=torch.zeros(size=(3, 1)),
... std=torch.ones(size=(3, 1)))
tensor([[-0.9613],
        [-2.0169],
        [ 0.2088]])

列表 3.23:随机张量

至于创建随机均匀张量,你可以通过 torch.rand 来完成。与 np.random.uniformtf.random.uniform 不同,输出形状应该作为每个维度的独立参数提供,如下所示:

>>> # Equivalent to tf.random.uniform(shape=(3, 1), minval=0.,
>>> # maxval=1.)
>>> torch.rand(3, 1)
张量分配和参数类

与 NumPy 数组类似,但与 TensorFlow 张量不同,PyTorch 张量是可以分配的。你可以进行如下操作:

>>> x = torch.zeros(size=(2, 1))
>>> x[0, 0] = 1.
>>> x
tensor([[1.],
        [0.]])

虽然你可以只用一个普通的 torch.Tensor 来存储模型的可训练状态,但 PyTorch 确实提供了一个专门的张量子类来达到这个目的,即 torch.nn.parameter.Parameter 类。与普通张量相比,它提供了语义清晰性——如果你看到一个 Parameter,你就会知道它是一块可训练状态,而一个 Tensor 可能是任何东西。因此,它使得 PyTorch 能够自动跟踪和检索分配给 PyTorch 模型的 Parameters——类似于 Keras 对 Keras Variable 实例所做的那样。

这里是一个 Parameter

>>> x = torch.zeros(size=(2, 1))
>>> # A Parameter can only be created using a torch.Tensor value — no
>>> # NumPy arrays allowed.
>>> p = torch.nn.parameter.Parameter(data=x)

列表 3.24:创建 PyTorch 参数

张量运算:在 PyTorch 中进行数学运算

PyTorch 中的数学与 NumPy 或 TensorFlow 中的数学相同,尽管与 TensorFlow 类似,PyTorch API 在细微之处经常与 NumPy API 有所不同。

a = torch.ones((2, 2))
# Takes the square, same as np.square
b = torch.square(a)
# Takes the square root, same as np.sqrt
c = torch.sqrt(a)
# Adds two tensors (element-wise)
d = b + c
# Takes the product of two tensors (see chapter 2), same as np.matmul
e = torch.matmul(a, b)
# Concatenates a and b along axis 0, same as np.concatenate
f = torch.cat((a, b), dim=0) 

列表 3.25:PyTorch 中的一些基本数学运算

这里是一个密集层:

def dense(inputs, W, b):
    return torch.nn.relu(torch.matmul(inputs, W) + b) 

使用 PyTorch 计算梯度

PyTorch 中没有显式的“梯度带”。类似的机制确实存在:当你运行 PyTorch 中的任何计算时,框架会创建一个一次性计算图(一个“带子”),记录刚刚发生的事情。然而,这个带子对用户来说是隐藏的。使用它的公共 API 在张量本身级别:你可以调用 tensor.backward() 来运行所有先前执行的操作的反向传播,这些操作导致了那个张量。这样做将会填充所有跟踪梯度的张量的 .grad 属性。

>>> # To compute gradients with respect to a tensor, it must be created
>>> # with requires_grad=True.
>>> input_var = torch.tensor(3.0, requires_grad=True)
>>> result = torch.square(input_var)
>>> # Calling backward() populates the "grad" attribute on all tensors
>>> # create with requires_grad=True.
>>> result.backward()
>>> gradient = input_var.grad
>>> gradient
tensor(6.)

列表 3.26:使用 .backward() 计算梯度

如果你连续多次调用backward().grad属性将“累积”梯度:每次新的调用都会将新的梯度与现有的梯度相加。例如,在下面的代码中,input_var.grad不是square(input_var)相对于input_var的梯度;而是该梯度与之前计算的梯度的总和——它的值自从我们上一个代码片段以来已经翻倍了:

>>> result = torch.square(input_var)
>>> result.backward()
>>> # .grad will sum all gradient values from each time backward() is
>>> # called.
>>> input_var.grad
tensor(12.)

要重置梯度,你只需将.grad设置为None

>>> input_var.grad = None

现在让我们将其付诸实践!

一个端到端的例子:纯 PyTorch 中的线性分类器

你现在知道足够多的知识来用 PyTorch 重写我们的线性分类器。它将非常类似于 TensorFlow 版本——唯一的重大区别是我们如何计算梯度。

让我们先创建我们的模型变量。别忘了传递requires_grad=True,这样我们就可以计算相对于它们的梯度:

input_dim = 2
output_dim = 1

W = torch.rand(input_dim, output_dim, requires_grad=True)
b = torch.zeros(output_dim, requires_grad=True) 

这是我们的模型——到目前为止没有区别。我们只是从tf.matmul切换到torch.matmul

def model(inputs, W, b):
    return torch.matmul(inputs, W) + b 

这是我们的损失函数。我们只是从tf.square切换到torch.square,从tf.reduce_mean切换到torch.mean

def mean_squared_error(targets, predictions):
    per_sample_losses = torch.square(targets - predictions)
    return torch.mean(per_sample_losses) 

现在是训练步骤。这是它的工作方式:

  1. loss.backward()loss输出节点开始运行反向传播,并在所有参与loss计算的张量上填充tensor.grad属性。tensor.grad表示相对于该张量的损失梯度。

  2. 我们使用.grad属性来恢复相对于Wb的损失梯度。

  3. 我们使用这些梯度来更新Wb。因为这些更新不是反向传播的一部分,所以我们在一个torch.no_grad()作用域内进行,它会跳过其中所有内容的梯度计算。

  4. 我们通过将其设置为None来重置Wb参数的.grad属性的内容。如果我们不这样做,梯度值将在多次调用training_step()时累积,导致无效值:

learning_rate = 0.1

def training_step(inputs, targets, W, b):
    # Forward pass
    predictions = model(inputs)
    loss = mean_squared_error(targets, predictions)
    # Computes gradients
    loss.backward()
    # Retrieves gradients
    grad_loss_wrt_W, grad_loss_wrt_b = W.grad, b.grad
    with torch.no_grad():
        # Updates weights inside a no_grad scope
        W -= grad_loss_wrt_W * learning_rate
        b -= grad_loss_wrt_b * learning_rate
    # Resets gradients
    W.grad = None
    b.grad = None
    return loss 

这可以变得更简单——让我们看看如何。

使用 Module 类打包状态和计算

PyTorch 还有一个用于执行反向传播的高级、面向对象的 API,它需要依赖于两个新类:torch.nn.Module类和来自torch.optim模块的优化器类,例如torch.optim.SGD(与keras.optimizers.SGD等价)。

通用思路是定义torch.nn.Module的一个子类,它将

  • 保持一些Parameters,以存储状态变量。这些在__init__()方法中定义。

  • forward()方法中实现前向传播计算。

它应该看起来就像以下这样。

class LinearModel(torch.nn.Module):
    def __init__(self):
        super().__init__()
        self.W = torch.nn.Parameter(torch.rand(input_dim, output_dim))
        self.b = torch.nn.Parameter(torch.zeros(output_dim))

    def forward(self, inputs):
        return torch.matmul(inputs, self.W) + self.b 

列表 3.27:定义torch.nn.Module

我们现在可以实例化我们的LinearModel

model = LinearModel() 

当使用torch.nn.Module的一个实例时,而不是直接调用forward()方法,你会使用__call__()(即直接在输入上调用模型类),这会重定向到forward()但会向其中添加一些框架钩子:

torch_inputs = torch.tensor(inputs)
output = model(torch_inputs) 

现在,让我们动手使用一个 PyTorch 优化器。为了实例化它,你需要提供优化器打算更新的参数列表。你可以通过我们的 Module 实例使用 .parameters() 方法来获取它:

optimizer = torch.optim.SGD(model.parameters(), lr=learning_rate) 

使用我们的 Module 实例和 PyTorch 的 SGD 优化器,我们可以运行一个简化的训练步骤:

def training_step(inputs, targets):
    predictions = model(inputs)
    loss = mean_squared_error(targets, predictions)
    loss.backward()
    optimizer.step()
    model.zero_grad()
    return loss 

之前,更新模型参数看起来是这样的:

with torch.no_grad():
    W -= grad_loss_wrt_W * learning_rate
    b -= grad_loss_wrt_b * learning_rate 

现在,我们只需执行 optimizer.step()

同样,之前我们需要手动通过在每个参数上执行 tensor.grad = None 来重置参数梯度。现在我们只需执行 model.zero_grad()

总体来说,这可能会感觉有点困惑——某种隐藏的背景机制似乎让损失张量、优化器和 Module 实例都相互了解。它们都是通过超距作用相互作用的。不过别担心——你只需将这个步骤序列(loss.backward() - optimizer.step() - model.zero_grad())视为一个在任何需要编写训练步骤函数时都可以念诵的咒语。只需确保不要忘记 model.zero_grad()。那将是一个严重的错误(而且遗憾的是,这种情况相当普遍)!

使用编译来加速 PyTorch 模块

最后一点。与 TensorFlow 允许你编译函数以获得更好的性能类似,PyTorch 允许你通过 torch.compile() 工具编译函数或甚至 Module 实例。此 API 使用 PyTorch 自己的编译器,名为 Dynamo。

让我们在我们的线性回归 Module 上试一试:

compiled_model = torch.compile(model) 

生成的对象旨在与原始对象工作方式相同——除了前向和反向传递应该运行得更快。

你还可以将 torch.compile() 用作函数装饰器:

@torch.compile
def dense(inputs, W, b):
    return torch.nn.relu(torch.matmul(inputs, W) + b) 

实际上,大多数 PyTorch 代码并不使用编译,而是简单地 eager 执行,因为编译器可能并不总是与所有模型兼容,并且当它工作时,可能不会总是导致速度提升。与 TensorFlow 和 Jax 中的编译从库的诞生之初就内置不同,PyTorch 的编译器是一个相对较新的功能。

那么,PyTorch 方法独特之处在哪里

与我们接下来要介绍的 TensorFlow 和 JAX 相比,什么让 PyTorch 独树一帜?为什么你应该使用它或不应使用它?

这里是 PyTorch 的两个关键优势:

  • PyTorch 代码默认是 eager 执行的,这使得调试变得容易。请注意,这也是 TensorFlow 代码和 JAX 代码的情况,但一个很大的区别是,PyTorch 通常旨在始终 eager 执行,而任何严肃的 TensorFlow 或 JAX 项目最终都需要在某些时候进行编译,这可能会严重影响调试体验。

  • 流行的预训练模型共享平台 Hugging Face 对 PyTorch 提供了一级支持,这意味着你想要使用的任何模型很可能都可用在 PyTorch 中。这是 PyTorch 当前采用的主要驱动力。

同时,使用 PyTorch 也有一些缺点:

  • 与 TensorFlow 一样,PyTorch API 与 NumPy 不一致。此外,它内部也不一致。例如,常用的关键字axis有时会被命名为dim,这取决于函数。一些伪随机数生成操作需要一个seed参数;而另一些则不需要。等等。这可能会让 PyTorch 的学习变得令人沮丧,尤其是对于来自 NumPy 的用户。

  • 由于其专注于即时执行,PyTorch 相当慢——它是所有主要框架中最慢的,差距很大。对于大多数模型,您可能会看到使用 JAX 可以提升 20%或 30%的速度。对于某些模型——尤其是大型模型——即使使用了torch.compile(),您也可能看到 3 倍或 5 倍的速度提升。

  • 虽然可以通过torch.compile()使 PyTorch 代码更快,但 PyTorch Dynamo 编译器在 2025 年仍然相当无效,充满了陷阱。因此,只有极少数的 PyTorch 用户使用编译。也许在未来的版本中会有所改进!

JAX 简介

JAX 是一个开源的微分计算库,主要由谷歌开发。自 2018 年发布以来,JAX 迅速在研究社区中获得认可,尤其是其能够大规模使用谷歌 TPU 的能力。如今,JAX 被生成 AI 领域的多数顶级玩家所使用,例如 DeepMind、苹果、Midjourney、Anthropic、Cohere 等公司。

JAX 采用了一种无状态的计算方法,这意味着 JAX 中的函数不维护任何持久状态。这与传统的命令式编程形成对比,在命令式编程中,变量可以在函数调用之间保持值。

JAX 函数的无状态特性有几个优点。特别是,它使得有效的自动并行化和分布式计算成为可能,因为函数可以在不需要同步的情况下独立执行。JAX 的极端可扩展性对于处理像谷歌和 DeepMind 这样的公司面临的非常大规模的机器学习问题至关重要。

JAX 的入门步骤

我们将介绍以下关键概念:

  • array

  • JAX 中的随机操作

  • JAX 中的数值操作

  • 通过jax.gradjax.value_and_grad计算梯度

  • 通过即时编译来提高 JAX 函数的速度

让我们开始吧。

JAX 中的张量

JAX 最优秀的特性之一是它不试图实现自己的独立、类似 NumPy 但略有差异的数值 API。相反,它只是实现了 NumPy API,正如其名。它作为jax.numpy命名空间提供,您通常会将其导入为jnp

这里有一些 JAX 数组。

>>> from jax import numpy as jnp
>>> jnp.ones(shape=(2, 1))
Array([[1.],
       [1.]], dtype=float32)
>>> jnp.zeros(shape=(2, 1))
Array([[0.],
       [0.]], dtype=float32)
>>> jnp.array([1, 2, 3], dtype="float32")
Array([1., 2., 3.], dtype=float32)

列表 3.28:全 1 或全 0 的张量

然而,jax.numpy和实际的 NumPy API 之间有两个细微的差异:随机数生成和数组赋值。让我们来看看。

JAX 中的随机数生成

JAX 与 NumPy 之间的第一个区别与 JAX 处理随机操作的方式有关——这被称为“PRNG”(伪随机数生成)操作。我们之前提到 JAX 是无状态的,这意味着 JAX 代码不能依赖于任何隐藏的全局状态。考虑以下 NumPy 代码。

>>> np.random.normal(size=(3,))
array([-1.68856166,  0.16489586,  0.67707523])
>>> np.random.normal(size=(3,))
array([-0.73671259,  0.3053194 ,  0.84124895])

列表 3.29:随机张量

第二次调用np.random.normal()是如何知道返回与第一次调用不同的值的?没错——这是一个隐藏的全局状态的一部分。实际上,你可以通过np.random.get_state()检索该全局状态,并通过np.random.seed(seed)设置它。

在无状态框架中,我们不能有任何这样的全局状态。相同的 API 调用必须始终返回相同的值。因此,在无状态版本的 NumPy 中,你必须依赖于向你的np.random调用传递不同的种子参数来获取不同的值。

现在,你的 PRNG 调用通常会在被多次调用的函数中进行,并且每次调用都打算使用不同的随机值。如果你不想依赖于任何全局状态,这要求你在目标函数之外管理你的种子状态,如下所示:

def apply_noise(x, seed):
    np.random.seed(seed)
    x = x * np.random.normal((3,))
    return x

seed = 1337
y = apply_noise(x, seed)
seed += 1
z = apply_noise(x, seed) 

在 JAX 中基本上是相同的。然而,JAX 不使用整数种子。它使用特殊的数组结构,称为keys。你可以从整数值创建一个,如下所示:

import jax

seed_key = jax.random.key(1337) 

为了强制你始终为 PRNG 调用提供种子“键”,所有使用 JAX PRNG 的操作都将key(随机种子)作为它们的第一个位置参数。以下是使用random.normal()的方法:

>>> seed_key = jax.random.key(0)
>>> jax.random.normal(seed_key, shape=(3,))
Array([ 1.8160863 , -0.48262316,  0.33988908], dtype=float32)

接收相同种子键的两次random.normal()调用将始终返回相同的值。

>>> seed_key = jax.random.key(123)
>>> jax.random.normal(seed_key, shape=(3,))
Array([-0.1470326,  0.5524756,  1.648498 ], dtype=float32)
>>> jax.random.normal(seed_key, shape=(3,))
Array([-0.1470326,  0.5524756,  1.648498 ], dtype=float32)

列表 3.30:在 Jax 中使用随机种子

如果你需要一个新种子键,你可以简单地使用jax.random.split()函数从一个现有的种子创建一个新的种子。它是确定性的,因此相同的分割序列将始终产生相同的最终种子键:

>>> seed_key = jax.random.key(123)
>>> jax.random.normal(seed_key, shape=(3,))
Array([-0.1470326,  0.5524756,  1.648498 ], dtype=float32)
>>> # You could even split your key into multiple new keys at once!
>>> new_seed_key = jax.random.split(seed_key, num=1)[0]
>>> jax.random.normal(new_seed_key, shape=(3,))
Array([ 0.5362355, -1.1920372,  2.450225 ], dtype=float32)

这肯定比np.random要复杂得多!但无状态的好处远远超过了成本:它使你的代码可向量化(即,JAX 编译器可以自动将其转换为高度并行的代码)同时保持确定性(即,你可以两次运行相同的代码并得到相同的结果)。使用全局 PRNG 状态是无法实现这一点的。

张量赋值

JAX 与 NumPy 之间的第二个区别是张量赋值。就像在 TensorFlow 中一样,JAX 数组是不可直接赋值的。这是因为任何形式的就地修改都会违反 JAX 的无状态设计。相反,如果你需要更新一个张量,你必须创建一个新的张量,其具有所需值。JAX 通过提供at()/set() API 来简化这一点。这些方法允许你在特定索引处创建一个新的张量,其元素已更新。以下是如何更新 JAX 数组第一个元素的示例。

>>> x = jnp.array([1, 2, 3], dtype="float32")
>>> new_x = x.at[0].set(10)

列表 3.31:在 JAX 数组中修改值

简单明了!

张量操作:在 JAX 中进行数学运算

在 JAX 中进行数学运算看起来与在 NumPy 中完全一样。这次不需要学习任何新东西!

a = jnp.ones((2, 2))
# Takes the square
b = jnp.square(a)
# Takes the square root
c = jnp.sqrt(a)
# Adds two tensors (element-wise)
d = b + c
# Takes the product of two tensors (see chapter 2)
e = jnp.matmul(a, b)
# Multiplies two tensors (element-wise)
e *= d 

列表 3.32:JAX 中的几个基本数学运算

这里有一个密集层:

def dense(inputs, W, b):
    return jax.nn.relu(jnp.matmul(inputs, W) + b) 

使用 JAX 计算梯度

与 TensorFlow 和 PyTorch 不同,JAX 采用 元编程 方法进行梯度计算。元编程指的是拥有 返回函数的函数 的想法——你可以称它们为“元函数”。在实践中,JAX 允许你 将损失计算函数转换为梯度计算函数。因此,在 JAX 中计算梯度是一个三步过程:

  1. 定义一个损失函数,compute_loss()

  2. 调用 grad_fn = jax.grad(compute_loss) 来检索一个梯度计算函数。

  3. 调用 grad_fn 来检索梯度值。

损失函数应满足以下属性:

  • 它应该返回一个标量损失值。

  • 其第一个参数(在以下示例中,这也是唯一的参数)应包含我们需要计算梯度的状态数组。这个参数通常命名为 state。例如,这个第一个参数可以是一个数组、一个数组列表,或者一个数组字典。

让我们看看一个简单的例子。这是一个损失计算函数,它接受一个单个标量 input_var 并返回一个标量损失值——只是输入的平方:

def compute_loss(input_var):
    return jnp.square(input_var) 

我们现在可以在这个损失函数上调用 JAX 的实用工具 jax.grad()。它返回一个梯度计算函数——一个接受与原始损失函数相同参数的函数,并返回相对于 input_var 的损失梯度:

grad_fn = jax.grad(compute_loss) 

一旦你获得了 grad_fn(),你可以用与 compute_loss() 相同的参数调用它,它将返回与 compute_loss() 的第一个参数相对应的梯度数组。在我们的例子中,我们的第一个参数是一个单个数组,所以 grad_fn() 直接返回相对于该数组的损失梯度:

input_var = jnp.array(3.0)
grad_of_loss_wrt_input_var = grad_fn(input_var) 

JAX 梯度计算最佳实践

到目前为止一切顺利!元编程是一个大词,但事实证明它相当简单。现在,在现实世界的用例中,你还需要考虑一些其他的事情。让我们看看。

返回损失值

通常,你不仅需要梯度数组;还需要损失值。在 grad_fn() 之外独立重新计算它将非常低效,因此,你可以配置你的 grad_fn() 以返回损失值。这是通过使用 JAX 实用工具 jax.value_and_grad() 而不是 jax.grad() 来实现的。它的工作方式相同,但它返回一个值的元组,其中第一个值是损失值,第二个值是梯度(s):

grad_fn = jax.value_and_grad(compute_loss)
output, grad_of_loss_wrt_input_var = grad_fn(input_var) 
为复杂函数获取梯度

现在,如果你需要计算多个变量的梯度怎么办?如果你的 compute_loss() 函数有多个输入怎么办?

假设你的状态包含三个变量,abc,而你的损失函数有两个输入,xy。你只需这样构建它:

# state contains a, b, and c. It must be the first argument.
def compute_loss(state, x, y):
    ...
    return loss

grad_fn = jax.value_and_grad(compute_loss)
state = (a, b, c)
# grads_of_loss_wrt_state has the same structure as state.
loss, grads_of_loss_wrt_state = grad_fn(state, x, y) 

注意,state 不一定是元组——它可以是字典、列表或任何嵌套的元组、字典和列表结构。在 JAX 术语中,这样的嵌套结构被称为

返回辅助输出

最后,如果你的 compute_loss() 函数需要返回不仅仅是损失呢?比如说,你想要返回一个作为损失计算副产品的额外值 output。如何将其获取出来?

你会使用 has_aux 参数:

  1. 编辑损失函数,使其返回一个元组,其中第一个元素是损失,第二个元素是你的额外输出。

  2. has_aux=True 参数传递给 value_and_grad()。这告诉 value_and_grad() 不仅返回梯度,还返回 compute_loss() 的“辅助”输出(输出),如下所示:

def compute_loss(state, x, y):
    ...
    # Returns a tuple
    return loss, output

# Passes has_aux=True here
grad_fn = jax.value_and_grad(compute_loss, has_aux=True)
# Gets back a nested tuple
loss, (grads_of_loss_wrt_state, output) = grad_fn(state, x, y) 

诚然,到了这一点,事情开始变得相当复杂。不过,别担心;这几乎是 JAX 最难的部分!与其他大部分内容相比,这几乎简单多了。

使用 @jax.jit 使 JAX 函数快速

还有一件事。作为一个 JAX 用户,你将经常使用 @jax.jit 装饰器,它与 @tf.function(jit_compile=True) 装饰器行为相同。它将任何无状态的 JAX 函数转换为 XLA 编译的代码片段,通常能带来显著的执行速度提升:

@jax.jit
def dense(inputs, W, b):
    return jax.nn.relu(jnp.matmul(inputs, W) + b) 

请注意,你只能装饰无状态函数——任何被函数更新的张量都应该是其返回值的一部分。

一个端到端示例:纯 JAX 中的线性分类器

现在你已经了解了足够的 JAX 知识,可以编写我们线性分类器的 JAX 版本。与 TensorFlow 和 PyTorch 版本相比,有两个主要区别:

  • 我们将创建的所有函数都将是无状态的。这意味着状态(数组 Wb)将作为函数参数提供,如果它们被函数修改,函数将返回它们的新的值。

  • 梯度是通过 JAX 的 value_and_grad() 工具计算的。

让我们开始吧。模型函数和均方误差函数应该看起来很熟悉:

def model(inputs, W, b):
    return jnp.matmul(inputs, W) + b

def mean_squared_error(targets, predictions):
    per_sample_losses = jnp.square(targets - predictions)
    return jnp.mean(per_sample_losses) 

为了计算梯度,我们需要将损失计算包装在单个 compute_loss() 函数中。它返回一个标量作为总损失,并接受 state 作为其第一个参数——我们需要计算梯度的所有张量的元组:

def compute_loss(state, inputs, targets):
    W, b = state
    predictions = model(inputs, W, b)
    loss = mean_squared_error(targets, predictions)
    return loss 

在这个函数上调用 jax.value_and_grad() 给我们一个新的函数,具有与 compute_loss 相同的参数,它返回损失和相对于 state 元素的损失梯度:

grad_fn = jax.value_and_grad(compute_loss) 

接下来,我们可以设置我们的训练步骤函数。它看起来很简单。请注意,与 TensorFlow 和 PyTorch 的等效函数不同,它必须是状态无关的,因此它必须返回 Wb 张量的更新值:

learning_rate = 0.1

# We use the jax.jit decorator to take advantage of XLA compilation.
@jax.jit
def training_step(inputs, targets, W, b):
    # Computes the forward pass and backward pass in one go
    loss, grads = grad_fn((W, b), inputs, targets)
    grad_wrt_W, grad_wrt_b = grads
    # Updates W and b
    W = W - grad_wrt_W * learning_rate
    b = b - grad_wrt_b * learning_rate
    # Make sure to return the new values of W and b in addition to the
    # loss!
    return loss, W, b 

因为在我们的例子中我们不会改变 learning_rate,所以我们可以将其视为函数本身的一部分,而不是我们模型的状态。如果我们想在训练过程中修改学习率,我们也需要将其传递过去。

最后,我们准备运行完整的训练循环。我们初始化Wb,然后通过无状态的training_step()调用反复更新它们:

input_dim = 2
output_dim = 1

W = jax.numpy.array(np.random.uniform(size=(input_dim, output_dim)))
b = jax.numpy.array(np.zeros(shape=(output_dim,)))
state = (W, b)
for step in range(40):
    loss, W, b = training_step(inputs, targets, W, b)
    print(f"Loss at step {step}: {loss:.4f}") 

就这些!你现在可以编写自定义的 JAX 训练循环了。

JAX 方法独特之处在于

在现代机器学习框架中,使 JAX 独特的最主要因素是其函数式、无状态的哲学。虽然一开始可能会觉得这会引起一些摩擦,但它正是解锁 JAX 力量的关键——它能够编译成极快的代码,并且能够扩展到任意大的模型和任意多的设备。

JAX 有很多优点:

  • 它很快。对于大多数模型来说,它是你迄今为止见过的所有框架中最快的。

  • 它的数值 API 与 NumPy 完全一致,这使得学习起来很愉快。

  • 这是最适合在 TPU 上训练模型的,因为它从头开始就是为 XLA 和 TPU 开发的。

使用 JAX 也可能带来一些开发者的摩擦:

  • 与纯即时执行相比,它的元编程和编译使用可能会使其调试变得显著困难。

  • 与 TensorFlow 或 PyTorch 相比,低级训练循环通常更冗长,更难编写。

到目前为止,你已经了解了 TensorFlow、PyTorch 和 JAX 的基础知识,并且可以使用这些框架从头开始实现一个基本的线性分类器。这是一个坚实的基石,现在我们可以转向更有效的深度学习路径:Keras API。

Keras 简介

Keras 是一个用于 Python 的深度学习 API,它提供了一种方便的方式来定义和训练任何类型的深度学习模型。它于 2015 年 3 月发布,2017 年发布了 v2 版本,2023 年发布了 v3 版本。

Keras 的用户包括学术研究人员、初创公司和大型公司的工程师和数据科学家、研究生和爱好者。Keras 被用于 Google、Netflix、Uber、YouTube、CERN、NASA、Yelp、Instacart、Square、Waymo、YouTube 以及成千上万的在各个行业中解决各种问题的较小组织。你的 YouTube 推荐来自 Keras 模型。Waymo 的自动驾驶汽车依赖于 Keras 模型来处理传感器数据。Keras 也是 Kaggle 机器学习竞赛网站上的一个流行框架。

由于 Keras 拥有多样化的用户群体,它不会强迫你遵循单一的“正确”的构建和训练模型的方式。相反,它允许广泛的不同工作流程,从非常高级到非常低级,对应不同的用户画像。例如,你有多种构建模型的方法和多种训练它们的方法,每种方法都代表了一定程度上的可用性和灵活性的权衡。在第七章中,我们将详细回顾这部分工作流程的很大一部分。

Keras 的入门步骤

在我们编写 Keras 代码之前,在导入库之前有一些事情需要考虑。

选择后端框架

Keras 可以与 JAX、TensorFlow 或 PyTorch 一起使用。它们是 Keras 的“后端框架”。通过这些后端框架,Keras 可以在不同的硬件类型上运行(参见图 3.4)——GPU、TPU 或普通 CPU——可以无缝扩展到数千台机器,并且可以部署到各种平台。

图片

图 3.4:Keras 及其后端。后端是一个低级张量计算平台;Keras 是一个高级深度学习 API。

后端框架是可插拔的:在你编写了一些 Keras 代码之后,你可以切换到不同的后端框架。你不会被锁定在单个框架和单个生态系统中——你可以根据当前需求将你的模型从 JAX 切换到 TensorFlow 或 PyTorch。例如,当你开发一个 Keras 模型时,你可以用 PyTorch 调试它,用 JAX 在 TPU 上进行训练以获得最大效率,最后使用 TensorFlow 生态系统中的优秀工具进行推理。

目前 Keras 的默认后端是 TensorFlow,所以如果你在一个全新的环境中运行 import keras 而没有进行任何配置,你将运行在 TensorFlow 上。有两种方法可以选择不同的后端:

  • 设置环境变量 KERAS_BACKEND。在你开始 python repl 之前,你可以运行以下 shell 命令来使用 JAX 作为你的 Keras 后端:export KERAS_BACKEND=jax。或者,你可以在你的 Python 文件或笔记本的顶部添加以下代码片段(请注意,它必须强制性地放在第一个 import keras 之前):
import os

# Sets the environment variable from within the Python runtime
os.environ["KERAS_BACKEND"] = "jax"

# Only then should you import Keras.
import keras 
  • ~/.keras/keras.json 位置编辑你的本地 Keras 配置文件。如果你已经导入过一次 Keras,这个文件已经根据默认设置创建好了。你可以使用任何文本编辑器打开和修改它——它是一个可读的 JSON 文件。它应该看起来像这样:
{
    # Default floating-point precision. It should typically not be
    # changed.
    "floatx": "float32",
    # Default numerical fuzzing factor. It should typically not be
    # changed.
    "epsilon": 1e-07,
    # Change "tensorflow" to "jax" or "torch."
    "backend": "tensorflow",
    # This is the default image layout. We'll talk about this in
    # chapter 8.
    "image_data_format": "channels_last",
} 

现在,你可能想知道,我应该选择哪个后端?这完全取决于你自己的选择:本书中所有其余部分的 Keras 代码示例都将与所有三个后端兼容。如果需要特定后端的代码(例如在第七章中),我会展示三个版本——TensorFlow、PyTorch 和 JAX。如果你没有特别的后端偏好,我个人的推荐是 JAX。它通常是性能最好的后端。

一旦你的后端配置完成,你就可以开始实际构建和训练 Keras 模型了。让我们看看。

层:深度学习的构建块

神经网络中的基本数据结构是 ,这在第二章中已经向你介绍了。层是一个数据处理模块,它接受一个或多个张量作为输入,并输出一个或多个张量。有些层是无状态的,但更常见的是层有状态:层的 权重,这些是通过随机梯度下降学习的一个或多个张量,它们共同包含了网络的 知识

不同的层类型适用于不同的张量格式和不同类型的数据处理。例如,简单的向量数据,存储在形状为(samples, features)的 2D 张量中,通常由密集连接层处理,也称为全连接密集层(Keras 中的Dense类)。序列数据,存储在形状为(samples, timesteps, features)的 3D 张量中,通常由循环层处理,例如LSTM层,或者 1D 卷积层(Conv1D)。图像数据,存储在四阶张量中,通常由 2D 卷积层(Conv2D)处理。

你可以将层视为深度学习的乐高积木,这是一个由 Keras 明确提出的比喻。在 Keras 中构建深度学习模型是通过将兼容的层拼接在一起来形成有用的数据转换管道。

Keras 中的基本Layer

一个简单的 API 应该围绕一个单一的抽象来构建,Keras 中那就是Layer类。Keras 中的所有内容要么是Layer,要么是与Layer紧密交互的东西。

Layer是一个封装了一些状态(权重)和一些计算(前向传递)的对象。权重通常在build()中定义(尽管它们也可以在构造函数__init__()中创建),计算在call()方法中定义。

在上一章中,我们实现了一个NaiveDense类,它包含两个权重Wb,并应用了计算output = activation(matmul(input, W) + b)。以下是在 Keras 中相同层的样子。

import keras

# All Keras layers inherit from the base Layer class.
class SimpleDense(keras.Layer):
    def __init__(self, units, activation=None):
        super().__init__()
        self.units = units
        self.activation = activation

    # Weight creation takes place in the build() method.
    def build(self, input_shape):
        batch_dim, input_dim = input_shape
        # add_weight is a shortcut method for creating weights. It's
        # also possible to create standalone variables and assign them
        # as layer attributes, like self.W = keras.Variable(shape=...,
        # initializer=...).
        self.W = self.add_weight(
            shape=(input_dim, self.units), initializer="random_normal"
        )
        self.b = self.add_weight(shape=(self.units,), initializer="zeros")

    # We define the forward pass computation in the call() method.
    def call(self, inputs):
        y = keras.ops.matmul(inputs, self.W) + self.b
        if self.activation is not None:
            y = self.activation(y)
        return y 

列表 3.33:Keras 中从头开始创建一个简单的密集层

在下一节中,我们将详细介绍这些build()call()方法的目的。如果你现在还不完全理解,请不要担心!

一旦实例化,这样的层就可以像函数一样使用,接受一个张量作为输入:

>>> # Instantiates our layer, defined previously
>>> my_dense = SimpleDense(units=32, activation=keras.ops.relu)
>>> # Creates some test inputs
>>> input_tensor = keras.ops.ones(shape=(2, 784))
>>> # Calls the layer on the inputs, just like a function
>>> output_tensor = my_dense(input_tensor)
>>> print(output_tensor.shape)
(2, 32)

现在,你可能想知道,为什么我们必须实现call()build(),因为我们最终是通过直接调用它,即使用它的__call__方法来使用我们的层的?这是因为我们希望能够在需要时创建状态。让我们看看它是如何工作的。

自动形状推断:动态构建层

就像乐高积木一样,你只能“拼接”兼容的层。这里的层兼容性概念具体指的是每个层将只接受特定形状的张量作为输入,并返回特定形状的张量作为输出。考虑以下示例:

from keras import layers

# A dense layer with 32 output units
layer = layers.Dense(32, activation="relu") 

这个层将返回一个非批量维度为 32 的张量。它只能连接到一个期望 32 维向量作为输入的下游层。

当使用 Keras 时,你大多数时候不必担心尺寸兼容性,因为添加到你的模型中的层会动态构建以匹配输入数据的形状。例如,假设你编写了以下代码:

from keras import models
from keras import layers

model = models.Sequential(
    [
        layers.Dense(32, activation="relu"),
        layers.Dense(32),
    ]
) 

层没有接收到任何关于其输入形状的信息。相反,它们自动推断它们的输入形状为它们看到的第一个输入的形状。

在第二章中我们实现的Dense层的玩具版本中,我们必须显式地将层的输入大小传递给构造函数,以便能够创建其权重。这并不理想,因为它会导致看起来像这样的模型,其中每个新的层都需要知道它前面的层的形状:

model = NaiveSequential(
    [
        NaiveDense(input_size=784, output_size=32, activation="relu"),
        NaiveDense(input_size=32, output_size=64, activation="relu"),
        NaiveDense(input_size=64, output_size=32, activation="relu"),
        NaiveDense(input_size=32, output_size=10, activation="softmax"),
    ]
) 

当一个层用来产生其输出形状的规则复杂时,情况会更糟。例如,如果我们的层返回形状为(batch, input_size * 2 if input_size % 2 == 0 else input_size * 3)的输出呢?

如果我们要重新实现我们的NaiveDense层为一个 Keras 层,使其能够自动推断形状,它将看起来像SimpleDense层,具有它的build()call()方法。

在 Keras 的SimpleDense中,我们不再像上一个例子那样在构造函数中创建权重。相反,我们在一个专门的状态创建方法build()中创建它们,该方法接收层看到的第一个输入形状作为参数。build()方法在层第一次被调用时自动调用(通过其__call__()方法)。事实上,这就是为什么我们定义了计算在单独的call()方法中而不是直接在__call__()方法中的原因!基类层的__call__()方法示意图如下:

def __call__(self, inputs):
    if not self.built:
        self.build(inputs.shape)
        self.built = True
    return self.call(inputs) 

使用自动形状推断,我们之前的例子变得简单而整洁:

model = keras.Sequential(
    [
        SimpleDense(32, activation="relu"),
        SimpleDense(64, activation="relu"),
        SimpleDense(32, activation="relu"),
        SimpleDense(10, activation="softmax"),
    ]
) 

注意,自动形状推断不是Layer类的__call__()方法处理的唯一事情。它还负责许多其他事情,特别是急切执行之间的路由,以及输入掩码(我们将在第十四章中介绍)。现在,只需记住:当你实现自己的层时,将前向传递放在call()方法中。

从层到模型

深度学习模型是层的图。在 Keras 中,这是Model类。到目前为止,你只看到了Sequential模型(Model的子类),它们是简单的层堆叠,将单个输入映射到单个输出。但随着你的前进,你将接触到更广泛的各种网络拓扑。一些常见的是

  • 双分支网络

  • 多头网络

  • 残差连接

网络拓扑可能相当复杂。例如,图 3.5 显示了 Transformer 层图的拓扑,这是一种常见的用于处理文本数据的架构。

图 3.5:Transformer 架构。这里有很多内容。在接下来的几章中,你将逐步了解它(在第十五章)。

在 Keras 中通常有两种构建此类模型的方法:你可以直接子类化Model类,或者你可以使用功能 API,这让你可以用更少的代码做更多的事情。我们将在第七章中介绍这两种方法。

模型的拓扑结构定义了一个假设空间。你可能记得,在第一章中,我们将机器学习描述为“在预定义的可能性空间内,使用反馈信号进行指导,寻找某些输入数据的有效表示。”通过选择网络拓扑,你将你的可能性空间(假设空间)限制为一系列特定的张量操作,将输入数据映射到输出数据。你接下来要寻找的是这些张量操作中涉及的权重张量的一组良好值。

为了从数据中学习,你必须对其做出假设。这些假设定义了可以学习的内容。因此,你的假设空间的结构——你的模型架构——非常重要。它编码了你关于问题的假设,模型开始时的先验知识。例如,如果你正在处理一个由单个没有激活(纯仿射变换)的 Dense 层组成的两分类问题,你是在假设你的两个类别是线性可分的。

选择正确的网络架构与其说是科学,不如说是艺术,尽管有一些最佳实践和原则你可以依赖,但只有实践才能帮助你成为一名合格的神经网络架构师。接下来的几章将既教你构建神经网络的明确原则,又帮助你培养对特定问题有效或无效的直觉。你将建立起对不同类型问题的模型架构类型、如何在实践中构建这些网络、如何选择正确的学习配置以及如何调整模型直到它产生你想要的结果的稳固直觉。

“编译”步骤:配置学习过程

一旦定义了模型架构,你仍然需要选择另外三件事:

  • 损失函数(目标函数)——在训练过程中将被最小化的量。它代表了当前任务的成功度量。

  • 优化器——根据损失函数确定网络如何更新。它实现了一种特定的随机梯度下降(SGD)变体。

  • 指标——你希望在训练和验证过程中监控的成功度量,例如分类准确率。与损失不同,训练不会直接优化这些指标。因此,指标不需要可微。

一旦你选择了损失、优化器和指标,你就可以使用内置的 compile()fit() 方法开始训练你的模型。或者,你也可以编写自己的自定义训练循环——我们将在第七章中介绍如何做这件事。这要复杂得多!现在,让我们来看看 compile()fit()

compile() 方法配置训练过程——你已经在第二章的第一个神经网络示例中对其有所了解。它接受 optimizerlossmetrics(一个列表)作为参数:

# Defines a linear classifier
model = keras.Sequential([keras.layers.Dense(1)])
model.compile(
    # Specifies the optimizer by name: RMSprop (it's case-insensitive)
    optimizer="rmsprop",
    # Specifies the loss by name: mean squared error
    loss="mean_squared_error",
    # Specifies a list of metrics: in this case, only accuracy
    metrics=["accuracy"],
) 

在之前的compile()调用中,我们以字符串的形式传递了优化器、损失和度量标准(例如"rmsprop")。实际上,这些字符串是快捷方式,会被转换为 Python 对象。例如,"rmsprop"变成了keras.optimizers.RMSprop()。重要的是,你也可以将这些参数指定为对象实例,如下所示:

model.compile(
    optimizer=keras.optimizers.RMSprop(),
    loss=keras.losses.MeanSquaredError(),
    metrics=[keras.metrics.BinaryAccuracy()],
) 

如果你想要传递你自己的自定义损失或度量标准,或者如果你想进一步配置你正在使用的对象——例如,通过传递learning_rate参数给优化器:

model.compile(
    optimizer=keras.optimizers.RMSprop(learning_rate=1e-4),
    loss=my_custom_loss,
    metrics=[my_custom_metric_1, my_custom_metric_2],
) 

在第七章中,我们介绍了如何创建自定义损失和度量标准。一般来说,你不需要从头开始创建自己的损失、度量标准或优化器,因为 Keras 提供了一系列内置选项,很可能包含你所需要的内容:

  • 优化器

    • SGD()(带或不带动量)

    • RMSprop()

    • Adam()

    • 等等。

  • 损失函数

    • CategoricalCrossentropy()

    • SparseCategoricalCrossentropy()

    • BinaryCrossentropy()

    • MeanSquaredError()

    • KLDivergence()

    • CosineSimilarity()

    • 等等。

  • 度量标准

    • CategoricalAccuracy()

    • SparseCategoricalAccuracy()

    • BinaryAccuracy()

    • AUC()

    • Precision()

    • Recall()

    • 等等。

在本书中,你会看到许多这些选项的具体应用。

选择损失函数

为正确的问题选择正确的损失函数非常重要:你的网络将采取任何可能的捷径来最小化损失。所以如果目标与当前任务的成功不完全相关,你的网络最终会做你可能不希望的事情。想象一下,一个通过 SGD 训练的愚蠢、全能的 AI,它选择了这个糟糕的目标函数:“最大化所有活着的人的平均幸福。”为了使它的任务更容易,这个 AI 可能会选择杀死除少数人之外的所有人,并专注于剩余人的幸福,因为平均幸福不受剩下多少人影响。这可能不是你想要的!记住,你构建的所有神经网络在降低损失函数方面都会同样无情,所以请明智地选择目标,否则你将不得不面对意想不到的副作用。

幸运的是,当涉及到分类、回归和序列预测等常见问题时,你可以遵循一些简单的指南来选择正确的损失函数。例如,对于二分类问题,你会使用二元交叉熵,对于多分类问题,你会使用分类交叉熵,等等。只有在你真正从事新的研究问题时,你才需要开发自己的损失函数。在接下来的几章中,我们将详细说明为各种常见任务选择哪些损失函数。

理解 fit 方法

compile()之后是fit()fit方法实现了训练循环本身。它的关键参数包括

  • 用于训练的数据(输入和目标)。它通常以 NumPy 数组或 TensorFlow Dataset对象的形式传递。你将在下一章中了解更多关于Dataset API 的信息。

  • 训练的epoch数量:训练循环应该遍历数据的次数。

  • 在每个 epoch 中用于迷你批梯度下降的批大小:用于计算一次权重更新步骤的梯度所需的训练样本数量。

history = model.fit(
    # The input examples, as a NumPy array
    inputs,
    # The corresponding training targets, as a NumPy array
    targets,
    # The training loop will iterate over the data 5 times.
    epochs=5,
    # The training loop will iterate over the data in batches of 128
    # examples.
    batch_size=128,
) 

列表 3.34:使用 NumPy 数据调用fit

fit的调用返回一个History对象。该对象包含一个history字段,它是一个字典,将键(如"loss"或特定的指标名称)映射到它们每个 epoch 的值列表:

>>> history.history
{"binary_accuracy": [0.855, 0.9565, 0.9555, 0.95, 0.951],
 "loss": [0.6573270302042366,
  0.07434618508815766,
  0.07687718723714351,
  0.07412414988875389,
  0.07617757616937161]}

监控验证数据上的损失和指标

机器学习的目标不是获得在训练数据上表现良好的模型,这很容易——你只需要遵循梯度。目标是获得在一般情况下表现良好的模型,尤其是在模型之前从未遇到过的数据点上。仅仅因为模型在训练数据上表现良好,并不意味着它在从未见过的数据上也会表现良好!例如,你的模型可能最终只是记忆了训练样本与其目标之间的映射,这对于预测模型之前从未见过的数据的目标将是无用的。我们将在第五章中更详细地讨论这一点。

为了监控模型在新数据上的表现,通常的做法是保留训练数据的一个子集作为“验证数据”:你不会在这个数据上训练模型,但你会用它来计算损失值和指标值。这是通过在fit()中使用validation_data参数来完成的。与训练数据一样,验证数据可以以 NumPy 数组或 TensorFlow Dataset对象的形式传递。

model = keras.Sequential([keras.layers.Dense(1)])
model.compile(
    optimizer=keras.optimizers.RMSprop(learning_rate=0.1),
    loss=keras.losses.MeanSquaredError(),
    metrics=[keras.metrics.BinaryAccuracy()],
)

# To avoid having samples from only one class in the validation data,
# shuffles the inputs and targets using a random indices permutation
indices_permutation = np.random.permutation(len(inputs))
shuffled_inputs = inputs[indices_permutation]
shuffled_targets = targets[indices_permutation]

# Reserves 30% of the training inputs and targets for "validation."
# (We'll exclude these samples from training and reserve them to
# compute the "validation loss" and metrics).
num_validation_samples = int(0.3 * len(inputs))
val_inputs = shuffled_inputs[:num_validation_samples]
val_targets = shuffled_targets[:num_validation_samples]
training_inputs = shuffled_inputs[num_validation_samples:]
training_targets = shuffled_targets[num_validation_samples:]
model.fit(
    # Training data, used to update the weights of the model
    training_inputs,
    training_targets,
    epochs=5,
    batch_size=16,
    # Validation data, used only to monitor the "validation loss" and
    # metrics
    validation_data=(val_inputs, val_targets),
) 

列表 3.35:使用验证数据参数

验证数据上的损失值被称为验证损失,以区别于训练损失。请注意,保持训练数据和验证数据严格分离是至关重要的:验证的目的是监控模型所学习的内容是否真正适用于新数据。如果在训练过程中模型已经看到了任何验证数据,你的验证损失和指标将会是错误的。

如果你想在训练完成后计算验证损失和指标,可以调用evaluate方法:

loss_and_metrics = model.evaluate(val_inputs, val_targets, batch_size=128)

evaluate()将按批次(大小为batch_size)遍历传递的数据,并返回一个标量列表,其中第一个条目是验证损失,后面的条目是验证指标。如果没有指标,则只返回验证损失(而不是列表)。

推理:训练后使用模型

一旦你训练好你的模型,你将想要用它来对新数据进行预测。这被称为推理。为此,一个简单的方法就是直接__call__模型:

# Takes a NumPy array or a tensor for your current backend and returns
# a tensor for your current backend
predictions = model(new_inputs) 

然而,这会一次性处理new_inputs中的所有输入,如果你正在查看大量数据(特别是,可能需要比你的 GPU 更多的内存),这可能不可行。

进行推理的更好方法是使用predict()方法。它将遍历数据的小批量,并返回一个包含预测的 NumPy 数组。而且与__call__不同,它还可以处理 TensorFlow Dataset对象:

# Takes a NumPy array or a Dataset and returns a NumPy array
predictions = model.predict(new_inputs, batch_size=128) 

例如,如果我们使用predict()对之前训练的线性模型的一些验证数据进行预测,我们得到的是与模型对每个输入样本预测相对应的标量分数:

>>> predictions = model.predict(val_inputs, batch_size=128)
>>> print(predictions[:10])
[[0.3590725 ]
 [0.82706255]
 [0.74428225]
 [0.682058  ]
 [0.7312616 ]
 [0.6059811 ]
 [0.78046083]
 [0.025846  ]
 [0.16594526]
 [0.72068727]]

目前,你只需要了解这么多关于 Keras 模型的知识。到目前为止,你已经准备好进入下一章,使用 Keras 解决现实世界的机器学习问题。

摘要

  • TensorFlow、PyTorch 和 JAX 是三种流行的用于数值计算和自动微分的基础层框架。它们都有自己的做事方式、优势和劣势。

  • Keras 是构建和训练神经网络的顶层 API。它可以与 TensorFlow、PyTorch 或 JAX 一起使用——只需选择你最喜欢的后端即可。

  • Keras 的核心类是Layer。一个层封装了一些权重和一些计算。层被组装成模型。

  • 在开始训练模型之前,你需要选择一个优化器、一个损失函数和一些指标,你可以通过model.compile()方法指定它们。

  • 要训练一个模型,你可以使用fit()方法,它会为你运行小批量梯度下降。你还可以用它来监控验证数据上的损失和指标,这是一组模型在训练期间没有见过的输入。

  • 模型训练完成后,你可以使用model.predict()方法对新输入生成预测。

脚注

  1. R. E. Wengert,“一个简单的自动导数评估程序”,ACM 通讯,第 7 卷第 8 期(1964 年)。[↩]

  2. 注意,PyTorch 是一个中间案例:虽然它主要是一个底层框架,但它也包括自己的层和自己的优化器。然而,如果你将 PyTorch 与 Keras 结合使用,那么你将只与低级的 PyTorch API 交互,例如张量操作。[↩]

posted @ 2025-12-08 20:09  绝不原创的飞龙  阅读(65)  评论(0)    收藏  举报