程序员的数学-全-

程序员的数学(全)

原文:Math for Programmers

译者:飞龙

协议:CC BY-NC-SA 4.0

前置内容

前言

我在 2017 年开始写这本书,当时我是 Tachyus 公司的 CTO,这是我创立的一家为石油和天然气公司构建预测分析软件的公司。到那时,我们已经完成了核心产品的构建:一个由物理和机器学习驱动的流体流动模拟器,以及一个优化引擎。这些工具让我们的客户能够看到他们石油储量的未来,并帮助他们发现数亿美元优化机会。

作为 CTO,我的任务是使这款软件产品化并扩展规模,因为世界上一些最大的公司开始使用它。挑战在于,这不仅是一个复杂的软件项目,而且代码非常数学化。大约在那个时期,我们开始招聘一个名为“科学软件工程师”的职位,我们的想法是需要有数学、物理和机器学习扎实背景的熟练专业软件工程师。在寻找和招聘科学软件工程师的过程中,我意识到这种组合既罕见又需求量大。我们的软件工程师也意识到了这一点,并渴望提高他们的数学技能,为我们的堆栈的专业后端组件做出贡献。在我们团队中已经有一些热切的数学学习者,以及在我们的招聘渠道中,我开始思考如何最好地训练一个强大的软件工程师,使他成为一个强大的数学使用者。

我意识到没有适合的数学书籍,以适当水平呈现。虽然关于线性代数和微积分等主题可能有数百本书和数千篇免费在线文章,但我不知道有任何一本书可以交给一个典型的专业软件工程师,并期望他们在几个月后掌握这些材料。我并不是说贬低软件工程师,我的意思是阅读和理解数学书籍是一项很难单独学会的技能。要做到这一点,你通常需要弄清楚你需要学习哪些具体主题(如果你对材料一无所知,这很困难!),阅读它们,然后选择一些高质量的练习来练习应用这些主题。如果你不那么挑剔,你可以阅读教科书中的每一个字,解决其所有的练习,但这可能需要几个月的全职学习才能完成!

通过《程序员数学》,我希望提供一个替代方案。我相信在合理的时间内,包括完成所有练习,可以通读这本书,然后带着掌握了一些关键数学概念离开。

本书的设计

2017 年秋季,我联系了 Manning 出版社,得知他们对出版这本书感兴趣。这开始了将我对这本书的愿景转化为具体计划的长过程,这比我想象的要困难得多,因为我是一个第一次当作者的人。Manning 对我的原始目录提出了一些棘手的问题,例如

  • 会有任何人对这个主题感兴趣吗?

  • 这会不会太抽象了?

  • 你真的能在一章中教完一个学期的微积分吗?

所有这些问题都迫使我更加仔细地思考哪些是可实现的。我将分享我们回答这些问题的几种方法,因为这将帮助你确切地了解这本书是如何运作的。

首先,我决定将这本书的重点放在一个核心技能上——用代码表达数学思想。我认为这是一种学习数学的好方法,即使你不是程序员。当我还在高中时,我在我的 TI-84 图形计算器上学习编程。我有一个宏伟的想法,那就是我可以编写程序来做我的数学和科学作业,给出正确答案,并且在过程中输出步骤。正如你可能预料的那样,这比最初做作业要困难得多,但它给了我一些有用的视角。对于任何我想编程的问题,我必须清楚地理解输入和输出,以及解决方案的每个步骤中发生了什么。到最后,我确信我掌握了这些材料,并且有一个可以证明的运行程序。

这就是我在这本书中试图与你分享的经验。每一章都是围绕一个具体的示例程序组织的,为了使其工作,你需要正确地将所有的数学元素组合在一起。一旦完成,你将对自己理解了概念并且可以在将来再次应用它充满信心。我已经包括了大量的练习,帮助你检查我对数学和代码的理解,以及一些迷你项目,邀请你尝试对材料的新变体进行实验。

我还与曼宁讨论了另一个问题,那就是我应该使用哪种编程语言来编写示例。最初,我想用一种函数式编程语言来写这本书,因为数学本身就是一种函数式语言。毕竟,“函数”的概念起源于数学,在计算机甚至存在之前就已经存在了。在数学的各个部分,你都有函数,比如微积分中的积分和导数。然而,要求读者在学习新数学概念的同时学习像 LISP、Haskell 或 F#这样的不熟悉的语言,会使这本书更难懂,也更难接近。因此,我们决定使用 Python,这是一种流行、易于学习的语言,拥有出色的数学库。Python 也恰好是学术界和工业界“现实世界”数学用户的喜爱。

最后一个我必须与曼宁一起回答的问题是,我会包括哪些具体的数学主题,哪些主题不会入选。这是一个艰难的决定,但至少我们在标题上达成了共识,即《程序员数学》,这个标题的广泛性给了我们一些灵活性,关于可以包含什么内容。我的主要标准变成了以下这个:这将是一本“程序员数学”,而不是“计算机科学家数学”。考虑到这一点,我可以省略离散数学、组合数学、图论、逻辑、大 O 符号等主题,这些主题在计算机科学课程中有所涉及,并且主要用于研究程序。

即使做出了这个决定,仍有大量的数学可以选择。最终,我选择专注于线性代数和微积分。我对这些主题有一些强烈的教学法观点,而且在这两个主题中都有许多好的示例应用,这些应用可以是视觉的,也可以是交互式的。你可以单独写一本关于线性代数或微积分的大部头教科书,所以我必须更加具体。为了做到这一点,我决定这本书将逐步构建到机器学习这一热门领域的某些应用中。做出了这些决定后,本书的内容变得更加清晰。

我们涵盖的数学思想

本书涵盖了大量的数学主题,但有几个主要主题。以下是一些你在开始阅读时可以留意的内容:

  • 多维空间--直观上,你可能对二维(2D)和三维(3D)这些词的含义有所了解。我们生活在一个三维世界中,而二维世界则是像一张纸或电脑屏幕那样平坦。二维空间中的一个位置可以用两个数字(通常称为 xy 坐标)来描述,而在三维空间中识别一个位置则需要三个数字。我们无法想象 17 维空间,但我们可以用 17 个数字的列表来描述其点。这样的数字列表被称为 向量,向量数学有助于阐明“维度”的概念。

  • 函数空间--有时,一组数字可以指定一个函数。例如,有两个数字 a = 5 和 b = 13,你可以创建一个(线性)函数,形式为 f(x) = ax + b,在这种情况下,函数将是 f(x) = 5x + 13。对于二维空间中的每一个点,用坐标 (a, b) 标记,都有一个与之对应的线性函数。因此,我们可以将所有线性函数的集合视为一个二维空间。

  • 导数和梯度--这些是微积分运算,用于测量函数变化的速率。导数告诉你当输入值 x 增加时,函数 f(x) 是如何增加或减少的。一个三维函数可能看起来像 f(x, y),并且当改变 xy 的值时,它可能会增加或减少。将 (x, y) 对视为二维空间中的点,你可以问在这个二维空间中你可以朝哪个方向走,才能使 f 增加得最快。梯度回答了这个问题。

  • 优化函数--对于一个形式为 f(x) 或 f(x, y) 的函数,你可以问一个更广泛的问题:哪些输入能够产生最大的输出?对于 f(x),答案将是某个值 x,而对于 f(x, y),它将是二维空间中的一个点。在二维情况下,梯度可以帮助我们。如果梯度告诉我们 f(x, y) 在某个方向上增加,那么如果我们朝那个方向探索,我们就可以找到 f(x, y) 的最大值。如果你想要找到一个函数的最小值,这个策略同样适用。

  • 使用函数预测数据--假设你想预测一个数值,比如在特定时间点的股票价格。你可以创建一个函数 p(t),它接受时间 t 并输出价格 p。你函数预测质量的标准是它如何接近实际数据。从这个意义上说,找到一个预测函数意味着最小化你的函数与实际数据之间的误差。为了做到这一点,你需要探索一个函数空间并找到一个最小值。这被称为 回归

我认为这是一套对任何人来说都很有用的数学概念,可以放入他们的工具箱中。即使你对机器学习不感兴趣,这些概念——以及本书中的其他概念——在其他许多应用中都有丰富的用途。

我最遗憾的是在书中没有包含概率和统计学的内容。概率以及量化不确定性的概念在机器学习中同样重要。这本书已经很大了,所以实在没有时间或空间来为这些主题提供一个有意义的介绍。请期待这本书的续集。还有许多有趣且有用的数学知识,超出了我在这些页面所能涵盖的范围,我希望将来能够与大家分享。

致谢

从开始到结束,这本书的创建大约花费了三年时间。在那段时间里,我得到了很多帮助,因此我有很多要感谢和认可的人。

首先,我要感谢 Manning 出版社让这本书成为现实。我非常感激他们对我这个首次担任作者的人的信任,在我几次未能按时完成书稿时,他们展现出了极大的耐心。特别是,我要感谢 Marjan Bace 和 Michael Stephens 推动项目前进,并帮助定义了这本书的具体内容。我的原始开发编辑 Richard Wattenbarger 在保持书稿活力方面也至关重要。我认为他在我们确定书的结构之前,审阅了第一章和第二章的六稿。

我在 2019 年在我的第二位编辑 Jennifer Stout 的专家指导下写下了这本书的大部分内容,她不仅帮助项目顺利完成,还教了我很多关于技术写作的知识。我的技术编辑 Kris Athi 和技术审稿人 Mike Shepard 也与我们走到了最后,多亏了他们阅读了每一个字和每一行代码,我们发现了并修复了无数错误。在 Manning 之外,我还从 Michaela Leung 那里得到了很多编辑帮助,她还审阅了整本书的语法和技术准确性。我还要感谢 Manning 的市场营销团队。通过 MEAP 项目,我们能够验证这本书是人们感兴趣的。在努力完成出版前的最后阶段时,知道这本书至少会取得一定的商业成功是一个巨大的动力。

我目前在 Tachyus 的以及以前的同事教了我很多关于编程的知识,其中许多教训都融入了这本书中。我要感谢 Jack Fox,是他首先让我开始思考函数式编程与数学之间的联系,这一点在第四章和第五章中有所体现。Will Smith 教了我关于视频游戏设计,我们关于 3D 渲染的矢量几何学进行了许多有益的讨论。最值得一提的是,Stelios Kyriacou 教了我大部分关于优化算法的知识,并帮助我让这本书中的部分代码能够运行。他还向我介绍了“一切皆可优化”的哲学,这是书中后半部分的主题。

向所有审稿人致谢:Adhir Ramjiawan、Anto Aravinth、Christopher Haupt、Clive Harber、Dan Sheikh、David Ong、David Trimm、Emanuele Piccinelli、Federico Bertolucci、Frances Buontempo、German Gonzalez-Morris、James Nyika、Jens Christian B. Madsen、Johannes Van Nimwegen、Johnny Hopkins、Joshua Horwitz、Juan Rufes、Kenneth Fricklas、Laurence Giglio、Nathan Mische、Philip Best、Reka Horvath、Robert Walsh、Sébastien Portebois、Stefano Paluello 和 Vincent Zhu,你们的建议帮助使这本书更加完善。

我绝不是机器学习专家,所以我咨询了许多资源以确保我能够正确有效地介绍它。我最受 Andrew Ng 在 Coursera 上的“机器学习”课程以及 3Blue1Brown 在 YouTube 上的“深度学习”系列的影响。这些是极好的资源,如果你看过它们,你会注意到这本书的第三部分受到了它们介绍主题的方式的影响。我还要感谢 Dan Rathbone,他的实用网站 CarGraph.com 为我许多示例提供了数据来源。

我还想感谢我的妻子玛格丽特,一位天文学家,是她让我接触到了 Jupyter 笔记本。将本书的代码切换到 Jupyter 使得它更容易跟随。我的父母在撰写本书时也一直给予大力支持;在几次假期拜访他们时,我不得不匆忙完成一章。他们还亲自担保我会至少卖出至少一本(谢谢,妈妈!)。

最后,这本书献给我的父亲,是他在我五年级时教我如何用 APL 编程时,首先向我展示了如何在代码中做数学。如果这本书有第二版,我可能会请他帮忙将所有的 Python 代码重写为单行 APL 代码!

关于本书

《程序员数学》教您如何使用 Python 编程语言用代码解决数学问题。数学技能对于专业软件开发人员来说越来越重要,尤其是在公司为数据科学和机器学习配备团队时。数学在现代应用中也扮演着至关重要的角色,如游戏开发、计算机图形和动画、图像和信号处理、定价引擎和股市分析。

本书首先介绍二维和三维向量几何、向量空间、线性变换和矩阵;这些都是线性代数主题的精华。在第二部分,它介绍了以程序员特别有用的几个主题为重点的微积分:导数、梯度、欧拉方法和符号评估。最后,在第三部分,所有这些部分汇集在一起,向您展示一些重要的机器学习算法是如何工作的。到本书的最后一章,您将学会足够的数学知识,可以从头开始编写自己的神经网络。

这不是教科书!它旨在成为一个友好的介绍,介绍那些常常显得令人生畏、神秘或无聊的材料。每一章都包含一个完整的、现实世界的数学概念应用,辅以练习来帮助您检查您的理解,以及迷你项目来帮助您继续探索。

谁应该阅读这本书?

这本书是为任何有扎实编程背景的人而写的,他们想刷新他们的数学技能或了解更多关于数学在软件中的应用。它不需要任何先前的微积分或线性代数知识,只需要高中水平的代数和几何(即使那感觉已经很久远了!)本书旨在在您的键盘上阅读。如果您跟随示例并尝试所有练习,您将从中获得最大收益。

本书是如何组织的

第一章邀请您进入数学的世界。它涵盖了计算机编程中一些重要的数学应用,介绍了本书中的一些主题,并解释了编程如何成为数学学习者的宝贵工具。在此之后,本书分为三个部分:

  • 第一部分重点介绍向量和线性代数。

    • 第二章介绍了二维向量数学,重点是使用坐标来定义二维图形。它还包含了一些基本三角学的复习。

    • 第三章将前一章的内容扩展到三维,其中点由三个坐标而不是两个坐标标记。它介绍了点积和叉积,这些有助于测量角度和渲染 3D 模型。

    • 第四章介绍了线性变换,这些函数以向量作为输入并返回向量,并且具有特定的几何效果,如旋转或反射。

    • 第五章介绍了矩阵,这些是编码线性向量变换的数字数组。

    • 第六章将二维和三维的思想扩展,以便你可以处理任何维度的向量集合。这些被称为向量空间。作为一个主要例子,它涵盖了如何使用向量数学处理图像。

    • 第七章专注于线性代数中最重要的计算问题:解线性方程组。它将此应用于一个简单视频游戏中的碰撞检测系统。

  • 第二部分介绍了微积分及其在物理学中的应用。

    • 第八章介绍了函数变化率的概念。它涵盖了导数,它计算函数的变化率,以及积分,它从函数的变化率中恢复函数。

    • 第九章介绍了一种近似积分的重要技术,称为欧拉方法。它将第七章的游戏扩展到包括移动和加速的物体。

    • 第十章展示了如何在代码中操作代数表达式,包括自动找到函数的导数公式。它介绍了符号编程,这是一种与本书其他部分不同的在代码中执行数学的方法。

    • 第十一章将微积分主题扩展到二维,定义了梯度操作,并展示了如何用它来定义力场。

    • 第十二章展示了如何使用导数来找到函数的最大值或最小值。

    • 第十三章展示了如何将声波视为函数,以及如何将它们分解为其他更简单函数的和,这些函数称为傅里叶级数。它涵盖了如何编写 Python 代码来播放音符和和弦。

  • 第三部分结合了前两部分的思路,介绍了机器学习的一些重要思想。

    • 第十四章介绍了如何将直线拟合到二维数据,这个过程被称为线性回归。我们探讨的例子是找到一个函数,根据汽车的里程数来最佳预测二手车价格。

    • 第十五章讨论了一个不同的机器学习问题:根据一些关于汽车的数据来确定其模型。确定数据点代表的是哪种类型的对象被称为分类。

    • 第十六章展示了如何设计和实现神经网络,这是一种特殊的数学函数,并使用它来对图像进行分类。这一章结合了几乎所有前面的章节的思想。

如果你已经阅读并理解了前面的章节,每一章都应该是可访问的。保持所有概念有序的成本是应用可能看起来很杂乱。希望各种示例使它成为一本有趣的读物,并展示了我们所涵盖数学的广泛应用范围。

关于代码

本书以(希望是)逻辑顺序呈现观点。你在第二章学到的观点适用于第三章,然后第二章和第三章的观点出现在第四章,依此类推。计算机代码并不总是像这样“按顺序”编写。也就是说,完成计算机程序中最简单的观点并不总是在源代码的第一个文件的第一行。这种差异使得以可理解的方式呈现书籍的源代码具有挑战性。

我的解决方案是为每一章包含一个“走查”代码文件,形式为 Jupyter 笔记本。Jupyter 笔记本类似于记录的 Python 交互会话,内置了如图表和图像等视觉元素。在 Jupyter 笔记本中,你输入一些代码,运行它,然后在你开发想法的过程中,也许会在会话中稍后覆盖它。每一章的笔记本都有每个部分和子部分的代码,按照它在书中的顺序运行。最重要的是,这意味着你可以在阅读书籍的同时运行代码。你不需要等到章节结束,你的代码就已经足够完整,可以工作。附录 A 介绍了如何设置 Python 和 Jupyter,附录 B 包含了一些如果你是 Python 语言新手的实用功能。

本书包含许多源代码示例,无论是编号列表还是与普通文本并列。在这两种情况下,源代码都使用 fixed-width font like this 这样的固定宽度字体格式化,以将其与普通文本区分开来。

此外,当代码在文本中描述时,源代码中的注释通常已从列表中删除。代码注释伴随着许多列表,突出显示重要概念。如果在线源代码中修复了勘误或错误,我将在那里包含注释,以解决与文本中打印的代码之间的任何差异。

在少数情况下,示例的代码是一个独立的 Python 脚本,而不是章节 Jupyter 笔记本中的单元格。你可以单独运行它,例如,python script.py,或者从 Jupyter 笔记本单元格中运行它,作为 !python script.py。我在一些 Jupyter 笔记本中包含了独立脚本的引用,这样你可以逐节跟随,并找到相关的源代码文件。

我在整本书中使用的约定是使用 Python 交互会话中会看到的 >>> 提示符号来表示单个 Python 命令的评估。我建议你使用 Jupyter 而不是 Python 交互,但无论如何,带有 >>> 的行代表输入,而没有 >>> 的行代表输出。以下是一个表示交互式评估 Python 代码片段“2 + 2”的代码块示例:

>>> 2 + 2 4

相比之下,下一个代码块没有 >>> 符号,所以它是普通的 Python 代码,而不是输入和输出的序列:

def square(x):     return x * x

本书包含数百个练习,这些练习旨在直接应用已覆盖的材料,以及小型项目,这些项目可能更复杂,需要更多创造力,或者引入新概念。本书中的大多数练习和微型项目都邀请您使用有效的 Python 代码解决一些数学问题。我几乎包括了所有问题的解决方案,除了某些更开放的小型项目。您可以在相应章节的 Jupyter notebook 的“漫步”中找到解决方案代码。

本书示例的代码可以从曼宁网站下载,网址为 www.manning.com/books/math-for-programmers,以及 GitHub 上的 github.com/orlandpm/math-for-programmers

liveBook 讨论论坛

购买 Math for Programmers 包括免费访问由曼宁出版社运行的私人网络论坛,您可以在那里对书籍发表评论,提出技术问题,并从作者和其他用户那里获得帮助。要访问论坛,请访问 livebook.manning.com/#!/book/math-for-programmers/discussion。您还可以在 livebook.manning.com/#!/discussion 上了解更多关于曼宁论坛和行为准则的信息。

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

关于作者

保罗·奥兰德是一位企业家、程序员和数学爱好者。在微软担任软件工程师后,他与他人共同创立了 Tachyus,这是一家初创公司,致力于为石油和天然气行业优化能源生产而构建预测分析。作为 Tachyus 的创始首席技术官,保罗领导了机器学习和基于物理的建模软件的产品化,后来作为首席执行官,他扩大了公司规模,为五大洲的客户提供服务。保罗拥有耶鲁大学的数学学士学位和华盛顿大学的物理学硕士学位。他的精神动物是龙虾。

关于封面插图

《程序员数学》封面上的插图名为“Femme Laponne”,或称拉普人女性,现称萨皮人,包括挪威北部、瑞典、芬兰和俄罗斯的部分地区。这幅插图取自雅克·格拉塞·德·圣索沃尔(1757-1810)的作品集,名为《不同国家的服饰》,于 1797 年在法国出版。每一幅插图都是手工精心绘制和着色的。格拉塞·德·圣索沃尔的收藏丰富多彩,生动地提醒我们,200 年前世界的城镇和地区在文化上有多么不同。人们彼此孤立,说着不同的方言和语言。在街道或乡村,仅凭他们的服饰就能轻易识别他们居住的地方以及他们的职业或社会地位。

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

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

1 使用代码学习数学

本章涵盖

  • 用数学和软件解决有利可图的问题

  • 避免学习数学时的常见陷阱

  • 从编程中的直觉来理解数学

  • 使用 Python 作为强大的可扩展计算器

数学就像棒球,或者诗歌,或者美酒。有些人对数学如此着迷,以至于他们把一生都奉献给了它,而有些人则觉得自己根本不懂。你可能已经因为在学校接受了十二年的强制数学教育而被迫进入了一个阵营或另一个阵营。

如果我们在学校像学习数学一样学习美酒呢?如果每天花一个小时,每周五天,被讲授葡萄品种和发酵技术,我想我可能根本不喜欢葡萄酒。也许在这样的世界里,我可能需要每天喝三到四杯酒来完成老师布置的作业。有时这会是一种美味的教育体验,但有时我可能不想在学校的夜晚喝得烂醉。我的数学课经历就是这样,它让我对这个科目暂时失去了兴趣。就像葡萄酒一样,数学是一种需要培养的口味,每天的课程和作业并不能培养一个人的品味。

容易认为你或者适合数学,或者不适合。如果你已经相信自己,并且对开始学习感到兴奋,那太好了!否则,这一章是为那些不那么乐观的人设计的。对数学感到害怕是很常见的,它有一个名字:数学焦虑。我希望消除你可能有的任何焦虑,并展示数学可以是一种令人兴奋的体验,而不是一种令人恐惧的体验。你所需要的只是正确的工具和正确的思维方式。

本书学习的主要工具是 Python 编程语言。我猜当你高中学习数学时,你看到的是写在黑板上的,而不是在计算机代码中。这真是个遗憾,因为高级编程语言比黑板强大得多,比你可能用过的任何高价计算器都要灵活得多。在代码中遇到数学的一个优点是,这些想法必须足够精确,以便计算机能够理解,而且永远不会有人挥手解释新符号的含义。

就像学习任何新学科一样,为了成功,最好的办法是“想要”学习。有很多很好的理由。你可能对数学概念的美感到着迷,或者喜欢数学问题的“脑筋急转弯”感觉。也许有一个你梦想着建造的应用程序或游戏,你需要编写一些数学代码让它工作。现在,我将专注于一种更实际的动机——用软件解决数学问题可以让你赚很多钱。

1.1 用数学和软件解决有利可图的问题

在高中数学课上经常听到的一个经典批评是,“我什么时候会在现实生活中用到这些?”我们的老师告诉我们,数学将帮助我们职业上取得成功并赚钱。我认为他们在这方面是正确的,尽管他们的例子不太准确。例如,我并不用手计算复利银行利息(我的银行也不这样做)。也许如果我像我的三角函数老师建议的那样成为建筑工地测量员,我每天都会用正弦和余弦来赚取我的工资。

结果表明,高中教科书中的“现实世界”应用并不那么有用。尽管如此,数学确实有实际应用,其中一些应用非常有利可图。许多应用都是通过将正确的数学思想转化为可用的软件来解决的。我将分享一些我最喜欢的例子。

1.1.1 预测金融市场走势

我们都听说过关于股票交易员通过在正确的时间买卖正确的股票而赚取数百万美元的传说。根据我看过的一些电影,我总是想象一个交易员是一个穿着西装的中年男子,在驾驶跑车时对着手机对着经纪人大喊。也许这个刻板印象在某个时刻是准确的,但现在的形势已经不同了。

在曼哈顿摩天大楼的后办公室里,有成千上万的人被称为量化分析师。量化分析师,也称为数量分析师,设计数学算法来自动交易股票并获利。他们不穿西装,也不在手机上大喊大叫,但我确信他们中的许多人拥有非常漂亮的跑车。

那么,量化分析师是如何编写一个自动赚钱的程序的呢?对这个问题的最佳答案都是严格保密的商业机密,但你可以确信它们涉及大量的数学。我们可以通过一个简短的例子来了解自动化交易策略可能的工作方式。

股票是代表公司所有权份额的金融资产类型。当市场认为一家公司表现良好时,其股价就会上涨−购买股票变得更加昂贵,而卖出股票则更加有利可图。股价实时且不规则地变化。图 1.1 显示了交易日一天中股票价格图表可能的样子。

图 1.1 股票价格随时间变化的典型图表

如果你在大约 100 分钟时以 24 美元的价格购买了这只股票的 1000 股,并在 400 分钟时以 38 美元的价格卖出,你一天就能赚 14000 美元。不错!挑战在于你必须事先知道股票会上涨,100 分钟和 400 分钟分别是买入和卖出的最佳时机。可能无法预测确切的最低或最高价格点,但也许你可以找到一天中相对较好的买卖时机。让我们看看如何从数学上解决这个问题。

我们可以通过找到一个“最佳拟合”线来测量股票是上涨还是下跌,这条线大致遵循价格移动的方向。这个过程称为线性回归,我们在本书的第三部分中会介绍它。基于数据的可变性,我们可以在“最佳拟合”线之上和之下计算两条线,显示价格波动区域。图 1.2 显示,这些线很好地遵循了趋势。

图 1.2 使用线性回归识别变化的股票价格趋势

通过对价格波动的数学理解,我们就可以编写代码,在价格相对于其趋势处于低波动时自动买入,在价格回升时卖出。具体来说,我们的程序可以通过网络连接到证券交易所,当价格穿过底部线时买入 100 股,当价格穿过顶部线时卖出 100 股。图 1.3 展示了这样一笔盈利交易:以约 27.80 美元的价格买入,以约 32.60 美元的价格卖出,一个小时就能赚得 480 美元。

图 1.3 根据我们的基于规则的软件进行买卖以获利

我并不声称我已经向你展示了一个完整或可行的策略,但重点是,有了正确的数学模型,你可以自动获利。此刻,一些未知数量的程序正在构建和更新模型,以衡量股票和其他金融工具的预测趋势。如果你编写这样的程序,你可以在它为你赚钱的同时享受一些闲暇时光!

1.1.2 寻找好交易

可能你的资金并不足够去考虑风险股票交易。但数学仍然可以帮助你在其他交易中赚钱和省钱,比如购买二手车。新车是容易理解的商品。如果两个经销商都在卖同一辆车,你显然会想从成本最低的经销商那里购买。但二手车有更多的数字与之相关:一个要价,以及里程数和车型年份。你甚至可以使用特定二手车在市场上停留的时间来评估其质量:停留时间越长,你可能越怀疑。

在数学中,你可以用有序数字列表来描述的对象被称为向量,有一个整个领域(称为线性代数)专门研究它们。例如,一辆二手车可能对应一个四维向量,意味着一组四个数字:

(2015, 41429, 22.27, 16980)

这些数字分别代表车型年份、里程、上市天数和要价。我的一个朋友运营着一个名为 CarGraph.com 的网站,该网站汇总了出售的二手汽车数据。在撰写本文时,它显示了 101 辆出售的丰田普锐斯,并为每一辆提供了一些或所有这四项数据。该网站也如其名,以图形的方式呈现数据(图 1.4)。可视化四维对象很困难,但如果你选择两个维度,比如价格和里程,你可以在散点图上以点的方式绘制它们。

图 1.4 CarGraph.com 上二手普锐斯价格与里程的图表

图 1.5 将指数衰减曲线拟合到二手丰田普锐斯的价格与里程数据

我们也许也感兴趣在这里绘制一条趋势线。这个图上的每一个点都代表了对公平价格的一种看法,因此趋势线会将这些看法汇总成一个在任何里程数下都更可靠的估价。在图 1.5 中,我决定拟合一个指数衰减曲线而不是直线,并且省略了一些几乎全新的、低于零售价出售的汽车。

为了使数字更易于管理,*我将里程值转换为以万英里为单位,因此 5 表示 50,000 英里。用 p 表示价格,m 表示里程,最佳拟合曲线的方程如下:

p = $26,500 · (0.905)m

方程 1.1

方程 1.1 显示最佳拟合价格是$26,500 乘以 0.905 的里程次方。将值代入方程,我发现如果我的预算是$10,000,那么我应该购买一辆大约行驶了 97,000 英里的普锐斯(图 1.6)。如果我相信曲线表示的是公平的价格,那么低于这条线的汽车通常都是好交易。

图 1.6 寻找我在$10,000 预算下应该期望的二手普锐斯的里程

但我们可以从方程 1.1 中学到的不仅仅是如何找到一个好交易。它讲述了一个关于汽车贬值的故事。方程中的第一个数字是$26,500,这是指数函数对零里程时价格的解读。这与新普锐斯的零售价非常接近。如果我们使用最佳拟合线,它暗示普锐斯每行驶一英里就会失去固定数量的价值。这个指数函数则说,相反,每行驶一英里就会失去固定百分比的价值。根据这个方程,行驶 10,000 英里后,普锐斯的价值只剩下 0.905 或原价的 90.5%。行驶 50,000 英里后,我们将它的价格乘以一个因子(0.905)⁵ = 0.607。这告诉我们,它的价值大约是原始价值的 61%。

为了制作图 1.6 中的图表,我在 Python 中实现了一个price(mileage)函数,它接受一个作为输入的里程数(以 10,000 英里为单位)并返回最佳拟合价格作为输出。计算price(0) − price(5)price(5) − price(10)告诉我,行驶的前 50,000 英里大约花费了 10,000 美元,而接下来的 50,000 英里则花费了 6,300 美元。

如果我们用最佳拟合线而不是指数曲线,这意味着汽车以每英里 0.10 美元的固定速率贬值。这表明每行驶 50,000 英里会导致 5,000 美元的相同贬值。传统观点认为,新汽车行驶的前几英里是最昂贵的,因此指数函数(方程 1.1)与这一点相符,而线性模型则不然。

记住,这只是一个二维分析。我们只建立了一个数学模型来关联描述每辆车的四个数值维度中的两个。在第一部分,你将学习更多关于不同维度的向量以及如何操作高维数据。在第二部分,我们将介绍不同类型的函数,如线性函数和指数函数,并通过分析它们的增长率来比较它们。最后,在第三部分,我们将探讨如何构建包含数据集所有维度的数学模型,以给我们一个更准确的图景。

1.1.3 构建 3D 图形和动画

许多最著名且财务上最成功的软件项目都涉及多维数据,特别是三维3D数据。这里我想到了 3D 动画电影和 3D 视频游戏,它们的收入都达到了数十亿美元。例如,皮克斯的 3D 动画软件帮助他们在大银幕上赚了超过 130 亿美元。动视的 3D 动作游戏系列《使命召唤》赚了超过 160 亿美元,而仅《侠盗猎车手 V》就带来了 60 亿美元。

这些备受赞誉的项目每一个都是基于对如何使用三维向量,或形式为 v = (x, y, z) 的数字三元组进行计算的理解。一个三元组

在三维空间中,相对于一个称为原点的参考点,只需要几个数字就可以定位一个点。图 1.7 展示了这三个数字如何告诉你沿着三个垂直方向中的哪一个方向走多远。

图片

图 1.7 使用三个数字向量 x, y, z 标记三维空间中的一个点

从《海底总动员》中的小丑鱼到《使命召唤》中的航空母舰,任何三维物体都可以被计算机定义为一个三维向量的集合。在代码中,这些物体看起来像是一个包含浮点值三元组的列表。有了三个浮点值三元组,我们就有三个空间中的点,可以定义一个三角形(图 1.8)。例如,

triangle = [(2.3,1.1,0.9), (4.5,3.3,2.0), (1.0,3.5,3.9)]

图片

图 1.8 使用每个角落的浮点值三元组构建三维三角形

通过组合许多三角形,你可以定义 3D 物体的表面。使用更多的、更小的三角形,你甚至可以使结果看起来更平滑。图 1.9 展示了使用不断增加的更小三角形来渲染 3D 球体的六种不同方式。

图 1.10

图 1.9 由指定数量的三角形构建的三维(3D)球体。

在第三章和第四章中,你将学习如何使用 3D 向量数学将 3D 模型转换为如图 1.9 所示的着色 2D 图像。你还需要使你的 3D 模型平滑,以便在游戏或电影中显得逼真,并且需要它们以逼真的方式移动和变化。这意味着你的物体应该遵守物理定律,这些定律也用 3D 向量表示。

假设你是一名《侠盗猎车手 V》的游戏程序员,并希望启用一个基本用例,比如向直升机发射火箭筒。火箭筒发射出的抛射体从主角的位置开始,然后其位置随时间变化。你可以使用数字下标来标记它在飞行过程中所经过的各种位置,从v[0] = (x[0], y[0], z[0])开始。随着时间的推移,抛射体到达新的位置,这些位置由向量v[1] = (x[1], y[1], z[1])、v[2] = (x[2], y[2], z[2])等标记。xyz值的改变率由火箭筒的方向和速度决定。此外,这些改变率可以随时间变化−由于重力持续向下的作用,抛射体的z位置以递减的速率增加(如图 1.10 所示)。

图 1.10

图 1.10 由于初始速度和重力的作用,抛射体的位置矢量随时间变化。

任何有经验的动作游戏玩家都会告诉你,你需要略微瞄准直升机上方才能击中它!为了模拟物理现象,你必须知道力如何影响物体并在时间上引起连续变化。连续变化的数学称为微积分,而物理定律通常用微积分中的对象,即微分方程来表示。在第四章和第五章中,你将学习如何动画化 3D 对象,然后在第二部分中,你将学习如何使用微积分的思想来模拟物理。

1.1.4 建模物理世界

我声称数学软件能产生真实财务价值并非空穴来风;我在自己的职业生涯中见证了其价值。2013 年,我创立了一家名为 Tachyus 的公司,该公司开发软件以优化石油和天然气生产。我们的软件使用数学模型来理解地下油气流动,帮助生产者更高效、更有利可图地提取油气。利用它产生的洞察力,我们的客户每年实现了数百万美元的成本节约和产量增加。

要解释我们的软件是如何工作的,你需要了解一些石油术语。被称为的孔被钻入地下,直到它们达到含有油的孔隙(海绵状)岩石的目标层。地下富含油的岩石层被称为储层。油被泵送到地面,然后卖给炼油厂,炼油厂将其转化为我们每天使用的各种产品。图 1.11 展示了油田的示意图(非比例图)。

在过去的几年里,石油价格波动很大,但为了我们的目的,让我们假设它的价值是每桶 50 美元,其中一桶是一个体积单位,等于 42 加仑或大约 159 升。如果通过钻井和有效抽油,一家公司每天能提取 1000 桶石油(相当于几个后院游泳池的体积),它将会有数百万美元的年收入。即使是效率提高几个百分点也可能意味着一大笔钱。

油藏示意图

图 1.11 油田的示意图

基本问题是地下发生了什么:现在的油在哪里,它是如何移动的?这是一个复杂的问题,但也可以通过求解微分方程来回答。这里变化的量不是弹道的位置,而是地下流体(如油)的位置、压力和流速。流体流速是一种特殊的函数,它返回一个向量,称为向量场。这意味着流体可以在任何三维方向以任何速率流动,并且这个方向和速率可以在储层内的不同位置变化。

对于这些参数的一些最佳猜测,我们可以使用名为达西定律的微分方程来预测液体通过多孔岩石介质(如砂岩)的流速。图 1.12 展示了达西定律,但如果某些符号不熟悉,请不要担心!代表流速的函数 q 被加粗,以表示它返回一个向量值。

地下情况示意图

图 1.12 标注了物理方程的达西定律,它控制着流体在多孔岩石中的流动。

这个方程最重要的部分是看起来像倒三角形的符号,它代表向量微积分中的梯度算子。在给定点(x, y, z)处的压力函数p(x, y, z)的梯度是一个 3D 向量q(x, y, z),指示压力增加的方向和在该点压力增加的速率。负号告诉我们流速的 3D 向量是相反方向的。这个方程用数学术语表述,即流体从高压区流向低压区。

负梯度在物理学定律中很常见。可以这样理解,自然界总是在寻求向更低势能状态移动。一个球在山上的势能取决于山在任意横向点 x 的海拔 h。如果山的高度由函数 h(x) 给出,梯度指向山顶,而球滚动的方向正好相反(图 1.13)。

图片

图 1.13 正梯度指向山顶,而负梯度指向山下。

在第十一章中,你将学习如何计算梯度。在那里,我向你展示如何将梯度应用于模拟物理现象,以及解决其他数学问题。梯度恰好是机器学习中最重要的数学概念之一。

我希望这些例子比你在高中数学课上听到的现实世界应用更有说服力和现实性。也许,到了这个时候,你已经相信这些数学概念值得学习,但你担心它们可能太难。确实,学习数学可能很困难,尤其是当你独自一人时。为了使其尽可能顺利,让我们谈谈作为数学学生可能会遇到的陷阱,以及我在这本书中如何帮助你避免它们。

1.2 如何避免学习数学

现在有很多数学书籍,但并非所有都同样有用。我有不少程序员朋友试图学习像上一节中那样的数学概念,要么是出于求知欲,要么是出于职业抱负。当他们将传统数学教科书作为主要资源时,他们常常会遇到困难并放弃。以下是一个典型的失败的数学学习故事。

1.2.1 简想要学习一些数学

我的(虚构的)朋友简是一名全栈网络开发者,在旧金山一家中型科技公司工作。在大学里,简没有深入研究计算机科学或任何数学学科,她以产品经理的身份开始了自己的职业生涯。在过去的十年里,她学习了 Python 和 JavaScript 编程,并成功转型为软件工程师。现在,在她的新工作中,她是团队中最有能力的程序员之一,能够构建数据库、网络服务和用户界面,以向客户交付重要的新功能。显然,她非常聪明!

简意识到学习数据科学可以帮助她在工作中设计和实施更好的功能,利用数据来改善客户的体验。大多数上班的火车上,简都会阅读关于新技术博客和文章,最近,她被几篇关于“深度学习”这个主题的文章所震撼。其中一篇文章讲述了由深度学习驱动的谷歌 AlphaGo 在棋类游戏中击败了世界排名第一的人类选手。另一篇文章展示了由深度学习系统从普通图像生成的令人惊叹的印象派画作。

在阅读了这些文章后,简妮听说她的朋友的朋友马库斯在一家大型科技公司得到了一个深度学习研究工作。据说马库斯每年工资加股票分红超过 40 万美元。想到她职业生涯的下一步,简妮还有什么比在有趣且有利可图的难题上工作更想要的呢?

简妮进行了一些研究,并在网上找到了一个权威的(而且是免费的!)资源:Goodfellow 等人撰写的《深度学习》一书(MIT Press,2016 年)。前言读起来就像她习惯的技术博客文章,这让她对学习这个主题更加兴奋。但随着她继续阅读,书的内容变得越来越难。第一章涵盖了所需的数学概念,并介绍了很多简妮从未见过的术语和符号。她浏览了一下,试图找到书的重点,但内容仍然越来越难。

简妮决定她需要暂停学习人工智能和深度学习,直到她学了一些数学。幸运的是,《深度学习》一书的数学章节为那些从未接触过这个主题的学生列出了一份线性代数参考书目。她找到了这本教科书,Georgi Shilov 撰写的《线性代数》(Dover,1977 年),并发现它有 400 页长,和《深度学习》一样密集。

在花了一下午阅读关于数域、行列式和余子式等概念的晦涩定理之后,她决定放弃。她不知道这些概念如何帮助她编写一个赢得棋盘游戏的程序或生成艺术品,而且她也不再愿意花数十个小时在这枯燥的材料上,试图找出答案。

简妮和我一起喝咖啡,互相了解近况。她告诉我,她因为不知道线性代数而难以阅读真正的 AI 文献。最近,我听到很多类似的抱怨:

“我正在尝试了解[新技术],但似乎我需要先学习[数学主题]。”

她的方法是值得称赞的:她找到了她想要学习的主题的最佳资源,并寻找了她缺少的先决条件的资源。但在将这种方法推向逻辑结论的过程中,她发现自己陷入了一种令人作呕的“深度优先搜索”的技术文献中。

1.2.2 拖延数学教科书

简妮挑选的线性代数这类大学水平的数学书籍往往非常公式化。每个部分都遵循相同的格式:定义一些新的术语,使用这些术语陈述一些事实(称为定理),然后证明这些定理是正确的。

这听起来像是一个好、逻辑顺序:你介绍你要讨论的概念,陈述一些可以得出的结论,然后证明它们。那么,为什么阅读高级数学教科书这么难呢?

问题在于这并不是数学实际上是如何被创造的。当你提出新的数学思想时,在你甚至找到正确的定义之前,可能会有一个漫长的实验期。我认为大多数职业数学家会这样描述他们的步骤:

  1. 发明一个游戏。例如,通过尝试列出所有数学对象,在他们之间寻找模式,或者找到一个具有特定属性的对象来开始玩一些数学对象。

  2. 形成一些猜想。对你的游戏提出一些可以陈述的普遍事实,并且至少要说服自己这些必须是真实的。

  3. 为你的游戏和猜想开发一些精确的语言来描述。毕竟,除非你能传达它们,否则你的猜想没有任何意义。

  4. 最后,带着一些决心和运气,为你的猜想找到一个证明,说明为什么它需要是真的。

从这个过程中学到的最重要的教训是,你应该先思考大的想法,而形式化可以稍后进行。一旦你对数学是如何工作的有一个大致的想法,词汇和符号就变成了你的资产,而不是干扰。数学教科书通常采用相反的顺序,所以我建议将教科书用作参考,而不是新主题的介绍。

与阅读传统的教科书相比,学习数学的最好方法是探索想法并得出你自己的结论。然而,你一天中并没有足够的时间自己重新发明一切。如何找到正确的平衡点?我将给出我谦逊的意见,这指导了我如何写这本关于数学的非传统书籍。

1.3 利用你训练有素的左脑

这本书是为那些有经验的程序员或那些在解决过程中对学习编程感到兴奋的人设计的。为程序员写关于数学的文章是很好的,因为如果你能编写代码,你已经训练了你的分析性左脑。我认为学习数学的最佳方式是借助高级编程语言,并且我预测在不久的将来,这将成为数学课堂上的常态。

有几种具体的方法,像你这样的程序员非常适合学习数学。我在这里列出这些方法,不仅是为了夸奖你,也是为了提醒你,你已经拥有的哪些技能可以在你的数学学习中依赖。

1.3.1 使用形式语言

在编程中你最早学到的一个艰难的教训是,你不能像写简单的英语那样编写你的代码。如果你的拼写或语法在给朋友的便条中略有错误,他们可能仍然能理解你试图表达的意思。但代码中的任何语法错误或拼写错误的标识符都会导致你的程序失败。在某些语言中,甚至在其他方面正确的情况下忘记在语句末尾的分号也会阻止程序运行。作为另一个例子,考虑以下两个语句:

*x* = 5
5 = x

我可以读这两个中的任何一个,认为符号x的值是 5。但这并不是这两个中的任何一个在 Python 中的确切含义,实际上,只有第一个是正确的。Python 语句x = 5是告诉计算机将变量x设置为值 5 的指令。另一方面,你不能将数字 5 设置为具有值x。这听起来可能有些繁琐,但你需要知道这一点才能编写正确的程序。

另一个让新手程序员(以及有经验的程序员)感到困惑的例子是引用相等。如果你定义一个新的 Python 类并创建两个相同的实例,它们并不相等!

>>> class A(): pass
...
>>> A() == A()
False

你可能预期两个相同的表达式应该是相等的,但在 Python 中这显然不是一条规则。因为这些是A类的不同实例,所以它们不被认为是相等的。

注意寻找看起来像你已知但行为不同的新数学对象。例如,如果字母aB代表数字,那么a · B = B · a。但是,正如你将在第五章学到的那样,如果aB不是数字,这就不一定是这种情况。如果aB是矩阵,那么a · BB · a的乘积是不同的。实际上,可能只有一个乘积是可行的,或者两个乘积都不正确。

当你编写代码时,仅仅写出正确的语法是不够的。你的语句所代表的思想需要有意义才能是有效的。如果你在写数学语句时也这样小心,你就能更快地发现错误。更好的是,如果你用代码写数学语句,计算机可以帮助你检查你的工作。

1.3.2 构建自己的计算器

计算器在数学课上很常见,因为检查你的工作是有用的。你需要知道如何不用计算器乘以 6 和 7,但通过查阅计算器来确认你的答案 42 是正确的也很好。一旦你掌握了数学概念,计算器也能帮助你节省时间。如果你在做三角学,需要知道 3.14159 / 6 的答案,计算器就可以处理这个问题,这样你就可以思考答案的意义。计算器能做的功能越多,理论上它应该越有用。

但有时我们的计算器对我们自己来说太复杂了。当我上高中时,我需要得到一个图形计算器,我得到了一个 TI-84。它大约有 40 个按钮,每个按钮有 2 到 3 种不同的模式。我只知道如何使用其中大约 20 个,所以它是一个笨重的学习工具。当我一年级第一次得到计算器时,情况也是一样。只有大约 15 个按钮,但我不知道其中一些按钮的作用。如果我要为学生们发明一个第一台计算器,我会让它看起来像图 1.14 中的那样。

图 1.14 学生学习计数用的计算器

这个计算器只有两个按钮。其中一个将值重置为 1,另一个递增到下一个数字。这样的工具对于学习计数的儿童来说可能是合适的“无装饰”工具。(我的例子可能看起来很傻,但实际上你可以买到这样的计算器!它们通常是机械的,作为计数器出售。)

在你熟练掌握计数之后,你想要练习书写数字和进行加法运算。在这个学习阶段,一个完美的计算器可能会有几个额外的按钮(图 1.15)。

图 1.15

图 1.15 一个能够书写整数并进行加法运算的计算器

在这个阶段,不需要像−、*或÷这样的按钮来妨碍你。当你解决像 5−2 这样的减法问题时,你仍然可以用这个计算器通过确认 3+2=5 来检查你的答案。同样,你可以通过重复加法来解决乘法问题。当你完成对这个计算器的探索后,你可以升级到一个可以进行所有算术运算的计算器。

我认为一个理想的计算器应该是可扩展的,这意味着你可以根据需要向其添加更多功能。例如,你可以为每个新学习的数学运算在你的计算器上添加一个按钮。一旦你进入了代数,也许你可以让它理解像xy这样的符号,而不仅仅是数字。当你

学习了微积分后,你可以进一步使其能够理解和操作数学函数。

能够存储多种类型数据的可扩展计算器似乎有些遥远,但当你使用高级编程语言时,这正是你得到的东西。Python 自带算术运算、math模块以及你可以在需要时引入的众多第三方数学库,使你的编程环境更加强大。因为 Python 是图灵完备的,所以你可以(原则上)计算任何可以计算的东西。你只需要一个足够强大的计算机、足够聪明的实现,或者两者都要。

在这本书中,我们用可重用的 Python 代码实现每个新的数学概念。亲自完成实现可以是你巩固对新概念理解的好方法,到头来,你为你的工具箱增添了一个新工具。在你亲自尝试之后,如果你喜欢,你总是可以替换成一个经过打磨的主流库。无论如何,你构建或导入的新工具为探索更大的想法奠定了基础。

1.3.3 使用函数构建抽象

在编程中,我刚才描述的过程被称为抽象。例如,当你厌倦了重复计数时,你创造了加法的抽象。当你厌倦了重复的加法运算时,你创造了乘法的抽象,以此类推。

在编程中,你可以采用多种方法进行抽象,其中最重要的一个方法是将其应用到数学中的函数。在 Python 中,函数是一种重复某些任务的方式,它可以接受一个或多个输入,或者可以产生一个输出。例如,

def greet(name):
    print("Hello %s!" % name)

允许我使用简短、富有表现力的代码发出多个问候,如下所示:

>>> for name in ["John","Paul","George","Ringo"]:
...     greet(name)
...
Hello John!
Hello Paul!
Hello George!
Hello Ringo!

这个函数可能很有用,但它并不像数学函数。数学函数总是接受输入值,并且总是返回输出值,没有副作用。

在编程中,我们将行为类似于数学函数的函数称为纯函数。例如,平方函数 f(x) = x² 接受一个数字并返回该数字与自身的乘积。当你评估 f(3) 时,结果是 9。这并不意味着数字 3 现在变成了 9。相反,这意味着 9 是函数 f 对输入 3 的对应输出。你可以将这个平方函数想象成一个机器,它在输入槽中接受数字,并在其输出槽中产生结果(数字)(图 1.16)。

图片

图 1.16 一个具有输入槽和输出槽的函数作为机器

这是一个简单而有用的思维模型,我将在整本书中反复提及它。我最喜欢它的一个方面是,你可以将一个函数想象成一个本身存在的对象。在数学中,就像在 Python 中一样,函数是可以独立操作的数据,甚至可以将它们传递给其他函数。

数学可能令人畏惧,因为它很抽象。记住,就像任何优秀的软件一样,抽象的引入是有原因的:它帮助你组织和传达更大、更强大的想法。当你掌握这些想法并将它们转化为代码时,你将打开一些令人兴奋的可能性。

如果你之前还没有意识到,我希望你现在相信数学在软件开发中有许多令人兴奋的应用。作为一名程序员,你已经拥有了学习一些新数学思想所需的心态和工具。这本书中的想法为我提供了专业和个人上的丰富,我希望它们也能为你带来同样的收获。让我们开始吧!

摘要

  • 数学在许多软件工程领域都有有趣且有利可图的用途。

  • 数学可以帮助你量化随时间变化的数据趋势,例如,预测股价的变动。

  • 不同类型的函数传达不同类型的定性行为。例如,指数折旧函数意味着汽车每行驶一英里就会损失其转售价值的一定百分比,而不是固定金额。

  • 数字元组(称为向量)代表多维数据。具体来说,三维向量是一组数字,可以表示空间中的点。你可以通过组装由向量指定的三角形来构建复杂的 3D 图形。

  • 微积分是数学对连续变化的研究,许多物理定律都是以称为微分方程的微积分方程来表述的。

  • 从传统的教科书中学习数学很难!你通过探索来学习数学,而不是简单地按部就班地通过定义和定理。

  • 作为程序员,你已经训练自己进行精确思考和交流;这项技能将帮助你学习数学。

第一部分:向量和图形

在本书的第一部分,我们深入探讨被称为线性代数的数学分支。在非常高的层次上,线性代数是处理多维数据计算的数学分支。维度这个概念是几何的;当你我说“一个正方形是二维的”而“一个立方体是三维的”时,你可能直觉地知道我的意思。除了其他方面,线性代数让我们将关于维度的几何思想转化为我们可以具体计算的东西。

线性代数中最基本的概念是向量,你可以将其想象为多维空间中的一个数据点。例如,你可能在高中的几何和代数中听说过二维(2D)坐标系。正如我们将在第二章中介绍的,二维空间中的向量对应于平面上的点,可以用有序的(x, y)形式的数字对进行标记。在第三章中,我们将考虑三维(3D)空间,其向量(点)可以用(x, y, z)形式的数字三元组进行标记。在这两种情况下,我们看到我们可以使用向量集合来定义几何形状,这些形状反过来又可以转化为有趣的图形。

线性代数中的另一个关键概念是线性变换,我们将在第四章中介绍这一概念。线性变换是一种函数,它以一个向量作为输入并返回一个向量作为输出,同时保持所涉及向量的几何形状(在特殊意义上)。例如,如果一个向量集合(点)在二维空间中位于一条直线上,经过线性变换后,它们仍然位于一条直线上。在第五章中,我们将介绍矩阵,它是由数字组成的矩形数组,可以表示线性变换。我们对线性变换的最终应用是将它们按时间顺序应用于 Python 程序中的图形,从而产生一些三维动画图形。

虽然我们只能在二维和三维中想象向量和线性变换,但我们可以定义任何数量的维度向量。在n维中,一个向量可以被识别为一个有序的n元组,形式为(x[1], x[2], ..., x[n])。在第六章中,我们将逆向工程二维和三维空间的概念,以定义向量空间的一般概念,并更具体地定义维度的概念。特别是,我们将看到由像素组成的数字图像可以被视为高维向量空间中的向量,并且我们可以使用线性变换进行图像处理。

最后,在第七章中,我们将探讨线性代数中最普遍使用的计算工具:解线性方程组。如您从高中代数中可能记得的那样,两个变量如xy的线性方程的解告诉我们两条直线在平面上的交点。一般来说,线性方程告诉我们直线、平面或更高维度的推广在向量空间中的交点。能够自动在 Python 中解决这个问题,我们将用它来构建一个视频游戏引擎的第一版。

2 使用二维向量绘图

本章涵盖了

  • 将二维绘图作为向量的集合创建和操作

  • 将二维向量视为箭头、位置和坐标的有序对

  • 使用向量算术来变换平面上的形状

  • 使用三角学来测量平面上的距离和角度

你可能已经对二维或三维的含义有一些直观的理解。一个二维(2D)对象就像一张纸上的图像或电脑屏幕上的图像一样平坦。它只有高度和宽度这两个维度。然而,在我们物理世界中,一个三维(3D)对象不仅有高度和宽度,还有深度。

二维和三维实体的模型在编程中很重要。任何出现在你手机、平板电脑或电脑屏幕上的东西都是一个二维对象,占据一定的像素宽度和高度。任何代表物理世界的模拟、游戏或动画都存储为三维数据,并最终投影到屏幕的两个维度上。在虚拟和增强现实应用中,三维模型必须与关于用户位置和视角的真实、测量过的三维数据相匹配。

尽管我们的日常经验发生在三维空间中,但将一些数据视为高维数据是有用的。在物理学中,通常将时间视为第四维度。当一个物体存在于三维空间中的某个位置时,一个事件发生在三维位置和指定的时间点。在数据科学问题中,数据集通常具有更多的维度。例如,一个在网站上追踪的用户可能有数百个可测量的属性,这些属性描述了使用模式。在图形学、物理学和数据分析中处理这些问题的框架需要处理高维数据的框架。这个框架是向量数学

向量是多维空间中的对象。它们有自己的算术概念(加法、乘法等)。我们首先研究二维向量,它们易于可视化和计算。在这本书中,我们使用了大量的二维向量,并且在推理更高维问题的时候,我们也把它们作为心理模型来使用。

2.1 描绘二维向量

二维世界就像一张纸或电脑屏幕一样平坦。在数学语言中,一个平坦的二维空间被称为平面。生活在二维平面上的物体具有高度和宽度这两个维度,但没有深度这个第三维度。同样,你可以通过两个信息来描述二维中的位置:它们的垂直和水平位置。为了描述平面上的点的位置,你需要一个参考点。我们称这个特殊的参考点为原点。图 2.1 展示了这种关系。

图片

图 2.1 在原点相对于几个点定位

有许多点可供选择,但我们必须将其中一个点固定为我们原点。为了区分它,我们用x而不是点标记原点,如图 2.1 所示。从原点出发,我们可以画一个箭头(如图 2.1 中的实心箭头)来显示另一个点的相对位置。

一个二维向量是相对于原点的平面上的一个点。等价地,你可以将向量视为平面上的直线箭头;任何箭头都可以放置在原点开始,并指示一个特定的点(见图 2.2)。

图片

图 2.2 在平面上叠加箭头表示相对于原点的点。

在本章以及之后的章节中,我们将使用箭头和点来表示向量。点很有用,因为我们可以用它们构建更有趣的图形。如果我像图 2.3 那样连接图 2.2 中的点,我就能得到一个恐龙的图形:

图片

图 2.3 在平面上连接点以绘制形状

每当计算机显示 2D 或 3D 图形时,从我的简陋恐龙到一部完整的皮克斯电影,它都是由点或向量连接起来以显示所需形状定义的。要创建你想要的图形,你需要选择正确的位置向量,这需要仔细的测量。让我们看看如何在平面上测量向量。

2.1.1 表示 2D 向量

使用尺子,我们可以测量一个维度,例如物体的长度。要测量两个维度,我们需要两个尺子. 这些尺子被称为坐标轴(单数形式为axis),我们将它们布局在平面中相互垂直,并在原点相交。用坐标轴绘制的图 2.4 显示了我们的恐龙具有上下和左右的概念。水平轴被称为x 轴,垂直轴被称为y 轴

图片

图 2.4 使用 x 轴和 y 轴绘制的恐龙。

使用标尺来定位,我们可以说,“四个点位于原点的上方和右侧。”但我们会希望比这更量化。尺子上带有刻度,显示我们沿着它测量的单位数。同样,在我们的二维图中,我们可以添加与坐标轴垂直的网格线,以显示点相对于它们的位置。按照惯例,我们在 x 轴和 y 轴的刻度 0 处放置原点(见图 2.5)。

在这个网格的背景下,我们可以测量平面中的向量。例如,在图 2.5 中,恐龙尾巴的尖端与 x 轴的正 6 和 y 轴的正 4 对齐。我们可以将这些距离视为厘米、英寸、像素或其他长度单位,但通常我们除非有特定的应用,否则不指定单位。

图片

图 2.5 网格线让我们能够测量点相对于坐标轴的位置。

数字 6 和 4 被称为该点的x-和 y 坐标,这足以告诉我们正在讨论哪个点。我们通常将坐标写成有序对(或元组),先写 x 坐标,再写 y 坐标,例如(6,4)。图 2.6 展示了我们现在可以用三种方式描述同一个向量。

图片

图 2.6 描述相同向量的三个心智模型。

从另一对坐标(-3,4.5)出发,我们可以找到平面上的点或表示它们的箭头。要到达具有这些坐标的平面上的点,从原点开始,然后向左移动三个网格线(因为 x 坐标是-3),然后向上移动四个半网格线(y 坐标是 4.5)。点不会位于两条网格线的交点处,但这没关系;任何一对实数都会给我们一个平面上的点。相应的箭头将是原点到该位置的直线路径,它指向上方和左方(如果你愿意,西北方)。试着自己画这个例子作为练习!

2.1.2 Python 中的 2D 绘图

当你在屏幕上生成图像时,你是在一个 2D 空间中工作。屏幕上的像素是该平面上的可用点。这些点用整数坐标而不是实数坐标标记,并且你不能照亮像素之间的空间。尽管如此,大多数图形库都允许你使用浮点坐标工作,并自动处理将图形转换为屏幕上的像素。

我们有很多语言选择和库来指定图形并将它们显示在屏幕上:OpenGL、CSS、SVG 等等。Python 有像 Pillow 和 Turtle 这样的库,非常适合用矢量数据创建绘图。在本章中,我使用一组自定义构建的函数来创建绘图,这些函数建立在另一个名为 Matplotlib 的 Python 库之上。这使得我们可以专注于使用 Python 构建矢量数据的图像。一旦你理解了这个过程,你将能够轻松地掌握其他任何库。

我包括的最重要函数是名为draw的函数,它接受表示几何对象的输入和指定你想要绘图外观的关键字参数。表 2.1 中列出的 Python 类代表每种可绘制的几何对象。

表 2.1 一些表示几何图形的 Python 类,可用于draw函数。

类别 构造函数示例 描述
Polygon Polygon(*vectors) 绘制一个多边形,其顶点(角)由向量列表表示
Points Points(*vectors) 表示要绘制的点(圆点)的列表,每个输入向量对应一个点
Arrow Arrow(tip)``Arrow(tip, tail) 从原点绘制到tip向量的箭头,或者如果指定了尾部,则从tail向量绘制到head向量
Segment Segment(start,end) 从起点到向量终点绘制线段

你可以在源代码的vector_drawing.py文件中找到这些函数的实现。在章节末尾,我会简要说明这些是如何实现的。

注意:对于本章(以及后续的每一章),源代码文件夹中都有一个 Jupyter 笔记本,展示了如何按顺序运行本章中的所有代码,包括从vector_drawing模块导入函数。如果您还没有设置,可以查阅附录 A 来配置 Python 和 Jupyter。

拥有这些绘图函数后,我们可以绘制恐龙轮廓的点(图 2.5):

from vector_drawing import *
dino_vectors = [(6,4), (3,1), (1,2), (−1,5), (−2,5), (−3,4), (−4,4),
     # insert 16 remaining vectors here
]

draw(
    Points(*dino_vectors)
)

我没有列出完整的dino_vectors列表,但有了合适的向量集合,代码会给出图 2.7 中显示的点(也匹配图 2.5)。

图片

图 2.7 使用 Python 的draw函数绘制恐龙的点

在我们的绘图过程中,下一步我们可以连接一些点。第一个线段可能连接恐龙尾巴上的点(6, 4)和点(3, 1)。我们可以使用这个函数调用绘制这些点以及新的线段,图 2.8 显示了结果:

draw(
    Points(*dino_vectors),
    Segment((6,4),(3,1))
)

图片

图 2.8 恐龙的两个点(6, 4)和(3, 1)之间的线段连接

线段实际上是包含点(6, 4)和(3, 1)以及它们之间所有点的集合。draw函数自动将这些点的所有像素着色为蓝色。Segment类是一个有用的抽象,因为我们不需要从构成我们的几何对象(在这种情况下,是恐龙)的点构建每个线段。绘制 20 更多线段,我们得到恐龙的完整轮廓(图 2.9)。

图片

图 2.9 总共 21 次函数调用产生了 21 条线段,完成了恐龙轮廓的绘制。

从原则上讲,我们现在可以绘制任何我们想要的 2D 形状,只要我们拥有指定它的所有向量。手动计算所有坐标可能会很繁琐,所以我们将开始研究使用向量进行计算以自动找到它们的坐标的方法。

2.1.3 练习

练习 2.1:恐龙脚尖的点的 x 和 y 坐标是什么?解答:(−1, −4)
练习 2.2:在平面上绘制点(2, −2)及其对应的箭头。解答:表示为平面上的点和箭头,(2, −2)看起来像这样:图片表示(2, −2)的点和箭头

| 练习 2.3:通过观察恐龙点的位置,推断出dino_vectors列表中未包含的剩余向量。例如,我已经包含了(6, 4),这是恐龙尾巴的尖端,但我没有包含点(-5, 3),这是恐龙鼻尖上的一个点。当你完成时,dino_vectors应该是一个包含 21 个向量的列表,表示为坐标对。解答:恐龙轮廓的完整向量集如下:

dino_vectors = [(6,4), (3,1), (1,2), (−1,5), (−2,5), (−3,4), (−4,4), 
    (−5,3), (−5,2), (−2,2), (−5,1), (−4,0), (−2,1), (−1,0), (0,−3), 
    (−1,−4), (1,−4), (2,−3), (1,−2), (3,−1), (5,1)
]

|

| 练习 2.4:通过构建一个以dino_vectors作为顶点的Polygon对象来连接点绘制恐龙。解答

draw(
    Points(*dino_vectors),
    Polygon(*dino_vectors)
)

将恐龙绘制为多边形。|

| 练习 2.5:使用draw函数绘制x在从x = -10 到x = 11 范围内的(x,x²)向量作为点(圆点)。结果是什么?解答:这些点绘制了函数 y = x²的图像,绘制了从 10 到 10 的整数:

draw(
    Points(*[(x,x**2) for *x* in range(−10,11)]),
    grid=(1,10),
    nice_aspect_ratio=False
)

|

2.2 平面向量算术

就像数字一样,向量也有它们自己的算术类型;我们可以通过操作来组合向量,从而得到新的向量。与向量不同的是,我们可以可视化结果。向量算术中的所有操作都完成了有用的几何变换,而不仅仅是代数变换。我们将从最基本操作开始:向量加法

向量加法计算简单:给定两个输入向量,将它们的x坐标相加得到结果x坐标,然后将它们的y坐标相加得到结果y坐标。用这些相加的坐标创建一个新的向量,就得到了原始向量的向量和。例如,(4, 3)+(-1, 1)=(3, 4),因为 4 +(-1)= 3,3 + 1 = 4。在 Python 中实现向量加法是一行代码:

def add(v1,v2):
    return (v1[0] + v2[0], v1[1] + v2[1])

由于我们可以将向量解释为箭头或平面上的点,因此我们可以用这两种方式可视化加法的结果(图 2.10)。作为一个平面上的点,你可以从原点(0, 0)开始,向左移动一个单位,向上移动一个单位,就能到达点(-1, 1)。通过从点(4, 3)开始,向左移动一个单位,向上移动一个单位,你可以得到向量之和(4, 3)+(-1, 1)。这也可以理解为先遍历一个箭头,然后遍历第二个箭头。

图 2.10 展示(4, 3)和(-1, 1)的向量和

箭头向量加法的规则有时被称为尾对尾相加。这是因为如果你将第二个箭头的尾端移动到第一个箭头的尖端(不改变其长度或方向!),那么和就是从第一个箭头的起点到第二个箭头的终点的箭头(图 2.11)。

图片

图 2.11 向量的尾对尾相加

当我们谈论箭头时,我们实际上是指“在特定方向上的特定距离。”如果你在一个方向上走一段距离,然后在另一个方向上走另一段距离,向量相加会告诉你你旅行的总距离和方向(图 2.12)。

图片

图 2.12 平面中旅行的总距离和方向向量和。

添加一个向量会影响到移动或平移现有的点或点的集合。如果我们将向量(-1.5, -2.5)添加到dino_vectors中的每一个向量,我们就会得到一个新的向量列表,其中每个向量都相对于原始向量向左移动 1.5 个单位,向下移动 2.5 个单位。以下是相应的代码:

dino_vectors2 = [add((−1.5,−2.5), v) for *v*  in dino_vectors]

结果是相同的恐龙形状,通过向量(-1.5, -2.5)向下和向左移动。为了看到这一点(图 2.13),我们可以将两个恐龙都画成多边形:

draw(
    Points(*dino_vectors, color=blue),
    Polygon(*dino_vectors, color=blue),
    Points(*dino_vectors2, color=red),
    Polygon(*dino_vectors2, color=red)
)

图片

图 2.13 原始恐龙(蓝色)和平移副本(红色)。平移后的恐龙上的每个点都从原始恐龙的位置向下和向左移动了(-1.5, -2.5)。

右侧副本中的箭头显示每个点都通过相同的向量向下和向左移动:(-1.5, -2.5)。这种平移在例如,如果我们想将恐龙变成一个 2D 电脑游戏中的移动角色时很有用。根据用户按下的按钮,恐龙可以在屏幕上相应地平移。我们将在第七章和第九章中实现这样的真实游戏,使用移动向量图形。

2.2.1 向量分量和长度

有时,将我们已有的向量分解为较小向量的和是有用的。例如,如果我需要纽约市的步行路线,听到“向东走四个街区,向北走三个街区”会比听到“向东北方向走 800 米”更有用。同样,将向量视为指向x方向的向量与指向y方向的向量的和也是有用的。

例如,图 2.14 显示了向量(4, 3)被重写为和(4, 0)+(0, 3)。将向量(4, 3)视为平面上的导航路径,和(4, 0)+(0, 3)将我们带到相同的位置,但路径不同。

图片

图 2.14 将向量(4, 3)分解为和(4, 0)+(0, 3)

两个向量(4, 0)和(0, 3)分别称为x 和 y 分量。如果你不能在这个平面上斜着走(就像纽约市一样),你需要向右走四个单位,然后向上走三个单位才能到达同一个目的地,总共七个单位。

向量的长度是表示它的箭头的长度,或者等价地,从原点到表示它的点的距离。在纽约市,这可能是两个交叉口的“飞鸟距离”。向量在xy方向的长度可以立即作为对应轴上经过的刻度数来衡量:(4, 0)或(0, 4)都是长度相同的向量,都是 4,尽管方向不同。然而,一般来说,向量可以沿着对角线放置,我们需要进行计算来得到它们的长度。

你可能还记得相关的公式:勾股定理。对于直角三角形(两个边在 90°角相遇的三角形),勾股定理表明最长边的长度的平方是其他两边长度的平方和。最长边称为斜边,其长度用公式中的c表示,即a² + b² = c²,其中ab是其他两边的长度。当a = 4 且b = 3 时,我们可以通过计算 4² + 3²的平方根来找到c(如图 2.15)。

图 2.15 使用勾股定理从 x 和 y 分量的长度找到向量的长度

将向量分解为分量是有用的,因为它总是给我们一个直角三角形。如果我们知道分量的长度,我们可以计算斜边的长度,即向量的长度。我们的向量(4, 3)等于(4, 0) + (0, 3),这是两个垂直向量的和,其边长分别为 4 和 3。向量(4, 3)的长度是 4² + 3²的平方根,即 25 的平方根,或 5。在一个完美方形的街区中,向东走 4 个街区,向北走 3 个街区,相当于向东北方向走了 5 个街区。

这是一个特殊情况,其中距离结果是整数,但通常,来自勾股定理的长度不是整数。向量(−3, 7)的长度通过以下计算以其组成部分 3 和 7 的长度表示:

我们可以将这个公式转换为 Python 中的length函数,它接受一个二维向量并返回其浮点长度:

from math import sqrt
def length(*v*):
    return sqrt(v[0]**2 + v[1]**2)

2.2.2 向量乘以数字

向量的重复相加是不含糊的;你可以一直将箭头尾对尾堆叠,直到你想要为止。如果一个名为v的向量坐标为(2, 1),那么五次相加v + v + v + v + v将看起来像图 2.16 所示的那样。

图 2.16 向量v = (2, 1)与其自身的重复相加

如果 v 是一个数,我们不会费心写出 v + v + v + v + v。相反,我们会写更简单的乘积 5 · v。我们没有理由不能对向量做同样的事情。向量的结果

v 加上自己 5 次是一个方向相同但长度增加 5 倍的向量。我们可以继续使用这个定义,它允许我们用任何整数或分数数乘以向量。

将向量乘以数字的操作称为 标量乘法。当处理向量时,普通数字通常称为 标量。这也是一个合适的术语,因为这种操作的效果是 按给定因子缩放 目标向量。标量是否为整数无关紧要;我们可以轻松地画出一个长度是另一个向量 2.5 倍的向量(图 2.17)。

图 2.17 向量 v 的标量乘法为 2.5

向量分量的结果是每个分量都按相同的因子缩放。你可以将标量乘法想象成改变由向量及其分量定义的直角三角形的尺寸,但不会影响其纵横比。图 2.18 叠加了一个向量 v 和其标量乘积 1.5 · v,其中标量乘积是 1.5 倍长。其分量也是 v 原始分量的 1.5 倍长。

图 2.18 向量的标量乘法按相同因子缩放两个分量。

在坐标中,向量 v = (6, 4) 的 1.5 倍标量乘法给我们一个新的向量 (9, 6),其中每个分量是其原始值的 1.5 倍。在计算上,我们通过将向量的每个坐标乘以标量来执行任何标量乘法。作为第二个例子,将向量 w = (1.2, −3.1) 以因子 6.5 进行缩放可以这样做:

6.5 · w = 6.5 · (1.2, −3.1) = (6.5 · 1.2, 6.5 · −3.1) = (7.8, −20.15)

我们测试了这个方法,将分数数作为标量,但我们也应该测试负数。如果我们的原始向量是 (6, 4),那么该向量的−½倍是多少?乘以坐标,我们预计答案将是 (−3, −2)。图 2.19 显示这个向量是原始长度的一半,并且指向相反方向。

图 2.19 向量乘以负数,−½

2.2.3 减法、位移和距离

标量乘法与我们对乘以数字的直觉一致。一个数的整数倍与重复求和相同,对于向量也是如此。我们可以对负向量和向量减法提出类似的论点。

给定一个向量 v,其 相反向量,-v,与标量乘法 −1 · v 相同。如果 v 是 (−4, 3),其相反向量,-v,是 (4, −3),如图 2.20 所示。我们通过将每个坐标乘以-1 得到这个结果,换句话说,改变每个坐标的符号。

图片

图 2.20 向量 v = (−4, 3) 和其相反向量 −v = (4, −3)。

在数轴上,从零只有两个方向:正方向和负方向。在平面上,有无数个方向(实际上确实如此),所以我们不能说 v 和 -v 中的一个为正,另一个为负。我们可以说的是,对于任何向量 v,其相反向量 -v 将具有相同的长度,但方向相反。

有向量取反的概念后,我们可以定义 向量减法。对于数字,xyx + (−y) 相同。我们为向量设定相同的规则。要从向量 v 中减去向量 w,你需要在 v 中加上向量 -w。将向量 vw 视为点,vwv 相对于 w 的位置。相反,将 vw 视为从原点开始的箭头,图 2.21 显示 vw 是从 w 的尖端到 v 的尖端的箭头。

图片

图 2.21 减去 vw 的结果是 w 的尖端到 v 的尖端的箭头。

vw 的坐标是 vw 的坐标之差。在图 2.21 中,v = (−1, 3) 和 w = (2, 2)。vw 的差异坐标为 (−1 − 2, 3 − 2) = (−3, 1)。

让我们再次看看向量 v = (−1, 3) 和 w = (2, 2) 的差异。你可以使用我给你的 draw 函数来绘制点 vw,并在它们之间绘制线段。代码如下:

draw(
    Points((2,2), (−1,3)),
    Segment((2,2), (−1,3), color=red)
)

向量 vw = (−3, 1) 的差异告诉我们,如果我们从点 w 出发,我们需要向左移动三个单位,向上移动一个单位才能到达点 v。这个向量有时被称为从 wv位移。图 2.22 中由这段 Python 代码绘制的从 wv 的直线、红色线段显示了这两个点之间的 距离

图片

图 2.22 平面上两点之间的距离

线段长度的计算使用勾股定理如下:

图片

虽然位移是一个向量,但距离是一个标量(一个单一的数字)。仅距离本身不足以指定如何从 wv;有许多点与 w 的距离相同。图 2.23 显示了一些具有整数坐标的其他点。

图片

图 2.23 与 w = (2, 2) 等距离的几个点

2.2.4 练习

练习 2.6: 如果向量 u = (−2, 0),向量 v = (1.5, 1.5),以及向量 w = (4, 1),那么 u + vv + w,以及 u + w 的结果是什么?u + v + w 的结果是什么?解答:给定向量 z = (−2, 0),向量 v = (1.5, 1.5),以及向量 w = (4, 1),结果如下:u + v = (−0.5, 1.5)v + w = (5.5, 2.5)u + w = (2, 1)u + v + w = (3.5, 2.5)

| 练习 2.7-迷你项目:你可以通过将所有向量的 x 坐标和所有向量的 y 坐标相加来相加任意数量的向量。例如,四重和 (1, 2) + (2, 4) + (3, 6) + (4, 8) 的 x 分量为 1 + 2 + 3 + 4 = 10,y 分量为 2 + 4 + 6 + 8 = 20,因此结果是 (10, 20)。实现一个修改后的 add 函数,该函数接受任意数量的向量作为参数。解答:

def add(*vectors):
    return (sum([v[0] for *v*  in vectors]), sum([v[1] for *v*  in vectors]))

|

| 练习 2.8:编写一个函数 translate(translation, vectors),该函数接受一个平移向量和输入向量的列表,并返回一个列表,其中所有输入向量都通过平移向量进行了平移。例如,translate ((1,1), [(0,0), (0,1,), (−3,−3)]) 应返回 [(1,1),(1,2),(−2, −2)]解答:

def translate(translation, vectors):
    return [add(translation, v) for *v*  in vectors]

|

练习 2.9-迷你项目:任意向量之和 v + w 给出的结果与 w + v 相同。使用坐标上的向量加法定义解释为什么这是真的。同时,画一个图来展示为什么在几何上这也是真的。解答:如果你将两个向量 z = (a, b) 和 v = (c, d) 相加,坐标 abc,和 d 都是实数。向量加法的结果是 z + v = (a + c, b + d)。结果
结果为 v + z 是 (c + a, d + b),这是一对相同的坐标,因为加实数时顺序无关。无论以何种顺序尾对尾加法,都会得到相同的和向量。从视觉上看,我们可以通过添加一个示例向量对尾对尾来看到这一点:无论以何种顺序尾对尾加法,都会得到相同的和向量。无论你加 z + v 还是 v + z(虚线),你都会得到相同的结果向量(实线)。在几何术语中,uv 定义了一个平行四边形,向量之和是对角线的长度。
练习 2.10:在以下三个箭头向量(标记为 uvw)中,哪一对的和给出了最长的箭头?哪一对的和给出了最短的箭头?!哪一对的和给出了最长或最短的箭头?解答:我们可以通过将向量尾对尾放置来测量每个向量之和:尾对尾加法检查结果,我们可以看到 v + z 是最短的向量(uv 几乎在相反的方向上,几乎相互抵消)。最长的向量是 v + w

| 练习 2.11-迷你项目:编写一个 Python 函数,使用向量加法来显示 100 个同时且不重叠的恐龙副本。这展示了计算机图形的强大功能;想象一下,如果用手动指定所有 2,100 个坐标对将多么繁琐!解答:通过一些尝试和错误,你可以将恐龙在垂直和水平方向上平移,使它们不重叠,并适当地设置边界。我决定省略网格线、坐标轴、原点和点,以使绘图更清晰。我的代码如下:

def hundred_dinos():
    translations = [(12*x,10*y) 
                    for *x* in range(−5,5) 
                    for y in range(−5,5)]
    dinos = [Polygon(*translate(t, dino_vectors),color=blue)
                for t in translations]
    draw(*dinos, grid=None, axes=None, origin=None)

hundred_dinos()

结果如下:100 只恐龙。快跑吧! |

练习 2.12:在向量(3, −2) + (1, 1) + (−2, −2)中,x分量和y分量哪个更长?解答:向量(3, −2) + (1, 1) + (−2, −2)的和是(2, −3)。x分量是(2, 0),y分量是(0, −3)。x分量的长度是 2 个单位(向右),而y分量的长度是 3 个单位(向下,因为它是负数)。这使得y分量更长。
练习 2.13:向量(−6, −6)和(5, −12)的分量和长度是什么?解答:(−6, −6)的分量是(−6, 0)和(0, −6),两者长度都是 6。向量(−6, −6)的长度是 6² + 6²的平方根,大约是 8.485。向量(5, −12)的分量是(5, 0)和(0, −12),长度分别为 5 和 12。向量(5, −12)的长度由 5² + 12² = 25 + 144 = 169 的平方根给出。平方根的结果正好是 13。
练习 2.14:假设我有一个向量v,其长度为 6,x分量为(1, 0)。v的可能坐标是什么?解答:(1, 0)的x分量长度为 1,总长度为 6,因此y分量的长度 b 必须满足方程 1² + b² = 6²,即 1 + b² = 36。然后 b² = 35,y分量的长度大约为 5.916。然而,这并没有告诉我们y分量的方向。向量v可以是(1, 5.916)或(1, −5.916)。

| 练习 2.15dino_vectors列表中的哪个向量长度最长?使用我们编写的length函数快速计算答案。解答

>>> max(dino_vectors, key=length)
(6, 4)

|

练习 2.16:假设向量w的坐标是(√2, √3)。π乘以w的大致坐标是什么?画出原始向量和新向量的近似图。解答:(√2, √3)的值大约是(1.4142135623730951, 1.7320508075688772)。将每个坐标乘以π(pi)的因子,我们得到(4.442882938158366, 5.441398092702653)。缩放后的向量比原始向量更长,如图所示:原始向量(较短)及其缩放版本(较长)

| 练习 2.17:编写一个 Python 函数scale(s,v),该函数将输入向量v乘以输入标量s解答

def scale(scalar,v):
    return (scalar * v[0], scalar * v[1])

|

练习 2.18-迷你项目:通过代数证明通过一个因子缩放坐标也会以相同的因子缩放向量的长度。假设一个长度为 c 的向量坐标为 (a, b). 证明对于任何非负实数 s,向量 (· a, s · b) 的长度是 s · c。 (对于 s 的负值,这是不可能的,因为向量不能有负长度。)解答:我们使用符号 |(a, b)| 表示向量 (a, b) 的长度。因此,练习的前提告诉我们:从那,我们可以计算 (sa, sb) 的长度:只要 s 不是负数,它就与其绝对值相同:s = |s|。然后缩放向量的长度是 sc,正如我们希望证明的那样。

| 练习 2.19-迷你项目:假设 z = (−1, 1) 和 v = (1, 1),并且假设 rs 是实数。具体来说,让我们假设 −3 < r < 3 和 −1 < s < 1. 在平面上,哪些可能的点使得向量 r · z + s · v 可能结束?请注意,向量的运算顺序与数字相同。我们假设标量乘法先进行,然后是向量加法(除非括号有其他指定)。解答:如果 r = 0,可能的位置在从 (−1, −1) 到 (1, 1) 的线段上。如果 r 不为零,可能的位置可以通过向 (−1, 1) 或 −(−1, 1) 方向移动最多三个单位离开那条线段。可能结果的范围是顶点在 (2, 4),(4, 2),(2, −4),和 (4, −2) 的平行四边形。我们可以测试许多随机、允许的 rs 的值来验证这一点:

from random import uniform
u = (−1,1)
v = (1,1)
def random_r():
    return uniform(−3,3)
def random_s(): 
    return uniform(−1,1)

possibilities = [add(scale(random_r(), u), scale(random_s(), v))
                 for i in range(0,500)]
draw(
    Points(*possibilities)
)

如果你运行此代码,你会得到以下图片,显示了在给定约束条件下 r • z + s • v 可能结束的可能点:图片给定约束条件下 ru + s ∙ v 的可能点位置。|

练习 2.20:通过代数证明一个向量及其相反向量具有相同的长度。提示:将坐标及其相反数代入勾股定理。解答:向量 (a, b) 的相反向量坐标为 (− a, − b), 但这不会影响长度:向量 (−a, −b) 与 (a, b) 具有相同的长度。
练习 2.21:在以下七个向量中,哪些两个是相反向量的一对?!图片解答:向量 v3 和 v7 是一对相反向量。
练习 2.22:假设z是任意二维向量。z + -u 的坐标是什么?解答:二维向量z有一些坐标(a, b)。它的相反向量有坐标(− a, − b),所以:u + (−u) = (a, b) + (− a, − b) = (aa, bb) = (0, 0)答案是(0, 0)。从几何学上来说,这意味着如果你跟随一个向量然后是其相反向量,你最终会回到原点,(0, 0)。
练习 2.23:对于向量u = (−2, 0),v = (1.5, 1.5),和w = (4, 1),向量减法vwzv,和w − v 的结果是什么?解答:给定z = (−2, 0),v = (1.5, 1.5),和w = (4, 1),我们有vw = (−2.5, 0.5)uv = (−3.5, −1.5)wv = (2.5, -0.5)

| 练习 2.24:编写一个 Python 函数subtract(v1,v2),该函数返回v1  - v2的结果,接受两个二维向量作为输入,并返回一个二维向量作为输出。解答

def subtract(v1,v2):
    return (v1[0] − v2[0], v1[1] − v2[1])

|

| 练习 2.25:编写一个 Python 函数distance(v1,v2),该函数返回两个输入向量之间的距离。(注意,前一个练习中的subtract函数已经给出了位移。)再编写另一个 Python 函数perimeter(vectors),该函数接受一个向量列表作为参数,并返回每个向量到下一个向量的距离之和,包括最后一个向量到第一个向量的距离。由dino_vectors定义的恐龙的周长是多少?解答:距离只是两个输入向量之差的长度:

def distance(v1,v2):
    return length(subtract(v1,v2))

对于周长,我们计算列表中每对后续向量的距离,以及第一对和最后一对向量的距离:

def perimeter(vectors):
    distances = [distance(vectors[i], vectors[(i+1)%len(vectors)])
                 for i in range(0,len(vectors))]
    return sum(distances)

我们可以用边长为 1 的正方形作为合理性检查:

>>> perimeter([(1,0),(1,1),(0,1),(0,0)])
4.0

然后,我们可以计算恐龙的周长:

>>> perimeter(dino_vectors)
44.77115093694563

|

| 练习 2.26-迷你项目:设z为向量(1, −1)。假设存在另一个向量v,其坐标为正整数(n, m),且n > xm,与 u 的距离为 13。从zv的位移是多少?提示:你可以使用 Python 来搜索向量 v。解答:我们只需要搜索可能的整数对(n, m),其中n在 1 的 13 个单位内,m在-1 的 13 个单位内:

for n in range(−12,15):
    for m in range(−14, 13):
        if distance((n,m), (1,−1)) == 13 and n > m > 0:
            print((n,m))

有一个结果:(13, 4)。它位于(1, −1)的右侧 12 个单位,上方 5 个单位,所以位移是(12, 5)。|

向量的长度不足以描述它,两个向量之间的距离也不足以从其中一个向量得到另一个向量。在这两种情况下,缺少的成分是方向。如果你知道一个向量的长度以及它指向的方向,你就可以识别它并找到它的坐标。在很大程度上,这就是三角学所涉及的内容,我们将在下一节中回顾这个主题。

2.3 平面中的角度和三角学

到目前为止,我们使用了两个“尺子”(称为 x 轴和 y 轴)来测量平面上的向量。从原点发出的箭头在水平和垂直方向上覆盖了一些可测量的位移,这些值唯一地指定了向量。我们完全可以只用一把尺子和一个量角器。从向量(4,3)开始,我们可以测量或计算出它的长度为 5 个单位,然后使用我们的量角器来确定方向,如图 2.24 所示。

图片

图 2.24 使用量角器测量向量指向的角度

这个向量长度为 5 个单位,它指向从正 x 轴正半轴逆时针大约 37°的方向。这给我们一组新的数字(5,37°),就像我们的原始坐标一样,唯一地指定了这个向量。这些数字被称为极坐标,和之前我们使用的笛卡尔坐标一样,可以很好地描述平面上的点。

有时候,比如当我们加向量时,使用笛卡尔坐标更容易;其他时候,极坐标更有用;例如,当我们想查看旋转了某个角度的向量时。在代码中,我们没有实际的尺子或量角器可用,所以我们使用三角函数来转换。

2.3.1 从角度到分量

让我们看看相反的问题:假设我们已经有了一个角度和距离,比如说 116.57°和 3。这些定义了一对极坐标(3,116.57°)。我们如何通过几何方法找到这个向量的笛卡尔坐标?

首先,我们可以将量角器放在原点以找到正确的方向。我们测量从正 x 轴逆时针 116.57°,并沿着这个方向画一条线(图 2.25)。我们的向量(3,116.57°)位于这条线上某处。

图片

图 2.25 使用量角器从正 x 轴测量 116.57°

下一步是拿一把尺子,测量从这个方向起距离原点三个单位的点。一旦我们找到了它,就像图 2.26 中所示,我们可以测量分量并得到我们的近似坐标(-1.34,2.68)。

图片

图 2.26 使用尺子测量距离原点三个单位的点的坐标

看起来 116.57°是一个随机选择的角度,但它有一个有用的性质。从原点开始并朝那个方向移动,每向左移动一个单位,就向上移动两个单位。大约沿着这条线的向量包括(-1,2),(-3,6)和当然还有(-1.34,2.68);y坐标是x坐标的两倍(图 2.27)。

图片

图 2.27 沿着 116.57°的方向前进,每向左移动一个单位,就向上移动两个单位。

奇怪的角度 116.57°恰好给出了一个很好的整数比率-2。我们并不总是这么幸运,能得到整数比率,但每个角度都给出一个恒定的比率。45°的角度给我们每个水平单位一个垂直单位,或者比率是 1。图 2.28 显示了另一个角度,200°。这给我们每覆盖-1 个水平单位 0.36 个垂直单位的恒定比率或 0.36。

图 2.28 在不同角度下,每单位水平距离覆盖的垂直距离是多少?

给定一个角度,该角度上的向量的坐标将有一个恒定的比例。这个比例称为该角度的正切,正切函数写作tan。你之前已经看到了它的一些近似值:

tan(37°) ≈ 3/4

tan(116.57°) ≈ -2

tan(45°) = 1

tan(200°) ≈ 0.36

在这里,为了表示近似相等,我使用符号≈而不是符号=。正切函数是一个三角函数,因为它帮助我们测量三角形。(“trigon”在“trigonometry”中的意思是三角形,“metric”的意思是测量。)请注意,我还没有告诉你如何计算正切,只是告诉你它的一些值。Python 有一个内置的正切函数,我很快会向你展示如何使用它。你几乎永远不必担心自己计算(或测量)角度的正切。

正切函数显然与我们的原始问题有关,即给定一个角度和距离来找到一个向量的笛卡尔坐标。但它实际上并不提供坐标,只提供它们的比率。为此,其他两个三角函数是有帮助的:正弦余弦。如果我们以某个角度测量一些距离(图 2.29),该角度的正切给出了垂直距离除以水平距离。

图 2.29 给定向量的距离和角度示意图

通过比较,正弦和余弦给出了相对于总距离的垂直和水平距离。这些简写为sincos,这个方程显示了它们的定义:

让我们以 37°的角度来看一个具体的例子(图 2.30)。我们看到了点(4,3)在这个角度下距离原点 5 个单位。

图 2.30 使用量角器测量到点(4,3)的角度

对于每 5 个单位的 37°行进,你大约覆盖 3 个垂直单位。因此,我们可以写成:

sin(37°) ≈ 3/5

同样,对于每 5 个单位的 37°行进,你大约覆盖 4 个水平单位,因此我们可以写成:

cos(37°) ≈ 4/5

这是一个将极坐标中的向量转换为相应笛卡尔坐标的一般策略。如果你知道角度 θ(希腊字母 theta,通常用于角度)的正弦和余弦以及在该方向上走过的距离 r,笛卡尔坐标由 (r · cos(θ), r · sin(θ)) 给出,并在图 2.31 中展示。

CH02_F31_Orland.png

图 2.31 展示了从极坐标到直角坐标系转换的图像

2.3.2 Python 中的弧度和三角学

让我们将我们关于三角学的复习内容转化为 Python 代码。具体来说,让我们构建一个函数,它接受一对极坐标(长度和角度)并输出一对笛卡尔坐标(xy 分量的长度)。

主要的难点是 Python 的内置三角函数使用的单位与我们使用的不同。例如,我们期望 tan(45°) = 1,但 Python 给出的结果却大不相同:

>>> from math import tan
>>> tan(45)
1.6197751905438615

Python 不使用度数,大多数数学家也不使用。相反,他们使用称为 弧度 的单位来测量角度。转换系数是

1 弧度 ≈ 57.296°

这可能看起来像是一个任意的转换系数。以下是一些关于度和弧度之间更具有启发性的关系,这些关系以特殊数字 π(pi)的形式给出,其值约为 3.14159。这里有一些例子:

π 弧度 = 180°

2π 弧度 = 360°

在弧度中,半圈是一个 π 弧度,整个旋转是 2π。这分别与半径为 1 的圆的半周和整个周长相对应(图 2.32)。

CH02_F32_Orland.png

图 2.32 半圈是 π 弧度,而整个旋转是 2π 弧度。

你可以将弧度视为另一种比例:对于给定的角度,其弧度测量值告诉你围绕圆周走了多少半径。由于这个特殊性质,没有单位的角测量值被认为是弧度。注意到 45° = π/4 (弧度),我们可以得到这个角度的正切值的正确结果:

>>> from math import tan, pi
>>> tan(pi/4)
0.9999999999999999

我们现在可以利用 Python 的三角函数编写一个 to_cartesian 函数,它接受一对极坐标并返回相应的笛卡尔坐标:

from math import sin, cos
def to_cartesian(polar_vector):
    length, angle = polar_vector[0], polar_vector[1]
    return (length*cos(angle), length*sin(angle))

使用这个,我们可以验证 5 个单位在 37° 的角度下可以接近点 (4, 3):

>>> from math import pi
>>> angle = 37*pi/180
>>> to_cartesian((5,angle))
(3.993177550236464, 3.0090751157602416)

现在我们已经可以从极坐标转换为笛卡尔坐标,让我们看看如何进行相反的转换。

2.3.3 从分量回到角度

给定一对笛卡尔坐标,例如 (−2, 3),我们知道如何使用勾股定理来找到长度。在这种情况下,它确实是w_gifs_99.gif,这是我们正在寻找的两个极坐标中的第一个。第二个是角度,我们可以称之为 θ (theta),表示这个向量的方向(图 2.33)。

CH02_F33_Orland.png

图 2.33 向量 (−2, 3) 指向什么角度?

我们可以对我们寻找的角度θ说一些事实。它的正切,tan(θ),是 3/2,而 sin(θ) = 3/√13,cos(θ) = −2/√13。剩下要做的就是找到一个满足这些条件的θ值。如果你愿意,你可以暂停并尝试通过猜测和检查来近似这个角度。

理想情况下,我们希望有一个比这更有效的方法。如果有一个函数可以接受 sin(θ)的值,例如,并返回θ,那将是极好的。这听起来比实际做起来容易,但 Python 的math.asin函数做出了良好的尝试。这是一个称为反正弦函数的反三角函数实现,并返回令人满意的θ值:

>>> from math import asin
>>> sin(1)
0.8414709848078965
>>> asin(0.8414709848078965)
1.0

到目前为止,一切顺利。但我们的角度 3/√13 的正弦值是多少呢?

>>> from math import sqrt
>>> asin(3/sqrt(13))
0.9827937232473292

这个角度大约是 56.3°,如图 2.34 所示,这是错误的方向!

图片

图 2.34 Python 的math.asin函数似乎给出了错误的角度。

math.asin给出这个答案并不错误;另一个点(2,3)确实位于这个方向上。它距离原点有长度图片。这就是为什么math.asin对我们来说不是完整的解决方案。存在多个角度可以有相同的正弦值。

反三角函数,称为余弦反函数,在 Python 中通过math.acos实现,恰好给出了正确的值:

>>> from math import acos
>>> acos(−2/sqrt(13))
2.1587989303424644

这样多的弧度大约等于 123.7°,我们可以用量角器来确认这是正确的。但这只是巧合;还有其他角度也可以给出相同的余弦值。例如,(−2,−3)也有图片距离原点,因此它位于与θ相同余弦的角度上:−2图片。为了找到我们真正想要的θ值,我们必须确保正弦和余弦都与我们的预期相符。Python 返回的角度,大约是 2.159,满足这个条件:

>> cos(2.1587989303424644)
-0.5547001962252293
>>> −2/sqrt(13)
-0.5547001962252291
>>> sin(2.1587989303424644)
0.8320502943378435
>>> 3/sqrt(13)
0.8320502943378437

任何反正弦、反余弦或反正切函数都不足以找到平面上的点到角度。通过你可能在上高中的三角学课程中学到的巧妙几何论证,确实可以找到正确角度。我将把它留作练习,直接切入正题−Python 可以为你做这项工作!math.atan2函数接受平面内一个点的笛卡尔坐标(顺序相反!)并返回它所在的角度。例如,

>>> from math import atan2
>>> atan2(3,−2)
2.158798930342464

我为埋没了重点表示歉意,但我这样做是因为了解使用反正切函数的潜在陷阱是值得的。总的来说,反三角函数在逆向操作时很棘手;多个不同的输入可以产生相同的输出,因此输出不能追溯到唯一的输入。这使得我们可以完成我们最初设定的函数:一个从笛卡尔坐标系到极坐标系的转换器:

def to_polar(vector):
    x, y = vector[0], vector[1]
    angle = atan2(y,x)
    return (length(vector), angle)

我们可以验证一些简单的例子:to_polar((1,0)) 应该是正 x 方向的一个单位或者零度角。确实,这个函数给我们一个零度角和长度为 1:

>>> to_polar((1,0))
(1.0, 0.0)

(输入和输出相同是巧合;它们有不同的几何意义。)同样,我们得到了预期的答案 (−2, 3):

>>> to_polar((−2,3))
(3.605551275463989, 2.158798930342464)

2.3.4  练习

| 练习 2.27: 确认由笛卡尔坐标 (−1.34, 2.68) 给出的向量长度约为 3,正如预期的那样。解答:

>>> length((−1.34,2.68))
2.9963310898497184

差不多就是了! |

练习 2.28: 图中显示了一条与正 x 轴逆时针形成 22° 角的直线。根据以下图片,tan(22°)的大致值是多少?!解答: 这条线接近点 (10, 4),所以 4 / 10 = 0.4 是 tan(22°) 的一个合理的近似,如图所示:!
练习 2.29: 反过来,如果我们知道一个向量的长度和方向,想要找到它的分量。长度为 15 且指向 37° 角的向量的 x 和 y 分量是什么?解答: 37°的正弦值大约是 3/5,这告诉我们,在这个角度上,每 5 个单位的距离会向上移动 3 个单位。所以,15 个单位的距离给我们一个垂直分量是 3/5 · 15,即 9。37°的余弦值大约是 4/5,这告诉我们,在这个方向上,每 5 个单位的距离会向右移动 4 个单位,所以水平分量是 4/5 · 15 或 12。总之,极坐标 (15, 37°) 大约对应于笛卡尔坐标 (12, 9)。
练习 2.30: 假设我从原点出发,以逆时针从正 x 轴测量的 125° 角度移动了 8.5 个单位。已知 sin(125°) = 0.819 和 cos(125°) = -0.574,我的最终坐标是什么?画一个图来显示角度和走过的路径。解答: x = r · cos(θ) = 8.5 · -0.574 = −4.879,y = r · sin(θ) = 8.5 · 0.819 = 6.962。以下图显示了最终位置,(−4.879, 6.962):!
练习 2.31: 0°、90°、180°的正弦和余弦值是多少?换句话说,在这些方向上,每单位距离覆盖了多少垂直和水平单位?解答: 在 0° 时,没有覆盖垂直距离,所以 sin(0°) = 0;相反,每单位距离的移动都是水平距离的单位,所以 cos(0°) = 1。对于 90°(逆时针四分之一转),每单位移动都是正垂直单位,所以 sin(90°) = 1,而 cos(90°) = 0。最后,在 180° 时,每单位距离的移动都是 x 方向的负单位,所以 cos(180°) = −1 和 sin(180°) = 0。
练习 2.32: 下图给出了一些直角三角形的精确测量值:首先,确认这些长度对于直角三角形是有效的,因为它们满足勾股定理。然后,使用图中的测量值计算 sin(30°)、cos(30°) 和 tan(30°) 的值,精确到小数点后三位!图片解答:这些边长确实满足勾股定理!图片将边长代入勾股定理,三角函数的值由边长适当的比值给出!图片通过它们的定义计算正弦、余弦和正切值
练习 2.33: 从不同角度观察上一个练习中的三角形,使用它来计算 sin(60°)、cos(60°) 和 tan(60°) 的值,精确到小数点后三位。解答:旋转和反射上一个练习中的三角形不会影响其边长或角度!图片上一个练习中三角形的旋转副本,边长的比值给出了 60° 的三角函数值!图片当水平和垂直分量交换时,计算定义比值
练习 2.34: 50° 的余弦值是 0.643。sin(50°) 和 tan(50°) 是多少?画一个图来帮助你计算答案。解答:已知 50° 的余弦值是 0.643,以下三角形是有效的!图片也就是说,它具有两个已知边长的正确比例:0.643 / 1 = 0.643。要找到未知边长,我们可以使用勾股定理!图片有了已知的边长,sin(50°) = 0.766/1 = 0.766。同样,tan(50°) = 0.766/0.643 = 1.192。

| 练习 2.35: 116.57° 是多少弧度?使用 Python 计算这个角度的正切值,并确认它接近我们之前看到的 -2。解答:116.57° · (1 弧度/57.296°) = 2.035 弧度:

>>> from math import tan
>>> tan(2.035)
−1.9972227673316139

|

| 练习 2.36: 找到角度 10π/6。你预计 cos(10π/6) 和 sin(10π/6) 的值是正还是负?使用 Python 计算它们的值并确认。解答:整个圆是 2π 弧度,所以 π/6 是圆的十二分之一。你可以想象将披萨切成 12 片,并从正 x 轴逆时针计数;10π/6 的角度是离完整旋转两片。这意味着它指向下方和右侧。余弦应该是正的,而正弦应该是负的,因为在这个方向上的距离对应于正的水平位移和负的垂直位移:

>>> from math import pi, cos, sin
>>> sin(10*pi/6)
-0.8660254037844386
>>> cos(10*pi/6)
0.5000000000000001

|

| 练习 2.37: 以下列表推导创建了 1,000 个极坐标点:

[(cos(5*x*pi/500.0), 2*pi*x/1000.0) for *x* in range(0,1000)]

在 Python 代码中,将它们转换为笛卡尔坐标系,并用线段连接成闭合回路以绘制图形。解答: 包括设置和原始数据列表,代码如下:

polar_coords = [(cos(x*pi/100.0), 2*pi*x/1000.0) for *x* in range(0,1000)]
vectors = [to_cartesian(p) for p in polar_coords]
draw(Polygon(*vectors, color=green))

结果是一个五瓣花:1,000 个连接点的图是一个花形。|

| 练习 2.38: 通过“猜测和检查”找到到达点 (−2, 3) 的角度。到达点 (−2, 3) 的角度是多少?提示: 我们可以直观地看出答案在 π/2 和 π 之间。在这个区间内,正弦和余弦的值随着角度的增加而总是减少。解答: 这是一个略小于顺时针方向四分之一转的例子。这里是在 π/2 和 π 之间猜测和检查,寻找切线接近 −3/2 = −1.5 的角度:

>>> from math import tan, pi
>>> pi, pi/2
(3.141592653589793, 1.5707963267948966)
>>> tan(1.8)
−4.286261674628062
>>> tan(2.5)
-0.7470222972386603
>>> tan(2.2)
−1.3738230567687946
>>> tan(2.1)
−1.7098465429045073
>>> tan(2.15)
−1.5289797578045665
>>> tan(2.16)
−1.496103541616277
>>> tan(2.155)
−1.5124173422757465
>>> tan(2.156)
−1.5091348993879299
>>> tan(2.157)
−1.5058623488727219
>>> tan(2.158)
−1.5025996395625054
>>> tan(2.159)
−1.4993467206361923

值必须在 2.158 和 2.159 之间。|

| 练习 2.39: 找到平面上与 θ 相同切线的另一点,即 −3/2。使用 Python 的反正切函数 math.atan 来找到这个角度的值。解答: 切线为 −3/2 的另一点是 (3, −2)。Python 的 math.atan 找到这个点的角度:

>>> from math import atan
>>> atan(−3/2)
-0.982793723247329

这略小于顺时针方向的四分之一转。|

练习 2.40: 不使用 Python,对应于笛卡尔坐标系 (1, 1) 和 (1, −1) 的极坐标是什么?一旦找到答案,使用 to_polar 来检查你的工作。解答: 在极坐标中,(1, 1) 变为 (√2, π/4) 和 (1, −1) 变为 (√2, −π/4)。通过一些小心,你可以找到由已知向量组成的形状上的任何角度。两个向量之间的角度是这些角度与 x 轴的求和或差。你将在下一个迷你项目中测量一些更复杂的角度。
练习 2.41-迷你项目: 恐龙的嘴巴角度是多少?恐龙的脚趾角度是多少?它的尾巴尖端的角度是多少?!我们可以在我们的恐龙上测量或计算一些角度。

2.4 向量集合的转换

向量集合存储空间数据,如恐龙的绘画,无论我们使用什么坐标系:极坐标或笛卡尔坐标系。结果是,当我们想要操作向量时,一个坐标系可能比另一个坐标系更好。我们已经看到,使用笛卡尔坐标系移动(或平移)向量集合很容易。在极坐标系中,这要困难得多。因为极坐标系内置了角度,这使得执行旋转变得简单。

在极坐标中,向角度增加会使向量进一步逆时针旋转,而从中减去会使向量顺时针旋转。极坐标 (1, 2) 距离为 1,角度为 2 弧度。(记住,如果没有度数符号,我们是在弧度下工作!)从角度 2 开始,增加或减少 1 将使向量分别逆时针或顺时针旋转 1 弧度(见图 2.35)。

图 2.35 从角度上添加或减去旋转会使向量绕原点旋转。

同时旋转多个向量会使这些向量所代表的图形绕原点旋转。draw函数只理解笛卡尔坐标,因此在使用之前我们需要将其从极坐标转换为笛卡尔坐标。同样,我们只看到了如何在极坐标中旋转向量,因此我们需要在执行旋转之前将笛卡尔坐标转换为极坐标。使用这种方法,我们可以这样旋转恐龙:

rotation_angle = pi/4
dino_polar = [to_polar(*v*) for *v*  in dino_vectors]
dino_rotated_polar = [(l,angle + rotation_angle) for l,angle in dino_polar]
dino_rotated = [to_cartesian(p) for p in dino_rotated_polar]
draw(
    Polygon(*dino_vectors, color=gray),
    Polygon(*dino_rotated, color=red)
)

此代码的结果是原始恐龙的灰色副本,加上一个叠加的红色副本,该副本旋转了π/4,即四分之一完整的逆时针旋转(见图 2.36)。

图 2.36 原始恐龙以灰色呈现,以及一个旋转后的红色副本

作为本节末尾的一个练习,你可以编写一个通用的rotate函数,该函数将相同指定的角度旋转向量列表。我将在接下来的几个示例中使用这样的函数,你可以使用我在源代码中提供的实现,或者自己编写一个。

2.4.1 组合向量变换

到目前为止,我们已经看到了如何平移、缩放和旋转向量。将这些变换应用于向量集合会在平面上定义的形状上产生相同的效果。这些向量变换的全部力量在于我们按顺序应用它们。

例如,我们首先旋转恐龙,然后进行平移。使用 2.2.4 节中的translate函数和rotate函数,我们可以简洁地写出这样的变换(见图 2.37 的结果):

new_dino = translate((8,8), rotate(5 * pi/3, dino_vectors))

旋转首先进行,将恐龙逆时针旋转 5π/3,即大部分逆时针旋转。然后恐龙向上和向右平移 8 个单位。正如你所想象的那样,适当地组合旋转和平移可以将恐龙(或任何形状)移动到平面上的任何所需位置和方向。无论我们是在电影中还是在游戏中动画化我们的恐龙,使用向量变换来移动它的灵活性让我们能够通过编程赋予它生命。

图 2.37 原始恐龙以灰色呈现,以及一个旋转并平移后的红色副本

我们的应用很快就会超越卡通恐龙;还有许多其他对向量的操作,并且许多可以推广到更高维度。现实世界的数据集通常存在于数十或数百个维度中,因此我们也将对这些数据应用相同的变换。通常,对数据集进行平移和旋转可以使重要特征更加清晰。我们无法想象 100 维度的旋转,但我们总是可以将二维视为一个可靠的隐喻。

2.4.2 练习

| 练习 2.42:创建一个 rotate(angle, vectors) 函数,该函数接受一个输入向量的笛卡尔坐标数组,并按指定角度旋转这些向量(根据角度的正负,逆时针或顺时针旋转)。解决方案

def rotate(angle, vectors):
    polars = [to_polar(*v*) for *v*  in vectors]
    return [to_cartesian((l, a+angle)) for l,a in polars]

|

| 练习 2.43:创建一个名为 regular_polygon(n) 的函数,该函数返回正 n 边形(即所有角度和边长都相等)的顶点的笛卡尔坐标。例如,polygon(7) 生成定义以下七边形的向量:一个具有七个均匀分布角度的点的正七边形提示:在这张图中,我使用了向量 (1, 0) 和围绕原点旋转的七个均匀分布的角度的副本。解决方案

def regular_polygon(n):
    return [to_cartesian((1, 2*pi*k/n)) for k in range(0,n)]

|

练习 2.44:首先将恐龙平移向量 (8, 8),然后旋转 5π/3,结果是什么?结果与先旋转再平移相同吗?解决方案先平移再旋转恐龙结果不同。一般来说,以不同的顺序应用旋转和平移会产生不同的结果。

2.5 使用 Matplotlib 绘图

正如承诺的那样,我将通过向你展示如何从 Matplotlib 库中构建本章使用的绘图函数“从头开始”来结束。在用 pip 安装 Matplotlib 后,你可以导入它(以及一些它的子模块);例如,

import matplotlib
from matplotlib.patches import Polygon
from matplotlib.collections import PatchCollection

PolygonPointsArrowSegment 类并不那么有趣;它们只是简单地在其构造函数中存储传递给它们的数据。例如,Points 类只包含一个构造函数,该构造函数接收并存储一个向量列表和一个颜色关键字参数:

class Points():
    def __init__(self, *vectors, color=black):
        self.vectors = list(vectors)
        self.color = color

draw 函数首先确定绘图的大小,然后逐个绘制传递给它的每个对象。例如,要在表示 Points 对象的平面上绘制点,draw 使用 Matplotlib 的散点图功能:

def draw(*objects, ...
    # ...                                         ❶
    for object in objects:                        ❷
    # ... 
        elif type(object) == Points:              ❸
            xs = [v[0] for *v*  in object.vectors]
            ys = [v[1] for *v*  in object.vectors]
            plt.scatter(xs, ys, color=object.color)
        # ...

❶ 这里发生了一些设置,但未显示。

❷ 遍历传入的对象

❸ 如果当前对象是 Points 类的实例,则使用 Matplotlib 的 scatter 函数为其所有向量绘制点

箭头、线段和多边形以类似的方式处理,使用不同的预构建 Matplotlib 函数使几何对象出现在图上。你可以在源代码文件 vector_drawing.py 中找到所有这些实现。我们将在这本书中使用 Matplotlib 绘制数据和数学函数,并且随着我们的使用,我会定期提供其功能的更新。

现在你已经掌握了二维空间,你准备好添加另一个维度。有了第三个维度,我们可以完全描述我们所生活的世界。在下一章中,你将看到如何在代码中建模三维对象。

摘要

  • 向量是存在于多维空间中的数学对象。这些可以是几何空间,如计算机屏幕上的二维(2D)平面或我们居住的三维(3D)世界。

  • 你可以将向量等价地视为具有指定长度和方向的箭头,或者视为相对于称为原点的参考点的平面上的点。给定一个点,有一个相应的箭头显示如何从原点到达该点。

  • 你可以在平面上连接点集合,形成像恐龙这样的有趣形状。

  • 在 2D 中,坐标是帮助我们测量平面上点位置的数字对。写成元组(xy),xy的值告诉我们水平方向和垂直方向要移动多远才能到达该点。

  • 我们可以在 Python 中将点存储为坐标元组,并从多个库中选择来在屏幕上绘制这些点。

  • 向量加法的效果是将第一个向量在第二个加向量方向上移动(或移动)。将向量集合视为旅行路径,它们的向量和给出了整体的方向和行进距离。

  • 向量与一个数值因子进行标量乘法运算会产生一个新的向量,其长度增加该因子,且方向与原始向量相同。

  • 从第二个向量中减去一个向量给出了第二个向量相对于第一个向量的相对位置。

  • 向量可以通过其长度和方向(作为一个角度)来指定。这两个数字定义了给定二维向量的极坐标。

  • 正弦、余弦和正切等三角函数用于在普通(笛卡尔)坐标系和极坐标系之间进行转换。

  • 在极坐标系中,通过向量集合定义的形状很容易旋转。你只需将给定的旋转角度加到或从每个向量的角度中减去。在平面上旋转和移动形状,使我们能够将它们放置在任何位置和任何方向。

3 升级到三维世界

本章涵盖

  • 为 3D 矢量建立心理模型

  • 进行 3D 矢量运算

  • 使用点积和叉积来测量长度和方向

  • 在 2D 中渲染 3D 对象

2D 世界容易可视化,但现实世界有三个维度。无论我们是用软件设计建筑、制作动画电影还是运行动作游戏,我们的程序都需要意识到我们生活的三个空间维度。

在一个 2D 空间中,就像这本书的页面一样,我们有一个垂直和水平方向。增加一个第三维度,我们也可以谈论页面外的点或垂直于页面的箭头。但即使程序模拟三维,大多数计算机显示器仍然是二维的。本章的使命是构建我们需要的工具,将 3D 矢量测量的 3D 对象转换为 2D,以便我们的对象可以在屏幕上显示。

球体是 3D 形状的一个例子。一个成功绘制的 3D 球体可能看起来像图 3.1 中所示的那样。如果没有阴影,它看起来就像一个圆。

图 3.1 在 2D 圆上的阴影使其看起来像一个 3D 球体。

阴影表明光线以一定的角度击中我们的球体,给它一种深度错觉。我们的总体策略不是绘制一个完美的球体,而是一个由多边形组成的近似。每个多边形都可以根据它与光源形成的精确角度进行着色。信不信由你,图 3.1 不是圆球的图片,而是 8,000 个不同阴影的三角形。图 3.2 显示了另一个具有较少三角形的例子。

 

图 3.2 使用许多小、单色的三角形绘制阴影球体

我们有数学工具在 2D 屏幕上定义一个三角形:我们只需要定义角落的三个 2D 矢量。但除非我们也认为它们在三维空间中有生命,否则我们无法决定如何给它们上色。为此,我们需要学会与 3D 矢量一起工作。

当然,这已经是一个已解决的问题,我们首先使用预构建的库来绘制我们的 3D 形状。一旦我们对 3D 矢量的世界有了感觉,我们就可以构建自己的渲染器,并展示如何绘制球体。

3.1 在 3D 空间中描绘矢量

在 2D 平面上,我们使用了三个可互换的矢量心理模型:坐标对、固定长度和方向的箭头,以及相对于原点的点。由于这本书的页面大小有限,我们只将视野限制在平面的一小部分——一个高度和宽度固定的矩形,如图 3.3 所示。

图 3.3 2D 平面上一个小段的高度和宽度

我们可以用类似的方式解释 3D 向量。我们不是查看平面的矩形部分,而是从一个有限的 3D 空间盒子开始。如图 3.4 所示,这样的 3D 盒子具有有限的高度、宽度和深度。在 3D 中,我们保持xy方向的概念,并添加一个z方向来测量深度。

图片

图 3.4 一个 3D 空间的小有限盒子具有宽度(x)、高度(y)和深度(z)。

我们可以将任何 2D 向量视为存在于 3D 空间中,具有相同的大小和方向,但固定在一个平面上,其中深度z为零。图 3.5 显示了向量(4, 3)在 3D 空间中的 2D 绘制,保留了它之前所有的特征。第二幅图(在底部)标注了所有仍然包含的特征。

图片

图 3.5 包含在 3D 世界中的 2D 世界和居民向量(4, 3)

虚线形成了一个在深度为零的 2D 平面上的矩形。画虚线相交成直角有助于我们在 3D 中定位点。否则,我们的视角可能会欺骗我们,一个点可能不在我们认为它所在的位置。

我们的向量仍然生活在平面上,但现在我们也可以看到它生活在更大的 3D 空间中。我们可以在原始平面上方绘制另一个 3D 向量(一个新的箭头和一个新的点),它延伸到更高的深度值(图 3.6)。

图片

图 3.6 与图 3.5 的 2D 世界及其居民向量(4, 3)相比,一个向量延伸到第三维度

为了使第二个向量的位置清晰,我画了一个虚线盒子而不是图 3.5 中的虚线矩形。在图 3.6 中,这个盒子显示了向量在 3D 空间中覆盖的长度、宽度和深度。箭头和点在 3D 中作为向量的心理模型,就像在 2D 中一样,我们可以用坐标相似地测量它们。

3.1.1 使用坐标表示 3D 向量

这一对数字(4, 3)足以指定 2D 中的一个单独的点或箭头,但在 3D 中,有无数个点具有x坐标为 4 和y坐标为 3。事实上,如图 3.7 所示,有一个整个点线在 3D 空间中具有这些坐标,每个点在z(或深度)方向上都有不同的位置。

图片

图 3.7 几个具有相同的 x 和 y 坐标但具有不同 z 坐标的向量

要指定 3D 中的一个唯一点,我们需要总共三个数字。像(4, 3, 5)这样的数字组合被称为 3D 向量中的xyz坐标。和之前一样,我们可以将这些读作找到所需点的指令。如图 3.8 所示,要到达点(4, 3, 5),我们首先在x方向上移动+4 个单位,然后在y方向上移动+3 个单位,最后在z方向上移动+5 个单位。

图片

图 3.8 三个坐标(4, 3, 5)为我们提供了到达 3D 中一个点的方向。

3.1.2 使用 Python 进行 3D 绘制

与上一章一样,我使用 Python 的 Matplotlib 库的包装器来绘制 3D 向量图。您可以在本书的源代码中找到实现,但我会坚持使用包装器来关注绘制的过程概念,而不是 Matplotlib 的细节。

我的包装器使用新的类如 Points3DArrow3D 来区分 3D 对象和它们的 2D 对应物。一个新的函数 draw3d 知道如何解释和渲染这些对象,以便使它们看起来是三维的。默认情况下,draw3d() 显示坐标轴和原点,以及一个小型的 3D 空间框(图 3.9),即使没有指定要绘制的对象。

图片

图 3.9 使用 Matplotlib 的 draw3d() 绘制空 3D 区域

尽管由于我们的视角而显得倾斜,但绘制的 x、y 和 z 轴在空间中是垂直的。为了提高视觉效果,Matplotlib 将单位显示在框外,但原点和坐标轴本身显示在框内。原点是坐标 (0, 0, 0),坐标轴从它向正负 xyz 方向延伸。

Points3D 类存储了我们想要将其视为点并因此绘制为 3D 空间中点的向量集合。例如,我们可以使用以下代码绘制向量 (2, 2, 2) 和 (1, −2, −2),该代码生成图 3.10:

draw3d(
    Points3D((2,2,2),(1,−2,−2))
)

图片

图 3.10 绘制点 (2, 2, 2) 和 (1, −2, −2)

要将这些向量可视化为箭头,我们可以将向量表示为 Arrow3D 对象。我们还可以使用 Segment3D 对象连接箭头的尖端,如下所示,生成图 3.11:

draw3d(
    Points3D((2,2,2),(1,−2,−2)),
    Arrow3D((2,2,2)),
    Arrow3D((1,−2,−2)),
    Segment3D((2,2,2), (1,−2,−2))
)

图片

图 3.11 绘制 3D 箭头

在图 3.11 中,箭头指向的方向有点难以看清。为了使其更清晰,我们可以围绕箭头绘制虚线框,使其看起来更有三维感。由于我们将频繁地绘制这些框,我创建了一个 Box3D 类来表示一个角落位于原点,相对角落位于给定点的框。图 3.12 展示了 3D 框,但首先,这是代码:

draw3d(
    Points3D((2,2,2),(1,−2,−2)),
    Arrow3D((2,2,2)),
    Arrow3D((1,−2,−2)),
    Segment3D((2,2,2), (1,−2,−2)),
    Box3D(2,2,2),
    Box3D(1,−2,−2)
)

图 3.12 绘制框以使我们的箭头看起来像 3D

在本章中,我使用了多个(希望是自解释的)关键字参数,而没有明确介绍它们。例如,可以将 color 关键字参数传递给这些构造函数中的大多数,以控制绘制中出现的对象的颜色。

3.1.3 练习

练习 3.1:绘制表示坐标 (−1, −2, 2) 的 3D 箭头和虚线框,使箭头看起来像 3D。为了练习,请手动绘制此图,但从现在起,我们将使用 Python 来绘制。解答图片向量 (−1, −2, 2) 和使其看起来像 3D 的框

| 练习 3.2-迷你项目:恰好有八个三维向量的坐标都是+1 或-1。例如,(1, -1, 1)就是其中之一。将这些八个向量作为点绘制出来。然后找出如何使用Segment3D对象通过线段将它们连接起来,以形成立方体的轮廓。提示:总共需要 12 条线段。解决方案:因为只有 8 个顶点和 12 条边,所以列出它们并不太繁琐,但我决定使用列表推导来枚举它们。对于顶点,我让xyz在可能的值列表[1,−1]上遍历,并收集了八个结果。对于边,我将它们分为三组,每组四条,指向每个坐标方向。例如,有四条边从x = −1 到x = 1,而它们的yz坐标在两端点都是相同的:

pm1 = [1,−1]
vertices = [(x,y,z) for *x* in pm1 for y in pm1 for z in pm1]
edges = [((−1,y,z),(1,y,z)) for y in pm1 for z in pm1] +\
            [((x,−1,z),(x,1,z)) for *x* in pm1 for z in pm1] +\
            [((x,y,−1),(x,y,1)) for *x* in pm1 for y in pm1]
draw3d(
    Points3D(*vertices,color=blue),
    *[Segment3D(*edge) for edge in edges]
)

图片所有顶点坐标都等于+1 或-1 的立方体 |

3.2 三维空间的向量运算

拥有这些 Python 函数,我们可以轻松地可视化三维空间中向量运算的结果。我们在二维空间中看到的所有算术运算在三维空间中都有类似之处,并且它们的几何效果也相似。

3.2.1 三维向量的加法

在三维空间中,向量的加法仍然可以通过加坐标来完成。向量(2, 1, 1)和(1, 2, 2)相加得到(2 + 1, 1 + 2, 1 + 2) = (3, 3, 3)。我们可以从原点开始,以任意顺序将两个输入向量尾对尾放置,以得到和点(3, 3, 3)(图 3.13)。

图片 图片

图 3.13 两个三维向量加法的视觉示例

与二维空间类似,我们可以通过将所有三维向量的x坐标、y坐标和z坐标相加来相加任意数量的三维向量。这三个总和给出了新向量的坐标。例如,在求和(1, 1, 3) + (2, 4, −4) + (4, 2, −2)中,相应的x坐标是 1、2 和 4,总和为 7。y坐标的总和也是 7,z坐标的总和为-3;因此,向量之和是(7, 7, −3)。尾对尾地,这三个向量看起来就像图 3.14 中的那些。

图片

图 3.14 在三维空间中尾对尾相加三个向量

在 Python 中,我们可以编写一个简洁的函数来求和任意数量的输入向量,这适用于二维或三维(或我们稍后将看到的更高维数)。下面是它的样子:

def add(*vectors):
    by_coordinate = zip(*vectors)
    coordinate_sums = [sum(coords) for coords in by_coordinate]
    return tuple(coordinate_sums)

让我们分解一下。在输入向量上调用 Python 的zip函数可以提取它们的 x 坐标、y坐标和z坐标。例如,

>>> list(zip(*[(1,1,3),(2,4,−4),(4,2,−2)]))
[(1, 2, 4), (1, 4, 2), (3, −4, −2)]

(您需要将zip的结果转换为列表以显示其值。)如果我们对每个分组的坐标应用 Python 的sum函数,我们将分别得到xyz值的总和:

[sum(coords) for coords in [(1, 2, 4), (1, 4, 2), (3, −4, −2)]]
[7, 7, −3]

最后,为了保持一致性,我们将这个列表转换为元组,因为我们已经将所有向量表示为元组。结果是元组 (7, 7, 3)。我们也可以将 add 函数写成以下单行代码(这可能不太符合 Python 风格):

def add(*vectors):
    return tuple(map(sum,zip(*vectors)))

3.2.2 三维中的标量乘法

要将一个三维向量乘以一个标量,我们需要将其所有分量乘以标量因子。例如,向量 (1, 2, 3) 乘以标量 2 得到 (2, 4, 6)。这个结果向量长度是原来的两倍,但方向与二维情况相同。图 3.15 展示了 v = (1, 2, 3) 和其标量倍数 2 · v = (2, 4, 6)。

图片

图 3.15 将向量乘以 2 的标量乘法得到一个指向相同方向的向量,其长度是原始向量的两倍。

3.2.3 减去三维向量

在二维中,两个向量 vw 的差是“从 wv”的向量,这被称为 位移。在三维中,故事是一样的;换句话说,vw 是从 wv 的位移,这是你可以加到 w 上以得到 v 的向量。将 vw 视为从原点出发的箭头,vw 的差是一个箭头,可以定位使其尖端在 v 的尖端,其尾部在 w 的尖端。图 3.16 展示了 v = (−1, −3, 3) 和 w = (3, 2, 4) 的差,既作为从 wv 的箭头,也作为一个独立的点。

图片 图片

图 3.16 从向量 v 中减去向量 w 得到从 wv 的位移。

从向量 v 中减去向量 w 在坐标上是通过取 vw 的坐标之差来完成的。例如,vw 得到 (−1 −3, −3 − 2, 3 − 4) = (−4, −5, −1) 作为结果。这些坐标与图 3.16 中 vw 的图像一致,它显示它是一个指向负 x、负 y 和负 z 方向的向量。

当我声称标量乘以二会使向量“长度加倍”时,我是从几何相似性的角度考虑的。如果 v 的三个分量都加倍,对应于长、宽和深的加倍,那么从一个角到另一个角的斜对角距离也应该加倍。为了实际测量和确认这一点,我们需要知道如何计算三维空间中的距离。

3.2.4 计算长度和距离

在二维中,我们使用勾股定理计算向量的长度,利用事实是箭头向量和其分量构成一个直角三角形。同样,平面上两点之间的距离只是它们差向量的长度。

我们需要更仔细地观察,但我们仍然可以在三维中找到一个合适的直角三角形来帮助我们计算向量的长度。让我们尝试找到向量(4, 3, 12)的长度。xy分量仍然给我们一个在z = 0 的平面上的直角三角形。这个三角形的斜边,或对角边,长度为图片。如果这是一个二维向量,我们就完成了,但 12 的z分量使这个向量变得相当长(图 3.17)。

图片

图 3.17 在 x,y 平面上应用勾股定理找到斜边长度

到目前为止,我们考虑的所有向量都位于z = 0 的x,y平面中。x分量是(4, 0, 0),y分量是(0, 3, 0),它们的向量和是(4, 3, 0)。向量(0, 0, 12)的z分量垂直于这三个分量。这很有用,因为它给我们图中的第二个直角三角形:由(4, 3, 0)和(0, 0, 12)组成的三角形,并放置在尖端。这个三角形的斜边是我们原始的向量(4, 3, 12),我们想要找到其长度。让我们专注于这个第二个直角三角形,并再次应用勾股定理来找到斜边长度(如图 3.18 所示)。

图片图片

图 3.18 第二次应用勾股定理给出了三维向量的长度。

对已知的两边进行平方并取平方根应该给出长度。在这里,长度是 5 和 12,所以结果是图片。一般来说,以下方程显示了三维向量长度的公式:

图片

这与二维长度公式非常相似。在二维或三维中,向量的长度是其各分量平方和的平方根。由于在下面的length函数中我们没有明确引用输入元组的长度,因此它适用于二维或三维向量:

from math import sqrt
def length(*v*):
    return sqrt(sum([coord ** 2 for coord in v]))

例如,length((3,4,12))返回 13。

3.2.5 计算角度和方向

与二维类似,你可以将三维向量视为箭头或一定长度和方向的位移。在二维中,这意味着两个数字−一个长度和一个角度,形成一个极坐标对−足以指定任何二维向量。在三维中,一个角度不足以指定一个方向,但两个角度可以。

对于第一个角度,我们再次考虑没有其z-坐标的向量,就像它仍然生活在x,y平面中一样。另一种思考方式是,这是向量从非常高的z位置发出的影子。这个影子与正x轴形成一些角度,这与极坐标中使用的角度类似,我们用希腊字母φ(phi)来标记它。第二个角度是向量与 z 轴形成的角度,用希腊字母θ(theta)来标记。图 3.19 显示了这些角度。

图 3.19 两个角度共同测量 3D 向量的方向

向量的长度,标记为r,以及角度φ和θ可以描述三维空间中的任何向量。这三个数字r、φ和θ合在一起被称为球坐标,而不是笛卡尔坐标xyz。从笛卡尔坐标计算球坐标是一个只需要我们已覆盖的三角学的可完成的练习,但在这里我们不会深入探讨。实际上,我们在这本书中不会再使用球坐标,但我想要简要地比较一下它们与极坐标。

极坐标很有用,因为它们允许我们通过简单地加减角度来执行一组平面向量的任何旋转。我们还能通过取它们极坐标角度的差来读取两个向量之间的角度。在三维空间中,角度φ和θ中的任何一个都不能立即决定两个向量之间的角度。虽然我们可以通过加减角度φ轻松地在 z 轴周围旋转向量,但在球坐标中围绕任何其他轴旋转则不太方便。

我们需要一些更通用的工具来处理 3D 中的角度和三角学。在下一节中,我们将介绍两个这样的工具,称为向量积

3.2.6 练习

| 练习 3.3:将(4, 0, 3)和(−1, 0, 1)作为Arrow3D对象绘制,使它们在 3D 中以尾对尾的方式排列。它们的向量和是什么?解答:我们可以使用我们构建的add函数来找到向量和:

>>> add((4,0,3),(−1,0,1))
(3, 0, 4)

然后为了绘制这些尾对尾的箭头,我们绘制从原点到每个点以及从每个点到向量和(3, 0, 4)的箭头。像 2D 的Arrow对象一样,Arrow3D首先取箭头的尖端向量,然后,如果它不是原点,可选地取尾向量:

draw3d(
    Arrow3D((4,0,3),color=red),
    Arrow3D((−1,0,1),color=blue),
    Arrow3D((3,0,4),(4,0,3),color=blue),
    Arrow3D((−1,0,1),(3,0,4),color=red),
    Arrow3D((3,0,4),color=purple)
)

尾对尾加法显示(4, 0, 3) + (−1, 0, 1) = (−1, 0, 1) + (4, 0, 3) = (3, 0, 4)。|

练习 3.4:假设我们设置了vectors1=[(1,2,3,4,5),(6,7,8,9,10)]vectors2=[(1,2),(3,4),(5,6)]。在不使用 Python 评估的情况下,zip(*vectors1)zip(*vectors2)的长度是多少?解答:第一个zip的长度是 5。因为两个输入向量中每个都有五个坐标,所以zip(*vectors1)包含五个元组,每个元组有两个元素。同样,zip(*vectors2)的长度是 2;zip(*vectors2)的两个条目是包含所有x分量和所有y分量的元组。

| 练习 3.5-迷你项目:以下理解创建了一个包含 24 个 Python 向量的列表:

from math import sin, cos, pi
vs = [(sin(pi*t/6), cos(pi*t/6), 1.0/3) for t in range(0,24)]

24 个向量的和是多少?将所有 24 个向量以尾对尾的方式作为Arrow3D对象绘制。解答:绘制这些向量尾对尾最终会产生一个螺旋形状:

from math import sin, cos, pi
vs = [(sin(pi*t/6), cos(pi*t/6), 1.0/3) for t in range(0,24)]

running_sum = (0,0,0)                            ❶
arrows = []
for *v*  in vs:
    next_sum = add(running_sum, v)               ❷
    arrows.append(Arrow3D(next_sum, running_sum)) 
    running_sum = next_sum
print(running_sum)
draw3d(*arrows)

❶ 从 (0, 0, 0) 开始进行累加,这是尾对尾加法开始的点❷ 要绘制每个后续向量的尾对尾,我们将它加到累加和中。最新的箭头连接了前一个累加和到下一个点。在三维空间中求 24 个向量的向量和这个和是

(−4.440892098500626e−16, −7.771561172376096e−16, 7.9999999999999964)

这大约是 (0, 0, 8)。|

| 练习 3.6: 编写一个函数 scale(scalar,vector),它返回输入标量乘以输入向量。具体来说,编写它使其适用于 2D 或 3D 向量,或任何数量的坐标向量。解答:使用理解,我们将向量中的每个坐标乘以标量。这是一个将生成器理解转换为元组的理解:

def scale(scalar,v):
    return tuple(scalar * coord for coord in v)

|

练习 3.7: 设 u = (1, −1, −1) 和 v = (0, 0, 2)。u + ½ · (vu) 的结果是什么?解答:给定 u = (1, −1, −1) 和 v = (0, 0, 2),我们首先计算 (v* − u) = (0 − 1, 0 − (−1), 2 − (−1)) = (−1, 1, 3)。然后 ½ · (v* − u) 是 (−½, ½, 3/2)。最终 u + ½ · (v* − u*) 的结果是 (½, −½, ½)。顺便说一下,这是点 u 和点 v 之间的中点。
练习 3.8: 尝试不使用代码来回答这个练习题,然后检查你的工作。2D 向量 (1, 1) 的长度是多少?3D 向量 (1, 1, 1) 的长度是多少?我们还没有讨论 4D 向量,但它们有四个坐标而不是两个或三个。如果你必须猜测,坐标为 (1, 1, 1, 1) 的 4D 向量的长度是多少?解答:(1, 1) 的长度是 。 (1, 1, 1) 的长度是 。正如你可能猜到的,我们同样使用相同的距离公式来计算高维向量。 (1, 1, 1, 1) 的长度遵循相同的模式:它是 ,即 2。

| 练习 3.9-迷你项目:坐标 3, 4, 12 以任何顺序创建一个长度为 13 的向量,这是一个整数。这是不寻常的,因为大多数数字不是完全平方数,所以长度公式中的平方根通常返回一个无理数。找到另一个定义具有整数长度的向量坐标的整数三元组。解答:以下代码搜索小于 100(一个任意选择)的递减整数三元组:

def vectors_with_whole_number_length(max_coord=100):
    for *x* in range(1,max_coord):
        for y in range(1,x+1):
          for z in range(1,y+1):
                if length((x,y,z)).is_integer():
                    yield (x,y,z)

它找到了 869 个具有整数坐标和整数长度的向量。其中最短的是长度正好为 3 的 (2, 2, 1),最长的长度正好为 150 的向量是 (99, 90, 70)。|

| 练习 3.10: 找到一个与 (−1, −1, 2) 方向相同但长度为 1 的向量。提示:找到适当的标量来乘以原始向量以适当改变其长度。解答:(−1, −1, 2) 的长度大约是 2.45,所以我们需要将这个向量的标量乘以 (1/2.45) 来使其长度为 1:

>>> length((−1,−1,2))
2.449489742783178
>>> s = 1/length((−1,−1,2))
>>> scale(s,(−1,−1,2))
(−0.4082482904638631, -0.4082482904638631, 0.8164965809277261)
>>> length(scale(s,(−1,−1,2)))
1.0

将每个坐标四舍五入到最接近的百分位,该向量是(-0.41,-0.41,0.82)。

3.3 点积:测量向量对齐

我们已经看到的一种向量乘法是标量乘法,它将一个标量(一个实数)和一个向量组合起来得到一个新的向量。我们还没有讨论过任何一种将一个向量与另一个向量相乘的方法。实际上,有两种重要的方法可以做到这一点,这两种方法都提供了重要的几何洞察。一种被称为点积,我们用点运算符(例如,u · v)来表示它,而另一种被称为叉积(例如,u × v)。对于数字,这些符号意味着相同的事情,所以例如 3 · 4 = 3 × 4。对于两个向量,u · vu × v的操作不仅仅是不同的符号,它们意味着完全不同的事情。

点积接受两个向量并返回一个标量(一个数字),而叉积接受两个向量并返回另一个向量。然而,这两个操作都是帮助我们推理三维空间中向量的长度和方向的运算。让我们首先关注点积。

3.3.1 点积的图像

点积(也称为内积)是作用于两个向量的运算,返回一个标量。换句话说,给定两个向量uvu · v的结果是一个实数。点积可以在 2D、3D 或任何数量的维度上的向量上工作。你可以将其视为测量输入向量对“如何对齐”。让我们首先看看xy平面上的某些向量,并展示它们的点积,以给你一些关于这个运算如何工作的直观感受。

向量uv的长度分别为 4 和 5,它们几乎指向同一方向。它们的点积是正的,这意味着它们是对齐的(图 3.20)。

图 3.20 相对对齐的两个向量给出较大的正点积。

指向相似方向的两个向量具有正的点积,向量越大,乘积越大。相似对齐的较短的向量具有较小的但仍然是正的点积。新的向量uv的长度均为 2(图 3.21)。

图 3.21 指向相似方向的较短的向量给出较小的但仍然是正的点积。

相比之下,如果两个向量指向相反方向或几乎指向相反方向,它们的点积是负的(图 3.22 和图 3.23)。向量的幅度越大,它们的点积越负。

图 3.22 指向相反方向的向量具有负的点积。

图 3.23 指向相反方向的较短的向量具有较小的但仍然是负的点积。

并非所有向量的对都明显指向相似或相反的方向,而点积可以检测到这一点。如图 3.24 所示,如果两个向量指向完全垂直的方向,无论它们的长度如何,它们的点积都是零。

图 3.24 垂直向量总是具有零点积。

这实际上是点积最重要的应用之一:它让我们能够计算两个向量是否垂直,而无需进行任何三角计算。这种垂直情况还用于区分其他情况:如果两个向量之间的角度小于 90°,则它们的点积为正。如果角度大于 90°,则它们的点积为负。虽然我还没有告诉你如何计算点积,但你现在知道如何解释这个值。我们继续计算它。

3.3.2 计算点积

给定两个向量的坐标,有一个简单的公式可以计算点积:将相应的坐标相乘,然后将乘积相加。例如,在点积(1, 2, −1)·(3, 0, 3)中,x坐标的乘积是 3,y坐标的乘积是 0,z坐标的乘积是−3。总和是 3 + 0 + (−3) = 0,所以点积是零。如果我的说法正确,这两个向量应该是垂直的。从正确的角度绘制它们(图 3.25)可以证明这一点!

图 3.25 两个点积为零的向量在三维空间中确实是垂直的。

在三维空间中,我们的视角可能会误导,因此能够计算相对方向而不是凭直觉判断就更加有价值。作为另一个例子,图 3.26 显示,二维向量(2, 3)和(4, 5)在xy平面上具有相似的方向。x坐标的乘积是 2 · 4 = 8,而y坐标的乘积是 3 · 5 = 15。总和 8 + 15 = 23 是点积。作为一个正数,这个结果证实了这两个向量之间的角度小于 90°。无论我们在二维还是三维中考虑它们,这些向量都具有相同的相对几何形状,因为它们恰好位于z = 0 的平面上,即向量(2, 3, 0)和(4, 5, 0)。

图 3.26 计算点积的另一个例子

在 Python 中,我们可以编写一个点积函数,只要输入的向量都具有相同数量的坐标,就可以处理任何一对输入向量。例如,

def dot(u,v):
    return sum([coord1 * coord2 for coord1,coord2 in zip(u,v)])

此代码使用 Python 的zip函数来配对适当的坐标,然后在理解中乘以每一对,并将结果列表相加。让我们用这个来进一步探索点积的行为。

3.3.3 通过示例计算点积

两个位于不同轴上的向量具有零点积并不令人惊讶。我们知道它们是垂直的:

>>> dot((1,0),(0,2))
0 
>>> dot((0,3,0),(0,0,−5))
0 

我们还可以确认较长的向量会产生较长的点积。例如,将输入向量之一按 2 倍因子缩放,将点积的输出翻倍:

>>> dot((3,4),(2,3))
18 
>>> dot(scale(2,(3,4)),(2,3))
36 
>>> dot((3,4),scale(2,(2,3)))
36 

结果表明点积与其输入向量的长度成正比。如果你取两个同方向向量的点积,点积恰好等于长度的乘积。例如,(4, 3) 的长度为 5,(8, 6) 的长度为 10。点积等于 5 · 10:

>>> dot((4,3),(8,6))
50 

当然,点积并不总是等于其输入长度的乘积。向量 (5, 0),(−3, 4),(0, −5) 和 (−4, −3) 都有相同的长度 5,但与原始向量 (4, 3) 的点积不同,如图 3.27 所示。

图片

图 3.27 同长度的向量与向量 (4, 3) 的点积不同,这取决于它们的方向。

长度为 5 的两个向量的点积从它们对齐时的 5 · 5 = 25 变化到它们指向相反方向时的 −25。在下一组练习中,我邀请你证明两个向量的点积可以从长度的乘积变化到该值的相反数。

3.3.4 使用点积测量角度

我们已经看到点积根据两个向量之间的角度而变化。具体来说,向量 u · v 的点积从 0° 到 180° 角度范围内,从 uv 长度的乘积到该值的相反数变化。我们已经看到了一个具有这种行为的函数,即余弦函数。结果证明点积有一个替代公式。如果 |u| 和 |v| 表示向量 uv 的长度,点积由以下公式给出

u · v = |u| · |v| · cos(θ)

其中 θ 是向量 uv 之间的角度。原则上这为我们计算点积提供了一种新的方法。我们可以测量两个向量的长度,然后测量它们之间的角度来得到结果。假设,如图 3.28 所示,我们有两个长度分别为 3 和 2 的已知向量,并使用我们的量角器发现它们之间相隔 75°。

图片

图 3.28 长度分别为 3 和 2 的两个向量,相隔 75°

图 3.28 中两个向量的点积是 3 · 2 · cos(75°)。通过适当的弧度转换,我们可以在 Python 中计算出大约为 1.55:

>>> from math import cos,pi
>>> 3 * 2 * cos(75 * pi / 180)
1.5529142706151244 

在进行向量计算时,通常从坐标开始,并从它们计算角度。我们可以将两个公式结合起来恢复一个角度:首先使用坐标计算点积和长度,然后求解角度。

让我们找出向量 (3, 4) 和 (4, 3) 之间的角度。它们的点积是 24,每个向量的长度都是 5。我们新的点积公式告诉我们:

(3, 4) · (4, 3) = 24 = 5 · 5 · cos(θ) = 25 · cos(θ)

从 24 = 25 · cos(θ) 中,我们可以简化为 cos(θ) = 24/25。使用 Python 的 math.acos,我们找到 θ 值为 0.284 弧度或 16.3° 时,余弦值为 24/25。

这个练习提醒我们为什么在 2D 中不需要点积。在第二章中,我们展示了如何从正 x 轴得到向量的角度。通过创造性地使用那个公式,我们可以在平面上找到任何我们想要的角。点积在 3D 中真正开始发光,因为在 3D 中,坐标变换不能像在 2D 中那样帮助我们。

例如,我们可以使用相同的公式来找到 (1, 2, 2) 和 (2, 2, 1) 之间的角度。点积是 1 · 2 + 2 · 2 + 2 · 1 = 8,长度都是 3。这意味着 8 = 3 · 3 · cos(θ),所以 cos(θ) = 8/9,θ = 0.476 弧度或 27.3°。

这个过程在 2D 或 3D 中都是一样的,我们会反复使用。我们可以通过实现一个 Python 函数来找到两个向量之间的角度来节省一些力气。由于我们的点积函数和长度函数都没有硬编码的维度数,这个新函数也不会。我们可以利用 u · v = |u| · |v| · cos(θ) 的事实,因此,

图片

图片

这个公式可以很好地转换为以下 Python 代码:

def angle_between(v1,v2):
    return acos(
                dot(v1,v2) /
                (length(v1) * length(v2))
            )

这段 Python 代码中的任何内容都不依赖于向量 v[1] 和 v[2] 的维度数。这两个都可以是 2 个坐标的元组或 3 个坐标的元组(实际上,也可以是 4 个或更多坐标的元组,我们将在接下来的章节中讨论)。相比之下,我们接下来遇到的下一个向量积(叉积)只在三维空间中有效。

3.3.5 练习

练习 3.11:根据以下图片,从大到小排列 u · vu · w,和 v · w图片解答u · v 是唯一的正点积,因为 uv 是唯一一对之间小于直角的组合。此外,u · w 小于(更负)于 v · w,因为 u 既是更大的也是离 w 更远的,所以 u · v > xv · w > xu · w
练习 3.12:(-1, -1, 1) 和 (1, 2, 1) 的点积是多少?这两个 3D 向量之间的距离是大于 90°,小于 90°,还是正好 90°?解答:(-1, -1, 1) 和 (1, 2, 1) 的点积是 -1 · 1 + -1 · 2 + 1 · 1 = -2。因为这个是一个负数,所以这两个向量之间的距离大于 90°。
练习 3.13-迷你项目: 对于两个 3D 向量 uv,(2u) · vu · (2v) 的值都等于 2(u · v)。在这种情况下,u · v = 18,并且 (2u) · vu · (2v) 都是 36,是原始结果的两倍。证明这对于任何实数 s 都成立,而不仅仅是 2. 换句话说,证明对于任何 s,(s u) · vu · (s v) 的值都等于 s(u · v)。
解答: 让我们命名 uv 的坐标,比如说 u = (a, b, c) 和 v = (d, e, f )。那么 u · v = ad + be + cf。因为 s u = (sa, sb, sc) 和 s v = (sd, se, sf), 我们可以通过展开点积来展示这两个结果:证明标量乘法相应地缩放点积的结果。并且另一个乘积也是同样的方式工作: 证明对于点积的第二个向量输入,这个事实同样成立。
练习 3.14-迷你项目: 用代数方法解释为什么一个向量与自身的点积等于其长度的平方。解答: 如果一个向量的坐标是 (a, b, c),那么它与自身的点积是 a · a + b · b + c · c。它的长度是  ,所以这确实是平方。

| 练习 3.15-迷你项目: 找到一个长度为 3 的向量 u 和一个长度为 7 的向量 v,使得 u · v = 21. 找到另一对向量 uv,使得 u · v = −21. 最后,找到三对长度分别为 3 和 7 的向量,并证明它们的长度都在 −21 和 21 之间。解答: 方向相同的两个向量(例如,沿着正 x 轴)将具有可能的最大点积:

>>> dot((3,0),(7,0))
21

方向相反的两个向量(例如,正负 y 方向)将具有可能的最小点积:

>>> dot((0,3),(0,−7))
−21

使用极坐标,我们可以轻松生成一些长度为 3 和 7 的随机角度的更多向量:

from vectors import to_cartesian
from random import random
from math import pi

def random_vector_of_length(l):
    return to_cartesian((l, 2 *pi*random()))

pairs = [(random_vector_of_length(3), random_vector_of_length(7))
            for i in range(0,3)]
for u,v in pairs:
    print("u = %s, v  = %s" % (u,v))
    print("length of u: %f, length of v: %f, dot product :%f" %
                (length(u), length(v), dot(u,v)))

|

| 练习 3.16: 设 uv 是向量,其中 |u| = 3.61 和 |v| = 1.44. 如果 uv 之间的角度是 101.3°,那么 u · v 是多少?

  • 5.198

  • 5.098

  • −1.019

  • 1.019

解答: 再次,我们可以将这些值代入新的点积公式,并通过适当的弧度转换,在 Python 中评估结果:

>>> 3.61 * 1.44 * cos(101.3 * pi / 180)
−1.0186064362303022

四舍五入到小数点后三位,答案与 c 相符。|

| 练习 3.17-迷你项目: 通过将它们转换为极坐标并取角度之差来找到 (3, 4) 和 (4, 3) 之间的角度。答案是

  • 1.569

  • 0.927

  • 0.643

  • 0.284

提示:结果应该与点积公式的值一致。解答:向量(3, 4)相对于正x轴逆时针更远,因此我们从(3, 4)的角度减去(4, 3)的角度来得到我们的答案。它与答案 d 完全匹配:

>>> from vectors import to_polar
>>> r1,t1 = to_polar((4,3))
>>> r2,t2 = to_polar((3,4))
>>> t1-t2
-0.2837941092083278 
>>> t2-t1
0.2837941092083278 

|

| 练习 3.18:向量(1, 1, 1)和向量(−1, −1, 1)之间的角度是多少度?

  • 180°

  • 120°

  • 109.5°

  • 90°

解答:这两个向量的长度都是√3 或大约 1.732。它们的点积是 1 · (−1) + 1 · (−1) + 1 · 1 = −1,所以−1 = √3 · √3 · cos(θ)。因此,cos(θ) = −1/3。这使得角度大约是 1.911 弧度或 109.5°(答案 c)。

3.4 叉积:测量有向面积

如前所述,叉积将两个 3D 向量uv作为输入,其输出u × v是另一个 3D 向量。它与点积类似,输入向量的长度和相对方向决定了输出,但不同之处在于输出不仅有大小,还有方向。我们需要仔细思考 3D 空间中的方向概念,以理解叉积的强大功能。

3.4.1 在 3D 空间中定位自己

在本章开头介绍 x-, y-, 和 z-轴时,我做出了两个明确的声明。首先,我承诺熟悉的x,y平面存在于 3D 世界中。其次,我将z方向设置为垂直于x,y平面,而x,y平面位于z = 0 的位置。我没有明确宣布的是,正z方向是向上而不是向下。

换句话说,如果我们从通常的角度看x,y平面,我们会看到正z轴从平面中指向我们。我们还可以选择让正z轴离开我们(如图 3.29 所示)。

图 3.29 将自己在 3D 空间中定位,以便像在第二章中看到的那样看到 x,y 平面。当我们看x,y平面时,我们选择了正z轴指向我们而不是离开我们。

这里的区别不是视角问题;这两个选择代表了 3D 空间的不同方向,并且从任何视角都可以区分它们。假设我们像图 3.29 左边的棍状人物一样漂浮在某个正z坐标上。我们应该看到正y轴位于正x轴顺时针方向四分之一转的位置;否则,轴的排列方向是错误的。

世界上许多事物都有方向性,并且与它们的镜像看起来并不相同。例如,左右脚的鞋大小和形状相同,但方向不同。一个普通的咖啡杯没有方向;我们无法通过两张未标记的咖啡杯图片来判断它们是否不同。但如图 3.30 所示,两个在相对侧面有图案的咖啡杯是可以区分的。

图 3.30 一个没有图像的杯子与其镜像图像是同一个物体。一个侧面有图像的杯子与其镜像图像并不相同。

大多数数学家用来检测取向的现成对象是手。我们的手是有取向的对象,因此即使它们不幸与我们的身体分离,我们也能区分左右手。你能判断图 3.31 中的手是右手还是左手吗?

图片

图 3.31 这是一只右手还是左手?

显然,这是一个右手:我们的左手指尖上没有指甲!数学家可以用他们的手来区分坐标轴的两个可能取向,他们称之为右手取向和左手取向。这是如图 3.32 所示的规定:如果你将你的右食指指向正 x 轴,并将你的剩余手指向正 y 轴卷曲,你的大拇指就会告诉你正 z 轴的方向。

图片

图 3.32 右手定则帮助我们记住我们选择的取向。

这被称为右手定则,如果它与你的坐标轴一致,那么你就是在(正确地!)使用右手取向。取向很重要!如果你正在编写一个控制无人机或腹腔镜手术机器人的程序,你需要保持你的上下、左右、前后的一致性。叉积是一个有取向的机器,因此它可以帮助我们在所有的计算中跟踪取向。

3.4.2 寻找叉积的方向

再次,在我告诉你如何计算叉积之前,我想先展示一下它的样子。给定两个输入向量,叉积输出一个与两个输入都垂直的结果。例如,如果u = (1,0,0)和v = (0,1,0),那么叉积u × v就是(0, 0, 1),如图 3.33 所示。

图片

图 3.33 z = (1, 0, 0)和v = (0, 1, 0)的叉积

事实上,如图 3.34 所示,xy平面上的任意两个向量都有一个位于 z 轴上的叉积。

图片 图片

图 3.34 x,y 平面上的任意两个向量的叉积位于 z 轴上。

这清楚地说明了为什么叉积在二维空间中不起作用:它返回一个位于包含两个输入向量的平面外的向量。即使输入不在xy平面上,我们也能看到叉积的输出与两个输入都垂直(如图 3.35 所示)。

图片

图 3.35 叉积总是返回一个与两个输入都垂直的向量。

但是有两个可能的垂直方向,而叉积只选择其中一个。例如,(1, 0, 0) × (0, 1, 0) 的结果是 (0, 0, 1),指向正 z 方向。z 轴上的任何向量,无论是正还是负,都会与这两个输入向量垂直。为什么结果是正方向?

这里就涉及到方向性:叉积也遵循右手法则。一旦你找到了与两个输入向量 uv 垂直的方向,叉积 u × v 就位于一个使得三个向量 uvu × v 处于右手配置的方向。也就是说,我们可以将右手的食指指向 u 的方向,将其他手指弯曲向 v 的方向,而大拇指则指向 u × v 的方向(图 3.36)。

图片

图 3.36 右手法则告诉我们叉积指向哪个垂直方向。

当输入向量位于两个坐标轴上时,找到它们的叉积的确切方向并不太难:它就是剩余轴上的两个方向之一。一般来说,不计算叉积就很难描述两个向量的垂直方向。这正是我们一旦学会了如何计算它,它就变得非常有用的一项特性。但一个向量不仅指定了一个方向;它还指定了 一个 长度。叉积的长度编码了有用的信息。

3.4.3 计算叉积的长度

就像点积一样,叉积的长度是一个数字,它给我们提供了关于输入向量相对位置的信息。它不是测量两个向量有多对齐,而是告诉我们“它们有多垂直”。更精确地说,它告诉我们两个输入向量所围成的面积有多大(图 3.37)。

图片

图 3.37 叉积的长度等于平行四边形的面积。

正如图 3.37 所示,由 uv 所围成的平行四边形面积与叉积 u × v 的长度相同。对于给定长度的两个向量,如果它们垂直,它们所围成的面积最大。另一方面,如果 uv 在同一方向上,它们不围成任何面积;叉积的长度为零。这是方便的;如果两个输入向量平行,我们无法选择一个唯一的垂直方向。

结合结果的方向,结果的长度给我们一个确切的向量。平面上的两个向量保证它们的叉积指向 +z 或 − z 方向。我们可以从图 3.38 中看到,平面向量所围成的平行四边形越大,叉积就越长。

图片 图片 图片

图 3.38 在 x,y 平面上的向量对,其叉积的大小取决于它们所围成的平行四边形的面积。

这个平行四边形的面积有一个三角公式:如果 uv 之间的角度是 θ,则面积是 |u| · |v| · sin(θ)。我们可以将长度和方向结合起来,看看一些简单的叉积。例如,(0, 2, 0) 和 (0, 0, −2) 的叉积是什么?这些向量分别位于 y 轴和 z 轴上,因此要垂直于两者,叉积必须位于 x 轴上。让我们使用右手定则来找到结果的方向。

将我们的食指指向第一个向量的方向(正 y 方向)并弯曲我们的手指指向第二个向量的方向(负 z 方向),我们发现我们的拇指指向负 x 方向。叉积的大小是 2 · 2 · sin(90°),因为 y 轴和 z 轴在 90°角处相交。(在这个情况下,平行四边形恰好是一个正方形,边长为 2)。这得出结果是 4,所以结果是 (−4, 0, 0):一个长度为 4 的向量,方向在负 x 方向。

通过几何计算来证明叉积是一个定义良好的操作是件好事。但通常情况下,当向量不总是位于轴上,且不明显需要找到什么坐标以获得垂直结果时,这并不实用。幸运的是,有一个显式公式,用于根据输入向量的坐标来计算叉积的坐标。

3.4.4 计算三维向量的叉积

叉积的公式乍一看很复杂,但我们可以快速将其封装在一个 Python 函数中,轻松计算。让我们从两个向量 uv 的坐标开始。我们可以将坐标命名为 u = (a, b, c) 和 v = (d, e, f ),但如果我们使用更好的符号会更清晰:u = (ux, uy, uz) 和 v = (v[x], v[y], vz). 记住称为 v[x] 的数字是 vx 坐标,比如果将其称为任意字母 d 更容易。在这些坐标下,叉积的公式是

图片

图 3.39 叉积可以指示多边形是否对观察者可见。

u × v = (u[y]v[z]u[z]v[y], u[z]v[x]u[x]v[z], u[x]v[y]u[y]v[x])

或者,在 Python 中:

def cross(u, v):
    ux,uy,uz = u
    vx,vy,vz = v
    return (uy*vz − uz*vy, uz*vx − ux*vz, ux*vy − uy*vx)

你可以在练习中尝试这个公式。注意,与迄今为止我们使用的多数公式不同,这个公式似乎不太适用于其他维度。它要求输入向量恰好有三个分量。

这个代数过程与我们本章建立的几何描述一致。因为它告诉我们面积和方向,叉积帮助我们决定 3D 空间中的占用者是否会看到与他们一起漂浮在空间中的多边形。例如,如图 3.39 所示,站在 x 轴上的观察者将不会看到由u = (1, 1, 0)和v = (−2, 1, 0)张成的平行四边形。

换句话说,图 3.39 中的多边形与观察者的视线平行。使用叉积,我们可以不画图就能判断这一点。因为叉积垂直于人的视线,所以多边形中没有任何一个是可见的。

现在是我们最终项目的时候了:用多边形构建一个 3D 对象并在 2D 画布上绘制它。你将使用到目前为止看到的所有矢量运算。特别是,叉积将帮助你决定哪些多边形是可见的。

3.4.5 练习

练习 3.19: 以下每个图都显示了三个相互垂直的箭头,指示正xyz方向。为了视角,显示了一个 3D 箱子,箱子的背面涂成灰色。哪个图与我们所选择的图兼容?也就是说,哪个图显示了与我们绘制的 x 轴、y 轴和 z 轴,即使是从不同的视角?!图像哪个轴与我们的一致?解答: 从上方向下看图a,我们会看到 x 轴和 y 轴如常,z 轴指向我们。与我们方向一致的图是a。在图b中,z 轴朝向我们,而+y 方向相对于+x 方向顺时针 90°。这与我们的方向不一致。
如果我们从正 z 方向(箱子的左侧)的点观察图c,我们会看到+y 方向相对于+x 方向逆时针 90°。图c也与我们的一致。从箱子的左侧观察图d,+z 方向会朝向我们,而+y 方向会再次相对于+x 方向逆时针。这也与我们的一致。
练习 3.20: 如果你面前放一面镜子,并竖立起三个坐标轴,镜中的图像会有相同的方向还是不同的方向?解答: 镜中的图像方向是反转的。从这个角度来看,z 轴和 y 轴保持指向相同方向。在原始图像中,x 轴是顺时针从 y 轴开始的,但在镜中图像中,它变为逆时针方向!图像x 轴、y 轴、z 轴及其镜像
练习 3.21:叉积 (0, 0, 3) × (0, −2, 0) 的结果指向什么方向?解答:如果我们将右手的食指指向 (0, 0, 3) 的方向,即正 z 方向,并将其他手指向 (0, −2, 0) 的方向卷曲,即负 y 方向,那么我们的拇指指向正 x 方向。因此,(0, 0, 3) × (0, −2, 0) 指向正 x 方向。
练习 3.22: (1, −2, 1) 和 (−6, 12, −6) 的叉积的坐标是什么?解答:由于这两个向量是彼此的负标量倍数,它们指向相反的方向,并且不跨越任何区域。因此,叉积的长度为零。唯一的长度为零的向量是 (0, 0, 0),所以这就是答案。

| 练习 3.23-迷你项目:平行四边形的面积等于其底边长度乘以其高度,如下所示:鉴于这一点,解释为什么公式

&#124;***u***&#124; · &#124;***v***&#124; · sin(φ) makes sense.

解答:在图中,向量 u 定义了底边,因此底边长度是 |u|。从 v 的尖端到基边,我们可以画一个直角三角形。v 的长度是斜边,三角形的垂直边是我们正在寻找的高度。根据正弦函数的定义,高度是 |v| · sin(φ)。以一个角度的正弦值表示平行四边形面积的公式由于底边长度是 |u|,高度是 |v| · sin(φ),因此平行四边形的面积确实是 |u| · |v| · sin(φ)。 |

| 练习 3.24:叉积 (1, 0, 1) × (−1, 0, 0) 的结果是什么?

  • (0, 1, 0)

  • (0, −1, 0)

  • (0, −1, −1)

  • (0, 1, −1)

解答:这些向量位于 xz 平面上,因此它们的叉积位于 y 轴上。将右手的食指指向 (1, 0, 1) 的方向,并使手指向 (−1, 0, 0) 方向卷曲,需要我们的拇指指向 − y 方向。通过几何方法计算 (1, 0, 1) 和 (−1, 0, 0) 的叉积我们可以找到向量的长度和它们之间的角度来得到叉积的大小,但我们已经从坐标中得到了底边和高度。这两个都是 1,所以长度是 1。因此,叉积是 (0, −1, 0),一个长度为 1 的在 − y 方向的向量;答案是 b。 |

| 练习 3.25:使用 Python 的 cross 函数计算 (0, 0, 1) × v 对于第二个向量 v 的几个不同值。每个结果的 z 坐标是什么,为什么?解答:无论选择什么向量 vz 坐标都是零:

>>> cross((0,0,1),(1,2,3))
(−2, 1, 0)
>>> cross((0,0,1),(−1,−1,0))
(1, −1, 0)
>>> cross((0,0,1),(1,−1,5))
(1, 1, 0)

因为 u = (0,0,1),所以 u xu y 都是零。这意味着在叉积公式中的项 u x v[y]uyvx 是零,无论 v xv[y] 的值如何。从几何学的角度来看,这是有意义的:叉积应该垂直于两个输入,并且要垂直于 (0, 0, 1),z 分量必须是零。|

练习 3.26-迷你项目:通过代数证明 u × v 不论 uv 的坐标如何,都垂直于 uv提示:通过将这些展开成坐标来证明 (u × v) · u 和 (u × v) · v解答:在以下方程中,设 u = (u[x], u[y], u[z]) 和 v = (v[x], v[y], v[z])。我们可以将 (u × v) · u 用坐标表示如下:u× v = (u[y]v[z] − u[z]v[y], u[z]v[x] − u[x]v[z], u[x]v[y] − u[y]v[x]) · (u[x], u[y], u[z])展开叉积的点积在展开点积后,我们看到有 6 项。这些项中的每一项都与另一项相抵消。= (u[y]v[z] − u[z]v[y])u[x] + (u[z]v[x] − u[x]v[z])u[y] + (u[x]v[y] − u[y]v[x])u[z]**= u[y]v[z]u[x] − u[z]v[y]u[x] + u[z]v[x]u[y] − u[x]v[z]u[y] + u[x]v[y]u[z] − u[y]v[x]u[z]在完全展开后,所有项都相互抵消。因为所有项都相互抵消,结果是零。为了节省“墨水”,我不会展示 (u × v) · v 的结果,但发生的情况相同:出现 6 项并相互抵消,结果为零。这意味着 (u × v) 垂直于 uv

3.5 在 2D 中渲染 3D 对象

让我们尝试使用我们所学到的知识来渲染一个简单的三维形状,称为八面体。一个立方体有六个面,都是正方形,而八面体有八个面,都是三角形。你可以把八面体想象成两个四棱锥叠加在一起。图 3.40 显示了八面体的骨架。

图 3.40 八面体的骨架,一个有八个面和六个顶点的形状。虚线显示的是相对于我们的八面体的对面边。

如果这是一个实心体,我们就看不到对面。相反,我们会看到如图 3.41 所示的八个三角形中的四个。

图 3.41 八面体的四个编号面,在我们当前的位置可见

渲染八面体归结为识别我们需要显示的四个三角形,并适当地着色它们。让我们看看如何做到这一点。

3.5.1 使用向量定义 3D 对象

八面体是一个简单的例子,因为它只有六个角或顶点。我们可以给它们简单的坐标:(1, 0, 0),(0, 1, 0),(0, 0, 1)以及它们在图 3.42 中所示的反向向量。

这六个向量定义了形状的边界,但并不提供我们绘制它所需的所有信息。我们还需要决定这些顶点中的哪些需要绘制。

这些顶点连接形成形状的边。例如,图 3.42 中的顶部点是 (0, 0, 1),并通过边与 xy 平面(图 3.43)中的所有四个点相连。

图 3.42 八面体的顶点

图 3.43 八面体的四个由箭头指示的边

这些边勾勒出了八面体的顶部金字塔。请注意,没有从 (0, 0, 1) 到 (0, 0, −1) 的边,因为那段线会在八面体内部,而不是外部。每条边由一对向量定义:边作为线段的起点和终点。例如,(0, 0, 1) 和 (1, 0, 0) 定义了其中一条边。

边界数据仍然不足以完成绘图。我们还需要知道哪些顶点和边的三元组定义了我们想要用实心、阴影颜色填充的三角形面。这就是方向发挥作用的地方:我们不仅想知道哪些线段定义了八面体的面,还要知道它们是朝向我们还是远离我们。

这里的策略是:我们将三角形面建模为三个向量 v[1]、v[2] 和 v[3],定义其边。请注意,在这里我使用下标 1、2 和 3 来区分三个不同的向量,而不是同一向量的分量。具体来说,我们将 v[1]、v[2] 和 v[3] 排序,使得 (v[2] - v[1]) × (v[3] − v[1]) 指向八面体外部(图 3.44)。如果一个向外指向的向量朝向我们,这意味着从我们的视角可以看到这个面。否则,这个面被遮挡,我们不需要绘制它。

图 3.44 八面体的一个面。定义面的三个点按顺序排列,使得 (v2 − v1) × (v3 − v1) 指向八面体外部。

我们可以将八个三角形面定义为三个向量的三元组 v[1]、v[2] 和 v[3],如下所示:

octahedron = [
    [(1,0,0), (0,1,0), (0,0,1)],
    [(1,0,0), (0,0,−1), (0,1,0)],
    [(1,0,0), (0,0,1), (0,−1,0)],
    [(1,0,0), (0,−1,0), (0,0,−1)],
    [(−1,0,0), (0,0,1), (0,1,0)],
    [(−1,0,0), (0,1,0), (0,0,−1)],
    [(−1,0,0), (0,−1,0), (0,0,1)],
    [(−1,0,0), (0,0,−1), (0,−1,0)],
]

实际上,我们只需要面的数据来渲染形状;这些数据隐含了边和顶点。例如,我们可以使用以下函数从面中获取顶点:

def vertices(faces):
    return list(set([vertex for face in faces for vertex in face]))

3.5.2 投影到二维

要将三维点转换为二维点,我们必须选择我们观察的 3D 方向。一旦我们有了两个定义“向上”和“向右”的 3D 向量,我们就可以将任何 3D 向量投影到它们上,并得到两个而不是三个分量。component 函数通过点积提取任何 3D 向量指向给定方向的部分:

def component(v,direction):
    return (dot(v,direction) / length(direction))

预设两个方向(在这种情况下,(1, 0, 0) 和 (0, 1, 0))后,我们可以建立一种从三维坐标投影到二维坐标的方法。这个函数接受一个三维向量或三个数字的元组,并返回一个二维向量或两个数字的元组:

def vector_to_2d(*v*):
    return (component(v,(1,0,0)), component(v,(0,1,0)))

我们可以想象这是“展平”3D 向量到平面。删除z分量移除了向量所具有的任何深度(图 3.45)。

图 3.45 删除 3D 向量的 z 分量将其展平到 x,y 平面。

最后,为了将三角形从 3D 转换为 2D,我们只需将此函数应用于定义面的所有顶点:

def face_to_2d(face):
    return [vector_to_2d(vertex) for vertex in face]

3.5.3 面向和着色

为了给我们的 2D 绘图着色,我们根据每个三角形面对给定光源的程度为每个三角形选择一个固定的颜色。假设我们的光源位于从原点出发的向量(1, 2, 3)。那么,三角形面的亮度取决于它对光的垂直程度。另一种衡量方法是,与面的垂直向量与光源的排列程度。我们不必担心计算颜色;Matplotlib 有一个内置库为我们完成这项工作。例如,

blues = matplotlib.cm.get_cmap('Blues')

给我们一个名为blues的函数,它将 0 到 1 的数字映射到从暗到亮的蓝色值谱。我们的任务是找到一个 0 到 1 之间的数字,表示一个面应该有多亮。

给定每个面的垂直(或法线)向量和指向光源的向量,它们的点积告诉我们它们有多对齐。此外,因为我们只考虑方向,我们可以选择长度为 1 的向量。然后,如果面指向光源,点积将在 0 和 1 之间。如果它比 90°远离光源,它将完全不发光。这个辅助函数接受一个向量并返回另一个方向相同但长度为 1 的向量:

def unit(*v*):
    return scale(1./length(*v*), v)

这个第二个辅助函数接受一个面并给我们一个垂直于它的向量:

def normal(face):
    return(cross(subtract(face[1 ], face[0 ]), subtract(face[2 ], face[0 ])))

将所有这些放在一起,我们有一个函数,它使用我们的draw函数绘制所有必要的三角形,以渲染 3D 形状。(我已经将draw重命名为draw2d,并将相应的类相应地重命名,以区分它们与 3D 对应物。)

def render(faces, light=(1,2,3), color_map=blues, lines=None):
    polygons = []
    for face in faces:
        unit_normal = unit(normal(face))                    ❶
        if unit_normal[2 ] > 0 :                            ❷
            c = color_map(1 − dot(unit(normal(face)), 
                          unit(light)))                     ❸
            p = Polygon2D(*face_to_2d(face), 
                          fill=c, color=lines)              ❹
            polygons.append(p)
    draw2d(*polygons,axes=False, origin=False, grid=None)

❶ 对于每个面,计算一个长度为 1 的垂直于它的向量

❷ 只有当这个向量的 z 分量是正的,换句话说,如果它指向观察者时,才进行操作

❸ 正交向量与光源向量的点积越大,着色

❹ 为每个三角形的边缘指定一个可选的线条参数,揭示我们正在绘制的形状的骨架

使用下面的render函数,只需几行代码就可以生成一个八面体。图 3.46 显示了结果。

render(octahedron, color_map=matplotlib.cm.get_cmap('Blues'), lines=black)

图 3.46 八面体的四个可见面,以蓝色色调着色

从侧面看,着色的八面体并不特别,但增加更多面后,我们可以看出着色效果正在起作用(图 3.47)。您可以在本书的源代码中找到具有更多面的预构建形状。

图 3.47 一个具有许多三角形边的 3D 形状。着色效果更为明显。

3.5.4 练习

| 练习 3.27-迷你项目:找到定义八面体 12 条边的向量对,并在 Python 中绘制所有边。解答:八面体的顶部是 (0, 0, 1)。它通过四条边连接到 xy 平面的所有四个点。同样,八面体的底部是 (0, 0, −1),它也连接到 xy 平面的所有四个点。最后,xy 平面的四个点以正方形的形式相互连接:

top = (0,0,1)
bottom = (0,0,−1)
xy_plane = [(1,0,0),(0,1,0),(−1,0,0),(0,−1,0)]
edges = [Segment3D(top,p) for p in xy_plane] +\
            [Segment3D(bottom, p) for p in xy_plane] +\
            [Segment3D(xy_plane[i],xy_plane[(i+1)%4 ]) for i in range(0,4)] 
draw3d(*edges)

八面体的结果边 |

练习 3.28:八面体的第一个面是 [(1, 0, 0), (0, 1, 0), (0, 0, 1)]。这是唯一有效的顶点顺序吗?解答:不是,例如 [(0, 1, 0), (0, 0, 1), (1, 0, 0)] 是相同的三点集合,并且在这个顺序中叉积仍然指向相同方向。

摘要

  • 与二维向量有长度和宽度不同,三维向量还有深度。

  • 三维向量由称为 xyz 坐标的数字三元组定义。它们告诉我们到达三维点需要沿每个方向移动多远。

  • 与二维向量一样,三维向量可以进行加法、减法和与标量相乘。我们可以使用三维版本的勾股定理来找到它们的长度。

  • 点积是乘以两个向量并得到一个标量的方法。它衡量两个向量的对齐程度,我们可以使用它的值来找到两个向量之间的角度。

  • 叉积是乘以两个向量并得到一个垂直于两个输入向量的第三个向量的方法。叉积输出的幅度是两个输入向量所围成的平行四边形的面积。

  • 我们可以将任何三维物体的表面表示为三角形的集合,其中每个三角形分别由表示其顶点的三个向量定义。

  • 使用叉积,我们可以决定从 3D 中三角形可见的方向。这可以告诉我们观察者是否可以看到它或它被给定光源照亮的程度。通过绘制和着色定义物体表面的所有三角形,我们可以使其看起来是三维的。

4 向量和图形的变换

本章涵盖了

  • 通过应用数学函数变换和绘制 3D 对象

  • 使用变换创建计算机动画,用于矢量图形

  • 识别保持直线和多边形不变的线性变换

  • 计算线性变换对向量和 3D 模型的影响

通过前两章的技术和一点创意,你可以渲染你所能想到的任何 2D 或 3D 图形。整个物体、角色和世界都可以由向量定义的线段和多边形构建。但是,仍然有一件事介于你和你的第一部特长篇计算机动画电影或逼真的动作视频游戏之间−你需要能够绘制随时间变化的物体。

动画在计算机图形学和电影中工作方式相同:你渲染静态图像,然后每秒显示数十个。当我们看到这么多移动对象的快照时,它看起来就像图像在持续变化。在第二章和第三章中,我们研究了几个数学运算,它们接受现有的向量并将它们几何变换成新的向量。通过将一系列小变换链接起来,我们可以创造出连续运动的错觉。

作为这个模型的心理模型,你可以记住我们旋转二维向量的例子。你看到你可以写一个 Python 函数,rotate,它接受一个二维向量并将其逆时针旋转 45°。如图 4.1 所示,你可以把rotate函数想象成一个机器,它接受一个向量并输出相应变换后的向量。

图 4.1 将向量函数想象成一个具有输入槽和输出槽的机器

如果我们将这个函数的 3D 类比应用于定义 3D 形状的每个多边形的每个向量,我们就可以看到整个形状旋转。这个 3D 形状可以是前一章中的八面体,或者是一个更有趣的形状,比如茶壶。在图 4.2 中,这个旋转机器接受茶壶作为输入,并返回旋转后的副本作为输出。

图 4.2 可以将变换应用于构成 3D 模型的每个向量,从而以相同的方式变换整个模型。

如果我们不是一次旋转 45°,而是旋转 45 次,每次旋转 1 度,我们就可以生成显示旋转茶壶的电影帧(图 4.3)。

旋转是很好的例子,因为当我们围绕原点以相同角度旋转线段上的每个点时,我们仍然有一个相同长度的线段。因此,当你旋转构成二维或三维对象的向量时,你仍然可以识别出该对象。

图 4.3 每次旋转茶壶 1°,连续旋转 45 次,从左上角开始

我将向您介绍一类称为线性变换的广泛向量变换,它们与旋转类似,将位于直线上的向量映射到新的位于直线上的向量。线性变换在数学、物理和数据分析中有着广泛的应用。当您在这些背景下再次遇到它们时,了解如何从几何角度想象它们是有帮助的。

为了可视化本章中的旋转、线性变换和其他向量变换,我们将升级到更强大的绘图工具。我们将用 OpenGL 替换 Matplotlib,OpenGL 是一个用于高性能图形的行业标准库。大多数 OpenGL 编程是用 C 或 C++完成的,但我们将使用一个友好的 Python 包装器 PyOpenGL。我们还将使用一个名为 PyGame 的 Python 视频游戏开发库。具体来说,我们将使用 PyGame 中的功能,这些功能使得将连续图像渲染成动画变得容易。所有这些新工具的设置都在附录 C 中介绍,因此我们可以直接进入并专注于向量的数学变换。如果您想跟随本章的代码(我强烈推荐!),那么您应该跳转到附录 C,一旦代码运行正常,再返回这里。

4.1 变换 3D 对象

本章的主要目标是取一个 3D 对象(如茶壶)并改变它以创建一个新的 3D 对象,该对象在视觉上不同。在第二章中,我们已经看到我们可以平移或缩放二维恐龙中的每个向量,整个恐龙形状会相应地移动或改变大小。我们在这里采取相同的方法。我们研究的每个变换都接受一个向量作为输入并返回一个向量作为输出,类似于以下伪代码:

def transform(*v*):
    old_x, old_y, old_z = v
    # ... do some computation here ...
    return (new_x, new_y, new_z)

让我们从将熟悉的二维平移和缩放示例适应到三维空间开始。

4.1.1 绘制变换后的对象

如果您已安装附录 C 中描述的依赖项,您应该能够在第四章的源代码中运行 draw_teapot.py 文件(有关从命令行运行 Python 脚本的说明,请参阅附录 A)。如果它运行成功,您应该看到一个显示图 4.4 中图像的 PyGame 窗口。

图像

图 4.4 运行 draw_teapot.py 的结果

在接下来的几个示例中,我们将修改定义茶壶的向量,然后重新渲染它,以便我们可以看到几何效果。作为一个例子,我们可以将所有向量按相同的因子缩放。以下函数scale2将输入向量乘以标量 2.0 并返回结果:

from vectors import scale
def scale2(*v*):
    return scale(2.0, v)

这个scale2(*v*)函数与本章开头给出的transform(*v*)函数具有相同的形式;当输入一个 3D 向量时,scale2返回一个新的 3D 向量作为输出。为了在茶壶上执行这种变换,我们需要变换它的每个顶点。我们可以一个三角形一个三角形地做这件事。对于构建茶壶的每个三角形,我们创建一个新的三角形,其顶点是应用scale2变换后的原始顶点:

original_triangles = load_triangles()        ❶
scaled_triangles = [
    [scale2(vertex) for vertex in triangle]  ❷
    for triangle in original_triangles       ❸
]

❶ 使用附录 C 中的代码加载三角形

❷ 将 scale2 应用到给定三角形中的每个顶点上以获得新顶点

❸ 对原始三角形列表中的每个三角形都这样做

现在我们已经得到了一组新的三角形,我们可以通过调用 draw_model(scaled_triangles) 来绘制它们。图 4.5 显示了调用此函数后的茶壶,你可以通过在源代码中运行文件 scale_teapot.py 来重现这个结果。

图片

图 4.5 将 scale2 应用到每个三角形的每个顶点上,我们得到了一个两倍大的茶壶。

这个茶壶看起来比原来的大,实际上,它大了两倍,因为我们把每个向量乘以 2。现在让我们对每个向量应用另一个变换:通过向量 (−1, 0, 0) 进行平移。

回想一下,“通过向量转换”是“加向量”的另一种说法,所以我真正要讨论的是将向量 (−1, 0, 0) 加到茶壶的每个顶点上。这应该将整个茶壶向负 x 方向移动一个单位,即从我们的视角来看是向左移动。这个函数为单个顶点完成了转换:

from vectors import add
def translate1left(*v*):
    return add((−1,0,0), v)

从原始三角形开始,我们现在想要像以前一样缩放每个顶点,然后应用平移。图 4.6 显示了结果。你可以使用源文件 scale_translate_teapot.py 来重现它:

scaled_translated_triangles = [
    [translate1left(scale2(vertex)) for vertex in triangle]
    for triangle in original_triangles
]
draw_model(scaled_translated_triangles)

图片

图 4.6 茶壶变大了,并且像我们希望的那样向左移动了!

不同的标量倍数以不同的因素改变茶壶的大小,不同的平移向量将茶壶移动到空间中的不同位置。在接下来的练习中,你将有机会尝试不同的标量倍数和平移,但到目前为止,让我们专注于组合和应用更多的变换。

4.1.2 组合向量变换

依次应用任意数量的变换定义了一个新的变换。例如,在上一节中,我们通过缩放然后平移来变换茶壶。我们可以将这个新变换打包成它自己的 Python 函数:

def scale2_then_translate1left(*v*):
    return translate1left(scale2(*v*))

这是一个重要的原则!因为向量变换以向量作为输入并返回向量作为输出,我们可以通过函数的组合来组合尽可能多的它们。如果你之前没有听说过这个术语,它意味着通过以指定顺序应用两个或多个现有函数来定义新函数。如果我们把 scale2translate1left 函数想象成接受 3D 模型并输出新模型的机器(图 4.7),我们可以通过将第一个机器的输出作为第二个机器的输入来组合它们。

图片

图 4.7 在茶壶上调用 scale2translate1left 以输出转换后的版本

我们可以想象通过将第一个机器的输出槽焊接到第二个机器的输入槽来隐藏中间步骤(图 4.8)。

图片

图 4.8 将两个函数机器焊接在一起以得到一个新的机器,该机器一步完成两种转换

我们可以把结果想象成一个新机器,它一步完成原始函数的工作。这种函数的“焊接”也可以在代码中实现。我们可以编写一个通用的compose函数,它接受两个 Python 函数(例如,用于向量变换的函数)并返回一个新的函数,这是它们的组合:

def compose(f1,f2):
    def new_function(input):
        return f1(f2(input))
    return new_function

我们不必为scale2_then_translate1left定义一个单独的函数,我们可以这样写

scale2_then_translate1left = compose(translate1left, scale2)

你可能听说过 Python 将函数视为“一等对象”的想法。这个口号通常意味着 Python 函数可以被分配给变量,作为其他函数的输入传递,或者即时创建并作为输出值返回。这些都是函数式编程技术,意味着它们通过组合现有函数来构建新的函数,帮助我们构建复杂的程序。

关于函数式编程在 Python 中是否合适(或者说,作为一个 Python 粉丝可能会说,函数式编程是否“Pythonic”),有一些争议。我不会对编码风格发表意见,但我使用函数式编程,因为函数,特别是向量变换,是我们研究的核心对象。在介绍了compose函数之后,我将向你展示一些更多的函数式“食谱”,以证明这种偏离是有道理的。这些都是在本书源代码中的新辅助文件transforms.py中添加的。

我们将反复进行的一项操作是将向量变换应用于定义 3D 模型的每个三角形的每个顶点。我们可以为这个操作编写一个可重用的函数,而不是每次都编写一个新的列表推导式。以下polygon_map函数接受一个向量变换和一个多边形列表(通常是三角形),并将变换应用于每个多边形的每个顶点,生成一个新的多边形列表:

def polygon_map(transformation, polygons):
    return [
        [transformation(vertex) for vertex in triangle]
        for triangle in polygons
    ]

使用这个辅助函数,我们可以一行代码就将scale2应用于原始的茶壶:

draw_model(polygon_map(scale2, load_triangles()))

composepolygon_map函数都接受向量变换作为参数,但拥有返回向量变换的函数也很有用。例如,我们可能觉得将一个函数命名为scale2并将数字二硬编码到其定义中有些麻烦。这种做法的一个替代方案是scale_by函数,它返回一个指定标量的缩放变换:

def scale_by(scalar):
    def new_function(*v*):
        return scale(scalar, v)
    return new_function

使用这个函数,我们可以写scale_by(2),返回值将是一个新的函数,其行为与scale2完全相同。当我们把函数想象成具有输入和输出槽位的机器时,你可以把scale_by想象成一个机器,它在其输入槽位中接受数字,并从其输出槽位输出新的函数机器,如图 4.9 所示。

图片

图 4.9 一个函数机器,它接受数字作为输入并产生新的函数机器作为输出

作为练习,你可以编写一个类似的 translate_by 函数,它接受一个平移向量作为输入,并返回一个平移函数作为输出。在函数式编程的术语中,这个过程称为 currying . Currying 将接受多个输入的函数重构为返回另一个函数的函数。

结果是一个程序化的机器,其行为相同,但调用方式不同;例如,scale_by(s)(*v*) 对于任何输入 sv 都会产生与 scale(s,v) 相同的结果。优点是 scale(...)add(...) 可以接受不同类型的参数,因此生成的函数 scale_by(s)translate_by(*w*) 可以互换使用。接下来,我们将类似地考虑旋转:对于任何给定的角度,我们可以生成一个向量变换,使我们的模型绕该角度旋转。

4.1.3 绕轴旋转对象

你已经在第二章中看到了如何在二维中进行旋转:你将笛卡尔坐标转换为极坐标,通过旋转因子增加或减少角度,然后转换回来。尽管这是一个二维技巧,但在三维中也很有用,因为所有三维

向量旋转在某种意义上是孤立在平面上的。例如,想象一个点在三维空间中绕 z 轴旋转。它的 xy 坐标会改变,但它的 z 坐标保持不变。如果给定点绕 z 轴旋转,它将保持在具有恒定 z 坐标的圆上,无论旋转角度如何(图 4.10)。

图 4.10 绕 z 轴旋转点

这意味着我们可以通过保持 z 坐标不变,并将我们的二维旋转函数仅应用于 xy 坐标来绕 z 轴旋转一个三维点。我们将在代码中实现这一点,你还可以在源代码中的 rotate_teapot.py 文件中找到它。首先,我们编写一个二维旋转函数,该函数是从我们在第二章中使用的方法改编而来的:

def rotate2d(angle, vector):
    l,a = to_polar(vector)
    return to_cartesian((l, a+angle))

此函数接受一个角度和一个二维向量,并返回一个旋转后的二维向量。现在,让我们创建一个函数 rotate_z,它只应用于三维向量的 xy 分量:

def rotate_z(angle, vector):
    x,y,z = vector
    new_x, new_y = rotate2d(angle, (x,y))
    return new_x, new_y, z

继续在函数式编程范式下思考,我们可以对函数进行 currying。给定任何角度,curried 版本会产生一个向量变换,执行相应的旋转:

def rotate_z_by(angle):
    def new_function(*v*):
        return rotate_z(angle,v)
    return new_function

让我们看看它的实际效果。以下行生成了图 4.11 中的茶壶,它绕 π/4 或 45° 旋转:

draw_model(polygon_map(rotate_z_by(pi/4.), load_triangles()))

图 4.11 茶壶绕 z 轴逆时针旋转 45°。

我们可以编写一个类似的函数来绕 x 轴旋转茶壶,这意味着旋转只影响向量的 yz 分量:

def rotate_x(angle, vector):
    x,y,z = vector
    new_y, new_z = rotate2d(angle, (y,z))
    return x, new_y, new_z
def rotate_x_by(angle):
    def new_function(*v*):
        return rotate_x(angle,v)
    return new_function

rotate_x_by 函数中,通过固定 x 坐标并在 yz 平面上执行 2D 旋转来实现绕 x 轴的旋转。以下代码绘制了 90° 或 π/2 弧度(逆时针)的旋转,结果如图 4.12 所示:

draw_model(polygon_map(rotate_x_by(pi/2.), load_triangles()))

图 4.12 茶壶绕 x 轴旋转 π/2。

你可以使用源文件 rotate_teapot_x.py 重新生成图 4.12。这些旋转茶壶的阴影是一致的;它们最亮的多边形位于图象的右上角,这是预期的,因为光源仍然位于 (1, 2, 3)。这是一个好迹象,表明我们正在成功移动茶壶,而不是像以前那样仅仅改变我们的 OpenGL 视角。

结果表明,通过在 xz 方向上组合旋转,我们可以得到我们想要的任何旋转。在章节末尾的练习中,你可以尝试一些更多的旋转,但到目前为止,我们将继续探讨其他类型的向量变换。

4.1.4 发明你自己的几何变换

到目前为止,我主要关注我们在前几章中以一种方式看到过的向量变换。现在,让我们大胆尝试,看看我们还能想出哪些有趣的变换。记住,3D 向量变换的唯一要求是它接受一个单一的 3D 向量作为输入,并返回一个新的 3D 向量作为输出。让我们看看一些不太符合我们之前看到过的类别的变换。

对于我们的茶壶,让我们一次修改一个坐标。这个函数通过(硬编码的)四倍因子拉伸向量,但只在 x 方向上:

def stretch_x(vector):
    x,y,z = vector
    return (4.*x, y, z)

结果是沿着 x 轴或把手到壶嘴的方向出现一个细长的茶壶(图 4.13)。这在 stretch_teapot.py 中完全实现。

图 4.13 沿 x 轴拉伸的茶壶。

类似的 stretch_y 函数可以使茶壶从上到下变长。你可以自己实现 stretch_y 并将其应用于茶壶,你应该得到图 4.14 中的图像。否则,你可以查看源代码中 stretch_teapot_y.py 中的实现。

我们甚至可以更有创意,通过将 y 坐标立方而不是简单地乘以一个数字来拉伸茶壶。这种变换在 cube_teapot.py 中实现,并在图 4.15 中展示:

def cube_stretch_z(vector):
    x,y,z = vector
    return (x, y*y*y, z)

图 4.14 在 y 方向上拉伸茶壶而不是其他方向

图 4.15 茶壶的垂直维度立方

如果我们选择性地在变换的公式中添加三个坐标中的两个,例如 xy 坐标,我们可以使茶壶倾斜。这已在 slant_teapot.py 中实现,并在图 4.16 中展示:

图 4.16 将 y 坐标添加到现有的 x 坐标会使茶壶在x方向上倾斜。

def slant_xy(vector):
    x,y,z = vector
    return (x+y, y, z)

关键点不是这些变换中的任何一个都很重要或有用,而是构成 3D 模型的向量的任何数学变换都会对模型的外观产生某种几何影响。过度使用变换可能会导致模型过度扭曲,以至于无法识别或成功绘制。实际上,一些变换在一般情况下表现得更好,我们将在下一节中对其进行分类。

4.1.5 练习

| 练习 4.1:实现一个translate_by函数(在 4.1.2 节中提到),它接受一个平移向量作为输入,并返回一个平移函数作为输出。解答

def translate_by(translation):
    def new_function(*v*):
        return add(translation,v)
    return new_function

|

| 练习 4.2:渲染沿负z方向平移 20 单位的茶壶。结果图像看起来像什么?解答:我们可以通过将translate_by((0,0,−20))应用于每个多边形的每个向量,使用polgyon_map来完成这项工作:

draw_model(polygon_map(translate_by((0,0,−20)), load_triangles()))

记住,我们是从 z 轴上方五单位处观察茶壶的。这个变换将茶壶带到我们前方 20 单位处,所以它看起来比原始的茶壶小得多。你可以在源代码中的 translate_teapot_down_z.py 中找到完整的实现。图片茶壶沿 z 轴向下平移了 20 单位。它看起来更小,因为它离观察者更远。|

| 练习 4.3-迷你项目:当你将每个向量按 0 到 1 之间的标量缩放时,茶壶会发生什么变化?当你将其按-1 的因子缩放时会发生什么?解答:我们可以通过应用scale_by(0.5)scale_by(−1)来查看结果:

draw_model(polygon_map(scale_by(0.5), load_triangles()))
draw_model(polygon_map(scale_by(−1), load_triangles()))

图片从左到右,原始的茶壶、放大 0.5 倍的茶壶和放大-1 倍的茶壶。如图所示,scale_by(0.5)将茶壶缩小到原来的一半大小。scale_by(−1)的动作看起来像是将茶壶旋转了 180°,但实际上情况要复杂一些。它实际上被翻转了!每个三角形都被反射了,所以每个法向量现在指向茶壶内部而不是从其表面向外!图片反射改变了三角形的方向。左边的索引顶点按逆时针顺序排列,右边的反射按顺时针顺序排列。这些三角形的法向量指向相反的方向。旋转茶壶,你可以看到它渲染得并不完全正确,这是由于这个原因!我们应该小心处理我们的图形的反射!!图片旋转并反射的茶壶看起来并不完全正确。一些特征出现了,但应该被隐藏。例如,在右下角的框架中,我们可以看到盖子和空心底部。|

| 练习 4.4: 首先对茶壶应用 translate1left,然后应用 scale2。与逆序组合的效果有何不同?为什么?解答:我们可以按指定顺序组合这两个函数,然后使用 polygon_map: 应用它们。|

draw_model(polygon_map(compose(scale2, translate1left), load_triangles()))

结果是茶壶仍然是原始大小的两倍,但这个茶壶向左平移得更远。这是因为当缩放因子 2 在平移之后应用时,平移的距离也会加倍。你可以通过运行源文件 scale_translate_teapot.pytranslate_scale_teapot.py 并比较结果来证实这一点!图片先缩放后平移茶壶(左)与先平移后缩放(右)|

练习 4.5: 变换 compose(scale_by (0.4), scale_by(1.5)) 的效果是什么?解答:应用此变换到向量上,首先将其缩放为 1.5 倍,然后缩放为 0.4 倍,最终缩放因子为 0.6。生成的图形将是原始大小的 60%。

| 练习 4.6: 将 compose(f,g) 函数修改为 compose(*args),它接受多个函数作为参数,并返回一个新的函数,该函数是它们的组合。解答

def compose(*args):
    def new_function(input):         ❶
        state = input                ❷
        for f in reversed(args):     ❸
            state = f(state)         ❹
        return state
    return new_function

❶ 开始定义 compose 返回的函数❷ 将当前状态设置为输入❸ 以相反的顺序遍历输入函数,因为组合的内函数先应用。例如,compose(f,g,h)(*x*) 应等于 f(g(h(*x*))),所以第一个要应用的函数是 h。❹ 在每一步中,通过应用下一个函数来更新状态。最终状态包含所有 |

| 为了验证我们的工作,我们可以构建一些函数并将它们组合起来:

def prepend(string):
    def new_function(input):
        return string + input
    return new_function

f = compose(prepend("P"), prepend("y"), prepend("t"))

然后,运行 *f*(“hon”) 返回字符串 “Python”。一般来说,构造的函数 f 将字符串 “Pyt” 追加到它所接受的任何字符串上。|

| 练习 4.7: 编写一个 curry2(f) 函数,该函数接受一个具有两个参数的 Python 函数 f(x,y) 并返回一个柯里化版本。例如,一旦你编写了 g = curry2(f),两个表达式 f(x,y)*g*(*x*)(*y*) 应该返回相同的结果。解答:返回值应该是一个新函数,当它被调用时,会再次产生一个新的函数:

def curry2(f):
    def *g*(*x*):
        def new_function(*y*):
            return f(x,y)
        return new_function
    return g

作为例子,我们可以这样构建 scale_by 函数:

>>> scale_by = curry2(scale)
>>> scale_by(2)((1,2,3))

(2, 4, 6)

|

练习 4.8: 不运行它,应用变换 compose(rotate_z_by(pi/2),rotate_x_by(pi/2)) 的结果是什么?如果你交换组合的顺序呢?解答:这个组合等价于绕 y 轴顺时针旋转 π/2。逆序则给出绕 y 轴逆时针旋转 π/2。

| 练习 4.9: 编写一个函数 stretch_x(scalar,vector),该函数通过给定的因子缩放目标向量,但仅在 x 方向上缩放。同时编写一个柯里化版本 stretch_x_by,使得 stretch_x_by(scalar)(vector) 返回相同的结果。解答

def stretch_x(scalar,vector):
    x,y,z = vector
    return (scalar*x, y, z)

def stretch_x_by(scalar):
    def new_function(vector):
        return stretch_x(scalar,vector)
    return new_function

|

4.2 线性变换

我们将要关注的良好行为的向量变换被称为 线性变换。与向量一样,线性变换是线性代数中研究的其他主要对象。线性变换是一种特殊的变换,其中向量算术在变换前后看起来相同。让我们绘制一些图表来展示这究竟意味着什么。

4.2.1 保持向量算术

向量上的两个最重要的算术运算分别是加法和标量乘法。让我们回到这些操作的 2D 图像,看看在应用变换前后它们看起来如何。

我们可以将两个向量的和想象成我们将它们尾对尾放置时到达的新向量,或者想象成定义的平行四边形的顶点向量。例如,图 4.17 表示向量和 u + v = w

图 4.17 向量和 z + v = w 的几何演示

我们想要问的问题是,如果我们将相同的向量变换应用到这个图中的三个向量上,它是否仍然看起来像向量和?让我们尝试一个向量变换,这是一个关于原点的逆时针旋转,我们称这个变换为 R。图 4.18 显示了 uvw 通过变换 R 以相同的角度旋转。

图 4.18 在将 uvw 通过相同的旋转 R 旋转后,和仍然成立。

旋转后的图表正好是表示向量和 R(u) + R(v) = R(w) 的图表。你可以为任何三个向量 uvw 绘制图像,只要 u + v = w,并且如果你将相同的旋转变换 R 应用到每个向量上,你会发现 R(u) + R(v) = R(w)。为了描述这个属性,我们说旋转 保持 向量和。

同样,旋转保持标量乘法。如果 v 是一个向量,而 sv 是通过标量 s 乘以 v 得到的,那么 sv 指向相同的方向,但按 s 的因子缩放。如果我们用相同的旋转 R 旋转 v 和 sv,我们会看到 R(s v) 是 R(*v**) 的一个标量倍数,倍数为相同的 s(图 4.19)。

图 4.19 旋转保持了标量乘法。

再次强调,这只是一个视觉示例,而不是一个证明,但你会发现对于任何向量 v,标量 s 和旋转 R,同样的图像是成立的。保持向量加法和标量乘法的旋转或其他向量变换被称为 线性变换

线性变换 线性变换 是一个保持向量加法和标量乘法的向量变换 T。也就是说,对于任何输入向量 uv,我们有 T(u) + T(v) = T(u + v),并且对于任何一对标量 s 和向量 v,我们有 T(sv) = sT(v*)

确保你暂停一下,消化这个定义;线性变换如此重要,以至于整个线性代数主题都是以它们的名称命名的。为了帮助你识别当你看到它们时的线性变换,我们将再看看几个例子。

4.2.2 图形化线性变换

首先,让我们来看一个反例:一个非线性的向量变换。这样的例子是一个变换 S(v),它将向量 v = (x, y) 转换为坐标都平方的向量:S(v) = (x², y²)。作为一个例子,让我们来看 u = (2, 3) 和 v = (1, −1) 的和。和是 (2, 3) + (1, −1) = (3, 2)。这如图 4.20 中的向量加法所示。

图 4.20 描绘了向量 z = (2, 3) 和 v = (1, −1) 的和,z + v = (3, 2)

现在,让我们将 S 应用到这些向量上:S(u) = (4, 9),S(v) = (1, 1),以及 S(u + v) = (9, 4)。图 4.21 清楚地显示 S(u) + S(v) 与 S(u + v) 不一致。

图 4.21 S 不尊重和!S(u) + S(v) 与 S(u + v) 相去甚远。

作为练习,你可以尝试找到一个反例来证明 S 也不保留标量乘法。现在,让我们检查另一个变换。设 D(v) 是将输入向量按因子 2 放大的向量变换。换句话说,D(v) = 2v。这确实保留了向量和:如果 u + v = w,那么 2u + 2v 也等于 2w。图 4.22 提供了一个视觉示例。

图 4.22 向量的长度加倍保留了它们的和:如果 z + v = w,那么 D(u) + D(v) = D(w).

同样地,D(v) 保留了标量乘法。这有点难以绘制,但你可以从代数上看到这一点。对于任何标量 sD(sv) = 2(sv) = s(2v) = sD(v).

翻译呢?假设 B(v) 将任何输入向量 v 平移到 (7, 0)。令人惊讶的是,这 不是 一个线性变换。图 4.23 提供了一个视觉反例,其中 u + v = w,但 B(v) + B(w) 并不等于 B(v + w).

图 4.23 平移变换 B 不保留向量和,因为 B(u) + B(v) 不等于 B(u + v).

结果表明,为了使一个变换成为线性变换,它必须不移动原点(为什么稍后作为练习说明)。任何非零向量的平移都会将原点移动到不同的点,因此它不能是线性的。

线性变换的其他例子包括反射、投影、剪切以及前面线性变换的任何 3D 类比。这些在练习部分定义,你应该通过几个例子来确信每个这些变换都保持向量加法和标量乘法。通过练习,你可以识别出哪些变换是线性的,哪些不是。接下来,我们将探讨线性变换的特殊性质为什么有用。

4.2.3 为什么是线性变换?

由于线性变换保持向量之和和标量乘积,它们也保持更广泛的向量算术运算类。最一般的运算称为 线性组合。向量集合的线性组合是它们的标量倍数之和。例如,两个向量 uv 的一个线性组合可以是 3u − 2v。给定三个向量 uvw,表达式 0.5uv + 6 w 是 uv* 和 w 的线性组合。因为线性变换保持向量之和和标量乘积,所以它们也保持线性组合。

我们可以用代数方式重述这个事实。如果你有一组 n 个向量 v[1]、v[2]、...、v n,以及任何选择的 n 个标量 s[1]、s[2]、s[3]、...、s[n],线性变换 T 保持线性组合:

T(s[1] v[1] + s[2] v[2] + s[3] v[3] + ... + s[n]v[n]) = s[1] T(v[1]) + s[2] T(v[2]) + s[3] T(v[3]) + ... + s[n]T(v[n])

我们之前看到的一个容易理解的线性组合是向量 uv 的 ½ u + ½ v,这相当于 ½ (u + v)。图 4.24 显示,这两个向量的这种线性组合给出了它们之间线段的中点。

图片

图 4.24 两个向量 zv 之间的中点可以表示为线性组合 ½ z + ½ v = ½ (u + v)。

这意味着线性变换将中点映射到其他中点:例如,Tu + ½ v) = ½ T(u) + ½ T(v),如图 4.25 所示,这是连接 T(u) 和 T(v) 的线段的中点。

图片

图 4.25 因为两个向量之间的中点是向量的线性组合,线性变换 T 将 zv 之间的中点设置为 T(u) 和 T(v) 之间的中点。

这一点可能不太明显,但像 0.25u + 0.75v 这样的线性组合也位于 uv 之间的线段上(图 4.26)。具体来说,这是从 uv 的 75% 处的点。同样,0.6u + 0.4v 是从 uv 的 40% 处,以此类推。

图片

图 4.26 点 0.25u + 0.75v位于连接zv的线段上,从zv的 75%。你可以通过u = (−2, 2)和v = (6, 6)具体地看到这一点。

事实上,两个向量之间的线段上的每一个点都是这样的“加权平均”,具有以下形式:su + (1 − s)v,其中s是介于 0 和 1 之间的某个数。为了让你信服,图 4.27 显示了u = (−1, 1)和v = (3, 4)在 0 和 1 之间 10 个值和 100 个值的s的向量su + (1 − s)v

图片图片

图 4.27 以 0 到 1 之间 10 个值和 100 个值的s绘制(−1, 1)和(3, 4)的各种加权平均(左侧)和右侧)。

这里的关键思想是,连接两个向量uv的线段上的每一个点都是一个加权平均,因此是点uv的线性组合。考虑到这一点,我们可以思考线性变换对整个线段的影响。

连接uv的线段上的任意点都是uv的加权平均,因此它具有以下形式:s · u + (1 − s) · v,其中s是某个值。线性变换Tuv变换为一些新的向量T(u)和T(v)。线段上的点被变换为新的点T(s · u + (1 − s) · v)或s · T(u) + (1 − s) · T(v)。这反过来又是一个T(u)和T(v)的加权平均,因此它是一个位于T(u)和T(v)连接线段上的点,如图 4.28 所示。

图片

图 4.28 线性变换 T 将zv的加权平均转换为 T(u)和 T(v)的加权平均。原始的加权平均位于连接zv的线段上,变换后的加权平均位于连接 T(u)和 T(v)的线段上。

由于这个原因,线性变换T将连接uv的线段上的每一个点变换为连接T(u)和T(v)的线段上的一个点。这是线性变换的一个关键特性:它们将每一个现有的线段映射到新的线段。因为我们的 3D 模型由多边形组成,而多边形由线段勾勒出来,所以线性变换可以在一定程度上保留我们的 3D 模型的结构(如图 4.29 所示)。

图片

图 4.29 将组成三角形的点应用线性变换(旋转 60°)。结果是旋转后的三角形(在左侧)。

相比之下,如果我们使用非线性变换 S(v),将 v = (x, y) 映射到 (x², y²),我们可以看到线段被扭曲。这意味着由向量 uvw 定义的三角形实际上并没有被映射到由 S(u),S(v),和 S(w) 定义的另一个三角形,如图 4.30 所示。

图片

图片

总结来说,线性变换尊重向量的代数性质,保持和、标量乘积和线性组合。它们也尊重向量集合的几何性质,将向量定义的线段和多边形映射到由变换向量定义的新线段和多边形。接下来,我们将看到线性变换不仅在几何上特殊;它们也易于计算。

4.2.4 计算线性变换

在第二章和第三章中,你看到了如何将 2D 和 3D 向量分解成分量。例如,向量 (4, 3, 5) 可以分解为和 (4, 0, 0) + (0, 3, 0) + (0, 0, 5)。这使得我们可以想象向量在我们所在空间的三个维度中延伸有多远。我们可以进一步将其分解为线性组合(图 4.31):

(4, 3, 5) = 4 · (1, 0, 0) + 3 · (0, 1, 0) + 5 · (0, 0, 1)

图片

图 4.31 3D 向量 (4, 3, 5) 是 (1, 0, 0),(0, 1, 0) 和 (0, 0, 1) 的线性组合

这个事实可能看起来很无聊,但它是从线性代数中得出的深刻见解之一:任何 3D 向量都可以分解为三个向量 (1, 0, 0),(0, 1, 0) 和 (0, 0, 1) 的线性组合。在这个分解中出现的标量正好是向量 v 的坐标。

三个向量 (1, 0, 0),(0, 1, 0) 和 (0, 0, 1) 被称为三维空间的标准基。这些表示为 e[1],e[2] 和 e[3],因此我们可以将之前的线性组合写成 (3, 4, 5) = 3 e[1] + 4 e[2] + 5 e[3]。当我们工作在 2D 空间时,我们称 e[1] = (1, 0) 和 e[2] = (0, 1);例如,(7, −4) = 7 e[1] − 4 e2。(当我们说 e[1] 时,我们可能指的是 (1, 0) 或 (1, 0, 0),但一旦我们确定了我们在二维还是三维空间中工作,通常就可以清楚地知道我们指的是哪一个。)

图片

图 4.32 2D 向量 (7, −4) 是标准基向量 e1 和 e2 的线性组合

我们只是以稍微不同的方式写下了相同的向量,但结果证明这种视角的改变使得计算线性变换变得容易。因为线性变换尊重线性组合,所以我们只需要知道线性变换如何影响标准基向量。

让我们来看一个视觉示例(图 4.33)。假设我们对二维向量变换 T 一无所知,除了它具有线性性质,并且我们知道 T(e[1]) 和 T(e[2]) 是什么。

图 4.33 当线性变换作用于二维中的两个标准基向量时,我们得到两个新的向量作为结果。

对于任何其他向量 v,我们自动知道 T(v) 最终会落在何处。假设 v = (3, 2),那么我们可以断言:

T(v) = T(3e[1] + 2e[2]) = 3T(e[1]) + 2T(e[2])

因为我们已经知道 T(e[1]) 和 T(e[2]) 的位置,我们可以根据图 4.34 定位 T(v)。

图 4.34 我们可以计算 T(v) 为 T(e1) 和 T(e2) 的线性组合。

为了使这个问题更具体,让我们在三维空间中做一个完整的例子。假设 a 是一个线性变换,我们只知道 a(e[1]) = (1, 1, 1),a(e[2]) = (1, 0, −1),和 a(e[3]) = (0, 1, 1)。如果 v = (−1, 2, 2),那么 a(v) 是什么?首先,我们可以将 v 展开为三个标准基向量的线性组合。因为 v = (−1, 2, 2) = −e[1] + 2e[2] + 2e[3],我们可以进行替换:

a(v) = a(−e[1] + 2e[2] + 2e[3])

接下来,我们可以利用 a 是线性且保持线性组合的事实:

= − a(e[1]) + 2a(e[2]) + 2a(e[3])

最后,我们可以代入已知的 a(e[1])、a(e[2]) 和 a(e[3]) 的值,并进行简化:

= − (1, 1, 1) + 2 · (1, 0, −1) + 2 · (0, 1, 1)

= (1, 1, −1)

为了证明我们确实知道 a 如何工作,我们可以将其应用于茶壶:

Ae1 = (1,1,1)                                        ❶
Ae2 = (1,0,−1)
Ae3 = (0,1,1)

def apply_A(v):                                      ❷
    return add(                                      ❸
        scale(v[0], Ae1),
        scale(v[1], Ae2),
        scale(v[2], Ae3)
    )

draw_model(polygon_map(apply_A, load_triangles()))   ❹

❶ 应用 A 到标准基向量的已知结果

❷ 构建一个函数 apply_A(v),它返回 A 对输入向量 v 的作用结果

❸ 结果应该是这些向量的线性组合,其中标量是目标向量 v 的坐标。

❹ 使用 polygon_map 将 A 应用到茶壶中每个三角形的每个向量上

图 4.35 显示了这次变换的结果。

图 4.35 在这个旋转、倾斜的配置中,我们看到茶壶没有底部!

这里的要点是,二维线性变换 T 完全由 T(e[1]) 和 T(e[2]) 的值定义;总共是两个向量或四个数字。同样,三维线性变换 T 完全由 T(e[1])、T(e[2]) 和 T(e[3]) 的值定义,总共是三个向量或九个数字。在任何数量的维度中,线性变换的行为由向量列表或数字的数组-数组指定。这种数组-数组称为 矩阵,我们将在下一章中看到如何使用矩阵。

4.2.5 练习

练习 4.10: 再次考虑 S,即平方所有坐标的向量变换,通过代数证明对于所有选择的标量 s 和 2D 向量 vS(sv) = sS(v) 不成立。解答:设 v = (x, y)。那么 s v = (sx, sy),且 S(sv) = (s² x², s² y²) = s² · (x², y²) = s² · S(v)。对于大多数 s 值和大多数向量 vS(sv) = s² · S(v) 不会等于 s · S(v)。一个具体的反例是 s = 2 和 v = (1, 1, 1),其中 S(sv) = (4, 4, 4) 而 s · S(v) = (2, 2, 2)。这个反例表明 S 不是线性的。
练习 4.11: 假设 T 是一个向量变换,且 T(0) ≠ 0,其中 0 代表所有坐标都等于零的向量。为什么根据定义,T 不是线性的?解答:对于任何向量 vv + 0 = v。为了保持向量加法,T 应该满足 T(v + 0) = T(v) + T(0)。因为 T(v + 0) = T(v),这要求 T(v) = T(v) + T(0) 或 0 = T(0)。鉴于这种情况并不成立,T 不能是线性的。
练习 4.12: 恒等变换是返回相同向量的向量变换。它用大写 I 表示,因此我们可以将其定义写为 I(v) = v 对于所有向量 v。解释为什么 I 是一个线性变换。解答:对于任何向量 vwI(v + w) = v + w = I(v) + I(w),对于任何标量 sI(sv) = s v = s · I(v)。这些等式表明恒等变换保持了向量加法和标量乘法。
练习 4.13: (5, 3) 和 (−2, 1) 之间的中点是什么?绘制这三个点以验证你的答案。解答:中点是 ½ (5, 3) + ½ (−2, 1) 或 (5/2, 3/2) + (−1, ½),等于 (3/2, 2)。这在以下按比例绘制的图中可以验证:连接 (5, 3) 和 (−2, 1) 的线段的中点是 (3/2, 2)。
练习 4.14: 再次考虑非线性变换 S(v),它将 v = (x, y) 映射到 (x², y²)。使用第二章中的绘图代码,将所有坐标为 0 到 5 的 36 个向量 v 作为点绘制出来,然后为每个点绘制 S(v)。在 S 的作用下,向量在几何上会发生什么变化?解答*:最初点之间的空间是均匀的,但在变换后的图像中,随着 xy 坐标的增加,水平方向和垂直方向上的间距分别增加。  点的网格最初是均匀分布的,但在应用变换 S 之后,点之间的间距发生变化,甚至在同一直线上也是如此。
练习 4.15-迷你项目: 基于属性的测试是一种单元测试,它涉及为程序发明任意输入数据,然后检查输出是否满足期望条件。有像 Hypothesis(通过 pip 可用)这样的流行 Python 库,可以轻松设置此功能。使用您选择的库,实现基于属性的测试,检查向量变换是否线性。具体来说,给定一个作为 Python 函数实现的向量变换 T,生成大量随机向量的成对,并断言所有这些向量在 T 下都保持其和。然后,对标量和向量的成对做同样的事情,并确保 T 保持标量倍数。你应该会发现像 rotate_x_by(pi/2) 这样的线性变换可以通过测试,但像坐标平方变换这样的非线性变换则不能通过。
练习 4.16: 一个 2D 向量变换是沿 x 轴的 反射。这种变换将一个向量转换成另一个向量,它是相对于 x 轴的镜像。它的 x 坐标应该保持不变,而它的 y 坐标应该改变其符号。表示这个变换为 S[x],下面是一个向量 v = (3, 2) 和变换后的向量 S[x](v) 的图像。向量 v = (3, 2) 和它在 x 轴上的反射 (3, −2)画两个向量及其和,以及这三个向量的反射,以证明这个变换保持向量加法。再画一个图,以类似的方式展示标量乘法也得到保持,从而证明线性性的两个标准。
解答: 下面是一个关于 x 轴反射保持向量和的例子!对于 z + v = w 如所示,x 轴反射保持和;也就是说,S[x](u) + S[x](v) = S[x](w)。下面是一个显示反射保持标量倍的例子:S[x](sv)位于预期 sS[x](v) 应该出现的位置。为了 证明 S[x] 是线性的,你需要展示你可以为每个向量之和和每个标量倍数绘制类似的图像。这些有无限多个,所以最好使用代数证明。(你能想出如何代数地展示这两个事实吗?)!x 轴反射保持这个标量倍数。
练习 4.17-迷你项目:假设ST都是线性变换。解释为什么ST的复合也是线性的。解答:如果对于任何向量之和u + v = w,我们有S(T(u)) + S(T(v)) = S(T(w)),并且对于任何标量乘积s v,我们有S(T(sv)) = s · S(T(v)),那么复合S(T(v))是线性的。这是一个必须满足的定义的陈述。现在让我们看看为什么它是正确的。首先假设对于任何给定的输入向量uvu + v = w。那么根据T的线性,我们也知道T(u) + T(v) = T(w)。因为这个和成立,S的线性告诉我们这个和在S下得到保留:S(T(u)) + S(T(v)) = S(T(w))。这意味着S(T(v))保留了向量之和。同样,对于任何标量乘积s vT的线性告诉我们s · T(v) = T(sv)。根据S的线性,s · S(T(v)) = S(T(sv))同样成立。这意味着S(T(v))保留了标量乘法,因此S(T(v))满足之前所述的线性定义的完整定义。我们可以得出结论,两个线性变换的复合是线性的。
练习 4.18:设T是由 Python 函数rotate_x_by(pi/2)执行的线性变换,T(e[1]),T(e[2])和T(e[3])是什么?解答:任何绕轴的旋转都不会影响轴上的点,因此因为T(e[1])位于 x 轴上,所以T(e[1]) = e[1] = (1, 0, 0)。在yz平面内逆时针旋转e[2] = (0, 1, 0)将这个向量从正y方向的一个单位点旋转到正z方向的一个单位点,所以T(e[2]) = e[3] = (0, 0, 1)。同样,e[3]从正z方向逆时针旋转到负y方向。T(e[3])在这个方向上的长度仍然为 1,所以它是-e[2]或(0, −1, 0)。在 y,z 平面内逆时针旋转 90 度将e[2]发送到e[3],将e[3]发送到- e[2]。

| 练习 4.19:编写一个linear_combination(scalars, *vectors)函数,它接受一个标量列表和相同数量的向量,并返回一个单个向量。例如,linear_combination([1,2,3], (1,0,0), (0,1,0), (0,0, 1))应该返回 1 · (1, 0, 0) + 2 · (0, 1, 0) + 3 · (0, 0, 1)或(1, 2, 3)。解答

from vectors import *
def linear_combination(scalars,*vectors):
    scaled = [scale(s,v) for s,v in zip(scalars,vectors)]
    return add(*scaled)

我们可以确认这给出了之前描述的预期结果:

>>> linear_combination([1,2,3], (1,0,0), (0,1,0), (0,0,1))
(1, 2, 3)

|

| 练习 4.20:编写一个函数transform_standard_basis(transform),它接受一个 3D 向量变换作为输入,并输出它对标准基的影响。它应该输出一个包含 3 个向量的元组,这些向量分别是transform作用于e[1],e[2]和e[3]的结果。解答:正如建议的那样,我们只需要将transform应用于每个标准基向量:

def transform_standard_basis(transform):
    return transform((1,0,0)), transform((0,1,0)), transform((0,0,1))

它在浮点误差范围内确认了我们之前练习中的解决方案,其中我们寻求rotate_x_by(pi/2)的输出:

>>> from math import *
>>> transform_standard_basis(rotate_x_by(pi/2))
((1, 0.0, 0.0), (0, 6.123233995736766e−17, 1.0), (0, −1.0,
    1.2246467991473532e−16))

这些向量大约是(1, 0, 0),(0, 0, 1),和(0, −1, 0)。|

练习 4.21:假设 B 是一个线性变换,其中 B(e[1]) = (0, 0, 1),B(e[2]) = (2, 1, 0),B(e[3]) = (−1, 0, −1),并且 v = (−1, 1, 2)。B(v)是什么?解答:因为 v = (−1, 1, 2) = -e[1] + e[2] + 2e[3],所以 B(v) = B(−e[1] + e[2] + 2e[3])。因为 B 是线性的,它保持这种线性组合:B(v) = − B(e[1]) + B(e[2]) + 2 · B(e[3])。现在我们有了所有需要的信息:B(v) = −(0, 0, 1) + (2, 1, 0) + 2 · (−1, 0, −1) = (0, 1, −3)。
练习 4.22:假设 aB 都是线性变换,其中 a(e[1]) = (1, 1, 1),a(e[2]) = (1, 0, −1),a(e[3]) = (0, 1, 1),和 B(e[1]) = (0, 0, 1),B(e[2]) = (2, 1, 0),B(e[3]) = (−1, 0, −1)。a(B(e[1])), a(B(e[2])), 和 a(B(e[3]))是什么?解答a(B(e[1]))是a应用于B(e[1]) = (0, 0, 1) = e[3]。我们已知a(e[3]) = (0, 1, 1),所以B(a(e[1])) = (0, 1, 1)。a(B(e[2]))是a应用于B(e[2]) = (2, 1, 0)。这是a(e[1]), a(e[2]), 和 a(e[3])的线性组合,标量是(2, 1, 0):2 · (1, 1, 1) + 1 · (1, 0, −1) + 0 · (0, 1, 1) = (3, 2, 1)。最后,a(B(e[3]))是a应用于B(e[3]) = (−1, 0, −1)。这是线性组合−1 · (1, 1, 1) + 0 · (1, 0, −1) + −1 · (0, 1, 1) = (−1, −2, −2)。注意,现在我们知道了aB对所有标准基向量的组合结果,因此我们可以计算a(B(v))对于任何向量v

线性变换因为所需数据很少,所以既表现良好又易于计算。我们将在下一章中更深入地探讨这一点,届时我们将使用矩阵符号来计算线性变换。

摘要

  • 向量变换是接受向量作为输入并返回向量作为输出的函数。向量变换可以作用于 2D 或 3D 向量。

  • 要对模型进行几何变换,将向量变换应用于 3D 模型中每个多边形的每个顶点。

  • 你可以通过函数的组合来组合现有的向量变换,以创建新的变换,这些变换相当于依次应用现有的向量变换。

  • 函数式编程是一种强调函数组合和操作的编程范式。

  • 柯里化(currying)的功能操作将接受多个参数的函数转换为一个接受一个参数并返回一个新函数的函数。柯里化允许你将现有的 Python 函数(如scaleadd)转换为向量变换。

  • 线性变换是向量变换,它们保持向量之和和标量乘积。特别是,在应用线性变换后,位于线段上的点仍然位于线段上。

  • 线性组合是标量乘法和向量加法最一般的组合。每一个三维向量都是三维标准基向量的线性组合,这些标准基向量表示为e[1] = (1, 0, 0),e[2] = (0, 1, 0),和e[3] = (0, 0, 1)。同样,每一个二维向量都是二维标准基向量的线性组合,这些标准基向量表示为e[1] = (1, 0)和e[2] = (0, 1)。

  • 一旦你知道一个给定的线性变换如何作用于标准基向量,你就可以通过将向量表示为标准基的线性组合,并利用线性组合的性质来决定它对任何向量如何作用。

    • 在三维空间中,三个向量或九个总数可以指定一个线性变换。

    • 在二维空间中,两个向量或四个总数执行相同的操作。

    最后这一点至关重要:线性变换因为只需要很少的数据就能被指定,所以它们既表现良好又易于计算。

5 使用矩阵计算变换

本章涵盖了

  • 将线性变换写成矩阵

  • 通过矩阵相乘来组合和应用线性变换

  • 使用线性变换对不同维度的向量进行操作

  • 使用矩阵在 2D 或 3D 中平移向量

在第四章的总结中,我提出了一个重要观点:任何三维线性变换都可以仅用三个向量或总共九个数字来指定。通过正确选择这九个数字,我们可以实现绕任意轴旋转任意角度,沿任意平面反射,在任意平面上投影,沿任意方向按任意因子缩放,或任何其他三维线性变换。

表达为“绕 z 轴逆时针旋转 90°的旋转”的变换可以等价地描述为它对标准基向量e[1] = (1, 0, 0),e[2] = (0, 1, 0),和e[3] = (0, 0, 1)的作用。具体来说,结果是(0, 1, 0),(−1, 0, 0),和(0, 0, 1)。无论我们是从几何角度还是通过这三个向量(或九个数字)来考虑这个变换,我们都是在思考同一个想象中的机器(图 5.1)对 3D 向量进行操作。实现可能不同,但机器仍然产生不可区分的结果。

图片

图 5.1:执行相同线性变换的两个机器。顶部的机器通过几何推理工作,而底部的机器通过九个数字工作。

当这些描述如何执行线性变换的数字适当地排列成网格时,这些数字被称为矩阵。本章的重点是使用这些数字网格作为计算工具,因此本章比前几章有更多的数字计算。不要被这吓倒!归根结底,我们还是在执行向量变换。

矩阵让我们能够通过该变换对标准基向量的作用来计算给定的线性变换。本章中所有的符号都是为了组织这个过程,我们在 4.2 节中已经讨论了这一点,而不是为了引入任何不熟悉的概念。我知道学习新的复杂符号可能会感到痛苦,但我保证,这会得到回报。我们最好能够将向量视为几何对象或数字元组。同样,我们将通过将线性变换视为数字矩阵来扩展我们的心理模型。

5.1 使用矩阵表示线性变换

让我们回到一个具体的例子,这个例子指定了 3D 线性变换的九个数字。假设a是一个线性变换,并且我们知道a(e[1]) = (1, 1, 1),a(e[2]) = (1, 0, −1),和a(e[3]) = (0, 1, 1)。这三个向量总共包含九个分量,包含了指定线性变换a所需的所有信息。

由于我们反复使用这个概念,它需要一个特殊的符号。我们将采用一种新的符号,称为 矩阵符号,来处理这些九个数字作为 a 的表示。

5.1.1 将向量和线性变换表示为矩阵

矩阵是数字的矩形网格,它们的形状告诉我们如何解释它们。例如,我们可以将只有一个数字列的矩阵解释为一个向量,其条目是坐标,从上到下排序。在这种形式中,向量被称为 列向量。例如,三维标准基可以写成如下三个列向量:

图片

对于我们的目的,这个符号意味着与 e[1] = (1, 0, 0), e[2] = (0, 1, 0), 和 e[3] = (0, 0, 1) 相同的意思。我们也可以用这个符号来表示 a 如何变换标准基向量:

图片

表示线性变换 a 的矩阵是一个由这些向量并排挤压而成的 3×3 网格:

图片

在二维中,一个列向量包含两个条目,因此两个变换后的向量总共包含 4 个条目。我们可以看看线性变换 D 如何通过 2 的倍数缩放输入向量。首先,我们写出它在基向量上的作用方式:

图片

然后通过将这些列并排放置,我们得到 D 的矩阵:

图片

矩阵可以有不同的形状和大小,但我们现在将专注于这两种形状:表示向量的单列矩阵和表示线性变换的方阵。

记住,这里没有新的概念,只是从第 4.2 节的核心思想的新写法:线性变换由其在标准基向量上的作用结果定义。从线性变换得到矩阵的方法是找到它从所有标准基向量产生的向量,并将结果并排组合。现在,我们将看看相反的问题:如何根据其矩阵评估一个线性变换。

5.1.2 矩阵与向量的乘法

如果一个线性变换 B 被表示为一个矩阵,并且一个向量 v 也被表示为一个矩阵(一个列向量),我们就有了评估 B(v) 所需的所有数字。例如,如果 Bv 由以下给出

图片

那么,向量 B(e[1]),B(e[2]),和 B(e[3]) 可以从 B 的矩阵中读出,作为其列。从那个点开始,我们使用之前相同的过程。因为 v = 3e[1] − 2e[2] + 5e[3],所以 B(v) = 3 B(e[1]) − 2 B(e[2]) + 5 B(e[3])。展开这个,我们得到

图片

结果是向量 (1, −2, −2)。将一个方阵视为作用于列向量的函数是矩阵乘法的一个特殊情况。同样,这会影响我们的符号和术语,但我们只是在做同样的事情:对一个向量应用线性变换。写成矩阵乘法的形式,它看起来像这样:

与乘以数字不同,当你用矩阵乘以向量时,顺序很重要。在这种情况下,B v 是一个有效的乘积,但 v B 不是。简而言之,我们很快就会看到如何乘以各种形状的矩阵,以及矩阵乘法的顺序的一般规则。现在,请相信我,认为这个乘法是有效的,因为它意味着对一个 3D 向量应用 3D 线性算子。

我们可以编写 Python 代码来将矩阵与向量相乘。假设我们像往常一样将矩阵 B 编码为元组元组,将向量 v 编码为元组:

 *B* = (
    (0,2,1),
    (0,1,0),
    (1,0,−1)
)

v = (3,−2,5)

这与我们对矩阵 B 的原始想法有点不同。我们最初是通过组合三列来创建它的,但在这里 B 是作为一个行序列创建的。将矩阵定义为 Python 中的行元组的好处是数字的排列顺序与我们写在纸上的顺序相同。然而,我们可以通过使用 Python 的 zip 函数(在附录 B 中介绍)在任何时候获取列:

>>> list(zip(*B))
[(0, 0, 1), (2, 1, 0), (1, 0, −1)]

这个列表的第一个元素是 (0, 0, 1),它是 B 的第一列,以此类推。我们想要的是这些向量的线性组合,其中标量是 v 的坐标。为了得到这个结果,我们可以使用第 4.2.5 节练习中的 linear_combination 函数。linear_combination 的第一个参数应该是 v,它作为标量的列表,后续参数应该是 B 的列。下面是这个完整的函数:

def multiply_matrix_vector(matrix, vector):
    return linear_combination(vector, *zip(*matrix))

它确认了我们用 Bv 手动做的计算:

>>> multiply_matrix_vector(B,v)
(1, −2, −2)

有两种其他记忆法来乘以矩阵和向量,它们都给出相同的结果。为了看到这些,让我们写一个典型的矩阵乘法:

这个计算的结果是矩阵列与坐标 xyz 作为标量的线性组合:

这是 3×3 矩阵与 3D 向量的乘积的显式公式。你可以为 2D 向量写一个类似的公式:

第一个记忆法是输出向量的每个坐标都是输入向量所有坐标的函数。例如,3D 输出向量的第一个坐标是函数 f(x, y, z) = ax + by + cz。此外,这是一个线性函数(在高中代数中你用这个词的意思);它是每个变量的数乘之和。我们最初引入“线性变换”这个术语是因为线性变换保持直线。使用这个术语的另一个原因是:线性变换是输入坐标上的线性函数的集合,这些函数给出了相应的输出坐标。

第二个记忆法以不同的方式呈现了相同的公式:输出向量的坐标是矩阵的行与目标向量的点积。例如,3x3 矩阵的第一行是(a, b, c),乘积向量是(x, y, z),所以输出向量的第一个坐标是(a, b, c) · (x, y, z) = ax + by + cz。我们可以结合我们的两种表示法,用公式表达这个事实:

图像

如果你因为看着数组中的这么多字母和数字而眼睛开始发花,不要担心。一开始,这种表示法可能会让人感到不知所措,并且需要一些时间才能将其与你的直觉联系起来。本章中有更多矩阵的例子,下一章提供了更多的复习和实践。

5.1.3 通过矩阵乘法组合线性变换

我们迄今为止看到的线性变换的一些例子包括旋转、反射、缩放以及其他几何变换。更重要的是,任何数量的线性变换串联在一起都会给我们一个新的线性变换。在数学术语中,任何数量的线性变换的组合也是一个线性变换。

因为任何线性变换都可以用一个矩阵来表示,所以任何两个组合的线性变换也可以。实际上,如果你想组合线性变换来构建新的变换,矩阵是完成这项工作的最佳工具。

注意:让我暂时摘下数学家的帽子,戴上程序员的帽子。假设你想计算对向量进行 1,000 次组合线性变换的结果。如果你在动画中通过在动画的每一帧中应用额外的、小的变换来动画化一个对象,这种情况可能会发生。在 Python 中,应用 1,000 个连续函数的计算成本很高,因为每个函数调用都有计算开销。然而,如果你找到一个表示 1,000 个线性变换组合的矩阵,你就可以将整个过程简化为少量数字和少量计算。

让我们看看两个线性变换的组合:a(B(v)),其中aB的矩阵表示是已知的:

图像

下面是逐步组合的过程。首先,将变换 B 应用到 v 上,得到一个新的向量 B(v),或者如果我们写作乘法,就是 B v。其次,这个向量成为变换 a 的输入,得到最终的 3D 向量作为结果:a(B v)。再次,我们将括号省略,并将 a(B v) 写作乘积 AB v。将这个乘积展开为 v = (x, y, z) 给出如下公式:

如果我们从右到左正确操作,我们知道如何评估这个。现在我要声称,我们也可以从左到右操作并得到相同的结果。具体来说,我们可以给乘积矩阵 AB 赋予意义;它将是一个新的矩阵(待发现),表示线性变换 aB 的组合:

现在,这个新矩阵的元素应该是什么?它的目的是表示变换 aB 的组合,这给我们一个新的线性变换,AB。正如我们所见,矩阵的列是将其变换应用于标准基向量得到的结果。矩阵 AB 的列是应用变换 ABe[1]、e[2] 和 e[3] 的结果。

因此,AB 的列是 AB(e[1])、AB(e[2]) 和 AB(e[3])。以第一列为例,它应该是 AB(e[1]) 或者 a 应用到向量 B(e[1])。换句话说,要得到 AB 的第一列,我们需要将一个矩阵乘以一个向量,这是我们已经练习过的操作:

同样,我们发现 AB(e[2]) = (3, 2, 1) 和 AB(e[3]) = (1, 0, 0),这是 AB 的第二列和第三列:

这就是我们进行矩阵乘法的方式。你可以看到,除了仔细组合线性算子之外,没有其他的事情要做。同样,你也可以使用助记符而不是每次都通过推理来完成这个过程。因为将一个 3x3 矩阵与一个列向量相乘相当于做三个点积,将两个 3x3 矩阵相乘相当于做九个点积——即第一矩阵的行与第二矩阵的列之间所有可能的点积,如图 5.2 所示。

图 5.2 乘积矩阵的每个元素是第一矩阵的行与第二矩阵的列之间的点积。

我们所说的关于 3x3 矩阵乘法的一切也适用于 2x2 矩阵。例如,要找到这些 2x2 矩阵的乘积

我们可以将第一个矩阵的行与第二个矩阵的列进行点积。第一个矩阵的第一行与第二个矩阵的第一列的点积是 (1, 2) · (0, 1) = 2。这告诉我们结果矩阵的第一行和第一列的元素是 2:

重复此过程,我们可以找到乘积矩阵的所有元素:

你可以做一些矩阵乘法的练习来熟悉它,但很快你就会更喜欢让计算机为你做这项工作。让我们在 Python 中实现矩阵乘法,以便使其成为可能。

5.1.4 实现矩阵乘法

我们可以以几种方式编写我们的矩阵乘法函数,但我更喜欢使用点积技巧。因为矩阵乘法的结果应该是一个元组的元组,我们可以将其写为一个嵌套的列表推导式。它也接受两个嵌套的元组,称为 ab,代表我们的输入矩阵 aB。输入矩阵 a 已经是第一个矩阵行的元组,我们可以用 zip(*b) 将它们配对,zip(*b) 是第二个矩阵列的元组。最后,对于每一对,我们应该计算点积并在内部推导式中产生它。以下是实现方式:

from vectors import *

def matrix_multiply(a,b):
    return tuple(
        tuple(dot(row,col) for col in zip(*b))
        for row in a
    )

外部推导式构建结果矩阵的行,内部推导式构建每一行的元素。因为输出行是由与 a 的行进行的各种点积形成的,所以外部推导式遍历 a

我们的 matrix_multiply 函数没有硬编码的维度。这意味着我们可以使用它来执行前面 2D 和 3D 示例中的矩阵乘法:

>>> xa = ((1,1,0),(1,0,1),(1,−1,1))
>>> b = ((0,2,1),(0,1,0),(1,0,−1))
>>> matrix_multiply(a,b)
((0, 3, 1), (1, 2, 0), (1, 1, 0))
>>> xc = ((1,2),(3,4))
>>> d = ((0,−1),(1,0))
>>> matrix_multiply(c,d)
((2, −1), (4, −3))

配备了矩阵乘法的计算工具,我们现在可以对我们的 3D 图形进行一些简单的操作。

5.1.5 使用矩阵变换进行 3D 动画

要动画化一个 3D 模型,我们需要在每一帧中重新绘制原始模型的变换版本。为了让模型看起来随时间移动或改变,我们需要使用不同的变换。如果这些变换是由矩阵指定的线性变换,那么我们需要为动画的每一帧提供一个新矩阵。

因为 PyGame 的内置时钟跟踪时间(以毫秒为单位),我们可以做的一件事是生成其元素依赖于时间的矩阵。换句话说,我们不是将矩阵的每个元素视为一个数字,而是将其视为一个函数,该函数接受当前时间 t 并返回一个数字(图 5.3)。

图 5.3 将矩阵元素视为时间的函数允许整体矩阵随时间变化。

例如,我们可以使用以下九个表达式:

正如我们在第二章中提到的,余弦和正弦都是接受一个数字并返回另一个数字作为结果的函数。其他五个条目恰好随时间不变,但如果你追求一致性,可以将这些视为常数函数(如在中心条目中的 f(t) = 1)。给定任何 t 的值,这个矩阵表示与 rotate_y_by(*t*) 相同的线性变换。时间向前推进,t 的值增加,因此如果我们将这个矩阵变换应用于每个帧,我们每次都会得到更大的旋转。

让我们给 draw_model 函数(在第 C 附录中介绍并在第四章中广泛使用)一个 get_matrix 关键字参数,其中传递给 get_matrix 的值是一个函数,该函数接受毫秒数作为时间并返回在该时间应应用的变换矩阵。在源代码文件 animate_teapot.py 中,我这样调用它来动画化第四章中的旋转茶壶:

from teapot import load_triangles
from draw_model import draw_model
from math import sin,cos

def get_rotation_matrix(t):                   ❶
    seconds = t/1000                          ❷
    return (
        (cos(seconds),0,−sin(seconds)),
        (0,1,0),
        (sin(seconds),0,cos(seconds))
    )
draw_model(load_triangles(), 
           get_matrix=get_rotation_matrix)    ❸

❶ 为任何表示时间的数值输入生成一个新的变换矩阵

❷ 将时间转换为秒,以便变换不会发生得太快

❸ 将函数作为关键字参数传递给 draw_model

现在,draw_model 被传递了随时间变换底层茶壶模型所需的数据,但我们需要在函数的主体中使用它。在遍历茶壶面之前,我们执行适当的矩阵变换:

def draw_model(faces, color_map=blues, light=(1,2,3),
               camera=Camera("default_camera",[]),
               glRotatefArgs=None,
               get_matrix=None):
        #...                                                ❶
        def do_matrix_transform(*v*):                         ❷
            if get_matrix:                                  ❸
               m = get_matrix(pygame.time.get_ticks())
               return multiply_matrix_vector(m, v)
            else:
               return *v*                                      ❹
        transformed_faces = polygon_map(do_matrix_transform, 
                                        faces)              ❺
        for face in transformed_faces:
        #...                                                ❻

❶ 函数主体的大部分保持不变,因此我们在这里不打印它。

❷ 在主 while 循环内部创建一个新的函数,用于应用此帧的矩阵

❸ 使用 pygame.time.get_ticks() 提供的经过毫秒数以及提供的 get_matrix 函数来计算此帧的矩阵

❹ 如果未指定 get_matrix,则不执行任何变换,并返回未改变的向量

❺ 使用 polygon_map 对每个多边形应用该函数

❻ draw_model 的其余部分与附录 C 中描述的相同

通过这些更改,你可以运行代码并看到茶壶旋转(图 5.4)。

图片

图 5.4 茶壶在每个帧中通过一个新的矩阵进行变换,这取决于绘制帧时的经过时间

希望通过前面的示例,我已经说服你矩阵完全可以与线性变换互换。我们已经成功地将茶壶进行了变换和动画化,仅使用九个数字来指定每个变换。你可以在下面的练习中进一步练习你的矩阵技能,然后我会向你展示从我们已实现的 matrix_multiply 函数中还有更多可以学习的内容。

5.1.6 练习

| 练习 5.1: 编写一个函数 infer_matrix(n, transformation),它接受一个维度(如 2 或 3)和一个向量变换函数,该函数假设为线性变换。它应该返回一个 n × n 的方阵(一个由 nn 元组组成的元组,这是表示线性变换的矩阵)。当然,只有当输入变换是线性的时,输出才有意义!否则,它代表一个完全不同的函数!解答

def infer_matrix(n, transformation):
    def standard_basis_vector(i):
        return tuple(1 if i==j else 0 for j in range(1,n+1))         ❶
    standard_basis = [standard_basis_vector(i) for i in range(1,n+1)]❷
    cols = [transformation(*v*) for *v*  in standard_basis]               ❸
    return tuple(zip(*cols))                                         ❹

❶ 创建第 i 个标准基向量,它是一个包含一个在 i 坐标上的 1 和其他所有坐标上的 0 的元组❷ 创建标准基,它是 n 个向量的列表❸ 定义矩阵的列是应用相应的线性变换到标准基向量得到的结果❹ 重新排列矩阵,使其成为行的元组而不是列的列表,遵循我们的约定我们可以在像 rotate_z_by(pi/2) 这样的线性变换上测试它:

>>> from transforms import rotate_z_by
>>> from math import pi
>>> infer_matrix(3,rotate_z_by(pi/2))
((6.123233995736766e−17, −1.0, 0.0), (1.0, 1.2246467991473532e−16, 0.0), (0, 0, 1))

|

练习 5.2: 以下 2 × 2 矩阵与 2D 向量相乘的结果是什么?!解答:向量与矩阵第一行的点积是 −2.5 · 1.3 + 0.3 · -0.7 = −3.46。向量与矩阵第二行的点积是 −2.5 · 6.5 + 0.3 · 3.2 = −15.29。这些是输出向量的坐标,所以结果是:!

| 练习 5.3-迷你项目:编写一个 random_matrix 函数,该函数生成具有指定大小和随机整数元素的矩阵。使用该函数生成五对 3 × 3 矩阵。手动(为了练习)将每对矩阵相乘,然后使用 matrix_multiply 函数检查你的工作。解答:首先,我们给 random_matrix 函数提供参数来指定行数、列数以及元素的最小值和最大值:

from random import randint
def random_matrix(rows,cols,min=−2,max=2):
    return tuple(
        tuple(
        randint(min,max) for j in range(0,cols))
        for i in range(0,rows)
    )

接下来,我们可以生成一个随机的 3 × 3 矩阵,其元素介于 0 和 10 之间,如下所示:

>>> random_matrix(3,3,0,10)
((3, 4, 9), (7, 10, 2), (0, 7, 4))

|

练习 5.4: 对于上一个练习中的每一对矩阵,以相反的顺序相乘。你得到相同的结果吗?解答:除非你非常幸运,否则你的结果都会不同。大多数矩阵对在以不同的顺序相乘时都会得到不同的结果。在数学术语中,我们说一个操作是 交换律,如果它无论输入顺序如何都会得到相同的结果。例如,乘法是一个交换律操作,因为对于任何选择的数字 xyxy = yx。然而,矩阵乘法 不是 交换律的,因为对于两个方阵 aBAB 不一定等于 BA
练习 5.5:在二维或三维中,有一个既无聊又重要的向量变换,称为单位变换,它接受一个向量并返回相同的向量作为输出。这种变换是线性的,因为它接受任何输入向量的和、标量乘积或线性组合,并返回相同的结果。二维和三维中代表单位变换的矩阵分别是什么?
解答:在二维或三维中,单位变换作用于标准基向量,并保持它们不变。因此,在任意维度中,这个变换的矩阵有标准基向量作为其列。在二维和三维中,这些单位矩阵分别表示为I[2]和I[3],它们的形状如下:

| 练习 5.6:将矩阵((2,1,1),(1,2,1),(1,1,2))应用于定义茶壶的所有向量。茶壶会发生什么变化?为什么?解答:以下函数包含在源文件 matrix_transform_teapot.py 中:

def transform(*v*):
    m = ((2,1,1),(1,2,1),(1,1,2))
    return multiply_matrix_vector(m,v)
draw_model(polygon_map(transform, load_triangles()))

运行代码,我们看到茶壶的前部被拉伸到xyz都为正的区域!将给定的矩阵应用于茶壶的所有顶点这是因为在所有标准基向量都被变换为具有正坐标的向量:分别是(2, 1, 1),(1, 2, 1),和(1, 1, 2)。由这个矩阵定义的线性变换如何影响标准基向量。这些新向量与正标量的线性组合在+x、+y和+z方向上比标准基向量的相同线性组合拉伸得更远。 |

| 练习 5.7:通过使用两个嵌套列表推导式以不同的方式实现multiply_matrix_vector,一个遍历矩阵的行,另一个遍历每行的条目。解答

def multiply_matrix_vector(matrix,vector):
    return tuple(
        sum(vector_entry * matrix_entry
            for vector_entry, matrix_entry in zip(row,vector))
        for row in matrix
    )

|

| 练习 5.8:利用输出坐标是输入矩阵行与输入向量点积的事实,以另一种方式实现multiply_matrix_vector解答:这是先前练习解答的简化版本:

def multiply_matrix_vector(matrix,vector):
    return tuple(
        dot(row,vector)
        for row in matrix
    )

|

练习 5.9-迷你项目:我首先向你介绍了线性变换的概念,然后展示了任何线性变换都可以用矩阵来表示。现在我们来证明相反的事实:所有矩阵都表示线性变换。从乘以 2x2 矩阵的 2D 向量或乘以 3x3 矩阵的 3D 向量的显式公式开始,从代数上证明。也就是说,要证明矩阵乘法保持和以及标量乘积。解答:我将展示 2D 的证明;3D 的证明结构相同,但需要更多的文字。假设我们有一个名为 a 的 2x2 矩阵,其元素为任意四个数字 abcd。让我们看看 a 如何作用于两个向量 uv你可以通过显式进行矩阵乘法来找到 a ua v然后我们可以计算 a u + a va(u + v),并看到结果是一致的:这告诉我们,通过乘以任何 2x2 矩阵定义的 2D 向量变换保持向量之和。同样,对于任何数字 s,我们有!图片所以 s · (a v) 和 a(s v) 给出相同的结果,我们看到乘以矩阵 a 保持了标量乘积。这两个事实意味着乘以任何 2x2 矩阵是 2D 向量的线性变换。

| 练习 5.10:再次使用 5.1.3 节中的两个矩阵:编写一个名为 compose_a_b 的函数,该函数执行 aB 的线性变换的组合。然后使用本节之前练习中的 infer_matrix 函数来证明 infer_matrix(3, compose_a_b) 与矩阵乘积 AB 相同。解答:首先,我们实现两个函数 transform_atransform_b,它们执行由矩阵 aB 定义的线性变换。然后,我们使用我们的 compose 函数将它们组合起来:

from transforms import compose

a = ((1,1,0),(1,0,1),(1,−1,1))
b = ((0,2,1),(0,1,0),(1,0,−1))

def transform_a(*v*):
    return multiply_matrix_vector(a,v)

def transform_b(*v*):
    return multiply_matrix_vector(b,v)

compose_a_b = compose(transform_a, transform_b)

现在我们可以使用我们的 infer_matrix 函数来找到这个线性变换组合对应的矩阵,并将其与矩阵乘积 AB 进行比较:

>>> infer_matrix(3, compose_a_b)
((0, 3, 1), (1, 2, 0), (1, 1, 0))
>>> matrix_multiply(a,b)
((0, 3, 1), (1, 2, 0), (1, 1, 0))

|

练习 5.11-迷你项目:找到两个 2×2 矩阵,它们都不是单位矩阵 I[2],但它们的乘积 单位矩阵。解答:一种方法是编写两个矩阵,并调整它们的元素,直到得到作为乘积的单位矩阵。另一种方法是考虑这个问题在线性变换的术语中。如果两个矩阵相乘得到单位矩阵,那么它们对应线性变换的复合应该产生单位变换。考虑到这一点,有哪些二维线性变换的复合是单位变换?当依次应用于给定的二维向量时,这些线性变换应该返回原始向量作为输出。这样一对变换是顺时针旋转 90°,然后旋转 270°。应用这两个变换执行 360° 的旋转,将任何向量返回到其原始位置。270° 旋转和 90° 旋转的矩阵如下,它们的乘积是单位矩阵:

| 练习 5.12: 我们可以对一个方阵进行任意次数的自乘。然后我们可以将连续的矩阵乘法视为“将矩阵提升到幂”。对于一个方阵 a,我们可以将 AA 写作 a²;我们可以将 AAA 写作 a³;以此类推。编写一个 matrix_power(power,matrix) 函数,该函数将矩阵提升到指定的(整数)幂。解答:以下是一个实现,它适用于大于或等于 1 的整数幂:

def matrix_power(power,matrix):
    result = matrix
    for _ in range(1,power):
        result = matrix_multiply(result,matrix)
    return result

|

5.2 不同形状矩阵的解释

matrix_multiply 函数没有硬编码输入矩阵的大小,因此我们可以用它来相乘 2×2 或 3×3 矩阵。实际上,它还可以处理其他大小的矩阵。例如,它可以处理这两个 5×5 矩阵:

>>> xa = ((−1, 0, −1, −2, −2), (0, 0, 2, −2, 1), (−2, −1, −2, 0, 1), (0, 2, −2,
−1, 0), (1, 1, −1, −1, 0))
>>> b = ((−1, 0, −1, −2, −2), (0, 0, 2, −2, 1), (−2, −1, −2, 0, 1), (0, 2, −2,
−1, 0), (1, 1, −1, −1, 0))
>>> matrix_multiply(a,b)
((−10, −1, 2, −7, 4), (−2, 5, 5, 4, −6), (−1, 1, −4, 2, −2), (−4, −5, −5, -9,
4), (−1, −2, −2, −6, 4))

没有理由我们不认真对待这个结果——我们的向量加法、标量乘法、点积以及因此矩阵乘法的函数都不依赖于我们使用的向量的维度。尽管我们无法想象五维向量,但我们可以在五个数字的元组上进行所有相同的代数运算,就像我们在二维和三维中分别对数字对和三元组进行运算一样。在这个五维乘积中,结果矩阵的元素仍然是第一个矩阵的行与第二个矩阵的列的点积(如图 5.5):

图 5.5 第一个矩阵的行与第二个矩阵的列的点积产生矩阵乘积的一个元素。

你不能以同样的方式可视化它,但你可以通过代数证明一个 5×5 矩阵指定了五维向量的线性变换。我们将在下一章讨论在四维、五维或更多维度中存在的对象类型。

5.2.1 列向量作为矩阵

让我们回到矩阵乘以列向量的例子。我已经向你展示了如何进行这种乘法,但我们将其作为一个单独的案例,使用multiply_matrix_vector函数来处理。结果发现matrix_multiply也能够进行这些乘法,但我们必须将列向量写成矩阵的形式。作为一个例子,让我们将以下方阵和单列矩阵传递给我们的matrix_multiply函数:

我之前说过,你可以将向量和单列矩阵互换地考虑,所以我们可以将d编码为向量(1,1,1)。但这次,让我们强迫自己将其视为一个矩阵,有三个行,每个行只有一个元素。请注意,我们必须写成(1,)而不是(1),这样 Python 才会将其视为一个 1 元组而不是一个数字。

>>> xc = ((−1, −1, 0), (−2, 1, 2), (1, 0, −1))
>>> d = ((1,),(1,),(1,))
>>> matrix_multiply(c,d)
((−2,), (1,), (0,))

结果有三个行,每个行只有一个元素,因此它也是一个单列矩阵。以下是这个乘积在矩阵表示法中的样子:

我们的multiply_matrix_vector函数可以评估相同的乘积,但格式不同:

>>> multiply_matrix_vector(c,(1,1,1))
(−2, 1, 0)

这表明矩阵和列向量的乘法是矩阵乘法的一个特例。最终我们并不需要一个单独的multiply_matrix_vector函数。我们还可以进一步看到输出中的项是第一个矩阵的行与第二个矩阵的单列的点积(如图 5.6 所示)。

图 5.6 结果向量的一个元素,作为点积计算得出

在纸上,你会看到向量可以互换地表示为元组(带有逗号)或列向量。但对我们编写的 Python 函数来说,这种区别是关键的。元组(−2, 1, 0)不能与元组-of-元组((−2,),(1,),(0,))互换使用。同样,另一种表示相同向量的方式是作为行向量,或者是一个只有一行的矩阵。以下是三种表示方法的比较:

表 5.1 向量数学符号与相应的 Python 表示的比较

表示法 数学符号 Python 表示
有序三元组(有序元组) v = (−2,1,0)
列向量 v = ((−2,),(1,),(0,))
行向量 v = ((−2,1,0),)

如果你曾在数学课上看到过这种比较,你可能认为这只是繁琐的符号区别。然而,一旦我们在 Python 中代表这些,我们会发现它们实际上是三个需要不同对待的不同对象。虽然它们都代表相同的空间几何数据,即一个 3D 箭头或空间中的点,但只有其中之一,即列向量,可以与 3×3 矩阵相乘。行向量不行,因为,如图 5.7 所示,我们无法将第一个矩阵的行与第二个矩阵的列进行点积。

图 5.7 不能相乘的两个矩阵

为了使我们的矩阵乘法定义保持一致,我们只能将矩阵与一个列向量相乘。这引发了下一节提出的一般问题。

5.2.2 哪些矩阵对可以相乘?

我们可以制作任何维度的数字网格。我们的矩阵乘法公式何时可以工作,它工作时的意义是什么?

答案是,第一矩阵的列数必须与第二矩阵的行数相匹配。当我们用点积来做矩阵乘法时,这一点是明显的。例如,我们可以将任何有三个列的矩阵与一个有三个行的第二个矩阵相乘。这意味着第一矩阵的行和第二矩阵的列各有三个元素,因此我们可以计算它们的点积。图 5.8 显示了第一矩阵的第一行与第二矩阵的第一列的点积,这给出了乘积矩阵的一个元素。

图 5.8 求乘积矩阵的第一个元素

我们可以通过计算剩余的七个点积来完成这个矩阵乘法。图 5.9 显示了另一个元素,它是通过点积计算得出的。

图 5.9 求乘积矩阵的另一个元素

这个约束也符合我们原始的矩阵乘法定义:输出矩阵的每一列都是第一矩阵的列与第二矩阵的行给出的标量线性组合(图 5.10)。

图 5.10 结果的每一列都是第一矩阵的列的线性组合。

我之前称那些平方矩阵为 2-by-2 和 3-by-3 矩阵。最后一个例子(图 5.10)是 2-by-3 和 3-by-4 矩阵的乘积。当我们这样描述矩阵的维度时,我们首先说行数,然后说列数。例如,一个 3D 列向量将是一个 3-by-1 矩阵。

注意:有时你会看到矩阵维度用乘号写成 3×3 矩阵或 3×1 矩阵。

在这种语言中,我们可以对可以相乘的矩阵形状做出一般性陈述:只有当m = p时,你才能将一个n -by- m矩阵与一个p -by- q矩阵相乘。当这一点成立时,结果矩阵将是一个n -by- q矩阵。例如,一个 17×9 矩阵不能与一个 6×11 矩阵相乘。然而,一个 5×8 矩阵可以与一个 8×10 矩阵相乘。图 5.11 显示了后者的结果,它是一个 5×10 矩阵。

图 5.11 第一矩阵的每一行都可以与第二矩阵的十列之一配对,以产生乘积矩阵的 5×10=50 个元素之一。我使用星号而不是数字来表明任何这些尺寸的矩阵都是兼容的。

与之相反,你不能以相反的顺序乘这些矩阵:一个 10×8 的矩阵不能被一个 5×8 的矩阵相乘。现在我们已经清楚如何乘更大的矩阵,但结果意味着什么呢?结果表明我们可以从结果中学到一些东西:所有矩阵都代表向量函数,所有有效的矩阵乘积都可以解释为这些函数的组合。让我们看看这是如何工作的。

5.2.3 将方阵和非方阵视为向量函数

我们可以将 2×2 矩阵视为执行二维向量给定线性变换所需的数据。如图 5.12 所示的机器,这种变换将二维向量输入其输入插槽,并从其输出插槽产生二维向量作为结果。

图片

图 5.12 将矩阵可视化为一个输入向量并输出向量的机器

在底层,我们的机器执行以下矩阵乘法:

图片

将矩阵视为输入向量并输出向量的机器是合理的。然而,图 5.13 显示,矩阵不能接受任何向量作为输入;它是一个 2×2 矩阵,因此它对二维向量进行线性变换。相应地,这个矩阵只能与一个有两个条目的列向量相乘。让我们将机器的输入和输出插槽分开,以表明它们接受和产生二维向量或数字对。

图片

图 5.13 通过重新绘制机器的输入和输出插槽来细化我们的心理模型,以表明其输入和输出是数字对

同样,由 3×3 矩阵供电的线性变换机器(图 5.14)只能接受 3D 向量并产生 3D 向量作为结果。

图片

图 5.14 由 3×3 矩阵供电的线性变换机器接受 3D 向量并输出 3D 向量。

现在,我们可以问自己,如果机器由一个非方阵供电,它看起来会是什么样子?也许矩阵看起来会像这样:

图片

作为具体例子,这个 2×3 矩阵可以作用于哪些类型的向量?如果我们打算用这个矩阵乘以一个列向量,这个列向量必须有三个条目以匹配这个矩阵的行的大小。将我们的 2×3 矩阵乘以一个 3×1 的列向量,我们得到一个 2×1 的矩阵作为结果,或者一个二维列向量。例如,

图片

这告诉我们这个 2×3 矩阵代表一个将 3D 向量映射到 2D 向量的函数。如果我们将其绘制成机器,如图 5.15 所示,它将接受 3D 向量作为其输入插槽,并从其输出插槽产生 2D 向量。

图片

图 5.15 由 2×3 矩阵供电的机器,接受 3D 向量并输出 2D 向量

通常,一个m×n矩阵定义了一个函数,它接受n维向量作为输入,并返回m维向量作为输出。任何这样的函数都是线性的,因为它保持向量之和和数乘。它不是一个变换,因为它不仅仅修改输入,它返回的是完全不同类型的输出:一个存在于不同维数的向量。因此,我们将使用一个更一般的术语;我们将称之为线性函数线性映射。让我们考虑一个从 3D 到 2D 的熟悉线性映射的深入例子。

5.2.4 从 3D 到 2D 的投影作为线性映射

我们已经看到了一个接受 3D 向量并产生 2D 向量的向量函数:将 3D 向量投影到x,y平面(第 3.5.2 节)。这种变换(我们可以称之为P)接受形式为(x, y, z)的向量,并返回删除其z分量的这些向量:(x, y)。我将花一些时间仔细说明为什么这是一个线性映射以及它是如何保持向量加法和数乘的。

首先,让我们将P写成矩阵的形式。为了接受 3D 向量并返回 2D 向量,它应该是一个 2×3 矩阵。让我们遵循我们可靠的矩阵寻找公式,通过测试P对标准基向量的作用。记住,在 3D 中,标准基向量定义为e[1] = (1, 0, 0),e[2] = (0, 1, 0),和e[3] = (0, 0, 1),当我们对这三个向量应用投影时,我们分别得到(1, 0),(0, 1),和(0, 0)。我们可以将这些写成列向量

图片

然后将它们并排放在一起以形成一个矩阵:

图片

为了检查这一点,让我们用一个测试向量(a, b, c)乘以它。(a, b, c)与(1, 0, 0)的点积是a,所以这是结果的第一项。第二项是(a, b, c)与(0, 1, 0)的点积,即b。你可以想象这个矩阵就像从(a, b, c)中抓取ab,而忽略c(如图 5.16)。

图片

图 5.16 只有 1 · a 对乘积的第一项有贡献,只有 1 · b对第二项有贡献。其他项被置零(在图中以灰色表示)。

这个矩阵做了我们想要的事情;它删除了 3D 向量的第三个坐标,只留下前两个坐标。我们可以把这个投影写成矩阵是个好消息,但让我们也给出一个代数证明,说明这是一个线性映射。为此,我们必须证明线性性的两个关键条件得到满足。

证明投影保持向量之和

如果P是线性的,那么任何向量之和u + v = w都应该被P所尊重。也就是说,P(u) + P(v)应该等于P(w)。让我们使用这些方程来确认这一点:u = (u[1], u[2], u[3])和v = (v[1], v[2], v[3])。那么w = u + v,所以

w = (u[1] + v[1], u[2] + v[2], u[3] + v[3])

对所有这些向量执行 P 是简单的,因为我们只需要移除第三个坐标:

P(u) = (u[1], u[2])

P(v) = (v[1], v[2])

所以

P(w) = (u[1] + v[1], u[2] + v[2])

P(u) 和 P(v)相加,我们得到 (u[1] + v[1], u[2] + v[2]),这与 P(w) 相同。对于任何三个三维向量 u + v = w,因此我们也有 P(u) + P(v) = P(w)。这验证了我们的第一个条件。

证明投影保持标量乘积

我们需要证明的第二件事是 P 保持了标量乘积。让 s 代表 任何 实数,并让 u = (u[1], u[2], u[3]),我们想要证明 P(s u) 与 sP(u) 相同。

删除第三个坐标并进行标量乘法,无论这些操作以何种顺序执行,都会得到相同的结果。s z 的结果是 (su[1], su[2], su[3]),所以 P(s u) = (su[1], su[2])。P(u) 的结果是 (u[1], u[2]),所以 sP(u) = (su[1], su[2])。这验证了第二个条件,并确认 P 满足线性的定义。

这类证明通常比理解起来更容易,所以我给你们提供了一个练习题。在练习题中,你可以检查一下,一个从二维到三维的函数,由一个给定的矩阵指定,是否可以使用相同的方法来证明它是线性的。

比代数证明更有说明性的是例子。当我们把一个三维向量的和投影到二维时,它看起来会是什么样子?我们可以分三步来看。首先,我们可以像图 5.17 所示的那样,在三维空间中画出两个向量 uv 的和。

图 5.17 两个任意向量 zv 在三维空间中的向量和

然后,我们可以从每个向量画一条线到 xy 平面,以显示这些向量在投影后的位置(图 5.18)。

图 5.18 展示了 uvz + v 在投影到 x,y 平面后的位置

最后,我们可以观察这些新向量,并看到它们 仍然 构成了一个向量和(图 5.19)。

图 5.19 投影后的向量形成一个和:P(v) + P(v) = P(u + v)。

换句话说,如果三个向量 uvw 构成了一个向量和 u + v = w,那么它们在 xy 平面上的“影子”也形成一个向量和。现在你已经对从三维到二维的线性变换及其表示的矩阵有了些了解,让我们回到对线性映射的一般讨论。

5.2.5 线性映射的合成

矩阵的美丽之处在于它们存储了评估给定向量上的线性函数所需的所有数据。更重要的是,矩阵的维度告诉我们底层函数的输入向量和输出向量的维度。我们在图 5.20 中通过绘制不同维度的矩阵机器来直观地捕捉这一点,这些机器的输入和输出槽具有不同的形状。以下是四个我们看到的例子,用字母标记以便我们可以回过来引用。

图片

图 5.20 以机器形式表示的四个线性函数,具有输入和输出槽。槽的形状告诉我们它接受或产生向量的维度。

这样画出来,很容易找出哪些线性函数机器的配对可以焊接在一起来构建一个新的。例如,M 的输出槽与 P 的输入槽形状相同,因此我们可以为 3D 向量 v 构造组合 P(M(v))。M 的输出是一个 3D 向量,可以直接传递到 P 的输入槽中(图 5.21)。

图片

图 5.21 P 和 M 的组合。一个向量被传递到 M 的输入槽中,M(v) 的输出无形中穿过管道进入 P,P(M(v)) 的输出从另一端出现。

相比之下,图 5.22 显示我们无法组合 NM,因为 N 没有足够的输出槽来填充 M 的每个输入。

图片

图 5.22 N 和 M 的组合是不可能的,因为 N 的输出是 2D 向量,而 M 的输入是 3D 向量。

我现在通过谈论槽位来使这个想法可视化,但隐藏在下面的推理与决定两个矩阵是否可以相乘的推理是相同的。第一个矩阵的列数必须与第二个矩阵的行数相匹配。当维度以这种方式匹配时,槽位也匹配,我们可以组合线性函数并乘以它们的矩阵。

PM 视为矩阵,PM 的组合写作 PM 作为矩阵乘积。(记住,如果 PM 对向量 v 作用为 PM v,则先应用 M,然后是 P。)当 v = (1, 1, 1) 时,乘积 PM v 是两个矩阵和一个列向量的乘积,如果我们评估 PM(图 5.23),它可以简化为一个矩阵乘以一个列向量。

图片

图 5.23 应用 M 和 P 等同于应用组合 PM。我们通过矩阵乘法将组合合并为一个单一的矩阵。

作为程序员,你习惯于从函数消耗和产生数据类型的角度来考虑函数。到目前为止,我在本章中已经给了你很多符号和术语来消化,但只要你掌握了这个核心概念,你最终会掌握它的。

我强烈建议你完成以下练习,以确保你理解矩阵的语言。在本章的其余部分和下一章中,不会有太多新的重大概念,只有对我们之前所学的应用。这些应用将使你在矩阵和向量计算方面有更多的实践机会。

5.2.6 练习

| 练习 5.13: 这个矩阵的维度是什么?!

  • 5×3

  • 3×5

解答:这是一个 3×5 矩阵,因为它有三行五列。|

练习 5.14: 将一个二维列向量视为矩阵时,它的维度是什么?二维行向量呢?三维列向量?三维行向量?解答:一个二维列向量有两行一列,因此它是一个 2×1 矩阵。一个二维行向量有一行两列,因此它是一个 1×2 矩阵。同样,三维列向量和行向量作为矩阵的维度分别是 3×1 和 1×3。

| 练习 5.15-迷你项目:我们的大多数向量和矩阵操作都使用了 Python 的 zip 函数。当给定不同大小的输入列表时,这个函数会截断较长的列表而不是失败。这意味着当我们传递无效输入时,我们会得到无意义的结果。例如,二维向量和三维向量之间没有点积,但我们的 dot 函数仍然返回某些内容:

>>> from vectors import dot
>>> dot((1,1),(1,1,1))
2

为所有向量算术函数添加安全措施,以便它们对无效大小的向量抛出异常,而不是返回值。一旦你完成了这个,就证明 matrix_multiply 不再接受 3×2 和 4×5 矩阵的乘积。|

练习 5.16: 以下哪些是有效的矩阵乘积?对于有效的乘积,乘积矩阵的维度是多少?A.B.C.D.解答:A. 这个 2×2 矩阵和 4×4 矩阵的乘积是不有效的;第一个矩阵有两列,但第二个矩阵有四行。B. 这个 2×4 矩阵和 4×2 矩阵的乘积是有效的;第一个矩阵的四个列与第二个矩阵的四个行相匹配。结果是 2×2 矩阵。C. 这个 3×1 矩阵和 1×8 矩阵的乘积是有效的;第一个矩阵的单列与第二个矩阵的单行相匹配。结果是 3×8 矩阵。D. 这个 3×3 矩阵和 2×3 矩阵的乘积是不有效的;第一个矩阵的三列不匹配第二个矩阵的两行。

| 练习 5.17:一个有 15 个总条目的矩阵乘以一个有 6 个总条目的矩阵。这两个矩阵的维度是什么,乘积矩阵的维度是什么?解答:让我们称矩阵的维度为 m -by- nn -by- k,因为第一个矩阵的列数必须与第二个矩阵的行数相匹配。那么 mn = 15 和 nk = 6。实际上有两种可能性:

  • 第一种可能性是 m = 5, n = 3, 和 k = 2。那么这将是一个 5×3 矩阵乘以一个 3×2 矩阵,结果是一个 5×2 矩阵。

  • 第二种可能性是 m = 15, n = 1, 和 k = 6。那么这将是一个 15×1 矩阵乘以一个 1×6 矩阵,结果是一个 15×6 矩阵。

|

| 练习 5.18:编写一个函数,将列向量转换为行向量,或反之亦然。像这样翻转矩阵称为转置,得到的矩阵称为原始矩阵的转置解答

def transpose(matrix):
    return tuple(zip(*matrix))

调用 zip(*matrix) 返回矩阵的列的列表,然后我们将它们元组化。这会交换任何输入矩阵的行和列,具体来说,将列向量转换为行向量,反之亦然:

>>> transpose(((1,),(2,),(3,)))
((1, 2, 3),)
>>> transpose(((1, 2, 3),))
((1,), (2,), (3,))

|

练习 5.19:画一个图来展示一个 10×8 和一个 5×8 矩阵不能按那种顺序相乘。解答动画第一个矩阵的行有十个条目,但第二个矩阵的列有五个,这意味着我们无法评估这个矩阵乘积。
练习 5.20:我们想将三个矩阵相乘:a 是 5×7,B 是 2×3,C 是 3×5。它们可以按什么顺序相乘,结果的大小是多少?解答:一个有效乘积是 BC,一个 2x3 乘以一个 3×5 矩阵得到一个 2×5 矩阵。另一个是 CA,一个 3×5 矩阵乘以一个 5×7 矩阵得到一个 3×7 矩阵。三个矩阵的乘积 BCA,无论你使用什么顺序都是有效的。(BC) a 是一个 2×5 矩阵乘以一个 5×7 矩阵,而 B(CA) 是一个 2×3 矩阵乘以一个 3×7 矩阵。每个都得到相同的 2×7 矩阵作为结果。图片以不同的顺序乘以三个矩阵
练习 5.21:将投影到 yz 平面和 xz 平面也是从 3D 到 2D 的线性映射。它们的矩阵是什么?解答:投影到 yz 平面删除了 x 坐标。这个操作的矩阵是图片。同样,投影到 xz 平面删除了 y 坐标:图片。例如,图片

| 练习 5.22:通过示例说明之前练习中的infer_matrix函数可以创建具有不同维度输入和输出的线性函数的矩阵。解答:我们可以测试的一个函数是将投影到xy平面的函数,它接受 3D 向量并返回 2D 向量。我们可以将这个线性变换实现为一个 Python 函数,然后推断其 2×3 矩阵:

>>> def project_xy(*v*):
...     x,y,z = v
...     return (x,y)
...
>>> infer_matrix(3,project_xy)
((1, 0, 0), (0, 1, 0))

注意,我们必须将输入向量的维度作为参数提供,这样我们才能构建正确的标准基向量以在project_xy的作用下进行测试。一旦project_xy传递了 3D 标准基向量,它就会自动输出 2D 向量以提供矩阵的列。|

练习 5.23:编写一个 4×5 矩阵,它通过删除其五个条目中的第三个来作用于一个 5D 向量,从而产生一个 4D 向量。例如,将其与列向量形式(1, 2, 3, 4, 5)相乘应该返回(1, 2, 4, 5)。解答:这个矩阵是你可以看到输入向量的第一个、第二个、第四个和第五个坐标构成了输出向量的四个坐标!矩阵中的 1 表示输入向量的坐标最终出现在输出向量中的位置。
练习 5.24-迷你项目:考虑六个变量(lemons)的向量。找到作用于该向量并产生向量(solemn)的线性变换的矩阵。提示:输出向量的第三个坐标等于输入向量的第一个坐标,因此变换必须将标准基向量(1, 0, 0, 0, 0, 0)映射到(0, 0, 1, 0, 0, 0)。解答这个矩阵以指定方式重新排列了 6D 向量的条目。
练习 5.25:从 5.2.5 节中的矩阵 MNPQ 中可以制作哪些有效的乘积?在考虑矩阵乘积时,包括矩阵与自身的乘积。对于有效的乘积,矩阵乘积的维度是什么?解答M 是 3×3,N 是 2×2,而 PQ 都是 2×3。M 与自身的乘积 MM = M² 是有效的,是一个 3×3 矩阵,同样 NN = N² 也是一个 2×2 矩阵。除此之外,PMQMNPNQ 都是 3×2 矩阵。

5.3 使用矩阵转换向量

矩阵的一个优点是,在任意数量的维度中计算看起来都是一样的。我们不需要担心在 2D 或 3D 中想象向量的配置;我们只需将它们插入到矩阵乘法的公式中,或者将它们用作 Python matrix_multiply的输入。当我们想要在超过三个维度中进行计算时,这特别有用。

人类的大脑并没有被设计成想象四维或五维的向量,更不用说 100 维了,但我们已经看到我们可以在高维向量上进行计算。在本节中,我们将介绍一个需要在高维中进行计算的计算:使用矩阵进行向量的平移。

5.3.1 使平面平移线性化

在上一章中,我们展示了平移不是线性变换。当我们将平面上的每个点移动一个给定的向量时,原点会移动,向量之和不会保持不变。如果它不是一个线性变换,我们怎么能希望用矩阵执行二维变换呢?

小技巧在于我们可以将我们的二维点想象成生活在三维空间中。让我们回到第二章中的恐龙。这只恐龙由 21 个点组成,我们可以按顺序连接这些点来创建图形的轮廓:

from vector_drawing import *

dino_vectors = [(6,4), (3,1), (1,2), (−1,5), (−2,5), (−3,4), (−4,4),
    (−5,3), (−5,2), (−2,2), (−5,1), (−4,0), (−2,1), (−1,0), (0,−3),
    (−1,−4), (1,−4), (2,−3), (1,−2), (3,−1), (5,1)
]

draw(
    Points(*dino_vectors),
    Polygon(*dino_vectors)
)

结果就是熟悉的二维恐龙(图 5.24)。

图 5.24 第二章中熟悉的二维恐龙

如果我们想将恐龙向右平移 3 个单位,向上平移 1 个单位,我们只需将向量(3, 1)加到恐龙的每个顶点上即可。但这不是一个线性映射,因此我们无法生成一个 2×2 的矩阵来完成这种平移。如果我们将恐龙想象成三维空间而不是二维平面的居民,那么我们发现可以将平移表示为一个矩阵。

请稍等,让我向你展示这个技巧;我很快就会解释它是如何工作的。让我们给恐龙的每个点赋予 z 坐标为 1。然后我们可以通过连接每个点来在三维空间中绘制它,并看到结果的多边形位于z = 1 的平面上(图 5.25)。我创建了一个名为polygon_segments_3d的辅助函数,用于获取恐龙多边形在三维空间中的段。

from draw3d import *
def polygon_segments_3d(points,color='blue'):
    count = len(points)
    return [Segment3D(points[i], points[(i+1) % count],color=color) for i in range(0,count)]

dino_3d = [(x,y,1) for x,y in dino_vectors]

draw3d(
    Points3D(*dino_3d, color='blue'),
    *polygon_segments_3d(dino_3d)
)

图 5.25 给恐龙的每个顶点赋予 z 坐标为 1 的相同恐龙

图 5.26 显示了一个“扭曲”三维空间的矩阵,使得原点保持不变,但z = 1 的平面按所需平移。现在就相信我吧!我已经突出显示了与平移相关的数字,你应该注意这些。

图 5.26 一个神奇的矩阵,将平面z = 1 在x方向上移动+3,在y方向上移动+1

我们可以将这个矩阵应用到恐龙的每个顶点上,然后 voila!恐龙在其平面内平移了(3, 1)(图 5.27)。

图 5.27 将矩阵应用到每个点,使恐龙保持在同一平面内,但在平面内平移了(3, 1)。

这里是代码:

magic_matrix = (
    (1,0,3),
    (0,1,1),
    (0,0,1))

translated = [multiply_matrix_vector(magic_matrix, v) for *v*  in dino_vectors_3d]

为了清晰起见,我们随后可以再次删除 z 坐标,并显示与原始恐龙在同一平面上的平移后的恐龙(图 5.28)。

图 5.28 将平移后的恐龙放回二维空间

你可以复制代码并检查坐标,以确认恐龙在最终图片中确实平移了 (3, 1)。现在让我向你展示这个技巧是如何工作的。

5.3.2 为 2D 平移找到一个 3D 矩阵

我们“神奇”矩阵的列,就像任何矩阵的列一样,告诉我们标准基向量在变换后最终的位置。将这个矩阵称为 T,向量 e[1]、e[2] 和 e[3] 将被变换为向量 T e[1] = (1, 0, 0),T e[2] = (0, 1, 0),和 T e[3] = (3, 1, 1)。这意味着 e[1] 和 e[2] 没有受到影响,而 e[3] 只改变了它的 xy 分量(图 5.29)。

图 5.29 这个矩阵不会移动 e1 或 e2,但它确实会移动 e3。

3D 中的任何一点,因此,我们恐龙上的任何一点都是作为 e[1]、e[2] 和 e[3] 的线性组合构建的。例如,恐龙尾巴的尖端位于 (6, 4, 1),即 6e[1] + 4e[2] + e[3]。因为 T 不会移动 e[1] 或 e[2],只有对 e[3] 的影响会移动点,T(e[3]) = e[3] + (3, 1, 0),所以点在 x 方向上平移了 +3,在 y 方向上平移了 +1。你也可以从代数上看到这一点。任何向量 (x, y, 1) 都可以通过这个矩阵平移 (3, 1, 0):

如果你想通过某个向量 (a, b) 平移一组 2D 向量,一般步骤如下:

  1. 将 2D 向量移动到 3D 空间中的平面,其中 z = 1,每个向量都有一个 z 坐标为 1。

  2. 将向量乘以矩阵,并插入你给定的 ab 选择:

  3. 删除所有向量的 z 坐标,这样你最终会得到 2D 向量。

现在我们可以用矩阵进行平移,我们可以创造性地将它们与其他线性变换结合。

5.3.3 将平移与其他线性变换结合

在之前的矩阵中,前两列正好是 e[1] 和 e[2],这意味着只有 e[3] 的变化会移动图形。我们不希望 T(e[1]) 或 T(e[2]) 有任何 z 分量,因为这会将图形移出平面 z = 1。但我们可以修改或交换其他分量(图 5.30)。

图 5.30 让我们看看当我们移动 T(e1) 和 T(e2) 到 x,y 平面时会发生什么。

结果表明,你可以通过在第三列指定的平移之外进行相应的线性变换,将任何 2×2 矩阵放入左上角(如图 5.30 所示)。例如,这个矩阵

产生一个 90° 逆时针旋转。将其插入平移矩阵中,我们得到一个新的矩阵,它将 xy 平面旋转 90°,然后按图 5.31 所示平移 (3, 1)。

图 5.31 一个矩阵,将 e1 和 e3 旋转 90°,并将 e3 平移到(3, 1)。任何在 z = 1 的平面上的图形都会经历这两种变换。

为了展示这是可行的,我们可以在 Python 中对所有 3D 恐龙顶点执行这种转换。图 5.32 显示了以下代码的输出:

rotate_and_translate = ((0,−1,3),(1,0,1),(0,0,1))
rotated_translated_dino = [
    multiply_matrix_vector(rotate_and_translate, v) 
    for v  in dino_vectors_3d]

图 5.32 原始恐龙(左)和第二个恐龙(右),它们都通过一个单一的矩阵进行了旋转和平移

一旦你掌握了使用矩阵进行 2D 平移的方法,你就可以将相同的方法应用于 3D 平移。要做到这一点,你将不得不使用 4×4 矩阵并进入神秘的 4D 世界。

5.3.4 在四维世界中转换 3D 对象

第四维度是什么?一个 4D 向量将是一个具有某些长度、宽度、深度和另一个维度的箭头。当我们从 2D 空间构建 3D 空间时,我们添加了一个 z 坐标。这意味着 3D 向量可以生活在xy平面,其中z = 0,或者它们可以生活在任何其他平行平面中,其中z具有不同的值。图 5.33 显示了这些平行平面中的几个。

图 5.33 通过堆叠平行平面构建 3D 空间,每个平面看起来像 x,y 平面,但在不同的 z 坐标上

我们可以将四个维度类比为这个模型:一组由某个第四个坐标索引的 3D 空间。解释第四个坐标的一种方式是“时间”。在给定时间的一个快照是一个 3D 空间,但所有快照的集合是一个称为时空的第四维度。时空的起点是当时间t等于 0 时空间的原点(图 5.34)。

图 5.34 4D 时空的示意图,类似于在给定的 z 值处 3D 空间的切片是一个二维平面,在给定的 t 值处 4D 时空的切片是一个三维空间

这是爱因斯坦相对论理论的起点。(事实上,你现在有资格去阅读有关这个理论的内容,因为它基于 4D 时空和由 4×4 矩阵给出的线性变换。)

向量数学在更高维度中是必不可少的,因为我们很快就会用尽好的类比。对于五维、六维、七维或更多维度,我很难想象它们,但坐标数学并不比二维或三维更难。对于我们当前的目的,我们可以将四维向量视为一个由四个数字组成的四元组。

让我们复制适用于在 3D 中转换 2D 向量的技巧。如果我们从一个 3D 向量(xyz)开始,并且我们想要通过向量(abc)进行平移,我们可以在目标向量上附加一个第四个坐标 1,并使用类似的 4D 矩阵进行平移。进行矩阵乘法确认我们得到了期望的结果(图 5.35)。

图 5.35 给向量 (x, y, z) 添加一个第四个坐标 1,我们可以使用这个矩阵通过 (a, b, c) 来平移向量。

这个矩阵将 x 坐标增加 ay 坐标增加 bz 坐标增加 c,因此它执行了通过向量 (a, b, c) 所需的变换。我们可以将添加第四个坐标、应用这个 4×4 矩阵以及然后删除第四个坐标的工作封装在一个 Python 函数中:

def translate_3d(translation):
    def new_function(target):                    ❶
        a,b,*c* = translation
        x,y,z = target
        matrix = ((1,0,0,a),
            0,1,0,b),
            (0,0,1,c),
            (0,0,0,1))                           ❷
        vector = (x,y,z,1)
        x_out, y_out, z_out, _ =\
          multiply_matrix_vector(matrix,vector)  ❸
        return (x_out,y_out,z_out)
    return new_function

❶ translate_3d 函数接受一个平移向量,并返回一个新的函数,该函数将平移应用于 3D 向量。

❷ 构建平移的 4×4 矩阵,并在下一行,将 (x, y, z) 转换为具有第四个坐标 1 的 4D 向量

❸ 执行 4D 矩阵变换

最后,绘制茶壶以及通过 (2, 2, −3) 平移的茶壶,我们可以看到茶壶适当地移动了。你可以通过运行 matrix_translate_teapot.py 来确认这一点。你应该看到与图 5.36 中相同的图像。

图 5.36 未翻译的茶壶(左)和已翻译的茶壶(右)。正如预期的那样,已翻译的茶壶向上和向右移动,并远离我们的视角。

将平移封装为矩阵运算后,我们现在可以将该操作与其他 3D 线性变换结合,并一次性完成。结果证明,你 可以 将这个设置中的虚拟第四个坐标解释为时间,t

图 5.36 中的两个图像可能是 t = 0 和 t = 1 时茶壶的快照,它以恒定速度沿 (2, 2, −3) 方向移动。如果你在寻找一个有趣的挑战,你可以将此实现中的向量 (x, y, z, 1) 替换为形式为 (x, y, z, t) 的向量,其中坐标 t 随时间变化。当 t = 0 和 t = 1 时,茶壶应该与图 5.36 中的帧相匹配,而在两个时间之间,它应该在两个位置之间平滑移动。如果你能弄清楚这是如何工作的,你将赶上爱因斯坦!

到目前为止,我们一直专注于将向量作为空间中的点渲染到计算机屏幕上。这显然是一个重要的用例,但它只是触及了我们可以用向量和矩阵做什么的表面。研究向量和线性变换如何在一般情况下一同工作的研究被称为 线性代数,我将在下一章中给你一个更广泛的这个主题的图景,以及一些与程序员相关的全新示例。

5.3.5 练习

练习 5.26:证明如果你将我们一直在使用的恐龙这样的 2D 图形移动到 z = 2 的平面上,3D 的“魔法”矩阵变换将不起作用。会发生什么?解答:使用 [(x,y,2) for x,y in dino_vectors] 并应用相同的 3×3 矩阵,恐龙被向量 (6, 2) 平移了两次,而不是 (3, 1)。这是因为向量 (0, 0, 1) 被平移了 (3, 1),变换是线性的。在 z = 2 的平面上,恐龙被相同的矩阵平移了更远的距离。
练习 5.27:想出一个矩阵,将恐龙在 x 方向上平移 -2 个单位,在 y 方向上平移 -2 个单位。执行变换并展示结果。解答:将原始矩阵中的值 3 和 1 替换为 -2 和 -2,我们得到!恐龙确实沿着向量 (−2, −2) 向下和向左平移了。
练习 5.28:证明任何形式为!的矩阵都不会影响它乘以的 3D 列向量的 z 坐标。解答:如果 3D 向量的初始 z 坐标是一个数字 z,这个矩阵不会改变该坐标:

| 练习 5.29-迷你项目:找到一个 3×3 矩阵,它能在 z = 1 的平面上将 2D 图形旋转 45°,将其大小缩小到原来的 1/2,并通过向量 (2, 2) 进行平移。通过将其应用于恐龙的顶点来证明它的工作原理。解答:首先,让我们找到一个用于将 2D 向量旋转 45° 的 2×2 矩阵:

>>> from vectors import rotate2d
>>> from transforms import *
>>> from math import pi
>>> rotate_45_degrees = curry2(rotate2d)(pi/4)          ❶
>>> rotation_matrix = infer_matrix(2,rotate_45_degrees)
>>> rotation_matrix
((0.7071067811865476, -0.7071067811865475), (0.7071067811865475, 0.7071067811865476))

❶ 编写一个函数,该函数使用 45°(或 4 弧度)的角度执行 rotate2d,对于输入的 2D 向量 This matrix is approximately:同样,我们可以找到一个缩放因子为 ½ 的矩阵:将这些矩阵相乘,我们一次使用这段代码就完成了两种变换:

>>> from matrices import *
>>> scale_matrix = ((0.5,0),(0,0.5))
>>> rotate_and_scale = matrix_multiply(scale_matrix,rotation_matrix)
>>> rotate_and_scale
((0.3535533905932738, -0.35355339059327373), (0.35355339059327373, 0.3535533905932738))

这是一个 3×3 矩阵,它将恐龙在 z = 1 的平面上平移 (2, 2):我们可以将我们的 2×2 旋转和缩放矩阵插入到这个矩阵的左上角,得到我们想要的最终矩阵:

>>> ((a,b),(c,d)) = rotate_and_scale
>>> final_matrix = ((a,b,2),(c,d,2),(0,0,1))
>>> final_matrix
((0.3535533905932738, -0.35355339059327373, 2), (0.35355339059327373, 0.3535533905932738, 2), (0, 0, 1))

将恐龙移动到平面 z = 1,应用这个矩阵进行 3D 变换,然后将其投影回 2D,我们得到了旋转、缩放和平移后的恐龙,这里只使用了一次矩阵乘法,如图所示: |

练习 5.30: 在前面的练习中,矩阵将恐龙旋转了 45°,然后通过 (3, 1) 进行平移。使用矩阵乘法,构建一个以相反顺序执行此操作的矩阵。解答:如果恐龙位于 z = 1 的平面上,则以下矩阵执行 90° 旋转且没有平移:我们想要先平移再旋转,因此我们将这个旋转矩阵与平移矩阵相乘:这与另一个矩阵不同,它先旋转再平移。在这种情况下,我们看到平移向量 (3, 1) 受到 90° 旋转的影响。新的有效平移是 (−1, 3)。

| 练习 5.31: 编写一个类似于 translate_3d 的函数,称为 translate_4d,它使用一个 5×5 矩阵通过另一个 4D 向量来平移一个 4D 向量。运行一个示例以显示坐标已平移。解答:设置与之前相同,只是我们将 4D 向量提升到 5D,给它一个第五个坐标为 1:

def translate_4d(translation):
    def new_function(target):
        a,b,c,d = translation
        x,y,z,w = target
        matrix = (
            (1,0,0,0,a),
            (0,1,0,0,b),
            (0,0,1,0,c),
            (0,0,0,1,d),
            (0,0,0,0,1))
        vector = (x,y,z,w,1)
        x_out,y_out,z_out,w_out,_ = multiply_matrix_vector(matrix,vector)
        return (x_out,y_out,z_out,w_out)
    return new_function

我们可以看到平移是有效的(效果与添加两个向量相同):

>>> translate_4d((1,2,3,4))((10,20,30,40))
(11, 22, 33, 44)

|

在前面的章节中,我们使用 2D 和 3D 的视觉示例来激发向量和矩阵算术。随着我们的进行,我们更多地强调了计算。在本章的末尾,我们在没有物理洞察力的情况下计算了高维向量变换。这是线性代数的一个好处:它为你提供了解决过于复杂而无法想象的几何问题的工具。我们将在下一章中概述这一广泛的应用范围。

概述

  • 线性变换由其对标准基向量的作用来定义。当你将线性变换应用于标准基时,结果向量包含执行变换所需的所有数据。这意味着指定任何类型的 3D 线性变换只需要九个数字(这三个结果向量的三个坐标)。对于 2D 线性变换,需要四个数字。

  • 在矩阵表示法中,我们通过将这些数字放入矩形网格中来表示线性变换。按照惯例,你通过将变换应用于标准基向量并将结果坐标向量并排放置作为列来构建矩阵。

  • 使用矩阵来评估它对给定向量所表示的线性变换的结果称为 矩阵乘以向量。当你进行这种乘法时,向量通常按从上到下的顺序写成其坐标的列,而不是作为元组。

  • 两个方阵也可以相乘。结果矩阵表示原始两个矩阵的线性变换的合成。

  • 要计算两个矩阵的乘积,你需要计算第一个矩阵的行与第二个矩阵的列的点积。例如,第一个矩阵的第 i 行与第二个矩阵的第 j 列的点积给出了乘积矩阵的第 i 行和第 j 列的值。

  • 由于方阵代表线性变换,非方阵代表从一维向量到另一维向量的线性函数。也就是说,这些函数将向量加和映射到向量加和,将标量乘积映射到标量乘积。

  • 矩阵的维度告诉你其对应的线性函数接受和返回什么类型的向量。一个有 m 行和 n 列的矩阵被称为 m -by- n 矩阵(有时写作 m × n)。它定义了一个从 n 维空间到 m 维空间的线性函数。

  • 翻译不是一个线性函数,但如果在更高维度下进行,它可以被转化为线性。这个观察结果使我们能够通过矩阵乘法来进行翻译(同时与其他线性变换一起进行)。

6 将概念推广到高维

本章涵盖

  • 实现一个 Python 抽象基类以实现通用向量

  • 定义向量空间并列举它们的 useful properties

  • 将函数、矩阵、图像和声波解释为向量

  • 寻找包含感兴趣数据的向量空间的有用子空间

即使你对动画茶壶不感兴趣,向量、线性变换和矩阵的机制仍然很有用。事实上,这些概念非常有用,以至于有一个整个数学分支专门研究它们:线性代数。线性代数将我们关于 2D 和 3D 几何的已知知识概括到研究任何数量的维度中的数据。

作为程序员,你可能擅长概括思想。在编写复杂的软件时,你可能会发现自己一遍又一遍地编写相似的代码。在某个时刻,你会发现自己正在这样做,并将代码合并到一个可以处理你所看到的所有情况的类或函数中。这可以节省你的打字时间,并经常改善代码的组织和可维护性。数学家遵循同样的过程:在反复遇到相似的图案后,他们可以更好地陈述他们所看到的内容,并完善他们的定义。

在本章中,我们使用这种逻辑来定义向量空间。向量空间是我们可以将它们视为向量的对象集合。这些可以是平面上的箭头、数字的元组,或者与我们迄今为止所看到的不同完全的对象。例如,你可以将图像视为向量,并对它们进行线性组合(图 6.1)。

图片

图 6.1 两个图像的线性组合产生了一个新的图像。

向量空间中的关键操作是向量加法和数乘。有了这些,你可以进行线性组合(包括取反、减法、加权平均等),并且可以推理哪些变换是线性的。结果证明,这些操作帮助我们理解“维度”这个词的含义。例如,我们将看到图 6.1 中使用的图像是 270,000 维的对象!我们很快就会涵盖更高维甚至无限维的空间,但让我们先回顾一下我们已知的 2D 和 3D 空间。

6.1 概括我们向量的定义

Python 支持面向对象编程(OOP),这是一个很好的概括框架。具体来说,Python 类支持继承:你可以创建新的对象类,这些类继承现有父类的属性和行为。在我们的例子中,我们希望将我们已看到的 2D 和 3D 向量实现为更一般类对象的实例,这个类简单地称为向量。然后,任何从父类继承行为的其他对象也可以正确地被称为向量(图 6.2)。

图片

图 6.2 使用继承将 2D 向量、3D 向量和其他对象视为向量的特殊情况

如果你还没有做过面向对象编程,或者你没有在 Python 中见过它,不要担心。我在本章中坚持简单的用例,并且会在我们前进的过程中帮助你掌握它。如果你想在开始之前更多地了解 Python 中的类和继承,我在附录 B 中进行了介绍。

6.1.1 为二维坐标向量创建一个类

在代码中,我们的二维和三维向量是 坐标 向量,这意味着它们被定义为数字的元组,即它们的坐标。(我们也看到向量算术可以基于箭头在几何上定义,但我们不能直接将这种方法转换为 Python 代码。)对于二维坐标向量,数据是 xy 坐标的有序对。元组是存储这种数据的好方法,但我们可以等效地使用一个类。我们将代表二维坐标向量的类称为 Vec2

class Vec2():
    def __init__(self,x,y):
        self.x = x
        self.y = y

我们可以初始化一个向量,例如 v = Vec2(1.6,3.8),并检索其坐标为 v.xv.y。接下来,我们可以给这个类添加进行二维向量算术运算所需的方法,特别是加法和标量乘法。加法函数 add 接收一个第二个向量作为参数,并返回一个新的 Vec2 对象,其坐标分别是 xy 坐标的和:

class Vec2():
    ...                 ❶
    def add(self, v2):
        return Vec2(self.x + v2.x, self.y + v2.y)

❶ 当向现有类添加内容时,我有时会使用 ... 作为现有代码的占位符。

使用 Vec2 进行向量加法可能看起来像这样:

v = Vec2(3,4)          ❶
w = v.add(Vec2(−2,6))  ❷
print(w.x)             ❸

❶ 创建一个新的名为 v 的 Vec2,其 x 坐标为 3,y 坐标为 4

❷ 将第二个 Vec2 添加到 v 中,以产生一个新的名为 w 的 Vec2 实例。这个操作返回 (3,4) + (−2,6) = (1,10)。

❸ 打印 w 的 x 坐标。结果是 1。

就像我们原始的向量加法实现一样,我们不执行“就地”加法。也就是说,两个输入向量不会被修改;创建一个新的 Vec2 对象来存储和。我们可以以类似的方式实现标量乘法,接收一个标量作为输入,并返回一个新的、缩放后的向量作为输出:

class Vec2():
    ...
    def scale(self, scalar):
        return Vec2(scalar * self.x, scalar * self.y)

Vec(1,1).scale(50) 返回一个新的向量,其 xy 坐标都等于 50。还有一个关键细节我们需要注意:目前比较操作 Vec2(3,4) == Vec2(3,4) 的结果是 False。这是有问题的,因为这些实例代表的是同一个向量。默认情况下,Python 通过引用(询问它们是否位于内存中的同一位置)来比较实例,而不是通过它们的值。我们可以通过重写相等方法来修复这个问题,这将导致 Python 对 Vec2 类的对象使用 == 操作符的方式不同。(如果你之前没有见过,附录 B 会更深入地解释。)

class Vec2():
    ...
    def __eq__(self,other):
        return self.x == other.x and self.y == other.y

我们希望两个二维坐标向量相等,如果它们的 xy 坐标相同,这个新的相等定义正是如此。实现后,你会发现 Vec2(3,4) == Vec2(3,4)

我们的Vec2类现在具有基本的向量运算,如加法和标量乘法,以及一个有意义的相等性测试。现在我们可以将注意力转向一些语法糖。

6.1.2 改进 Vec2 类

由于我们改变了==运算符的行为,我们还可以自定义 Python 运算符+*,分别表示向量加法和标量乘法。这被称为运算符重载,它在本附录 B 中有详细说明:

class Vec2():
    ...
    def __add__(self, v2):
        return self.add(v2)
    def __mul__(self, scalar):     ❶
        return self.scale(scalar)
    def __rmul__(self,scalar): 
        return self.scale(scalar)

__mul____rmul__方法定义了乘法的两种顺序,因此我们可以从左或右将向量与标量相乘。从数学上讲,我们考虑这两种顺序意味着相同的事情。

我们现在可以简洁地写出线性组合。例如,3.0 * Vec2(1,0) + 4.0 * Vec2(0,1)给我们一个新的Vec2对象,其x坐标为 3.0,y坐标为 4.0。然而,在交互式会话中阅读它很困难,因为 Python 没有很好地打印Vec2

>>> 3.0 * Vec2(1,0) + 4.0 * Vec2(0,1)
<__main__.Vec2 at 0x1cef56d6390>

Python 为我们提供了结果Vec2实例的内存地址,但我们已经观察到这并不是我们关心的。幸运的是,我们可以通过重写__repr__方法来改变Vec2对象的字符串表示:

class Vec2():
    ...
    def __repr__(self):
        return "Vec2({},{})".format(self.x,self.y)

这种字符串表示法显示了对于Vec2对象来说最重要的坐标数据。现在Vec2算术的结果更加清晰:

>>> 3.0 * Vec2(1,0) + 4.0 * Vec2(0,1)
Vec2(3.0,4.0)

我们在这里做的数学运算与我们在原始元组向量上所做的相同,但在我看来,这要优雅得多。构建一个类需要一些样板代码,比如我们想要的定制相等性,但它也使得向量的算术运算可以进行运算符重载。定制的字符串表示法也清楚地表明,我们不仅仅是在处理任何元组,而是在以某种特定方式使用 2D 向量。现在,我们可以实现由自己的特殊类表示的 3D 向量。

6.1.3 使用 3D 向量重复此过程

我将 3D 向量类称为Vec3,它看起来与 2D 的Vec2类非常相似,除了它的定义数据将是三个坐标而不是两个。在显式引用坐标的每个方法中,我们需要确保正确使用Vec3xyz值。

class Vec3():
    def __init__(self,x,y,z): #1
        self.x = x
        self.y = y
        self.z = z
    def add(self, other):
        return Vec3(self.x + other.x, self.y + other.y, self.z + other.z)
    def scale(self, scalar):
        return Vec3(scalar * self.x, scalar * self.y, scalar * self.z)
    def __eq__(self,other):
        return (self.x == other.x 
                and self.y == other.y 
                and self.z == other.z)
    def __add__(self, other):
        return self.add(other)
    def __mul__(self, scalar):
        return self.scale(scalar)
    def __rmul__(self,scalar):
        return self.scale(scalar)
    def __repr__(self):
        return "Vec3({},{},{})".format(self.x,self.y, self.z)

我们现在可以使用 Python 的内置算术运算符编写 3D 向量数学:

>>> 2.0 * (Vec3(1,0,0) + Vec3(0,1,0))
Vec3(2.0,2.0,0.0)

这个Vec3类,就像Vec2类一样,使我们处于一个很好的位置来考虑泛化。我们可以走几个不同的方向,就像许多软件设计选择一样,这个决定是主观的。例如,我们可以专注于简化算术。我们不需要为Vec2Vec3实现不同的add函数,它们都可以使用我们在第三章中构建的add函数,该函数已经可以处理任何大小的坐标向量。我们还可以将坐标内部存储为元组或列表,让构造函数接受任意数量的坐标并创建 2D、3D 或其他坐标向量。然而,我将这些可能性留给你作为练习,并带我们走向不同的方向。

我想要关注的一般化是基于我们如何使用向量,而不是它们是如何工作的。这使我们达到一个既能很好地组织代码又与向量的数学定义相一致的心理模型。例如,我们可以编写一个通用的 average 函数,它可以用于任何类型的向量:

def average(v1,v2):
    return 0.5 * v1 + 0.5 * v2

我们可以插入 3D 向量或 2D 向量;例如,average(Vec2(9.0, 1.0), Vec2(8.0,6.0))average(Vec3(1,2,3), Vec3(4,5,6)) 都会给出正确且有意义的结果。作为预告,我们很快就能将图片一起平均。一旦我们实现了适合图像的类,我们就能编写 average(img1, img2) 并得到一个新的图像。

这就是我们看到一般化带来的美和经济效益的地方。我们可以编写一个单一的通用函数,如 average,并用于广泛的输入类型。对输入的唯一约束是它需要支持与标量相乘和相互相加。算术的实现因 Vec2 对象、Vec3 对象、图像或其他类型的数据而异,但它们之间始终存在一个重要的重叠,即我们可以用它们做什么算术。当我们把“做什么”与“怎么做”分开时,我们为代码重用和广泛的数学陈述打开了大门。

我们如何最好地描述我们可以用向量做什么,而不是我们如何执行这些操作的细节?我们可以使用 Python 中的抽象基类来捕捉这一点。

6.1.4 构建向量基类

我们可以用 Vec2Vec3 做的基本事情包括构造一个新的实例、与其他向量相加、乘以一个标量、测试与另一个向量的相等性,以及将实例表示为字符串。在这些操作中,只有加法和标量乘法是独特的向量操作。任何新的 Python 类都会自动包含其余的操作。这促使我们定义一个 Vector 基类:

from abc import ABCMeta, abstractmethod

class Vector(metaclass=ABCMeta):
    @abstractmethod
    def scale(self,scalar):
        pass
    @abstractmethod
    def add(self,other):
        pass

abc 模块包含辅助类、函数和方法装饰器,这些可以帮助定义一个抽象基类,一个不打算实例化的类。相反,它被设计成用作继承自它的类的模板。@abstractmethod 装饰器意味着基类中没有实现该方法,并且任何子类都需要实现它。例如,如果你尝试使用如下代码实例化一个向量 v = Vector(),你会得到以下 TypeError

TypeError: Can't instantiate abstract class Vector with abstract methods add, scale

这是有意义的;不存在“仅仅是一个向量”的向量。它需要有一些具体的体现,比如坐标列表、平面上的箭头或其他东西。但这个基类仍然很有用,因为它迫使任何子类都包含必需的方法。此外,拥有这个基类也很有用,因为我们可以在其中装备所有只依赖于加法和标量乘法的依赖方法,就像我们的运算符重载一样:

class Vector(metaclass=ABCMeta):
    ...
    def __mul__(self, scalar):
        return self.scale(scalar)
    def __rmul__(self, scalar):
        return self.scale(scalar)
    def __add__(self,other):
        return self.add(other)

与抽象方法scaleadd相比,这些实现自动适用于任何子类。我们可以简化Vec2Vec3,使它们继承自Vector。以下是Vec2的新实现:

class Vec2(Vector):
    def __init__(self,x,y):
        self.x = x
        self.y = y
    def add(self,other):
        return Vec2(self.x + other.x, self.y + other.y)
    def scale(self,scalar):
        return Vec2(scalar * self.x, scalar * self.y)
    def __eq__(self,other):
        return self.x == other.x and self.y == other.y
    def __repr__(self):
        return "Vec2({},{})".format(self.x, self.y)

这确实让我们免于重复自己!在Vec2Vec3之间相同的那些方法现在都生活在Vector类中。Vec2上剩余的所有方法都是针对二维向量的;它们需要修改才能为Vec3(你将在练习中看到)或任何其他坐标数的向量工作。

Vector基类是向量的一个很好的表示。如果我们可以向它添加任何有用的方法,那么它们很可能会对 任何 类型的向量都很有用。例如,我们可以向Vector添加两个方法:

class Vector(metaclass=ABCMeta):
    ...
    def subtract(self,other):
        return self.add(−1 * other)
    def __sub__(self,other):
        return self.subtract(other)

在没有任何修改的情况下,我们可以自动从Vec2中减去它们:

>>> Vec2(1,3) − Vec2(5,1)
Vec2(−4,2)

这个抽象类使得实现一般的向量运算变得更容易,并且它与向量的数学定义一致。让我们从 Python 语言切换到英语,看看这种抽象是如何从代码转化为真正的数学定义的。

6.1.5 定义向量空间

在数学中,向量是通过它的作用来定义的,而不是通过它的本质,这与我们定义的抽象Vector类非常相似。这里是一个向量的第一个(不完整)定义。

定义 一个向量是一个具有将自身添加到其他向量以及乘以标量的合适方式的对象。

我们的Vec2Vec3对象,或任何从Vector类继承的对象,可以相互相加,并且可以与标量相乘。这个定义是不完整的,因为我没有说“合适”是什么意思,而这最终是定义中最重要的一部分!

有一些重要的规则禁止奇怪的行为,其中许多你可能已经假设了。没有必要记住所有这些规则。如果你发现自己正在测试一种新的对象是否可以被视为向量,你可以参考这些规则。第一组规则说加法应该表现得很好。具体来说:

  1. 以任何顺序添加向量不应该有关系:对于任何向量 vwv + w = w + v

  2. 在任何分组中添加向量不应该有关系:u + (v + w) 应该等于 (u + v) + w,这意味着像 u + v + w 这样的陈述应该是无歧义的。

一个好的反例是通过连接字符串。在 Python 中,你可以做 sum "hot" + "dog",但这不支持字符串可以作为向量的情况,因为 "hot" + "dog""dog" + "hot"` 的和不相等,违反了规则 1。

标量乘法也需要表现得很好,并且与加法兼容。例如,一个整数标量乘数应该等于重复加法(如 3v = v + v + v)。以下是具体的规则:

  1. 将向量乘以多个标量应该与一次乘以所有标量相同。如果 ab 是标量,而 v 是一个向量,那么 a · (b · v) 应该与 (a · b) · v 相同。

  2. 将向量乘以 1 应该不会改变它:1 · v = v

  3. 标量的加法应该与标量乘法兼容:a · v + b · v 应该与 (a + b) · v 相同。

  4. 向量的加法也应该与标量乘法兼容:a · (v + w) 应该与 a · v + a · w 相同。

这些规则中没有任何一条应该令人惊讶。例如,3 · v + 5 · v 可以翻译成英语为“3 个 v 相加再加上 5 个 v 相加。”当然,这等同于 8 个 v 相加,或者 8 · v,与规则 5 一致。

这些规则得出的结论是,并非所有的加法和乘法运算都是平等的。我们需要验证每一条规则,以确保加法和乘法的行为符合预期。如果是这样,那么所讨论的对象就可以正确地被称为向量。

向量空间 是一组兼容的向量集合。以下是定义:

定义 向量空间是一组称为向量的对象集合,配备了适当的向量加法和标量乘法运算(遵守上述规则),使得该集合中向量的每个线性组合都产生一个也在集合中的向量。

类似于 [Vec2(1,0), Vec2(5,−3), Vec2(1.1,0.8)] 的集合是一组可以适当相加和乘以的向量,但它不是一个向量空间。例如,1 * Vec2(1,0) + 1 * Vec2(5,−3) 是一个线性组合,其结果是 Vec2(6,−3),它不在集合中。向量空间的一个例子是所有可能的 2D 向量的无限集合。事实上,你遇到的绝大多数向量空间都是无限集合;毕竟,使用无限多个标量可以有无限多个线性组合!

向量空间需要包含所有它们的标量倍数的事实有两个含义,并且这些含义足够重要,可以单独提及。首先,无论你在向量空间中选取什么向量 v,0 · v 都会得到相同的结果,这个结果被称为 零向量,表示为 0(粗体,以区别于数字 0)。将零向量加到任何向量上都不会改变该向量:0 + v = v + 0 = v。第二个含义是每个向量 v 都有一个相反向量,表示为 -1 · v,写作 -v。由于规则 #5,v + -v = (1 + −1) · v = 0 · v = 0。对于每个向量,向量空间中都有一个向量可以通过加法“抵消”它。作为一个练习,你可以通过添加零向量和否定函数作为必需的成员来改进 Vector 类。

类似于 Vec2Vec3 的类本身不是集合,但它确实描述了一组值。这样,我们可以将 Vec2Vec3 类视为代表两个不同的向量空间,它们的实例代表向量。在下一节中,我们将看到更多由表示它们的类实现的向量空间示例,但首先,让我们看看如何验证它们是否满足我们已讨论的特定规则。

6.1.6 单元测试向量空间类

使用抽象的 Vector 基类来思考向量应该能够做什么,而不是如何做,这很有帮助。但即使给基类提供一个抽象的 add 方法,也不能保证每个继承类都会实现合适加法操作。

在数学中,我们通常通过 写出证明 来保证适宜性。在代码中,尤其是在像 Python 这样的动态语言中,我们能做的最好的事情就是编写单元测试。例如,我们可以通过创建两个向量和标量,并确保它们相等来检查上一节中的规则 #6:

>>> s = −3
>>> xu, *v*  = Vec2(42,−10), Vec2(1.5, 8)
>>> s * (*u + v*) == s * v  + s * u
True

这通常是编写单元测试的方式,但这是一个相当弱的测试,因为我们只尝试了一个例子。我们可以通过插入随机数字并确保它工作来使其更强。在这里,我使用 random.uniform 函数生成介于 -10 和 10 之间的均匀分布的浮点数:

from random import uniform

def random_scalar():
    return uniform(−10,10)

def random_vec2():
    return Vec2(random_scalar(),random_scalar())

a = random_scalar()
u, v  = random_vec2(), random_vec2()
assert a * (u + v) == a * v  + a * u

除非你很幸运,否则这个测试将以 AssertionError 失败。以下是我测试失败时 auv 的值:

>>> a, *u*, v
(0.17952747449930084,
 Vec2(0.8353326458605844,0.2632539730989293),
 Vec2(0.555146137477196,0.34288853317521084))

并且上一段代码中 assert 调用左边和右边的等号表达式具有这些值:

>>> a * (u + v), a * *z* + a * v
(Vec2(0.24962914431749222,0.10881923333807299),
 Vec2(0.24962914431749225,0.108819233338073))

这两个向量是不同的,但仅仅因为它们的分量相差几个十亿分之一(非常、非常小的数字)。这并不意味着数学是错误的,只是说明浮点运算是大致而非精确的。

为了忽略这样的小差异,我们可以使用适合测试的另一种相等性概念。Python 的 math.isclose 函数检查两个浮点值之间的差异不是很大(默认情况下,大于较大值的十亿分之一)。使用该函数代替,测试可以连续通过 100 次:

from math import isclose

def approx_equal_vec2(v,w):
    return isclose(v.x,w.x) and isclose(v.y,w.y)  ❶

for _ in range(0,100):                            ❷
    a = random_scalar()
    u, v  = random_vec2(), random_vec2()
    assert approx_equal_vec2(a * (u + v), 
                             a * v + a * u)       ❸

❶ 检查 xy 分量是否接近(即使不相等)

❷ 对 100 个不同随机生成的标量和向量对运行测试

❸ 使用新函数替换严格的相等性检查

从方程中移除浮点误差后,我们可以以这种方式测试所有六个向量空间属性:

def test(eq, a, b, u, v, w):           ❶
    assert eq(u + v, v  + u)
    assert eq(u + (v + w), (u + v) + w)
    assert eq(a * (b * v), (a * b) * v)
    assert eq(1 * v, v)
    assert eq((a + b) * v, a * v  + b * v)
    assert eq(a * v  + a * w, a * (v + w))

for i in range(0,100):
    a,b = random_scalar(), random_scalar()
    u,v,w = random_vec2(), random_vec2(), random_vec2()
    test(approx_equal_vec2,a,b,u,v,w)

❶ 将相等性测试函数作为 eq 传入。这使测试函数对传入的特定具体向量实现保持无偏见。

这个测试表明,对于 100 个不同的随机选择的标量和向量,所有六个规则(性质)都成立。600 个随机单元测试通过是一个很好的迹象,表明我们的 Vec2 类满足上一节中列出的性质列表。一旦你在练习中实现了 zero() 属性和否定运算符,你就可以测试更多性质。

这种设置并不完全通用;我们不得不编写特殊函数来生成随机的 Vec2 实例,以及进行比较。重要的是,test 函数本身以及其中的表达式是完全通用的。只要我们测试的类继承自 Vector,它就可以运行像 *a* * *v* + *a* * *w**a* * (v + w) 这样的表达式,然后我们可以测试它们的相等性。现在,我们可以尽情探索所有可以作为向量对待的不同对象,并且我们知道如何测试它们。

6.1.7 练习

| 练习 6.1: 实现一个从 Vector 继承的 Vec3 类。解决方案

class Vec3(Vector):
    def __init__(self,x,y,z):
        self.x = x
        self.y = y
        self.z = z
    def add(self,other):
        return Vec3(self.x + other.x, 
                    self.y + other.y, 
                    self.z + other.z)
    def scale(self,scalar):
        return Vec3(scalar * self.x, 
                    scalar * self.y, 
                    scalar * self.z)
    def __eq__(self,other):
        return (self.x == other.x 
                and self.y == other.y 
                and self.z == other.z)
    def __repr__(self):
        return "Vec3({},{},{})".format(self.x, self.y, self.z)

|

| 练习 6.2-迷你项目:实现一个从 Vector 继承的 CoordinateVector 类,具有表示维度的抽象属性。这应该在实现特定的坐标向量类时节省重复工作。从 CoordinateVector 继承并设置维度为 6 应该是你实现 Vec6 类所需做的全部工作。解决方案:我们可以使用第二章和第三章中的维度无关操作 addscale。在以下类中没有实现的是维度,而不知道我们正在处理多少维度阻止了我们实例化一个 CoordinateVector

from abc import abstractproperty
from vectors import add, scale

class CoordinateVector(Vector):
    @abstractproperty
    def dimension(self):
        pass
    def __init__(self,*coordinates):
        self.coordinates = tuple(*x* for *x* in coordinates)
    def add(self,other):
        return self.__class__(*add(self.coordinates, other.coordinates))
    def scale(self,scalar):
        return self.__class__(*scale(scalar, self.coordinates))
    def __repr__(self):
        return "{}{}".format(self.__class__.__qualname__, self.coordinates)

一旦我们选择了一个维度(比如说 6),我们就有一个具体的类可以实例化:

class Vec6(CoordinateVector):
    def dimension(self):
        return 6

加法、标量乘法等定义是从 CoordinateVector 基类中获取的:

>>> Vec6(1,2,3,4,5,6) + Vec6(1, 2, 3, 4, 5, 6)
Vec6(2, 4, 6, 8, 10, 12)

|

| 练习 6.3: 向 Vector 类添加一个返回给定向量空间中零向量的 zero 抽象方法,以及否定运算符的实现。这些方法很有用,因为我们需要有一个零向量以及向量空间的任何向量的否定。解决方案

from abc import ABCMeta, abstractmethod, abstractproperty

class Vector(metaclass=ABCMeta):
    ...
    @classmethod             ❶
    @abstractproperty        ❷
    def zero():
        pass

    def __neg__(self):       ❸
        return self.scale(−1)

❶ 零是一个类方法,因为任何向量空间只有一个零值。❷ 它也是一个抽象属性,因为我们还没有定义什么是零。❸ 特殊方法名称用于重载否定运算符。我们不需要为任何子类实现 __neg__,因为它的定义仅基于标量乘法包含在父类中。然而,我们确实需要为每个类实现 zero

class Vec2(Vector):
    ...
    def zero():
        return Vec2(0,0)

|

| 练习 6.4: 编写单元测试以显示 Vec3 的加法和标量乘法运算满足向量空间的性质。解决方案:因为测试函数是通用的,我们只需要为 Vec3 对象提供一个新的相等性函数和 100 组随机输入:

def random_vec3():
    return Vec3(random_scalar(),random_scalar(),random_scalar())

def approx_equal_vec3(v,w):
    return isclose(v.x,w.x) and isclose(v.y,w.y) and isclose(v.z, w.z)

for i in range(0,100):
    a,b = random_scalar(), random_scalar()
    u,v,w = random_vec3(), random_vec3(), random_vec3()
    test(approx_equal_vec3,a,b,u,v,w)

|

| 练习 6.5:添加单元测试以检查对于任何向量v0 + v = v,0 · v = 0,和-v + v = 0。再次,0 是数字零,0是零向量。解决方案:因为零向量取决于我们正在测试的类,我们需要将其作为参数传递给函数:

def test(zero,eq,a,b,u,v,w):
    ...
    assert eq(zero + *v*, v)
    assert eq(0 * v, zero)
    assert eq(−v + v, zero)

我们可以使用实现了zero方法的任何向量类进行测试(参见练习 6.3):

for i in range(0,100):
    a,b = random_scalar(), random_scalar()
    u,v,w = random_vec2(), random_vec2(), random_vec2()
    test(Vec2.zero(), approx_equal_vec2, a,b,u,v,w)

|

| 练习 6.6:由于Vec2Vec3实现了相等性,结果Vec2(1,2) == Vec3(1,2,3)返回True。Python 的鸭子类型对自己过于宽容!通过添加一个检查,确保在测试向量相等性之前类必须匹配来修复这个问题。解决方案:实际上,我们还需要对加法进行检查!

class Vec2(Vector):
    ...
    def add(self,other):
        assert self.__class__ == other.__class__
        return Vec2(self.x + other.x, self.y + other.y)
    ...
    def __eq__(self,other):
        return (self.__class__ == other.__class__
            and self.x == other.x and self.y == other.y)

为了安全起见,您还可以将此类检查添加到Vector的其他子类中。|

| 练习 6.7:在Vector上实现一个__truediv__函数,允许您用标量除以向量。您可以通过乘以标量的倒数(1.0/scalar)来除以非零标量。解决方案

class Vector(metaclass=ABCMeta):
    ...
    def __truediv__(self, scalar):
        return self.scale(1.0/scalar)

实现了这一点,您就可以进行除法,如Vec2(1,2)/2,返回Vec2(0.5,1.0)。|

6.2 探索不同的向量空间

现在您已经知道了向量空间是什么,让我们看看一些例子。在每种情况下,我们取一种新的对象类型,并将其实现为一个从Vector继承的类。到那时,无论它是什么类型的对象,我们都可以用它进行加法、标量乘法或其他任何向量运算。

6.2.1 列举所有坐标向量空间

我们到目前为止已经花费了很多时间在坐标向量Vec2Vec3上,所以 2D 和 3D 中的坐标向量不需要更多的解释。然而,值得回顾的是,坐标向量的向量空间可以具有任何数量的坐标。Vec2向量有两个坐标,Vec3向量有三个,我们也可以有一个具有 15 个坐标的Vec15类。我们无法在几何上想象它,但Vec15对象代表 15 维空间中的点。

值得一提的一个特殊情况是我们可能称之为Vec1的类,它具有单个坐标的向量。其实现如下:

class Vec1(Vector):
    def __init__(self,x):
        self.x = x
    def add(self,other):
        return Vec1(self.x + other.x)
    def scale(self,scalar):
        return Vec1(scalar * self.x)
    @classmethod
    def zero(cls):
        return Vec1(0)
    def __eq__(self,other):
        return self.x == other.x
    def __repr__(self):
        return "Vec1({})".format(self.x)

这是一大堆样板代码,只是为了包装一个单个数字,而且它并没有给我们带来任何我们已有的算术。添加和乘以Vec1标量对象只是底层数字的加法和乘法:

>>> Vec1(2) + Vec1(2)
Vec1(4)
>>> 3 * Vec1(1)
Vec1(3)

因此,我们可能永远不需要Vec1类。但重要的是要知道,单独的数字本身就是向量。所有实数的集合(包括整数、分数和像π这样的无理数)表示为ℝ,它本身就是一个向量空间。这是一个特殊情况,其中标量和向量是同一种类型的对象。

坐标向量空间表示为 ℝ^(n),其中 n 是维度或坐标的数量。例如,二维平面表示为 ℝ²,三维空间表示为 ℝ³。只要你的标量是实数,你遇到的任何向量空间都是某种伪装的 ℝ^(n)。1 这就是为什么我们需要提到向量空间 ℝ,即使它很无聊。我们还需要提到的另一个向量空间是零维的,即 ℝ⁰。这是由零坐标组成的向量集合,我们可以将其描述为空元组或作为从 Vector 继承的 Vec0 类:

class Vec0(Vector):
    def __init__(self):
        pass
    def add(self,other):
        return Vec0()
    def scale(self,scalar):
        return Vec0()
    @classmethod
    def zero(cls):
        return Vec0()
    def __eq__(self,other):
        return self.__class__ == other.__class__ == Vec0
    def __repr__(self):
        return "Vec0()"

没有坐标并不意味着没有可能的向量;这意味着恰好有一个零维向量。这使得零维向量数学非常简单;任何结果向量总是相同的:

>>> − 3.14 * Vec0()
Vec0()
>>> Vec0() + Vec0() + Vec0() + Vec0()
Vec0()

从面向对象的角度来看,这就像是一个单例类。从数学的角度来看,我们知道每个向量空间都必须有一个零向量,因此我们可以将 Vec0() 视为零向量。

这就涵盖了零维、一维、二维、三维或更多维度的坐标向量。现在,当你看到自然界中的向量时,你将能够将其与这些向量空间之一相匹配。

6.2.2 在自然界中识别向量空间

让我们回到第一章的一个例子,并查看一个二手丰田普锐斯的数据集。在源代码中,你会看到如何加载由我的朋友 Dan Rathbone 在 CarGraph.com 提供的慷慨数据集。为了使车辆易于处理,我将它们加载到一个类中:

class CarForSale():
    def __init__(self, model_year, mileage, price, posted_datetime, 
                 model, source, location, description):
        self.model_year = model_year
        self.mileage = mileage
        self.price = price
        self.posted_datetime = posted_datetime
        self.model = model
        self.source = source
        self.location = location
        self.description = description

CarForSale 对象视为向量是有用的。例如,我可以将它们作为一个线性组合的平均值来查看典型的销售普锐斯是什么样的。为了做到这一点,我需要将这个类修改为从 Vector 继承。

我们如何将两辆车相加?数值字段 model_yearmileageprice 可以像向量的分量一样相加,但字符串属性无法以有意义的方式相加。(记住,你看到我们不能将字符串视为向量。)当我们对车辆进行算术运算时,结果不是一辆真正的销售车辆,而是一个由其属性定义的虚拟车辆。为了表示这一点,我将所有的字符串属性更改为字符串

“(虚拟)” 以提醒我们这一点。最后,我们无法添加日期时间,但我们可以添加时间跨度。在图 6.3 中,我使用我获取数据的那天作为参考点,并添加了自车辆发布以来经过的时间跨度。整个过程的代码在列表 6.1 中显示。

图 6.3 发布销售车辆的时序图

所有这些也适用于标量乘法。我们可以将数值属性和自发布以来的时间跨度乘以一个标量。然而,字符串属性不再有意义。

列表 6.1 通过实现所需方法使 CarForSale 表现得像 Vector

from datetime import datetime

class CarForSale(Vector):
    retrieved_date = datetime(2018,11,30,12)                         ❶
    def __init__(self, model_year, mileage, price, posted_datetime, 
                 model="(virtual)", 
                         source="(virtual)",                         ❷
                 location="(virtual)", description="(virtual)"):
        self.model_year = model_year
        self.mileage = mileage
        self.price = price
        self.posted_datetime = posted_datetime
        self.model = model
        self.source = source
        self.location = location
        self.description = description
    def add(self, other):
        def add_dates(d1, d2):                                       ❸
            age1 = CarForSale.retrieved_date − d1
            age2 = CarForSale.retrieved_date − d2
            sum_age = age1 + age2
            return CarForSale.retrieved_date − sum_age
        return CarForSale(                                           ❹
            self.model_year + other.model_year,
            self.mileage + other.mileage,
            self.price + other.price,
            add_dates(self.posted_datetime, other.posted_datetime)
        )
    def scale(self,scalar):
        def scale_date(d):                                           ❺
            age = CarForSale.retrieved_date − d
            return CarForSale.retrieved_date − (scalar * age)
        return CarForSale(
            scalar * self.model_year,
            scalar * self.mileage,
            scalar * self.price,
            scale_date(self.posted_datetime)
        )
    @classmethod
    def zero(cls):
        return CarForSale(0, 0, 0, CarForSale.retrieved_date)

❶ 我在 2018 年 11 月 30 日中午从 CarGraph.com 获取了数据集。

❷ 为了简化虚拟汽车的构建,所有的字符串参数都是可选的,默认值为“(virtual)”。

❸ 辅助函数,通过添加从参考日期的时间跨度来添加日期

❹ 通过添加底层属性并构建一个新的对象来添加 CarForSale 对象

❺ 辅助函数,通过缩放从参考日期的时间跨度来缩放日期时间

在源代码中,你可以找到类的完整实现以及加载样本汽车数据的代码。加载了汽车列表后,我们可以尝试一些向量运算:

>>> (cars[0] + cars[1]).__dict__
{'model_year': 4012,
 'mileage': 306000.0,
 'price': 6100.0,
 'posted_datetime': datetime.datetime(2018, 11, 30, 3, 59),
 'model': '(virtual)',
 'source': '(virtual)',
 'location': '(virtual)',
 'description': '(virtual)'}

前两辆车的总和显然是一辆 4012 年款的普锐斯(也许它能飞?),行驶里程为 306,000 英里,要价 6,100 美元。它是在我查看 CarGraph.com 的同一天早上 3:59 AM 发布的。这辆不同寻常的汽车看起来不太有用,但请耐心等待,平均数(如下所示)看起来要更有意义:

>>> average_prius = sum(cars, CarForSale.zero()) * (1.0/len(cars))
>>> average_prius.__dict__

{'model_year': 2012.5365853658536,
 'mileage': 87731.63414634147,
 'price': 12574.731707317074,
 'posted_datetime': datetime.datetime(2018, 11, 30, 9, 0, 49, 756098),
 'model': '(virtual)',
 'source': '(virtual)',
 'location': '(virtual)',
 'description': '(virtual)'}

我们可以从这个结果中学习到真实的东西。市场上平均的普锐斯车龄约为 6 年,行驶里程约为 88,000 英里,售价约为 12,500 美元,而我访问网站的那天早上 9:49 AM 就已经发布了。(在第三部分,我们将花费大量时间通过将数据集视为向量来学习数据集。)

忽略文本数据,CarForSale的行为就像一个向量。实际上,它就像一个具有价格、车型年份、里程和发布日期的 4D 向量。它不是一个坐标向量,因为发布日期不是一个数字。尽管数据不是数字的,但该类满足向量空间属性(你可以在练习中的单元测试中验证这一点),因此其对象是向量,可以像这样操作。具体来说,它们是 4D 向量,因此可以在CarForSale对象和Vec4对象之间编写一对一的映射(这也是你的一个练习)。在我们的下一个例子中,我们将看到一些看起来更像坐标向量但仍然满足定义属性的对象。

6.2.3 将函数视为向量

结果表明,数学函数可以被看作是向量。具体来说,我指的是那些接受一个实数并返回一个实数的函数,尽管还有许多其他类型的数学函数。用数学简写来说,函数f接受任何实数并返回实数是f: ℝ: → ℝ。在 Python 中,我们将考虑接受float值并返回float值的函数。

就像 2D 或 3D 向量一样,我们可以通过直观或代数方法进行函数的加法和标量乘法。首先,我们可以代数地写出函数;例如,f(x) = 0.5 · x + 3 或 g(x) = sin(x)。或者,我们可以用图形来可视化这些函数。

在源代码中,我编写了一个简单的plot函数,该函数可以在指定的输入范围内绘制一个或多个函数的图形(图 6.4)。例如,以下代码在-10 到 10 的x值之间绘制了我们的两个函数f(x)和g(x):

def f(x):
    return 0.5 * *x* + 3
def *g*(*x*):
    return sin(*x*)
plot([f,g],−10,10)

图片

图 6.4 函数 f(x) = 0.5 · x + 3 和 g(x) = sin(x) 的图像

从代数上讲,我们可以通过相加定义它们的表达式来相加函数。这意味着 f + g 是一个由 (f + g)(x) = f(x) + g(x) = 0.5 · x + 3 + sin(x) 定义的函数。从图形上看,每个点的 y 值被相加,所以它就像将两个函数堆叠在一起,如图 6.5 所示。

图片

图 6.5 在图上可视化两个函数的和

要实现这个和,你可以编写一些功能性的 Python 代码。这段代码接受两个函数作为输入,并返回一个新的函数,即它们的和:

def add_functions(f,g):
    def new_function(*x*):
        return *f*(*x*) + *g*(*x*)
    return new_function

同样,我们可以通过将函数的表达式乘以标量来乘以一个函数。例如,3 g 定义为 (3 g)(x) = 3 · g(x) = 3 · sin(x)。这会使函数 gy 方向上拉伸 3 倍(如图 6.6 所示)。

图片

图 6.6 函数 (3g) 看起来就像函数 g 在 y 方向上拉伸了 3 倍。

可以将 Python 函数封装在一个继承自向量的类中,这留作你的练习。完成之后,你可以编写令人满意的函数算术表达式,如 3 · f 或 2 · f − 6 · g。你甚至可以使这个类 可调用 或接受参数,就像它是一个函数一样,允许表达式如 (f + g)(6)。不幸的是,为了确定函数是否满足向量空间属性,单元测试要困难得多,因为很难生成随机函数或测试两个函数是否相等。要真正知道两个函数是否相等,你必须知道它们对每个可能的输入都返回相同的输出。这意味着要对每个实数或至少每个 float 值进行测试!

这引出了另一个问题:函数向量空间的 维度 是什么?或者,更具体地说,需要多少个实数坐标才能唯一标识一个函数?

与将 Vec3 对象的坐标命名为 xyz 不同,你可以从 i = 1 到 3 对它们进行索引。同样,你也可以从 i = 1 到 15 对 Vec15 的坐标进行索引。然而,一个函数有无限多个定义它的数字;例如,任何 x 值的 f(x)。换句话说,你可以将 f 的坐标视为它在每个点的值,通过所有实数而不是前几个整数进行索引。这意味着函数向量空间是 无限维的。这有重要的含义,但它主要使得所有函数的向量空间难以处理。我们稍后会回到这个空间,具体来看一些更简单的子集。现在,让我们回到有限维度的舒适区,看看两个更多的例子。

6.2.4 将矩阵视为向量

因为一个 n × m 矩阵是一系列 n · m 个数字,尽管它们排列成一个矩形,但我们仍然可以将其视为一个 n · m 维向量。例如,5×3 矩阵的向量空间与 15D 坐标向量的向量空间之间的唯一区别是坐标以矩阵的形式呈现。我们仍然逐个坐标地相加和进行标量乘法。图 6.7 展示了这种加法是如何进行的。

图 6.7 通过相加对应项来加两个 5×3 矩阵

实现 5×3 矩阵的类继承自 Vector 比简单地实现一个 Vec15 类需要更多的输入,因为你需要两个循环来遍历矩阵。然而,算术运算并不比本列表中展示的更复杂。

列表 6.2 将 5×3 矩阵视为向量的类

class Matrix5_by_3(Vector):
    rows = 5                                          ❶
    columns = 3
    def __init__(self, matrix):
        self.matrix = matrix
    def add(self, other):
        return Matrix5_by_3(tuple(
            tuple(a + b for a,b in zip(row1, row2))
            for (row1, row2) in zip(self.matrix, other.matrix)
        ))
    def scale(self,scalar):
        return Matrix5_by_3(tuple(
            tuple(scalar * *x* for *x* in row)
            for row in self.matrix
        ))
    @classmethod
    def zero(cls):
        return Matrix5_by_3(tuple(                    ❷
            tuple(0 for j in range(0, cls.columns))
            for i in range(0, cls.rows)
        ))

❶ 你需要知道行数和列数才能构造零矩阵。

❷ 5×3 矩阵的零向量是一个由所有零组成的 5×3 矩阵。将这个向量加到任何其他 5×3 矩阵 M 上,结果仍然是 M。

你同样可以创建一个 Matrix2_by_2 类或一个 Matrix99_by_17 类来表示不同的向量空间。在这些情况下,大部分实现都是相同的,但维度将不再是 15,而是 2 × 2 = 4 或 99 × 17 = 1,683。作为一个练习,你可以创建一个继承自 VectorMatrix 类,包括所有数据,除了指定的行数和列数。然后任何 MatrixM_by_N 类都可以继承自 Matrix

矩阵的有趣之处不在于它们是排列成网格的数字,而在于我们可以将它们视为表示线性函数。我们已经看到数字列表和函数是向量空间的两种情况,但结果是矩阵在两种意义上都是向量。如果一个矩阵 an 行和 m 列,它表示从 m 维空间到 n 维空间的线性函数。(你可以用 a : ℝ^(m) → ℝ^(n) 来用数学简写表达同样的句子。)

正如我们添加和标量乘以从 ℝ → ℝ 的函数一样,我们也可以添加和标量乘以从 ℝ^(m) → ℝ^(n) 的函数。在本节末尾的小型项目中,你可以尝试运行矩阵的向量空间单元测试,以检查它们在两种意义上都是向量。这并不意味着数字网格本身没有用处;有时我们并不关心将它们解释为函数。例如,我们可以使用数字数组来表示图像。

6.2.5 使用向量运算操作图像

在计算机上,图像以称为 像素 的彩色方块的数组形式显示。典型的图像可以有几百像素高和几百像素宽。在彩色图像中,需要三个数字来指定任何给定像素的颜色(RGB)的红色、绿色和蓝色内容(图 6.8)。总共,一个 300×300 像素的图像由 300 · 300 · 3 = 270,000 个数字指定。当将这种大小的图像视为向量时,像素存在于一个 270,000 维的空间中!

图 6.8 将我的狗梅尔巴的图片放大,直到我们可以挑选出一个包含红色、绿色和蓝色内容(分别为 230、105、166)的像素

根据你阅读的格式,你可能看不到梅尔巴舌头的粉红色。但因为我们将在这次讨论中以数值而不是视觉方式表示颜色,所以一切应该仍然有意义。你还可以在本书的源代码中查看全彩图片。

Python 有一个事实上的标准图像处理库 PIL,它以 pillow 包名在 pip 中分发。你不需要学习很多关于这个库的知识,因为我们立即将我们对它的使用封装在一个新的类(列表 6.3)中。这个类 ImageVectorVector 继承,存储 300×300 图像的像素数据,并支持加法和标量乘法。

列表 6.3 表示图像为向量的类

from PIL import Image
class ImageVector(Vector):
    size = (300,300)                                                ❶
    def __init__(self,input):
        try:
            img = Image.open(input).\
                  resize(ImageVector.size)                          ❷
            self.pixels = img.getdata()
        except:
            self.pixels = input                                     ❸
    def image(self):
        img = Image.new('RGB', ImageVector.size)                    ❹
        img.putdata([(int(r), int(g), int(b)) 
                     for (r,g,b) in self.pixels])
        return img
    def add(self,img2):                                             ❺
        return ImageVector([(r1+r2,g1+g2,b1+b2) 
                            for ((r1,g1,b1),(r2,g2,b2)) 
                            in zip(self.pixels,img2.pixels)])
    def scale(self,scalar):                                         ❻
        return ImageVector([(scalar*r,scalar*g,scalar*b) 
                      for (r,g,b) in self.pixels])
    @classmethod
    def zero(cls):                                                  ❼
        total_pixels = cls.size[0] * cls.size[1]
        return ImageVector([(0,0,0) for _ in range(0,total_pixels)])
    def _repr_png_(self):                                           ❽
        return self.image()._repr_png_()

❶ 处理固定大小的图像:例如 300×300 像素

❷ 构造函数接受图像文件的名称。我们使用 PIL 创建一个 Image 对象,将其调整大小为 300×300,然后使用 getdata() 方法提取其像素列表。每个像素是一个由红色、绿色和蓝色值组成的元组。

❸ 构造函数还接受像素列表。

❹ 此方法返回由类上存储的像素重建的基本 PIL 图像。值必须转换为整数以创建可显示的图像。

❺ 通过为每个像素的相应红色、绿色和蓝色值执行向量加法来对图像执行向量加法

❻ 通过将每个像素的红色、绿色和蓝色值乘以给定的标量来执行标量乘法

❼ 零图像在任何像素处都没有红色、绿色或蓝色内容。

❽ Jupyter 笔记本可以显示 PIL 图像的内联,只要我们传递底层图像的 repr_png 函数的实现。

配备了这个库,我们可以通过文件名加载图像,并对图像执行向量运算。例如,两个图片的平均值可以按以下方式计算为线性组合,结果如图 6.9 所示:

0.5 * ImageVector("inside.JPG") + 0.5 * ImageVector("outside.JPG")

图 6.9 梅尔巴两张图像的平均值作为线性组合

虽然任何 ImageVector 都是有效的,但渲染为视觉上不同的最小和最大颜色值分别是 0 和 255。因此,任何导入的图像的负值将是黑色,因为每个像素的亮度都低于最小亮度。同样,正标量乘数很快就会变得苍白,因为大多数像素都超过了可显示的最大亮度。图 6.10 展示了这些特性。

图 6.10 图像的否定和标量乘法

要创建视觉上有趣的变化,您需要执行将您带入所有颜色正确亮度范围的运算。零向量(黑色)和所有值都等于 255 的向量(白色)是很好的参考点。例如,从图像中减去一个全白色图像的效果是反转颜色。如图 6.11 所示,对于以下白色向量

white = ImageVector([(255,255,255) for _ in range(0,300*300)])

减去一个图像会产生一个令人毛骨悚然的重新着色的图片。(即使你在黑白图片中查看,差异也应该很显著。)

图 6.11 通过从纯白色图像中减去图像来反转图像的颜色

向量代数显然是一个通用概念:加法和标量乘法的定义概念适用于数字、坐标向量、函数、矩阵、图像以及许多其他类型的对象。当我们将相同的数学应用于无关领域时,看到这样的视觉结果是非常引人注目的。我们将记住所有这些向量空间的例子,并继续探索它们之间可以做出的推广。

6.2.6 练习

| 练习 6.8:使用 uvw 的浮点值而不是从 Vector 类继承的对象来运行向量空间单元测试。这表明实数确实是向量。解决方案:使用随机标量作为向量、零向量作为零向量,以及 math.isclose 作为相等测试,100 次随机测试通过:

for i in range(0,100):
    a,b = random_scalar(), random_scalar()
    u,v,w = random_scalar(), random_scalar(), random_scalar()
    test(0, isclose, a,b,u,v,w)

|

| 练习 6.9-迷你项目:运行 CarForSale 的向量空间单元测试以显示其对象形成一个向量空间(忽略它们的文本属性)。解决方案:大部分工作是在生成随机数据并构建一个近似相等测试,如下所示,该测试处理日期和时间:

from math import isclose
from random import uniform, random, randint
from datetime import datetime, timedelta

def random_time():
    return CarForSale.retrieved_date − timedelta(days=uniform(0,10))

def approx_equal_time(t1, t2):
    test = datetime.now()
    return isclose((test-t1).total_seconds(), (test-t2).total_seconds())

def random_car():
    return CarForSale(randint(1990,2019), randint(0,250000), 
              27000\. * random(), random_time())

def approx_equal_car(c1,c2):
    return (isclose(c1.model_year,c2.model_year) 
            and isclose(c1.mileage,c2.mileage) 
            and isclose(c1.price, c2.price)
            and approx_equal_time(c1.posted_datetime, c2.posted_datetime))

for i in range(0,100):
    a,b = random_scalar(), random_scalar()
    u,v,w = random_car(), random_car(), random_car()
    test(CarForSale.zero(), approx_equal_car, a,b,u,v,w)

|

| 练习 6.10:实现一个名为 Function(Vector) 的类,该类将其构造函数的参数为一个单变量函数,并实现一个 __call__ 方法,以便您可以将其视为一个函数。您应该能够运行 plot([f,g,f+g,3*g],−10,10)解决方案

class Function(Vector):
    def __init__(self, f):
        self.function = f
    def add(self, other):
        return Function(lambda x: self.function(*x*) + other.function(*x*))
    def scale(self, scalar):
        return Function(lambda x: scalar * self.function(*x*))
    @classmethod
    def zero(cls):
        return Function(lambda x: 0)
    def __call__(self, arg):
        return self.function(arg)

f = Function(lambda x: 0.5 * *x* + 3)
g = Function(sin)

plot([f, g, f+g, 3*g], −10, 10)

最后行的结果是显示在这个图中:我们的对象 fg 的行为像向量,因此我们可以将它们相加并标量乘以它们。因为它们也像函数一样,我们可以绘制它们。|

| 练习 6.11-迷你项目:测试函数的等价性是困难的。尽力编写一个函数来测试两个函数是否相等。解答:因为我们通常对行为良好、连续的函数感兴趣,所以检查它们在几个随机输入值上的值是否接近可能就足够了,如下所示:

def approx_equal_function(f,g):
    results = []
    for _ in range(0,10):
        x = uniform(−10,10)
        results.append(isclose(f(x),g(x)))
    return all(results)

不幸的是,这可能会给我们带来误导性的结果。以下返回True,尽管函数不能等于零:

approx_equal_function(lambda x: (x*x)/x, lambda x: x)

结果表明,计算函数的等价性是一个不可解的问题。也就是说,已经证明没有算法可以保证任意两个函数是否相等。|

| 练习 6.12-迷你项目:对Function类进行单元测试,以证明函数满足向量空间的属性。解答:测试函数等价性很困难,生成随机函数也很困难。在这里,我使用了一个Polynomial类(你将在下一节中遇到)来生成一些随机的多项式函数。使用前一个迷你项目中的approx_equal_function,我们可以使测试通过:

def random_function():
    degree = randint(0,5)
    p = Polynomial(*[uniform(−10,10) for _ in range(0,degree)])
    return Function(lambda x: *p*(*x*))

for i in range(0,100):
    a,b = random_scalar(), random_scalar()
    u,v,w = random_function(), random_function(), random_function()
    test(Function.zero(), approx_equal_function, a,b,u,v,w)

|

| 练习 6.13-迷你项目:实现一个Function2(Vector)类,它存储一个像f(x, y) = x + y这样的两个变量的函数。解答:定义与Function类没有太大区别,但所有函数都给出了两个参数:

class Function(Vector):
    def __init__(self, f):
        self.function = f
    def add(self, other):
        return Function(lambda x,y: self.function(x,y) + other.function(x,y))
    def scale(self, scalar):
        return Function(lambda x,y: scalar * self.function(x,y))
    @classmethod
    def zero(cls):
        return Function(lambda x,y: 0)
    def __call__(self, *args):
        return self.function(*args)

例如,f(x, y) = x + yg(x, y) = xy +1 的和应该是 2x + 1。我们可以确认这一点:

>>> f = Function(lambda x,y:x+y)
>>> g = Function(lambda x,y: x-y+1)
>>> (f+g)(3,10)
7

|

| 练习 6.14:9×9 矩阵的向量空间的维度是多少?

  1. 9

  2. 18

  3. 27

  4. 81

解答:一个 9×9 矩阵有 81 个条目,所以有 81 个独立的数字(或坐标)来决定它。因此,它是一个 81 维的向量空间,答案d是正确的。|

| 练习 6.15-迷你项目:实现一个从Vector继承的Matrix类,具有表示行数和列数的抽象属性。你不应该能够实例化一个Matrix类,但你可以通过从Matrix继承并显式指定行数和列数来创建一个Matrix5_by_3类。解答

class Matrix(Vector):
    @abstractproperty
    def rows(self):
        pass
    @abstractproperty
    def columns(self):
        pass
    def __init__(self,entries):
        self.entries = entries
    def add(self,other):
        return self.__class__(
            tuple(
                tuple(self.entries[i][j] + other.entries[i][j]
                        for j in range(0,self.columns()))
                for i in range(0,self.rows())))
    def scale(self,scalar):
        return self.__class__(
            tuple(
                tuple(scalar * e for e in row) 
                for row in self.entries))
    def __repr__(self):
        return "%s%r" % (self.__class__.__qualname__, self.entries)
    def zero(self):
        return self.__class__(
            tuple(
                tuple(0 for i in range(0,self.columns())) 
                for j in range(0,self.rows())))

|

| 现在我们可以快速实现任何表示固定大小矩阵的向量空间类,例如,2×2:

class Matrix2_by_2(Matrix):
    def rows(self):
        return 2
    def columns(self):
        return 2  

然后,我们可以像向量一样使用 2×2 矩阵:

>>> 2 * Matrix2_by_2(((1,2),(3,4))) + Matrix2_by_2(((1,2),(3,4)))
Matrix2_by_2((3, 6), (9, 12))

|

| 练习 6.16:对Matrix5_by_3类进行单元测试,以证明它遵循向量空间的定义属性。解答

def random_matrix(rows, columns):
    return tuple(
        tuple(uniform(−10,10) for j in range(0,columns))
        for i in range(0,rows)
    )

def random_5_by_3():
    return Matrix5_by_3(random_matrix(5,3))

def approx_equal_matrix_5_by_3(m1,m2):
    return all([
        isclose(m1.matrix[i][j],m2.matrix[i][j]) 
        for j in range(0,3)
        for i in range(0,5)
    ])

for i in range(0,100):
    a,b = random_scalar(), random_scalar()
    u,v,w = random_5_by_3(), random_5_by_3(), random_5_by_3()
    test(Matrix5_by_3.zero(), approx_equal_matrix_5_by_3, a,b,u,v,w)

|

练习 6.17-迷你项目:编写一个从Vector继承的LinearMap3d_to_5d类,它使用 5×3 矩阵作为其数据,但实现__call__以作为从ℝ³到ℝ⁵的线性映射。证明它与Matrix5_by_3在底层计算上是一致的,并且它独立地通过了向量空间的定义属性。
练习 6.18-迷你项目:编写一个 Python 函数,使你能够将 Matrix5_by_3 对象与 Vec3 对象按矩阵乘法的方式进行乘法。更新向量类和矩阵类的 * 操作符的重载,以便你可以将向量左乘以标量或矩阵。
练习 6.19: 证明对于 ImageVector 类的零向量,当它被添加时不会明显改变任何图像。解决方案:对于你选择的任何图像,查看 ImageVector ("my_image.jpg") + ImageVector.zero() 的结果。

| 练习 6.20: 选择两张图像,并显示它们的 10 个不同加权平均值。这些将在 270,000 维空间中连接图像的线段上的点!解决方案:我运行了以下代码,其中 s = 0.1, 0.2, 0.3, ..., 0.9, 1.0:

s * ImageVector("inside.JPG") + (1-s) * ImageVector("outside.JPG")

当你并排放置你的图像时,你会得到类似这样的东西!多个图像的加权平均值两张图像的多个不同加权平均值

| 练习 6.21: 将向量空间单元测试适配到图像并运行它们。你的随机单元测试作为图像看起来是什么样子?解决方案:生成随机图像的一种方法是在每个像素处放置随机的红色、绿色和蓝色值,例如,

def random_image():
    return ImageVector([(randint(0,255), randint(0,255), randint(0,255))
                            for i in range(0,300 * 300)])

|

| 结果是模糊的一团糟,但这对我们来说无关紧要。单元测试比较每个像素。使用以下近似相等测试,我们可以运行测试:

def approx_equal_image(i1,i2):
    return all([isclose(c1,c2)
        for p1,p2 in zip(i1.pixels,i2.pixels)
        for c1,c2 in zip(p1,p2)])

for i in range(0,100):
    a,b = random_scalar(), random_scalar()
    u,v,w = random_image(), random_image(), random_image()
    test(ImageVector.zero(), approx_equal_image, a,b,u,v,w)

|

6.3 寻找更小的向量空间

300×300 彩色图像的向量空间有 270,000 维,这意味着我们需要列出这么多数字来指定任何这种大小的图像。这本身并不是一个问题的数据量,但当我们有更大的图像、大量的图像,或者将成千上万的图像连接起来制作电影时,数据量就会累积起来。

在本节中,我们探讨如何从一个向量空间开始,找到更小的空间(具有更少的维度),同时保留原始空间中的大部分有趣数据。对于图像,我们可以减少图像中使用的不同像素的数量,或者将其转换为黑白。结果可能不美丽,但仍然可以辨认。例如,图 6.12 右侧的图像需要 900 个数字来指定,而左侧的图像需要 270,000 个数字来指定。

图 6.12 将由 270,000 个数字指定的图像(左侧)转换为由 900 个数字指定的另一个图像(右侧)

看起来像右侧的图片生活在 270,000 维空间的 900 维 子空间 中。这意味着它们仍然是 270,000 维的图像向量,但可以用只有 900 个坐标来表示或存储。这是研究 压缩 的起点。我们不会深入探讨压缩的最佳实践,但我们会仔细研究向量空间的子空间。

6.3.1 识别子空间

向量子空间,或简称子空间,正如其名所示:存在于另一个向量空间内部的向量空间。我们已经多次看过的例子是 3D 空间内的 2D x, y平面,即z = 0 的平面。具体来说,这个子空间由形式为(x, y, 0)的向量组成。这些向量有三个分量,因此它们是真正的 3D 向量,但它们形成了一个恰好位于平面上的子集。因此,我们说这是ℝ³的 2D 子空间。

注意:为了避免过于繁琐,由有序对(x, y)组成的 2D 向量空间ℝ²,在技术上不是 3D 空间ℝ³的子空间。这是因为形式为(x, y)的向量不是 3D 向量。然而,它与向量(x, y, 0)的集合一一对应,向量代数在是否有额外的零z坐标的情况下看起来都一样。因此,我认为称ℝ²为ℝ³的子空间是可以的。

并非 3D 向量的每个子集都是子空间。z = 0 的平面是特殊的,因为向量(x, y, 0)形成了一个自包含的向量空间。在这个平面上,无法构建向量的线性组合,使其“逃离”这个平面;第三个坐标始终为零。在数学术语中,精确地说一个子空间是自包含的,可以说它是线性组合下的封闭

为了了解向量子空间在一般情况下是什么样的,让我们寻找向量空间的子集,这些子集也是子空间(见图 6.13)。平面上哪些向量的子集可以构成一个独立的向量空间?我们能否仅仅在平面上画任何区域,然后只取其内部的向量?

图片

图 6.13 S 是平面ℝ²中点的(向量)子集。S 是ℝ²的子空间吗?

答案是否定的:图 6.13 中的子集包含一些位于 x 轴上的向量和一些位于 y 轴上的向量。这些向量分别可以被缩放,给我们标准的基向量e[1] = (1, 0)和e[2] = (0, 1)。从这些向量中,我们可以通过线性组合得到平面上的任何点,而不仅仅是S(见图 6.14)。

图片

图 6.14 S中两个向量的线性组合为我们提供了一个“逃离”S的途径。它不能是平面的子空间。

而不是画一个随机的子空间,让我们模仿 3D 空间中平面的例子。没有z坐标,所以我们选择y = 0 的点。这留下了 x 轴上的点,形式为(x, 0)。无论我们如何努力,我们都无法找到这种形式的向量的线性组合,它们具有非零的y坐标(见图 6.15)。

图片

图 6.15 关注 y = 0 的直线。这是一个向量空间,包含其所有点的线性组合。

这条线,y = 0,是 ℝ² 的向量子空间。正如我们最初在三维空间中找到一个二维子空间一样,我们也找到了二维空间中的一个一维子空间。与三维 空间 或二维 平面 不同,这样的 1 维向量空间被称为 线。实际上,我们可以将这个子空间识别为实数线 ℝ。

下一步可以是设置 x = 0。一旦我们将 x = 0 和 y = 0 都设置为 0,就只剩下一个点:零向量。这同样是一个向量子空间!无论你如何取零向量的线性组合,结果都是零向量。这是 1 维线、2 维平面和 3 维空间的零维子空间。从几何上看,零维子空间是一个点,而这个点必须是零。如果它是其他点,例如 v,它也会包含 0 · v = 0 和无数其他不同的标量倍数,如 3 · v 和 −42 · v。让我们继续这个想法。

6.3.2 从单个向量开始

包含非零向量 v 的向量子空间包含(至少)v 的所有标量倍数。从几何上看,非零向量 v 的所有标量倍数都位于原点的一条线上,如图 6.16 所示。

图片

图 6.16 两条不同的向量,用虚线表示它们所有标量倍数的位置。

通过原点的每条线都是一个向量空间。无法通过添加或缩放该空间内的向量来逃离任何这样的线。这在三维空间中通过原点的线也是成立的:它们都是单个三维向量的线性组合,并形成一个向量空间。这是构建子空间的一般方法的第一例:选择一个向量,并查看所有必须与之一起出现的线性组合。

6.3.3 生成更大的空间

给定一组一个或多个向量,它们的 span 被定义为所有线性组合的集合。span 的重要之处在于它自动是一个向量子空间。为了重新表述我们刚才发现的,单个向量 v 的 span 是通过原点的一条线。我们通过在大括号中包含对象来表示一组对象,因此只包含 v 的集合是 {v},这个集合的 span 可以写成 span({v})。

一旦我们包含另一个向量 w,它不与 v 平行,空间就会变大,因为我们不再局限于单一线性方向。由两个向量 {v, w} 生成的空间包括两条线,即 span({v}) 和 span({w}),以及包含 vw 的线性组合,这些组合既不在任何一条线上(见图 6.17)。

图片

图 6.17 两条非平行向量的空间。每个向量单独生成一条线,但它们一起生成更多的点,例如,v + w 不在任何一条线上。

虽然可能不太明显,但这两个向量的张成空间是整个平面。这在平面上任何一对非平行向量中都是成立的,但对于标准基向量来说尤为显著。任何点(x, y)都可以表示为线性组合x · (1, 0) + y · (0, 1)。对于其他非平行向量对,如v = (1, 0)和w = (1, 1),也是如此,但需要更多的代数运算来证明。

你可以通过(1, 0)和(1, 1)的适当线性组合得到任何点,如(4, 3)。要得到 3 的y坐标,需要三个向量(1, 1)。那就是(3, 3)而不是(4, 3),所以可以通过添加一个单位的(1, 0)来纠正x坐标。这样我们就得到了线性组合 3 · (1, 1) + 1 · (1, 0),它将我们带到了图 6.18 中显示的点(4, 3)。

图片

图 6.18 通过(1, 0)和(1, 1)的线性组合得到任意点(4, 3)

单个非零向量在 2D 或 3D 中张成一条线,而且,两个非平行向量可以张成整个 2D 平面或在 3D 空间中通过原点的平面。由两个 3D 向量张成的平面可能看起来像图 6.19 中所示的那样。

图片

图 6.19 由两个 3D 向量张成的平面

它是斜的,所以它看起来不像z = 0 的平面,它也不包含三个标准基向量中的任何一个。但仍然是一个平面,是 3D 空间的一个向量子空间。一个向量张成 1D 空间,两个非平行向量张成 2D 空间。如果我们向其中添加一个第三个非平行向量,这三个向量是否张成 3D 空间?图 6.20 清楚地表明答案是否定的。

图片

图 6.20 只能张成 2D 空间的三个非平行向量

向量uvw的任意一对都不平行,但这些向量并不张成 3D 空间。它们都位于 2D 平面上,因此它们的任意线性组合都不能张成 3D 空间。

神奇地获得一个z坐标。我们需要对“非平行”向量的概念进行更好的推广。

如果我们要将一个向量添加到集合中并生成一个更高维度的空间,新的向量需要指向一个不在现有向量张成空间中的新方向。在平面上,三个向量总是存在一些冗余。例如,如图 6.21 所示,uw的线性组合给出了v

图片

图 6.21 zw的线性组合返回v,因此uvw的张成空间不应大于zw的张成空间。

“非平行”的正确推广是线性无关。如果一个向量集合中的任何一个向量都可以表示为其他向量的线性组合,那么这个向量集合就是线性相关的。两个平行的向量是线性相关的,因为它们是彼此的标量倍数。同样,向量集合{u, v, w}是线性相关的,因为我们可以用uw的线性组合来得到v(或者用w的线性组合来得到uv,以此类推)。你应该确保自己对这个概念有深刻的理解。作为本节末尾的一个练习,你可以检查一下三个向量(1, 0),(1, 1)和(−1, 1)中的任何一个都可以写成另外两个向量的线性组合。

相比之下,集合{u, v}是线性无关的,因为它们的分量非平行,不能是彼此的标量倍数。这意味着uv张成的空间比它们各自单独张成的空间要大。同样,标准基{e[1], e[2], e[3] }对于ℝ³是一个线性无关的集合。这些向量中的任何一个都不能由其他两个向量构建,而且需要所有三个向量来张成三维空间。我们开始接触到向量空间或子空间的性质,这些性质表明了它的维度。

6.3.4 定义维度

这是一个激励人心的问题:以下这组三维向量是否线性无关?

{(1, 1, 1), (2, 0, −3), (0, 0, 1), (−1, −2, 0)}

要回答这个问题,你可以在三维空间中绘制这些向量,或者尝试找到三个向量的线性组合来得到第四个向量。但有一个更简单的答案:只需要三个向量就可以张成整个三维空间,所以任何包含四个三维向量的列表都必须有一些冗余。

我们知道,一个包含一个或两个三维向量的集合将分别张成一条线或一个平面,而不是整个ℝ³。三个向量是魔法数字,既可以张成三维空间,又可以保持线性无关。这正是我们称之为三维的原因:毕竟有三个独立的方向。

一个张成整个向量空间(如ℝ³的{e[1], e[2], e[3] })的线性无关向量集合被称为。任何空间的一个基都有相同数量的向量,这个数量就是它的维度。例如,我们看到了(1, 0)和(1, 1)是线性无关的,并且张成了整个平面,因此它们是向量空间ℝ²的一个基。同样,(1, 0, 0)和(0, 1, 0)是线性无关的,并且张成了ℝ³中z = 0 的平面。这使得它们成为这个二维子空间的基,尽管不是整个ℝ³的基。

我已经在“标准基”的上下文中使用了“基”这个词,对于 ℝ² 和 ℝ³。它们被称为“标准”,因为它们是如此自然的选择。在标准基中分解坐标向量不需要计算;坐标 就是 这个分解中的标量。例如,(3, 2) 表示线性组合 3 · (1, 0) + 2 · (0, 1) 或 3e[1] + 2e[2]。

通常,判断向量是否线性无关需要一些工作。即使你知道一个向量是某些其他向量的线性组合,找到这个线性组合也需要做一些代数运算。在下一章中,我们将介绍如何做到这一点;这最终成为线性代数中的一个普遍计算问题。但在那之前,让我们再练习一下识别子空间和测量它们的维度。

6.3.5 寻找函数向量空间的子空间

从 ℝ 到 ℝ 的数学函数包含无限多的数据,即当它们被给以无限多个实数作为输入时的输出值。但这并不意味着描述一个函数需要无限多的数据。例如,线性函数只需要两个实数。它们是这个通用公式中 ab 的值,你可能已经见过:

f(x) = ax + b

其中 ab 可以是任何实数。这比所有函数的无限维空间更容易处理。任何线性函数都可以由两个实数指定,所以看起来线性函数的子空间将是 2D。

注意:我在过去几章中使用了“线性”这个词的许多新上下文。在这里,我回到了你在高中代数中使用的含义:一个 线性函数 是其图形是一条直线的函数。不幸的是,这种形式的函数在我们花了整个第四章讨论的意义上不是线性的,你可以在练习中自己证明这一点。因此,我将尽力在任何时候都清楚地说明我使用的“线性”这个词的含义。

我们可以快速实现一个继承自 VectorLinearFunction 类。它不是持有函数作为其底层数据,而是可以持有两个数字作为系数 ab。我们可以通过相加系数来添加这些函数,因为

(ax + b) + (cx + d) = (ax + cx) + (b + d) = (a + c)x + (b + d)

我们可以通过将两个系数乘以标量来缩放函数:r(ax + b) = rax + rb。最后,结果证明零函数 f(x) = 0 也是线性的。这是 a = b = 0 的情况。以下是实现方法:

class LinearFunction(Vector):
    def __init__(self,a,b):
        self.a = a
        self.b = b
    def add(self,v):
        return LinearFunction(self.a + v.a, self.b + v.b)
    def scale(self,scalar):
        return LinearFunction(scalar * self.a, scalar * self.b)
    def __call__(self,x):
        return self.a * *x* + self.b
    @classmethod
    def zero(cls):
        return LinearFunction(0,0,0)

如图 6.22 所示,结果是线性函数 plot([LinearFunction (−2,2)],−5,5) 给出了 f(x) = −2x + 2 的直线图。

图片

图 6.22 代表 f(x) = −2x + 2 的 LinearFunction(−2,2) 的图形

我们可以通过写一个基来证明线性函数形成了一个维度为 2 的向量子空间。基向量应该是函数,它们应该覆盖整个线性函数空间,并且应该是线性无关的(不是彼此的倍数)。这样的集合是{x, 1},或者更具体地说,是{f(x) = x, g(x) = 1}。这样命名,形式为ax + b的函数可以写成线性组合a · f + b · g

这是我们能接近线性函数标准基的最接近的方法;f(x) = xf(x) = 1 是明显不同的函数,不是彼此的标量倍数。相比之下,f(x) = xh(x) = 4x是彼此的标量倍数,不会是一个线性无关的对。但是{x, 1}不是我们唯一能选择的基;{4 x + 1, x − 3}也是一个基。

同样的概念适用于形式为f(x) = ax² + bx + c二次函数。这些构成了函数向量空间中的一个 3 维子空间,其中一个基的选择是{x², x, 1}。线性函数构成了一个向量子空间,其中二次函数的x²分量是零。线性函数和二次函数是多项式函数的例子,它们是x的幂的线性组合;例如,

f(x) = a[0] + a[1] x + a[2] x² + ... + a[n] x^n

线性函数和二次函数分别具有次数1 和 2,因为每个函数中出现的最高次幂是x。前面方程中写的多项式具有n次幂,总共有n + 1 个系数。在练习中,你会看到任何次数的多项式空间构成了函数空间中的另一个向量子空间。

6.3.6 图像子空间

由于我们的ImageVector对象由 270,000 个数字表示,我们可以遵循标准基公式并构建一个由 270,000 个图像组成的基,每个图像中有一个 270,000 个数字等于 1,其余都等于 0。列表显示了第一个基向量将看起来是什么样子。

列表 6.4 构建第一个标准基向量的伪代码

ImageVector([
    (1,0,0), (0,0,0), (0,0,0), ..., (0,0,0),  ❶
    (0,0,0), (0,0,0), (0,0,0), ..., (0,0,0),  ❷
    ...                                       ❸
])

❶ 只有第一行的第一个像素是非零的:它有一个红色值为 1。所有其他像素的值为(0,0,0)。

❷ 第二行由 300 个黑色像素组成,每个像素的值为(0,0,0)。

❸ 我跳过了接下来的 298 行,但它们都与第 2 行相同;没有任何像素有任何颜色值。

这个单个向量覆盖了一个一维子空间,其中图像除了左上角有一个红色像素外都是黑色的。这个图像的标量倍数在这个位置可以有更亮或更暗的红色像素,但其他像素不能被照亮。为了显示更多像素,我们需要更多的基向量。

从列出这些 270,000 个基向量中学习的东西并不多。相反,让我们寻找一组可以覆盖有趣子空间的向量。这里有一个由每个位置上的深灰色像素组成的单个ImageVector

gray = ImageVector([
    (1,1,1), (1,1,1), (1,1,1), ..., (1,1,1),
    (1,1,1), (1,1,1), (1,1,1), ..., (1,1,1),
    ...
])

更简洁地说,我们可以这样写:

gray = ImageVector([(1,1,1) for _ in range(0,300*300)])

想象由单个向量灰度生成的子空间的一种方法是通过查看属于它的某些向量。图 6.23 显示了灰度的标量倍数。

图片

图 6.23 由ImageVector的灰度实例生成的 1D 子空间中的一些向量。

这组图像在通俗意义上是“一维”的。它们只有一个属性在变化,那就是亮度。

另一种看待这个子空间的方法是考虑像素值。在这个子空间中,任何图像在每个像素处的值都相同。对于任何给定的像素,有一个由红、绿、蓝坐标测量的 3D 颜色可能性空间。灰度像素形成这个空间的 1D 子空间,包含所有坐标为s · (1, 1, 1)的点,其中s是一个标量(图 6.24)。

图片

图 6.24 一条线上的不同亮度的灰度像素。灰度像素形成像素值 3D 向量空间的 1D 子空间。

基础中的每个图像都会是黑色,除了一个像素会是非常暗的红色、绿色或蓝色。一次改变一个像素不会产生显著的结果,所以让我们寻找更小、更有趣的子空间。

你可以探索许多图像的子空间。你可以查看任何颜色的纯色图像。这些图像的形式如下:

ImageVector([
    (r,g,b), (r,g,b), (r,g,b), ..., (r,g,b),
    (r,g,b), (r,g,b), (r,g,b), ..., (r,g,b),
    ...
])

像素本身没有约束;纯色图像的唯一约束是每个像素都相同。作为一个最终的例子,你可以考虑由图 6.25 所示的类似低分辨率、灰度图像组成的子空间。

图片

图 6.25 一个低分辨率的灰度图像。每个 10×10 像素块具有相同的值。

每个像素块在其像素之间具有恒定的灰度值,使其看起来像一个 30×30 的网格。定义这个图像的只有 30×30=900 个数字,所以像这样的图像定义了图像 270,000 维空间中的 900 维子空间。数据量少得多,但仍然可以创建可识别的图像。

在这个子空间中创建图像的一种方法是从任何图像开始,并平均每个 10×10 像素块中的所有红、绿和蓝色值。这个平均值给出了亮度b,你可以将块中的所有像素设置为(b, b, b)来构建你的新图像。这实际上是一个线性映射(图 6.26),你可以将其作为迷你项目稍后实现。

图片

图 6.26 线性映射将任何图像(左侧)转换为一个新图像(右侧),该图像位于 900 维子空间中。

我家的狗,梅尔巴,在第二张照片中并不那么上镜,但照片仍然可以辨认。这就是我在本节开头提到的例子,令人惊奇的是,你只需用 0.3% 的数据就能判断出这是同一张照片。显然还有改进的空间,但将映射到子空间的方法是更深入探索的起点。在第十三章中,我们将看到如何以这种方式压缩音频数据。

6.3.7 练习题

练习 6.22: 给出几何论证,说明以下平面区域 S 为什么不能成为平面的向量子空间。图片解答: 这个区域中许多点的线性组合最终不会落在该区域内。更明显的是,这个区域不能成为向量空间,因为它不包含零向量。零向量是任何向量的标量倍数(通过标量零),因此它必须包含在任何向量空间或子空间中。
练习 6.23: 证明平面上 x = 0 的区域形成一个 1D 向量空间。解答: 这些是位于 y 轴上的向量,形式为 (0, y),其中 y 是实数。形式为 (0, y) 的向量的加法和标量乘法与实数相同;只是碰巧有一个额外的 0 一起出现。我们可以得出结论,这实际上是 ℝ 的伪装,因此是一个 1D 向量空间。如果你想要更严谨,你可以明确检查所有向量空间性质。
练习 6.24: 通过将每个向量表示为其他两个向量的线性组合,证明三个向量 (1, 0), (1, 1), 和 (−1, 1) 是线性相关的。解答:(1, 0) = ½ · (1, 1) − ½ · (−1, 1)(1, 1) = 2 · (1, 0) + (−1, 1)(−1, 1) = (1, 1) − 2 · (1, 0)
练习 6.25: 证明你可以将任意向量 (x, y) 表示为 (1, 0) 和 (1, 1) 的线性组合。解答: 我们知道 (1, 0) 不能对 y-坐标做出贡献,因此我们需要 y 倍的 (1, 1) 作为线性组合的一部分。为了使代数运算成立,我们需要 (xy) 单位的 (1, 0):(x, y) = (xy) · (1, 0) + y(1, 1)
练习 6.26: 给定一个单个向量 v,解释为什么所有 v 的线性组合的集合与所有 v 的标量倍数的集合相同。解答: 向量的线性组合和自身根据向量空间的一条法则简化为标量倍数。例如,线性组合 a · v + b · v 等于 (a + b) · v
练习 6.27: 从几何角度解释为什么不通过原点的直线不是向量子空间(平面或三维空间的子空间)。解答: 这不能成为子空间的一个简单原因是它不包含原点(零向量)。另一个原因是这样的直线将有两个非平行向量。它们的生成空间将是整个平面,这比直线大得多。
练习 6.28: {e[1], e[2], e[3] } 中的任意两个向量都无法张成整个 ℝ³,而是会张成 3D 空间中的 2D 子空间。这些子空间是什么?解答: 集合 {e[1], e[2] } 的张成由所有线性组合 a · e[1] + b · e[2] 组成,或者 a · (1, 0, 0) + b · (0, 1, 0) = (a, b, 0)。根据 ab 的选择,这可以是平面上 z = 0 的任意一点,通常称为 x,y 平面。通过同样的论证,向量 {e[2], e[3] } 张成 x = 0 的平面,称为 y,z 平面,而向量 {e[1], e[3] } 张成 y = 0 的平面,称为 x,z 平面。
练习 6.29: 将向量 (−5, 4) 写成 (0, 3) 和 (−2, 1) 的线性组合。解答: 只有 (−2, 1) 可以对 x 坐标做出贡献,因此我们需要在和中包含 2.5 · (−2, 1)。这使我们得到 (−5, 2.5),因此我们需要在 x 坐标上额外 1.5 个单位或 0.5 · (0, 3)。线性组合是(−5, 4) = 0.5 · (0, 3) + 2.5 · (−2, 1)
练习 6.30-迷你项目: 向量 (1, 2, 0), (5, 0, 5), 和 (2, −6, 5) 是线性无关还是线性相关?解答: 找到这一点并不容易,但前两个向量的一个线性组合可以得到第三个向量:−3 · (1, 2, 0) + (5, 0, 5) = (2, −6, 5)。这意味着第三个向量是多余的,向量线性相关的。它们只张成 3D 空间中的 2D 子空间,而不是整个 3D 空间。
练习 6.31: 解释为什么线性函数 f(x) = ax + b 除非 b = 0,否则不是从向量空间 ℝ 到自身的线性映射。解答: 我们可以直接转向定义:线性映射必须保持线性组合。我们看到 f 并不保持实数的线性组合。例如,f(1+1) = 2a + b,而 f(1) + f(1) = (a + b) + (a + b) = 2a + 2b。除非 b = 0,否则这不会成立。作为另一种解释,我们知道线性函数 ℝ: → ℝ 应该可以表示为 1x1 矩阵。1D 列向量 [x ] 与 1x1 矩阵 [ a ] 的矩阵乘法给出 [ ax ]。这是一个不寻常的矩阵乘法情况,但第五章中的实现确认了这一结果。如果一个函数 ℝ: → ℝ 将要成为线性函数,它必须与 1x1 矩阵乘法一致,因此必须是乘以一个标量。

| 练习 6.32: 通过继承 Vec2 类并实现 __call__ 方法来重建 LinearFunction 类。解答: Vec2 的数据称为 xy 而不是 ab;否则,功能相同。你只需要实现 __call__ 方法:

class LinearFunction(Vec2):
    def __call__(self,input):
        return self.x * input + self.y

|

练习 6.33: 证明(代数地!)形式为 f(x) = ax + b 的线性函数构成了所有函数向量空间的一个向量子空间。解答:为了证明这一点,你需要确保两个线性函数的线性组合仍然是另一个线性函数。如果 f(x) = ax + bg(x) = cx + d,那么 r · f + s · g 返回 r · f + s · g = r · (ax + b) + s · (cx + d) = rax + b + scx + d = (ra + sc) · x + (b + d)因为 (ra + sc) 和 (b + d) 是标量,这符合我们想要的形式。我们可以得出结论,线性函数在线性组合下是封闭的,因此它们构成一个子空间。
练习 6.34: 找到一个 3x3 矩阵集合的基。这个向量空间的维数是多少?解答:这里有一个由九个 3x3 矩阵组成的基!它们是线性无关的;每个矩阵都对任何线性组合贡献了一个独特的项。它们也张成了这个空间,因为任何矩阵都可以被表示为这些矩阵的线性组合;任何特定矩阵的系数决定了结果中的一个项。因为这九个向量提供了 3x3 矩阵空间的基,所以这个空间有九个维度。

| 练习 6.35-迷你项目:实现一个类 QuadraticFunction(Vector),它表示形式为 ax² + bx + c 的函数向量子空间。这个子空间的基是什么?解答:这个实现看起来很像 LinearFunction,除了有三个系数而不是两个,并且 __call__ 函数有一个平方项:

class QuadraticFunction(Vector):
    def __init__(self,a,b,c):
        self.a = a
        self.b = b
        self.*c* = c
    def add(self,v):
        return QuadraticFunction(self.a + v.a, 
                                 self.b + v.b, 
                                 self.c + v.c)
    def scale(self,scalar):
        return QuadraticFunction(scalar * self.a, 
                                 scalar * self.b, 
                                 scalar * self.c)
    def __call__(self,x):
        return self.a * *x* * *x* + self.b * *x* + self.c
    @classmethod
    def zero(cls):
        return QuadraticFunction(0,0,0)

|

我们可以注意到 ax² + bx + c 看起来像是集合 {x², x, 1} 的线性组合。确实,这三个函数张成了这个空间,而且这三个函数中的任何一个都不能被表示为其他函数的线性组合。例如,通过将线性函数相加无法得到 x² 项。因此,这是一个基。因为有三个向量,我们可以得出结论,这是函数空间的 3D 子空间。
练习 6.36-迷你项目:我声称 {4 x + 1, x − 2} 是线性函数集合的一个基。证明你可以将 −2x + 5 表示为这两个函数的线性组合。解答:(( \frac{1}{9} )) · (4x + 1) − (( \frac{22}{9} )) · (x − 2) = −2x + 5。如果你的代数技能不是很生疏,你可以手动解决这个问题。否则,不用担心;我们将在下一章中介绍如何解决这类难题。

| 练习 6.37-迷你项目:所有多项式的向量空间是一个无限维的子空间。实现这个向量空间作为一个类,并描述一个基(这必须是一个无限集!)解答

class Polynomial(Vector):
    def __init__(self, *coefficients):
        self.coefficients = coefficients
    def __call__(self,x):
        return sum(coefficient * *x* ** power 
                   for (power,coefficient) 
                   in enumerate(self.coefficients))
    def add(self,p):
        return Polynomial([a + b 
                          for a,b 
                          in zip(self.coefficients, 
                                 p.coefficients)])
    def scale(self,scalar):
        return Polynomial([scalar * a  
                           for a in self.coefficients])
        return "$ %s $" % (" + ".join(monomials))
    @classmethod
    def zero(cls):
        return Polynomial(0)

所有多项式的集合的基是无限集 {1, x, x², x³, x⁴, ...}。给定所有可用的 x 的幂,你可以将任何多项式表示为线性组合。|

| 练习 6.38:我向你展示了 270,000 维图像空间的基向量的伪代码。第二个基向量看起来会是什么样子?解答:第二个基向量可以通过在下一个可能的位置放置一个 1 来给出。它将在图像的非常左上角产生一个深绿色像素:

ImageVector([
    (0,1,0), (0,0,0), (0,0,0), ..., (0,0,0),  ❶
    (0,0,0), (0,0,0), (0,0,0), ..., (0,0,0),  ❷
    ...
])

❶ 对于第二个基向量,1 已经移动到第二个可能的位置。❷ 所有其他行保持为空 |

| 练习 6.39:编写一个函数solid_color(r,g,b),该函数返回一个具有给定红色、绿色和蓝色内容的每个像素的纯色ImageVector解答

def solid_color(r,g,b):
    return ImageVector([(r,g,b) for _ in range(0,300*300)])

|

| 练习 6.40-迷你项目:编写一个线性映射,它从 30×30 的灰度图像生成一个ImageVector,该图像实现为一个 30×30 的亮度值矩阵。然后,实现一个线性映射,它将 300×300 的图像转换为 30×30 的灰度图像,通过在每个像素处平均亮度(红色、绿色和蓝色的平均值)来实现。解答

image_size = (300,300)
total_pixels = image_size[0] * image_size[1]
square_count = 30                                ❶
square_width = 10

def ij(n):
    return (n // image_size[0], n % image_size[1])

def to_lowres_grayscale(img):                    ❷

    matrix = [
        [0 for i in range(0,square_count)]
        for j in range(0,square_count)
    ]
    for (n,p) in enumerate(img.pixels):
        i,j = ij(n)
        weight = 1.0 / (3 * square_width * square_width)
        matrix[i // square_width][ j // square_width] += (sum(p) * weight)
    return matrix
def from_lowres_grayscale(matrix):            ❸
    def lowres(pixels, ij):
        i,j = ij
        return pixels[i // square_width][ j // square_width]
    def make_highres(limg):
        pixels = list(matrix)
        triple = lambda x: (x,x,x)
        return ImageVector([triple(lowres(matrix, ij(n))) for n in range(0,total_pixels)])
    return make_highres(matrix)

❶ 表示我们将图片分割成 30×30 的网格❷ 函数接受一个 ImageVector 并返回一个包含 30 个 30 值数组的数组,每个值代表一个灰度值,按平方排列。❸ 第二个函数接受一个 30×30 的矩阵,并返回一个由 10×10 像素块组成的图像,亮度由矩阵值给出。调用from_lowres_grayscale(to_lowres_grayscale(img))将图像img转换为我在章节中展示的方式。|

摘要

  • 向量空间是二维平面和三维空间的一般化:一组可以加法和标量乘法的对象。这些加法和标量乘法操作必须以某种方式表现(在 6.1.5 节中列出),以模仿在二维和三维中更熟悉的操作。

  • 你可以通过在 Python 中将不同数据类型的共同特性拉入一个抽象基类并从中继承来泛化。

  • 你可以在 Python 中重载算术运算符,以便无论使用什么类型的向量,代码中的向量数学看起来都一样。

  • 加法和标量乘法需要以某种方式表现,以符合你的直觉,你可以通过编写涉及随机向量的单元测试来验证这些行为。

  • 实际对象,如二手车,可以用几个数字(坐标)来描述,因此可以被视为向量。这使得我们可以考虑像“两辆车的加权平均值”这样的抽象概念。

  • 函数可以被看作是向量。你可以通过添加或乘以定义它们的表达式来添加或乘以它们。

  • 矩阵可以被看作是向量。一个m × n矩阵的条目可以被看作是一个(m · n)-维向量的坐标。添加或标量乘以矩阵的效果与添加或标量乘以它们定义的线性函数的效果相同。

  • 固定高度和宽度的图像组成一个向量空间。它们在每个像素处由红色、绿色和蓝色(RGB)值定义,因此坐标的数量以及空间的维度由像素数量的三倍定义。

  • 向量空间的一个子空间是向量空间中向量的一个子集,它本身也是一个向量空间。也就是说,子空间中向量的线性组合仍然保持在子空间内。

  • 对于通过原点的任意二维或三维直线,位于其上的向量集形成一个一维子空间。对于通过原点的任意三维平面,位于其上的向量形成一个二维子空间。

  • 向量集的张成是指所有向量的线性组合的集合。它保证是向量所在空间的子空间。

  • 如果你不能将任何一个向量表示为其他向量的线性组合,那么这个向量集是线性独立的。否则,该集合是线性相关的。一个能够张成向量空间(或子空间)的线性独立向量集被称为该空间的。对于给定的空间,任何基都将包含相同数量的向量。这个数量定义了空间的维度。

  • 当你可以将你的数据视为存在于向量空间中时,子空间通常由具有相似属性的数据组成。例如,由纯色图像向量组成的子集形成一个子空间。


  1. 也就是说,只要你能保证你的向量空间只有有限多个维度!存在一个称为ℝ∞的向量空间,但它并不是唯一的无穷维向量空间。

7 解决线性方程组

本章涵盖

  • 在 2D 视频游戏中检测物体碰撞

  • 编写方程来表示直线,并找到平面上直线的交点

  • 在 3D 或更高维度中描绘和解决线性方程组

  • 将向量重写为其他向量的线性组合

当你想到代数时,你可能想到需要“解出 x”的问题。例如,你可能花了相当多的时间在代数课上学习如何解方程 3x² + 2x + 4 = 0;也就是说,找出哪些 x 的值可以使方程成立。

线性代数作为代数的一个分支,具有相同类型的计算问题。不同之处在于你想要解决的问题可能是一个向量或矩阵,而不是一个数字。如果你参加传统的线性代数课程,你可能会覆盖很多算法来解决这类问题。但因为你手头有 Python,你只需要知道如何识别你面临的问题,并选择合适的库来为你找到答案。

我将介绍你在现实世界中会遇到的最重要一类线性代数问题:线性方程组。这些问题归结为找到直线、平面或其更高维的类似物相交的点。一个例子是臭名昭著的高中数学问题,涉及两列火车在不同时间和速度下从波士顿和纽约出发。但由于我不假设你对铁路运营感兴趣,我将使用一个更有趣的例子。

在本章中,我们构建了一个经典的《小行星》街机游戏的简单重制(图 7.1)。在这个游戏中,玩家控制一个代表宇宙飞船的三角形,并向漂浮在其周围的代表小行星的多边形发射激光。玩家必须摧毁小行星,以防止它们撞击并摧毁宇宙飞船。

图 7.1 经典《小行星》街机游戏的设置

游戏中的关键机制之一是判断激光是否击中了一颗小行星。这需要我们弄清楚定义激光束的直线是否与勾勒小行星的线段相交。如果这些直线相交,小行星将被摧毁。我们首先设置游戏,然后我们将看到如何解决背后的线性代数问题。

在我们实现游戏之后,我将向你展示这个 2D 示例如何推广到 3D 或任何数量的维度。本章的后半部分涵盖了一些更多的理论,但它将完善你的线性代数教育。我们将涵盖许多你在大学水平的线性代数课程中会找到的主要概念,尽管深度不如。完成本章后,你应该为打开一本更密集的线性代数教科书并填补细节做好了充分准备。但就目前而言,让我们专注于构建我们的游戏。

7.1 设计街机游戏

在本章中,我专注于一个简化的版本的小行星游戏,其中飞船和小行星是静态的。在源代码中,你会发现我已经使小行星移动,我们将在第二部分介绍如何根据物理定律使它们移动。

2 本书。为了开始,我们建模游戏实体——飞船、激光和小行星——并展示如何在屏幕上渲染它们。

7.1.1 游戏建模

在本节中,我们将飞船和小行星作为游戏中的多边形显示。与之前一样,我们将它们建模为向量的集合。例如,我们可以用八个向量(如图 7.2 中的箭头所示)表示一个八边形小行星,并将它们连接起来绘制其轮廓。

图 7.2 代表小行星的八边形

小行星或宇宙飞船在穿越太空时会进行平移或旋转,但其形状保持不变。因此,我们将表示这种形状的向量与中心点的 xy 坐标分开存储,这些坐标会随时间变化。我们还存储一个角度,表示物体在当前时刻的旋转角度。PolygonModel 类代表一个游戏实体(飞船或小行星),它保持其形状但可以进行平移或旋转。它通过一组定义小行星轮廓的向量点进行初始化,默认情况下,其中心 xy 坐标及其旋转角度被设置为零:

class PolygonModel():
    def __init__(self,points):
        self.points = points
        self.rotation_angle = 0
        self.x = 0
        self.y = 0

当飞船或小行星移动时,我们需要通过 self .x,self.y 应用平移,并通过 self.rotation_angle 应用旋转,以找出其实际位置。作为一个练习,你可以给 PolygonModel 添加一个方法来计算围绕其实际变换的向量。

飞船和小行星是 PolygonModel 的特例,它们会自动初始化为各自的形状。例如,飞船具有固定的三角形形状,由三个点给出:

class Ship(PolygonModel):
    def __init__(self):
        super().__init__([(0.5,0), (−0.25,0.25), (−0.25,-0.25)])

对于小行星,我们通过在等间距的角度和 0.5 到 1.0 之间的随机长度下初始化 5 到 9 个向量。这种随机性赋予了小行星一些特征:

class Asteroid(PolygonModel):
    def __init__(self):
        sides = randint(5,9)                                          ❶
        vs = [vectors.to_cartesian((uniform(0.5,1.0), 2*pi*i/sides)) 
                for i in range(0,sides)]                              ❷
        super().__init__(vs)

❶ 小行星具有介于 5 到 9 之间的随机边数。

❷ 长度在 0.5 和 1.0 之间随机选择,角度是 2π/n 的倍数,其中 n 是边的数量。

定义了这些对象后,我们可以将它们实例化并在屏幕上渲染。

7.1.2 渲染游戏

对于游戏的初始状态,我们需要一艘飞船和几个小行星。飞船可以开始于屏幕中心,但小行星应该在屏幕上随机分布。我们可以显示一个平面区域,范围从-10 到 10,在 xy 方向上如下所示:

ship = Ship()

asteroid_count = 10
asteroids = [Asteroid() for _ in range(0,asteroid_count)]   ❶

for ast in asteroids:                                       ❷
    ast.x = randint(−9,9)
    ast.y = randint(−9,9)

❶ 创建一个指定数量的 Asteroid 对象列表,在这种情况下,10 个

❷ 将每个对象的位置设置为介于-10 和 10 之间的随机点,以便在屏幕上显示

我使用一个 400×400 像素的屏幕,这需要在渲染之前将xy坐标进行转换。使用 PyGame 内置的 2D 图形而不是 OpenGL,屏幕左上角的像素坐标是(0, 0),右下角是(400, 400)。这些坐标不仅更大,而且它们是平移的,并且是颠倒的,因此我们需要编写一个to_pixels函数(如图 7.3 所示)来完成从我们的坐标系到 PyGame 像素的转换。

图片

图 7.3 to_pixels函数将对象从我们的坐标系中心映射到 PyGame 屏幕中心。

通过实现to_pixels函数,我们可以编写一个函数将定义多边形的点绘制到 PyGame 屏幕上。首先,我们取定义多边形的变换点(平移和旋转),并将它们转换为像素。然后,我们使用 PyGame 函数绘制它们:

GREEN = (0, 255, 0)
def draw_poly(screen, polygon_model, color=GREEN):
    pixel_points = [to_pixels(x,y) for x,y in polygon_model.transformed()]
    pygame.draw.aalines(screen, color, True, pixel_points, 10)            ❶

❶ 绘制连接给定点到指定 PyGame 对象的线条。True 参数将第一个和最后一个点连接起来,创建一个封闭的多边形。

你可以在源代码中看到整个游戏循环,但基本上每次渲染帧时都会调用draw_poly函数来绘制船和每个小行星。结果是我们在 PyGame 窗口中的简单三角形太空船,周围环绕着小行星场(如图 7.4)。

图片

图 7.4 在 PyGame 窗口中渲染的游戏

7.1.3 射击激光

现在是至关重要的部分:给我们的船提供一种防御方式!玩家应该能够使用左右箭头键瞄准船,然后按空格键发射激光。激光束应该从太空船的尖端发出,延伸到屏幕的边缘。

在我们发明的 2D 世界中,激光束应该是一条从太空船的变换尖端开始,并指向船所指方向的线段。我们可以通过使其足够长来确保它达到屏幕的末端。因为激光的线段与Ship对象的状态相关联,我们可以在Ship类中创建一个方法来计算它:

class Ship(PolygonModel):
    ...
   def laser_segment(self):
        dist = 20\. * sqrt(2)                        ❶
        x,y = self.transformed()[0]                 ❷
        return ((x,y), 
            (*x* + dist * cos(self.rotation_angle), 
             y + dist*sin(self.rotation_angle)))    ❸

❶ 使用勾股定理找到适合屏幕的最长线段

❷ 获取定义点中的第一个值(船的尖端)

❸ 使用三角学找到激光如果从尖端(x,y)延伸 dist 单位,在 self.rotation_angle 角度的终点(如图 7.5)

图片

图 7.5 使用三角学找到激光束结束的屏幕外点

在源代码中,你可以看到如何让 PyGame 响应按键并仅在按下空格键时将激光绘制为线段。最后,如果玩家发射激光并击中了一颗小行星,我们想知道发生了什么。在游戏循环的每一次迭代中,我们都要检查每一颗小行星是否被激光击中。我们通过PolygonModel类上的does_intersect(segment)方法来完成这项工作,该方法计算输入线段是否与给定的PolygonModel的任何线段相交。最终的代码包括以下类似的几行:

laser = ship.laser_segment()                  ❶
keys = pygame.key.get_pressed()               ❷
    if keys[pygame.K_SPACE]:
    draw_segment(*laser)

    for asteroid in asteroids:
        if asteroid.does_intersect(laser):    ❸
            asteroids.remove(asteroid)

❶ 根据飞船的当前位置和方向计算代表激光束的线段

❷ 检测哪些键被按下。如果按下空格键,则使用辅助函数 draw_segment(类似于 draw_poly)将激光束渲染到屏幕上。

❸ 对于每一颗小行星,检查激光线段是否与之相交。如果是这样,通过从小行星列表中删除它来销毁给定的小行星。

剩下的工作是实现does_intersect(segment)方法。在下一节中,我们将介绍实现该方法的数学原理。

7.1.4 练习

| 练习 7.1:在PolygonModel上实现一个transformed()方法,该方法返回模型通过对象的xy属性平移以及通过其rotation_angle属性旋转的点。解决方案:确保首先应用旋转;否则,平移向量也会被旋转角度旋转;例如,

class PolygonModel():
    ...
    def transformed(self):
        rotated = [vectors.rotate2d(self.rotation_angle, v) for *v*  in self.points]
        return [vectors.add((self.x,self.y),v) for *v*  in rotated]

|

| 练习 7.2:编写一个函数to_pixels(x,y),它接受在-10 < x < 10 和 -10 < y < 10 的正方形中的xy坐标对,并将它们映射到相应的 PyGame xy像素坐标,每个坐标的范围从 0 到 400。解决方案

width, height = 400, 400
def to_pixels(x,y):
    return (width/2 + width * *x*/ 20, height/2 − height * y / 20)

|

7.2 线的交点查找

当前的问题是判断激光束是否击中小行星。为此,我们将查看定义小行星的每个线段,并判断它是否与定义激光束的线段相交。我们可以使用几种算法,但我们将将其作为一个两个变量的线性方程组来解决这个问题。从几何学的角度来看,这意味着查看由小行星的边缘和激光束定义的直线,并观察它们的交点(图 7.6)。

图 7.6 激光击中小行星的边缘(左侧)以及相应的线性方程组(右侧)

一旦我们知道了交点的位置,我们就可以判断它是否位于两个线段的范围之内。如果是这样,线段就发生了碰撞,小行星被击中。我们首先回顾平面内直线的方程,然后介绍如何找到两条直线的交点。最后,我们为游戏编写does_intersect方法的代码。

7.2.1 选择合适的直线公式

在上一章中,我们看到了二维平面的 1D 子空间是直线。这些子空间由单个选择的向量v的所有标量倍数t · v组成。因为这样的标量倍数中有一个是 0 · v,所以这些直线总是通过原点,因此t · v并不是我们遇到的任何直线的通用公式。

如果我们从通过原点的直线开始,并通过另一个向量u进行平移,我们可以得到任何可能的直线。这条直线上的点具有形式u + t · v,其中t是某个标量。例如,取v = (2, −1)。形式为t · (2, −1)的点位于通过

如果我们通过第二个向量进行平移,u = (2, 3),那么现在的点是(2, 3) + t · (2, −1),这些点构成了一条不通过原点的直线(图 7.7)。

图片

图 7.7 向量z = (2, 3)和v = (2, −1)。形式为z + t · v的点位于一条直线上。

任何直线都可以描述为某些向量uv以及所有可能的标量倍数t的点u + t · v。这可能不是你习惯的直线的一般公式。我们不是将y作为x的函数来写,而是给出了直线上点的xy坐标作为另一个参数t的函数。有时,你会看到直线写成 r(t) = u + t · v,以表明这条直线是标量参数t的向量值函数 r。输入t决定了从起点u到输出 r(t)需要走多少个单位的v

这种直线公式的优点是,如果你在线上有两个点,那么找到它非常简单。如果你的点是uw,那么你可以使用u作为平移向量,而wu作为缩放向量(图 7.8)。

图片

图 7.8 给定zw,连接它们的直线是 r(t) = z + t · (w − u)。

公式 r(t) = u + t · v也有其缺点。正如你在练习中将会看到的,用这种形式写同一条直线有多个方法。额外的参数t也使得解方程更困难,因为有一个额外的未知变量。让我们看看一些具有其他优点的替代公式。

如果你回忆起高中时的直线公式,那可能就是y = m · x + b。这个公式很有用,因为它明确地将y-坐标作为x-坐标的函数给出。在这个形式下,画直线很容易;你通过一系列x值,计算相应的y值,并在得到的(x, y)点上画点。但这个公式也有一些局限性。最重要的是,你不能表示像 r(t) = (3, 0) + t · (0, 1)这样的垂直线。这是由x = 3 组成的向量线。

我们将继续使用参数公式 r(t) = u + t · v,因为它避免了这个问题,但有一个没有额外参数t的公式可以表示任何直线会更好。我们使用的公式是ax + by = c。例如,我们在最后几张图片中看到的直线可以写成x + 2y = 8(见图 7.9)。它是满足该方程的平面上的(x, y)点集。

图 7.9 线上的所有(x, y)点满足x + 2*y = 8。

形式ax + by = c没有额外参数,可以表示任何直线。即使是垂直线也可以用这种形式表示;例如,x = 3 可以写成 1 · x + 0 · y = 3。任何表示直线的方程都称为线性方程,而此方程特别称为线性方程的标准形式。我们更喜欢在本章中使用它,因为它使得组织我们的计算变得容易。

7.2.2 求直线标准形式方程

公式x + 2y = 8 是包含示例小行星上某一段的直线方程。接下来,我们将看看另一个(见图 7.10),然后尝试系统地找到线性方程的标准形式。准备好一点代数!我会仔细解释每个步骤,但阅读起来可能有点枯燥。如果你自己用铅笔和纸跟着做,会更有趣。

图 7.10 点(1, 5)和(2, 3)定义了小行星的第二段。

向量(1, 5) − (2, 3)是(−1, 2),与直线平行。因为(2, 3)位于直线上,所以直线的参数方程是r(t) = (2, 3) + t · (−1, 2)。知道直线上所有点的形式为(2, 3) + t · (−1, 2)(对于某个t),我们如何将这个条件改写为标准形式方程?我们需要做一些代数运算,特别是消除t。因为(x, y) = (2, 3) + t · (−1, 2),我们实际上有两个起始方程:

x = 2 − t

y = 3 + 2t

我们可以操作这两个方程,得到两个具有相同值(2t)的新方程:

4 − 2x = 2t

y − 3 = 2t

因为左侧的两个表达式都等于 2t,所以它们相等:

4 - 2x = y - 3

现在,我们已经消除了t!最后,将xy项移到一边,我们得到标准形式方程:

2x + y = 7

这个过程并不太难,但如果我们要将其转换为代码,就需要更精确地了解如何操作。让我们尝试解决一般问题:给定两个点(x[1], y[1])和(x[2], y[2]),通过这两个点的直线方程是什么(见图 7.11)?

图 7.11 通过两个已知点求直线方程的一般问题

使用参数公式,直线上的点具有以下形式:

(x, y) = (x[1], y[1]) + t · (x[2] - x[1], y[2] - y[1])

这里有很多 xy 变量,但记住,x[1],x[2],y[1],和 y[2] 在这次讨论中都是常数。我们假设我们有两个已知坐标的点,我们也可以像 (a, b) 和 (c, d) 一样称呼它们。变量是 xy(没有下标),它们代表线上任何点的坐标。像之前一样,我们可以将这个方程分成两部分:

x = x[1] + t · (x² − x[1])

y = y[1] + t · (y² − y[1])

我们可以将 x[1] 和 y[1] 移到它们各自方程的左边:

xx[1] = t · (x[2] − x[1])

yy[1] = t · (y[2] − y[1])

我们接下来的目标是使两个方程的右边看起来相同,这样我们就可以将左边设置为相等。将第一个方程的两边乘以 (y[2] − y[1]),将第二个方程的两边乘以 (x[2] − x[1]),我们得到

(y[2] − y[1]) · (xx[1]) = t · (x[2] − x[1]) · (y[2] − y[1])

(x[2] − x[1]) · (yy[1]) = t · (x[2] − x[1]) · (y[2] − y[1])

因为右边是相同的,我们知道第一个和第二个方程的左边也相等。这让我们可以创建一个没有 t 的新方程:

(y[2] − y[1]) · (xx[1]) = (x[2] − x[1]) · (yy[1])

记住,我们想要一个形式为 ax + by = c 的方程,所以我们需要将 xy 放在同一侧,并将常数放在另一侧。我们可以做的第一件事是展开两边:

(y[2] − y[1]) · x − (y[2] − y[1]) · x = (x[2] − x[1]) · y − (x[2] − x[1]) · y[1]

然后,我们可以将常数移到左边,将变量移到右边:

(y[2] − y[1]) · x − (x[2] − x[1]) · y = (y[2] − y[1]) · x[1] − (x[2] − x[1]) · y[1]

展开右边,我们看到一些项相互抵消:

(y[2] − y[1]) · x − (x[2] − x[1]) · y = y[2]x[1] − y[1]x1 − x[2]y[1] + x[1]y[1] = x[1]y[2] − x[2]y*[1]

我们做到了!这是一个标准形式的线性方程 ax + by = c,其中 a = (y[2] − y[1]),b = −(x[2] − x[1]),或者说,(x[1] − x[2]),而 c = (x[1] y[2] − x[2] y[1])。让我们用之前做的例子来检查这个,使用两个点 (x[1], y[1]) = (2, 3) 和 (x[2], y[2]) = (1, 5)。在这种情况下,

a = y[2] − y[1] = 5 − 3 = 2

b = −(x[2] − x[1]) = −(1 − 2) = 1

and

c = x[1]y[2] − x[2]y[1] = 2 · 5 − 3 · 1 = 7

如预期的那样,这意味着标准形式的方程是 2x + y = 7。这个公式看起来很可信!作为最后的运用,让我们找到由激光定义的线的标准形式方程。它看起来像它穿过我之前画过的 (2, 2) 和 (4, 4)(图 7.12)。

图 7.12 激光穿过点 (2, 2) 和 (4, 4)。

在我们的小行星游戏中,激光线段的起始点和终点是精确的,但这些数字对于例子来说很合适。将这些数字代入公式,我们得到

a = y[2] − y[1] = 4 − 2 = 2

b = −(x[2] − x[1]) = −(4 − 2) = −2

c = x[1]y[2] − x[2]y[1] = 2 · 4 − 2 · 4 = 0

这意味着直线是 2y − 2x = 0,这相当于说 xy = 0(或者简单地 x = y)。为了决定激光是否击中小行星,我们必须找到直线 xy = 0 与直线 x + 2y = 8、直线 2x + y = 7 或任何其他界定小行星的直线相交的地方。

7.2.3 矩阵表示法中的线性方程

让我们关注一个我们可以看到的交点:激光显然击中了小行星最近的边缘,其线的方程为 x + 2y = 8(图 7.13)。

图 7.13 激光击中小行星,其线 x − y = 0 和 x + 2y = 8 相交。

经过一番铺垫,我们遇到了第一个真正的线性方程组。通常我们会像下面这样以网格的形式写出线性方程组,以便变量 xy 对齐:

xy = 0

x + 2y = 8

回想第五章,我们可以将这些两个方程组织成一个矩阵方程。一种方法是写一个列向量的线性组合,其中 xy 是系数:

另一种方法是将它进一步合并,并以矩阵乘法的形式写出。系数为 xy 的 (1,−1) 和 (−1,−2) 的线性组合与矩阵乘积相同:

当我们这样写时,解线性方程组的任务看起来就像解矩阵乘法问题中的向量。如果我们称 2×2 矩阵为 a,问题就变成了什么向量 (x, y) 乘以矩阵 a 得到 (0, 8)?换句话说,我们知道线性变换 a 的输出是 (0, 8),我们想知道什么输入会产生它(图 7.14)。

图 7.14 将问题表述为寻找产生所需输出向量的输入向量

这些不同的符号展示了看待同一问题的新的方法。解线性方程组相当于找到一些向量的线性组合,这些组合产生另一个给定的向量。这也相当于找到一个线性变换的输入向量,该变换产生一个给定的输出。因此,我们将看到如何一次性解决所有这些问题。

7.2.4 使用 NumPy 解线性方程

找到 xy = 0 和 x + 2y = 8 的交点等同于找到满足矩阵乘法方程的向量 (x, y):

这只是一个符号上的差异,但以这种形式表述问题使我们能够使用预先构建的工具来解决它。具体来说,Python 的 NumPy 库有一个线性代数模块和一个函数可以解决这类方程。以下是一个例子:

>>> import numpy as np
>>> matrix = np.array(((1,−1),(1,2)))       ❶
>>> output = np.array((0,8))                ❷

>>> np.linalg.solve(matrix,output)          ❸
array([2.66666667, 2.66666667])             ❹

❶ 将矩阵打包为 NumPy 数组对象

❷ 将输出向量打包为 NumPy 数组(尽管它不需要重塑为列向量)

numpy.linalg.solve函数接受一个矩阵和一个输出向量,并找到产生它的输入向量。

❹ 结果是(x, y) = (2.66..., 2.66...)。

NumPy 告诉我们,交点的xy坐标大约是 22/3 或 8/3,这在几何上看起来是正确的。通过目测图,看起来交点的两个坐标应该在 2 和 3 之间。我们可以通过将其代入两个方程来检查这个点是否同时位于两条直线上:

1x − 1y = 1 ⋅ (2.66666667) − 1 ⋅ (2.66666667) = 0

1x + 2y = 1 ⋅ (2.66666667) + 2 ⋅ (2.66666667) = 8.00000001

这些结果足够接近(0, 8),并且确实是一个精确解。这个解向量,大约是(8/3, 8/3),也是满足矩阵方程 7.1 的向量。

如图 7.15 所示,我们可以将(8/3, 8/3)视为我们传递给由矩阵定义的线性变换机器的向量,该矩阵给出了我们期望的输出向量。

图 7.15 将向量(8/3, 8/3)传递给线性变换会产生期望的输出(0, 8)。

我们可以将 Python 函数numpy.linalg.solve视为一个不同形状的机器,它接受矩阵和输出向量,并返回它们所代表的线性方程的“解”向量(图 7.16)。

图 7.16 numpy.linalg.solve函数接受一个矩阵和一个向量,并输出它们所代表的线性系统的解向量。

这可能是线性代数中最重要的计算任务;从矩阵a和一个向量w开始,找到向量v使得a v = w。这样的向量给出了由aw表示的线性方程组的解。我们很幸运有一个 Python 函数可以为我们完成这项工作,这样我们就不必担心手动完成所需的繁琐代数。现在我们可以使用这个函数来找出我们的激光击中小行星的时刻。

7.2.5 判断激光是否击中小行星

我们游戏缺失的部分是PolygonModel类上的does_intersect方法的实现。对于这个类的任何实例,它代表存在于我们的 2D 游戏世界中的多边形对象,这个方法应该返回True,如果输入的线段与多边形的任何线段相交。

为了做到这一点,我们需要一些辅助函数。首先,我们需要将给定的线段从端点向量对转换为标准形式的一次方程。在本节的末尾,我给你一个练习来实现函数 standard_form,它接受两个输入向量并返回一个元组 (a, b, c),其中 ax + by = c 是部分所在的线。

接下来,给定两个部分,每个部分由其端点向量对表示,我们想要找出它们的线在哪里相交。如果 u 1 和 u 2 是第一部分的端点,而 v 1 和 v 2 是第二部分的端点,我们需要首先找到标准形式方程,然后将它们传递给 NumPy 求解。例如,

def intersection(u1,u2,v1,v2):
    a1, b1, c1 = standard_form(u1,u2)
    a2, b2, c2 = standard_form(v1,v2)
    m = np.array(((a1,b1),(a2,b2)))
    c = np.array((c1,c2))
    return np.linalg.solve(m,c)

输出是两条部分所在的线相交的点。但这个点可能不在图 7.17 所示的任一部分上。

图 7.17 一个部分连接 u[1] 和 u[2],另一个部分连接点 v[1] 和 v[2]。延伸这些部分的两条线相交,但部分本身并不相交。

要检测两个部分是否相交,我们需要检查它们线的交点是否位于两对端点之间。我们可以使用距离来检查。在图 7.17 中,交点距离点 v[2] 比点 v[1] 更远。同样,它比 u[1] 更远于 u[2]。这表明该点不在任一部分上。通过四个总距离检查,我们可以确认线的交点 (x, y) 是否也是部分的交点:

def do_segments_intersect(s1,s2):
    u1,u2 = s1
    v1,v2 = s2
    d1, d2 = distance(*s1), distance(*s2)         ❶
    x,y = intersection(u1,u2,v1,v2)               ❷
    return (distance(u1, (x,y)) <= d1 and         ❸
            distance(u2, (x,y)) <= d1 and
            distance(v1, (x,y)) <= d2 and
            distance(v2, (x,y)) <= d2)

❶ 将第一和第二部分的长度分别存储为 d1 和 d2

❷ 找到部分所在的线的交点 (x, y)

❸ 进行四个检查以确保交点位于线段四端点之间,确认部分相交

最后,我们可以通过检查 do _segments_intersect 对于输入部分和(变换后的)多边形的任何边返回 True 来编写 does_intersect 方法:

class PolygonModel():
    ...
    def does_intersect(self, other_segment):
        for segment in self.segments():
            if do_segments_intersect(other_segment,segment):
                return True                                  ❶
        return False

❶ 如果多边形的任何部分与其他部分 intersect,该方法返回 True。

在接下来的练习中,你可以通过构建具有已知坐标点的彗星和具有已知起点和终点的激光束来确认这实际上有效。如果 does_intersect 实现如源代码所示,你应该能够旋转宇宙飞船以瞄准彗星并摧毁它们。

7.2.6 识别不可解系统

让我给你最后的忠告:二维中的每一个线性方程组都可以求解!在像彗星游戏这样的应用中很少见,但二维中的一些线性方程对可能没有唯一解,甚至没有解。如果我们向 NumPy 传递一个无解的线性方程组,我们会得到一个异常,因此我们需要处理这种情况。

当二维空间中的一对直线不平行时,它们会在某处相交。即使图 7.18 中的两条直线几乎是平行的(但并不完全平行),它们也会在远处某处相交。

图 7.18

图 7.18 中,两条并非完全平行的直线在远处某处相交。

我们遇到麻烦的地方在于当直线平行时,这意味着直线永远不会相交(或者它们是同一条线!),如图 7.19 所示。

图 7.19

图 7.19 一对永不相交的平行线和一对实际上是同一条线但方程不同的平行线

在第一种情况下,没有交点,而在第二种情况下,有无限多个交点−直线上每个点都是一个交点。这两种情况在计算上都有问题,因为我们的代码要求一个单一、唯一的结果。如果我们尝试用 NumPy 解决这两个系统中的任何一个,例如,由 2x + y = 6 和 4x + 2y = 8 组成的系统,我们会得到一个异常:

>>> import numpy as np
>>> m = np.array(((2,1),(4,2)))
>>> v  = np.array((6,4))
>>> np.linalg.solve(m,v)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
...
numpy.linalg.linalg.LinAlgError: Singular matrix

NumPy 将错误归咎于矩阵。该矩阵

图 7.19

被称为奇异矩阵,意味着线性系统没有唯一解。线性方程组由一个矩阵和一个向量定义,但仅矩阵本身就足以告诉我们直线是否平行以及系统是否有唯一解。对于任何非零的w,都不会有一个唯一的v来解这个系统。

图 7.19

我们将在后面更深入地探讨奇异矩阵,但就目前而言,你可以看到行(2, 1)和(4, 2)以及列(2, 4)和(1, 2)都是平行的,因此它们线性相关。这是告诉我们直线平行并且系统没有唯一解的关键线索。线性系统的可解性是线性代数中的一个核心概念;它与线性无关性和维数的概念密切相关。我们将在本章的最后两节中讨论这一点。

为了我们的小行星游戏,我们可以做出简化的假设,即任何平行的线段都不会相交。鉴于我们是用随机浮点数构建游戏,两个线段完全平行的情况非常不可能。即使激光正好对准小行星的边缘,这也会是一个擦肩而过,玩家不配让小行星被摧毁。我们可以修改do_segments_intersect来捕获异常并返回默认结果False

def do_segments_intersect(s1,s2):
    u1,u2 = s1
    v1,v2 = s2
    l1, l2 = distance(*s1), distance(*s2)
    try:
        x,y = intersection(u1,u2,v1,v2)
        return (distance(u1, (x,y)) <= l1 and
                distance(u2, (x,y)) <= l1 and
                distance(v1, (x,y)) <= l2 and
                distance(v2, (x,y)) <= l2)
    except np.linalg.linalg.LinAlgError:
        return False

7.2.7 练习

练习 7.3: u + t · v 可能是一条通过原点的直线。在这种情况下,关于向量 uv 你可以说什么?解答: 一种可能性是 u = 0 = (0, 0);在这种情况下,直线自动通过原点。在这种情况下,u + 0 · v 是原点,无论 v 是什么。否则,如果 uv 是标量倍数,例如 u = s · v,那么直线也会通过原点,因为 us · v = 0 在这条直线上。
练习 7.4: 如果 v = 0 = (0, 0),形式为 u + t · v 的点是否代表一条直线?解答: 不,无论 t 的值如何,我们都有 u + t · v = u + t · (0, 0) = u。这种形式的所有点都等于 u
练习 7.5: 结果表明公式 u + t · v 不是唯一的;也就是说,你可以选择不同的 uv 值来表示同一条直线。另一条表示 (2, 2) + t · (−1, 3) 的直线是什么?解答: 一种可能性是将 v = (−1, 3) 替换为其自身的标量倍数,例如 (2, −6)。当 t = −2 · s 时,形式为 (2, 2) + t · (−1, 3) 的点与形式为 (2, 2) + s · (2, −6) 的点一致。你也可以用线上的任何点来替换 u。因为 (2, 2) + 1 · (−1, 3) = (1, 5) 在这条线上,所以 (1, 5) + t · (2, −6) 也是同一条直线的有效方程。
练习 7.6: a · x + b · y = c 对于 abc 的任何值都代表一条直线吗?解答: 不,如果 ab 都为零,则该方程不描述一条直线。在这种情况下,公式将是 0 · x + 0 · y = c。如果 c = 0,这始终是真的,如果 c ≠ 0,则永远不是真的。无论如何,它不建立 xy 之间的关系,因此它不会描述一条直线。
练习 7.7: 找到 2x + y = 3 这条直线的另一个方程,以表明 abc 的选择不是唯一的。解答: 另一个方程的例子是 6x + 3y = 9。实际上,将方程两边乘以相同的非零数会得到同一条直线的不同方程。
练习 7.8: 方程 ax + by = c 等价于涉及两个二维向量点积的方程:(a, b) · (x, y) = c。因此,可以说一条直线是一组向量,这些向量与给定向量的点积是常数。这个陈述的几何解释是什么?解答: 请参阅 7.3.1 节的讨论。
练习 7.9: 确认向量 (0, 7) 和 (3.5, 0) 都满足方程 2x + y = 7。解答: 2 · 0 + 7 = 7 和 2 · (3.5) + 0 = 7.
练习 7.10: 画出 (3, 0) + t · (0, 1) 的图形,并使用公式将其转换为标准形式。解答: (3, 0) + t · (0, 1) 得到一条垂直线,其中 x = 3:公式 x = 3 已经是标准形式的直线方程,但我们可以用公式来确认这一点。我们线上的第一个点已经给出:(x[1], y[1]) = (3, 0)。线上的第二个点是 (3, 0) + (0, 1) = (3, 1) = (x[2], y[2])。我们有 a = y[2] − y[1] = 1,b = x[1] − x[2] = 0,c = x[1] y[2] − x[2]y[1] = 3 · 1 − 1 · 0 = 3。这给我们 1 · x + 0 · y = 3 或简单地 x = 3.

| 练习 7.11: 编写一个 Python 函数 standard_form,它接受两个向量 v 1 和 v 2,并找到通过它们的直线 ax + by = c。具体来说,它应该输出常数的元组 (a, b, c)。解答:你所需要做的就是翻译你在 Python 中写的公式:

def standard_form(v1, v2):
    x1, y1 = v1
    x2, y2 = v2
    *a* = y2 − y1
    b = x1 − x2
    c = x1 * y2 − y1 * x2
    return a,b,c

|

| 练习 7.12-迷你项目:对于 do _segments_intersect 中的四个距离检查中的每一个,找到一对线段,它们在一个检查中失败,但在其他三个检查中通过。解答:为了更容易运行实验,我们可以创建 do_segments_intersect 的一个修改版本,该版本返回每个四个检查返回的 True/False 值的列表:

def segment_checks(s1,s2):
    u1,u2 = s1
    v1,v2 = s2
    l1, l2 = distance(*s1), distance(*s2)
    x,y = intersection(u1,u2,v1,v2)
    return [
        distance(u1, (x,y)) <= l1,
        distance(u2, (x,y)) <= l1,
        distance(v1, (x,y)) <= l2,
        distance(v2, (x,y)) <= l2
    ]

通常,当线段的一个端点比交点更接近另一个端点时,这些检查会失败。以下是我使用 y = 0 和 x = 0 上的线段找到的一些其他解决方案,这些线段在原点相交。这些解决方案中的每一个都恰好失败四个检查中的一个。如果有疑问,请自己画出它们以了解发生了什么。

>>> segment_checks(((−3,0),(−1,0)),((0,−1),(0,1)))
[False, True, True, True]
>>> segment_checks(((1,0),(3,0)),((0,−1),(0,1)))
[True, False, True, True]
>>> segment_checks(((−1,0),(1,0)),((0,−3),(0,−1)))
[True, True, False, True]
>>> segment_checks(((−1,0),(1,0)),((0,1),(0,3)))
[True, True, True, False]

|

| 练习 7.13: 对于示例激光线和陨石,确认 does_intersect 函数返回 True。(提示:使用网格线找到陨石的顶点并构建表示它的 PolygonModel 对象。) 激光击中了陨石。解答:逆时针顺序,从最上面的点开始,顶点是 (2, 7),(1, 5),(2, 3),(4, 2),(6, 2),(7, 4),(6, 6),和 (4, 6)。我们可以假设激光束的端点是 (1, 1) 和 (7, 7):

>>> from asteroids import PolygonModel
>>> asteroid = PolygonModel([(2,7), (1,5), (2,3), (4,2), (6,2), (7,4), (6,6), (4,6)])
>>> asteroid.does_intersect([(0,0),(7,7)])
True

这证实了激光击中了小行星!相比之下,从 (0, 0) 到 (0, 7) 直接向上射击的子弹没有击中:

>>> asteroid.does_intersect([(0,0),(0,7)])
False

|

| 练习 7.14: 编写一个 does_collide(other_polygon) 方法,通过检查定义两个多边形的任何线段是否相交,来判断当前的 PolygonModel 对象是否与另一个 other_polygon 发生碰撞。这可以帮助我们判断小行星是否撞击了飞船或另一个小行星。解决方案:首先,在 PolygonModel 中添加一个 segments() 方法是方便的,以避免重复返回构成多边形(变换后的)线段的工作。然后,我们可以检查另一个多边形的每个线段,看它是否对当前的一个返回 does_intersect 为真:

class PolygonModel():
    ...
    def segments(self):
        point_count = len(self.points)
        points = self.transformed()
        return [(points[i], points[(i+1)%point_count])
                for i in range(0,point_count)]

    def does_collide(self, other_poly):
        for other_segment in other_poly.segments():
            if self.does_intersect(other_segment):
                return True
        return False

我们可以通过构建一些应该重叠和不应该重叠的正方形来测试这一点,并查看 does_collide 方法是否正确地检测出哪些是哪些。确实,它做到了:

>>> square1 = PolygonModel([(0,0), (3,0), (3,3), (0,3)])
>>> square2 = PolygonModel([(1,1), (4,1), (4,4), (1,4)])
>>> square1.does_collide(square2)
True
>>> square3 = PolygonModel([(−3,−3),(−2,−3),(−2,−2),(−3,−2)])
>>> square1.does_collide(square3)
False

|

练习 7.15-迷你项目:我们无法选择一个向量 w,使得以下系统有唯一解 v找到一个向量 w,使得该系统有无限多个解;也就是说,有无限多个满足该方程的 v 值。解决方案:例如,如果 w = (0, 0),那么系统表示的两条直线是相同的。(如果你怀疑,可以画出来!)解的形式是 v = (a, −2a),其中 a 是任意实数。以下是 w = (0, 0) 时 v 的无限多种可能性的几个例子:

7.3 将线性方程推广到高维

现在我们已经构建了一个功能性的(尽管是基本的)游戏,让我们拓宽我们的视野。我们可以将各种问题表示为线性方程组,而不仅仅是街机游戏。野外的线性方程通常有超过两个“未知”变量 xy,而不仅仅是两个。这样的方程描述了超过两个维度的点的集合。在超过三个维度的情况下,很难想象任何东西,但三维情况可以是一个有用的心理模型。三维中的平面是二维中线的类比,它们也由线性方程表示。

7.3.1 在三维中表示平面

要理解为什么线和面是相似的,用点积来考虑线是有用的。正如你在之前的练习中看到的那样,或者你可能自己注意到的,方程 ax + by = c 是在 2D 平面上,与固定向量 (a, b) 的点积等于固定数 c 的点的集合。也就是说,方程 ax + by = c 等价于方程 (a, b) · (x, y) = c。如果你在练习中没有想出如何从几何上解释这一点,让我们在这里过一遍。

如果我们在 2D 中有一个点和(非零)向量,那么存在一条唯一的直线,它垂直于该向量,并且通过该点,如图 7.20 所示。

图 7.20 通过给定点且垂直于给定向量的唯一直线

如果我们将给定的点 (x[0], y[0]) 和给定的向量 (a, b) 分别称为 (x, y) 和 (a, b),我们可以为点 (x, y) 在直线上提供一条标准。具体来说,如果 (x, y) 位于直线上,那么 (xx[0], yy[0]) 与直线平行并且垂直于 (a, b),如图 7.21 所示。

图片

图 7.21 向量 (xx[0], yy[0]) 与直线平行,因此垂直于 (a, b).

因为两个垂直向量的点积为零,所以这与代数陈述等价:

(a, b) · (xx[0], yy[0]) = 0

那个点积可以展开为

a(xx[0]) + b(yy[0]) = 0

或者

ax + by = ax[0] + by[0]

这个方程右侧的量是一个常数,因此我们可以将其重命名为 c,从而得到直线的通用形式方程:ax + by = c。这是公式 ax + by = c 的一个方便的几何解释,并且我们可以将其推广到三维空间。

给定一个点和三维空间中的一个向量,存在一个唯一垂直于该向量并通过该点的平面。如果向量是 (a, b, c),点为 (x[0], y[0], z[0]),我们可以得出结论,如果向量 (x, y, z) 位于该平面上,那么 (xx[0], y -y[0], zz[0]) 是垂直于 (a, b, c) 的。图 7.22 展示了这一逻辑。

图片

图 7.22 一个平行于向量 (a, b, c) 的平面通过点 (x[0], y[0], z[0] )。

平面上的每一个点都给我们提供了一个垂直于 (a, b, c) 的向量,而每一个垂直于 (a, b, c) 的向量都指向平面上的一个点。我们可以将这种垂直性表达为两个向量的点积,因此,平面上的每一个点 (x, y, z) 满足的方程是

(a, b, c) · (xx[0], yy[0], zz[0]) = 0

这可以展开为

ax + by + cz = ax[0] + by[0] + cz[0]

并且因为方程的右侧是一个常数,我们可以得出结论,三维空间中的每一个平面都有一个形式为 ax + by + cz = d 的方程。在三维空间中,计算问题是要决定这些平面的交点在哪里,或者哪些 (x, y, z) 的值同时满足多个这样的线性方程。

7.3.2 解三维线性方程

平面上的非平行直线在 exactly one point 相交。这个单一点交对于平面也是成立的吗?如果我们画一对相交的平面,我们可以看到非平行平面可以在多个点上相交。事实上,图 7.23 显示,存在一条由无限多个点组成的 whole line,这些点是两个非平行平面相交的点。

图片

图 7.23 两个非平行平面沿一条直线相交。

如果你添加一个不平行于这条交线的第三个平面,你可以找到一个唯一的交点。图 7.24 显示了三个平面中的每一对都沿一条线相交,而这些线共享一个单一的点。

图片

图 7.24 两个非平行平面沿一条线相交。

通过代数方法找到这个点需要找到三个变量三个线性方程的公共解,每个变量代表一个平面,形式为 ax + by + cz = d。这样的三个线性方程组的形式如下:

a[1]x + b[1]y + c[1]z = d[1]

a[2]x + b[2]y + c[2]z = d[2]

a[3]x + b[3]y + c[3]z = d[3]

每个平面由四个数字确定:a[i]b[i]c[i]d[i],其中 i = 1、2 或 3,是我们要看的平面的索引。这样的下标在有许多变量需要命名的线性方程组中很有用。这十二个数字总共足以找到平面相交的点 (x, y, z),如果有的话。为了解这个系统,我们可以将系统转换为矩阵方程:

图片

让我们尝试一个例子。假设我们的三个平面由以下方程给出:

x + yz = −1

2yz = 3

x + z = 2

你可以在本书的源代码中看到如何在 Matplotlib 中绘制这些平面。图 7.25 显示了结果。

图片

图 7.25 在 Matplotlib 中绘制的三个平面

不容易看出,但三个平面在某处相交。为了找到这个交点,我们需要满足所有三个线性方程的 xyz 的值。再次,我们可以将系统转换为矩阵形式并使用 NumPy 来求解。与这个线性系统等价的矩阵方程是

图片

在 Python 中将矩阵和向量转换为 NumPy 数组,我们可以快速找到解向量:

>>> matrix = np.array(((1,1,−1),(0,2,−1),(1,0,1)))
>>> vector = np.array((−1,3,2))
>>> np.linalg.solve(matrix,vector)
array([−1., 3., 3.])

这告诉我们,(−1, 3, 3) 是 (x, y, z) 点,所有三个平面相交于此点,并且该点同时满足所有三个线性方程。

虽然使用 NumPy 计算这个结果很容易,但你也可以看到在 3D 中可视化线性方程组已经有点困难了。在 3D 之外,可视化线性方程组是困难的(如果不是不可能的),但求解它们是机械上相同的。任何数量维度的线或平面的类比称为 超平面,问题归结为找到多个超平面相交的点。

7.3.3 代数方法研究超平面

严格来说,n 维超平面是 n 个未知变量的线性方程的解。一条线是存在于 2D 中的 1D 超平面,一个平面是存在于 3D 中的 2D 超平面。正如你可能猜到的,4D 中标准形式的线性方程具有以下形式:

aw + bx + cy + dz = e

解集(w, x, y, z)形成一个位于 4 维空间中的 3 维超平面区域。当我们使用形容词 3D 时需要小心,因为这并不一定是ℝ⁴的 3 维向量子空间。这与二维情况类似:通过二维空间原点的线是ℝ²的向量子空间,但其他线则不是。无论是否是向量空间,3 维超平面在解集中有三个线性无关的方向可以旅行,就像在任何平面上你可以旅行两个线性无关的方向一样。我在本节的末尾包含了一个小项目,以帮助你检查你对这一点的理解。

当我们在更高维度的空间中写线性方程时,我们可能会用完字母来表示坐标和系数。为了解决这个问题,我们将使用带有下标索引的字母。例如,在 4 维空间中,我们可以将线性方程写成标准形式:

a[1] x[1] + a[2] x[2] + a[3] x[3] + a[4] x[4] = b

在这里,系数是a[1],a[2],a[3],和a[4],4 维向量的坐标是(x[1],x[2],x[3],x[4])。我们同样可以写出 10 维的线性方程:

a[1] x[1] + a[2] x[2] + a[3] x[3] + a[4] x[4] + a[5] x[5] + a[6] x[6] + a[7] x[7] + a[8] x[8] + a[9] x[9] + a[10] x[10] = b

当我们求和的项的规律清晰时,我们有时会使用省略号(...)来节省空间。你可能看到像前面的方程一样写的方程 a[1] x[1] + a[2] x[2] + ... + a[10] x[10] = b。你还会看到另一种紧凑的表示法,涉及到求和符号Σ,它是希腊字母 sigma。如果我想写形式为aixi的项的和,其中索引ii = 1 到i = 10,并且我想声明这个和等于某个其他数字b,我可以使用数学简写:

这个方程与前面的方程表达的意思相同;它只是更简洁的写法。无论我们在多少维度的空间中工作,线性方程的标准形式都有相同的形状:

a[1] x[1] + a[2] x[2] + ... + a[n] x[n]* = b

为了表示在 n 维空间中具有 m 个线性方程的系统,我们需要更多的索引。等号左边的常数数组可以用aij表示,其中下标i表示我们正在讨论哪个方程,下标j表示常数(x[j])乘以哪个坐标。例如,

a[11] x[1] + a[12] x[2] + ... + a[1n] x[n] = b[1]

a[21] x[1] + a[22] x[2] + ... + a[2n] x[n] = b[2]

...

a[m1] x[1] + a[m2] x[2] + ... + a[mn] x[n] = b[m]

你可以看到,我也使用了省略号来跳过中间的三个方程到 m -1。每个方程中有 n 个常数,所以总共有 mn 个形式为 aij 的常数。在等式的右边,总共有 m 个常数,每个方程一个:b[1],b[2],...,bm

无论维度数(与未知变量的数量相同)和方程的数量如何,我们都可以将这样的系统表示为一个线性方程。具有 n 个未知数和 m 个方程的先前系统可以重写如图 7.26 所示。

图 7.26 具有 n 个未知数和 m 个方程的线性方程组以矩阵形式表示

7.3.4 计算维度、方程和解的数量

我们在二维和三维中都看到,可以写出没有解或至少不是唯一解的线性方程。我们如何知道一个包含 n 个未知数的 m 个方程组是可解的?换句话说,我们如何知道 n 维空间中的 m 个超平面有一个唯一的交点?我们将在本章的最后部分详细讨论这个问题,但现在我们可以得出一个重要的结论。

在二维中,一对直线可以在一个点上相交。它们并不总是这样(例如,如果直线是平行的),但它们可以。这个陈述的代数等价物是,在两个变量中的两个线性方程组可以有一个唯一的解。

在三维中,三个平面可以相交于一个点。同样,这并不总是这样,但三个是确定三维空间中一个点的最小平面数(或线性方程数)。只有两个平面时,你至少有一个一维的可能解空间,即交线。从代数上来说,这意味着你需要两个线性方程在二维中获取一个唯一解,三个线性方程在三维中获取一个唯一解。一般来说,你需要 n 个线性方程才能在 n 维空间中获取一个唯一解。

这里有一个例子,当在四维空间中工作时,使用坐标 (x[1],x[2],x[3],x[4]),这可能会显得过于简单,但因为它具体而有用。让我们将我们的第一个线性方程设为 x[4] = 0. 这个线性方程的解形成一个三维超平面,由形式为 (x[1],x[2],x[3],0) 的向量组成。这显然是一个三维解空间,并且它实际上是 ℝ⁴ 的一个向量子空间,其基为 (1, 0, 0, 0),(0, 1, 0, 0),(0, 0, 1, 0)。

第二个线性方程可以是 x[2] = 0. 这个方程的解本身也是一个三维超平面。这两个三维超平面的交集是一个二维空间,由形式为 (x[1], 0, x[3], 0) 的向量组成,这些向量满足两个方程。如果我们能想象这样的东西,我们会看到这是一个存在于四维空间中的二维平面。具体来说,它是通过 (1, 0, 0, 0) 和 (0, 0, 1, 0) 这两个向量张成的平面。

添加一个额外的线性方程,x[1] = 0,它定义了自己的超平面,现在所有三个方程的解是一个一维空间。这个一维空间中的向量位于 4 维空间中的一条线上,形式为 (0, 0, x[3], 0)。这条线正好是 x[3] -轴,它是 ℝ⁴ 的一个一维子空间。

最后,如果我们施加第四个线性方程,x[3] = 0,唯一的可能解是 (0, 0, 0, 0),一个零维的向量空间。x[4] = 0,x[2] = 0,x[1] = 0,和 x[3] = 0 这些陈述实际上都是线性方程,但它们非常简单,可以精确地描述解:(x[1], x[2], x[3], x[4]) = (0, 0, 0, 0)。每次我们添加一个方程,我们都会减少解空间的维度,直到我们得到一个由单个点 (0, 0, 0, 0) 组成的零维空间。

如果我们选择了不同的方程,每一步可能就不会那么清晰;我们就必须测试每个后续的超平面是否真正减少了解空间的维度。例如,如果我们从

x[1] = 0

x[2] = 0

我们会将解集减少到二维空间,但随后添加另一个方程到其中

x[1] + x[2] = 0

对解空间没有影响。因为 x[1] 和 x[2] 已经被限制为零,方程 x[1] + x[2] = 0 自动满足。因此,第三个方程没有给解集添加更多的具体性。

在第一种情况下,四个维度和三个线性方程需要满足,给我们留下了一个 4 − 3 = 1 维的解空间。但在第二种情况下,三个方程描述了一个更不具体的 2D 解空间。如果你有 n 维度(n 个未知变量)和 n 个线性方程,可能存在一个唯一的解−一个零维的解空间−但这并不总是如此。更一般地,如果你在 n 维空间中工作,使用 m 个线性方程可以得到最低维度的解空间是 nm。在这种情况下,我们称线性方程组为独立的

空间中的每个基向量都给我们提供了一个新的独立方向,我们可以在空间中移动。空间中的独立方向有时被称为自由度;例如,z方向,“解放”了我们从平面到更大的 3D 空间。相比之下,我们引入的每个独立线性方程都是一个约束;它减少了一个自由度,并限制了解空间的维度数减少。当独立自由度(维度)的数量等于独立约束(线性方程)的数量时,就不再有任何自由度,我们只剩下了一个唯一的点。

这是在线性代数中的一个重要哲学观点,你可以在接下来的某些小项目中进一步探索。在本章的最后部分,我们将连接独立方程和(线性)独立向量的概念。

7.3.5 练习

练习 7.16:通过点 (5, 4) 且垂直于 (−3, 3) 的直线的方程是什么?解答:这里是设置!图片。对于直线上的每个点 (x, y),向量 (x − 5, y − 4) 与直线平行,因此与 (−3, 3) 垂直。这意味着对于直线上的任何 (x, y),向量 (x − 5, y − 4) 与 (−3, 3) 的点积为零。这个方程展开为 −3x + 15 + 3y − 12 = 0,重新排列后得到 −3x + 3y = −3。我们可以将两边都除以 −3 来得到一个更简单、等价的方程:xy = 1。
练习 7.17-迷你项目:考虑一个 4 维线性方程组:x[1] + 2x[2] + 2x[3] + x[4] = 0x[1] − x[4] = 0。用代数方法(而不是几何方法)解释为什么解构成 4 维向量子空间。解答:我们可以证明,如果 (a[1], a[2], a[3], a[4]) 和 (b[1], b[2], b[3], b[4]) 是两个解,那么它们的线性组合也是一个解。这意味着解集包含其向量的所有线性组合,因此它是一个向量子空间。让我们从假设 (a[1], a[2], a[3], a[4]) 和 (b[1], b[2], b[3], b[4]) 是两个线性方程的解开始,这明确意味着:a[1] + 2a[2] + 2a[3] + a[4] = 0b[1] + 2b[2] + 2b[3] + b[4] = 0a[1] − a[4] = 0b[1] − b[4] = 0。选择标量 cd,线性组合 c(a[1], a[2], a[3], a[4]) + d(b[1], b[2], b[3], b[4]) 等于 (ca[1] + db[1], ca[2] + db[2], ca[3] + db[3], ca[4] + db[4])。这是这两个方程的解吗?我们可以通过将四个坐标代入 x[1],x[2],x[3] 和 x[4] 来找出答案。在第一个方程中,x[1] + 2x[2] + 2x[3] + x[4] 变为 (ca[1] + db[1]) + 2(ca[2] + db[2]) + 2(ca[3] + db[3]) + (ca[4] + db[4])。这展开为给我们 ca[1] + db[1] + 2ca[2] + 2db[2] + 2ca[3] + 2db[3] + ca[4] + db[4],这重新排列为 c(a[1] + 2a[2] +2a[3] + a[4]) + d(b[1] + 2b[2] + 2b[3] + b[4])。因为 a[1] + 2a[2] + 2a[3] + a[4] 和 b[1] + 2b[2] + 2b[3] + b[4] 都是零,这个表达式是零:c(a[1] + 2a[2] + 2a[3] + a[4]) + d(b[1] + 2b[2] + 2b[3] + b[4]) = c · 0 + d · 0 = 0。这意味着线性组合是第一个方程的解。同样,将线性组合代入第二个方程,我们看到它也是那个方程的解:(ca[1] + db[1]) − (ca[4] + db[4]) = c(a[1] − a[4]) + d(b[1] − b[4]) = c · 0 + d · 0 = 0。任何两个解的任何线性组合也是解,所以解集包含其所有线性组合。这意味着解集是 4 维向量子空间。
练习 7.18:通过点 (1, 1, 1) 且垂直于向量 (1, 1, 1) 的平面的标准形式方程是什么?解答:对于平面上的任意点 (x, y, z),向量 (x − 1, y − 1, z − 1) 都垂直于 (1, 1, 1)。这意味着对于平面上的任何 xy,和 z 值,点积 (x − 1, y − 1, z − 1) · (1, 1, 1) 都为零。这展开后给出 (x − 1) + (y − 1) + (z − 1) = 0 或 x + y + z = 3,这是平面的标准形式方程。

| 练习 7.19-迷你项目:编写一个 Python 函数,该函数接受三个 3D 点作为输入,并返回它们所在的平面的标准形式方程。例如,如果标准形式方程是 ax + by + cz = d,则该函数可以返回元组 (a, b, c, d)。提示:三个向量的任意两对之差都与平面平行,所以差分的叉积垂直于平面。解答:如果给定的点是 p[1],p[2],和 p[3],那么向量差如 p[3] − p[1] 和 p[2] − p[1] 都与平面平行。那么 (p[2] − p[1]) × (p[3] − p[1]) 的叉积就垂直于平面。只要点 p[1],p[2],和 p[3] 形成一个三角形(所以差分不平行),一切就都很好了。有了平面上的一个点(例如,p[1])和一个垂直向量,我们就可以重复寻找解的标准形式的过程,就像前两个练习中那样:

from vectors import *

def plane_equation(p1,p2,p3):
    parallel1 = subtract(p2,p1)
    parallel2 = subtract(p3,p1)
    a,b,c = cross(parallel1, parallel2)
    d = dot((a,b,c), p1)
    return a,b,c,d

例如,以下是从先前的练习中得到的平面 x + y + z = 3 的三个点:

>>> plane_equation((1,1,1), (3,0,0), (0,3,0))
(3, 3, 3, 9)

结果是 (3, 3, 3, 9),意味着 3x + 3y + 3z = 9,这相当于 x + y + z = 3。这意味着我们做对了!|

练习 7.20: 在以下矩阵方程中,总共有多少个常数 aij?有多少个方程?有多少个未知数?写出完整的矩阵方程(不带点)和完整的线性方程组(不带点)。矩阵形式的简略线性方程组解答:为了清晰起见,我们首先写出完整的矩阵方程:矩阵方程的非简略版本。这个矩阵中有 5 · 7 = 35 个条目,线性系统方程左侧有 35 个 aij 常数。有 7 个未知变量:x[1],x[2],...,x[7] 和 5 个方程(每个矩阵的行一个)。你可以通过执行矩阵乘法得到完整的线性方程组:a[11]x[1] + a[12]x[2] + a[13]x[3] + a[14]x[4] + a[15]x[5] + a[16]x[6] + a[17]x[7] = b[1]a[21]x[1] + a[22]x[2] + a[23]x[3] + a[24]x[4] + a[25]x[5] + a[26]x[6] + a[27]x[7] = b[2]a[31]x[1] + a[32]x[2] + a[33]x[3] + a[34]x[4] + a[35]x[5] + a[36]x[6] + a[37]x[7] = b[3]a[41]x[1] + a[42]x[2] + a[43]x[3] + a[44]x[4] + a[45]x[5] + a[46]x[6] + a[47]x[7] = b[4]a[5]1x[1] + a[5]2x[2] + a[5]3x[3] + a[54]x[4] + a[55]x[5] + a[56]x[6] + a[57]x[7] = b[5]这个矩阵方程表示的完整线性方程组。你可以看到为什么我们用缩写来避免这种繁琐的写作!
练习 7.21: 将以下线性方程写成不带求和简写的形式。从几何上看,解集是什么样的?!解答:这个方程的左侧是形式为 x[i] 的项的和,其中 i 从 1 到 3。这给出了 x[1] + x[2] + x[3] = 1。这是三个变量的线性方程的标准形式,因此其解形成了一个三维空间中的平面。
练习 7.22: 绘制三个平面,这三个平面之间没有平行关系,并且没有唯一的交点。(更好的方法是找到它们的方程并绘制它们!)解答:这里有三个平面:z + y = 0,zy = 0,和 z = 3,以及图表:三个不平行且没有交点的平面。我画出了三对平面的交点,这些交点是平行线。因为这些线永远不会相交,所以这三个平面没有唯一的交点。这就像你在第六章中看到的例子:即使其中没有一对向量平行,三个向量也可以线性相关。

| 练习 7.23: 假设我们有一个 m 个线性方程和 n 个未知变量。以下 mn 的值说明了是否存在唯一解?

  1. m = 2, n = 2

  2. m = 2, n = 7

  3. m = 5, n = 5

  4. m = 3, n = 2

解答

  1. 当有两个线性方程和两个未知数时,可能有唯一的解。这两个方程代表平面上的线,除非它们平行,否则它们会在一个唯一点上相交。

  2. 当有两个线性方程和七个未知数时,不可能有唯一的解。假设由这些方程定义的 6 维超平面不是平行的,那么将会有一个 5 维的解空间。

  3. 当有五个线性方程和五个未知数时,只要方程是独立的,就可能有唯一的解。

  4. 当有三个线性方程和两个未知数时,可能会有一个唯一的解,但这需要一些运气。这意味着第三条线恰好通过前两条线的交点,这是不太可能但可能的。

!图片平面上三条恰好相交于一点的线 |

练习 7.24: 找出三个相交于一点的平面,三个相交于一条线的平面,以及三个相交于一个平面的平面。解答: 平面 zy = 0, z + y = 0, 和 z + x = 0 相交于单一点 (0, 0, 0)。大多数随机选择的平面都会相交于这样一个唯一的点!图片三个平面相交于一点
平面 zy = 0, z + y = 0, 和 z = 0 在一条线上相交,具体是 x 轴。如果你玩弄这些方程,你会发现 yz 都被限制为零,但 x 甚至没有出现,所以它没有约束。因此,x 轴上的任何向量 (x, 0, 0) 都是解!图片三个平面相交于一条线最终,如果三个方程都代表同一个平面,那么整个平面就是解集。例如,zy = 0, 2z − 2y = 0, 和 3z − 3y = 0 都代表同一个平面。图片三个相同的平面叠加;它们的解集是整个平面。

| 练习 7.25: 不使用 Python,5 维线性方程组的解是什么?x[5] = 3, x[2] = 1, x[4] = −1, x[1] = 0, 和 x[1] + x[2] + x[3] = −2?使用 NumPy 验证答案。解答: 因为这四个线性方程指定了坐标的值,我们知道解的形式是 (0,1, x[3], −1,3)。我们需要使用最后一个方程进行一些代数运算来找出 x[3] 的值。因为 x[1] + x[2] + x[3] = −2,我们知道 0 + 1 + x[3] = −2,所以 x[3] 必须是 −3。因此,唯一的解点是 (0, 1, −3, −1, 3)。将这个系统转换为矩阵形式,我们可以使用 NumPy 来解它,以确认我们得到了正确的答案:

>>> matrix = np.array(((0,0,0,0,1),(0,1,0,0,0),(0,0,0,1,0),(1,0,0,0,0),(1,1,1,0,0)))
>>> vector = np.array((3,1,−1,0,−2))
>>> np.linalg.solve(matrix,vector)
array([ 0., 1., −3., −1., 3.])

|

练习 7.26-迷你项目:在任何维度中,都有一个单位矩阵充当单位映射。也就是说,当你将n维单位矩阵I乘以任何向量v时,你得到的结果向量v相同;因此,I v = v .这意味着I v = w是一个容易解决的线性方程组:v的一个可能答案是v = w。这个迷你项目的想法是,你可以从一个线性方程组a v = w开始,将两边乘以另一个矩阵B,使得(BA) = I。如果是这样,那么你就有(BA)v = B wI v = B wv = B w。换句话说,如果你有一个系统a v = w,并且有一个合适的矩阵B,那么B w是系统的解。这个矩阵B被称为a逆矩阵。让我们再次看看我们在 7.3.2 节中解决的方程组!使用 NumPy 函数numpy.linalg.inv(matrix),它返回给定矩阵的逆,来找到方程左边矩阵的逆。然后,将两边乘以这个矩阵以找到线性方程组的解。将你的结果与我们从 NumPy 的求解器得到的结果进行比较。提示:你可能还想使用 NumPy 的内置矩阵乘法例程numpy.matmul来简化计算。

| 解决方案:首先,我们可以使用 NumPy 计算矩阵的逆:

>>> matrix = np.array(((1,1,−1),(0,2,−1),(1,0,1)))
>>> vector = np.array((−1,3,2))
>>> inverse = np.linalg.inv(matrix)
>>> inverse
array([[ 0.66666667, -0.33333333,  0.33333333],
       [-0.33333333,  0.66666667,  0.33333333],
       [-0.66666667,  0.33333333,  0.66666667]])

逆矩阵与原矩阵的乘积给出了单位矩阵,对角线上的值为 1,其他地方为 0,尽管存在一些数值误差:

>>> np.matmul(inverse,matrix)
array([[ 1.00000000e+00,  1.11022302e−16, −1.11022302e−16],
       [ 0.00000000e+00,  1.00000000e+00,  0.00000000e+00],
       [ 0.00000000e+00,  0.00000000e+00,  1.00000000e+00]])

诀窍是将矩阵方程的两边都乘以这个逆矩阵。在这里,为了便于阅读,我已经对逆矩阵中的值进行了四舍五入。我们已经知道,左边的第一个乘积是一个矩阵及其逆,因此我们可以相应地简化!将系统方程的两边乘以逆矩阵并进行简化

>>> np.matmul(inverse, vector)
array([−1., 3., 3.])

这与我们从求解器得到的解相同。|

7.4 通过解线性方程改变基

向量线性无关的概念显然与线性方程独立性的概念有关。这种联系来自于解线性方程组相当于用不同的基重新表示向量的事实。让我们探讨这在二维空间中的含义。当我们为向量(4,3)写坐标时,我们隐式地将该向量表示为标准基向量的线性组合:

(4, 3) = 4e[1] + 3e[2]

在上一章中,你学习了标准基由 e[1] = (1, 0) 和 e[2] = (0, 1) 组成,但这并不是唯一可用的基。例如,像 u[1] = (1, 1) 和 u[2] = (−1, 1) 这样的向量对形成了一个基,用于 ℝ²。由于任何二维向量都可以写成 e[1] 和 e[2] 的线性组合,所以任何二维向量也可以写成 u[1] 和 u[2] 的线性组合。对于某些 cd,我们可以使以下方程成立,但并不立即明显 cd 的值是什么:

c · (1, 1) + d · (−1, 1) = (4, 2)

图 7.27 展示了这一点的直观表示。

图片

图 7.27 将 (4, 2) 写成 u1 = (1, 1) 和 u2 = (−1, 1) 的线性组合

作为线性组合,这个方程等价于一个矩阵方程,即:

图片

这同样是一个线性方程组!在这种情况下,未知向量是 (c, d) 而不是 (x, y),矩阵方程中隐藏的线性方程是 cd = 4 和 c + d = 2。存在一个二维向量空间 (c, d),它定义了 u[1] 和 u[2] 的不同线性组合,但只有一种组合同时满足这两个方程。

任何 (c, d) 对的选择都定义了一个不同的线性组合。例如,让我们考虑一个任意的 (c, d) 值,比如 (c, d) = (3, 1)。向量 (3, 1) 不在 u[1] 和 u[2] 的同一向量空间中;它存在于 (c, d) 对的向量空间中,每个都描述了 u[1] 和 u[2] 的不同线性组合。点 (c, d) = (3, 1) 描述了我们原始二维空间中的一个特定线性组合:3u[1] + 1u[2] 将我们带到点 (x, y) = (2, 4)(图 7.28)。

图片

图 7.28 存在一个二维值空间 (c, d),其中 (c, d) = (3, 1) 并产生线性组合 3u[1] + 1u[2] = (2, 4)。

回想一下,我们正在尝试将 (4, 2) 作为 u[1] 和 u[2] 的线性组合,所以这不是我们寻找的线性组合。为了使 c u[1] + d u[2] 等于 (4, 2),我们需要满足 cd = 4 和 c + d = 2,正如我们之前看到的。

让我们在 c, d 平面上绘制线性方程组。直观上,我们可以看出 (3, −1) 是一个满足 c + d = 2 和 cd = 4 的点。这给我们提供了用于线性组合的标量对,以将 u[1] 和 u[2] 组合成 (4, 2),如图 7.29 所示。

图片

图 7.29 点 (c, d) = (3, −1) 满足 c + d = 2 和 cd = 4。因此,它描述了我们寻找的线性组合。

现在我们可以将 (4, 2) 写成两个不同基向量的线性组合:( (4, 2) = 4*e[1] + 2*e[2] ) 和 ( (4, 2) = 3*u[1] − 1*u[2] )。记住,坐标 (4, 2) 正是线性组合 ( 4*e[1] + 2*e[2] ) 中的标量。如果我们画轴的方式不同,*u[1] 和 *u[2] 也可以是我们的标准基;我们的向量将是 ( 3*u[1] − *u[2] ),我们可以说它的坐标是 (3, 1)。为了强调坐标是由我们选择的基决定的,我们可以这样说,这个向量相对于标准基的坐标是 (4, 2),但相对于由 *u[1] 和 *u**[2] 组成的基的坐标是 (3, −1)。

找到向量相对于不同基的坐标是一个计算问题,实际上是一个隐藏的线性方程组。这是一个重要的例子,因为每个线性方程组都可以这样考虑。让我们再试一个例子,这次是 3D 的,看看我的意思。

7.4.1 解决一个 3D 例子

让我们先写一个 3D 线性方程组的例子,然后我们将解释它。与 2x2 矩阵和 2D 向量不同,我们可以从一个 3x3 矩阵和 3D 向量开始:

这里的未知数是一个 3D 向量;我们需要找到三个数字来识别它。进行矩阵乘法,我们可以将其分解为三个方程:

( 1 \cdot x − 1 \cdot y + 0 \cdot z = 1 )

( 0 \cdot x − 1 \cdot y − 1 \cdot z = 3 )

( 1 \cdot x + 0 \cdot y + 2 \cdot z = −7 )

这是一个有三个未知数和三个线性方程的系统,( ax + by + cz = d ) 是 3D 线性方程的标准形式。在下一节中,我们将探讨 3D 线性方程的几何解释。(实际上,它们在 3D 中代表平面,而不是 2D 中的线。)

现在,让我们把这个系统看作是一个待定系数的线性组合。前面的矩阵方程等价于以下方程:

解这个方程等价于问:什么线性组合 ( (1, 0, 1) ),( (−1, −1, 0) ),和 ( (0, −1, 2) ) 产生向量 ( (1, 3, −7) )?这比 2D 例子更难想象,手动计算答案也更困难。幸运的是,我们知道 NumPy 可以处理三个未知数的线性方程组,所以我们只需将 3x3 矩阵和 3D 向量作为输入传递给求解器,如下所示:

>>> import numpy as np
>>> xw = np.array((1,3,−7))
>>> xa = np.array(((1,−1,0),(0,−1,−1),(1,0,2)))
>>> np.linalg.solve(a,w)
array([ 3., 2., −5.])

解决我们的线性方程组的值是:( x = 3 ),( y = 2 ),和 ( z = −5 )。换句话说,这些是我们构建所需线性组合的系数。我们可以这样说,向量 ( (1, 3, −7) ) 相对于基 ( (1, 0, 1) ),( (−1, −1, 0) ),( (0, −1, 2) ) 的坐标是 ( (3, 2, −5) )。

在更高维度的故事也是一样的;只要可能,我们可以通过求解相应的线性方程组来将一个向量表示为其他向量的线性组合。但是,并不是总能写出线性组合,并不是每个线性方程组都有一个唯一解,甚至可能没有解。一个向量集合是否构成基的问题在计算上等同于一个线性方程组是否有唯一解的问题。

这种深刻的联系是一个很好的地方,可以用来结束第一部分,其重点是线性代数。整本书中会有很多关于线性代数的精华,但当我们把它们与第二部分的核心主题——微积分相结合时,它们就更有用了。

7.4.2 练习

| 练习 7.27:如何将向量 (5, 5) 写成向量 (10, 1) 和 (3, 2) 的线性组合?解答:这相当于询问哪些数字 ab 满足方程方程或者哪个向量 (a, b) 满足矩阵方程矩阵方程。我们可以使用 NumPy 找到一个解:

>>> matrix = np.array(((10,3),(1,2)))
>>> vector = np.array((5,5))
>>> np.linalg.solve(matrix,vector)
array([-0.29411765, 2.64705882])

这意味着线性组合(你可以检查!)如下所示线性组合 |

| 练习 7.28:将向量 (3, 0, 6, 9) 写成向量 (0, 0, 1, 1),(0, −2, −1, −1),(1, −2, 0, 2) 和 (0, 0, −2, 1) 的线性组合。解答:要解决的线性系统是线性系统,其中 4×4 矩阵的列是我们想要构建线性组合的向量。NumPy 给出了这个系统的解:

>>> matrix = np.array(((0, 0, 1, 0), (0, −2, −2, 0), (1, −1, 0, −2), (1, −1, 2, 1)))
>>> vector = np.array((3,0,6,9))
>>> np.linalg.solve(matrix,vector)
array([ 1., −3., 3., −1.])

这意味着线性组合是线性组合 |

概述

  • 2D 视频游戏中的模型对象可以是线段构成的多边形形状。

  • 给定两个向量 uv,形式为 u + tv 的点对于任何实数 t 都在一条直线上。事实上,任何直线都可以用这个公式来描述。

  • 给定实数 abc,其中至少有一个 ab 不为零,满足 ax + by = c 的平面上的点 (x, y) 在一条直线上。这被称为直线的标准形式,任何直线都可以通过某种选择 abc 的方式写成这种形式。直线的方程被称为线性方程

  • 在平面上找到两条直线的交点等价于找到同时满足两个线性方程的值 (x, y)。我们试图同时求解的一组线性方程称为线性方程组

  • 求解两个线性方程组等价于找到什么向量可以乘以一个已知的 2×2 矩阵,以得到一个已知的向量。

  • NumPy 有一个内置函数,numpy.linalg.solve,它接受一个矩阵和一个向量,并在可能的情况下自动求解相应的线性方程组。

  • 一些线性方程组无法求解。例如,如果两条直线平行,它们可能没有交点,或者有无限多个交点(这意味着它们是同一条直线)。这意味着没有(x, y)值可以同时满足这两条直线的方程。表示这种系统的矩阵被称为奇异的

  • 3D 空间中的平面是 2D 空间中直线的类似物。它们是满足形式为ax + by + cz = d的方程的点集(x, y, z)。

  • 3D 空间中的两个非平行平面在无限多个点上相交,并且具体来说,它们共有的点集在 3D 空间中形成一条 1D 线。三个平面可以有一个唯一的交点,这个交点可以通过求解表示这些平面的三个线性方程组来找到。

  • 2D 空间中的直线和 3D 空间中的平面都是超平面的例子,它们是n维空间中满足单个线性方程的点集。

  • n维空间中,你需要至少n个线性方程组来找到一个唯一解。如果你恰好有n个线性方程,并且它们有唯一解,那么这些方程被称为独立方程

  • 确定如何将一个向量表示为给定向量集的线性组合在计算上等同于求解一个线性方程组。如果向量集是空间的基,这总是可能的。

第二部分:微积分与物理模拟

在本书的第二部分,我们开始对微积分进行概述。从广义上讲,微积分是连续变化的研究,因此我们谈论了很多如何测量不同量的变化率以及这些变化率可以告诉我们什么。

在我看来,微积分之所以被认为是一门难学的科目,并不是因为其概念不熟悉,而是因为需要大量的代数知识。如果你曾经拥有或驾驶过汽车,你对速度和累积值的直观理解就存在:速度表测量你在一段时间内的移动速度,而里程表测量你驾驶的总里程数。在一定程度上,它们的测量必须一致。如果你的速度表在一段时间内显示的值更高,你的里程表应该增加更多,反之亦然。

在微积分中,我们了解到如果我们有一个在任何时间给出累积值的函数,我们可以计算其变化率,这也是一个随时间变化的函数。将“累积”函数转换为“率”函数的操作称为导数。同样,如果我们从一个率函数开始,我们可以重建一个与它一致的累积函数,这称为积分操作。我们在第八章中花费了全部时间确保这些转换在概念上是合理的,将其应用于测量的流体体积(一个累积函数)和流体流速(相应的率函数)。在第九章中,我们将这些思想扩展到多个维度。为了在视频游戏引擎中模拟移动对象,我们需要独立考虑每个坐标中速度和位置之间的关系。

一旦你在第八章和第九章中对微积分有了概念上的理解,我们将在第十章中介绍其机制。我们将比在普通的微积分课程中更有趣,因为 Python 将为我们做大部分公式计算。我们将数学表达式建模成小程序,我们可以解析和转换它们以找到它们的导数和积分。因此,第十章展示了在代码中做数学的相当不同的方法,这种方法被称为符号编程

在第十一章,我们回到多维微积分。虽然速度计上的速度或通过管道的流体流速是随时间变化的函数,但我们也可以有随空间变化的函数。这些函数以向量作为输入,并返回数字或向量作为输出。例如,将重力强度表示为二维空间上的函数,可以使我们为第七章的视频游戏添加一些有趣的物理元素。对于随空间变化的函数,一个关键的微积分操作是梯度,这是一个告诉我们函数在哪个空间方向上增加最快的操作。因为它是测量一个率,所以梯度就像是一个普通导数的向量版本。在第十二章,我们使用梯度来优化一个函数或找到使其返回最大输出的输入。通过跟随梯度向量的方向,我们可以找到越来越大的输出,最终,我们可以收敛到整个函数的最大值。

在第十三章,我们介绍微积分的另一种完全不同的应用。结果证明,一个函数的积分可以告诉我们很多关于函数图形几何的信息。特别是,两个函数乘积的积分告诉我们它们的图形有多相似。我们将应用这种分析到声波上。声波是一个描述声音的函数的图形,这个图形告诉我们声音是响亮还是柔和,是高音还是低音,等等。通过比较不同音符的声波,我们可以找出它包含的音符。将声波视为一个函数,对应于一个重要的数学概念,称为傅里叶级数

与第一部分相比,第二部分更像是一个主题拼盘,但有两个主要主题你应该关注。第一个是函数变化率的观念;一个函数在一点上是增加还是减少,这告诉我们如何找到更大或更小的值。第二个是关于一个操作,它以函数作为输入并返回函数作为输出。在微积分中,许多问题的答案都是以函数的形式出现的。这两个观念将是我们在第三部分机器学习应用中的关键。

8 理解变化率

本章涵盖

  • 计算数学函数的平均变化率

  • 近似某点的瞬时变化率

  • 想象变化率本身是如何变化的

  • 从其变化率重建函数

在本章中,我向你介绍了微积分中最重要的一些概念:导数和积分。这两个操作都与函数一起工作。导数接受一个函数并给你另一个函数,该函数测量其变化率。积分做的是相反的事情;它接受一个表示变化率的函数,并给你一个测量原始累积值的函数。

我将专注于我从自己的石油生产数据分析工作中提取的一个简单例子。我们将设想的情况是一个泵从油井中抽出原油,然后通过管道流入油罐。管道配备了一个连续测量流体流速的仪表,油罐配备了一个传感器,它可以检测油罐中液体的高度并报告存储在其中的油的体积(图 8.1)。

图 8.1 从油井中抽取油并将其泵入油罐的泵的示意图

体积传感器的测量告诉我们油罐中油的体积作为时间的函数,而流量计的测量告诉我们每小时流入油罐的体积,这也是作为时间的函数。在这个例子中,体积是累积值,流速是其变化率。

在本章中,我们解决两个主要问题。首先,在我们的例子中,我们从一个已知的时间累积体积开始,使用导数计算作为时间的函数的流速。其次,我们执行相反的任务,从一个作为时间的函数的流速开始,使用积分计算油罐中油的累积体积。图 8.2 展示了这个过程。

图 8.2 使用导数从体积中找到随时间变化的流速,然后使用积分从流速中找到随时间变化的体积

我们将编写一个名为get_flow_rate(volume_function)的函数,它接受体积函数作为输入,并返回一个新 Python 函数,该函数在任何时间给出流速。然后我们将编写第二个函数get_volume(flow_rate_function),它接受流速函数并返回一个 Python 函数,该函数给出随时间变化的体积。我在过程中穿插一些较小的例子作为热身,帮助你思考变化率。

尽管它的基本思想并不复杂或陌生,但微积分因其需要大量的繁琐代数而名声不佳。因此,我在本章中侧重于介绍新思想,而不是很多新技术。大多数例子只需要我们在第七章中覆盖的线性函数数学。让我们开始吧!

8.1 从体积计算平均流速

让我们先假设我们知道随时间变化的罐中体积,这被编码为一个名为 volume 的 Python 函数。这个函数接受一个参数,即从预定义的起始点之后的小时数,并返回该时间点的油罐体积,以桶(缩写为“bbl”)为单位。为了将重点放在思想上而不是代数上,我甚至不会告诉你 volume 函数的公式(尽管如果你好奇可以在源代码中看到它)。你现在需要做的只是调用它并绘制它。当你绘制它时,你会看到类似于图 8.3 的东西。

图片

图 8.3 volume 函数的绘图显示了随时间变化的油罐体积。

我们希望朝着在任何时间点找到进入罐中流速的方向前进,因此,作为我们的第一步,让我们以直观的方式计算这个值。在这个例子中,让我们编写一个函数 average_flow_rate(v, t1, t2),它接受一个体积函数 v,一个起始时间 t1 和一个结束时间 t2,并返回一个数字,表示在时间间隔内进入罐的平均流速。也就是说,它告诉我们每小时进入罐中的总桶数。

8.1.1 实现 average_flow_rate 函数

“每小时桶数”中的“每”一词表明我们将进行一些除法来得到答案。计算平均流速的方法是将总体体积变化除以经过的时间:

图片

从起始时间 t[1] 到结束时间 t[2] 测量的时间(以小时为单位)是 t[2] − t[1]。如果我们有一个函数 V(t),它告诉我们体积作为时间的函数,总体体积变化是 t[2] 时的体积减去 t[1] 时的体积,即 V(t[2]) − V(t[1])。这给了我们一个更具体的方程来工作:

图片

这是我们计算不同情境中变化率的方法。例如,当你开车时,你的速度是你相对于时间覆盖距离的速率。为了计算你的平均速度,你将行驶的总英里数除以经过的小时数,以每小时英里(mph)的结果。为了知道行驶的距离和经过的时间,你需要在旅行的开始和结束时检查你的时钟和里程表。

我们的平均流速公式依赖于体积函数 V 和起始时间 t[1] 和结束时间 t[2],这些是我们将传递给相应 Python 函数的参数。函数的主体是将这个数学公式直接翻译成 Python:

def average_flow_rate(v,t1,t2):
    return (v(t2) - v(t1))/(t2 - t1)

这个函数很简单,但重要到足以作为一个示例计算来讲解。让我们使用volume函数(如图 8.3 所示,并包含在本书的源代码中),并假设我们想知道在 4 小时标记和 9 小时标记之间油罐的平均流速。在这种情况下,t1 = 4t2 = 9。为了找到起始和结束的体积,我们可以在这两个时间点上评估volume函数:

>>> volume(4)
3.3
>>> volume(9)
5.253125

为了简化计算,两个体积之间的差值是 5.25 bbl − 3.3 bbl = 1.95 bbl,总经过时间是 9 hr − 4 hr = 5 hr。因此,油罐的平均流速大约是 1.95 bbl 除以 5 hr,即 0.39 bbl/hr。我们的函数确认我们得到了正确的结果:

>>> average_flow_rate(volume,4,9)
0.390625

图片

图 8.4 一条割线连接了体积图上的起始点和结束点。

这完成了我们寻找函数变化率的第一个基本示例。这并不太难!在我们继续一些更有趣的例子之前,让我们花更多的时间来解释体积函数的作用。

8.1.2 使用割线描绘平均流速

考虑到体积随时间变化的平均变化率,另一种有用的思考方式是查看体积图。让我们专注于我们计算平均流速的两个体积图上的点。在图 8.4 中,这些点在图上显示为点,我画了一条穿过它们的线。穿过这种图上两点的一条线被称为割线

如您所见,由于在这个时间段内油罐中的油量增加,所以在 9 小时时的图形比 4 小时时更高。这导致连接起始点和结束点的割线向上倾斜。结果证明,割线的斜率精确地告诉我们时间间隔内的平均流速。

原因如下。给定直线上的两个点,斜率是垂直坐标变化除以水平坐标变化。在这种情况下,垂直坐标从V(t[1])变为V(t[2]),变化为V(t[2]) − V(t[1]),水平坐标从t[1]变为t[2],变化为t[2] − t[1]。斜率因此是(V(t[2]) − V(t[1]))除以(t[2] − t[1]),这与平均流速的计算完全相同(图 8.5)!

图片

图 8.5 我们以计算volume函数平均变化率相同的方式计算割线的斜率。

在我们继续的过程中,您可以在图上想象割线来推理函数的平均变化率。

8.1.3 负变化率

值得简要提及的一个例子是割线可以具有斜率。图 8.6 显示了不同体积函数的图形,您可以在本书的源代码中找到作为decreasing_volume实现的代码。图 8.6 显示了油罐中体积随时间减少的情况。

图片

图 8.6 不同的 volume 函数显示油箱中的体积随时间减少。

这个例子与我们的前一个例子不兼容,因为我们不期望油会从油箱流回地面。但它确实说明了割线可以向下延伸,例如,从 t = 0 到 t = 4。在这个时间段内,体积变化为 -3.2 bbl(图 8.7)。

图 8.7 定义具有负斜率的割线的图上的两个点

在这种情况下,斜率为 -3.2 bbl 除以 4 小时,即 -0.8 bbl/小时。这意味着油进入油箱的速度为 -0.8 bbl/小时。更合理的说法是,油以 0.8 bbl/小时的速度离开油箱。无论 volume 函数是增加还是减少,我们的 average_flow_rate 函数都是可靠的。在这种情况下,

>>> average_flow_rate(decreasing_volume,0,4)
-0.8

配备了这个函数来测量平均流速,我们可以在下一节中更进一步−了解流速随时间的变化。

8.1.4 练习

练习 8.1:假设您在中午出发开始长途旅行,当时您的里程表读数为 77,641 英里,您在下午 4:30 结束旅行,当时里程表读数为 77,905 英里。旅行中的平均速度是多少?解答:总行程为 77,905 − 77,641 = 264 英里,覆盖了 4.5 小时。平均速度为 264 英里 / 4.5 小时,约为 58.7 英里/小时。

| 练习 8.2:编写一个 Python 函数 secant_line(f,x1,x2),该函数接受一个函数 *f*(*x*) 和两个值 x1x2,并返回一个表示随时间变化的割线的函数。例如,如果您运行 line = secant_line (f,x1,x2),那么 line(3) 将给出在 x = 3 处的割线的 y 值。解答

def secant_line(f,x1,x2):
    def line(*x*):
        return f(x1) + (x-x1) * (f(x2)-f(x1))/(x2-x1)
    return line

|

| 练习 8.3:编写一个函数,使用前一个练习中的代码来绘制两个给定点之间函数 f 的割线。解答

def plot_secant(f,x1,x2,color='k'):
    line = secant_line(f,x1,x2)
    plot_function(line,x1,x2,c=color)
    plt.scatter([x1,x2],[f(x1),f(x2)],c=color)

|

8.2 随时间绘制平均流速

我们本章的一个主要目标是,从体积函数开始,恢复流速函数。为了找到流速作为时间的函数,我们需要了解油箱在不同时间点的体积变化速度。首先,我们可以从图 8.8 中看到,流速随时间变化−体积图上的不同割线具有不同的斜率。

图 8.8 体积图上的不同割线具有不同的斜率,表明流速在变化。

在本节中,我们通过在不同时间段计算平均流速来更接近找到流速作为时间的函数。我们将 10 小时的时间段分成多个较短且固定持续时间的小时间段(例如,十个 1 小时的时间段),并为每个时间段计算平均流速。

我们将这项工作封装在一个名为interval_flow_rates(v,t1, t2,dt)的函数中,其中v是体积函数,t1t2是起始和结束时间,dt是时间间隔的固定持续时间。此函数返回时间与流速的成对列表。例如,如果我们将 10 小时分成 1 小时段,结果应该如下所示:

[(0,...), (1,...), (2,...), (3,...), (4,...), (5,...), (6,...), (7,...),
     (8,...), (9,...)]

其中每个...将被相应小时的流速所替换。一旦我们得到这些成对,我们就可以将它们作为散点图绘制在章节开头的流速函数旁边,并比较结果。

8.2.1 在不同时间间隔中寻找平均流速

作为实现interval_flow_rates()的第一步,我们需要找到每个时间间隔的起始点。这意味着找到从起始时间t1到结束时间t2的时间值列表,增量是时间间隔长度dt。Python 的 NumPy 库中有一个方便的函数叫做arange,它可以为我们完成这个任务。例如,从时间零开始,以 0.5 小时为增量到时间 10,我们得到以下时间间隔起始时间:

>>> import numpy as np
>>> np.arange(0,10,0.5)
array([0\. , 0.5, 1\. , 1.5, 2\. , 2.5, 3\. , 3.5, 4\. , 4.5, 5\. , 5.5, 6\. ,
       6.5, 7\. , 7.5, 8\. , 8.5, 9\. , 9.5])

注意,10 小时结束时间不包括在列表中。这是因为我们列出每个半小时的起始时间,而从t =10 到t =10.5 的半小时不是我们考虑的整体时间间隔的一部分。

对于这些时间间隔的起始时间,加上dt将返回相应的结束时间。例如,在前面列表中,从 3.5 小时开始的时间间隔结束于 3.5 + 0.5 = 4.0 小时。要实现interval_flow_rates函数,我们只需在各个时间间隔上使用我们的average_flow_rate函数。下面是这个完整函数的示例:

def interval_flow_rates(v,t1,t2,dt):
    return [(t,average_flow_rate(v,t,t+dt))      ❶
                for t in np.arange(t1,t2,dt)]

❶ 对于每个时间间隔的起始时间t,计算从tt+dt的平均流速。(我们想要的是t与相应流速的成对列表。)

如果我们将volume函数与 0 小时和 10 小时作为起始和结束时间,以及 1 小时作为时间间隔长度传递,我们将得到一个列表,告诉我们每个小时的流速:

>>> interval_flow_rates(volume,0,10,1)
[(0, 0.578125),
 (1, 0.296875),
 (2, 0.109375),
 (3, 0.015625),
 (4, 0.015625),
 (5, 0.109375),
 (6, 0.296875),
 (7, 0.578125),
 (8, 0.953125),
 (9, 1.421875)]

通过查看这个列表,我们可以得出一些结论。平均流速始终为正,这意味着在每小时中油罐中都有净增加的油量。流速在 3 小时和 4 小时左右降至最低值,然后在最后一小时增加到最高值。如果我们在图上绘制,这会更加清晰。

8.2.2 绘制时间间隔流速图

我们可以使用 Matplotlib 的scatter函数快速绘制这些流速随时间变化的图表。该函数在给定的水平坐标列表后面跟一个垂直坐标列表的情况下,在图上绘制一系列点。我们需要提取时间和流速作为两个单独的 10 数字列表,然后将它们传递给该函数。为了避免重复这个过程,我们可以将其全部构建到一个函数中:

def plot_interval_flow_rates(volume,t1,t2,dt):
    series = interval_flow_rates(volume,t1,t2,dt)
    times = [t for (t,_) in series]
    rates = [q for (_,q) in series]
    plt.scatter(times,rates)

调用plot_interval_flow_rates(volume,0,10,1)生成由interval_flow_rates产生的数据的散点图。图 8.9 显示了从 0 小时到 10 小时以 1 小时为增量绘制volume函数的结果。

图片

图 8.9 每小时的平均流量图

这证实了我们从数据中看到的情况:平均流量在 3 小时和 4 小时左右降至最低值,然后在此之后再次增加,达到近 1.5 桶/小时的最高速率。让我们将这些平均流量与实际流量函数进行比较。同样,我不想让你担心流量随时间变化的公式。我在这本书的源代码中包含了一个flow_rate函数,我们可以绘制它(图 8.10),以及散点图。

图片

图 8.10 每小时的平均流量图(点)和每小时的实际流量图(平滑曲线)

这两个图表讲述的是同一个故事,但它们并不完全吻合。区别在于点测量平均流量,而flow_rate函数显示了在任何时间点的流量瞬时值

为了理解这一点,再次思考长途旅行的例子可能会有所帮助。如果你在 1 小时内行驶了 60 英里,你的平均速度是 60 英里/小时。然而,你的速度计在每小时的每一瞬间都显示 60 英里/小时的可能性不大。在开阔道路上某个地方,你的瞬时速度可能达到 70 英里/小时,而在交通中,你可能会减速到 50 英里/小时。

同样,管道上的流量计不需要与下一小时的平均流量一致。实际上,如果你使时间间隔更小,图表会更接近。图 8.11 显示了 20 分钟间隔(1/3 小时)的平均流量图,与流量函数并排。

图片

图 8.11 流量随时间变化的图表与 20 分钟间隔的平均流量图

平均流量仍然与瞬时流量不完全匹配,但它们要接近得多。在下一节中,我们将继续这个想法,并计算极小时间间隔的流量,其中平均流量和瞬时流量的差异几乎不可察觉。

8.2.3 练习

练习 8.4:以 0.5 小时为间隔绘制decreasing_volume流量随时间的变化。何时其流量最低?也就是说,何时油罐中油流出速度最快?解答:运行plot_interval_flow_rates(decreasing_volume,0, 10,0.5),我们可以看到在 5 小时前流量最低(最负值)!图片

| 练习 8.5:编写一个linear_volume_function并绘制流量随时间的变化图以显示它是恒定的。解答:一个linear_volume_function(*t*)的形式为 V(t) = at + b,其中 ab 是常数。例如,

def linear_volume_function(t):
    return 5*t + 3

plot_interval_flow_rates(linear_volume_function,0,10,0.25)

图像此图显示,对于线性体积函数,流量随时间保持恒定。|

8.3 近似瞬时流量

随着我们计算体积函数在越来越小的时段时间内的平均变化率,我们越来越接近测量单个瞬间的实际情况。但如果我们尝试测量单个瞬间的体积平均变化率,即起始时间和结束时间相同的区间,我们会遇到麻烦。在时间 t 时,平均流量公式的读数如下:

图像

0 除以 0 是未定义的,所以这种方法不起作用。这就是代数不再帮助我们,我们需要转向微积分推理的地方。在微积分中,有一个称为导数的运算,它绕过这个未定义的除法问题,告诉你函数的瞬时变化率。

在本节中,我将解释瞬时流量函数(在微积分中称为体积函数的导数)为何定义良好,以及如何近似它。我们将编写一个函数 instantaneous_flow_rate(v,t),它接受一个体积函数 v 和一个时间点 t,并返回油流入油罐的瞬时流量的近似值。这个结果是以每小时桶数表示的,应该与 instantaneous_flow_rate 函数的值完全匹配。

一旦我们这样做,我们将编写第二个函数 get_flow_rate_function(*v*),它是 instantaneous_flow_rate() 的柯里化版本。它的参数是一个体积函数,它返回一个函数,该函数接受一个时间并返回一个瞬时流量。这个函数完成了我们本章的两个主要目标中的第一个:从一个体积函数开始,生成相应的流量函数。

8.3.1 求小割线的斜率

在我们进行任何编码之前,我想说服你首先讨论“瞬时流量”是有意义的。为此,让我们放大单个瞬间的体积图,看看发生了什么(图 8.12)。让我们选择 t = 1 小时的位置,并观察它周围的小窗口。

图像

图 8.12 在 t = 1 小时附近的 1 小时窗口放大

在这个较短的时间间隔内,我们不再看到体积图曲线的很多部分。也就是说,图形的陡峭程度在整个 10 小时窗口中变化较小。我们可以通过绘制一些割线并观察它们的斜率相当接近(图 8.13)来测量这一点。

图像

图 8.13 在 t = 1 小时附近的两个割线具有相似的斜率,这意味着在这个时间间隔内流量变化不大。

如果我们进一步放大,图表的陡峭度看起来越来越恒定。将放大到 0.9 小时和 1.1 小时之间的间隔,体积图几乎是一条直线。如果你在这段间隔上画一条割线,几乎看不到图表高于割线的上升(图 8.14)。

图片

图 8.14 在 t = 1 小时附近的较小时间间隔内,体积图看起来几乎是直线。

最后,如果我们放大到 t = 0.99 小时和 t = 1.01 小时之间的窗口,体积图与直线无法区分(图 8.15)。在这个层面上,割线似乎与函数图完全重叠,看起来像一条线。

图片

图 8.15 进一步放大,体积图在视觉上与直线无法区分。

如果你继续放大,图表看起来会越来越像一条线。并不是图表在这个点就是一条线,而是当你放大时,它越来越接近看起来像一条线。在微积分中,我们可以做出的推理飞跃是,在任何一点,都有一个单一的、最佳的线来逼近像体积图这样的平滑图表。以下是一些计算,表明越来越小的割线斜率会收敛到一个单一的值,这表明我们确实正在接近斜率的单一“最佳”逼近:

>>> average_flow_rate(volume,0.5,1.5)
0.42578125
>>> average_flow_rate(volume,0.9,1.1)
0.4220312499999988
>>> average_flow_rate(volume,0.99,1.01)
0.42187656249998945
>>> average_flow_rate(volume,0.999,1.001)
0.42187501562509583
>>> average_flow_rate(volume,0.9999,1.0001)
0.42187500015393936
>>> average_flow_rate(volume,0.99999,1.00001)
0.4218750000002602

除非这些零点是极大的巧合,我们趋近的数字是 0.421875 bbl/hr。我们可以得出结论,在 t = 1 小时时体积函数的最佳逼近线的斜率为 0.421875。如果我们再次放大(图 8.16),我们可以看到这条最佳逼近线的外观。

图片

图 8.16 在时间 t = 1 小时时,斜率为 0.421875 的线是体积函数的最佳逼近。

这条线被称为在点 t = 1 处体积图的切线,它之所以与众不同,是因为它在该点与体积图平行。因为切线是最佳逼近体积图的线,所以它的斜率是衡量该图瞬时斜率(即,t = 1 处的瞬时流速)的最佳指标。瞧瞧,我提供的源代码中的flow_rate函数给出的数字正是越来越小的割线斜率所趋近的数字:

>>> flow_rate(1)
0.421875

要有一条切线,一个函数需要是“平滑”的。在本节末尾的迷你项目中,你可以尝试用不平滑的函数重复这个练习,你会发现没有最佳逼近线。当我们能在某一点找到函数图的切线时,该点的斜率被称为该函数的导数。例如,体积函数在 t = 1 处的导数等于 0.421875(桶/小时)。

8.3.2 构建瞬时流速函数

现在我们已经看到了如何计算体积函数的瞬时变化率,我们有了实现instantaneous_flow_rate函数所需的一切。我们之前使用的程序自动化的一个主要障碍是,Python 无法“目测”几条小割线段的斜率并决定它们收敛到哪个数字。为了解决这个问题,我们可以计算越来越小的割线线段的斜率,直到它们稳定到一定的小数位数。

例如,我们可能决定要找到一系列割线线的斜率,每一条都比前一条窄十分之一,直到数值稳定到四位小数。以下表格再次显示了斜率。

割线线段间隔 割线线段斜率
0.5 to 1.5 0.42578125
0.9 to 1.1 0.4220312499999988
0.99 to 1.01 0.42187656249998945
0.999 to 1.001 0.42187501562509583

在最后两行中,斜率在四位小数上是一致的(它们之间的差异小于 10^(-4)),因此我们可以将最终结果四舍五入到 0.4219,并将其称为我们的结果。这并不是 0.421875 的确切结果,但它是对指定小数位数的良好近似。

固定近似数的小数位数后,我们现在有了一种方法来判断是否完成。如果在经过大量步骤之后,我们还没有收敛到指定的小数位数,我们可以认为不存在最佳近似线,因此在该点没有导数。以下是这个程序如何转换为 Python 代码:

def instantaneous_flow_rate(v,t,digits=6):
    tolerance = 10 ** (−digits)                      ❶
    h = 1
    approx = average_flow_rate(v,t-h,t+h)            ❷
    for i in range(0,2*digits):                      ❸
        h = h / 10
        next_approx = average_flow_rate(v,t-h,t+h)   ❹
        if abs(next_approx − approx) < tolerance:
            return round(next_approx,digits)         ❺
        else:
            approx = next_approx                     ❻
    raise Exception("Derivative did not converge")   ❼

❶ 如果两个数字之间的差异小于 10^(-d)的容差,则它们在 d 位小数上一致。

❷ 在目标点 t 两侧各 1 个单位长度的间隔上计算第一条割线线的斜率

❸ 作为粗略近似,我们在放弃收敛之前只尝试 2·digits 次迭代。

❹ 在每一步,计算围绕点 t 在 10 倍更小间隔上的新割线线的斜率

❺ 如果最后两个近似值之间的差异小于容差,则四舍五入结果并返回

❻ 否则,以更小的间隔再次运行循环

❼ 如果我们超过了最大迭代次数,则程序没有收敛到结果。

我任意选择了六位数字作为默认精度,因此这个函数与我们在 1 小时标记处的瞬时流速结果相匹配:

>>> instantaneous_flow_rate(volume,1)
0.421875

我们现在可以计算任何时间点的瞬时流速,这意味着我们有了流速函数的完整数据。接下来,我们可以绘制它并确认它是否与我在源代码中提供的flow_rate函数相匹配。

8.3.3 对瞬时流速函数进行柯西和绘图

对于一个像源代码中的flow_rate函数那样表现的行为,接受一个时间变量并返回一个流速,我们需要对instantaneous_flow_rate函数进行柯西。柯西函数接受一个体积函数(v)并返回一个流速函数:

def get_flow_rate_function(*v*):
    def flow_rate_function(t):
        instantaneous_flow_rate(v,t)
    return flow_rate_function

get_flow_rate_function(v*)的输出是一个函数,它应该与源代码中的flow_rate函数相同。我们可以在 10 小时的时间段内绘制这两个函数以确认,确实,图 8.17 显示它们的图形无法区分:

plot_function(flow_rate,0,10)
plot_function(get_flow_rate_function(volume),0,10)

图片

图 8.17 将flow_rate函数与get_flow_rate函数一起绘制,显示它们的图形无法区分。

我们已经完成了本章的第一个主要目标,从体积函数中产生了流量函数。正如我在本章开头提到的,这个过程被称为求导

给定一个像“体积”函数这样的函数,另一个在任意给定点给出其瞬时变化率的函数被称为其导数。你可以将导数想象成一个操作,它接受一个(足够平滑的)函数并返回另一个函数,测量原始函数的变化率(图 8.18)。在这种情况下,可以说流量函数是体积函数的导数。

图片

图 8.18 你可以将导数想象成一个机器,它接受一个函数并返回另一个函数,测量输入函数的变化率。

导数是一个通用的过程,它适用于任何足够平滑以在每一点都有切线的函数 f(x)。函数 f 的导数写作 f'(并读作“f 的导数”),所以 f'(x)表示 f 相对于 x 的瞬时变化率。具体来说,f'(5)是 f(x)在 x = 5 处的导数,测量 f 在 x = 5 处的切线斜率。函数导数还有一些其他常见的表示法,包括:

图片

df 和 dx 分别表示 f 和 x 的无限小(无限小)变化,它们的商给出了无限小割线的斜率。这三个表示法中的最后一个很好,因为它使 d/dx 看起来像是对 f(x)应用的操作。在许多情况下,你会看到独立的导数运算符,如 d/dx。这特别意味着“对 x 求导的操作。”图 8.19 显示了这些表示法如何结合在一起。

图片

图 8.19 “关于 x 的导数”作为一个操作,它接受一个函数并返回一个新的函数

在本书的其余部分,我们将更多地使用导数,但就目前而言,让我们转向其对应操作——积分。

8.3.4 练习

| 练习 8.6:确认“体积”函数在 0.999 小时到 1.001 小时的时间间隔内不是一条直线。解答:如果它是一条直线,那么在每一个点上它都等于其割线。然而,从 0.999 小时到 1.001 小时的割线在 t = 1 小时时的值与“体积”函数不同:

>>> volume(1)
2.878125
>>> secant_line(volume,0.999,1.001)(1)
2.8781248593749997

|

| 练习 8.7:通过计算围绕t = 8 的越来越小的割线的斜率,来近似t = 8 时体积图的切线斜率。解答

>>> average_flow_rate(volume,7.9,8.1)
0.7501562500000007
>>> average_flow_rate(volume,7.99,8.01)
0.750001562499996
>>> average_flow_rate(volume,7.999,8.001)
0.7500000156249458
>>> average_flow_rate(volume,7.9999,8.0001)
0.7500000001554312

看起来在t = 8 时的瞬时变化率是 0.75 桶/小时。|

| 练习 8.8:对于 Python 中定义的sign函数,说服自己它在x = 0 处没有导数:

def sign(*x*):
    return *x*/ abs(*x*)

解答:在越来越小的间隔内,割线的斜率越来越大,而不是收敛到一个单一的数字:

>>> average_flow_rate(sign,-0.1,0.1)
10.0
>>> average_flow_rate(sign,-0.01,0.01)
100.0
>>> average_flow_rate(sign,-0.001,0.001)
1000.0
>>> average_flow_rate(sign,−0.000001,0.000001)
1000000.0

这是因为sign函数在x = 0 处立即从-1 跳到 1,当你放大查看时,它看起来并不像一条直线。|

8.4 近似体积变化

在本章的剩余部分,我将专注于我们的第二个主要目标:从一个已知的流量函数中恢复体积函数。这是求导过程的逆过程,因为我们假设我们知道函数的变化率,我们想要恢复原始函数。在微积分中,这被称为积分

我会将恢复体积函数的任务分解成几个更小的例子,这将帮助你了解积分是如何工作的。对于第一个例子,我们编写两个 Python 函数来帮助我们找到在指定时间段内油箱中的体积变化。

我们称第一个函数为brief_volume_change(q,t,dt),它接受一个流量函数q,一个时间t,以及一个短的时间持续时间dt,该函数返回从时间t到时间t + dt的体积变化的近似值。这个函数通过假设时间间隔非常短,流量变化不大来计算其结果。

我们称第二个函数为volume_change(q,t1,t2,dt),正如命名上的差异所暗示的,我们用它来计算任何时间间隔的体积变化,而不仅仅是短暂的时间间隔。它的参数是流量函数q,一个起始时间t1,一个结束时间t2,以及一个小的时间间隔dt。该函数将时间间隔分解为持续时间dt的增量,这些增量足够短,可以使用brief_volume_change函数。返回的总体积变化是所有短时间间隔体积变化的总和。

8.4.1 在短时间间隔内寻找体积变化

要理解brief_volume_change函数背后的原理,让我们回到熟悉的汽车速度表例子。如果你瞥了一眼速度表,它显示正好是 60 英里/小时,你可能会预测在接下来的 2 小时内,你会行驶 120 英里,这是 2 小时乘以 60 英里/小时。如果你运气好,这个估计可能是正确的,但也有可能速度限制提高了,或者你离开了高速公路并停车。关键是,仅仅看一眼速度表并不能帮助你估计长时间内的行驶距离。

另一方面,如果你使用 60 英里/小时的速度来计算你在查看速度表后单秒内行驶的距离,你可能会得到一个非常准确的答案;你的速度在一秒内不会变化太多。一秒是小时的 1/3600,所以 60 英里/小时乘以每小时的 1/3600 给你 1/60 英里,或 88 英尺。除非你正在积极地踩刹车或油门到底,这很可能是一个好的估计。

返回到流速和体积,假设我们正在处理一个足够短的时间段,使得流速大致恒定。换句话说,时间间隔内的流速接近时间间隔内的平均流速,因此我们可以应用我们的原始方程:

重新排列这个方程,我们可以得到体积变化的近似:

我们的small_volume_change函数只是将这个假设公式翻译成 Python 代码。给定一个流速函数q,我们可以找到输入时间t的流速为q(*t*),我们只需要将其乘以持续时间dt以得到体积的变化:

def small_volume_change(q,t,dt):
    return q(t) * dt

因为我们现在有一对实际的体积和流速函数,我们可以现在测试我们的近似有多好。正如预期的那样,对于整个小时的间隔,预测并不太好:

>>> small_volume_change(flow_rate,2,1)
0.1875
>>> volume(3) − volume(2)
0.109375

这个近似值偏差大约 70%。相比之下,我们在 0.01 小时的时间间隔上得到了很好的近似。结果是实际体积变化的 1%以内:

>>> small_volume_change(flow_rate,2,0.01)
0.001875
>>> volume(2.01) − volume(2)
0.0018656406250001645

因为我们可以得到小时间间隔内体积变化的良好近似,我们可以将它们拼接起来以得到较长时间间隔的体积变化。

8.4.2 将时间分成更小的间隔

要实现函数volume_change(q,t1,t2,dt),我们将从t1t2的时间分成持续时间为dt的间隔。为了简单起见,我们只会处理可以均匀除尽t2 - t1dt值,这样我们就可以将时间周期分成整数的间隔。

再次,我们可以使用 NumPy 的arange函数来获取每个间隔的起始时间。函数调用np.arange(t1,t2,dt)给我们一个从t1t2,以dt为增量的时间数组。对于这个数组中的每个时间值t,我们可以使用small_volume_change找到随后时间间隔的体积变化。最后,我们需要将结果相加以得到所有间隔的总体积变化。这可以在大约一行代码中完成:

def volume_change(q,t1,t2,dt):
    return sum(small_volume_change(q,t,dt)
               for t in np.arange(t1,t2,dt))

使用这个函数,我们可以将 0 到 10 小时的时间分成 100 个持续时间为 0.1 小时的时间间隔,并计算每个时间间隔内的体积变化。结果与实际体积变化匹配到小数点后一位:

>>> volume_change(flow_rate,0,10,0.1)
4.32890625
>>> volume(10) − volume(0)
4.375

如果我们将时间分成越来越小的间隔,结果会越来越好。例如:

>>> volume_change(flow_rate,0,10,0.0001)
4.3749531257812455

就像求导的过程一样,我们可以使间隔越来越小,我们的结果将收敛到预期的答案。从某个区间内的变化率计算函数的整体变化被称为定积分。我们将在最后一节回到定积分的定义,但现在是时候关注如何描绘它了。

8.4.3 在流量图上描绘体积变化

假设我们将 10 小时的时间段划分为 1 小时的间隔,即使我们知道这不会给我们非常准确的结果。我们唯一关心的流量图上的 10 个点,是每个间隔的开始时间:0 小时,1 小时,2 小时,3 小时,以此类推,直到 9 小时。图 8.20 显示了这些点在图上的标记。

图片

图 8.20 绘制用于计算volume_change(flow_rate,0,10,1)的点

我们的计算假设每个间隔内的流量保持恒定,这显然是不正确的。在每个这样的间隔内,流量明显是变化的。在我们的假设中,这就像我们正在使用一个不同的流量函数,其图形在每小时都是恒定的。图 8.21 显示了这些间隔与原始图形并排的样子。

图片

图 8.21 如果我们假设每个间隔内的流量是恒定的,其图形将看起来像一座上下起伏的楼梯。

在每个间隔内,我们计算流量(即每个平坦图形段的高度)乘以 1 小时的经过时间(即每个图形段的宽度)。我们计算出的每个小体积是图上的高度乘以宽度,或者是一个想象中的矩形的面积。图 8.22 显示了填充的矩形。

图片

图 8.22 体积的整体变化是 10 个矩形面积的累加

图片

图 8.23 流量图下方的体积是 30 个矩形面积(顶部)或 100 个矩形面积(底部)的累加

随着时间间隔的缩短,我们看到我们的结果在改善。直观上看,这对应着更多的矩形可以更紧密地贴合图形。图 8.23 显示了使用 30 个 1/3 小时(20 分钟)的间隔或 100 个 0.1 小时间隔的矩形的样子。

从这些图片中,你可以看到,随着我们的间隔变小,我们的计算结果接近实际的体积变化,矩形越来越接近填充流量图下方的空间。这里的见解是,在给定时间间隔下流量图下方的面积(近似地)等于同一时间间隔内添加到水箱中的体积。

将近似图形下方的矩形面积之和称为黎曼和。由越来越瘦的矩形组成的黎曼和收敛到图形下的面积,这与越来越小的割线斜率收敛到切线斜率的方式非常相似。我们将回到黎曼和与定积分的收敛性,但首先让我们在找到随时间变化的体积方面取得更多进展。

8.4.4 练习

| 练习 8.9:在前 6 小时内大约向油罐中添加了多少油?在最后 4 小时内?在哪个时间段添加的更多?解答:在前 6 小时内,大约有 1.13 桶油被泵入油罐,这比在最后 4 小时内泵入油罐的大约 3.24 桶油要少:

>>> volume_change(flow_rate,0,6,0.01)
1.1278171874999996
>>> volume_change(flow_rate,6,10,0.01)
3.2425031249999257

|

8.5 随时间绘制体积

在上一节中,我们能够从流速开始,对给定时间间隔内的体积变化进行近似。我们的主要目标是得到任何给定时间点的油罐体积。

这里有一个技巧问题:如果油以每小时 1.2 桶的恒定速率流入油罐 3 小时,3 小时后油罐中有多少油?答案是:我们不知道,因为我没有告诉你最初油罐中有多少油!幸运的是,如果告诉我,那么答案就很容易找出。例如,如果最初油罐中有 0.5 桶油,那么在这段时间内添加了 3.6 桶油,0.5 + 3.6 = 4.1 桶油在 3 小时结束时在油罐中。将时间零的初始体积加到任何时间T的体积变化上,我们可以找到时间T的总体积。

在本节的最后几个例子中,我们将这个想法转化为代码来重建体积函数。我们实现了一个名为approximate_volume(q,v0, dt,T)的函数,它接受一个流速q,油罐中初始的油体积v0,一个小的时间间隔dt,以及一个需要的时间T。该函数的输出是时间T时油罐中总体积的近似值,通过将起始体积v0加到从时间零到时间T的体积变化上。

一旦我们做到了这一点,我们就可以通过它得到一个名为approximate_volume_function(q,v0,dt)的函数,该函数产生一个作为时间函数的近似体积。approximate_volume_function返回的函数是我们可以在与原始体积函数进行比较时绘制的体积函数。

8.5.1 随时间找到体积

我们将使用的公式如下:

时间T的体积 = (时间 0 的体积) + (从时间 0 到时间T的体积变化)

我们需要提供求和的第一个项,即时间零时的罐内体积,因为没有方法可以从流速函数中推断它。然后我们可以使用我们的volume_change函数来找到从时间零到时间T的体积。下面是实现的示例:

def approximate_volume(q,v0,dt,T):
    return v0 + volume_change(q,0,T,dt)

为了计算这个函数,我们可以定义一个新的函数,它将前三个参数作为参数,并返回一个新的函数,该函数接受最后一个参数T

def approximate_volume_function(q,v0,dt):
    def volume_function(T):
        return approximate_volume(q,v0,dt,T)
    return volume_function

这个函数直接从我们的flow_rate函数生成一个可绘制的体积函数。因为我在源代码中提供的volume函数的volume(0)等于 2.3,所以让我们用这个值作为v0。最后,让我们尝试一个dt值为 0.5,这意味着我们以半小时(30 分钟)的间隔计算体积变化。让我们看看这与原始体积函数(图 8.24)的对比效果:

plot_function(approximate_volume_function(flow_rate,2.3,0.5),0,10)
plot_function(volume,0,10)

图片

图 8.24 approximate_volume_function(锯齿线)与原始volume函数(平滑线)的输出对比图

好消息是,输出非常接近我们的原始体积函数!但是approximate_volume_function产生的结果是锯齿状的,每隔 0.5 小时有一个步骤。你可能猜测这与我们的dt值 0.5 有关,如果我们减小这个值,我们会得到更好的近似。这是正确的,但让我们深入了解体积变化是如何计算的,以确切地了解为什么图表看起来是这样的,以及为什么更小的时间间隔会改善它。

8.5.2 体积函数的黎曼和图示

在任何时间点,我们通过近似volume函数计算的罐中体积是初始罐中体积加上到该点的体积变化。对于t = 4 小时,方程看起来是这样的:

4 小时时的体积 = (0 小时时的体积)+ (从 0 小时到 4 小时的体积变化)

这个和的结果给我们提供了图表上 4 小时标记的一个点。任何其他时间的值都是用相同的方式计算的。在这种情况下,和包括时间零点的 2.3 桶,以及一个黎曼和,给出了从 0 小时到 4 小时的变化。这是八个矩形的和,每个矩形的宽度为 0.5 小时,它们均匀地分布在 4 小时的窗口内。结果是大约 3.5 桶(图 8.25)。

图片

图 8.25 使用黎曼和计算 4 小时时罐中的体积

我们可以为任何其他时间点做同样的事情。例如,图 8.26 显示了 8 小时的结果。

图片

图 8.26 使用黎曼和计算 8 小时时罐中的体积

在这种情况下,答案是 8 小时标记时罐中大约 4.32 桶。这需要求和 8/0.5 = 16 个矩形面积。这两个值出现在我们生成的图表上(图 8.27)的点:

图片

图 8.27 在近似体积图上显示的前两个结果

在这两种情况下,我们可以通过使用整数的 timesteps 从零到问题中的时间点。为了生成这个图表,我们的 Python 代码计算了大量的黎曼和,对应于整数小时和半小时,以及所有绘制在之间的点。

我们如何得到 3.9 小时的近似体积,这个值不能被 0.5 小时的 dt 值整除?回顾 volume_change(q,t1,t2,dt) 的实现,我们在体积计算中做了一点点改变,对应于 np.arange(t1,t2,dt) 中每个起始时间的一个矩形的面积。当我们用 0.5 的 dt 值从 0 到 3.9 小时计算体积变化时,我们的矩形如下所示:

>>> np.arange(0,3.9,0.5)
array([0\. , 0.5, 1\. , 1.5, 2\. , 2.5, 3\. , 3.5])

尽管宽度为 0.5 小时的八个矩形超过了 3.9 小时的标记,我们计算了所有八个矩形的面积!为了完全干净,我们可能应该缩短最后一个时间间隔到 0.4 小时,从第 7 个时间间隔的结束时间 3.5 小时持续到 3.9 小时的结束时间,不再继续。作为一个本节末尾的小型项目,你可以尝试更新 volume_change 函数,如果需要的话,使用更短的时间间隔。现在,我将忽略这个疏忽。

在上一节中,我们看到了通过缩小 dt 值以及因此矩形的宽度,我们得到了更好的结果。除了更好地拟合图表外,较小的矩形即使略微超出时间间隔的末端,也可能会产生更少的误差。例如,0.5 小时的时间间隔只能累计到 3.5 小时或 4.0 小时,但不能累计到 3.9 小时,而 0.1 小时的时间间隔可以均匀累计到 3.9 小时。

8.5.3 改进近似

让我们尝试使用更小的 dt 值,对应于更小的矩形尺寸,并看看我们得到的改进。这是 dt = 0.1 小时的近似(图 8.28 展示了结果)。图表上的步骤几乎看不见,但它们更小,并且图表比 0.5 小时的时间间隔更接近实际的体积图表:

plot_function(approximate_volume_function(flow_rate,2.3,0.1),0,10)
plot_function(volume,0,10)

图 8.28 当 dt = 0.1 小时时,图表几乎吻合。

使用更小的步长,例如 dt = 0.01 小时,图表几乎无法区分(见图 8.29):

plot_function(approximate_volume_function(flow_rate,2.3,0.01),0,10)
plot_function(volume,0,10)

尽管图表看起来完全匹配,但我们仍然可以问这个近似有多准确。随着 dt 值越来越小,近似 体积 函数的图表在每一个点都越来越接近实际的体积图表,因此我们可以说这些值正在 收敛 到实际的体积值。但在每一步,近似仍然可能与实际的体积测量值不一致。

图 8.29 当时间步长为 0.01 小时时,近似 体积 函数的图表与实际 体积 函数几乎无法区分。

这里有一种方法,我们可以找到任意点的体积,达到任意精度(在任意我们想要的公差范围内)。对于时间点 t,我们可以通过使用越来越小的 dt 值重新计算 volume_change(q,0,t,dt),直到输出停止变化超过公差值。这很像我们用来重复近似导数直到它们稳定下来的函数:

def get_volume_function(q,v0,digits=6):
    def volume_function(T):
        tolerance = 10 ** (−digits)
        dt = 1
        approx = v0 + volume_change(q,0,T,dt)
        for i in range(0,digits*2):
            dt = dt / 10
            next_approx = v0 + volume_change(q,0,T,dt)
            if abs(next_approx − approx) < tolerance:
                return round(next_approx,digits)
            else:
                approx = next_approx
        raise Exception("Did not converge!")
    return volume_function

例如,体积 v(1) 精确为 2.878125 桶,我们可以要求任何精度的结果。例如,对于三位数字,我们得到

>>> xv  = get_volume_function(flow_rate,2.3,digits=3)
>>> v(1)
2.878

对于六位数字,我们得到精确答案:

>>> xv  = get_volume_function(flow_rate,2.3,digits=6)
>>> v(1)
2.878125

如果你亲自运行这段代码,你会看到第二次计算花费了相当长的时间。这是因为它必须运行包含数百万个小体积变化的黎曼和,以得到这个精确度的答案。这个函数计算任意精度的体积值可能没有实际用途,但它说明了随着 dt 值越来越小,我们的体积近似值会收敛volume 函数的精确值。它收敛到的结果被称为流速的积分

8.5.4 定积分与不定积分

在最后两节中,我们积分了流速函数以获得体积函数。就像求导数一样,求积分是一个通用的步骤,你可以对函数进行操作。我们可以对任何指定变化率的函数进行积分,以得到一个提供兼容累积值的函数。例如,如果我们知道汽车的速度是时间的函数,我们可以对其进行积分,以得到随时间变化的行驶距离。在本节中,我们将探讨两种类型的积分:定积分和不定积分。

定积分告诉你函数在某个区间上的总变化,这个区间是从其导数函数得到的。函数和参数的起始和结束值对,在我们的例子中是时间,指定了定积分。输出是一个单一的数字,它给出了累积变化。例如,如果 f(x) 是我们感兴趣的功能,而 f'(x) 是 f(x) 的导数,那么 fx = ax = b 的变化是 f(b) − f(a),并且可以通过取定积分(图 8.30)来找到。

图 8.30 定积分取函数的速率变化(导数)和指定的区间,并恢复该区间上函数的累积变化。

在微积分中,从 x = ax = bf(x) 的定积分写作如下:

其值为 f(b) − f(a)。大写的积分符号 ʃ 是积分符号,ab 被称为积分的界限,f'(x*) 是被积函数,而 dx 表示积分是相对于 x 进行的。

我们的 volume_change 函数近似定积分,正如我们在第 8.4.3 节中看到的,它也近似流速图下的面积。结果证明,函数在区间上的定积分等于该区间速率图下的面积。对于你在野外遇到的多数速率函数,图形将足够好,你可以用越来越瘦的矩形来近似它们下面的面积,并且你的近似值会收敛到一个单一的值。

在计算定积分之后,让我们看看不定积分。不定积分取函数的导数并恢复原始函数。例如,如果你知道 f(x) 是 f(x) 的导数,那么为了重建 f(x),你必须找到 f(x) 的不定积分。

但问题是,导数 f(x) 单独不足以重建原始函数 f(x)。正如我们在 get_volume_function 中看到的那样,它计算了一个定积分,你需要知道 f(x) 的初始值,例如 f(0)。然后可以通过将定积分加到 f(0) 上来找到 f(x) 的值。因为

图片

我们可以得到 f(x) 的任何值:

图片

注意我们必须为 f 的自变量使用不同的名字 t,因为在这里 x 成为了积分的界限。函数 f(x) 的不定积分写作

图片

这看起来像是一个定积分,但没有指定界限。例如,如果 g(x) = ʃ f(x) dx,那么 g(x) 被称为 f(x) 的反导数。反导数不是唯一的,实际上,对于你选择的任何初始值 g(0),都有一个不同的函数 g(x),其导数是 f(x)。

在短时间内吸收这么多术语确实很多,但幸运的是,我们在这本书的第二部分剩余部分会回顾它们。我们将继续使用导数和积分在它们之间相互切换,来处理函数及其变化率。

概述

  • 函数的平均变化率,比如 f(x),是 f 在某个 x 间隔上的值的变化除以间隔的长度。例如,从 x = ax = bf(x) 的平均变化率是

    图片

  • 函数的平均变化率可以想象成一条割线的陡度,这是一条穿过函数图形上两点的线。

  • 在一个光滑函数的图形上放大,它看起来与一条直线无法区分。看起来像的线是该区域内函数的最佳线性近似,其斜率被称为函数的导数

  • 你可以通过取包含该点的连续更小的间隔上的割线的斜率来近似导数。这近似了函数在感兴趣点的瞬时变化率。

  • 函数的导数是另一个函数,它告诉你每个点的瞬时变化率。你可以绘制函数的导数来观察其随时间的变化率。

  • 从一个函数的导数开始,你可以通过将其分解为短暂的时间间隔并假设在每个间隔上速率是恒定的,来找出它随时间的变化情况。如果每个间隔足够短,速率将大致保持恒定,并累加以找到总量。这近似于函数的定积分。

  • 知道函数的初始值,并在各种时间间隔上对其速率取定积分,你可以重建该函数。这被称为函数的不定积分

9 模拟移动物体

本章涵盖

  • 在代码中实现牛顿运动定律以模拟真实运动

  • 计算速度和加速度向量

  • 使用欧拉方法近似移动物体的位置

  • 使用微积分找到移动物体的精确轨迹

我们在第七章中的小行星游戏虽然功能齐全,但挑战性并不强。为了使其更有趣,我们需要让小行星真正地移动!而且,为了让玩家有机会躲避移动的小行星,我们需要让太空船也能移动和转向。

为了在 asteroid 游戏中实现运动,我们将使用第八章中提到的许多相同的微积分概念。我们将考虑的数值量是小行星和太空船的 xy 位置。如果我们想让小行星移动,这些值在时间上的不同点会有所不同,因此我们可以将它们视为时间的函数:x(t) 和 y(t)。位置函数对时间的导数称为 速度,而速度对时间的导数称为 加速度。因为我们有两个位置函数,所以我们有两个速度函数和两个加速度函数。这使我们能够将速度和加速度视为向量。

我们的首要目标是让小行星移动。为此,我们将为小行星提供随机的、恒定的速度函数。然后我们将这些速度函数在“实时”中积分,使用称为 Euler’s method 的算法来获取每一帧中每个小行星的位置。欧拉方法在数学上与第八章中我们做的积分相似,但它有一个优点,那就是我们可以在游戏运行时进行。

之后,我们可以允许用户控制太空船。当用户按下键盘上的上箭头时,太空船应该朝其指向的方向加速。这意味着 x(t) 和 y(t) 的二阶导数变为非零;速度开始变化,位置也开始变化。同样,我们将使用欧拉方法实时积分加速度函数和速度函数。

欧拉方法仅仅是积分的一个近似,在这个应用中,它类似于第八章中的黎曼和。我们可以计算小行星和太空船随时间变化的精确位置,并在本章末尾简要比较欧拉方法的结果和精确解。

9.1 模拟恒定速度运动

在日常用语中,单词 velocity 是单词 speed 的同义词。在数学和物理学中,速度有特殊的意义;它包括速度和运动方向的概念。因此,速度将是我们要关注的概念,我们将将其视为一个向量。

我们想要做的是给每个小行星对象提供一个随机的速度向量,即一对数字 (v[x], v[y]),并将这些解释为位置对时间导数的常数值。也就是说,我们假设 x(t) = v[x]y(t) = v[y]。有了这些信息编码,我们可以更新游戏引擎,使小行星在游戏进行过程中以这些速度移动。

由于我们的游戏是二维的,我们处理位置和速度的对。我交替使用 x(t) 和 y(t) 作为一对位置函数以及 x'(t) 和 y'(t) 作为一对速度函数,并将它们写成 向量值 函数:s(t) = (x(t), y(t)) 和 v(t) = (x'(t), y'(t))。这种表示法只是意味着 s(t) 和 v(t) 都是函数,它们接受一个时间值并返回一个向量,分别表示在那个时间点的位置和速度。

小行星已经具有位置向量,由它们的 xy 属性表示,但我们需要给它们也提供速度向量,以指示它们在 xy 方向上的移动速度。这是我们让它们逐帧移动的第一步。

9.1.1 向小行星添加速度

为了给每个小行星提供一个速度向量,我们可以在 PolygonModel 对象(在源代码中 asteroids.py 的第九章版本)上添加向量的两个分量作为属性:

class PolygonModel():
    def __init__(self,points):
        self.points = points     ❶
        self.angle = 0
        self.x = 0
        self.y = 0
        self.vx = 0              ❷
        self.vy = 0

❶ 前四个属性保留自第七章中此类的原始实现。

❷ 这些 vx 和 vy 属性存储了当前值 v[x] = x(t) 和 v[y] = y(t)。默认情况下,它们被设置为 0,这意味着对象没有移动。

接下来,为了使我们的小行星移动得更加不规则,我们可以给它们速度的两个分量赋予随机值。这意味着在 Asteroid 构造函数的底部添加两行:

class Asteroid(PolygonModel):
    def __init__(self):
        sides = randint(5,9)
        vs = [vectors.to_cartesian((uniform(0.5,1.0), 2 * pi * i / sides))
                for i in range(0,sides)]
        super().__init__(vs)              ❶
        self.vx = uniform(−1,1)           ❷
        self.vy = uniform(−1,1)

❶ 到这一行,代码与第七章没有变化;它初始化小行星的形状为一个具有随机位置顶点的多边形。

❷ 在最后两行,xy 速度被设置为介于 -1 和 1 之间的随机值。

记住,负导数意味着函数正在减少,而正值则意味着函数正在增加。xy 速度可以是正也可以是负的事实意味着 xy 位置各自可以是增加或减少。这意味着我们的小行星可以向右或向左以及向上或向下移动。

9.1.2 更新游戏引擎以移动小行星

接下来我们需要做的是使用速度来更新位置。无论我们是在谈论宇宙飞船、小行星还是其他 PolygonModel 对象,速度分量 v[x]v[y] 告诉我们如何更新位置分量 xy

如果在帧之间经过一段时间 Δt,我们通过 v[x] · Δt 更新 x,通过 v[y] · Δt 更新 y。 (符号 Δ 是大写希腊字母 delta,常用来表示变量的变化)。这是我们在第八章中用来从流量的小变化中找到体积小变化的相同近似。在这种情况下,由于速度是恒定的,速度乘以经过的时间给出位置的变化,所以这个近似比其他近似更好。

我们可以在 PolygonModel 类中添加一个 move 方法,根据这个公式更新对象的位置。唯一对象本身不会内在意识到的是经过的时间,所以我们将其传递进来(以毫秒为单位):

class PolygonModel():
    ...
    def move(self, milliseconds):
        dx, dy = (self.vx * milliseconds / 1000.0, 
                  self.vy * milliseconds / 1000.0        ❶
        self.x, self.y = vectors.add((self.x,self.y), 
                                     (dx,dy))            ❷

x 位置的变化称为 dx,y 位置的变化称为 dy。两者都是通过将陨石的速度乘以经过的秒数来计算的。

❷ 完成框架的运动,通过添加相应的变化 dx 和 dy 更新位置

这是对 Euler 方法算法的一个简单应用。算法包括跟踪一个或多个函数的值(在我们的情况下,是位置 x(t) 和 y(t) 以及它们的导数 x'(t) = v[x]y'(t) = v[y]),并在每一步根据它们的导数更新函数。如果导数是恒定的,这会非常完美,但如果导数本身在变化,它仍然是一个相当好的近似。当我们转向宇宙飞船时,我们将处理变化的速率值并更新我们的 Euler 方法实现。

9.1.3 保持陨石在屏幕上

我们可以添加一个额外的功能来提高游戏体验。一个具有随机速度的陨石注定会在某个时刻从屏幕上漂移出去。为了保持陨石在屏幕区域内,我们可以添加一些逻辑来确保两个坐标都在 -10 和 10 的最小和最大值之间。例如,当 x 属性从 10.0 增加到 10.1 时,我们减去 20,使其成为可接受值 -9.9。这会产生将陨石从屏幕的右侧“传送”到左侧的效果。这种游戏机制与物理学无关,但通过保持陨石在游戏中,使游戏更有趣(图 9.1)。

图片

图 9.1 通过在对象即将离开屏幕时将其“传送”到屏幕的另一侧,保持所有对象的坐标在 -10 和 10 之间

这是传送代码:

class PolygonModel():
    ...
    def move(self, milliseconds):
        dx, dy = (self.vx * milliseconds / 1000.0, 
                  self.vy * milliseconds / 1000.0)
        self.x, self.y = vectors.add((self.x,self.y), 
                                     (dx,dy))
        if self.x < −10:
            self.x += 20      ❶
        if self.y < −10:      ❷
            self.y += 20
        if self.x > 10:
            self.x -= 20
        if self.y > 10:
            self.y -=20

❶ 如果 x < −10,陨石会从屏幕的左侧漂移出去,所以我们向 x 位置添加 20 个单位,将其传送到屏幕的右侧。

❷ 如果 y < −10,陨石会从屏幕的底部漂移出去,所以我们向 y 位置添加 20 个单位,将其传送到屏幕的顶部。

最后,我们需要为每个在游戏中运行的陨石调用 move 方法。为了做到这一点,我们需要在绘图开始之前在我们的游戏循环中添加以下几行:

milliseconds = clock.get_time()   ❶
for ast in asteroids:
    ast.move(milliseconds)        ❷

❶ 计算自上一帧以来经过的毫秒数

❷ 向所有陨石发送信号以根据它们的速度更新它们的位置

当打印在这页上时,这并不引人注目,但当你自己运行代码时,你会看到陨石在屏幕上随机移动,每个陨石都朝一个随机方向移动。但如果你专注于一个陨石,你会看到它的运动并不是随机的;它在每一秒内以相同的距离和方向改变位置(图 9.2)。

图片

图 9.2 包含前面的代码后,每个陨石都以随机恒定的速度移动。

由于有移动的陨石,飞船现在处于危险之中−它需要移动以避开它们。但即使以恒定速度移动,飞船也可能会在某一点撞上陨石。玩家需要改变飞船的速度,这意味着它的速度和方向。接下来,我们将探讨如何模拟速度的变化,这被称为加速度

9.1.4 练习

| 练习 9.1:一颗陨石的速度向量为 v = (v[x], v[y]) = (−3, 1)。它在屏幕上移动的方向是什么?

  • 向上并向右

  • 向上并向左

  • 向下并向左

  • 向下并向右

解答:因为在这个时刻,x'(t) = v[x] = −3,所以陨石正在向负 x 方向移动,即向左。因为 y'(t) = v[y] = 1,所以陨石在这个时刻正在向正 y 方向移动,即向上。因此,答案是 b。 |

9.2 模拟加速度

让我们想象我们的宇宙飞船装备了一个燃烧火箭燃料的推进器,膨胀的气体将宇宙飞船推向它指向的方向(图 9.3)。

图片

图 9.3 火箭推进自身的示意图

我们假设当火箭点燃其推进器时,它以恒定的速率在其指向的方向上加速。因为加速度被定义为速度的导数,恒定的加速度值意味着速度在时间上以恒定的速率在两个方向上变化。当加速度不为零时,速度 v[x]v[y] 不是常数;它们是随时间变化的函数 v[x](t) 和 v[y](t)。我们假设加速度是恒定的,意味着有两个数字,a[x]a[y],所以 v'[x](t) = a[x]v'[y](t) = a[y]。作为一个向量,我们用 a = (a[x], a[y]) 表示加速度。

我们的目标是为 Python 飞船提供一对属性,代表 (a[x]a[y]),并使其根据这些值加速并在屏幕上移动。当用户没有按下任何按钮时,飞船在两个方向上应该都没有加速度,而当用户按下上箭头键时,加速度值应立即更新,使得 (a[x], a[y]) 是一个非零向量,指向飞船前进的方向。当用户按下上箭头键时,飞船的速度和位置都应该以逼真的方式改变,使其逐帧移动。

9.2.1 加速飞船

无论飞船指向哪个方向,我们都希望它看起来以相同的速率加速。这意味着当推进器正在发射时,向量 (a[x], a[y]) 的大小应该有一个固定的值。通过试错,我发现加速度大小为 3 可以使飞船足够灵活。让我们将这个常数包含到我们的游戏代码中:

acceleration = 3

将我们游戏中的距离单位视为米,这代表每秒每秒 3 米(m/s/s)的值。如果飞船从静止开始,并且玩家按下上箭头键,飞船将以每秒 3 m/s 的速度在其指向的方向上增加速度。PyGame 以毫秒为单位工作,因此相关的速度变化将是每毫秒 0.003 m/s,或者每毫秒每秒 0.003 米。

让我们来看看如何在按下上箭头键时计算加速度向量 a = (a[x], a[y])。如果飞船指向一个旋转角度 θ,那么我们需要使用三角学来找到加速度的垂直和水平分量,其大小为 |a| = 3。根据正弦和余弦的定义,水平和垂直分量分别是 |a| · cos(θ) 和 |a| · sin(θ)(见图 9.4)。换句话说,加速度向量是分量对 ( |a| · cos(θ), |a| · sin(θ))。顺便提一下,你也可以使用我们在第二章中编写的 from_polar 函数,从加速度的大小和方向得到这些分量。

图 9.4 使用三角学从加速度的大小和方向找到加速度分量

在游戏循环的每次迭代中,我们可以在飞船移动之前更新其速度。在经过的时间 Δt 内,v[x] 的更新将是 a[x] · Δt,而 v[y] 的更新将是 a[y] · Δt。在代码中,我们需要将适当的速度变化添加到飞船的 vxvy 属性中:

while not done:
    ...
        if keys[pygame.K_UP]:                               ❶
            ax = acceleration * cos(ship.rotation_angle)    ❷
            ay = acceleration * sin(ship.rotation_angle)
            ship.vx += ax * milliseconds/1000               ❸
            ship.vy += ay * milliseconds/1000

        ship.move(milliseconds)                             ❹

❶ 检测上箭头键是否被按下

❷ 根据加速度的固定大小和飞船指向的角度计算 a[x] 和 a[y] 的值

❸ 分别通过 a[x] · Δta[y] · Δt 更新 xy 速度

❹ 使用更新的速度来更新飞船的位置,从而移动飞船

就这样!添加了这段代码后,当按下上箭头键时,宇宙飞船应该会加速。使用左右箭头键旋转宇宙飞船的代码类似,包含在源代码中,但这里不会详细介绍。实现了左右和上箭头的功能后,你可以将飞船指向任何方向,以便在需要避开小行星时加速。

这是对欧拉方法的一个稍微高级的应用,其中我们有了 二阶 导数:x''(t) = v* 'x(t) = axy''(t) = v* 'y(t) = ay。在每一步中,我们首先更新速度,然后使用更新的速度在 move 方法中确定更新的位置。我们完成了本章的游戏编程,但在接下来的几节中,我们将更深入地研究欧拉方法并评估它对运动的逼近程度。

9.3 深入研究欧拉方法

欧拉方法的核心思想是从一个量的初始值(如位置)和一个描述其导数的方程(如速度和加速度)开始。导数然后告诉我们如何更新这个量。让我们通过逐步分析一个例子来回顾我们是如何做到这一点的。

假设一个物体在时间 t = 0 时从位置 (0, 0) 开始,以初始速度 (1, 0) 和恒定加速度 (0, 0.2) 运动。(为了表述清晰,我在本节中省略了单位,但你可以继续以秒、米、米/秒等单位思考。)这个初始速度指向正 x 方向,加速度指向正 y 方向。这意味着如果我们观察平面,物体开始时直接向右移动,但随时间向上偏移。

我们的任务是使用欧拉方法找到从 t = 0 到 t = 10 每两秒的位置矢量值。首先,我们将手动完成它,然后我们将在 Python 中进行相同的计算。有了这些结果位置,我们将在 xy 平面上绘制它们,以显示宇宙飞船遵循的路径。

9.3.1 手动执行欧拉方法

我们将继续将位置、速度和加速度视为时间的函数:在任何给定的时间,物体将具有这些量的某些矢量值。我将称这些矢量值函数为:s(t) , v (t) 和 a(t),其中 s(t) = (x(t), y(t)), v(t) = (x'(t), y'(t)), 和 a(t) = (x''(t), y''(t)). 这里是时间 t = 0 时给出的初始值表:

在我们的彗星游戏中,PyGame 决定了每次计算位置之间的毫秒数。在这个例子中,为了快速起见,让我们以 2 秒的增量从时间 t = 0 到 t = 10 重建位置。我们需要完成的表格如下:

我已经为我们填写了加速度列,因为我们已经规定加速度是恒定的。在 t = 0 和 t = 2 之间的 2 秒期间会发生什么?速度会根据以下一对方程中计算出的加速度发生变化。在这些方程中,我们再次使用希腊字母 Δ(delta)来表示我们考虑的区间内变量的变化。例如,Δt 是时间的变化,所以 Δt = 2 秒,对于这 5 个区间中的每一个。因此,在 2 秒时的速度分量是:

在时间 t = 2 时,速度的新向量值是 v(2) = (v[x] (2), v[y] (2)) = (1, 0.4)。位置也根据速度 v(0) 发生变化:

它的更新值是 s = (x, y) = (2, 0)。这为我们提供了表格的第二行:

t = 2 和 t = 4 之间,加速度保持不变,因此速度增加的量相同,a · Δt = (0, 0.2) · 2 = (0, 0.4),到一个新的值,v(4) = (1, 0.8)。位置根据速度 v(2) 增加:

这将位置提升到 s(4) = (4, 0.8)。现在我们已经完成了表格的三行,并且计算了我们想要的五个位置中的两个:

我们可以继续这样做,但如果让 Python 为我们完成这项工作会更好——这是我们下一步要做的事情。但首先,让我们暂停一下。在过去的几段中,我已经带你们经历了很多算术。我的任何假设看起来可疑吗?我会给你一个提示:我这样使用方程 Δs = v · Δt 并不完全合法,所以表格中的位置只是近似正确的。如果你还没有看到我如何偷偷使用近似值,不要担心。一旦我们在图上绘制了位置向量,一切就会变得清晰。

9.3.2 在 Python 中实现算法

在 Python 中描述这个程序并不需要太多工作。我们首先需要设置时间、位置、速度和加速度的初始值:

t = 0
s = (0,0)
v = (1,0)
a = (0,0.2)

我们还需要指定的其他值是我们感兴趣的时间点:0、2、4、6、8 和 10 秒。我们不必列出所有这些,我们可以使用 t = 0 作为起点,并指定每个时间步长为 2 秒的恒定 Δt,总共有 5 个时间步长:

dt = 2
steps = 5

最后,我们需要在每个时间步长更新一次时间、位置和速度。在这个过程中,我们可以将位置存储在数组中以供以后使用:

from vectors import add, scale
positions = [s]
for _ in range(0,5):
    t += 2
    s = add(s, scale(dt,v))    ❶

    v  = add(v, scale(dt,a))   ❷
    positions.append(s)

❶ 通过将位置变化 Δs = v·Δt 加到当前位置 s 上来更新位置。 (我使用了第二章中的缩放和加法函数。)

❷ 通过将速度变化 Δv = a·Δt 加到当前速度 v 上来更新速度

如果我们运行此代码,位置列表将填充六个向量s的值,对应于时间t = 0, 2, 4, 6, 8, 10。现在我们有了代码中的值,我们可以绘制它们并想象物体的运动。如果我们使用第二章和第三章中的绘图模块在 2D 中绘制它们,我们可以看到物体最初向右移动,然后像预期的那样向上倾斜(图 9.5)。以下是 Python 代码及其生成的图表:

from draw2d import *
draw2d(Points2D(*positions))

图片

图 9.5 根据我们的欧拉方法计算得到的物体轨迹上的点。

在我们的近似中,物体似乎在每个五段时间间隔内以不同的速度沿着五条直线移动(图 9.6)。

图片

图 9.6 通过直线连接轨迹上各点的五个位移向量。

物体应该一直在加速,所以你可能期望它移动在一个平滑的曲线上而不是直线。现在我们已经在 Python 中实现了欧拉方法,我们可以快速用不同的参数重新运行它,以评估近似的质量。

9.4 使用更小的时间步长运行欧拉方法

我们可以通过将dt设置为1steps设置为10来重新运行计算,使用两倍的时间步数。这仍然模拟了 10 秒的运动,但用 10 条直线路径来建模(图 9.7)。

图片

图 9.7 使用相同的初始值和不同步数,欧拉方法产生了不同的结果。

再次尝试使用 100 步和dt = 0.1,我们在同样的 10 秒内看到了另一条轨迹(图 9.8)。

图片

图 9.8 使用 100 步而不是 5 或 10 步,我们得到了另一条轨迹。由于点太多,这条轨迹中的点被省略了。

为什么尽管三个计算都使用了相同的方程,我们得到的结果却不同?看起来我们使用的步数越多,y坐标就越大。如果我们仔细观察前两秒,就能看到这个问题。

在 5 步近似中,没有加速度;物体仍在 x 轴上移动。在 10 步近似中,物体已经更新了一次速度,因此它已经上升到 x 轴以上。最后,100 步近似在t = 0 和t = 1 之间有 19 次速度更新,因此它的速度增加最大(图 9.9)。

图片

图 9.9 仔细观察前两个段落,100 步近似值最大,因为它的速度更新最频繁。

这就是我之前忽略的事情。方程 Δs = v · Δt 仅在速度恒定时才正确。当使用大量时间步长时,欧拉方法是一个很好的近似,因为在较短的时间间隔内,速度变化不大。为了证实这一点,你可以尝试一些大时间步长和小的 dt 值。例如,使用 100 步,每步 0.1 秒,最终位置是

(9.99999999999998, 9.900000000000006)

以及使用 100,000 步,每步 0.0001 秒,最终位置是

(9.999999999990033, 9.999899999993497)

最终位置的精确值是 (10.0, 10.0),随着我们使用欧拉方法添加越来越多的步骤,我们的结果似乎 收敛 到这个值。现在你必须相信我,(10.0, 10.0) 是精确值。我们将在下一章中介绍如何进行精确积分来证明这一点。请耐心等待!

练习

| 练习 9.2-迷你项目:创建一个函数,该函数可以自动对匀加速物体执行欧拉方法。你需要向该函数提供加速度向量、初始速度向量、初始位置向量,以及可能的其他参数。解决方案:我还包括了总时间和步数作为参数,以便于测试解决方案中的各种答案。

def eulers_method(s0,v0,a,total_time,step_count):
    trajectory = [s0]
    s = s0
    v  = v0
    dt = total_time/step_count     ❶
    for _ in range(0,step_count):
        s = add(s,scale(dt,v))     ❷
        v  = add(v,scale(dt,a))
        trajectory.append(s)
    return trajectory

❶ 每个时间步长 dt 的持续时间是总时间除以时间步长的数量。❷ 对于每一步,更新位置和速度,并将最新的位置作为轨迹(位置列表)中的下一个位置。

| 练习 9.3-迷你项目:在 9.4 节的计算中,我们低估了位置坐标的 y 值,因为我们是在每个时间间隔结束时更新速度的 y 分量。在每个时间间隔开始时更新速度,并展示你如何随着时间的推移高估 y 位置。解决方案:我们可以通过将 eulers_method 函数的 sv 更新顺序进行切换来调整迷你项目 9.2 中的实现:

def eulers_method_overapprox(s0,v0,a,total_time,step_count):
    trajectory = [s0]
    s = s0
    v  = v0
    dt = total_time/step_count
    for _ in range(0,step_count):
        v  = add(v,scale(dt,a))
        s = add(s,scale(dt,v))
        trajectory.append(s)
    return trajectory

使用相同的输入,这确实给出了比原始实现更高的 y 坐标近似值。如果你仔细观察以下图中的轨迹,你可以看到它已经在第一个时间步中向 y 方向移动。

eulers_method_overapprox((0,0),(1,0),(0,0.2),10,10)

原始欧拉方法轨迹和新轨迹 原始的欧拉方法轨迹和新的轨迹。为了比较,精确轨迹用黑色显示。

| 练习 9.4-迷你项目:任何像投掷的棒球、子弹或空中滑雪板运动员这样的抛射体都会经历相同的加速度向量:9.81 m/s/s 指向地球。如果我们把平面的 x 轴看作是平坦的地面,正 y 轴向上,那么加速度向量就是(0, 9.81)。如果一个棒球从肩高处以x = 0 的位置投出,我们可以说它的初始位置是(0, 1.5)。假设它以 30 m/s 的初始速度,从正x方向向上 20°的角度投出,并使用欧拉方法模拟其轨迹。棒球在击中地面之前在大约 x 轴的 67 米处能飞多远?解答:初始速度是(30 · cos(20°), 30 · sin(20°))。我们可以使用迷你项目 9.2 中的eulers_method函数来模拟棒球在几秒钟内的运动:

from math import pi,sin,cos
angle = 20 * pi/180
s0 = (0,1.5)
v0 = (30*cos(angle),30*sin(angle))
a = (0,−9.81)

result = eulers_method(s0,v0,a,3,100)

绘制得到的轨迹,这个图显示了棒球在空中形成一个弧线,在大约 x 轴的 67 米处返回地球。轨迹继续地下延伸,因为我们没有告诉它停止。投掷棒球 |

| 练习 9.5-迷你项目:重新运行之前迷你项目中使用的欧拉方法模拟,初始速度相同为 30,但使用初始位置(0, 0)并尝试不同的初始速度角度。哪个角度使棒球在击中地面之前飞得最远?解答:为了模拟不同的角度,你可以将这段代码打包成一个函数。使用新的起始位置(0, 0),你可以在下面的图中看到各种轨迹。结果发现,棒球在 45°的角度下飞得最远。(注意,我已经过滤掉了轨迹上具有负y分量的点,只考虑棒球击中地面之前的运动。)

def baseball_trajectory(degrees):
    radians = degrees * pi/180
    s0 = (0,0)
    v0 = (30*cos(radians),30*sin(radians))
    *a* = (0,−9.81)
    return [(x,y) for (x,y) in eulers_method(s0,v0,a,10,1000) if y>=0]

投掷棒球以 30 m/s 的速度从不同角度投掷棒球 |

| 练习 9.6-迷你项目:一个在 3D 空间中移动的物体具有初始速度(1, 2, 0)和恒定的加速度向量(0, −1, 1)。如果它从原点开始,10 秒后它在哪里?使用第三章的绘图函数绘制其在 3D 空间中的轨迹。解答:我们的eulers_method实现已经可以处理 3D 向量了!代码片段之后的图显示了 3D 空间中的轨迹。

from draw3d import *
traj3d = eulers_method((0,0,0), (1,2,0), (0,−1,1), 10, 10)
draw3d(
    Points3D(*traj3d)
)

跑步使用 1,000 步以提高精度,我们可以找到最后的位置:

>>> eulers_method((0,0,0), (1,2,0), (0,−1,1), 10, 1000)[−1]
(9.999999999999831, −29.949999999999644, 49.94999999999933)

它接近于(10, −30, 50),这最终是确切的位置。|

概述

  • 速度是位置对时间的导数。它是一个向量,由每个位置函数的导数组成。在 2D 中,使用位置函数x(t)和y(t),我们可以将位置向量写成一个函数s(t) = (x(t), y(t)),速度向量写成一个函数v(t) = (x'(t), y'(t)).

  • 在一款视频游戏中,你可以通过在每一帧更新对象的位置来使对象以恒定速度移动。测量帧之间的时间并乘以对象的速度,可以得到该帧的位置变化。

  • 加速度是速度对时间的导数。它是一个向量,其分量是速度分量的导数,例如,a(t) = (v'x(t), v'y(t))。

  • 要在视频游戏中模拟加速对象,你需要不仅更新每一帧的位置,还要更新速度。

  • 如果你知道一个量相对于时间的改变率,你可以通过计算该量在许多小时间间隔内的变化来计算该量随时间的变化值。这被称为欧拉法

使用符号表达式进行工作

本章涵盖

  • 将代数表达式建模为数据结构

  • 编写代码来分析、转换或评估代数表达式

  • 通过操作定义函数的表达式来求函数的导数

  • 编写 Python 函数来计算导数公式

  • 使用 SymPy 库计算积分公式

如果你已经跟随着第八章和第九章中的所有代码示例并完成了所有练习,那么你已经对微积分中最重要的两个概念有了坚实的掌握:导数和积分。首先,你学习了如何通过取越来越小的割线斜率来近似函数在某点的导数。然后,你学习了如何通过估计图形下瘦长矩形的面积来近似积分。最后,你学习了如何通过在每个坐标中进行相关的微积分运算来用向量进行微积分。

这可能听起来像是一个大胆的声明,但我真的希望在这本书的几个章节中,你已经学到了在一年制的大学微积分课程中会学到的重要概念。这里的关键是:因为我们使用 Python,所以我跳过了传统微积分课程中最费力的部分,即通过手工进行大量的公式操作。这类工作使你能够对函数的公式,如 f(x) = x³,找到一个导数 f'(x) 的确切公式。在这种情况下,有一个简单的答案,f'(x) = 3x²,如图 10.1 所示。

图 10.1 函数 f(x) = x³ 的导数有一个确切的公式,即 f'(x) = 3x²。

你可能想要知道无限多个公式的导数,而且你不可能记住所有这些公式的导数,所以在微积分课程中,你最终学到的是一组小规则以及如何系统地应用这些规则将一个函数转换为其导数。总的来说,这对程序员来说并不是一个非常有用的技能。如果你想知道导数的确切公式,你可以使用一个称为 计算机代数系统 的专用工具来为你计算。

10.1 使用计算机代数系统求精确导数

最受欢迎的计算机代数系统之一是 Mathematica,你可以在名为 Wolfram Alpha 的网站上免费在线使用其引擎(wolframalpha.com)。根据我的经验,如果你想为正在编写的程序找到一个导数的确切公式,最好的方法是咨询 Wolfram Alpha。例如,当我们第十六章构建神经网络时,了解函数的导数将是有用的:

要找到这个函数导数的公式,你只需访问 wolframalpha.com 并在输入框中输入公式(图 10.2)。Mathematica 有自己的数学公式语法,但 Wolfram Alpha 令人印象深刻地宽容,并能理解你输入的大多数简单公式(甚至包括 Python 语法!)。

图 10.2 在 wolframalpha.com 的输入框中输入函数

当你按下 Enter 键时,Wolfram Alpha 后面的 Mathematica 引擎会计算关于这个函数的许多事实,包括它的导数。如果你向下滚动,你会看到一个关于函数导数的公式(图 10.3)。

图 10.3 Wolfram Alpha 报告了该函数导数的公式。

对于我们的函数 f(x),它在任何 x 值处的瞬时变化率由以下公式给出

如果你理解了“导数”和“瞬时变化率”的概念,学习如何在 Wolfram Alpha 中输入公式是一项比你在微积分课程中学到的任何其他单一技能都更重要的技能。我并不是要表现得愤世嫉俗;通过手动求导数,我们可以学到很多关于特定函数行为的知识。只是在你作为专业软件开发者的生活中,当你有像 Wolfram Alpha 这样的免费工具可用时,你可能永远不需要去计算导数或积分的公式。

话虽如此,你内心的极客可能会问,“Wolfram Alpha 是如何做到的?”通过在各个点取图形的近似斜率来找到导数的粗略估计是一件事情,但生成一个精确公式则是另一回事。Wolfram Alpha 成功地解释了你输入的公式,通过一些代数操作对其进行转换,并输出一个新的公式。这种与公式本身而不是数字打交道的方法被称为 符号编程

我内心的实用主义者想告诉你“就使用 Wolfram Alpha 吧”,而我内心的数学爱好者则想教你如何手动求导数和积分,因此在本章中,我将折中处理。我们将在 Python 中进行一些符号编程,直接操作代数公式,并最终找出它们的导数公式。这让你熟悉了寻找导数公式的过程,同时仍然让计算机为你做大部分工作。

10.1.1 在 Python 中进行符号代数

让我先向你展示我们如何在 Python 中表示和操作公式。假设我们有一个数学函数,例如

f(x) = (3x² + x) sin(x)

在 Python 中表示它的常用方法是如下所示:

from math import sin
def f(x):
    return (3*x**2 + *x*) * sin(*x*)

虽然这段 Python 代码使得评估公式变得容易,但它并没有给我们提供计算关于公式的事实的方法。例如,我们可以询问

  • 这个公式是否依赖于变量 x

  • 它是否包含三角函数?

  • 是否涉及到除法运算?

我们可以快速查看这些问题并决定答案:是、是、否。没有简单、可靠的方法来编写一个 Python 程序来为我们回答这些问题。例如,编写一个函数 contains_division(f),它接受函数 f 并返回如果它在定义中使用除法操作则返回 true,是困难的,如果不是不可能的话。

这正是这种技巧派上用场的地方。为了调用一个代数规则,你需要知道正在应用哪些操作以及它们的顺序。例如,函数 f(x) 是正弦(x)与和的乘积,如图 10.4 所示,有一个已知的代数过程可以展开和的乘积。

图片

图 10.4 因为 (3x2+x) sin(x) 是和的乘积,它可以被展开。

我们的策略是将代数表达式建模为数据结构,而不是直接将它们转换为 Python 代码,这样它们就更容易被操作。一旦我们能够对函数进行符号操作,我们就可以自动化微积分的规则。

大多数用简单公式表示的函数也有它们导数的简单公式。例如,x³的导数是 3x²,这意味着对于任何 x 的值,函数 f(x) = x³ 的导数由 3x² 给出。到本章结束时,你将能够编写一个 Python 函数,它接受一个代数表达式并给出其导数表达式。我们的代数公式数据结构将能够表示变量、数字、和、差、积、商、幂以及正弦和余弦等特殊函数。如果你这么想,我们可以用那几个构建块表示大量不同的公式,并且我们的导数将适用于所有这些(图 10.5)。

图片

图 10.5 目标是编写一个 Python 中的导数函数,它接受一个函数的表达式并返回其导数表达式。

我们将从将表达式建模为数据结构而不是 Python 代码中的函数开始。然后,为了热身,我们可以使用这些数据结构进行一些简单的计算,比如为变量插入数字或展开和的乘积。之后,我将教你一些求导公式的规则,我们将编写自己的导数函数并在我们的符号数据结构上自动执行这些操作。

10.2 建模代数表达式

让我们集中关注一下函数 f(x) = (3x² + x) sin(x),看看我们如何将其分解成片段。这是一个很好的示例函数,因为它包含了许多不同的构建块:变量 x,以及数字、加法、乘法、幂次和一个特别命名的函数,sin(x)。一旦我们有了将这个函数分解成概念片段的策略,我们就可以将其翻译成 Python 数据结构。这个数据结构是函数的符号表示,而不是像 "(3*x**2 + x) * sin(*x*)" 这样的字符串表示。

第一个观察结果是,f 是这个函数的任意名称。例如,无论我们称其为什么,这个方程的右侧都以相同的方式展开。正因为如此,我们只需关注定义函数的表达式,在这种情况下是 (3x² + x) sin(x)。这被称为表达式,与必须包含等号 (=) 的方程相对。表达式 是一些数学符号(数字、字母、运算符等)以某些有效方式组合而成的集合。因此,我们的第一个目标是通过 Python 模拟这些符号和组合这个表达式的有效方式。

10.2.1 将表达式分解成片段

我们可以通过将代数表达式分解成更小的表达式来开始模拟代数表达式。分解表达式 (3x² + x) sin(x) 只有一种有意义的分解方式。也就是说,它是 (3x² + x) 和 sin(x) 的乘积,如图 10.6 所示。

图 10.6 以有意义的方式将代数表达式分解成两个更小的表达式

相比之下,我们不能在加号周围拆分这个表达式。如果我们尝试,我们可以理解加号两边的表达式,但结果并不等同于原始表达式(图 10.7)。

图 10.7 在加号周围拆分表达式没有意义,因为原始表达式不是 3x² 和 x · sin(x) 的和。

如果我们看表达式 3x² + x,它可以分解成一个和:3x² 和 x。同样,传统的运算顺序告诉我们,3x² 是 3 和 x² 的乘积,而不是 3x 的平方。

在本章中,我们将把乘法、加法等操作视为将两个(或更多)代数表达式并排放置以形成一个新的大代数表达式的方式。同样,运算符是拆分现有代数表达式为更小表达式的有效位置。

在函数式编程的术语中,将较小的对象组合成较大的对象,如这种函数通常被称为 组合子。以下是我们表达式中隐含的一些组合子:

  • 3x² 是表达式 3 和 x² 的乘积

  • x² 是一个幂次:一个表达式 x 被提升到另一个表达式 2 的幂次。

  • 表达式 sin(x) 是一个 函数应用。给定表达式 sin 和表达式 x,我们可以构建一个新的表达式 sin(x)。

变量 x、数字 2 或名为 sin 的函数不能进一步分解。为了区分这些与组合符,我们称它们为 元素。这里的教训是,虽然 (3x² + x) sin(x) 只是在这一页上打印的一堆符号,但这些符号以某种方式组合起来以传达某些数学意义。为了使这个概念更加清晰,我们可以可视化这个表达式是如何从其基本元素构建而成的。

10.2.2 构建表达式树

元素 3、x、2 和 sin,以及加法、乘法、幂运算和函数应用的组合符足够重建整个表达式 (3x² + x) sin(x)。让我们一步一步地走,绘制我们将要构建的结构。我们可以构建的第一个结构之一是 x²,它使用幂组合符将 x 和 2 结合起来(图 10.8)。

图 10.8 使用幂组合符将 x 和 2 结合起来表示更大的表达式 x²

一个好的下一步是将 x² 与数字 3 通过乘法组合符结合,得到表达式 3x²(图 10.9)。

图 10.9 使用幂将数字 3 与一个幂结合来表示乘积 3x2

这个结构有两层深度:输入到乘法组合符的表达式本身就是一个组合符。当我们添加更多表达式的项时,它变得更深。下一步是使用加法组合符将元素 x 添加到 3x² 中(图 10.10),这代表了加法操作。

图 10.10 使用表达式 3x²、元素 x 和加法组合符得到 3x² + x

最后,我们需要使用函数应用组合符将 sin 应用到 x 上,然后使用乘法组合符将 sin(x) 与我们迄今为止构建的内容结合起来(图 10.11)。

图 10.11 一个完成的图,展示了如何从元素和组合符构建 (3x2 + x) sin(x)

你可能会认出我们构建的结构是一个 。树的根是乘法组合符,从中伸出两个分支:SumApply。树中出现的每个组合符都会添加额外的分支,直到你达到没有分支的叶子元素。任何使用数字、变量和命名函数作为元素以及操作符为组合符的代数表达式都对应于一个独特的树,揭示了其结构。接下来,我们可以用 Python 构建相同的树。

10.2.3 将表达式树转换为 Python

当我们在 Python 中构建这个树时,我们就实现了将表达式表示为数据结构的目标。我将使用附录 B 中介绍的 Python 类来表示每种元素和每个组合器。随着我们的进行,我们将修改这些类,使它们具有更多的功能。如果你想跟随文本,可以查看第十章的 Jupyter 笔记本,或者你可以跳到 Python 脚本文件 expressions.py 中的更完整的实现。

在我们的实现中,我们将组合器建模为包含所有输入的容器。例如,一个幂 x 的 2,或 x²,有两块数据:基数 x 和幂 2. 这里是一个设计用来表示幂表达式的 Python 类:

class Power():
    def __init__(self,base,exponent):
        self.base = base
        self.exponent = exponent

我们可以写 Power("x",2) 来表示表达式 x²。但而不是使用原始字符串和数字,我将创建特殊的类来表示数字和变量。例如,

class Number():
    def __init__(self,number):
        self.number = number

class Variable():
    def __init__(self,symbol):
        self.symbol = symbol

这可能看起来像是多余的负担,但能够区分 Variable("x"),这意味着将字母 x 作为变量考虑,与字符串 "x",它仅仅是一个字符串,是有用的。使用这三个类,我们可以将表达式 x² 建模为

Power(Variable("x"),Number(2))

每个我们的组合器都可以实现为一个具有适当名称的类,该类存储它组合的任何表达式的数据。例如,一个乘积组合器可以是一个存储要相乘的两个表达式的类:

class Product():
    def __init__(self, exp1, exp2):
        self.exp1 = exp1
        self.exp2 = exp2

使用这个组合器可以表示乘积 3x²

Product(Number(3),Power(Variable("x"),Number(2)))

在介绍了我们需要的其余类之后,我们可以模拟原始表达式以及无限多的其他可能性。(注意,我们允许 Sum 组合器有任意数量的输入表达式,我们也可以为 Product 组合器做同样的事情。我限制 Product 组合器的输入为两个,以使我们在第 10.3 节开始计算导数时代码更简单。)

class Sum():
    def __init__(self, *exps):            ❶
        self.exps = exps

class Function():                         ❷
    def __init__(self,name):
        self.name = name

class Apply():                            ❸
    def __init__(self,function,argument):
        self.function = function
        self.argument = argument

f_expression = Product(>                  ❹
               Sum(
                   Product(
                       Number(3),
                       Power(
                           Variable("x"),
                           Number(2))), 
                   Variable("x")), 
               Apply(
                   Function("sin"),
                   Variable("x")))

❶ 允许任何数量的项之和,因此我们可以将两个或多个表达式相加

❷ 存储一个字符串,它是函数的名称(例如“sin”)

❸ 存储一个函数及其应用到的参数

❹ 我使用额外的空白来使表达式的结构更清晰可见。

这是对原始表达式(3x² + x)sin(x)的忠实呈现。我的意思是,我们可以查看这个 Python 对象,并看到它描述的是代数表达式,而不是另一个表达式。对于另一个表达式,例如

Apply(Function("cos"),Sum(Power(Variable("x"),Number("3")), Number(−5)))

我们可以仔细阅读它,并看到它代表了一个不同的表达式:cos(x³ + −5)。在接下来的练习中,你可以练习将一些代数表达式翻译成 Python,反之亦然。你会发现,输入整个表达式的表示可能会很繁琐。好消息是,一旦你在 Python 中将其编码,手动工作就结束了。在下一节中,我们将看到如何编写 Python 函数来自动处理我们的表达式。

10.2.4 练习

练习 10.1: 你可能已经遇到过自然对数,这是一个特殊的数学函数,写作 ln(x)。将表达式 ln(yz) 绘制成由前一小节描述的元素和组合器构成的树。解答:最外层的组合器是一个 Apply。被应用的是 ln 函数,即自然对数,参数是 yz。反过来,yz 是一个以 y 为底,z 为指数的幂。结果看起来像这样:
练习 10.2: 在自然对数由 Python 函数 math.log 计算的情况下,将前一个练习中的表达式翻译成 Python 代码。请将其作为 Python 函数和由元素和组合器构建的数据结构写出。

| 解答:你可以将 ln(yz) 视为两个变量 yz 的函数。它可以直接翻译成 Python,其中 ln 被称为 log

from math import log
def *f*(y,z):
    return log(y**z)

表达式树是这样构建的:

Apply(Function("ln"), Power(Variable("y"), Variable("z")))

|

练习 10.3: 表达式 Product(Number(3), Sum(Variable("y"),Variable("z"))) 表示的是什么?解答:这个表达式表示 3 · (y + z)。注意,由于运算顺序,括号是必要的。

| 练习 10.4: 实现一个表示一个表达式除以另一个表达式的 Quotient 组合器。如何表示以下表达式?!解答Quotient 组合器需要存储两个表达式:上面的表达式称为分子,下面的称为分母

class Quotient():
    def __init__(self,numerator,denominator):
        self.numerator = numerator
        self.denominator = denominator

样本表达式是和 a + b 与数字 2 的商:

Quotient(Sum(Variable("a"),Variable("b")),Number(2))

|

| 练习 10.5: 实现一个表示一个表达式从另一个表达式中减去的 Difference 组合器。如何表示表达式 b² − 4 ac解答Difference 组合器需要存储两个表达式,它表示第二个表达式从第一个表达式中减去:

class Difference():
    def __init__(self,exp1,exp2):
        self.exp1 = exp1
        self.exp2 = exp2

表达式 b² − 4 ac 是表达式 b² 和 4 ac 的差,表示如下:

Difference(
    Power(Variable('b'),Number(2)),
    Product(Number(4),Product(Variable('a'), Variable('c'))))

|

| 练习 10.6: 实现一个表示表达式取反的 Negative 组合器。例如,x² + y 的取反是 −(x² + y)。使用你新创建的组合器以代码形式表示后者表达式。解答Negative 组合器是一个包含一个表达式的类:

class Negative():
    def __init__(self,exp):
        self.exp = exp

要对 x² + y 取反,我们将其传递给 Negative 构造器:

Negative(Sum(Power(Variable("x"),Number(2)),Variable("y")))

|

| 练习 10.7: 添加一个名为 Sqrt 的函数来表示平方根,并使用它来编码以下公式: 解答:为了节省一些打字,我们可以在一开始就命名我们的变量和平方根函数:

A = Variable('a')
B = Variable('b')
C = Variable('c')
Sqrt = Function('sqrt')

然后只需将代数表达式翻译成适当的元素和组合器的结构。在最高级别上,你可以看到这是一个和(在上面)与积(在下面)的商:

Quotient(
    Sum(
        Negative(B),
        Apply(
            Sqrt, 
            Difference(
                Power(B,Number(2)),
                Product(Number(4), Product(A,C))))),
    Product(Number(2), A))

|

练习 10.8-迷你项目:创建一个抽象基类 Expression 并使所有元素和组合子从它继承。例如,class Variable() 将变为 class Variable(Expression)。然后重载 Python 算术运算 +-*/,以便它们产生 Expression 对象。例如,代码 2*Variable("x")+3 应该产生 [cos(x)%20%5C%2C%20dx%250">](https://www.codecogs.com/eqnedit.php?latex=%5Cint%20%5C%3Cspan%20class=)Sum(Product(Number(2),Variable("x")),Number(3))解决方案:请参阅本章源代码中的 expressions.py 文件。

10.3 将符号表达式投入应用

对于我们迄今为止研究过的函数,f(x) = (3x² + x) sin(x),我们编写了一个 Python 函数来计算它:

def f(x):
    return (3*x**2 + x)*sin(x)

作为 Python 中的一个实体,这个函数只适用于一件事:对于给定的输入值 x 返回一个输出值。Python 中的 f 并没有使其特别容易以编程方式回答我们在本章开头提出的问题:f 是否依赖于其输入,f 是否包含三角函数,或者如果 f 以代数方式展开,其主体会是什么样子。在本节中,我们看到一旦我们将表达式翻译成由元素和组合子构成的 Python 数据结构,我们就可以回答所有这些问题以及更多!

10.3.1 在表达式中查找所有变量

让我们编写一个函数,它接受一个表达式并返回其中出现的所有不同变量的列表。例如,h(z) = 2z + 3 使用输入变量 z 定义,而 g(x) = 7 的定义不包含任何变量。我们可以编写一个 Python 函数,distinct_variables,它接受一个表达式(意味着我们的任何元素或组合子)并返回一个包含变量的 Python 集合。

如果我们的表达式是一个元素,如 z 或 7,答案很明确。仅包含变量的表达式包含一个不同的变量,而仅包含数字的表达式则不包含任何变量。我们期望我们的函数按预期行为:

>>> distinct_variables(Variable("z"))
{'z'}
>>> distinct_variables(Number(3))
set()

当表达式由一些组合子如 y · z + x^z 构成时,情况变得更加复杂。人类阅读所有变量,yzx,很容易,但我们在 Python 中如何从表达式中提取这些变量呢?这实际上是一个表示 y · zx^z 之和的 Sum 组合子。求和中的第一个表达式包含 yz,而第二个包含 xz。求和包含了这两个表达式中的所有变量。

这表明我们应该使用递归解决方案:组合子的 distinct_variables 是它包含的每个表达式的 distinct_variables 的收集。行尾的变量和数字显然包含一个或零个变量。为了实现 distinct_variables 函数,我们需要处理构成有效表达式的每个元素和组合子的情况:

def distinct_variables(exp):
    if isinstance(exp, Variable):
        return set(exp.symbol)
    elif isinstance(exp, Number):
        return set()
    elif isinstance(exp, Sum):
        return set().union(*[distinct_variables(exp) for exp in exp.exps])
    elif isinstance(exp, Product):
        return distinct_variables(exp.exp1).union(distinct_variables(exp.exp2))
    elif isinstance(exp, Power):
        return distinct_variables(exp.base).union(distinct_variables(exp.exponent))
    elif isinstance(exp, Apply):
        return distinct_variables(exp.argument)
    else:
        raise TypeError("Not a valid expression.")

这段代码看起来很复杂,但实际上它只是一个长 if/else 语句,每个可能的元素或组合器对应一行。可以说,给每个元素和组合器类添加一个distinct_variables方法会是一个更好的编码风格,但这会使单个代码列表中的逻辑更难理解。正如预期的那样,我们的f_expression只包含变量x

>>> distinct_variables(f_expression)
{'x'}

如果你熟悉树形数据结构,你会认出这是对表达式树的递归遍历。当这个函数完成时,它已经对目标表达式中的每个表达式调用了distinct_variables,这些表达式是树中的所有节点。这确保了我们看到每个变量,并且得到我们预期的正确答案。在本节末尾的练习中,你可以使用类似的方法找到所有的数字或所有的函数。

10.3.2 评估表达式

现在,我们有了同一个数学函数f(x)的两种表示形式。一个是 Python 函数f,它适用于评估给定输入值x的函数。新的一个是这个描述定义f(x)的表达式结构的树形数据结构。结果是后者表示形式兼具两者之长;我们可以用它来评估f(x),只需做一点额外的工作。

从机械的角度来看,在x = 5 这样的值上评估函数f(x)意味着将 5 的值插入x的所有地方,然后进行算术运算以找到结果。如果表达式只是f(x) = x,插入x = 5 会告诉我们f(5) = 5。另一个简单的例子是g(x) = 7,其中用 5 替换x没有任何影响;在等式右边没有x的出现,所以g(5)的结果只是 7。

在 Python 中评估表达式的代码与我们刚刚编写的用于查找所有变量的代码类似。我们需要评估每个子表达式,而不是查看每个子表达式中出现的变量集合,然后组合器告诉我们如何将这些结果组合起来得到整个表达式的值。

我们需要起始数据是插入哪些值以及哪些变量来替换。像z(x, y) = 2xy³这样的两个不同变量的表达式需要两个值来得到结果;例如,x = 3 和y = 2。在计算机科学术语中,这些被称为变量绑定。有了这些,我们可以评估子表达式y³为(2)³,等于 8。另一个子表达式是 2x,它评估为 2 · (3) = 6。这两个子表达式通过乘法组合器组合在一起,所以整个表达式的值是 6 和 8 的乘积,即 48。

当我们将此过程转换为 Python 代码时,我将向您展示与上一个示例略有不同的风格。我们不需要一个单独的 evaluate 函数,而是可以为每个表示表达式的类添加一个 evaluate 方法。为了强制执行这一点,我们可以创建一个具有抽象 evaluate 方法的抽象 Expression 基类,并让每种表达式都从它继承。如果您需要回顾 Python 中的抽象基类,请花点时间回顾第六章中我们与 Vector 类一起完成的工作,或者在附录 B 中的概述。以下是一个包含 evaluate 方法的 Expression 基类:

from abc import ABC, abstractmethod

class Expression(ABC):
    @abstractmethod
    def evaluate(self, **bindings):
        pass

由于一个表达式可以包含多个变量,我设置了这样的结构,您可以通过关键字参数传递变量绑定。例如,绑定 {"x":3,"y":2} 表示用 3 替换 x,用 2 替换 y。这在评估表达式时提供了一些语法糖。如果 z 代表表达式 2xy³,那么一旦我们完成,我们就能执行以下操作:

>>> z.evaluate(x=3,y=2)
48

到目前为止,我们只有一个抽象类。现在我们需要让所有的表达式类都从 Expression 继承。例如,一个 Number 实例作为一个单独的数字(如 7)是一个有效的表达式。无论提供的变量绑定如何,数字都会评估为自身:

class Number(Expression):
    def __init__(self,number):
        self.number = number
    def evaluate(self, **bindings):
        return self.number

例如,评估 Number(7).evaluate(x=3,y=6,q=−15) 或任何其他评估,都会返回基础数字 7。

处理变量也很简单。如果我们查看表达式 Variable("x"),我们只需要查看绑定,看看变量 x 被设置为哪个数字。当我们完成时,我们应该能够运行 Variable("x").evaluate(x=5) 并得到 5 作为结果。如果我们找不到 x 的绑定,那么我们无法完成评估,并需要引发异常。以下是 Variable 类的更新定义:

class Variable(Expression):
    def __init__(self,symbol):
        self.symbol = symbol
    def evaluate(self, **bindings):
        try:
            return bindings[self.symbol]
        except:
            raise KeyError("Variable '{}' is not bound.".format(self.symbol))

处理这些元素后,我们需要将注意力转向组合子。请注意,我们不会将 Function 对象视为单独的 Expression,因为像正弦这样的函数不是一个独立的表达式。它只能在 Apply 组合子提供的参数上下文中进行评估。)对于像 Product 这样的组合子,评估它的规则很简单:评估产品中包含的两个表达式,然后将结果相乘。在产品中不需要进行替换,但我们将绑定传递给两个子表达式,以防其中任何一个包含 Variable

class Product(Expression):
    def __init__(self, exp1, exp2):
        self.exp1 = exp1
        self.exp2 = exp2
    def evaluate(self, **bindings):
        return self.exp1.evaluate(**bindings) * self.exp2.evaluate(**bindings)

在这三个类更新了 evaluate 方法之后,我们现在可以评估由变量、数字和乘积构建的任何表达式。例如,

>>> Product(Variable("x"), Variable("y")).evaluate(x=2,y=5)
10

同样,我们可以为 SumPowerDifferenceQuotient 组合子(以及您可能作为练习创建的任何其他组合子)添加 evaluate 方法。一旦我们评估了它们的子表达式,组合子的名称就会告诉我们可以使用哪种操作来获得整体结果。

Apply组合子的工作方式略有不同,因此值得特别注意。我们需要动态地查看像 sin 或 sqrt 这样的函数名,并找出如何计算其值。有几种可能的方法可以做到这一点,但我选择在Apply类上保留已知函数的字典作为数据。作为第一步,我们可以让我们的评估器意识到三个命名函数:

_function_bindings = {
    "sin": math.sin,
    "cos": math.cos,
    "ln": math.log
}
class Apply(Expression):
    def __init__(self,function,argument):
        self.function = function
        self.argument = argument
    def evaluate(self, **bindings):
        return _function_bindingsself.function.name)

你可以自己练习编写其余的评估方法,或者在本书的源代码中找到它们。一旦你完全实现了所有这些,你将能够评估第 10.1.3 节中的f_expression

>>>  f_expression.evaluate(x=5)
−76.71394197305108

这里的结果并不重要,重要的是它与普通 Python 函数f(x)给出的结果相同:

>>> xf(5)
−76.71394197305108

配备了评估函数,我们的Expression对象可以执行与其对应的普通 Python 函数相同的工作。

10.3.3 展开一个表达式

我们可以用我们的表达式数据结构做很多事情。在练习中,你可以尝试构建一些更多以不同方式操作表达式的 Python 函数。现在,我将展示一个例子,这是我在这章开头提到的:展开一个表达式。我的意思是从任何乘积或幂的和开始执行。

代数的相关规则是和与积的分配律。这个规则说明,形式为(a + b) · c的乘积等于ac + bc,同样,x(y + z) = xy + xz。例如,我们的表达式(3x² + x) sin(x)等于 3x² sin(x) + x sin(x),这就是第一个乘积的展开形式。你可以使用这个规则多次展开更复杂的表达式,例如:

(x + y)³ = (x + y)(x + y)(x + y)

= x(x + y)(x + y) + y(x + y)(x + y)

= x²(x + y) + x**y(x + y) + y**x(x + y) + y²(x + y)

= x³ + x²y + x²y + x**y² + y**x² + y²x + y²x + y³

= x³ + 3x²y + 3y²x + y³

如你所见,展开一个短的表达式如(x + y)³可能需要很多写作。除了展开这个表达式外,我还稍微简化了结果,将看起来像xyxxxy的乘积重写为x² y,例如。这是可能的,因为乘法中顺序并不重要。然后我进一步通过合并同类项来简化,注意到每个x² yy² x各有三个加和,并将它们组合成 3x² y和 3y² x。在下面的例子中,我们只看如何进行展开;你可以将简化作为练习来实现。

我们可以从向Expression基类添加一个抽象的expand方法开始:

class Expression(ABC):
    ...
    @abstractmethod
    def expand(self):
        pass

如果一个表达式是变量或数字,它已经展开。对于这些情况,expand方法返回对象本身。例如,

class Number(Expression):
    ...
    def expand(self):
        return self

和已经被视为展开的表达式,但和的各个项不能展开。例如,5 + a(x + y) 是一个和,其中第一个项 5 已经完全展开,但第二个项 a(x + y) 没有展开。要展开一个和,我们需要展开每个项并将它们相加:

class Sum(Expression):
    ...
    def expand(self):
        return Sum(*[exp.expand() for exp in self.exps])

同样的程序也适用于函数应用。我们无法展开 Apply 函数本身,但我们可以展开其参数。这将展开一个像 sin(x(y + z)) 这样的表达式到 sin(xy + xz):

class Apply(Expression):
    ...
    def expand(self):
        return Apply(self.function, self.argument.expand())

当我们展开乘积或幂时,表达式的结构会完全改变,这才是真正的挑战。例如,a(b + c) 是一个变量与两个变量的和的乘积,而其展开形式是 ab + ac,即两个变量的乘积之和。为了实现分配律,我们必须处理三种情况:乘积的第一个项可能是一个和,第二个项可能是一个和,或者两者都不是和。在后一种情况下,不需要展开:

class Product(Expression):
    ...
    def expand(self):
        expanded1 = self.exp1.expand()                 ❶
        expanded2 = self.exp2.expand()
        if isinstance(expanded1, Sum):                 ❷
            return Sum(*[Product(e,expanded2).expand() 
                         for e in expanded1.exps])
        elif isinstance(expanded2, Sum):               ❸
            return Sum(*[Product(expanded1,e) 
                         for e in expanded2.exps])
        else:
            return Product(expanded1,expanded2)        ❹

❶ 展开乘积的两个项

❷ 如果乘积的第一个项是一个和,它将每个项与乘积的第二个项相乘,然后对结果调用 expand 方法,如果乘积的第二个项也是一个和。

❸ 如果乘积的第二个项是一个和,它将每个项乘以乘积的第一个项。

❹ 否则,两个项都不是和,不需要使用分配律。

实现了所有这些方法后,我们可以测试 expand 函数。通过适当的 __repr__ 实现(见练习),我们可以在 Jupyter 或交互式 Python 会话中清楚地看到结果字符串表示。它正确地将 (a + b) (x + y) 展开为 ax + ay + bx + by :

Y = Variable('y')
Z = Variable('z')
A = Variable('a')
B = Variable('b')
>>> Product(Sum(A,B),Sum(Y,Z))
Product(Sum(Variable("a"),Variable("b")),Sum(Variable("x"),Variable("y")))
>>> Product(Sum(A,B),Sum(Y,Z)).expand()
Sum(Sum(Product(Variable("a"),Variable("y")),Product(Variable("a"),
Variable("z"))),Sum(Product(Variable("b"),Variable("y")),
Product(Variable("b"),Variable("z"))))

我们的表达式 (3x² + x) sin(x) 正确地展开为 3x² sin(x) + x sin(x):

>>> f_expression.expand()
Sum(Product(Product(3,Power(Variable("x"),2)),Apply(Function("sin"),Variable("x"))),Product(Variable("x"),Apply(Function("sin"),Variable("x"))))

到目前为止,我们已经编写了一些 Python 函数,它们真正为我们做代数运算,而不仅仅是算术运算。这种类型的编程(称为 符号编程,或更具体地说,计算机代数)有很多令人兴奋的应用,我们无法在本书中涵盖所有这些应用。你应该尝试以下练习中的几个,然后我们继续到我们最重要的例子:求导数的公式。

10.3.4 练习

| 练习 10.9:编写一个函数 contains(expression, variable),该函数检查给定的表达式是否包含指定变量的任何出现。解决方案:你可以轻松地检查变量是否出现在 distinct_variables 的结果中,但这里是从头开始实现的:

def contains(exp, var):
    if isinstance(exp, Variable):
        return exp.symbol == var.symbol
    elif isinstance(exp, Number):
        return False
    elif isinstance(exp, Sum):
        return any([contains(e,var) for e in exp.exps])
    elif isinstance(exp, Product):
        return contains(exp.exp1,var) or contains(exp.exp2,var)
    elif isinstance(exp, Power):
        return contains(exp.base, var) or contains(exp.exponent, var)
    elif isinstance(exp, Apply):
        return contains(exp.argument, var)
    else:
        raise TypeError("Not a valid expression.")

|

| 练习 10.10:编写一个 distinct_functions 函数,该函数接受一个表达式作为参数,并返回表达式中出现的不同、命名的函数(如 sin 或 ln)。解决方案:实现看起来与第 10.3.1 节中的 distinct_variables 函数非常相似:

def distinct_functions(exp):
    if isinstance(exp, Variable):
        return set()
    elif isinstance(exp, Number):
        return set()
    elif isinstance(exp, Sum):
        return set().union(*[distinct_functions(exp) for exp in exp.exps])
    elif isinstance(exp, Product):
        return distinct_functions(exp.exp1).union(distinct_functions(exp.exp2))
    elif isinstance(exp, Power):
        return distinct_functions(exp.base).union(distinct_functions(exp.exponent))
    elif isinstance(exp, Apply):
        return set([exp.function.name]).union(distinct_functions(exp.argument))
    else:
        raise TypeError("Not a valid expression.")

|

| 练习 10.11:编写一个函数 contains_sum,它接受一个表达式并返回 True 如果它包含一个 Sum,否则返回 False解决方案

def contains_sum(exp):
    if isinstance(exp, Variable):
        return False
    elif isinstance(exp, Number):
        return False
    elif isinstance(exp, Sum):
        return True
    elif isinstance(exp, Product):
        return contains_sum(exp.exp1) or contains_sum(exp.exp2)
    elif isinstance(exp, Power):
        return contains_sum(exp.base) or contains_sum(exp.exponent)
    elif isinstance(exp, Apply):
        return contains_sum(exp.argument)
    else:
        raise TypeError("Not a valid expression.")

|

练习 10.12-迷你项目:在 Expression 类上编写一个 __repr__ 方法,以便在交互会话中清晰显示。解决方案:请参阅第十章的教程笔记本或参阅附录 B 中对 __repr__ 和 Python 类上的其他特殊方法的讨论。
练习 10.13-迷你项目:如果你知道如何使用 LaTeX 语言编码方程,请在 Expression 类上编写一个 _repr_latex_ 方法,该方法返回表示给定表达式的 LaTeX 代码。在添加此方法后,你应该能在 Jupyter 中看到你表达式的精美排版渲染!图片添加 _repr_latex_ 方法会导致 Jupyter 在 REPL 中以优美的形式渲染方程。解决方案:请参阅第十章的教程笔记本。

| 练习 10.14-迷你项目:编写一个方法来生成表示表达式的 Python 代码。使用 Python 的 eval 函数将其转换为可执行的 Python 函数。将结果与评估方法进行比较。例如,Power(Variable("x"),Number(2)) 表示表达式 x²。这应该生成 Python 代码 x**2。然后使用 Python 的 eval 函数执行此代码,并展示它如何与评估方法的结果相匹配。解决方案:请参阅实现教程笔记本。完成后,你可以运行以下代码:

>>> Power(Variable("x"),Number(2))._python_expr()
'(*x*) ** (2)'
>>> Power(Variable("x"),Number(2)).python_function(x=3)
9

|

10.4 求函数的导数

虽然可能看起来不明显,但函数的导数通常有一个干净代数公式。例如,如果 f(x) = x³,那么其导数 f(x),它衡量的是在任意点 xf 的瞬时变化率,由 f(x) = 3x² 给出。如果你知道这样的公式,你可以得到一个精确的结果,例如 f'(2) = 12,而不需要使用小割线相关的数值问题。

如果你曾在高中或大学学习过微积分,那么你很可能花了很多时间学习和练习如何找到导数的公式。这是一个不需要太多创造力的直接任务,可能会很繁琐。这就是为什么我们将简要地介绍规则,然后专注于让 Python 为我们完成剩下的工作。

10.4.1 幂的导数

即使不知道任何微积分,你也可以找到形式为 f(x) = mx + b 的线性函数的导数。这条线上任何割线的斜率,无论多小,都与线的斜率 m 相同;因此,f(x) 不依赖于 x。具体来说,我们可以得出 f(x) = m。这很有意义:线性函数 f(x) 相对于其输入 x 的变化率是恒定的,因此其导数是一个常数函数。此外,常数 b 对线的斜率没有影响,因此它不会出现在导数中(图 10.12)。

图片

图 10.12 线性函数的导数是常数函数。

结果表明,二次函数的导数是一次函数。例如,q(x) = x²的导数是q'(x) = 2x。如果你绘制q(x)的图形,这也很有道理。q(x)的斜率开始是负的,然后增加,最终在x = 0 后变成正的。函数q'(x) = 2x与这种定性描述相符。

作为另一个例子,我向你展示了x³的导数是 3x²。所有这些事实都是一般规则的特例:当你对一个函数f(x)求导,该函数是x的幂时,你得到的是一个比原来低一级的幂的函数。具体来说,图 10.13 展示了形式为axn的函数的导数是nax^n的负一次方。

图片

图 10.13 幂函数导数的一般规则:对一个函数f(x)求导,该函数是x的幂,得到的是一个比原来低一级的幂的函数。

让我们通过一个具体的例子来分析这个问题。如果g(x) = 5x⁴,那么这个函数的形式是axn,其中a = 5,n = 4。导数是nax^n的负一次方,这变成了 4 · 5 · x^(4−1) = 20x³。像本章中我们讨论的任何其他导数一样,你可以通过将图形与第九章中我们的数值导数函数的结果并排绘制来双重检查这个结果。图形应该完全一致。

线性函数f(x)是x的幂:f(x) = mx¹。幂规则在这里同样适用:mx¹的导数是 1 · mx⁰,因为x⁰ = 1。通过几何考虑,添加一个常数b不会改变导数;它会使图形上下移动,但不会改变斜率。

10.4.2 变换函数的导数

给函数添加一个常数永远不会改变它的导数。例如,x¹⁰⁰的导数是 100x⁹⁹,而x¹⁰⁰ − π的导数也是 100x⁹⁹。但是,函数的一些修改确实会改变导数。例如,如果你在函数前加上一个负号,图形会翻转过来,任何割线的图形也会翻转。如果翻转前的割线斜率是m,翻转后就是- mx的变化与之前相同,但y = f(x)的变化现在方向相反(图 10.14)。

图片

图 10.14 对于f(x)上的任何割线,f(x)的同一x区间的割线具有相反的斜率。

因为导数是由割线的斜率决定的,所以负函数-f(−x)的导数等于负导数-f'(x)。这与我们之前看到的公式一致:如果f(x) = −5x²,那么a = −5,f'(x) = −10x(与导数为+10x的 5x²相比)。另一种说法是,如果你将一个函数乘以-1,那么它的导数也会乘以-1。

对于任何常数也是如此。如果你将 f(x)乘以 4 得到 4f(x),图 10.15 显示这个新函数在每一点上都变得四倍陡峭,因此它的导数是 4f'(x)。

图 10.15 将一个函数乘以 4 会使每条割线变得四倍陡峭。

这与我所展示的导数的幂规则一致。知道 x²的导数是 2x,你也知道 10x²的导数是 20x,−3x²的导数是−6x,等等。我们还没有涉及这一点,但如果我告诉你 sin(x)的导数是 cos(x),你将立刻知道 1.5 · sin(x)的导数是 1.5 · cos(x)。

一个重要的最终变换是将两个函数相加。如果你观察图 10.16 中任意一对函数 f 和 g 的 f(x) + g(x)的图像,任何割线的垂直变化都是该区间内 f 和 g 的垂直变化的和。

当我们处理公式时,我们可以独立地对和中的每一项求导。如果我们知道 x²的导数是 2x,x³的导数是 3x²,那么 x² + x³的导数是 2x + 3x²。这个规则给出了为什么 mx + b 的导数是 m 的更精确的理由;项的导数分别是 m 和 0,因此整个公式的导数是 m + 0 = m。

图 10.16 在某个 x 区间上 f(x)的垂直变化是 f(x)和 g(x)在该区间的垂直变化的和。

10.4.3 一些特殊函数的导数

有许多函数不能写成 ax^n 的形式,甚至不能写成这种形式的项的和。例如,三角函数、指数函数和对数都需要单独考虑。在微积分课程中,你学习如何从头开始计算这些函数的导数,但这超出了本书的范围。我的目标是向你展示如何求导,这样当你遇到这些函数时,你将能够解决手头的问题。为此,我给你一个快速列表,其中包含一些其他重要的导数规则(表 10.1)。

表 10.1 一些基本的导数(续)

函数名称 公式 导数
正弦 sin(x) cos(x)
余弦 cos(x) −sin(x)
指数 e^x e^x
指数(任何底数) a^x ln(a) · a^x
自然对数 ln(x) 1/x
对数(任何底数) log_a(x) 1/ln(a) · x

你可以使用这个表格以及之前的规则来找出更复杂的导数。例如,让 f(x) = 6x + 2 sin(x) + 5 ex。第一项的导数是 6,根据第 10.4.1 节中的幂法则。第二项包含 sin(x),其导数是 cos(x),因子 2 将结果加倍,得到 2 cos(x)。最后,ex 是它自己的导数(一个非常特殊的情况!),所以 5 ex 的导数是 5 ex。所有这些加在一起,导数是 f'(x) = 6 + 2 cos(x) + 5 ex

你必须小心,只使用我们之前提到的规则:幂法则(第 10.4.1 节)、表 10.1 中的规则以及和与标量乘法的规则。如果你的函数是 g(x) = sin(sin(x)),你可能会想写成 g'(x) = cos(cos(x)),在两个出现的地方都代入正弦的导数。但这是不正确的!你也不能推断出乘积 ex cos(x) 的导数是 − ex sin(x)。当函数以除了加法和减法以外的其他方式组合时,我们需要新的规则来求它们的导数。

10.4.4 乘积和复合函数的导数

让我们看看像 f(x) = x² sin(x) 这样的乘积。这个函数可以写成两个其他函数的乘积:f(x) = g(x) · h(x),其中 g(x) = x² 和 h(x) = sin(x)。正如我刚才警告你的,f'(x) 并不等于 g'(x) · h'(x)。幸运的是,还有一个正确的公式,它被称为乘积法则

乘积法则 如果 f(x) 可以写成两个其他函数 gh 的乘积,即 f(x) = g(x) · h(x),那么 f(x) 的导数由以下公式给出:

f'(x) = g'(x) · h(x) + g(x) · h'(x)

让我们练习将这个规则应用到 f(x) = x² sin(x) 上。在这种情况下,g(x) = x² 和 h(x) = sin(x),所以 g'(x) = 2xh'(x) = cos(x),正如我之前所展示的。将这些值代入乘积法则公式 f'(x) = g'(x) · h(x) + g(x) · h'(x),我们得到 f'(x) = 2x sin(x) + x² cos(x)。这就是全部内容!

你可以看到,这个乘积法则与第 10.4.1 节中的幂法则是兼容的。如果你将 x² 写成 x · x 的乘积,乘积法则会告诉你它的导数是 1 · x + x · 1 = 2x

另一条重要的规则告诉我们如何对像 ln(cos(x)) 这样的复合函数求导。这个函数的形式是 f(x) = g(h(x)),其中 g(x) = ln(x) 和 h(x) = cos(x)。我们并不能简单地将导数代入我们看到的函数中,得到 −1/sin(x);答案要复杂一些。形式为 f(x) = g(h(x)) 的函数的导数公式被称为链式法则

链式法则 如果 f(x) 是两个函数的复合,意味着它可以写成 f(x) = g(h(x)) 的形式,对于某些函数 gh,那么 f 的导数由以下公式给出:

f'(x) = h'(x) · g'(h(x))

在我们的例子中,g'(x) = 1/xh'(x) = −sin(x) 都是从表 10.1 中读取的。然后将它们代入链式法则公式,我们得到以下结果:

你可能记得 sin(x)/cos(x) = tan(x),因此我们可以更简洁地写出 ln(cos(x)) 的导数 = tan(x)。我会在练习中给你更多练习乘积和链式法则的机会,你也可以查阅任何微积分书籍,以获取计算导数的丰富示例。你不必相信我的话,如果你找到一个导数的公式或者使用第九章中的导数函数,你应该得到相同的结果。在下一节中,我将向你展示如何将导数规则转换为代码。

10.4.5 练习

| 练习 10.15:通过绘制数值导数(使用第八章中的导数函数)和符号导数 f'(x) = 5x⁴ 并排,来证明 f(x) = x⁵ 的导数确实是 f'(x) = 5x⁴。解答

def *p*(*x*):
    return x**5
plot_function(derivative(p), 0, 1)
plot_function(lambda x: 5*x**4, 0, 1)

两个图完全重叠!5x⁴ 的图像和 x⁵ 的(数值)导数 |

练习 10.16-迷你项目:让我们再次将一元函数视为向量空间,就像我们在第六章中所做的那样。解释为什么求导数的规则意味着导数是这个向量空间的线性变换。(具体来说,你必须将注意力限制在处处有导数的函数上。)解答:将函数 fg 视为向量,我们可以将它们相加,并用标量乘以它们。记住,(f + g)(x) = f(x) + g(x) 和 (c · f )(x) = c · f(x)。一个 线性变换 是一个保持向量加法和标量乘法的变换。如果我们把导数写成函数 D,我们可以把它看作是输入一个函数并返回其导数的输出。例如,Df = f'。两个函数和的导数是导数的和:D(f + g) = Df + Dg 函数乘以一个数 c 的导数是原始函数导数的 c 倍:D(c · f ) = c · Df 这两个规则意味着 D 是一个线性变换。特别地,函数线性组合的导数与它们的导数的线性组合相同:D(a · f + b · g) = a · Df + b · Dg
练习 10.17-迷你项目:找到一个商的导数公式:f(x) / g(x)。提示:使用以下事实!幂法则对负指数也成立;例如,x^(−1) 的导数是 − x^(−2) = −1/x²。解答:根据链式法则,g(x)^(−1) 的导数是 − g(x)^(−2) · g'(x) 或!
利用这些信息,商 f(x)/ g(x) 的导数等于乘积 f(x)/ g(x)^(−1) 的导数,由乘积法则给出图片将第一项乘以 g(x)/ g(x) 使得两项有相同的分母,因此我们可以将它们相加图片
练习 10.18: sin(x) · cos(x) · ln(x) 的导数是什么?解答: 这里有两个乘积,幸运的是,我们可以以任何顺序应用乘积法则,并得到相同的结果。sin(x) · cos(x) 的导数是 sin(x) · −sin(x) + cos(x) · cos(x) = cos(x)² − sin(x)²。ln(x) 的导数是 1/x,所以乘积法则告诉我们整个乘积的导数是图片
练习 10.19: 假设我们知道三个函数 fgh 的导数,分别写作 f'、g ' 和 h '。f(g(h(x))) 关于 x 的导数是什么?解答: 这里我们需要应用链式法则两次。一个项是 f'(g(h(x))),但我们需要乘以 g(h(x)) 的导数。这个导数是 g'(h(x)) 乘以内函数 h(x) 的导数。因为 g(h(x)) 的导数是 h'(x) · g'(h(x)),所以 f(g(h(x))) 的导数是 f'(x) · g'(h(x)) · f'(g(h(x)))。

10.5 自动求导

尽管我只教了你一些求导的规则,但你现在已经准备好处理无限多可能的函数了。只要函数是由和、积、幂、复合、三角函数和对数函数构成的,你就能够使用链式法则、乘积法则等来找出它的导数。

这与我们在 Python 中构建代数表达式所使用的方法相似。尽管可能性无限,但它们都是由相同的构建块和少量预定义的组装方式构成的。为了自动求导,我们需要将可表示的表达式的每一种情况(无论是元素还是组合器)与求导的适当规则相匹配。最终结果是 Python 函数,它接受一个表达式并返回一个表示其导数的新表达式。

10.5.1 为表达式实现求导方法

再次,我们可以将求导函数实现为 Expression 类中的方法。为了强制它们都具有这个方法,我们可以在抽象基类中添加一个抽象方法:

class Expression(ABC):
    ...
    @abstractmethod
    def derivative(self,var):
        pass

该方法需要接受一个参数var,表示我们对哪个变量求导。例如,f(y) = y²就需要对y求导。作为一个更复杂的例子,我们处理过像axn这样的表达式,其中an代表常数,而x是变量。从这个角度来看,导数是nax^n的(-1)次方。然而,如果我们将其视为a的函数,例如f(a) = axn*,那么导数是*xn的(-1)次方,这是一个常数乘以一个常数的幂。如果我们将其视为n的函数:如果f(n) = axn,那么f'(n) = a ln(n) x^n。为了避免混淆,在以下讨论中,我们将考虑所有表达式都是关于变量x的函数。

与往常一样,我们最容易的例子是元素:NumberVariable对象。对于Number,导数始终是表达式 0,无论传入的变量是什么:

class Number(Expression):
    ...
    def derivative(self,var):
        return Number(0)

如果我们求函数f(x) = x的导数,结果是f'(x) = 1,这是直线的斜率。求函数f(x) = c的导数应该给出 0,因为在这里c代表一个常数,而不是函数f的参数。因此,只有当我们对所求导的变量进行求导时,变量的导数才是 1;否则,导数是 0:

class Variable(Expression):
    ...
    def derivative(self, var):
        if self.symbol == var.symbol:
            return Number(1)
        else:
            return Number(0)

求导最容易的组合器是 Sum;Sum函数的导数只是其项的导数之和:

class Sum(Expression):
    ...
    def derivative(self, var):
        return Sum(*[exp.derivative(var) for exp in self.exps])

实现了这些方法后,我们可以做一些基本的例子。例如,表达式Sum(Variable("x"),Variable("c"),Number(1))代表x + c + 1,将其视为x的函数,我们可以对其关于x求导:

>>> Sum(Variable("x"),Variable("c"),Number(1)).derivative(Variable("x"))
Sum(Number(1),Number(0),Number(0))

这正确地报告了x + c + 1 关于x的导数是 1 + 0 + 0,等于 1。这是一种报告结果的方式,但至少我们得到了正确的结果。

我鼓励你进行一个迷你项目,编写一个简化方法,该方法可以消除多余的项,如添加的零。我们可以在计算导数时添加一些逻辑来简化表达式,但最好是将我们的关注点分开,并专注于现在正确地得到导数。记住这一点,让我们继续介绍其他组合器。

10.5.2 实现乘积法则和链式法则

乘积法则证明是剩余组合器中最容易实现的。给定构成乘积的两个表达式,乘积的导数定义为这些表达式及其导数。记住,如果乘积是g(x) · h(x),那么导数是g'(x) · h(x) + g(x) · h'(x)。这转化为以下代码,它将结果作为两个乘积的和返回:

class Product(Expression):
    ...
    def derivative(self,var):
        return Sum(
            Product(self.exp1.derivative(var), self.exp2),
            Product(self.exp1, self.exp2.derivative(var)))

再次,这给我们带来了正确(尽管未简化)的结果。例如,cx关于x的导数是

>>> Product(Variable("c"),Variable("x")).derivative(Variable("x"))
Sum(Product(Number(0),Variable("x")),Product(Variable("c"),Number(1)))

那个结果代表 0 · x + c · 1,这当然是c

现在我们已经处理了 SumProduct 组合子,所以让我们看看 Apply。要处理像 sin(x²) 这样的函数应用,我们需要编码正弦函数的导数以及由于括号内的 x² 而使用的链式法则。

首先,让我们用占位符变量来编码一些特殊函数的导数,这个占位符变量不太可能与我们实际使用的任何变量混淆。这些导数存储为一个字典,从函数名映射到表示其导数的表达式:

_var = Variable('placeholder variable')                       ❶

_derivatives = {
    "sin": Apply(Function("cos"), _var),                      ❷
    "cos": Product(Number(−1), Apply(Function("sin"), _var)),
    "ln": Quotient(Number(1), _var)
}

❶ 创建一个占位符变量,设计得使其不会与任何我们可能实际使用的符号(如 x 或 y)混淆

❷ 记录正弦的导数是余弦,余弦用占位符变量表示的表达式来表示

下一步是为 Apply 类添加 derivative 方法,从 _derivatives 字典中查找正确的导数并适当地应用链式法则。记住,g(h(x)) 的导数是 h'(x) · g'(h(x))。例如,如果我们正在查看 sin(x²),那么 g(x) = sin(x) 和 h(x) = x²。我们首先去字典中获取 sin 的导数,我们得到的是余弦和一个占位符值。我们需要将 h(x) = x² 插入占位符以获取链式法则中的 g'(h(x)) 项。这需要一个替换函数,该函数用表达式替换变量的所有实例(这是本章早期的一个小项目)。如果您没有完成那个小项目,您可以在源代码中查看实现。Apply 的导数方法如下:

class Apply(Expression):
    ...
    def derivative(self, var):
        return Product(
                self.argument.derivative(var),                    ❶
                _derivatives[self.function.name].substitute(_var, self.argument))                                                   ❷

❶ 返回链式法则公式中的 h'(x) = h'(x) · g'(h(x))

❷ 这是链式法则公式的 g'(h(x)),其中导数字典查找 g',并将 h(x) 替换进去。

例如,对于 sin(x²),我们有

>>> Apply(Function("sin"),Power(Variable("x"),Number(2))).derivative(*x*)
Product(Product(Number(2),Power(Variable("x"),Number(1))),Apply(Function("cos"),Power(Variable("x"),Number(2))))

事实上,这个结果可以翻译为 (2x¹) · cos(x²),这是链式法则的正确应用。

10.5.3 实现幂法则

我们需要处理的最后一种表达式是 Power 组合子。实际上,我们需要在 Power 类的 derivative 方法中包含三个导数规则。第一个是称为幂法则的规则,它告诉我们当 n 是常数时,x^n 的导数是 nx^n ^(−1)。第二个是函数 ax 的导数,其中基数 a 假设是常数,而指数变化。这个函数相对于 x 的导数是 ln(a) · a^x

最后,我们需要处理这里的链式法则,因为可能涉及到基数或指数的表达式,如 sin(x)⁸ 或 15^(cos(x))。还有一种情况是基数和指数都是变量,如 n^x 或 ln(x)^(sin(x))。在我多年的求导经历中,我从未见过一个实际应用中出现这种情况,所以我会跳过它,并抛出一个异常。

因为 xn*,*g*(*x*)n,a^x* 和 a^(g(x)) 在 Python 中都表示为 Power(expression1, expression2) 的形式,我们必须进行一些检查以确定使用哪个规则。如果指数是数字,我们使用 x^n 规则,但如果基数是数字,我们使用 a^x 规则。在这两种情况下,我默认使用链式法则。毕竟,x^nf(x)^(n) 的特例,其中 f(x) = x。以下是代码的示例:

class Power(Expression):
    ...
    def derivative(self,var):
        if isinstance(self.exponent, Number):                            ❶
            power_rule = Product(
                    Number(self.exponent.number), 
                    Power(self.base, Number(self.exponent.number − 1)))
            return Product(self.base.derivative(var),power_rule)         ❷
        elif isinstance(self.base, Number):                              ❸
            exponential_rule = Product(
                Apply(Function("ln"),
                Number(self.base.number)
            ), 
            self)
            return Product(
                self.exponent.derivative(var), 
                exponential_rule)                                        ❹
        else:
            raise Exception(
            "can't take derivative of power {}".format(
            self.display()))

❶ 如果指数是数字,则使用幂规则

f(x)^n 的导数是 f'(x) · nf(x)^(n−1),因此我们根据链式法则乘以 f'(x) 的因子。

❸ 检查基数是否为数字;如果是,我们使用指数规则。

❹ 如果我们试图对 a^(f(x)) 求导,则根据链式法则乘以 f'(x) 的因子

在最终情况下,如果基数或指数都不是数字,我们将引发一个错误。实现了这个最后的组合器后,你就有了一个完整的导数计算器!它可以处理(几乎)由你的元素和组合器构建的任何表达式。如果你用我们的原始表达式(3x² + x)sin(x)来测试它,你会得到如下详尽但正确的结果:

0 · x² + 3 · 1 · 2 · x¹ + 1 · sin(x) + (e · x² + x) · 1 · cos(x)

这简化为 (6x + 1) sin(x) + (3x² + x) cos(x),并展示了正确使用乘积和幂规则。进入这一章之前,你已经知道如何使用 Python 进行算术运算,然后你学习了如何让 Python 进行代数运算。现在,你真的可以说,你已经在 Python 中进行微积分了!在最后一节,我会告诉你一些关于在 Python 中使用 SymPy 库进行符号积分的信息。

10.5.4 练习

| 练习 10.20:我们的代码已经处理了一个表达式构成乘积且为常数的情形,即形式为 c · f(x) 或 f(x) · c 的乘积,对于某个表达式 f(x)。无论哪种方式,导数都是 c · f'(x)。你不需要乘积规则的第二项,即 f(x) · 0 = 0。更新代码以直接处理这种情况,而不是展开乘积规则并包含一个零项。解决方案:我们可以检查乘积中的任一表达式是否是 Number 类的实例。更通用的方法是查看乘积的任一项是否包含我们对它求导的变量。例如,对 (3 + sin(5^(a))) f(x) 关于 x 的导数不需要乘积规则,因为第一项不包含 x 的任何出现。因此,其导数(关于 x)是 0。我们可以使用之前练习中的 contains(expression, variable) 函数来为我们进行检查:

class Product(Expression):
    ...
    def derivative(self,var):
        if not contains(self.exp1, var):                         ❶
            return Product(self.exp1, self.exp2.derivative(var))
        elif not contains(self.exp2, var):                       ❷
            return Product(self.exp1.derivative(var), self.exp2)
        else:                                                    ❸
            return Sum(
                Product(self.exp1.derivative(var), self.exp2),
                Product(self.exp1, self.exp2.derivative(var)))

❶ 如果第一个表达式对变量没有依赖,则返回第一个表达式乘以第二个表达式的导数❷ 如果第二个表达式对变量没有依赖,则返回第一个表达式的导数乘以未修改的第二个表达式❸ 否则,使用乘积法则的一般形式 |

练习 10.21: 将平方根函数添加到已知函数字典中,并自动求其导数。提示: x 的平方根等于 x^(1/2)。解答: 使用幂法则,x 的平方根相对于 x 的导数是 ½ · x^(−1/2),也可以写成如下所示:图片

| 我们可以将那个导数公式编码成如下表达式:

_function_bindings = {
    ...
    "sqrt": math.sqrt
}

_derivatives = {
    ...
    "sqrt": Quotient(Number(1), Product(Number(2), Apply(Function("sqrt"), _var)))
}

|

10.6 符号积分函数

在前两章中,我们学习过的另一种微积分运算是积分。虽然导数接受一个函数并返回描述其变化率的函数,但积分则相反−它从其变化率重建一个函数。

10.6.1 积分作为原函数

例如,当 y = x² 时,导数告诉我们 y 相对于 x 的瞬时变化率是 2x。如果我们从 2x 开始,不定积分回答的问题是:哪个 x 的函数的瞬时变化率等于 2x?因此,不定积分也被称为 原函数

2x 关于 x 的不定积分的一个可能答案是 x²,但其他可能性还有 x² − 6 或 x² + π。因为任何常数项的导数都是 0,所以不定积分没有唯一的结果。记住,即使你知道整个旅程中汽车的速度表读数,它也不会告诉你汽车是从哪里开始或结束旅程的。因此,我们说 x² 是 2x一个 原函数,但不是 唯一的 原函数。

如果我们要谈论 不定积分不定积分,我们必须添加一个未指定的常数,写成类似 x² + C 的形式。C 被称为积分常数,在微积分课程中有些臭名昭著;它似乎是一个技术性细节,但很重要,如果学生忘记这一点,大多数老师都会扣分。

如果你已经足够练习了导数,一些积分是显而易见的。例如,x 关于 cos(x) 的积分写成

∫ cos(x)dx

结果是 sin(x) + C,因为对于任何常数 C,sin(x) + C 的导数是 cos(x)。如果你对幂法则记忆犹新,你可能能够解决这个积分:

∫ 3x²dx

当你将幂法则应用于 x³ 时,得到的表达式是 3x²,因此积分是

∫ 3x²dx = x³ + C

有些积分比较难,例如

∫ tan(x)dx

这些没有明显的解。你需要反向调用多个导数规则来找到答案。在微积分课程中,大量的时间都用于解决这类棘手的积分。使情况变得更糟的是,有些积分是不可能的。著名的是,函数

f(x) = e^(x²)

这是一个无法找到其不定积分公式(至少在没有创造一个新函数来表示它的情况下)的地方。与其让你忍受一大堆积分规则,不如让我展示如何使用一个带有integrate函数的预构建库,这样 Python 就可以为你处理积分了。

10.6.2 介绍 SymPy 库

SymPy(Sym bolic Py thon)库是一个开源的 Python 符号数学库。它有自己的表达式数据结构,就像我们构建的那样,以及重载运算符,使它们看起来像普通的 Python 代码。在这里,你可以看到一些看起来像我们一直在编写的 SymPy 代码:

>>> from sympy import *
>>> from sympy.core.core import *
>>> Mul(Symbol('y'),Add(3,Symbol('x')))
y*(x + 3)

MulSymbolAdd构造函数替换了我们的ProductVariableSum构造函数,但结果相似。SymPy 还鼓励你使用缩写;例如,

>>> y = Symbol('y')
>>> xx = Symbol('x')
>>> y*(3+x)
y*(x + 3)

创建了一个等效的表达式数据结构。你可以通过我们的替换和求导能力看到它是一个数据结构:

>>> y*(3+x).subs(x,1)
4*y
>>> (x**2).diff(*x*)
2*x

当然,SymPy 是一个比我们在本章中构建的库更健壮的库。正如你所见,表达式会自动简化。

我介绍 SymPy 的原因是展示其强大的符号积分功能。你可以这样找到表达式 3x²的积分:

>>> (3*x**2).integrate(*x*)
x**3

这告诉我们,

∫ 3x²dx = x³ + C

在接下来的几章中,我们将继续使用导数和积分。

10.6.3 练习

| 练习 10.22f(x) = 0 的积分是什么?用 SymPy 确认你的答案,记住 SymPy 不会自动包含积分常数。解答:另一种问这个问题的方式是询问什么函数的导数是零?任何常数值函数在所有地方都有一个零斜率,因此它有一个零导数。积分是∫ f(x)dx = ∫ dx = C在 SymPy 中,代码Integer(0)给你一个作为表达式的数字 0,所以对变量x的积分是

>>> Integer(0).integrate(*x*)
0

零作为一个函数,是零的一个反导数。加上积分常数,我们得到 0 + C或者就是C,这与我们得出的结果相符。任何常数函数都是常数零函数的反导数。|

练习 10.23x cos(x)的积分是什么?提示:看看x sin(x)的导数。用 SymPy 确认你的答案。

| 解答:让我们从提示开始−根据乘积法则,x sin(x) 的导数是 sin(x) + x cos(x)。这几乎是我们想要的,但多了一个 sin(x) 项。如果我们有一个出现在导数中的 −sin(x) 项,它就会抵消这个额外的 sin(x),而 cos(x) 的导数是 −sin(x)。也就是说,x sin(x) + cos(x) 的导数是 sin(x) + x cos(x) − sin(x) = x cos(x)。这是我们想要的结果,所以积分是∫ x cos(x)dx* = x sin(x) + cos(x) + C。我们的答案在 SymPy 中得到了验证:

>>> (x*cos(*x*)).integrate(*x*)
x*sin(*x*) + cos(*x*)

这种将导数作为乘积的一个项进行逆向工程的方法被称为分部积分,并且是所有微积分教师的喜爱技巧。|

| 练习 10.24x² 的积分是什么?用 SymPy 验证你的答案。解答:如果 f(x) = x²,那么 f(x) 可能包含 x³,因为幂律将幂次降低一个。x³ 的导数是 3x²,所以我们想要一个函数,它给出这个结果的三分之一。我们想要的是 x³/3,它的导数是 x²。换句话说,∫ x²dx* = x³/3 + C。SymPy 验证了这一点:

>>> (x**2).integrate(x)
x**3/3

|

摘要

  • 将代数表达式建模为数据结构而不是代码字符串,让你能够编写程序来回答更多关于这些表达式的问题。

  • 在代码中建模代数表达式最自然的方式是将其视为一个。树的节点可以分为元素(变量和数字)这些是独立的表达式,以及组合器(和、积等)这些包含两个或更多子表达式的组合。

  • 通过递归遍历表达式树,你可以回答关于它的问题,例如它包含哪些变量。你也可以评估或简化表达式,或者将其翻译成另一种语言。

  • 如果你知道定义函数的表达式,你可以应用一些规则来将其转换成函数导数的表达式。其中包含乘积法则和链式法则,它们告诉你如何对表达式的乘积和函数的复合求导。

  • 如果你为你的 Python 表达式树中的每个组合器编程相应的导数规则,你将得到一个 Python 函数,它可以自动找到导数的表达式。

  • SymPy 是一个强大的 Python 库,用于在 Python 代码中处理代数表达式。它具有内置的简化、替换和导数函数。它还有一个符号积分函数,可以告诉你函数不定积分的公式。

11 模拟力场

本章涵盖

  • 使用标量和矢量场建模像重力这样的力

  • 使用梯度计算力矢量

  • 在 Python 中计算函数的梯度

  • 为小行星游戏添加引力

  • 在高维空间中计算梯度场和操作矢量场

在我们的小行星游戏宇宙中刚刚发生了一场灾难性事件:屏幕中央出现了一个黑洞!由于这个新物体(如图 11.1 所示)的出现,飞船和所有的小行星都将感受到“引力吸引”向屏幕中央。这使得游戏更具挑战性,同时也给我们带来了一个新的数学挑战−理解 力场

图 11.1 哎呀,黑洞!

重力是距离作用力的熟悉例子,这意味着你不需要接触一个物体就能感受到它的引力。例如,当你乘坐飞机时,你仍然可以正常行走,因为即使在

30,000 英尺的高度,地球正在向下拉着你。磁力和静电是其他熟悉的距离作用力。在物理学中,我们想象这些力的来源,如磁铁或静电充气的气球,在它们周围产生一个看不见的力场。在任何地球引力场(称为引力场)中的任何地方,一个物体都会感受到向地球的拉力。

本章的核心编码挑战是为小行星游戏添加引力场,一旦完成这个任务,我们将更普遍地介绍数学。具体来说,力场是用称为矢量场的数学函数建模的。矢量场通常作为微积分运算(称为 梯度)的输出,而梯度是我们在第三部分中涵盖的机器学习示例中的关键工具。

本章中的数学和代码并不特别难,但有很多新的概念需要熟悉。因此,在深入探讨之前,我想先展示本章的故事线。

11.1 使用矢量场建模重力

一个 矢量场 是在空间中的每一个点分配一个矢量。一个 引力场 是一个矢量场,它告诉我们从任何给定点引力有多强以及引力方向是什么。你可以通过选择一些点并从每个点开始绘制分配给该点的矢量作为箭头来可视化矢量场。例如,我们小行星游戏中由黑洞产生的引力场可能看起来像图 11.2。

图 11.2 在我们的小行星游戏中描绘由黑洞产生的引力场

图 11.2 与我们关于引力的直觉一致;围绕黑洞的所有箭头都指向黑洞,因此任何放置在这个区域的物体都会感受到被黑洞吸引。靠近黑洞的地方,它的引力更强,因此箭头更长。

本章的第一件事是将引力场建模为函数,通过空间中的一个点来告诉我们物体在该点会感受到的力的强度和方向。在 Python 中,一个二维矢量场是一个函数,它接受一个表示点的二维矢量,并返回一个在该点的二维矢量,即该点的力。

一旦我们构建了这个函数,我们就用它来为我们的小行星游戏添加引力场。它将告诉我们宇宙飞船和小行星在它们的位置上感受到的引力,因此,它们的加速度速率和方向应该是什么。一旦我们实现了加速度,我们将在小行星游戏中看到物体向黑洞加速。

11.1.1 使用势能函数建模引力

在建模引力场之后,我们将探讨第二个等效的心理模型,即称为势能的远程力。你可以将势能视为储存的能量,准备转换为运动。例如,一开始弓箭没有势能,但是当你拉弓时,它就获得了势能。当弓被释放时,这种能量就转换为运动(图 11.3)。

图片

图 11.3 左边,弓没有势能。右边,它具有大量的势能,准备用来使箭运动。

你可以想象将宇宙飞船从黑洞拉远就像拉回一个想象中的弓箭。你将宇宙飞船拉得越远,它就具有越多的势能,释放后它最终的速度就越快。我们将势能建模为另一个 Python 函数,它接受游戏世界中物体的二维位置矢量,并返回该点势能的数值。将数值(而不是矢量)分配给空间中的每个点称为标量场

使用势能函数,我们将使用几个 Matplotlib 可视化来查看其外观。一个重要的例子是称为热图,它使用深浅不同的颜色来显示标量场在二维空间中的值如何变化(图 11.4)。

图片

图 11.4 使用较亮的颜色表示较高势能值的热图

如图 11.4 所示,在这个热力图上,你离黑洞越远,颜色越亮,这意味着势能越大。表示势能的标量场与表示引力场的矢量场是不同的数学模型,但它们代表相同的物理现象。它们还通过称为“梯度”的运算在数学上相互关联。

标量场的梯度是一个矢量场,它告诉我们标量场中最大增加的方向和大小。在我们的例子中,势能随着你远离黑洞而增加,因此势能的梯度是一个在每个点上指向外部的矢量场。将梯度矢量场叠加在势能热力图上,图 11.5 显示箭头指向势能增加的方向。

图片

图 11.5 展示了势能函数作为热力图绘制,其梯度,一个矢量场,叠加在上面。梯度指向势能增加的方向。

图 11.5 中的梯度矢量场看起来类似于黑洞的引力场,但箭头指向相反的方向,大小相反。要从势能函数中获得引力场,我们需要取梯度,然后通过添加负号来反转力场矢量的方向。在本章结束时,我将向您展示如何使用导数来计算标量场的梯度,这使我们能够从重力的势能模型切换到力场模型。

既然你对本章的内容有了大致的了解,我们就准备深入研究了。我们将首先更仔细地研究矢量场,并看看如何将它们转换为 Python 函数。

11.2 建模引力场

矢量场是对空间中每个点分配一个矢量,例如,在我们的小行星游戏中,每个位置的引力力矢量。我们将专门研究二维矢量场,它将二维矢量分配给二维空间中的每个点。我们将首先

我们要做的是构建矢量场的具体表示,作为 Python 函数,它接受二维矢量作为输入,并返回二维矢量作为输出。我在源代码中提供了一个函数plot_vector_field,它接受这样的函数作为参数,并通过在二维的大量输入点上绘制输出矢量来绘制它的图像。

然后,我们将编写代码将黑洞添加到我们的小行星游戏中。对我们来说,黑洞只是一个黑色圆圈,它对周围的物体施加吸引力,如图 11.6 所示。

图片

图 11.6 显示,在我们的小行星游戏中,黑洞是一个黑色圆圈,游戏中的每个物体都感受到向它施加的引力。

要使这起作用,我们实现一个 BlackHole 类,定义其相应的引力场为一个函数,然后更新我们的游戏循环,使太空船和小行星根据牛顿定律对力做出反应。

11.2.1 定义向量场

让我们简要介绍一些向量场的基本符号。二维平面上的向量场是一个函数 F(x, y),它接受由其两个坐标 xy 表示的向量。它返回另一个二维向量,这是向量场在点 (x, y) 处的值。粗体 F 表示其返回值是向量,我们可以说 F 是一个向量值函数。当我们谈论向量场时,我们通常将输入解释为平面上的点,将输出解释为箭头。图 11.7 显示了向量场 F(x, y) = (−2y, x) 的示意图。

图 11.7 向量场 F(x, y) = (−2y, x) 以点 (3, 1) 为输入,并产生输出箭头 (−2, 3)。

通常,我们将输出向量绘制为从平面上的输入向量点开始的箭头,这样输出向量就“附加”到输入点上(图 11.8)。

图 11.8 将向量 (−2, 3) 附加到点 (3, 1)

如果你计算 F 的几个值,你可以通过同时绘制多个附加到点的箭头来开始想象向量场。图 11.9 显示了三个额外的点 (−2, 2),(−1, −2),和 (−1, −2),它们各自附加了表示 F 在这些点上的值的箭头。结果分别是 (−4, −2),(4, −1),和 (4, 3)。

图 11.9 将箭头附加到点,表示向量场 F(x, y) = (−2y, x) 的更多值

图 11.10 以向量形式绘制 F(x, y),这些向量从由 Matplotlib 生成的 (x, y) 点发散出来

如果我们画很多箭头,它们开始重叠,图变得难以辨认。为了避免这种情况,我们通常通过一个常数因子缩小向量的长度。我在 Matplotlib 上包含了一个名为 plot_vector_field 的包装函数,你可以按照以下方式使用它来生成向量场的可视化。你可以看到向量场 F(x, y) 以逆时针方向围绕原点循环(图 11.10):

def f(x,y):
    return (−2*y, x)

plot_vector_field(f, −5,5,−5,5)     ❶

❶ 第一个参数是向量场;下一个参数是绘图的范围 x 界限,然后是 y 界限。

物理学的一个重大思想是某些类型的力如何通过向量场建模。我们接下来要关注的例子是重力的一种简化模型。

11.2.2 定义简单的力场

如您所预期,随着您靠近它们的来源,引力会变得更强。尽管太阳的引力比地球强,但您离地球更近,所以您只感觉到地球的引力。为了简化,我们不会使用真实的引力场。相反,我们将使用向量场 F(r) = −r,在平面上的表示为 F(x, y) = (−x, − y)。以下是它在代码中的样子,图 11.11 显示了它在图上的样子:

def f(x,y):
    return (−x,-y)

plot_vector_field(f,−5,5,−5,5)

图片

图 11.11 向量场 F(x, y) = (−x, -y) 的可视化

这个向量场就像一个引力场,它在任何地方都指向原点,但它有一个优点,即随着距离的增加,场变得更强。这保证了模拟物体无法达到逃逸速度并完全消失在视野中;任何偏离的物体最终都会到达一个力场足够强大以减慢其速度并将其拉回原点的点。让我们通过在我们的小行星游戏中实现这个引力场来验证这一点。

11.3 在小行星游戏中添加引力

我们游戏中的黑洞是一个具有 20 个等距顶点的 PolygonModel 对象,因此它将大致呈圆形。我们通过一个数字指定黑洞的引力强度,我们将称之为其引力。这个数字传递给了黑洞的构造函数:

class BlackHole(PolygonModel):
    def __init__(self,gravity):
        vs = [vectors.to_cartesian((0.5, 2 * pi * i / 20))
                for i in range(0,20)]                     ❶
        super().__init__(vs)
        self.gravity = gravity #<2>

❶ 定义 BlackHole 的顶点为 PolygonModel

注意,我们的 BlackHole 中的 20 个顶点都位于原点 0.5 个单位距离处,角度均匀分布,因此黑洞看起来大致呈圆形。添加以下行

black_hole = BlackHole(0.1)

创建了一个 BlackHole 对象,其 gravity 值为 0.1,默认情况下位于原点。为了使黑洞出现在屏幕上(图 11.12),我们需要用以下方式绘制它:

图片

图 11.12 使黑洞出现在我们的游戏屏幕中心

游戏循环的每次迭代。在下面的内容中,我向 draw_poly 函数添加了一个 fill 关键字参数来填充黑洞,使其(适当地)变黑:

draw_poly(screen, black_hole, fill=True)

我们的黑洞产生的引力场灵感来源于向量场 F(x, y) = (−x, − y),它指向原点。如果黑洞位于 (x[bh], y[bh]),则向量场 g(x, y) = (x[bh]x, y[bh]y) 指向从 (x, y) 到 (x[bh], y[bh]) 的方向。这意味着,如果将箭头附着在点 (x, y) 上,它将指向黑洞的中心。为了使力场的强度与黑洞的引力成正比,我们可以将向量场的向量乘以引力值:

def gravitational_field(source, x, y):
    relative_position = (*x* − source.x, y − source.y)
    return vectors.scale(− source.gravity, relative_position)

在这个函数中,source 是一个 BlackHole 对象,其 xy 属性表示其中心作为一个 PolygonModel,而其 gravity 属性是在其构造函数中传递给它的值。用数学符号表示的等效力场可以写成这样:

g(x, y) = G[bh] ·(xx[bh], yy[bh])

这里,Gbh 代表黑洞的虚构 gravity 属性,而 (x[bh], y[bh]),再次,代表其位置。下一步是使用这个重力场来决定物体应该如何移动。

11.3.1 使游戏对象感受到重力

如果这个矢量场像重力场一样工作,它告诉我们位于 (x, y) 位置的单位质量物体上的力。换句话说,质量为 m 的物体上的力将是 F(x, y) = m · g(x, y)。如果这是物体感受到的唯一力,我们可以使用牛顿第二定律来计算其加速度:

这个加速度的最后一个表达式在分子和分母中都有质量 m,所以它们相互抵消。结果发现,重力场矢量等于由重力引起的加速度矢量——它与物体的质量无关。这个计算对于真实重力场同样适用,这就是为什么在地球表面附近,不同质量的物体都以大约每秒 9.81 米的相同速率下落。在游戏循环的一次迭代中,考虑经过的时间 Δt,宇宙飞船或小行星的速度变化由其 (x, y) 位置决定:

Δv = a · Δt = g(x, y) · Δt

我们需要在游戏循环的每次迭代中添加一些代码来更新宇宙飞船以及每个小行星的速度。关于如何组织我们的代码,有几个选择,而我将选择将所有物理封装到 PolygonModel 对象的 move 方法中。你可能也记得,我们不是让物体飞离屏幕,而是将它们传送到另一边。我在这里做的另一个小改动是添加一个全局 bounce 标志,表示物体是传送还是简单地从屏幕边缘弹回。我这样做是因为如果物体传送,它们会立即感受到不同的重力场;如果它们弹回,我们得到更直观的物理现象。下面是新的 move 方法:

def move(self, milliseconds, 
         thrust_vector, gravity_source):             ❶
    tx, ty = thrust_vector
    gx, gy = gravitational_field(src, self.x, self.y)
    ax = tx + gx                                     ❷
    ay = ty + gy
    self.vx += ax * milliseconds/1000                ❸
    self.vy += ay * milliseconds/1000

    self.x += self.vx * milliseconds / 1000.0        ❹
    self.y += self.vy * milliseconds / 1000.0

    if bounce:                                       ❺
        if self.x < −10 or self.x > 10:
            self.vx = − self.vx
        if self.y < −10 or self.y > 10:
            self.vy = − self.vy
    else:                                            ❻
        if self.x < −10:
            self.x += 20
        if self.y < −10:
            self.y += 20
        if self.x > 10:
            self.x -= 20
        if self.y > 10:
            self.y -=20

❶ 将推力矢量(可以是 (0,0))和重力源(黑洞)作为参数传递给移动方法

❷ 这里,合力是推力矢量和重力矢量的和。假设质量 = 1,加速度是推力和重力场的和。

❸ 如前所述更新速度,使用 Δv = a*Δt

❹ 如前所述更新位置矢量,使用 Δs = v*Δt

❺ 如果全局反弹标志为真,则在物体即将离开屏幕的左侧或右侧时翻转速度的 x 分量,或者在物体即将通过顶部或底部离开屏幕时翻转速度的 y 分量

❻ 否则,当物体即将离开屏幕时,使用之前相同的传送效果

剩下的工作是在游戏循环中调用太空船的 move 方法以及每个小行星:

while not done:
    ...
    for ast in asteroids:
        ast.move(milliseconds, (0,0), black_hole)                        ❶

    thrust_vector = (0,0)                                                ❷

    if keys[pygame.K_UP]:                                                ❸
        thrust_vector=vectors.to_cartesian((thrust, ship.rotation_angle))

    elif keys[pygame.K_DOWN]:
        thrust_vector=vectors.to_cartesian((−thrust, ship.rotation_angle))

    ship.move(milliseconds, thrust_vector, black_hole)                   ❹

❶ 对于每个小行星,使用推力向量为 0 调用其 move 方法

❷ 船的推力矢量默认也是 (0,0)。

❸ 如果按下上箭头或下箭头,则使用太空船的方向和固定的推力标量值计算 thrust_vector

❹ 调用太空船的 move 方法使其移动

运行游戏后,你会看到物体开始被黑洞吸引,并且从零速度开始,太空船直接掉入其中!图 11.13 显示了飞船加速的延时照片。

图 11.13 在没有初始速度的情况下,太空船掉入黑洞。

在任何其他起始速度和没有推力的情况下,太空船开始围绕黑洞轨道运动,描绘出椭圆或拉长的圆形状(图 11.14)。

图 11.14 在与黑洞垂直的初始速度下,太空船开始椭圆轨道运动。

图 11.15 在我们的黑洞周围另一个椭圆轨道上的小行星

结果表明,任何只感受到黑洞引力的物体要么直接掉入黑洞,要么进入椭圆轨道。图 11.15 显示了一个随机初始化的小行星和太空船。你可以看到它的轨迹是一个不同的椭圆。

你可以尝试将所有的小行星重新添加进来。你会发现,随着 11 个同时加速的物体,游戏变得更有趣了!

11.3.2 练习

练习 11.1: 向量场 (−2 − x, 4 − y) 中的所有向量都指向哪里?绘制向量场以确认你的答案。解答: 这个向量场与位移向量 (−2, 4) − (x, y) 相同,这是一个从点 (x, y) 指向 (−2, 4) 的向量。因此,我们预计这个向量场中的每个向量都指向 (−2, 4)。绘制这个向量场可以确认这一点。
练习 11.2-迷你项目:假设我们有两个黑洞,它们的引力均为 0.1,分别位于(-3,4)和(2,1)。引力场分别为g1(x, y) = 0.1 · (−3 − x, 4 − y)和g2(x, y) = 0.1 · (2 − x, 1 − y)。计算由这两个黑洞产生的总引力场g(x, y)的公式。它是否等同于一个黑洞?如果是,为什么?解答:在每一个位置(x, y),一个质量为m的物体感受到两个引力:m · g1(x, y)和m · g2(x, y)。这些力的矢量之和是m(g1(x, y) + g2(x, y))。按单位质量计算,感受到的力将是g1(x, y) + g2(x, y),这证实了总引力场矢量是每个黑洞产生的引力场矢量的和。这个总引力场是g(x, y) = g1 + g2            = 0.1 · (−3 − x, 4 − y) + 0.1 · (2 − x, 1 − y)我们可以除以一个因子 2,并重写为g(x, y) = 0.1 · 2 · (0.5 − x, 2.5 − y)            = 0.2 · (0.5 − x, 2.5 − y)这与一个位于(0.5, 2.5)且引力为 0.2 的单个黑洞相同。

| 练习 11.3-迷你项目:在彗星游戏中,添加两个黑洞,并允许它们感受到彼此的引力。然后移动,同时这两个黑洞也对彗星和宇宙飞船施加引力。解答:为了完整实现,请参阅源代码。关键的增加是在游戏循环的每次迭代中调用每个黑洞的move方法,并传递给它所有其他黑洞作为引力源的黑名单:

for bh in black_holes:
    others = [other for other in black_holes if other != bh]
    bh.move(milliseconds, (0,0), others)

|

11.4 引入势能

现在我们已经看到了宇宙飞船和彗星在我们引力场中的行为,我们可以使用势能来构建它们行为的第二个模型。我们已经在彗星游戏中使用了黑洞,所以本章剩余部分的目的在于拓宽你对底层数学的视野。矢量场,包括引力场,通常作为微积分运算梯度(在本书的剩余章节中是一个关键工具)的结果出现。

基本思想如下:我们不是将重力想象成在每个点上的力矢量,将物体拉向源头,而是可以将处于引力场中的物体想象成在碗边滚动的弹珠。弹珠可能会来回滚动,但它们在滚动远离碗边时,总是会“被拉回”碗底。势能函数本质上定义了这个碗的形状。你可以在图 11.16 的中心图像中预览这个碗的形状。

我们将势能写成函数的形式,它接受一个点(x, y)并返回一个单一的数值,代表该点的重力势能。从碗的类比来说,这就像是在某个特定点的碗的高度。一旦我们在 Python 中实现了势能函数,我们可以用三种方式来可视化它:作为热图,这是你在本章开头看到的;作为 3D 图表;以及如图 11.16 所示的等高线图。

图片

图 11.16 标量场的三个图像:热图、图表和等高线图

这些可视化将帮助我们想象本章最后部分以及本书剩余章节中的势能函数。

11.4.1 定义势能标量场

就像场一样,我们可以将标量场视为一个函数,它接受(x, y)点作为输入。然而,这个函数的输出是标量,而不是向量。例如,让我们考虑函数U(x, y) = ½(x² + y²),它定义了一个标量场。图 11.17 显示,你可以插入一个二维向量,输出是由U(x, y)的公式确定的某个标量。

图片

图 11.17 作为函数,标量场将平面上的一个点映射为一个相应的数值。在这种情况下,(x, y) = (3, 1),U(x, y)的值为½ · (32 + 12) = 5。

函数U(x, y)实际上是与矢量场F(x, y) = (−x, − y)相对应的势能函数。我需要做一些额外的工作来从数学上解释这一点,但我们可以通过想象标量场U(x, y)来从定性上确认它。

想象U(x, y)的一种方法是通过绘制一个类似于图 11.18 的 3D 图表,其中U(x, y)是(x, y, z)点的表面,其中z = U(x, y)。例如,U(3, 1) = 5,因此我们会在 x,y 平面上点(3, 1)的上方绘制一个 z 坐标为 5 的点。

图片

图 11.18 要绘制U(x, y) = ½(x² + y²)的一个点,使用(x, y) = (3, 1),然后使用U(3, 1) = 5 作为 z 坐标。

对于每个(x, y)的值绘制一个 3D 点,我们可以得到一个表示标量场U(x, y)及其在平面上变化的整个表面。在源代码中,你会找到一个名为plot_scalar_field的函数,它接受定义标量场的函数以及xy的界限,并绘制代表该场的 3D 点表面:

def u(x,y):
    return 0.5 * (x**2 + y**2)

plot_scalar_field(u, −5, 5, −5, 5)

尽管有几种方法可以可视化标量场,但我将参考图 11.19 中所示的U(x, y)函数的图表。

图片

图 11.19 势能标量场U(x, y) = ½(x² + y²)的图表

这就是之前类比中的“碗”。结果证明,这个势能函数给出的重力模型与矢量场 F(x, y)= (−x, − y) 相同。我们将在第 11.5 节中详细解释为什么是这样,但到目前为止,我们可以确认势能随着从原点(0, 0)的距离增加而增加。在所有径向方向上,图形的高度增加,意味着 U 的值增加。

11.4.2 将标量场绘制为热图

另一种表示标量函数的方法是绘制热图。我们不是使用 z 坐标来可视化 U(x, y) 的值,而是可以使用颜色方案。这允许我们在二维空间内绘制标量场。通过在旁边包含一个颜色图例(如图 11.20 所示),我们可以从图上该点的颜色中看到 (x, y) 处的大致标量值。

图 11.20 函数 U(x, y) 的热图

在图 11.20 的中心,靠近 (0, 0) 的地方,颜色较暗,意味着 U(x, y) 的值较低。向边缘移动时,颜色较浅,意味着 U(x, y) 的值较高。你可以使用源代码中找到的 scalar_field_heatmap 函数来绘制势能函数。

11.4.3 将标量场绘制为等高线图

与热图类似的是 等高线图。你可能之前见过等高线图,它是地形图的格式,一种显示地理区域内地形高程的地图。这类地图由高程恒定的路径组成,所以如果你沿着地图上显示的路径行走,你既不上坡也不下坡。图 11.21 显示了 U(x, y) 的类似等高线图,显示了 x,y 平面上的路径,其中 U(x, y) 等于 10, 20, 30, 40, 50 和 60。

图 11.21 U(x, y) 的等高线图,显示了 U(x, y) 值恒定的曲线

你可以看到曲线都是圆形的,并且随着向外延伸而彼此靠得更近。我们可以解释这一点意味着 U(x, y) 随着我们离原点越来越远而变得更加陡峭。例如,U(x, y) 在较短的距离上从 30 增加到 40,而增加从 10 到 20 的距离更长。你可以使用源代码中的 scalar_field_contour 函数将标量场 U 绘制为等高线图。

11.5 使用梯度将能量和力联系起来

这个关于 陡峭度 的概念很重要——势能函数的陡峭度告诉我们物体在某个方向上移动需要施加多少能量。正如你所期望的,在给定方向上移动所需的努力是 相反方向 力的量度。在本节的剩余部分,我们将得到这个陈述的精确和定量版本。

正如我在本章引言中提到的,梯度是一个操作,它将标量场(如势能)转换为向量场(如引力场)。在平面上每个位置(x, y),该位置的梯度向量场指向标量场最快增加的方向。在本节中,我将向您展示如何取标量场U(x, y)的梯度,这需要分别对U关于xy求导。我们将能够证明我们一直在使用的势能函数U(x, y)的梯度是-F(x, y),其中F(x, y)是我们在我们的小行星游戏中实现的引力场。我们将在本书的剩余章节中广泛使用梯度。

11.5.1 使用横截面测量陡度

有一种可视化函数U(x, y)的方法,可以让我们很容易地看到它在各个点的陡度。让我们关注一个特定的点:(x, y) = (-5, 2)。在一个像图 11.22 中显示的等高线图上,这个点位于U = 10 和U = 20 曲线之间,实际上,U(-5, 2) = 14.5。如果我们沿+x方向移动,我们会碰到U = 10 曲线,这意味着U在+x方向上减小。如果我们相反地沿+y方向移动,我们会碰到U = 20 曲线,这意味着U在这个方向上增加。

图片

图 11.22 从(-5,2)在+x 和+y 方向上探索U(x, y)的值

图 11.22 显示,U(x, y)的陡度取决于方向。我们可以通过绘制U(x, y)的横截面来想象这一点,其中x = -5 和y = 2。横截面U(x, y)在固定的xy值处的图形切片。例如,图 11.23 显示,在x = -5 时,U(x, y)的横截面是x = -5 平面上U(x, y)的切片。

图片

图 11.23 U(x, y)在x = -5 处的横截面

使用第四章中的函数式编程术语,我们可以将x = -5 的U部分应用到一个函数上,该函数接受一个单独的数字y并返回U的值。在(5, 2)处也有一个y方向的横截面。这是y = 2 的U(x, y)横截面。图 11.24 显示了使用y = 2 部分应用后的U(x, y)的形状作为图形。

图片

图 11.24 U(x, y)在 y = 2 处的横截面

这些横截面一起告诉我们,在(-5,2)处,Uxy方向上的变化情况。在x = -5 时,U(x, 2)的斜率为负,这告诉我们从(-5,2)沿+x方向移动会使U减小。同样,在y = 2 时,U(−5, y)的斜率为正,这告诉我们从(-5,2)沿+y方向移动会使U增加(图 11.25)。

图片

图 11.25 横截面显示,U(x, y)在+y 方向上增加,在+x 方向上减少。

我们还没有找到标量场U(x, y)在这个点的斜率,但我们已经找到了可以称为x方向和y方向的斜率。这些值被称为U偏导数

11.5.2 计算偏导数

你已经知道找到之前斜率所需的一切。U(−5, y)和U(x, 2)都是单变量函数,因此你可以通过计算小割线的斜率来近似它们的导数。

例如,如果我们想在点(−5, 2)处找到U(x, y)关于x的偏导数,我们是在询问U(x, 2)在x = −5 处的斜率。也就是说,我们想知道U(x, y)在点(x, y) = (−5, 2)处x方向的改变速度。我们可以通过将一个小的Δx值代入以下斜率计算来近似这个值:

图片

我们也可以通过写出U(x, 2)的公式来精确计算导数。因为U(x, y) = ½(x² + y²),所以U(x, 2) = ½(x² + 2²) = ½(x² + 4) = 2 + (x²/2)。使用导数的幂规则,U(x, 2)关于x的导数是 0 + 2x/2 = x。在x = −5 时,导数是−5。

注意,在斜率近似和符号导数过程中,变量y都没有出现。相反,我们正在处理常数 2。这是可以预料的,因为当我们考虑x方向的偏导数时,y并没有变化。计算偏导数的通用方法是,将导数视为只有一个符号(如x)是变量,而所有其他符号(如y)都是常数。

使用这种方法,U(x, y)关于x的偏导数是½(2x + 0) = x,关于y的偏导数是½(0 + 2y) = y。顺便说一下,我们之前用于函数f(x)导数的记号f'(x)对于扩展到偏导数是不够的。在求偏导数时,你可以对不同的变量求导,并且需要指定你正在处理的是哪一个。对于f'(x)的导数,还有一个等效的记号:

图片

(我使用≡符号来表示这些符号是等价的;它们代表相同的概念。)这让人联想到斜率公式Δf/Δx,但在这个符号中,df 和 dx 代表的是 f 和 x 值的无穷小变化。df/dx 表示的含义与 f'(x)相同,但它使得导数是相对于 x 取的这一点更加清晰。对于一个像 U(x, y)这样的函数的偏导数,我们可以相对于 x 或 y 取导数。传统上,使用不同形状的 d 来表示我们不是取一个普通导数(称为全导数)。U 相对于 x 和 y 的偏导数(分别相对于 y)可以写成以下形式:

图像

这里还有一个函数 q(x, y) = x sin(xy) + y 的例子。如果我们把 y 看作常数并对 x 求导,我们需要使用乘积规则和链式规则。结果是相对于 x 的偏导数:

图像

要对 y 求偏导数,我们把 x 看作常数,并需要使用链式规则和加法规则:

图像

确实,每个偏导数只讲述了函数像 U(x, y)在任意一点如何变化的其中一部分故事。接下来,我们将它们结合起来,以获得全面的理解,类似于单变量函数的全导数。

11.5.3 使用梯度找到图形的陡度

让我们放大 U(x, y)图形上的点(−5, 2)(图 11.26)。正如任何光滑函数 f(x)在足够小的 x 值范围内看起来像一条直线一样,结果是一个光滑标量场的图形在 x,y 平面的足够小的邻域内看起来像是一个平面。

图像

图 11.26 从近距离看,U(x, y)在(x, y) = (−5, 2)附近的图形区域看起来像一个平面。

正如导数 df/dx 告诉我们关于在给定点近似 f(x)的线的斜率一样,偏导数∂U/∂x 和∂U/∂y 告诉我们关于在给定点近似 U(x, y)的平面的信息。图 11.26 中的虚线显示了 U(x, y)在此点的 x 和 y 截面。在这个窗口中,它们近似为直线,它们在 x,z 和 y,z 平面上的斜率接近偏导数∂U/∂x 和∂U/∂y。

我还没有证明它,但假设存在一个最佳逼近于 U(x, y) 的平面,且该平面在 (−5, 2) 附近,因为我们无法区分它,我们可以暂时假设图 11.26 中的图形就是那个平面。偏导数告诉我们它在 xy 方向上的倾斜程度。在一个平面上,实际上有两个更好的方向可以考虑。首先,有一个方向在平面上你可以行走而不会升高或降低高度。换句话说,这就是平面上与 x,y 平面平行的线。对于在 (−5, 2) 处逼近 U(x, y) 的平面,结果是在方向 (2, 5) 上,如图 11.27 所示。

图 11.27 从 (x, y) = (−5,2) 沿着 U(x, y) 的图形在方向 (2,5) 上行走,你不会升高或降低高度。

图 11.27 中的行进者行走起来很轻松,因为他们在这个方向上行走时不会爬升或下降平面。然而,如果行进者向左转 90°,他们就会在可能的最陡方向上上山。这就是方向 (−5, 2),它与 (2, 5) 垂直。

这个最陡上升的方向恰好是一个向量,其分量是 U 在给定点的偏导数。我给出了一幅示意图而不是证明,但这个事实在一般情况下是正确的。对于函数 U(x, y),其偏导数的向量被称为其 梯度,表示为 ∇U。它给出了 U 在给定点的最陡上升的幅度和方向:

由于我们有偏导数的公式,我们可以知道,例如,对于我们的函数,∇U(x, y) = (x, y)。函数 ∇U,即 U 的梯度,是将一个向量分配给平面上每一个点,因此它确实是一个向量场!∇U 的图示告诉我们,在每一个点 (x, y) 上,U(x, y) 的图形上哪个方向是上坡,以及它有多陡(图 11.28)。

图 11.28 梯度 ∇U 是一个向量场,它告诉我们 U 在任何点 (x, y) 的图形上最陡上升的幅度和方向。

梯度是连接标量场和向量场的一种方式。结果,这给出了势能与力之间的联系。

11.5.4 使用梯度从势能计算力场

梯度是标量场的普通导数的最佳类比。它包含了找到标量场最陡上升方向、沿 xy 方向的斜率,或最佳逼近平面的所有必要信息。但从物理学的角度来看,最陡上升的方向并不是我们寻找的。毕竟,自然界中没有物体会自发向上移动。

在小行星游戏中的太空船和在碗边滚动的球都不会感受到推动它们向势能更高的区域移动的力。正如我们之前讨论的,它们需要施加力或牺牲一些动能来获得更多的势能。因此,描述物体感受到的力的正确方式是势能的负梯度,它指向最陡的下降方向,而不是最陡的上升方向。如果 U(x, y) 代表势能的标量场,那么相关的力场 F(x, y) 可以通过以下方式计算:

F(x, y) = −∇U(x, y)

让我们尝试一个新例子。以下势能函数会产生什么样的力场?

V(x, y) = 1 + y² − 2x² + x

通过绘制这个函数,我们可以对其行为有一个大致的了解。

图 11.29 3D 显示的势能函数 V(x, y)

图 11.29 说明这个势能函数具有双碗形状,有两个最小值点,它们之间有一个驼峰。与这个势能函数相关的力场看起来是什么样子?为了找出答案,我们需要计算 V 的负梯度:

我们可以通过将 y 视为一个常数来得到 Vx 的偏导数,因此项 1 和 y² 不贡献于结果。结果是 -2x² + x⁶ 对 x 的导数,即 -4x + 6x⁵。

对于 Vy 的偏导数,我们将 x 视为一个常数,因此只有 y² 有导数 2y 贡献于结果。因此,V(x, y) 的负梯度是

F(x, y) = −∇V(x, y) = (4x − 6x⁵, −2y)

通过绘制这个矢量场,图 11.30 显示力场指向势能最低的点。感受到这个力场的物体会将这些点视为施加吸引力的点。

图 11.30 是矢量场 -∇V(x, y) 的图,这是与势能函数 V(x, y) 相关的力场。这是一种指向图中所示两点的吸引力的力。

势能的负梯度是自然界偏好的方向;它是释放储存能量的方向。物体自然地被推向使它们的势能最小化的状态。梯度是寻找标量场最优值的重要工具,我们将在下一章中看到。具体来说,在这本书的最后一部分,我将向你展示如何通过跟随负梯度寻找最优值来模拟某些机器学习算法中的“学习”过程。

11.5.5 练习题

练习 11.4: 绘制函数 h(x, y) = ey sin(x) 在 y = 1 时的横截面。然后绘制 h(x, y) 在 x = π/6 时的横截面。解答:当 y = 1 时,h(x, y) 的横截面仅是 x 的函数:h(x, 1) = e¹ sin(x) = e · sin(x),如图所示:。当 x = π/6 时,h(x, y) 的值仅取决于 y。也就是说,h(π/6, y) = ey sin(π/6) = ey/2。图形如下所示:
练习 11.5: 第一个练习中的函数 h(x, y) 的偏导数是什么?梯度是什么?梯度在 (x, y) = (π/6, 1) 处的值是多少?解答:求 ey sin(x) 关于 x 的偏导数时,将 y 视为常数。因此,ey 也被视为常数。结果是!。同样,通过将 x 和 sin(x) 视为常数,我们得到关于 y 的偏导数:。梯度 ∇h(x, y) 是一个向量场,其分量是偏导数:。在 (x, y) = (π/6, 1) 处,这个向量场评估如下:
练习 11.6: 证明点 (−5, 2) 与点 (2, 5) 垂直。解答:这是对第二章内容的复习。这两个向量是垂直的,因为它们的点积为零:(−5, 2) · (2, 5) = −10 + 10 = 0。
练习 11.7-迷你项目:设 z = p(x, y) 为在 (−5, 2) 处最佳逼近 U(x, y) 的平面方程。从零开始!找出 p(x, y) 的一个方程以及包含在 p 中并通过 (−5, 2) 的直线,该直线平行于 x, y 平面。这条直线应该与我在上一个练习中提到的向量 (2, 5, 0) 平行。解答:记住 U(x, y) 的公式是 ½(x² + y²)。U(−5, 2) 的值是 14.5,所以点 (x, y, z) = (−5, 2, 14.5) 在 3D 中 U(x, y) 的图像上。在我们考虑 U(x, y) 的最佳逼近平面方程之前,让我们回顾一下我们是如何得到函数 f(x) 的最佳逼近线的。在点 x[0] 处最佳逼近函数 f(x) 的线是通过点 (x[0], f(x[0])) 并具有斜率 f'(x[0]) 的线。这两个事实确保了 f(x) 的值和导数与逼近它的线相一致。遵循这个模型,让我们寻找平面 p(x, y),其值和 x, y 在 (x, y) = (−5, 2) 处的偏导数都匹配。这意味着我们必须有 p(−5, 2) = 14.5,同时 p/x* = −5 和 *∂p/*∂y* = 2。作为一个平面,p(x, y) 的形式是 p(x, y) = ax + by + c,其中 ab 是一些数字(你记得为什么吗?)。偏导数是图片。为了使它们匹配,公式必须是 p(x, y) = −5x + 2y + c,为了满足 p(−5, 2) = 14.5,必须满足 c = −14.5。因此,最佳逼近平面的公式是 p(x, y) = −5x + 2y − 14.5。现在,让我们寻找平面 p(x, y) 中通过 (−5, 2) 且平行于 x, y 平面的直线。这是满足 p(x, y) = p(−5, 2) 的点集 (x, y),这意味着在 (−5, 2) 和 (x, y) 之间没有高度变化。如果 p(x, y) = p(−5, 2),那么 −5x + 2y − 14.5 = −5 · −5 + 2 · 2 − 14.5。这简化为一条直线的方程:−5x + 2y = 29。这条直线等价于向量集 (−5, 2, 14.5) + r · (2, 5, 0),其中 r 是一个实数,因此它确实平行于 (2, 5, 0)。

摘要

  • 向量场是一个函数,它既接受向量作为输入,也接受向量作为输出。具体来说,我们将其想象为将箭头向量分配到空间中的每一个点。

  • 重力可以通过向量场来模拟。向量场在空间中任何一点的值告诉你物体受到重力作用的大小和方向。

  • 要模拟物体在向量场中的运动,你需要使用它的位置来计算它所在位置的力场的强度和方向。反过来,力场的值告诉你物体受到的力,而牛顿第二定律告诉你由此产生的加速度。

  • 势能是储存的能量,具有产生运动的可能性。一个物体在力场中的势能取决于物体的位置。

  • 势能可以被建模为一个标量场:为空间中的每一个点分配一个数值,这个数值代表物体在该点的势能。

  • 有几种方式可以在二维中想象标量场:作为一个三维表面,一个热图,一个等高线图,或者一对横截面图。

  • 标量场的偏导数给出了场值相对于坐标的变化率。例如,如果 U(x, y) 是二维中的标量场,那么存在关于 xy 的偏导数。

  • 偏导数与标量场的横截面导数相同。你可以通过将其他变量视为常数来计算关于一个变量的偏导数。

  • 标量场 U 的梯度是一个矢量,其分量是 U 对每个坐标的偏导数。梯度指向 U 最陡上升的方向,或者 U 增加最快的方向。

  • 与力场对应的势能函数的负梯度告诉我们该点的力场矢量值。这意味着物体会被推向势能较低的区域。

12 优化物理系统

本章涵盖

  • 构建和可视化弹道模拟

  • 使用导数寻找函数的最大值和最小值

  • 使用参数调整模拟

  • 可视化模拟的输入参数空间

  • 实现梯度上升以最大化多个变量的函数

在过去几章的大部分时间里,我们一直专注于视频游戏的物理模拟。这是一个有趣且简单的例子,但还有更多重要且有利可图的用途。对于任何像向火星发射火箭、建造桥梁或钻油井这样的重大工程壮举,在尝试之前知道它将安全、成功且在预算范围内是非常重要的。在这些项目中的每一个,都有你想要优化的量。例如,你可能希望最小化火箭的旅行时间,最小化桥梁中混凝土的量或成本,或者最大化油井的产油量。

要了解优化,我们将关注一个简单的例子,即弹道,即从大炮中发射的弹丸。假设弹丸每次从炮管中出来时的速度都相同,发射角度将决定轨迹(图 12.1)。

图片

图 12.1 从四个不同发射角度发射弹道的轨迹

如您在图 12.1 中看到的,四个不同的发射角度产生了四个不同的轨迹。在这些轨迹中,45°是使弹道飞得最远的发射角度,而 80°是使弹道飞得最高的角度。这些只是 0 到 90°之间所有可能值中的一部分角度,所以我们不能确定它们是最好的。我们的目标是系统地探索可能的发射角度范围,以确保我们已经找到了优化射程的那个角度。

要做到这一点,我们首先为弹道构建一个模拟器。这个模拟器将是一个 Python 函数,它接受发射角度作为输入,运行欧拉方法(正如我们在第九章所做的那样)来模拟弹道在击中地面之前的每一刻的运动,并输出弹道随时间变化的各个位置列表。从结果中,我们将提取弹道的最终水平位置,这将是着陆位置或射程。将这些步骤组合起来,我们实现了一个函数,该函数接受发射角度并返回该角度下弹道的射程(图 12.2)。

图片

图 12.2 使用模拟器计算弹道射程

一旦我们将所有这些逻辑封装在一个名为 landing_position 的单个 Python 函数中,该函数将弹道作为发射角度的函数来计算,我们就可以考虑寻找最大化射程的发射角度的问题。我们可以有两种方法来做这件事:首先,我们绘制射程与发射角度的图表,寻找最大的值(图 12.3)。

图片

图 12.3 通过观察射程与发射角度的图表,我们可以看到产生最长射程的发射角度的大致值。

我们找到最优发射角度的第二种方式是将我们的模拟器放在一边,找到一个公式来表示发射角度θ作为弹道射程r(θ)的函数。这应该会产生与模拟相同的结果,但由于它是一个数学公式,我们可以使用第十章中的规则对其求导。关于发射角度的着陆位置的导数告诉我们,对于发射角度的小幅度增加,我们将获得多少射程的增加。在某个角度,我们可以看到我们得到了递减的回报−增加发射角度会导致射程减少,我们将超过最优值。在此之前,r(θ)的导数将瞬间为零,导数为零的θ值恰好是最大值。

一旦我们使用这两种优化技术在我们的二维模拟中进行了预热,我们就可以尝试一个更具挑战性的三维模拟,在这个模拟中,我们可以控制大炮的仰角以及发射的方向。如果地形在大炮周围变化,方向会影响炮弹在击中地面之前飞行的距离(图 12.4)。

对于这个例子,让我们构建一个函数r(θ, φ),它接受两个输入角度θ和φ,并输出一个着陆位置。挑战在于找到一对(θ, φ),以最大化大炮的射程。这个例子让我们涵盖了我们的第三种也是最重要的优化技术:梯度上升

图 12.4

图 12.4 在不均匀的地形上,我们发射大炮的方向可以影响炮弹的射程。

正如我们在上一章所学,点(θ, φ)处函数r(θ, φ)的梯度是一个指向使r增加最快的方向的向量。我们将编写一个名为gradient_ascent的 Python 函数,它接受一个要优化的函数、一对起始输入,并使用梯度找到越来越高的值,直到达到最优值。

优化数学领域非常广泛,我希望能够让你对一些基本技术有一个大致的了解。我们将要使用的所有函数都是光滑的,因此你将能够利用你迄今为止学到的许多微积分工具。此外,我们在本章中处理优化的方式为在机器学习算法中优化计算机“智能”奠定了基础,这在本书的最后几章中会进行探讨。

12.1 测试弹道模拟

我们的首要任务是构建一个计算炮弹飞行路径的模拟器。这个模拟器将是一个名为trajectory的 Python 函数,它接受发射角度以及我们可能想要控制的几个其他参数,并返回炮弹在撞击地球之前的各个时间点的位置。为了构建这个模拟,我们转向第九章的老朋友——欧拉方法。

作为提醒,我们可以通过在时间上以小增量前进来使用欧拉方法模拟运动(我们将使用 0.01 秒)。在每一个时刻,我们将知道炮弹的位置,以及它的导数:速度和加速度。速度和加速度使我们能够近似下一个时刻的位置变化,我们将重复这个过程,直到炮弹击中地面。在这个过程中,我们可以保存炮弹在每个步骤的时间和xy位置,并将它们作为trajectory函数的结果输出。

最后,我们将编写函数,这些函数将测量从trajectory函数返回的结果的一个数值属性。函数landing_positionhang_timemax_height分别告诉我们炮弹的射程、空中时间和最大高度。这些都将是我们随后可以优化的值。

12.1.1 使用欧拉方法构建模拟

在我们的第一个 2D 模拟中,我们将水平方向称为x方向,将垂直方向称为z方向。这样,当我们添加另一个水平方向时,我们就不必重命名这两个方向中的任何一个。我们将炮弹发射的角度称为θ,将炮弹的速度称为v,如图 12.5 所示。

图片

图 12.5 我们抛体模拟中的变量

移动物体的速度v定义为它的速度向量的模,因此v = |v|。给定发射角度θ,炮弹的xz速度分量是v[x] = |v| · cos(θ)和v[z] = |v| · sin(θ)。我将假设炮弹在时间t = 0 离开炮筒,其(x, z)坐标为(0, 0),但我也会包括一个可配置的发射高度。以下是使用欧拉方法的基本模拟:

def trajectory(theta,speed=20,height=0,
               dt=0.01,g=−9.81):           ❶
    vx = speed * cos(pi * theta / 180)     ❷
    vz = speed * sin(pi * theta / 180)
    t,x,z = 0, 0, height
    ts, xs, zs = [t], [x], [z]             ❸
    while z >= 0:                          ❹
        t += dt                            ❺
        vz += g * dt
        *x* += vx * dt
        z += vz * dt
        ts.append(*t*)
        xs.append(*x*)
        zs.append(*z*)
    return ts, xs, zs                      ❻

❶ 额外输入:时间步长 dt、重力场强度 g 和角度 theta(以度为单位)

❷ 计算初始xz速度分量,将输入角度从度转换为弧度

❸ 初始化列表,用于存储模拟过程中所有时间值和xz位置值

❹ 仅在炮弹在地面以上时运行模拟

❺ 更新时间、z 速度和位置。在x方向上没有作用力,因此x速度保持不变。

❻ 返回 t、x 和 z 值的列表,给出炮弹的运动轨迹

你可以在本书的源代码中找到一个plot_trajectories函数,它接受一个或多个trajectory函数的结果,并将它们传递给 Matplotlib 的plot函数,绘制出显示每个炮弹路径的曲线。例如,图 12.6 显示了 45°发射角度与 60°发射角度的对比,这是通过以下代码实现的:

plot_trajectories( 
    trajectory(45),
    trajectory(60))

图片

图 12.6 plot_trajectories函数的输出,显示了 45°和 60°发射角度的结果。

我们已经可以看到,45°的发射角度产生了更远的射程,而 60°的发射角度产生了更高的最大高度。为了能够优化这些属性,我们需要从轨迹中测量它们。

12.1.2 测量轨迹的属性

保留轨迹的原始输出是有用的,以防我们想要绘制它,但有时我们可能只想关注一个最重要的数字。例如,抛射体的射程是轨迹的最后一个x坐标,这是炮弹击中地面之前的最后一个x位置。以下是一个函数,它接受trajectory函数的结果(包含时间和xz位置的并行列表),并提取射程或着陆位置。对于输入轨迹trajtraj[1]列出x坐标,而traj[1][−1]是该列表的最后一个条目:

def landing_position(traj):
    return traj[1][−1]

这是我们对抛射体轨迹感兴趣的主要指标,但我们也可以测量其他一些指标。例如,我们可能想知道悬停时间(或炮弹在空中停留的时间)或其最大高度。我们可以轻松创建其他 Python 函数,从模拟轨迹中测量这些属性;例如,

def hang_time(traj):
    return traj[0][−1]   ❶
def max_height(traj):
    return max(traj[2])  ❷

❶ 空中总时间是最后一个时间值,即当抛射体击中地面时的时钟时间。

❷ 最大高度是 z 位置中的最大值,轨迹输出中的第三个列表。

要为这些指标中的任何一个找到最佳值,我们需要探索参数(即发射角度)如何影响它们。

12.1.3 探索不同的发射角度

trajectory函数接受一个发射角度,并生成炮弹在飞行过程中的完整时间和位置数据。像landing_position这样的函数接受这些数据并生成一个单一数字。将这两个函数组合起来(如图 12.7 所示),我们得到一个关于发射角度的着陆位置函数,其中假设模拟的所有其他属性都是恒定的。

图片

图 12.7 发射角度作为着陆位置函数

测试发射角度对着陆位置影响的一种方法是为几个不同的发射角度绘制着陆位置的图(图 12.8)。为此,我们需要计算theta的几个不同值的结果,并将这些值传递给 Matplotlib 的scatter函数。例如,我使用range(0,95,5)作为发射角度。这是从 0 到 90 度,以 5 度为增量:

import matplotlib.pyplot as plt
angles = range(0,90,5)
landing_positions = [landing_position(trajectory(theta)) 
                     for theta in angles]
plt.scatter(angles,landing_positions)

图 12.8 对于几个不同的发射角度,抛射物的着陆位置与发射角度的图

从这张图中,我们可以猜测最佳值是多少。在发射角度为 45°时,着陆位置在发射位置 40 米多一点处达到最大。在这种情况下,45°恰好是使着陆位置最大化的角度的确切值。在下一节中,我们将使用微积分来确认这个最大值,而无需进行任何模拟。

12.1.4 练习

| 练习 12.1:当从初始高度为零的角度为 50°发射时,炮弹能飞多远?如果从 130°发射呢?解决方案:在 50°时,炮弹在正方向上大约飞行 40.1 米,而在 130°时,它在负方向上飞行 40.1 米:

>>> landing_position(trajectory(50))
40.10994684444007
>>> landing_position(trajectory(130))
−40.10994684444007

这是因为从正 x 轴 130°与从负 x 轴 50°是相同的。|

| 练习 12.2-迷你项目:增强plot_trajectories函数,以便在轨迹图上每个经过的秒数处绘制一个大的点,这样我们就可以在图上看到时间的流逝。解决方案:以下是函数的更新。它寻找每个整秒之后最近的索引,并在这些索引处绘制(x, z)值的散点图:

def plot_trajectories(*trajs,show_seconds=False):
    for traj in trajs:
        xs, zs = traj[1], traj[2]
        plt.plot(xs,zs)
        if show_seconds:
            second_indices = []
            second = 0
            for i,t in enumerate(traj[0]):
                if t>= second:
                    second_indices.append(i)
                    second += 1
            plt.scatter([xs[i] for i in second_indices], 
                        [zs[i] for i in second_indices])
      ...

因此,你可以想象出你绘制的每个轨迹所经过的时间;例如,

plot_trajectories(
    trajectory(20), 
    trajectory(45),
    trajectory(60),
    trajectory(80), 
    show_seconds=True)

显示每个整数秒位置的四条轨迹图。|

| 练习 12.3:绘制 0°到 180°之间角度的悬挂时间与角度的散点图。哪个发射角度产生了最大的悬挂时间?解决方案

test_angles = range(0,181,5)
hang_times = [hang_time(trajectory(theta)) for theta in test_angles]
plt.scatter(test_angles, hang_times)

抛射物悬挂时间作为发射角度的函数的图 |

看起来大约 90°的发射角度产生了大约 4 秒的最长悬挂时间。这很有道理,因为θ = 90°提供了最大的垂直分量初始速度。

| 练习 12.4-迷你项目:编写一个函数plot_trajectory_metric,该函数可以绘制我们想要在给定的 theta(θ)值集上的任何度量结果。例如,

plot_trajectory_metric(landing_position,[10,20,30]) 

为发射角度 10°、20° 和 30° 绘制了着陆位置与发射角度的散点图。作为额外奖励,将 plot_trajectory_metric 函数的键值参数传递给 trajectory 函数的内部调用,这样你可以使用不同的模拟参数重新运行测试。例如,以下代码使用 10 米的初始发射高度进行相同的绘图:

plot_trajectory_metric(landing_position,[10,20,30], height=10)

解答:

def plot_trajectory_metric(metric,thetas,**settings):
    plt.scatter(thetas,
                [metric(trajectory(theta,**settings)) 
                 for theta in thetas])

我们可以通过运行以下代码来制作之前练习中的图表:

plot_trajectory_metric(hang_time, range(0,181,5))

|

练习 12.5-迷你项目:对于初始发射高度为 10 米的炮弹,产生最大射程的大约发射角度是多少?解答:使用前面迷你项目中提供的 plot_trajectory_metric 函数,我们可以简单地运行 plot_trajectory_metric(landing_position,range(0,90,5), height=10)炮弹射程与发射角度的图表,发射高度为 10 米最佳发射角度大约为 40°。

12.2 计算最佳射程

使用微积分,我们可以计算出大炮的最大射程以及产生该射程的发射角度。这实际上需要应用微积分两次。首先,我们需要找到一个精确的函数,它告诉我们射程 r 是发射角度 θ 的函数。作为警告,这需要相当多的代数运算。我会仔细地引导你通过所有步骤,所以如果你感到困惑,不要担心;你将能够跳到函数 r(θ) 的最终形式并继续阅读。

然后,我将向你展示一个使用导数来找到该函数 r(θ) 的最大值以及产生该最大值的发射角度 θ 的技巧。也就是说,使导数 r'(θ) 等于零的 θ 值也是产生 r(θ) 最大值的 θ 值。这可能不是立即显而易见的,但一旦我们检查 r(θ) 的图形并研究其变化斜率,它就会变得清晰。

12.2.1 将射程作为发射角度的函数找到

炮弹飞行的水平距离实际上相当简单计算。速度 v[x]x 分量在其整个飞行过程中是恒定的。对于总飞行时间 Δt,弹丸飞行总距离为 r = v[x] · Δt。挑战在于找到那个经过时间 Δt 的确切值。

那次,反过来,取决于弹丸随时间变化的 z 位置,这是一个函数 z(t)。假设炮弹是从初始高度为零发射的,那么 z(t) = 0 的第一次是它在 t = 0 时发射。第二次是我们要找的经过时间。图 12.9 显示了 θ = 45° 的模拟中 z(t) 的图形。注意,它的形状看起来非常像轨迹,但现在水平轴 (t) 代表时间。

trj = trajectory(45)
ts, zs = trj[0], trj[2]
plt.plot(ts,zs)

图 12.9 抛体 z(t)的图像,显示了发射和着陆时间,其中 z = 0。我们可以从图中看到经过的时间大约是 2.9 秒。

我们知道z ''(t) = g = −9.81,这是重力加速度。我们还知道初始z速度z'(0) = |v| · sin(θ)和初始z位置z(0) = 0。为了恢复位置函数z(t),我们需要对加速度z ''(t)进行两次积分。第一次积分给出了速度:

第二个积分给出了位置:

我们可以通过绘制它(图 12.10)来确认这个公式与模拟相匹配。它与模拟几乎无法区分。

def z(t):                                         ❶
    return 20*sin(45*pi/180)*t + (−9.81/2)*t**2

plot_function(z,0,2.9)

❶ 将积分结果 z(t)直接转换为 Python 代码

图 12.10 在模拟值上方绘制精确函数 z(t)的图像

为了符号简单,让我们将初始速度|v| · sin(θ)写成v[z],这样z(t) = v[z]t + gt²/2。我们想要找到使z(t) = 0 的t值,这是炮弹的总悬挂时间。你可能记得如何从高中代数中找到这个值,但如果忘记了,让我快速提醒你。如果你想知道什么值的t可以解方程at² + bt + c = 0,你只需要将值abc代入二次公式

方程at² + bt + c = 0 可以满足两次;两次都是当我们的抛体击中z = 0 时。符号±是简写,让你知道在这个方程的这个点使用+或−会得到两个不同的(但有效的)答案。

在解z(t) = v[z]t + gt²/2 = 0 的情况下,我们有a = g/2,b = v[z]c = 0。将它们代入公式,我们得到

将±符号视为+(加号),结果是t = (− v[z] + v[z])/g = 0。这意味着z = 0 当t = 0 时,这是一个很好的合理性检查;它证实了炮弹从z = 0 开始。有趣的是,当我们把±视为−(减号)时。在这种情况下,结果是t = (− v[z]v[z])/g = −2v[z]/g

让我们确认结果是有意义的。以 20 米/秒的初始速度和 45°的发射角度(我们在模拟中使用),初始z速度v[z]是−2 · (20 · sin(45°))/−9.81 ¼ 2.88。这接近我们从图中读取的 2.9 秒的结果。

这使我们相信计算悬挂时间Δt为Δt = −2v[z]/g或Δt = −2|v|sin(θ)/g。因为射程r = v[x] · Δt = |v|cos(θ) · Δt,所以射程r作为发射角度θ的函数的完整表达式是

我们可以将其与图 12.11 中各种角度的模拟着陆位置并排绘制,并看到它们是一致的。

def r(theta):
    return (−2*20*20/−9.81)*sin(theta*pi/180)*cos(theta*pi/180)

plot_function(r,0,90)

图 12.11 我们将射程作为发射角度r(θ)的函数的计算,这与我们的模拟着陆位置相匹配

拥有r(θ)函数比反复运行模拟器有很大的优势。首先,它告诉我们大炮在每个发射角度的射程,而不仅仅是我们在模拟中模拟的一小部分角度。其次,评估这个函数比运行数百次欧拉方法的迭代要计算成本低得多。对于更复杂的模拟,这可能会产生很大的差异。此外,这个函数给出了确切的结果而不是近似值。下一个我们将利用的最终好处是,函数r(θ)是平滑的,因此我们可以求它的导数。这让我们了解了射程如何随着发射角度的变化而变化。

12.2.2 求最大射程

观察图 12.12 中r(θ)的图像,我们可以设定对导数r'(θ)的预期。随着发射角度从零增加,射程在一段时间内也会增加,但增加的速率在减小。最终,增加发射角度开始减少射程。

关键的观察是,当r'(θ)为正时,射程相对于θ是增加的。然后导数r'(θ)穿过零点,射程从那里开始减少。正是在这个角度(导数为零的地方),函数r(θ)达到了它的最大值。你可以通过观察图 12.12 中r(θ)的图像在斜率为零时达到最大值来可视化这一点。

图 12.12 r(θ)的图像在导数为零时达到最大值,因此图像的斜率为零。

我们应该能够对r(θ)进行符号求导,找到它在 0°到 90°之间等于零的位置,并且这应该与 45°的大致最大值一致。记住r的公式是

因为与θ无关的-2|v|²/g是常数,所以唯一困难的工作就是使用乘积法则对 sin(θ)cos(θ)进行求导。结果是

注意我已经提取了负号。如果你以前没有见过这种符号,sin²(θ)意味着(sin(θ))²。当表达式 sin²(θ) − cos²(θ)为零时(换句话说,我们可以忽略常数),导数r'(θ)的值为零。有几种方法可以找出这个表达式在哪里为零,但特别好的一个方法是使用三角恒等式,cos(2θ) = cos²(θ) − sin²(θ),这进一步简化了我们的问题。现在我们需要找出 cos(2θ) = 0 的位置。

余弦函数在π/2 加上任何π的倍数时为零,或者 90°加上任何 180°的倍数(即 90°,270°,430°等等)。如果 2θ等于这些值,θ可以是这些值的一半:45°,135°,215°等等。

在这些结果中,有两个有趣的结果。首先,θ = 45° 是 θ = 0 和 θ = 90° 之间的解,因此它既是我们所期望的解,也是我们正在寻找的解!第二个有趣解是 135°,因为这与以 45° 的角度向相反方向射击炮弹相同(图 12.13)。

图片

图 12.13 在我们的模型中,以 135° 的角度射击炮弹就像以 45° 的角度向相反方向射击。

在 45° 和 135° 的角度下,得到的射程是

>>> r(45)
40.774719673802245
>>> r(135)
−40.77471967380224

结果表明,这些是炮弹可能到达的极限位置,其他参数都相等。以 45° 的发射角度产生最大的着陆位置,而以 135° 的发射角度产生最小的着陆位置。

12.2.3 识别极大值和极小值

为了区分 45° 的最大射程和 135° 的最小射程,我们可以扩展 r(θ) 的图。记住,我们找到这两个角度是因为它们是在导数 r'(θ) 为零的地方(图 12.14)。

图片

图 12.14 θ = 45° 和 θ = 135° 是在 0 和 180 之间,r'(θ) = 0 的两个值。

虽然平滑函数的极大值出现在导数为零的地方,但反过来不一定成立;导数为零的每个地方不一定产生最大值。正如我们在图 12.14 中看到的那样,在 θ = 135° 处,它也可以产生函数的 最小 值。

你还需要注意函数的全局行为,因为导数可以在所谓的 局部 极大值或极小值处为零,函数在短时间内获得最大值或最小值,但它的真实,全局 最大值或最小值可能位于其他地方。图 12.15 显示了一个经典例子:y = x³ − x。在 −1 < x < 1 的区域内放大,有两个导数为零的地方,分别看起来像最大值和最小值。当你放大时,你会看到这两个都不是整个函数的最大值或最小值,因为它在两个方向上都趋向于无穷大。

图片

图 12.15 两个局部最小值和局部最大值,但都不是函数的最小值或最大值

作为另一种令人困惑的可能性,导数为零的点甚至可能不是局部最小值或最大值。例如,函数 y = x³ 在 x = 0 处导数为零(图 12.16)。这个点恰好是函数 x³ 短暂停止增加的地方。

图片

图 12.16 对于 y = x³,导数在 x = 0 处为零,但这不是最小值或最大值。

我不会深入讲解如何判断一个导数为零的点是最小值、最大值还是都不是,或者如何区分局部最小值和最大值与全局最小值和最大值。关键思想是,在自信地说你已经找到了最优值之前,你需要完全理解函数的行为。有了这个想法,让我们继续探讨一些更复杂的函数优化以及一些新的优化技术。

12.2.4 练习

练习 12.6:使用关于发射角度θ的经过时间Δt公式,找出使炮弹悬停时间最大的角度。解答:在空中的时间是 t = 2v[z]/g = 2v sin(θ)/g,其中炮弹的初始速度是 v = |v|。当 sin(θ)最大时,这个值达到最大。我们不需要微积分来做这个;对于 0 ≤ θ ≤ 180°,sin(θ)的最大值发生在θ = 90°。换句话说,当所有其他参数保持不变时,炮弹直接向上发射时悬停时间最长。
练习 12.7:确认 sin(x)在x = 11π/2 处的导数为零。这是 sin(x)的最大值还是最小值?解答:sin(x)的导数是 cos(x),因此 sin(x)在x = 11π/2 处的导数确实是零。因为 sin(11π/2) = sin(3π/2) = −1,且正弦函数的范围在−1 和 1 之间,我们可以确定这是一个局部最大值。以下是 sin(x)的图像,以确认这一点:
练习 12.8:函数f(x) = x³ − x的局部最大值和最小值在哪里?这些值是多少?解答:从函数的图像中可以看出,f(x)在某个x > 0 处达到局部最小值,在某个x < 0 处达到局部最大值。让我们找到这两个点。导数是f'(x) = 3x² − 1,因此我们想要找到 3x² − 1 = 0 的地方。我们可以使用二次公式来解x,但这足够简单,可以直观地找到解。如果 3x² − 1 = 0,那么x² = 1/3,所以x = −1/ 或 x = 1/。这些是f(x)达到局部最小值和最大值的x值。局部最大值是,局部最小值是
练习 12.9-迷你项目:二次函数q(x) = ax² + bx + ca ≠ 0)的图像是一个抛物线,一个具有单一最大值或单一最小值的拱形。根据数字abcq(x)在何处达到最大值或最小值?如何判断这个点是最大值还是最小值?解答:导数q'(x)由 2ax + b给出。当x = - b/2a时,导数为零。如果a是正的,导数在某个低的x值开始为负,然后在x = - b/2a时达到零,之后为正。这意味着qx = - b/2a之前是递减的,之后是递增的;这描述了q(x)的最小值。如果a是负的,你可以讲述相反的故事。因此,如果a是正的,x = - b/2aq(x)的最小值;如果a是负的,它是最大值

12.3 增强我们的模拟

随着你的模拟器变得越来越复杂,可能会有多个参数控制其行为。对于我们的原始大炮,发射角度θ是我们唯一操作的参数。为了优化大炮的射程,我们与一个一元函数r(θ)一起工作。在本节中,我们将使大炮在 3D 中发射,这意味着我们需要改变两个发射角度作为参数来优化炮弹的射程。

12.3.1 添加另一个维度

第一件事是为我们的模拟添加一个y维度。现在我们可以想象大炮坐在xy平面的原点,以某个角度θ将炮弹射向z方向。在这个版本的模拟器中,你可以控制角度θ以及第二个角度,我们将称之为φ(希腊字母 phi)。这衡量了大炮从+x方向横向旋转的距离(图 12.17)。

图片

图 12.17 3D 展示大炮发射。两个角度θ和ϕ决定了大炮发射的方向。

为了在 3D 中模拟大炮,我们需要在y方向上添加运动。在z方向上的物理保持完全相同,但水平速度在xy方向上根据角度φ的值进行分配。而之前初始速度的x分量是v[x] = |v|cos(θ),现在它乘以一个因子 cos(φ),给出v[x] = |v|cos(θ)cos(φ)。初始速度的y分量是v[y] = |v|cos(θ)sin(φ)。因为重力在y方向上不起作用,我们不需要在模拟过程中更新v[y]。以下是更新的轨迹函数:

def trajectory3d(theta,phi,speed=20,
                 height=0,dt=0.01,g=−9.81):          ❶
    vx = speed * cos(pi*theta/180)*cos(pi*phi/180)
    vy = speed * cos(pi*theta/180)*sin(pi*phi/180)   ❷
    vz = speed * sin(pi*theta/180)
    t,x,y,z = 0, 0, 0, height
    ts, xs, ys, zs = [t], [x], [y], [z]              ❸
    while z >= 0:
        t += dt
        vz += g * dt
        x += vx * dt
        y += vy * dt                                 ❹
        z += vz * dt
        ts.append(t)
        xs.append(x)
        ys.append(y)
        zs.append(z)
    return ts, xs, ys, zs

❶ 横向角度ϕ是模拟的输入参数。

❷ 计算初始 y 速度

❸ 在整个模拟过程中存储时间以及 x、y 和 z 位置值

❹ 在每次迭代中更新 y 位置

如果这个模拟成功,我们预计它不会改变产生最大射程的角度 θ。无论你在 +x 方向上方 45° 水平发射弹丸,还是在 − x 方向上方 45° 水平发射,或者在任何其他平面上方发射,弹丸应该飞行相同的距离。也就是说,φ 不影响飞行的距离。接下来,我们在发射点周围添加具有可变高度的地形,这样飞行的距离就会改变。

12.3.2 建模大炮周围的地形

大炮周围的山丘和山谷意味着其射击的持续时间会根据射击方向的不同而不同。我们可以通过一个函数来模拟 z = 0 平面以上或以下的高度,该函数为每个 (x,y) 点返回一个数字。例如,

def flat_ground(x,y):
    return 0

表示平坦地面,其中每个 (x,y) 点的高度为零。我们将使用的另一个函数是两个山谷之间的山脊:

def ridge(x,y):
    return (x**2 − 5*y**2) / 2500

在这个山脊上,地面从原点向正负 x 方向上升,向正负 y 方向下降。 (您可以在 x = 0 和 y = 0 处绘制该函数的横截面来确认这一点。)

无论我们是要模拟平坦地面上的弹丸还是山脊上的弹丸,我们都必须调整 trajectory3d 函数,使其在弹丸击中地面时终止,而不仅仅是当其高度为零时。为此,我们可以传递定义地形的 elevation 函数作为关键字参数,默认为平坦地面,并修改测试弹丸是否在地面以上的条件。以下是函数中更改的行:

def trajectory3d(theta,phi,speed=20,height=0,dt=0.01,g=−9.81,
                    elevation=flat_ground):
    ...
    while z >= elevation(x,y):
       ...

在源代码中,我还提供了一个名为 plot_trajectories_3d 的函数,它绘制了 trajectory3D 的结果以及指定的地形。为了确认我们的模拟工作正常,我们看到当炮弹从下山发射时,轨迹在 z = 0 以下结束,而当它从上山发射时,轨迹在 z = 0 以上结束(图 12.18):

plot_trajectories_3d(
    trajectory3d(20,0,elevation=ridge),
    trajectory3d(20,270,elevation=ridge),
    bounds=[0,40,−40,0],
    elevation=ridge)

图 12.18 向下发射的弹丸在 z = 0 以下着陆,向上发射的弹丸在 z = 0 以上着陆。

如果你必须猜测,大炮的最大射程似乎是在下山方向而不是上山方向达到的。在下山的过程中,炮弹有更长的下落距离,需要更多的时间,这允许它飞得更远。由于我们的 45° 计算假设地面是平坦的,所以不清楚垂直角度 θ 是否会产生最佳射程。为了回答这个问题,我们需要将弹丸的射程 r 写成 θ 和 φ 的函数。

12.3.3 在 3D 中求解弹丸的射程

尽管在我们的最新模拟中炮弹是在三维空间中发射的,但其轨迹位于一个垂直平面内。因此,给定一个角度 φ,我们只需要处理炮弹发射方向的地形切片。例如,如果炮弹以角度 φ = 240° 发射,我们只需要考虑当 (x, y) 沿着从原点出发的 240° 线时地形值。这就像只考虑轨迹投射下的地形高程点(图 12.19)。

图像

图 12.19 我们只需要考虑投体发射平面上的地形高程。这就是轨迹阴影投射的地方。

我们的目标是在阴影轨迹的平面内进行所有计算,以从原点到 x, y 平面的距离 d 作为我们的坐标,而不是 xy 本身。在某个距离处,炮弹的轨迹和地面的高程将具有相同的 z 值,这就是炮弹停止的地方。这个距离是我们想要找到一个表达式的射程。

让我们继续称投体的高度为 z。作为时间的函数,高度与我们的二维示例完全相同

图像

其中 v[z] 是初始速度的 z 分量。xy 位置也作为时间 t 的简单函数给出,x(t) = v[x]ty(t) = v[y]t,因为 xy 方向上没有作用力。

在脊上,高程是作为 x, y 位置的函数给出的,公式为 (x² − 5y²)/2500。我们可以将这个高程写成 h(x, y) = B[x]² − C[y]²,其中 B = 1/2500 = 0.0004 和 C = 5/2500 = 0.002。知道在给定时间 t 投体正下方的地形高程是有用的,我们可以称之为 h(t)。我们可以在任何时间 t 计算投体下方的 h 值,因为投体的 xy 位置由 v[x]tv[y]t 给出,并且在相同的 (x, y) 点上的高程将是 h(v[x]t, v[y]t) = Bv[x]² t² − Cv[y]² t²。

投体在时间 t 的高度相对于地面的高度是 z(t) 和 h(t) 的差值。碰撞时间是指这个差值为零的时间,即 z(t) − h(t) = 0。我们可以用 z(t) 和 h(t) 的定义来展开这个条件:

图像

再次,我们可以将其重塑为形式 at² + bt + c = 0:

图像

具体来说,a = g/2 − Bv[x]² + Cv[y]²,b = v[z]c = 0。为了找到满足这个方程的时间 t,我们可以使用二次公式:

图像

因为 c = 0,所以形式更加简单:

图像

当我们使用+运算符时,我们找到t = 0,确认炮弹在发射的瞬间处于地面水平。有趣的是,使用-运算符得到的解,这是投射物落地的时间。这个时间是t = (− bb)/2a = − b/a。将ab的表达式代入,我们得到一个关于我们已知如何计算的数量落地时间的表达式:

投射物在(x,y)平面上的落点距离为对于这个时间t。这扩展到。你可以把看作是初始速度平行于x,y平面的分量,所以我将这个数字称为vxy。落点距离是

表达式中的所有这些数字要么是我指定的常数,要么是关于初始速度v = |v|和发射角度θ和φ的计算值。将这个表达式翻译成 Python(尽管有点繁琐)是直接的,这样我们可以清楚地看到我们如何将距离视为θ和φ的函数:

B = 0.0004                                         ❶
C = 0.005
v = 20
g = −9.81

def velocity_components(v,theta,phi):              ❷
    vx = v  * cos(theta*pi/180) * cos(phi*pi/180)
    vy = v  * cos(theta*pi/180) * sin(phi*pi/180)
    vz = v  * sin(theta*pi/180)
    return vx,vy,vz

def landing_distance(theta,phi):
    vx, vy, vz = velocity_components(v, theta, phi)
    v_xy = sqrt(vx**2 + vy**2)                     ❸
    a = (g/2) − B * vx**2 + C * vy**2              ❹
    b = vz
    landing_time = -b/a                            ❺
    landing_distance = v_xy * landing_time         ❻
    return landing_distance

❶ 山脊形状、发射速度和重力加速度的常数

❷ 一个辅助函数,用于找到初始速度的 x, y 和 z 分量

❸ 初始速度的水平分量(平行于 x,y 平面)

❹ 常数 a 和 b

❺ 解落点时间的二次方程,即-b/a

❻ 水平距离

水平距离是水平速度乘以经过的时间。将这个点与模拟轨迹一起绘制,我们可以验证我们计算出的落点位置与使用欧拉方法进行的模拟(图 12.20)相匹配。

图 12.20 比较计算出的落点与θ = 30°和ϕ = 240°的模拟结果

现在我们有了关于发射角度θ和φ的函数r(θ, φ)来表示大炮的射程,我们可以将注意力转向寻找优化射程的角度。

12.3.4 练习

练习 12.10:如果 v = v是炮弹的初始速度,验证初始速度向量的大小等于v。换句话说,证明向量(v cos θ cos φ, v cos θ sin φ, v sin θ)的长度是v提示:根据正弦和余弦的定义以及勾股定理,sin² x + cos² x = 0 对于任何x的值都成立。解答:向量(v cos θ cos φ, v cos θ sin φ, v sin θ)的大小由给出
练习 12.11:明确写出在脊地形上,以 Bx² − Cy² 为函数的炮弹射程公式,其中 θ 和 φ 为变量。出现的常数包括 BC,以及初始发射速度 v 和重力加速度 g解答:从公式开始,我们可以将 v[z] = v sin θ,vxy = v cos θ,v[y] = v cos θ sin φ,和 v[x] = v cos θ cos φ 代入,得到。通过在分母中进行一些简化,这变成

| 练习 12.12-迷你项目:当一个物体如炮弹快速通过空气时,它会受到空气的摩擦力,称为 drag,这会将其推向相反的方向。阻力取决于许多因素,包括炮弹的大小和形状以及空气的密度,但为了简化,让我们假设它如下工作。如果 v 是炮弹在任何点的速度向量,那么阻力,F[d],是F[d] = −αv其中α(希腊字母 alpha)是一个数字,表示特定物体在空气中感受到的阻力大小。阻力与速度成正比的事实意味着,随着物体速度的增加,它感受到的阻力越来越大。找出如何向炮弹模拟中添加阻力参数,并展示阻力会导致炮弹减速。解答:我们想在模拟中添加基于阻力的加速度。力将是 -αv,因此它引起的加速度是 -αv/m。由于我们没有改变炮弹的质量,我们可以使用一个单一的阻力常数,即α/ m。阻力引起的加速度分量是 v[x]α/ mv[y]α/ mv[z]α/ m。以下是代码更新的部分:

def trajectory3d(theta,phi,speed=20,height=0,dt=0.01,g=−9.81,
                 elevation=flat_ground, drag=0):
    ...
    while z >= elevation(x,y):
        t += dt
        vx -= (drag * vx) * dt         ❶
        vy -= (drag * vy) * dt
        vz += (g − (drag * vz)) * dt   ❷
        ...
    return ts, xs, ys, zs

❶ 按照阻力大小成比例地减少 vx 和 vy❷ 通过重力和阻力的影响改变 z 速度 (vz) 你可以看到,一个小的阻力常数 0.1 明显地减慢了炮弹的速度,导致它没有阻力的情况下无法达到轨迹!阻力分别为 drag = 0drag = 0.1 的炮弹轨迹 |

12.4 使用梯度上升优化射程

让我们继续假设我们在脊地形上以某些发射角度 θ 和 φ 发射大炮,并将所有其他发射参数设置为默认值。在这种情况下,函数 r(θ, φ) 告诉我们大炮在这些发射角度下的射程。为了定性了解角度如何影响射程,我们可以绘制函数 r

12.4.1 绘制射程与发射参数的关系图

在上一章中,我向你展示了多种绘制二维变量函数的方法。我偏好使用热图来绘制 r(θ, φ)。在一个二维画布上,我们可以在一个方向上改变 θ,在另一个方向上改变 φ,然后使用颜色来表示相应弹道物体的射程(图 12.21)。

图片

图 12.21 大炮射程作为发射角度 θ 和 ϕ 的函数的热图

这个二维空间是一个抽象的空间,具有 θ 和 φ 坐标。也就是说,这个矩形并不是我们建模的 3D 世界二维切片的绘制。相反,它只是显示范围 r 随两个参数变化而变化的一种方便方式。

在图 12.22 的图表中,亮度值表示更高的范围,并且似乎有两个最亮的点。这些是大炮射程的可能最大值。

图片

图 12.22 最亮的点出现在弹道范围最大化的时刻。

这些点出现在大约 θ = 40, φ = 90 和 φ = 270 的位置。φ 值是有意义的,因为它们是脊的下坡方向。我们的下一个目标是找到 θ 和 φ 的确切值以最大化范围。

12.4.2 射程函数的梯度

正如我们使用一元函数的导数来找到它的最大值一样,我们将使用函数 r(θ, φ) 的梯度 ∇r(θ, φ) 来找到它的最大值。对于一个一元变量的光滑函数 f(x),我们看到了当 f 达到最大值时 f'(x) = 0。这是当 f(x) 的图表暂时平直时,意味着 f(x) 的斜率为零,或者更精确地说,给定点的最佳近似线的斜率为零。同样,如果我们绘制 r(θ, φ) 的三维图,我们可以看到它在最大点处是平的(图 12.23)。

图片

图 12.23 r(θ, ϕ) 的图表在其最大点处是平的。

让我们精确地说明这意味着什么。因为 r(θ, φ) 是光滑的,所以存在一个最佳近似平面。这个平面在 θ 和 φ 方向上的斜率分别由偏导数 ∂r/∂θ 和 ∂r/∂φ 给出。只有当这两个都为零时,平面才是平的,这意味着 r(θ, φ) 的图表是平的。

因为 r 的偏导数被定义为 r 梯度的分量,这个平直的条件等同于说 ∇r(θ, φ) = 0。为了找到这样的点,我们必须对 r(θ, φ) 的完整公式求梯度,然后求解 θ 和 φ 的值,使得它为零。求这些导数并求解它们是很多工作,而且并不那么启发人心,所以我把这个作为你的练习。接下来,我将向你展示一种沿着图表斜坡向上追踪近似梯度到最大点的方法,这不会要求任何代数。

在我继续之前,我想重申一下前一部分的一个观点。仅仅因为你在图表上找到一个梯度为零的点,并不意味着它是一个最大值。例如,在 r(θ, φ) 的图表中,在两个最大值之间有一个点,图表是平的,梯度为零(图 12.24)。

图片

图 12.24 r(θ, ϕ) 的图形平坦的点 (θ, ϕ)。梯度为零,但函数没有达到最大值。

这个点并非没有意义,它恰好告诉你当你在 φ = 180° 射击抛体时最佳的角度 θ,这是最糟糕的方向,因为它是最陡的上升方向。这样的点被称为 鞍点,函数在某一变量上达到最大值,而在另一变量上达到最小值。这个名字来源于图表看起来有点像马鞍。

再次,我不会深入讲解如何识别极大值、极小值、鞍点或其他梯度为零的位置,但请注意:随着维度的增加,图形平坦的方式也会变得更为奇特。

12.4.3 使用梯度找到上升方向

我们不必对复杂的函数 r(θ, φ) 进行符号求偏导数,我们可以找到偏导数的近似值。它们给出的梯度方向告诉我们,对于任何给定的点,函数增加最快的方向。如果我们沿着这个方向跳到新的点,我们应该向上移动并朝向最大值。这个过程被称为 梯度上升,我们将在 Python 中实现它。

第一步是能够近似任何点的梯度。为了做到这一点,我们使用我在第九章中介绍的方法:取小割线的斜率。以下是作为提醒的函数:

def secant_slope(f,xmin,xmax):                ❶
    return (f(xmax) − f(xmin)) / (xmax − xmin)

def approx_derivative(f,x,dx=1e−6):           ❷
    return secant_slope(f,x-dx,x+dx)

❶ 找到介于 xmin 和 xmax 之间的割线,f(x) 的斜率

❷ 近似导数是介于 x − 10 − 6 和 x + 10 − 6 之间的割线。

要找到函数 f(x, y) 在点 (x[0], y[0]) 处的近似偏导数,我们想要固定 x = x[0] 并对 y 求导,或者固定 y = y[0] 并对 x 求导。换句话说,(x[0], y[0]) 处的偏导数 ∂f/∂xf(x, y[0]) 在 x = x[0] 时关于 x 的普通导数。同样,偏导数 ∂f/∂yf(x[0], y) 在 y = y[0] 时关于 y 的普通导数。梯度是这些偏导数的矢量(元组):

def approx_gradient(f,x0,y0,dx=1e−6):
    partial_x = approx_derivative(lambda x: f(x,y0), x0, dx=dx)
    partial_y = approx_derivative(lambda y: f(x0,y), y0, dx=dx)
    return (partial_x,partial_y)

在 Python 中,函数 r(θ, φ) 被编码为 landing_distance 函数,我们可以存储一个特殊函数,approx_gradient,代表其梯度:

def landing_distance_gradient(theta,phi):
    return approx_gradient(landing_distance_gradient, theta, phi)

这,就像所有梯度一样,定义了一个矢量场:为空间中的每个点分配一个矢量。在这种情况下,它告诉我们任何一点上 r 的最大增加矢量。图 12.25 显示了在 r(θ, φ) 的热图上 landing_distance_gradient 的绘图。

图 12.25 在函数 r(θ, ϕ) 的热图上显示的梯度矢量场 ∇r(θ, ϕ) 的绘图。箭头指向 r 增加的方向,指向热图上更亮的点。

如果放大查看(图 12.26),梯度箭头会汇聚到函数的最大点。

图 12.26 与图 12.25 相同的图表,在(θ, ϕ)=(37.5°, 90°)附近,这是最大值之一的近似位置

下一步是实现 梯度上升 算法,我们从任意选择的点(θ, φ)开始,沿着梯度场移动,直到我们到达 一个 最大值。

12.4.4 实现梯度上升

梯度上升算法将我们要最大化的函数以及一个起始点作为输入,我们将从这里开始我们的探索。我们的简单实现计算起始点的梯度并将其加到起始点上,从而得到一个新的点,该点在原点附近,沿着梯度的方向有一定距离。重复此过程,我们可以移动到越来越接近最大值的点。

最终,当我们接近最大值时,梯度将接近零,因为图表达到一个平台期。当梯度接近零时,我们就没有更多的上坡路可走了,算法应该终止。为了实现这一点,我们可以传递一个 tolerance,这是我们应跟随的最小梯度值。如果梯度更小,我们可以确信图表是平的,我们已经到达了函数的最大值。以下是实现方式:

def gradient_ascent(f,xstart,ystart,tolerance=1e−6):
    *x* = xstart                                       ❶
    y = ystart
    grad = approx_gradient(f,x,y)                    ❷
    while length(grad) > tolerance:                  ❸
        *x* += grad[0]                                 ❹
        y += grad[1]
        grad = approx_gradient(f,x,y)                ❺
    return x,y                                       ❻

❶ 将 (x, y) 的初始值设置为输入值

❷ 告诉我们如何从当前的 (x, y) 值向上移动

❸ 只有当梯度大于最小长度时才向新点移动

❹ 将 (x, y) 更新为 (x, y) + ∇f(x, y)

❺ 更新此新点的梯度

❻ 当没有更多的上坡路可走时,返回 xy 的值

让我们测试一下,从(θ, φ)=(36°, 83°)的值开始,这个值看起来相当接近最大值:

>>> gradient_ascent(landing_distance,36,83)
(37.58114751557887, 89.99992616039857)

这是一个很有希望的结果!在我们的热图(图 12.27)中,我们可以看到从初始点(θ, φ)=(36°, 83°)移动到一个新的位置大约为(θ, φ)=(37.58, 90.00),看起来它具有最大的亮度。

图 12.27 梯度上升的起始点和结束点

为了更好地理解算法的工作原理,我们可以追踪梯度上升在 θ, φ 平面上的轨迹。这与我们迭代欧拉方法时跟踪时间和位置值的方式类似:

def gradient_ascent_points(f,xstart,ystart,tolerance=1e−6):
    *x* = xstart
    y = ystart
    xs, ys = [x], [y]
    grad = approx_gradient(f,x,y)
    while length(grad) > tolerance:
        *x* += grad[0]
        y += grad[1]
        grad = approx_gradient(f,x,y)
        xs.append(*x*)
        ys.append(*y*)
    return xs, ys

实现了这一点后,我们可以运行

gradient_ascent_points(landing_distance,36,83)

我们得到了两个列表,包含上升过程中的每个步骤的 θ 值和 φ 值。这两个列表都有 855 个数字,这意味着这个梯度上升需要 855 步才能完成。当我们把 θ 和 φ 点绘制在热图(图 12.28)上时,我们可以看到我们的算法上升图所采取的路径。

图 12.28 梯度上升算法达到范围函数最大值所采取的路径

注意,由于有两个最大值,路径和目的地都取决于我们选择的初始点。如果我们从 φ = 90° 附近开始,我们很可能会达到那个最大值,但如果我们更接近 φ = 270°,我们的算法会找到那个最大值(图 12.29)。

图片

图 12.29 从不同的点开始,梯度上升算法可以找到不同的最大值。

发射角度 (37.58°, 90°) 和 (37.58°, 270°) 最大化了函数 r(θ, φ),因此它们是产生大炮最大射程的发射角度。这个射程大约是 53 米

>>> landing_distance(37.58114751557887, 89.99992616039857)
52.98310689354378

我们可以将相关的轨迹绘制如图 12.30 所示。

图片

图 12.30 具有最大射程的大炮轨迹

随着我们探索一些机器学习应用,我们将继续依赖梯度来找出如何优化函数。具体来说,我们将使用梯度上升的对立面,称为梯度下降。通过在梯度相反的方向探索参数空间,它找到函数的最小值,从而向下而不是向上移动。因为梯度上升和下降可以自动执行,我们将看到它们为机器提供了一种自主学习问题最优解的方法。

12.4.5 练习

| 练习 12.13:在热图中,同时绘制从 20 个随机选择点开始的梯度上升路径。所有路径都应该结束在两个最大值之一。解答:在已经绘制了热图的情况下,我们可以运行以下代码来执行并绘制 20 次随机的梯度上升:

from random import uniform
for *x* in range(0,20):
    gap = gradient_ascent_points(landing_distance, 
                                 uniform(0,90), 
                                 uniform(0,360))
    plt.plot(*gap,c='k')

结果表明,所有路径都导向了同一个地方!图片从 20 个随机初始点开始的梯度上升路径

练习 12.14-迷你项目:符号地找到 ∂r/∂θ 和 ∂r/∂φ 的偏导数,并写出梯度 ∇r(θ, φ) 的公式。

| 练习 12.15:找到 r(θ, φ) 上梯度为零但函数不是最大化的点。解答:我们可以通过将 φ 初始化为 180° 来欺骗梯度上升。由于设置的对称性,我们可以看到当 φ = 180° 时,∂r/∂φ = 0,因此梯度上升没有理由离开 φ = 0 的线:

>>> gradient_ascent(landing_distance,0,180)
(46.122613357930206, 180.0)

|

如果你将 φ 固定为 0 或 180°,这就是最佳发射角度,因为你是向上发射的!图片通过在 ∂r/∂ϕ = 0 的横截面上初始化梯度上升来欺骗梯度上升。

| 练习 12.16: 梯度上升从 (36, 83) 到达原点需要多少步?而不是跳过一个梯度,跳 1.5 个梯度。证明你可以用更少的步骤到达那里。如果你在每一步中跳得更远会发生什么?解答:让我们向梯度上升计算中引入一个参数 rate,它表示上升尝试的速度。速率越高,我们越信任当前计算的梯度,并朝那个方向跳跃:

def gradient_ascent_points(f,xstart,ystart,rate=1,tolerance=1e−6):
    ...
    while length(grad) > tolerance:
        *x* += rate * grad[0]
        y += rate * grad[1]
        ...
    return xs, ys

这是一个函数,它计算梯度上升过程收敛所需的步数:

def count_ascent_steps(f,x,y,rate=1):
    gap = gradient_ascent_points(f,x,y,rate=rate)
    print(gap[0][−1],gap[1][−1])
    return len(gap[0])

使用 rate 参数等于 1 进行原始上升需要 855 步:

>>> count_ascent_steps(landing_distance,36,83)
855

rate=1.5 时,我们每步跳一个半梯度。不出所料,我们更快地到达最大值,只需 568 步:

>>> count_ascent_steps(landing_distance,36,83,rate=1.5)
568

尝试更多的值,我们发现增加速率可以使我们在更少的步骤中达到解:

>>> count_ascent_steps(landing_distance,36,83,rate=3)
282
>>> count_ascent_steps(landing_distance,36,83,rate=10)
81
>>> count_ascent_steps(landing_distance,36,83,rate=20)
38

但不要过于贪婪!当我们使用速率为 20 时,我们可以在更少的步骤中得到答案,但一些步骤似乎超出了答案,并且下一步会加倍回退。如果你将速率设置得太高,算法可能会越来越远离解;在这种情况下,我们说它发散而不是收敛!速率为 20 的梯度上升。算法最初超出了最大 θ 值,并不得不回退。|

如果你将速率提高到 40,你的梯度上升将不会收敛。每次跳跃都比上一次跳得更远,参数空间的探索会跑到无限远处。
练习 12.17: 当你尝试直接使用模拟结果(将 r 作为 θ 和 φ 的函数)而不是计算结果来运行 gradient_ascent 时会发生什么?解答:结果并不理想。这是因为模拟结果依赖于数值估计(例如决定弹丸何时击中地面),因此当发射角度发生微小变化时,这些估计会迅速波动。以下是我们导数近似器在计算偏导数 ∂r/∂θ 时会考虑的横截面 r(θ, 270°) 的图像:模拟轨迹的横截面显示,我们的模拟器没有产生一个平滑的函数 r(θ, ϕ)。导数的值波动很大,因此梯度上升会在随机方向上移动。

摘要

  • 我们可以通过使用欧拉方法并记录所有的时间和位置来模拟移动物体的轨迹。我们可以计算关于轨迹的事实,如最终位置或经过的时间。

  • 改变我们模拟的一个参数,如大炮的发射角度,可能会导致不同的结果——例如,弹丸的射程不同。如果我们想找到最大化射程的角度,将射程写成角度 r(θ) 的函数会有所帮助。

  • 光滑函数 f(x) 的最大值出现在其导数 f'(x) 为零的地方。不过,你需要小心,因为当 f'(x) = 0 时,函数 f 可能处于最大值,也可能是最小值,或者是一个函数 f 临时停止变化的点。

  • 要优化两个变量的函数,例如将射程 r 作为垂直发射角 θ 和水平发射角 φ 的函数进行优化,你需要探索所有可能的输入 (θ, φ) 的二维空间,并找出哪一对产生最优值。

  • 对于两个变量的光滑函数 f(x, y) 的最大值和最小值,发生在两个偏导数都为零的情况下;也就是说,∂f/∂x = 0 和 ∂f/∂y = 0,因此 ∇f(x, y) = 0(根据定义)。如果偏导数为零,它也可能是一个鞍点,相对于一个变量最小化函数,相对于另一个变量最大化函数。

  • 梯度上升算法通过从二维空间中任意选择一个点开始,沿着梯度 ∇f(x, y) 的方向移动,来寻找函数 f(x, y) 的近似最大值。由于梯度指向函数 f 增加最快的方向,因此该算法找到的 (x, y) 点具有增加的 f 值。当梯度接近零时,算法终止。

13 使用傅里叶级数分析声波

本章涵盖

  • 使用 Python 和 PyGame 定义和播放声波

  • 将正弦函数转换为可演奏的音乐音符

  • 通过将声波作为函数相加来组合两个声音

  • 将声波函数分解为其傅里叶级数以查看其音乐音符

在第二部分的很多内容中,我们专注于使用微积分来模拟运动物体。在本章中,我将向你展示一个完全不同的应用:处理音频数据。数字音频数据是计算机对声波的表示,声波是我们耳朵感知为声音的空气压力的重复变化。我们将声波视为可以像向量一样相加和缩放的函数,然后我们可以使用积分来理解它们代表的声音类型。因此,我们对声波的研究结合了你在前几章中学到的关于线性代数和微积分的很多内容。

我不会深入探讨声波的物理原理,但了解它们在基本层面的工作方式是有用的。我们感知到的声音并不是空气压力本身,而是空气压力的快速变化,这些变化导致我们的耳膜振动。例如,如果你拉小提琴,你会将弓拉过一根弦,使弦振动。振动的弦使其周围的空气快速改变压力,压力的变化以声波的形式传播,直到到达你的耳朵。在那个时刻,你的耳膜以相同的速率振动,你感知到声音(图 13.1)。

图片

图 13.1 小提琴声到达耳膜示意图

你可以将数字音频文件视为描述随时间变化的振动的函数。音频软件解释该函数并指示你的扬声器相应地振动,在扬声器周围的空气中产生形状相似的声波。就我们的目的而言,函数的确切表示并不重要,但你可以将其松散地解释为描述随时间变化的空气压力(图 13.2)。

图片

图 13.2 将声波视为函数,松散地解释为表示随时间变化的压力

像音乐音符这样的有趣声音具有重复模式的声波,如图 13.2 所示。函数重复自身的速率称为频率,它告诉我们音乐音符的高低。声音的音色由重复模式的形状控制,例如,它听起来更像是小提琴、喇叭还是人声。

13.1 结合声波并分解它们

在本章中,我们对函数进行数学运算,并使用 Python 将它们作为实际声音播放。我们将做的主要两件事是将现有的声波组合成新的声波,然后将复杂的声波分解成更简单的声波。例如,我们可以将几个音符组合成一个和弦,然后我们可以将和弦分解以查看其音符。

然而,在我们这样做之前,我们需要了解基本构建块:声波和音符。我先向您展示如何使用 Python 将代表声波的一系列数字转换为从您的扬声器中发出的真实声音。为了制作与函数相对应的声音,我们从函数的图形中提取一些y值,并将这些值作为数组传递给音频库。这个过程被称为采样(图 13.3)。

我们将使用的主要声波函数是周期性函数,其图形由相同的重复形状构成。具体来说,我们将使用正弦波函数,这是一个包括正弦和余弦在内的周期函数族,可以产生自然音调的音符。在将它们采样转换为数字序列后,我们将构建 Python 函数来播放音符。

一旦我们能够产生单个音符,我们将编写 Python 代码来帮助我们组合不同的音符以创建和弦和其他复杂的声音。我们将通过将定义每个声波的函数相加来实现这一点。我们将看到,组合几个音符可以形成一个和弦,而组合几十个音符可以产生一些相当有趣且在性质上不同的声音。

图片

图 13.3 从函数f(t)的图形(顶部)开始,采样一些y值(底部)以发送到音频库

我们最后一个目标是将表示任何声波的函数分解为(纯)音符及其对应音量的和,这些音符构成了声波(图 13.4)。这种分解成和的过程称为傅里叶级数(发音为FOR-ee-yay)。一旦我们找到了构成傅里叶级数的声波,我们就可以一起播放它们,得到原始声音。

图片

图 13.4 使用傅里叶级数将声波函数分解为更简单的组合

从数学上讲,找到傅里叶级数意味着将一个函数写成和的形式,或者更具体地说,是正弦和余弦函数的线性组合。这个程序及其变体是所有时间中最重要的算法之一。与我们将要介绍的方法类似的方法被用于常见的应用,如 MP3 压缩,以及更宏伟的应用,如最近获得诺贝尔奖的引力波探测。

看这些声波作为图形是一回事,但真正听到它们从您的扬声器中发出是另一回事。让我们制造一些噪音!

13.2 在 Python 中播放声波

要在 Python 中播放声音,我们转向在前面几个章节中使用过的 PyGame 库。这个库中的一个特定函数接受一个数字数组作为输入,并播放声音作为结果。作为第一步,我们使用 Python 中的随机数字序列,并编写代码来使用 PyGame 解释和播放这些声音。目前这将是噪音(是的,这是一个技术术语!)而不是美丽的音乐,但我们需要从某个地方开始。

在产生一些噪音之后,我们将通过在具有重复模式的数字序列上运行相同的过程,而不是完全随机的数字序列,来制作一个稍微更有吸引力的声音。这为我们设置了下一节的内容,我们将通过采样周期函数来获取重复数字的序列。

13.2.1 生成我们的第一个声音

在我们将表示声音的数字数组传递给 PyGame 之前,我们需要告诉它如何解释这些数字。这里有一些关于音频数据的详细技术信息,我会解释它们,这样你就知道 PyGame 是如何考虑这些的,但这些细节对于本章的其余部分不是关键的。

在这个应用中,我们使用 CD 音频中常用的约定。具体来说,我们将用包含 44,100 个值的数组来表示一秒钟的音频,每个值都是一个 16 位整数(介于-32,768 和 32,767 之间)。这些数字大致代表了每个时间步骤的声音强度,每秒钟有 44,100 个步骤。这与我们在第六章中表示图像的方式类似。而不是一个表示像素亮度的值数组,我们有一个表示不同时间点的声音波强度的值数组。最终,我们将这些数字作为声音波图上点的y坐标,但就目前而言,我们将随机选择它们来制造一些噪音。

我们还使用单个通道,这意味着我们只播放一个声音波,而不是立体声音频,后者同时产生两个声音波,一个在左扬声器,一个在右扬声器。我们配置的另一件事是声音的比特深度。虽然频率类似于图像的分辨率,但比特深度就像允许的像素颜色数量,比特深度越高,意味着声音强度范围越精细。我们使用介于 0 到 256 之间的三个数字来表示像素的颜色,但在这里,我们使用一个 16 位数字来表示某一时刻的声音强度。选择这些参数后,我们代码中的第一步是导入 PyGame 并初始化声音库:

>>> import pygame, pygame.sndarray
>>> pygame.mixer.init(frequency=44100, 
                      size=−16,          ❶
                      channels=1) 

❶ -16 表示 16 位比特深度,输入为 16 位有符号整数,范围从-32,768 到 32,767

首先,我们可以通过创建一个包含 44,100 个介于-32,768 和 32,767 之间的随机整数的 NumPy 数组来生成一秒钟的音频。我们可以使用 NumPy 的randint函数在一行内完成此操作:

>>> import numpy as np
>>> arr = np.random.randint(−32768, 32767, size=44100)
>>> arr
array([−16280, 30700, −12229, ..., 2134, 11403, 13338])

要将这个数组解释为声波,我们可以在散点图上绘制它的前几个值。我在这本书的源代码中包含了一个plot_sequence函数,以帮助您快速绘制这样的整数数组。如果你运行plot_sequence (arr, max=100),你会得到这个数组前 100 个值的图像。与从平滑函数中采样的数字相比,这些数字分布得非常广泛(图 13.5)。

图片

图 13.5 声波采样值(左)与我们的随机值(右)的比较

如果你连接这些点,你可以想象它们定义了这个时间段内的一个函数。图 13.6 显示了连接点的数字数组的两张图,分别显示了前 100 个和 441 个数字。这些数据是完全随机的,所以没有什么特别有趣的东西可以看,但这将是我们将要播放的第一个声波。

因为 44,100 个值定义了一秒钟的声音,所以底部的 441 个值定义了第一个百分之一秒内的声音。接下来,我们可以使用一个库调用来播放声音。

图片

图 13.6 连接到定义函数的前 100 个值(顶部)和前 441 个值(底部)

注意:在运行下面的几行 Python 代码之前,请确保你的扬声器音量不要太大。我们制作的第一个声音不会那么愉快,更不用说,你也不想伤害你的耳朵!

要播放声音,你可以运行:

sound = pygame.sndarray.make_sound(arr)
sound.play()

结果应该听起来像一秒钟的静电,就像你打开了收音机但没有调到任何频道一样。这种随时间变化随机值的声波被称为白噪音

关于白噪音,你唯一能调整的可能是音量。人耳对压力的变化有反应,声音波越大,压力变化越大,听到的声音就越响。如果你觉得这个白噪音对你来说声音太大,你可以通过生成由较小数字组成的声数据来创建一个更安静版本。例如,这个白噪音是由从-10,000 到 10,000 的数字生成的:

arr = np.random.randint(−10000, 10000, size=44100)
sound = pygame.sndarray.make_sound(arr)
sound.play()

这个声音应该几乎与你播放的第一个白噪音相同,只是它更安静。声波的响度取决于函数值的大小,这种测量的结果被称为波的振幅。在这种情况下,因为值从平均值的 0 变化了 10,000 个单位,所以振幅被认为是 10,000。

尽管有些人觉得白噪音很舒缓,但它并不很有趣。让我们产生一个更有趣的声音,即一个音符。

13.2.2 演奏一个音符

当我们听到一个音符时,我们的耳朵正在检测振动中的模式,与白噪声的随机性形成对比。我们可以组合一系列具有明显模式的 44,100 个数字,你会听到它们产生一个音符。具体来说,让我们先重复数字 10,000 五十次,然后重复数字-10,000 五十次。我选择 10,000 是因为我们刚刚看到它有足够大的振幅,可以使声音波可听。图 13.7 显示了以下代码片段返回的前 100 个数字的图表:

form = np.repeat([10000,−10000],50)     ❶
plot_sequence(form)

❶ 列表中每个值重复指定的次数

如果我们重复这个 100 个数字的序列 441 次,我们就有 44,100 个总值来定义一秒的音频。为了实现这一点,我们可以使用另一个方便的 NumPy 函数,称为tile,它将给定的数组重复指定的次数:

arr = np.tile(form,441)

图 13.7 由数字 10,000 重复 50 次,然后是数字-10,000 重复 50 次组成的序列的图表。

图 13.8 显示了数组前 1,000 个值的图表,其中“点”是连接的。你可以看到它每隔 50 个数字在 10,000 和-10,000 之间跳来跳去。这意味着模式每 100 个数字重复一次。

图 13.8 展示了 44,100 个数字中的前 1,000 个的图表,显示了重复的模式。

这个波形被称为方波,因为它的图形有尖锐的 90°角。(注意,垂直线只是为了说明 MatPlotLib 连接了所有的点;在 10,000 和-10,000 之间没有序列的值,只是在 10,000 处有一个点连接到-10,000 处的点。)

44,100 个数字代表一秒,所以图 13.8 中绘制的 1,000 个数字代表 1/44.1 秒(或 0.023 秒)的音频。使用以下行播放这些声音数据会产生一个清晰的音符。这大约是音符 A(或科学音高记号中的 A[4])。你可以使用与第 13.2.1 节中相同的play()方法来听它。

sound = pygame.sndarray.make_sound(arr)
sound.play()

重复的速率(在这种情况下,每秒 441 次重复)称为声音波的频率,它决定了音符的音调,即音符听起来有多高或多低。重复频率的单位是赫兹,缩写为 Hz,其中 441 Hz 意味着每秒 441 次。音调 A 最常见的规定是 440 Hz,但 441 足够接近,并且它方便地除以每秒 44,100 个值的 CD 采样率。

有趣的声波来自周期性的函数,它们在固定的区间内重复,就像图 13.8 中的方波。方波的重复序列由 100 个数字组成,我们重复它 441 次来得到 44,100 个数字,从而得到一秒钟的音频。这是 441 Hz 的重复率,或者每 0.0023 秒一次。我们耳朵检测到的音乐音符就是这个重复率。在下一节中,我们将播放与最重要的周期性函数正弦和余弦在不同频率下相对应的声音。

13.2.3 练习

| 练习 13.1:我们的音符 A 在一秒钟内重复了 441 次。创建一个在一秒钟内重复 350 次的类似模式,这将产生音符 F。解答:幸运的是,44,100 Hz 的频率可以被 350 整除:44,100 / 350 = 126。我们有 63 个 10,000 和 63 个-10,000 的值,我们可以重复这个序列 350 次来创建一秒钟的音频。这个音符听起来比 A 低,确实是一个 F:

form = np.repeat([10000,−10000],63)
arr = np.tile(form,350)
sound = pygame.sndarray.make_sound(arr)
sound.play()

|

13.3 将正弦波转换为声音

我们用方波播放的声音是一个可识别的音符,但听起来并不很自然。这是因为在大自然中,事物通常不会以方波形式振动。更常见的是振动是正弦波形的,这意味着如果我们测量并绘制这些波形,我们会得到类似于正弦或余弦函数的图形。这些函数在数学上也是自然的,因此我们可以将它们作为我们即将制作的音乐的构建块。在采样音符并将它们传递给 PyGame 之后,你将能够听到方波和正弦波之间的区别。

13.3.1 从正弦函数制作音频

我们在这本书中已经多次使用过的正弦和余弦函数,本质上是有周期性的函数。这是因为它们的输入被解释为角度;如果你旋转 360°或 2π弧度,你将回到起点,正弦和余弦函数将返回相同的值。因此,sin(t)和 cos(t)每隔 2π单位重复一次,如图 13.9 所示。

图 13.12

图 13.9 每隔 2π单位,函数 sin(t)重复相同的值。

这个重复的区间被称为周期函数的周期,所以对于正弦和余弦,周期是 2π。当你绘制它们时(图 13.10),你可以看到它们在 0 到 2π之间看起来和 2π到 4π之间,或者 4π到 6π之间,以及如此等等,看起来是一样的。

图 13.11

图 13.10 因为正弦函数的周期是 2π,所以它的图形在每个 2π区间内都有相同的形状。

对于余弦函数,唯一的区别是图形向左移动了π/2 个单位,但它仍然每隔 2π单位重复一次(图 13.11)。

图 13.10

图 13.11 余弦函数的图形与正弦函数的图形形状相同,但它向左移动。它也每 2π个单位重复一次。

对于音频来说,每 2π秒重复一次的频率是 1/2π或大约 0.159 Hz,这对人类耳朵来说太小,无法听到。1.0 的振幅也太小,在 16 位音频中无法听到。为了解决这个问题,让我们编写一个 Python 函数make_sinusoid(frequency,amplitude),该函数产生一个正弦函数,垂直和水平拉伸或压缩以具有更理想的频率和振幅。441 Hz 的频率和 10,000 的振幅应该代表一个可听到的声波。

一旦我们生成了那个函数,我们希望提取 44,100 个均匀分布的函数值传递给 PyGame。提取这种函数值的过程称为采样,因此我们可以编写一个名为sample(f,start,end,count)的函数,该函数在t值在startend之间的范围内获取f(t)的指定计数值。一旦我们得到了所需的正弦函数,我们可以运行sample (sinusoid,0,1,44100)来获取一个包含 44,100 个样本的数组,传递给 PyGame,我们就能听到正弦波的声音。

13.3.2 改变正弦波的频率

作为第一个例子,让我们创建一个频率为 2 的正弦波,这意味着一个类似于正弦图形的函数,但在零和一之间重复两次。正弦函数的周期是 2π,所以默认情况下,它需要 4π个单位来重复两次(图 13.12)。

图 13.12 正弦函数在 t = 0 和 t = 4π之间重复两次。

要得到正弦函数图形的两个周期,我们需要正弦函数接收从 0 到 4π的输入值,但我们希望输入变量t从 0 到 1 变化。为了实现这一点,我们可以使用函数 sin(4πt)。从t* = 0 到t = 1,0 到 4π之间的所有值都传递给正弦函数。图 13.13 中 sin(4*πt)的图形与图 13.12 相同,但将正弦函数的两个完整周期挤压到第一个 1.0 单位内。

图 13.13 sin(4πt)的图形是正弦的,在 t 的每个单位内重复两次,频率为 2。

函数 sin(4πt)的周期是½而不是 2π,所以“挤压因子”是 4π。也就是说,原始周期是 2π,而缩短后的周期是 4π的 1/4。一般来说,对于任何常数k,形式为f(t) = sin(kt)的函数的周期将缩短k倍到 2π/ k。频率将增加k倍,从通常的 1/(2π)增加到k/2π*。

如果我们想要一个频率为 441 的正弦函数,适当的k值将是 441 · 2 · π。这给我们一个频率为

与此相比,增加正弦波的振幅要简单得多。你只需要将正弦函数乘以一个常数因子,振幅就会以相同的因子增加。有了这个,我们就有了定义我们的make_sinusoid函数所需的一切:

def make_sinusoid(frequency,amplitude):
    def f(t):                                      ❶
        return amplitude * sin(2*pi*frequency*t)   ❷
    return f

❶ 定义f(t) - 返回的正弦函数

❷ 将输入 t 乘以 2 ⋅ π倍的频率,然后将正弦函数的输出乘以振幅

我们可以通过创建一个频率为 5、振幅为 4 的正弦函数并从t = 0 到t = 1 绘制它(图 13.14)来测试这一点:

>>> plot_function(make_sinusoid(5,4),0,1)

图 13.14 make_sinusoid(5,4)的图形高度(振幅)为 4,从 t = 0 到 t = 5 重复 5 次,因此频率为 5。

接下来,我们处理由make_sinusoid (441,8000)得到的声音波函数,其频率为 441 Hz,振幅为 8,000。

13.3.3 采样和播放声音波

要播放上一节中提到的声音波,我们需要对其进行采样以获取 PyGame 可播放的数字数组。让我们设置

sinusoid = make_sinusoid(441,8000)

因此,从t = 0 到t = 1 的正弦函数代表我们尝试播放的声音波的 1 秒钟。我们选择 44,100 个t的值,在 0 和 1 之间均匀分布,并且相应的函数值是正弦函数(t)的对应值。

我们可以使用 NumPy 函数np.arange,它提供了一个给定区间上的均匀分布的数字。例如,np.arange(0,1,0.1)给出 10 个均匀分布的值,从 0 开始,在 0.1 单位间隔下小于 1:

>>> np.arange(0,1,0.1)
array([0\. , 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9])

对于我们的应用,我们希望在 0 和 1 之间使用 44,100 个时间值,这些值由 1/44100 单位均匀分布:

>>> np.arange(0,1,1/44100)
array([0.00000000e+00, 2.26757370e-05, 4.53514739e-05, ...,
       9.99931973e-01, 9.99954649e-01, 9.99977324e-01])

我们希望将正弦函数应用于数组的每个条目,以产生另一个作为结果的 NumPy 数组。NumPy 函数np.vectorize(f)接受一个 Python 函数f并产生一个新的函数,该函数将相同的操作应用于数组的每个条目。因此,对于我们来说,np.vectorize(sinusoid)(arr)将正弦函数应用于数组的每个条目。

这几乎是一个完整的函数采样过程。我们需要包含的最后细节是将输出转换为使用 NumPy 数组的astype方法得到的 16 位整数值。将这些步骤组合起来,我们可以构建以下通用的采样函数:

def sample(f,start,end,count):                    ❶
    mapf = np.vectorize(f)                        ❷
    ts = np.arange(start,end,(end-start)/count)   ❸
    values = mapf(ts)                             ❹
    return values.astype(np.int16)                ❺

❶ 输入是函数 f,用于采样范围的起始和结束以及我们想要的值的数量。

❷ 创建一个可以应用于 NumPy 数组的 f 版本

❸ 为函数在所需范围内创建均匀分布的输入值

❹ 将函数应用于 NumPy 数组中的每个值

❺ 将结果数组转换为 16 位整数并返回

配备以下函数,你可以听到 441 Hz 正弦波的声波:

sinusoid = make_sinusoid(441,8000)
arr = sample(sinusoid, 0, 1, 44100)
sound = pygame.sndarray.make_sound(arr)
sound.play()

如果你将它与 441 Hz 的方波一起播放,你会注意到它演奏的是同一个音符;换句话说,它有相同的音高。然而,声音的质量大不相同;正弦波演奏的声音要平滑得多。它听起来几乎像是来自长笛而不是老式电子游戏。这种声音的质量被称为 音色(发音为 TAM-ber)

在本章的剩余部分,我们将专注于由正弦波组合而成的声波。结果是,通过正确的组合,你可以近似任何波形的波,因此可以近似任何你想要的音色。

13.3.4 练习

| 练习 13.2:绘制正切函数 tan(t) = sin(t)/cos(t)。它的周期是多少?解答:正切函数在每个周期内都会变得无限大,因此最好在限制的 y 值范围内绘制它:

from math import tan
plot_function(tan,0,5*pi)
plt.ylim(−10,10)            ❶

❶ 将图形窗口限制在 y 范围 -10 < y < 10 内。一个周期性的 tan(x) 图形看起来是这样的!图像因为 tan(t) 只依赖于 cos(t) 和 sin(t) 的值,它至少应该在每 2π 个单位重复一次。实际上,它在每 2π 个单位重复两次;我们可以从图中看到它的周期是 π。|

练习 13.3:sin(3πt) 的频率是多少?周期是多少?解答:sin(t) 的频率是 1/(2π),将自变量乘以 3π 会将这个频率增加 3π 倍。得到的频率是 (3π)/(2π) = 3/2。周期是这个值的倒数,即 2/3。

| 练习 13.4:找到 k 的值,使得 cos(kt) 的频率为 5。绘制从零到一的 cos(kt) 函数,并证明它重复了 5 次。解答:cos(t) 的默认频率是 1/(2π),所以 cos(kt) 的频率是 k/(2π)。如果我们想这个值等于 5,我们需要 k = 10π。得到的函数是 cos(10πt):

>>> plot_function(lambda t: cos(10*pi*t),0,1)

这里是它的图形,它在 t = 0 到 t = 1 之间重复了五次!图像|

13.4 将声波组合成新的声波

在第六章中,你学习了函数可以被当作向量来处理;你可以将函数相加或者用标量乘以它们来产生新的函数。当你创建定义声波的函数的线性组合时,你可以创造出新的、有趣的声音。

在 Python 中将两个声波组合的最简单方法是采样两个声波,然后将两个数组的对应值相加来创建一个新的声波。我们首先编写一些 Python 代码来添加不同频率的采样声波,它们产生的结果听起来就像是一个音乐和弦,就像你同时弹奏吉他上的几根弦一样。

一旦我们做到了这一点,我们就可以进行一个更高级、更令人惊讶的例子——我们将把几十个不同频率的正弦声波按照规定的线性组合加在一起,其结果看起来和听起来就像之前那个方波一样。

13.4.1 将采样声音波相加以构建和弦

NumPy 数组可以使用 Python 中的普通 + 运算符进行相加,这使得添加采样声音波变得容易。以下是一个小示例,说明 NumPy 通过将每个数组的对应值相加以构建新数组来进行加法操作:

>>> np.array([1,2,3]) + np.array([4,5,6])
array([5, 7, 9])

结果表明,使用两个采样声音波进行此操作会产生与同时播放两个声音相同的音效。这里有两组样本:我们 441 Hz 的正弦波和第二个正弦波,其频率约为第一个的 5/4:

sample1 = sample(make_sinusoid(441,8000),0,1,44100)
sample2 = sample(make_sinusoid(551,8000),0,1,44100)

如果你让 PyGame 同时开始播放一个并立即开始播放下一个,它会几乎同时播放两个声音。如果你运行以下代码,你应该听到由两个不同的音符组成的和弦。如果你单独运行最后两行中的任何一行,你会听到两个单独的音符之一:

sound1 = pygame.sndarray.make_sound(sample1)
sound2 = pygame.sndarray.make_sound(sample2)
sound1.play()
sound2.play()

现在,使用 NumPy,我们可以将两个样本数组相加以生成一个新的数组,并用 PyGame 播放它。当 sample1sample2 相加时,会创建一个长度为 44,100 的新数组,包含来自 sample1sample2 的条目之和。如果你播放这个结果,听起来就像播放之前的声音一样:

chord = pygame.sndarray.make_sound(sample1 + sample2)
chord.play()

13.4.2 绘制两个声音波的叠加

让我们看看在声音波形的图形方面这看起来是什么样子。以下是 sample1(441 Hz) 和 sample2(551 Hz) 的前 400 个点。在图 13.15 中,你可以看到样本 1 完成了四个周期,而样本 2 完成了五个周期。

图 13.15 绘制 sample1sample2 的前 400 个点

图 13.16 绘制两个波的叠加,sample1 + sample2

可能会令人惊讶,尽管 sample1sample2 是由两个正弦波构建的,但它们的和并不产生正弦波。相反,sample1 + sample2 的序列绘制出一个振幅似乎波动的波形。图 13.16 显示了总和的外观。

让我们仔细看看叠加,看看我们是如何得到这个形状的。在样本的第 85 个点附近,两个波都是大的正值,所以总和的第 85 个点也是大的正值。在 350 个点附近,两个波都有大的负值,它们的和也是如此。当两个波对齐时,它们的和甚至更大(更响亮),这被称为 构造性干涉

图 13.17 中有一个有趣的效果,其中值是相反的(在第 200 个点)。例如,sample1 是大的正值,而 sample2 是大的负值。这导致它们的和接近零,尽管单独的每个波都不接近零。当两个波以这种方式相互抵消时,这被称为 破坏性干涉

图 13.17 总波的绝对值在发生构造性干涉的地方很大,而在发生破坏性干涉的地方很小。

由于波具有不同的频率,它们会相互进入和退出同步,在建设性和破坏性干涉之间交替。因此,波的叠加不是一个正弦波;相反,它似乎随时间改变振幅。图 13.17 显示了两个图形并排排列,显示了两个样本及其总和之间的关系。

如您所见,叠加正弦波的相对频率会影响结果的图形形状。接下来,我将向您展示一个更极端的例子,当我们使用几十个正弦函数构建线性组合时。

13.4.3 构建正弦波的线性组合

让我们从一大堆不同频率的正弦波开始。我们可以制作一个(只要我们想要)正弦函数的列表,从:

sin(2πt),sin(4πt),sin(6πt),sin(8πt),...

这些函数的频率为 1,2,3,4,等等。同样,余弦函数的列表,从

cos(2πt),cos(4πt),cos(6πt),cos(8πt),...

分别具有 1,2,3,4 等频率。我们的想法是,有了这么多不同的频率可供选择,我们可以通过这些函数的线性组合创建各种不同的形状。由于我们将在后面看到的原因,我还会在线性组合中包括一个常量函数 f(x) = 1。如果我们选择某个最高频率 N,正弦、余弦和常量的最一般线性组合如图 13.18 所示。

图 13.18 我们线性组合中的正弦和余弦函数

这个线性组合是一个傅里叶级数,它本身是变量 t 的函数。它由 2 N + 1 个数字指定:常数项 a 0,余弦函数上的系数 a[1] 到 aN,以及正弦函数上的系数 b[1] 到 bN。我们可以通过将给定的 t 值插入每个正弦和余弦函数,并将结果的线性组合相加来评估该函数。让我们在 Python 中这样做,这样我们可以轻松地测试几个不同的傅里叶级数。

fourier_series 函数接受一个单个常数 a 0,并列出 ab 包含的系数 a[1],... ,aNb[1],... ,bN,分别。此函数即使在数组长度不同的情况下也能正常工作;未指定的系数被视为零。请注意,正弦和余弦频率从 1 开始,而 Python 的 enumerate 从 0 开始,因此 (n + 1) 是对应于任一数组中索引 n 处系数的频率:

def const(n):                                ❶
    return 1

def fourier_series(a0,a,b):
    def result(t):
        cos_terms = [an*cos(2*pi*(n+1)*t) 
            for (n,an) in enumerate(a)]      ❷
        sin_terms = [bn*sin(2*pi*(n+1)*t)
            for (n,bn) in enumerate(b)]      ❸
        return a0*const(t) + \
            sum(cos_terms) + sum(sin_terms)  ❹
    return result

❶ 创建一个常量函数,对于任何输入都返回 1

❷ 使用各自的常量评估所有余弦项,并将结果相加

❸ 使用各自的常量评估正弦项,并将结果相加

❹ 将两个结果与常量系数 a[0] 乘以常量函数(1)的值相加

这里有一个示例,调用此函数时 b[4] = 1 和 b[5] = 1,而所有其他常数为 0。这是一个非常短的傅里叶级数,sin(8πt) + sin(10πt),其图形如图 13.19 所示。因为频率之比为 4:5,所以结果的形状应该类似于我们最后绘制的图形(图 13.17):

>>> f = fourier_series(0,[0,0,0,0,0],[0,0,0,1,1])
>>> plot_function(f,0,1)

图像

图 13.19 傅里叶级数 sin(8πt) + sin(10πt) 的图形

这是一个很好的测试,看看我们的函数是否工作,但它还没有展示傅里叶级数的全部威力。接下来,我们尝试一个具有更多项的傅里叶级数。

13.4.4 使用正弦波构建熟悉函数

让我们创建一个傅里叶级数,它仍然没有常数项和余弦项,但有很多正弦项。具体来说,我们使用以下序列的值来设置 b[1],b[2],b[3],等等:

图像

或者 b[n] = 0 对于每个偶数 n,而当 n 为奇数时,b[n] = 4/(nπ)。这为我们提供了一个基础,可以构建任意项数的傅里叶级数。例如,第一个非零项是

图像

并且在添加下一个项之后,级数变为

图像

图 13.20 傅里叶级数的第一项和前两项的图形

这是代码,图 13.20 显示了这两个函数同时绘制的图形。

>>> f1 = fourier_series(0,[],[4/pi])
>>> f3 = fourier_series(0,[],[4/pi,0,4/(3*pi)])
>>> plot_function(f1,0,1)
>>> plot_function(f3,0,1)

图像

图 13.20 傅里叶级数的第一项和前两项的图形

使用列表推导,我们可以创建一个更长的系数列表,b[n],并程序化地构建傅里叶级数。我们可以留出余弦系数列表为空,那么所有 a[n] 的值都将被设置为 0:

*b* = [4/(n * pi) 
    if n%2 != 0 else 0 for n in range(1,10)]    ❶
f = fourier_series(0,[],b)

❶ 列出 bn = 4/nπ 对于 n 的奇数值和 bn = 0,否则

这个列表涵盖了 1 ≤ n < 10,所以非零系数是 b[1],b[3],b[5],b[7],和 b[9]。有了这些项,级数的图形看起来像图 13.21。

图像

图 13.21 傅里叶级数前 5 个非零项的总和

这是一个有趣的构造性和破坏性干涉模式!在 t = 0 和 t = 1 附近,所有的正弦函数同时增加,而在 t = 0.5 附近,它们同时减少。这种构造性干涉是主要效应,而交替的构造性和破坏性干涉使其他区域的图形相对平坦。当 n 的范围达到 19 时,如图 13.22 所示,有 10 个非零项,这种效应更加明显。

>>> b = [4/(n * pi) if n%2 != 0 else 0 for n in range(1,20)]
>>> f = fourier_series(0,[],b)

如果我们将 n 的范围扩展到 99,我们得到 50 个正弦函数的总和,函数在几个大的跳跃之外几乎变得平坦(图 13.23)。

>>> b = [4/(n * pi) if n%2 != 0 else 0 for n in range(1,100)]
>>> f = fourier_series(0,[],b)

图像

图 13.22 傅里叶级数的前 10 个非零项

图像

图 13.23 使用 99 项时,傅里叶级数的图形几乎平坦,除了在 0、0.5 和 1.0 处的大步。

如果你放大查看,你可以看到这个傅里叶级数接近我们在本章开头绘制的方波(图 13.24)。

图 13.24 傅里叶级数的前 50 个非零项接近方波,就像我们在本章遇到的第一项函数。

我们在这里所做的是将方波函数构建为一个正弦波的线性组合的近似。我们能够做到这一点是反直觉的!毕竟,傅里叶级数中的所有正弦波都是圆滑的,而方波是平坦且锯齿状的。我们将通过展示如何从任何周期函数开始并恢复其傅里叶级数的系数来逆向工程这个近似,以结束本章。

13.4.5 练习

练习 13.5-迷你项目:创建一个方波傅里叶级数的处理版本,使其频率为 441 Hz,然后对其进行采样并确认它不仅看起来像方波。它应该听起来也像方波。

13.5 将声音波分解为其傅里叶级数

我们最后的目的是将任意周期函数,如方波,找出如何将其(或至少其近似)表示为正弦函数的线性组合。这意味着将任何声音波分解为纯音符的组合。作为一个基本示例,我们将查看定义和弦的声音波,并确定哪些音符构成了和弦。更深刻的是,我们可以将任何声音分解为音乐音符:一个人说话,一只狗吠叫,或一辆汽车轰鸣。在这个结果背后是一些优雅的数学思想,而现在你已经拥有了理解它们所需的所有背景知识。

将一个函数分解为其傅里叶级数的过程类似于我们在第一部分中将一个向量表示为基向量的线性组合。这个类比是如何工作的。我们将在函数的向量空间中工作,并将一个函数,如方波,视为一个感兴趣的函数。然后,我们将基视为函数集 sin(2πt)、sin(4πt)、sin(6*πt) 等等。在第 13.3 节中,我们将方波近似为从

你可以将两个基向量 sin(2πt) 和 sin(6πt) 视为定义无限维函数空间中的两个垂直方向,其他方向由其他基向量定义。方波在 sin(2πt) 方向有一个长度为 4/π* 的分量,在 sin(6πt) 方向有一个长度为 4/3π* 的分量。这些是这个基下方波无限列表中的前两个坐标(图 13.25)。

图 13.25 你可以将方波视为函数空间中的一个向量,其 sin(2πt)方向上的分量长度为 4/π,sin(6πt)方向上的分量长度为 4/3π。方波在这两个分量之外还有无限多个分量。

我们可以编写一个 fourier_coefficients(f,N) 函数,该函数接受一个周期为 1 的函数 f 和一个所需的系数数量 N。该函数将常数函数以及从 1 ≤ n < N 的 cos(2nπt)和 sin(2nπt)函数视为函数空间中的方向,并找到 f 在这些方向上的分量。它返回表示常数函数的傅里叶系数 a[0],以及一系列傅里叶系数 a[1],a[2],...,aN 和一系列傅里叶系数 b[1],b[2],...,bN

13.5.1 使用内积找到向量分量

在第六章中,我们介绍了如何使用函数进行向量加法和标量乘法,这些操作与 2D 和 3D 向量的操作类似。我们还需要一个与点积相对应的工具。点积是内积的一个例子,它通过将两个向量相乘得到一个标量,该标量衡量了两个向量的对齐程度。

让我们暂时回顾一下 3D 世界,并展示如何使用点积找到 3D 向量的分量,然后我们将做同样的事情来找到正弦函数基中的函数分量。假设我们的目标是找到向量 v = (3, 4, 5) 在标准基向量 e[1] = (1, 0, 0),e[2] = (0, 1, 0),和 e[3] = (0, 0, 1) 方向上的分量。这个问题如此明显,以至于我们从未深入思考过。分量分别是 3,4,和 5,这就是坐标(3, 4, 5)的含义!

在这里,我将向您展示另一种使用点积找到 v = (3, 4, 5) 的分量方法。这将是多余的,因为我们已经有了答案,但对于函数向量的情况将很有用。请注意,v 与标准基向量的每个点积都给我们回一个分量:

v · e[1] = (3, 4, 5) · (1, 0,0) = 3 + 0 + 0 = 3

v · e[2] = (3, 4, 5) · (0, 1,0) = 0 + 4 + 0 = 4

v · e[3] = (3, 4, 5) · (0, 0,1) = 0 + 0 + 5 = 5

这些点积立即告诉我们如何将 v 表示为标准基的线性组合:v = 3e[1] + 4e[2] + 5e[3]。请注意,这仅因为点积与我们的长度和角度定义一致。任何一对垂直的标准基向量都具有零点积:

e[1] · e[2] = e[2] · e[3] = e[3] · e[1] = 0

标准基向量与自身的点积产生它们的(平方)长度为 1:

e[1] · e[1] = e[2] · e[2] = e[3] · e[3] = |e[1]|² = |e[2]|² = |e[3]|² = 1

另一种看待这些关系的方法是,根据点积,标准基向量在另一个标准基向量的方向上没有分量。此外,每个标准基向量在其自身方向上的分量是 1。如果我们想发明一个内积来计算函数的分量,我们需要我们的基具有相同的理想特性。换句话说,我们需要知道我们的基函数,如 sin(2π**t),cos(2π**t) 等,都是垂直的并且长度为 1。我们将为函数创建一个内积并测试这些事实。

13.5.2 为周期函数定义内积

假设 f(t) 和 g(t) 是在区间 t = 0 到 t = 1 上定义的两个函数,并且它们每隔一个单位的 t 重复一次。我们可以将 fg 的内积写作 <f , g >,并通过一个定积分来定义它:

图像

让我们在 Python 代码中实现这一点,将积分近似为 Riemann 和(就像我们在第八章中做的那样),这样你就可以了解这个内积是如何像熟悉的点积一样工作的。这个 Riemann 和默认为 1,000 个时间步长,如下所示:

def inner_product(f,g,N=1000):
    dt = 1/N                                    ❶
    return 2*sum([f(t)*g(t)*dt 
                  for t in np.arange(0,1,dt)])  ❷

❶ dt 的大小默认为 1/1000 = 0.001。

❷ 对于每个时间步长,积分的贡献是 f(t) * g(t) * dt。根据公式,积分的结果乘以 2。

与点积类似,这个积分近似是输入向量值的乘积之和。它不是坐标乘积之和,而是函数值乘积之和。你可以将函数的值视为一组无限多的坐标,而这个内积可以看作是这些坐标上的“无限点积”。

让我们深入探讨这个内积。为了方便起见,让我们定义一些 Python 函数来创建我们基中的第 n 次正弦和余弦函数,然后我们可以使用 inner_product 函数来测试它们。这些函数类似于第 13.3.2 节中 make_sinusoid 函数的简化版本:

def s(n):                     ❶
    def f(t):
        return sin(2*pi*n*t)
    return f

def c(n):                      ❷
    def f(t):
        return cos(2*pi*n*t)
    return f

❶ s(n) 接受一个整数 n 并返回函数 sin(2nπt)。

❷ c(n) 接受一个整数 n 并返回函数 cos(2nπt)。

两个三维向量(1, 0, 0)和(0, 1, 0)的点积为零,这证实了它们是垂直的。我们的内积表明,我们所有的基函数对(近似地)都是垂直的。例如,

>>> inner_product(s(1),c(1))
4.2197487366314734e−17
>>> inner_product(s(1),s(2))
−1.4176155163484784e−18
>>> inner_product(c(3),s(10))
−1.7092447249233977e−16

这些数字非常接近零,证实了 sin(2π**t) 和 cos(2π**t) 是垂直的,以及 sin(2π**t) 和 sin(4π**t) 也是垂直的,同样 cos(6π**t) 和 cos(20π**t) 也是垂直的。使用我们在这里不会介绍的精确积分公式,可以 证明 对于任何整数 nm

〈sin(2nπt), cos(2mπt)〉 = 0

对于任何两个不同的整数 nm,都有

〈sin(2nπt), sin(2mπt)〉 = 0

〈cos(2nπt), cos(2mπt)〉 = 0

这意味着,相对于这个内积,我们所有的正弦基函数都是相互垂直的;没有一个在另一个方向上有分量。我们还需要检查的是,内积意味着我们的基向量在自己的方向上有 1 的分量。实际上,在数值误差范围内,这似乎是真的:

>>> inner_product(s(1),s(1))
1.0000000000000002
>>> inner_product(c(1),c(1))
0.9999999999999999
>>> inner_product(c(3),c(3))
1.0

尽管我们在这里不会详细讲解,但使用积分公式可以直接证明,对于任何整数 n

〈sin(2nπt), sin(2nπt)〉 = 1

〈cos(2nπt), cos(2nπt)〉 = 1

我们需要做的最后一件整理工作是将常数函数包含在这个讨论中。我之前承诺过要解释为什么我们需要在傅里叶级数中包含常数项,现在我可以给出一个初步的解释。常数函数是构建完整函数基所必需的;如果不包含它,就像在 3D 空间的基中省略 e[2],只使用 e[1] 和 e[3] 一样。如果你这样做,有些函数你就无法用基向量构建出来。

任何常数函数都垂直于我们基中的所有正弦和余弦函数,但我们需要选择常数函数的值,使其在自身方向上的分量是 1。也就是说,如果我们实现一个 Python 函数 const(*t*),我们应该找到 inner_product(const,const) 返回 1。const 返回的正确常数值是 1/√2(你可以在下面的练习中检查这个值是否合理!):

from math import sqrt

def const(n):
    return 1 /sqrt(2)

在此定义的基础上,我们可以确认常数函数具有正确的属性:

>>> inner_product(const,s(1))
−2.2580204307905138e−17
>>> inner_product(const,c(1))
−3.404394821604484e−17
>>> inner_product(const,const)
1.0000000000000007

我们现在已经拥有了寻找周期函数傅里叶系数所需的工具。这些系数不过是函数在我们定义的基中的组成部分。

13.5.3 编写一个寻找傅里叶系数的函数

在 3D 示例中,我们看到了向量 v 与基向量 e i 的点积给出了 ve i 方向上的分量。我们将对周期函数 f 使用相同的过程。

对于 n ≥ 1 的系数 a[n] 告诉我们 f 在基函数 cos(2nπt) 方向上的分量。它们是通过计算 f 与这些基函数的内积来得到的:

a[n] = 〈f, cos(2nπt)〉 , n ≥ 1

同样,每个傅里叶系数 b[n] 告诉我们 f 在基函数 sin(2nπt) 方向上的分量,也可以通过内积来计算:

b[n] = 〈f, sin(2nπt)〉

最后,系数 a 0 是 f 与常数函数的内积,其值为 1/√2。所有这些傅里叶系数都可以使用我们之前编写的 Python 函数来计算,因此我们准备好编写 fourier_coefficients 函数。记住,函数的第一个参数是我们想要分析的功能,第二个参数是我们想要的最大正弦和余弦项数:

def fourier_coefficients(f,N):
    a0 = inner_product(f,const)     ❶
    an = [inner_product(f,c(n)) 
          for n in range(1,N+1)]    ❷
    bn = [inner_product(f,s(n)) 
          for n in range(1,N+1)]    ❸
    return a0, an, bn

❶ 常数项 a[0] 是 f 与常数基函数的内积。

❷ 系数 an 是 f 与 cos(2nπt) 的内积,对于 1 < n < N + 1。

❸ 系数 bn 是 f 与 sin(2nπt) 的内积,对于 1 ≤ n < N + 1。

作为合理性检查,傅里叶级数应该返回其自身的系数。例如

>>> f = fourier_series(0,[2,3,4],[5,6,7])
>>> fourier_coefficients(f,3)
(−3.812922200197022e−15,
 [1.9999999999999887, 2.999999999999999, 4.0],
 [5.000000000000002, 6.000000000000001, 7.0000000000000036])

注意:如果你想让输入和输出匹配非零常数项,你需要将 const 函数修改为 f(t) = 1/√2 而不是 f(t) = 1。参见练习 13.8。

现在我们能够自动计算傅里叶系数,我们可以通过构建一些有趣形状的周期函数的傅里叶近似来结束我们的探索。

13.5.4 求方波的傅里叶系数

我们在上一个章节中看到,方波的傅里叶系数除了奇数 n 值的 b[n] 系数外都是零。也就是说,傅里叶级数是由 sin(2nπt) 的奇数 n 值的线性组合构成的。对于奇数 n,系数是 bn = 4/n**π。我没有解释为什么这些是系数,但现在我们可以检查我们的工作。

为了使周期为 t 的方波重复出现,我们可以在 Python 中使用 t%1 的值,它计算 t 的分数部分。因为,例如,2.3 % 10.3,而 0.3 % 1 仍然是 0.3,以 t % 1 为术语编写的函数自动具有周期 1。当 t % 1 < 0.5 时,方波值为 +1,否则为 −1。

def square(t):
    return 1 if (t%1) < 0.5 else −1

让我们看看这个方波的第一个 10 个傅里叶系数。运行

a0, a, b = fourier_coefficients(square,10)

你会看到 a[0] 和 a 的条目都很小,就像 b 的其他条目一样。b[1]、b[3]、b[5] 等的值由 b[0]b[2]b[4] 等表示,因为 Python 数组是零索引的。这些值都接近预期的值:

>>> b[0], 4/pi
(1.273235355942202, 1.2732395447351628)
>>> b[2], 4/(3*pi)
(0.4244006151333577, 0.4244131815783876)
>>> b[4], 4/(5*pi)
(0.2546269646514865, 0.25464790894703254)

我们已经看到,具有这些系数的傅里叶级数是对方波图的稳健近似。让我们通过查看两个我们之前没有见过的示例函数,并将傅里叶级数与原始函数一起绘制来结束本节,以展示近似是如何工作的。

13.5.5 其他波形的傅里叶系数

接下来,我们考虑更多可以用傅里叶变换建模的函数,而不仅仅是方波图。图 13.26 显示了新的、有趣形状的波形,称为锯齿波。

图 13.26 一个锯齿波在五个周期内绘制

t = 0 到 t = 1 的区间内,锯齿波与函数 f(t) = t 相同,然后每单位重复一次。为了将锯齿波定义为 Python 函数,我们可以简单地写出

def sawtooth(t):
    return t%1

要看到其包含最多 10 个正弦和余弦项的傅里叶级数近似,我们可以直接将傅里叶系数代入我们的傅里叶级数函数中。如图 13.27 所示,将其与锯齿波一起绘制,我们可以看到它有很好的拟合度。

>>> approx = fourier_series(*fourier_coefficients(sawtooth,10))
>>> plot_function(sawtooth,0,5)
>>> plot_function(approx,0,5)

图片

图 13.27 图 13.26 中的原始锯齿波及其傅里叶级数近似

再次强调,使用仅由平滑的正弦和余弦波线性组合来逼近具有尖锐角的功能,其接近程度令人印象深刻。这个函数恰好有一个非零的常数系数 a[0]。这是必需的,因为此函数的值仅在零以上,而正弦和余弦函数贡献的是负值。

作为最后的例子,看看这本书源代码中定义的以下函数 speedbumps(*t*)。图 13.28 显示了该函数的图形。

图片

图 13.28 在源代码中定义为 speedbumps(*t*) 的函数,它在平坦的延伸和圆形突起之间交替

这个函数的实现并不重要,但这个例子很有趣,因为它对于余弦函数有非零系数,而对于正弦函数则全部为零。即使有 10 项,我们也能得到一个好的近似。图 13.29 显示了包含 a[0] 和十个余弦项(系数 b[n] 全为零)的傅里叶级数的图形。

图片

图 13.29 speedbumps(*t*) 函数傅里叶级数的常数项和前 10 个余弦项

当我们绘制这些近似时,可以看到一些波动,但当这些波形转换为声音时,傅里叶级数可以足够好。因为我们能够将所有形状的波形转换为它们的傅里叶系数列表,我们可以有效地存储和传输音频文件。

13.5.6 练习

练习 13.6: 向量 u[1] = (2, 0, 0), u[2] = (0, 1, 1), 和 u[3] = (1, 0, −1) 构成 ℝ³ 的一个基。对于向量 v = (3, 4, 5),计算三个点积 a[1] = v · u[1], a[2] = v · u[2], 和 a[3] = v · u[3]。证明 v 不等于 a[1] u[1] + a[2] u[2] + a[3] u[3]。为什么它们不相等?解答: 点积为a[1] = v · u[1] = (3, 4, 5) · (2, 0, 0) = 6a[2] = v · u[2] = (3, 4, 5) · (0, 1, 1) = 9a[3] = v · u[3] = (3, 4, 5) · (1, 0,−1) = −2。这使得线性组合 6 · (2, 0, 0) + 9 · (0, 1, 1) − 2 · (1, 0, −1) = (16, 9, 2),这并不等于 (3, 4, 5)。这种方法不能给出正确的结果,因为这些基向量长度不为 1,且它们之间不垂直。
练习 13.7-迷你项目: 假设 f(t) 是常数,即 f(t) = k。使用内积的积分公式找到一个值 k 使得 <f , f > = 1。是的,我已经告诉你 k =1/√2,但看看你是否能自己得到这个值!
解答:如果 f(t) = k,那么 <f , f > 由以下积分给出:(常数函数 k² 从 0 到 1 下的面积是 k²。)如果我们想使 2 k² 等于 1,那么 k² = ,k = √1/2 = 1/√2。

| 练习 13.8:更新 fourier_series 函数,使用 f(t) = 1/√2 作为常数函数,而不是 f(t) = 1。解答

def fourier_series(a0,a,b):
    def result(t):
        cos_terms = [an*cos(2*pi*(n+1)*t) for (n,an) in enumerate(a)]
        sin_terms = [bn*sin(2*pi*(n+1)*t) for (n,bn) in enumerate(b)]
        return a0/sqrt(2) + sum(cos_terms) + sum(sin_terms)          ❶
    return result

❶ 将系数 a[0] 乘以常数函数 f(t) = 1/√2,并将其加到傅里叶级数的结果中,无论 t 的值如何 |

| 练习 13.9-迷你项目:播放 441 Hz 的锯齿波,并将其与您在该频率下播放的方波和正弦波进行比较。解答:我们可以创建一个振幅为 8,000、频率为 441 的修改后的锯齿波函数,然后对其进行采样,传递给 PyGame:

def modified_sawtooth(t):
    return 8000 * sawtooth(441*t)
arr = sample(modified_sawtooth,0,1,44100)
sound = pygame.sndarray.make_sound(arr)
sound.play()

人们常常将锯齿波的声音与弦乐器,如小提琴的声音相比较。|

概述

  • 声波是随时间传播的空气压力变化,到达我们的耳朵时,我们将其感知为声音。我们可以将声波表示为一个函数,该函数大致表示随时间变化的空气压力变化。

  • PyGame 和大多数其他数字音频系统使用 采样 音频。这些系统不是使用定义声波函数,而是使用函数在均匀间隔下的值数组。例如,CD 音频通常每秒使用 44,100 个值。

  • 形状随机的声波听起来像噪音,而形状在固定间隔内重复的波产生明确的音乐音符。在某个间隔上重复其值的函数称为 周期函数

  • 正弦和余弦函数是周期函数,它们的图形重复称为 正弦波 的曲线形状。

  • 正弦和余弦函数每 2π 个单位重复其值。这个值称为它们的 周期。周期函数的 频率 是周期的倒数,对于正弦和余弦来说,是 1/(2π)。

  • 形式为 sin(2nπt) 或 cos(2nπt) 的函数具有频率 n。高频声波函数产生高音调的音符。

  • 周期函数的最大高度称为其 振幅。将正弦或余弦函数乘以一个数字会增加函数的振幅和相应声波的音量。

  • 要创建同时播放两个声音的效果,您可以添加定义它们对应声波的函数,以创建一个新的函数和一个新的声波。通常,您可以通过现有声波的任何线性组合来创建一个新的声波。

  • 由一个常数函数以及形式为 sin(2nπt) 和 cos(2nπt) 的函数的线性组合,对于各种 n 的值,称为傅里叶级数。尽管傅里叶级数是由平滑的正弦和余弦函数构建的,但它可以很好地近似任何周期函数,甚至那些具有尖锐拐角的函数,如方波。

  • 你可以将不同频率的正弦和余弦函数以及常数函数视为周期函数向量空间的基。这些基向量的线性组合,用以最佳逼近给定函数的,被称为傅里叶系数

  • 我们可以使用二维或三维向量与标准基向量的点积来找到其在该基向量方向上的分量。

  • 类似地,我们可以取一个周期函数与正弦或余弦函数的特殊内积,以找到与该函数相关联的分量。周期函数的内积是在指定范围内取定的定积分,在我们的例子中,是从零到一。

第三部分. 机器学习应用

在第三部分,我们将你学到的关于数学函数、向量和微积分的知识应用到实现一些机器学习算法中。关于机器学习,我们听到了很多炒作,因此明确了解它实际上是什么非常重要。机器学习是人工智能领域的一部分,或称为 AI,它研究如何编写计算机程序以智能地完成任务。如果你曾经与计算机对手玩过电子游戏,那么你就已经与人工智能互动过了。这样的对手通常被编程(通常)使用一系列规则,这些规则帮助它摧毁你、操纵你或以其他方式击败你。

要将一个算法归类为机器学习,它不仅必须自主且智能地运行,而且必须从经验中学习。这意味着它接收到的数据越多,它在手头任务上的表现就越好。接下来的三章将重点介绍一种称为监督学习的特定类型的机器学习。当我们编写监督学习算法时,我们向它们提供包含输入和相应输出的训练数据集,然后算法应该能够查看新的输入并自行生成正确的输出。从这个意义上说,训练机器学习算法的结果是一个新的数学函数,它可以有效地将某种输入数据映射到某种决策作为输出。

在第十四章中,我们介绍了一种简单的监督学习算法,称为线性回归,并使用它根据汽车的里程数预测二手车价格。训练数据集包括许多二手车的已知里程数和价格,并且在没有关于汽车估值任何先验知识的情况下,我们的算法学会了根据汽车的里程数为其定价。线性回归算法通过取里程数 x 和价格 p 的配对 (x, p),并找到最佳逼近它们的线性函数来工作。这相当于找到最接近所有已知 (x, p) 点的 2D 直线的方程。我们的大部分工作都是确定“最接近”这个词应该意味着什么!

在第十五章和第十六章中,我们介绍了一种不同类型的监督学习问题,称为分类。对于任何数值输入数据点,我们希望回答关于它的一个是/否或多项选择题。在第十五章中,我们创建了一个算法,该算法查看两种不同车型的里程数和价格数据,并尝试根据它看到的新数据正确识别汽车型号。同样,这也相当于找到一个函数,它“最接近”训练数据集中的值,我们必须决定对于回答是/否问题的函数,“接近”意味着什么。

在第十六章中,分类问题更难。输入数据集是手绘数字的图像(从 0 到 9),期望的输出将是所绘制的数字。正如我们在第六章中看到的,图像由大量数据组成;我们可以将图像视为存在于具有许多不同维度的向量空间中。为了处理这种复杂性,我们使用一种特殊的数学函数,称为多层感知器。这是一种特定类型的人工神经网络,是目前最被讨论的机器学习算法之一。

虽然你可能不会在读完这三个简短章节后成为机器学习专家,但我希望你能为在主题上的进一步探索打下坚实的基础。具体来说,这些章节应该为你揭开机器学习这个主题的神秘面纱。我们不会神奇地将类似人类的意识注入我们的计算机中。相反,我们将使用 Python 处理现实世界的数据,然后创造性地应用我们迄今为止所看到的数学知识。

14 将函数拟合到数据中

本章涵盖了

  • 测量函数与数据集拟合的紧密程度

  • 探索由常数确定的函数空间

  • 使用梯度下降优化“拟合”质量

  • 使用不同类型的函数建模数据集

在第二部分中你学到的微积分技术需要适用性良好的函数。为了存在导数,函数需要足够平滑,并且为了计算精确的导数或积分,你需要一个具有简单公式的函数。对于大多数现实世界的数据,我们并不这么幸运。由于随机性或测量误差,我们在野外很少遇到完全平滑的函数。在本章中,我们介绍了如何将杂乱的数据用简单的数学函数进行建模−这是一个称为 回归 的任务。

我将带您通过一个真实数据集的例子,这个数据集包括在 CarGraph.com 网站上出售的 740 辆二手车的信息。这些车都是丰田普锐斯,并且都报告了行驶里程和销售价格。将这些数据绘制在散点图上,如图 14.1 所示,我们可以看到随着里程的增加,价格呈下降趋势。这反映了汽车在使用过程中会贬值。我们的目标是找到一个简单的函数来描述二手普锐斯价格随里程增加的变化。

图片

图 14.1 CarGraph.com 上出售的二手丰田普锐斯的售价与里程的对比图

我们无法绘制一个穿过所有这些点的平滑函数,即使我们能够做到,那也是没有意义的。其中许多点是 异常值,可能存在错误(例如,图 14.1 中那些售价低于 5000 美元的几乎全新的汽车)。当然,还有其他因素会影响二手车的再销售价格。我们不应该期望仅凭里程就能准确评估价格。

我们能做的是找到一个函数来近似这些数据的变化趋势。我们的函数 p(x) 以里程 x 作为输入,并返回给定里程的普锐斯的典型价格。为了做到这一点,我们需要对这个函数的类型做出一个 假设。我们可以从最简单的例子开始:一个线性函数。

在第七章中,我们以多种形式探讨了线性函数,但在这章中,我们将以 p(x) = ax + b 的格式来书写这些函数,其中 x 是汽车的行驶里程,p 是其价格,而 ab 是决定函数形状的数字。通过选择 ab,这个函数就是一个想象中的机器,它根据丰田普锐斯的行驶里程预测其价格,如图 14.2 所示。

图片

图 14.2 从里程 x 预测价格 p 的线性函数示意图

记住,a是线的斜率,b是它在零点的值。对于像a = −0.05 和b = 20,000 这样的值,函数的图形变成了一条从 20,000 美元的价格开始,每增加一英里里程就减少 0.05 美元的线(图 14.3)。

图片

图 14.3 基于里程预测普锐斯的价格,使用形式为p(x) = ax + b的函数,其中a = −0.05 和b = 20,000

这种预测函数的选择意味着新普锐斯的价值为 20,000 美元,并且以每英里 0.05 美元的速度折旧或损失价值。这些值可能正确也可能不正确;事实上,我们有理由相信它们并不完美,因为这条线的图形与大多数数据相差甚远。找到ab的值,使得p(x)尽可能好地遵循数据趋势的任务被称为线性回归。一旦我们找到最佳值,我们就可以说p(x)是最佳拟合线。

如果p(x)要接近真实数据,那么斜率a应该是负的,这样随着里程的增加,预测的价格就会下降,这似乎是合理的。然而,我们不必假设这一点,因为我们可以直接从原始数据中实现一个算法来解决这个问题。这就是为什么回归是机器学习算法的一个简单例子;它仅基于数据,推断出趋势,然后对新数据点做出预测。

我们施加的唯一真正约束是,我们的算法寻找线性函数。线性函数假设折旧率是恒定的−即汽车在前 1,000 英里内的价值损失与在 10 万到 10 万 1,000 英里内的价值损失相同。传统智慧认为这不是事实,实际上,汽车一旦离开停车场就会损失很大一部分价值。我们的目标不是找到完美的模型,而是找到一个简单但仍然表现良好的模型。

我们需要做的第一件事是能够衡量一个给定的线性函数(即给定ab的选择)如何预测普锐斯的价格。为此,我们编写一个 Python 函数,称为成本函数,它接受一个函数p(x)作为输入,并返回一个数字,告诉我们它距离原始数据有多远。对于任何一对数字ab,我们都可以使用成本函数来衡量函数p(x) = ax + b如何拟合数据集。对于每一对(a, b),都有一个线性函数,因此我们可以将我们的任务视为探索这种对对的 2D 空间,并评估它们所暗示的线性函数。

图 14.4 显示,选择正的ab值会产生一条向上倾斜的线。如果这是我们的价格函数,那么它将意味着汽车在行驶过程中会增值,这不太可能。

图片

图 14.4 一对数字 (a, b) 定义了一个线性函数,我们可以在图上作为一条线来绘制。对于 a 的正值,图线向上倾斜。

我们的成本函数将此类线与实际数据进行比较,并返回一个大的数字,表示该线远离数据。线越接近数据,成本越低,拟合度越好。

我们希望的是 ab 的值,不仅使成本函数变小,而且使其成为可能的最小函数。我们将编写的第二个主要函数称为 linear_regression,它自动找到这些最佳的 ab 值。这反过来又告诉我们最佳拟合线。为了实现这一点,我们构建一个函数,它告诉我们任何 ab 值的成本,并使用第十二章中提到的梯度下降技术来最小化它。让我们从在 Python 中实现一个成本函数来衡量函数与数据集的拟合程度开始。

14.1 测量函数拟合质量

我们将编写我们的成本函数,使其能够适用于任何数据集,而不仅仅是我们的二手车集合。这允许我们在更简单(虚构的)数据集上测试它,这样我们就可以看到它是如何工作的。考虑到这一点,成本函数是一个接受两个输入的 Python 函数。其中一个是我们要测试的 Python 函数 f(x),另一个是要测试的数据集,即 (x, y) 对的集合。对于二手车示例,我们的 f(x) 可能是一个线性函数,它为任何里程数提供美元成本,而 (x, y) 对是数据集中里程和价格的实际值。

成本函数的输出是一个单一的数字,衡量 f(x) 的值与正确的 y 值之间的距离。如果 y = f(x),对于每一个 x,函数是数据的完美拟合,因此成本函数返回零。更现实的情况是,函数不会与所有数据点完全一致,它将返回一些正数。我们实际上将编写两个成本函数来比较它们,并给你一个成本函数是如何工作的感觉:

  • sum_error − 将数据集中每个 (x, y) 值的 f(x) 到 y 的距离相加

  • sum_square_error − 将这些距离的平方相加

这其中第二个是实践中最常用的成本函数,你很快就会明白原因。

14.1.1 从函数测量距离

在本书的源代码中,你可以找到一个名为 test_data 的虚构数据集。它是一个 (x, y) 值的 Python 列表,其中 x 值从 -1 到 1。我故意选择了 y 值,使得点靠近线 f(x) = 2x。图 14.5 显示了 test_data 数据集的散点图,并显示在那条线旁边。

图 14.5 一组故意靠近线 f(x) = 2x 的随机生成数据

事实是 f(x) = 2x 与数据集保持接近,意味着对于数据集中的任何 x 值,2x 是对应 y 值的一个相当好的猜测。例如,这个点

(x, y) = (0.2, 0.427)

是数据集中的实际值。仅给定 x = 0.2 的值,我们的 f(x) = 2x 会预测 y = 0.4。|f(0.2) − 0.4| 的绝对值告诉我们这个误差的大小,大约是 0.027。

误差值,即实际 y 值与函数 f(x) 预测的值之间的差异,可以想象为实际 (x, y) 点到 f 图像的垂直距离。图 14.6 显示了误差距离被绘制为垂直线。

图片

图 14.6 误差值是函数 f(x) 和实际 y 值之间的差异。

这些误差中有些比其他的小,但我们如何量化拟合的质量?让我们将此与函数 g(x) = 1 − x 的图像进行比较,它显然是一个不好的拟合(图 14.7)。

图片

图 14.7 展示了具有较大误差值的函数

我们的功能,g(x) = 1 − x,恰好接近一个点,但误差总和要大得多。因此,我们可以通过添加所有误差来编写我们的第一个成本函数。误差总和越大,拟合越差,而值越低,拟合越好。要实现此功能,我们只需遍历 (x, y) 对,取 f(x) 和 y 之间差异的绝对值,并求和结果:

def sum_error(f,data):
    errors = [abs(*f*(*x*) − y) for (x,y) in data]
    return sum(errors)

要测试这个函数,我们可以将我们的 f(x) 和 g(x) 转换为代码:

def f(x): 
    return 2*x

def *g*(*x*): 
    return 1-x

如预期,f(x) = 2x 的总误差低于 g(x) = 1 − x

>>> sum_error(f,test_data)
5.021727176394801
>>> sum_error(g,test_data)
38.47711311130152

这些输出的确切值并不重要;重要的是它们之间的比较。因为 f(x) 的误差总和低于 g(x),我们可以得出结论,f(x) 是给定数据的更好拟合。

14.1.2 误差平方和

sum_error 函数可能是衡量数据点到直线距离的最明显方式,但在实践中,我们将使用一个求和所有误差平方的成本函数。这样做有几个很好的理由。最简单的是,平方距离函数是光滑的,因此我们可以使用导数来最小化它,而绝对值函数不是光滑的,所以我们不能在所有地方取其导数。记住函数 |x| 和 x² 的图像(图 14.8),两者在 x 离 0 更远时都返回更大的值,但只有后者在 x = 0 处是光滑的,并且在该处有导数。

图片

图 14.8 y = |x| 的图像在 x = 0 处不光滑,但 y = x² 的图像是光滑的。

给定一个测试函数f(x),我们可以查看每个(x, y)对,并将(f(x) − y)²的值添加到成本中。sum_squared_error函数就是这样做的,它的实现与sum_error没有太大区别。我们只需要平方误差而不是取其绝对值:

def sum_squared_error(f,data):
    squared_errors = [(f(x) − y)**2 for (x,y) in data]
    return sum(squared_errors)

我们还可以可视化这个成本函数。我们不是通过观察点与函数图像之间的垂直距离来观察这些距离,我们可以将这些距离视为正方形的边缘。每个正方形的面积是该数据点的平方误差,所有正方形的总面积是sum_squared_error的结果。图 14.9 中正方形的总面积显示了test_dataf(x) = 2x之间的平方误差之和。(注意,由于 x 轴和 y 轴有不同的刻度,这些正方形看起来并不像正方形!)

图 14.9 展示函数与数据集之间平方误差之和的图像

图 14.9 中的y值,其距离图中的图形是图 14.9 的两倍,通过四倍的因子对平方误差总和做出贡献。选择这个成本函数的一个原因是它更严厉地惩罚不良拟合。对于h(x) = 3x,你可以看到正方形相当大(图 14.10)。

图 14.10 相对于测试数据展示 h(x) = 3xsum_squared_error

对于g(x) = 1 − x,绘制平方误差是不值得的,因为正方形太大,几乎填满了整个图表区域,并且相互重叠很大。尽管如此,你可以看到,对于f(x)和g(x),sum_squared_error的值差异甚至比sum_error的差异更剧烈:

>>> sum_squared_error(f,test_data)
2.105175107540148
>>> sum_squared_error(g,test_data)
97.1078879283203

图 14.8 中的y = x²的图像非常平滑,而且如果你通过改变定义它的参数ab来移动这条线,成本函数也会“平滑”地改变。因此,我们将继续使用sum_squared_error成本函数。

14.1.3 计算汽车价格函数的成本

我将首先对普锐斯随着行驶里程的增加而贬值的情况进行一个有根据的猜测。丰田普锐斯有多种不同的型号,但我猜测平均零售价格约为$25,000。为了简化计算,我们的第一个、直观的模型假设它们在行驶了 125,000 英里后仍保持在道路上,之后它们的市值正好为$0。这意味着汽车的平均折旧率为每英里$0.20。这意味着以里程x为条件的普锐斯价格p可以通过从起始价格$25,000 中减去 0.2x美元的折旧来计算,这意味着p(x)是一个线性函数,因为它具有熟悉的形式,p(x) = ax + b,其中a = −0.2 和b = 25,000:

p(x) = −0.2x + 25,000

让我们通过将其与 CarGraph 数据(图 14.11)并排放置来查看这个函数在图上的样子。你可以在本章的源代码中找到这些数据和绘制它的 Python 代码。

图 14.11 使用我假设的折旧函数的二手普锐斯价格与里程的散点图

很明显,数据集中许多汽车已经超过了我对 125,000 英里的猜测。这意味着我对折旧率的猜测可能太高了。让我们尝试每英里 0.10 美元的折旧率,这意味着定价函数:

p(x) = −0.10x + 25,000

这也不是完美的。我们可以从图 14.12 中的图表中看到,这个函数高估了大多数汽车的价格。

图 14.12 绘制一个假设每英里折旧$0.10 的不同函数

我们还可以实验起始值,我们假设它是$25,000。根据经验,一辆车一旦离开停车场就会失去很大一部分价值,所以对于行驶里程很少的二手车来说,$25,000 可能是一个高估。如果一辆车在离开停车场时价值下降了 10%,那么零里程时的$22,500 可能给我们更好的结果(图 14.13)。

图 14.13 测试二手丰田普锐斯的起始价值 $22,500

我们可以花很多时间猜测最适合数据的最佳线性函数,但要看看我们的猜测是否在改进,我们需要使用成本函数。使用sum_squared_error函数,我们可以衡量我们的有根据的猜测哪一个最接近数据。以下是三个定价函数转换为 Python 代码:

def p1(x):
    return 25000 − 0.2 * x

def p2(x):
    return 25000 − 0.1 * x

def p3(x):
    return 22500 − 0.1 * x

sum_squared_error函数接受一个函数以及代表数据的数字对列表。在这种情况下,我们想要里程和价格的对:

prius_mileage_price = [(p.mileage, p.price) for p in priuses]

使用sum_squared_error函数对三个定价函数中的每一个进行计算,我们可以比较它们与数据的拟合质量:

>>> sum_squared_error(p1, prius_mileage_price)
88782506640.24002
>>> sum_squared_error(p2, prius_mileage_price)
34723507681.56001
>>> sum_squared_error(p3, prius_mileage_price)
22997230681.560013

这些是一些很大的数值,分别是大约 8870 亿、3470 亿和 2290 亿。再次强调,数值本身并不重要,重要的是它们的相对大小。因为最后一个数值最低,我们可以得出结论,p3是三个定价函数中最好的。考虑到我在构造这些函数时是多么的不科学,我似乎可以继续猜测并找到一个使成本更低的线性函数。与其猜测和检查,我们将探讨如何系统地探索可能的线性函数空间。

14.1.4 练习

| 练习 14.1:创建一组位于直线上的数据点,并证明sum_errorsum_squared_error成本函数对于适当的线性函数都返回正好为零。解答:这是一个线性函数和一些位于其图形上的点:

def line(*x*):
    return 3*x−2
points = [(x,line(*x*)) for *x* in range(0,10)]

Both sum_error(line,points) and sum_squared_error(line,points)返回零,因为没有任何一个点到直线的距离。|

| 练习 14.2:计算两个线性函数,x + 0.5 和 2x − 1 的成本值。哪一个相对于 test_data 产生更低的平方和误差,这说明了拟合质量如何?解答

>>> sum_squared_error(lambda x:2*x−1,test_data)
23.1942461283472
>>> sum_squared_error(lambda x:x+0.5,test_data)
16.607900877665685

函数 x + 0.5 产生的 sum_squared_error 值更低,因此它更适合 test_data。|

| 练习 14.3:找到一个比 p1p2p3 更好的线性函数 p4。通过显示成本函数比 p1p2p3 更低来证明它是更好的拟合。解答:我们迄今为止找到的最佳拟合是 p3,表示为 p(x) = 22,500 − 0.1 · x。为了得到更好的拟合,你可以尝试调整这个公式中的常数,直到成本降低。你可能观察到 p3 是更好的拟合,因为我们把 b 值从 25,000 降低到 22,500。如果我们进一步降低它,拟合会更好。如果我们定义一个新的函数 p4,其 b 值为 20,000

def p4(*x*):
    return 20000 − 0.1 * x

结果表明 sum_squared_error 甚至更低:

>>> sum_squared_error(p4, prius_mileage_price)
18958453681.560005

这比前三个函数的值都要低,表明它更适合数据。|

14.2 探索函数空间

我们在上一个部分通过猜测一些定价函数的形式 p(x) = ax + b 结束,其中 x 代表二手丰田普锐斯的里程数,而 p 是其价格的预测。通过选择不同的 ab 值并绘制结果函数 p(x),我们可以判断哪些选择比其他选择更好。成本函数为我们提供了一种衡量函数接近数据程度的方法,而不是仅仅凭肉眼判断。本节的目标是系统地尝试不同的 ab 值,以使成本函数尽可能小。

如果你完成了 14.1 节的最后一个练习并手动搜索更好的拟合,你可能已经注意到挑战的一部分是同时调整 ab。如果你还记得第六章,所有像 p(x) = ax + b 这样的函数构成一个 2D 向量空间。当你猜测和检查时,你在这个 2D 空间中盲目地选择各个方向上的点,并希望成本函数降低。

在本节中,我们将通过绘制关于参数 absum_squared_error 成本函数图来尝试理解可能的线性函数的 2D 空间。具体来说,我们可以绘制成本作为两个参数 ab 的函数,这两个参数定义了 p(x) 的选择(图 14.14)。

我们将要绘制的实际函数接受两个数字,ab,并返回一个数字,即函数 p(x) = ax + b 的成本。我们称这个函数为 coefficient_cost(a,b),因为数字 ab系数。为了绘制这样的函数,我们使用类似于第十二章中的热图。

图 14.14 显示了一对数字 (a, b) 定义了一个线性函数。将其与固定实际数据比较,产生一个单一的成本数值。

图片

图 14.15 不同斜率 a 值的线和对应的成本

作为热身,我们可以尝试将函数 f(x) = ax 拟合到之前使用的 test_data 数据集。这是一个更简单的问题,因为 test_data 没有那么多数据点,而且我们只有一个参数需要调整:f(x) = ax 是一个线性函数,其 b 的值固定为零。这种形式函数的图表是通过原点的一条线,系数 a 控制其斜率。这意味着只有一个维度需要探索,我们可以绘制平方和误差值与 a 值的普通函数图。

14.2.1 通过原点的线的成本图示

让我们使用之前相同的 test_data 数据集,并从形式为 f(x) = ax 的函数中计算 sum_squared_error。然后我们可以编写一个函数 test_data_coefficient_cost,它接受参数 a(斜率)并返回 f(x) = ax 的成本。为此,我们首先从输入 a 的值创建函数 f,然后我们可以将其和测试数据传递给 sum_squared_error 成本函数:

def test_data_coefficient_cost(a):
    def f(x):
        return a * x
    return sum_squared_error(f,test_data)

这个函数的每个值对应于斜率 a 的一个选择,因此,它告诉我们可以绘制在 test_data 旁边的线的成本。图 14.15 展示了几个 a 值及其对应线的散点图。我特别指出了斜率为 a = −1 的情况,这会产生最高的成本和最差的拟合线。

test_data_coefficient_cost 函数最终证明是一个平滑的函数,我们可以在一系列 a 值的范围内绘制它。图 14.16 显示,成本逐渐降低,直到在 a ≈ 2 处达到最小值,然后开始增加。

图片

图 14.16 成本与斜率 a 的图表,显示了不同斜率值的拟合质量

图 14.16 中的图表告诉我们,通过原点产生的成本最低的线,因此,是 最佳拟合,其斜率大约为 2(我们很快就会找到确切值)。为了找到最佳线性函数来拟合二手车数据,让我们看看在一个多一个维度的空间上的成本。

14.2.2 所有线性函数的空间

我们正在寻找一个函数 p(x) = ax + b,它通过 sum_squared_error 函数最接近地预测普锐斯的售价。为了评估系数 ab 的不同选择,我们首先需要编写一个函数 coefficient_cost(a,b),它给出 p(x) = ax + b 相对于汽车数据的平方和误差。这看起来像 test_data_coefficient_cost 函数,除了有两个参数,我们使用不同的数据集:

def coefficient_cost(a,b):
    def *p*(*x*):
        return a * *x* + b
    return sum_squared_error(p,prius_mileage_price)

现在,有一个二维空间,包含系数对 (a, b) 的组合,其中任何一个都能给我们一个不同的候选函数 p(x) 来与价格数据进行比较。图 14.17 显示了 a, b 平面上的两个点以及相应的图形上的线条。

图 14.17 不同的 (a, b) 数字对对应不同的价格函数

对于每一对 (a, b) 和相应的函数 p(x) = ax + b,我们可以计算 sum_squared_error 函数;这正是 coefficient_cost 函数为我们一步完成的事情。这为我们提供了 a, b 平面上每个点的成本值,我们可以将其绘制为热图(图 14.18)。

图 14.18 线性函数的成本作为 a 和 b 值的热图

在这个热图中,你可以看到当 (a, b) 处于极端时,成本函数较高。热图在中间部分最暗,但视觉上并不清楚成本是否有最小值或确切的位置。幸运的是,我们有一种方法可以找到在 (a, b) 平面上成本函数最小化的位置−梯度下降。

14.2.3 练习

练习 14.4:找到通过原点并通过一点 (3, 4) 的直线的确切公式。通过找到函数 f(x) = ax,该函数相对于这个一点数据集最小化平方误差。解答:我们需要找到一个系数,即 a。平方误差之和是 f(3) = a · 3 和 4 之间的平方差。这是 (3 a − 4)²,展开为 9 a² − 24 a + 16。我们可以将其视为关于 a 的成本函数,即 c(a) = 9 a² − 24 a + 16。a 的最佳值是使成本函数的导数为零的值。使用第十章中的导数规则,我们找到 c'(a) = 18 a − 24。当 a = 4/3 时,这被解决,这意味着我们的最佳拟合线是。这显然包含原点和点 (4, 3)。
练习 14.5:假设我们使用线性函数来模拟跑车与其里程数的关系,系数为 (a, b) = (−0.4, 80000)。用英语来说,这说明了汽车随时间贬值的情况?解答:当 x = 0 时,ax + b 的值只是 b = 80,000。这意味着在里程数为 0 时,我们预计汽车可以卖 80,000 美元。a 的值为 −0.4 表示,对于 x 的每增加一个单位,函数值 ax + b 会以 0.4 个单位的速率减少。这意味着汽车的价值平均每行驶一英里就会减少 40 美分。

14.3 使用梯度下降法找到最佳拟合线

在第十二章中,我们使用了梯度下降算法来最小化形式为 f(x, y) 的光滑函数。用更简单的话说,这意味着找到 xy 的值,使得 f(x, y) 的值尽可能小。因为我们已经实现了 gradient_descent 函数,所以我们可以简单地将我们想要最小化的函数的 Python 版本传递给它,它将自动找到最小化它的输入。

现在,我们想要找到 ab 的值,使得 p(x) = ax + b 的成本尽可能小,换句话说,最小化 Python 函数 coefficient _cost(a,b)。当我们把 coefficient_cost 插入到我们的 gradient_descent 函数中时,我们得到 (a, b) 这一对值,使得 p(x) = ax + b 是最佳拟合线。我们可以使用我们找到的 ab 的值来绘制 ax + b 的线,并直观地确认它很好地拟合了数据。

14.3.1 数据重缩放

在应用梯度下降之前,我们还需要处理一个棘手的细节。我们一直在处理的数据有不同的规模:折旧率在 0 和 -1 之间,价格在数万之间,成本函数返回的结果在数千亿。如果我们不指定其他,我们的导数近似将使用 10^(-6) 的 dx 值。因为这些数字的量级差异很大,如果我们尝试以这种方式运行梯度下降,我们可能会遇到数值错误。

注意,我不会深入探讨出现的数值问题的细节;我的目标是向您展示如何应用数学概念,而不是编写健壮的数值代码。相反,我将只向您展示如何通过重塑我们使用的数据来解决这个问题。

根据我们对数据集的直觉,我们可以确定 ab 的某些保守界限,从而产生最佳拟合线。a 的值代表折旧,所以最佳值可能大于 0.5 或每英里 50 美分。b 的值代表零里程的普锐斯车的价格,应该安全地低于 50,000 美元。

如果我们通过 a = 0.5 · cb = 50,000 · d 定义新的变量 cd,那么当 cd 的大小小于 1 时,ab 应该分别小于 0.5 和 50,000。对于小于这些值的 ab 的值,成本函数不会超过 10¹³。如果我们把成本函数的结果除以 10¹³ 并用 cd 表示,我们就得到了一个新的成本函数版本,其输入和输出所有绝对值都在零和一之间:

def scaled_cost_function(c,d):
    return coefficient_cost(0.5*c,50000*d)/1e13

如果我们找到最小化这个缩放成本函数的 cd 的值,我们可以找到最小化原始函数的 ab 的值,使用的事实是 a = 0.5 · cb = 50,000 · d

这是一种相当简化的方法,还有更多科学的方法可以缩放数据,使其更易于数值处理,其中之一我们将在第十五章中看到。如果您想了解更多,通常在机器学习文献中称为 特征缩放 的过程。现在,我们已经得到了我们需要的−一个可以插入梯度下降算法的函数。

14.3.2 寻找和绘制最佳拟合线

我们将要优化的函数是 scaled_cost_function,我们可以预期最小值出现在一个点 (c, d),其中 |c| < 1 和 |d| < 1。因为最优的 cd 与原点相当接近,我们可以从 (0,0) 开始梯度下降。以下代码找到最小值,但具体运行时间取决于您使用的机器类型:

c,d = gradient_descent(scaled_cost_function,0,0)

当它运行时,它会找到以下 cd 的值:

>>> (c,d)
(−0.12111901781176426, 0.31495422888049895)

为了恢复 ab,我们需要乘以相应的系数:

>>> xa = 0.5*c
>>> b = 50000*d
>>> (a,b)
(−0.06055950890588213, 15747.711444024948)

最后,我们找到了我们一直在寻找的系数!四舍五入后,我们可以说价格函数

p(x) = −0.0606 · x + 15,700

这是(近似地)在整个汽车数据集上最小化平方误差的线性函数。它意味着一辆零里程的丰田普锐斯的价格平均为 15,700 美元,普锐斯的价格平均贬值率略超过每英里 6 美分。图 14.19 显示了这条线在图上的样子。

图 14.19 汽车价格数据的最佳拟合线

这看起来至少与其他我们尝试过的线性函数 p1, p2, 和 p3 一样好,如果不是更好。我们可以确信,根据成本函数衡量,它对我们的数据拟合得更好:

>>> coefficient_cost(a,b)
14536218169.403479

自动找到最佳拟合线,最小化成本函数后,我们可以说我们的算法“学会了”如何根据里程来评估普锐斯,我们实现了本章的主要目标。

有许多方法可以计算线性回归以获得最佳拟合线,包括许多优化的 Python 库。无论采用哪种方法,它们都应该得到相同的线性函数,该函数最小化平方误差之和。我选择使用梯度下降法来采用这种方法,这不仅是因为它是我们在第一部分和第二部分中覆盖的许多概念的出色应用,而且还因为它具有高度的可推广性。在最后一节中,我将向您展示梯度下降法在回归中的另一个应用,我们将在接下来的两个章节中也会使用梯度下降法和回归。

14.3.3 练习

| 练习 14.6: 使用梯度下降法找到最佳拟合测试数据的线性函数。您得到的结果函数应该接近 2x + 0,但不是完全一样,因为数据是围绕该线随机生成的。解答: 首先,我们需要编写一个函数来计算 f(x) = ax + b 相对于测试数据中系数 ab 的成本:

def test_data_linear_cost(a,b):
    def f(x):
        return a*x+b
    return sum_squared_error(f,test_data)

使这个函数最小化的 ab 的值给我们提供了最佳拟合的线性函数。我们预计 ab 分别接近 2 和 0,因此我们可以围绕这些点绘制一个热图来理解我们正在最小化的函数:

scalar_field_heatmap(test_data_linear_cost,-0,4,−2,2)

图片相对于测试数据,ax + b 的成本作为 ab 的函数 |

| 如预期,在 (a, b) = (2,0) 附近,这个成本函数似乎有一个最小值。使用梯度下降法最小化这个函数,我们可以找到确切的值:

>>> gradient_descent(test_data_linear_cost,1,1)
(2.103718204728344, 0.0021207385859157535)

这意味着最佳拟合线到测试数据的大致方程是 2.10372 · x + 0.00212。 |

14.4 拟合非线性函数

在我们迄今为止所做的工作中,没有一步是 需要 价格函数 p(x) 为线性的。线性函数是一个好的选择,因为它简单,但我们可以用任何由两个常数定义的单变量函数应用相同的方法。作为一个例子,让我们找到形式为 p(x) = qe^(rx) 的最佳拟合指数函数,并最小化相对于汽车数据的平方误差之和。在这个方程中,e 是特殊的常数 2.71828……,我们将找到 qr 的值,以给出最佳拟合。

14.4.1 理解指数函数的行为

如果你已经有一段时间没有处理指数函数了,让我们快速回顾一下它们是如何工作的。你可以通过当自变量 x 在指数中时,识别一个函数 f(x) 为指数函数。例如,f(x) = 2x 是一个指数函数,而 f(x) = x² 则不是。实际上,f(x) = 2x 是最熟悉的指数函数之一。在 x 的每个整数处,2x 的值是 2 乘以自身 x 次。表 14.1 给出了 2x 的一些值。

表 14.1 熟悉的指数函数 2x 的值

x 0 1 2 3 4 5 6 7 8 9
2x 1 2 4 8 16 32 64 128 256 512

被提升到 x 次方的数被称为底数,所以对于 2x 的情况,底数是 2。如果底数大于 1,当 x 增加时函数增加,但如果它小于 1,当 x 增加时函数减少。例如,在 (½)x 中,每个整数值是前一个值的一半,如表 14.2 所示。

表 14.2 减少指数函数 (½)x 的值

x 0 1 2 3 4 5 6 7 8 9
(½)x 1 0.5 0.25 0.125 ~0.06 ~0.03 ~0.015 ~0.008 ~0.004 ~0.002

这被称为指数衰减,它更符合我们汽车折旧模型的需求。指数衰减意味着函数的值在固定大小的每个 x 间隔内按相同的比例减少。一旦我们有了模型,它就可以告诉我们,普锐斯每行驶 50,000 英里就会损失一半的价值,这意味着在 100,000 英里时,它的价值是其原始价格的 ¼,依此类推。

直观上看,这可能是一种更好的折旧建模方式。丰田车,作为可靠且耐用的汽车,只要能驾驶就会保留一些价值。相比之下,我们的线性模型表明,它们的值在长时间后会变成负数(图 14.20)。

图片图片

图 14.20 线性模型预测普锐斯的值为负,而指数模型在任何里程数下都显示正值。

我们可以使用的指数函数形式是 p(x) = qe^(rx),其中 e = 2.71828 . . . 是固定的底数,rq 是我们可以调整的系数。(使用底数 e 可能看起来是任意的,甚至不方便,但 ex 是标准的指数函数,所以值得习惯。)在指数衰减的情况下,r 的值是负的。因为 er^(·0) = e⁰ = 1,我们有 p(0) = qer^(·0) = q,所以 q 仍然代表零里程的普锐斯价格。常数 r 决定了折旧率。

14.4.2 寻找最佳拟合的指数函数

在心中牢记公式 p(x) = qe^(rx),我们可以使用前几节中的方法来找到最佳拟合的指数函数。第一步是编写一个函数,它接受系数 qr 并返回相应函数的成本:

def exp_coefficient_cost(q,r):
    def f(x):
        return q*exp(r*x)                           ❶
    return sum_squared_error(f,prius_mileage_price)

❶ Python 的 exp 函数计算指数函数 ex。

我们接下来需要做的是为系数 qr 选择一个合理的范围,分别设置起始价格和折旧率。对于 q,我们期望它接近我们线性模型中 b 的值,因为 qb 都代表零里程的汽车价格。我将使用从 $0 到 $30,000 的范围以确保安全。

控制折旧率的 r 值理解起来有点复杂,并且需要设定限制。具有负 r 值的方程 p(x) = qe^(rx) 表明,每当 x 增加 -1/r 单位时,价格就会以 e因子 减少,这意味着它乘以 1/ e 或大约 0.36。(我在本节末尾添加了一个练习,帮助你确信这一点!)

为了保守起见,让我们假设一辆车在行驶了 10,000 英里后,价格降低到原始价值的 1/ e,即 36%。这将给我们 r = 10^(−4)。较小的 r 值意味着更慢的折旧率。这些基准量级告诉我们如何重新缩放函数,如果我们除以 10¹¹,成本值也会保持较小。以下是缩放成本函数的实现,图 14.21 显示了其输出的热图:

def scaled_exp_coefficient_cost(s,t):
    return exp_coefficient_cost(30000*s,1e−4*t) / 1e11

scalar_field_heatmap(scaled_exp_coefficient_cost,0,1,−1,0)

图片

图 14.21 成本作为重新缩放后的 qr 值的函数,分别称为 st

图 14.21 中热力图顶部的深色区域表明,在t的小值和 0 到 1 范围内的某个值s处,成本最低。我们准备好将缩放的成本函数插入梯度下降算法中。梯度下降函数的输出是使成本函数最小化的st值,我们可以取消缩放以得到qr

>>> s,t = gradient_descent(scaled_exp_coefficient_cost,0,0)
>>> (s,t)
(0.6235404892859356, -0.07686877731125034)
>>> q,r = 30000*s,1e−4*t
>>> (q,r)
(18706.214678578068, −7.686877731125035e-06)

这意味着,从里程数来预测普锐斯价格的最佳指数函数大约是

p(x) = 18,700 · e^(−0.00000768) · x

图 14.22 显示了实际价格数据的图表。

图片

图 14.22 普锐斯和其里程数的最佳拟合指数函数

你可以争辩说,这个模型甚至比我们的线性模型更好,因为它产生了更小的总和平方误差,这意味着根据成本函数,它(略微)更好地拟合了数据:

>>> exp_coefficient_cost(q,r)
14071654468.28084

使用像指数函数这样的非线性函数只是这种回归技术众多变化中的一种。我们可以使用其他非线性函数,例如定义超过两个常数的函数或数据拟合超过 2 维的函数。在接下来的两章中,我们将继续使用成本函数来衡量回归模型的质量,然后使用梯度下降法使拟合尽可能好。

14.4.3 练习

| 练习 14.7:通过选择一个样本值r,确认当x增加 1/r单位时,e^(−rx)的值会减少一个因子e解答**:让我们取r* = 3,因此我们的测试函数是e^(−3x)。我们想要确认每次x增加 1/3 时,这个函数会减少一个因子。将函数在 Python 中定义为以下内容

def test(x):
    return exp(−3*x)

我们可以看到,它在x = 0 时从 1 的值开始,并且每次x增加 1/3 时,都会减少一个因子e

>>> test(0)
1.0
>>> from math import e
>>> test(1/3), test(0)/e
(0.36787944117144233, 0.36787944117144233)
>>> test(2/3), test(1/3)/e
(0.1353352832366127, 0.1353352832366127)
>>> test(1), test(2/3)/e
(0.049787068367863944, 0.04978706836786395)

在这些情况中,将test的输入增加 1/3 得到的结果与将前一个结果除以e相同。|

| 练习 14.8:根据最佳拟合的指数函数,每行驶 10,000 英里,普锐斯的价值会损失多少百分比?解答:价格函数是p(x) = 18,700 · e^(−0.00000768) · x,其中值q = $18,700 代表初始价格,而不是价格下降的速度。我们可以关注erx = e^(−0.00000768) · x这一项,并查看它在 10,000 英里内的变化。对于x = 0,这个表达式的值是 1,而对于x = 10,000,这个值是

>>> exp(r * 10000)
0.9422186306357088

这意味着,在行驶了 10,000 英里之后,普锐斯的价值仅为原始价值的 94.2%,下降了 5.8%。鉴于指数函数的行为,这种情况将在任何10,000 英里里程增加的情况下发生。|

| 练习 14.9:断言零售价格(零英里处的价格)为$25,000,什么是最适合数据的指数函数?换句话说,固定q = 25,000,什么值r能产生q·e^(rx)的最佳拟合?解答:我们可以编写一个单独的函数,该函数以单个未知系数r为条件给出指数函数的代价:

def exponential_cost2(r):
    def f(x):
        return 25000 * exp(r*x)
    return sum_squared_error(f,prius_mileage_price)

以下图表确认了存在一个介于−10^(−4)和 0 之间的r值,该值最小化了代价函数:

plot_function(exponential_cost2,−1e−4,0)

图像它看起来像r = −10^(−5)的近似值最小化了代价函数。为了自动最小化这个函数,我们需要编写梯度下降的一维版本或使用另一个最小化算法。如果你喜欢,可以尝试这种方法,但由于只有一个参数,我们可以简单地猜测并检查r = −1.12 · 10^(−5)是否是产生最小代价的r值。这表明最佳拟合函数是p(x) = 25,000 · e^(−0.0000112) · x。以下是新的指数拟合图,与原始价格数据一起绘制:图像 |

摘要

  • 回归是找到一个模型来描述各种数据集之间关系的过程。在本章中,我们使用线性回归来近似汽车的价格,将其里程作为线性函数。

  • 对于一组许多(x, y)数据点,可能没有一条线能穿过所有这些点。

  • 对于一个建模数据的函数f(x, y),你可以通过计算指定点(x, y)处的f(x)和y之间的距离来衡量它接近数据的程度。

  • 测量一个模型如何适应数据集的函数被称为代价函数。一个常用的代价函数是从(x, y)点到相应模型值f(x)的距离平方和。最适合数据的函数具有最低的代价函数。

  • 考虑形式为f(x)的线性函数,每一对系数(a, b)定义了一个唯一的线性函数。存在一个这样的系数对的二维空间,因此也存在一个二维空间来探索线条。

  • 编写一个函数,该函数接受一对系数(a, b)并计算ax + b的代价,得到一个函数,它接受一个二维点并返回一个数字。最小化这个函数给出了定义最佳拟合线的系数。

  • 与线性函数p(x)在x的常数变化下增加或减少一个常数量不同,指数函数在x的常数变化下按一个常数比率增加或减少。

  • 要将指数方程拟合到数据中,你可以遵循与线性方程相同的程序;你需要找到一对(q, r),它产生一个指数函数q·e^(rx),并使代价函数最小化。

15 使用逻辑回归对数据进行分类

本章涵盖

  • 理解分类问题和衡量分类器

  • 寻找决策边界以对两种数据进行分类

  • 使用逻辑函数近似分类数据集

  • 为逻辑回归编写成本函数

  • 执行梯度下降以找到最佳拟合的逻辑函数

机器学习中最重要的问题类别之一是分类,我们将在这本书的最后两章中重点关注。分类问题是指我们有一份或多份原始数据,我们想要说明每一份数据代表的是哪种类型的对象。例如,我们可能希望一个算法查看进入我们收件箱的所有电子邮件的数据,并将每一封电子邮件分类为有趣的邮件或不受欢迎的垃圾邮件。作为一个更有影响力的例子,我们可以编写一个分类算法来分析医学扫描数据集,并决定它们是否包含良性或恶性的肿瘤。

我们可以构建机器学习算法进行分类,我们的算法看到的真实数据越多,它学到的就越多,在分类任务上的表现就越好。例如,每次电子邮件用户将电子邮件标记为垃圾邮件或放射科医生识别出恶性肿瘤时,这些数据都可以反馈给算法以改进其校准。

在本章中,我们将查看与上一章相同的简单数据集:二手车的里程和价格。与上一章使用单一车型数据不同,我们将查看两种车型:丰田普锐斯和宝马 5 系列轿车。仅基于车辆的里程和价格等数值数据以及已知示例的参考数据集,我们希望我们的算法能够给出一个肯定或否定的答案,即该车辆是否为宝马。与接受一个数字并产生另一个数字的回归模型不同,分类模型将接受一个向量并产生一个介于零和一之间的数字,表示该向量代表宝马而不是普锐斯的置信度(图 15.1)。

图 15.1 我们的分类器接受一个包含两个数字的向量,即二手车的里程和价格,并返回一个表示其对车辆是宝马的置信度的数字。

尽管分类与回归的输入和输出不同,但结果表明我们可以使用回归的一种类型来构建我们的分类器。本章我们将实现的算法称为逻辑回归。为了训练这个算法,我们从一个已知的数据集开始,该数据集包含二手车的里程和价格,如果它们是宝马则标记为 1,如果是普锐斯则标记为 0。表 15.1 显示了用于训练我们的算法的该数据集中的样本点。

表 15.1 用于训练算法的样本数据点

里程(英里) 价格(美元) 是否为宝马?
110,890.0 13,995.00 1
94,133.0 13,982.00 1
70,000.0 9,900.00 0
46,778.0 14,599.00 1
84,507.0 14,998.00 0
. . . . . . . . .

我们希望一个函数能够接受前两列的值,并产生一个介于零和一之间的结果,并且希望这个结果尽可能接近正确的汽车选择。我将向你介绍一种特殊类型的函数,称为逻辑函数,它接受一对输入数字并产生一个始终介于零和一之间的单个输出数字。我们的分类函数是“最佳拟合”我们提供的样本数据的逻辑函数。

我们的分类函数并不总是能得到正确的答案,但同样,人类也不总是能得到正确的答案。宝马 5 系列轿车是豪华车,所以我们预计,与宝马相比,普锐斯的售价会低一些。出乎我们的意料,表 5.1 中的最后两行显示,普锐斯和宝马的价格大致相同,而普锐斯的里程几乎是宝马的两倍。由于这样的意外例子,我们不会期望逻辑函数对每个宝马或普锐斯都能产生精确的 1 或 0。相反,它可以返回 0.51,这是函数告诉我们它不确定,但数据稍微更有可能代表宝马。

在上一章中,我们了解到我们选择的线性函数是由公式 f(x) = ax + b 中的两个参数 ab 决定的。在本章中,我们将使用的逻辑函数由三个参数参数化,因此逻辑回归的任务可以归结为找到三个数字,使逻辑函数尽可能接近提供的样本数据。我们将为逻辑函数创建一个特殊的成本函数,并使用梯度下降法找到最小化成本函数的三个参数。这里有很多步骤,但幸运的是,它们都与我们在上一章中做的事情平行,所以如果你是第一次学习回归,这将是一个有用的复习。

将逻辑回归算法编码为分类汽车是本章的重点,但在做这件事之前,我们花更多的时间让你熟悉分类的过程。在我们训练计算机进行分类之前,让我们衡量我们能够完成这个任务的程度。然后,一旦我们构建了逻辑回归模型,我们可以通过比较来评估它的表现。

15.1 在实际数据上测试分类函数

让我们看看我们如何能够使用一个简单的标准来识别数据集中的宝马。也就是说,如果一辆二手车的价格高于 25,000 美元,那么它可能太贵了,不能是普锐斯(毕竟,你可以以接近这个价格买到一辆全新的普锐斯)。如果价格高于 25,000 美元,我们将说它是宝马;否则,我们将说它是普锐斯。这个分类很容易构建为一个 Python 函数:

def bmw_finder(mileage,price):
    if price > 25000:
        return 1
    else:
        return 0

这个分类器的性能可能不会很好,因为可以想象,行驶里程很多的宝马可能售价低于 25,000 美元。但我们不必猜测:我们可以衡量这个分类器在实际数据上的表现如何。

在本节中,我们通过编写一个名为 test_classifier 的函数来衡量我们算法的性能,该函数接受一个分类函数(如 bmw_finder)以及要测试的数据集。数据集是一个包含里程数、价格和 10 的元组数组,表示汽车是宝马还是普锐斯。一旦我们用真实数据运行 test_classifier 函数,它将返回一个百分比值,告诉我们它正确识别了多少辆车。在章节末尾,当我们实现了逻辑回归时,我们可以将我们的逻辑分类函数传递给 test_classifier 并查看其相对性能。

15.1.1 加载汽车数据

如果我们首先加载汽车数据,编写 test_classifier 函数会更容易。而不是在从 CarGraph.com 或从平面文件加载数据上浪费时间,我已经通过在本书的源代码中提供一个名为 cardata.py 的 Python 文件来简化了这一过程。它包含两个数据数组:一个用于普锐斯,一个用于宝马。你可以如下导入这两个数组:

from car_data import bmws, priuses

如果你检查 car_data.py 文件中的宝马或普锐斯原始数据,你会看到这个文件包含比我们所需更多的数据。目前,我们专注于每辆汽车的里程数和价格,并且我们知道它属于哪个列表。例如,宝马列表开始如下:

[('bmw', '5', 2013.0, 93404.0, 13999.0, 22.09145859494213),
 ('bmw', '5', 2013.0, 110890.0, 13995.0, 22.216458611342592),
 ('bmw', '5', 2013.0, 94133.0, 13982.0, 22.09145862741898),
 ...

每个元组代表一辆出售的汽车,其里程数和价格分别由元组的第四和第五个条目给出。在 car_data.py 中,这些被转换为 Car 对象,因此我们可以写 car.price 而不是 car[4],例如。我们可以通过从宝马元组和普锐斯元组中提取所需的条目来创建一个形状符合我们要求的 all_car_data 列表:

all_car_data = []
for bmw in bmws:
    all_car_data.append((bmw.mileage,bmw.price,1))
for prius in priuses:
    all_car_data.append((prius.mileage,prius.price,0))

一旦运行,all_car_data 就是一个以宝马车开始并以普锐斯车结束的 Python 列表,分别用 1 和 0 标记:

>>> all_car_data
[(93404.0, 13999.0, 1),
 (110890.0, 13995.0, 1),
 (94133.0, 13982.0, 1),
 (46778.0, 14599.0, 1),
 ....
(45000.0, 16900.0, 0),
(38000.0, 13500.0, 0),
(71000.0, 12500.0, 0)]

15.1.2 测试分类函数

当数据以合适的格式存在时,我们现在可以编写 test_classifier 函数。bmw_finder 的任务是查看一辆汽车的里程数和价格,并告诉我们这些是否代表一辆宝马。如果答案是肯定的,它返回 1;否则,它返回 0。很可能会出现 bmw_finder 预测错误的情况。如果它预测一辆车是宝马(返回 1),但实际上是普锐斯,我们将称之为 假阳性。如果它预测汽车是普锐斯(返回 0),但实际上是宝马,我们将称之为 假阴性。如果它正确地识别出宝马或普锐斯,我们将称之为 真阳性真阴性,分别。

为了测试分类函数对所有汽车数据集,我们需要在该列表中的每个里程数和价格上运行分类函数,并查看结果 1 或 0 是否与给定的值匹配。以下是代码中的样子:

def test_classifier(classifier, data):
    trues = 0
    falses = 0
    for mileage, price, is_bmw in data:
        if classifier(mileage, price) == is_bmw:  ❶
            trues += 1
        else:
            falses += 1                           ❷
    return trues / (trues + falses)

❶ 如果分类正确,则将 trues 计数器加 1

❷ 否则,将 falses 计数器加 1

如果我们用 bmw_finder 分类函数和所有汽车数据集运行这个函数,我们看到它的准确率是 59%:

>>> test_classifier(bmw_finder, all_car_data)
0.59

这还不错;我们大部分的答案都对了。但我们会看到我们可以做得比这更好!在下一节中,我们将数据集绘制出来,以了解 bmw_finder 函数在定性上有什么问题。这有助于我们了解如何通过我们的逻辑分类函数改进分类。

15.1.3 练习

| 练习 15.1:更新 test_classifier 函数以打印出真正的正例、真正的负例、错误的正例和错误的负例的数量。对于 bmw_finder 分类器打印这些信息,你能对分类器的性能有什么了解?解答:我们不仅跟踪正确和错误的预测,还可以分别跟踪真正的正例、真正的负例、错误的正例和错误的负例:

def test_classifier(classifier, data, verbose=False):   ❶
    true_positives = 0                                  ❷
    true_negatives = 0
    false_positives = 0
    false_negatives = 0
    for mileage, price, is_bmw in data:
        predicted = classifier(mileage,price)
        if predicted and is_bmw:                       ❸
            true_positives += 1
        elif predicted:
            false_positives += 1
        elif is_bmw:
            false_negatives += 1
        else:
            true_negatives += 1

    if verbose:        
        print("true positives %f" % true_positives)    ❹
        print("true negatives %f" % true_negatives)
        print("false positives %f" % false_positives)
        print("false negatives %f" % false_negatives)

    total = true_positives + true_negatives

    return total / len(data)                           ❺

❶ 我们现在有 4 个计数器来跟踪。❷ 指定是否打印数据(我们可能不想每次都打印)。❸ 根据汽车是普锐斯还是宝马以及它是否被正确分类,增加 4 个计数器中的一个❹ 打印每个计数器的结果❺ 返回正确分类的数量(真正的正例或负例)除以数据集的长度。对于 bmw_finder 函数,这会打印以下文本:

true positives 18.000000
true negatives 100.000000
false positives 0.000000
false negatives 82.000000

因为分类器没有返回任何误报,这告诉我们它总是正确地识别出汽车不是宝马。但我们还不能过于自豪我们的函数,因为它说大部分的汽车都不是宝马,包括很多确实是宝马的汽车!在下一个练习中,你可以放宽限制以获得更高的整体成功率。|

| 练习 15.2:找到一种方法来更新 bmw_finder 函数以提高其性能,并使用 test_classifier 函数来确认你的改进函数的准确率超过 59%。解答:如果你解决了上一个练习,你会看到 bmw_finder 在说汽车不是宝马时过于激进。我们可以降低价格阈值到 $20,000 看看是否有所改变:

def bmw_finder2(mileage,price):
    if price > 20000:
        return 1
    else:
        return 0

|

| 确实,通过降低这个阈值,bmw_finder 提高了成功率到 73.5%:

>>> test_classifier(bmw_finder2, all_car_data)
0.735

|

15.2 描绘决策边界

在我们实现逻辑回归函数之前,让我们看看另一种衡量我们在分类中成功的方法。因为里程和价格这两个数字定义了我们的二手车数据点,我们可以把它们看作是二维向量,并将它们绘制在二维平面上作为点。这个图让我们更好地了解我们的分类函数在宝马和普锐斯之间“划线”的位置,我们可以看到如何改进它。结果发现,使用我们的 bmw_finder 函数相当于在二维平面上画一条实际的线,任何在线上方的点被称作宝马,任何在下方的不被称作宝马。

在本节中,我们使用 Matplotlib 绘制我们的图表,并查看bmw_finder在宝马和普锐斯之间放置的分割线。这条线被称为决策边界,因为一个点位于线的哪一侧,有助于我们决定它属于哪个类别。在图表上查看汽车数据后,我们可以找出绘制更好分割线的地方。这使得我们可以定义bmw_finder函数的改进版本,并可以精确地测量它的性能提升。

15.2.1 想象汽车的空间

我们数据集中的所有汽车都有里程和价格值,但其中一些代表宝马,一些代表普锐斯,这取决于它们是否被标记为 1 或 0。为了使我们的图表易于阅读,我们希望在散点图上使宝马和普锐斯在视觉上明显不同。

图片

图 15.2 数据集中所有汽车的售价与里程对比图,其中每个宝马用 X 表示,每个普锐斯用圆圈表示

源代码中的plot_data辅助函数接受整个汽车数据列表,并自动用 X 标记宝马,用圆圈标记普锐斯。图 15.2 显示了该图表。

>>> plot_data(all_car_data)

通常情况下,我们可以看到宝马比普锐斯更贵;大多数宝马在价格轴上更高。这证明了我们将更贵的汽车分类为宝马的策略是合理的。具体来说,我们在 25,000 美元的价格上画了这条线(图 15.3)。在图表上,这条线将图表上更贵的汽车顶部与更便宜的汽车底部分开。

图片

图 15.3 展示了绘制了汽车数据的决策线

这是我们决策边界。线上的每一个 X 都被正确地识别为宝马,而线下的每一个圆都被正确地识别为普锐斯。所有其他点都被错误地分类。很明显,如果我们移动这个决策边界,我们可以提高我们的准确度。让我们试一试。

15.2.2 绘制更好的决策边界

根据图 15.3 中的图表,我们可以降低线并正确识别更多的宝马,同时不会错误地识别任何普锐斯。图 15.4 显示了如果我们把截止价格降低到 21,000 美元,决策边界看起来会是什么样子。

图片

图 15.4 降低决策边界线似乎提高了我们的准确度。

21,000 美元的截止点可能适合低里程汽车,但里程越高,阈值越低。例如,看起来大多数 75,000 英里或以上的宝马价格都低于 21,000 美元。为了建模这一点,我们可以使我们的截止价格与里程相关。从几何上讲,这意味着绘制一条向下倾斜的线(图 15.5)。

图片

图 15.5 使用向下倾斜的决策边界

这条线由函数p(x) = 21,000 − 0.07 · x给出,其中p是价格,x是里程。这个方程式没有什么特别之处;我只是随意调整数字,直到我绘制出一条看起来合理的线。但它看起来甚至可以正确识别比以前更多的宝马车,只有少数误判(将普锐斯错误地分类为宝马)。与其只是凭直觉判断这些决策边界,我们不如将它们转换成分类函数并衡量它们的性能。

15.2.3 实现分类函数

为了将这个决策边界转换成一个分类函数,我们需要编写一个 Python 函数,它接受汽车里程和价格作为参数,并根据该点是否位于直线之上或之下返回一个或零。这意味着需要将给定的里程值插入到决策边界函数p(x)中,以查看阈值价格是多少,并将结果与给定的价格进行比较。这看起来是这样的:

def decision_boundary_classify(mileage,price):
    if price > 21000 − 0.07 * mileage:
        return 1
    else:
        return 0

通过测试,我们可以看到它比我们的第一个分类器要好得多;80.5%的汽车被这条线正确分类。不错!

>>> test_classifier(decision_boundary_classify, all_car_data)
0.805

你可能会问为什么我们不能直接对定义决策边界线的参数进行梯度下降。如果 20,000 和 0.07 不能给出最准确的决策边界,也许它们附近的某个数字对可以。这不是一个疯狂的想法。当我们实现逻辑回归时,你将看到在底层,它使用梯度下降移动决策边界,直到找到最佳位置。

我们将实现更复杂的逻辑回归算法,而不是对决策边界函数ax + b的参数ab进行梯度下降,有两个重要的原因。第一个原因是,如果在梯度下降的任何步骤中决策边界接近垂直,ab的值可能会变得非常大,导致数值问题。另一个原因是没有明显的成本函数。在下一节中,我们将看到逻辑回归如何处理这两个问题,以便我们可以使用梯度下降来寻找最佳决策边界。

15.2.4 练习

| 练习 15.3-迷你项目:哪种形式的决策边界p = constant在测试数据集上给出最佳的分类准确率?解答:以下函数为任何指定的、常数的截止价格构建一个分类器函数。换句话说,生成的分类器如果测试汽车的价格高于截止值则返回 true,否则返回 false:

def constant_price_classifier(cutoff_price):
    def c(x,p):
        if p > cutoff_price:
            return 1
        else:
            return 0
    return c

这个函数的准确性可以通过将生成的分类器传递给test_classify函数来衡量。这里有一个辅助函数,可以自动检查我们想要测试的任何价格作为截止值的情况:

def cutoff_accuracy(cutoff_price):
    c = constant_price_classifier(cutoff_price)
    return test_classifier(c,all_car_data)

最佳的截断价格位于我们列表中的两个价格之间。检查每个价格并查看它是否是最佳截断价格就足够了。我们可以使用 Python 中的 max 函数快速做到这一点。关键字参数 key 允许我们选择通过哪个函数来最大化。在这种情况下,我们想要找到列表中最佳的截断价格,因此我们可以通过 cutoff_accuracy 函数来最大化:

>>> max(all_prices,key=cutoff_accuracy)
17998.0

这告诉我们,根据我们的数据集,$17,998 是决定汽车是宝马 5 系列还是普锐斯时作为截断的最佳价格。对于我们的数据集来说,它相当准确,准确率为 79.5%:

>>> test_classifier(constant_price_classifier(17998.0), all_car_data)
0.795

|

15.3 将分类问题作为回归问题来处理

我们可以将我们的分类任务重新构造成回归问题的方法是通过创建一个函数,该函数接受汽车的里程和价格作为输入,并返回一个数字,衡量它成为宝马而不是普锐斯的可能性。在本节中,我们实现了一个名为 logistic_classifier 的函数,从外部看,它与我们迄今为止构建的分类器非常相似;它接受里程和价格,并输出一个数字,告诉我们汽车是宝马还是普锐斯。唯一的区别是,它不是输出一个或零,而是输出一个介于零和一之间的值,告诉我们汽车是宝马的可能性有多大。

你可以将这个数字看作是里程和价格描述的是宝马的概率,或者更抽象地说,你可以将其视为给出数据点的“宝马特性”(图 15.6)。(是的,这是一个虚构的词,我读作“bee-em-doubleyou-ness。”它的意思是有多像宝马。也许我们可以将反义词称为“普锐斯性”。)

图 15.6 “宝马特性”的概念描述了平面上一个点有多像宝马。

要构建逻辑分类器,我们从一个好的决策边界线的猜测开始。位于线上的点具有高的“宝马特性”,意味着这些点很可能是宝马,逻辑函数应该返回接近一的值。位于线下的数据点具有低的“宝马特性”,意味着这些点更有可能是普锐斯,我们的函数应该返回接近零的值。在决策边界上,“宝马特性”值将是 0.5,这意味着该点的宝马和普锐斯的概率是相等的。

15.3.1 缩放原始汽车数据

在回归过程中,我们迟早需要处理一项任务,所以现在处理它也无妨。正如我们在上一章中讨论的,里程和价格的大数值可能会引起数值错误,所以最好将它们缩放到一个小的、一致的大小。如果我们将所有里程和价格线性缩放到零到一之间的值,我们应该是安全的。

我们需要能够缩放和未缩放里程和价格中的每一个,因此我们需要总共四个函数。为了使这个过程稍微不那么痛苦,我编写了一个辅助函数,它接受一个数字列表并返回用于将这些数字线性缩放和未缩放到零和一之间的函数,使用列表中的最大和最小值。将此辅助函数应用于里程和价格的整个列表,我们得到了所需的四个函数:

def make_scale(data):
    min_val = min(data)                           ❶
    max_val = max(data)
    def scale(*x*):                                 ❷
        return (x-min_val) / (max_val − min_val)
    def unscale(*y*):                               ❸
        return y * (max_val − min_val) + min_val
    return scale, unscale                         ❹

price_scale, price_unscale =\ 
    make_scale([x[1] for *x* in all_car_data])      ❺
mileage_scale, mileage_unscale =\
    make_scale([x[0] for *x* in all_car_data])

❶ 最大值和最小值提供了当前数据集的范围。

❷ 将数据点放置在 0 到 1 之间的相同分数位置,就像它在 min_val 到 max_val 之间一样

❸ 将缩放数据点放置在 min_val 到 max_val 之间的相同分数位置,就像它在 0 到 1 之间一样

❹ 返回用于缩放或未缩放此数据集成员时的缩放和未缩放函数(如果你熟悉这个术语,则是闭包)。

❺ 返回两组函数,一组用于价格,另一组用于里程

现在,我们可以将这些缩放函数应用于我们列表中的每个汽车数据点,以获得数据集的缩放版本:

scaled_car_data = [(mileage_scale(mileage), price_scale(price), is_bmw) 
                    for mileage,price,is_bmw in all_car_data]

好消息是,图表看起来相同(图 15.7),只是坐标轴上的数值不同。

图 15.7 将里程和价格数据缩放,使所有值都在零和一之间。图表看起来与之前相同,但我们的数值误差风险降低了。

因为缩放数据集的几何形状相同,这应该让我们有信心,这个缩放数据集的良好决策边界将转化为原始数据集的良好决策边界。

15.3.2 测量汽车的“宝马”程度

让我们从与上一节中相似的决策边界开始。函数 p(x) = 0.56 − 0.35 · x 给出了决策边界上价格作为里程的函数。这非常接近我在上一节中通过目测找到的,但它适用于缩放后的数据集(图 15.8)。

图 15.8 缩放数据集上的决策边界 p(x) = 0.56 − 0.35 · x

我们仍然可以使用我们的 test_classifier 函数在缩放后的数据集上测试分类器;我们只需要确保传入缩放后的数据而不是原始数据。结果发现这个决策边界给我们提供了 78.5%准确度的数据分类。

结果表明,这个决策边界函数可以被重新排列,以给出数据点的“宝马”程度的度量。为了使我们的代数更简单,让我们将决策边界写成

p = ax + b

其中 p 是价格,x 仍然是里程,而 ab 是直线的斜率和截距(在这种情况下,a = -0.35 和 b = 0.56),分别。我们不必将其视为函数,我们可以将其视为满足决策边界上点 (x, p) 的方程。如果我们从方程的两边减去 ax + b,我们得到另一个正确的方程:

paxb = 0

决策边界上的每一个点 (x, p) 都满足这个方程。换句话说,对于决策边界上的每一个点,paxb 的值都是零。

这段代数的关键在于,paxb 是点 (x, p) 的“宝马度”的度量。如果 (x, p) 在决策边界之上,这意味着相对于 xp 太大,所以 paxb > 0。相反,如果 (x, p) 在决策边界之下,这意味着相对于 xp 太小,那么 paxb < 0。否则,表达式 paxb 精确为零,点正好位于将解释为普锐斯或宝马的阈值。这可能在第一次阅读时有点抽象,所以表 15.2 列出了三种情况。

表 15.2 可能情况的总结

(x, p) 在决策边界之上 paxb > 0 很可能是一辆宝马
(x, p) 在决策边界上 paxb = 0 可能是任何车型
(x, p) 在决策边界之下 paxb < 0 很可能是一辆普锐斯

如果你还没有确信 paxb 是与决策边界兼容的“宝马度”的度量,一个更简单的方法是查看 f(x, p) = paxb 的热图,以及数据(图 15.9)。当 a = -0.35 和 b = 0.56 时,函数是 f(x, p) = p − 0.35 · x − 0.56。

图片

图 15.9 展示了热图和决策边界的图,显示明亮的值(正“宝马度”)位于决策边界之上,而暗的值(负“宝马度”)出现在决策边界之下

函数 f(x, p) 几乎满足我们的要求。它接受里程和价格作为输入,并输出一个数字,如果这个数字可能代表宝马车,则数值较高;如果数值可能代表普锐斯,则数值较低。唯一缺少的是输出数字没有限制在零到一之间,截止值在零而不是期望的 0.5。幸运的是,有一个方便的数学辅助函数我们可以用来调整输出。

15.3.3 介绍 Sigmoid 函数

函数 f(x, p) = paxb 是线性的,但这不是关于线性回归的章节!当前的主题是 逻辑回归,要进行逻辑回归,你需要使用逻辑函数。最基本的逻辑函数如下,通常称为 Sigmoid 函数:

图片

我们可以使用 Python 中的 exp 函数来实现这个函数,它代表 ex,其中 e = 2.71828... 是我们之前用于指数底数的常数:

from math import exp
def sigmoid(*x*):
    return 1 / (1+exp(−x))

图 15.10 展示了其图像。

图片

图 15.10 Sigmoid 函数 σ(x) 的图像

在这个函数中,我们使用希腊字母σ(西格玛),因为σ是字母S的希腊版本,σ(x)的图形看起来有点像字母S。有时“逻辑函数”和“S 形函数”这两个词可以互换使用,指的是像图 15.10 中的那种函数,它从一个值平滑地过渡到另一个值。在本章(以及下一章)中,当我提到 S 形函数时,我会谈论这个特定的函数:σ(x)。

你不需要过于担心这个函数是如何定义的,但你确实需要理解图形的形状及其含义。这个函数将任何输入数字映射到 0 到 1 之间的一个值,大负数产生接近 0 的结果,而大正数产生接近 1 的结果。σ(0)的结果是 0.5。我们可以把σ看作是将从-∞到∞的范围转换到更易管理的从 0 到 1 的范围。

15.3.4 将 S 形函数与其他函数组合

回到我们的函数f(x, p) = paxb,我们看到了它接受一个里程值和一个价格值,并返回一个衡量这些值看起来像宝马而不是普锐斯的数字。这个数字可以是大的、正的或负的,而零值表示它位于宝马和普锐斯之间的边界上。

我们希望我们的函数返回一个介于 0 到 1 之间的值(接近 0 和 1 的值),分别代表可能是普锐斯或宝马的汽车,而 0.5 的值表示一辆汽车有同等可能性是普锐斯或宝马。我们只需要调整f(x, p)的输出,使其处于预期的范围内,就像图 15.11 中所示的那样通过 S 形函数σ(x)。也就是说,我们想要的函数是σ(f(x, p)),其中xp是里程和价格。

图片

图 15.11 “BMWness”函数f(x, p)与 S 形函数σ(x)组合的示意图

让我们将得到的函数称为L(x, p),换句话说,L(x, p) = σ(f(x, p))。在 Python 中实现函数L(x, p)并绘制其热图(图 15.12),我们可以看到它沿着与f(x, p)相同的方向增加,但其值不同。

图片

图 15.12 热图看起来基本上是一样的,但函数的值略有不同。

基于这张图,你可能会想知道为什么我们费尽心机将“BMWness”函数通过 S 形函数。从这个角度看,函数看起来几乎一样。然而,如果我们将其图形作为 3D 空间中的 2D 表面来绘制(图 15.13),你会发现 S 形函数的曲线形状有影响。

图片

图 15.13 当f(x, p)线性向上倾斜时,L(x, p)从 0 的最小值曲线上升到 1 的最大值。

公平起见,我不得不在 (x, p) 空间中稍微放大一些,以便使曲率清晰。重点是,如果汽车类型由 0 或 1 表示,函数 L(x, p) 的值实际上接近这些数字,而 f(x, p) 的值则趋向于正负无穷大!

图 15.14 展示了两个夸张的图来展示我的意思。记住,在我们的数据集 scaled_car_data 中,我们用 (mileage, price, 0) 形式的三元组来表示普锐斯,用 (mileage, price, 1) 形式的三元组来表示宝马。我们可以将这些解释为 3D 中的点,其中宝马位于 z = 1 的平面上,而普锐斯位于 z = 0 的平面上。将 scaled_car_data 作为 3D 散点图绘制,你可以看到线性函数无法像逻辑函数那样接近许多数据点。

对于形状像 L(x, p) 的函数,我们实际上可以希望拟合数据,我们将在下一节中看到如何做到这一点。

图 15.14 3D 中线性函数的图形无法像逻辑函数的图形那样接近数据点。

15.3.5 练习

练习 15.4: 找到一个函数 h(x),使得当 x 的正值很大时,h(x) 接近 0,当 x 的负值很大时,h(x) 接近 1,并且 h(3) = 0.5。解答: 函数 y(x) = 3 − xy(3) = 0 时成立,并且当 x 很大且为负时,它趋向于正无穷,当 x 很大且为正时,它趋向于负无穷。这意味着将 y(x) 的结果传递到我们的 sigmoid 函数中,可以得到具有所需特性的函数。具体来说,h(x) = σ(y(x)) = σ(3 − x) 是有效的,其图形如下以说服你:
练习 15.5-迷你项目: 实际上,f(x, p) 的结果有一个下限,因为 xp 不允许是负数(毕竟,负里程和价格没有意义)。你能找出汽车可能产生的 f 的最低值吗?解答: 根据热图,函数 f(x, p) 随着我们向下和向左移动而减小。方程也证实了这一点;如果我们减小 xpf = paxb = p + 0.35 · x − 0.56 的值会减小。因此,f(x, p) 的最小值发生在 (x, p) = (0, 0),并且它为 f(0, 0) = -0.056。

15.4 探索可能的逻辑函数

让我们快速回顾一下步骤。在散点图上绘制我们的一组普锐斯和宝马的里程和价格,我们可以尝试在这些值之间画一条线,称为决策边界,它定义了一个区分普锐斯和宝马的规则。我们将决策边界写成形式 p(x) = ax + b 的线,看起来 -0.35 和 0.56 是 ab 的合理选择,这给我们带来了大约 80% 正确的分类。

重新排列这个函数,我们发现 f(x, p) = p − ax − b 是一个接受里程和价格 (x, p) 作为输入并返回一个数字的函数,这个数字在决策边界的宝马一侧大于零,在普锐斯一侧小于零。在决策边界上,f(x, p) 返回零,这意味着一辆车成为宝马或普锐斯的概率是相等的。因为我们用 1 表示宝马,用 0 表示普锐斯,所以我们希望 f(x, p) 返回的值在零和一之间,其中 0.5 表示一辆车成为宝马或普锐斯的概率相等。将 f 的结果传递给 sigmoid 函数 σ,我们得到了一个新的函数 L(x, p) = σ(f(x, p)),满足这一要求。

但我们不想用肉眼确定的最佳决策边界 L(x, p) 我制作的 L(x, p) — 我们想要的是 best fits the data 的 L(x, p)。在我们实现这一目标的过程中,我们将看到有三个参数我们可以控制,以编写一个通用的逻辑函数,它接受二维向量并返回零到一之间的数字,并且具有决策边界 L(x, p) = 0.5,这是一条直线。我们将编写一个 Python 函数 make_logistic(a,b,c),它接受三个参数 abc,并返回它们定义的逻辑函数。正如我们在第十四章中探索了 (a, b) 对的二维空间来选择线性函数一样,我们将探索 (a, b, c) 的三维空间来定义我们的逻辑函数(图 15.15)。

图片

图 15.15 探索参数值 (a, b, c) 的三维空间以定义函数 L(x, p)

然后,我们将创建一个成本函数,这与我们为线性回归创建的成本函数非常相似。我们将称之为 logistic_cost(a,b,c) 的成本函数,它接受参数 abc,这些参数定义了一个逻辑函数并产生一个数字,衡量逻辑函数与我们的汽车数据集的距离。logistic_cost 函数需要以这种方式实现,即其值越低,相关的逻辑函数的预测就越好。

15.4.1 逻辑函数的参数化

第一个任务是找到逻辑函数 L(x, p) 的一般形式,其值在零到一之间,其决策边界 L(x, p) = 0.5 是一条直线。我们在上一节中接近了这个目标,从决策边界 p(x) = ax + b 开始,并从那里反向工程出一个逻辑函数。唯一的问题是,形式为 ax + b 的线性函数不能表示平面上的任何直线。例如,图 15.16 显示了一个数据集,其中垂直的决策边界 x = 0.6 是有意义的。然而,这样的线不能用 p = ax + b 的形式表示。

图片

图 15.16 垂直的决策边界可能是有意义的,但它不能以 p = ax + b 的形式表示。

一个有效的工作线的通用形式是我们第七章遇到的形式:ax + by = c。因为我们把我们的变量命名为 xp,我们将写作 ax + bp = c。给定这样的方程,函数 z(x, p) = ax + bpc 在具有正值的这一边和负值的另一边的线上为零。对我们来说,z(x, p) 为正的线的一边是宝马的一边,而 z(x, p) 为负的线的一边是普锐斯的一边。

z(x, p) 通过 sigmoid 函数传递,我们得到一个通用的逻辑函数 L(x, p) = σ(z(x, p)),其中当 z(x, p) = 0 时,L(x, p) = 0.5。换句话说,函数 L(x, p) = σ(ax + bpc) 是我们寻找的通用形式。这很容易翻译成 Python,给我们一个返回对应逻辑函数 L(x, p) = σ(ax + bpc) 的 abc 的函数:

def make_logistic(a,b,c):
    def l(x,p):
        return sigmoid(a*x + b*p − c)
    return l

下一步是提出一个衡量这个函数接近我们的缩放汽车数据集的指标。

15.4.2 测量逻辑函数的拟合质量

对于任何宝马车,scaled_car_data 列表中包含一个形式为 (x, p, 1) 的条目,而对于每辆普锐斯,它包含一个形式为 (x, p, 0) 的条目,其中 xp 分别表示(缩放后的)里程和价格值。如果我们对 xp 值应用一个逻辑函数,L(x, p),我们将得到一个介于零和一之间的结果。

测量函数 L 的错误或成本的一个简单方法就是找出它与正确值(要么是零,要么是一)有多远。如果你把这些错误加起来,你会得到一个总值,告诉你函数 L(x, p) 离数据集有多远。以下是 Python 中的样子:

def simple_logistic_cost(a,b,c):
    l = make_logistic(a,b,c)
    errors = [abs(is_bmw-l(x,p)) 
              for x,p,is_bmw in scaled_car_data]
    return sum(errors)

这个成本报告了合理的错误,但它还不够好,不能让我们的梯度下降收敛到 abc 的最佳值。我不会深入解释为什么是这样,但我将尝试快速给你一个大致的想法。

假设我们有两个逻辑函数,L1 和 L2,我们想要比较两者的性能。让我们假设它们都查看相同的数据点 (x, p, 0),这意味着一个代表普锐斯的数据点。那么,假设 L1 返回 0.99,这大于 0.5,因此它错误地预测这辆车是宝马。这个点的错误是 |0-0.99| = 0.99。如果另一个逻辑函数 L2 预测值为 0.999,模型更有信心地预测这辆车是宝马,并且错误更大。也就是说,错误将是 |0-0.999| = 0.999,这并没有太大的不同。

图 15.17 函数 -log(x)对于小的输入返回大值,且 -log(1) = 0。

L[1]视为报告有 99%的可能性数据点代表宝马,有 1%的可能性代表普锐斯,而L[2]报告有 99.9%的可能性是宝马,有 0.1%的可能性是普锐斯。与其将其视为比普锐斯预测差 0.09%,我们实际上应该认为它差了十倍!因此,我们可以认为L[2]比L[1]错误十倍。

我们希望有一个成本函数,如果L(x, p)对错误答案非常确定,那么L的成本就很高。为了达到这个目的,我们可以查看L(x, p)与错误答案之间的差异,并通过一个将小值放大成大值的函数。例如,L1 对普锐斯返回了 0.99,这意味着它距离错误答案有 0.01 个单位,而L2 对普锐斯返回了 0.999,这意味着它距离错误答案有 0.001 个单位。从小的输入返回大值的好函数是−log(x),其中 log 是特殊的自然对数函数。你不必了解−log 函数的具体作用,只需知道它对小的输入返回大数字。图 15.17 显示了−log(x)的图像。

为了熟悉−log(x),你可以用一些小的输入对其进行测试。对于L1,它距离错误答案有 0.01 个单位,我们得到的成本比L2 小,后者距离错误答案有 0.001 个单位:

from math import log
>>> −log(0.01)
4.605170185988091
>>> −log(0.001)
6.907755278982137

相比之下,如果L(x, p)对普锐斯返回零,它就会给出正确答案。这离错误答案有 1 个单位,所以−log(1) = 0,因此正确答案的成本为零。

现在我们准备实现我们设定的logistic_cost函数。为了找到一个给定点的成本,我们计算给定的逻辑函数接近错误答案的程度,然后取结果的负对数。总成本是scaled_car_data数据集中每个数据点的成本之和:

def point_cost(l,x,p,is_bmw):                     ❶
    wrong = 1 − is_bmw
    return −log(abs(wrong − l(x,p)))

def logistic_cost(a,b,c):
    l = make_logistic(a,b,c)
    errors = [point_cost(l,x,p,is_bmw)            ❷
              for x,p,is_bmw in scaled_car_data]
    return sum(errors)

❶ 确定单个数据点的成本

❷ 逻辑函数的整体成本与之前相同,只是我们为每个数据点使用新的 point_cost 函数,而不是仅仅使用误差的绝对值。

结果表明,如果我们尝试使用梯度下降法最小化logistic_cost函数,我们会得到好的结果。但在我们这样做之前,让我们进行一个合理性检查,并确认logistic_cost对于具有(显然)更好的决策边界的逻辑函数返回更低的值。

15.4.3 测试不同的逻辑函数

让我们尝试两个具有不同决策边界的逻辑函数,并确认一个是否比另一个具有更明显的更好决策边界,或者它是否具有更低的成本。作为我们的两个例子,让我们使用p = 0.56 − 0.35 · x,这是我最好的猜测决策边界,它与 0.35 · x + 1 · p = 0.56 相同,还有一个任意选择的,比如x + p = 1。显然,前者是普锐斯和宝马之间更好的分割线。

在源代码中,你可以找到一个 plot_line 函数,用于根据方程 ax + by = c 中的值 abc 绘制一条线(并且作为本节末尾的练习,你可以尝试自己实现这个函数)。相应的 (a, b, c) 值是 (0.35, 1, 0.56) 和 (1, 1, 1)。我们可以用这三条线与汽车数据的散点图(如图 15.18 所示)一起绘制:

plot_data(scaled_car_data)
plot_line(0.35,1,0.56)
plot_line(1,1,1)

图片

图 15.18 两个决策边界线的图形。其中一条在将普锐斯与宝马分开方面明显优于另一条。

相应的逻辑函数是 σ(0.35 · x + p − 0.56) 和 σ(x + p − 1),我们预计第一个函数在数据方面具有更低的成本。我们可以使用 logistic_cost 函数来确认这一点:

>>> logistic_cost(0.35,1,0.56)
130.92490748700456
>>> logistic_cost(1,1,1)
135.56446830870456

如预期,直线 x + p = 1 是一个较差的决策边界,因此逻辑函数 σ(x + p − 1) 具有更高的成本。第一个函数 σ(0.35 · x + p − 0.56) 具有更低的成本和更好的拟合度。但它是最佳拟合吗?当我们下一节中对 logistic_cost 函数进行梯度下降时,我们将找到答案。

15.4.4 练习

| 练习 15.6:实现 15.4.3 节中提到的 plot_line(a,b,c) 函数,该函数绘制直线 ax + by = c,其中 0 ≤ x ≤ 1 和 0 ≤ y ≤ 1。解答:请注意,我使用了除了 abc 之外的其他名称作为函数参数,因为 c 是一个关键字参数,用于设置 Matplotlib 的 plot 函数绘制的线条颜色,我经常使用这个函数:

def plot_line(acoeff,bcoeff,ccoeff,**kwargs):
    a,b,*c* = acoeff, bcoeff, ccoeff
    if b == 0:
        plt.plot([c/a,c/a],[0,1])
    else:
        def y(*x*):
            return (c-a*x)/b
        plt.plot([0,1],[y(0),y(1)],**kwargs)

|

练习 15.7:使用 S 型函数 σ 的公式,写出 σ(ax + byc) 的展开公式。解答:鉴于,我们可以写出
练习 15.8-迷你项目:函数 k(x, y) = σ(x² + y² − 1) 的图形是什么样的?决策边界是什么样的,即 k(x, y) = 0.5 的点的集合。解答:我们知道 σ(x² + y² − 1) = 0.5,无论 x² + y² − 1 = 0 还是 x² + y² = 1。你可以识别出这个方程的解为距离原点一个单位的点或半径为 1 的圆。在圆内,从原点的距离较小,因此 x² + y² < 1 且 σ(x² + y²) < 0.5,而在圆外 x² + y² > 1,因此 σ(x² + y² − 1) > 0.5。随着我们沿任何方向远离原点,这个函数的图形趋近于 1,而在圆内下降到原点处的最小值约为 0.27。以下是图形!。σ(x² + y² − 1) 的图形。在半径为 1 的圆内,其值小于 0.5,而在该圆外的每个方向上增加到 1。
练习 15.9-迷你项目:两个方程 2x + y = 1 和 4x + 2y = 2 定义了同一条线,因此也定义了相同的决策边界。逻辑函数 σ(2x + y − 1) 和 σ(4x + 2y − 2) 是否相同?解答:它们不是同一个函数。与 xy 的增加相比,量 4x + 2y − 2 的增加速度更快,因此后一个函数的图像更陡峭:图像第二个逻辑函数的图像比第一个更陡峭。
练习 15.10-迷你项目:给定一条直线 ax + by = c,定义这条线以上和以下的部分并不容易。你能描述函数 z(x, y) = ax + byc 返回正值的那一侧吗?解答:直线 ax + by = c 是点集,其中 z(x, y) = ax + byc = 0。正如我们在第七章中看到的那样,这种形式的方程的 z(x, y) = ax + byc 的图像是一个平面,因此它从直线开始在一个方向上增加,在另一个方向上减少。z(x, y) 的梯度是 ∇z(x, y) = (a, b),因此 z(x, y) 在向量 (a, b) 的方向上增加最快,在相反方向(− a, − b)上减少最快。这两个方向都与直线的方向垂直。

15.5 寻找最佳逻辑函数

我们现在有一个直接的最小化问题要解决;我们希望找到使 logistic_cost 函数尽可能小的 abc 的值。然后相应的函数,L(x, p) = σ(ax + bpc) 将是数据的最佳拟合。我们可以使用这个结果函数通过插入未知汽车的里程 x 和价格 p 来构建一个分类器,如果 L(x, p) > 0.5,则将其标记为宝马,否则标记为普锐斯。我们将这个分类器命名为 best_logistic_classifier(x,p),并将其传递给 test_classifier 以查看其表现如何。

我们在这里要做的唯一一项主要工作是升级我们的 gradient_descent 函数。到目前为止,我们只对那些接受二维向量并返回数字的函数进行了梯度下降。logistic_cost 函数接受一个三维向量 (a, b, c) 并输出一个数字,因此我们需要一个新的梯度下降版本。幸运的是,我们已经为每个二维向量操作覆盖了三维类比,所以这不会太难。

15.5.1 三维梯度下降

让我们看看我们在第十二章和第十四章中用来处理两个变量函数的现有梯度计算方法。函数 f(x, y) 在点 (x[0], y[0]) 的偏导数是相对于 xy 的单独导数,同时假设另一个变量是常数。例如,将 y[0] 插入到 f(x, y) 的第二个槽中,我们得到 f(x, y[0]),我们可以将其视为仅关于 x 的函数并对其求普通导数。将这两个偏导数作为二维向量的分量放在一起,我们就得到了梯度:

def approx_gradient(f,x0,y0,dx=1e-6):
    partial_x = approx_derivative(lambda x:f(x,y0),x0,dx=dx)
    partial_y = approx_derivative(lambda y:f(x0,y),y0,dx=dx)
    return (partial_x,partial_y)

对于三个变量的函数,区别在于我们还可以取另一个偏导数。如果我们看 f(x, y, z) 在点 (x[0], y[0], z[0]),我们可以将 f(x, y[0], z[0])、f(x[0], y, z[0]) 和 f(x[0], y[0], z) 分别视为 xyz 的函数,并对其求普通导数以得到三个偏导数。将这些三个偏导数作为一个向量放在一起,我们就得到了梯度的三维版本:

def approx_gradient3(f,x0,y0,z0,dx=1e-6):
    partial_x = approx_derivative(lambda x:f(x,y0,z0),x0,dx=dx)
    partial_y = approx_derivative(lambda y:f(x0,y,z0),y0,dx=dx)
    partial_z = approx_derivative(lambda z:f(x0,y0,z),z0,dx=dx)
    return (partial_x,partial_y,partial_z)

在三维空间中进行梯度下降,过程正如你所期望的那样;我们从三维空间中的某个点开始,计算梯度,然后朝那个方向迈出小一步到达一个新的点,在那里,希望 f(x, y, z) 的值会更小。作为额外的增强,我添加了一个 max_steps 参数,这样我们就可以设置梯度下降过程中可以采取的最大步数。有了这个参数设置为合理的限制,即使算法没有收敛到容差内的点,我们也不必担心我们的程序会停滞。以下是 Python 中的结果:

def gradient_descent3(f,xstart,ystart,zstart,
                      tolerance=1e-6,max_steps=1000):
    x = xstart
    y = ystart
    z = zstart
    grad = approx_gradient3(f,x,y,z)
    steps = 0
    while length(grad) > tolerance and steps < max_steps:
        x -= 0.01 * grad[0]
        y -= 0.01 * grad[1]
        z -= 0.01 * grad[2]
        grad = approx_gradient3(f,x,y,z)
        steps += 1
    return x,y,z

剩下的就是插入 logistic_cost 函数,然后 gradient_descent3 函数会找到最小化它的输入。

15.5.2 使用梯度下降法寻找最佳拟合

为了谨慎起见,我们可以先使用少量的 max_steps,比如 100:

>>> gradient_descent3(logistic_cost,1,1,1,max_steps=100)
(0.21114493546399946, 5.04543972557848, 2.1260122558655405)

如果我们允许它走 200 步而不是 100 步,我们会看到它实际上需要走得更远:

>>> gradient_descent3(logistic_cost,1,1,1,max_steps=200)
(0.884571531298388, 6.657543188981642, 2.955057286988365)

记住,这些结果是定义逻辑函数所需的参数,但它们也是定义形式为 ax + bp = c 的决策边界的参数 (a, b, c)。如果我们对梯度下降进行 100 步、200 步、300 步等,并用 plot_line 绘制相应的线,我们可以看到决策边界如图 15.19 所示的那样收敛。

图片

图 15.19 随着步数的增加,梯度下降法返回的 (a, b, c) 值似乎正在稳定在一个清晰的决策边界上。

在 7,000 到 8,000 步之间,算法实际上收敛了,这意味着它找到了一个梯度长度小于 10^(-6)的点。大致来说,这就是我们寻找的最小点:

>>> gradient_descent3(logistic_cost,1,1,1,max_steps=8000)
(3.7167003153580045, 11.422062409195114, 5.596878367305919)

我们可以看到这个决策边界相对于我们一直在使用的决策边界(图 15.20 显示了结果)的样子:

plot_data(scaled_car_data)
plot_line(0.35,1,0.56)
plot_line(3.7167003153580045, 11.422062409195114, 5.596878367305919)

图片

图 15.20 比较我们之前最佳猜测的决策边界与梯度下降结果所暗示的决策边界

这个决策边界与我们猜测的并不太远。逻辑回归的结果似乎将决策边界稍微向下移动,以换取一些假阳性(现在错误地位于图 15.20 中的线上方的普锐斯)和一些更多的真阳性(现在正确地位于线上方的宝马)。

15.5.3 测试和理解最佳逻辑分类器

我们可以轻松地将(a, b, c)的这些值插入到逻辑函数中,然后使用它来创建一个汽车分类函数:

def best_logistic_classifier(x,p):
    l = make_logistic(3.7167003153580045, 11.422062409195114, 5.596878367305919)
    if l(x,p) > 0.5:
        return 1
    else:
        return 0

将这个函数插入到test_classifier函数中,我们可以看到它在测试数据集上的准确率大约与我们的最佳尝试结果一致,精确到 80%:

>>> test_classifier(best_logistic_classifier,scaled_car_data)
0.8

决策边界相当接近,所以性能没有偏离我们在第 15.2 节中的猜测也就不足为奇了。然而,如果我们之前的结果已经很接近,为什么决策边界会如此果断地收敛到那个位置呢?

结果表明,逻辑回归不仅仅是找到最优的决策边界。实际上,我们在本节早期就看到了一个决策边界,它的性能比这个最佳拟合逻辑分类器高出 0.5%,所以逻辑分类器并没有在测试数据集上最大化准确率。相反,逻辑回归从整体上审视数据集,并找到在所有示例中最有可能准确性的模型。而不是稍微移动决策边界以获取测试集上的一两个百分点的准确率,算法基于对数据集的整体视角来定位决策边界。如果我们的数据集具有代表性,我们可以相信我们的逻辑分类器在未见过的数据上也能表现良好,而不仅仅是训练集中的数据。

我们的逻辑分类器还有其他信息,那就是对每个分类点的确定性程度。仅基于决策边界的分类器对位于该边界之上的点是一辆宝马,位于该边界之下的点是一辆普锐斯的 100%确定。我们的逻辑分类器有更细微的看法;我们可以将它在 0 到 1 之间返回的值解释为汽车是宝马而不是普锐斯的概率。在现实世界的应用中,了解机器学习模型的最佳猜测以及它认为自己的可信度如何可能非常有价值。如果我们根据医学扫描将良性肿瘤与恶性肿瘤分类,如果算法告诉我们肿瘤有 99%的确定性是恶性的,而不是 51%,我们可能会采取截然不同的行动。

确定性在分类器形状中体现为系数的幅度(abc)。例如,你可以看到在我们猜测的 (0.35, 1, 0.56) 中,(abc) 的比例与最优值 (3.717, 11.42, 5.597) 中的比例相似。最优值大约是我们最佳猜测的十倍。造成这种变化的最大差异是逻辑函数的陡峭程度。最优逻辑函数比第一个更确定决策边界。它告诉我们,一旦你越过决策边界,结果确定性就会显著增加,如图 15.21 所示。

图 15.21 优化后的逻辑函数更陡峭,这意味着当你越过决策边界时,它确定一辆车是宝马而不是普锐斯的确定性会迅速增加。

在最后一章,我们将继续使用 Sigmoid 函数来生成介于零和一之间的结果确定性,当我们使用神经网络实现分类时。

15.5.4 练习

| 练习 15.11: 修改 gradient_descent3 函数,使其在返回结果前打印出所采取的总步数。梯度下降法对 logistic_cost 的收敛需要多少步?解答:你只需要在 gradient_descent3 返回结果前添加一行 print(steps) 即可:

def gradient_descent3(f,xstart,ystart,zstart,tolerance=1e−6,max_steps=1000):
    ...
    print(steps)
    return x,y,z

运行以下梯度下降

gradient_descent3(logistic_cost,1,1,1,max_steps=8000)

打印出的数字是 7244,这意味着算法在 7,244 步中收敛。|

| 练习 15.12-迷你项目:编写一个 approx_gradient 函数,该函数可以计算任何数量维度的函数的梯度。然后编写一个 gradient_descent 函数,该函数可以在任何数量维度上工作。为了测试你的 gradient_descentn 维函数上的效果,你可以尝试一个函数,例如 f(x[1],x[2],... ,x^n ) = (x[1] − 1)² + (x[2] − 1)² + ... + (x^n − 1)²,其中 x[1],x[2],... ,x^n 是函数 fn 个输入变量。这个函数的最小值应该是 (1, 1, ..., 1),一个每个条目都是数字 1 的 n 维向量。解答:让我们将任意维度的向量建模为数字列表。为了在向量 v = (v[1],v[2],... ,v[n])的 i^(th) 坐标上求偏导数,我们想要对 i^(th) 坐标 x[i] 求普通导数。也就是说,我们想要查看函数:f(v[1],v[2],... ,v[i−1],x[i]v[i+1],... ,v[n]),换句话说,就是将 v 的每个坐标都插入到 f 中,除了 i^(th) 条目,它被留作变量 x[i]。这给我们一个单变量 x[i] 的函数,它的普通导数就是 i^(th) 偏导数。偏导数的代码看起来像这样:

def partial_derivative(f,i,v,**kwargs):
    def cross_section(*x*):
        arg = [(vj if j != i else x) for j,vj in enumerate(*v*)]
        return *f*(*arg)
    return approx_derivative(cross_section, v[i], **kwargs)

注意,我们的坐标是零索引的,输入到 f 的维度从 v 的长度推断出来。其余的工作相对容易。要构建梯度,我们只需取 n 个偏导数并将它们按顺序放入列表:

def approx_gradient(f,v,dx=1e−6):
    return [partial_derivative(f,i,v) for i in range(0,len(*v*))]

要进行梯度下降,我们将所有对命名坐标变量的操作,如 xyz,替换为对坐标列表向量 v 的列表操作:*

def gradient_descent(f,vstart,tolerance=1e−6,max_steps=1000):
    v  = vstart
    grad = approx_gradient(f,v)
    steps = 0
    while length(grad) > tolerance and steps < max_steps:
        v  = [(vi − 0.01 * dvi) for vi,dvi in zip(v,grad)]
        grad = approx_gradient(f,v)
        steps += 1
    return v

|

| 要实现建议的测试函数,我们可以编写一个通用版本,它接受任意数量的输入,并返回它们与一个值的平方差的和:

def sum_squares(*v):
    return sum([(x−1)**2 for *x* in v])

这个函数不能低于零,因为它是由平方和组成的,而平方不能小于零。当输入向量 v 的每个元素都是一的时候,得到零值,这就是最小值。我们的梯度下降法确认了这一点(只有很小的数值误差),所以一切看起来都很正常!请注意,因为起始向量 v 是 5 维的,所以计算中的所有向量都是自动 5 维的。

>>> xv  = [2,2,2,2,2]
>>> gradient_descent(sum_squares,v)
[1.0000002235452137,
 1.0000002235452137,
 1.0000002235452137,
 1.0000002235452137,
 1.0000002235452137]

|

练习 15.13-迷你项目:尝试使用 simple_logistic_cost 成本函数运行梯度下降。会发生什么?解决方案:它似乎没有收敛。尽管决策边界稳定,但 abc 的值仍然无限增加。这意味着随着梯度下降探索越来越多的逻辑函数,这些函数保持同一方向,但变得越来越陡峭。它被激励着越来越接近大多数点,而忽略了它已经错误分类的点。正如我提到的,可以通过惩罚逻辑函数最自信的错误分类来解决此问题,我们的 logistic_cost 函数就很好地做到了这一点。

摘要

  • 分类是一种机器学习任务,其中算法被要求查看未标记的数据点,并将每个点识别为某一类的成员。在本章的示例中,我们查看二手车里程和价格数据,并编写了一个算法来将它们分类为 5 系列宝马或丰田普锐斯。

  • 在 2D 空间中对向量数据进行分类的一种简单方法就是建立决策边界;这意味着在数据存在的 2D 空间中绘制一个实际的边界,边界的一侧的点被分类为一类,另一侧的点被分类为另一类。简单的决策边界是一条直线。

  • 如果我们的决策边界线具有形式 ax + by = c,那么 ax + byc 的值在直线的一侧为正,在另一侧为负。我们可以将这个值解释为衡量数据点看起来像宝马的程度的一个指标。正值意味着数据点看起来像宝马,而负值则意味着它更像普锐斯。

  • 定义如下,sigmoid 函数将介于 -∞ 和 ∞ 之间的数字压缩到从零到一的有限区间:

    图片

  • 将 Sigmoid 函数与函数ax + byc组合,我们得到一个新的函数σ(ax + byc),它也衡量数据点看起来有多像宝马,但它只返回介于零和一之间的值。这种类型的函数是二维中的对数函数。

  • 对数分类器输出的介于零和一之间的值可以解释为它有多自信地认为一个数据点属于某一类而不是另一类。例如,返回值 0.51 或 0.99 都表明模型认为我们正在看一辆宝马,但后者将是一个更加自信的预测。

  • 使用一个对自信但错误的分类进行惩罚的适当代价函数,我们可以使用梯度下降法找到最佳拟合的对数函数。这是根据数据集得出的最佳对数分类器。

16 训练神经网络

本章涵盖了

  • 将手写数字图像分类为向量数据

  • 设计一种称为多层感知器的神经网络类型

  • 将神经网络作为向量变换进行评估

  • 使用成本函数和梯度下降法拟合神经网络

  • 在反向传播中计算神经网络的偏导数

在本书的最后一章,我们将你迄今为止学到的几乎所有内容结合起来,介绍今天最著名的机器学习工具之一:人工神经网络。人工神经网络,简称神经网络,是一种数学函数,其结构大致基于人脑的结构。这些被称为“人工”的,是为了与大脑中存在的“有机”神经网络相区别。这听起来可能是一个高远且复杂的目标,但它全部基于对大脑工作原理的一个简单隐喻。

在解释这个隐喻之前,我想先提醒你,我不是神经学家。粗略的想法是,大脑是一大团相互连接的细胞,称为神经元,当你思考某些想法时,实际上发生的是特定神经元的电活动。你可以在适当的脑扫描中看到这种电活动,大脑的各个部分会亮起(图 16.1)。

图片

图 16.1 不同类型的脑活动导致不同的神经元电激活,在脑扫描中显示出明亮区域。

与人脑中的数十亿个神经元相比,我们在 Python 中构建的神经网络只有几十个神经元,而特定神经元被激活的程度由一个称为其激活的单个数字表示。当一个神经元在大脑或我们的人工神经网络中激活时,它可以导致相邻的、连接的神经元也被激活。这允许一个想法导致另一个想法,我们可以将其松散地视为创造性思维。

从数学的角度来看,我们神经网络中神经元的激活是一个函数,该函数取决于与之相连的神经元的数值激活值。如果一个神经元连接到四个其他神经元,其激活值分别为 a[1] , a[2] , a[3] 和 a[4] ,那么它的激活将是一个数学函数应用于这四个值的结果,比如说 f(a[1] , a[2] , a[3] , a[4] )。

图 16.2 显示了一个示意图,其中所有神经元都被绘制成圆形。我以不同的阴影来表示它们具有不同的激活水平,有点像脑扫描中较亮或较暗的区域。

图片

图 16.2 将神经元激活描绘为数学函数,其中 a[1] , a[2] , a[3] 和 a[4] 是应用于函数 f 的激活值。

如果a[1]、a[2]、a[3]和a[4]的值依赖于其他神经元的激活,那么a的值可能依赖于更多的数字。随着神经元和连接的增加,您可以构建一个任意复杂的数学函数,目标是模拟任意复杂的思想。

我刚刚向您解释的内容是对神经网络的一种哲学性的介绍,但这绝对不足以让您开始编码。在本章中,我将详细向您展示如何运用这些想法并构建您自己的神经网络。正如上一章一样,我们将使用神经网络解决的问题是分类。构建神经网络并训练其在分类任务上表现良好有许多步骤,因此在深入之前,我会先制定计划。

16.1 使用神经网络进行数据分类

在本节中,我专注于神经网络的一个经典应用:图像分类。具体来说,我们将使用手写数字的低分辨率图像(从 0 到 9 的数字),我们希望我们的神经网络能够识别给定图像中显示的是哪个数字。图 16.3 展示了这些数字的一些示例图像。

图片

图 16.3 一些手写数字的低分辨率图像

图片

图 16.4 我们的 Python 神经网络函数如何对数字图像进行分类。

如果您将图 16.3 中的数字识别为 6、0、5 和 1,那么恭喜您!您的有机神经网络(即您的头脑)训练得很好。我们的目标在这里是构建一个人工神经网络,它可以观察这样的图像并将其分类为十个可能的数字之一,也许和人类一样好。

在第十五章中,分类问题相当于观察一个二维向量并将其分类为两个类别之一。在本问题中,我们观察 8x8 像素的灰度图像,其中每个 64 像素由一个数字描述,该数字告诉我们其亮度。正如我们在第六章中将图像视为向量一样,我们将 64 像素的亮度值视为一个 64 维向量。我们希望将每个 64 维向量放入十个类别之一,表示它代表的数字。因此,我们的分类函数将比第十五章中的函数有更多的输入和输出。

具体来说,我们将用 Python 构建的神经网络分类函数将看起来像一个具有 64 个输入和 10 个输出的函数。换句话说,它是一个从ℝ⁶⁴到ℝ¹⁰的(非线性!)向量变换。输入数字是像素的亮度值,从 0 到 1 缩放,十个输出值代表图像是十个数字中的哪一个的可能性。最大输出值的索引是答案。在以下情况(如图 16.4 所示)中,一个 5 的图像被传入,神经网络在第五个槽位返回其最大值,因此它正确地识别了图像中的数字。

图 16.4 中间的神经网络函数不过是一个数学函数。它的结构将比我们之前看到的更复杂,实际上,定义它的公式太长了,无法写在纸上。评估神经网络更像是在执行一个算法。我将向您展示如何做这件事,并在 Python 中实现它。

正如我们在上一章测试了许多不同的逻辑函数一样,我们也可以尝试许多不同的神经网络,看看哪一个具有最佳的预测准确性。再次强调,系统地做这件事的方法是梯度下降。虽然一个线性函数由公式 f(x) = ax + b 中的两个常数 ab 决定,但给定形状的神经网络可以有数千个常数决定其行为方式。这需要计算很多偏导数!幸运的是,由于连接我们神经网络中神经元的函数的形式,存在一个用于计算梯度的快捷算法,这被称为反向传播

从头开始推导反向传播算法并仅使用我们至今为止所学的数学知识来实现它是有可能的,但不幸的是,这个项目太大,不适合放在这本书中。相反,我将向您展示如何使用一个名为 scikit-learn(“sci”发音类似于“science”)的著名 Python 库来为我们执行梯度下降,这样它就可以自动训练神经网络,以尽可能好地预测我们的数据集。最后,我将向您透露反向传播背后的数学原理。我希望这将是您在机器学习领域辉煌职业生涯的起点。

16.2 手写数字图像分类

在我们开始实现神经网络之前,我们需要准备数据。我使用的数字图像是 scikit-learn 数据中附带的大量免费测试数据之一。一旦我们下载了这些数据,我们需要将它们转换为介于零和一之间的 64 维向量。数据集还包含了每个数字图像的正确答案,表示为从零到九的 Python 整数。

然后,我们构建了两个 Python 函数来练习分类。第一个是一个名为random_classifier的假数字识别函数,它接受代表图像的 64 个数字,并(随机地)输出代表图像代表从 0 到 9 每个数字的确定性的 10 个数字。第二个是一个名为test_digit_classify的函数,它接受一个分类器,并自动将数据集中的每个图像插入其中,返回正确答案的数量。由于我们的random_classifier产生随机结果,它应该只有 10%的时间猜对答案。这为我们用真正的神经网络替换它时提供了改进的基础。

16.2.1 构建 64 维图像向量

如果你正在使用附录 A 中描述的 Anacondas Python 发行版,你应该已经有了名为sklearn的 scikit-learn 库。如果没有,你可以使用 pip 安装它。要打开sklearn并导入数字数据集,你需要以下代码:

from sklearn import datasets
digits = datasets.load_digits()

数字中的每个条目都是一个 2D NumPy 数组(一个矩阵),给出了一个图像的像素值。例如,digits.images[0]给出了数据集中第一张图像的像素值,它是一个 8x8 的值矩阵:

>>> digits.images[0]
array([[ 0.,  0.,  5., 13.,  9.,  1.,  0.,  0.],
       [ 0.,  0., 13., 15., 10., 15.,  5.,  0.],
       [ 0.,  3., 15.,  2.,  0., 11.,  8.,  0.],
       [ 0.,  4., 12.,  0.,  0.,  8.,  8.,  0.],
       [ 0.,  5.,  8.,  0.,  0.,  9.,  8.,  0.],
       [ 0.,  4., 11.,  0.,  1., 12.,  7.,  0.],
       [ 0.,  2., 14.,  5., 10., 12.,  0.,  0.],
       [ 0.,  0.,  6., 13., 10.,  0.,  0.,  0.]])

图 16.5 sklearn 数字数据集中的第一张图像,看起来像零

你可以看到灰度值的范围是有限的。矩阵仅由 0 到 15 的整数组成。

Matplotlib 有一个有用的内置函数叫做imshow,它可以将矩阵的条目显示为图像。使用正确的灰度指定,矩阵中的零值显示为白色,较大的非零值显示为较深的灰色阴影。例如,图 16.5 显示了数据集中的第一张图像,看起来像零,这是由imshow生成的:

import matplotlib.pyplot as plt
plt.imshow(digits.images[0], cmap=plt.cm.gray_r)

为了再次强调我们将如何将此图像视为一个 64 维向量,图 16.6 显示了图像的一个版本,其中每个 64 像素的亮度值都叠加在相应的像素上。

图 16.6 数字数据集中的一张图像,每个像素的亮度值都叠加在相应的像素上。

要将这个 8x8 的数字矩阵转换成一个包含 64 个元素的向量,我们可以使用一个内置的 NumPy 函数,称为np.matrix.flatten。这个函数从矩阵的第一行开始构建一个向量,接着是第二行,以此类推,从而给我们一个与第六章中使用的类似图像的向量表示。将第一张图像矩阵展平确实给我们一个包含 64 个元素的向量:

>>> import numpy as np
>>> np.matrix.flatten(digits.images[0])
array([ 0.,  0.,  5., 13.,  9.,  1.,  0.,  0.,  0.,  0., 13., 15., 10.,
       15.,  5.,  0.,  0.,  3., 15.,  2.,  0., 11.,  8.,  0.,  0.,  4.,
       12.,  0.,  0.,  8.,  8.,  0.,  0.,  5.,  8.,  0.,  0.,  9.,  8.,
        0.,  0.,  4., 11.,  0.,  1., 12.,  7.,  0.,  0.,  2., 14.,  5.,
       10., 12.,  0.,  0.,  0.,  0.,  6., 13., 10.,  0.,  0.,  0.])

为了保持我们的分析在数值上整洁,我们再次将数据缩放,使值在 0 到 1 之间。因为数据集中每个条目的像素值都在 0 到 15 之间,我们可以将这些向量乘以 1/15 来得到缩放版本。NumPy 重载了*/运算符,以便自动作为向量的标量乘法(和除法)操作,所以我们只需简单地输入

np.matrix.flatten(digits.images[0]) / 15

我们将得到一个缩放的结果。现在我们可以构建一个样本数字分类器来将这些值插入其中。

16.2.2 构建随机数字分类器

数字分类器的输入是一个 64 维向量,就像我们刚才构建的那样,输出是一个包含每个元素值在 0 到 1 之间的 10 维向量。在我们的第一个例子中,输出向量的元素可以随机生成,但我们将它们解释为分类器对图像代表十个数字中的每一个的确定性。

由于我们现在对随机输出没有问题,这很容易实现;NumPy 有一个函数,np.random.rand,它产生一个指定大小的介于 0 和 1 之间的随机数数组。例如,np.random.rand(10) 给我们一个介于 0 和 1 之间的 10 个随机数的 NumPy 数组。我们的 random_classifier 函数接受一个输入向量,忽略它,并返回一个随机向量:

def random_classifier(input_vector):
    return np.random.rand(10)

要对数据集中的第一张图像进行分类,我们可以运行以下代码:

>>> xv  = np.matrix.flatten(digits.images[0]) / 15.
>>> result = random_classifier(*v*)
>>> result
array([0.78426486, 0.42120868, 0.47890909, 0.53200335, 0.91508751,
       0.1227552 , 0.73501115, 0.71711834, 0.38744159, 0.73556909])

这个输出中的最大条目大约是 0.915,出现在索引 4 处。返回这个向量,我们的分类器告诉我们,图像代表任何数字的可能性,它最可能是 4。要程序化地获取最大值的索引,我们可以使用以下 Python 代码:

>>> list(result).index(max(result))
4

在这里,max(result) 找到数组中的最大条目,list(result) 将数组视为普通的 Python 列表。然后我们可以使用内置的 list 索引函数来找到最大值的索引。返回值 4 是不正确的;我们之前看到这张图片是 0,我们也可以检查官方结果。

每个图像的正确数字存储在 digits.target 数组的相应索引中。对于图像 digits.images[0],正确值是 digits.target[0],正如我们所期望的是零:

>>> digits.target[0]
0

我们的随机分类器预测图像为 4,而实际上它是 0。因为它是在随机猜测,所以 90%的时间应该是错误的,我们可以通过在大量测试示例上测试它来确认这一点。

16.2.3 测量数字分类器的性能

现在我们将编写函数 test_digit_classify,它接受一个分类器函数并测量其在大量数字图像上的性能。任何分类器函数都将具有相同的形状;它接受一个 64 维输入向量并返回一个 10 维输出向量。test_digit_classify 函数遍历所有测试图像和已知正确答案,查看分类器是否产生正确答案:

def test_digit_classify(classifier,test_count=1000):
    correct = 0                                          ❶
    for img, target in zip(digits.images[:test_count], 
digits.target[:test_count]):                             ❷
        v  = np.matrix.flatten(img) / 15\.                ❸
        output = classifier(*v*)                           ❹
        answer = list(output).index(max(output))         ❺
        if answer == target:
            correct += 1                                 ❻
    return (correct/test_count)                          ❼

❶ 将正确分类的计数器从 0 开始

❷ 在测试集中循环图像对及其对应的目标,给出数字的正确答案

❸ 将图像矩阵展平成一个 64D 向量,并适当地缩放

❹ 将图像向量通过分类器以获得一个 10D 结果

❺ 找到这个结果中最大条目的索引,这是分类器的最佳猜测

❻ 如果这与我们的答案匹配,则增加计数器

❼ 返回正确分类的数量与总测试数据点的比例

我们预计我们的随机分类器大约能正确回答 10%的问题。因为它随机行动,它可能在某些试验中做得比其他试验好,但由于我们在这么多图像上进行了测试,结果应该每次都接近 10%。让我们试一试:

>>> test_digit_classify(random_classifier)
0.107

在这个测试中,我们的随机分类器在 10.7%的准确率上略好于预期。这本身并不太有趣,但现在我们已经组织好了数据并有了可以超越的基线示例,因此我们可以开始构建我们的神经网络。

16.2.4 练习

| 练习 16.1:假设一个数字分类器函数输出以下 NumPy 数组。它推断图像中包含的是哪个数字?

array([5.00512567e-06, 3.94168539e-05, 5.57124430e-09, 9.31981207e-09,
       9.98060276e-01, 9.10328786e-07, 1.56262695e-03, 1.82976466e-04,
       1.48519455e-04, 2.54354113e-07])

解决方案:此数组中的最大数是9.98060276e-01,或大约 0.998,出现在第五位,或索引 4。因此,这个输出表示图像被分类为 4。

| 练习 16.2-迷你项目:以我们在第六章中取平均值的方式,找到数据集中所有 9 的图像的平均值。绘制结果图像。解决方案:此代码接受一个整数i,并计算表示数字i的数据集中图像的平均值。因为数字图像以 NumPy 数组的形式表示,支持加法和标量乘法,我们可以使用普通的 Python sum函数和除法运算符来计算平均值:

def average_img(i):
    imgs = [img for img,target in zip(digits.images[1000:], digits.target[1000:]) if target==i]
    return sum(imgs) / len(imgs)

使用此代码,average_img(9)计算一个 8-by-8 矩阵,表示所有 9 的图像的平均值,其外观如下: |

| 练习 16.3-迷你项目:通过找到测试数据集中每种数字的平均图像并与目标图像进行比较,构建一个比随机分类器更好的分类器。具体来说,返回目标图像与每个平均数字图像的点积向量。解决方案

avg_digits = [np.matrix.flatten(average_img(i)) for i in range(10)]
def compare_to_avg(*v*):
    return [np.dot(v,avg_digits[i]) for i in range(10)]

测试这个分类器,我们在测试数据集中正确识别了 85%的数字。还不错!

>>> test_digit_classify(compare_to_avg)
0.853

|

16.3 设计神经网络

在本节中,我向您展示如何将神经网络视为一个数学函数,以及根据其结构,您可以期望它如何表现。这为我们下一节做准备,在那里我们将我们的第一个神经网络作为 Python 函数实现,以便对数字图像进行分类。

对于我们的图像分类问题,我们的神经网络有 64 个输入值和 10 个输出值,需要数百次操作来评估。因此,在本节中,我坚持使用一个更简单的神经网络,它有三个输入和两个输出。这使得我们可以想象整个网络并逐步了解其评估的每一步。一旦我们覆盖了这一点,编写适用于任何大小神经网络的评估步骤在一般 Python 代码中就会变得容易。

16.3.1 组织神经元和连接

正如我在本章开头所描述的,神经网络的模型是一组神经元,其中给定神经元的激活取决于其连接神经元的激活程度。从数学上讲,激活一个神经元是连接神经元激活的函数。根据使用的神经元数量、连接的神经元以及连接它们的函数,神经网络的行为可能不同。在本章中,我们将关注最简单且有用的神经网络类型之一−多层感知器

多层感知器,简称 MLP,由从左到右排列的几列神经元组成,称为。每个神经元的激活是前一层激活的函数,即紧靠左侧的一层。最左侧的层不依赖于其他任何神经元,其激活基于训练数据。图 16.7 展示了四层 MLP 的示意图。

图 16.7 多层感知器(MLP)的示意图,由几层神经元组成

在图 16.7 中,每个圆圈代表一个神经元,圆圈之间的线条表示连接的神经元。一个神经元的激活仅取决于前一层的神经元激活,并影响下一层中每个神经元的激活。我任意选择了每层的神经元数量,在这个特定的示意图中,层分别由三个、四个、三个和两个神经元组成。

因为总共有 12 个神经元,所以总共有 12 个激活值。通常可以有更多的神经元(我们将在数字分类中使用 90 个),所以不能给每个神经元一个字母变量名。相反,我们用字母 a 来表示所有激活,并用上标和下标来索引它们。上标表示层,下标表示层内讨论的神经元。例如,a

代表第二层第二个神经元的激活。

16.3.2 神经网络中的数据流

要将神经网络作为一个数学函数来评估,有三个基本步骤,我将用激活值来描述。我将从概念上解释它们,然后展示公式。记住,神经网络只是一个接受输入向量并产生输出向量的函数。中间的步骤只是从给定输入得到输出的方法。这是管道中的第一步。

第一步:将输入层激活设置为输入向量的条目

输入层是第一层或最左侧层的另一种说法。图 16.7 中的网络在输入层有三个神经元,所以这个神经网络可以接受 3D 向量作为输入。如果我们的输入向量是(0.3, 0.9, 0.5),那么我们可以通过将a[1]⁰ = 0.3、a[2]⁰ = 0.9 和a[3]⁰ = 0.5 来执行这一步。这填满了网络中总共 12 个神经元中的 3 个(图 16.8)。

图片

图 16.8 将输入层激活设置为输入向量的条目(左侧)

第一层中的每个激活值都是零层激活的函数。现在我们有了足够的信息来计算它们,所以这是步骤 2。

步骤 2:将下一层的每个激活计算为输入层所有激活的函数

这一步是计算的核心,在我概念性地走完所有步骤之后,我会回到这里。现在需要知道的重要一点是,下一层的每个激活通常是由前一层激活的一个独特函数给出的。比如说,我们要计算a[0]。这个激活是a[1]⁰、a[2]⁰和a[3]⁰的某个函数,我们可以暂时将其写作a 1¹ = f(a[1]⁰ , a[2]⁰ , a[3]⁰)。假设,例如,我们计算f(0.3, 0.9, 0.5)的结果是 0.6。那么在我们的计算中a 1¹的值就变成了 0.6(图 16.9)。

图片

图 16.9 将第一层的激活计算为零层激活的某个函数

当我们计算第一层的下一个激活a[2]¹时,它也是输入激活a[1]⁰、a[2]⁰和a[3]⁰的函数,但在一般情况下,它是一个不同的函数,比如说a[2]¹ = g(a[1]⁰ , a[2]⁰ , a[3]⁰)。结果仍然依赖于相同的输入,但由于是一个不同的函数,我们可能会得到不同的结果。比如说,g(0.3, 0.9, 0.5) = 0.1,那么这就是a[2]¹的值(图 16.10)。

图片

图 16.10 使用输入层激活的另一个函数计算第一层的另一个激活

我使用fg作为简单的占位符函数名。关于输入层,a[3]¹和a[4]¹还有另外两个独特的函数。我不会继续命名这些函数,因为我们很快就会用完字母,但重要的是每个激活都有一个特殊的函数,该函数作用于前一层激活。一旦我们计算出第一层的所有激活,我们就已经填满了 12 个总激活中的 7 个。这里的数字仍然是虚构的,但结果可能看起来像图 16.11 中所示的那样。

图片

图 16.11 计算了我们的多层感知器(MLP)的两个激活层

从现在开始,我们重复这个过程,直到我们计算出网络中每个神经元的激活,这是步骤 3。

步骤 3:重复此过程,根据前一层的激活计算后续每一层的激活

我们首先计算a[1]²作为一层激活a[1]¹、a[2]¹、a[3]¹和a[4]¹的函数。然后我们继续计算a[2]²和a[3]²,它们由它们自己的函数给出。最后,我们计算a[1]³和a[2]³作为它们自己关于二层激活的函数。此时,网络中每个神经元的激活都已计算出来(图 16.12)。

图片

图 16.12 一个所有激活都已计算的 MLP 示例

到目前为止,我们的计算已经完成。我们已经计算了中间层(称为隐藏层)和最终层(称为输出层)的激活。我们现在需要做的就是读取输出层的激活以获得我们的结果,这就是步骤 4。

步骤 4:返回一个向量,其条目是输出层的激活

在这种情况下,向量是(0.2, 0.9),因此将我们的神经网络作为输入向量(0.3, 0.9, 0.5)的函数评估,产生输出向量(0.2, 0.9)。

就这些了!我没有涵盖的唯一内容是如何计算单个激活,这正是神经网络独特之处。除了输入层中的神经元外,每个神经元都有自己的函数,定义这些函数的参数是我们将调整以使神经网络做我们想要的事情的数字。

16.3.3 计算激活

好消息是,我们将使用一种熟悉的形式的函数来计算一层的激活作为前一层的函数:逻辑函数。棘手的部分是,我们的神经网络在输入层之外有 9 个神经元,因此有 9 个不同的函数需要跟踪。更重要的是,有几个常数需要确定每个逻辑函数的行为。大部分工作将是跟踪所有这些常数。

为了专注于一个具体的例子,我们注意到在我们的样本 MLP 中,我们的激活依赖于三个输入层激活:a[1]⁰、a[2]⁰和a[3]⁰。给出a[1]¹的函数是这些输入(包括一个常数)通过 sigmoid 函数传递的线性函数。这里有四个自由参数,我暂时命名为aBCD(图 16.13)。

图片

图片

我们需要调整变量aBCD,使a[1]¹能够适当地响应输入。在第十五章中,我们将逻辑函数视为接受几个数字并就它们做出是或否的决定,将答案报告为零到一的“是”的确定性。从这个意义上说,你可以将网络中间的神经元视为将整体分类问题分解成更小的是或否分类。

网络中的每个连接都有一个常数告诉我们输入神经元激活如何强烈地影响输出神经元激活。在这种情况下,常数 a 告诉我们 a[1]⁰ 如何强烈地影响 a[1]¹,而 BC 分别告诉我们 a[2]⁰ 和 a[3]⁰ 如何强烈地影响 a[1]¹。这些常数被称为神经网络的 权重,网络中每条线段在整章中使用的通用图中都有一个权重。

常数 D 不影响连接,而是独立地增加或减少 a[1]¹ 的值,这个值不依赖于输入激活。这恰当地被命名为神经元的 偏置,因为它衡量了在没有任何输入的情况下做出决策的倾向。单词 偏置 有时带有负面含义,但它却是任何决策过程中的重要部分;它有助于避免异常决策,除非有强有力的证据。

尽管看起来可能很混乱,我们需要对这些权重和偏置进行索引,而不是像 aBCD 这样命名。我们将权重写成 w[ij]^l 的形式,其中 l 是连接右侧的层,i 是层 l − 1 中前一个神经元的索引,j 是层 l 中目标神经元的索引。例如,影响第一层第一个神经元的权重 a,基于零层第一个神经元的值,表示为 w[11]¹。连接第三层第二个神经元到前一层的第一个神经元的权重是 w[21]³(图 16.14)。

图 16.14 显示了与权重 w[11]¹ 和 w[32]¹ 对应的连接

偏置对应于神经元,而不是神经元对,因此每个神经元都有一个偏置:b[j]^l 表示第 i 层中第 j 个神经元的偏置。根据这些命名约定,我们可以写出 a[1]¹ 的公式如下

a[1]¹ = σ(w[11]¹ a[1]⁰ + w[12]² a[2]⁰ + w[13]³ a[3]⁰ + b[1]¹)

或者 a[3]² 的公式如下

a[3]² = σ(w[31]² a[1]¹ + w[32]² a[2]¹ + w[33]² a[3]¹ + w[34]² a[4]¹ + b[3]²)

如您所见,计算激活以评估多层感知器(MLP)并不困难,但变量的数量可能会使其变得繁琐且容易出错。幸运的是,我们可以使用第五章中介绍的矩阵符号来简化这个过程,使其更容易实现。

16.3.4 使用矩阵符号计算激活

尽管可能很复杂,让我们做一个具体的例子,并写出整个网络层的激活公式,然后我们将看到如何使用矩阵符号简化它并编写一个可重用的公式。让我们以第二层为例。三个激活的公式如下:

a[1]² = σ(w[11]² a[1]¹ + w[12]² a[2]¹ + w[13]² a[3]¹ + w[14]² a[4]¹ + b[1]²)

a[2]² = σ(w[21]² a[1]¹ + w[22]² a[2]¹ + w[23]² a[3]¹ + w[24]² a[4]¹ + b[2]²)

a[3]² = σ(w[31]² a[1]¹ + w[32]² a[2]¹ + w[33]² a[3]¹ + w[34]² a[4]¹ + b[3]²)

在 sigmoid 函数内部命名这些量是有用的。让我们用 z[1]²、z[2]² 和 z[3]² 来表示这三个量,因此根据定义

a[1]² = σ(z[1]²)

a[2]² = σ(z[2]²)

and

a[3]² = σ(z[3]²)

这些 z 值的公式更简洁,因为它们都是前一层激活的线性组合,加上一个常数。这意味着我们可以用矩阵向量表示法来写它们。从以下开始:

z[1]² = w[11]² a[1]¹ + w[12]² a[2]¹ + w[13]² a[3]¹ + w[14]² a[4]¹ + b[1]²

z[2]² = w[21]² a[1]¹ + w[22]² a[2]¹ + w[23]² a[3]¹ + w[24]² a[4]¹ + b[2]²

z[3]² = w[31]² a[1]¹ + w[32]² a[2]¹ + w[33]² a[3]¹ + w[34]² a[4]¹ + b[3]²

我们可以将这三个方程写成一个向量

图片

然后将偏差作为一个向量求和:

图片

这只是一个三维向量加法。尽管中间的大向量看起来像是一个更大的矩阵,但它实际上只是一个三个求和的列。然而,这个大向量可以展开成如下矩阵乘法:

图片

第二层的激活是通过将σ函数应用于结果向量的每个条目来获得的。这仅仅是一个符号简化,但从心理上讲,将数字 w[ij]^lb[j]^l 提取到它们自己的矩阵中是有用的。这些数字定义了神经网络本身,而不是定义评估增量步骤的激活 a jl。

为了说明我的意思,你可以将评估神经网络与评估函数 f(x) = ax + b 进行比较。输入变量是 x,相比之下,ab 是定义函数的常数;可能的线性函数空间由 ab 的选择定义。即使我们将 ax 重命名为类似 q 的东西,它也仅仅是 f(x) 计算中的一个增量步骤。类比是,一旦你决定了你的多层感知器(MLP)每层的神经元数量,每层的权重矩阵和偏差向量实际上就是定义神经网络的参数。考虑到这一点,我们可以在 Python 中实现 MLP。

16.3.5 练习

练习 16.4:激活 a[2]³ 代表哪个神经元和层?在以下图像中,这个激活的值是多少?(神经元和层的索引方式与前面的章节相同。)图片解答:上标表示层,下标表示层内的神经元。因此,激活 a[2]³ 对应于第 3 层的第二个神经元。在图像中,它的激活值为 0.9。
练习 16.5:如果一个神经网络的第 5 层有 10 个神经元,第 6 层有 12 个神经元,第 5 层和第 6 层之间共有多少个神经元连接?解答:第 5 层的每个神经元都与第 6 层的每个神经元相连。总共有 120 个连接。
练习 16.6:假设我们有一个有 12 层的 MLP。连接第 4 层的第三个神经元到第 5 层的第七个神经元的权重w[ij]^l的索引lij是什么?解答:记住l是连接的目标层,所以在这个例子中l = 5。索引ij分别指代层ll - 1 中的神经元,所以i = 7,j = 3。这个权重被标记为w[73]⁵。
练习 16.7:在本节的整个网络中,权重w[31]³在哪里使用?解答:没有这样的权重。这将连接到第三层的第三个神经元,即输出层,但这个层只有两个神经元。
练习 16.8:在本节的神经网络中,a[1]³的公式是什么,用第 2 层的激活值、权重和偏差表示?解答:前一层激活是a[1]²、a[2]²和a[2]³,连接到a[1]³的权重是w[11]³、w[12]³和w[1]³。激活a[1]³的偏差表示为b[1]³,所以公式如下!
练习 16.9-迷你项目:编写一个 Python 函数sketch_mlp(*layer_sizes),它接受神经网络的层大小,并输出本节中使用的类似图表。显示所有神经元及其标签,并用直线绘制它们的连接。调用sketch_mlp(3,4,3,2)应该产生我们用来表示整个神经网络所用的示例图。解答:请参阅本书的源代码以获取实现方法。

16.4 在 Python 中构建神经网络

在本节中,我将向您展示如何将我在上一节中解释的评估 MLP 的步骤在 Python 中实现。具体来说,我们将实现一个名为MLP的 Python 类,该类存储权重和偏差(最初随机生成),并提供一个evaluate方法,该方法接受一个 64 维输入向量并返回一个 10 维输出向量。这段代码是将我在上一节中描述的 MLP 设计转换为 Python 的某种例行公事,但一旦我们完成实现,我们就可以在分类手写数字的任务上对其进行测试。

只要权重和偏差是随机选择的,它可能不会比我们最初构建的随机分类器表现得更好。但一旦我们有了预测为我们工作的神经网络结构,我们就可以调整权重和偏差,使其更具预测性。我们将在下一节中转向这个问题。

16.4.1 在 Python 中实现 MLP 类

如果我们想让我们的类表示一个 MLP,我们需要指定我们想要多少层以及每层想要多少个神经元。为了用我们想要的架构初始化我们的 MLP,我们的构造器可以接受一个数字列表,表示每层的神经元数量。

我们需要评估 MLP 的数据是输入层之后每一层的权重和偏差。正如我们刚刚讨论的,我们可以将权重存储为一个矩阵(一个 NumPy 数组)和偏差存储为一个向量(也是一个 NumPy 数组)。首先,我们可以为所有的权重和偏差使用随机值,然后在训练网络时,我们可以逐渐用更有意义的值替换这些值。

让我们快速回顾一下我们想要的权重矩阵和偏差向量的维度。如果我们选择一个有m个神经元的层,并且前一层有n个神经元,那么我们的权重描述了从n维激活向量到m维激活向量的转换的线性部分。这由一个m×n的矩阵描述,换句话说,一个有m行和n列的矩阵。为了看到这一点,我们可以回到 16.3 节中的例子,其中连接四个神经元层到三个神经元层的权重构成一个 4×3 的矩阵,如图 16.15 所示。

图 16.15 连接四个神经元层到三个神经元层的权重矩阵是一个 3×4 矩阵。

一个m个神经元的层的偏差简单地组成一个有m个条目的向量,每个条目对应一个神经元。现在我们已经提醒了自己如何找到每一层的权重矩阵和偏差向量的尺寸,我们就可以准备让我们的类构造器创建它们了。注意,我们遍历layer_sizes[1:],这给出了 MLP 中层的尺寸,跳过了最先的输入层:

class MLP():
    def __init__(self,layer_sizes):                 ❶
        self.layer_sizes = layer_sizes
        self.weights = [
            np.random.rand(n,m)                     ❷
            for m,n in zip(layer_sizes[:−1],
                           layer_sizes[1:])         ❸
        ]
        self.biases = [np.random.rand(n) 
                       for n in layer_sizes[1:]]    ❹

❶ 使用一个包含每层神经元数量的层大小列表初始化 MLP

❷ 权重矩阵是 n×m 的矩阵,包含随机条目...

❸ ... 其中 m 和 n 是神经网络中相邻层的神经元数量。

❹ 每一层的偏差(跳过第一层)是一个包含每层中每个神经元的条目的向量。

实现了这一点之后,我们可以再次确认一个两层的 MLP 恰好有一个权重矩阵和一个偏差向量,并且它们的维度匹配。假设第一层有两个神经元,第二层有三个神经元。然后我们可以运行以下代码:

>>> nn = MLP([2,3])
>>> nn.weights
[array([[0.45390063, 0.02891635],
        [0.15418494, 0.70165829],
        [0.88135556, 0.50607624]])]
>>> nn.biases
[array([0.08668222, 0.35470513, 0.98076987])]

这确认了我们有一个 3×2 的权重矩阵和一个 3D 的偏差向量,两者都填充了随机条目。

输入层和输出层的神经元数量应该与我们要传递的向量的维度以及作为输出接收的向量的维度相匹配。我们图像分类的问题需要 64 维的输入向量和 10 维的输出向量。对于本章,我坚持使用 64 个神经元的输入层,10 个神经元的输出层,以及一个介于两者之间的 16 个神经元的单层。选择合适的层数和层大小以使神经网络在特定任务上表现良好,这需要一些艺术和科学相结合,这也是机器学习专家获得高薪的原因。为了本章的目的,我说这种结构足以让我们得到一个好的、可预测的模型。

我们可以将神经网络初始化为MLP([64,16,10]),它比我们之前画的所有网络都要大得多。图 16.16 显示了它的样子。

图 16.16 一个具有 64、16 和 10 个神经元的三个层的 MLP

幸运的是,一旦我们实现了评估方法,评估大型神经网络对我们来说并不比评估小型神经网络更难。这是因为 Python 为我们做了所有的工作!

16.4.2 评估 MLP

我们MLP类的评估方法应该接受一个 64 维向量作为输入,并返回一个 10 维向量作为输出。从输入到输出的过程是基于从输入层到输出层逐层计算激活。正如我们在讨论反向传播时将看到的,在过程中跟踪所有激活,即使是网络中间的隐藏层也很有用。因此,我将分两步构建evaluate函数:首先,我将构建一个方法来计算所有的激活,然后我将构建另一个方法来提取最后一层的激活值并生成结果。

我把第一种方法称为feedforward,这是按层计算激活过程的常用名称。输入层的激活给出后,为了到达下一层,我们需要将这些激活的向量与权重矩阵相乘,加上下一层的偏差,然后将结果的坐标通过 sigmoid 函数传递。我们重复这个过程,直到到达输出层。下面是这个过程的示意图:

class MLP():
    ...
    def feedforward(self,v):
        activations = []                            ❶
        a = v
        activations.append(a)                       ❷
        for w,b in zip(self.weights, self.biases):  ❸
            z = w @ a + b                           ❹
            a = [sigmoid(x) for x in z]             ❺
            activations.append(a)                   ❻
        return activations

❶ 使用空激活列表进行初始化

❷ 第一层的激活正好是输入向量的条目;我们将这些添加到激活列表中。

❸ 对每一层的单个权重矩阵和偏差向量进行迭代

❹ 向量 z 是权重矩阵与前一层的激活的矩阵乘积加上偏差向量。

❺ 对 z 的每个条目应用 sigmoid 函数以获得激活

❻ 将新计算的激活向量添加到激活列表中

最后一层的激活是我们想要的结果,因此神经网络的一个评估方法简单地运行输入向量的feedforward方法,然后像这样提取最后一个激活向量:

class MLP():
    ...
    def evaluate(self,v):
        return np.array(self.feedforward(*v*)[−1])

就是这样!你可以看到矩阵乘法为我们节省了很多原本需要编写的神经元激活循环。

16.4.3 测试 MLP 的分类性能

现在有了适当大小的 MLP,它现在可以接受一个代表数字图像的向量并输出结果:

>>> nn = MLP([64,16,10])
>>> xv  = np.matrix.flatten(digits.images[0]) / 15.
>>> nn.evaluate(*v*)
array([0.99990572, 0.9987683 , 0.99994929, 0.99978464, 0.99989691,
       0.99983505, 0.99991699, 0.99931011, 0.99988506, 0.99939445])

这意味着输入一个代表图像的 64 维向量,并返回一个 10 维向量作为输出,因此我们的神经网络正在作为一个正确形状的向量转换器运行。由于权重和偏差是随机的,这些数字不应该是对图像可能代表的数字的良好预测。(顺便说一句,这些数字都接近 1,因为我们的所有权重、偏差和输入数字都是正的,而 sigmoid 函数将大的正数发送到接近 1 的值。)即便如此,输出向量中有一个最大的条目,恰好是索引 2 处的数字。这(错误地)预测数据集中的图像 0 代表数字 2。

随机性表明我们的 MLP 只猜对了 10%的答案。我们可以通过test_digit_classify函数来证实这一点。对于我初始化的随机 MLP,它给出了正好 10%:

>>> test_digit_classify(nn.evaluate)
0.1

这可能看起来没有多少进步,但我们可以为自己感到高兴,因为我们的分类器已经工作,即使它在这个任务上并不擅长。评估神经网络比评估像 f(x) = ax + b 这样的简单函数要复杂得多,但当我们训练神经网络以更准确地分类图像时,我们很快就会看到回报。

16.4.4 练习

练习 16.10-迷你项目:使用显式遍历层和权重而不是使用 NumPy 矩阵乘法重写feedforward方法。确认你的结果与之前的实现完全一致。

16.5 使用梯度下降训练神经网络

训练一个神经网络可能听起来像是一个抽象的概念,但这只是意味着找到最佳权重和偏差,使神经网络尽可能好地完成手头的任务。我们在这里不能涵盖整个算法,但我将向你展示它是如何从概念上工作的,以及如何使用第三方库来自动完成它。在本节结束时,我们将调整我们神经网络的权重和偏差,以高度准确度预测图像代表的数字。然后我们可以再次运行test_digit_classify并衡量其表现。

16.5.1 将训练视为最小化问题

在前几章中,对于线性函数 ax + b 或对数函数 σ(ax + by + c),我们创建了一个成本函数来衡量线性或对数函数的失败,这取决于公式中的常数,以精确匹配数据。线性函数的常数是斜率和 y -截距 ab,因此成本函数的形式是 C(a, b)。对数函数有常数 abc(待确定),因此其成本函数的形式是 C(a, b, c)。内部,这两个成本函数都依赖于 所有 训练示例。为了找到最佳参数,我们将使用梯度下降来最小化成本函数。

对于多层感知器(MLP)来说,一个很大的不同之处在于它的行为可以依赖于数百或数千个常数:它所有层 l 和有效神经元索引 ij 的权重 w[ij] 和偏差 b[j]^l。我们的具有 64、16 和 10 个神经元及其三层神经网络有第一层和第二层之间 1,024 个权重,第二层和第三层之间 160 个权重。它在隐藏层有 16 个偏差,在输出层有 10 个偏差。总的来说,我们需要调整 1,210 个常数。你可以想象我们的成本函数是这些 1,210 个值的函数,我们需要最小化它。如果我们把它写出来,它看起来可能像这样:

图片

在这个方程中,我写省略号的地方,还有超过一千个未写出的权重和 24 个未写出的偏差。简要思考一下如何创建成本函数是值得的,并且作为一个迷你项目,你可以尝试自己实现它。

我们的神经网络输出向量,但我们认为分类问题的答案是图像所代表的数字。为了解决这个问题,我们可以将正确答案视为一个完美的分类器作为输出的 10 维向量。例如,如果一个图像清楚地表示数字 5,我们希望看到 100%的确定性,即图像是 5,而 0%的确定性表示图像是任何其他数字。这意味着第五个索引处有一个 1,其他地方都是 0(图 16.17)。

图片

图 16.17 神经网络的理想输出:正确索引处为 1.0,其他地方为 0.0

正如我们之前的回归尝试从未完全拟合数据一样,我们的神经网络也不会。为了衡量我们的 10 维输出向量到理想输出向量的误差,我们可以使用它们在 10 维空间中的距离平方。

假设理想输出是写为 y = (y[1] , y[1] , y[2] , ..., y[10] )。请注意,我遵循的是从 1 开始的数学索引惯例,而不是 Python 从 0 开始的索引惯例。这实际上是我用于层内神经元的相同惯例,因此输出层(第二层)的激活是按 (a[1]² , a[2]² , a[3]² ,..., a[10]¹) 索引的。这些向量之间的平方距离是它们的和:

作为另一个可能令人困惑的点,a 值上方的上标 2 表示我们的网络中的输出层是第二层,而括号外的 2 表示对数量进行平方。为了得到相对于数据集的总成本,你可以评估所有样本图像的神经网络并取平均平方距离。在本节的末尾,你可以尝试迷你项目,在 Python 中自己实现这一功能。

16.5.2 使用反向传播计算梯度

使用在 Python 中编写的成本函数 C(w[11]¹ ,w[12]¹ , ..., b[1]¹ , b[2]¹ , ...),我们可以编写一个 1,210 维的梯度下降版本。这意味着在每一步都要计算 1,210 个偏导数以获得一个梯度。这个梯度将是该点的 1,210 维偏导数向量,具有以下形式

估计如此多的偏导数在计算上会很昂贵,因为每个都需要评估 C 两次以测试调整其输入变量的效果。反过来,评估 C 需要查看训练集中的每一张图片并通过网络传递。这可能是有可能的,但对于我们这样的大多数现实世界问题,计算时间会过长。

相反,计算偏导数最好的方法是使用与我们在第十章中介绍的方法类似的方法找到它们的精确公式。我不会完全介绍如何做这件事,但我会给你一个预告,在最后一节。关键是虽然需要计算 1,210 个偏导数,但它们都具有以下形式:

或者

对于某些选择的索引 lij。反向传播算法递归地计算所有这些偏导数,从输出层的权重和偏差反向工作到第一层。

如果你想了解更多关于反向传播的信息,请关注本章的最后部分。现在,我将转向 scikit-learn 库来计算成本、执行反向传播并自动完成梯度下降。

16.5.3 使用 scikit-learn 进行自动训练

使用 scikit-learn 训练 MLP 不需要任何新概念;我们只需要告诉它以与我们相同的方式设置问题,然后找到答案。我不会解释 scikit-learn 库能做什么,但我将逐步引导你通过代码来训练用于数字分类的 MLP。

第一步是将所有我们的训练数据(在这种情况下,数字图像作为 64 维向量)放入一个单一的 NumPy 数组中。使用数据集中的前 1,000 个图像给我们一个 1,000-by-64 的矩阵。我们还将前 1,000 个答案放入一个输出列表中:

*x* = np.array([np.matrix.flatten(img) for img in digits.images[:1000]]) / 15.0
y = digits.target[:1000]

接下来,我们使用 scikit-learn 提供的 MLP 类来初始化一个 MLP。输入和输出层的尺寸由数据决定,所以我们只需要指定中间隐藏层的尺寸。此外,我们还包括参数来告诉 MLP 我们希望如何训练它。以下是代码:

from sklearn.neural_network import MLPClassifier

mlp = MLPClassifier(hidden_layer_sizes=(16,),  ❶
                    activation='logistic',     ❷
                    max_iter=100,              ❸
                    verbose=10,                ❹
                    random_state=1,            ❺
                    learning_rate_init=.1)     ❻

❶ 指定我们想要一个包含 16 个神经元的隐藏层

❷ 指定我们希望在网络上使用逻辑(普通 sigmoid)激活函数

❸ 设置梯度下降步骤的最大数量,以防出现收敛问题

❹ 选择训练过程提供详细日志

❺ 使用随机权重和偏差初始化 MLP

❻ 决定学习率或每次梯度下降迭代中移动梯度的倍数

一旦完成这项工作,我们就可以在一行中将神经网络训练到输入数据 x 和相应的输出数据 y

mlp.fit(x,y)

当你运行这一行代码时,你会在终端窗口中看到许多文本打印出来,作为神经网络训练的日志。这些日志显示了它需要多少次梯度下降迭代以及成本函数的值,scikit-learn 将其称为“损失”而不是“成本。”

Iteration 1, loss = 2.21958598
Iteration 2, loss = 1.56912978
Iteration 3, loss = 0.98970277
...
Iteration 58, loss = 0.00336792
Iteration 59, loss = 0.00330330
Iteration 60, loss = 0.00321734
Training loss did not improve more than tol=0.000100 for two consecutive epochs. Stopping.

在这一点上,经过 60 次梯度下降迭代后,找到了最小值,MLP 已经被训练。你可以使用 _predict 方法在图像向量上测试它。此方法接受一个输入数组,意味着一个 64 维向量的数组,并返回所有这些向量的输出向量。例如,mlp._predict(*x*) 给出存储在 x 中的所有 1,000 个图像向量的 10 维输出向量。对于零^(th) 个训练示例的结果是结果中的零^(th) 个条目:

>>> mlp._predict(*x*)[0]
array([9.99766643e-01, 8.43331208e−11, 3.47867059e-06, 1.49956270e-07,
       1.88677660e-06, 3.44652605e-05, 6.23829017e-06, 1.09043503e-04,
       1.11195821e-07, 7.79837557e-05])

这些数字在科学记数法中需要仔细观察,但第一个是 0.9998,而其他所有数字都小于 0.001。这正确地预测了零^(th) 个训练示例是一张数字 0 的图片。到目前为止一切顺利!

通过一个小包装器,我们可以编写一个函数,使用这个 MLP 进行 一次 预测,接受一个 64 维图像向量并输出一个 10 维结果。因为 scikit-learn 的 MLP 在输入向量的集合上工作并产生结果数组,我们只需要在传递给 mlp._predict 之前将我们的输入向量放入一个列表中:

def sklearn_trained_classify(*v*):
    return mlp._predict([v])[0]

在这一点上,向量具有正确的形状,可以通过我们的 test_digit_classify 函数测试其性能。让我们看看它正确识别测试数字图像的百分比:

>>> test_digit_classify(sklearn_trained_classify)
1.0

这是一个惊人的 100%准确率!你可能对这个结果持怀疑态度;毕竟,我们是在使用神经网络训练所用的相同数据集进行测试。从理论上讲,当存储 1,210 个数字时,神经网络可能只是记住了训练集中的每一个例子。如果你测试神经网络之前未见过的新图像,你会发现情况并非如此;它仍然能够出色地将图像正确分类为数字。我发现它在数据集中接下来的 500 个图像上的准确率为 96.2%,你可以在练习中自己测试这一点。

16.5.4 练习

练习 16.11:修改test_digit_classify函数,使其能够在测试集的定制范围内工作。它在 1,000 个训练例子之后的下一个 500 个例子上的表现如何?

| 解答:在这里,我添加了一个start关键字参数来指示从哪个测试例子开始。test_count关键字参数仍然表示要测试的例子数量:

def test_digit_classify(classifier,start=0,test_count=1000):
    correct = 0
    end = start + test_count                           ❶
    for img, target in zip(digits.images[start:end], 
digits.target[start:end]):                             ❷
        v  = np.matrix.flatten(img) / 15
        output = classifier(*v*)
        answer = list(output).index(max(output))
        if answer == target:
            correct += 1
    return (correct/test_count)

❶ 计算我们想要考虑的测试数据的结束索引❷ 只在起始和结束索引之间的测试数据上循环 My trained MLP 正确识别了 96.2%的这些新数字图像:

>>> test_digit_classify(sklearn_trained_classify,start=1000,test_count=500)
0.962

|

| 练习 16.12:使用平方距离成本函数,你的随机生成的 MLP 在第一个 1,000 个训练例子上的成本是多少?scikit-learn 的 MLP 的成本是多少?解答:首先,我们可以编写一个函数来给出给定数字的理想输出向量。例如,对于数字 5,我们希望输出向量y,除了第五个索引处有一个 1 之外,其余都是 0。

def y_vec(digit):
    return np.array([1 if i == digit else 0 for i in range(0,10)])

一个测试例子的成本是分类器输出与理想结果之间的平方距离之和。这就是坐标差的平方和的总和:

def cost_one(classifier,x,i):
    return sum([(classifier(*x*)[j] − y_vec(i)[j])**2 for j in range(10)])

一个分类器的总成本是所有 1,000 个训练例子上的平均成本:

def total_cost(classifier):
    return sum([cost_one(classifier,x[j],y[j]) for j in range(1000)])/1000.

如预期的那样,一个随机初始化的 MLP,其预测准确率为 10%,比由 scikit-learn 生成的 100%准确率的 MLP 具有更高的成本:

>>> total_cost(nn.evaluate)
8.995371023185067
>>> total_cost(sklearn_trained_classify)
5.670512721637246e-05

|

| 练习 16.13-迷你项目:使用MLPClassifier的属性coefs_intercepts_分别提取MLPClassifier的权重和偏置。将这些权重和偏置插入到本章前面从头构建的MLP类中,并展示你的 MLP 在数字分类上的表现良好。解答:如果你尝试这样做,你会注意到一个问题;我们期望权重矩阵是 16-by-64 和 10-by-16,而MLPClassifiercoefs_属性给出的是一个 64-by-16 的矩阵和一个 16-by-10 的矩阵。看起来 scikit-learn 使用了一个与我们不同的惯例来存储权重矩阵的列。有一个快速的方法可以解决这个问题。NumPy 数组有一个T属性,返回矩阵的转置(通过旋转矩阵使得行成为结果的列)。有了这个技巧,我们可以将权重和偏置插入到我们的神经网络中,并对其进行测试:

>>> nn = MLP([64,16,10])
>>> nn.weights = [w.T for w in mlp.coefs_]       ❶
>>> nn.biases = mlp.intercepts_                  ❷
>>> test_digit_classify(nn.evaluate,
                        start=1000,
                        test_count=500) 0.962    ❸

❶ 将我们的权重矩阵设置为 scikit-learn MLP 中的矩阵,在转置后与我们的约定一致❷ 将我们的网络偏差设置为 scikit-learn MLP 中的偏差❸ 使用新的权重和偏差测试我们的神经网络在分类任务中的性能 This is 96.2% accurate on the 500 images after the training data set, just like the MLP produced by scikit-learn directly. |

16.6 使用反向传播计算梯度

这一节完全是可选的。坦白说,因为你已经知道如何使用 scikit-learn 训练 MLP,你现在可以解决实际问题了。你可以在分类问题上测试不同形状和大小的神经网络,并尝试它们的结构设计以提高分类性能。因为这是本书的最后一节,我想给你一些最后的、具有挑战性(但可行!)的数学问题来思考−手动计算成本函数的偏导数。

计算多层感知器(MLP)的偏导数的过程称为 反向传播,因为它从最后一层的权重和偏差开始,逆向工作非常高效。反向传播可以分为四个步骤:计算与最后一层权重、最后一层偏差、隐藏层权重和隐藏层偏差的偏导数。我将向您展示如何获取与最后一层权重相关的偏导数,您可以用这种方法尝试完成剩余部分。

16.6.1 以最后一层权重表示的成本

让我们称 MLP 的最后一层的索引为 L。这意味着最后一层的权重矩阵由权重 w[ij]^L 组成,其中 l = L,换句话说,权重 w[ij]^L。这一层的偏差是 b jL,激活值标记为 a[j]^L

获取最后一层中第 j 个神经元的激活值 a[j]^L 的公式是层 L 中每个神经元贡献的总和,这些贡献由索引 i 表示。用虚构的符号表示,它变为

图像

求和是对层 L 中从 1 到神经元数量的所有 i 值进行的。让我们将层 l 中的神经元数量写成 ni,其中 iln[L][−1] 在我们的求和中。在适当的数学求和符号中,这个求和写成:

图像

这个公式的英文翻译是“通过将 Lj 的值固定,将表达式 w[ij]^L a[i]^(L−1) 的值从 1 到 n[L] 的每个 i 相加。”这不过是将矩阵乘法写成求和的形式。在这种形式下,激活如下:

图像

给定一个实际的训练示例,我们可以有一个理想的输出向量 y,其中正确的槽位为 1,其他地方为 0。成本是激活向量 a[j]^L 和理想输出值 y[j] 之间的平方距离。即,

图像

权重 w[ij]^LC 的影响是间接的。首先,它被前一层的一个激活值乘以,加上偏差,通过 sigmoid 函数,然后通过二次成本函数。幸运的是,我们在第十章中已经介绍了如何求函数复合的导数。这个例子稍微复杂一些,但你应该能够认出它为之前看到的相同的链式法则。

16.6.2 使用链式法则计算最后一层权重的偏导数

让我们将它分解为三个步骤,从 w[ij]^LC。首先,我们可以计算传递给 sigmoid 函数的值,我们之前在章节中称之为 z[j]^L

图片

然后,我们可以将 z[j]^L 传递给 sigmoid 函数以获得激活 a[j]^L

图片

最后,我们可以计算成本:

图片

要找到 Cw[ij]^L 的偏导数,我们需要将这些三个“复合”表达式的导数相乘。z[j]^L 对一个特定的 w[ij]^L 的导数是它乘以的特定激活 a[j]^L [−1]。这与 y(x) = axx 的导数相似,其导数是常数 a。偏导数是

图片

下一步是应用 sigmoid 函数,所以 a[j]^Lz[j]^L 的导数是σ的导数。实际上,你可以作为一个练习来验证这一点,σ(x)的导数是σ(x)(1 − σ(x))。这个漂亮的公式部分来自于 ex 是它自己的导数这一事实。这给了我们

图片

这是一个普通导数,而不是偏导数,因为 a[j]^L 只是一个输入的函数:z[j]^L。最后,我们需要求 Ca[j]^L 的导数。求和中的只有一个项依赖于 w[ij]^L,所以我们只需要求 (a[j]^Ly[j])² 对 a[j]^L 的导数。在这个上下文中,y[j] 是一个常数,所以导数是 2a[j]^L。这来自于幂规则,告诉我们如果 f(x) = x²,那么 f'(x) = 2x。对于我们的最后一个导数,我们需要

图片

链式法则的多变量版本如下所示:

图片

这看起来与我们在第十章中看到的版本有点不同,当时只涉及一个变量的两个函数的复合。但原理在这里是相同的:将 Ca[j]^L 表示,将 a[j]^Lz[j]^L 表示,将 z[j]^Lw[ij]^L 表示,我们就有 Cw[ij]^L 表示。链式法则告诉我们,要得到整个链的导数,我们需要将每一步的导数相乘。将导数代入,结果是

图片

这个公式是我们需要找到整个梯度C的四个公式之一。具体来说,这给出了最后一层中任何权重的偏导数。这里有 16 × 10 个这样的,所以我们已经覆盖了 1,210 个总偏导数中的 160 个。

我会在这里停止,因为其他权重的导数需要更复杂的链式法则的应用。激活影响神经网络中的每个后续激活,所以每个权重影响每个后续激活。这并不超出你的能力范围,但我感觉在深入挖掘之前,我需要给你一个更好的多元链式法则的解释。如果你有兴趣深入了解,网上有很好的资源,详细介绍了反向传播的所有步骤。否则,你可以期待这本书的( fingers crossed)续集。感谢阅读!

16.6.3 练习

| 练习 16.14-迷你项目:使用 SymPy 或第十章中的代码来自动找到 sigmoid 函数的导数图证明你得到的答案是σ(x)(1 − σ(x))。解答:在 SymPy 中,我们可以快速得到导数的公式:

>>> from sympy import *
>>> X = symbols('x')
>>> diff(1 / (1+exp(-X)),X)
exp(-x)/(1 + exp(-x))**2

用数学符号表示,那就是图证明这个表达式等于σ(x)(1 − σ(x))需要一点死记硬背的代数,但这是值得的,以使你自己相信这个公式是有效的。将分子和分母都乘以ex*,并注意到*ex · e^(−x) = 1,我们得到图 |

摘要

  • 人工神经网络是一种数学函数,其计算过程与人类大脑中信号流动的流程相似。作为一个函数,它接受一个向量作为输入,并返回另一个向量作为输出。

  • 神经网络可以用来对向量数据进行分类:例如,将图像转换为灰度像素值的向量。神经网络的输出是一个表示输入向量应该被分类到任何可能的类别中的置信度的数字向量。

  • 多层感知器(MLP)是一种特殊的人工神经网络,由几个有序的神经元层组成,其中每一层的神经元都与前一层的神经元相连并受到其影响。在评估神经网络时,每个神经元都会得到一个表示其激活的数字。你可以将激活看作是在解决分类问题过程中,对中间是或否答案的肯定。

  • 为了评估一个神经网络,神经元第一层的激活被设置为输入向量的条目。每个后续层的激活都是作为前一层的一个函数来计算的。最后一层的激活被当作一个向量并作为计算的结果向量返回。

  • 神经元的激活基于前一层中所有神经元的激活的线性组合。线性组合中的系数被称为权重。每个神经元还有一个偏差,这是一个加到线性组合中的数字。这个值通过 sigmoid 函数传递以获得激活函数。

  • 训练神经网络意味着调整所有权重和偏差的值,以便它能够最优地执行其任务。为此,你可以使用成本函数来衡量神经网络预测的错误相对于训练数据集中实际答案的错误。在固定的训练数据集下,成本函数只依赖于权重和偏差。

  • 梯度下降法使我们能够找到权重和偏差的值,以最小化成本函数并得到最佳的神经网络。

  • 神经网络可以高效地训练,因为成本函数关于权重和偏差的偏导数有简单的、精确的公式。这些公式是通过一种称为反向传播的算法找到的,该算法反过来又利用了微积分中的链式法则。

  • Python 的 scikit-learn 库有一个内置的MLPClassifer类,它可以自动对分类向量数据进行训练。

附录 A:配置 Python 环境

本附录涵盖了安装 Python 和相关工具的基本步骤,以便你可以运行本书中的代码示例。主要要安装的是 Anaconda,这是一个流行的 Python 发行版,适用于数学编程和数据科学。具体来说,Anaconda 包含一个运行 Python 代码的解释器,以及一些最受欢迎的数学和数据科学库,还有一个名为 Jupyter 的编码接口。在任何 Linux、Mac 或 Windows 机器上,步骤大致相同。我将向你展示在 Mac 上的步骤。

A.1 检查现有 Python 安装

有可能你的电脑上已经安装了 Python,即使你没有意识到。要检查现有安装,打开一个终端窗口(或在 Windows 上打开 CMD 或 PowerShell)并输入python。在出厂的 Mac 上,你应该会看到一个 Python 2.7 终端出现。你可以按 Ctrl-D 退出终端。

本书中的示例使用 Python 3,它正在成为新的标准,并且特别使用 Anaconda 发行版。作为警告,如果你有一个现有的 Python 安装,以下步骤可能会有些棘手。如果你遇到以下任何指令不起作用,我最好的建议是使用你看到的任何错误信息在 Google 或 StackOverflow 上搜索。

如果你是一位 Python 专家,并且不想安装或使用 Anaconda,你应该能够使用 pip 包管理器找到并安装相关的库,如 NumPy、Matplotlib 和 Jupyter。对于初学者,我强烈建议按照以下步骤安装 Anaconda。

A.2 下载和安装 Anaconda

访问www.anaconda.com/distribution/下载 Anaconda Python 发行版。点击下载并选择以 3 开头的 Python 版本(图 A.1)。在撰写本文时,这是 Python 3.7。

图 A.1 在撰写本文时,点击 Anaconda 网站上的下载后我会看到什么。要安装 Python,请选择 Python 3.x 下载链接。

打开安装程序。它会引导你完成安装过程。安装程序对话框的外观取决于你的操作系统,但图 A.2 显示了在我的 Mac 上的样子。

图 A.2 在我的 Mac 上出现的 Anaconda 安装程序

我使用了默认的安装位置,并且没有添加任何可选功能,如 PyCharm IDE。一旦安装完成,你应该能够打开一个新的终端。输入python以进入带有 Anaconda 的 Python 3 会话(图 A.3)。

图 A.3 安装 Anaconda 后 Python 交互会话应该看起来像什么。注意出现的 Python 3.7.3 和 Anaconda, Inc.标签。

如果你没有看到以数字 3 和单词Anaconda开头的 Python 版本,这可能意味着你卡在了系统上之前的 Python 安装上。你需要编辑你的 PATH 环境变量,以便在终端中输入python时,终端知道你想要哪个 Python 版本。希望你不会遇到这个问题,但如果遇到,你可以在网上搜索解决方案。你也可以输入python3而不是python来显式使用你的 Python 3 安装。

A.3 使用 Python 的交互模式

在终端窗口中,三个尖括号(>>>)提示你输入一行 Python 代码。当你输入2+2并按 Enter 键时,你应该看到 Python 解释器对这个语句的评估,结果是4(图 A.4)。

APPA_F04_Orland

图 A.4 在交互会话中输入一行 Python 代码

交互模式也被称为 REPL(read-evaluate-print loop,即读取-评估-打印循环)。Python 会话读取一行输入的代码,评估它,并打印结果,这个过程可以循环多次。按下 Ctrl-D 表示你已完成代码输入,并返回到你的终端会话。

Python 交互通常可以识别你是否输入了多行语句。例如,def f(x):是在定义名为f的新 Python 函数时输入的第一行。Python 交互会话显示...以表示它期望更多的输入(图 A.5)。

APPA_F05_Orland

图 A.5 Python 解释器知道你还没有完成你的多行语句。

你可以通过缩进来增强函数,然后你需要按两次 Enter 键来让 Python 知道你已经完成了多行代码输入并实现函数(图 A.6)。

APPA_F06_Orland

图 A.6 完成你的多行代码后,你需要按两次 Enter 键来提交它。

函数f现在已经在你的交互会话中定义了。在下一行,你可以给 Python 输入以进行评估(图 A.7)。

APPA_F07_Orland

图 A.7 评估之前定义的函数

注意,你在交互会话中编写的任何代码在退出会话时都会消失。因此,如果你要编写大量代码,最好将其放入脚本文件或 Jupyter 笔记本中。我将在下一节中介绍这两种方法。

A.3.1 创建和运行 Python 脚本文件

你可以使用你喜欢的任何文本编辑器创建 Python 文件。通常,使用专为编程设计的文本编辑器比使用像 Microsoft Word 这样的富文本编辑器更好,因为后者可能会插入不可见或不需要的字符进行格式化。我的首选是 Visual Studio Code,其他流行的选择包括跨平台的 Atom 和 Windows 的 Notepad++。在自行承担风险的情况下,你可以使用基于终端的文本编辑器,如 Emacs 或 Vim。所有这些工具都是免费的,并且可以轻松下载。

要创建一个 Python 脚本,只需在你的编辑器中创建一个以 .py 为文件扩展名的新的文本文件。图 A.8 显示我已经创建了一个名为 first.py 的文件,它位于我的 ~/Documents 目录中。你还可以在图 A.8 中看到,我的文本编辑器 Visual Studio Code 自带 Python 的语法高亮功能。关键字、函数和字面量值被着色,以便于阅读代码。许多编辑器(包括 Visual Studio Code)都有可安装的扩展,这些扩展可以为你提供更多有用的工具,例如在输入时检查简单错误等。

图片

图 A.8 文件中的某些示例 Python 代码。此代码打印出从 0 到 9 所有数字的平方。

图 A.8 展示了输入到 first.py 文件中的几行 Python 代码。因为这是一本数学书,我们可以使用一个比“Hello World”更“数学化”的例子。当我们运行代码时,它会打印出从 0 到 9 所有数字的平方。

在终端中,转到你的 Python 文件所在的目录。在我的 Mac 上,我通过输入 cd ~/Documents 来转到 ~/Documents 目录。你可以输入 ls first.py 以确认你与你的 Python 脚本在同一目录中(图 A.9)。

图片

图 A.9 ls 命令显示文件 first.py 在目录中。

要执行脚本文件,请在终端窗口中输入 python first.py。这会调用 Python 解释器,并告诉它运行 first.py 文件。解释器做了我们希望的事情,并打印了一些数字(图 A.10)。

图片

图 A.10 从命令行运行简单 Python 脚本的输出结果

当你解决更复杂的问题时,你可能想要将你的代码拆分成单独的文件。接下来,我将向你展示如何将函数 f(x) 放入一个不同的 Python 文件中,该文件可以被 first.py 使用。让我们称这个新文件为 function.py,并将其保存在与 first.py 相同的目录中,然后将 f(x) 的代码剪切并粘贴到其中(图 A.11)。

图片

图 A.11 将定义函数 f(x) 的代码放入其自己的 Python 文件中

要让 Python 知道你打算将此目录中的多个文件组合在一起,你需要在目录中添加一个名为 init.py 的空文本文件。(在单词 init 前后各有两个下划线。)

小贴士:在 Mac 或 Linux 机器上创建此空文件的一个快速方法是输入 touch __init__.py

要在脚本 first.py 中使用 function.py 中的函数 f(x),我们需要让 Python 解释器知道去检索它。为此,我们在 first.py 的第一行写入 from function import f(图 A.12)。

图片

图 A.12 将文件 first.py 重写以包含函数 f(x)

当你再次运行命令 python first.py 时,你应该得到与上次运行相同的结果。这次,Python 正在从 function.py 文件中获取函数 f

除了在文本文件中完成所有工作并通过命令行运行它们之外,还可以使用 Jupyter 笔记本,我将在下一节中介绍。对于这本书,我大部分的例子都是在 Jupyter 笔记本中完成的,但我将任何可重用的代码写在单独的 Python 文件中,并导入这些文件。

A.3.2 使用 Jupyter 笔记本

Jupyter 笔记本是一个用于在 Python(以及其他语言)中进行编码的图形界面。与 Python 交互会话一样,你在 Jupyter 笔记本中输入代码行,它会打印结果。区别在于,Jupyter 的界面比你的终端更美观,你可以保存你的会话以供稍后恢复或重新运行。

Jupyter Notebook 应该会自动与 Anaconda 一起安装。如果你使用的是不同的 Python 发行版,你也可以使用 pip 安装 Jupyter。有关自定义安装的文档,请参阅 jupyter.org/install

要打开 Jupyter 笔记本界面,在你想工作的目录中输入 jupyter notebookpython -m notebook。你应该会在终端中看到大量的文本流过,并且你的默认网页浏览器应该会打开,显示 Jupyter 笔记本界面。

图 A.13 展示了我在终端中输入 python -m notebook 后看到的内容。再次强调,具体效果可能因你拥有的 Anaconda 版本而异。

图片

图 A.13 打开 Jupyter 笔记本时终端的外观

你的默认网页浏览器应该会打开,显示 Jupyter 界面。图 A.14 展示了我在 Google Chrome 浏览器中打开 Jupyter 时的界面。

图片

图 A.14 当你启动 Jupyter 时,浏览器标签页会自动打开,看起来像这样。

这里发生的情况是,终端在后台运行 Python 并在地址 localhost:8888 上提供本地网站。从现在开始,你只需要考虑浏览器中发生的事情。浏览器会自动通过网页请求将你编写的代码发送到终端中的 Python 进程。在 Jupyter 术语中,这个 Python 后台进程被称为 内核

在浏览器打开的第一个屏幕上,你可以看到你正在工作的目录中的所有文件。例如,我打开了位于 ~/Documents 文件夹中的笔记本,因此我可以看到我们在上一节中编写的 Python 文件。如果你点击其中一个文件,你会看到你可以在网页浏览器中直接查看和编辑它。图 A.15 展示了当我点击 first.py 时看到的界面。

图片

图 A.15 Jupyter 为 Python 文件提供了一个基本的文本编辑器。在这里,我打开了 first.py 文件。

这还不是一个笔记本。笔记本是一种不同于普通 Python 文件的文件类型。要创建一个笔记本,通过点击左上角的 Jupyter 标志返回主视图,然后转到右侧的“新建”下拉菜单,并点击 Python 3(图 A.16)。

图片

图 A.16 选择创建新的 Python 3 笔记本的菜单选项

一旦您点击 Python 3,您将被带到您的新笔记本。它应该看起来像图 A.17 中所示,其中有一个空白输入行,准备好接受一些 Python 代码。

图片

图 A.17 一个新的、空的 Jupyter 笔记本,准备进行编码

您可以在文本框中输入 Python 表达式,然后按 Shift-Enter 来评估它。在图 A.18 中,我输入了2+2并按 Shift-Enter 来查看输出,4

图片

图 A.18 在 Jupyter 笔记本中评估 2 + 2

如您所见,它的工作方式就像一个交互式会话一样,只是看起来更美观。每个输入都显示在一个框中,相应的输出显示在其下方。

如果您只是按 Enter 而不是 Shift-Enter,您可以在输入框内添加新行。在界面中,上方框中定义的变量和函数可以被下方的框使用。图 A.19 显示了我们的原始示例在 Jupyter 笔记本中的样子。

图片

图 A.19 在 Jupyter 笔记本中编写和评估几个 Python 代码片段。注意输入框和相应的输出。

严格来说,每个框不依赖于上面的框,而是依赖于您之前评估的框。例如,如果我在下一个输入框中重新定义函数f(x),然后重新运行前面的框,我将覆盖之前的输出(图 A.20)。

图片

图 A.20 如果您在之前的输出下方重新定义了像f这样的符号,然后重新运行上面的框,Python 将使用新的定义。将此图与图 A.19 进行比较,以查看新的单元格。

这可能会让人困惑,但至少 Jupyter 在运行时会重新编号输入框。为了可重复性,我建议您在第一次使用之前定义变量和函数。您可以通过点击菜单项“内核”>“重启并运行所有”(图 A.21)来确认您的代码从上到下运行正确。这将清除您所有的现有计算,但如果您保持组织有序,您应该会得到相同的结果。

图片

图 A.21 使用“重启并运行所有”菜单项清除输出并从上到下运行所有输入

您的笔记本在您编码时会自动保存。当您完成编码后,您可以通过点击屏幕顶部的“未命名”来命名笔记本,并输入一个新名称(图 A.22)。

图片

图 A.22 给您的笔记本命名

然后您可以再次点击 Jupyter 标志返回主菜单,您应该会看到您的新的笔记本已保存为具有.ipynb 扩展名的文件(图 A.23)。如果您想返回您的笔记本,您可以点击其名称来打开它。

图片

图 A.23 您的新 Jupyter 笔记本出现。

小贴士 为了确保所有文件都已保存,你应该通过点击“退出”来关闭 Jupyter,而不是仅仅关闭浏览器标签页或停止交互过程。

如需了解有关 Jupyter 笔记本更详细的信息,您可以查阅jupyter.org/上的全面文档。然而,到目前为止,您已经掌握了足够的信息来下载并尝试这本书的源代码,该代码几乎组织在 Jupyter 笔记本中,用于几乎所有章节。

附录 B. Python 技巧与窍门

在遵循附录 A 中的设置说明后,你应该能够在你的计算机上运行 Python 代码。如果你是 Python 新手,下一步是学习一些语言特性。如果你以前从未见过 任何 Python,不要担心!它是目前最简单、最容易学习的编程语言之一。此外,还有许多优秀的在线资源和书籍可以帮助你学习 Python 编程的基础知识,而 python.org 是一个很好的起点。

本附录假设你已经对 Python 进行了一些实验,并且对基础知识(如数字、字符串、True 和 False、if/else 语句等)感到舒适。为了使这本书尽可能易于理解,我避免了使用高级 Python 语言特性。本附录涵盖了本书中使用的某些 Python 特性,这些特性要么超出了“基础知识”,要么因为它们在本书中的重要性而需要特别关注。如果你觉得这些内容太多,不要担心;当这些特性在书中出现时,我通常会包括一个快速回顾它们是如何工作的。本附录中的所有代码都在源代码中的“walkthrough”笔记本中进行了覆盖。

B.1 Python 数字和数学

与大多数语言一样,Python 内置了对基本数学的支持。我假设你已经熟悉了基本的 Python 算术运算符:+-*/。请注意,在 Python 3 中,当你除以整数时,你可以得到一个分数值,例如,

>>> 7/2
3.5

与此相反,在 Python 2 中,这将返回 2,这是整数除法的结果,余数 1 被丢弃。但有时我们想要得到余数,在这种情况下,我们可以使用 % 运算符,称为 模数 运算符。运行 13 % 5 返回 3,这告诉我们 13 除以 5 的余数是 3(即 13 = 2 × 5 + 3)。请注意,模数运算符也适用于浮点数。特别是,你可以用它来获取一个数的分数部分作为除以 1 时的余数。运行 3.75 % 1 返回 0.75

除了基本的前四个运算符之外,还有一个有用的运算符是 ** 运算符,它可以将数字提升到指定的幂。例如,2 ** 3 表示 2 的三次幂,即 2 的立方,等于 8。同样,4 ** 2 是 4 的平方,等于 16。

在 Python 中进行数学运算时,还需要注意的一点是浮点数运算不是精确的。我不会深入解释为什么是这样,但我会向你展示它看起来是什么样子,这样你就不会感到意外。例如,1000.1 - 1000.0 显然是 0.1,但 Python 并没有精确地计算这个值:

>>> 1000.1 − 1000.0
0.10000000000002274

当然,这个结果与正确答案相差一万亿分之一,所以它不会给我们带来问题,但它可能导致看起来不正确的结果。例如,我们期望 (1000.1 - 1000.0) - 0.1 等于零,但 Python 给我们一个庞大、复杂的看起来不正确的结果:

>>> (1000.1 − 1000.0) − 0.1
2.273181642920008e-14

这个长数字用科学记数法表示,大约是 2.27 乘以 10 的负 14 次方。10 的负 14 次方等同于 1/100,000,000,000,000(1 除以后面跟着 14 个零,或者 1 除以 100 万亿),所以这个数字实际上非常接近于零。

B.1.1 数学模块

Python 有一个包含更多有用数学值和函数的数学模块。像任何 Python 模块一样,你需要从其中导入你想要使用的对象。例如,

from math import pi

math 模块导入变量 pi,它代表数字 π。你可能还记得从几何课上学到的 π;它是圆周长与其直径的比值。导入 pi 值后,我们可以在后续代码中像使用任何其他变量一样使用它:

>>> pi
3.141592653589793
>>> tau = 2 * pi
>>> tau
6.283185307179586

在 Python 中访问模块中的值的另一种方法是导入模块本身,然后根据需要访问值。在这里,我导入了 math 模块,然后使用它来访问数字 π 和我们将在几处遇到的另一个特殊数字 e

>>> import math
>>> math.pi
3.141592653589793
>>> math.e
2.718281828459045

数学模块还包含了一些我们在书中会用到的非常重要的函数。其中就包括平方根函数 sqrt,三角函数 cossin,指数函数 exp 和自然对数函数 log。我们将在需要时逐一介绍这些函数,但你现在需要知道的重要一点是,你可以像调用普通的 Python 函数一样调用它们,在括号中提供它们的输入值:

>>> math.sqrt(25)
5.0
>>> math.sin(pi/2)
1.0
>>> math.cos(pi/3)
0.5000000000000001
>>> math.exp(2)
7.38905609893065
>>> math.log(math.exp(2))
2.0

作为对指数函数的快速提醒,math.exp(x) 对于任何 x 的值都等同于 math.e** xmath.log 函数可以取消 math.exp 的效果。三角函数在第二章中介绍。

B.1.2 随机数

有时候我们想要选择一些任意的数字来测试我们的计算,我们可以使用 Python 的随机数生成器来做这件事。这些生成器存储在 random 模块中,因此我们首先需要导入它:

import random

这个模块中第一个重要的函数是 randint,它从一个给定的范围内返回一个随机选择的浮点数。如果你运行 random.randint(0,10),你将得到一个从 0 到 10 的随机选择的整数,0 和 10 都是可能的输出:

>>> random.randint(0,10)
7
>>> random.randint(0,10)
1

我们用于生成随机数的另一个函数是 random.uniform,它在指定的区间内生成一个随机浮点数。以下代码返回一个在 7.5 和 9.5 之间的随机选择的数字:

>>> random.uniform(7.5, 9.5)
8.200084576283352

词语 uniform 表示没有任何子范围比其他子范围更有可能。相比之下,如果你随机挑选人群并返回他们的年龄,你会得到一个 非均匀 的随机数分布,这意味着你会在 10-20 岁之间找到比在 100-110 岁之间更多的人。

B.2 Python 中的数据集合

在本书的整个过程中,我们处理涉及 集合 的数学。这些可以是表示平面中点的有序数对、表示来自现实世界的测量数据的数字列表,或者代数表达式中的符号集合。Python 有多种方式来模拟集合,在本节中,我将介绍它们并进行比较。

B.2.1 列表

Python 中最基本的集合类型是列表。要创建一个列表,只需在方括号内包围一些值并用逗号分隔它们即可。以下是一个包含三个字符串的列表,保存为名为 months 的变量:

months = ["January", "February", "March"]

我们可以通过索引(复数,indices)或列表中的数值位置来检索列表中的条目。在 Python 中,列表是 零索引 的,这意味着条目是编号的,从零开始计数而不是从一。在 months 列表中,三个索引是 0、1 和 2。因此,我们可以得到

>>> months[0]
'January'
>>> months[1]
'February'
>>> months[2]
'March'

尝试访问超出有效索引范围的列表条目会返回一个错误。例如,没有 months[3]months[17]。我在书中的一些地方使用了一个技巧,即对索引使用取模运算符以确保有效的条目。对于任何 Python 整数 n,表达式 months[n % 3] 总是有效的,因为 n % 3 总是返回 012

另一种访问列表条目的方式是 解包 它们。如果我们确信月份列表中有三个条目,我们可以写

j, f, m = months

这将变量 jfm 分别设置为 months 中的三个值。运行此代码后,我们有

>>> j
'January'
>>> f
'February'
>>> m
'March'

我们可以用列表的 连接 或按顺序组合它们来做的另一件基本事情。在 Python 中,这是通过 + 运算符完成的。将 [1, 2, 3][4, 5, 6] 连接起来,我们得到一个新列表,其条目顺序是第一个列表的条目后跟第二个列表的条目:

>>> [1,2,3] + [4,5,6]
[1, 2, 3, 4, 5, 6]

更多列表索引和切片

Python 还允许你从列表中提取一个 切片,即两个索引之间的所有值的列表。例如,

>>> months[1:3]
['February', 'March']

给出从索引 1 开始到(但不包括)索引 3 的切片。为了更清楚地说明,我们可以看看一个条目等于其对应索引的列表:

>>> nums = [0,1,2,3,4,5,6,7,8,9,10]
>>> nums[2:5]
[2, 3, 4]

列表的长度可以用 len 函数来计算:

>>> len(months)
3
>>> len(nums)
11

因为列表的条目是从零开始索引的,所以列表中的最后一个条目的索引比列表长度少一。要获取列表(如 nums)的最后一个条目,我们可以写

>>> nums[len(nums)-1]
10

要获取列表的最后一个条目,你也可以使用

>>> nums[-1]
10

同样地,nums[-2] 返回 nums 列表的倒数第二个条目,即 9。组合正负索引和切片的方式有很多种。例如,nums[1:] 返回列表中除了第一个条目(索引为零)之外的所有条目,而 nums[3:-1] 返回从索引 3 开始直到倒数第二个条目的 nums 中的条目:

>>> nums[1:]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> nums[3:-1]
[3, 4, 5, 6, 7, 8, 9]

确保不要混淆涉及两个索引的切片语法,与从列表中检索条目,这也涉及两个索引。对于一个像这样的列表

list_of_lists = [[1,2,3],[4,5,6],[7,8,9]]

数字 8 位于第三个列表(索引 2)中,并且是该列表中的第二个条目(索引 1),因此如果我们运行list_of_lists[2][1],我们得到8

迭代列表

经常当我们对列表进行计算时,我们希望使用列表中的每个值。这意味着迭代列表,访问其所有值。在 Python 中,最简单的方法是使用for 循环。以下for循环为months列表中的每个值打印一条语句:

>>> for x in months:
>>>     print('Month: ' + x)
Month: January
Month: February
Month: March

还可以通过从空列表开始,并使用append方法逐个添加条目来构建一个新的列表。以下代码创建了一个名为squares的空列表,然后遍历nums列表,通过调用squares.appendnums列表中每个数的平方添加到squares列表中:

squares = []
for n in nums:
    squares.append(n * n)

for循环结束时,squares包含nums中每个数的平方:

>>> squares
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

列表推导式

Python 有一种特殊的语法来迭代构建列表:列表推导式。列表推导式本质上是一种特殊的for循环,它位于方括号之间,表示在迭代的每一步中添加列表条目。列表推导式读起来像普通的英语,这使得理解它们的作用变得容易。例如,以下列表推导式构建了一个由nums列表中每个值x的平方x * x组成的列表:

>>> [x * x for x in nums]
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

在列表推导式中,可以迭代多个源列表。例如,以下代码遍历years列表和months列表的所有可能值,将每个年份和月份的组合转换为字符串:

>>> years = [2018,2019,2020]
>>> [m + " " + str(y) for y in years for m in months]
['January 2018',
 'February 2018',
 'March 2018',
 'January 2019',
 'February 2019',
 'March 2019',
 'January 2020',
 'February 2020',
 'March 2020']

类似地,我们可以通过将一个推导式放入另一个推导式中来构建一个列表的列表。通过添加另一对方括号,我们将推导式更改为对months列表中的每个值返回一个列表:

>>> [[m + " " + str(y) for y in years] for m in months]
[['January 2018', 'January 2019', 'January 2020'],
 ['February 2018', 'February 2019', 'February 2020'],
 ['March 2018', 'March 2019', 'March 2020']]

B.2.2 其他可迭代对象

在 Python 中,尤其是在 Python 3.x中,还有一些其他类型的集合。特别是,其中一些被称为可迭代,因为我们可以像列表一样迭代它们。在这本书中,最常用的是范围,它用于按顺序构建数字序列。例如,range(5,10)表示从 5 开始到(但不包括)10 的整数序列。如果你在 Python 中单独评估range(5,10),结果可能并不令人兴奋:

>>> range(5,10)
range(5, 10)

尽管范围没有显示组成它的数字,但我们仍然可以像列表一样迭代它:

>>> for i in range(5,10):
>>>    print(i)
5
6
7
8
9

由于范围不是列表,这允许我们使用非常大的范围,而无需一次性迭代它们。例如,range(0,1000000000)定义了一个包含十亿个数字的范围,我们可以迭代这些数字,但实际上它并没有存储十亿个数字。它只存储了在迭代过程中产生数字的指令。如果你想将一个可迭代的范围转换为列表,你只需要使用list函数将其转换:

>>> list(range(0,10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

获取连续整数的列表很有用,所以我们经常使用 range 函数。关于 range 函数的另一个注意事项是,它的某些参数是可选的。如果你只提供一个输入调用它,它将自动从零开始,并增加到输入的数字,如果你提供一个第三个参数,它将按该数字计数。例如,range(10) 从 0 计数到 9,而 range(0,10,3) 以 3 的增量从 0 计数到 9:

>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
>>> list(range(0,10,3))
[0, 3, 6, 9]

另一个返回其自身特殊类型可迭代对象的函数示例是 zip 函数。zip 函数接受两个长度相同的可迭代对象,并返回一个由第一和第二个可迭代对象中对应条目组成的可迭代对象:

>>> z = zip([1,2,3],["a","b","c"])
>>> z
<zip at 0x15fa8104bc8>
>>> list(z)
[(1, 'a'), (2, 'b'), (3, 'c')]

注意,并非所有可迭代对象都支持索引;z[2] 是无效的,因此你需要先将其转换为列表(例如 list(z)[2]),才能获取 zip z 的第三个条目。(范围支持索引,range(5,10)[3] 返回 8。)小心 - 一旦迭代过 zip,它就不再存在了!如果你打算重复使用它,立即将其转换为列表是个好主意。

B.2.3 生成器

Python 的 生成器 给你一种创建不一次性存储所有值,而是存储生成值的可迭代对象的方法。这允许我们定义大型甚至无限序列的值,而无需在内存中存储它们。生成器可以通过几种方式创建,其中最基本的方式看起来像是一个带有 yield 关键字而不是 return 的函数。区别在于生成器可以产生多个值,而函数最多返回一次然后结束。

这是一个表示从 0 开始的无限整数序列的生成器,1, 2, 3, 4,等等。while 循环会无限进行,在每次循环中,变量 x 被产生,然后增加 1。

def count():
    x = 0
    while True:
        yield x
        x += 1

尽管这代表了一个无限序列,但你可以在不损坏你的电脑的情况下运行 count()。它只返回一个生成器对象,而不是一个完整的值列表:

>>> count()
<generator object count at 0x0000015FA80EC750>

for x in count() 开头的 for 循环是有效的,但它会无限运行。以下是一个使用这种无限生成器在 for 循环中通过 break 跳出而不是无限迭代示例:

for x in count():
    if x > 1000:
        break
    else:
        print(x)

这是一个更实用的 count 生成器版本,它只产生有限数量的值。它的工作方式与 range 函数类似,从第一个输入值开始,并增加到第二个:

def count(a,b):
    x = a
    while x < b:
        yield x
        x += 1

count(10,20) 的结果是类似于 range(10,20) 的生成器;我们无法直接看到它的值,但我们可以迭代它,例如,在列表理解中:

>>> count(10,20)
<generator object count at 0x0000015FA80EC9A8>
>>> [x for x in count(10,20)]
[10, 11, 12, 13, 14, 15, 16, 17, 18, 19]

我们可以通过将理解代码包裹在括号中而不是方括号中来创建类似于列表理解的生成器理解。例如,

(x*x for x in range(0,10))

是一个生成器,它产生从 0 到 9 的数字的平方。它的行为与生成器相同:

def squares():
    for x in range(0,10):
        yield x*x

当生成器有限时,你可以安全地使用 list 函数将其转换为列表:

>>> list(squares())
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

B.2.4 元组

元组 是可迭代的,与列表非常相似,但它们是不可变的;一旦创建,就无法更改。这意味着元组上没有 append 方法。特别是,一旦创建了元组,它总是具有相同的固定长度。这使得它们非常适合存储成对或成三的数据。元组的创建方式与列表类似,唯一的区别是我们使用圆括号(或根本不使用括号)而不是方括号:

>>> (1,2)
(1, 2)
>>> ("a","b","c")
('a', 'b', 'c')
>>> 1,2,3,4,5
(1, 2, 3, 4, 5)

如果您再次查看 B.2.2 节中的 zip,您会看到其条目实际上是元组。在某种意义上,元组是 Python 中的默认集合。如果您写 a = 1,2,3,4,5(不带括号),那么 a 将自动解释为包含这些数字的元组。同样,如果您以 return a,b 结束函数,输出实际上将是元组 (a,b)

元组通常很短,所以我们通常不需要迭代它们。没有元组推导式这样的东西,但您可以在另一个推导式中迭代元组,并使用内置的 tuple 函数将结果转换回元组。这里有一些看起来像元组推导式的东西,但实际上是一个生成器推导式,其结果被传递给 tuple 函数:

>>> a = 1,2,3,4,5
>>> tuple(x + 10 for x in a)
(11, 12, 13, 14, 15)

B.2.5 集合

Python 的 集合 是每个条目都必须是唯一的集合,并且它们不跟踪顺序。在这本书中,我们不会过多地使用集合,除了将列表转换为集合是一种快速保证其没有重复值的方法。set 函数将可迭代对象转换为集合,如下所示:

>>> dups = [1,2,3,3,3,3,4,5,6,6,6,6,7,8,9,9,9]
>>> set(dups)
{1, 2, 3, 4, 5, 6, 7, 8, 9}
>>> list(set(dups))
[1, 2, 3, 4, 5, 6, 7, 8, 9]

Python 集合的写法是使用花括号括起来的条目列表,顺便说一下,这与数学集合的写法相同。您可以通过列出一些条目,用逗号分隔并在花括号中包围它们来从头定义一个集合。由于集合不尊重顺序,如果它们具有完全相同的条目,则集合相等:

>>> set([1,1,2,2,3]) == {3,2,1}
True

B.2.6 NumPy 数组

在本书中,我们广泛使用的一种最终集合不是内置的 Python 集合;它来自 NumPy 包,这是数值计算的默认 Python 库(高效的数值计算)。这个集合是 NumPy 的 数组,它之所以很重要,主要是因为 NumPy 的普遍性。许多其他 Python 库都有期望输入 NumPy 数组的函数。

要使用 NumPy 数组,请确保您有权访问 NumPy 库。首先,您需要确保 NumPy 已安装。如果您正在使用附录 A 中描述的 Anaconda,那么您应该已经安装了它。否则,您将通过在终端中运行 pip install numpy 来使用 pip 软件包管理器安装 NumPy。一旦安装了 NumPy,您需要将其导入到您的 Python 程序中。导入 NumPy 的传统方法是用名称 np

import numpy as np

要创建 NumPy 数组,只需将可迭代对象传递给 np.array 函数:

>>> np.array([1,2,3,4,5,6])
array([1, 2, 3, 4, 5, 6])

我们使用的一个 NumPy 函数是 np.arange,它类似于内置 Python range 函数的浮点版本。使用两个参数,np.arange 的工作方式与 range 相同,生成一个 NumPy 数组而不是 range 对象:

>>> np.arange(0,10)
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

使用第三个参数,你可以指定一个要计数的值,这可以是一个浮点数。以下代码给出了一个包含从 0 到 10 的增量 0.1 的 NumPy 数组,总共有 100 个数字:

>>> np.arange(0,10,0.1)
array([0\. , 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1\. , 1.1, 1.2,
       1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2\. , 2.1, 2.2, 2.3, 2.4, 2.5,
       2.6, 2.7, 2.8, 2.9, 3\. , 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8,
       3.9, 4\. , 4.1, 4.2, 4.3, 4.4, 4.5, 4.6, 4.7, 4.8, 4.9, 5\. , 5.1,
       5.2, 5.3, 5.4, 5.5, 5.6, 5.7, 5.8, 5.9, 6\. , 6.1, 6.2, 6.3, 6.4,
       6.5, 6.6, 6.7, 6.8, 6.9, 7\. , 7.1, 7.2, 7.3, 7.4, 7.5, 7.6, 7.7,
       7.8, 7.9, 8\. , 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 8.8, 8.9, 9\. ,
       9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7, 9.8, 9.9])
>>> len(np.arange(0,10,0.1))
100

B.2.7 字典

字典 是与列表、元组或生成器工作方式相当不同的集合。你无法通过数字索引访问字典条目,而是可以用另一份数据来标记它们,称为 。至少在这本书中,键最常见的是字符串。以下代码定义了一个名为 dog 的字典,包含两个键和相应的值;键 "name" 与字符串 "Melba" 相关联,键 "age" 与数字 2 相关联:

dog = {"name" : "Melba", "age" : 2}

为了使字典更易于阅读,我们经常使用一些额外的空白,并将每个键值对单独一行写出。以下是在额外空白下的相同 dog 字典:

dog = {
    "name" : "Melba",
    "age" : 2
}

要访问字典的值,你使用与获取列表条目时类似的语法,但不是传递索引,而是传递键:

>>> dog["name"]
'Melba'
>>> dog["age"]
2

如果你想要从字典中获取所有值,你可以使用字典上的 items 方法获取键值对元组的可迭代对象。字典不排序它们的值,因此不要期望 items 的结果有任何特定的顺序:

>>> list(dog.items())
[('name', 'Melba'), ('age', 2)]

B.2.8 有用的集合函数

Python 随带了一些有用的内置函数,用于处理可迭代对象,尤其是用于数字的可迭代对象。我们已经看到了 len 函数,我们将最频繁地使用它,以及 zip 函数,但还有一些其他值得简要提及的函数。sum 函数将可迭代的数字相加,而 maxmin 函数分别返回最大和最小值:

>>> sum([1,2,3])
6
>>> max([1,2,3])
3
>>> min([1,2,3])
1

sorted 函数返回一个列表,它是可迭代对象的排序副本。需要注意的是,sorted 返回一个新的列表;原始列表的顺序不受影响:

>>> q = [3,4,1,2,5]
>>> sorted(q)
[1, 2, 3, 4, 5]
>>> q
[3, 4, 1, 2, 5]

同样,reversed 函数返回给定可迭代对象的反转版本,同时保持原始可迭代对象的顺序不变。结果是可迭代的,但不是列表,因此你需要将其转换为查看结果:

>>> q
[3, 4, 1, 2, 5]
>>> reversed(q)
<list_reverseiterator at 0x15fb652eb70>
>>> list(reversed(q))
[5, 2, 1, 4, 3]

相反,如果你确实想要原地排序或反转列表,可以使用 sortreverse 方法,例如 q.sort()q.reverse()

B.3 与函数一起工作

Python 函数就像小程序,它们接受一些输入值(或可能没有),执行一些计算,并可能产生一个输出值。我们已经使用了一些 Python 函数,如 math.sqrtzip,并看到了它们对不同的输入值产生的输出。

我们可以使用 def 关键字定义自己的 Python 函数。以下代码定义了一个名为 square 的函数,它接受一个名为 x 的输入值,将值 x * x 存储在一个名为 y 的变量中,并返回 y 的值。就像 for 循环或 if 语句一样,我们需要使用缩进来表示哪些行属于函数定义:

def square(x):
    y = x * x
    return y

这个函数的净结果是返回输入值的平方:

>>> square(5)
25

本节介绍了我们在书中使用函数的一些更高级的方法。

B.3.1 为函数提供更多输入

我们可以定义我们的函数,让它接受我们想要的任意数量的输入,或 参数。以下函数接受三个参数并将它们相加:

def add3(x,y,z):
    return x + y + z

有时,让一个函数接受可变数量的参数是有用的。例如,我们可能想要编写一个单一的 add 函数,其中 add(2,2) 返回 4add(1,2,3) 返回 6,依此类推。我们可以通过在单个输入值上添加一个星号来实现这一点,这通常称为 args。星号表示我们将接受所有输入值并将它们存储在一个名为 args 的元组中。然后我们可以在函数内部编写逻辑,遍历所有参数。这个 add 函数遍历它接收到的所有参数并将它们相加,返回总和:

def add(*args):
    total = 0
    for x in args:
        total += x
    return total

然后 add(1,2,3,4,5) 返回 1 + 2 + 3 + 4 + 5 = 15,正如预期的那样,而 add() 返回 0。我们的 add 函数与之前的 sum 函数工作方式不同;sum 函数接受一个可迭代对象,而 add 函数直接接受作为参数的底层值。以下是一个比较:

>>> sum([1,2,3,4,5])
15
>>> add(1,2,3,4,5)
15

* 运算符有第二个应用:你可以用它将列表转换为函数的参数。例如,

>>> p = [1,2,3,4,5]
>>> add(*p)
15

这与评估 add(1,2,3,4,5) 等效。

B.3.2 关键字参数

使用函数的星号参数是一种可选参数的方式。另一种方式是通过传递命名参数,称为 关键字参数。以下是一个示例函数,它有两个可选关键字参数,分别称为 nameage,该函数返回一个包含生日祝福的字符串:

def birthday(name="friend", age=None):
    s = "Happy birthday, %s" % name
    if age:
        s += ", you're %d years old" % age
    return s + "!"

(此函数使用字符串格式化运算符 %,它将 %s 出现的位置替换为给定的字符串,将 %d 出现的位置替换为给定的数字。)因为它们是关键字参数,所以 nameage 都是可选的。名称默认为 "friend",因此如果我们不带参数调用 birthday,我们将得到一个通用的问候:

>>> birthday()
'Happy birthday, friend!'

我们可以可选地指定一个不同的名称。第一个参数被认为是名称,但我们可以通过直接设置 name 参数来使其明确:

>>> birthday('Melba')
'Happy birthday, Melba!'
>>> birthday(name='Melba')
'Happy birthday, Melba!'

age 参数也是可选的,默认值为 None。我们可以指定一个姓名和一个年龄,或者只指定一个年龄。因为 age 是第二个关键字参数,如果我们不提供名称,我们需要识别它。当所有参数都被识别后,我们可以以任何顺序传递它们。以下是一些示例:

>>> birthday('Melba', 2)
"Happy birthday, Melba, you're 2 years old!"
>>> birthday(age=2)
"Happy birthday, friend, you're 2 years old!"
>>> birthday('Melba', age=2)
"Happy birthday, Melba, you're 2 years old!"
>>> birthday(age=2,name='Melba')
"Happy birthday, Melba, you're 2 years old!"

如果你有很多参数,你可以将它们打包成一个字典,并使用 ** 操作符将它们传递给函数。这类似于 * 操作符,但不同的是,你传递的是一个关键字参数的字典,而不是参数列表:

>>> dog = {"name" : "Melba", "age" : 2}
>>> dog
{'name': 'Melba', 'age': 2}
>>> birthday(**dog)
"Happy birthday, Melba, you're 2 years old!"

当定义你的函数时,你可以同样使用 ** 操作符来处理传递给函数的所有关键字参数作为一个单一的字典。我们可以将生日函数重写如下,但这样调用时我们需要指定所有参数的名称:

def birthday(**kwargs):
    s = "Happy birthday, %s" % kwargs['name']
    if kwargs['age']:
        s += ", you're %d years old" % kwargs['age']
    return s + "!"

具体来说,nameage 变量被替换为 kwargs['name']kwargs['age'],我们可以以以下两种方式之一运行它:

>>> birthday(**dog)
"Happy birthday, Melba, you're 2 years old!"
>>> birthday(age=2,name='Melba')
"Happy birthday, Melba, you're 2 years old!"

B.3.3 函数作为数据

在 Python 中,函数是 一等 值,这意味着你可以将它们赋值给变量,将它们传递给其他函数,以及将函数作为其他函数的输出值。换句话说,Python 中的函数看起来就像 Python 中的其他任何数据。在 函数式编程 模式(我们在第四章中介绍)中,通常会有操作其他函数的函数。以下这个函数接受两个输入,一个函数 f 和一个值 x,并返回值 f(x)

def evaluate(f,x):
    return f(x)

使用 B.3 节中的 square 函数,evaluate(square,10) 应该返回 square(10)100

>>> evaluate(square,10)
100

一个更实用的函数,它接受一个函数作为输入,是 Python 的 map 函数。map 函数接受一个函数和一个可迭代对象,并返回一个新的可迭代对象,该对象通过将函数应用于原始对象的每个条目而获得。例如,以下 mapsquare 函数应用于 range(10) 中的每个数字。将其转换为列表,我们可以看到前 10 个平方数:

>>> map(square,range(10))
<map at 0x15fb752e240>
>>> list(map(square,range(10)))
[0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

evaluatemap 函数是接受其他函数作为输入的函数的例子。一个函数也可以返回另一个函数作为输出。以下这个函数,例如,返回一个将数字提升到某个幂的函数。特别注意的是,一个完整的函数定义可以存在于另一个函数内部:

def make_power_function(power):

    def power_function(x):
        return x ** power

    return power_function

定义之后,make_power_function(2) 返回一个函数,其行为与之前的 square 函数完全相同。同样,make_power_function(3) 返回一个函数,它对输入进行立方:

>>> square = make_power_function(2)
>>> square(2)
4
>>> cube = make_power_function(3)
>>> cube(2)
8

make_power_function 完成评估后,返回的 power_function 仍然记得传递给它的 power 变量,尽管通常情况下函数运行完成后函数内部的变量就会消失。这种记住其定义中使用的外部变量的函数被称为 闭包

B.3.4 Lambda:匿名函数

在创建函数时,我们可以使用另一种更简单的语法。lambda 关键字允许我们创建一个没有名称的函数,称为 匿名函数lambda。这个名字来自希腊字母 λ,写作 lambda,发音为 LAM-duh,这是计算机科学家在函数式编程理论中用于函数定义的符号。要定义一个 Lambda 函数,你指定输入变量或变量,用逗号分隔,然后是一个冒号,然后是函数的返回表达式。这个 Lambda 定义了一个函数,它接受单个输入 x 并将其加 2:

>>> lambda x: x + 2
<function __main__.<lambda>(x)>

你可以在使用函数的任何地方使用 Lambda,因此可以直接将其应用于一个值,如下所示:

>>> (lambda x: x + 2)(7)
9

这里还有一个 Lambda 函数,它接受两个输入变量并返回第一个加上第二个的两倍。在这种情况下,第一个输入是 2,第二个是 3,所以输出是 2 + 2 · 3 = 8:

>>> (lambda x,y: x + 2 * y)(2,3)
8

你也可以像在 Python 中使用任何函数值一样将 Lambda 绑定到一个名称上,尽管这多少有些违背了使用匿名函数语法的初衷:

>>> plus2 = lambda x: x + 2
>>> plus2(5)
7

应该谨慎使用 Lambda,因为如果一个函数做了任何有趣的事情,它可能值得有一个名字。你可能使用 Lambda 的情况之一是当你编写一个返回另一个函数的函数时。例如,make_power_function 可以用 Lambda 等价地实现,如下所示:

def make_power_function(p):
    return lambda x: x ** p

我们可以看到这个函数的行为与原始实现相同:

>>> make_power_function(2)(3)
9

外部函数的名称使这一点很清楚,给返回函数命名也收获不大。还可以将 Lambda 作为函数的输入使用。例如,如果你想将 2 加到从 0 到 9 的每个数字上,你可以简洁地写出

map(lambda x: x + 2, range(0,9))

要查看数据,我们再次需要将此结果转换为列表。然而,在大多数地方,使用推导式既简洁又易于阅读。等价的列表推导式是

[x+2 for x in range(0,9)]

B.3.5 将函数应用于 NumPy 数组

NumPy 有一些内置的 Python 数学函数的版本,这些函数很有用,因为它们可以一次应用于 NumPy 数组的每个条目。例如,np.sqrt 是一个平方根函数,它可以取一个数字或整个 NumPy 数组的平方根。例如,np.sqrt(np.arange(0,10)) 产生一个包含从 0 到 9 的整数的平方根的 NumPy 数组:

>>> np.sqrt(np.arange(0,10))
array([0\.        , 1\.        , 1.41421356, 1.73205081, 2\.        ,
       2.23606798, 2.44948974, 2.64575131, 2.82842712, 3\.        ])

这不仅仅是一个捷径。NumPy 实际上有一个实现,其速度比在 Python 中遍历数组要快。如果你想将自定义函数应用于 NumPy 数组的每个条目,你可以使用 np.vectorize 函数。以下是一个示例,它接受一个数字并返回另一个数字:

def my_function(x):
    if x % 2 == 0:
        return x/2
    else:
        return 0

以下代码将函数向量化并应用于 NumPy 数组的每个条目:np.arange(0,10)

>>> my_numpy_function = np.vectorize(my_function)
>>> my_numpy_function(np.arange(0,10))
array([0., 0., 1., 0., 2., 0., 3., 0., 4., 0.])

B.4 使用 Matplotlib 绘制数据

Matplotlib 是 Python 中最受欢迎的绘图库。在整个书中,我们使用它来创建数据集的图表、函数的图表以及其他几何图形的绘制。为了避免特定于库的讨论,我已经将 Matplotlib 的大多数用法隐藏在包装函数中,因此你可以主要使用这些函数来完成所有练习和迷你项目。如果你想要深入了解实现,这里有一个快速概述,如何在 Matplotlib 中制作图表。你应该已经通过 Anaconda 安装了 Matplotlib,或者你可以使用 pip install matplotlib 手动安装。

B.4.1 制作散点图

散点图用于可视化形式为 (x, y) 的有序数对集合作为平面上的点(在第二章中更详细地介绍)。要使用 Matplotlib 创建散点图(或任何图表),第一步是安装库并将其导入到你的 Python 脚本中。传统上,使用名称 plt 导入 Matplotlib 的绘图模块,pyplot:

import matplotlib.pyplot as plt

假设我们想要绘制一个散点图,包含点 (1, 1), (2, 4), (3, 9), (4, 16), 和 (5, 25),这些是一些数字及其平方的配对。将这些视为形式为 (x, y) 的点,x 的值是 1, 2, 3, 4 和 5,而 y 的值是 1, 4, 9, 16 和 25。要制作散点图,我们使用 plt.scatter 函数,首先传递一个 x 值的列表,然后是一个 y 值的列表:

x_values = [1,2,3,4,5]
y_values = [1,4,9,16,25]
plt.scatter(x_values,y_values)

图 B.1 使用 Matplotlib 的 plt.scatter 函数创建的散点图

水平位置告诉我们给定点的 x 值,而垂直位置告诉我们 y 值。请注意,Matplotlib 自动调整图形区域以适应所有点,因此在这种情况下,y 轴的比例大于 x 轴的比例。

你还可以使用一些关键字参数来自定义散点图的外观。例如,marker 关键字参数设置图表上点的形状,而 c 关键字参数设置点的颜色。以下行使用红色“x”代替默认的蓝色圆圈绘制相同的数据:

plt.scatter(x_values,y_values,marker='x',c='red')

图 B.2. 自定义 Matplotlib 散点图的外观

matplotlib.org/ 上的文档涵盖了所有可能的关键字参数和 Matplotlib 图表的可定制的自定义选项。

B.4.2 制作折线图

如果我们使用 Matplotlib 的 plt.plot 函数而不是 plt.scatter 函数,我们的点将通过线条连接而不是作为点标记。这有时被称为 折线图。例如,

plt.plot(x_values,y_values)

图 B.3 使用 Matplotlib 的 plt.plot 函数创建折线图

这种用法的一个有用之处是只需指定两个点来绘制线段。例如,我们可以编写一个函数,该函数接受两个输入 (x, y) 点作为元组,并通过提取它们的 xy 值然后使用 plt.plot 来绘制连接它们的线段:

def plot_segment(p1,p2):
    x1,y1 = p1
    x2,*y*2 = p2
    plt.plot([x1,x2],[y1,y2],marker='o')

这个例子还表明,你可以为 plt.plot 设置一个 marker 关键字参数来标记单独的点,而不仅仅是绘制线:

point1 = (0,3)
point2 = (2,1)
plot_segment(point1,point2)

图 B.4 在两个点之间绘制线段的函数

这个 draw_segment 函数是一个包装函数的例子;我们现在可以在需要绘制两个 (x, y) 点之间的线段时,随时使用 draw_segment,而不是使用 Matplotlib 函数。

线图的另一个重要用途是绘制函数的 图像。也就是说,在 x 的某个固定函数 f 的值域内绘制所有 (x, f(x)) 对。从理论上讲,一个平滑、连续的图像由无限多个点组成。我们无法使用无限多个点,但使用的点越多,图像看起来就越准确。以下是从 x = 0 到 x = 10 的 f(x) = sin(x) 的图像,使用 1,000 个点:

x_values = np.arange(0,10,0.01)
y_values = np.sin(x_values)
plt.plot(x_values,y_values)

图 B.5 使用很多点,我们可以近似一个平滑的函数图像。

B.4.3. 更多的绘图定制

正如我提到的,了解如何定制你的 Matplotlib 图表的最佳方式是在你想要完成特定任务时,搜索 matplotlib.org 上的文档。还有一些其他重要的方式可以控制你的 Matplotlib 图表的外观,我将提到它们,因为它们在这本书的例子中经常出现。

图 B.6 更新带有 x 和 y 范围的图表上的 x 和 y 刻度

第一点是设置你的图表的刻度和大小。你可能已经注意到,plot _segment(point1,point2) 的结果并没有按比例绘制。如果我们想按比例看到我们的线段绘制,我们可以显式设置图形的 xy 范围相同。例如,这段代码将 xy 范围都设置为从 0 到 5:

plt.ylim(0,5)
plt.xlim(0,5)
plot_segment(point1,point2)

这仍然不完全按比例。x 轴上的一个单位与 y 轴上的一个单位不同。为了使它们在视觉上大小相同,我们需要将我们的图形设置为正方形。这可以通过 set_size_inches 方法完成。该方法实际上属于 Matplotlib 当前正在处理的“图形”对象,我们可以通过在 plt 上使用 gcf(获取当前图形)方法来检索它。以下代码绘制了线段:

在一个 5 英寸乘 5 英寸的图表区域内按正确比例绘制。根据你的显示,它可能看起来是另一个大小,但比例应该是正确的:

plt.ylim(0,5)
plt.xlim(0,5)
plt.gcf().set_size_inches(5,5)
plot_segment(point1,point2)

图 B.7 通过设置图尺寸为英寸来正确绘制比例

你可以向你的图表添加的其他重要自定义之一是为坐标轴和整个图表设置标签。你可以使用 plt.title 函数给当前图表添加标题,并且可以使用 plt.xlabelplt.ylabel 函数分别添加 x 轴和 y 轴的标签。以下是一个向正弦函数图表添加标签的示例:

x_values = np.arange(0,10,0.01)
y_values = np.sin(x_values)
plt.plot(x_values,y_values)
plt.title('Graph of sin(x) vs. x',fontsize=16)
plt.xlabel('this is the x value',fontsize=16)
plt.ylabel('the value of sin(x)',fontsize=16)

图片

图 B.8 带标题和坐标轴标签的 Matplotlib 图

B.5 Python 中的面向对象编程

面向对象编程(OOP)大致上是一种编程范式,它强调使用 组织程序数据。类可以存储称为 属性 的值以及称为 方法 的函数,这些函数在程序中将数据和功能联系起来。你不需要了解很多关于 OOP 的知识就能欣赏这本书,但一些数学思想确实带有面向对象的色彩。特别是在第六章和第十章中,我们使用类和一些面向对象设计原则来帮助理解数学。本节简要介绍了 Python 中的类和面向对象编程。

B.5.1 定义类

让我们用一个具体的例子来操作。假设你正在编写一个处理几何形状的 Python 程序,例如一个绘图应用程序。你可能想要描述的一种形状是矩形。为此,我们在 Python 中定义一个 Rectangle 类来描述矩形的属性,然后我们创建这个类的 实例 来代表特定的矩形。

在 Python 中,一个类是通过 class 关键字定义的,类的名称通常是大写的,如 Rectangle。位于类名称下方缩进的行描述了与类相关的属性(值)和方法(函数)。类最基本的方法是其 构造函数,这是一个允许我们创建类实例的函数。在 Python 中,构造函数被赋予特殊的名称 __init__。对于一个矩形,我们可能想要通过两个数字来描述它,代表其高度和宽度。在这种情况下,__init__ 函数接受三个值:第一个代表我们正在构建的新类实例,接下来的两个代表高度和宽度值。构造函数负责将新实例的高度和宽度属性设置为输入值:

class Rectangle():
    def __init__(self,w,h):
        self.width = w
        self.height = h

创建构造函数后,我们可以像使用一个接受两个数字并返回一个 Rectangle 对象的函数一样使用类的名称。例如 Rectangle(3,4) 创建了一个宽度属性设置为 3,高度属性设置为 4 的实例。尽管构造函数是用 self 参数定义的,但在调用构造函数时你不需要包含它。有了这个 Rectangle 对象,我们可以访问其高度和宽度属性:

>>> xr = Rectangle(3,4)
>>> type(r)
__main__.Rectangle
>>> r.width
3
>>> r.height
4

B.5.2 定义方法

方法是与类相关联的函数,它允许你计算有关实例的信息或给实例提供某种功能。对于一个矩形来说,有一个area()方法来计算面积是有意义的,面积是高度乘以宽度。像构造函数一样,任何方法都必须接受一个self参数,它代表当前实例。同样,你不需要向方法传递self参数;self会自动被视为被调用的对象:

class Rectangle():
    def __init__(self,w,h):
        self.width = w
        self.height = h

    def area(self):
        return self.width * self.height

要计算矩形的面积,我们可以按以下方式调用area方法:

>>> Rectangle(3,4).area()
12

注意,函数中不会传递self参数;实例Rectangle(3,4)会自动被视为self值。作为另一个例子,我们可以有一个scale方法,它接受一个数字并返回一个新的Rectangle对象,其高度和宽度都按该因子从原始值缩放。(我将开始在打印页上使用“...”作为占位符,以代替Rectangle类中我们已编写的代码。)

class Rectangle():
    ...
    def scale(self, factor):
        return Rectangle(factor * self.width, factor * self.height)

调用Rectangle(2,1)会构建一个宽度为 2、高度为 1 的矩形。如果我们将其按 3 的因子缩放,我们得到一个新矩形,其宽度为 6、高度为 3:

>>> xr = Rectangle(2,1)
>>> s = r.scale(3)
>>> s.width
6
>>> s.height
3

B.5.3 特殊方法

一些方法在实现后要么自动可用,要么具有特殊效果。例如,__dict__方法默认在每个新类的实例上可用,并返回实例所有属性的字典。在不修改Rectangle类的情况下,我们可以编写:

>>> Rectangle(2,1).__dict__
{'width': 2, 'height': 1}

另一个特殊方法名是__eq__。当实现此方法时,它描述了类实例上==运算符的行为,因此决定了何时两个实例相等。如果没有实现自定义相等方法,类实例总是不相等,即使它们包含相同的数据:

>>> Rectangle(3,4) == Rectangle(3,4)
False

对于矩形,我们可能希望说如果它们在几何上无法区分,具有相同的宽度和高度,则它们是相同的。我们可以相应地实现__eq__方法。该方法接受两个参数,通常是self参数,以及一个表示另一个实例的第二个参数,我们将self与该实例进行比较:

class Rectangle():
    ...
    def __eq__(self,other):
        return self.width == other.width and self.height == other.height

完成这些后,如果矩形的宽度和高度相同,则Rectangle实例相等:

>>> Rectangle(3,4) == Rectangle(3,4)
True

另一个有用的特殊方法是__repr__,它生成对象的默认字符串表示。以下__repr__方法使得一眼就能看到矩形的宽度和高度:

class Rectangle():
    ...
    def __repr__(self):
        return 'Rectangle (%r by %r)' % (self.width, self.height)

我们可以看到它是如何工作的:

>>> Rectangle(3,4)
Rectangle (3 by 4)

B.5.4 运算符重载

我们可以实施更多特殊方法来描述 Python 操作符如何与类的实例一起使用。将具有现有意义的操作符重新用于处理新类的对象称为 操作符重载。例如,__mul____rmul__ 方法描述了类在乘法操作符 *(分别作用于右侧和左侧)方面的行为。对于一个 Rectangle 实例 r,我们可能想写 r * 33 * r 来表示将矩形按 3 倍缩放。以下对 __mul __ 和 __rmul __ 的实现调用了我们已实现的 scale 方法,生成一个按给定因子缩放的新矩形:

class Rectangle():
    ...
    def __mul__(self,factor):
        return self.scale(factor)

    def __rmul__(self,factor):
        return self.scale(factor)

我们可以看到,无论是 10 * Rectangle(1,2) 还是 Rectangle(1,2) * 10,都会返回一个新的 Rectangle 实例,其宽度为 10,高度为 20:

>>> 10 * Rectangle(1,2)
Rectangle (10 by 20)
>>> Rectangle(1,2) * 10
Rectangle (10 by 20)

B.5.5 类方法

方法 是只能通过类的现有实例来运行的函数。另一个选择是创建一个 类方法,这是一个附加到类本身而不是单个实例的函数。对于一个 Rectangle 类,类方法将包含一些与一般矩形相关的功能,而不是与特定矩形相关。

类方法的一个典型用途是创建一个替代构造函数。例如,我们可以在 Rectangle 类上创建一个接受单个数字作为参数的类方法,返回一个高度和宽度都等于该数字的矩形。换句话说,这个类方法构建了一个给定边长的正方形矩形。类方法的第一参数代表类本身,通常缩写为 cls

class Rectangle():
    ...
    @classmethod
    def square(cls,side):
        return Rectangle(side,side)

通过实现这个类方法,我们可以使用 Rectangle.square(5) 来得到与 Rectangle(5,5) 相同的结果。

B.5.6 继承和抽象类

在面向对象编程中,我们使用的最后一个主题是 继承。如果我们说类 A 继承自类 B,就像说类 A 的实例是类 B 的特殊情况;它们像类 B 的实例一样工作,但有一些额外的或修改后的功能。在这种情况下,我们也说 AB子类,而 BA超类。作为一个简单的例子,我们可以从 Rectangle 创建一个表示正方形的子类 Square,同时保留 Rectangle 的大部分底层逻辑。编写 class Square(Rectangle) 意味着 SquareRectangle 的子类,并且调用 super().__init__ 会从 Square 构造函数中运行超类(Rectangle)的构造函数:

class Square(Rectangle):
    def __init__(self,s):
        return super().__init__(s,s)
    def __repr__(self):
        return "Square (%r)" % self.width

这就是我们定义 Square 类所需做的所有事情,我们可以直接使用任何 Rectangle 方法:

>> Square(5).area()
25

在实践中,你可能需要重新实现或 重写 一些更多方法,如 scale,它默认返回缩放后的正方形作为矩形。

面向对象编程中的一种常见模式是让两个类从同一个 抽象基类 继承,这是一个定义了一些公共方法或代码的类,但你永远不能创建其实例。例如,假设我们有一个类似的类 Circle,表示给定半径的圆。Circle 类的大部分实现与 Rectangle 类类似:

from math import pi
class Circle():
    def __init__(self, r):
        self.radius = r
    def area(self):
        return pi * self.radius * self.radius
    def scale(self, factor):
        return Circle(factor * self.radius)
    def __eq__(self,other):
        return self.radius == other.radius
    def __repr__(self):
        return 'Circle (radius %r)' % self.radius
    def __mul__(self,factor):
        return self.scale(factor)
    def __rmul__(self,factor):
        return self.scale(factor)

(记得几何课上学到的,半径为 r 的圆的面积是 πr²。)如果我们程序中处理多种不同的形状,我们可以让 CircleRectangle 都继承自一个共同的 Shape 类。形状的概念不够具体,以至于我们无法创建其实例,因此只能实现其中的一些方法。其他方法被标记为 抽象方法,这意味着它们不能单独为 Shape 实现,但可以为任何具体的子类实现。

我们可以使用以下代码在 Python 中创建一个抽象类。ABC 代表“抽象基类”,ABC 是一个特殊的基类,任何抽象类都必须在 Python 中从它继承:

from abc import ABC, abstractmethod
class Shape(ABC):
    @abstractmethod
    def area(self):
        pass
    @abstractmethod
    def scale(self, factor):
        pass
    def __eq__(self,other):
        return self.__dict__ == other.__dict__
    def __mul__(self,factor):
        return self.scale(factor)
    def __rmul__(self,factor):
        return self.scale(factor)

等式和乘法重载已完全实现,__eq__ 方法检查两个形状的所有属性是否一致。面积和缩放功能留待实现,它们的实现取决于我们正在处理的特定形状。

如果我们要根据 Shape 抽象基类重新实现 Rectangle 类,我们可以先让它继承自 Shape,同时为其提供一个自己的构造函数:

class Rectangle(Shape):
    def __init__(self,w,h):
        self.width = w
        self.height = h

如果我们尝试仅用这段代码实例化一个 Rectangle,我们会遇到错误,因为 areascale 方法尚未实现:

>>> Rectangle(1,3)
TypeError: Can't instantiate abstract class Rectangle with abstract methods area, scale

我们可以包含之前实现的代码:

class Rectangle(Shape):
    def __init__(self,w,h):
        self.width = w
        self.height = h
    def area(self):
        return self.width * self.height
    def scale(self, factor):
        return Rectangle(factor * self.width, factor * self.height)

一旦我们有了这些特定于矩形的特定方法,我们就能够访问 Shape 基类中的所有功能。例如,等式和乘法运算符重载的行为符合预期:

>>> 3 * Rectangle(1,2) == Rectangle(3,6)
True

现在我们可以快速实现一个 Circle 类、一个 Triangle 类,或者任何其他二维形状的类,所有这些类都会通过它们的面积和形状方法以及运算符重载实现统一。

附录 C:使用 OpenGL 和 PyGame 加载和渲染 3D 模型

在第三章之后,当我们开始编写转换和动画图形的程序时,我使用 OpenGL 和 PyGame 而不是 Matplotlib。本附录概述了如何在 PyGame 中设置游戏循环并在连续帧中渲染 3D 模型。最终,我们实现了一个 draw_model 函数,用于渲染一个 3D 模型的单张图像,就像我们在第四章中使用的水壶一样。

draw_model 函数的目标是封装特定库的工作,这样你就不必花费大量时间与 OpenGL 斗争。但如果你想知道这个函数是如何工作的,请随时跟随本附录并亲自尝试代码。让我们从第三章中的八面体开始,使用 PyOpenGL(Python 和 PyGame 的 OpenGL 绑定)重新创建它。

C.1 重新创建第三章中的八面体

要开始使用 PyOpenGL 和 PyGame 库,你需要安装它们。我建议使用以下 pip 命令:

> pip install PyGame
> pip install PyOpenGL

我将首先向你展示如何使用这些库来重新创建我们已经完成的工作,渲染一个简单的 3D 对象。

在一个名为 octahedron.py 的新 Python 文件中(你可以在附录 C 的源代码中找到它),我们开始导入一些模块。前几个来自两个新的库,PyGame 和 PyOpenGL,其余的应该与第三章中熟悉的内容相同。特别是,我们将继续使用我们已构建的所有 3D 向量算术函数,这些函数组织在本书源代码中的 vectors.py 文件中。以下是导入语句:

import pygame
from pygame.locals import *
from OpenGL.GL import *
from OpenGL.GLU import *
import matplotlib.cm
from vectors import *
from math import *

虽然 OpenGL 具有自动着色功能,但让我们继续使用第三章中提到的着色机制。我们可以使用 Matplotlib 的蓝色颜色图来计算八面体的着色面的颜色:

def normal(face):
    return(cross(subtract(face[1], face[0]), subtract(face[2], face[0])))

blues = matplotlib.cm.get_cmap('Blues')

def shade(face,color_map=blues,light=(1,2,3)):
    return color_map(1 − dot(unit(normal(face)), unit(light)))

接下来,我们必须指定八面体的几何形状和光源。这和第三章中一样:

light = (1,2,3)
faces = [
    [(1,0,0), (0,1,0), (0,0,1)],
    [(1,0,0), (0,0,-1), (0,1,0)],
    [(1,0,0), (0,0,1), (0,-1,0)],
    [(1,0,0), (0,-1,0), (0,0,-1)],
    [(−1,0,0), (0,0,1), (0,1,0)],
    [(−1,0,0), (0,1,0), (0,0,-1)],
    [(−1,0,0), (0,-1,0), (0,0,1)],
    [(−1,0,0), (0,0,-1), (0,-1,0)],
]

现在是进入一些不熟悉的领域的时候了。我们将以 PyGame 游戏窗口的形式展示八面体,这需要几行样板代码。在这里,我们启动游戏,设置窗口的像素大小,并告诉 PyGame 使用 OpenGL 作为图形引擎:

pygame.init()
display = (400,400) 
window = pygame.display.set_mode(display,            ❶
                                 DOUBLEBUF|OPENGL)   ❷

❶ 请求 PyGame 在一个 400 × 400 像素的窗口中显示我们的图形

❷ 让 PyGame 知道我们正在使用 OpenGL 进行图形处理,并指出我们想要使用一个名为双缓冲的内置优化,这对于我们的目的来说并不重要

在第 3.5 节的简化示例中,我们从 z 轴上方的某一点观察者的视角绘制了八面体。我们计算了哪些三角形对这样的观察者是可见的,并通过移除 z 轴将它们投影到 2D。OpenGL 内置了更精确地配置我们视角的函数:

gluPerspective(45, 1, 0.1, 50.0)
glTranslatef(0.0,0.0, -5)
glEnable(GL_CULL_FACE)
glEnable(GL_DEPTH_TEST)
glCullFace(GL_BACK)

为了学习数学,你实际上并不需要知道这些函数具体做什么,但我还是会给你一个简要的概述,以防你感兴趣。对gluPerspective的调用描述了我们观察场景的视角,其中我们有一个 45°的观察角度和宽高比为 1。这意味着垂直单位和水平单位显示为相同的大小。作为一个性能优化,数字 0.1 和 50.0 限制了渲染的z坐标:没有物体距离观察者超过 50.0 个单位或小于 0.1 个单位将显示出来。我们使用glTranslatef表明我们将从 z 轴上的 5 个单位处观察场景,这意味着我们通过向量(0, 0, -5)将场景向下移动。调用glEnable(GL_CULL_FACE)打开了一个 OpenGL 选项,该选项会自动隐藏面向观察者之外的多边形,这节省了我们已经在第三章中完成的一些工作,而glEnable(GL_DEPTH_TEST)确保我们渲染距离我们更近的多边形在距离我们更远的多边形之上。最后,glCullFace(GL_BACK)打开了一个 OpenGL 选项,该选项会自动隐藏面向我们但位于其他多边形后面的多边形。对于球体来说,这不是问题,但对于更复杂的形状来说,可能会有问题。

最后,我们可以实现绘制我们的八面体的主要代码。因为我们的最终目标是动画化物体,所以我们将编写重复绘制物体的代码。这些连续的绘制,就像电影的一帧,随着时间的推移显示了相同的八面体。而且,就像任何静止物体的视频一样,结果是和静态图片无法区分的。

要渲染单个帧,我们遍历向量,决定如何着色它们,使用 OpenGL 绘制它们,并使用 PyGame 更新帧。在一个无限while循环中,只要程序运行,这个过程可以尽可能快地自动重复:

clock = pygame.time.Clock()                            ❶
while True:
    for event in pygame.event.get():                   ❷
        if event.type == pygame.QUIT:
            pygame.quit()
            quit()

    clock.tick()                                       ❸
    glClear(GL_COLOR_BUFFER_BIT|GL_DEPTH_BUFFER_BIT)
    glBegin(GL_TRIANGLES)                              ❹
    for face in faces:
        color = shade(face,blues,light)
        for vertex in face:
            glColor3fv((color[0], 
                        color[1], 
                        color[2]))                     ❺
            glVertex3fv(vertex)                        ❻
    glEnd()
    pygame.display.flip()                              ❼

❶ 初始化一个时钟来测量 PyGame 的时间推进

❷ 在每次迭代中,检查 PyGame 接收的事件,如果用户关闭窗口则退出

❸ 指示时钟时间应该流逝

❹ 指示 OpenGL 我们即将绘制三角形

❺ 对于每个面的每个顶点(三角形),根据着色设置颜色

❻ 指定当前三角形的下一个顶点

❼ 指示 PyGame 最新的动画帧已准备好并使其可见

运行此代码,我们会看到一个 400 × 400 像素的 PyGame 窗口出现,其中包含看起来像第三章(图 C.1)中的图像。

图片

图 C.1 在 PyGame 窗口中渲染的八面体

如果你想要证明有更有趣的事情发生,你可以在while True循环的末尾包含以下行:

print(clock.get_fps())

这打印出 PyGame 渲染和重新渲染八面体的速率(每秒帧数,或 fps)的瞬时值。对于这样的简单动画,PyGame 应该达到或超过其默认的最大帧率 60 fps。

但如果没有任何变化,渲染这么多帧又有什么意义呢?一旦我们为每一帧加入一个矢量变换,我们就能看到八面体以各种方式移动。目前,我们可以通过移动“相机”来欺骗,而不是真正移动八面体。

C.2 改变我们的视角

上一节中的glTranslatef函数告诉 OpenGL 我们想要从哪个位置查看我们正在渲染的 3D 场景。同样,还有一个glRotatef函数允许我们改变观察场景的角度。调用glRotatef (theta, x, y, z)将整个场景绕由向量(x, y, z)指定的轴旋转角度theta

让我澄清一下“绕轴旋转一个角度”的意思。你可以想想地球在空间中旋转的熟悉例子。地球每天旋转 360°或每小时旋转 15°。是地球围绕旋转的看不见的线;它穿过北极和南极−这是唯一两个不旋转的点。对于地球来说,旋转轴并不是直接垂直的,而是倾斜了 23.5°(图 C.2)。

图 C.2 一个绕轴旋转的物体的熟悉例子。地球的旋转轴相对于其轨道平面倾斜了 23.5°。

向量(0, 0, 1)沿着 z 轴,所以调用glRotatef(30,0,0,1)将场景绕 z 轴旋转 30°。同样,glRotatef(30,0,1,1)将场景绕 30°旋转,但绕的是轴(0, 1, 1),它在 y 轴和 z 轴之间倾斜 45°。如果我们调用glRotatef (30,0,0,1)glRotatef(30,0,1,1)在八面体代码中的glTranslatef(...)之后,我们会看到八面体旋转(图 C.3)。

注意到图 C.3 中八面体四个可见面的阴影没有改变。这是因为没有任何向量发生变化;八面体的顶点和光源都是相同的!我们只是改变了“相机”相对于八面体的位置。当我们实际改变八面体的位置时,我们也会看到阴影的变化。

图 C3. 使用 OpenGL 的glRotatef函数从三个不同的旋转视角看到的八面体

为了使立方体的旋转动画化,我们可以在每一帧调用glRotate一个小角度。例如,如果 PyGame 以大约 60 fps 的速度绘制八面体,并且我们在每一帧调用glRotatef(1,x,y,z),那么八面体将绕(x, y, z)轴每秒旋转 60°。在glBegin之前无限循环中添加glRotatef(1,1,1,1)会导致八面体绕方向(1, 1, 1)的轴每帧旋转 1°,如图 C.4 所示。

图 C.4 我们八面体每帧旋转 1°。经过 36 帧后,八面体完成了一次完整的旋转。

这个旋转速率只有在 PyGame 精确绘制 60 fps 时才是准确的。从长远来看,这可能并不成立;如果复杂场景需要超过六十分之一秒来计算所有向量和绘制所有多边形,实际的运动速度会减慢。为了使场景的运动不受帧率影响而保持恒定,我们可以使用 PyGame 的时钟。

假设我们想让场景每 5 秒旋转一周(360°)。PyGame 的时钟以毫秒为单位思考,即千分之一秒。对于千分之一秒,旋转的角度被除以 1,000:

degrees_per_second = 360./5
degrees_per_millisecond = degrees_per_second / 1000

我们创建的 PyGame 时钟有一个 tick() 方法,它既推进时钟又返回自上次调用 tick() 以来经过的毫秒数。这为我们提供了一个自上次渲染帧以来可靠的毫秒数,并允许我们计算场景在此时间内应该旋转的角度:

milliseconds = clock.tick()
glRotatef(milliseconds * degrees_per_millisecond, 1,1,1)

每帧调用 glRotatef 如此保证场景每 5 秒旋转正好 360°。在附录 C 的源代码文件 rotate_octahedron.py 中,你可以看到这段代码是如何插入的。

通过能够在时间上移动我们的视角,我们已经拥有了比第三章中开发的更好的渲染能力。现在,我们可以将注意力转向绘制比八面体或球体更有趣的形状。

C.3 加载和渲染犹他茶壶

正如我们在第二章中手动识别描绘 2D 恐龙的向量一样,我们可以手动识别任何 3D 对象的顶点,将它们组织成代表三角形的三个一组的顶点,并构建一个由三角形组成的表面列表。设计 3D 模型的艺术家有专门的空间定位顶点和将它们保存到文件中的界面。在本节中,我们使用一个著名的预构建 3D 模型:犹他茶壶。这个茶壶的渲染是图形程序员的 Hello World 程序:一个简单、可识别的示例,用于测试。

茶壶模型保存在源代码中的 teapot.off 文件中,其中 .off 文件扩展名代表对象文件格式。这是一种纯文本格式,指定组成 3D 对象表面的多边形以及构成多边形顶点的 3D 向量。teapot.off 文件看起来就像这个列表中所示的那样。

列表 C.1 teapot.off 文件的示意图

OFF                                ❶
480  448  926                      ❷
0  0  0.488037                     ❸
0.00390625  0.0421881  0.476326
0.00390625  -0.0421881  0.476326
0.0107422  0  0.575333
...
4 324 306 304 317                  ❹
4 306 283 281 304
4 283 248 246 281
...

❶ 表示此文件遵循对象文件格式

❷ 按顺序包含 3D 模型的顶点数、面数和边数

❸ 指定每个顶点的 3D 向量,作为 x、y 和 z 坐标值

❹ 指定模型的 448 个面

对于此文件的最后几行,指定面时,每行的第一个数字告诉我们该面是何种多边形。数字 3 表示三角形,4 表示四边形,5 表示五边形,依此类推。茶壶的大部分面都是四边形。每行接下来的数字告诉我们构成给定多边形顶点的索引,这些索引来自前面的行。

在附录 C 的源代码文件 teapot.py 中,你会找到 load_vertices()load_polygons() 函数,它们从 teapot.off 文件中加载顶点和面(多边形)。第一个函数返回一个包含 440 个向量的列表,这些向量是模型的全部顶点。第二个返回一个包含 448 个列表的列表,每个列表包含构成模型中 448 个多边形之一的顶点向量。最后,我还包括了一个第三个函数 load_triangles(),它将具有四个或更多顶点的多边形分解,这样我们的整个模型就由三角形组成。

我把它留作一个迷你项目,让你更深入地挖掘我的代码,或者尝试加载 teapot.off 文件。现在,我将继续使用 teapot.py 加载的三角形,这样我们可以更快地进入绘制和玩茶壶的阶段。我跳过的另一个步骤是将 PyGame 和 OpenGL 的初始化组织到一个函数中,这样我们就不必每次绘制模型时都重复它。在 draw_model.py 中,你会找到一个以下函数:

def draw_model(faces, color_map=blues, light=(1,2,3)):
        ...

它接受一个 3D 模型的面(假设是正确方向的三角形),一个用于着色的颜色图,以及一个表示光源的向量,并据此绘制模型。还有一些我们在第四章和第五章中介绍的关键字参数。就像我们用来绘制八面体的代码一样,它反复循环地绘制传入的任何模型。这个列表展示了我在 draw_teapot.py 中如何将这些组合在一起。

列表 C.2 加载茶壶三角形并将其传递给 draw_model

from teapot import load_triangles
from draw_model import draw_model

draw_model(load_triangles())

结果是一个茶壶的俯视图。你可以看到圆形的盖子,左侧的把手和右侧的壶嘴(图 C.5)。

图片

图 C.5 渲染茶壶

现在我们能够渲染比简单几何图形更有趣的形状,是时候玩耍了!如果你阅读了第四章,你学习了可以对茶壶的所有顶点执行数学变换,以在 3D 空间中移动和扭曲它。这里,我也为你留下了一些练习,如果你想要对渲染代码进行一些有指导的探索。

C.4 练习

练习 C.1: 修改 draw_model 函数以从任何旋转视角显示输入的图形。具体来说,给 draw_model 函数一个关键字参数 glRotatefArgs,它提供一个包含四个数字的元组,对应于 glRotatef 的四个参数。有了这些额外信息,在 draw_model 函数体中添加一个适当的 glRotatef 调用来执行旋转。解决方案: 在本书的源代码中,请参阅 draw_model.py 以获取解决方案,以及 draw_teapot_glrotatef.py 以获取示例用法。
练习 C.2: 如果我们每帧调用 glRotatef(1,1,1,1),场景完成一次完整旋转需要多少秒?解决方案: 答案取决于帧率。这个 glRotatef 调用每帧旋转视角 1°。在 60 fps 下,它每秒旋转 60°,并在 6 秒内完成 360° 的完整旋转。
练习 C.3-迷你项目: 实现之前展示的 load_triangles() 函数,该函数从 teapot.off 文件中加载茶壶,并生成一个包含三角形的 Python 列表。每个三角形应由三个 3D 向量指定。然后,将你的结果传递给 draw_model() 并确认你是否看到了相同的结果。解决方案: 在源代码中,你可以在文件 teapot.py 中找到 load_triangles() 的实现。提示: 你可以通过连接它们的对顶点将四边形转换为两个三角形!图片四边形的四个顶点索引,通过顶点 0, 1, 2 和 0, 2, 3 分别形成两个三角形。
练习 C.4-迷你项目: 通过改变 gluPerspectiveglTranslatef 的参数来动画化茶壶。这将帮助你可视化每个参数的效果。解决方案: 在源代码中的文件 animated_octahedron.py 中,给出了一个通过每帧更新 glRotatefangle 参数来使八面体每秒旋转 360 / 5 = 72° 的示例。你可以尝试对茶壶或八面体进行类似的修改。
posted @ 2025-11-20 09:29  绝不原创的飞龙  阅读(43)  评论(0)    收藏  举报