Python-数学应用-全-
Python 数学应用(全)
原文:
zh.annas-archive.org/md5/123a7612a4e578f6816d36f968cfec22译者:飞龙
第一章:前言
Python 是一种功能强大、灵活且易于学习的编程语言。它是许多专业人士、爱好者和科学家的首选编程语言。Python 的强大之处来自其庞大的软件包生态系统和友好的社区,以及其与编译扩展模块无缝通信的能力。这意味着 Python 非常适合解决各种问题,特别是数学问题。
数学通常与计算和方程联系在一起,但实际上,这些只是更大主题的很小部分。在其核心,数学是关于解决问题、以及逻辑、结构化方法的学科。一旦你探索了方程、计算、导数和积分之外,你会发现一个庞大而优雅的世界。
本书是使用 Python 解决数学问题的介绍。它介绍了一些来自数学的基本概念,以及如何使用 Python 来处理这些概念,并提供了解决数学问题的模板,涵盖了数学中大量主题的各种数学问题。前几章侧重于核心技能,如使用 NumPy 数组、绘图、微积分和概率。这些主题在整个数学中非常重要,并作为本书其余部分的基础。在剩下的章节中,我们讨论了更多实际的问题,涵盖了数据分析和统计、网络、回归和预测、优化和博弈论等主题。我们希望本书为解决数学问题提供了基础,并为您进一步探索数学世界提供了工具。
本书的读者
读者需要具备基本的 Python 知识。我们不假设读者具有任何数学知识,尽管熟悉一些基本数学概念的读者将更好地理解我们讨论的技术的背景和细节。
本书涵盖的内容
第一章,基本软件包、函数和概念,介绍了本书其余部分需要的一些基本工具和概念,包括用于数学编程的主要 Python 软件包 NumPy 和 SciPy。
第二章,使用 Matplotlib 进行数学绘图,介绍了使用 Matplotlib 进行绘图的基础知识,这在解决几乎所有数学问题时都很有用。
第三章,微积分和微分方程,介绍了微积分的主题,如微分和积分,以及一些更高级的主题,如常微分方程和偏微分方程。
第四章,处理随机性和概率,介绍了随机性和概率的基础知识,以及如何使用 Python 探索这些概念。
第五章,处理树和网络,介绍了使用 NetworkX 软件包在 Python 中处理树和网络(图)的内容。
第六章,处理数据和统计,介绍了使用 Python 处理、操作和分析数据的各种技术。
第七章,回归和预测,描述了使用 Statsmodels 软件包和 scikit-learn 预测未来值的各种建模技术。
第八章,几何问题,演示了使用 Shapely 软件包在 Python 中处理几何对象的各种技术。
第九章,寻找最优解,介绍了使用数学方法找到问题的最佳解的优化和博弈论。
第十章,杂项主题,涵盖了使用 Python 解决数学问题时可能遇到的各种情况。
充分利用本书
本书中唯一的要求是使用最新版本的 Python,至少 Python 3.6,但更高版本更好。一些读者可能更喜欢使用 Python 的 Anaconda 发行版,该发行版包含本书中所需的许多软件包和工具。如果是这种情况,您应该使用conda软件包管理器来安装这些软件包。Python 支持所有主要操作系统——Windows、macOS 和 Linux——以及许多平台。以下表格涵盖了在撰写本书时使用的主要库及其版本:
| 书中涵盖的软件/库 | 版本 | 章节 |
|---|---|---|
| Python | 3.6 或更高版本 | 所有 |
| NumPy | 1.18.3 | 所有 |
| SciPy | 1.4.1 | 所有 |
| Matplotlib | 3.2.1 | 所有 |
| Pandas | 1.0.3 | 6 - 10 |
| Bokeh | 2.1.0 | 6 |
| Scikit-Learn | 0.22.1 | 7 |
| Dask | 2.18.1 | 10 |
| Apache Kafka | 2.5.0 | 10 |
如果您使用本书的数字版本,我们建议您自己输入代码或通过 GitHub 存储库(下一节中提供链接)访问代码。这样做将有助于避免与复制和粘贴代码相关的任何潜在错误。
一些读者可能更喜欢在 Jupyter 笔记本中而不是在简单的 Python 文件中逐步完成本书中的代码示例。本书中有一两个地方可能需要重复绘图命令。这些地方在说明中有标记。
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
-
登录或注册www.packt.com。
-
选择支持选项卡。
-
点击代码下载。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
下载文件后,请确保使用最新版本的以下软件解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Applying-Math-with-Python。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还有来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。去看看吧!
代码示例
本书的代码示例视频可以在bit.ly/2ZQcwIM上观看。
使用的约定
本书中使用了许多文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这里有一个例子:“decimal包还提供了一个Context对象,它允许对Decimal对象的精度、显示和属性进行精细控制。”
代码块设置如下:
from decimal import getcontext
ctx = getcontext()
num = Decimal('1.1')
num**4 # Decimal('1.4641')
ctx.prec = 4 # set new precision
num**4 # Decimal('1.464')
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
from numpy import linalg
A = np.array([[3, -2, 1], [1, 1, -2], [-3, -2, 1]])
b = np.array([7, -4, 1])
任何命令行输入或输出都是这样写的:
python3.8 -m pip install numpy scipy
粗体:表示一个新术语、一个重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这里有一个例子:“从管理面板中选择系统信息。”
警告或重要说明会以这种方式出现。提示和技巧会以这种方式出现。
章节
在本书中,您会经常看到几个标题(准备工作,如何做,它是如何工作的,还有更多,和另请参阅)。
为了清晰地说明如何完成食谱,使用以下各节:
准备工作
本节告诉您在食谱中可以期待什么,并描述如何设置任何所需的软件或初步设置。
如何做…
本节包含按照食谱所需的步骤。
它是如何工作的…
本节通常包括对前一节发生的事情的详细解释。
还有更多…
本节包含有关食谱的额外信息,以使您对食谱更加了解。
另请参阅
本节提供了其他有用信息的链接,以帮助制作食谱。
第二章:基本包、函数和概念
在开始任何实际配方之前,我们将使用本章来介绍几个核心数学概念和结构及其 Python 表示。特别是,我们将研究基本数值类型、基本数学函数(三角函数、指数函数和对数)以及矩阵。由于矩阵与线性方程组的解之间的联系,矩阵在大多数计算应用中都是基本的。我们将在本章中探讨其中一些应用,但矩阵将在整本书中发挥重要作用。
我们将按照以下顺序涵盖以下主要主题:
-
Python 数值类型
-
基本数学函数
-
NumPy 数组
-
矩阵
技术要求
在本章和本书的整个过程中,我们将使用 Python 3.8 版本,这是写作时最新的 Python 版本。本书中的大部分代码将适用于 Python 3.6 及更高版本。我们将在不同的地方使用 Python 3.6 引入的功能,包括 f-strings。这意味着您可能需要更改任何终端命令中出现的python3.8,以匹配您的 Python 版本。这可能是另一个版本的 Python,如python3.6或python3.7,或者更一般的命令,如python3或python。对于后者的命令,您需要使用以下命令检查 Python 的版本至少为 3.6:
python --version
Python 具有内置的数值类型和基本的数学函数,足以满足只涉及小计算的小型应用。NumPy 包提供了高性能的数组类型和相关例程(包括对数组进行高效操作的基本数学函数)。这个包将在本章和本书的其余部分中使用。我们还将在本章的后续配方中使用 SciPy 包。这两个包都可以使用您喜欢的包管理器(如pip)进行安装:
python3.8 -m pip install numpy scipy
按照惯例,我们将这些包导入为更短的别名。我们使用以下import语句将numpy导入为np,将scipy导入为sp:
import numpy as np
import scipy as sp
这些约定在这些包的官方文档中使用,以及许多使用这些包的教程和其他材料中使用。
本章的代码可以在 GitHub 存储库的Chapter 01文件夹中找到,网址为github.com/PacktPublishing/Applying-Math-with-Python/tree/master/Chapter%2001。
查看以下视频以查看代码实际操作:bit.ly/3g3eBXv。
Python 数值类型
Python 提供了基本的数值类型,如任意大小的整数和浮点数(双精度)作为标准,但它还提供了几种在精度特别重要的特定应用中有用的附加类型。Python 还提供了对复数的(内置)支持,这对一些更高级的数学应用很有用。
十进制类型
对于需要精确算术运算的十进制数字的应用,可以使用 Python 标准库中的decimal模块中的Decimal类型:
from decimal import Decimal
num1 = Decimal('1.1')
num2 = Decimal('1.563')
num1 + num2 # Decimal('2.663')
使用浮点对象进行此计算会得到结果 2.6630000000000003,这包括了一个小误差,这是因为某些数字不能用有限的 2 的幂的和来精确表示。例如,0.1 的二进制展开是 0.000110011...,它不会终止。因此,这个数字的任何浮点表示都会带有一个小误差。请注意,Decimal的参数是一个字符串,而不是一个浮点数。
Decimal 类型基于 IBM 通用十进制算术规范(speleotrove.com/decimal/decarith.html),这是一种浮点算术的替代规范,它通过使用 10 的幂而不是 2 的幂来精确表示十进制数。这意味着它可以安全地用于金融计算,其中舍入误差的累积将产生严重后果。然而,Decimal 格式的内存效率较低,因为它必须存储十进制数字而不是二进制数字(位),并且比传统的浮点数更加计算密集。
decimal 包还提供了一个Context对象,它允许对Decimal对象的精度、显示和属性进行精细控制。可以使用decimal模块的getcontext函数访问当前(默认)上下文。getcontext返回的Context对象具有许多可以修改的属性。例如,我们可以设置算术运算的精度:
from decimal import getcontext
ctx = getcontext()
num = Decimal('1.1')
num**4 # Decimal('1.4641')
ctx.prec = 4 # set new precision
num**4 # Decimal('1.464')
当我们将精度设置为 4 时,而不是默认的 28,我们会发现 1.1 的四次方被舍入为 4 个有效数字。
甚至可以使用localcontext函数在本地设置上下文,该函数返回一个上下文管理器,在with块结束时恢复原始环境:
from decimal import localcontext
num = Decimal("1.1")
with localcontext() as ctx:
ctx.prec = 2
num**4 # Decimal('1.5')
num**4 # Decimal('1.4641')
这意味着上下文可以在with块内自由修改,并且在结束时将返回默认值。
分数类型
或者,对于需要准确表示整数分数的应用程序,例如处理比例或概率时,可以使用 Python 标准库中 fractions 模块的 Fraction 类型。用法类似,只是我们通常将分数的分子和分母作为参数给出:
from fractions import Fraction
num1 = Fraction(1, 3)
num2 = Fraction(1, 7)
num1 * num2 # Fraction(1, 21)
Fraction 类型只是简单地存储两个整数,即分子和分母,并且使用分数的加法和乘法的基本规则执行算术运算。
复数类型
Python 也支持复数,包括在代码中表示复数单位 1j 的文字字符。这可能与您从其他复数源上熟悉的表示复数单位的习语不同。大多数数学文本通常会使用符号 i 来表示复数单位:
z = 1 + 1j
z + 2 # 3 + 1j
z.conjugate() # 1 - 1j
Python 标准库的 cmath 模块提供了专门针对“复数” - 意识的数学函数。
基本数学函数
基本数学函数出现在许多应用程序中。例如,对数可以用于将呈指数增长的数据缩放为线性数据。指数函数和三角函数在处理几何信息时是常见的固定内容,gamma 函数 出现在组合学中,高斯误差函数 在统计学中很重要.
Python 标准库中的 math 模块提供了所有标准数学函数,以及常见常数和一些实用函数,可以使用以下命令导入:
import math
一旦导入,我们可以使用此模块中包含的任何数学函数。例如,要找到非负数的平方根,我们将使用 math 中的 sqrt 函数:
import math
math.sqrt(4) # 2.0
尝试使用 sqrt 函数的负参数将引发 ValueError。这个 sqrt 函数不定义负数的平方根,它只处理实数。负数的平方根——这将是一个复数——可以使用 Python 标准库中 cmath 模块的替代 sqrt 函数找到。
三角函数,正弦、余弦和正切,在math模块中分别以它们的常见缩写sin、cos和tan可用。pi 常数保存了π的值,约为 3.1416:
theta = pi/4
math.cos(theta) # 0.7071067811865476
math.sin(theta) # 0.7071067811865475
math.tan(theta) # 0.9999999999999999
math模块中的反三角函数分别命名为acos、asin和atan:
math.asin(-1) # -1.5707963267948966
math.acos(-1) # 3.141592653589793
math.atan(1) # 0.7853981633974483
math模块中的log函数执行对数。它有一个可选参数来指定对数的底数(注意第二个参数只能是位置参数)。默认情况下,没有可选参数,它是以自然对数为底数e。e常数可以使用math.e来访问:
math.log(10) # 2.302585092994046
math.log(10, 10) # 1.0
math模块还包含gamma函数,即伽玛函数,以及erf函数,即高斯误差函数,这在统计学中非常重要。这两个函数都是通过积分来定义的。伽玛函数由积分定义

误差函数由下式定义

误差函数定义中的积分无法使用微积分来求解,而必须通过数值计算来完成:
math.gamma(5) # 24.0
math.erf(2) # 0.9953222650189527
除了标准函数,如三角函数、对数和指数函数之外,math模块还包含各种理论和组合函数。这些包括comb和factorial函数,它们在各种应用中非常有用。使用参数n和k调用的comb函数返回从n个项目的集合中选择k个项目的方式数,如果顺序不重要且没有重复。例如,先选择 1 再选择 2 与先选择 2 再选择 1 是相同的。这个数字有时被写为^nC[k]。使用参数n调用的阶乘函数返回阶乘n! = n(n-1)(n-2)…1:
math.comb(5, 2) # 10
math.factorial(5) # 120
对负数应用阶乘会引发ValueError。整数n的阶乘与n + 1处的伽玛函数的值相符,即

math模块还包含一个返回其参数的最大公约数的函数,称为gcd。a和b的最大公约数是最大的整数k,使得k能够完全整除a和b:
math.gcd(2, 4) # 2
math.gcd(2, 3) # 1
还有一些用于处理浮点数的函数。fsum函数对数字的可迭代对象执行加法,并在每一步跟踪总和,以减少结果中的误差。以下示例很好地说明了这一点:
nums = [0.1]*10 # list containing 0.1 ten times
sum(nums) # 0.9999999999999999
math.fsum(nums) # 1.0
isclose函数返回True,如果参数之间的差小于公差。这在单元测试中特别有用,因为基于机器架构或数据变异性,结果可能会有小的变化。
最后,math中的floor和ceil函数提供了它们的参数的下限和上限。数字x的floor是最大的整数f,使得f ≤ x,x的ceiling是最小的整数c,使得x ≤ c。在将一个数字除以另一个数字得到浮点数和整数之间转换时,这些函数非常有用。
math模块包含了在 C 中实现的函数(假设你正在运行 CPython),因此比在 Python 中实现的函数要快得多。如果你需要将函数应用于一个相对较小的数字集合,这个模块是一个不错的选择。如果你想要同时将这些函数应用于大量数据集合,最好使用 NumPy 包中的等效函数,这些函数对数组的处理更有效率。总的来说,如果你已经导入了 NumPy 包,那么最好总是使用这些函数的 NumPy 等效函数,以减少出错的机会。
NumPy 数组
NumPy 提供了高性能的数组类型和用于在 Python 中操作这些数组的例程。这些数组对于处理性能至关重要的大型数据集非常有用。NumPy 构成了 Python 中的数值和科学计算堆栈的基础。在幕后,NumPy 利用低级库来处理向量和矩阵,例如基本线性代数子程序(BLAS)包,以及线性代数包(LAPACK)包含更高级的线性代数例程。
传统上,NumPy 包是使用更短的别名np导入的,可以使用以下import语句来实现:
import numpy as np
特别是在 NumPy 文档和更广泛的科学 Python 生态系统(SciPy、Pandas 等)中使用了这种约定。
NumPy 库提供的基本类型是ndarray类型(以下简称 NumPy 数组)。通常,您不会创建此类型的自己的实例,而是使用array之类的辅助例程之一来正确设置类型。array例程从类似数组的对象创建 NumPy 数组,这通常是一组数字或一组(数字)列表。例如,我们可以通过提供包含所需元素的列表来创建一个简单的数组:
ary = np.array([1, 2, 3, 4]) # array([1, 2, 3, 4])
NumPy 数组类型(ndarray)是围绕基础 C 数组结构的 Python 包装器。数组操作是用 C 实现的,并针对性能进行了优化。NumPy 数组必须由同质数据组成(所有元素具有相同的类型),尽管此类型可以是指向任意 Python 对象的指针。如果在创建时未明确提供使用dtype关键字参数,则 NumPy 将推断出适当的数据类型:
np.array([1, 2, 3, 4], dtype=np.float32)
# array([1., 2., 3., 4.], dtype=float32)
在幕后,任何形状的 NumPy 数组都是一个包含原始数据的缓冲区,作为一个平坦(一维)数组,并包含一系列额外的元数据,用于指定诸如元素类型之类的细节。
创建后,可以使用数组的dtype属性访问数据类型。修改dtype属性将产生不良后果,因为构成数组中的原始字节将被重新解释为新的数据类型。例如,如果我们使用 Python 整数创建数组,NumPy 将在数组中将其转换为 64 位整数。更改dtype值将导致 NumPy 将这些 64 位整数重新解释为新的数据类型:
arr = np.array([1, 2, 3, 4])
print(arr.dtype) # dtype('int64')
arr.dtype = np.float32
print(arr)
# [1.e-45 0.e+00 3.e-45 0.e+00 4.e-45 0.e+00 6.e-45 0.e+00]
每个 64 位整数都被重新解释为两个 32 位浮点数,这显然会产生无意义的值。相反,如果您希望在创建后更改数据类型,请使用astype方法指定新类型。更改数据类型的正确方法如下所示:
arr = arr.astype(np.float32)
print(arr)
# [1\. 2\. 3\. 4.]
NumPy 还提供了一些用于创建各种标准数组的例程。zeros例程创建一个指定形状的数组,其中每个元素都是0,而ones例程创建一个数组,其中每个元素都是1。
元素访问
NumPy 数组支持getitem协议,因此可以像列表一样访问数组中的元素,并支持所有按组件执行的算术操作。这意味着我们可以使用索引表示法和索引来检索指定索引处的元素,如下所示:
ary = np.array([1, 2, 3, 4])
ary[0] # 1
ary[2] # 3
这还包括从现有数组中提取数据数组的常规切片语法。数组的切片再次是一个数组,其中包含切片指定的元素。例如,我们可以检索包含ary的前两个元素的数组,或者包含偶数索引处的元素的数组,如下所示:
first_two = ary[:2] # array([1, 2])
even_idx = ary[::2] # array([1, 3])
切片的语法是start:stop:step。我们可以省略start和stop中的一个或两个,以从所有元素的开头或结尾分别获取。我们也可以省略step参数,这种情况下我们也会去掉尾部的:。step参数描述了应该选择的选定范围内的元素。值为1选择每个元素,或者如本例中,值为2选择每第二个元素(从0开始给出偶数编号的元素)。这个语法与切片 Python 列表的语法相同。
数组的算术和函数
NumPy 提供了许多通用函数(ufunc),这些函数可以高效地操作 NumPy 数组类型。特别是,在基本数学函数部分讨论的所有基本数学函数在 NumPy 中都有类似的函数,可以在 NumPy 数组上操作。通用函数还可以执行广播,以允许它们在不同但兼容的形状的数组上进行操作。
NumPy 数组上的算术运算是逐分量执行的。以下示例最能说明这一点:
arr_a = np.array([1, 2, 3, 4])
arr_b = np.array([1, 0, -3, 1])
arr_a + arr_b # array([2, 2, 0, 5])
arr_a - arr_b # array([0, 2, 6, 3])
arr_a * arr_b # array([ 1, 0, -9, 4])
arr_b / arr_a # array([ 1\. , 0\. , -1\. , 0.25])
arr_b**arr_a # array([1, 0, -27, 1])
请注意,数组必须具有相同的形状,这意味着具有相同的长度。对不同形状的数组进行算术运算将导致ValueError。通过数字进行加法、减法、乘法或除法将导致数组,其中已对每个分量应用了操作。例如,我们可以使用以下命令将数组中的所有元素乘以2:
arr = np.array([1, 2, 3, 4])
new = 2*arr
print(new)
# [2, 4, 6, 8]
有用的数组创建例程
要在两个给定端点之间以规则间隔生成数字数组,可以使用arange例程或linspace例程。这两个例程之间的区别在于linspace生成一定数量(默认为 50)的值,这些值在两个端点之间具有相等的间距,包括两个端点,而arange生成给定步长的数字,但不包括上限。linspace例程生成封闭区间a ≤ x ≤ b中的值,而arange例程生成半开区间a≤ x < b中的值:
np.linspace(0, 1, 5) # array([0., 0.25, 0.5, 0.75, 1.0])
np.arange(0, 1, 0.3) # array([0.0, 0.3, 0.6, 0.9])
请注意,使用linspace生成的数组恰好有 5 个点,由第三个参数指定,包括0和1两个端点。使用arange生成的数组有 4 个点,不包括右端点1;0.3 的额外步长将等于 1.2,这比 1 大。
更高维度的数组
NumPy 可以创建任意维度的数组,使用与简单一维数组相同的array例程创建。数组的维数由提供给array例程的嵌套列表的数量指定。例如,我们可以通过提供一个列表的列表来创建一个二维数组,其中内部列表的每个成员都是一个数字,如下所示:
mat = np.array([[1, 2], [3, 4]])
NumPy 数组具有shape属性,描述了每个维度中元素的排列方式。对于二维数组,形状可以解释为数组的行数和列数。
*NumPy 将形状存储为数组对象上的shape属性,这是一个元组。这个元组中的元素数量就是数组的维数:
vec = np.array([1, 2])
mat.shape # (2, 2)
vec.shape # (2,)
由于 NumPy 数组中的数据存储在一个扁平(一维)数组中,可以通过简单地更改相关的元数据来以很小的成本重新塑造数组。这是通过 NumPy 数组的reshape方法完成的:
mat.reshape(4,) # array([1, 2, 3, 4])
请注意,元素的总数必须保持不变。矩阵mat最初的形状为(2, 2),共有 4 个元素,后者是一个形状为(4,)的一维数组,再次共有 4 个元素。当总元素数量不匹配时,尝试重新塑造将导致ValueError。
要创建更高维度的数组,只需添加更多级别的嵌套列表。为了更清楚地说明这一点,在下面的示例中,我们在构造数组之前将第三维度中的每个元素的列表分开:
mat1 = [[1, 2], [3, 4]]
mat2 = [[5, 6], [7, 8]]
mat3 = [[9, 10], [11, 12]]
arr_3d = np.array([mat1, mat2, mat3])
arr_3d.shape # (3, 2, 2)
请注意,形状的第一个元素是最外层的,最后一个元素是最内层的。
这意味着向数组添加一个额外的维度只是提供相关的元数据。使用array例程,shape元数据由参数中每个列表的长度描述。最外层列表的长度定义了该维度的相应shape参数,依此类推。
NumPy 数组在内存中的大小并不显著取决于维度的数量,而只取决于元素的总数,这是shape参数的乘积。但是,请注意,高维数组中的元素总数往往较大。
要访问多维数组中的元素,您可以使用通常的索引表示法,但是不是提供单个数字,而是需要在每个维度中提供索引。对于 2×2 矩阵,这意味着指定所需元素的行和列:
mat[0, 0] # 1 - top left element
mat[1, 1] # 4 - bottom right element
索引表示法还支持在每个维度上进行切片,因此我们可以使用切片mat[:, 0]提取单列的所有成员,如下所示:
mat[:, 0]
# array([1, 3])
请注意,切片的结果是一个一维数组。
数组创建函数zeros和ones可以通过简单地指定一个具有多个维度参数的形状来创建多维数组。
矩阵
NumPy 数组也可以作为矩阵,在数学和计算编程中是基本的。矩阵只是一个二维数组。矩阵在许多应用中都是核心,例如几何变换和同时方程,但也出现在其他领域的有用工具中,例如统计学。矩阵本身只有在我们为它们配备矩阵算术时才是独特的(与任何其他数组相比)。矩阵具有逐元素的加法和减法运算,就像 NumPy 数组一样,还有一种称为标量乘法的第三种运算,其中我们将矩阵的每个元素乘以一个常数,以及一种不同的矩阵乘法概念。矩阵乘法与其他乘法概念根本不同,我们稍后会看到。
矩阵的一个最重要的属性是其形状,与 NumPy 数组的定义完全相同。具有m行和n列的矩阵通常被描述为m×n矩阵。具有与列数相同的行数的矩阵被称为方阵,这些矩阵在向量和矩阵理论中起着特殊的作用。
单位矩阵(大小为n)是n×n矩阵,其中(i,i)-th 条目为 1,而(i,j)-th 条目对于i ≠ j为零。有一个数组创建例程,为指定的n值提供n×n单位矩阵:
np.eye(3)
# array([[1., 0., 0.],
# [0., 1., 0.],
# [0., 0., 1.]])
基本方法和属性
与矩阵相关的术语和数量有很多。我们在这里只提到两个这样的属性,因为它们以后会有用。这些是矩阵的转置,其中行和列互换,以及迹,它是方阵沿着主对角线的元素之和。主对角线由从矩阵左上角到右下角的线上的元素a[ii]组成。
NumPy 数组可以通过在array对象上调用transpose方法轻松转置。实际上,由于这是一个常见的操作,数组有一个方便的属性T,它返回矩阵的转置。转置会颠倒矩阵(数组)的形状顺序,使行变为列,列变为行。例如,如果我们从一个 3×2 矩阵(3 行,2 列)开始,那么它的转置将是一个 2×3 矩阵,就像下面的例子一样:
mat = np.array([[1, 2], [3, 4]])
mat.transpose()
# array([[1, 3],
# [2, 4]])
mat.T
# array([[1, 3],
# [2, 4]])
与矩阵相关的另一个数量有时也是有用的是trace。方阵A的 trace,其条目如前面的代码所示,被定义为leading diagonal上的元素之和,它由从左上角对角线到右下角的元素组成。trace 的公式如下*
*
NumPy 数组有一个trace方法,返回矩阵的迹:
A = np.array([[1, 2], [3, 4]])
A.trace() # 5
trace 也可以使用np.trace函数访问,它不绑定到数组。
矩阵乘法
矩阵乘法是在两个矩阵上执行的操作,它保留了两个矩阵的一些结构和特性。形式上,如果A是一个l × m矩阵,B是一个m × n矩阵,如下所述

那么矩阵积A和B是一个l × n矩阵,其(p, q)-th 条目由下式给出

请注意,第一个矩阵的列数必须与第二个矩阵的行数匹配,以便定义矩阵乘法。如果定义了矩阵乘积AB,我们通常写AB,如果它被定义。矩阵乘法是一种特殊的操作。它不像大多数其他算术运算那样是交换的:即使AB和BA都可以计算,它们不一定相等。实际上,这意味着矩阵的乘法顺序很重要。这源自矩阵代数作为线性映射的表示的起源,其中乘法对应于函数的组合。
Python 有一个保留给矩阵乘法的运算符@,这是在 Python 3.5 中添加的。NumPy 数组实现了这个运算符来执行矩阵乘法。请注意,这与数组的分量乘法*在本质上是不同的:
A = np.array([[1, 2], [3, 4]])
B = np.array([[-1, 1], [0, 1]])
A @ B
# array([[-1, 3],
# [-3, 7]])
A * B # different from A @ B
# array([[-1, 2],
# [ 0, 4]])
单位矩阵是矩阵乘法下的中性元素。也就是说,如果A是任意的l × m矩阵,I是m × m单位矩阵,则AI = A。可以使用 NumPy 数组轻松检查特定示例:
A = np.array([[1, 2], [3, 4]])
I = np.eye(2)
A @ I
# array([[1, 2],
# [3, 4]])
行列式和逆
一个方阵的determinant在大多数应用中都很重要,因为它与找到矩阵的逆的强连接。如果行和列的数量相等,则矩阵是方阵。特别地,一个具有非零行列式的矩阵具有(唯一的)逆,这对于某些方程组的唯一解是成立的。矩阵的行列式是递归定义的。对于一个 2×2 矩阵

矩阵A的determinant由以下公式定义

对于一个一般的n×n矩阵

其中n > 2,我们定义子矩阵A[i,j],对于 1 ≤ i,j ≤ n,为从A中删除第i行和第j列的结果。子矩阵A[i,j]是一个(n-1) × (n-1)矩阵,因此我们可以计算行列式。然后我们定义A的行列式为数量

实际上,出现在前述方程中的索引 1 可以被任何 1 ≤ i≤ n替换,结果将是相同的。
计算矩阵行列式的 NumPy 例程包含在一个名为linalg的单独模块中。这个模块包含了许多关于线性代数的常见例程,线性代数是涵盖向量和矩阵代数的数学分支。计算方阵行列式的例程是det例程:
from numpy import linalg
linalg.det(A) # -2.0000000000000004
请注意,在计算行列式时发生了浮点舍入误差。
如果安装了 SciPy 包,还提供了一个扩展了 NumPylinalg的linalg模块。SciPy 版本不仅包括额外的例程,而且始终使用 BLAS 和 LAPACK 支持进行编译,而对于 NumPy 版本,这是可选的。因此,如果速度很重要,SciPy 变体可能更可取,这取决于 NumPy 的编译方式。
n × n矩阵A的逆是(必然唯一的)n × n矩阵B,使得AB=BA=I,其中I表示n × n单位矩阵,这里执行的乘法是矩阵乘法。并非每个方阵都有逆;那些没有逆的有时被称为奇异矩阵。事实上,当A没有逆时,也就是说,当该矩阵的行列式为 0 时,它是非奇异的。当A有逆时,习惯上用A^(-1)表示它。
linalg模块的inv例程计算矩阵的逆,如果存在的话:
linalg.inv(A)
# array([[-2\. , 1\. ],
# [ 1.5, -0.5]])
我们可以通过矩阵乘法(在任一侧)乘以逆矩阵,并检查我们是否得到了 2 × 2 单位矩阵,来检查inv例程给出的矩阵是否确实是A的矩阵逆:
Ainv = linalg.inv(A)
Ainv @ A
# Approximately
# array([[1., 0.],
# [0., 1.]])
A @ Ainv
# Approximately
# array([[1., 0.],
# [0., 1.]])
这些计算中会有浮点误差,这已经被隐藏在Approximately注释后面,这是由于计算矩阵的逆的方式。
linalg包还包含许多其他方法,如norm,它计算矩阵的各种范数。它还包含了各种分解矩阵和解方程组的函数。
还有指数函数expm、对数函数logm、正弦函数sinm、余弦函数cosm和切线函数tanm的矩阵类比。请注意,这些函数与基本 NumPy 包中的标准exp、log、sin、cos和tan函数不同,后者是在元素基础上应用相应的函数。相反,矩阵指数函数是使用矩阵的“幂级数”定义的。

其中A是一个n × n矩阵,A^k是A的第k个矩阵幂;也就是说,A矩阵连续乘以自身k次。请注意,这个“幂级数”总是在适当的意义下收敛。按照惯例,我们取A⁰ = I,其中I是n × n单位矩阵。这与实数或复数的指数函数的常规幂级数定义完全类似,但是用矩阵和矩阵乘法代替了数字和(常规)乘法。其他函数也是以类似的方式定义的,但我们将跳过细节。
方程组
解(线性)方程组是研究矩阵的主要动机之一。这类问题在各种应用中经常出现。我们从写成线性方程组的形式开始

其中n至少为 2,a[i,j]和b[i]是已知值,x[i]值是我们希望找到的未知值。
在解这样的方程组之前,我们需要将问题转化为矩阵方程。这是通过将系数a[i,j]收集到一个n × n矩阵中,并使用矩阵乘法的性质将这个矩阵与方程组联系起来实现的。因此,让

是包含方程中系数的矩阵。然后,如果我们将x作为未知数(列)向量,包含x[i]值,b作为(列)向量,包含已知值b[i],那么我们可以将方程组重写为单个矩阵方程

现在我们可以使用矩阵技术来解决这个问题。在这种情况下,我们将列向量视为n × 1矩阵,因此前面方程中的乘法是矩阵乘法。为了解决这个矩阵方程,我们使用linalg模块中的solve例程。为了说明这种技术,我们将解决以下方程组作为示例:

这些方程有三个未知值,x[1],x[2]和x[3]。首先,我们创建系数矩阵和向量b。由于我们使用 NumPy 来处理矩阵和向量,我们为矩阵A创建一个二维 NumPy 数组,为b创建一个一维数组:
import numpy as np
from numpy import linalg
A = np.array([[3, -2, 1], [1, 1, -2], [-3, -2, 1]])
b = np.array([7, -4, 1])
现在,可以使用solve例程找到方程组的解:
linalg.solve(A, b) # array([ 1., -1., 2.])
这确实是方程组的解,可以通过计算A @ x并将结果与b数组进行对比来轻松验证。在这个计算中可能会出现浮点舍入误差。
solve函数需要两个输入,即系数矩阵A和右侧向量b。它使用 LAPACK 例程解决方程组,将矩阵A分解为更简单的矩阵,以快速减少为一个可以通过简单替换解决的更简单的问题。这种解决矩阵方程的技术非常强大和高效,并且不太容易受到浮点舍入误差的影响。例如,可以通过与矩阵A的逆矩阵相乘(在左侧)来计算方程组的解,如果已知逆矩阵。然而,这通常不如使用solve例程好,因为它可能更慢或导致更大的数值误差。
在我们使用的示例中,系数矩阵A是方阵。也就是说,方程的数量与未知值的数量相同。在这种情况下,如果且仅当矩阵A的行列式不为 0 时,方程组有唯一解。在矩阵A的行列式为 0 的情况下,可能会出现两种情况:方程组可能没有解,这种情况下我们称方程组是不一致的;或者可能有无穷多个解。一致和不一致系统之间的区别通常由向量b决定。例如,考虑以下方程组:

左侧的方程组是一致的,有无穷多个解;例如,取x = 1 和y = 1或x = 0和y = 2都是解。右侧的方程组是不一致的,没有解。在上述两种情况下,solve例程将失败,因为系数矩阵是奇异的。
系数矩阵不需要是方阵才能解决方程组。例如,如果方程比未知值多(系数矩阵的行数多于列数)。这样的系统被称为过度规定,只要是一致的,它就会有解。如果方程比未知值少,那么系统被称为不足规定。如果是一致的,不足规定的方程组通常有无穷多个解,因为没有足够的信息来唯一指定所有未知值。不幸的是,即使系统有解,solve例程也无法找到系数矩阵不是方阵的系统的解。
特征值和特征向量
考虑矩阵方程Ax = λx,其中A是一个方阵(n × n),x是一个向量,λ是一个数字。对于这个方程有一个x可以解决的λ被称为特征值,相应的向量x被称为特征向量。特征值和相应的特征向量对编码了关于矩阵A的信息,因此在许多矩阵出现的应用中非常重要。
我们将演示使用以下矩阵计算特征值和特征向量:

我们必须首先将其定义为 NumPy 数组:
import numpy as np
from numpy import linalg
A = np.array([[3, -1, 4], [-1, 0, -1], [4, -1, 2]])
linalg模块中的eig例程用于找到方阵的特征值和特征向量。这个例程返回一对(v, B),其中v是包含特征值的一维数组,B是其列是相应特征向量的二维数组:
v, B = linalg.eig(A)
只有具有实数条目的矩阵才可能具有复特征值和特征向量。因此,eig例程的返回类型有时将是复数类型,如complex32或complex64。在某些应用中,复特征值具有特殊含义,而在其他情况下,我们只考虑实特征值。
我们可以使用以下序列从eig的输出中提取特征值/特征向量对:
i = 0 # first eigenvalue/eigenvector pair
lambda0 = v[i]
print(lambda0)
# 6.823156164525971
x0 = B[:, i] # ith column of B
print(x0)
# array([ 0.73271846, -0.20260301, 0.649672352])
eig例程返回的特征向量是归一化的,使得它们的范数(长度)为 1。 (欧几里得范数被定义为数组成员的平方和的平方根。)我们可以通过使用linalg中的norm例程计算向量的范数来检查这一点:
linalg.norm(x0) # 1.0 - eigenvectors are normalized.
最后,我们可以通过计算乘积A @ x0并检查,直到浮点精度,这等于lambda0*x0,来检查这些值确实满足特征值/特征向量对的定义:
lhs = A @ x0
rhs = lambda0*x0
linalg.norm(lhs - rhs) # 2.8435583831733384e-15 - very small.
这里计算的范数表示方程Ax = λx的左侧lhs和右侧rhs之间的“距离”。由于这个距离非常小(小数点后 0 到 14 位),我们可以相当确信它们实际上是相同的。这不为零的事实可能是由于浮点精度误差。
eig例程是围绕低级 LAPACK 例程的包装器,用于计算特征值和特征向量。找到特征值和特征向量的理论过程是首先通过解方程找到特征值

其中I是适当的单位矩阵,以找到值λ。左侧确定的方程是λ的多项式,称为A的特征多项式。然后可以通过解决矩阵方程找到相应的特征向量

其中λ[j]是已经找到的特征值之一。实际上,这个过程有些低效,有替代策略可以更有效地计算特征值和特征向量。
特征值和特征向量的一个关键应用是主成分分析,这是一种将大型复杂数据集减少到更好地理解内部结构的关键技术。
我们只能计算方阵的特征值和特征向量;对于非方阵,该定义没有意义。有一种将特征值和特征值推广到非方阵的称为奇异值的方法。
稀疏矩阵
诸如前面讨论的那样的线性方程组在数学中非常常见,特别是在数学计算中。在许多应用中,系数矩阵将非常庞大,有数千行和列,并且可能来自替代来源而不是简单地手动输入。在许多情况下,它还将是稀疏矩阵,其中大多数条目为 0。
如果矩阵的大多数元素为零,则矩阵是稀疏的。要调用矩阵稀疏,需要为零的确切元素数量并不明确定义。稀疏矩阵可以更有效地表示,例如,只需存储非零的索引(i,j)和值a[i,j]。有整个集合的稀疏矩阵算法,可以在矩阵确实足够稀疏的情况下大大提高性能。
稀疏矩阵出现在许多应用程序中,并且通常遵循某种模式。特别是,解决偏微分方程(PDEs)的几种技术涉及解决稀疏矩阵方程(请参阅第三章,微积分和微分方程),与网络相关的矩阵通常是稀疏的。sparse.csgraph模块中包含与网络(图)相关的稀疏矩阵的其他例程。我们将在第五章中进一步讨论这些内容,处理树和网络。
sparse模块包含几种表示稀疏矩阵存储方式的不同类。存储稀疏矩阵的最基本方式是存储三个数组,其中两个包含表示非零元素的索引的整数,第三个包含相应元素的数据。这是coo_matrix类的格式。然后有压缩列 CSC(csc_matrix)和压缩行 CSR(csr_matrix)格式,它们分别提供了有效的列或行切片。sparse中还有三个额外的稀疏矩阵类,包括dia_matrix,它有效地存储非零条目沿对角线带出现的矩阵。
来自 SciPy 的sparse模块包含用于创建和处理稀疏矩阵的例程。我们使用以下import语句从 SciPy 导入sparse模块:
import numpy as np
from scipy import sparse
稀疏矩阵可以从完整(密集)矩阵或其他某种数据结构创建。这是使用特定格式的构造函数来完成的,您希望将稀疏矩阵存储在其中。
例如,我们可以通过使用以下命令将密集矩阵存储为 CSR 格式:
A = np.array([[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]])
sp_A = sparse.csr_matrix(A)
如果您手动生成稀疏矩阵,该矩阵可能遵循某种模式,例如以下三对角矩阵:

在这里,非零条目出现在对角线上以及对角线两侧,并且每行中的非零条目遵循相同的模式。要创建这样的矩阵,我们可以使用sparse中的数组创建例程之一,例如diags,这是一个用于创建具有对角线模式的矩阵的便利例程:
T = sparse.diags([-1, 2, -1], (-1, 0, 1), shape=(5, 5), format="csr")
这将创建矩阵T,并按压缩稀疏行 CSR 格式存储它。第一个参数指定应出现在输出矩阵中的值,第二个参数是相对于应放置值的对角线位置的位置。因此,元组中的 0 索引表示对角线条目,-1 表示在行中对角线的左侧,+1 表示在行中对角线的右侧。shape关键字参数给出了生成的矩阵的维度,format指定了矩阵的存储格式。如果没有使用可选参数提供格式,则将使用合理的默认值。数组T可以使用toarray方法扩展为完整(密集)矩阵:
T.toarray()
# array([[ 2, -1, 0, 0, 0],
# [-1, 2, -1, 0, 0],
# [ 0, -1, 2, -1, 0],
# [ 0, 0, -1, 2, -1],
# [ 0, 0, 0, -1, 2]])
当矩阵很小时(就像这里一样),稀疏求解例程和通常的求解例程之间的性能差异很小。
一旦矩阵以稀疏格式存储,我们可以使用sparse的linalg子模块中的稀疏求解例程。例如,我们可以使用该模块的spsolve例程来解决矩阵方程。spsolve例程将把矩阵转换为 CSR 或 CSC,如果矩阵不是以这些格式之一提供的话,可能会增加额外的计算时间:
from scipy.sparse import linalg
linalg.spsolve(T.tocsr(), np.array([1, 2, 3, 4, 5]))
# array([ 5.83333333, 10.66666667, 13.5 , 13.33333333, 9.16666667])
sparse.linalg模块还包含许多可以在 NumPy(或 SciPy)的linalg模块中找到的接受稀疏矩阵而不是完整 NumPy 数组的例程,例如eig和inv。
摘要
Python 提供了对数学的内置支持,包括一些基本的数值类型、算术和基本的数学函数。然而,对于涉及大量数值值数组的更严肃的计算,您应该使用 NumPy 和 SciPy 软件包。NumPy 提供高性能的数组类型和基本例程,而 SciPy 提供了更多用于解方程和处理稀疏矩阵(以及许多其他内容)的特定工具。
NumPy 数组可以是多维的。特别是,二维数组具有矩阵属性,可以使用 NumPy 或 SciPy 的linalg模块(前者是后者的子集)来访问。此外,Python 中有一个特殊的矩阵乘法运算符@,它是为 NumPy 数组实现的。
在下一章中,我们将开始查看一些配方。
进一步阅读
有许多数学教科书描述矩阵和线性代数的基本属性,线性代数是研究向量和矩阵的学科。一个很好的入门文本是Blyth, T. and Robertson, E. (2013). Basic Linear Algebra**. London: Springer London, Limited。
NumPy 和 SciPy 是 Python 数学和科学计算生态系统的一部分,并且有广泛的文档可以从官方网站scipy.org访问。我们将在本书中看到这个生态系统中的几个其他软件包。
有关 NumPy 和 SciPy 在幕后使用的 BLAS 和 LAPACK 库的更多信息可以在以下链接找到:BLAS:www.netlib.org/blas/ 和 LAPACK:www.netlib.org/lapack/。
第三章:使用 Matplotlib 进行数学绘图
绘图是数学中的基本工具。一个好的图可以揭示隐藏的细节,建议未来的方向,验证结果或加强论点。因此,科学 Python 堆栈中拥有一个名为 Matplotlib 的强大而灵活的绘图库并不奇怪。
在本章中,我们将以各种样式绘制函数和数据,并创建完全标记和注释的图。我们将创建三维图,自定义图的外观,使用子图创建包含多个图的图,并直接将图保存到文件中,以供在非交互式环境中运行的应用程序使用。
在本章中,我们将涵盖以下示例:
-
使用 Matplotlib 进行基本绘图
-
更改绘图样式
-
为绘图添加标签和图例
-
添加子图
-
保存 Matplotlib 图
-
表面和等高线图
-
自定义三维图
技术要求
Python 的主要绘图包是 Matplotlib,可以使用您喜欢的软件包管理器(如pip)进行安装:
python3.8 -m pip install matplotlib
这将安装最新版本的 Matplotlib,在撰写本书时,最新版本是 3.2.1。
Matplotlib 包含许多子包,但主要用户界面是matplotlib.pyplot包,按照惯例,它被导入为plt别名。可以使用以下导入语句来实现这一点:
import matplotlib.pyplot as plt
本章中的许多示例还需要 NumPy,通常情况下,它被导入为np别名。
本章的代码可以在 GitHub 存储库的Chapter 02文件夹中找到,网址为github.com/PacktPublishing/Applying-Math-with-Python/tree/master/Chapter%2002。
查看以下视频以查看代码实际操作:bit.ly/2ZOSuhs。
使用 Matplotlib 进行基本绘图
绘图是理解行为的重要部分。通过简单地绘制函数或数据,可以学到很多原本隐藏的东西。在这个示例中,我们将介绍如何使用 Matplotlib 绘制简单的函数或数据。
Matplotlib 是一个非常强大的绘图库,这意味着用它执行简单任务可能会令人畏惧。对于习惯于使用 MATLAB 和其他数学软件包的用户,有一个称为pyplot的基于状态的接口。还有一个面向对象的接口,对于更复杂的绘图可能更合适。pyplot接口是创建基本对象的便捷方式。
准备工作
通常情况下,要绘制的数据将存储在两个单独的 NumPy 数组中,我们将为了清晰起见将它们标记为x和y(尽管在实践中这个命名并不重要)。我们将演示绘制函数的图形,因此我们将生成一组x值的数组,并使用函数生成相应的y值。我们定义将要绘制的函数如下:
def f(x):
return x*(x - 2)*np.exp(3 - x)
操作步骤
在我们绘制函数之前,我们必须生成要绘制的x和y数据。如果要绘制现有数据,可以跳过这些命令。我们需要创建一组覆盖所需范围的x值,然后使用函数创建y值:
- NumPy 中的
linspace例程非常适合创建用于绘图的数字数组。默认情况下,它将在指定参数之间创建 50 个等间距点。可以通过提供额外的参数来自定义点的数量,但对于大多数情况来说,50 就足够了。
x = np.linspace(-0.5, 3.0) # 100 values between -0.5 and 3.0
- 一旦我们创建了
x值,就可以生成y值:
y = f(x) # evaluate f on the x points
- 要绘制数据,我们只需要从
pyplot接口调用plot函数,该接口被导入为plt别名。第一个参数是x数据,第二个是y数据。该函数返回一个用于绘制数据的轴对象的句柄:
plt.plot(x, y)
- 这将在新的图形上绘制
y值与x值。如果你在 IPython 中工作或者使用 Jupyter 笔记本,那么图形应该会自动出现;否则,你可能需要调用plt.show函数来使图形出现:
plt.show()
如果使用plt.show,图形应该会出现在一个新窗口中。生成的图形应该看起来像图 2.1中的图形。你的默认绘图颜色可能与你的绘图不同。这是为了增加可见性而更改的默认绘图颜色:

图 2.1:使用 Matplotlib 绘制的函数的图形,没有任何额外的样式参数
我们不会在本章的其他配方中添加这个命令,但是你应该知道,如果你不是在自动渲染图形的环境中工作,比如在 IPython 控制台或 Jupyter Notebook 中,你将需要使用它。
工作原理...
如果当前没有Figure或Axes对象,plt.plot例程会创建一个新的Figure对象,向图形添加一个新的Axes对象,并用绘制的数据填充这个Axes对象。返回一个指向绘制线的句柄列表。每个句柄都是一个Lines2D对象。在这种情况下,这个列表将包含一个单独的Lines2D对象。我们可以使用这个Lines2D对象稍后自定义线的外观(参见更改绘图样式配方)。
Matplotlib 的对象层与较低级别的后端进行交互,后端负责生成图形绘图的繁重工作。plt.show函数发出指令给后端来渲染当前的图形。Matplotlib 可以使用多个后端,可以通过设置MPLBACKEND环境变量、修改matplotlibrc文件,或者在 Python 中调用matplotlib.use并指定替代后端的名称来自定义。
plt.show函数不仅仅是在图形上调用show方法。它还连接到一个事件循环,以正确显示图形。应该使用plt.show例程来显示图形,而不是在Figure对象上调用show方法。
还有更多...
有时候在调用plot例程之前手动实例化一个Figure对象是有用的,例如,强制创建一个新的图形。这个配方中的代码也可以写成如下形式:
fig = plt.figure() # manually create a figure
lines = plt.plot(x, y) # plot data
plt.plot例程接受可变数量的位置输入。在前面的代码中,我们提供了两个位置参数,它们被解释为x值和y值(按顺序)。如果我们只提供了一个单一的数组,plot例程会根据数组中的位置绘制数值;也就是说,x值被视为0、1、2等等。我们还可以提供多对数组来在同一坐标轴上绘制多组数据:
x = np.linspace(-0.5, 3.0)
lines = plt.plot(x, f(x), x, x**2, x, 1 - x)
前面代码的输出如下:

图 2.2:在 Matplotlib 中使用一次调用 plot 例程产生单个图形上的多个图形
有时候需要创建一个新的图形,并在该图形中显式地创建一组新的坐标轴。实现这一目标的最佳方法是使用pyplot接口中的subplots例程(参见添加子图配方)。这个例程返回一对对象,第一个对象是Figure,第二个对象是Axes:
fig, ax = plt.subplots()
l1 = ax.plot(x, f(x))
l2 = ax.plot(x, x**2)
l3 = ax.plot(x, 1 - x)
这一系列命令产生了与前面显示的图 2.2中相同的图形。
Matplotlib 除了这里描述的plot例程之外,还有许多其他绘图例程。例如,有一些绘图方法使用不同的比例尺来绘制坐标轴,包括分别使用对数x轴或对数y轴(semilogx或semilogy)或同时使用(loglog)。这些在 Matplotlib 文档中有解释。
更改绘图样式
Matplotlib 绘图的基本样式适用于绘制有序的函数或数据,但对于不按任何顺序呈现的离散数据来说,这种样式就不太合适了。为了防止 Matplotlib 在每个数据点之间绘制线条,我们可以将绘图样式更改为“关闭”线条绘制。在这个示例中,我们将通过向plot方法添加格式字符串参数来为坐标轴上的每条线自定义绘图样式。
准备工作
您需要将数据存储在数组对中。为了演示目的,我们将定义以下数据:
y1 = np.array([1.0, 2.0, 3.0, 4.0, 5.0])
y2 = np.array([1.2, 1.6, 3.1, 4.2, 4.8])
y3 = np.array([3.2, 1.1, 2.0, 4.9, 2.5])
我们将根据数组中的位置(即x坐标将分别为0、1、2、3或4)绘制这些点。
如何做到...
控制绘图样式的最简单方法是使用格式字符串,它作为plot命令中x-y对或plot命令中的ydata 后的可选参数提供。在绘制多组数据时,可以为每组参数提供不同的格式字符串。以下步骤提供了创建新图并在该图上绘制数据的一般过程:
*1. 我们首先使用pyplot中的subplots例程显式创建Figure和Axes对象:
fig, ax = plt.subplots()
- 现在我们已经创建了
Figure和Axes对象,可以使用Axes对象上的plot方法绘制数据。这个方法接受与pyplot中的plot例程相同的参数:
lines = ax.plot(y1, 'o', y2, 'x', y3, '*')
这将使用圆圈标记绘制第一个数据集(y1),使用x标记绘制第二个数据集(y2),使用星号(*)标记绘制第三个数据集(y3)。这个命令的输出显示在图 2.3中。格式字符串可以指定多种不同的标记线和颜色样式。如果我们改为使用pyplot接口中的plot例程,其调用签名与plot方法相同,也是一样的。

图 2.3:绘制三组数据,每组数据使用不同的标记样式绘制
它是如何工作的...
格式字符串有三个可选部分,每个部分由一个或多个字符组成。第一部分控制标记样式,即打印在每个数据点处的符号;第二部分控制连接数据点的线条样式;第三部分控制绘图的颜色。在这个示例中,我们只指定了标记样式,这意味着在相邻数据点之间不会绘制连接线。这对于绘制不需要在点之间进行插值的离散数据非常有用。有四种线条样式参数可用:实线(-);虚线(--);点划线(-.);或点线(:)。格式字符串中只能指定有限数量的颜色;它们是红色、绿色、蓝色、青色、黄色、品红色、黑色和白色。格式字符串中使用的字符是每种颜色的第一个字母(黑色除外),因此相应的字符分别是r、g、b、c、y、m、k和w。
例如,如果我们只想更改标记样式,就像在这个示例中所做的那样,改为加号字符,我们将使用"+"格式字符串。如果我们还想将线条样式更改为点划线,我们将使用"+-."格式字符串。最后,如果我们还希望将标记的颜色更改为红色,我们将使用"+-.r"格式字符串。这些指定符也可以以其他配置提供,例如在标记样式之前指定颜色,但这可能会导致 Matplotlib 解析格式字符串的方式存在歧义。
如果您正在使用 Jupyter 笔记本和subplots命令,则必须在与绘图命令相同的单元格中包含对subplots的调用,否则图形将不会被生成。
还有更多...
plot方法还接受许多关键字参数,这些参数也可以用于控制图的样式。如果同时存在关键字参数和格式字符串参数,则关键字参数优先,并且它们适用于调用绘制的所有数据集。控制标记样式的关键字是marker,线型的关键字是linestyle,颜色的关键字是color。color关键字参数接受许多不同的格式来指定颜色,其中包括 RGB 值作为(r, g, b)元组,其中每个字符都是0到1之间的浮点数,或者是十六进制字符串。可以使用linewidth关键字控制绘制的线的宽度,应该提供一个float值。plot还可以传递许多其他关键字参数;在 Matplotlib 文档中列出了一个列表。这些关键字参数中的许多都有较短的版本,例如c代表color,lw代表linewidth。
例如,我们可以使用以下命令通过在调用plot时使用color关键字参数来设置配方中所有标记的颜色:
ax.plot(y1, 'o', y2, 'x', y3, '*', color="k")
从对plot方法(或plt.plot例程)的调用返回的Line2D对象也可以用于自定义每组数据的外观。例如,可以使用Line2D对象中的set_linestyle方法,使用适当的线型格式字符串设置线型。
可以使用Axes对象上的方法自定义图的其他方面。可以使用Axes对象上的set_xticks和set_yticks方法修改坐标轴刻度,可以使用grid方法配置网格外观。pyplot接口中还有方便的方法,可以将这些修改应用于当前坐标轴(如果存在)。
例如,我们修改轴限制,在x和y方向上的每个0.5的倍数设置刻度,并通过以下命令向图添加网格:
ax.axis([-0.5, 5.5, 0, 5.5]) # set axes
ax.set_xticks([0.5*i for i in range(9)]) # set xticks
ax.set_yticks([0.5*i for i in range(11)] # set yticks
ax.grid() # add a grid
注意我们将限制设置为略大于图的范围。这是为了避免标记放在图窗口的边界上。
如果希望在轴上绘制离散数据而不连接点与线,则scatter绘图例程可能更好。这允许更多地控制标记的样式。例如,可以根据一些额外信息调整标记的大小。
向绘图添加标签和图例
每个图应该有一个标题,并且轴应该被正确标记。对于显示多组数据的图,图例是帮助读者快速识别不同数据集的标记、线条和颜色的好方法。在本示例中,我们将向图添加轴标签和标题,然后添加一个图例来帮助区分不同的数据集。为了保持代码简单,我们将绘制上一个示例中的数据。
如何做...
按照以下步骤向您的图添加标签和图例,以帮助区分它们代表的数据集:
- 我们首先使用以下
plot命令从上一个示例中重新创建图:
fig, ax = plt.subplots()
ax = ax.plot(y1, "o-", y2, "x--", y3, "*-.")
- 现在,我们有了一个
Axes对象的引用,我们可以开始通过添加标签和标题来自定义这些轴。可以使用subplots例程创建的ax对象上的set_title、set_xlabel和set_ylabel方法向图中添加标题和轴标签。在每种情况下,参数都是包含要显示的文本的字符串:
ax.set_title("Plot of the data y1, y2, and y3")
ax.set_xlabel("x axis label")
ax.set_ylabel("y axis label")
在这里,我们使用不同的样式绘制了三个数据集。标记样式与上一个示例中相同,但我们为第一个数据集添加了实线,为第二个数据集添加了虚线,为第三个数据集添加了点划线。
- 要添加图例,我们在
ax对象上调用legend方法。参数应该是一个包含每组数据在图例中的描述的元组或列表:
ax.legend(("data y1", "data y2", "data y3"))
上述一系列命令的结果如下:

图 2.4:使用 Matplotlib 生成的带有轴标签、标题和图例的图
工作原理...
set_title、set_xlabel和set_ylabel方法只是将文本参数添加到Axes对象的相应位置。如前面的代码中调用的legend方法,按照它们添加到图中的顺序添加标签,本例中为y1、y2,然后是y3。
可以提供一些关键字参数给set_title、set_xlabel和set_ylabel方法来控制文本的样式。例如,fontsize关键字可以用来指定标签字体的大小,通常使用pt点度量。还可以通过向例程提供usetex=True来使用 TeX 格式化标签。标签的 TeX 格式化在图 2.5中演示。如果标题或轴标签包含数学公式,这是非常有用的。不幸的是,如果系统上没有安装 TeX,就不能使用usetex关键字参数,否则会导致错误。但是,仍然可以使用 TeX 语法来格式化标签中的数学文本,但这将由 Matplotlib 而不是 TeX 来排版。
我们可以使用fontfamily关键字来使用不同的字体,其值可以是字体的名称或serif、sans-serif或monospace,它将选择适当的内置字体。可以在 Matplotlib 文档中找到matplotlib.text.Text类的完整修饰符列表。
要向图添加单独的文本注释,可以在Axes对象上使用annotate方法。这个例程接受两个参数——要显示的文本作为字符串和注释应放置的点的坐标。这个例程还接受前面提到的样式关键字参数。
添加子图
有时,将多个相关的图放在同一图中并排显示,但不在同一坐标轴上是很有用的。子图允许我们在单个图中生成一个网格的单独图。在这个示例中,我们将看到如何使用子图在单个图上并排创建两个图。
准备工作
您需要将要绘制在每个子图上的数据。例如,我们将在第一个子图上绘制应用于f(x) = x²-1函数的牛顿法的前五个迭代,初始值为x[0] = 2,对于第二个子图,我们将绘制迭代的误差。我们首先定义一个生成器函数来获取迭代:
def generate_newton_iters(x0, number):
iterates = [x0]
errors = [abs(x0 - 1.)]
for _ in range(number):
x0 = x0 - (x0*x0 - 1.)/(2*x0)
iterates.append(x0)
errors.append(abs(x0 - 1.))
return iterates, errors
这个例程生成两个列表。第一个列表包含应用于函数的牛顿法的迭代,第二个包含近似值的误差:
iterates, errors = generate_newton_iters(2.0, 5)
如何做...
以下步骤显示了如何创建包含多个子图的图:
- 我们使用
subplots例程创建一个新的图和每个子图中的所有Axes对象的引用,这些子图在一个行和两个列的网格中排列。我们还将tight_layout关键字参数设置为True,以修复生成图的布局。这并不是严格必要的,但在这种情况下是必要的,因为它产生的结果比默认的更好:
fig, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True) # 1 row, 2 columns
- 一旦创建了
Figure和Axes对象,我们可以通过在每个Axes对象上调用相关的绘图方法来填充图。对于第一个图(显示在左侧),我们在ax1对象上使用plot方法,它与标准的plt.plot例程具有相同的签名。然后我们可以在ax1上调用set_title、set_xlabel和set_ylabel方法来设置标题和x和y标签。我们还通过提供usetex关键字参数来使用 TeX 格式化轴标签;如果您的系统上没有安装 TeX,可以忽略这一点:
ax1.plot(iterates, "x")
ax1.set_title("Iterates")
ax1.set_xlabel("$i$", usetex=True)
ax1.set_ylabel("$x_i$", usetex=True)
- 现在,我们可以使用
ax2对象在第二个图上(显示在右侧)绘制错误值。我们使用了一种使用对数刻度的替代绘图方法,称为semilogy。该方法的签名与标准的plot方法相同。同样,我们设置了轴标签和标题。如果没有安装 TeX,可以不使用usetex。
ax2.semilogy(errors, "x") # plot y on logarithmic scale
ax2.set_title("Error")
ax2.set_xlabel("$i$", usetex=True)
ax2.set_ylabel("Error")
这些命令序列的结果如下图所示:

图 2.5:Matplotlib 子图
左侧绘制了牛顿法的前五次迭代,右侧是以对数刻度绘制的近似误差。
工作原理...
Matplotlib 中的Figure对象只是一个特定大小的绘图元素(如Axes)的容器。Figure对象通常只包含一个Axes对象,该对象占据整个图形区域,但它可以在相同的区域中包含任意数量的Axes对象。subplots例程执行几项任务。首先创建一个新的图形,然后在图形区域内创建一个指定形状的网格。然后,在网格的每个位置添加一个新的Axes对象。然后将新的Figure对象和一个或多个Axes对象返回给用户。如果请求单个子图(一行一列,没有参数),则返回一个普通的Axes对象。如果请求单行或单列(分别具有多于一个列或行),则返回Axes对象的列表。如果请求多行和多列,则将返回一个列表的列表,其中行由填充有Axes对象的内部列表表示。然后我们可以使用每个Axes对象上的绘图方法来填充图形以显示所需的绘图。
在本示例中,我们在左侧使用了标准的plot方法,就像我们在以前的示例中看到的那样。但是,在右侧绘图中,我们使用了一个将y轴更改为对数刻度的绘图。这意味着y轴上的每个单位代表 10 的幂的变化,而不是一个单位的变化,因此0代表 10⁰=1,1代表 10,2代表 100,依此类推。轴标签会自动更改以反映这种比例变化。当值按数量级变化时,例如近似误差随着迭代次数的增加而变化时,这种缩放是有用的。我们还可以使用semilogx方法仅对x使用对数刻度进行绘制,或者使用loglog方法对两个轴都使用对数刻度进行绘制。
还有更多...
在 Matplotlib 中有几种创建子图的方法。如果已经创建了一个Figure对象,则可以使用Figure对象的add_subplot方法添加子图。或者,您可以使用matplotlib.pyplot中的subplot例程将子图添加到当前图。如果尚不存在,则在调用此例程时将创建一个新的图。subplot例程是Figure对象上add_subplot方法的便利包装。
要创建一个具有一个或多个子图的新图形,还可以使用pyplot接口中的subplots例程(如更改绘图样式中所示),它返回一个新的图形对象和一个Axes对象的数组,每个位置一个。这三种方法都需要子图矩阵的行数和列数。add_subplot方法和subplot例程还需要第三个参数,即要修改的子图的索引。返回当前子图的Axes对象。
在前面的例子中,我们创建了两个具有不同比例的y轴的图。这展示了子图的许多可能用途之一。另一个常见用途是在矩阵中绘制数据,其中列具有共同的x标签,行具有共同的y标签,这在多元统计中特别常见,用于研究各组数据之间的相关性。用于创建子图的plt.subplots例程接受sharex和sharey关键字参数,允许轴在所有子图或行或列之间共享。此设置会影响轴的比例和刻度。
另请参阅
Matplotlib 通过为subplots例程提供gridspec_kw关键字参数来支持更高级的布局。有关更多信息,请参阅matplotlib.gridspec的文档。
保存 Matplotlib 图
当您在交互式环境中工作,例如 IPython 控制台或 Jupyter 笔记本时,运行时显示图是完全正常的。但是,有很多情况下,直接将图存储到文件中而不是在屏幕上呈现会更合适。在本示例中,我们将看到如何将图直接保存到文件中,而不是在屏幕上显示。
准备工作
您需要要绘制的数据以及要存储输出的路径或文件对象。我们将结果存储在当前目录中的savingfigs.png中。在此示例中,我们将绘制以下数据:
x = np.arange(1, 5, 0.1)
y = x*x
如何做...
以下步骤显示了如何将 Matplotlib 图直接保存到文件:
- 第一步是像往常一样创建图,并添加任何必要的标签、标题和注释。图将以其当前状态写入文件,因此应在保存之前进行对图的任何更改:
fig, ax = plt.subplots()
ax.plot(x, y)
ax.set_title("Graph of $y = x²$", usetex=True)
ax.set_xlabel("$x$", usetex=True)
ax.set_ylabel("$y$", usetex=True)
- 然后,我们使用
fig上的savefig方法将此图保存到文件。唯一必需的参数是要输出的路径或可以写入图形的文件对象。我们可以通过提供适当的关键字参数来调整输出格式的各种设置,例如分辨率。我们将输出图的每英寸点数(DPI)设置为300,这对于大多数应用程序来说是合理的分辨率:
fig.savefig("savingfigs.png", dpi=300)
Matplotlib 将根据给定文件的扩展名推断我们希望以便携式网络图形(PNG)格式保存图像。或者,可以通过提供关键字参数(使用format关键字)显式地提供格式,或者可以从配置文件中回退到默认格式。
它是如何工作的...
savefig方法选择适合输出格式的后端,然后以该格式呈现当前图。生成的图像数据将写入指定的路径或文件对象。如果您手动创建了Figure实例,则可以通过在该实例上调用savefig方法来实现相同的效果。
还有更多...
savefig例程接受许多额外的可选关键字参数来自定义输出图像。例如,可以使用dpi关键字指定图像的分辨率。本章中的图是通过将 Matplotlib 图保存到文件中生成的。
可用的输出格式包括 PNG、可缩放矢量图形(SVG)、PostScript(PS)、Encapsulated PostScript(EPS)和便携式文档格式(PDF)。如果安装了 Pillow 软件包,还可以保存为 JPEG 格式,但自 Matplotlib 3.1 版本以来就不再原生支持此功能。JPEG 图像还有其他自定义关键字参数,如quality和optimize。可以将图像元数据的字典传递给metadata关键字,在保存时将其写入图像元数据。
另请参阅
Matplotlib 网站上的示例库包括使用几种常见的 Python GUI 框架将 Matplotlib 图嵌入到图形用户界面(GUI)应用程序中的示例。
曲面和等高线图
Matplotlib 还可以以各种方式绘制三维数据。显示这种数据的两种常见选择是使用表面图或等高线图(类似于地图上的等高线)。在本示例中,我们将看到一种从三维数据绘制表面和绘制三维数据等高线的方法。
准备就绪
要绘制三维数据,需要将其排列成x、y和z分量的二维数组,其中x和y分量必须与z分量的形状相同。为了演示,我们将绘制对应于f(x, y) = x²y³函数的表面。
如何做...
我们想要在-2≤x≤2 和-1≤y≤1 范围内绘制f(x, y) = x²y³函数。第一项任务是创建一个适当的(x, y)对的网格,以便对该函数进行评估:
- 首先使用
np.linspace在这些范围内生成合理数量的点:
X = np.linspace(-2, 2)
Y = np.linspace(-1, 1)
- 现在,我们需要创建一个网格来创建我们的z值。为此,我们使用
np.meshgrid例程:
x, y = np.meshgrid(X, Y)
- 现在,我们可以创建要绘制的z值,这些值保存了每个网格点上函数的值:
z = x**2 * y**3
- 要绘制三维表面,我们需要加载一个 Matplotlib 工具箱
mplot3d,它随 Matplotlib 包一起提供。这不会在代码中明确使用,但在幕后,它使三维绘图实用程序可用于 Matplotlib:
from mpl_toolkits import mplot3d
- 接下来,我们创建一个新的图和一组三维坐标轴用于该图:
fig = plt.figure()
ax = fig.add_subplot(projection="3d") # declare 3d plot
- 现在,我们可以在这些坐标轴上调用
plot_surface方法来绘制数据:
ax.plot_surface(x, y, z)
- 为三维图添加轴标签非常重要,因为在显示的图上可能不清楚哪个轴是哪个:
ax.set_xlabel("$x$")
ax.set_ylabel("$y$")
ax.set_zlabel("$z$")
- 此时我们还应该设置一个标题:
ax.set_title("Graph of the function $f(x) = x²y³$)
您可以使用plt.show例程在新窗口中显示图(如果您在 Python 中交互使用,而不是在 Jupyter 笔记本或 IPython 控制台上使用),或者使用plt.savefig将图保存到文件中。上述序列的结果如下所示:

图 2.6:使用默认设置使用 Matplotlib 生成的三维表面图
- 等高线图不需要
mplot3d工具包,在pyplot接口中有一个contour例程可以生成等高线图。但是,与通常的(二维)绘图例程不同,contour例程需要与plot_surface方法相同的参数。我们使用以下顺序生成绘图:
fig = plt.figure() # Force a new figure
plt.contour(x, y, z)
plt.title("Contours of $f(x) = x²y³$")
plt.xlabel("$x$")
plt.ylabel("$y$")
结果显示在以下图中:

图 2.7:使用默认设置使用 Matplotlib 生成的等高线图
它是如何工作的...
mplot3d工具包提供了一个Axes3D对象,这是核心 Matplotlib 包中Axes对象的三维版本。当给定projection="3d"关键字参数时,这将被提供给Figure对象上的axes方法。通过在三维投影中在相邻点之间绘制四边形,可以获得表面图。这与用直线连接相邻点来近似二维曲线的方式相同。
plot_surface方法需要提供z值,这些值作为二维数组编码在(x, y)对的网格上的z值。我们创建了我们感兴趣的x和y值的范围,但是如果我们简单地在这些数组的对应值上评估我们的函数,我们将得到一条线上的z值,而不是整个网格上的值。相反,我们使用meshgrid例程,它接受两个X和Y数组,并从中创建一个网格,其中包含X和Y中所有可能的值的组合。输出是一对二维数组,我们可以在其上评估我们的函数。然后我们可以将这三个二维数组全部提供给plot_surface方法。
还有更多...
在前面的部分中描述的例程contour和plot_contour只适用于高度结构化的数据,其中x、y和z分量被排列成网格。不幸的是,现实生活中的数据很少有这么结构化的。在这种情况下,您需要在已知点之间执行某种插值,以近似均匀网格上的值,然后可以绘制出来。执行这种插值的常见方法是通过对(x, y)对的集合进行三角剖分,然后使用每个三角形顶点上的函数值来估计网格点上的值。幸运的是,Matplotlib 有一个方法可以执行所有这些步骤,然后绘制结果,这就是plot_trisurf例程。我们在这里简要解释一下如何使用它:
- 为了说明
plot_trisurf的用法,我们将从以下数据绘制表面和等高线:
x = np.array([ 0.19, -0.82, 0.8 , 0.95, 0.46, 0.71,
-0.86, -0.55, 0.75,-0.98, 0.55, -0.17, -0.89,
-0.4 , 0.48, -0.09, 1., -0.03, -0.87, -0.43])
y = np.array([-0.25, -0.71, -0.88, 0.55, -0.88, 0.23,
0.18,-0.06, 0.95, 0.04, -0.59, -0.21, 0.14, 0.94,
0.51, 0.47, 0.79, 0.33, -0.85, 0.19])
z = np.array([-0.04, 0.44, -0.53, 0.4, -0.31, 0.13,
-0.12, 0.03, 0.53, -0.03, -0.25, 0.03, -0.1 ,
-0.29, 0.19, -0.03, 0.58, -0.01, 0.55, -0.06])
- 这次,我们将在同一图中绘制表面和等高线(近似),作为两个单独的子图。为此,我们向包含表面的子图提供
projection="3d"关键字参数。我们在三维坐标轴上使用plot_trisurf方法绘制近似表面,并在二维坐标轴上使用tricontour方法绘制近似等高线:
fig = plt.figure(tight_layout=True) # force new figure
ax1 = fig.add_subplot(1, 2, 1, projection="3d") # 3d axes
ax1.plot_trisurf(x, y, z)
ax1.set_xlabel("$x$")
ax1.set_ylabel("$y$")
ax1.set_zlabel("$z$")
ax1.set_title("Approximate surface")
- 现在,我们可以使用以下命令绘制三角剖分表面的等高线:
ax2 = fig.add_subplot(1, 2, 2) # 2d axes
ax2.tricontour(x, y, z)
ax2.set_xlabel("$x$")
ax2.set_ylabel("$y$")
ax2.set_title("Approximate contours")
我们在图中包含tight_layout=True关键字参数,以避免稍后调用plt.tight_layout例程。结果如下所示:

图 2.8:使用三角剖分生成的近似表面和等高线图
除了表面绘图例程外,Axes3D对象还有一个用于简单三维绘图的plot(或plot3D)例程,其工作方式与通常的plot例程完全相同,但在三维坐标轴上。该方法还可用于在其中一个轴上绘制二维数据。
自定义三维图
等高线图可能会隐藏表示的表面的一些细节,因为它们只显示“高度”相似的地方,而不显示值是多少,甚至与周围的值相比如何。在地图上,这可以通过在特定等高线上打印高度来解决。表面图更具启发性,但是将三维对象投影到二维以在屏幕上显示可能会模糊一些细节。为了解决这些问题,我们可以自定义三维图(或等高线图)的外观,以增强图表并确保我们希望突出显示的细节清晰可见。最简单的方法是通过更改图表的颜色映射来实现这一点。
在这个示例中,我们将使用binary颜色映射的反转。
准备工作
我们将为以下函数生成表面图:

我们生成应该绘制的点,就像在前一个示例中一样:
X = np.linspace(-2, 2)
Y = np.linspace(-2, 2)
x, y = np.meshgrid(X, Y)
t = x**2 + y**2 # small efficiency
z = np.cos(2*np.pi*t)*np.exp(-t)
如何做...
Matplotlib 有许多内置的颜色映射,可以应用于图表。默认情况下,表面图是用根据光源进行着色的单一颜色绘制的(请参阅本示例的更多信息部分)。颜色映射可以显著改善图表的效果。以下步骤显示了如何向表面和等高线图添加颜色映射:
- 首先,我们只需应用内置的颜色映射之一
binary_r,通过向plot_surface例程提供cmap="binary_r"关键字参数来实现:
fig = plt.figure()
ax = fig.add_subplot(projection="3d")
ax.plot_surface(x, y, z, cmap="binary_r")
ax.set_title("Surface with colormap")
ax.set_xlabel("$x$")
ax.set_ylabel("$y$")
ax.set_zlabel("$z$")
结果是一个图(图 2.9),其中表面的颜色根据其值而变化,颜色映射的两端具有最极端的值——在本例中,z值越大,灰度越浅。请注意,下图中的不规则性是由网格中相对较少的点造成的:

图 2.9:应用灰度颜色映射的表面图
颜色映射适用于表面绘图以外的其他绘图类型。特别是,颜色映射可以应用于等高线图,这有助于区分代表较高值和代表较低值的等高线。
- 对于等高线图,更改颜色映射的方法是相同的;我们只需为
cmap关键字参数指定一个值:
fig = plt.figure()
plt.contour(x, y, z, cmap="binary_r")
plt.xlabel("$x$")
plt.ylabel("$y$")
plt.title("Contour plot with colormap set")
上述代码的结果如下所示:
图 2.10:具有替代颜色映射设置的等高线图
图中较深的灰色阴影对应于 z 的较低值。
工作原理...
颜色映射通过根据比例尺分配 RGB 值来工作——颜色映射。首先,对值进行归一化,使其介于0和1之间,通常通过线性变换来实现,将最小值取为0,最大值取为1。然后将适当的颜色应用于表面绘图的每个面(或者在另一种类型的绘图中是线)。
Matplotlib 带有许多内置的颜色映射,可以通过简单地将名称传递给cmap关键字参数来应用。这些颜色映射的列表在文档中给出(matplotlib.org/tutorials/colors/colormaps.html),还有一个反转的变体,通过在所选颜色映射的名称后添加_r后缀来获得。
还有更多...
应用颜色映射中的归一化步骤是由Normalize类派生的对象执行的。Matplotlib 提供了许多标准的归一化例程,包括LogNorm和PowerNorm。当然,您也可以创建自己的Normalize子类来执行归一化。可以使用plot_surface或其他绘图函数的norm关键字添加替代Normalize子类。
对于更高级的用途,Matplotlib 提供了一个接口,用于使用光源创建自定义阴影。这是通过从matplotlib.colors包中导入LightSource类,然后使用该类的实例根据z值对表面元素进行着色来完成的。这是使用LightSource对象的shade方法完成的:
from matplotlib.colors import LightSource
light_source = LightSource(0, 45) # angles of lightsource
cmap = plt.get_cmap("binary_r")
vals = light_source.shade(z, cmap)
surf = ax.plot_surface(x, y, z, facecolors=vals)
如果您希望了解更多关于这个工作原理的内容,可以在 Matplotlib 库中查看完整的示例。
进一步阅读
Matplotlib 包非常庞大,我们在这么短的篇幅内几乎无法充分展现它。文档中包含的细节远远超过了这里提供的内容。此外,还有一个大型的示例库(matplotlib.org/gallery/index.html#),其中包含了比本书中更多的包功能。
还有其他构建在 Matplotlib 之上的包,为特定应用程序提供了高级绘图方法。例如,Seaborn 库提供了用于可视化数据的例程(seaborn.pydata.org/)。
第四章:微积分和微分方程
在本章中,我们将讨论与微积分相关的各种主题。微积分是涉及微分和积分过程的数学分支。从几何上讲,函数的导数代表函数曲线的梯度,函数的积分代表函数曲线下方的面积。当然,这些特征只在某些情况下成立,但它们为本章提供了一个合理的基础。
我们首先来看一下简单函数类的微积分:多项式。在第一个示例中,我们创建一个表示多项式的类,并定义不同 iate 和积分多项式的方法。多项式很方便,因为多项式的导数或积分再次是多项式。然后,我们使用 SymPy 包对更一般的函数进行符号微分和积分。之后,我们看到使用 SciPy 包解方程的方法。接下来,我们将注意力转向数值积分(求积)和解微分方程。我们使用 SciPy 包来解常微分方程和常微分方程组,然后使用有限差分方案来解简单的偏微分方程。最后,我们使用快速傅里叶变换来处理嘈杂的信号并滤除噪音。
在本章中,我们将涵盖以下示例:
-
使用多项式和微积分
-
使用 SymPy 进行符号微分和积分
-
解方程
-
使用 SciPy 进行数值积分
-
使用数值方法解简单的微分方程
-
解微分方程组
-
使用数值方法解偏微分方程
-
使用离散傅里叶变换进行信号处理
技术要求
除了科学 Python 包 NumPy 和 SciPy 之外,我们还需要 SymPy 包。可以使用您喜欢的软件包管理器(如pip)来安装它:
python3.8 -m pip install sympy
本章的代码可以在 GitHub 存储库的Chapter 03文件夹中找到,网址为github.com/PacktPublishing/Applying-Math-with-Python/tree/master/Chapter%2003。
查看以下视频以查看代码的实际操作:bit.ly/32HuH4X。
使用多项式和微积分
多项式是数学中最简单的函数之一,定义为一个求和:

x代表要替换的占位符,a[i]是一个数字。由于多项式很简单,它们提供了一个很好的方式来简要介绍微积分。微积分涉及函数的微分和积分。积分大致上是反微分,因为先积分然后微分会得到原始函数。
在本示例中,我们将定义一个表示多项式的简单类,并为该类编写方法以执行微分和积分。
准备工作
从几何上讲,通过微分得到的导数是函数的梯度,通过积分得到的积分是函数曲线与x轴之间的面积,考虑到曲线是在轴的上方还是下方。在实践中,微分和积分是通过一组规则和特别适用于多项式的标准结果来进行符号化处理。
本示例不需要额外的软件包。
如何做...
以下步骤描述了如何创建表示多项式的类,并为该类实现微分和积分方法:
- 让我们首先定义一个简单的类来表示多项式:
class Polynomial:
"""Basic polynomial class"""
def __init__(self, coeffs):
self.coeffs = coeffs
def __repr__(self):
return f"Polynomial({repr(self.coeffs)})"
def __call__(self, x):
return sum(coeff*x**i for i, coeff
in enumerate(self.coeffs))
- 现在我们已经为多项式定义了一个基本类,我们可以继续实现这个
Polynomial类的微分和积分操作,以说明这些操作如何改变多项式。我们从微分开始。我们通过将当前系数列表中的每个元素(不包括第一个元素)相乘来生成新的系数。我们使用这个新的系数列表来创建一个新的Polynomial实例,然后返回:
def differentiate(self):
"""Differentiate the polynomial and return the derivative"""
coeffs = [i*c for i, c in enumerate(self.coeffs[1:], start=1)]
return Polynomial(coeffs)
- 要实现积分方法,我们需要创建一个包含由参数给出的新常数(转换为浮点数以保持一致性)的新系数列表。然后我们将旧系数除以它们在列表中的新位置,加到这个系数列表中:
def integrate(self, constant=0):
"""Integrate the polynomial, returning the integral"""
coeffs = [float(constant)]
coeffs += [c/i for i, c in enumerate(self.coeffs, start=1)]
return Polynomial(coeffs)
- 最后,为了确保这些方法按预期工作,我们应该用一个简单的案例测试这两种方法。我们可以使用一个非常简单的多项式来检查,比如x² - 2x + 1:
p = Polynomial([1, -2, 1])
p.differentiate()
# Polynomial([-2, 2])
p.integrate(constant=1)
# Polynomial([1.0, 1.0, -1.0, 0.3333333333])
工作原理...
多项式为我们提供了一个对微积分基本操作的简单介绍,但对于其他一般类的函数来说,构建 Python 类并不那么容易。也就是说,多项式非常有用,因为它们被很好地理解,也许更重要的是,对于多项式的微积分非常容易。对于变量x的幂,微分的规则是乘以幂并减少 1,因此xn*变为*nx(n-1)。
积分更复杂,因为函数的积分不是唯一的。我们可以给积分加上任意常数并得到第二个积分。对于变量x的幂,积分的规则是将幂增加 1 并除以新的幂,因此xn*变为*x(n+1)/(n+1),所以要对多项式进行积分,我们将每个x的幂增加 1,并将相应的系数除以新的幂。
我们在这个食谱中定义的Polynomial类相当简单,但代表了核心思想。多项式由其系数唯一确定,我们可以将其存储为一组数值值的列表。微分和积分是我们可以对这个系数列表执行的操作。我们包括一个简单的__repr__方法来帮助显示Polynomial对象,以及一个__call__方法来促进在特定数值上的评估。这主要是为了演示多项式的评估方式。
多项式对于解决涉及评估计算昂贵函数的某些问题非常有用。对于这样的问题,我们有时可以使用某种多项式插值,其中我们将一个多项式“拟合”到另一个函数,然后利用多项式的性质来帮助解决原始问题。评估多项式比原始函数要“便宜”得多,因此这可能会大大提高速度。这通常是以一些精度为代价的。例如,辛普森法则用二次多项式逼近曲线下的面积,这些多项式是由三个连续网格点定义的间隔内的。每个二次多项式下面的面积可以通过积分轻松计算。
还有更多...
多项式在计算编程中扮演的角色远不止是展示微分和积分的效果。因此,NumPy 包中提供了一个更丰富的Polynomial类,numpy.polynomial。NumPy 的Polynomial类和各种派生子类在各种数值问题中都很有用,并支持算术运算以及其他方法。特别是,有用于将多项式拟合到数据集合的方法。
NumPy 还提供了从Polynomial派生的类,表示各种特殊类型的多项式。例如,Legendre类表示一种特定的多项式系统,称为Legendre多项式。Legendre 多项式是为满足-1 ≤ x ≤ 1的x定义的,并且形成一个正交系统,这对于诸如数值积分和有限元方法解决偏微分方程等应用非常重要。Legendre 多项式使用递归关系定义。我们定义

对于每个n ≥ 2,我们定义第n个 Legendre 多项式满足递推关系,

还有一些所谓的正交(系统的)多项式,包括Laguerre多项式,Chebyshev 多项式和Hermite 多项式*。
参见
微积分在数学文本中有很好的文档记录,有许多教科书涵盖了从基本方法到深层理论的内容。正交多项式系统在数值分析文本中也有很好的文档记录。
使用 SymPy 进行符号微分和积分
在某些时候,您可能需要区分一个不是简单多项式的函数,并且可能需要以某种自动化的方式来做这件事,例如,如果您正在编写教育软件。Python 科学堆栈包括一个名为 SymPy 的软件包,它允许我们在 Python 中创建和操作符号数学表达式。特别是,SymPy 可以执行符号函数的微分和积分,就像数学家一样。
在这个示例中,我们将创建一个符号函数,然后使用 SymPy 库对这个函数进行微分和积分。
准备工作
与其他一些科学 Python 软件包不同,文献中似乎没有一个标准的别名来导入 SymPy。相反,文档在几个地方使用了星号导入,这与 PEP8 风格指南不一致。这可能是为了使数学表达更自然。我们将简单地按照其名称sympy导入模块,以避免与scipy软件包的标准缩写sp混淆(这也是sympy的自然选择):
import sympy
在这个示例中,我们将定义一个表示函数的符号表达式

如何做...
使用 SymPy 软件包进行符号微分和积分(就像您手工操作一样)非常容易。按照以下步骤来看看它是如何完成的:
- 一旦导入了 SymPy,我们就定义将出现在我们的表达式中的符号。这是一个没有特定值的 Python 对象,就像数学变量一样,但可以在公式和表达式中表示许多不同的值。对于这个示例,我们只需要定义一个符号用于x,因为除此之外我们只需要常数(文字)符号和函数。我们使用
sympy中的symbols例程来定义一个新符号。为了保持符号简单,我们将这个新符号命名为x:
x = sympy.symbols('x')
- 使用
symbols函数定义的符号支持所有算术运算,因此我们可以直接使用我们刚刚定义的符号x构造表达式:
f = (x**2 - 2*x)*sympy.exp(3 - x)
- 现在我们可以使用 SymPy 的符号微积分能力来计算
f的导数,即对f进行微分。我们使用sympy中的diff例程来完成这个操作,它对指定的符号进行符号表达式微分,并返回导数的表达式。这通常不是以最简形式表达的,因此我们使用sympy.simplify例程来简化结果:
fp = sympy.simplify(sympy.diff(f)) # (x*(2 - x) + 2*x - 2)
*exp(3 - x)
- 我们可以通过以下方式检查使用 SymPy 进行符号微分的结果是否正确,与手工计算的导数相比,定义为 SymPy 表达式:
fp2 = (2*x - 2)*sympy.exp(3 - x) - (x**2 - 2*x)*sympy.exp(3 - x)
- SymPy 相等性测试两个表达式是否相等,但不测试它们是否在符号上等价。因此,我们必须首先简化我们希望测试的两个语句的差异,并测试是否等于
0:
sympy.simplify(fp2 - fp) == 0 # True
- 我们可以使用 SymPy 通过
integrate函数对函数f进行积分。还可以通过将其作为第二个可选参数提供来提供要执行积分的符号:
F = sympy.integrate(f, x) # -x**2*exp(3 - x)
它是如何工作的...
SymPy 定义了表示某些类型表达式的各种类。例如,由Symbol类表示的符号是原子表达式的例子。表达式是以与 Python 从源代码构建抽象语法树的方式构建起来的。然后可以使用方法和标准算术运算来操作这些表达式对象。
SymPy 还定义了可以在Symbol对象上操作以创建符号表达式的标准数学函数。最重要的特性是能够执行符号微积分 - 而不是我们在本章剩余部分中探索的数值微积分 - 并给出对微积分问题的精确(有时称为解析)解决方案。
SymPy 软件包中的diff例程对这些符号表达式进行微分。这个例程的结果通常不是最简形式,这就是为什么我们在配方中使用简化例程来简化导数的原因。integrate例程用给定的符号对scipy表达式进行符号积分。(diff例程还接受一个符号参数,用于指定微分的符号。)这将返回一个其导数为原始表达式的表达式。这个例程不会添加积分常数,这在手工积分时是一个好的做法。
还有更多...
SymPy 可以做的远不止简单的代数和微积分。它有各种数学领域的子模块,如数论、几何和其他离散数学(如组合数学)。
SymPy 表达式(和函数)可以构建成 Python 函数,可以应用于 NumPy 数组。这是使用sympy.utilities模块中的lambdify例程完成的。这将 SymPy 表达式转换为使用 SymPy 标准函数的 NumPy 等价函数来数值评估表达式。结果类似于定义 Python Lambda,因此得名。例如,我们可以使用这个例程将这个配方中的函数和导数转换为 Python 函数:
from sympy.utilities import lambdify
lam_f = lambdify(x, f)
lam_fp = lambdify(x, fp)
lambdify例程接受两个参数。第一个是要提供的变量,上一个代码块中的x,第二个是在调用此函数时要评估的表达式。例如,我们可以评估之前定义的 lambdified SymPy 表达式,就好像它们是普通的 Python 函数一样:
lam_f(4) # 2.9430355293715387
lam_fp(7) # -0.4212596944408861
我们甚至可以在 NumPy 数组上评估这些 lambdified 表达式:
lam_f(np.array([0, 1, 2])) # array([ 0\. , -7.3890561, 0\. ])
lambdify例程使用 Python 的exec例程来执行代码,因此不应该与未经过消毒的输入一起使用。
解方程
许多数学问题最终归结为解形式为f(x) = 0 的方程,其中f是单变量函数。在这里,我们试图找到一个使方程成立的x的值。使方程成立的x的值有时被称为方程的根。有许多算法可以找到这种形式的方程的解。在这个配方中,我们将使用牛顿-拉弗森和弦截法来解决形式为f(x) = 0 的方程。
牛顿-拉弗森方法(牛顿法)和弦截法是良好的标准根查找算法,几乎可以应用于任何情况。这些是迭代方法,从一个根的近似值开始,并迭代改进这个近似值,直到它在给定的容差范围内。
为了演示这些技术,我们将使用使用 SymPy 进行符号计算配方中定义的函数

它对所有实数x都有定义,并且恰好有两个根,一个在x=0,另一个在x=2。
准备工作
SciPy 包含用于解方程的例程(以及许多其他内容)。根查找例程可以在scipy包的optimize模块中找到。
如果你的方程不是形式上的f(x) = 0,那么你需要重新排列它,使其成为这种情况。这通常不太困难,只需要将右侧的任何项移到左侧即可。例如,如果你希望找到函数的不动点,也就是当g(x)= x时,我们会将方法应用于由f(x) =g(x)- x.给出的相关函数。
如何操作...
optimize包提供了用于数值根查找的例程。以下说明描述了如何使用该模块中的newton例程:
optimize模块没有列在scipy命名空间中,所以你必须单独导入它:
from scipy import optimize
- 然后我们必须在 Python 中定义这个函数及其导数:
from math import exp
def f(x):
return x*(x - 2)*exp(3 - x)
- 这个函数的导数在前一个配方中被计算出来:
def fp(x):
return -(x**2 - 4*x + 2)*exp(3 - x)
- 对于牛顿-拉弗森和割线法,我们使用
optimize中的newton例程。割线法和牛顿-拉弗森法都需要函数和第一个参数以及第一个近似值x0作为第二个参数。要使用牛顿-拉弗森法,我们必须提供f的导数,使用fprime关键字参数:
optimize.newton(f, 1, fprime=fp) # Using the Newton-Raphson method
# 2.0
- 使用割线法时,只需要函数,但是我们必须提供根的前两个近似值;第二个作为
x1关键字参数提供:
optimize.newton(f, 1., x1=1.5) # Using x1 = 1.5 and the secant method
# 1.9999999999999862
牛顿-拉弗森法和割线法都不能保证收敛到根。完全有可能方法的迭代只是在一些点之间循环(周期性)或者波动剧烈(混沌)。
工作原理...
对于具有导数f'(x)和初始近似值x[0]的函数f(x),牛顿-拉弗森方法使用以下公式进行迭代定义

对于每个整数i ≥0。从几何上讲,这个公式是通过考虑梯度的负方向(所以函数是递减的)如果f(x[i])>0或正方向(所以函数是递增的)如果f(x[i]) <o。
割线法基于牛顿-拉弗森法,但是用近似值替换了一阶导数

当x[i]-x[i-1]足够小时,这意味着方法正在收敛,这是一个很好的近似值。不需要函数f的导数的代价是我们需要一个额外的初始猜测来启动方法。该方法的公式如下

一般来说,如果任一方法得到一个足够接近根的初始猜测(割线法的猜测),那么该方法将收敛于该根。牛顿-拉弗森法在迭代中导数为零时也可能失败,此时公式未被很好地定义。
还有更多...
本配方中提到的方法是通用方法,但在某些情况下可能有更快或更准确的方法。广义上讲,根查找算法分为两类:在每次迭代中使用函数梯度信息的算法(牛顿-拉弗森、割线、Halley)和需要根位置的界限的算法(二分法、regula-falsi、Brent)。到目前为止讨论的算法属于第一类,虽然通常相当快,但可能无法收敛。
第二种算法是已知根存在于指定区间内的算法a ≤**x ≤b。我们可以通过检查f(a)和f(b)是否有不同的符号来检查根是否在这样的区间内,也就是说,f(a) <0<f(b)或f(b) <0<f(a)其中一个为真。(当然,前提是函数是连续的,在实践中往往是这样。)这种类型的最基本算法是二分法算法,它重复地将区间二分,直到找到根的足够好的近似值。基本前提是在a和b之间分割区间,并选择函数改变符号的区间。该算法重复,直到区间非常小。以下是 Python 中此算法的基本实现:
from math import copysign
def bisect(f, a, b, tol=1e-5):
"""Bisection method for root finding"""
fa, fb = f(a), f(b)
assert not copysign(fa, fb) == fa, "Function must change signs"
while (b - a) > tol:
m = (b - a)/2 # mid point of the interval
fm = f(m)
if fm == 0:
return m
if copysign(fm, fa) == fm: # fa and fm have the same sign
a = m
fa = fm
else: # fb and fm have the same sign
b = m
return a
该方法保证收敛,因为在每一步中,距离b-a减半。但是,可能需要比牛顿-拉弗森或割线法更多的迭代次数。optimize中也可以找到二分法的版本。这个版本是用 C 实现的,比这里呈现的版本要高效得多,但是在大多数情况下,二分法不是最快的方法。
Brent 的方法是对二分法的改进,在optimize模块中作为brentq可用。它使用二分和插值的组合来快速找到方程的根:
optimize.brentq(f, 1.0, 3.0) # 1.9999999999998792
重要的是要注意,涉及括号(二分法、regula-falsi、Brent)的技术不能用于找到复变量的根函数,而不使用括号(Newton、割线、Halley)的技术可以。
使用 SciPy 进行数值积分
积分可以解释为曲线与x轴之间的区域,根据这个区域是在轴的上方还是下方进行标记。有些积分无法直接使用符号方法计算,而必须进行数值近似。其中一个经典例子是高斯误差函数,在第一章的基本数学函数部分中提到。这是由以下公式定义的

这里出现的积分也无法通过符号方法计算。
在本示例中,我们将看到如何使用 SciPy 包中的数值积分例程来计算函数的积分。
准备工作
我们使用scipy.integrate模块,其中包含几个用于计算数值积分的例程。我们将此模块导入如下:
from scipy import integrate
操作步骤...
以下步骤描述了如何使用 SciPy 进行数值积分:
- 我们评估出现在误差函数定义中的积分在x = 1处的值。为此,我们需要在 Python 中定义被积函数(出现在积分内部):
def erf_integrand(t):
return np.exp(-t**2)
scipy.integrate中有两个主要例程用于执行数值积分(求积),可以使用。第一个是quad函数,它使用 QUADPACK 执行积分,第二个是quadrature。
quad例程是一个通用的积分工具。它期望三个参数,即要积分的函数(erf_integrand),下限(-1.0)和上限(1.0):
val_quad, err_quad = integrate.quad(erf_integrand, -1.0, 1.0)
# (1.493648265624854, 1.6582826951881447e-14)
第一个返回值是积分的值,第二个是误差的估计。
- 使用
quadrature例程重复计算,我们得到以下结果。参数与quad例程相同:
val_quadr, err_quadr = integrate.quadrature(erf_integrand, -1.0,
1.0)
# (1.4936482656450039, 7.459897144457273e-10)
输出与代码的格式相同,先是积分的值,然后是误差的估计。请注意,quadrature例程的误差更大。这是因为一旦估计的误差低于给定的容差,方法就会终止,当调用例程时可以修改这个容差。
工作原理...
大多数数值积分技术都遵循相同的基本过程。首先,我们选择积分区域中的点x[i],对于i = 1, 2,…, n,然后使用这些值和值f(x[i])来近似积分。例如,使用梯形法则,我们通过以下方式近似积分

其中a < x[1]< x[2]< … < x[n-1]< b,h是相邻x[i]值之间的(公共)差异,包括端点a和b。这可以在 Python 中实现如下:
def trapezium(func, a, b, n_steps):
"""Estimate an integral using the trapezium rule"""
h = (b - a) / n_steps
x_vals = np.arange(a + h, b, h)
y_vals = func(x_vals)
return 0.5*h*(func(a) + func(b) + 2.*np.sum(y_vals))
quad和quadrature使用的算法比这复杂得多。使用这个函数来近似使用trapezium积分erf_integrand的积分得到的结果是 1.4936463036001209,这与quad和quadrature例程的近似结果在 5 位小数的情况下是一致的。
quadrature例程使用固定容差的高斯积分,而quad例程使用 Fortran 库 QUADPACK 例程中实现的自适应算法。对两个例程进行计时,我们发现对于配方中描述的问题,quad例程大约比quadrature例程快 5 倍。quad例程在大约 27 微秒内执行,平均执行 1 百万次,而quadrature例程在大约 134 微秒内执行。(您的结果可能会因系统而异。)
还有更多...
本节提到的例程需要知道被积函数,但情况并非总是如此。相反,可能是我们知道一些(x,y)对,其中y = f(x),但我们不知道要在额外点上评估的函数f。在这种情况下,我们可以使用scipy.integrate中的采样积分技术之一。如果已知点的数量非常大,并且所有点都是等间距的,我们可以使用 Romberg 积分来很好地近似积分。为此,我们使用romb例程。否则,我们可以使用梯形法则的变体(如上所述)使用trapz例程,或者使用simps例程使用辛普森法则。
数值解简单微分方程
微分方程出现在一个数量根据给定关系演变的情况中,通常是随着时间的推移。它们在工程和物理学中非常常见,并且自然地出现。一个经典的(非常简单)微分方程的例子是牛顿提出的冷却定律。物体的温度以与当前温度成比例的速率冷却。从数学上讲,这意味着我们可以写出物体在时间t > 0时的温度T的导数,使用微分方程

常数k是一个确定冷却速率的正常数。这个微分方程可以通过首先“分离变量”,然后积分和重新排列来解析地解决。执行完这个过程后,我们得到了一般解

其中T[0]是初始温度。
在这个配方中,我们将使用 SciPy 的solve_ivp例程数值地解决一个简单的常微分方程。
准备工作
我们将演示在 Python 中使用先前描述的冷却方程数值地解决微分方程的技术,因为在这种情况下我们可以计算真实解。我们将初始温度取为T[0]= 50和k = 0.2。让我们也找出t值在 0 到 5 之间的解。
一般(一阶)微分方程的形式为

其中f是t(自变量)和y(因变量)的某个函数。在这个公式中,T是因变量,f(t, T) = -kt。SciPy 包中用于解决微分方程的例程需要函数f和初始值y[0][以及我们需要计算解的t值范围。要开始,我们需要在 Python 中定义我们的函数f并创建变量y[0][和t范围,准备提供给 SciPy 例程:]]
def f(t, y):
return -0.2*y
t_range = (0, 5)
接下来,我们需要定义应从中找到解的初始条件。出于技术原因,初始y值必须指定为一维 NumPy 数组:
T0 = np.array([50.])
由于在这种情况下,我们已经知道真实解,我们也可以在 Python 中定义这个解,以便与我们将计算的数值解进行比较:
def true_solution(t):
return 50.*np.exp(-0.2*t)
如何做到...
按照以下步骤数值求解微分方程并绘制解以及误差:
- 我们使用 SciPy 中的
integrate模块中的solve_ivp例程来数值求解微分方程。我们添加了一个最大步长的参数,值为0.1,这样解就可以在合理数量的点上计算出来:
sol = integrate.solve_ivp(f, t_range, T0, max_step=0.1)
- 接下来,我们从
solve_ivp方法返回的sol对象中提取解的值:
t_vals = sol.t
T_vals = sol.y[0, :]
- 接下来,我们按如下方式在一组坐标轴上绘制解。由于我们还将在同一图中绘制近似误差,我们使用
subplots例程创建两个子图:
fig, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True)
ax1.plot(t_vals, T_vals)
ax1.set_xlabel("$t$")
ax1.set_ylabel("$T$")
ax1.set_title("Solution of the cooling equation")
这在图 3.1的左侧显示了解决方案的图。
- 为此,我们需要计算从
solve_ivp例程中获得的点处的真实解,然后计算真实解和近似解之间的差的绝对值:
err = np.abs(T_vals - true_solution(t_vals))
- 最后,在图 3.1的右侧,我们使用y轴上的对数刻度绘制近似误差。然后,我们可以使用
semilogy绘图命令在右侧使用对数刻度y轴,就像我们在第二章中看到的那样,使用 Matplotlib 进行数学绘图:
ax2.semilogy(t_vals, err)
ax2.set_xlabel("$t$")
ax2.set_ylabel("Error")
ax2.set_title("Error in approximation")
图 3.1中的左侧图显示随时间降低的温度,而右侧图显示随着我们远离初始条件给出的已知值,误差增加:
图 3.1:使用默认设置使用 solve_ivp 例程获得冷却方程的数值解的绘图
它是如何工作的...
解决微分方程的大多数方法都是“时间步进”方法。(t[i], y[i])对是通过采取小的t步骤并逼近函数y的值来生成的。这可能最好地通过欧拉方法来说明,这是最基本的时间步进方法。固定一个小的步长h > 0,我们使用以下公式在第i步形成近似值

从已知的初始值y[0]开始。我们可以轻松地编写一个执行欧拉方法的 Python 例程如下(当然,实现欧拉方法有许多不同的方法;这是一个非常简单的例子):
- 首先,通过创建将存储我们将返回的t值和y值的列表来设置方法:
def euler(func, t_range, y0, step_size):
"""Solve a differential equation using Euler's method"""
t = [t_range[0]]
y = [y0]
i = 0
- 欧拉方法一直持续到我们达到t范围的末尾。在这里,我们使用
while循环来实现这一点。循环的主体非常简单;我们首先递增计数器i,然后将新的t和y值附加到它们各自的列表中。
while t[i] < t_range[1]:
i += 1
t.append(t[i-1] + step_size) # step t
y.append(y[i-1] + step_size*func(t[i-1], y[i-1])) # step y
return t, y
solve_ivp例程默认使用龙格-库塔-费尔伯格(RK45)方法,该方法能够自适应步长,以确保近似误差保持在给定的容差范围内。这个例程期望提供三个位置参数:函数f,应找到解的t范围,以及初始y值(在我们的例子中为T[0])。可以提供可选参数来更改求解器、要计算的点数以及其他几个设置。
传递给solve_ivp例程的函数必须有两个参数,就像准备就绪部分中描述的一般微分方程一样。函数可以有额外的参数,可以使用args关键字为solve_ivp例程提供这些参数,但这些参数必须位于两个必要参数之后。将我们之前定义的euler例程与步长为 0.1 的solve_ivp例程进行比较,我们发现solve_ivp解的最大真实误差在 10^(-6)数量级,而euler解只能达到 31 的误差。euler例程是有效的,但步长太大,无法克服累积误差。
solve_ivp例程返回一个存储已计算解的信息的解对象。这里最重要的是t和y属性,它们包含计算解的t值和解y本身。我们使用这些值来绘制我们计算的解。y值存储在形状为(n, N)的 NumPy 数组中,其中n是方程的分量数(这里是 1),N是计算的点数。sol中的y值存储在一个二维数组中,在这种情况下有 1 行和许多列。我们使用切片y[0, :]来提取这个第一行作为一维数组,可以用来在步骤 4中绘制解。
我们使用对数缩放的y轴来绘制误差,因为有趣的是数量级。在非缩放的y轴上绘制它会得到一条非常靠近x轴的线,这不会显示出随着t值的变化误差的增加。对数缩放的y轴清楚地显示了这种增加。
还有更多...
solve_ivp例程是微分方程的多个求解器的便捷接口,默认为龙格-库塔-费尔伯格(RK45)方法。不同的求解器有不同的优势,但 RK45 方法是一个很好的通用求解器。
另请参阅
有关如何在 Matplotlib 中的图中添加子图的更详细说明,请参阅第二章中的添加子图示例,使用 Matplotlib 进行数学绘图。
解决微分方程系统
微分方程有时出现在由两个或更多相互关联的微分方程组成的系统中。一个经典的例子是竞争物种的简单模型。这是一个由以下方程给出的竞争物种的简单模型,标记为P(猎物)和W(捕食者):*
*
第一个方程规定了猎物物种P的增长,如果没有任何捕食者,它将是指数增长。第二个方程规定了捕食者物种W的增长,如果没有任何猎物,它将是指数衰减。当然,这两个方程是耦合的;每种群体的变化都取决于两种群体。捕食者以与两种群体的乘积成比例的速率消耗猎物,并且捕食者以与猎物相对丰富度成比例的速率增长(再次是两种群体的乘积)。
在本示例中,我们将分析一个简单的微分方程系统,并使用 SciPy 的integrate模块来获得近似解。
准备就绪
使用 Python 解决常微分方程组的工具与解决单个方程的工具相同。我们再次使用 SciPy 中的integrate模块中的solve_ivp例程。然而,这只会给我们一个在给定起始种群下随时间预测的演变。因此,我们还将使用 Matplotlib 中的一些绘图工具来更好地理解这种演变。
如何做到...
以下步骤介绍了如何分析一个简单的常微分方程组:
- 我们的第一个任务是定义一个包含方程组的函数。这个函数需要接受两个参数,就像单个方程一样,除了依赖变量y(在Solving simple differential equations numerically配方中的符号)现在将是一个包含与方程数量相同的元素的数组。在这里,将有两个元素。在这个配方中,我们需要的函数如下:
def predator_prey_system(t, y):
return np.array([5*y[0] - 0.1*y[0]*y[1], 0.1*y[1]*y[0] -
6*y[1]])
- 现在我们已经在 Python 中定义了系统,我们可以使用 Matplotlib 中的
quiver例程来生成一个图表,描述种群将如何演变——由方程给出——在许多起始种群中。我们首先设置一个网格点,我们将在这些点上绘制这种演变。选择相对较少的点数作为quiver例程的一个好主意,否则在图表中很难看到细节。对于这个例子,我们绘制种群值在 0 到 100 之间:
p = np.linspace(0, 100, 25)
w = np.linspace(0, 100, 25)
P, W = np.meshgrid(p, w)
- 现在,我们计算每对这些点的系统值。请注意,系统中的任何一个方程都不是时间相关的(它们是自治的);时间变量t在计算中并不重要。我们为t参数提供值
0:
dp, dw = predator_prey_system(0, np.array([P, W]))
- 现在变量
dp和dw分别保存了种群P和W在我们的网格中每个点开始时将如何演变的“方向”。我们可以使用matplotlib.pyplot中的quiver例程一起绘制这些方向:
fig, ax = plt.subplots()
ax.quiver(P, W, dp, dw)
ax.set_title("Population dynamics for two competing species")
ax.set_xlabel("P")
ax.set_ylabel("W")
现在绘制这些命令的结果给出了图 3.2,它给出了解决方案演变的“全局”图像:

图 3.2:显示由常微分方程组模拟的两个竞争物种的种群动态的 quiver 图
为了更具体地理解解决方案,我们需要一些初始条件,这样我们就可以使用前面配方中描述的solve_ivp例程。
- 由于我们有两个方程,我们的初始条件将有两个值。(回想一下,在Solving simple differential equations numerically配方中,我们看到提供给
solve_ivp的初始条件需要是一个 NumPy 数组。)让我们考虑初始值P(0) = 85和W(0) = 40。我们在一个 NumPy 数组中定义这些值,小心地按正确的顺序放置它们:
initial_conditions = np.array([85, 40])
- 现在我们可以使用
scipy.integrate模块中的solve_ivp。我们需要提供max_step关键字参数,以确保我们在解决方案中有足够的点来得到平滑的解曲线:
from scipy import integrate
sol = integrate.solve_ivp(predator_prey_system, (0., 5.),
initial_conditions, max_step=0.01)
- 让我们在现有的图上绘制这个解,以展示这个特定解与我们已经生成的方向图之间的关系。我们同时也绘制初始条件:
ax.plot(initial_conditions[0], initial_conditions[1], "ko")
ax.plot(sol.y[0, :], sol.y[1, :], "k", linewidth=0.5)
这个结果显示在图 3.3中:
图 3.3:在显示一般行为的 quiver 图上绘制的解轨迹
它是如何工作的...
用于一组常微分方程的方法与单个常微分方程完全相同。我们首先将方程组写成一个单一的向量微分方程,

然后可以使用时间步进方法来解决,就好像y是一个简单的标量值一样。
使用quiver例程在平面上绘制方向箭头的技术是学习系统如何从给定状态演变的一种快速简单的方法。函数的导数代表曲线的梯度(x,u(x)),因此微分方程描述了解决方案函数在位置y和时间t的梯度。一组方程描述了在给定位置y和时间t的单独解决方案函数的梯度。当然,位置现在是一个二维点,所以当我们在一个点上绘制梯度时,我们将其表示为从该点开始的箭头,指向梯度的方向。箭头的长度表示梯度的大小;箭头越长,解决方案曲线在该方向上移动得越“快”。
当我们在这个方向场上绘制解的轨迹时,我们可以看到曲线(从该点开始)遵循箭头指示的方向。解的轨迹所显示的行为是一个极限环,其中每个变量的解都是周期性的,因为两种物种的人口增长或下降。如果我们将每个人口随时间的变化绘制成图,从图 3.4中可以看出,这种行为描述可能更清晰。从图 3.3中并不立即明显的是解的轨迹循环几次,但这在图 3.4中清楚地显示出来:

图 3.4:人口P和W随时间的变化图。两种人口都表现出周期性行为。
还有更多...
通过在变量之间绘制相空间(平面)分析系统的普通微分方程组的技术称为相空间(平面)分析。在这个示例中,我们使用quiver绘图例程快速生成了微分方程系统的相平面的近似值。通过分析微分方程系统的相平面,我们可以识别解的不同局部和全局特征,如极限环。
数值求解偏微分方程
偏微分方程是涉及函数在两个或多个变量中的偏导数的微分方程,而不是仅涉及单个变量的普通导数。偏微分方程是一个广泛的主题,可以轻松填满一系列书籍。偏微分方程的典型例子是(一维)热方程。

其中α是一个正常数,f(t,x)是一个函数。这个偏微分方程的解是一个函数u(t,x),它表示了在给定时间t>0 时,占据x范围 0≤x≤L的杆的温度。为了简单起见,我们将取f(t,x)=0,这相当于说系统没有加热/冷却,α=1,L=2。在实践中,我们可以重新调整问题来修复常数α,所以这不是一个限制性的问题。在这个例子中,我们将使用边界条件

这相当于说杆的两端保持在恒定温度 0。我们还将使用初始温度剖面

这个初始温度剖面描述了 0 和 2 之间的值之间的平滑曲线,峰值为 3,这可能是将杆在中心加热到 3 度的结果。
我们将使用一种称为有限差分的方法,将杆分成若干相等的段,并将时间范围分成若干离散步骤。然后我们计算每个段和每个时间步长的解的近似值。
在这个示例中,我们将使用有限差分来解一个简单的偏微分方程。
准备工作
对于这个示例,我们将需要 NumPy 包和 Matplotlib 包,通常导入为np和plt。我们还需要从mpl_toolkits导入mplot3d模块,因为我们将生成一个 3D 图:
from mpl_toolkits import mplot3d
我们还需要一些来自 SciPy 包的模块。
如何做...
在接下来的步骤中,我们将通过有限差分来解决热方程:
- 首先创建代表系统物理约束的变量:杆的范围和α的值:
alpha = 1
x0 = 0 # Left hand x limit
xL = 2 # Right hand x limit
- 我们首先将x范围分成N个相等的间隔—我们在这个例子中取N = 10—使用N+1个点。我们可以使用 NumPy 中的
linspace例程生成这些点。我们还需要每个间隔的公共长度h*:
N = 10
x = np.linspace(x0, xL, N+1)
h = (xL - x0) / N
- 接下来,我们需要设置时间方向上的步长。我们在这里采取了稍微不同的方法;我们设置时间步长k和步数(隐含地假设我们从时间 0 开始):
k = 0.01
steps = 100
t = np.array([i*k for i in range(steps+1)])
- 为了使方法正常运行,我们必须满足:

否则系统可能变得不稳定。我们将左侧存储在一个变量中,以便在步骤 4中使用,并使用断言来检查这个不等式是否成立:
r = alpha*k / h**2
assert r < 0.5, f"Must have r < 0.5, currently r={r}"
- 现在我们可以构建一个矩阵,其中包含来自有限差分方案的系数。为此,我们使用
scipy.sparse模块中的diags例程创建一个稀疏的三对角矩阵:
from scipy import sparse
diag = [1, *(1-2*r for _ in range(N-1)), 1]
abv_diag = [0, *(r for _ in range(N-1))]
blw_diag = [*(r for _ in range(N-1)), 0]
A = sparse.diags([blw_diag, diag, abv_diag], (-1, 0, 1), shape=(N+1,
N+1), dtype=np.float64, format="csr")
- 接下来,我们创建一个空白矩阵来保存解决方案:
u = np.zeros((steps+1, N+1), dtype=np.float64)
- 我们需要将初始配置添加到第一行。这样做的最佳方法是创建一个保存初始配置的函数,并将在刚刚创建的矩阵
u上的x数组上评估这个函数的结果:
def initial_profile(x):
return 3*np.sin(np.pi*x/2)
u[0, :] = initial_profile(x)
- 现在我们可以简单地循环每一步,通过将
A和前一行相乘来计算矩阵u的下一行:
for i in range(steps):
u[i+1, :] = A @ u[i, :]
- 最后,为了可视化我们刚刚计算的解,我们可以使用 Matplotlib 将解作为曲面绘制出来:
X, T = np.meshgrid(x, t)
fig = plt.figure()
ax = fig.add_subplot(projection="3d")
ax.plot_surface(T, X, u, cmap="hot")
ax.set_title("Solution of the heat equation")
ax.set_xlabel("t")
ax.set_ylabel("x")
ax.set_zlabel("u")
这导致了图 3.5中显示的曲面图:

图 3.5:使用有限差分方法计算的热方程解在范围 0 ≤ x ≤ 2 上的曲面图,使用了 10 个网格点
它是如何工作的...
有限差分方法通过用仅涉及函数值的简单分数替换每个导数来工作,我们可以估计这些分数。要实现这种方法,我们首先将空间范围和时间范围分解为若干离散间隔,由网格点分隔。这个过程称为离散化。然后我们使用微分方程和初始条件以及边界条件来形成连续的近似,这与solve_ivp例程在Solving differential equations numerically示例中使用的时间步进方法非常相似。
为了解决热方程这样的偏微分方程,我们至少需要三个信息。通常,对于热方程,这将以空间维度的边界条件的形式出现,告诉我们在杆的两端行为如何,以及时间维度的初始条件,即杆上的初始温度分布。
前面描述的有限差分方案通常被称为前向时间中心空间(FTCS)方案,因为我们使用前向有限差分来估计时间导数,使用中心有限差分来估计(二阶)空间导数。这些有限差分的公式如下:

和

将这些近似代入热方程,并使用近似值u[i]^(j)来表示在i空间点上经过j时间步后u(t[j], x[i])的值,我们得到

可以重新排列以获得公式

粗略地说,这个方程表示给定点的下一个温度取决于以前时间的周围温度。这也显示了为什么r值的条件是必要的;如果条件不成立,右侧的中间项将是负的。
我们可以将这个方程组写成矩阵形式,

其中uj*是包含近似*u[i]j和矩阵A的向量,矩阵A在步骤 4中定义。这个矩阵是三对角的,这意味着非零条目出现在或邻近主对角线上。我们使用 SciPy sparse模块中的diag例程,这是一种定义这种矩阵的实用程序。这与本章中解方程配方中描述的过程非常相似。这个矩阵的第一行和最后一行都是零,除了在左上角和右下角,分别代表(不变的)边界条件。其他行的系数由微分方程两侧的有限差分近似给出。我们首先创建对角线条目和对角线上下方的条目,然后我们使用diags例程创建稀疏矩阵。矩阵应该有N+1行和列,以匹配网格点的数量,并且我们将数据类型设置为双精度浮点数和 CSR 格式。
初始配置给我们向量u⁰,从这一点开始,我们可以通过简单地执行矩阵乘法来计算每个后续时间步骤,就像我们在步骤 7中看到的那样。
还有更多...
我们在这里描述的方法相当粗糙,因为近似可能变得不稳定,正如我们提到的,如果时间步长和空间步长的相对大小没有得到仔细控制。这种方法是显式的,因为每个时间步骤都是显式地使用来自上一个时间步骤的信息来计算的。还有隐式方法,它给出了一个可以求解以获得下一个时间步骤的方程组。不同的方案在解的稳定性方面具有不同的特性。
当函数f(t, x)不为 0 时,我们可以通过使用赋值来轻松适应这种变化

其中函数被适当地向量化以使这个公式有效。在解决问题的代码方面,我们只需要包括函数的定义,然后将解决方案的循环更改如下:
for i in range(steps):
u[i+1, :] = A @ u[i, :] + f(t[i], x)
从物理上讲,这个函数代表了杆上每个点的外部热源(或者热池)。这可能随时间变化,这就是为什么一般来说,这个函数应该有t和x作为参数(尽管它们不一定都被使用)。
我们在这个例子中给出的边界条件代表了杆的两端保持在恒定温度为 0。这种边界条件有时被称为Dirichlet边界条件。还有Neumann边界条件,其中函数u的导数在边界处给定。例如,我们可能已经给出了边界条件

这在物理上可以被解释为杆的两端被绝缘,因此热量不能通过端点逃逸。对于这种边界条件,我们需要稍微修改矩阵A,但方法本质上保持不变。实际上,在边界的左侧插入一个虚拟的x值,并在左边界(x = 0)使用向后有限差分,我们得到

使用这个二阶有限差分近似,我们得到

这意味着我们矩阵的第一行应包含1-r,然后是r,然后是 0。对右手极限进行类似的计算得到矩阵的最后一行。
diag = [1-r, *(1-2*r for _ in range(N-1)), 1-r]
abv_diag = [*(r for _ in range(N))]
blw_diag = [*(r for _ in range(N))]
A = sparse.diags([blw_diag, diag, abv_diag], (-1, 0, 1), shape=(N+1, N+1), dtype=np.float64, format="csr")
对于涉及偏微分方程的更复杂问题,可能更适合使用有限元求解器。有限元方法使用比我们在本篇中看到的有限差分方法更复杂的方法来计算解决方案,通常比偏微分方程更灵活。然而,这需要更多依赖更高级数学理论的设置。另一方面,有一个用于使用有限元方法解决偏微分方程的 Python 包,如 FEniCS (fenicsproject.org)。使用 FEniCS 等包的优势在于它们通常针对性能进行了调整,这在解决复杂问题时对高精度至关重要。
另请参阅
FEniCS 文档介绍了有限元方法以及使用该包解决各种经典偏微分方程的示例。有关该方法和理论的更全面介绍可在以下书籍中找到:
- Johnson, C. (2009).Numerical solution of partial differential equations by the finite element method. Mineola, N.Y.: Dover Publications.
有关如何使用 Matplotlib 生成三维曲面图的更多详细信息,请参阅第二章中的曲面和等高线图食谱。
使用离散傅立叶变换进行信号处理
来自微积分中最有用的工具之一是傅立叶变换。粗略地说,傅立叶变换以可逆的方式改变了某些函数的表示。这种表示的改变在处理作为时间函数的信号时特别有用。在这种情况下,傅立叶变换将信号表示为频率函数;我们可以将其描述为从信号空间到频率空间的转换。这可以用于识别信号中存在的频率以进行识别和其他处理。在实践中,我们通常会有信号的离散样本,因此我们必须使用离散傅立叶变换来执行这种分析。幸运的是,有一种计算效率高的算法,称为快速傅立叶变换(FFT),用于对样本应用离散傅立叶变换。
*我们将遵循使用 FFT 处理嘈杂信号的常见过程。第一步是应用 FFT 并使用数据计算信号的功率谱密度。然后我们识别峰值并滤除对信号贡献不足够大的频率。然后我们应用逆 FFT 来获得滤波后的信号。
在本篇中,我们使用 FFT 来分析信号的样本,并识别存在的频率并清除信号中的噪音。
准备工作
对于本篇,我们只需要导入 NumPy 和 Matplotlib 包,如往常一样命名为np和plt。
如何做...
按照以下说明使用 FFT 处理嘈杂信号:
- 我们定义一个函数来生成我们的基础信号:
def signal(t, freq_1=4.0, freq_2=7.0):
return np.sin(freq_1 * 2 * np.pi * t) + np.sin(freq_2 * 2 *
np.pi * t)
- 接下来,我们通过向基础信号添加一些高斯噪声来创建我们的样本信号。我们还创建一个数组,以便在以后方便时保存样本t值处的真实信号:
state = np.random.RandomState(12345)
sample_size = 2**7 # 128
sample_t = np.linspace(0, 4, sample_size)
sample_y = signal(sample_t) + state.standard_normal(sample_size)
sample_d = 4./(sample_size - 1) # Spacing for linspace array
true_signal = signal(sample_t)
- 我们使用 NumPy 的
fft模块来计算离散傅立叶变换。在开始分析之前,我们从 NumPy 中导入这个模块:
from numpy import fft
- 要查看嘈杂信号的样子,我们可以绘制样本信号点并叠加真实信号:
fig1, ax1 = plt.subplots()
ax1.plot(sample_t, sample_y, "k.", label="Noisy signal")
ax1.plot(sample_t, signal(sample_t), "k--", label="True signal")
ax1.set_title("Sample signal with noise")
ax1.set_xlabel("Time")
ax1.set_ylabel("Amplitude")
ax1.legend()
此处创建的图表显示在图 3.6中。正如我们所看到的,嘈杂信号与真实信号几乎没有相似之处(用虚线表示):

图 3.6:带真实信号的噪声信号样本
- 现在,我们将使用离散傅立叶变换来提取样本信号中存在的频率。
fft模块中的fft例程执行 FFT(离散傅立叶变换):
spectrum = fft.fft(sample_y)
fft模块提供了一个用于构建适当频率值的例程,称为fftfreq。为了方便起见,我们还生成一个包含正频率出现的整数的数组:
freq = fft.fftfreq(sample_size, sample_d)
pos_freq_i = np.arange(1, sample_size//2, dtype=int)
- 接下来,计算信号的功率谱密度(PSD)如下:
psd = np.abs(spectrum[pos_freq_i])**2 + np.abs(spectrum[-
pos_freq_i])**2
- 现在,我们可以绘制信号的正频率的 PSD,并使用这个图来识别频率:
fig2, ax2 = plt.subplots()
ax2.plot(freq[pos_freq_i], psd)
ax2.set_title("PSD of the noisy signal")
ax2.set_xlabel("Frequency")
ax2.set_ylabel("Density")
结果可以在图 3.7中看到。我们可以在这个图表中看到大约在 4 和 7 左右有尖峰,这些是我们之前定义的信号的频率:

图 3.7:使用 FFT 生成的信号的功率谱密度
- 我们可以识别这两个频率,尝试从噪声样本中重建真实信号。所有出现的次要峰值都不大于 10,000,所以我们可以将其作为滤波器的截止值。现在,我们从所有正频率索引的列表中提取(希望是 2 个)对应于 PSD 中大于 10,000 的峰值的索引:
filtered = pos_freq_i[psd > 1e4]
- 接下来,我们创建一个新的干净频谱,其中只包含我们从噪声信号中提取的频率。我们通过创建一个只包含 0 的数组,然后将
spectrum的值从对应于滤波频率及其负值的索引复制过来来实现这一点:
new_spec = np.zeros_like(spectrum)
new_spec[filtered] = spectrum[filtered]
new_spec[-filtered] = spectrum[-filtered]
- 现在,我们使用逆 FFT(使用
ifft例程)将这个干净的频谱转换回原始样本的时间域。我们使用 NumPy 的real例程取实部以消除错误的虚部:
new_sample = np.real(fft.ifft(new_spec))
- 最后,我们绘制这个滤波信号和真实信号进行比较:
fig3, ax3 = plt.subplots()
ax3.plot(sample_t, true_signal, color="#8c8c8c", linewidth=1.5, label="True signal")
ax3.plot(sample_t, new_sample, "k--", label="Filtered signal")
ax3.legend()
ax3.set_title("Plot comparing filtered signal and true signal")
ax3.set_xlabel("Time")
ax3.set_ylabel("Amplitude")
步骤 12的结果显示在图 3.8中。我们可以看到,滤波信号与真实信号非常接近,除了一些小的差异:

图 3.8:比较使用 FFT 和滤波生成的滤波信号与真实信号的图
工作原理...
函数f(t)的傅立叶变换由积分给出

离散傅立叶变换如下:

这里,f[k]值是复数形式的样本值。可以使用前面的公式计算离散傅立叶变换,但实际上这并不高效。使用这个公式计算的复杂度是O(N²)。FFT 算法将复杂度提高到O(N log N),这显然更好。书籍Numerical Recipes(在进一步阅读部分给出完整的参考文献细节)对 FFT 算法和离散傅立叶变换有很好的描述。
我们将对从已知信号(具有已知频率模式)生成的样本应用离散傅立叶变换,以便我们可以看到我们获得的结果与原始信号之间的联系。为了保持这个信号简单,我们创建了一个只有两个频率分量(值为 4 和 7)的信号。从这个信号,我们生成了我们分析的样本。由于 FFT 的工作方式,最好是样本的大小是 2 的幂;如果不是这种情况,我们可以用零元素填充样本使其成为这种情况。我们向样本信号添加了一些高斯噪声,这是一个正态分布的随机数。
fft例程返回的数组包含N+1个元素,其中N是样本大小。索引为 0 的元素对应于 0 频率,或者直流偏移。接下来的N/2个元素是对应于正频率的值,最后的N/2个元素是对应于负频率的值。频率的实际值由采样点数N和采样间距确定,在这个例子中,采样间距存储在sample_d中。
频率ω处的功率谱密度由以下公式给出

其中H(ω)代表信号在频率ω处的傅里叶变换。功率谱密度测量了每个频率对整体信号的贡献,这就是为什么我们在大约 4 和 7 处看到峰值。由于 Python 索引允许我们对从序列末尾开始的元素使用负索引,我们可以使用正索引数组从spectrum中获取正频率和负频率元素。
在步骤 9中,我们提取了在图表上峰值超过 10,000 的两个频率的索引。对应于这些索引的频率分别是 3.984375 和 6.97265625,它们并不完全等于 4 和 7,但非常接近。这种差异的原因是我们使用有限数量的点对连续信号进行了采样。(使用更多点当然会得到更好的近似。)
在步骤 11中,我们提取了从逆 FFT 返回的数据的实部。这是因为从技术上讲,FFT 处理复杂数据。由于我们的数据只包含实数据,我们期望这个新信号也只包含实数据。然而,会有一些小错误产生,这意味着结果并非完全是实数。我们可以通过取逆 FFT 的实部来纠正这一点。这是合适的,因为我们可以看到虚部非常小。
我们可以在图 3.8中看到,滤波信号非常接近真实信号,但并非完全相同。这是因为,如前所述,我们正在用相对较小的样本来逼近连续信号。
还有更多...
在生产环境中的信号处理可能会使用专门的软件包,比如scipy中的signal模块,或者一些更低级的代码或硬件来执行信号的滤波或清理。这个示例更多地应该被看作是 FFT 作为处理从某种基础周期结构(信号)采样的数据的工具的演示。FFT 对于解决偏微分方程非常有用,比如在数值解偏微分方程食谱中看到的热方程。
另请参阅
有关随机数和正态分布(高斯)的更多信息可以在第四章中找到,处理随机性和概率。
进一步阅读
微积分是每门本科数学课程中非常重要的一部分。有许多关于微积分的优秀教科书,包括 Spivak 的经典教科书和 Adams 和 Essex 的更全面的课程:
-
Spivak, M. (2006). Calculus. 3rd ed. Cambridge: Cambridge University Press
-
Adams, R. and Essex, C. (2018). Calculus: A Complete Course. 9th ed. Don Mills, Ont: Pearson.Guassian
关于数值微分和积分的良好来源是经典的数值方法书,其中详细描述了如何在 C++中解决许多计算问题的理论概述:
- Press, W., Teukolsky, S., Vetterling, W. and Flannery, B. (2007). Numerical recipes: The Art of Scientific Computing. 3rd ed. Cambridge: Cambridge University Press****
第五章:处理随机性和概率
在本章中,我们将讨论随机性和概率。我们将首先通过从数据集中选择元素来简要探讨概率的基本原理。然后,我们将学习如何使用 Python 和 NumPy 生成(伪)随机数,以及如何根据特定概率分布生成样本。最后,我们将通过研究涵盖随机过程和贝叶斯技术的一些高级主题,并使用马尔可夫链蒙特卡洛方法来估计简单模型的参数来结束本章。
概率是特定事件发生的可能性的量化。我们在日常生活中直观地使用概率,尽管有时正式理论可能相当反直觉。概率论旨在描述随机变量的行为,其值是未知的,但是该随机变量取某些(范围的)值的概率是已知的。这些概率通常以几种概率分布的形式存在。可以说,最著名的概率分布是正态分布,例如,它可以描述大规模人口中某一特征的分布。
我们将在第六章 处理数据和统计 中再次在更应用的环境中看到概率,那里我们将讨论统计学。在这里,我们将利用概率理论来量化误差,并建立一个系统的数据分析理论。
在本章中,我们将涵盖以下示例:
-
随机选择项目
-
生成随机数据
-
更改随机数生成器
-
生成正态分布的随机数
-
处理随机过程
-
使用贝叶斯技术分析转化率
-
使用蒙特卡罗模拟估计参数
技术要求
对于本章,我们需要标准的科学 Python 包 NumPy、Matplotlib 和 SciPy。我们还需要 PyMC3 包来完成最后的示例。您可以使用您喜欢的软件包管理器(如pip)来安装它:
python3.8 -m pip install pymc3
此命令将安装 PyMC3 的最新版本,在撰写本文时为 3.9.2。该软件包提供了概率编程的功能,涉及执行许多由随机生成的数据驱动的计算,以了解问题解的可能分布。
本章的代码可以在 GitHub 存储库的Chapter 04文件夹中找到:github.com/PacktPublishing/Applying-Math-with-Python/tree/master/Chapter%2004。
查看以下视频以查看代码实际运行情况:bit.ly/2OP3FAo。
随机选择项目
概率和随机性的核心是从某种集合中选择一个项目的概念。我们知道,从集合中选择项目的概率量化了被选择的项目的可能性。随机性描述了根据概率从集合中选择项目,而没有任何额外的偏见。随机选择的相反可能被描述为确定性选择。一般来说,使用计算机复制纯随机过程是非常困难的,因为计算机及其处理本质上是确定性的。然而,我们可以生成伪随机数序列,当正确构造时,可以展示出对随机性的合理近似。
在这个示例中,我们将从集合中选择项目,并学习本章中将需要的一些与概率和随机性相关的关键术语。
准备工作
Python 标准库包含一个用于生成(伪)随机数的模块称为random,但在这个示例中,以及本章的其他地方,我们将使用 NumPy 的random模块。NumPy 的random模块中的例程可以用来生成随机数数组,比标准库中的例程更灵活。和往常一样,我们使用别名np导入 NumPy。
在我们继续之前,我们需要确定一些术语。样本空间是一个集合(一个没有重复元素的集合),事件是样本空间的子集。事件A发生的概率表示为P(A),是 0 到 1 之间的数字。概率为 0 表示事件永远不会发生,而概率为 1 表示事件一定会发生。整个样本空间的概率必须为 1。
当样本空间是离散的时,概率就是与每个元素相关的 0 到 1 之间的数字,所有这些数字的总和为 1。这赋予了从集合中选择单个项目(由单个元素组成的事件)的概率以意义。我们将在这里考虑从离散集合中选择项目的方法,并在“生成正态分布随机数”示例中处理连续情况。
如何做…
执行以下步骤从容器中随机选择项目:
- 第一步是设置随机数生成器。目前,我们将使用 NumPy 的默认随机数生成器,在大多数情况下这是推荐的。我们可以通过调用 NumPy 的
random模块中的default_rng例程来实现这一点,这将返回一个随机数生成器的实例。通常情况下,我们会不带种子地调用这个函数,但是在这个示例中,我们将添加种子12345,以便我们的结果是可重复的:
rng = np.random.default_rng(12345)
# changing seed for repeatability
- 接下来,我们需要创建数据和概率,我们将从中进行选择。如果您已经存储了数据,或者希望以相等的概率选择元素,则可以跳过此步骤:
data = np.arange(15)
probabilities = np.array(
[0.3, 0.2, 0.1, 0.05, 0.05, 0.05, 0.05, 0.025,
0.025, 0.025, 0.025, 0.025, 0.025, 0.025, 0.025]
)
作为一个快速的健全性测试,我们可以使用断言来检查这些概率确实相加为 1:
assert round(sum(probabilities), 10) == 1.0, \
"Probabilities must sum to 1"
- 现在,我们可以使用随机数生成器
rng上的choice方法,根据刚刚创建的概率从data中选择样本。对于这种选择,我们希望打开替换,因此调用该方法多次可以从整个data中选择:
selected = rng.choice(data, p=probabilities, replace=True)
# 0
- 要从
data中选择多个项目,我们还可以提供size参数,该参数指定要选择的数组的形状。这与许多其他 NumPy 数组创建例程的shape关键字参数起着相同的作用。给定size的参数可以是整数或整数元组:
selected_array = rng.choice(data, p=probabilities, replace=True, size=(5, 5))
#array([[ 1, 6, 4, 1, 1],
# [ 2, 0, 4, 12, 0],
# [12, 4, 0, 1, 10],
# [ 4, 1, 5, 0, 0],
# [ 0, 1, 1, 0, 7]])
工作原理…
default_rng例程创建一个新的伪随机数生成器(PRNG)实例(带有或不带有种子),可以用来生成随机数,或者如我们在示例中看到的,从预定义数据中随机选择项目。NumPy 还具有基于隐式状态的接口,可以直接使用random模块中的例程生成随机数。然而,通常建议显式地创建生成器,使用default_rng或自己创建Generator实例。以这种方式更加明确更符合 Python 的风格,并且应该会导致更可重现的结果(在某种意义上)。
种子是传递给随机数生成器以生成值的值。生成器以完全确定的方式基于种子生成一系列数字。这意味着给定相同种子的相同 PRNG 的两个实例将生成相同的随机数序列。如果没有提供种子,生成器通常会产生一个依赖于用户系统的种子。
NumPy 的Generator类是低级伪随机比特生成器的包装器,这是实际生成随机数的地方。在最近的 NumPy 版本中,默认的 PRNG 算法是 128 位置换同余生成器。相比之下,Python 内置的random模块使用 Mersenne Twister PRNG。有关不同 PRNG 算法的更多信息,请参阅更改随机数生成器示例。
Generator实例上的choice方法根据底层BitGenerator生成的随机数执行选择。可选的p关键字参数指定与提供的数据中的每个项目相关联的概率。如果没有提供此参数,则假定均匀概率,其中每个项目被选择的概率相等。replace关键字参数指定是否应进行带或不带替换的选择。我们打开了替换,以便可以多次选择相同的元素。choice方法使用生成器给出的随机数进行选择,这意味着使用相同种子的相同类型的两个 PRNG 在使用choice方法时将选择相同的项目。
还有更多...
choice方法也可以通过将replace=False作为参数来创建给定大小的随机样本。这保证了从数据中选择不同的项目,这对于生成随机样本是有利的。例如,这可能用于从整个用户组中选择用户来测试界面的新版本;大多数样本统计技术依赖于随机选择的样本。
生成随机数据
许多任务涉及生成大量的随机数,这些随机数在它们最基本的形式下要么是整数,要么是浮点数(双精度),位于范围 0 ≤ x < 1. 理想情况下,这些数字应该是均匀选择的,这样如果我们绘制大量这样的数字,它们应该大致均匀地分布在范围 0 ≤ x < 1 之间。
在这个示例中,我们将看到如何使用 NumPy 生成大量的随机整数和浮点数,并使用直方图显示这些数字的分布。
准备工作
在开始之前,我们需要从 NumPy 的random模块中导入default_rng例程,并创建默认随机数生成器的实例以在示例中使用:
from numpy.random import default_rng
rng = default_rng(12345) # changing seed for reproducibility
我们已经在随机选择项目示例中讨论了这个过程。
我们还将 Matplotlib 的pyplot模块导入为别名plt。
如何做...
执行以下步骤生成均匀随机数据并绘制直方图以了解其分布:
- 要生成 0 到 1 之间的随机浮点数,包括 0 但不包括 1,我们使用
rng对象上的random方法:
random_floats = rng.random(size=(5, 5))
# array([[0.22733602, 0.31675834, 0.79736546, 0.67625467, 0.39110955],
# [0.33281393, 0.59830875, 0.18673419, 0.67275604, 0.94180287],
# [0.24824571, 0.94888115, 0.66723745, 0.09589794, 0.44183967],
# [0.88647992, 0.6974535 , 0.32647286, 0.73392816, 0.22013496],
# [0.08159457, 0.1598956 , 0.34010018, 0.46519315, 0.26642103]])
- 要生成随机整数,我们使用
rng对象上的integers方法。这将返回指定范围内的整数:
random_ints = rng.integers(1, 20, endpoint=True, size=10)
# array([12, 17, 10, 4, 1, 3, 2, 2, 3, 12])
- 为了检查随机浮点数的分布,我们首先需要生成一个大数组的随机数,就像我们在步骤 1中所做的那样。虽然这并不是严格必要的,但更大的样本将能够更清楚地显示分布。我们生成这些数字如下:
dist = rng.random(size=1000)
- 为了显示我们生成的数字的分布,我们绘制了数据的直方图:
fig, ax = plt.subplots()
ax.hist(dist)
ax.set_title("Histogram of random numbers")
ax.set_xlabel("Value")
ax.set_ylabel("Density")
生成的图表显示在图 4.1中。正如我们所看到的,数据大致均匀地分布在整个范围内:

图 4.1:在 0 和 1 之间生成的随机数的直方图
它是如何工作的...
Generator接口提供了三种简单的方法来生成基本的随机数,不包括我们在随机选择项目示例中讨论的choice方法。除了random方法用于生成随机浮点数,integers方法用于生成随机整数,还有一个bytes方法用于生成原始的随机字节。这些方法中的每一个都调用底层BitGenerator实例上的相关方法。这些方法还允许生成的数字的数据类型进行更改,例如,从双精度到单精度浮点数。
还有更多...
Generator类上的integers方法通过添加endpoint可选参数,结合了旧的RandomState接口上的randint和random_integers方法的功能。(在旧接口中,randint方法排除了上限点,而random_integers方法包括了上限点。)Generator上的所有随机数据生成方法都允许自定义生成的数据类型,而在旧接口中是不可能的。(这个接口是在 NumPy 1.17 中引入的。)
在图 4.1中,我们可以看到我们生成的数据的直方图在范围 0 ≤ x < 1 上大致均匀。也就是说,所有的柱状图大致是水平的。(由于数据的随机性,它们并不完全水平。)这是我们从random方法生成的均匀分布的随机数所期望的。我们将在生成正态分布随机数的示例中更详细地解释随机数的分布。
更改随机数生成器
NumPy 中的random模块提供了几种替代默认 PRNG 的选择,它使用了 128 位置换同余生成器。虽然这是一个很好的通用随机数生成器,但对于您的特定需求可能不够。例如,这个算法与 Python 内部的随机数生成器使用的算法非常不同。我们将遵循 NumPy 文档中为运行可重复但适当随机的模拟设置的最佳实践指南。
在这个示例中,我们将向您展示如何切换到另一种伪随机数生成器,并如何在程序中有效地使用种子。
准备工作
像往常一样,我们使用别名np导入 NumPy。由于我们将使用random包中的多个项目,我们也从 NumPy 中导入该模块,使用以下代码:
from numpy import random
您需要选择 NumPy 提供的替代随机数生成器之一(或者定义自己的;请参阅本示例中的还有更多...部分)。在本示例中,我们将使用 MT19937 随机数生成器,它使用了类似于 Python 内部随机数生成器中使用的 Mersenne Twister 算法。
如何做...
以下步骤展示了如何以可重现的方式生成种子和不同的随机数生成器:
- 我们将生成一个
SeedSequence对象,可以从给定的熵源可重现地生成新的种子。我们可以像为default_rng提供种子一样提供我们自己的熵,或者让 Python 从操作系统中收集熵。在这里,我们将使用后者,以演示其用法。为此,我们不提供任何额外的参数来创建SeedSequence对象:
seed_seq = random.SeedSequence()
- 现在我们有了一种方法来为会话的其余部分生成随机数生成器的种子,接下来我们记录熵,以便以后如果需要的话可以重现这个会话。以下是熵应该看起来的示例;您的结果必然会有些不同:
print(seed_seq.entropy)
# 9219863422733683567749127389169034574
- 现在,我们可以创建底层的
BitGenerator实例,为包装的Generator对象提供随机数:
bit_gen = random.MT19937(seed_seq)
- 接下来,我们创建包装
Generator对象以围绕此BitGenerator实例创建可用的随机数生成器:
rng = random.Generator(bit_gen)
它是如何工作的...
如随机选择项目配方中所述,Generator类是围绕实现给定伪随机数算法的基础BitGenerator的包装器。NumPy 通过BitGenerator类的各种子类提供了几种伪随机数算法的实现:PCG64(默认);MT19937(在此配方中看到);Philox;和SFC64。这些位生成器是用 Cython 实现的。
PCG64生成器应该提供具有良好统计质量的高性能随机数生成。 (在 32 位系统上可能不是这种情况。)MT19937生成器比更现代的 PRNG 慢,不会产生具有良好统计特性的随机数。然而,这是 Python 标准库random模块使用的随机数生成器算法。Philox生成器相对较慢,但产生非常高质量的随机数,而SFC64生成器速度快,质量良好,但缺少其他生成器可用的一些功能。
在此配方中创建的SeedSequence对象是以独立且可重现的方式为随机数生成器创建种子的一种方法。特别是,如果您需要为几个并行进程创建独立的随机数生成器,但仍然需要能够稍后重建每个会话以进行调试或检查结果,这将非常有用。存储在此对象上的熵是从操作系统中收集的 128 位整数,它作为随机种子的来源。
SeedSequence对象允许我们为每个独立的进程/线程创建一个独立的随机数生成器,这些生成器彼此独立,消除了可能使结果不可预测的数据竞争问题。它还生成非常不同的种子值,这可以帮助避免一些 PRNG(例如 MT19937,它可以使用两个相似的 32 位整数种子值产生非常相似的流)的问题。显然,当我们依赖这些值的独立性时,有两个独立的随机数生成器产生相同或非常相似的值将是有问题的。
还有更多...
BitGenerator类充当原始随机整数生成器的通用接口。先前提到的类是 NumPy 中使用BitGenerator接口实现的类。您也可以创建自己的BitGenerator子类,尽管这需要在 Cython 中实现。
有关更多信息,请参阅 NumPy 文档numpy.org/devdocs/reference/random/extending.html#new-bit-generators。
生成正态分布的随机数
在生成随机数据配方中,我们生成了在 0 和 1 之间遵循均匀分布的随机浮点数,但不包括 1。然而,在大多数需要随机数据的情况下,我们需要遵循几种不同的分布之一。粗略地说,分布函数是一个描述随机变量具有低于x值的概率的函数f(x)。在实际情况下,分布描述了随机数据在范围内的分布。特别是,如果我们创建遵循特定分布的数据的直方图,那么它应该大致类似于分布函数的图形。这最好通过示例来看。
最常见的分布之一是正态分布,在统计学中经常出现,并且构成了我们将在第六章中看到的许多统计方法的基础,处理数据和统计。在这个示例中,我们将演示如何生成遵循正态分布的数据,并绘制这些数据的直方图以查看分布的形状。
准备工作
与生成随机数据示例中一样,我们从 NumPy 的random模块中导入default_rng例程,并创建一个具有种子生成器的Generator实例以进行演示:
from numpy.random import default_rng
rng = default_rng(12345)
像往常一样,我们导入了 Matplotlib 的pyplot模块并将其命名为plt,以及导入了 NumPy 并将其命名为np。
如何操作...
在接下来的步骤中,我们生成遵循正态分布的随机数据:
- 我们在
Generator实例上使用normal方法来生成符合normal分布的随机数据。正态分布有两个参数,位置和比例。还有一个可选的size参数,用于指定生成数据的形状。(有关size参数的更多信息,请参见生成随机数据示例。)我们生成一个包含 10,000 个值的数组,以获得一个相当大的样本:
mu = 5.0 # mean value
sigma = 3.0 # standard deviation
rands = rng.normal(loc=mu, scale=sigma, size=10000)
- 接下来,我们将绘制这些数据的直方图。我们增加了直方图中的
bins数量。这并不是严格必要的,因为默认数量(10)已经足够了,但这样做可以更好地显示分布:
fig, ax = plt.subplots()
ax.hist(rands, bins=20)
ax.set_title("Histogram of normally distributed data")
ax.set_xlabel("Value")
ax.set_ylabel("Density")
- 接下来,我们创建一个函数,用于生成一系列值的预期密度。这是通过将正态分布的概率密度函数乘以样本数(10,000)得到的:
def normal_dist_curve(x):
return 10000*np.exp(-0.5*((x-
mu)/sigma)**2)/(sigma*np.sqrt(2*np.pi))
- 最后,我们在数据的直方图上绘制我们的预期分布:
x_range = np.linspace(-5, 15)
y = normal_dist_curve(x_range)
ax.plot(x_range, y, "k--")
结果显示在图 4.2中。我们可以看到这里,我们抽样数据的分布与正态分布曲线的预期分布非常接近:

图 4.2:从均值为 5,比例为 3 的正态分布中绘制的数据的直方图,并叠加了预期密度
工作原理...
正态分布具有以下公式定义的概率密度函数:

这与正态分布函数F(x)相关,根据以下公式:

这个概率密度函数在均值处达到峰值,与位置参数重合,而"钟形曲线"的宽度由比例参数确定。我们可以在图 4.2中看到,由Generator对象上的normal方法生成的数据的直方图非常接近预期分布。
Generator类使用 256 步 Ziggurat 方法生成正态分布的随机数据,与 NumPy 中还可用的 Box-Muller 或逆 CDF 实现相比,速度更快。
还有更多...
正态分布是连续概率分布的一个例子,因为它是针对实数定义的,并且分布函数是由积分(而不是求和)定义的。正态分布(以及其他连续概率分布)的一个有趣特征是,选择任何给定实数的概率都是 0。这是合理的,因为只有在给定范围内测量所选分布中的值的概率才有意义。(选择特定值的概率为零是没有意义的。)
正态分布在统计学中很重要,主要是因为中心极限定理。粗略地说,该定理指出,具有共同均值和方差的独立同分布(IID)随机变量的总和最终会像具有共同均值和方差的正态分布。这适用于这些随机变量的实际分布。这使我们能够在许多情况下使用基于正态分布的统计检验,即使变量的实际分布不一定是正态的。(但是,当引用中心极限定理时,我们需要非常谨慎。)
除了正态分布之外,还有许多其他连续概率分布。我们已经遇到了在 0 到 1 范围内的均匀分布。更一般地,范围为a ≤ x**≤ b的均匀分布具有以下概率密度函数:

连续概率密度函数的其他常见例子包括指数分布、贝塔分布和伽玛分布。每个分布都有一个对应的Generator类的方法,用于从该分布生成随机数据。这些方法通常根据分布的名称命名,全部使用小写字母。因此,对于上述分布,对应的方法分别是exponential、beta和gamma。这些分布都有一个或多个参数,例如正态分布的位置和尺度,用于确定分布的最终形状。您可能需要查阅 NumPy 文档(numpy.org/doc/1.18/reference/random/generator.html#numpy.random.Generator)或其他来源,以查看每个分布需要哪些参数。NumPy 文档还列出了可以生成随机数据的概率分布。
处理随机过程
随机过程无处不在。粗略地说,随机过程是一组相关的随机变量系统,通常是关于时间t ≥ 0的索引,对于连续随机过程,或者是关于自然数n = 1, 2, …的索引,对于离散随机过程。许多(离散)随机过程满足马尔可夫性质,这使它们成为马尔可夫链。马尔可夫性质是指该过程是无记忆的,即只有当前值对于下一个值的概率是重要的。
在本教程中,我们将研究一个简单的随机过程示例,该示例模拟了一段时间内公交车到站的数量。这个过程被称为泊松过程。泊松过程N(t)有一个参数λ,通常称为强度或速率,在给定时间t时,N(t)取值为n的概率由以下公式给出:

这个方程描述了在时间t之前到达的n辆公共汽车的概率。数学上,这个方程意味着N(t)服从参数为λt的泊松分布。然而,有一种简单的方法可以通过取遵循指数分布的到达间隔时间的总和来构建泊松过程。例如,让X[i]表示第(i-1)次到达和第i次到达之间的时间,这些时间遵循参数为λ的指数分布。现在,我们得到以下方程:
*
在这里,数字N(t)是最大的n,使得T_n <= t。这是我们将在本教程中进行的构造。我们还将通过计算到达间隔时间的平均值来估计该过程的强度。
准备工作
在开始之前,我们从 NumPy 的random模块中导入default_rng例程,并创建一个新的随机数生成器,为了演示目的设置了一个种子:
from numpy.random import default_rng
rng = default_rng(12345)
除了随机数生成器,我们还导入 NumPy 作为np和 Matplotlib 的pyplot模块作为plt。我们还需要有 SciPy 包可用。
如何操作...
接下来的步骤展示了如何使用泊松过程模拟公交车的到达:
- 我们的第一个任务是通过从指数分布中抽样数据来创建样本到达时间间隔。NumPy 的
Generator类上的exponential方法需要一个scale参数,这是1/λ,其中λ是速率。我们选择速率为 4,并创建 50 个样本到达时间间隔:
rate = 4.0
inter_arrival_times = rng.exponential(scale=1./rate, size=50)
- 接下来,我们使用 NumPy 的
add通用函数的accumulate方法计算实际到达时间。我们还创建一个包含 0 到 49 的整数的数组,表示每个点的到达次数:
arrivals = np.add.accumulate(inter_arrival_times)
count = np.arange(50)
- 接下来,我们使用
step绘图方法绘制随时间到达的情况:
fig1, ax1 = plt.subplots()
ax1.step(arrivals, count, where="post")
ax1.set_xlabel("Time")
ax1.set_ylabel("Number of arrivals")
ax1.set_title("Arrivals over time")
结果显示在图 4.3中,每条水平线的长度代表了到达时间间隔:

图 4.3:随时间到达,其中到达时间间隔呈指数分布,使得某一时间的到达次数成为泊松过程
- 接下来,我们定义一个函数,将评估在某个时间内计数的概率分布,这里我们取
1。这使用了我们在本篇介绍中给出的泊松分布公式:
from scipy.special import factorial
N = np.arange(15)
def probability(events, time=1, param=rate):
return ((param*time)**events/factorial(events))*np.exp(-
param*time)
- 现在,我们绘制每单位时间的计数的概率分布,因为在上一步中我们选择了
time=1。我们稍后会在这个图上添加内容:
fig2, ax2 = plt.subplots()
ax2.plot(N, probability(N), "k", label="True distribution")
ax2.set_xlabel("Number of arrivals in 1 time unit")
ax2.set_ylabel("Probability")
ax2.set_title("Probability distribution")
- 现在,我们继续从我们的样本数据中估计速率。我们通过计算到达时间间隔的均值来实现这一点,对于指数分布来说,这是一个1/λ的估计量:
estimated_scale = np.mean(inter_arrival_times)
estimated_rate = 1.0/estimated_scale
- 最后,我们使用估计的速率绘制每单位时间的计数的概率分布。我们将这个绘制在我们在步骤 5中产生的真实概率分布上:
ax2.plot(N, probability(N, param=estimated_rate), "k--", label="Estimated distribution")
ax2.legend()
得到的图在图 4.4中,我们可以看到,除了一点小差异外,估计的分布非常接近真实分布:

图 4.4:单位时间内到达次数的泊松分布,真实分布,以及从采样数据估计的分布
工作原理...
泊松过程是一个计数过程,它计算在一段时间内发生的事件(公交车到达)的数量,如果事件在时间上是随机间隔的(时间上)并且具有固定参数的指数分布。我们通过从指数分布中抽样到达时间间隔来构建泊松过程,遵循我们在介绍中描述的构建过程。然而,事实证明,当泊松过程在概率方面给出其正式定义时,这一事实(到达时间间隔呈指数分布)是所有泊松过程的属性。
在本篇中,我们从具有给定rate参数的指数分布中抽样了 50 个点。我们必须进行一些小的转换,因为 NumPy 的Generator方法用于从指数分布中抽样使用了一个相关的scale参数,即1除以rate。一旦我们有了这些点,我们创建一个包含这些指数分布数字的累积和的数组。这创建了我们的到达时间。实际的泊松过程是在图 4.3中显示的,是到达时间和在该时间发生的事件数量的组合。
指数分布的均值(期望值)与比例参数相符,因此从指数分布中抽取的样本的均值是估计比例(速率)参数的一种方法。由于我们的样本相对较小,这种估计不会完美。这就是为什么在图 4.4中两个图之间存在一些小的差异。
还有更多...
有许多类型的随机过程描述各种真实世界的情景。在这个示例中,我们使用泊松过程模拟到达时间。泊松过程是一个连续的随机过程,这意味着它是由一个连续变量t≥0 来参数化的,而不是一个离散变量n=1,2,….泊松过程实际上是马尔可夫链,在适当的马尔可夫链定义下,也是更新过程的一个例子。更新过程描述了在一段时间内发生的事件数量。这里描述的泊松过程是更新过程的一个例子。
许多马尔可夫链除了其定义的马尔可夫性质外,还满足一些其他属性。例如,如果对于所有n、i和j值,以下等式成立,则马尔可夫链是均匀的:

简单来说,这意味着在单个步骤中从一个状态转移到另一个状态的概率随着步数的增加而不变。这对于检查马尔可夫链的长期行为非常有用。
构建均匀马尔可夫链的简单示例非常容易。假设我们有两个状态A和B。在任何给定的步骤中,我们可能处于状态A或状态B。我们根据概率在状态之间移动。例如,假设从状态A转移到状态A的概率为 0.4,从状态A转移到状态B的概率为 0.6。同样,假设从状态B转移到状态B的概率为 0.2,从状态B转移到状态A的概率为 0.8。请注意,在这两种情况下,切换的概率加上保持不变的概率总和为 1。我们可以用矩阵形式表示每个状态的转移概率,如下所示:
*
这个矩阵被称为转移矩阵。这里的想法是,一步后处于特定状态的概率是通过将包含状态A和B的概率的向量(分别为位置 0 和 1)相乘得到的。例如,如果我们从状态A开始,那么概率向量将在索引 0 处包含 1,在索引 1 处包含 0。然后,一步后处于状态A的概率为 0.4,处于状态B的概率为 0.6。这是我们预期的结果,根据我们之前概述的概率。然而,我们也可以使用矩阵公式来表示这个计算:

为了得到两个步骤后处于任一状态的概率,我们再次将右侧向量乘以转移矩阵T,得到以下结果:

我们可以无限地继续这个过程,得到一系列状态向量,构成我们的马尔可夫链。这种构造可以应用于许多简单的真实世界问题,如果需要,可以使用更多的状态。
使用贝叶斯技术分析转化率
贝叶斯概率允许我们通过考虑数据系统地更新我们对情况的理解(以概率意义)。更加技术性的说法是,我们使用数据更新先验分布(我们当前的理解)以获得后验分布。例如,当检查用户在查看网站后购买产品的比例时,这是特别有用的。我们从我们的先验信念分布开始。为此,我们将使用beta分布,该分布模拟了成功的概率,给定成功(完成购买)和失败(未购买)的数量。在这个示例中,我们假设我们的先验信念是,我们期望从 100 次浏览中获得 25 次成功(75 次失败)。这意味着我们的先验信念遵循 beta(25, 75)分布。假设我们希望计算成功率至少为 33%的概率。
我们的方法大致分为三个步骤。首先,我们需要了解我们对转化率的先验信念,我们决定其遵循 beta(25, 75)分布。我们通过(数值)积分先验分布的概率密度函数来计算转化率至少为 33%的概率,从 0.33 到 1。下一步是应用贝叶斯推理来使用新信息更新我们的先验信念。然后,我们可以使用后验信念执行相同的积分,以检查在给定这些新信息的情况下,转化率至少为 33%的概率。
在这个示例中,我们将看到如何使用贝叶斯技术根据我们假设的网站的新信息更新先验信念。
准备工作
像往常一样,我们需要导入 NumPy 和 Matplotlib 包,分别命名为np和plt。我们还需要导入 SciPy 包,命名为sp。
如何做...
以下步骤显示了如何使用贝叶斯推理来估计和更新转化率估计:
- 第一步是建立先验分布。为此,我们使用 SciPy 的 stats 模块中的
beta分布对象,该对象具有各种用于处理 beta 分布的方法。我们从stats模块中导入beta分布,并使用别名beta_dist创建一个方便的概率密度函数:
from scipy.stats import beta as beta_dist
beta_pdf = beta_dist.pdf
- 接下来,我们需要计算在先验信念分布下,成功率至少为 33%的概率。为此,我们使用 SciPy 的 integrate 模块中的
quad例程,该例程执行函数的数值积分。我们使用这个例程来积分 beta 分布的概率密度函数(在步骤 1中导入),并使用我们的先验参数。我们将根据我们的先验分布将概率打印到控制台上:
prior_alpha = 25
prior_beta = 75
args = (prior_alpha, prior_beta)
prior_over_33, err = sp.integrate.quad(beta_pdf, 0.33, 1, args=args)
print("Prior probability", prior_over_33)
# 0.037830787030165056
- 现在,假设我们已经收到了关于一个新时间段内成功和失败的一些信息。例如,我们观察到这段时间内有 122 次成功和 257 次失败。我们创建新的变量来反映这些值:
observed_successes = 122
observed_failures = 257
- 要获得 beta 分布的后验分布的参数值,我们只需将观察到的成功和失败添加到
prior_alpha和prior_beta参数中:
posterior_alpha = prior_alpha + observed_successes
posterior_beta = prior_beta + observed_failures
- 现在,我们重复数值积分,使用后验分布(使用先前计算的新参数)计算成功率现在高于 33%的概率。然后,我们在终端打印这个概率:
args = (posterior_alpha, posterior_beta)
posterior_over_33, err2 = sp.integrate.quad(beta_pdf, 0.33, 1,
args=args)
print("Posterior probability", posterior_over_33)
# 0.13686193416281017
- 我们可以看到,根据更新后的后验分布,新的概率为 13%,而不是先前的 3%。这是一个显著的差异,尽管我们仍然不能确定在给定这些值的情况下转化率是否高于 33%。现在,我们绘制先验和后验分布,以可视化这种概率增加。首先,我们创建一个值数组,并根据这些值评估我们的概率密度函数:
p = np.linspace(0, 1, 500)
prior_dist = beta_pdf(p, prior_alpha, prior_beta)
posterior_dist = beta_pdf(p, posterior_alpha, posterior_beta)
- 最后,我们将在一个新的图中绘制步骤 6中计算的两个概率密度函数:
fig, ax = plt.subplots()
ax.plot(p, prior_dist, "k--", label="Prior")
ax.plot(p, posterior_dist, "k", label="Posterior")
ax.legend()
ax.set_xlabel("Success rate")
ax.set_ylabel("Density")
ax.set_title("Prior and posterior distributions for success rate")
结果图显示在图 4.5中,我们可以看到后验分布比先验分布更窄,且向右集中:

图 4.5:遵循 beta 分布的成功率的先验和后验分布
工作原理...
贝叶斯技术通过采用先验信念(概率分布)并使用贝叶斯定理将先验信念与给定此先验信念的数据的可能性相结合,形成后验信念。这实际上类似于我们在现实生活中理解事物的方式。例如,当你在某一天醒来时,你可能会相信(来自预报或其他方式)外面下雨的可能性是 40%。打开窗帘后,你看到外面非常多云,这可能表明下雨的可能性更大,因此我们根据这些新数据更新我们的信念,说有 70%的可能性会下雨。
要理解这是如何工作的,我们需要了解条件概率。条件概率涉及一个事件在另一个事件已经发生的情况下发生的概率。用符号表示,事件B发生的情况下事件A发生的概率如下所示:

贝叶斯定理是一个强大的工具,可以用以下方式(符号化)表示:

概率P(A)代表我们的先验信念。事件B代表我们收集到的数据,因此P(B | A)是我们的数据出现在我们先验信念下的可能性。概率P(B)代表我们的数据出现的可能性,P(A | B)代表我们的后验信念给定数据。在实践中,概率P(B)可能很难计算或估计,因此用贝叶斯定理的比例版本替换上面的强等式是非常常见的:

在这个配方中,我们假设我们的先验分布是 beta 分布。Beta 分布的概率密度函数由以下方程给出:

这里,Γ(α)是伽玛函数。可能性是二项分布的,其概率密度函数由以下方程给出:

这里,k是观察次数,j是其中一个成功的次数。在这个配方中,我们观察到m = 122次成功和n = 257 次失败,这给出k = m + n = 379和j = m = 122。要计算后验分布,我们可以使用 beta 分布是二项分布的共轭先验的事实,看到贝叶斯定理的比例形式的右侧是具有参数α + m**和β +* n的 beta 分布。这就是我们在这个配方中使用的。Beta 分布是二项随机变量的共轭先验的事实使它们在贝叶斯统计中非常有用。*
**我们在这个配方中展示的方法是使用贝叶斯方法的一个相当基本的例子,但它仍然对以系统的方式更新我们的先验信念给出了有用的方法。
还有更多...
贝叶斯方法可以用于各种各样的任务,使其成为一个强大的工具。在这个配方中,我们使用了贝叶斯方法来基于我们对网站表现的先验信念和从用户那里收集到的额外数据来建模网站的成功率。这是一个相当复杂的例子,因为我们将我们的先验信念建模为 beta 分布。这里是另一个使用贝叶斯定理来检验两个竞争假设的例子,只使用简单的概率(0 到 1 之间的数字)。
假设你每天回家时都把钥匙放在同一个地方,但有一天早上你醒来发现它们不在那里。搜索了一会儿后,你找不到它们,于是得出结论它们必须已经从存在中消失了。让我们称这个假设为H[1]。现在,H[1]确实解释了你找不到钥匙的数据D,因此似然P(D | H[1]) = 1。 (如果你的钥匙从存在中消失了,那么你不可能找到它们。)另一个假设是你昨晚回家时把它们放在了别的地方。让我们称这个假设为H[2]。现在这个假设也解释了数据,所以P(D | H[2]) = 1,但实际上,H[2]比H[1]更合理。假设你的钥匙完全消失的概率是 100 万分之 1——这是一个巨大的高估,但我们需要保持合理的数字——而你估计你昨晚把它们放在别的地方的概率是 100 分之 1。计算后验概率,我们有以下结果:

这突显了一个现实,那就是你简单地把钥匙放错地方的可能性要比它们突然消失的可能性大 10,000 倍。果然,你很快就发现你的钥匙已经在口袋里了,因为你早上早些时候已经把它们拿起来了。
使用蒙特卡洛模拟估计参数
蒙特卡洛方法广泛描述了使用随机抽样解决问题的技术。当潜在问题涉及某种不确定性时,这些技术尤其强大。一般方法涉及执行大量模拟,每个模拟根据给定的概率分布抽样不同的输入,然后聚合结果,以给出比任何单个样本解更好的真实解的近似。
马尔可夫链蒙特卡洛(MCMC)是一种特定类型的蒙特卡洛模拟,其中我们构建一个马尔可夫链,逐步得到我们寻求的真实分布的更好近似。这是通过在每个阶段基于精心选择的接受概率接受或拒绝随机抽样的提议状态来实现的,旨在构建一个唯一的稳态分布恰好是我们希望找到的未知分布的马尔可夫链。
在这个食谱中,我们将使用 PyMC3 包和 MCMC 方法来估计一个简单模型的参数。该包将处理运行模拟的大部分技术细节,因此我们不需要进一步了解不同 MCMC 算法的工作原理。
准备工作
像往常一样,我们导入 NumPy 包和 Matplotlib pyplot模块,分别命名为np和plt。我们还导入并创建一个默认的随机数生成器,为了演示目的,设置了一个种子:
from numpy.random import default_rng
rng = default_rng(12345)
对于这个食谱,我们还需要从 SciPy 包中导入一个模块,以及 PyMC3 包,这是一个用于概率编程的包。
如何做...
执行以下步骤,使用马尔可夫链蒙特卡洛模拟来估计简单模型的参数:
- 我们的第一个任务是创建一个代表我们希望识别的基本结构的函数。在这种情况下,我们将估计二次函数的系数。这个函数接受两个参数,一个是固定的范围内的点,另一个是我们希望估计的变量参数:
def underlying(x, params):
return params[0]*x**2 + params[1]*x + params[2]
- 接下来,我们设置
true参数和一个size参数,确定我们生成的样本中有多少点:
size = 100
true_params = [2, -7, 6]
- 我们生成将用于估计参数的样本。这将包括由我们在Step 1中定义的
underlying函数生成的基础数据,以及遵循正态分布的一些随机噪音。我们首先生成一系列x值,这将在整个配方中保持不变,然后使用underlying函数和我们的随机数生成器上的normal方法来生成样本数据:
x_vals = np.linspace(-5, 5, size)
raw_model = underlying(x_vals, true_params)
noise = rng.normal(loc=0.0, scale=10.0, size=size)
sample = raw_model + noise
- 在开始分析之前,将样本数据与基础数据叠加在一起是一个好主意。我们使用
scatter绘图方法仅绘制数据点(不连接线),然后使用虚线绘制基础的二次结构:
fig1, ax1 = plt.subplots()
ax1.scatter(x_vals, sample, label="Sampled data")
ax1.plot(x_vals, raw_model, "k--", label="Underlying model")
ax1.set_title("Sampled data")
ax1.set_xlabel("x")
ax1.set_ylabel("y")
结果是图 4.6,我们可以看到即使有噪音,基础模型的形状仍然可见,尽管这个模型的确切参数不再明显:

图 4.6:叠加了基础模型的采样数据
- 我们已经准备好开始我们的分析,所以现在导入 PyMC3 包并使用别名
pm如下:
import pymc3 as pm
- PyMC3 编程的基本对象是
Model类,通常使用上下文管理器接口创建。我们还为参数创建先验分布。在这种情况下,我们假设我们的先验参数服从均值为 1,标准差为 1 的正态分布。我们需要 3 个参数,因此我们提供shape参数。Normal类创建将在蒙特卡洛模拟中使用的随机变量:
with pm.Model() as model:
params = pm.Normal("params", mu=1, sigma=1, shape=3)
- 我们为基础数据创建一个模型,可以通过将我们在Step 6中创建的随机变量
param传递给我们在Step 1中定义的underlying函数来完成。我们还创建一个处理我们观测值的变量。为此,我们使用Normal类,因为我们知道我们的噪音在基础数据y周围是正态分布的。我们设置标准差为2,并将我们观察到的sample数据传递给observed关键字参数(这也在Model上下文中):
y = underlying(x_vals, params)
y_obs = pm.Normal("y_obs", mu=y, sigma=2, observed=sample)
- 要运行模拟,我们只需要在
Model上下文中调用sample例程。我们传递cores参数以加快计算速度,但将所有其他参数保持默认值:
trace = pm.sample(cores=4)
这些模拟应该需要很短的时间来执行。
- 接下来,我们绘制使用 PyMC3 中的
plot_posterior例程的后验分布。这个例程使用了从进行模拟的采样步骤中得到的trace结果。我们提前使用plt.subplots例程创建自己的图和坐标轴,但这并不是严格必要的。我们在单个图上使用了三个子图,并将axs2的Axes元组传递给绘图例程的ax关键字参数:
fig2, axs2 = plt.subplots(1, 3, tight_layout=True)
pm.plot_posterior(trace, ax=axs2)
结果图显示在图 4.7中,您可以看到每个分布都近似正态,均值与真实参数值相似:

图 4.7:估计参数的后验分布
- 现在通过使用
trace中的params项上的mean方法检索每个估计参数的均值,这只是一个 NumPy 数组。我们传递axis=0参数,因为我们想要矩阵参数估计的每一行的均值。我们在终端打印这些估计参数:
estimated_params = trace["params"].mean(axis=0)
print("Estimated parameters", estimated_params)
# Estimated parameters [ 2.03213559 -7.0957161 5.27045299]
- 最后,我们使用我们估计的参数通过将x值和估计的参数传递给Step 1中定义的
underlying函数来生成我们估计的基础数据。然后我们在同一坐标轴上绘制这个估计的基础数据和真实的基础数据:
estimated = underlying(x_vals, estimated_params)
fig3, ax3 = plt.subplots()
ax3.plot(x_vals, raw_model, "k", label="True model")
ax3.plot(x_vals, estimated, "k--", label="Estimated model")
ax3.set_title("Plot of true and estimated models")
ax3.set_xlabel("x")
ax3.set_ylabel("y")
ax3.legend()
结果图在图 4.8中,这两个模型在这个范围内只有很小的差异:

图 4.8:真实模型和估计模型绘制在同一坐标轴上。估计参数和真实参数之间存在一些小差异
它是如何工作的...
这个示例中代码的有趣部分可以在Model上下文管理器中找到。这个对象跟踪随机变量,编排模拟,并跟踪状态。上下文管理器为我们提供了一个方便的方法,将概率变量与周围代码分开。
首先,我们为代表我们的参数的随机变量的分布提出了先验分布,其中有三个参数。我们提出了正态分布,因为我们知道参数不能偏离值 1 太远。(例如,通过查看我们在步骤 4中生成的图表可以得知。)使用正态分布将使靠近当前值的值具有更高的概率。接下来,我们添加了与观察数据相关的细节,这些细节用于计算用于接受或拒绝状态的接受概率。最后,我们使用sample例程启动采样器。这构建了马尔可夫链并生成了所有步骤数据。
sample例程根据将要模拟的变量的类型设置了采样器。由于正态分布是一个连续变量,sample例程选择了无 U 转弯采样器(NUTS)。这是一个适用于连续变量的合理通用采样器。NUTS 的一个常见替代品是 Metropolis 采样器,在某些情况下比 NUTS 更快但不太可靠。PyMC3 文档建议尽可能使用 NUTS。
一旦采样完成,我们绘制了轨迹的后验分布(由马尔可夫链给出的状态),以查看我们生成的近似的最终形状。我们可以看到,我们的三个随机变量(参数)都大致上以正确的值为中心呈正态分布。
在幕后,PyMC3 使用 Theano 来加速计算。这使得 PyMC3 能够在图形处理单元(GPU)上执行计算,而不是在中央处理单元(CPU)上,从而大大提高了计算速度。Theano 还支持动态生成 C 代码以进一步提高计算速度。
还有更多...
蒙特卡洛方法非常灵活,我们在这里给出的例子是它可以使用的一个特定情况。蒙特卡洛方法应用的一个更典型的基本例子是估计积分的值,通常是蒙特卡洛积分。蒙特卡洛积分的一个非常有趣的案例是估计π的值≈3.1415。让我们简要地看一下它是如何工作的。
首先,我们取单位圆盘,其半径为 1,因此面积为π。我们可以将这个圆盘包含在一个顶点为(1,1),(-1,1),(1,-1),和(-1,-1)的正方形内。由于边长为 2,这个正方形的面积为 4。现在我们可以在这个正方形上均匀地生成随机点。当我们这样做时,任何一个随机点位于给定区域内的概率与该区域的面积成比例。因此,通过将随机生成的点中位于该区域内的比例乘以正方形的总面积,可以估计出一个区域的面积。特别地,我们可以通过简单地将位于圆盘内的随机生成点的数量乘以 4,并除以我们生成的总点数来估计圆盘的面积。
我们可以很容易地用 Python 编写一个执行这个计算的函数,可能是以下内容:
import numpy as np
from numpy.random import default_rng
def estimate_pi(n_points=10000):
rng = default_rng()
points = rng.uniform(-1, 1, size=(2, n_points))
inside = np.less(points[0, :]**2 + points[1, :]**2, 1)
return 4.0*inside.sum() / n_points
仅运行此函数一次将给出对π的合理近似:
estimate_pi() # 3.14224
我们可以通过使用更多的点来提高我们的估计准确性,但我们也可以多次运行这个过程并平均结果。让我们运行这个模拟 100 次并平均结果(我们将使用并发 futures 来并行化这个过程,这样我们就可以运行更多的样本):
from concurrent.futures import ProcessPoolExecutor, as_completed
from statistics import mean
with ProcessPoolExecutor() as pool:
fts = [pool.submit(estimate_pi) for _ in range(100)]
results = list(ft.result() for ft in as_completed(fts))
print(mean(results))
运行此代码一次会打印出估计的π值为 3.1415752,这是对真实值的更好估计。
另请参阅
PyMC3 软件包有许多功能,有许多示例文档(docs.pymc.io/)。还有另一个基于 TensorFlow 的概率编程库(www.tensorflow.org/probability)。
进一步阅读
关于概率和随机过程的一个很好的综合参考书是以下书籍:
- Grimmett, G. and Stirzaker, D. (2009). Probability and random processes. 3rd ed. Oxford: Oxford Univ. Press.
对贝叶斯定理和贝叶斯统计的简单介绍如下:
- Kurt, W. (2019).Bayesian statistics the fun way. San Francisco, CA: No Starch Press, Inc*.*****
第六章:处理树和网络
网络是包含n**odes和节点对之间的edges的对象。它们可以用来表示各种真实世界的情况,如分布和调度。在数学上,网络对于可视化组合问题非常有用,并且构成了一个丰富而迷人的理论。
当然,有几种不同类型的网络。我们将主要处理简单的网络,其中边连接两个不同的节点(因此没有自环),任何两个节点之间最多只有一条边,并且所有边都是双向的。树是一种特殊类型的网络,其中没有循环;也就是说,没有节点列表,其中每个节点都通过一条边连接到下一个节点,并且最后一个节点连接到第一个节点。树在理论上特别简单,因为它们用尽可能少的边连接了许多节点。完全网络是一种网络,其中每个节点都通过一条边连接到其他每个节点。
网络可以是有向的,其中每条边都有源节点和目标节点,或者可以携带额外的属性,如权重。在某些应用中,加权网络特别有用。还有一些网络,我们允许两个给定节点之间有多条边。
在本章中,我们将学习如何创建、操作和分析网络,然后应用网络算法来解决各种问题。
在文献中,特别是在数学文本中,网络更常被称为图。节点有时被称为顶点。我们更倾向于使用术语网络,以避免与图常用于表示函数图的更常见用法混淆。
在本章中,我们将涵盖以下配方:
-
在 Python 中创建网络
-
可视化网络
-
获取网络的基本特征
-
为网络生成邻接矩阵
-
创建有向和加权网络
-
在网络中查找最短路径
-
量化网络中的聚类
-
给网络着色
-
查找最小生成树和支配集
让我们开始吧!
技术要求
在本章中,我们将主要使用 NetworkX 包来处理树和网络。可以使用您喜欢的软件包管理器(如pip)安装此软件包:
python3.8 -m pip install networkx
通常,我们按照官方 NetworkX 文档中建立的约定,将其别名为nx导入:
import networkx as nx
本章的代码可以在 GitHub 存储库的Chapter 05文件夹中找到:github.com/PacktPublishing/Applying-Math-with-Python/tree/master/Chapter%2005。
查看以下视频以查看代码的实际操作:bit.ly/2WJQt4p。
在 Python 中创建网络
为了解决可以表示为网络问题的多种问题,我们首先需要一种在 Python 中创建网络的方法。为此,我们将利用 NetworkX 包及其提供的例程和类来创建、操作和分析网络。
在这个示例中,我们将创建一个代表网络的 Python 对象,并向该对象添加节点和边。
准备工作
正如我们在技术要求部分中提到的,我们需要导入 NetworkX 包,并使用以下import语句将其别名为nx:
import networkx as nx
如何做...
按照以下步骤创建简单图的 Python 表示形式:
- 我们需要创建一个将存储构成图的节点和边的新
Graph对象:
G = nx.Graph()
- 接下来,我们需要使用
add_node方法为网络添加节点:
G.add_node(1)
G.add_node(2)
- 为了避免重复调用此方法,我们可以使用
add_nodes_from方法从可迭代对象(如列表)中添加节点:
G.add_nodes_from([3, 4, 5, 6])
- 接下来,我们需要使用
add_edge方法或add_edges_from方法在我们添加的节点之间添加边,分别向网络添加单个边或边的列表(作为元组):
G.add_edge(1, 2) # edge from 1 to 2
G.add_edges_from([(2, 3), (3, 4), (3, 5), (3, 6), (4, 5), (5, 6)])
- 最后,通过访问
nodes和edges属性,我们可以检索图中当前节点和边的视图:
print(G.nodes)
print(G.edges)
# [1, 2, 3, 4, 5, 6]
# [(1, 2), (2, 3), (3, 4), (3, 5), (3, 6), (4, 5), (5, 6)]
工作原理...
NetworkX 软件包添加了几个类和例程,用于使用 Python 创建、操作和分析网络。Graph类是表示不包含任何给定节点之间多条边的网络的最基本类,其边是无向的(双向的)。
创建一个空的Graph对象后,我们可以使用本示例中描述的方法添加新节点和边。在这个示例中,我们创建了保存整数值的节点。然而,节点可以保存除None之外的任何可散列的 Python 对象。此外,可以通过传递给add_node方法的关键字参数向节点添加关联数据。在使用add_nodes_from方法时,还可以添加属性,方法是提供包含节点对象和属性字典的元组列表。add_nodes_from方法用于批量添加节点,而add_node用于将单个节点附加到现有网络。
网络中的边是包含两个(不同的)节点的元组。在简单网络中,例如基本的Graph类表示的网络中,任何两个给定节点之间最多只能有一条边。边是通过add_edge或add_edges_from方法添加的,分别向网络添加单个边或边的列表。与节点一样,边可以通过属性字典保存任意关联数据。特别是,可以通过在添加边时提供weight属性来添加权重。我们将在创建有向和加权网络中提供有关加权图的更多细节。
nodes和edges属性分别保存构成网络的节点和边。nodes属性返回一个NodesView对象,它是节点及其关联数据的类似字典的接口。类似地,edges属性返回一个EdgeView对象。这可以用于检查单个边及其关联数据。
还有更多...
Graph类表示简单网络,这些网络是指节点之间最多只有一条边相连,并且边是无向的。我们将在创建有向和加权网络中讨论有向网络。有一个单独的类用于表示节点对之间可以有多条边的网络,称为MultiGraph。所有网络类型都允许自环,这在文献中有时不允许在“简单网络”中,在那里简单网络通常指的是没有自环的无向网络。
所有网络类型都提供了各种方法来添加节点和边,以及检查当前节点和边。还有一些方法可以将网络复制到其他类型的网络中,或者提取子网络。NetworkX 软件包中还有几个实用程序例程,用于生成标准网络并将子网络添加到现有网络中。
NetworkX 还提供了各种例程,用于将网络读取和写入不同的文件格式,例如 GraphML、JSON 和 YAML。例如,我们可以使用nx.write_graphml例程将网络写入 GraphML 文件,并使用nx.read_graphml例程进行读取。
可视化网络
分析网络的常见第一步是绘制网络,这可以帮助我们识别网络的一些显著特征。(当然,绘图可能会产生误导,因此我们不应过分依赖它们进行分析。)
在这个示例中,我们将描述如何使用 NetworkX 软件包中的网络绘图工具来可视化网络。
准备工作
对于本示例,我们需要按照技术要求部分中描述的方式导入 NetworkX 包,并且还需要 Matplotlib 包。像往常一样,我们使用以下import语句将pyplot模块导入为plt:
import matplotlib.pyplot as plt
如何做...
以下步骤概述了如何使用 NetworkX 的绘图例程绘制简单的网络对象:
- 首先,我们创建一个简单的示例网络来绘制:
G = nx.Graph()
G.add_nodes_from(range(1, 7))
G.add_edges_from([
(1, 2), (2, 3), (3, 4), (3, 5),
(3, 6), (4, 5), (5, 6)
])
- 接下来,我们为其创建新的 Matplotlib
Figure和Axes对象,准备使用plt的subplots例程绘制网络:
fig, ax = plt.subplots()
- 现在,我们可以创建一个布局,用于在图上放置节点。对于这个图,我们将使用
shell_layout例程使用壳布局:
layout = nx.shell_layout(G)
- 我们可以使用
draw例程在图上绘制网络。由于我们已经创建了 Matplotlib 的Figure和Axes,我们将提供ax关键字参数。我们还将使用with_labels关键字参数为节点添加标签,并使用pos参数指定我们刚刚创建的布局:
nx.draw(G, ax=ax, pos=layout, with_labels=True)
ax.set_title("Simple network drawing")
生成的绘图如下图所示:
图 5.1:使用壳布局排列的简单网络的绘图
工作原理...
draw例程是专门用于绘制网络的专用绘图例程。我们创建的布局指定了每个节点将被放置的坐标。我们使用了壳布局,它将节点排列在同心圆的布局中,这由网络的节点和边确定。默认情况下,draw例程会创建一个随机布局。
draw例程有许多关键字参数,用于自定义绘制网络的外观。在本示例中,我们添加了with_labels关键字参数,根据节点所持有的对象在图中标记节点。节点持有整数,这就是为什么前面的图中的节点被标记为整数。
我们还使用plt.subplots例程单独创建了一组坐标轴。这并不是严格必要的,因为如果没有提供,draw例程将自动创建新的图和坐标轴。
还有更多...
NetworkX 包提供了几种生成布局的例程,类似于我们在本示例中使用的shell_layout例程。布局简单地是一个由节点索引的字典,其元素是节点应该被绘制的位置的x和y坐标。NetworkX 用于创建布局的例程表示了对大多数情况有用的常见布局,但如果需要,您也可以创建自定义布局。不同布局创建例程的完整列表在 NetworkX 文档中提供。还有一些快捷绘图例程,它们将使用特定布局而无需单独创建布局;例如,draw_shell例程将使用与本示例中给出的draw调用等效的壳布局绘制网络。
draw例程接受许多关键字参数来自定义图形的外观。例如,有关键字参数来控制节点的大小、颜色、形状和透明度。我们还可以添加箭头(用于有向边)和/或仅从网络中绘制特定的节点和边。
获取网络的基本特征
网络具有各种基本特征,除了节点和边的数量之外,这些特征对于分析图形是有用的。例如,节点的度是以该节点为起点(或终点)的边的数量。较高的度表明该节点与网络的其余部分连接更好。
在本示例中,我们将学习如何访问基本属性并计算与网络相关的各种基本度量。
准备工作
像往常一样,我们需要将 NetworkX 包导入为nx。我们还需要将 Matplotlib 的pyplot模块导入为plt。
如何做...
按照以下步骤访问网络的各种基本特征:
- 创建一个我们将在本示例中分析的示例网络,如下所示:
G = nx.Graph()
G.add_nodes_from(range(10))
G.add_edges_from([
(0, 1), (1, 2), (2, 3), (2, 4),
(2, 5), (3, 4), (4, 5), (6, 7),
(6, 8), (6, 9), (7, 8), (8, 9)
])
- 接下来,将网络绘制并将节点布置在圆形布局中是一个良好的做法:
fig, ax = plt.subplots()
nx.draw_circular(G, ax=ax, with_labels=True)
ax.set_title("Simple network")
可以在下图中看到生成的图。正如我们所看到的,网络分为两个不同的部分:
图 5.2:以圆形排列绘制的简单网络。在这个网络中可以看到两个不同的组件
- 接下来,我们使用
nx.info例程显示有关网络的一些基本信息:
print(nx.info(G))
# Name:
# Type: Graph
# Number of nodes: 10
# Number of edges: 12
# Average degree: 2.4000
- 现在,我们使用
Graph对象的degree属性来检索特定节点的度:
for i in [0, 2, 7]:
degree = G.degree[i]
print(f"Degree of {i}: {degree}")
# Degree of 0: 1
# Degree of 2: 4
# Degree of 7: 2
- 我们可以使用
connected_components例程获取网络的连接组件,它返回一个我们可以转换为列表的生成器:
components = list(nx.connected_components(G))
print(components)
# [{0, 1, 2, 3, 4, 5}, {8, 9, 6, 7}]
- 我们使用
density例程计算网络的密度,它返回一个介于 0 和 1 之间的浮点数。这代表了满足节点的边与节点可能的总边数之间的比例:
density = nx.density(G)
print("Density", density)
# Density 0.26666666666666666
- 最后,我们可以使用
check_planarity例程确定网络是否平面——意味着没有两条边需要绘制交叉——:
is_planar, _ = nx.check_planarity(G)
print("Is planar", is_planar)
# Is planar True
工作原理...
info例程生成网络的一个小总结,包括网络的类型(在本示例中是简单的Graph类型),节点和边的数量,以及网络中节点的平均度。可以使用degree属性访问网络中节点的实际度,该属性提供类似字典的接口来查找每个节点的度。
如果一组节点中的每个节点都通过边或一系列边连接到其他节点,则称为连接的。网络的连接组件是连接的最大节点集。任何两个不同的连接组件显然是不相交的。每个网络可以分解为一个或多个连接的组件。我们在本示例中定义的网络有两个连接的组件,{0, 1, 2, 3, 4, 5}和{8, 9, 6, 7}。这些在前面的图中清晰可见,第一个连接的组件绘制在第二个连接的组件上方。在这个图中,我们可以沿着网络的边从一个组件中的任何节点到达另一个组件中的任何节点;例如,从 0 到 5。
网络的密度衡量了网络中边的数量与网络中节点数量给出的总可能边数之间的比率。完全网络的密度为 1,但一般情况下,密度会小于 1。
如果网络可以在平面表面上绘制而不交叉,则网络是平面的。非平面网络的最简单示例是具有五个节点的完全网络。至多具有四个节点的完全网络是平面的。通过在纸上绘制这些网络的方式进行一些实验,将会发现一个不包含交叉边的图。此外,任何包含至少五个节点的完全图的网络都不是平面的。平面网络在理论上很重要,因为它们相对简单,但在应用中出现的网络中它们较少。
还有更多...
除了网络类中的方法之外,NetworkX 包中还有许多其他例程可用于访问网络中节点和边的属性。例如,nx.get_node_attributes从网络中的每个节点获取一个命名属性。
生成网络的邻接矩阵
在图的分析中,一个强大的工具是邻接矩阵,它的条目a[ij] = 1,如果有一条边从节点i到节点j,否则为 0。对于大多数网络,邻接矩阵将是稀疏的(大多数条目为 0)。对于非定向的网络,矩阵也将是对称的(a[ij] =a[ji])。还有许多其他可以与网络相关联的矩阵。我们将在本教程的更多内容...*部分简要讨论这些。**
**在这个教程中,我们将生成网络的邻接矩阵,并学习如何从这个矩阵中获得网络的一些基本属性。
准备工作
在这个教程中,我们将需要将 NetworkX 包导入为nx,将 NumPy 模块导入为np。
如何做...
以下步骤概述了如何为网络生成邻接矩阵,并从这个矩阵中推导出网络的一些简单属性:
- 首先,我们将生成一个网络,然后在整个教程中使用它。我们将生成一个具有五个节点和五条边的随机网络,同时使用一个种子以便重现:
G = nx.dense_gnm_random_graph(5, 5, seed=12345)
- 要生成邻接矩阵,我们使用 NetworkX 的
adjacency_matrix例程。这默认返回一个稀疏矩阵,因此我们还将使用todense方法将其转换为完整的 NumPy 数组以进行演示:
matrix = nx.adjacency_matrix(G).todense()
print(matrix)
# [[0 0 1 0 0]
# [0 0 1 1 0]
# [1 1 0 0 1]
# [0 1 0 0 1]
# [0 0 1 1 0]]
- 对邻接矩阵进行n次幂运算可以得到从一个节点到另一个节点的长度为n的路径数:
paths_len_4 = np.linalg.matrix_power(matrix, 4)
print(paths_len_4)
# [[ 3 5 0 0 5]
# [ 5 9 0 0 9]
# [ 0 0 13 10 0]
# [ 0 0 10 8 0]
# [ 5 9 0 0 9]]
它是如何工作的...
dense_gnm_random_graph例程生成一个(密集的)随机网络,从所有具有n个节点和m条边的网络家族中均匀选择。在这个教程中,n=5,m=5。密集前缀表示这个例程使用的算法应该比对于节点数相对较大的密集网络的替代gnm_random_graph更快。
网络的邻接矩阵很容易生成,特别是在图相对较小的情况下,尤其是在稀疏形式下。对于更大的网络,这可能是一个昂贵的操作,因此可能不太实际,特别是如果你将其转换为完整矩阵,就像我们在这个教程中看到的那样。一般来说,你不需要这样做,因为我们可以简单地使用adjacency_matrix例程生成的稀疏矩阵和 SciPy sparse模块中的稀疏线性代数工具。
矩阵的幂提供了关于给定长度的路径数的信息。通过追踪矩阵乘法的定义,这很容易看出。请记住,当两个给定节点之间存在边(长度为 1 的路径)时,邻接矩阵的条目为 1。
更多内容...
网络的邻接矩阵的特征值提供了关于网络结构的一些额外信息,例如网络色数的上下界。(有关网络着色的更多信息,请参见着色网络教程。)有一个单独的例程用于计算邻接矩阵的特征值。例如,我们可以使用adjacency_spectrum例程生成网络的邻接矩阵的特征值。与网络相关的矩阵的特征值的方法通常被称为谱方法。
与网络相关的还有其他矩阵,如关联矩阵和拉普拉斯矩阵。网络的关联矩阵是一个M × N矩阵,其中M是节点数,N是边数。如果节点i出现在边j中,则该矩阵的第i-j个条目为 1,否则为 0。网络的拉普拉斯矩阵被定义为L = D - A矩阵,其中D是包含网络中节点度数的对角线矩阵,A是网络的邻接矩阵。这些矩阵对于分析网络很有用。
创建定向和加权网络
简单的网络,比如前面的教程中描述的那些,用于描述边的方向不重要且边的权重相等的网络是有用的。实际上,大多数网络都携带额外的信息,比如权重或方向。
在这个教程中,我们将创建一个有向且带权重的网络,并探索这种网络的一些基本属性。
准备工作
对于这个教程,我们将需要 NetworkX 包,以通常的方式导入为nx,导入为plt的 Matplotlibpyplot模块,以及导入为np的 NumPy 包。
如何操作...
以下步骤概述了如何创建一个带权重的有向网络,以及如何探索我们在前面教程中讨论的一些属性和技术:
- 为了创建一个有向网络,我们使用 NetworkX 中的
DiGraph类,而不是简单的Graph类:
G = nx.DiGraph()
- 像往常一样,我们使用
add_node或add_nodes_from方法向网络添加节点:
G.add_nodes_from(range(5))
- 要添加加权边,我们可以使用
add_edge方法并提供weight关键字参数,或者使用add_weighted_edges_from方法:
G.add_edge(0, 1, weight=1.0)
G.add_weighted_edges_from([
(1, 2, 0.5), (1, 3, 2.0), (2, 3, 0.3), (3, 2, 0.3),
(2, 4, 1.2), (3, 4, 0.8)
])
- 接下来,我们用箭头绘制网络,以指示每条边的方向。我们还为这个图提供了自己的位置:
fig, ax = plt.subplots()
pos = {0: (-1, 0), 1: (0, 0), 2: (1, 1), 3: (1, -1), 4: (2, 0)}
nx.draw(G, ax=ax, pos=pos, with_labels=True)
ax.set_title("Weighted, directed network")
可以在下图中看到得到的图:
图 5.3:一个带权重的有向网络
- 有向矩阵的邻接矩阵的创建方式与简单网络相同,但是得到的矩阵不会是对称的:
adj_mat = nx.adjacency_matrix(G).todense()
print(adj_mat)
# [[0\. 1\. 0\. 0\. 0\. ]
# [0\. 0\. 0.5 2\. 0\. ]
# [0\. 0\. 0\. 0.3 1.2]
# [0\. 0\. 0.3 0\. 0.8]
# [0\. 0\. 0\. 0\. 0\. ]]
工作原理...
DiGraph类表示一个有向网络,其中在添加边时节点的顺序很重要。在这个教程中,我们添加了两条连接节点 2 和 3 的边,每个方向一条。在简单网络(Graph类)中,添加第二条边不会增加额外的边。然而,在有向网络(DiGraph类)中,添加边时给出的节点的顺序决定了方向。
关于加权边,除了添加附加到边的weight属性之外,没有什么特别之处。(可以通过关键字参数向网络中的边或节点附加任意数据。)add_weighted_edges_from方法只是将相应的权重值(元组中的第三个值)添加到相关的边上。权重可以添加到任何网络中的任何边,而不仅仅是本教程中显示的有向网络。
在绘制有向网络时,draw例程会自动向边添加箭头。可以通过传递arrows=False关键字参数来关闭这种行为。有向或带权重网络的邻接矩阵也与简单网络不同。在有向网络中,矩阵通常不对称,因为边可能只存在一个方向而不是另一个方向。对于带权重的网络,条目可以不同于 1 或 0,而是对应边的权重。
还有更多...
带权重的网络出现在许多应用中,比如用距离或速度描述交通网络。您还可以使用网络来通过为网络中的边提供“容量”(作为权重或其他属性)来研究网络中的流动。NetworkX 有几个工具用于分析网络中的流动,比如通过nx.maximum_flow例程找到网络中的最大流量。
有向网络为网络添加了方向信息。许多现实世界的应用产生了具有单向边的网络,比如工业流程或供应链网络中的网络。这种额外的方向信息对许多处理网络的算法都有影响,我们将在本章中看到。
在网络中查找最短路径
网络出现的一个常见问题是在网络中找到两个节点之间的最短路径或者更准确地说是最高奖励的路径。例如,这可能是两个城市之间的最短距离,其中节点代表城市,边代表连接城市对的道路。在这种情况下,边的权重将是它们的长度。
在这个示例中,我们将在一个带权重的网络中找到两个节点之间的最短路径。
准备工作
对于这个示例,我们将需要导入 NetworkX 包,通常使用nx作为名称,导入 Matplotlib 的pyplot模块作为plt,以及从 NumPy 导入一个随机数生成器对象:
from numpy.random import default_rng
rng = default_rng(12345) # seed for reproducibility
如何做...
按照以下步骤在网络中找到两个节点之间的最短路径:
- 首先,我们将使用
gnm_random_graph和一个seed创建一个随机网络,用于这个演示:
G = nx.gnm_random_graph(10, 17, seed=12345)
- 接下来,我们将以圆形排列的方式绘制网络,以查看节点之间的连接方式:
fig, ax = plt.subplots()
nx.draw_circular(G, ax=ax, with_labels=True)
ax.set_title("Random network for shortest path finding")
生成的图可以在下面的图片中看到。在这里,我们可以看到从节点 7 到节点 9 没有直接的边:
图 5.4:一个随机生成的具有 10 个节点和 17 条边的网络
- 现在,我们需要给每条边添加权重,以便在最短路径方面有些路线更可取:
for u, v in G.edges:
G.edges[u, v]["weight"] = rng.integers(5, 15)
- 接下来,我们将使用
nx.shortest_path例程计算从节点 7 到节点 9 的最短路径:
path = nx.shortest_path(G, 7, 9, weight="weight")
print(path)
# [7, 5, 2, 9]
- 我们可以使用
nx.shortest_path_来找到这条最短路径的长度
长度routine:
length = nx.shortest_path_length(G, 7, 9, weight="weight")
print("Length", length)
# Length 32
它是如何工作的...
shortest_path例程计算每对节点之间的最短路径。或者,当提供源节点和目标节点时,就像我们在这个示例中所做的那样,它计算两个指定节点之间的最短路径。我们提供了可选的weight关键字参数,这使算法根据边的“权重”属性找到最短路径。这个参数改变了“最短”的含义,默认是“最少的边”。
找到两个节点之间最短路径的默认算法是 Dijkstra 算法,这是计算机科学和数学课程的基础。它是一个很好的通用算法,但效率并不是特别高。其他寻路算法包括 A算法。使用 A算法并提供额外的启发式信息来指导节点选择可以获得更高的效率。
还有更多...
有许多算法可以在网络中找到两个节点之间的最短路径。还有一些变体用于找到最大加权路径。
关于在网络中找到路径的问题还有一些相关问题,比如旅行推销员问题和路线检查问题。在旅行推销员问题中,我们找到一个循环(从同一个节点开始和结束的路径),访问网络中的每个节点,总权重最小(或最大)。在路线检查问题中,我们寻找通过网络中每条边并返回到起点的最短循环(按权重计算)。已知旅行推销员问题是 NP 难题,但路线检查问题可以在多项式时间内解决。
图论中一个著名的问题是 Königsberg 的桥,它要求在网络中找到一条路径,该路径恰好穿过网络中的每条边一次。事实证明,正如欧拉证明的那样,在 Königsberg 桥问题中找到这样的路径是不可能的。穿过每条边恰好一次的路径称为欧拉回路。如果一个网络允许欧拉回路,则称为欧拉。事实上,当且仅当每个节点的度都是偶数时,网络才是欧拉的。Königsberg 桥问题的网络表示如下图所示。这里的边代表河流上的不同桥梁,而节点代表不同的陆地。我们可以看到所有四个节点的度都是奇数,这意味着不能有一条穿过每条边恰好一次的路径:
图 5.5:表示 Königsberg 桥问题的网络
边代表节点之间的桥梁。
在网络中量化聚类
与网络相关的各种量度可以衡量网络的特性。例如,节点的聚类系数衡量了附近节点之间的互连性(这里,附近意味着通过边连接)。实际上,它衡量了邻近节点接近形成一个完整网络或团的程度。
节点的聚类系数衡量了与该节点相邻的节点之间通过边连接的比例;也就是说,两个相邻的节点与给定节点形成一个三角形。我们计算三角形的数量,并将其除以可能形成的总三角形数量,考虑到节点的度。从数值上看,简单无权重网络中节点u的聚类系数由以下方程给出:

这里,T[u]是u处的三角形数,分母是u处可能的三角形总数。如果u的度(u的边数)为 0 或 1,则将c[u]设为 0。
在这个示例中,我们将学习如何计算网络中节点的聚类系数。
准备工作
对于这个示例,我们需要导入 NetworkX 包作为nx,并导入 Matplotlib pyplot模块作为plt。
如何做...
以下步骤向您展示了如何计算网络中节点的聚类系数:
- 首先,我们需要创建一个样本网络来使用:
G = nx.Graph()
complete_part = nx.complete_graph(4)
cycle_part = nx.cycle_graph(range(4, 9))
G.update(complete_part)
G.update(cycle_part)
G.add_edges_from([(0, 8), (3, 4)])
- 接下来,我们将绘制网络,以便比较我们将要计算的聚类系数。这将使我们能够看到这些节点在网络中的出现方式:
fig, ax = plt.subplots()
nx.draw_circular(G, ax=ax, with_labels=True)
ax.set_title("Network with different clustering behavior")
结果图可以在下图中看到:
图 5.6:用于测试聚类的示例网络
- 现在,我们可以使用
nx.clustering例程计算网络中节点的聚类系数:
cluster_coeffs = nx.clustering(G)
nx.clustering例程的输出是网络中节点的字典。因此,我们可以按如下方式打印一些选定的节点:
for i in [0, 2, 6]:
print(f"Node {i}, clustering {cluster_coeffs[i]}")
# Node 0, clustering 0.5
# Node 2, clustering 1.0
# Node 6, clustering 0
- 可以使用
nx.average_clustering例程计算网络中所有节点的平均聚类系数:
av_clustering = nx.average_clustering(G)
print(av_clustering)
# 0.3333333333333333
它是如何工作的...
节点的聚类系数衡量了该节点的邻域接近形成一个完整网络(所有节点彼此连接)。在这个示例中,我们可以看到我们有三个不同的计算值:0 的聚类系数为 0.5,2 的聚类系数为 1.0,6 的聚类系数为 0。这意味着连接到节点 2 的节点形成了一个完整的网络,这是因为我们设计了我们的网络。 (节点 0-4 按设计形成一个完整的网络。)节点 6 的邻域离完整很远,因为它的邻居之间没有相互连接的边。
平均聚类值是网络中所有节点的聚类系数的简单平均值。它与全局聚类系数(使用 NetworkX 中的nx.transitivity例程计算)不完全相同,但它确实让我们了解整个网络接近完全网络的程度。全局聚类系数衡量了三角形的数量与三元组的数量之比 - 由至少两条边连接的三个节点组成 - 在整个网络上。
平均聚类之间的差异非常微妙。全局聚类系数衡量了整个网络的聚类程度,但平均聚类系数衡量了网络在局部平均聚类的程度。这种差异最好在风车网络中看到,它由一个单一节点围绕着偶数个节点的圆圈组成。所有节点都连接到中心,但圆圈上的节点只以交替模式连接。外部节点的局部聚类系数为 1,而中心节点的局部聚类系数为 1/(2N-1),其中N表示连接到中心节点的三角形的数量。然而,全局聚类系数为 3/(2N-1)。
还有更多...
聚类系数与网络中的团相关。团是一个完全的子网络(所有节点都通过一条边连接)。网络理论中的一个重要问题是找到网络中的最大团,这在一般情况下是一个非常困难的问题(这里,最大意味着“不能再扩大”)。
着色网络
网络在调度问题中也很有用,您需要将活动安排到不同的时间段中,以确保没有冲突。例如,我们可以使用网络来安排课程,以确保选择不同选项的学生不必同时上两节课。在这种情况下,节点将代表不同的课程,边将指示有学生同时上两门课。我们用来解决这类问题的过程称为网络着色。这个过程涉及为网络中的节点分配尽可能少的颜色,以便相邻的两个节点没有相同的颜色。
在本教程中,我们将学习如何着色网络以解决简单的调度问题。
准备工作
对于本教程,我们需要导入 NetworkX 包为nx,导入 Matplotlib 的pyplot模块为plt。
如何做...
按照以下步骤解决网络着色问题:
- 首先,我们将创建一个示例网络,用于本教程:
G = nx.complete_graph(3)
G.add_nodes_from(range(3, 7))
G.add_edges_from([
(2, 3), (2, 4), (2, 6), (0, 3), (0, 6), (1, 6),
(1, 5), (2, 5), (4, 5)
])
- 接下来,我们将绘制网络,以便在生成着色时能够理解。为此,我们将使用
draw_circular例程:
fig, ax = plt.subplots()
nx.draw_circular(G, ax=ax, with_labels=True)
ax.set_title("Scheduling network")
生成的绘图如下图所示:
图 5.7:简单调度问题的示例网络
- 我们将使用
nx.greedy_color例程生成着色:
coloring = nx.greedy_color(G)
print("Coloring", coloring)
# Coloring {2: 0, 0: 1, 1: 2, 5: 1, 6: 3, 3: 2, 4: 2}
- 要查看此着色中使用的实际颜色,我们将从
coloring字典生成一组值:
different_colors = set(coloring.values())
print("Different colors", different_colors)
# Different colors {0, 1, 2, 3}
它是如何工作的...
nx.greedy_color例程使用多种可能的策略对网络进行着色。默认情况下,它按照从最大到最小的顺序按度数工作。在我们的情况下,它首先为度为 6 的节点 2 分配颜色 0,然后为度为 4 的节点 0 分配颜色 1,依此类推。对于这个序列中的每个节点,选择第一个可用的颜色。这不一定是着色网络的最有效算法。
显然,通过为每个节点分配不同的颜色,可以给任何网络上色,但在大多数情况下,需要更少的颜色。在本教程中,网络有七个节点,但只需要四种颜色。所需的最小颜色数称为网络的色数。
还有更多...
网络的着色问题有几种变体。其中一种变体是列表着色问题,在这个问题中,我们寻找一个网络的着色,其中每个节点从可能颜色的预定义列表中选择一个颜色。这个问题显然比一般的着色问题更困难。
一般着色问题有一些令人惊讶的结果。例如,每个平面网络最多可以用四种不同的颜色着色。这是图论中著名的四色定理,由 Appel 和 Haken 在 1977 年证明。
找到最小生成树和支配集
网络在各种问题中都有应用。两个明显的领域是通信和分配。例如,我们可能希望找到一种在覆盖从特定点到许多城市(节点)的道路网络中最小距离的分配方式。对于这样的问题,我们需要查看最小生成树和支配集。
在这个教程中,我们将在网络中找到一个最小生成树和一个支配集。
准备工作
对于这个教程,我们需要将 NetworkX 包导入为nx,将 Matplotlib 的pyplot模块导入为plt。
如何做...
按照以下步骤找到网络的最小生成树和支配集:
- 首先,我们将创建一个样本网络进行分析:
G = nx.gnm_random_graph(15, 22, seed=12345)
- 接下来,和往常一样,在进行任何分析之前,我们将绘制网络:
fig, ax = plt.subplots()
pos = nx.circular_layout(G)
nx.draw(G, pos=pos, ax=ax, with_labels=True)
ax.set_title("Network with minimum spanning tree overlaid")
- 最小生成树可以使用
nx.minimum_来计算
生成树`例程:
min_span_tree = nx.minimum_spanning_tree(G)
print(list(min_span_tree.edges))
# [(0, 13), (0, 7), (0, 5), (1, 13), (1, 11),
# (2, 5), (2, 9), (2, 8), (2, 3), (2, 12),
# (3, 4), (4, 6), (5, 14), (8, 10)]
- 接下来,我们将在绘图上叠加最小生成树的边:
nx.draw_networkx_edges(min_span_tree, pos=pos, ax=ax, width=1.5,
edge_color="r")
- 最后,我们将使用
nx.dominating_set例程为网络找到一个支配集-一个集合,其中网络中的每个节点都与支配集中的至少一个节点相邻:
dominating_set = nx.dominating_set(G)
print("Dominating set", dominating_set)
# Dominating set {0, 1, 2, 4, 10, 14}
可以在下图中看到叠加了最小生成树的网络的绘图:
图 5.8:叠加了最小生成树的网络绘制
它是如何工作的...
网络的生成树是网络中包含所有节点的树。最小生成树是包含尽可能少的边的生成树,或者说具有最低的总权重。最小生成树对于网络上的分配问题非常有用。一种简单的找到最小生成树的算法是简单地选择边(如果网络是加权的,则首先选择最小权重的边),以便不会创建循环,直到不再可能为止。
网络的支配集是一个顶点集,其中网络中的每个节点都与支配集中的至少一个节点相邻。支配集在通信网络中有应用。我们经常有兴趣找到最小的支配集,但这在计算上是困难的。事实上,测试是否存在一个比给定大小更小的支配集是 NP 完全的。然而,对于某些类别的图形,有一些有效的算法可以找到最小的支配集。非正式地说,问题在于一旦你确定了一个最小大小支配集的候选者,你必须验证是否存在比它更小的支配集。如果你事先不知道所有可能的支配集,这显然是非常困难的。
进一步阅读
有几本经典的图论著作,包括 Bollobás 和 Diestel 的书:
-
- Diestel, R., 2010. Graph Theory. 3rd ed. Berlin: Springer.*
-
- Bollobás, B., 2010. Modern Graph Theory. New York, NY: Springer.***
第七章:处理数据和统计
对于需要分析数据的人来说,Python 最吸引人的特点之一是数据操作和分析软件包的庞大生态系统,以及与 Python 合作的数据科学家活跃的社区。Python 使用起来很容易,同时还提供非常强大、快速的库,使得即使是相对新手的程序员也能够快速、轻松地处理大量数据。许多数据科学软件包和工具的核心是 pandas 库。Pandas 提供了两种数据容器类型,它们建立在 NumPy 数组的基础上,并且对于标签(除了简单的整数)有很好的支持。它们还使得处理大量数据变得非常容易。
统计学是使用数学—特别是概率—理论对数据进行系统研究。统计学有两个方面。第一个是找到描述一组数据的数值,包括数据的中心(均值或中位数)和离散程度(标准差或方差)等特征。统计学的第二个方面是推断,使用相对较小的样本数据集来描述一个更大的数据集(总体)。
在本章中,我们将看到如何利用 Python 和 pandas 处理大量数据并进行统计测试。
本章包含以下示例:
-
创建 Series 和 DataFrame 对象
-
从 DataFrame 中加载和存储数据
-
在数据框中操作数据
-
从 DataFrame 绘制数据
-
从 DataFrame 获取描述性统计信息
-
使用抽样了解总体
-
使用 t 检验来测试假设
-
使用方差分析进行假设检验
-
对非参数数据进行假设检验
-
使用 Bokeh 创建交互式图表
技术要求
在本章中,我们将主要使用 pandas 库进行数据操作,该库提供了类似于 R 的数据结构,如 Series 和 DataFrame 对象,用于存储、组织和操作数据。我们还将在本章的最后一个示例中使用 Bokeh 数据可视化库。这些库可以使用您喜欢的软件包管理器(如 pip)进行安装:
python3.8 -m pip install pandas bokeh
我们还将使用 NumPy 和 SciPy 软件包。
本章的代码可以在 GitHub 代码库的 Chapter 06 文件夹中找到:github.com/PacktPublishing/Applying-Math-with-Python/tree/master/Chapter%2006。
查看以下视频以查看代码示例:bit.ly/2OQs6NX。
创建 Series 和 DataFrame 对象
Python 中的大多数数据处理都是使用 pandas 库完成的,它构建在 NumPy 的基础上,提供了类似于 R 的数据结构来保存数据。这些结构允许使用字符串或其他 Python 对象而不仅仅是整数来轻松索引行和列。一旦数据加载到 pandas 的 DataFrame 或 Series 中,就可以轻松地进行操作,就像在电子表格中一样。这使得 Python 结合 pandas 成为处理和分析数据的强大工具。
在本示例中,我们将看到如何创建新的 pandas Series 和 DataFrame 对象,并访问 Series 或 DataFrame 中的项目。
准备工作
对于这个示例,我们将使用以下命令导入 pandas 库:
import pandas as pd
NumPy 软件包是 np。我们还可以从 NumPy 创建一个(种子)随机数生成器,如下所示:
from numpy.random import default_rng
rng = default_rng(12345)
如何做...
以下步骤概述了如何创建包含数据的 Series 和 DataFrame 对象:
- 首先,创建我们将存储在
Series和DataFrame对象中的随机数据:
diff_data = rng.normal(0, 1, size=100)
cumulative = np.add.accumulate(diff_data)
- 接下来,创建一个包含
diff_data的Series对象。我们将打印Series以查看数据的视图:
data_series = pd.Series(diff_data)
print(data_series)
- 现在,创建一个具有两列的
DataFrame对象:
data_frame = pd.DataFrame({
"diffs": data_series,
"cumulative": cumulative
})
- 打印
DataFrame对象以查看其包含的数据:
print(data_frame)
它是如何工作的...
pandas 包提供了Series和DataFrame类,它们反映了它们的 R 对应物的功能和能力。Series用于存储一维数据,如时间序列数据,DataFrame用于存储多维数据;您可以将DataFrame对象视为"电子表格"。
Series与简单的 NumPy ndarray的区别在于Series索引其项的方式。NumPy 数组由整数索引,这也是Series对象的默认索引。但是,Series可以由任何可散列的 Python 对象索引,包括字符串和datetime对象。这使得Series对于存储时间序列数据非常有用。Series可以以多种方式创建。在这个示例中,我们使用了 NumPy 数组,但是任何 Python 可迭代对象,如列表,都可以替代。
DataFrame 对象中的每一列都是包含行的系列,就像传统数据库或电子表格中一样。在这个示例中,当通过字典的键构造 DataFrame 对象时,列被赋予标签。
DataFrame和Series对象在打印时会创建它们包含的数据的摘要。这包括列名、行数和列数,以及框架(系列)的前五行和最后五行。这对于快速获取对象和包含的数据的概述非常有用。
还有更多...
Series对象的单个行(记录)可以使用通常的索引符号通过提供相应的索引来访问。我们还可以使用特殊的iloc属性对象按其数值位置访问行。这允许我们按照它们的数值(整数)索引访问行,就像 Python 列表或 NumPy 数组一样。
可以使用通常的索引符号访问DataFrame对象中的列,提供列的名称。这样做的结果是一个包含所选列数据的Series对象。DataFrames 还提供了两个属性,可以用来访问数据。loc属性提供对个别行的访问,无论这个对象是什么。iloc属性提供按数值索引访问行,就像Series对象一样。
您可以向loc(或只使用对象的索引符号)提供选择条件来选择数据。这包括单个标签、标签列表、标签切片或布尔数组(适当大小的数组)。iloc选择方法接受类似的条件。
除了我们在这里描述的简单方法之外,还有其他从 Series 或 DataFrame 对象中选择数据的方法。例如,我们可以使用at属性来访问对象中指定行(和列)的单个值。
另请参阅
pandas 文档包含了创建和索引 DataFrame 或 Series 对象的不同方法的详细描述,网址为pandas.pydata.org/docs/user_guide/indexing.html。
从 DataFrame 加载和存储数据
在 Python 会话中从原始数据创建 DataFrame 对象是相当不寻常的。实际上,数据通常来自外部来源,如现有的电子表格或 CSV 文件、数据库或 API 端点。因此,pandas 提供了许多用于加载和存储数据到文件的实用程序。pandas 支持从 CSV、Excel(xls 或 xlsx)、JSON、SQL、Parquet 和 Google BigQuery 加载和存储数据。这使得将数据导入 pandas 然后使用 Python 操纵和分析这些数据变得非常容易。
在这个示例中,我们将看到如何将数据加载和存储到 CSV 文件中。加载和存储数据到其他文件格式的指令将类似。
做好准备
对于这个示例,我们需要导入 pandas 包作为pd别名和 NumPy 库作为np,并使用以下命令创建一个默认的随机数生成器:
from numpy.random import default_rng
rng = default_rng(12345) # seed for example
如何做...
按照以下步骤将数据存储到文件,然后将数据加载回 Python:
- 首先,我们将使用随机数据创建一个样本
DataFrame对象。然后打印这个DataFrame对象,以便我们可以将其与稍后将要读取的数据进行比较:
diffs = rng.normal(0, 1, size=100)
cumulative = np.add.accumulate(diffs)
data_frame = pd.DataFrame({
"diffs": diffs,
"cumulative": cumulative
})
print(data_frame)
- 我们将使用
DataFrame对象中的数据将数据存储到sample.csv文件中,使用DataFrame对象上的to_csv方法。我们将使用index=False关键字参数,以便索引不存储在 CSV 文件中:
data_frame.to_csv("sample.csv", index=False)
- 现在,我们可以使用 pandas 中的
read_csv例程将sample.csv文件读入一个新的DataFrame对象。我们将打印这个对象以显示结果:
df = pd.read_csv("sample.csv", index_col=False)
print(df)
它是如何工作的...
这个示例的核心是 pandas 中的read_csv例程。这个例程以路径或类文件对象作为参数,并将文件的内容读取为 CSV 数据。我们可以使用sep关键字参数自定义分隔符,默认为逗号(,)。还有一些选项可以自定义列标题和自定义每列的类型。
DataFrame或Series中的to_csv方法将内容存储到 CSV 文件中。我们在这里使用了index关键字参数,以便索引不会打印到文件中。这意味着 pandas 将从 CSV 文件中的行号推断索引。如果数据是由整数索引的,这种行为是可取的,但如果数据是由时间或日期索引的,情况可能不同。我们还可以使用这个关键字参数来指定 CSV 文件中的哪一列是索引列。
另请参阅
请参阅 pandas 文档,了解支持的文件格式列表pandas.pydata.org/docs/reference/io.html。
在 DataFrames 中操作数据
一旦我们在DataFrame中有了数据,我们经常需要对数据应用一些简单的转换或过滤,然后才能进行任何分析。例如,这可能包括过滤缺少数据的行或对单独的列应用函数。
在这个示例中,我们将看到如何对DataFrame对象执行一些基本操作,以准备数据进行分析。
准备工作
对于这个示例,我们需要导入pandas包并使用pd别名,导入 NumPy 包并使用np别名,并使用以下命令从 NumPy 创建一个默认随机数生成器对象:
from numpy.random import default_rng
rng = default_rng(12345)
如何做...
以下步骤说明了如何对 pandas 的DataFrame执行一些基本的过滤和操作:
- 我们将首先使用随机数据创建一个样本
DataFrame:
three = rng.uniform(-0.2, 1.0, size=100)
three[three < 0] = np.nan
data_frame = pd.DataFrame({
"one": rng.random(size=100),
"two": np.add.accumulate(rng.normal(0, 1, size=100)),
"three": three
})
- 接下来,我们必须从现有列生成一个新列。这个新列将在相应的列
"one"的条目大于0.5时保持True,否则为False:
data_frame["four"] = data_frame["one"] > 0.5
- 现在,我们必须创建一个新的函数,我们将应用到我们的
DataFrame上。这个函数将把行"two"的值乘以行"one"和0.5的最大值(有更简洁的编写这个函数的方法):
def transform_function(row):
if row["four"]:
return 0.5*row["two"]
return row["one"]*row["two"]
- 现在,我们将对 DataFrame 中的每一行应用先前定义的函数以生成一个新列。我们还将打印更新后的 DataFrame,以便稍后进行比较:
data_frame["five"] = data_frame.apply(transform_function, axis=1)
print(data_frame)
- 最后,我们必须过滤掉 DataFrame 中包含NaN值的行。我们将打印结果 DataFrame:
df = data_frame.dropna()
print(df)
它是如何工作的...
可以通过简单地将它们分配给新的列索引来向现有的DataFrame添加新列。但是,在这里需要注意一些问题。在某些情况下,pandas 会创建一个“视图”到DataFrame对象,而不是复制,这种情况下,分配给新列可能不会产生预期的效果。这在 pandas 文档中有所讨论(pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy)。
Pandas Series对象(DataFrame中的列)支持丰富的比较运算符,如等于、小于或大于(在本示例中,我们使用了大于运算符)。这些比较运算符返回一个包含布尔值的Series,对应于比较为真和假的位置。这可以用来索引原始Series,并只获取比较为真的行。在本示例中,我们简单地将这个布尔值的Series添加到原始的DataFrame中。
apply方法接受一个函数(或其他可调用函数)并将其应用于 DataFrame 中的每一列。在本示例中,我们希望将函数应用于每一行,因此我们使用了axis=1关键字参数将函数应用于 DataFrame 中的每一行。无论哪种情况,函数都提供了一个由行(列)索引的Series对象。我们还将函数应用于每一行,返回使用每一行数据计算的值。实际上,如果 DataFrame 包含大量行,这种应用会相当慢。如果可能的话,应该整体操作列,使用设计用于操作 NumPy 数组的函数,以获得更好的效率。这对于在 DataFrame 的列中执行简单的算术运算尤其如此。就像 NumPy 数组一样,Series对象实现了标准的算术运算,这可以极大地提高大型 DataFrame 的操作时间。
在本示例的最后一步中,我们使用了dropna方法,快速选择了不包含 NaN 值的 DataFrame 行。Pandas 使用 NaN 来表示 DataFrame 中的缺失数据,因此这个方法选择了不包含缺失值的行。这个方法返回原始DataFrame对象的视图,但也可以通过传递inplace=True关键字参数来修改原始 DataFrame。在本示例中,这大致相当于使用索引表示法,使用包含布尔值的索引数组来选择行。
当直接修改原始数据时,应该始终谨慎,因为可能无法返回到原始数据以重复分析。如果确实需要直接修改数据,应确保数据已备份,或者修改不会删除以后可能需要的数据。
还有更多...
大多数 pandas 例程以明智的方式处理缺失数据(NaN)。然而,如果确实需要在 DataFrame 中删除或替换缺失数据,则有几种方法可以做到这一点。在本示例中,我们使用了dropna方法,简单地删除了缺失数据的行。我们也可以使用fillna方法填充所有缺失值,或者使用interpolate方法插值缺失值使用周围的值。
更一般地,我们可以使用replace方法来用其他值替换特定(非 NaN)值。这种方法可以处理数字值或字符串值,包括与正则表达式匹配。
DataFrame类有许多有用的方法。我们在这里只涵盖了非常基本的方法,但还有两个方法我们也应该提到。这些是agg方法和merge方法。
agg方法在 DataFrame 的给定轴上聚合一个或多个操作的结果。这允许我们通过应用聚合函数快速为每列(或行)生成摘要信息。输出是一个 DataFrame,其中应用的函数的名称作为行,所选轴的标签(例如列标签)作为列。
merge方法在两个 DataFrame 上执行类似 SQL 的连接。这将产生一个包含连接结果的新 DataFrame。可以传递各种参数给how关键字参数,以指定要执行的合并类型,默认为inner。应该将要执行连接的列或索引的名称传递给on关键字参数 - 如果两个DataFrame对象包含相同的键 - 或者传递给left_on和right_on。
从 DataFrame 绘制数据
与许多数学问题一样,找到可视化问题和所有信息的一种方法是制定策略。对于基于数据的问题,这通常意味着生成数据的图表,并在视觉上检查趋势、模式和基本结构。由于这是一个常见的操作,pandas 提供了一个快速简单的接口,可以直接从Series或DataFrame中以各种形式使用 Matplotlib 默认情况下的底层绘制数据。
在本教程中,我们将看到如何直接从DataFrame或Series绘制数据,以了解其中的趋势和结构。
准备工作
对于本教程,我们将需要导入 pandas 库为pd,导入 NumPy 库为np,导入 matplotlib 的pyplot模块为plt,并使用以下命令创建一个默认的随机数生成器实例:
from numpy.random import default_rng
rng = default_rng(12345)
操作步骤...
按照以下步骤使用随机数据创建一个简单的 DataFrame,并绘制其包含的数据的图表:
- 使用随机数据创建一个示例 DataFrame:
diffs = rng.standard_normal(size=100)
walk = np.add.accumulate(diffs)
df = pd.DataFrame({
"diffs": diffs,
"walk": walk
})
- 接下来,我们必须创建一个空白图,准备好绘图的两个子图:
fig, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True)
- 我们必须将
walk列绘制为标准折线图。这是通过在Series(列)对象上使用plot方法而不使用其他参数来完成的。我们将通过传递ax=ax1关键字参数来强制在ax1上绘图:
df["walk"].plot(ax=ax1, title="Random walk")
ax1.set_xlabel("Index")
ax1.set_ylabel("Value")
- 现在,我们必须通过将
kind="hist"关键字参数传递给plot方法来绘制diffs列的直方图:
df["diffs"].plot(kind="hist", ax=ax2, title="Histogram of diffs")
ax2.set_xlabel("Difference")
生成的图表如下所示:
图 6.1 - DataFrame 中行走值和差异直方图的图表
工作原理...
Series(或DataFrame)上的plot方法是绘制其包含的数据与行索引的快速方法。kind关键字参数用于控制生成的图表类型,默认情况下是线图。有许多选项可用于绘图类型,包括bar用于垂直条形图,barh用于水平条形图,hist用于直方图(也在本教程中看到),box用于箱线图,scatter用于散点图。还有其他几个关键字参数可用于自定义生成的图表。在本教程中,我们还提供了title关键字参数,以向每个子图添加标题。
由于我们想要将两个图形放在同一图上,我们使用了ax关键字参数将各自的轴句柄传递给绘图例程。即使您让plot方法构建自己的图形,您可能仍然需要使用plt.show例程来显示具有某些设置的图形。
还有更多...
我们可以使用 pandas 接口生成几种常见类型的图表。除了本教程中提到的图表类型之外,还包括散点图、条形图(水平条形图和垂直条形图)、面积图、饼图和箱线图。plot方法还接受各种关键字参数来自定义图表的外观。
从 DataFrame 获取描述性统计信息
描述统计或汇总统计是与一组数据相关的简单值,例如平均值、中位数、标准差、最小值、最大值和四分位数。这些值以各种方式描述了数据集的位置和分布。平均值和中位数是数据的中心(位置)的度量,其他值则度量了数据相对于平均值和中位数的分布。这些统计数据对于理解数据集至关重要,并为许多分析技术奠定了基础。
在这个示例中,我们将看到如何为 DataFrame 中的每列生成描述性统计。
准备工作
为了这个示例,我们需要导入 pandas 包为 pd,导入 NumPy 包为 np,导入 matplotlib 的 pyplot 模块为 plt,并使用以下命令创建一个默认的随机数生成器:
from numpy.random import default_rng
rng = default_rng(12345)
如何做到...
以下步骤展示了如何为 DataFrame 中的每一列生成描述性统计:
- 我们首先创建一些样本数据,以便进行分析:
uniform = rng.uniform(1, 5, size=100)
normal = rng.normal(1, 2.5, size=100)
bimodal = np.concatenate([rng.normal(0, 1, size=50),
rng.normal(6, 1, size=50)])
df = pd.DataFrame({
"uniform": uniform,
"normal": normal,
"bimodal": bimodal
})
- 接下来,我们绘制数据的直方图,以便了解 DataFrame 中数据的分布:
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, tight_layout=True)
df["uniform"].plot(kind="hist", title="Uniform", ax=ax1)
df["normal"].plot(kind="hist", title="Normal", ax=ax2)
df["bimodal"].plot(kind="hist", title="Bimodal", ax=ax3, bins=20)
- Pandas
DataFrame对象有一个方法,可以为每列获取几个常见的描述性统计。describe方法创建一个新的 DataFrame,其中列标题与原始对象相同,每行包含不同的描述性统计:
descriptive = df.describe()
- 我们还计算了峰度并将其添加到我们刚刚获得的新 DataFrame 中。我们还将描述性统计打印到控制台上,以查看这些值是什么:
descriptive.loc["kurtosis"] = df.kurtosis()
print(descriptive)
# uniform normal bimodal
# count 100.000000 100.000000 100.000000
# mean 2.813878 1.087146 2.977682
# std 1.093795 2.435806 3.102760
# min 1.020089 -5.806040 -2.298388
# 25% 1.966120 -0.498995 0.069838
# 50% 2.599687 1.162897 3.100215
# 75% 3.674468 2.904759 5.877905
# max 4.891319 6.375775 8.471313
# kurtosis -1.055983 0.061679 -1.604305
- 最后,我们在直方图上添加了垂直线,以说明每种情况下的平均值:
uniform_mean = descriptive.loc["mean", "uniform"]
normal_mean = descriptive.loc["mean", "normal"]
bimodal_mean = descriptive.loc["mean", "bimodal"]
ax1.vlines(uniform_mean, 0, 20)
ax2.vlines(uniform_mean, 0, 25)
ax3.vlines(uniform_mean, 0, 20)
结果直方图如下所示:
图 6.2 – 三组数据的直方图及其平均值
工作原理...
describe 方法返回一个包含以下数据描述统计的 DataFrame:计数、平均值、标准差、最小值、25% 四分位数、中位数(50% 四分位数)、75% 四分位数和最大值。计数相当直观,最小值和最大值也是如此。平均值和中位数是数据的两种不同的“平均值”,大致代表了数据的中心值。平均值的定义是所有值的总和除以值的数量。我们可以用以下公式表示这个数量:

这里,x[i] 值代表数据值,N 是值的数量。在这里,我们也采用了用条形表示平均值的常见符号。中位数是当所有数据排序时的“中间值”(如果值的数量是奇数,则取两个中间值的平均值)。25% 和 75% 的四分位数同样定义,但是取排序后数值的 25% 或 75% 处的值。你也可以将最小值看作是 0% 四分位数,最大值看作是 100% 四分位数。
标准差是数据相对于平均值的分布的度量,与统计学中经常提到的另一个量方差有关。方差是标准差的平方,定义如下:

你可能还会看到这里的分数中出现了 N – 1,这是从样本中估计总体参数时的偏差校正。我们将在下一个示例中讨论总体参数及其估计。标准差、方差、四分位数、最大值和最小值描述了数据的分布。例如,如果最大值是 5,最小值是 0,25% 四分位数是 2,75% 四分位数是 4,那么这表明大部分(实际上至少有 50% 的值)数据集中在 2 和 4 之间。
kurtosis是衡量数据在分布的“尾部”(远离平均值)集中程度的指标。这不像我们在本教程中讨论的其他数量那样常见,但在一些分析中确实会出现。我们在这里包括它主要是为了演示如何计算不出现在describe方法返回的 DataFrame 中的摘要统计值,使用适当命名的方法——在这里是kurtosis。当然,还有单独的方法来计算平均值(mean)、标准差(std)和describe方法中的其他数量。
当 pandas 计算本教程中描述的数量时,它将自动忽略由 NaN 表示的任何“缺失值”。这也将反映在描述性统计中报告的计数中。
还有更多...
我们在统计中包含的第三个数据集说明了查看数据的重要性,以确保我们计算的值是合理的。事实上,我们计算的平均值约为2.9,但通过查看直方图,很明显大部分数据与这个值相差甚远。我们应该始终检查我们计算的摘要统计数据是否准确地总结了样本中的数据。仅仅引用平均值可能会给出样本的不准确表示。
使用抽样理解人口
统计学中的一个核心问题是对整个人口的分布进行估计,并量化这些估计的准确程度,只给出一个小(随机)样本。一个经典的例子是,在测量随机选择的人群的身高时,估计一个国家所有人的平均身高。当通常意味着整个人口的平均值的真实人口分布无法被测量时,这种问题尤其有趣。在这种情况下,我们必须依靠我们对统计学的知识和一个(通常要小得多的)随机选择的样本来估计真实的人口平均值和标准差,并量化我们估计的准确程度。后者是导致广泛世界中统计学的混淆、误解和错误表述的根源。
在本教程中,我们将看到如何估计总体均值,并为这些估计提供置信区间。
准备工作
对于本教程,我们需要导入 pandas 包作为pd,从 Python 标准库导入math模块,以及使用以下命令导入 SciPy 的stats模块:
from scipy import stats
操作步骤...
在接下来的步骤中,我们将根据随机选择的 20 个人的样本,对英国男性的平均身高进行估计:
- 我们必须将我们的样本数据加载到 pandas 的
Series中:
sample_data = pd.Series(
[172.3, 171.3, 164.7, 162.9, 172.5, 176.3, 174.8, 171.9,
176.8, 167.8, 164.5, 179.7, 157.8, 170.6, 189.9, 185\. ,
172.7, 165.5, 174.5, 171.5]
)
- 接下来,我们将计算样本均值和标准差:
sample_mean = sample_data.mean()
sample_std = sample_data.std()
print(f"Mean {sample_mean}, st. dev {sample_std}")
# Mean 172.15, st. dev 7.473778724383846
- 然后,我们将计算标准误差,如下所示:
N = sample_data.count()
std_err = sample_std/math.sqrt(N)
- 我们将计算我们从学生t分布中所需的置信值的临界值:
cv_95, cv_99 = stats.t.ppf([0.975, 0.995], df=N-1)
- 现在,我们可以使用以下代码计算真实总体均值的 95%和 99%置信区间:
pm_95 = cv_95*std_err
conf_interval_95 = [sample_mean - pm_95, sample_mean + pm_95]
pm_99 = cv_99*std_err
conf_interval_99 = [sample_mean - pm_99, sample_mean + pm_99]
print("95% confidence", conf_interval_95)
# 95% confidence [168.65216388659374, 175.64783611340627]
print("99% confidence", conf_interval_99)
# 99% confidence [167.36884119608774, 176.93115880391227]
它是如何工作的...
参数估计的关键是正态分布,我们在第四章中讨论过。如果我们找到z的临界值,使得标准正态分布随机数小于这个值z的概率为 97.5%,那么这样的数值在-z和z之间的概率为 95%(每个尾部为 2.5%)。这个z的临界值结果为 1.96,四舍五入到 2 位小数。也就是说,我们可以有 95%的把握,标准正态分布随机数的值在-z和z之间。类似地,99%置信的临界值为 2.58(四舍五入到 2 位小数)。
如果我们的样本是“大”的,我们可以引用中心极限定理,它告诉我们,即使总体本身不服从正态分布,从这个总体中抽取的随机样本的均值将服从与整个总体相同均值的正态分布。然而,这仅在我们的样本足够大的情况下才有效。在这个配方中,样本并不大——它只有 20 个值,与英国男性总体相比显然不大。这意味着,我们不得不使用具有N-1 自由度的学生t分布来找到我们的临界值,而不是正态分布,其中N是我们样本的大小。为此,我们使用 SciPy stats模块中的stats.t.ppf例程。
学生t分布与正态分布有关,但有一个参数——自由度——它改变了分布的形状。随着自由度的增加,学生t分布将越来越像正态分布。你认为分布足够相似的点取决于你的应用和你的数据。一个经验法则说,样本大小为 30 足以引用中心极限定理,并简单使用正态分布,但这绝不是一个好的规则。在基于样本进行推断时,你应该非常小心,特别是如果样本与总体相比非常小。(显然,如果总体由 30 人组成,使用 20 个样本量将是相当描述性的,但如果总体由 3000 万人组成,情况就不同了。)
一旦我们有了临界值,真实总体均值的置信区间可以通过将临界值乘以样本的标准误差,并从样本均值中加减得出。标准误差是对给定样本大小的样本均值分布与真实总体均值之间的差距的近似。这就是为什么我们使用标准误差来给出我们对总体均值的估计的置信区间。当我们将标准误差乘以从学生t分布中取得的临界值(在这种情况下)时,我们得到了在给定置信水平下观察到的样本均值与真实总体均值之间的最大差异的估计。
在这个配方中,这意味着我们有 95%的把握,英国男性的平均身高在 168.7 厘米和 175.6 厘米之间,我们有 99%的把握,英国男性的平均身高在 167.4 厘米和 176.9 厘米之间。事实上,我们的样本是从一个平均身高为 175.3 厘米,标准偏差为 7.2 厘米的人群中抽取的。这个真实的平均值(175.3 厘米)确实位于我们两个置信区间内,但仅仅是刚好。
参见
有一个有用的包叫做uncertainties,用于进行涉及一定不确定性的值的计算。请参阅第十章中的计算中的不确定性配方,其他主题。
使用 t 检验进行假设检验
统计学中最常见的任务之一是在从总体中收集样本数据的情况下,测试关于正态分布总体均值的假设的有效性。例如,在质量控制中,我们可能希望测试在工厂生产的一张纸的厚度是否为 2 毫米。为了测试这一点,我们将随机选择样本纸张并测量厚度以获得我们的样本数据。然后,我们可以使用t 检验来测试我们的零假设H[0],即纸张的平均厚度为 2 毫米,对抗备择假设H[1],即纸张的平均厚度不是 2 毫米。我们使用 SciPy 的stats模块来计算t统计量和p值。如果p值小于 0.05,则我们接受零假设,显著性为 5%(置信度 95%)。如果p值大于 0.05,则我们必须拒绝零假设,支持备择假设。
*在这个步骤中,我们将看到如何使用 t 检验来测试给定样本的假设总体均值是否有效。
准备工作
对于这个步骤,我们需要导入 pandas 包作为pd,并使用以下命令导入 SciPy 的stats模块:
from scipy import stats
如何做...
按照以下步骤使用 t 检验来测试给定一些样本数据的假设总体均值的有效性:
- 我们首先将数据加载到 pandas 的
Series中:
sample = pd.Series([
2.4, 2.4, 2.9, 2.6, 1.8, 2.7, 2.6, 2.4, 2.8, 2.4, 2.4,
2.4, 2.7, 2.7, 2.3, 2.4, 2.4, 3.2, 2.2, 2.5, 2.1, 1.8,
2.9, 2.5, 2.5, 3.2, 2\. , 2.3, 3\. , 1.5, 3.1, 2.5, 3.1,
2.4, 3\. , 2.5, 2.7, 2.1, 2.3, 2.2, 2.5, 2.6, 2.5, 2.8,
2.5, 2.9, 2.1, 2.8, 2.1, 2.3
])
- 现在,设置我们将进行测试的假设总体均值和显著性水平:
mu0 = 2.0
significance = 0.05
- 接下来,使用 SciPy 的
stats模块中的ttest_1samp例程生成t统计量和p值:
t_statistic, p_value = stats.ttest_1samp(sample, mu0)
print(f"t stat: {t_statistic}, p value: {p_value}")
# t stat: 9.752368720068665, p value: 4.596949515944238e-13
- 最后,测试p值是否小于我们选择的显著性水平:
if p_value <= significance:
print("Reject H0 in favour of H1: mu != 2.0")
else:
print("Accept H0: mu = 2.0")
# Reject H0 in favour of H1: mu != 2.0
它是如何工作的...
t统计量是使用以下公式计算的:

在这里,μ[0]是假设均值(来自零假设),x bar 是样本均值,s是样本标准差,N是样本大小。t统计量是观察到的样本均值与假设总体均值μ[0]之间差异的估计,通过标准误差进行归一化。假设总体呈正态分布,t统计量将遵循N-1 自由度的t分布。查看 t 统计量在相应的学生t分布中的位置,可以让我们了解我们观察到的样本均值来自具有假设均值的总体的可能性。这以p值的形式给出。
p值是观察到比我们观察到的样本均值更极端值的概率,假设总体均值等于μ[0]。如果p值小于我们选择的显著性值,那么我们不能期望真实的总体均值是我们假设的值μ[0]。在这种情况下,我们必须接受备择假设,即真实的总体均值不等于μ[0]。
还有更多...
在这个步骤中我们演示的测试是 t 检验的最基本用法。在这里,我们比较了样本均值和假设的总体均值,以决定整个总体的均值是否合理为假设值。更一般地,我们可以使用 t 检验来比较从每个样本中取出的两个独立总体的2 样本 t 检验,或者使用配对 t 检验来比较数据成对(某种方式)的总体。这使得 t 检验成为统计学家的重要工具。
在统计学中,显著性和置信度是两个经常出现的概念。统计上显著的结果是指具有高正确概率的结果。在许多情境中,我们认为任何具有低于一定阈值(通常为 5%或 1%)的错误概率的结果都是统计上显著的。置信度是对结果的确定程度的量化。结果的置信度是 1 减去显著性。
不幸的是,结果的显著性经常被误用或误解。说一个结果在 5%的显著水平上是统计显著的,意味着我们有 5%的机会错误地接受了零假设。也就是说,如果我们从总体中另外抽取 20 个样本进行相同的测试,我们至少期望其中一个会给出相反的结果。然而,这并不意味着其中一个一定会这样做。
高显著性表明我们更加确信我们得出的结论是正确的,但这并不意味着这确实是情况。事实上,这个配方中找到的结果就是证据;我们使用的样本实际上是从均值为2.5,标准差为0.35的总体中抽取的。(在创建后对样本进行了一些四舍五入,这会稍微改变分布。)这并不意味着我们的分析是错误的,或者我们从样本得出的结论不正确。
重要的是要记住,t 检验只有在基础总体遵循正态分布,或者至少近似遵循正态分布时才有效。如果不是这种情况,那么您可能需要使用非参数检验。我们将在测试非参数数据的假设配方中讨论这一点。
使用 ANOVA 进行假设检验
假设我们设计了一个实验,测试两个新的过程与当前过程,并且我们想测试这些新过程的结果是否与当前过程不同。在这种情况下,我们可以使用方差分析(ANOVA)来帮助我们确定这三组结果的均值之间是否有任何差异(为此,我们需要假设每个样本都是从具有共同方差的正态分布中抽取的)。
*在这个配方中,我们将看到如何使用 ANOVA 来比较多个样本。
做好准备
对于这个配方,我们需要 SciPy 的stats模块。我们还需要使用以下命令创建一个默认的随机数生成器实例:
from numpy.random import default_rng
rng = default_rng(12345)
如何做...
按照以下步骤执行(单向)ANOVA 测试,以测试三个不同过程之间的差异:
- 首先,我们将创建一些样本数据,然后对其进行分析:
current = rng.normal(4.0, 2.0, size=40)
process_a = rng.normal(6.2, 2.0, size=25)
process_b = rng.normal(4.5, 2.0, size=64)
- 接下来,我们将为我们的测试设置显著性水平:
significance = 0.05
- 然后,我们将使用 SciPy 的
stats模块中的f_oneway例程来生成 F 统计量和p值:
F_stat, p_value = stats.f_oneway(current, process_a, process_b)
print(f"F stat: {F_stat}, p value: {p_value}")
# F stat: 9.949052026027028, p value: 9.732322721019206e-05
- 现在,我们必须测试p值是否足够小,以确定我们是否应该接受或拒绝所有均值相等的零假设:
if p_value <= significance:
print("Reject H0: there is a difference between means")
else:
print("Accept H0: all means equal")
# Reject H0: there is a difference between means
工作原理...
ANOVA 是一种强大的技术,可以同时比较多个样本。它通过比较样本的变化与总体变化的相对变化来工作。ANOVA 在比较三个或更多样本时特别强大,因为不会因运行多个测试而产生累积误差。不幸的是,如果 ANOVA 检测到不是所有的均值都相等,那么从测试信息中无法确定哪个样本与其他样本有显著差异。为此,您需要使用额外的测试来找出差异。
f_oneway SciPy stats包例程执行单向 ANOVA 测试——ANOVA 生成的检验统计量遵循 F 分布。同样,p值是来自测试的关键信息。如果p值小于我们预先设定的显著性水平(在这个配方中为 5%),我们接受零假设,否则拒绝零假设。
还有更多...
ANOVA 方法非常灵活。我们在这里介绍的单因素方差分析检验是最简单的情况,因为只有一个因素需要测试。双因素方差分析检验可用于测试两个不同因素之间的差异。例如,在药物临床试验中,我们测试对照组,同时也测量性别(例如)对结果的影响。不幸的是,SciPy 在stats模块中没有执行双因素方差分析的例程。您需要使用其他包,比如statsmodels包。我们将在第七章 回归和预测 中使用这个包。
如前所述,ANOVA 只能检测是否存在差异。如果存在显著差异,它无法检测这些差异发生在哪里。例如,我们可以使用 Durnett's 检验来测试其他样本均值是否与对照样本不同,或者使用 Tukey's 范围检验来测试每个组均值与其他每个组均值之间的差异。
非参数数据的假设检验
t 检验和 ANOVA 都有一个主要缺点:被抽样的总体必须遵循正态分布。在许多应用中,这并不太严格,因为许多真实世界的总体值遵循正态分布,或者一些规则,如中心极限定理,允许我们分析一些相关数据。然而,事实并非所有可能的总体值以任何合理的方式都遵循正态分布。对于这些(幸运地是罕见的)情况,我们需要一些替代的检验统计量来替代 t 检验和 ANOVA。
在这个配方中,我们将使用 Wilcoxon 秩和检验和 Kruskal-Wallis 检验来测试两个(或更多,在后一种情况下)总体之间的差异。
准备工作
对于这个配方,我们需要导入 pandas 包作为pd,SciPy 的stats模块,以及使用以下命令创建的默认随机数生成器实例:
from numpy.random import default_rng
rng = default_rng(12345)
如何做...
按照以下步骤比较两个或更多个不服从正态分布的总体:
- 首先,我们将生成一些样本数据用于分析:
sample_A = rng.uniform(2.5, 4.5, size=22)
sample_B = rng.uniform(3.0, 4.4, size=25)
sample_C = rng.uniform(3.0, 4.4, size=30)
- 接下来,我们设置在此分析中使用的显著性水平:
significance = 0.05
- 现在,我们使用
stats.kruskal例程生成零假设的检验统计量和p值,即总体具有相同中位数值的零假设:
statistic, p_value = stats.kruskal(sample_A, sample_B, sample_C)
print(f"Statistic: {statistic}, p value: {p_value}")
# Statistic: 5.09365664638392, p value: 0.07832970895845669
- 我们将使用条件语句打印关于测试结果的声明:
if p_value <= significance:
print("Accept H0: all medians equal")
else:
print("There are differences between population medians")
# There are differences between population medians
- 现在,我们使用 Wilcoxon 秩和检验来获得每对样本之间比较的p值:
_, p_A_B = stats.ranksums(sample_A, sample_B)
_, p_A_C = stats.ranksums(sample_A, sample_C)
_, p_B_C = stats.ranksums(sample_B, sample_C)
- 接下来,我们使用条件语句打印出针对那些表明存在显著差异的比较的消息:
if p_A_B > significance:
print("Significant differences between A and B, p value",
p_A_B)
# Significant differences between A and B, p value
0.08808151166219029
if p_A_C > significance:
print("Significant differences between A and C, p value",
p_A_C)
# Significant differences between A and C, p value
0.4257804790323789
if p_B_C > significance:
print("Significant differences between B and C, p value",
p_B_C)
else:
print("No significant differences between B and C, p value",
p_B_C)
# No significant differences between B and C, p value
0.037610047044153536
工作原理...
如果从数据抽样的总体不遵循可以用少量参数描述的分布,我们称数据为非参数数据。这通常意味着总体不是正态分布,但比这更广泛。在这个配方中,我们从均匀分布中抽样,但这仍然比通常需要非参数检验时更有结构化的例子。非参数检验可以和应该在我们不确定基础分布的任何情况下使用。这样做的代价是检验略微不够有力。
任何(真实)分析的第一步应该是绘制数据的直方图并通过视觉检查分布。如果你从一个正态分布的总体中抽取一个随机样本,你可能也期望样本是正态分布的(我们在本书中已经看到了几次)。如果你的样本显示出正态分布的典型钟形曲线,那么总体本身很可能也是正态分布的。你还可以使用核密度估计图来帮助确定分布。这在 pandas 绘图界面上可用,kind="kde"。如果你仍然不确定总体是否正态分布,你可以应用统计测试,比如 D'Agostino 的 K 平方检验或 Pearson 的卡方检验。这两个测试被合并成一个用于正态性检验的单一程序,称为 SciPy stats模块中的normaltest,还有其他几个正态性测试。
Wilcoxon 秩和检验——也称为 Mann-Whitney U 检验——是双样本 t 检验的非参数替代方法。与 t 检验不同,秩和检验不会比较样本均值,以量化两个总体是否具有不同分布。相反,它将样本数据组合并按大小排序。检验统计量是从具有最少元素的样本的秩的总和生成的。从这里开始,像往常一样,我们为零假设生成一个p值,即两个总体具有相同分布的假设。
Kruskal-Wallis 检验是一种一元 ANOVA 检验的非参数替代方法。与秩和检验一样,它使用样本数据的排名来生成检验统计量和零假设的p值,即所有总体具有相同中位数值的假设。与一元 ANOVA 一样,我们只能检测所有总体是否具有相同的中位数,而不能确定差异在哪里。为此,我们需要使用额外的测试。
在这个实验中,我们使用了 Kruskal-Wallis 检验来确定与我们三个样本对应的总体之间是否存在显著差异。我们发现了一个p值为0.07的差异,这离 5%的显著性并不远。然后我们使用秩和检验来确定总体之间的显著差异发生在哪里。在这里,我们发现样本 A 与样本 B 和 C 存在显著差异,而样本 B 和 C 之间没有显著差异。考虑到这些样本的生成方式,这并不奇怪。
不幸的是,由于我们在这个实验中使用了多个测试,我们对结论的整体信心并不像我们期望的那样高。我们进行了四次测试,置信度为 95%,这意味着我们对结论的整体信心仅约为 81%。这是因为错误在多次测试中累积,降低了整体信心。为了纠正这一点,我们需要调整每个测试的显著性阈值,使用 Bonferroni 校正(或类似方法)。
使用 Bokeh 创建交互式图表
测试统计和数值推理对于系统分析数据集是很好的。然而,它们并不能真正给我们一个数据集的整体图像,就像图表那样。数值是确定的,但在统计学中可能很难理解,而图表可以立即说明数据集之间的差异和趋势。因此,有大量的库用于以越来越有创意的方式绘制数据。一个特别有趣的用于生成数据图的包是 Bokeh,它允许我们通过利用 JavaScript 库在浏览器中创建交互式图。
在这个实验中,我们将看到如何使用 Bokeh 创建一个可以在浏览器中显示的交互式图。
准备工作
对于这个示例,我们需要将 pandas 包导入为pd,将 NumPy 包导入为np,使用以下代码构建默认随机数生成器的实例,并从 Bokeh 导入plotting模块,我们使用bk别名导入:
from bokeh import plotting as bk
from numpy.random import default_rng
rng = default_rng(12345)
如何做...
这些步骤展示了如何使用 Bokeh 在浏览器中创建交互式绘图:
- 我们首先需要创建一些样本数据来绘制:
date_range = pd.date_range("2020-01-01", periods=50)
data = np.add.accumulate(rng.normal(0, 3, size=50))
series = pd.Series(data, index=date_range)
- 接下来,我们使用
output_file例程指定 HTML 代码的输出文件位置:
bk.output_file("sample.html")
- 现在,我们创建一个新的图,并设置标题和轴标签,并将x轴类型设置为
datetime,以便我们的日期索引将被正确显示:
fig = bk.figure(title="Time series data",
x_axis_label="date",
x_axis_type="datetime",
y_axis_label="value")
- 我们将数据添加到图中作为一条线:
fig.line(date_range, series)
- 最后,我们使用
show例程或save例程来保存或更新指定输出文件中的 HTML。我们在这里使用show来在浏览器中打开绘图:
bk.show(fig)
Bokeh 绘图不是静态对象,应该通过浏览器进行交互。这里使用matplotlib重新创建了数据,以便进行比较:
图 6.3 - 使用 Matplotlib 创建的时间序列数据的绘图
它是如何工作的...
Bokeh 使用 JavaScript 库在浏览器中呈现绘图,使用 Python 后端提供的数据。这样做的好处是用户可以自行生成绘图。例如,我们可以放大以查看绘图中可能隐藏的细节,或者以自然的方式浏览数据。本示例只是展示了使用 Bokeh 可能性的一小部分。
figure例程创建一个代表绘图的对象,我们可以向其中添加元素,比如通过数据点的线条,就像我们向 matplotlib 的Axes对象添加绘图一样。在这个示例中,我们创建了一个简单的 HTML 文件,其中包含 JavaScript 代码来呈现数据。无论是保存还是调用show例程,这段 HTML 代码都会被转储到指定的文件中。在实践中,p值越小,我们对假设的总体均值正确性的信心就越大。
还有更多...
Bokeh 的功能远不止本文所描述的。Bokeh 绘图可以嵌入到文件中,比如 Jupyter 笔记本,这些文件也可以在浏览器中呈现,或者嵌入到现有的网站中。如果您使用的是 Jupyter 笔记本,您应该使用output_notebook例程,而不是output_file例程,将绘图直接打印到笔记本中。它有各种不同的绘图样式,支持在绘图之间共享数据(例如,可以在一个绘图中选择数据,并在其他绘图中突出显示),并支持流数据。
进一步阅读
统计学和统计理论的教科书有很多。以下书籍被用作本章的参考:
- Mendenhall, W., Beaver, R., and Beaver, B., (2006), Introduction To Probability And Statistics, 12th ed., (Belmont, Calif.: Thomson Brooks/Cole)
pandas 文档(pandas.pydata.org/docs/index.html)和以下 pandas 书籍是使用 pandas 的良好参考资料:
- McKinney, W.,(2017),Python for Data Analysis, 2nd ed.,(Sebastopol: O'Reilly Media, Inc, US)
SciPy 文档(docs.scipy.org/doc/scipy/reference/tutorial/stats.html)还包含了本章中多次使用的统计模块的详细信息。
第八章:回归和预测
统计学家或数据科学家最重要的任务之一是生成对两组数据之间关系的系统性理解。这可能意味着两组数据之间的“连续”关系,其中一个值直接取决于另一个变量的值。或者,它可能意味着分类关系,其中一个值根据另一个值进行分类。处理这些问题的工具是回归。在其最基本的形式中,回归涉及将一条直线拟合到两组数据的散点图中,并进行一些分析,以查看这条直线如何“拟合”数据。当然,我们通常需要更复杂的东西来模拟现实世界中存在的更复杂的关系。
时间序列代表这些回归类型问题的一种专门类别,其中一个值在一段时间内发展。与更简单的问题不同,时间序列数据通常在连续值之间有复杂的依赖关系;例如,一个值可能依赖于前两个值,甚至可能依赖于前一个“噪音”。时间序列建模在科学和经济学中非常重要,有各种工具可用于建模时间序列数据。处理时间序列数据的基本技术称为ARIMA,它代表自回归综合移动平均。该模型包括两个基本组件,一个自回归(AR)组件和一个移动平均(MA**)组件,用于构建观察数据的模型。
在本章中,我们将学习如何建立两组数据之间的关系模型,量化这种关系的强度,并对其他值(未来)生成预测。然后,我们将学习如何使用逻辑回归,在分类问题中,这是简单线性模型的一种变体。最后,我们将使用 ARIMA 为时间序列数据构建模型,并基于这些模型构建不同类型的数据。我们将通过使用一个名为 Prophet 的库来自动生成时间序列数据模型来结束本章。
在本章中,我们将涵盖以下内容:
-
使用基本线性回归
-
使用多元线性回归
-
使用对数回归进行分类
-
使用 ARMA 对时间序列数据进行建模
-
使用 ARIMA 从时间序列数据进行预测
-
使用 ARIMA 对季节性数据进行预测
-
使用 Prophet 对时间序列进行建模
让我们开始吧!
技术要求
在本章中,像往常一样,我们需要导入 NumPy 包并使用别名np,导入 Matplotlib pyplot模块并使用plt别名,以及导入 Pandas 包并使用pd别名。我们可以使用以下命令来实现:
import numpy as np
import matplotlib.pyplot as plt
import pandas as pd
在本章中,我们还需要一些新的包。statsmodels 包用于回归和时间序列分析,scikit-learn包(sklearn)提供通用数据科学和机器学习工具,Prophet 包(fbprophet)用于自动生成时间序列数据模型。这些包可以使用您喜欢的包管理器(如pip)进行安装:
python3.8 -m pip install statsmodels sklearn fbprophet
Prophet 包可能在某些操作系统上安装起来比较困难,因为它的依赖关系。如果安装fbprophet出现问题,您可能希望尝试使用 Python 的 Anaconda 发行版及其包管理器conda,它可以更严格地处理依赖关系:
conda install fbprophet
最后,我们还需要一个名为tsdata的小模块,该模块包含在本章的存储库中。该模块包含一系列用于生成样本时间序列数据的实用程序。
本章的代码可以在 GitHub 存储库的Chapter 07文件夹中找到:github.com/PacktPublishing/Applying-Math-with-Python/tree/master/Chapter%2007。
查看以下视频以查看代码实际操作:bit.ly/2Ct8m0B。
使用基本线性回归
线性回归是一种建模两组数据之间依赖关系的工具,这样我们最终可以使用这个模型进行预测。名称来源于我们基于第二组数据形成一个线性模型(直线)。在文献中,我们希望建模的变量通常被称为响应变量,而我们在这个模型中使用的变量是预测变量。
在这个步骤中,我们将学习如何使用 statsmodels 包执行简单的线性回归,以建模两组数据之间的关系。
准备工作
对于这个步骤,我们需要导入 statsmodels 的api模块并使用别名sm,导入 NumPy 包并使用别名np,导入 Matplotlib 的pyplot模块并使用别名plt,以及一个 NumPy 默认随机数生成器的实例。所有这些都可以通过以下命令实现:
import statsmodels.api as sm
import numpy as np
import matplotlib.pyplot as plt
from numpy.random import default_rng
rng = default_rng(12345)
如何做到这一点...
以下步骤概述了如何使用 statsmodels 包对两组数据执行简单线性回归:
- 首先,我们生成一些示例数据进行分析。我们将生成两组数据,这将说明一个良好的拟合和一个不太好的拟合:
x = np.linspace(0, 5, 25)
rng.shuffle(x)
trend = 2.0
shift = 5.0
y1 = trend*x + shift + rng.normal(0, 0.5, size=25)
y2 = trend*x + shift + rng.normal(0, 5, size=25)
- 执行回归分析的一个很好的第一步是创建数据集的散点图。我们将在同一组坐标轴上完成这一步:
fig, ax = plt.subplots()
ax.scatter(x, y1, c="b", label="Good correlation")
ax.scatter(x, y2, c="r", label="Bad correlation")
ax.legend()
ax.set_xlabel("X"),
ax.set_ylabel("Y")
ax.set_title("Scatter plot of data with best fit lines")
- 我们需要使用
sm.add_constant实用程序例程,以便建模步骤将包括一个常数值:
pred_x = sm.add_constant(x)
- 现在,我们可以为我们的第一组数据创建一个
OLS模型,并使用fit方法来拟合模型。然后,我们使用summary方法打印数据的摘要:
model1 = sm.OLS(y1, pred_x).fit()
print(model1.summary())
- 我们重复第二组数据的模型拟合并打印摘要:
model2 = sm.OLS(y2, pred_x).fit()
print(model2.summary())
- 现在,我们使用
linspace创建一个新的x值范围,我们可以用它来在散点图上绘制趋势线。我们需要添加constant列以与我们创建的模型进行交互:
model_x = sm.add_constant(np.linspace(0, 5))
- 接下来,我们在模型对象上使用
predict方法,这样我们就可以使用模型在前一步生成的每个x值上预测响应值:
model_y1 = model1.predict(model_x)
model_y2 = model2.predict(model_x)
- 最后,我们将在散点图上绘制在前两个步骤中计算的模型数据:
ax.plot(model_x[:, 1], model_y1, 'b')
ax.plot(model_x[:, 1], model_y2, 'r')
散点图以及我们添加的最佳拟合线(模型)可以在下图中看到:
图 7.1:使用最小二乘法回归计算的数据散点图和最佳拟合线。
工作原理...
基本数学告诉我们,一条直线的方程如下所示:

在这里,c是直线与y轴相交的值,通常称为y截距,m是直线的斜率。在线性回归的背景下,我们试图找到响应变量Y和预测变量X之间的关系,使其形式为一条直线,使以下情况发生:

在这里,c和m现在是要找到的参数。我们可以用另一种方式来表示这一点,如下所示:

这里,E是一个误差项,一般来说,它取决于X。为了找到“最佳”模型,我们需要找到E误差被最小化的c和m参数值(在适当的意义上)。找到使这个误差最小化的参数值的基本方法是最小二乘法,这给了这里使用的回归类型以它的名字:普通最小二乘法。一旦我们使用这种方法建立了响应变量和预测变量之间的某种关系,我们的下一个任务就是评估这个模型实际上如何代表这种关系。为此,我们形成了以下方程给出的残差:

我们对每个数据点X[i]和Y[i]进行这样的操作。为了对我们对数据之间关系建模的准确性进行严格的统计分析,我们需要残差满足某些假设。首先,我们需要它们在概率意义上是独立的。其次,我们需要它们围绕 0 呈正态分布,并且具有相同的方差。(在实践中,我们可以稍微放松这些条件,仍然可以对模型的准确性做出合理的评论。)
在这个配方中,我们使用线性关系从预测数据中生成响应数据。我们创建的两个响应数据集之间的差异在于每个值的误差的“大小”。对于第一个数据集y1,残差呈正态分布,标准差为 0.5,而对于第二个数据集y2,残差的标准差为 5.0。我们可以在图 7.1中的散点图中看到这种变异性,其中y1的数据通常非常接近最佳拟合线 - 这与用于生成数据的实际关系非常接近 - 而y2数据则远离最佳拟合线。
来自 statsmodels 包的OLS对象是普通最小二乘回归的主要接口。我们将响应数据和预测数据作为数组提供。为了在模型中有一个常数项,我们需要在预测数据中添加一列 1。sm.add_constant例程是一个简单的实用程序,用于添加这个常数列。OLS类的fit方法计算模型的参数,并返回一个包含最佳拟合模型参数的结果对象(model1和model2)。summary方法创建一个包含有关模型和拟合优度的各种统计信息的字符串。predict方法将模型应用于新数据。顾名思义,它可以用于使用模型进行预测。
除了参数值本身之外,摘要中报告了另外两个统计量。第一个是R²值,或者调整后的版本,它衡量了模型解释的变异性与总变异性之间的关系。这个值将在 0 和 1 之间。较高的值表示拟合效果更好。第二个是 F 统计量的 p 值,它表示模型的整体显著性。与 ANOVA 测试一样,较小的 F 统计量表明模型是显著的,这意味着模型更有可能准确地对数据进行建模。
在这个配方中,第一个模型model1的调整后的R²值为 0.986,表明该模型非常紧密地拟合了数据,p 值为 6.43e-19,表明具有很高的显著性。第二个模型的调整后的R²值为 0.361,表明该模型与数据的拟合程度较低,p 值为 0.000893,也表明具有很高的显著性。尽管第二个模型与数据的拟合程度较低,但从统计学的角度来看,并不意味着它没有用处。该模型仍然具有显著性,尽管不如第一个模型显著,但它并没有解释所有的变异性(或者至少是数据中的一个显著部分)。这可能表明数据中存在额外的(非线性)结构,或者数据之间的相关性较低,这意味着响应和预测数据之间的关系较弱(由于我们构造数据的方式,我们知道后者是真实的)。
还有更多...
简单线性回归是统计学家工具包中一个很好的通用工具。它非常适合找到两组已知(或被怀疑)以某种方式相互关联的数据之间关系的性质。衡量一个数据集依赖于另一个数据集的程度的统计测量称为相关性。我们可以使用相关系数来衡量相关性,例如Spearman 秩相关系数。高正相关系数表示数据之间存在强烈的正相关关系,就像在这个示例中看到的那样,而高负相关系数表示强烈的负相关关系,其中通过数据的最佳拟合线的斜率为负。相关系数为 0 意味着数据没有相关性:数据之间没有关系。
如果数据集之间明显相关,但不是线性(直线)关系,那么它可能遵循一个多项式关系,例如,一个值与另一个值的平方有关。有时,您可以对一个数据集应用转换,例如对数,然后使用线性回归来拟合转换后的数据。当两组数据之间存在幂律关系时,对数特别有用。
使用多元线性回归
简单线性回归,如前面的示例所示,非常适合产生一个响应变量和一个预测变量之间关系的简单模型。不幸的是,有一个单一的响应变量依赖于许多预测变量更为常见。此外,我们可能不知道从一个集合中选择哪些变量作为良好的预测变量。对于这个任务,我们需要多元线性回归。
在这个示例中,我们将学习如何使用多元线性回归来探索响应变量和几个预测变量之间的关系。
准备就绪
对于这个示例,我们将需要导入 NumPy 包作为np,导入 Matplotlib 的pyplot模块作为plt,导入 Pandas 包作为pd,并创建 NumPy 默认随机数生成器的实例,使用以下命令:
from numpy.random import default_rng
rng = default_rng(12345)
我们还需要导入 statsmodels 的api模块作为sm,可以使用以下命令导入:
import statsmodels.api as sm
如何做...
以下步骤向您展示了如何使用多元线性回归来探索几个预测变量和一个响应变量之间的关系:
- 首先,我们需要创建要分析的预测数据。这将采用 Pandas 的
DataFrame形式,有四个项。我们将通过添加一个包含 1 的列来添加常数项:
p_vars = pd.DataFrame({
"const": np.ones((100,)),
"X1": rng.uniform(0, 15, size=100),
"X2": rng.uniform(0, 25, size=100),
"X3": rng.uniform(5, 25, size=100)
})
- 接下来,我们将仅使用前两个变量生成响应数据:
residuals = rng.normal(0.0, 12.0, size=100)
Y = -10.0 + 5.0*p_vars["X1"] - 2.0*p_vars["X2"] + residuals
- 现在,我们将生成响应数据与每个预测变量的散点图:
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, sharey=True,
tight_layout=True)
ax1.scatter(p_vars["X1"], Y)
ax2.scatter(p_vars["X2"], Y)
ax3.scatter(p_vars["X3"], Y)
- 然后,我们将为每个散点图添加轴标签和标题,因为这是一个很好的做法:
ax1.set_title("Y against X1")
ax1.set_xlabel("X1")
ax1.set_ylabel("Y")
ax2.set_title("Y against X2")
ax2.set_xlabel("X2")
ax3.set_title("Y against X3")
ax3.set_xlabel("X3")
The resulting plots can be seen in the following figure:

Figure 7.2: Scatter plots of the response data against each of the predictor variables
As we can see, there appears to be some correlation between the response data and the first two predictor columns, `X1` and `X2`. This is what we expect, given how we generated the data.
5. We use the same `OLS` class to perform multilinear regression; that is, providing the response array and the predictor `DataFrame`:
model = sm.OLS(Y, p_vars).fit()
print(model.summary())
The output of the `print` statement is as follows:
OLS 回归结果
==================================================================
因变量:y R 平方:0.770
模型:OLS 调整 R 平方:0.762
方法:最小二乘法 F 统计量:106.8
日期:2020 年 4 月 23 日星期四 概率(F 统计量):1.77e-30
时间:12:47:30 对数似然:-389.38
观测数量:100 AIC:786.8
残差自由度:96 BIC:797.2
模型自由度:3
协方差类型:非鲁棒
===================================================================
coef std err t P>|t| [0.025 0.975]
常数 -9.8676 4.028 -2.450 0.016 -17.863 -1.872
X1 4.7234 0.303 15.602 0.000 4.122 5.324
X2 -1.8945 0.166 -11.413 0.000 -2.224 -1.565
X3 -0.0910 0.206 -0.441 0.660 -0.500 0.318
===================================================================
Omnibus: 0.296 Durbin-Watson: 1.881
Prob(Omnibus): 0.862 Jarque-Bera (JB): 0.292
偏度:0.123 Prob(JB): 0.864
峰度:2.904 条件数 72.9
===================================================================
In the summary data, we can see that the `X3` variable is not significant since it has a p value of 0.66.
6. Since the third predictor variable is not significant, we eliminate this column and perform the regression again:
second_model = sm.OLS(Y, p_vars.loc[:, "const":"X2"]).fit()
print(second_model.summary())
This results in a small increase in the goodness of fit statistics.
How it works...
Multilinear regression works in much the same way as simple linear regression. We follow the same procedure here as in the previous recipe, where we use the statsmodels package to fit a multilinear model to our data. Of course, there are some differences behind the scenes. The model we produce using multilinear regression is very similar in form to the simple linear model from the previous recipe. It has the following form:

Here, *Y* is the response variable, *X[i]* represents the predictor variables, *E* is the error term, and *β*[*i*] is the parameters to be computed. The same requirements are also necessary for this context: residuals must be independent and normally distributed with a mean of 0 and a common standard deviation.
In this recipe, we provided our predictor data as a Pandas `DataFrame` rather than a plain NumPy array. Notice that the names of the columns have been adopted in the summary data that we printed. Unlike the first recipe, *Using basic linear regression*, we included the constant column in this `DataFrame`, rather than using the `add_constant` utility from statsmodels.
In the output of the first regression, we can see that the model is a reasonably good fit with an adjusted *R²* value of 0.762, and is highly significant (we can see this by looking at the regression F statistic p value). However, looking closer at the individual parameters, we can see that both of the first two predictor values are significant, but the constant and the third predictor are less so. In particular, the third predictor parameter, `X3`, is not significantly different from 0 and has a p value of 0.66\. Given that our response data was constructed without using this variable, this shouldn't come as a surprise. In the final step of the analysis, we repeat the regression without the predictor variable, `X3`, which is a mild improvement to the fit.
Classifying using logarithmic regression
Logarithmic regression solves a different problem to ordinary linear regression. It is commonly used for classification problems where, typically, we wish to classify data into two distinct groups, according to a number of predictor variables. Underlying this technique is a transformation that's performed using logarithms. The original classification problem is transformed into a problem of constructing a model for the **log-odds***.* This model can be completed with simple linear regression. We apply the inverse transformation to the linear model, which leaves us with a model of the probability that the desired outcome will occur, given the predictor data. The transform we apply here is called the **logistic function**, which gives its name to the method. The probability we obtain can then be used in the classification problem we originally aimed to solve.
In this recipe, we will learn how to perform logistic regression and use this technique in classification problems.
Getting ready
For this recipe, we will need the NumPy package imported as `np`, the Matplotlib `pyplot`module imported as `plt`, the Pandas package imported as `pd`, and an instance of the NumPy default random number generator to be created using the following commands:
从 numpy.random 导入 default_rng
rng = default_rng(12345)
We also need several components from the `scikit-learn` package to perform logistic regression. These can be imported as follows:
从 sklearn.linear_model 导入 LogisticRegression
从 sklearn.metrics 导入 classification_report
How to do it...
Follow these steps to use logistic regression to solve a simple classification problem:
1. First, we need to create some sample data that we can use to demonstrate how to use logistic regression. We start by creating the predictor variables:
df = pd.DataFrame({
"var1": np.concatenate([rng.normal(3.0, 1.5, size=50),
rng.normal(-4.0, 2.0, size=50)]),
"var2": rng.uniform(size=100),
"var3": np.concatenate([rng.normal(-2.0, 2.0, size=50),
rng.normal(1.5, 0.8, size=50)])
})
2. Now, we use two of our three predictor variables to create our response variable as a series of Boolean values:
分数 = 4.0 + df["var1"] - df["var3"]
Y = score >= 0
3. Next, we scatter plot the points, styled according to the response variable, of the `var3` data against the `var1` data, which are the variables used to construct the response variable:
fig1, ax1 = plt.subplots()
ax1.plot(df.loc[Y, "var1"], df.loc[Y, "var3"], "bo", label="True
数据")
ax1.plot(df.loc[~Y, "var1"], df.loc[~Y, "var3"], "rx", label="False
数据")
ax1.legend()
ax1.set_xlabel("var1")
ax1.set_ylabel("var3")
ax1.set_title("Scatter plot of var3 against var1")
The resulting plot can be seen in the following figure:

Figure 7.3: Scatter plot of the var3 data against var1, with classification marked
4. Next, we create a `LogisticRegression` object from the `scikit-learn` package and fit the model to our data:
model = LogisticRegression()
model.fit(df, Y)
5. Next, we prepare some extra data, different from what we used to fit the model, to test the accuracy of our model:
test_df = pd.DataFrame({
"var1": np.concatenate([rng.normal(3.0, 1.5, size=50),
rng.normal(-4.0, 2.0, size=50)]),
"var2": rng.uniform(size=100),
"var3": np.concatenate([rng.normal(-2.0, 2.0, size=50),
rng.normal(1.5, 0.8, size=50)])
})
test_scores = 4.0 + test_df["var1"] - test_df["var3"]
test_Y = test_scores >= 0
6. Then, we generate predicted results based on our logistic regression model:
test_predicts = model.predict(test_df)
7. Finally, we use the `classification_report` utility from `scikit-learn` to print a summary of predicted classification against known response values to test the accuracy of the model. We print this summary to the Terminal:
print(classification_report(test_Y, test_predicts))
The report that's generated by this routine looks as follows:
precision recall f1-score support
False 1.00 1.00 1.00 18
True 1.00 1.00 1.00 32
accuracy 1.00 50
macro avg 1.00 1.00 1.00 50
weighted avg 1.00 1.00 1.00 50
How it works...
Logistic regression works by forming a linear model of the *log odds* ratio *(*or *logit*), which, for a single predictor variable, *x*, has the following form:

Here, *p*(*x*) represents the probability of a true outcome in response to the given the predictor, *x*. Rearranging this gives a variation of the logistic function for the probability:

The parameters for the log odds are estimated using a maximum likelihood method.
The `LogisticRegression` class from the `linear_model` module in `scikit-learn` is an implementation of logistic regression that is very easy to use. First, we create a new model instance of this class, with any custom parameters that we need, and then use the `fit` method on this object to fit (or train) the model to the sample data. Once this fitting is done, we can access the parameters that have been estimated using the `get_params` method.
The `predict` method on the fitted model allows us to pass in new (unseen) data and make predictions about the classification of each sample. We could also get the probability estimates that are actually given by the logistic function using the `predict_proba` method.
Once we have built a model for predicting the classification of data, we need to validate the model. This means we have to test the model with some previously unseen data and check whether it correctly classifies the new data. For this, we can use `classification_report`, which takes a new set of data and the predictions generated by the model and computes the proportion of the data that was correctly predicted by the model. This is the *precision* of the model.
The classification report we generated using the `scikit-learn` utility performs a comparison between the predicted results and the known response values. This is a common method for validating a model before using it to make actual predictions. In this recipe, we saw that the reported precision for each of the categories (`True` and `False`) was 1.00, indicating that the model performed perfectly in predicting the classification with this data. In practice, it is unlikely that the precision of a model will be 100%.
There's more...
There are lots of packages that offer tools for using logistic regression for classification problems. The statsmodels package has the `Logit` class for creating logistic regression models. We used the `scikit-learn` package in this recipe, which has a similar interface. `Scikit-learn` is a general-purpose machine learning library and has a variety of other tools for classification problems.
Modeling time series data with ARMA
Time series, as the name suggests, tracks a value over a sequence of distinct time intervals. They are particularly important in the finance industry, where stock values are tracked over time and used to make predictions – known as forecasting – of the value at some future time. Good predictions coming from such data can be used to make better investments. Time series also appear in many other common situations, such as weather monitoring, medicine, and any places where data is derived from sensors over time.
Time series, unlike other types of data, do not usually have independent data points. This means that the methods that we use for modeling independent data will not be particularly effective. Thus, we need to use alternative techniques to model data with this property. There are two ways in which a value in a time series can depend on previous values. The first is where there is a direct relationship between the value and one or more previous values. This is the *autocorrelation* property and is modeled by an *autoregressive* model. The second is where the noise that's added to the value depends on one or more previous noise terms. This is modeled by a *moving average* model. The number of terms involved in either of these models is called the *order* of the model.
In this recipe, we will learn how to create a model for stationary time series data with ARMA terms.
Getting ready
For this recipe, we need the Matplotlib `pyplot` module imported as `plt` and the statsmodels package `api` module imported as `sm`. We also need to import the `generate_sample_data` routine from the `tsdata` package from this book's repository, which uses NumPy and Pandas to generate sample data for analysis:
from tsdata import generate_sample_data
How to do it...
Follow these steps to create an autoregressive moving average model for stationary time series data:
1. First, we need to generate the sample data that we will analyze:
sample_ts, _ = generate_sample_data()
2. As always, the first step in the analysis is to produce a plot of the data so that we can visually identify any structure:
ts_fig, ts_ax = plt.subplots()
sample_ts.plot(ax=ts_ax, label="Observed")
ts_ax.set_title("Time series data")
ts_ax.set_xlabel("Date")
ts_ax.set_ylabel("Value")
The resulting plot can be seen in the following figure. Here, we can see that there doesn't appear to be an underlying trend, which means that the data is likely to be stationary:

Figure 7.4: Plot of the time series data that we will analyze. There doesn't appear to be a trend in this data
3. Next, we compute the augmented Dickey-Fuller test. The null hypothesis is that the time series is not stationary:
adf_results = sm.tsa.adfuller(sample_ts)
adf_pvalue = adf_results[1]
print("Augmented Dickey-Fuller test:\nP-value:", adf_pvalue)
The reported p value is 0.000376 in this case, so we reject the null hypothesis and conclude that the series is stationary.
4. Next, we need to determine the order of the model that we should fit. For this, we'll plot the **autocorrelation function** (**ACF**) and the **partial autocorrelation function**(**PACF**) for the time series:
ap_fig, (acf_ax, pacf_ax) = plt.subplots(2, 1, sharex=True,
tight_layout=True)
sm.graphics.tsa.plot_acf(sample_ts, ax=acf_ax,
title="Observed autocorrelation")
sm.graphics.tsa.plot_pacf(sample_ts, ax=pacf_ax,
title="Observed partial autocorrelation")
pacf_ax.set_xlabel("Lags")
pacf_ax.set_ylabel("Value")
acf_ax.set_ylabel("Value")
The plots of the ACF and PACF for our time series can be seen in the following figure. These plots suggest the existence of both autoregressive and moving average processes:

Figure 7.5: ACF and PACF for the sample time series data
5. Next, we create an ARMA model for the data, using the `ARMA` class from statsmodels, `tsa` module. This model will have an order 1 AR and an order 1 MA:
arma_model = sm.tsa.ARMA(sample_ts, order=(1, 1))
6. Now, we fit the model to the data and get the resulting model. We print a summary of these results to the Terminal:
arma_results = arma_model.fit()
print(arma_results.summary())
The summary data given for the fitted model is as follows:
ARMA Model Results
===================================================================
Dep. Variable: y No. Observations: 366
Model: ARMA(1, 1) Log Likelihood -513.038
方法:css-mle 创新的标准偏差 0.982
日期:2020 年 5 月 1 日 AIC 1034.077
时间:12:40:00 BIC 1049.687
Sample: 01-01-2020 HQIC 1040.280
- 12-31-2020
===================================================================
coef std err z P>|z| [0.025 0.975]
const -0.0242 0.143 -0.169 0.866 -0.305 0.256
ar.L1.y 0.8292 0.057 14.562 0.000 0.718 0.941
ma.L1.y -0.5189 0.090 -5.792 0.000 -0.695 -0.343
Roots
===================================================================
实部 虚部 模数 频率
AR.1 1.2059 +0.0000j 1.2059 0.0000
MA.1 1.9271 +0.0000j 1.9271 0.0000
Here, we can see that both of the estimated parameters for the AR and MA components are significantly different from 0\. This is because the value in the `P >|z|` column is 0 to 3 decimal places.
7. Next, we need to verify that there is no additional structure remaining in the residuals (error) of the predictions from our model. For this, we plot the ACF and PACF of the residuals:
residuals = arma_results.resid
rap_fig, (racf_ax, rpacf_ax) = plt.subplots(2, 1,
sharex=True, tight_layout=True)
sm.graphics.tsa.plot_acf(residuals, ax=racf_ax,
title="Residual autocorrelation")
sm.graphics.tsa.plot_pacf(residuals, ax=rpacf_ax,
标题="残差部分自相关")
rpacf_ax.set_xlabel("滞后")
rpacf_ax.set_ylabel("值")
racf_ax.set_ylabel("值")
The ACF and PACF of the residuals can be seen in the following figure. Here, we can see that there are no significant spikes at lags other than 0, so we conclude that there is no structure remaining in the residuals:

Figure 7.6: ACF and PACF for the residuals from our model
8. Now that we have verified that our model is not missing any structure, we plot the values that are fitted to each data point on top of the actual time series data to see whether the model is a good fit for the data. We plot this model in the plot we created in *step 2*:
fitted = arma_results.fittedvalues
fitted.plot(c="r", ax=ts_ax, 标签="拟合")
ts_ax.legend()
The updated plot can be seen in the following figure:

Figure 7.7: Plot of the fitted time series data over the observed time series data
The fitted values give a reasonable approximation of the behavior of the time series, but reduce the noise from the underlying structure.
How it works...
A time series is stationary if it does not have a trend. They usually have a tendency to move in one direction rather than another. Stationary processes are important because we can usually remove the trend from an arbitrary time series and model the underlying stationary series. The ARMA model that we used in this recipe is a basic means of modeling the behavior of stationary time series. The two parts of an ARMA model are the autoregressive and moving average parts, which model the dependence of the terms and noise, respectively, on previous terms and noise.
An order 1 autoregressive model has the following form:

Here, *φ[i]* represents the parameters and *ε[t]* is the noise at a given step. The noise is usually assumed to be normally distributed with a mean of 0 and a standard deviation that is constant across all the time steps. The *Y[t]* value represents the value of the time series at the time step, *t*. In this model, each value depends on the previous value, though it can also depend on some constants and some noise. The model will give rise to a stationary time series precisely when the *φ[1]* parameter lies strictly between -1 and 1.
An order 1 moving average model is very similar to an autoregressive model and is given by the following equation:

Here, the variants of *θ[i]* are parameters. Putting these two models together gives us an ARMA(1, 1) model, which has the following form:

In general, we can have an ARMA(p, q) model that has an order *p* AR component and an order q MA component. We usually refer to the quantities, *p* and *q*, as the orders of the model.
Determining the orders of the AR and MA components is the most tricky aspect of constructing an ARMA model. The ACF and PACF give some information toward this, but even then, it can be quite difficult. For example, an autoregressive process will show some kind of decay or oscillating pattern on the ACF as lag increases, and a small number of peaks on the PACF and values that are not significantly different from 0 beyond that. The number of peaks that appear on the PAF plot can be taken as the order of the process. For a moving average process, the reverse is true. There are usually a small number of significant peaks on the ACF plot, and a decay or oscillating pattern on the PACF plot. Of course, sometimes, this isn't obvious.
In this recipe, we plotted the ACF and PACF for our sample time series data. In the autocorrelation plot in *Figure 7.5* (top), we can see that the peaks decay rapidly until they lie within the confidence interval of zero (meaning they are not significant). This suggests the presence of an autoregressive component. On the partial autocorrelation plot in *Figure 7.5* (bottom), we can see that there are only two peaks that can be considered not zero, which suggests an autoregressive process of order 1 or 2\. You should try to keep the order of the model as small as possible. Due to this, we chose an order 1 autoregressive component. With this assumption, the second peak on the partial autocorrelation plot is indicative of decay (rather than an isolated peak), which suggests the presence of a moving average process. To keep the model simple, we try an order 1 moving average process. This is how the model that we used in this recipe was decided on. Notice that this is not an exact process, and you might have decided differently.
We use the augmented Dickey-Fuller test to test the likelihood that the time series that we have observed is stationary. This is a statistical test, such as those seen in Chapter 6, *Working with Data and Statistics*, that generates a test statistic from the data. This test statistic, in turn, determines a p-value that is used to determine whether to accept or reject the null hypothesis. For this test, the null hypothesis is that a unit root is present in the time series that's been sampled. The alternative hypothesis – the one we are really interested in – is that the observed time series is (trend) stationary. If the p-value is sufficiently small, then we can conclude with the specified confidence that the observed time series is stationary. In this recipe, the p-value was 0.000 to 3 decimal places, which indicates a strong likelihood that the series is stationary. Stationarity is an essential assumption for using the ARMA model for the data.
Once we have determined that the series is stationary, and also decided on the orders of the model, we have to fit the model to the sample data that we have. The parameters of the model are estimated using a maximum likelihood estimator. In this recipe, the learning of the parameters is done using the `fit` method, in *step 6*.
The statsmodels package provides various tools for working with time series, including utilities for calculating – and plotting –ACF and PACF of time series data, various test statistics, and creating ARMA models for time series. There are also some tools for automatically estimating the order of the model.
We can use the **Akaike information criterion** (**AIC**), **Bayesian information criterion** (**BIC**), and **Hannan-Quinn Information Criterion** (**HQIC**) quantities to compare this model to other models to see which model best describes the data. A smaller value is better in each case.
When using ARMA to model time series data, as in all kinds of mathematical modeling tasks, it is best to pick the simplest model that describes the data to the extent that is needed. For ARMA models, this usually means picking the smallest order model that describes the structure of the observed data.
There's more...
Finding the best combination of orders for an ARMA model can be quite difficult. Often, the best way to fit a model is to test multiple different configurations and pick the order that produces the best fit. For example, we could have tried ARMA(0, 1) or ARMA(1, 0) in this recipe, and compared it to the ARMA(1, 1) model we used to see which produced the best fit by considering the **Akaike Information Criteria** (**AIC**) statistic reported in the summary. In fact, if we build these models, we will see that the AIC value for ARMA(1, 1) – the model we used in this recipe – is the "best" of these three models.
Forecasting from time series data using ARIMA
In the previous recipe, we generated a model for a stationary time series using an ARMA model, which consists of an **autoregressive** (**AR**) component and an **m****oving average** (**MA**) component. Unfortunately, this model cannot accommodate time series that have some underlying trend; that is, they are not stationary time series. We can often get around this by *differencing* the observed time series one or more times until we obtain a stationary time series that can be modeled using ARMA. The incorporation of differencing into an ARMA model is called an ARIMA model, which stands for **Autoregressive** (**AR**) **Integrated** (**I**) **Moving Average** (**MA**).
Differencing is the process of computing the difference of consecutive terms in a sequence of data. So, applying first-order differencing amounts to subtracting the value at the current step from the value at the next step (*t[i+1] - t[i]*). This has the effect of removing the underlying upward or downward linear trend from the data. This helps to reduce an arbitrary time series to a stationary time series that can be modeled using ARMA. Higher-order differencing can remove higher-order trends to achieve similar effects.
An ARIMA model has three parameters, usually labeled *p*, *d*, and *q*. The *p* and *q* order parameters are the order of the autoregressive component and the moving average component, respectively, just as they are for the ARMA model. The third order parameter, *d*, is the order of differencing to be applied. An ARIMA model with these orders is usually written as ARIMA (*p*, *d*, *q*). Of course, we will need to determine what order differencing should be included before we start fitting the model.
In this recipe, we will learn how to fit an ARIMA model to a non-stationary time series and use this model to make forecasts about future values.
Getting ready
For this recipe, we will need the NumPy package imported as `np`, the Pandas package imported as `pd`, the Matplotlib `pyplot` module as `plt`, and the statsmodels `api` module imported as `sm`. We will also need the utility for creating sample time series data from the `tsdata` module, which is included in this book's repository:
从 tsdata 导入生成样本数据
How to do it...
The following steps show you how to construct an ARIMA model for time series data and use this model to make forecasts:
1. First, we load the sample data using the `generate_sample_data` routine:
sample_ts,test_ts = generate_sample_data(trend=0.2, undiff=True)
2. As usual, the next step is to plot the time series so that we can visually identify the trend of the data:
ts_fig, ts_ax = plt.subplots(tight_layout=True)
sample_ts.plot(ax=ts_ax, c="b", 标签="观察到的")
ts_ax.set_title("训练时间序列数据")
ts_ax.set_xlabel("日期")
ts_ax.set_ylabel("值")
The resulting plot can be seen in the following figure. As we can see, there is a clear upward trend in the data, so the time series is certainly not stationary:

Figure 7.8: Plot of the sample time series. There is an obvious positive trend in the data.
3. Next, we difference the series to see if one level of differencing is sufficient to remove the trend:
差分=sample_ts.diff().dropna()
4. Now, we plot the ACF and PACF for the differenced time series:
ap_fig, (acf_ax, pacf_ax) = plt.subplots(1, 2,
tight_layout=True, sharex=True)
sm.graphics.tsa.plot_acf(diffs, ax=acf_ax)
sm.graphics.tsa.plot_pacf(diffs, ax=pacf_ax)
acf_ax.set_ylabel("值")
pacf_ax.set_xlabel("滞后")
pacf_ax.set_ylabel("值")
The ACF and PACF can be seen in the following figure. We can see that there does not appear to be any trends left in the data and that there appears to be both an autoregressive component and a moving average component:

Figure 7.9: ACF and PACF for the differenced time series
5. Now, we construct the ARIMA model with order 1 differencing, an autoregressive component, and a moving average component. We fit this to the observed time series and print a summary of the model:
model = sm.tsa.ARIMA(sample_ts, order=(1,1,1))
fitted = model.fit(trend="c")
print(fitted.summary())
The summary information that's printed looks as follows:
ARIMA 模型结果
==================================================================
因变量:D.y 观察次数:365
模型:ARIMA(1, 1, 1) 对数似然-512.905
方法:css-mle 创新的标准差 0.986
日期:星期六,2020 年 5 月 2 日 AIC 1033.810
时间:14:47:25 BIC 1049.409
样本:2020 年 01 月 02 日 HQIC 1040.009
- 12-31-2020
==================================================================
coef std err z P>|z| [0.025 0.975]
const 0.9548 0.148 6.464 0.000 0.665 1.244
ar.L1.D.y 0.8342 0.056 14.992 0.000 0.725 0.943
ma.L1.D.y -0.5204 0.088 -5.903 0.000 -0.693 -0.348
根
==================================================================
实际 虚数 模数 频率
AR.1 1.1987 +0.0000j 1.1987 0.0000
MA.1 1.9216 +0.0000j 1.9216 0.0000
Here, we can see that all three of our estimated coefficients are significantly different from 0 due to the fact that all three have 0 to 3 decimal places in the `P>|z|` column.
6. Now, we can use the `forecast` method to generate predictions of future values. This also returns the standard error and confidence intervals for predictions:
forecast, std_err, fc_ci = fitted.forecast(steps=50)
forecast_dates = pd.date_range("2021-01-01", periods=50)
forecast = pd.Series(forecast, index=forecast_dates)
7. Next, we plot the forecast values and their confidence intervals on the figure containing the time series data:
forecast.plot(ax=ts_ax, c="g", 标签="预测")
ts_ax.fill_between(forecast_dates, fc_ci[:, 0], fc_ci[:, 1],
颜色="r", alpha=0.4)
8. Finally, we add the actual future values to generate, along with the sample in *step 1*, to the plot (it might be easier if you repeat the plot commands from *step 1* to regenerate the whole plot here):
test_ts.plot(ax=ts_ax, c="k", 标签="实际")
ts_ax.legend()
The final plot containing the time series with the forecast and the actual future values can be seen in the following figure:

Figure 7.10: Plot of the sample time series with forecast values and actual future values for comparison
Here, we can see that the actual future values are within the confidence interval for the forecast values.
How it works...
The ARIMA model – with orders *p*, *d*, and *q –* is simply an ARMA (*p*, *q*) model that's applied to a time series. This is obtained by applying differencing of order *d* to the original time series data. It is a fairly simple way to generate a model for time series data. The statsmodels `ARIMA` class handles the creation of a model, while the `fit` method fits this model to the data. We passed the `trend="c"` keyword argument because we know, from *Figure 7.9*, that the time series has a constant trend.
The model is fit to the data using a maximum likelihood method and the final estimates for the parameters – in this case, one parameter for the autoregressive component, one for the moving average component, the constant trend parameter, and the variance of the noise. These parameters are reported in the summary. From this output, we can see that the estimates for the AR coefficient (0.8342) and the MA constant (-0.5204) are very good approximations of the true estimates that were used to generate the data, which were 0.8 for the AR coefficient and -0.5 for the MA coefficient. These parameters are set in the `generate_sample_data` routine from the `tsdata.py` file in the code repository for this chapter. This generates the sample data in *step 1*. You might have noticed that the constant parameter (0.9548) is not 0.2, as specified in the `generate_sample_data` call in *step 1*. In fact, it is not so far from the actual drift of the time series.
The `forecast` method on the fitted model (the output of the `fit` method) uses the model to make predictions about the value after a given number of steps. In this recipe, we forecast for up to 50 time steps beyond the range of the sample time series. The output of the `forecast` method is a tuple containing the forecast values, the standard error for the forecasts, and the confidence interval (by default, 95% confidence) for the forecasts. Since we provided the time series as a Pandas series, these are returned as `Series` objects (the confidence interval is a `DataFrame`).
When you construct an ARIMA model for time series data, you need to make sure you use the smallest order differencing that removes the underlying trend. Applying more differencing than is necessary is called *overdifferencing* and can lead to problems with the model.
Forecasting seasonal data using ARIMA
Time series often display periodic behavior so that peaks or dips in the value appear at regular intervals. This behavior is called *seasonality* in the analysis of time series. The methods we have used to far in this chapter to model time series data obviously do not account for seasonality. Fortunately, it is relatively easy to adapt the standard ARIMA model to incorporate seasonality, resulting in what is sometimes called a SARIMA model.
In this recipe, we will learn how to model time series data that includes seasonal behavior and use this model to produce forecasts.
Getting ready
For this recipe, we will need the NumPy package imported as `np`, the Pandas package imported as `pd`, the Matplotlib `pyplot`module as `plt`, and the statsmodels `api`module imported as `sm`. We will also need the utility for creating sample time series data from the `tsdata`module, which is included in this book's repository:
从 tsdata 导入生成样本数据
How to do it...
Follow these steps to produce a seasonal ARIMA model for sample time series data and use this model to produce forecasts:
1. First, we use the `generate_sample_data` routine to generate a sample time series to analyze:
sample_ts,test_ts = generate_sample_data(undiff=True,
季节性=True)
2. As usual, our first step is to visually inspect the data by producing a plot of the sample time series:
ts_fig, ts_ax = plt.subplots(tight_layout=True)
sample_ts.plot(ax=ts_ax, 标题="时间序列", 标签="观察到的")
ts_ax.set_xlabel("日期")
ts_ax.set_ylabel("值")
The plot of the sample time series data can be seen in the following figure. Here, we can see that there seem to be periodic peaks in the data:

Figure 7.11: Plot of the sample time series data
3. Next, we plot the ACF and PACF for the sample time series:
ap_fig,(acf_ax,pacf_ax) = plt.subplots(2, 1,
sharex=True, tight_layout=True)
sm.graphics.tsa.plot_acf(sample_ts, ax=acf_ax)
sm.graphics.tsa.plot_pacf(sample_ts, ax=pacf_ax)
pacf_ax.set_xlabel("滞后")
acf_ax.set_ylabel("值")
pacf_ax.set_ylabel("值")
The ACF and PACF for the sample time series can be seen in the following figure:

Figure 7.12: ACF and PACF for the sample time series
These plots possibly indicate the existence of autoregressive components, but also a significant spike on the PACF with lag 7.
4. Next, we difference the time series and produce plots of the ACF and PACF for the differenced series. This should make the order of the model clearer:
差分=sample_ts.diff().dropna()
dap_fig, (dacf_ax, dpacf_ax) = plt.subplots(2, 1, sharex=True,
tight_layout=True)
sm.graphics.tsa.plot_acf(diffs, ax=dacf_ax,
标题="差分 ACF")
sm.graphics.tsa.plot_pacf(diffs, ax=dpacf_ax,
标题="差分 PACF")
dpacf_ax.set_xlabel("滞后")
dacf_ax.set_ylabel("值")
dpacf_ax.set_ylabel("值")
The ACF and PACF for the differenced time series can be seen in the following figure. We can see that there is definitely a seasonal component with lag 7:

Figure 7.13: Plot of the ACF and PACF for the differenced time series
5. Now, we need to create a `SARIMAX` object that holds the model, with ARIMA order `(1, 1, 1)` and seasonal ARIMA order `(1, 0, 0, 7)`. We fit this model to the sample time series and print summary statistics. We plot the predicted values on top of the time series data:
model = sm.tsa.SARIMAX(sample_ts, order=(1, 1, 1),
季节性顺序=(1, 0, 0, 7))
fitted_seasonal = model.fit()
print(fitted_seasonal.summary())
fitted_seasonal.fittedvalues.plot(ax=ts_ax, c="r",
标签="预测")
The summary statistics that are printed to the Terminal look as follows:
SARIMAX 结果
===================================================================
因变量:y 观察次数:366
模型:SARIMAX(1, 1, 1)x(1, 0, [], 7) 对数似然-509.941
日期:星期一,2020 年 5 月 4 日 AIC 1027.881
时间:18:03:27 BIC 1043.481
样本:2020 年 01 月 01 日 HQIC 1034.081
- 12-31-2020
协方差类型: opg
===================================================================
coef std err z P>|z| [0.025 0.975]
ar.L1 0.7939 0.065 12.136 0.000 0.666 0.922
ma.L1 -0.4544 0.095 -4.793 0.000 -0.640 -0.269
ar.S.L7 0.7764 0.034 22.951 0.000 0.710 0.843
sigma2 0.9388 0.073 12.783 0.000 0.795 1.083
===================================================================
Ljung-Box (Q): 31.89 Jarque-Bera (JB): 0.47
Prob(Q): 0.82 Prob(JB): 0.79
异方差性(H): 1.15 偏度: -0.03
Prob(H) (双侧): 0.43 峰度: 2.84
===================================================================
警告:
[1] 使用外积计算的协方差矩阵
梯度的数量(复杂步骤)。
6. This model appears to be a reasonable fit, so we move ahead and forecast `50` time steps into the future:
forecast_result = fitted_seasonal.get_forecast(steps=50)
forecast_index = pd.date_range("2021-01-01", periods=50)
预测 = 预测结果.预测均值
7. Finally, we add the forecast values to the plot of the sample time series, along with the confidence interval for these forecasts:
forecast.plot(ax=ts_ax, c="g", label="预测")
conf = forecast_result.conf_int()
ts_ax.fill_between(forecast_index, conf["lower y"],
conf["upper y"], color="r", alpha=0.4)
test_ts.plot(ax=ts_ax, color="k", label="实际未来")
ts_ax.legend()
The final plot of the time series, along with the predictions and the confidence interval for the forecasts, can be seen in the following figure:

Figure 7.14: Plot of the sample time series, along with the forecasts and confidence interval
How it works...
Adjusting an ARIMA model to incorporate seasonality is a relatively simple task. A seasonal component is similar to an autoregressive component, where the lag starts at some number larger than 1\. In this recipe, the time series exhibits seasonality with period 7 (weekly), which means that the model is approximately given by the following equation:

Here *φ[1]* and *Φ**[1]**are the parameters and *ε[t]* is the noise at time step *t*. The standard ARIMA model is easily adapted to include this additional lag term.* *The SARIMA model incorporates this additional seasonality into the ARIMA model. It has four additional order terms on top of the three for the underlying ARIMA model. These four additional parameters are the seasonal AR, differencing, and MA components, along with the period of the seasonality. In this recipe, we took the seasonal AR to be order 1, with no seasonal differencing or MA components (order 0), and a seasonal period of 7\. This gives us the additional parameters (1, 0, 0, 7) that we used in *step 5* of this recipe.
Seasonality is clearly important in modeling time series data that is measured over a period of time covering days, months, or years. It usually incorporates some kind of seasonal component based on the time frame that they occupy. For example, a time series of national power consumption measured hourly over several days would probably have a 24-hour seasonal component since power consumption will likely fall during the night hours.
Long-term seasonal patterns might be hidden if the time series data that you are analyzing does not cover a sufficiently large time period for the pattern to emerge. The same is true for trends in the data. This can lead to some interesting problems when trying to produce long-term forecasts from a relatively short period represented by observed data.
The `SARIMAX` class from the statsmodels package provides the means of modeling time series data using a seasonal ARIMA model. In fact, it can also model external factors that have an additional effect on the model, sometimes called *exogenous regressors*. (We will not cover these here.) This class works much like the `ARMA` and `ARIMA` classes that we used in the previous recipes. First, we create the model object by providing the data and orders for both the ARIMA process and the seasonal process, and then use the `fit` method on this object to create a fitted model object. We use the `get_forecasts` method to generate an object holding the forecasts and confidence interval data that we can then plot, thus producing the *Figure 7.14*.
## There's more...
There is a small difference in the interface between the `SARIMAX` class used in this recipe and the `ARIMA` class used in the previous recipe. At the time of writing, the statsmodels package (v0.11) includes a second `ARIMA` class that builds on top of the `SARIMAX` class, thus providing the same interface. However, at the time of writing, this new `ARIMA` class does not offer the same functionality as that used in this recipe.
# Using Prophet to model time series data
The tools we have seen so far for modeling time series data are very general and flexible methods, but they require some knowledge of time series analysis in order to be set up. The analysis needed to construct a good model that can be used to make reasonable predictions into the future can be intensive and time-consuming, and may not be viable for your application. The Prophet library is designed to automatically model time series data quickly, without the need for input from the user, and make predictions into the future.
In this recipe, we will learn how to use Prophet to produce forecasts from a sample time series.
## Getting ready
For this recipe, we will need the Pandas package imported as `pd`, the Matplotlib `pyplot` package imported as `plt`, and the `Prophet` object from the Prophet library, which can be imported using the following command:
from fbprophet import Prophet
We also need to import the `generate_sample_data` routine from the `tsdata` module, which is included in the code repository for this book:
from tsdata import generate_sample_data
## How to do it...
The following steps show you how to use the Prophet package to generate forecasts for a sample time series:
1. First, we use `generate_sample_data` to generate the sample time series data:
sample_ts, test_ts = generate_sample_data(undiff=True, trend=0.2)
2. We need to convert the sample data into a `DataFrame` that Prophet expects:
df_for_prophet = pd.DataFrame({
"ds": sample_ts.index, # dates
"y": sample_ts.values # values
})
3. Next, we make a model using the `Prophet` class and fit it to the sample time series:
model = Prophet()
model.fit(df_for_prophet)
4. Now, we create a new `DataFrame` that contains the time intervals for the original time series, plus the additional periods for the forecasts:
forecast_df = model.make_future_dataframe(periods=50)
5. Then, we use the `predict` method to produce the forecasts along the time periods we just created:
forecast = model.predict(forecast_df)
6. Finally, we plot the predictions on top of the sample time series data, along with the confidence interval and the true future values:
fig, ax = plt.subplots(tight_layout=True)
sample_ts.plot(ax=ax, label="观察到的", title="预测")
forecast.plot(x="ds", y="yhat", ax=ax, c="r",
label="预测")
ax.fill_between(forecast["ds"].values, forecast["yhat_lower"].values,
forecast["yhat_upper"].values, color="r", alpha=0.4)
test_ts.plot(ax=ax, c="k", label="未来")
ax.legend()
ax.set_xlabel("日期")
ax.set_ylabel("值")
The plot of the time series, along with forecasts, can be seen in the following figure:

Figure 7.15: Plot of sample time series data, along with forecasts and a confidence interval
## How it works...
Prophet is a package that's used to automatically produce models for time series data based on sample data, with little extra input needed from the user. In practice, it is very easy to use; we just need to create an instance of the `Prophet` class, call the `fit` method, and then we are ready to produce forecasts and understand our data using the model.
The `Prophet` class expects the data in a specific format: a `DataFrame` with columns named `ds` for the date/time index, and `y` for the response data (the time series values). This `DataFrame` should have integer indices. Once the model has been fit, we use `make_future_dataframe` to create a `DataFrame` in the correct format, with appropriate date intervals, and with additional rows for future time intervals. The `predict` method then takes this `DataFrame` and produces values using the model to populate these time intervals with predicted values. We also get other information, such as the confidence intervals, in this forecast's `DataFrame`.
## There's more...
Prophet does a fairly good job of modeling time series data without any input from the user. However, the model can be customized using various methods from the `Prophet` class. For example, we could provide information about the seasonality of the data using the `add_seasonality` method of the `Prophet` class, prior to fitting the model.
There are alternative packages for automatically generating models for time series data. For example, popular machine learning libraries such as TensorFlow can be used to model time series data.
# Further reading
A good textbook on regression in statistics is the book *Probability and Statistics* by Mendenhall, Beaver, and Beaver, as mentioned in Chapter 6, *Working with Data and Statistics*. The following books provide a good introduction to classification and regression in modern data science:
* *James, G. and Witten, D., 2013\. An Introduction To Statistical Learning: With Applications In R. New York: Springer.*
* *Müller, A. and Guido, S., 2016\. Introduction To Machine Learning With Python. Sebastopol: O'Reilly Media.*
A good introduction to time series analysis can be found in the following book:
* *Cryer, J. and Chan, K., 2008\. Time Series Analysis. New York: Springer.**
```**
# 第九章:几何问题
本章描述了关于二维几何的几个问题的解决方案。几何是数学的一个分支,涉及点、线和其他图形(形状)的特征,这些图形之间的相互作用以及这些图形的变换。在本章中,我们将重点关注二维图形的特征以及这些对象之间的相互作用。
在 Python 中处理几何对象时,我们必须克服几个问题。最大的障碍是表示问题。大多数几何对象占据二维平面中的一个区域,因此不可能存储区域内的每个点。相反,我们必须找到一种更紧凑的方式来表示可以存储为相对较少的点的区域。例如,我们可以存储沿对象边界的一些点,从而可以重建边界和对象本身。此外,我们将几何问题重新表述为可以使用代表性数据回答的问题。
第二个最大的问题是将纯几何问题转化为可以使用软件理解和解决的形式。这可能相对简单-例如,找到两条直线相交的点是解决矩阵方程的问题-或者可能非常复杂,这取决于所提出的问题类型。解决这些问题的常见技术是使用更简单的对象表示所讨论的图形,并使用每个简单对象解决(希望)更容易的问题。然后,这应该给我们一个关于原始问题的解决方案的想法。
我们将首先向您展示如何可视化二维形状,然后学习如何确定一个点是否包含在另一个图形中。然后,我们将继续查看边缘检测、三角剖分和寻找凸包。最后,我们将通过构造贝塞尔曲线来结束本章。
本章包括以下教程:
+ 可视化二维几何形状
+ 寻找内部点
+ 在图像中查找边缘
+ 对平面图形进行三角剖分
+ 计算凸包
+ 构造贝塞尔曲线
让我们开始吧!
# 技术要求
对于本章,我们将像往常一样需要`numpy`包和`matplotlib`包。我们还需要 Shapely 包和`scikit-image`包,可以使用您喜欢的软件包管理器(如`pip`)进行安装:
```py
python3.8 -m pip install numpy matplotlib shapely scikit-image
该章节的代码可以在 GitHub 存储库的Chapter 08文件夹中找到:github.com/PacktPublishing/Applying-Math-with-Python/tree/master/Chapter%2008。
查看以下视频以查看代码实际操作:bit.ly/3hpeKEF。
可视化二维几何形状
本章的重点是二维几何,因此我们的第一个任务是学习如何可视化二维几何图形。这里提到的一些技术和工具可能适用于三维几何图形,但通常需要更专门的软件包和工具。
几何图形,至少在本书的上下文中,是指边界是一组线和曲线的任何点、线、曲线或封闭区域(包括边界)的集合。简单的例子包括点和线(显然)、矩形、多边形和圆。
在本教程中,我们将学习如何使用 Matplotlib 可视化几何图形。
准备工作
对于本教程,我们需要导入 NumPy 包作为np,导入 Matplotlib pyplot模块作为plt。我们还需要从 Matplotlib patches模块导入Circle类和从 Matplotlib collections模块导入PatchCollection类。可以使用以下命令完成这些操作:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle
from matplotlib.collections import PatchCollection
我们还需要本章的代码库中的swisscheese-grid-10411.csv数据文件。
如何做...
以下步骤向您展示了如何可视化一个二维几何图形:
- 首先,我们从本书的代码库中的
swisscheese-grid-10411.csv文件加载数据:
data = np.loadtxt("swisscheese-grid-10411.csv")
- 我们创建一个代表绘图区域的新补丁对象。这将是一个圆(圆盘),其中心在原点,半径为
1。我们创建一个新的轴集,并将这个补丁添加到其中:
fig, ax = plt.subplots()
outer = Circle((0.0, 0.0), 1.0, zorder=0, fc="k")
ax.add_patch(outer)
- 接下来,我们从步骤 1中加载的数据创建一个
PatchCollection对象,其中包含了一些其他圆的中心和半径。然后我们将这个PatchCollection添加到步骤 2中创建的轴上:
col = PatchCollection(
(Circle((x, y), r) for x, y, r in data),
facecolor="white", zorder=1, linewidth=0.2,
ls="-", ec="k"
)
ax.add_collection(col)
- 最后,我们设置x-和y-轴的范围,以便整个图像都能显示出来,然后关闭轴线:
ax.set_xlim((-1.1, 1.1))
ax.set_ylim((-1.1, 1.1))
ax.set_axis_off()
结果图像是一个瑞士奶酪,如下所示:
图 8.1:瑞士奶酪的绘图
它是如何工作的...
这个食谱的关键是Circle和PatchCollection对象,它们代表了 Matplotlib Axes上的绘图区域的区域。在这种情况下,我们创建了一个大的圆形补丁,它位于原点,半径为1,具有黑色的面颜色,并使用zorder=0将其放在其他补丁的后面。这个补丁被添加到Axes对象中使用add_patch方法。
下一步是创建一个对象,它将呈现从 CSV 文件中加载的数据所代表的圆。这些数据包括中心(x,y)和半径r的值,用于表示个别圆的中心和半径(总共 10,411 个)。PatchCollection对象将一系列补丁组合成一个单一的对象,可以添加到Axes对象中。在这里,我们为我们的数据中的每一行添加了一个Circle,然后使用add_collection方法将其添加到Axes对象中。请注意,我们已经将面颜色应用到整个集合,而不是每个单独的Circle成员。我们将面颜色设置为白色(使用facecolor="w"参数),边缘颜色设置为黑色(使用ec="k"),边缘线宽设置为 0.2(使用linewidth=0.2),边缘样式设置为连续线。所有这些放在一起,就得到了我们的图像。
我们在这里创建的图像被称为“瑞士奶酪”。这些最初是由爱丽丝·罗斯在 1938 年在有理逼近理论中使用的;随后它们被重新发现,并且类似的构造自那时以来已经被多次使用。我们使用这个例子是因为它由一个大的个体部分和一个大量的较小的个体部分组成。罗斯的瑞士奶酪是平面上具有正面积但没有拓扑内部的一个集合的例子。(这样一个集合甚至能存在是相当惊人的!)更重要的是,有一些连续函数在这个瑞士奶酪上是不能被有理函数逼近的。这个特性使得类似的构造在均匀 代数理论中非常有用。
Circle类是更一般的Patch类的子类。还有许多其他Patch类,代表不同的平面图形,比如Polygon和PathPatch,它们代表了由路径(曲线或曲线集合)所界定的区域。这些可以用来生成可以在 Matplotlib 图中呈现的复杂补丁。集合可以用来同时应用设置到多个补丁对象上,这在本例中特别有用,因为你有大量的对象都将以相同的样式呈现。
还有更多...
Matplotlib 中有许多不同的补丁类型。在本教程中,我们使用了Circle补丁类,它表示坐标轴上的圆形区域。还有Polygon补丁类,它表示多边形(规则或其他)。还有PatchPath对象,它们是由不一定由直线段组成的曲线包围的区域。这类似于许多矢量图形软件包中可以构造阴影区域的方式。
除了 Matplotlib 中的单个补丁类型外,还有许多集合类型,它们将许多补丁聚集在一起,以便作为单个对象使用。在本教程中,我们使用了PatchCollection类来收集大量的Circle补丁。还有更多专门的补丁集合,可以用来自动生成这些内部补丁,而不是我们自己生成它们。
另请参阅
在数学中,可以在以下传记文章中找到关于瑞士奶酪的更详细的历史:Daepp,U., Gauthier, P., Gorkin, P. and Schmieder, G., 2005. Alice in Switzerland: The life and mathematics of Alice Roth. The Mathematical Intelligencer, 27(1), pp.41-54。
查找内部点
在编程环境中处理二维图形的一个问题是,您不可能存储所有位于图形内的点。相反,我们通常存储表示图形的远少于的点。在大多数情况下,这将是一些点(通过线连接)来描述图形的边界。这在内存方面是有效的,并且可以使用 MatplotlibPatches轻松在屏幕上可视化它们。但是,这种方法使确定点或其他图形是否位于给定图形内变得更加困难。这是许多几何问题中的一个关键问题。
在本教程中,我们将学习如何表示几何图形并确定点是否位于图形内。
做好准备
对于本教程,我们需要将matplotlib包(整体)导入为mpl,将pyplot模块导入为plt:
import matplotlib as mpl
import matplotlib.pyplot as plt
我们还需要从 Shapely 包的geometry模块中导入Point和Polygon对象。Shapely 包包含许多用于表示、操作和分析二维几何图形的例程和对象:
from shapely.geometry import Polygon, Point
如何操作...
以下步骤向您展示如何创建多边形的 Shapely 表示,然后测试点是否位于此多边形内:
- 创建一个样本多边形进行测试:
polygon = Polygon(
[(0, 2), (-1, 1), (-0.5, -1), (0.5, -1), (1, 1)],
)
- 接下来,我们在新图上绘制多边形。首先,我们需要将多边形转换为可以添加到图中的 Matplotlib
Polygon补丁:
fig, ax = plt.subplots()
poly_patch = mpl.patches.Polygon(polygon.exterior, ec="k",
lw="1", alpha=0.5)
ax.add_patch(poly_patch)
ax.set(xlim=(-1.05, 1.05), ylim=(-1.05, 2.05))
ax.set_axis_off()
- 现在,我们需要创建两个测试点,其中一个将位于多边形内,另一个将位于多边形外:
p1 = Point(0.0, 0.0)
p2 = Point(-1.0, -0.75)
- 我们在多边形上方绘制并注释这两个点,以显示它们的位置:
ax.plot(0.0, 0.0, "k*")
ax.annotate("p1", (0.0, 0.0), (0.05, 0.0))
ax.plot(-0.8, -0.75, "k*")
ax.annotate("p2", (-0.8, -0.75), (-0.8 + 0.05, -0.75))
- 最后,我们使用
contains方法测试每个点是否位于多边形内,然后将结果打印到终端:
print("p1 inside polygon?", polygon.contains(p1))
print("p2 inside polygon?", polygon.contains(p2))
结果显示,第一个点p1包含在多边形内,而第二个点p2不包含。这也可以在下图中看到,清楚地显示了一个点包含在阴影多边形内,而另一个点不包含:

图 8.2:多边形区域内外的点
工作原理...
ShapelyPolygon类是多边形的表示,它将其顶点存储为点。外边界包围的区域-存储顶点之间的五条直线-对我们来说是明显的,并且很容易被眼睛识别,但是“在”边界内的概念很难以一种计算机容易理解的方式定义。甚至很难给出关于“在”给定曲线内的含义的正式数学定义。
确定点是否位于简单闭合曲线内有两种主要方法 - 即从同一位置开始并结束且不包含任何自交点的曲线。第一种方法使用数学概念称为绕数,它计算曲线“绕”点的次数,以及射线交叉计数方法,其中我们计算从点到无穷远处的点的射线穿过曲线的次数。幸运的是,我们不需要自己计算这些数字,因为我们可以使用 Shapely 包中的工具来为我们执行这些计算。这就是多边形的contains方法所做的。(在底层,Shapely 使用 GEOS 库执行此计算。)
Shapely 的Polygon类可用于计算与这些平面图形相关的许多数量,包括周长和面积。contains方法用于确定点或一组点是否位于对象表示的多边形内(该类有关多边形的表示存在一些限制)。实际上,您可以使用相同的方法来确定一个多边形是否包含在另一个多边形内,因为正如我们在这个示例中看到的,多边形由一组简单的点表示。
在图像中找到边缘
在图像中找到边缘是减少包含大量噪音和干扰的复杂图像为包含最突出轮廓的非常简单图像的好方法。这可以作为我们分析过程的第一步,例如在图像分类中,或者作为将线轮廓导入计算机图形软件包的过程。
在这个示例中,我们将学习如何使用scikit-image包和 Canny 算法来找到复杂图像中的边缘。
准备工作
对于这个示例,我们需要将 Matplotlib 的pyplot模块导入为plt,从skimage.io模块导入imread例程,以及从skimage.feature模块导入canny例程:
import matplotlib.pyplot as plt
from skimage.io import imread
from skimage.feature import canny
如何做到...
按照以下步骤学习如何使用scikit-image包在图像中找到边缘:
- 从源文件加载图像数据。这可以在本章的 GitHub 存储库中找到。关键是,我们传入
as_gray=True以以灰度加载图像:
image = imread("mandelbrot.png", as_gray=True)
以下是原始图像,供参考。集合本身由白色区域显示,如您所见,边界由较暗的阴影表示,非常复杂:

图 8.3:使用 Python 生成的 Mandelbrot 集合的绘图
- 接下来,我们使用
canny例程,需要从scikit-image包的features模块导入。对于这个图像,sigma值设置为 0.5:
edges = canny(image, sigma=0.5)
- 最后,我们将
edges图像添加到一个新的图中,使用灰度(反转)色图:
fig, ax = plt.subplots()
ax.imshow(edges, cmap="gray_r")
ax.set_axis_off()
已检测到的边缘可以在以下图像中看到。边缘查找算法已经识别出 Mandelbrot 集合边界的大部分可见细节,尽管并不完美(毕竟这只是一个估计):
图 8.4:使用 scikit-image 包的 Canny 边缘检测算法找到的 Mandelbrot 集合的边缘
它是如何工作的...
scikit-image包提供了各种用于操作和分析从图像中导出的数据的实用程序和类型。正如其名称所示,canny例程使用 Canny 边缘检测算法来找到图像中的边缘。该算法使用图像中的强度梯度来检测边缘,其中梯度较大。它还执行一些过滤以减少它找到的边缘中的噪音。
我们提供的sigma关键字值是应用于图像的高斯平滑的标准偏差,用于计算边缘检测。这有助于我们去除图像中的一些噪音。我们设置的值(0.5)小于默认值(1),但在这种情况下可以提供更好的分辨率。较大的值会遮盖 Mandelbrot 集边界的一些细节。
对平面图形进行三角剖分
正如我们在第三章中看到的,微积分和微分方程,我们经常需要将连续区域分解为更小、更简单的区域。在之前的示例中,我们将实数区间缩小为一系列长度较小的小区间。这个过程通常称为离散化。在本章中,我们正在处理二维图形,因此我们需要这个过程的二维版本。为此,我们将一个二维图形(在这个示例中是一个多边形)分解为一系列更小和更简单的多边形。所有多边形中最简单的是三角形,因此这是二维离散化的一个很好的起点。找到一组"铺砌"几何图形的三角形的过程称为三角剖分。
在这个示例中,我们将学习如何使用 Shapely 包对多边形(带有孔)进行三角剖分。
准备工作
在这个示例中,我们需要将 NumPy 包导入为np,将 Matplotlib 包导入为mpl,并将pyplot模块导入为plt。
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
我们还需要从 Shapely 包中获取以下项目:
from shapely.geometry import Polygon
from shapely.ops import triangulate
如何做...
以下步骤向您展示了如何使用 Shapely 包对带有孔的多边形进行三角剖分:
- 首先,我们需要创建一个代表我们希望进行三角剖分的图形的
Polygon对象:
polygon = Polygon(
[(2.0, 1.0), (2.0, 1.5), (-4.0, 1.5), (-4.0, 0.5),
(-3.0, -1.5), (0.0, -1.5), (1.0, -2.0), (1.0, -0.5),
(0.0, -1.0), (-0.5, -1.0), (-0.5, 1.0)],
holes=[np.array([[-1.5, -0.5], [-1.5, 0.5], [-2.5, 0.5],
[-2.5, -0.5]])]
)
- 现在,我们应该绘制图形,以便了解我们将在其中工作的区域:
fig, ax = plt.subplots()
plt_poly = mpl.patches.Polygon(polygon.exterior,
ec="k", lw="1", alpha=0.5, zorder=0)
ax.add_patch(plt_poly)
plt_hole = mpl.patches.Polygon(polygon.interiors[0],
ec="k", fc="w")
ax.add_patch(plt_hole)
ax.set(xlim=(-4.05, 2.05), ylim=(-2.05, 1.55))
ax.set_axis_off()
可以在下图中看到这个多边形。正如我们所看到的,这个图形中有一个"孔",必须仔细考虑:

图 8.5:带有孔的示例多边形
- 我们使用
triangulate例程生成多边形的三角剖分。这个三角剖分包括外部边缘,这是我们在这个示例中不想要的:
triangles = triangulate(polygon)
- 为了去除位于原始多边形外部的三角形,我们需要使用内置的
filter例程,以及contains方法(在本章前面已经看到):
filtered = filter(lambda p: polygon.contains(p), triangles)
- 将三角形绘制在原始多边形上,我们需要将 Shapely 三角形转换为 Matplotlib
Patch对象,然后将其存储在PatchCollection中:
patches = map(lambda p: mpl.patches.Polygon(p.exterior), filtered)
col = mpl.collections.PatchCollection(patches, fc="none", ec="k")
- 最后,我们将三角形补丁的集合添加到之前创建的图形中:
ax.add_collection(col)
在原始多边形上绘制的三角剖分可以在下图中看到。在这里,我们可以看到每个顶点都连接到另外两个顶点,形成了覆盖整个原始多边形的三角形系统:
图 8.6:带有孔的示例多边形的三角剖分
它是如何工作的...
triangulate例程使用一种称为Delaunay 三角剖分的技术将一组点连接到一组三角形中。在这种情况下,这组点是多边形的顶点。Delaunay 方法以这样一种方式找到这些三角形,即没有任何点包含在任何三角形的外接圆内。这是该方法的技术条件,但这意味着三角形被有效地选择,因为它避免了非常长、细的三角形。得到的三角剖分利用了原始多边形中存在的边缘,并连接了一些外部边缘。
为了去除原多边形外的三角形,我们使用内置的filter例程,它通过移除标准函数失败的项目来创建一个新的可迭代对象。这与 Shapely Polygon对象上的contains方法一起使用,以确定每个三角形是否位于原始图形内。正如我们之前提到的,我们需要将这些 Shapely 项目转换为 Matplotlib 补丁,然后才能将它们添加到图中。
还有更多...
三角剖分通常用于将复杂的几何图形简化为一组三角形,这些三角形对于某种计算任务来说要简单得多。然而,它们也有其他用途。三角剖分的一个特别有趣的应用是解决“艺术画廊问题”。这个问题涉及找到必要的“守卫”艺术画廊的最大数量。三角剖分是 Fisk 对艺术画廊定理的简单证明的重要部分,这个定理最初是由 Chvátal 证明的。
假设这个食谱中的多边形是一个艺术画廊的平面图,并且一些守卫需要放置在顶点上。一点工作就会表明,你需要在多边形的顶点处放置三个守卫,整个博物馆才能被覆盖。在下面的图像中,我们绘制了一个可能的布局:

图 8.7:在顶点上放置守卫的艺术画廊问题的一个可能解决方案。
点由点表示,并且它们相应的视野范围被阴影表示。
每个顶点都放置了一个守卫,并且他们的视野范围由相应的阴影区域表示。在这里,你可以看到整个多边形至少被一种颜色覆盖。艺术画廊问题的解决方案——实际上是原问题的一个变体——告诉我们,最多需要四名守卫。
另请参阅
关于艺术画廊问题的更多信息可以在 O'Rourke 的经典著作中找到:ORourke, J. (1987). Art gallery theorems and algorithms. New York: Oxford University Press.
计算凸包
如果图形内的每一对点都可以使用一条直线连接,并且这条直线也包含在图形内,那么几何图形被称为凸。凸体的简单例子包括点、直线、正方形、圆(圆盘)、正多边形等。图 8.5 中显示的几何图形不是凸的,因为孔的对面的点不能通过保持在图形内的直线连接起来。
从某种角度来看,凸图形是简单的,这意味着它们在各种应用中都很有用。一个特别的问题涉及找到包含一组点的最小凸集。这个最小凸集被称为这组点的凸包。
在这个食谱中,我们将学习如何使用 Shapely 包找到一组点的凸包。
准备工作
对于这个食谱,我们需要导入 NumPy 包作为np,导入 Matplotlib 包作为mpl,并导入plt模块:
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
我们还需要从 NumPy 导入默认的随机数生成器。我们可以这样导入:
from numpy.random import default_rng
rng = default_rng(12345)
最后,我们需要从 Shapely 导入MultiPoint类:
from shapely.geometry import MultiPoint
操作方法...
按照以下步骤找到一组随机生成点的凸包:
- 首先,我们生成一个二维数组的随机数:
raw_points = rng.uniform(-1.0, 1.0, size=(50, 2))
- 接下来,我们创建一个新图形,并在这个图形上绘制这些原始样本点:
fig, ax = plt.subplots()
ax.plot(raw_points[:, 0], raw_points[:, 1], "k.")
ax.set_axis_off()
这些随机生成的点可以在下图中看到。这些点大致分布在一个正方形区域内:
图 8.8:平面上的一组点
- 接下来,我们构建一个
MultiPoint对象,收集所有这些点并将它们放入一个单一对象中:
points = MultiPoint(raw_points)
- 现在,我们使用
convex_hull属性获取这个MultiPoint对象的凸包:
convex_hull = points.convex_hull
- 然后,我们创建一个 Matplotlib
Polygon补丁,可以在我们的图中绘制,以显示找到的凸包的结果:
patch = mpl.patches.Polygon(convex_hull.exterior, alpha=0.5,
ec="k", lw=1.2)
- 最后,我们将
Polygon补丁添加到图中,以显示凸包:
ax.add_patch(patch)
随机生成的点的凸包可以在下图中看到:
图 8.9:平面上一组点的凸包
工作原理...
Shapely 包是围绕 GEOS 库的 Python 包装器,用于几何分析。Shapely 几何对象的convex_hull属性调用 GEOS 库中的凸包计算例程,从而产生一个新的 Shapely 对象。从这个教程中,我们可以看到一组点的凸包是一个多边形,其顶点是离“中心”最远的点。
构造贝塞尔曲线
贝塞尔曲线,或B 样条,是一族曲线,在矢量图形中非常有用-例如,它们通常用于高质量的字体包中。这是因为它们由少量点定义,然后可以用来廉价地计算沿曲线的大量点。这允许根据用户的需求来缩放细节。
在本教程中,我们将学习如何创建一个表示贝塞尔曲线的简单类,并计算沿其路径的若干点。
准备工作
在本教程中,我们将使用导入为np的 NumPy 包,导入为plt的 Matplotlib pyplot模块,以及 Python 标准库math模块中导入为binom的comb例程:
from math import comb as binom
import matplotlib.pyplot as plt
import numpy as np
如何做...
按照以下步骤定义一个表示贝塞尔曲线的类,该类可用于计算沿曲线的点:
- 第一步是设置基本类。我们需要为实例属性提供控制点(节点)和一些相关的数字:
class Bezier:
def __init__(self, *points):
self.points = points
self.nodes = n = len(points) - 1
self.degree = l = points[0].size
- 仍然在
__init__方法中,我们生成贝塞尔曲线的系数,并将它们存储在实例属性的列表中:
self.coeffs = [binom(n, i)*p.reshape((l, 1)) for i,
p in enumerate(points)]
- 接下来,我们定义一个
__call__方法,使类可调用。我们将实例中的节点数加载到本地变量中,以便清晰明了:
def __call__(self, t):
n = self.nodes
- 接下来,我们重新整理输入数组,使其包含单行:
t = t.reshape((1, t.size))
- 现在,我们使用实例的
coeffs属性中的每个系数生成值数组的列表:
vals = [c @ (t**i)*(1-t)**(n-i) for i,
c in enumerate(self.coeffs)]
- 最后,我们对步骤 5中构造的所有数组进行求和,并返回结果数组:
return np.sum(vals, axis=0)
- 现在,我们将通过一个示例来测试我们的类。我们将为此示例定义四个控制点:
p1 = np.array([0.0, 0.0])
p2 = np.array([0.0, 1.0])
p3 = np.array([1.0, 1.0])
p4 = np.array([1.0, 3.0])
- 接下来,我们为绘图设置一个新的图形,并用虚线连接线绘制控制点:
fig, ax = plt.subplots()
ax.plot([0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 1.0, 3.0], "*--k")
ax.set(xlabel="x", ylabel="y", title="Bezier curve with
4 nodes, degree 3")
- 然后,我们使用步骤 7中定义的四个点创建我们的
Bezier类的新实例:
b_curve = Bezier(p1, p2, p3, p4)
- 现在,我们可以使用
linspace创建 0 到 1 之间等间距点的数组,并计算沿着贝塞尔曲线的点:
t = np.linspace(0, 1)
v = b_curve(t)
- 最后,我们在之前绘制的控制点上绘制这条曲线:
ax.plot(v[0,:], v[1, :])
我们绘制的贝塞尔曲线可以在下图中看到。正如你所看到的,曲线从第一个点(0, 0)开始,结束于最终点(1, 3):
图 8.10:使用四个节点构造的三次贝塞尔曲线
工作原理...
贝塞尔曲线由一系列控制点描述,我们以递归方式构造曲线。一个点的贝塞尔曲线是一个保持在该点的常数曲线。具有两个控制点的贝塞尔曲线是这两个点之间的线段:

当我们添加第三个控制点时,我们取对应点之间的线段,这些点是由一个较少点构成的贝塞尔曲线的曲线。这意味着我们使用以下公式构造具有三个控制点的贝塞尔曲线:

这种构造可以在下图中看到:
图 8.11:使用递归定义构造二次贝塞尔曲线。黑色虚线显示了两条线性贝塞尔曲线。
这种构造方式继续定义了任意数量控制点上的贝塞尔曲线。幸运的是,在实践中我们不需要使用这种递归定义,因为我们可以将公式展开成曲线的单一公式,即以下公式:

在这里,p[i]元素是控制点,t是一个参数,而

是二项式系数。请记住,t参数是生成曲线点的变化量。我们可以分离前述求和中涉及t的项和不涉及t的项。这定义了我们在步骤 2中定义的系数,每个系数由以下代码片段给出:
binom(n, i)*p.reshape((l, 1))
我们在这一步中对每个点p进行了 reshape,以确保它被排列为列向量。这意味着每个系数都是一个列向量(作为 NumPy 数组),由二项式系数缩放的控制点组成。
现在,我们需要指定如何在不同的t值上评估贝塞尔曲线。这就是我们利用 NumPy 包中的高性能数组操作的地方。在形成系数时,我们将控制点 reshape 为列向量。在步骤 4中,我们将输入t值 reshape 为行向量。这意味着我们可以使用矩阵乘法运算符将每个系数乘以相应的(标量)值,具体取决于输入的t。这就是步骤 5中列表推导式中发生的情况。在下一行中,我们将l×1数组乘以1×N数组,得到一个l×N数组:
c @ (t**i)*(1-t)**(n-i)
我们为每个系数都得到一个这样的数组。然后,我们可以使用np.sum例程来对这些l×N数组中的每一个进行求和,以得到贝塞尔曲线上的值。在本示例中,输出数组的顶行包含曲线的x值,底行包含曲线的y值。在指定axis=0关键字参数时,我们必须小心确保sum例程对我们创建的列表进行求和,而不是对该列表包含的数组进行求和。
我们定义的类是使用贝塞尔曲线的控制点进行初始化的,然后用于生成系数。曲线值的实际计算是使用 NumPy 完成的,因此这种实现应该具有相对良好的性能。一旦创建了这个类的特定实例,它的功能就非常像一个函数,正如你所期望的那样。但是,这里没有进行类型检查,所以我们只能用 NumPy 数组作为参数来调用这个“函数”。
还有更多...
贝塞尔曲线是使用迭代构造定义的,其中具有n个点的曲线是使用连接由第一个和最后一个n-1点定义的曲线来定义的。使用这种构造跟踪每个控制点的系数将很快导致我们用来定义前述曲线的方程。这种构造还导致贝塞尔曲线的有趣和有用的几何特性。
正如我们在这个配方的介绍中提到的,贝塞尔曲线出现在许多涉及矢量图形的应用程序中,比如字体。它们也出现在许多常见的矢量图形软件包中。在这些软件包中,通常会看到二次贝塞尔曲线,它们由三个点的集合定义。然而,你也可以通过提供两个端点以及这些点上的梯度线来定义一个二次贝塞尔曲线。这在图形软件包中更常见。生成的贝塞尔曲线将沿着梯度线离开每个端点,并在这些点之间平滑地连接曲线。
我们在这里构建的实现对于小型应用程序来说性能相对较好,但对于涉及在大量t值上渲染具有大量控制点的曲线的应用程序来说是不够的。对于这一点,最好使用一个用编译语言编写的低级软件包。例如,bezier Python 软件包使用编译的 Fortran 后端进行计算,并提供比我们在这里定义的类更丰富的接口。
当然,贝塞尔曲线可以自然地扩展到更高的维度。结果是一个贝塞尔曲面,使它们成为非常有用的通用工具,用于高质量、可伸缩的图形。
进一步阅读
-
计算几何中一些常见算法的描述可以在以下书籍中找到:Press, W.H., Teukolsky, S.A., Vetterling, W.T., and Flannery, B.P., 2007. Numerical recipes: the art of scientific computing**. 3rd ed. Cambridge: Cambridge University Press。
-
有关计算几何中一些问题和技术的更详细描述,请查阅以下书籍:O'Rourke, J., 1994. Computational geometry in C**. Cambridge: Cambridge University Press。
第十章:寻找最优解
在本章中,我们将讨论寻找给定情况下最佳结果的各种方法。这被称为优化,通常涉及最小化或最大化目标函数。目标函数是一个接受多个参数作为参数并返回代表给定参数选择的成本或回报的单个标量值的函数。关于最小化和最大化函数的问题实际上是相互等价的,因此我们只会在本章讨论最小化目标函数。最小化函数f(x)等同于最大化函数-f(x)。在我们讨论第一个配方时将提供更多细节。
我们可以利用的算法来最小化给定函数取决于函数的性质。例如,包含一个或多个变量的简单线性函数与具有许多变量的非线性函数相比,可用的算法不同。线性函数的最小化属于线性规划范畴,这是一个发展完善的理论。对于非线性函数,我们通常利用函数的梯度(导数)来寻找最小点。我们将讨论几种不同类型函数的最小化方法。
寻找单变量函数的极小值和极大值特别简单,如果函数的导数已知,可以轻松完成。如果不知道导数,则适用于适当配方的方法。最小化非线性函数配方中的注释提供了一些额外细节。
我们还将提供一个非常简短的介绍博弈论。广义上讲,这是一个围绕决策制定的理论,并在经济学等学科中具有广泛的影响。特别是,我们将讨论如何在 Python 中将简单的双人游戏表示为对象,计算与某些选择相关的回报,并计算这些游戏的纳什均衡。
我们将首先看如何最小化包含一个或多个变量的线性和非线性函数。然后,我们将继续研究梯度下降方法和使用最小二乘法进行曲线拟合。最后,我们将通过分析双人游戏和纳什均衡来结束本章。
在本章中,我们将涵盖以下配方:
-
最小化简单线性函数
-
最小化非线性函数
-
使用梯度下降法进行优化
-
使用最小二乘法拟合数据的曲线
-
分析简单的双人游戏
-
计算纳什均衡
让我们开始吧!
技术要求
在本章中,我们将像往常一样需要 NumPy 包、SciPy 包和 Matplotlib 包。我们还将需要 Nashpy 包用于最后两个配方。这些包可以使用您喜欢的包管理器(如pip)进行安装:
python3.8 -m pip install numpy scipy matplotlib nashpy
本章的代码可以在 GitHub 存储库的Chapter 09文件夹中找到,网址为github.com/PacktPublishing/Applying-Math-with-Python/tree/master/Chapter%2009。
查看以下视频以查看代码的实际操作:bit.ly/2BjzwGo。
最小化简单线性函数
在优化中我们面临的最基本问题是找到函数取得最小值的参数。通常,这个问题受到参数可能值的一些限制的约束,这增加了问题的复杂性。显然,如果我们要最小化的函数也很复杂,那么这个问题的复杂性会进一步增加。因此,我们必须首先考虑线性函数,它们的形式如下:

为了解决这些问题,我们需要将约束转化为计算机可用的形式。在这种情况下,我们通常将它们转化为线性代数问题(矩阵和向量)。一旦完成了这一步,我们就可以使用 NumPy 和 SciPy 中的线性代数包中的工具来找到我们所寻求的参数。幸运的是,由于这类问题经常发生,SciPy 有处理这种转化和随后求解的例程。
在这个配方中,我们将使用 SciPy optimize模块的例程来解决以下受限线性最小化问题:

这将受到以下条件的约束:

准备工作
对于这个配方,我们需要在别名np下导入 NumPy 包,以plt的名称导入 Matplotlib pyplot模块,以及 SciPy optimize模块。我们还需要从mpl_toolkits.mplot3d导入Axes3D类,以使 3D 绘图可用:
import numpy as np
from scipy import optimize
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
如何做...
按照以下步骤使用 SciPy 解决受限线性最小化问题:
- 将系统设置为 SciPy 可以识别的形式:
A = np.array([
[2, 1], # 2*x0 + x1 <= 6
[-1, -1] # -x0 - x1 <= -4
])
b = np.array([6, -4])
x0_bounds = (-3, 14) # -3 <= x0 <= 14
x1_bounds = (2, 12) # 2 <= x1 <= 12
c = np.array([1, 5])
- 接下来,我们需要定义一个评估线性函数在x值处的例程,这是一个向量(NumPy 数组):
def func(x):
return np.tensordot(c, x, axes=1)
- 然后,我们创建一个新的图并添加一组
3d轴,我们可以在上面绘制函数:
fig = plt.figure()
ax = fig.add_subplot(projection="3d")
ax.set(xlabel="x0", ylabel="x1", zlabel="func")
ax.set_title("Values in Feasible region")
- 接下来,我们创建一个覆盖问题区域的值网格,并在该区域上绘制函数的值:
X0 = np.linspace(*x0_bounds)
X1 = np.linspace(*x1_bounds)
x0, x1 = np.meshgrid(X0, X1)
z = func([x0, x1])
ax.plot_surface(x0, x1, z, alpha=0.3)
- 现在,我们在函数值平面上绘制与临界线
2*x0 + x1 == 6对应的线,并在我们的图上绘制落在范围内的值:
Y = (b[0] - A[0, 0]*X0) / A[0, 1]
I = np.logical_and(Y >= x1_bounds[0], Y <= x1_bounds[1])
ax.plot(X0[I], Y[I], func([X0[I], Y[I]]), "r", lw=1.5)
- 我们重复这个绘图步骤,对第二条临界线
x0 + x1 == -4:
Y = (b[1] - A[1, 0]*X0) / A[1, 1]
I = np.logical_and(Y >= x1_bounds[0], Y <= x1_bounds[1])
ax.plot(X0[I], Y[I], func([X0[I], Y[I]]), "r", lw=1.5)
- 接下来,我们着色位于两条临界线之间的区域,这对应于最小化问题的可行区域:
B = np.tensordot(A, np.array([x0, x1]), axes=1)
II = np.logical_and(B[0, ...] <= b[0], B[1, ...] <= b[1])
ax.plot_trisurf(x0[II], x1[II], z[II], color="b", alpha=0.5)
函数值在可行区域上的图可以在以下图片中看到:
图 9.1:突出显示了可行区域的线性函数值
正如我们所看到的,位于这个着色区域内的最小值发生在两条临界线的交点处。
- 接下来,我们使用
linprog来解决带有我们在步骤 1中创建的边界的受限最小化问题。我们在终端中打印出结果对象:
res = optimize.linprog(c, A_ub=A, b_ub=b, bounds=
(x0_bounds, x1_bounds))
print(res)
- 最后,我们在可行区域上绘制最小函数值:
ax.plot([res.x[0]], [res.x[1]], [res.fun], "k*")
更新后的图可以在以下图片中看到:
图 9.2:在可行区域上绘制的最小值
在这里,我们可以看到linprog例程确实发现了最小值在两条临界线的交点处。
工作原理...
受限线性最小化问题在经济情况中很常见,您尝试在保持参数的其他方面的同时最小化成本。实际上,优化理论中的许多术语都反映了这一事实。解决这些问题的一个非常简单的算法称为单纯形法,它使用一系列数组操作来找到最小解。从几何上讲,这些操作代表着改变单纯形的不同顶点(我们在这里不定义),正是这一点赋予了算法其名称。
在我们继续之前,我们将简要概述单纯形法用于解决受限线性优化问题的过程。我们所面临的问题不是一个矩阵方程问题,而是一个矩阵不等式问题。我们可以通过引入松弛变量来解决这个问题,将不等式转化为等式。例如,通过引入松弛变量s[1],可以将第一个约束不等式重写如下:

只要s[1]不是负数,这就满足了所需的不等式。第二个约束不等式是大于或等于类型的不等式,我们必须首先将其更改为小于或等于类型。我们通过将所有项乘以-1 来实现这一点。这给了我们在配方中定义的矩阵A的第二行。引入第二个松弛变量s[2]后,我们得到了第二个方程:

从中,我们可以构建一个矩阵,其列包含两个参数变量x[1]和x[2]以及两个松弛变量s[1]和s[2]的系数。该矩阵的行代表两个边界方程和目标函数。现在可以使用对该矩阵进行初等行操作来解决这个方程组,以获得最小化目标函数的x[1]和x[2]的值。由于解决矩阵方程很容易且快速,这意味着我们可以快速高效地最小化线性函数。
幸运的是,我们不需要记住如何将不等式系统化简为线性方程组,因为诸如linprog之类的例程会为我们完成这一工作。我们只需将边界不等式提供为矩阵和向量对,包括每个系数,以及定义目标函数的单独向量。linprog例程负责制定和解决最小化问题。
实际上,单纯形法并不是linprog例程用于最小化函数的算法。相反,linprog使用内点算法,这更有效率。(可以通过提供method关键字参数并使用适当的方法名称将方法设置为simplex或revised-simplex。在打印的输出结果中,我们可以看到只用了五次迭代就达到了解决方案。)该例程返回的结果对象包含最小值发生的参数值存储在x属性中,该最小值存储在fun属性中,以及有关解决过程的各种其他信息。如果方法失败,那么status属性将包含一个数值代码,描述方法失败的原因。
在本文的步骤 2中,我们创建了一个代表此问题的目标函数的函数。该函数以单个数组作为输入,该数组包含应在其上评估函数的参数空间值。在这里,我们使用了 NumPy 的tensordot例程(带有axes=1)来评估系数向量c与每个输入x的点积。在这里我们必须非常小心,因为我们传递给函数的值在后续步骤中将是一个 2×50×50 的数组。普通的矩阵乘法(np.dot)在这种情况下不会给出我们所期望的 50×50 数组输出。
在步骤 5和6中,我们计算了临界线上的点,这些点具有以下方程:

然后我们计算了相应的z值,以便绘制在由目标函数定义的平面上的线。我们还需要“修剪”这些值,以便只包括在问题中指定范围内的值。
还有更多...
本文介绍了受约束的最小化问题以及如何使用 SciPy 解决它。然而,相同的方法也可以用于解决受约束的最大化问题。这是因为最大化和最小化在某种意义上是对偶的,即最大化函数f(x)等同于最小化函数-f(x),然后取其负值。事实上,我们在本文中使用了这一事实,将第二个约束不等式从≥改为≤。
在这个示例中,我们解决了一个只有两个参数变量的问题,但是相同的方法将适用于涉及两个以上这样的变量的问题(除了绘图步骤)。我们只需要向每个数组添加更多的行和列,以考虑这增加的变量数量 - 这包括提供给例程的边界元组。在处理非常大量的变量时,例程也可以与稀疏矩阵一起使用,以获得额外的效率。
linprog例程的名称来自线性规划,用于描述这种类型的问题 - 找到满足一些矩阵不等式的x的值,受其他条件的限制。由于与矩阵理论和线性代数的理论有非常紧密的联系,因此在线性规划问题中有许多非常快速和高效的技术,这些技术在非线性情境中是不可用的。
最小化非线性函数
在上一个示例中,我们看到了如何最小化一个非常简单的线性函数。不幸的是,大多数函数都不是线性的,通常也没有我们希望的良好性质。对于这些非线性函数,我们不能使用为线性问题开发的快速算法,因此我们需要设计可以在这些更一般情况下使用的新方法。我们将使用的算法称为 Nelder-Mead 算法,这是一种健壮且通用的方法,用于找到函数的最小值,并且不依赖于函数的梯度。
在这个示例中,我们将学习如何使用 Nelder-Mead 单纯形法来最小化包含两个变量的非线性函数。
准备工作
在这个示例中,我们将使用导入为np的 NumPy 包,导入为plt的 Matplotlib pyplot模块,从mpl_toolkits.mplot3d导入的Axes3D类以启用 3D 绘图,以及 SciPy optimize模块:
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
from scipy import optimize
如何做...
以下步骤向您展示如何使用 Nelder-Mead 单纯形法找到一般非线性目标函数的最小值:
- 定义我们将最小化的目标函数:
def func(x):
return ((x[0] - 0.5)**2 + (x[1] + 0.5)**2)*
np.cos(0.5*x[0]*x[1])
- 接下来,创建一个值网格,我们可以在上面绘制我们的目标函数:
x_r = np.linspace(-1, 1)
y_r = np.linspace(-2, 2)
x, y = np.meshgrid(x_r, y_r)
- 现在,我们在这个点网格上评估函数:
z = func([x, y])
- 接下来,我们创建一个带有
3d轴对象的新图,并设置轴标签和标题:
fig = plt.figure(tight_layout=True)
ax = fig.add_subplot(projection="3d")
ax.tick_params(axis="both", which="major", labelsize=9)
ax.set(xlabel="x", ylabel="y", zlabel="z")
ax.set_title("Objective function")
- 现在,我们可以在我们刚刚创建的轴上将目标函数绘制为表面:
ax.plot_surface(x, y, z, alpha=0.7)
- 我们选择一个初始点,我们的最小化例程将从该点开始迭代,并在表面上绘制这个点:
x0 = np.array([-0.5, 1.0])
ax.plot([x0[0]], [x0[1]], func(x0), "r*")
可以在以下图像中看到目标函数表面的绘图,以及初始点。在这里,我们可以看到最小值似乎出现在 x 轴上约 0.5,y 轴上约-0.5 的位置:
图 9.3:具有起始值的非线性目标函数
- 现在,我们使用
optimize包中的minimize例程来找到最小值,并打印它产生的result对象:
result = optimize.minimize(func, x0, tol=1e-6, method=
"Nelder-Mead")
print(result)
- 最后,我们在目标函数表面上绘制
minimize例程找到的最小值:
ax.plot([result.x[0]], [result.x[1]], [result.fun], "r*")
包括minimize例程找到的最小点的目标函数的更新绘图可以在以下图像中看到:
图 9.4:具有起始点和最小点的目标函数
它是如何工作的...
Nelder-Mead 单纯形法-不要与线性优化问题的单纯形法混淆-是一种简单的算法,用于找到非线性函数的最小值,即使目标函数没有已知的导数也可以工作。(这不适用于此示例中的函数;使用基于梯度的方法的唯一收益是收敛速度。)该方法通过比较单纯形的顶点处的目标函数值来工作,在二维空间中是一个三角形。具有最大函数值的顶点通过相反的边被“反射”,并执行适当的扩展或收缩,实际上将单纯形“下坡”移动。
SciPyoptimize模块中的minimize例程是许多非线性函数最小化算法的入口点。在这个示例中,我们使用了 Nelder-Mead 单纯形算法,但还有许多其他可用的算法。其中许多算法需要对函数的梯度有所了解,该梯度可能会被算法自动计算。可以通过向method关键字参数提供适当的名称来使用该算法。
“最小化”例程返回的result对象包含有关求解器找到的解决方案的大量信息-或者如果发生错误,则未找到-。特别是,计算出的最小值发生的期望参数存储在结果的x属性中,而函数值存储在fun属性中。
“最小化”例程需要函数和x0的起始值。在这个示例中,我们还提供了一个容差值,最小值应该使用tol关键字参数计算。更改此值将修改计算解的准确度。
还有更多...
Nelder-Mead 算法是“无梯度”最小化算法的一个例子,因为它不需要任何关于目标函数的梯度(导数)的信息。有几种这样的算法,通常涉及在指定点评估目标函数,然后使用这些信息朝向最小值移动。一般来说,无梯度方法的收敛速度比梯度下降模型慢。但是,它们可以用于几乎任何目标函数,即使很难精确计算梯度或通过近似手段计算。
通常,优化单变量函数比多维情况更容易,并且在 SciPyoptimize库中有其专用函数。minimize_scalar例程对单变量函数执行最小化,并且在这种情况下应该使用而不是minimize。
在优化中使用梯度下降方法
在上一个示例中,我们使用 Nelder-Mead 单纯形算法最小化包含两个变量的非线性函数。这是一种相当健壮的方法,即使对目标函数了解甚少也可以工作。然而,在许多情况下,我们对目标函数了解更多,这一事实使我们能够设计更快和更有效的最小化函数的算法。我们可以通过利用函数的梯度等属性来做到这一点。
多于一个变量的函数的梯度描述了函数在各个分量方向上的变化率。这是函数对每个变量的偏导数的向量。从这个梯度向量中,我们可以推断出函数在哪个方向上增长最快,反之亦然,从任意给定位置开始,函数在哪个方向上下降最快。这为梯度下降方法最小化函数提供了基础。算法非常简单:给定一个起始位置x,我们计算在这个x处的梯度以及梯度最快下降的相应方向,然后沿着那个方向迈出一小步。经过几次迭代,这将从起始位置移动到函数的最小值。
在这个示例中,我们将学习如何实现基于最陡下降算法的算法,以在有界区域内最小化目标函数。
准备工作
对于这个示例,我们需要导入 NumPy 包作为np,导入 Matplotlib 的pyplot模块作为plt,并从mpl_toolkits.mplot3d导入Axes3D对象:
import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
如何做...
在接下来的步骤中,我们将实现一个简单的梯度下降方法,以最小化具有已知梯度函数的目标函数(实际上我们将使用一个生成器函数,以便我们可以看到方法的工作方式):
- 我们将首先定义一个
descend例程,它将执行我们的算法。函数声明如下:
def descend(func, x0, grad, bounds, tol=1e-8, max_iter=100):
- 接下来,我们需要实现这个例程。我们首先定义将在方法运行时保存迭代值的变量:
xn = x0
xnm1 = np.inf
grad_xn = grad(x0)
- 然后,我们开始我们的循环,这将运行迭代。我们立即检查是否在继续之前正在取得有意义的进展:
for i in range(max_iter):
if np.linalg.norm(xn - xnm1) < tol:
break
- 方向是梯度向量的负。我们计算一次并将其存储在
direction变量中:
direction = -grad_xn
- 现在,我们更新先前和当前的值,分别为
xnm1和xn,准备进行下一次迭代。这结束了descend例程的代码:
xnm1 = xn
xn = xn + 0.2*direction
- 现在,我们可以计算当前值的梯度并产生所有适当的值:
grad_xn = grad(xn)
yield i, xn, func(xn), grad_xn
这结束了descend例程的定义。
- 我们现在可以定义一个要最小化的样本目标函数:
def func(x):
return ((x[0] - 0.5)**2 + (x[1] + 0.5)**2)*np.cos(0.5*x[0]*x[1])
- 接下来,我们创建一个网格,我们将评估并绘制目标函数:
x_r = np.linspace(-1, 1)
y_r = np.linspace(-2, 2)
x, y = np.meshgrid(x_r, y_r)
- 一旦网格创建完成,我们可以评估我们的函数并将结果存储在
z变量中:
z = func([x, y])
- 接下来,我们创建目标函数的三维表面图:
surf_fig = plt.figure(tight_layout=True)
surf_ax = surf_fig.add_subplot(projection="3d")
surf_ax.tick_params(axis="both", which="major", labelsize=9)
surf_ax.set(xlabel="x", ylabel="y", zlabel="z")
surf_ax.set_title("Objective function")
surf_ax.plot_surface(x, y, z, alpha=0.7)
- 在我们开始最小化过程之前,我们需要定义一个初始点
x0。我们在前一步中创建的目标函数图上绘制这一点:
x0 = np.array([-0.8, 1.3])
surf_ax.plot([x0[0]], [x0[1]], func(x0), "r*")
目标函数的表面图,以及初始值,可以在以下图像中看到:
图 9.5:具有初始位置的目标函数表面
- 我们的
descend例程需要一个评估目标函数梯度的函数,因此我们将定义一个:
def grad(x):
c1 = x[0]**2 - x[0] + x[1]**2 + x[1] + 0.5
cos_t = np.cos(0.5*x[0]*x[1])
sin_t = np.sin(0.5*x[0]*x[1])
return np.array([
(2*x[0]-1)*cos_t - 0.5*x[1]*c1*sin_t,
(2*x[1]+1)*cos_t - 0.5*x[0]*c1*sin_t
])
- 我们将在轮廓图上绘制迭代,因此我们设置如下:
cont_fig, cont_ax = plt.subplots()
cont_ax.set(xlabel="x", ylabel="y")
cont_ax.set_title("Contour plot with iterates")
cont_ax.contour(x, y, z, levels=30)
- 现在,我们创建一个变量,将x和y方向的边界作为元组的元组保存。这些是步骤 10中
linspace调用中的相同边界:
bounds = ((-1, 1), (-2, 2))
- 我们现在可以使用
for循环驱动descend生成器来产生每个迭代,并将步骤添加到轮廓图中:
xnm1 = x0
for i, xn, fxn, grad_xn in descend(func, x0, grad, bounds):
cont_ax.plot([xnm1[0], xn[0]], [xnm1[1], xn[1]], "k*--")
xnm1, grad_xnm1 = xn, grad_xn
- 循环完成后,我们将最终值打印到终端:
print(f"iterations={i}")
print(f"min val at {xn}")
print(f"min func value = {fxn}")
前面打印语句的输出如下:
iterations=37
min val at [ 0.49999999 -0.49999999]
min func value = 2.1287163880894953e-16
在这里,我们可以看到我们的例程使用了 37 次迭代来找到大约在(0.5,-0.5)处的最小值,这是正确的。
可以在以下图像中看到带有其迭代的轮廓图:
图 9.6:梯度下降迭代到最小值的目标函数轮廓图
在这里,我们可以看到每次迭代的方向 - 由虚线表示 - 是目标函数下降最快的方向。最终迭代位于目标函数“碗”的中心,这是最小值出现的地方。
它是如何工作的...
这个配方的核心是descend例程。在这个例程中定义的过程是梯度下降法的一个非常简单的实现。在给定点计算梯度由grad参数处理,然后用direction = -grad推断迭代的旅行方向。我们将这个方向乘以一个固定的比例因子(有时称为学习率)0.2 的值来获得缩放步骤,然后通过添加0.2*direction到当前位置来进行这一步。
这个配方的解决方案需要 37 次迭代才能收敛,这比最小化非线性函数配方中的 Nelder-Mead 单纯形算法需要 58 次迭代要好一点。(这并不是一个完美的比较,因为我们改变了这个配方的起始位置。)这种性能在很大程度上取决于我们选择的步长。在这种情况下,我们将最大步长固定为方向向量的 0.2 倍。这使得算法简单,但并不特别高效。
在这个配方中,我们选择将算法实现为生成器函数,以便我们可以看到每一步的输出,并在迭代中绘制在等高线图上。在实践中,我们可能不想这样做,而是在迭代完成后返回计算出的最小值。为此,我们可以简单地删除yield语句,并在函数的最后,即主函数的缩进位置(即不在循环内),用return xn替换它。如果你想防止不收敛,你可以使用for循环的else特性来捕捉循环完成的情况,因为它已经到达了迭代器的末尾而没有触发break关键字。这个else块可以引发一个异常,以表明算法未能稳定到一个解决方案。在这个配方中,我们用来结束迭代的条件并不能保证该方法已经达到了最小值,但这通常是这种情况。
还有更多...
在实践中,你通常不会为自己实现梯度下降算法,而是使用来自库(如 SciPy optimize模块)的通用例程。我们可以使用与前一个配方中使用的相同的minimize例程来执行多种不同算法的最小化,包括几种梯度下降算法。这些实现可能比这样的自定义实现具有更高的性能和更强大的鲁棒性。
我们在这个配方中使用的梯度下降方法是一个非常天真的实现,可以通过允许例程在每一步选择步长来大大改进。(允许选择自己步长的方法有时被称为自适应方法。)这种改进的困难部分是选择在这个方向上采取的步长大小。为此,我们需要考虑单变量函数,它由以下方程给出:

在这里,x[n]表示当前点,d[n]表示当前方向,t是一个参数。为了简单起见,我们可以使用 SciPy optimize模块中的minimize_scalar最小化例程来处理标量值函数。不幸的是,这并不像简单地传入这个辅助函数并找到最小值那样简单。我们必须限定t的可能值,以便计算出的最小化点x[n]+ td[n]位于我们感兴趣的区域内。
要理解我们如何限制t的值,我们必须首先从几何上看这个构造。我们引入的辅助函数在给定方向上沿着一条直线评估目标函数。我们可以将其想象为通过当前x[n]点在d[n]方向上穿过的表面的单个横截面。算法的下一步是找到最小化沿着这条直线的目标函数值的步长t,这是一个标量函数,要最小化它要容易得多。然后边界应该是t值的范围,在这个范围内,这条直线位于由x和y边界值定义的矩形内。我们确定这条直线穿过这些x和y边界线的四个值,其中两个将是负值,另外两个将是正值。(这是因为当前点必须位于矩形内。)我们取两个正值中的最小值和两个负值中的最大值,并将这些边界传递给标量最小化例程。这是通过以下代码实现的:
alphas = np.array([
(bounds[0][0] - xn[0]) / direction[0], # x lower
(bounds[1][0] - xn[1]) / direction[1], # y lower
(bounds[0][1] - xn[0]) / direction[0], # x upper
(bounds[1][1] - xn[1]) / direction[1] # y upper
])
alpha_max = alphas[alphas >= 0].min()
alpha_min = alphas[alphas < 0].max()
result = minimize_scalar(lambda t: func(xn + t*direction),
method="bounded", bounds=(alpha_min, alpha_max))
amount = result.x
一旦选择了步长,唯一剩下的步骤就是更新当前的xn值,如下所示:
xn = xn + amount * direction
使用这种自适应步长会增加例程的复杂性,但性能大大提高。使用这个修改后的例程,该方法在只进行了三次迭代后就收敛了,远少于本食谱中使用的朴素代码(37 次迭代)或上一个食谱中使用的 Nelder-Mead 单纯形算法(58 次迭代)。通过在梯度函数中提供更多信息,减少了迭代次数,这正是我们预期的。
我们创建了一个函数,返回给定点处函数的梯度。我们在开始之前手动计算了这个梯度,这并不总是容易或甚至可能的。相反,更常见的是用数值计算的梯度替换这里使用的“解析”梯度,这个数值梯度是使用有限差分或类似算法估计的。这对性能和准确性有影响,就像所有近似一样,但鉴于梯度下降方法提供的收敛速度的提高,这些问题通常是次要的。
梯度下降类型的算法在机器学习应用中特别受欢迎。大多数流行的 Python 机器学习库,包括 PyTorch、TensorFlow 和 Theano,都提供了用于自动计算数据数组梯度的实用工具。这使得梯度下降方法可以在后台使用以提高性能。
梯度下降方法的一种流行变体是随机梯度下降,其中梯度是通过随机抽样来估计的,而不是使用整个数据集。这可以显著减少方法的计算负担,但会以更慢的收敛速度为代价,特别是对于高维问题,比如在机器学习应用中常见的问题。随机梯度下降方法通常与反向传播结合,构成了机器学习应用中训练人工神经网络的基础。
基本随机梯度下降算法有几种扩展。例如,动量算法将先前的增量纳入到下一个增量的计算中。另一个例子是自适应梯度算法,它纳入每个参数的学习率,以改善涉及大量稀疏参数的问题的收敛速度。
使用最小二乘法拟合数据曲线
最小二乘法是一种强大的技术,用于从相对较小的潜在函数族中找到最能描述特定数据集的函数。这种技术在统计学中特别常见。例如,最小二乘法用于线性回归问题-在这里,潜在函数族是所有线性函数的集合。通常,我们尝试拟合的这个函数族具有相对较少的参数,可以调整以解决问题。
最小二乘法的思想相对简单。对于每个数据点,我们计算残差的平方-点的值与给定函数的期望值之间的差异-并尝试使这些残差的平方和尽可能小(因此最小二乘法)。
在这个配方中,我们将学习如何使用最小二乘法来拟合一组样本数据的曲线。
做好准备
对于这个配方,我们将需要导入 NumPy 包,通常作为np,并导入 Matplotlib pyplot模块作为plt:
import numpy as np
import matplotlib.pyplot as plt
我们还需要从 NumPy random模块中导入默认随机数生成器的实例,如下所示:
from numy.random import default_rng
rng = default_rng(12345)
最后,我们需要从 SciPy optimize模块中的curve_fit例程:
from scipy.optimize import curve_fit
如何做...
以下步骤向您展示如何使用curve_fit例程来拟合一组数据的曲线:
- 第一步是创建样本数据:
SIZE = 100
x_data = rng.uniform(-3.0, 3.0, size=SIZE)
noise = rng.normal(0.0, 0.8, size=SIZE)
y_data = 2.0*x_data**2 - 4*x_data + noise
- 接下来,我们生成数据的散点图,以查看是否可以识别数据中的潜在趋势:
fig, ax = plt.subplots()
ax.scatter(x_data, y_data)
ax.set(xlabel="x", ylabel="y", title="Scatter plot of sample data")
我们生成的散点图可以在下面的图像中看到。在这里,我们可以看到数据显然不遇线性趋势(直线)。由于我们知道趋势是多项式,我们的下一个猜测将是二次趋势。这就是我们在这里使用的:
图 9.7:样本数据的散点图。我们可以看到数据不遵循线性趋势
- 接下来,我们创建一个代表我们希望拟合的模型的函数:
def func(x, a, b, c):
return a*x**2 + b*x + c
- 现在,我们可以使用
curve_fit例程将模型函数拟合到样本数据中:
coeffs, _ = curve_fit(func, x_data, y_data)
print(coeffs)
# [ 1.99611157 -3.97522213 0.04546998]
- 最后,我们在散点图上绘制最佳拟合曲线,以评估拟合曲线描述数据的效果如何:
x = np.linspace(-3.0, 3.0, SIZE)
y = func(x, coeffs[0], coeffs[1], coeffs[2])
ax.plot(x, y, "k--")
更新后的散点图可以在下面的图像中看到:
图 9.8:散点图,使用最小二乘法找到的最佳拟合曲线
在这里,我们可以看到我们找到的曲线相当合理地拟合了数据。
它是如何工作的...
curve_fit例程执行最小二乘拟合,将模型的曲线拟合到样本数据中。在实践中,这相当于最小化以下目标函数:

这里,配对(x[i],y[i])是样本数据中的点。在这种情况下,我们正在优化一个三维参数空间,每个参数都有一个维度。该例程返回估计的系数-参数空间中的点,其中目标函数被最小化-和包含拟合的协方差矩阵的估计的第二个变量。在这个配方中,我们忽略了这一点。
从curve_fit例程返回的估计协方差矩阵可以用于为估计的参数提供置信区间。这是通过取对角线元素的平方根除以样本大小(在这个配方中为 100)来完成的。这给出了估计的标准误差,当乘以与置信度对应的适当值时,给出了置信区间的大小。(我们在第六章中讨论了置信区间,使用数据和统计。)
您可能已经注意到,curve_fit例程估计的参数接近,但并非完全等于我们用于定义步骤 1中的样本数据的参数。这些参数并非完全相等的原因是我们向数据中添加了正态分布的噪声。在这个示例中,我们知道数据的基本结构是二次的 - 也就是二次多项式 - 而不是其他更神秘的函数。实际上,我们不太可能知道数据的基本结构,这就是我们向样本添加噪声的原因。
还有更多...
SciPy optimize模块中还有另一个用于执行最小二乘拟合的例程,名为least_squares。这个例程的签名略微不太直观,但确实返回了一个包含有关优化过程的更多信息的结果对象。然而,这个例程的设置方式可能更类似于我们在它是如何工作的...部分中构造基础数学问题的方式。要使用这个例程,我们定义目标函数如下:
def func(params, x, y):
return y - (params[0]*x**2 + params[1]*x + params[2])
我们将这个函数与参数空间中的起始估计x0一起传递,例如(1, 0, 0)。目标函数的额外参数func可以使用args关键字参数传递;例如,我们可以使用args=(x_data, y_data)。这些参数被传递到目标函数的x和y参数中。总之,我们可以使用以下调用least_squares来估计参数:
results = least_squares(func, [1, 0, 0], args=(x_data, y_data))
从least_squares例程返回的results对象实际上与本章中描述的其他优化例程返回的对象相同。它包含诸如使用的迭代次数、过程是否成功、详细的错误消息、参数值以及最小值处的目标函数值等细节。
分析简单的两人游戏
博弈论是数学的一个分支,涉及决策和策略分析。它在经济学、生物学和行为科学中有应用。许多看似复杂的情况可以简化为一个相对简单的数学游戏,可以以系统的方式进行分析,找到“最优”解决方案。
博弈论中的一个经典问题是囚徒困境,其原始形式如下:两个同谋被抓住,必须决定是保持沉默还是对对方作证。如果两人都保持沉默,他们都要服刑 1 年;如果一个作证而另一个不作证,作证者将获释,而另一个将服刑 3 年;如果两人都相互作证,他们都将服刑 2 年。每个同谋应该怎么做?事实证明,在对对方有任何合理的不信任的情况下,每个同谋可以做出的最佳选择是作证。采用这种策略,他们将不会服刑或最多服刑 2 年。
由于这本书是关于 Python 的,我们将使用这个经典问题的变体来说明这个问题的普遍性。考虑以下问题:你和你的同事必须为客户编写一些代码。你认为你可以用 Python 更快地编写代码,但你的同事认为他们可以用 C 更快地编写代码。问题是,你应该选择哪种语言来进行项目?
你认为你可以用 Python 代码比 C 代码快 4 倍,所以你用速度 1 写 C,用速度 4 写 Python。你的同事说他们可以比 Python 稍微更快地写 C,所以他们用速度 3 写 C,用速度 2 写 Python。如果你们两个都同意一种语言,那么你们按照你预测的速度编写代码,但如果你们意见不一致,那么更快的程序员的生产力会降低 1。我们可以总结如下:
| 同事/你 | C | Python |
|---|---|---|
| C | 3 / 1 | 3 / 2 |
| Python | 2 / 1 | 2 / 4 |
在这个配方中,我们将学习如何在 Python 中构建一个对象来表示这个简单的双人游戏,然后对这个游戏的结果进行一些基本分析。
准备工作
对于这个配方,我们需要导入 NumPy 包为np,导入 Nashpy 包为nash:
import numpy as np
import nashpy as nash
如何做...
以下步骤向您展示了如何使用 Nashpy 创建和执行一些简单的双人游戏分析:
- 首先,我们需要创建矩阵,用于保存每个玩家(在这个例子中是您和您的同事)的支付信息。
you = np.array([[1, 3], [1, 4]])
colleague = np.array([[3, 2], [2, 2]])
- 接下来,我们创建一个
Game对象,它保存了由这些支付矩阵表示的游戏:
dilemma = nash.Game(you, colleague)
- 我们使用索引表示法计算给定选择的效用:
print(dilemma[[1, 0], [1, 0]]) # [1 3]
print(dilemma[[1, 0], [0, 1]]) # [3 2]
print(dilemma[[0, 1], [1, 0]]) # [1 2]
print(dilemma[[0, 1], [0, 1]]) # [4 2]
- 我们还可以根据做出特定选择的概率计算预期效用:
print(dilemma[[0.1, 0.9], [0.5, 0.5]]) # [2.45 2.05]
它是如何工作的...
在这个配方中,我们构建了一个代表非常简单的双人战略游戏的 Python 对象。 这里的想法是有两个“玩家”需要做决定,每个玩家的选择组合都会给出一个特定的支付值。 我们的目标是找到每个玩家可以做出的最佳选择。 假设玩家同时进行一次移动,即没有人知道对方的选择。 每个玩家都有一个确定他们所做选择的策略。
在步骤 1中,我们创建两个矩阵 - 每个玩家一个 - 分配给每个选择组合的支付值。 这两个矩阵由 Nashpy 的Game类包装,它提供了一个方便且直观(从博弈论的角度来看)的接口来处理游戏。 通过使用索引表示法传递选择,我们可以快速计算给定选择组合的效用。
我们还可以根据一种策略提供预期效用的计算,其中选择是根据某种概率分布随机选择的。 语法与先前描述的确定性情况相同,只是我们为每个选择提供了一个概率向量。 我们根据您选择 Python 的概率计算预期效用为 90%,而您的同事选择 Python 的概率为 50%。 预期速度分别为 2.45 和 2.05。
还有更多...
在 Python 中,还有一种计算博弈论的替代方法。 Gambit 项目是一组用于博弈论计算的工具,具有 Python 接口(www.gambit-project.org/)。 这是一个成熟的项目,建立在 C 库周围,并提供比 Nashpy 更高的性能。
计算纳什均衡
纳什均衡是一个双人战略游戏 - 类似于我们在分析简单的双人游戏配方中看到的游戏 - 代表每个玩家都看到“最佳可能”结果的“稳态”。 但是,这并不意味着与纳什均衡相关联的结果是最好的。 纳什均衡比这更微妙。 纳什均衡的非正式定义如下:在其中没有个别玩家可以改善他们的结果的行动配置,假设所有其他玩家都遵守该配置。
我们将探讨纳什均衡的概念,使用经典的猜拳游戏。 规则如下。 每个玩家可以选择以下选项之一:石头,纸或剪刀。 石头打败剪刀,但输给纸; 纸打败石头,但输给剪刀; 剪刀打败纸,但输给石头。 如果两名玩家做出相同选择的游戏是平局。 在数值上,我们用+1 表示赢,-1 表示输,0 表示平局。 从中,我们可以构建一个双人游戏,并计算该游戏的纳什均衡。
在这个配方中,我们将计算猜拳游戏的纳什均衡。
准备工作
对于这个配方,我们需要导入 NumPy 包为np,导入 Nashpy 包为nash:
import numpy as np
import nashpy as nash
如何做...
以下步骤向您展示了如何计算简单的两人游戏的纳什均衡:
- 首先,我们需要为每个玩家创建一个收益矩阵。我们将从第一个玩家开始:
rps_p1 = np.array([
[ 0, -1, 1], # rock payoff
[ 1, 0, -1], # paper payoff
[-1, 1, 0] # scissors payoff
])
- 第二个玩家的收益矩阵是
rps_p1的转置:
rps_p2 = rps_p1.transpose()
- 接下来,我们创建
Game对象来表示游戏:
rock_paper_scissors = nash.Game(rps_p1, rps_p2)
- 我们使用支持枚举算法计算游戏的纳什均衡:
equilibria = rock_paper_scissors.support_enumeration()
- 我们遍历均衡,并打印每个玩家的策略:
for p1, p2 in equilibria:
print("Player 1", p1)
print("Player 2", p2)
这些打印语句的输出如下:
Player 1 [0.33333333 0.33333333 0.33333333]
Player 2 [0.33333333 0.33333333 0.33333333]
它是如何工作的...
纳什均衡在博弈论中非常重要,因为它们允许我们分析战略游戏的结果,并确定有利的位置。它们最早由约翰·F·纳什在 1950 年描述,并在现代博弈论中发挥了重要作用。一个两人游戏可能有许多纳什均衡,但任何有限的两人游戏必须至少有一个。问题在于找到给定游戏的所有可能的纳什均衡。
在这个示例中,我们使用了支持枚举,它有效地枚举了所有可能的策略,并筛选出了那些是纳什均衡的策略。在这个示例中,支持枚举算法只找到了一个纳什均衡,这是一个混合策略。这意味着唯一没有改进的策略是随机选择其中一个选项,每个选项的概率为 1/3。这对于任何玩过石头剪刀布的人来说都不足为奇,因为无论我们做出什么选择,我们的对手都有 1/3 的机会(随机)选择打败我们选择的动作。同样,我们有 1/3 的机会平局或赢得比赛,因此我们在所有这些可能性中的期望值如下:

在不知道我们的对手会选择哪一个选项的情况下,没有办法改进这个预期结果。
还有更多...
Nashpy 软件包还提供了其他计算纳什均衡的算法。具体来说,vertex_enumeration方法在Game对象上使用顶点枚举算法,而lemke_howson_enumeration方法使用Lemke Howson算法。这些替代算法具有不同的特性,对于某些问题可能更有效。
另请参阅
Nashpy 软件包的文档包含了有关所涉及的算法和博弈论的更详细信息。其中包括了一些关于博弈论的文本参考。这些文档可以在nashpy.readthedocs.io/en/latest/找到。
进一步阅读
通常情况下,Numerical Recipes书是数值算法的良好来源。第十章,其他主题,涉及函数的最大化和最小化:
- Press, W.H., Teukolsky, S.A., Vetterling, W.T., and Flannery, B.P., 2017. Numerical recipes: the art of scientific computing. 3rd ed. Cambridge: Cambridge University Press。
有关优化的更具体信息可以在以下书籍中找到:
-
Boyd, S.P. and Vandenberghe, L., 2018. Convex optimization**. Cambridge: Cambridge University Press。
-
Griva, I., Nash, S., and Sofer, A., 2009. Linear and nonlinear optimization.**2nd ed. Philadelphia: Society for Industrial and Applied Mathematics。
最后,以下书籍是博弈论的良好入门:
- Osborne, M.J., 2017. An introduction to game theory**. Oxford: Oxford University Press。
第十一章:其他主题
在本章中,我们将讨论一些在本书前几章中没有涉及的主题。这些主题大多涉及不同的计算方式以及优化代码执行的其他方式。其他主题涉及处理特定类型的数据或文件格式。
在前两个内容中,我们将介绍帮助跟踪计算中的单位和不确定性的软件包。这些对于涉及具有直接物理应用的数据的计算非常重要。在下一个内容中,我们将讨论如何从 NetCDF 文件加载和存储数据。NetCDF 通常用于存储天气和气候数据的文件格式。(NetCDF 代表网络通用数据格式。)在第四个内容中,我们将讨论处理地理数据,例如可能与天气或气候数据相关的数据。之后,我们将讨论如何可以在不必启动交互式会话的情况下从终端运行 Jupyter 笔记本。接下来的两个内容涉及验证数据和处理从 Kafka 服务器流式传输的数据。我们最后两个内容涉及两种不同的方式,即使用诸如 Cython 和 Dask 等工具来加速我们的代码。
在本章中,我们将涵盖以下内容:
-
使用 Pint 跟踪单位
-
在计算中考虑不确定性
-
从 NetCDF 文件加载和存储数据
-
处理地理数据
-
将 Jupyter 笔记本作为脚本执行
-
验证数据
-
处理数据流
-
使用 Cython 加速代码
-
使用 Dask 进行分布式计算
让我们开始吧!
技术要求
由于本章包含的内容的性质,需要许多不同的软件包。我们需要的软件包列表如下:
-
Pint
-
不确定性
-
NetCDF4
-
xarray
-
GeoPandas
-
Geoplot
-
Papermill
-
Cerberus
-
Faust
-
Cython
-
Dask
所有这些软件包都可以使用您喜欢的软件包管理器(如pip)进行安装:
python3.8 -m pip install pint uncertainties netCDF4 xarray geopandas
geoplot papermill cerberus faust cython
安装 Dask 软件包,我们需要安装与软件包相关的各种额外功能。我们可以在终端中使用以下pip命令来执行此操作:
python3.8 -m pip install dask[complete]
除了这些 Python 软件包,我们还需要安装一些支持软件。对于处理地理数据的内容,GeoPandas 和 Geoplot 库可能需要单独安装许多低级依赖项。详细说明在 GeoPandas 软件包文档中给出,网址为geopandas.org/install.html。
对于处理数据流的内容,我们需要安装 Kafka 服务器。如何安装和运行 Kafka 服务器的详细说明可以在 Apache Kafka 文档页面上找到,网址为kafka.apache.org/quickstart。
对于Cython 加速代码的内容,我们需要安装 C 编译器。如何获取GNU C 编译器(GCC)的说明在 Cython 文档中给出,网址为cython.readthedocs.io/en/latest/src/quickstart/install.html。
本章的代码可以在 GitHub 存储库的Chapter 10文件夹中找到,网址为github.com/PacktPublishing/Applying-Math-with-Python/tree/master/Chapter%2010。
查看以下视频以查看代码的实际操作:bit.ly/2ZMjQVw。
使用 Pint 跟踪单位
在计算中正确跟踪单位可能非常困难,特别是在可以使用不同单位的地方。例如,很容易忘记在不同单位之间进行转换 – 英尺/英寸转换成米 – 或者公制前缀 – 比如将 1 千米转换成 1,000 米。
在这个内容中,我们将学习如何使用 Pint 软件包来跟踪计算中的测量单位。
准备工作
对于这个示例,我们需要 Pint 包,可以按如下方式导入:
import pint
如何做...
以下步骤向您展示了如何使用 Pint 包在计算中跟踪单位:
- 首先,我们需要创建一个
UnitRegistry对象:
ureg = pint.UnitRegistry(system="mks")
- 要创建带有单位的数量,我们将数字乘以注册对象的适当属性:
distance = 5280 * ureg.feet
- 我们可以使用其中一种可用的转换方法更改数量的单位:
print(distance.to("miles"))
print(distance.to_base_units())
print(distance.to_base_units().to_compact())
这些print语句的输出如下:
0.9999999999999999 mile
1609.3439999999998 meter
1.6093439999999999 kilometer
- 我们包装一个例程,使其期望以秒为参数并输出以米为结果:
@ureg.wraps(ureg.meter, ureg.second)
def calc_depth(dropping_time):
# s = u*t + 0.5*a*t*t
# u = 0, a = 9.81
return 0.5*9.81*dropping_time*dropping_time
- 现在,当我们使用分钟单位调用
calc_depth例程时,它会自动转换为秒进行计算:
depth = calc_depth(0.05 * ureg.minute)
print("Depth", depth)
# Depth 44.144999999999996 meter
它是如何工作的...
Pint 包为数字类型提供了一个包装类,为类型添加了单位元数据。这个包装类型实现了所有标准的算术运算,并在这些计算过程中跟踪单位。例如,当我们将长度单位除以时间单位时,我们将得到速度单位。这意味着您可以使用 Pint 来确保在复杂计算后单位是正确的。
UnitRegistry对象跟踪会话中存在的所有单位,并处理不同单位类型之间的转换等问题。它还维护一个度量参考系统,在这个示例中是标准国际系统,以米、千克和秒作为基本单位,表示为mks。
wrap功能允许我们声明例程的输入和输出单位,这允许 Pint 对输入函数进行自动单位转换-在这个示例中,我们将分钟转换为秒。尝试使用没有关联单位或不兼容单位的数量调用包装函数将引发异常。这允许对参数进行运行时验证,并自动转换为例程的正确单位。
还有更多...
Pint 包带有一个大型的预设测量单位列表,涵盖了大多数全球使用的系统。单位可以在运行时定义或从文件加载。这意味着您可以定义特定于您正在使用的应用程序的自定义单位或单位系统。
单位也可以在不同的上下文中使用,这允许在不同单位类型之间轻松转换,这些单位类型通常是不相关的。这可以在需要在计算的多个点之间流畅地移动单位的情况下节省大量时间。
在计算中考虑不确定性
大多数测量设备并不是 100%准确的,通常只能准确到一定程度,通常在 0 到 10%之间。例如,温度计可能准确到 1%,而一对数字卡尺可能准确到 0.1%。在这两种情况下,报告的数值不太可能是真实值,尽管它会非常接近。跟踪数值的不确定性是困难的,特别是当您有多种不同的不确定性以不同的方式组合在一起时。与其手动跟踪这些,最好使用一个一致的库来为您完成。这就是uncertainties包的作用。
在这个示例中,我们将学习如何量化变量的不确定性,并看到这些不确定性如何通过计算传播。
准备工作
对于这个示例,我们将需要uncertainties包,我们将从中导入ufloat类和umath模块:
from uncertainties import ufloat, umath
如何做...
以下步骤向您展示了如何在计算中对数值的不确定性进行量化:
- 首先,我们创建一个不确定的浮点值为
3.0加减0.4:
seconds = ufloat(3.0, 0.4)
print(seconds) # 3.0+/-0.4
- 接下来,我们进行涉及这个不确定值的计算,以获得一个新的不确定值:
depth = 0.5*9.81*seconds*seconds
print(depth) # 44+/-12
- 接下来,我们创建一个新的不确定浮点值,并在与之前计算相反的方向上应用
umath模块的sqrt例程:
other_depth = ufloat(44, 12)
time = umath.sqrt(2.0*other_depth/9.81)
print("Estimated time", time)
# Estimated time 3.0+/-0.4
它是如何工作的...
ufloat类包装了float对象,并在整个计算过程中跟踪不确定性。该库利用线性误差传播理论,使用非线性函数的导数来估计计算过程中传播的误差。该库还正确处理相关性,因此从自身减去一个值会得到 0,没有误差。
要跟踪标准数学函数中的不确定性,您需要使用umath模块中提供的版本,而不是 Python 标准库或第三方包(如 NumPy)中定义的版本。
还有更多...
uncertainties包支持 NumPy,并且前面示例中提到的 Pint 包可以与不确定性结合使用,以确保正确地将单位和误差边界归因于计算的最终值。例如,我们可以从本示例的步骤 2中计算出计算的单位,如下所示:
import pint
from uncertainties import ufloat
g = 9.81*ureg.meters / ureg.seconds ** 2
seconds = ufloat(3.0, 0.4) * ureg.seconds
depth = 0.5*g*seconds**2
print(depth)
如预期的那样,最后一行的print语句给出了我们预期的44+/-12 米。
从 NetCDF 文件加载和存储数据
许多科学应用程序要求我们以稳健的格式开始大量的多维数据。NetCDF 是天气和气候行业用于开发数据的格式的一个例子。不幸的是,数据的复杂性意味着我们不能简单地使用 Pandas 包的实用程序来加载这些数据进行分析。我们需要netcdf4包来能够读取和导入数据到 Python 中,但我们还需要使用xarray。与 Pandas 库不同,xarray可以处理更高维度的数据,同时仍提供类似于 Pandas 的接口。
在这个示例中,我们将学习如何从 NetCDF 文件中加载数据并存储数据。
准备就绪
对于这个示例,我们需要导入 NumPy 包作为np,Pandas 包作为pd,Matplotlib pyplot模块作为plt,以及从 NumPy 导入默认随机数生成器的实例:
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from numpy.random import default_rng
rng = default_rng(12345)
我们还需要导入xarray包并使用别名xr。您还需要安装 Dask 包,如“技术要求”部分所述,以及 NetCDF4 包:
import xarray as xr
我们不需要直接导入这两个包。
操作方法...
按照以下步骤加载和存储样本数据到 NetCDF 文件中:
- 首先,我们需要创建一些随机数据。这些数据包括一系列日期、位置代码列表和随机生成的数字:
dates = pd.date_range("2020-01-01", periods=365, name="date")
locations = list(range(25))
steps = rng.normal(0, 1, size=(365,25))
accumulated = np.add.accumulate(steps)
- 接下来,我们创建一个包含数据的 xarray
Dataset对象。日期和位置是索引,而steps和accumulated变量是数据:
data_array = xr.Dataset({
"steps": (("date", "location"), steps),
"accumulated": (("date", "location"), accumulated)
},
{"location": locations, "date": dates}
)
print(data_array)
print语句的输出如下所示:
<xarray.Dataset>
Dimensions: (date: 365, location: 25)
Coordinates:
* location (location) int64 0 1 2 3 4 5 6 7 8 ... 17 18 19 20 21 22 23 24
* date (date) datetime64[ns] 2020-01-01 2020-01-02 ... 2020-12-30
Data variables:
steps (date, location) float64 geoplot.pointplot(cities, ax=ax, fc="r", marker="2")
ax.axis((-180, 180, -90, 90))-1.424 1.264 ... -0.4547 -0.4873
accumulated (date, location) float64 -1.424 1.264 -0.8707 ... 8.935 -3.525
- 接下来,我们计算每个时间索引处所有位置的平均值:
means = data_array.mean(dim="location")
- 现在,我们在新的坐标轴上绘制平均累积值:
fig, ax = plt.subplots()
means["accumulated"].to_dataframe().plot(ax=ax)
ax.set(title="Mean accumulated values", xlabel="date", ylabel="value")
生成的绘图如下所示:
图 10.1:随时间累积平均值的绘图
- 使用
to_netcdf方法将此数据集保存到新的 NetCDF 文件中:
data_array.to_netcdf("data.nc")
- 现在,我们可以使用
xarray的load_dataset例程加载新创建的 NetCDF 文件:
new_data = xr.load_dataset("data.nc")
print(new_data)
前面代码的输出如下所示:
<xarray.Dataset>
Dimensions: (date: 365, location: 25)
Coordinates:
* location (location) int64 0 1 2 3 4 5 6 7 8 ... 17 18 19 20 21 22 23 24
* date (date) datetime64[ns] 2020-01-01 2020-01-02 ... 2020-12-30
Data variables:
steps (date, location) float64 -1.424 1.264 ... -0.4547 -0.4873
accumulated (date, location) float64 -1.424 1.264 -0.8707 ... 8.935 -3.525
工作原理...
xarray包提供了DataArray和DataSet类,它们(粗略地说)是 PandasSeries和DataFrame对象的多维等价物。在本例中,我们使用数据集,因为每个索引(日期和位置的元组)都与两个数据相关联。这两个对象都暴露了与它们的 Pandas 等价物类似的接口。例如,我们可以使用mean方法沿着其中一个轴计算平均值。DataArray和DataSet对象还有一个方便的方法,可以将其转换为 PandasDataFrame,称为to_dataframe。我们在这个示例中使用它将其转换为DataFrame进行绘图,这并不是真正必要的,因为xarray内置了绘图功能。
这个配方的真正重点是to_netcdf方法和load_dataset例程。前者将DataSet存储在 NetCDF 格式文件中。这需要安装 NetCDF4 包,因为它允许我们访问相关的 C 库来解码 NetCDF 格式的文件。load_dataset例程是一个通用的例程,用于从各种文件格式(包括 NetCDF,这同样需要安装 NetCDF4 包)将数据加载到DataSet对象中。
还有更多...
xarray包支持除 NetCDF 之外的许多数据格式,如 OPeNDAP、Pickle、GRIB 和 Pandas 支持的其他格式。
处理地理数据
许多应用涉及处理地理数据。例如,当跟踪全球天气时,我们可能希望在地图上以各种传感器在世界各地的位置测量的温度为例进行绘图。为此,我们可以使用 GeoPandas 包和 Geoplot 包,这两个包都允许我们操纵、分析和可视化地理数据。
在这个配方中,我们将使用 GeoPandas 和 Geoplot 包来加载和可视化一些样本地理数据。
准备工作
对于这个配方,我们需要 GeoPandas 包,Geoplot 包和 Matplotlib 的pyplot包作为plt导入:
import geopandas
import geoplot
import matplotlib.pyplot as plt
如何做...
按照以下步骤,使用样本数据在世界地图上创建首都城市的简单绘图:
- 首先,我们需要从 GeoPandas 包中加载样本数据,其中包含世界地理信息:
world = geopandas.read_file(
geopandas.datasets.get_path("naturalearth_lowres")
)
- 接下来,我们需要加载包含世界各个首都城市名称和位置的数据:
cities = geopandas.read_file(
geopandas.datasets.get_path("naturalearth_cities")
)
- 现在,我们可以创建一个新的图形,并使用
polyplot例程绘制世界地理的轮廓:
fig, ax = plt.subplots()
geoplot.polyplot(world, ax=ax)
- 最后,我们使用
pointplot例程在世界地图上添加首都城市的位置。我们还设置轴限制,以使整个世界可见:
geoplot.pointplot(cities, ax=ax, fc="r", marker="2")
ax.axis((-180, 180, -90, 90))
结果绘制的世界各国首都城市的位置如下:
图 10.2:世界首都城市在地图上的绘图
工作原理...
GeoPandas 包是 Pandas 的扩展,用于处理地理数据,而 Geoplot 包是 Matplotlib 的扩展,用于绘制地理数据。GeoPandas 包带有一些我们在这个配方中使用的样本数据集。naturalearth_lowres包含描述世界各国边界的几何图形。这些数据不是非常高分辨率,正如其名称所示,这意味着地理特征的一些细节可能在地图上不存在(一些小岛根本没有显示)。naturalearth_cities包含世界各国首都城市的名称和位置。我们使用datasets.get_path例程来检索包数据目录中这些数据集的路径。read_file例程将数据导入 Python 会话。
Geoplot 包提供了一些专门用于绘制地理数据的附加绘图例程。polyplot例程从 GeoPandas DataFrame 绘制多边形数据,该数据可能描述一个国家的地理边界。pointplot例程从 GeoPandas DataFrame 在一组轴上绘制离散点,这种情况下描述了首都城市的位置。
将 Jupyter 笔记本作为脚本执行
Jupyter 笔记本是用于编写科学和数据应用的 Python 代码的流行媒介。 Jupyter 笔记本实际上是一个以JavaScript 对象表示(JSON)格式存储在带有ipynb扩展名的文件中的块序列。每个块可以是多种不同类型之一,例如代码或标记。这些笔记本通常通过解释块并在后台内核中执行代码然后将结果返回给 Web 应用程序的 Web 应用程序访问。如果您在个人 PC 上工作,这很棒,但是如果您想在服务器上远程运行笔记本中包含的代码怎么办?在这种情况下,甚至可能无法访问 Jupyter 笔记本软件提供的 Web 界面。papermill 软件包允许我们从命令行参数化和执行笔记本。
在本教程中,我们将学习如何使用 papermill 从命令行执行 Jupyter 笔记本。
准备工作
对于本教程,我们需要安装 papermill 软件包,并且当前目录中需要有一个示例 Jupyter 笔记本。我们将使用本章的代码存储库中存储的sample.ipynb笔记本文件。
如何做...
按照以下步骤使用 papermill 命令行界面远程执行 Jupyter 笔记本:
- 首先,我们从本章的代码存储库中打开样本笔记本
sample.ipynb。笔记本包含三个代码单元格,其中包含以下代码:
import matplotlib.pyplot as plt
from numpy.random import default_rng
rng = default_rng(12345)
uniform_data = rng.uniform(-5, 5, size=(2, 100))
fig, ax = plt.subplots(tight_layout=True)
ax.scatter(uniform_data[0, :], uniform_data[1, :])
ax.set(title="Scatter plot", xlabel="x", ylabel="y")
- 接下来,我们在终端中打开包含 Jupyter 笔记本的文件夹并使用以下命令:
papermill --kernel python3 sample.ipynb output.ipynb
- 现在,我们打开输出文件
output.ipynb,该文件现在应该包含已更新为执行代码结果的笔记本。在最终块中生成的散点图如下所示:

图 10.3:在远程使用 papermill 执行的 Jupyter 笔记本中生成的随机数据的散点图
它是如何工作的...
papermill 软件包提供了一个简单的命令行界面,用于解释和执行 Jupyter 笔记本,然后将结果存储在新的笔记本文件中。在本教程中,我们提供了第一个参数 - 输入笔记本文件 - sample.ipynb和第二个参数 - 输出笔记本文件 - output.ipynb。然后工具执行笔记本中包含的代码并生成输出。笔记本文件格式跟踪上次运行的结果,因此这些结果将添加到输出笔记本并存储在所需的位置。在本教程中,这是一个简单的本地文件,但是 papermill 也可以存储到云位置,例如Amazon Web Services(AWS)S3 存储或 Azure 数据存储。
在步骤 2中,我们在使用 papermill 命令行界面时添加了--kernel python3选项。此选项允许我们指定用于执行 Jupyter 笔记本的内核。如果 papermill 尝试使用与用于编写笔记本的内核不同的内核执行笔记本,则可能需要这样做以防止错误。可以使用以下命令在终端中找到可用内核的列表:
jupyter kernelspec list
如果在执行笔记本时出现错误,您可以尝试切换到不同的内核。
还有更多...
Papermill 还具有 Python 接口,因此您可以从 Python 应用程序内执行笔记本。这对于构建需要能够在外部硬件上执行长时间计算并且结果需要存储在云中的 Web 应用程序可能很有用。它还具有向笔记本提供参数的能力。为此,我们需要在笔记本中创建一个标有默认值的参数标记的块。然后可以通过命令行界面使用-p标志提供更新的参数,后跟参数的名称和值。
验证数据
数据通常以原始形式呈现,可能包含异常或不正确或格式不正确的数据,这显然会给后续处理和分析带来问题。通常最好在处理管道中构建验证步骤。幸运的是,Cerberus 包为 Python 提供了一个轻量级且易于使用的验证工具。
对于验证,我们必须定义一个模式,这是关于数据应该如何以及应该对数据执行哪些检查的技术描述。例如,我们可以检查类型并设置最大和最小值的边界。Cerberus 验证器还可以在验证步骤中执行类型转换,这使我们可以将直接从 CSV 文件加载的数据插入验证器中。
在这个示例中,我们将学习如何使用 Cerberus 验证从 CSV 文件加载的数据。
准备工作
对于这个示例,我们需要从 Python 标准库中导入csv模块,以及 Cerberus 包:
import csv
import cerberus
我们还需要这一章的代码库中的sample.csv文件。
如何做...
在接下来的步骤中,我们将使用 Cerberus 包从 CSV 中加载的一组数据进行验证:
- 首先,我们需要构建描述我们期望的数据的模式。为此,我们必须为浮点数定义一个简单的模式:
float_schema = {"type": "float", "coerce": float, "min": -1.0,
"max": 1.0}
- 接下来,我们为单个项目构建模式。这些将是我们数据的行:
item_schema = {
"type": "dict",
"schema": {
"id": {"type": "string"},
"number": {"type": "integer", "coerce": int},
"lower": float_schema,
"upper": float_schema,
}
}
- 现在,我们可以定义整个文档的模式,其中将包含一系列项目:
schema = {
"rows": {
"type": "list",
"schema": item_schema
}
}
- 接下来,我们使用刚刚定义的模式创建一个
Validator对象:
validator = cerberus.Validator(schema)
- 然后,我们使用
csv模块中的DictReader加载数据:
with open("sample.csv") as f:
dr = csv.DictReader(f)
document = {"rows": list(dr)}
- 接下来,我们使用
Validator上的validate方法来验证文档:
validator.validate(document)
- 然后,我们从
Validator对象中检索验证过程中的错误:
errors = validator.errors["rows"][0]
- 最后,我们可以打印出任何出现的错误消息:
for row_n, errs in errors.items():
print(f"row {row_n}: {errs}")
错误消息的输出如下:
row 11: [{'lower': ['min value is -1.0']}]
row 18: [{'number': ['must be of integer type', "field 'number' cannot be coerced: invalid literal for int() with base 10: 'None'"]}]
row 32: [{'upper': ['min value is -1.0']}]
row 63: [{'lower': ['max value is 1.0']}]
它是如何工作的...
我们创建的模式是对我们需要根据数据检查的所有标准的技术描述。这通常被定义为一个字典,其中项目的名称作为键,属性字典作为值,例如字典中的值的类型或值的边界。例如,在步骤 1中,我们为浮点数定义了一个模式,限制了数字的范围,使其在-1 和 1 之间。请注意,我们包括coerce键,该键指定在验证期间应将值转换为的类型。这允许我们传入从 CSV 文档中加载的数据,其中只包含字符串,而不必担心其类型。
Validator对象负责解析文档,以便对其进行验证,并根据模式描述的所有标准检查它们包含的数据。在这个示例中,我们在创建Validator对象时向其提供了模式。但是,我们也可以将模式作为第二个参数传递给validate方法。错误存储在一个嵌套字典中,其结构与文档的结构相似。
处理数据流
一些数据是从各种来源以恒定流的形式接收的。例如,我们可能会遇到多个温度探头通过 Kafka 服务器定期报告值的情况。Kafka 是一个流数据消息代理,根据主题将消息传递给不同的处理代理。
处理流数据是异步 Python 的完美应用。这使我们能够同时处理更大量的数据,这在应用程序中可能非常重要。当然,在异步上下文中我们不能直接对这些数据进行长时间的分析,因为这会干扰事件循环的执行。
使用 Python 的异步编程功能处理 Kafka 流时,我们可以使用 Faust 包。该包允许我们定义异步函数,这些函数将充当处理代理或服务,可以处理或以其他方式与来自 Kafka 服务器的数据流进行交互。
在这个食谱中,我们将学习如何使用 Faust 包来处理来自 Kafka 服务器的数据流。
准备工作
与本书中大多数食谱不同,由于我们将从命令行运行生成的应用程序,因此无法在 Jupyter 笔记本中运行此食谱。
对于这个食谱,我们需要导入 Faust 包:
import faust
我们还需要从 NumPy 包中运行默认随机数生成器的实例:
from numpy.random import default_rng
rng = default_rng(12345)
我们还需要在本地机器上运行 Kafka 服务的实例,以便我们的 Faust 应用程序可以与消息代理进行交互。
一旦您下载了 Kafka 并解压了下载的源代码,就导航到 Kafka 应用程序所在的文件夹。在终端中打开此文件夹。使用以下命令启动 ZooKeeper 服务器(适用于 Linux 或 Mac):
bin/zookeeper-server-start.sh config/zookeeper.properties
如果您使用 Windows,改用以下命令:
bin\windows\zookeeper-server-start.bat config\zookeeper.properties
然后,在一个新的终端中,使用以下命令启动 Kafka 服务器(适用于 Linux 或 Mac):
bin/kafka-server-start.sh config/server.properties
如果您使用 Windows,改用以下命令:
bin\windows\kafka-server-start.bat config\server.properties
在每个终端中,您应该看到一些日志信息,指示服务器正在运行。
操作步骤...
按照以下步骤创建一个 Faust 应用程序,该应用程序将读取(和写入)数据到 Kafka 服务器并进行一些简单的处理:
- 首先,我们需要创建一个 Faust
App实例,它将充当 Python 和 Kafka 服务器之间的接口:
app = faust.App("sample", broker="kafka://localhost")
- 接下来,我们将创建一个记录类型,模拟我们从服务器期望的数据:
class Record(faust.Record):
id_string: str
value: float
- 现在,我们将向 Faust
App对象添加一个主题,将值类型设置为我们刚刚定义的Record类:
topic = app.topic("sample-topic", value_type=Record)
- 现在,我们定义一个代理,这是一个包装在
App对象上的agent装饰器的异步函数:
@app.agent(topic)
async def process_record(records):
async for record in records:
print(f"Got {record.id_string}: {record.value}")
- 接下来,我们定义两个源函数,将记录发布到我们设置的样本主题的 Kafka 服务器上。这些是异步函数,包装在
timer装饰器中,并设置适当的间隔:
@app.timer(interval=1.0)
async def producer1(app):
await app.send(
"sample-topic",
value=Record(id_string="producer 1", value=
rng.uniform(0, 2))
)
@app.timer(interval=2.0)
async def producer2(app):
await app.send(
"sample-topic",
value=Record(id_string="producer 2", value=
rng.uniform(0, 5))
)
- 在文件底部,我们启动应用程序的
main函数:
app.main()
- 现在,在一个新的终端中,我们可以使用以下命令启动应用程序的工作进程(假设我们的应用程序存储在
working-with-data-streams.py中):
python3.8 working-with-data-streams.py worker
在这个阶段,您应该看到代理生成的一些输出被打印到终端中,如下所示:
[2020-06-21 14:15:27,986] [18762] [WARNING] Got producer 1: 0.4546720449343393
[2020-06-21 14:15:28,985] [18762] [WARNING] Got producer 2: 1.5837916985487643
[2020-06-21 14:15:28,989] [18762] [WARNING] Got producer 1: 1.5947309146654682
[2020-06-21 14:15:29,988] [18762] [WARNING] Got producer 1: 1.3525093415019491
这将是由 Faust 生成的一些应用程序信息的下方。
- 按下Ctrl + C关闭工作进程,并确保以相同的方式关闭 Kafka 服务器和 Zookeeper 服务器。
工作原理...
这是 Faust 应用程序的一个非常基本的示例。通常,我们不会生成记录并通过 Kafka 服务器发送它们,并在同一个应用程序中处理它们。但是,这对于本演示来说是可以的。在生产环境中,我们可能会连接到远程 Kafka 服务器,该服务器连接到多个来源并同时发布到多个不同的主题。
Faust 应用程序控制 Python 代码与 Kafka 服务器之间的交互。我们使用agent装饰器添加一个函数来处理发布到特定通道的信息。每当新数据被推送到样本主题时,将执行此异步函数。在这个食谱中,我们定义的代理只是将Record对象中包含的信息打印到终端中。
timer装饰器定义了一个服务,定期在指定的间隔执行某些操作。在我们的情况下,计时器通过App对象向 Kafka 服务器发送消息。然后将这些消息推送给代理进行处理。
Faust 命令行界面用于启动运行应用程序的工作进程。这些工作进程实际上是在 Kafka 服务器上或本地进程中对事件做出反应的处理者,例如本示例中定义的定时器服务。较大的应用程序可能会使用多个工作进程来处理大量数据。
此外
Faust 文档提供了有关 Faust 功能的更多详细信息,以及 Faust 的各种替代方案:faust.readthedocs.io/en/latest/。
有关 Kafka 的更多信息可以在 Apache Kafka 网站上找到:kafka.apache.org/。
使用 Cython 加速代码
Python 经常因为速度慢而受到批评——这是一个无休止的争论。使用具有 Python 接口的高性能编译库(例如科学 Python 堆栈)可以解决许多这些批评,从而大大提高性能。然而,在某些情况下,很难避免 Python 不是编译语言的事实。在这些(相当罕见的)情况下,改善性能的一种方法是编写 C 扩展(甚至完全重写代码为 C)以加速关键部分。这肯定会使代码运行更快,但可能会使维护软件包变得更加困难。相反,我们可以使用 Cython,这是 Python 语言的扩展,可以转换为 C 并编译以获得更好的性能改进。
例如,我们可以考虑一些用于生成 Mandelbrot 集图像的代码。为了比较,我们假设纯 Python 代码——我们假设这是我们的起点——如下所示:
# mandelbrot/python_mandel.py
import numpy as np
def in_mandel(cx, cy, max_iter):
x = cx
y = cy
for i in range(max_iter):
x2 = x**2
y2 = y**2
if (x2 + y2) >= 4:
return i
y = 2.0*x*y + cy
x = x2 - y2 + cx
return max_iter
def compute_mandel(N_x, N_y, N_iter):
xlim_l = -2.5
xlim_u = 0.5
ylim_l = -1.2
ylim_u = 1.2
x_vals = np.linspace(xlim_l, xlim_u, N_x, dtype=np.float64)
y_vals = np.linspace(ylim_l, ylim_u, N_y, dtype=np.float64)
height = np.empty((N_x, N_y), dtype=np.int64)
for i in range(N_x):
for j in range(N_y):
height[i, j] = in_mandel(x_vals[i], y_vals[j], N_iter)
return height
纯 Python 中这段代码相对较慢的原因是相当明显的:嵌套循环。为了演示目的,让我们假设我们无法使用 NumPy 对这段代码进行矢量化。一些初步测试显示,使用这些函数生成 Mandelbrot 集的 320×240 点和 255 步大约需要 6.3 秒。您的时间可能会有所不同,这取决于您的系统。
在这个示例中,我们将使用 Cython 大大提高前面代码的性能,以生成 Mandelbrot 集图像。
准备工作
对于这个示例,我们需要安装 NumPy 包和 Cython 包。您还需要在系统上安装 GCC 等 C 编译器。例如,在 Windows 上,您可以通过安装 MinGW 来获取 GCC 的版本。
操作步骤
按照以下步骤使用 Cython 大大提高生成 Mandelbrot 集图像的代码性能:
- 在
mandelbrot文件夹中创建一个名为cython_mandel.pyx的新文件。在这个文件中,我们将添加一些简单的导入和类型定义:
# mandelbrot/cython_mandel.pyx
import numpy as np
cimport numpy as np
cimport cython
ctypedef Py_ssize_t Int
ctypedef np.float64_t Double
- 接下来,我们使用 Cython 语法定义
in_mandel例程的新版本。我们在这个例程的前几行添加了一些声明:
cdef int in_mandel(Double cx, Double cy, int max_iter):
cdef Double x = cx
cdef Double y = cy
cdef Double x2, y2
cdef Int i
- 函数的其余部分与 Python 版本的函数相同:
for i in range(max_iter):
x2 = x**2
y2 = y**2
if (x2 + y2) >= 4:
return i
y = 2.0*x*y + cy
x = x2 - y2 + cx
return max_iter
- 接下来,我们定义
compute_mandel函数的新版本。我们向这个函数添加了 Cython 包的两个装饰器:
@cython.boundscheck(False)
@cython.wraparound(False)
def compute_mandel(int N_x, int N_y, int N_iter):
- 然后,我们像在原始例程中一样定义常量:
cdef double xlim_l = -2.5
cdef double xlim_u = 0.5
cdef double ylim_l = -1.2
cdef double ylim_u = 1.2
- 我们使用 NumPy 包中的
linspace和empty例程的方式与 Python 版本完全相同。这里唯一的添加是我们声明了i和j变量,它们是Int类型的:
cdef np.ndarray x_vals = np.linspace(xlim_l, xlim_u,
N_x, dtype=np.float64)
cdef np.ndarray y_vals = np.linspace(ylim_l, ylim_u,
N_y, dtype=np.float64)
cdef np.ndarray height = np.empty((N_x, N_y), dtype=np.int64)
cdef Int i, j
- 定义的其余部分与 Python 版本完全相同:
for i in range(N_x):
for j in range(N_y):
height[i, j] = in_mandel(x_vals[i], y_vals[j], N_iter)
return height
- 接下来,在
mandelbrot文件夹中创建一个名为setup.py的新文件,并将以下导入添加到此文件的顶部:
# mandelbrot/setup.py
import numpy as np
from setuptools import setup, Extension
from Cython.Build import cythonize
- 之后,我们使用指向原始
python_mandel.py文件的源定义一个扩展模块。将此模块的名称设置为hybrid_mandel:
hybrid = Extension(
"hybrid_mandel",
sources=["python_mandel.py"],
include_dirs=[np.get_include()],
define_macros=[("NPY_NO_DEPRECATED_API",
"NPY_1_7_API_VERSION")]
)
- 现在,我们定义第二个扩展模块,将源设置为刚刚创建的
cython_mandel.pyx文件:
cython = Extension(
"cython_mandel",
sources=["cython_mandel.pyx"],
include_dirs=[np.get_include()],
define_macros=[("NPY_NO_DEPRECATED_API",
"NPY_1_7_API_VERSION")]
)
- 接下来,将这两个扩展模块添加到列表中,并调用
setup例程来注册这些模块:
extensions = [hybrid, cython]
setup(
ext_modules = cythonize(extensions, compiler_directives=
{"language_level": "3"}),
)
-
在
mandelbrot文件夹中创建一个名为__init__.py的新空文件,以便将其转换为可以在 Python 中导入的包。 -
在
mandelbrot文件夹中打开终端,并使用以下命令构建 Cython 扩展模块:
python3.8 setup.py build_ext --inplace
- 现在,开始一个名为
run.py的新文件,并添加以下导入语句:
# run.py
from time import time
from functools import wraps
import matplotlib.pyplot as plt
- 从我们定义的每个模块中导入各种
compute_mandel例程:原始的python_mandel;Cython 化的 Python 代码hybrid_mandel;以及编译的纯 Cython 代码cython_mandel:
from mandelbrot.python_mandel import compute_mandel
as compute_mandel_py
from mandelbrot.hybrid_mandel import compute_mandel
as compute_mandel_hy
from mandelbrot.cython_mandel import compute_mandel
as compute_mandel_cy
- 定义一个简单的计时器装饰器,我们将用它来测试例程的性能:
def timer(func, name):
@wraps(func)
def wrapper(*args, **kwargs):
t_start = time()
val = func(*args, **kwargs)
t_end = time()
print(f"Time taken for {name}: {t_end - t_start}")
return val
return wrapper
- 将
timer装饰器应用于每个导入的例程,并定义一些用于测试的常量:
mandel_py = timer(compute_mandel_py, "Python")
mandel_hy = timer(compute_mandel_hy, "Hybrid")
mandel_cy = timer(compute_mandel_cy, "Cython")
Nx = 320
Ny = 240
steps = 255
- 用我们之前设置的常量运行每个装饰的例程。将最终调用(Cython 版本)的输出记录在
vals变量中:
mandel_py(Nx, Ny, steps)
mandel_hy(Nx, Ny, steps)
vals = mandel_cy(Nx, Ny, steps)
- 最后,绘制 Cython 版本的输出,以检查例程是否正确计算了 Mandelbrot 集:
fig, ax = plt.subplots()
ax.imshow(vals.T, extent=(-2.5, 0.5, -1.2, 1.2))
plt.show()
运行run.py文件将在终端打印每个例程的执行时间,如下所示:
Time taken for Python: 6.276328802108765
Time taken for Hybrid: 5.816391468048096
Time taken for Cython: 0.03116750717163086
Mandelbrot 集的绘图可以在以下图像中看到:
图 10.4:使用 Cython 代码计算的 Mandelbrot 集的图像
这是我们对 Mandelbrot 集的期望。
它是如何工作的...
在这个示例中发生了很多事情,所以让我们从解释整个过程开始。Cython 接受用 Python 语言的扩展编写的代码,并将其编译成 C 代码,然后用于生成可以导入 Python 会话的 C 扩展库。实际上,您甚至可以使用 Cython 直接将普通 Python 代码编译为扩展,尽管结果不如使用修改后的语言好。在这个示例中的前几个步骤中,我们在修改后的语言中定义了 Python 代码的新版本(保存为.pyx文件),其中包括类型信息以及常规 Python 代码。为了使用 Cython 构建 C 扩展,我们需要定义一个设置文件,然后创建一个文件来生成结果。
Cython 代码的最终编译版本比其 Python 等效代码运行速度快得多。Cython 编译的 Python 代码(在本示例中称为混合代码)的性能略优于纯 Python 代码。这是因为生成的 Cython 代码仍然必须处理带有所有注意事项的 Python 对象。通过在.pyx文件中向 Python 代码添加类型信息,我们开始看到性能的重大改进。这是因为in_mandel函数现在有效地被定义为一个 C 级别函数,它不与 Python 对象交互,而是操作原始数据类型。
Cython 代码和 Python 等效代码之间存在一些小但非常重要的区别。在步骤 1中,您可以看到我们像往常一样导入了 NumPy 包,但我们还使用了cimport关键字将一些 C 级别的定义引入了作用域。在步骤 2中,我们在定义in_mandel例程时使用了cdef关键字而不是def关键字。这意味着in_mandel例程被定义为一个 C 级别函数,不能从 Python 级别使用,这在调用这个函数时(这经常发生)节省了大量开销。
关于这个函数定义的唯一其他真正的区别是在签名和函数的前几行中包含了一些类型声明。我们在这里应用的两个装饰器禁用了访问列表(数组)元素时的边界检查。boundscheck装饰器禁用了检查索引是否有效(在 0 和数组大小之间),而wraparound装饰器禁用了负索引。尽管它们禁用了 Python 内置的一些安全功能,但这两个装饰器在执行过程中都会对速度产生适度的改进。在这个示例中,禁用这些检查是可以的,因为我们正在使用循环遍历数组的有效索引。
设置文件是我们告诉 Python(因此也是 Cython)如何构建 C 扩展的地方。Cython 中的cythonize例程在这里起着关键作用,因为它触发了 Cython 构建过程。在步骤 9和10中,我们使用setuptools中的Extension类定义了扩展模块,以便我们可以为构建定义一些额外的细节;具体来说,我们为 NumPy 编译设置了一个环境变量,并添加了 NumPy C 头文件的include文件。这是通过Extension类的define_macros关键字参数完成的。我们在步骤 13中使用setuptools命令来构建 Cython 扩展,并且添加了--inplace选项,这意味着编译后的库将被添加到当前目录,而不是放在一个集中的位置。这对开发来说是很好的。
运行脚本相当简单:从每个定义的模块中导入例程 - 其中两个实际上是 C 扩展模块 - 并计算它们的执行时间。我们必须在导入别名和例程名称上有一些创造性,以避免冲突。
还有更多...
Cython 是改进代码性能的强大工具。然而,在优化代码时,您必须始终谨慎地花费时间。使用像 Python 标准库中提供的 cProfiler 这样的性能分析工具可以用来找到代码中性能瓶颈出现的地方。在这个示例中,性能瓶颈出现的地方是相当明显的。在这种情况下,Cython 是解决问题的良药,因为它涉及对(双重)for循环内的函数进行重复调用。然而,它并不是解决性能问题的通用方法,往往情况下,通过重构代码以利用高性能库,可以大大提高代码的性能。
Cython 与 Jupyter 笔记本集成良好,并且可以无缝地在笔记本的代码块中使用。Cython 也包含在 Python 的 Anaconda 发行版中,因此在使用 Anaconda 发行版安装了 Cython 后,就无需额外设置即可在 Jupyter 笔记本中使用 Cython。
在从 Python 生成编译代码时,Cython 并不是唯一的选择。例如,NumBa 包(numba.pydata.org/)提供了一个即时(JIT)编译器,通过简单地在特定函数上放置装饰器来优化 Python 代码的运行时。NumBa 旨在与 NumPy 和其他科学 Python 库一起使用,并且还可以用于利用 GPU 加速代码。
使用 Dask 进行分布式计算
Dask 是一个用于在多个线程、进程或甚至计算机之间进行分布式计算的库,以有效地进行大规模计算。即使您只是在一台笔记本电脑上工作,这也可以极大地提高性能和吞吐量。Dask 提供了 Python 科学堆栈中大多数数据结构的替代品,如 NumPy 数组和 Pandas DataFrames。这些替代品具有非常相似的接口,但在内部,它们是为分布式计算而构建的,以便它们可以在多个线程、进程或计算机之间共享。在许多情况下,切换到 Dask 就像改变import语句一样简单,可能还需要添加一些额外的方法调用来启动并发计算。
在这个示例中,我们将学习如何使用 Dask 对 DataFrame 进行一些简单的计算。
准备工作
对于这个示例,我们需要从 Dask 包中导入dataframe模块。按照 Dask 文档中的约定,我们将使用别名dd导入此模块:
import dask.dataframe as dd
我们还需要这一章的代码库中的sample.csv文件。
如何做...
按照以下步骤使用 Dask 对 DataFrame 对象执行一些计算:
- 首先,我们需要将数据从
sample.csv加载到 Dask 的DataFrame中:
data = dd.read_csv("sample.csv")
- 接下来,我们对 DataFrame 的列执行标准计算:
sum_data = data.lower + data.upper
print(sum_data)
与 Pandas DataFrames 不同,结果不是一个新的 DataFrame。print语句给了我们以下信息:
Dask Series Structure:
npartitions=1
float64
...
dtype: float64
Dask Name: add, 6 tasks
- 要实际获得结果,我们需要使用
compute方法:
result = sum_data.compute()
print(result.head())
结果现在如预期所示:
0 -0.911811
1 0.947240
2 -0.552153
3 -0.429914
4 1.229118
dtype: float64
- 我们计算最后两列的均值的方式与 Pandas DataFrame 完全相同,但我们需要添加一个调用
compute方法来执行计算:
means = data.loc[:, ("lower", "upper")].mean().compute()
print(means)
打印的结果与我们的预期完全一致:
lower -0.060393
upper -0.035192
dtype: float64
它是如何工作的...
Dask 为计算构建了一个任务图,描述了需要对数据集合执行的各种操作和计算之间的关系。这样可以将计算步骤分解,以便可以按正确的顺序在不同的工作器之间进行计算。然后将此任务图传递给调度程序,调度程序将实际任务发送给工作器执行。Dask 配备了几种不同的调度程序:同步、线程、多进程和分布式。可以在compute方法的调用中选择调度程序的类型,或者全局设置。如果没有给出一个合理的默认值,Dask 会选择一个合理的默认值。
同步、线程和多进程调度程序在单台机器上工作,而分布式调度程序用于与集群一起工作。Dask 允许您以相对透明的方式在调度程序之间切换,尽管对于小任务,您可能不会因为设置更复杂的调度程序而获得任何性能优势。
compute方法是这个示例的关键。通常会在 Pandas DataFrames 上执行计算的方法现在只是设置了一个通过 Dask 调度程序执行的计算。直到调用compute方法之前,计算才会开始。这类似于Future作为异步函数调用结果的代理返回,直到计算完成才会实现。
还有更多...
Dask 提供了 NumPy 数组的接口,以及本示例中显示的 DataFrames。还有一个名为dask_ml的机器学习接口,它提供了类似于 scikit-learn 包的功能。一些外部包,如xarray,也有 Dask 接口。Dask 还可以与 GPU 一起工作,以进一步加速计算并从远程源加载数据,这在计算分布在集群中时非常有用。


浙公网安备 33010602011771号