Python-数学应用第二版-全-

Python 数学应用第二版(全)

原文:annas-archive.org/md5/f7fb298b3279144e9a56cc1cc6fa87cf

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Python 是一种强大且灵活的编程语言,既有趣又易学。它是许多专业人士、爱好者和科学家的首选编程语言。Python 的强大之处在于其庞大的包生态系统和友好的社区,以及与编译扩展模块的无缝通信能力。这意味着 Python 非常适合解决各种问题,特别是数学问题。

数学通常与计算和方程式相关,但实际上,这些只是一个更大主题的极小部分。数学的核心在于解决问题,以及通过逻辑结构化的方式进行求解。一旦你突破方程、计算、导数和积分的表面,就会发现一个广阔的美丽而优雅的结构世界。

本书是使用 Python 解决数学问题的入门介绍。它介绍了一些数学的基本概念,以及如何利用 Python 处理这些概念。书中还介绍了几种基本模板,用于解决涵盖多个数学主题的各种数学问题。前几章重点讲解了核心技能,如操作 NumPy 数组、绘图、微积分和概率。这些主题在数学中非常重要,为后续章节打下了基础。在接下来的章节中,我们讨论了更多实际问题,涉及数据分析与统计、网络、回归与预测、优化和博弈论等主题。我们希望本书能够为你解决数学问题提供基础,并为你进一步探索数学世界提供工具。

本书适合谁阅读

本书主要面向那些有一定 Python 基础,并且有涉及数学问题解决的项目(无论是工作还是兴趣)的人。在前几章中,我们为那些不熟悉基础知识的读者提供了一些数学背景的简要介绍,但由于篇幅有限,内容有所限制。我在每章结尾提供了一些进一步阅读的建议,引导你去阅读更多的资源。希望本书能帮助你入门数学问题,并激发你对这些主题背后数学原理的好奇心。

本书涵盖的内容

第一章基础包、函数和概念简介,介绍了本书其余部分所需的一些基本工具和概念,包括用于数学编程的主要 Python 包 NumPy 和 SciPy。

第二章使用 Matplotlib 进行数学绘图,涵盖了使用 Matplotlib 绘图的基础知识,这在解决几乎所有数学问题时都非常有用。

第三章微积分和微分方程,介绍了微积分中的不同主题,如微分和积分,以及一些更高级的主题,如常微分方程和偏微分方程。

第四章使用随机性和概率,介绍了随机性和概率的基础知识,以及如何使用 Python 探索这些概念。

第五章使用树和网络,讲解了如何在 Python 中使用NetworkX包处理树和网络(图)。

第六章处理数据和统计,提供了使用 Python 处理、操作和分析数据的各种技术。

第七章使用回归和预测,描述了使用Statsmodels包和 scikit-learn 进行数据建模和预测未来值的各种技术。

第八章几何问题,展示了使用Shapely包处理几何对象的各种技术。

第九章寻找最优解,介绍了优化和博弈论,使用数学方法寻找问题的最佳解决方案。

第十章提高您的生产力,涵盖了在使用 Python 解决数学问题时可能遇到的各种情况。

要充分利用本书

您需要具备基本的 Python 知识。我们不假设您具备数学知识,但如果您熟悉一些基本的数学概念,将更好地理解我们讨论的技术的上下文和细节。

本书唯一的要求是使用最新版本的 Python – 至少 Python 3.6,但更高版本更好。(本版的代码已在 Python 3.10 上进行了测试,但也应在较早版本上工作。)您可能更喜欢使用 Anaconda Python 发行版,该发行版包含本书所需的许多包和工具。如果是这种情况,您应使用conda包管理器安装这些包。Python 支持所有主要操作系统 – Windows、macOS 和 Linux – 以及许多平台。

本书使用的包及其版本在编写时为:NumPy 1.23.3, SciPy 1.9.1 Matplotlib 3.6.0, Jax 0.3.13(及 jaxlib 0.3.10),Diffrax 0.1.2, PyMC 4.2.2, pandas 1.4.3 Bokeh 2.4.3, NetworkX 3.5.3, Scikit-learn 1.1.2, StatsModels 0.13.2, Shapely 1.8.4, NashPy 0.0.35, Pint 0.20.1, Uncertainties 3.1.7, Xarray 2022.11.0, NetCDF4 1.6.1, Geopandas 0.12.1, CartoPy 0.21.0, Cerberus 1.3.4, Cython 0.29.32, Dask 2022.10.2。

书中涉及的软件/硬件 操作系统要求
Python 3.10 Windows、macOS 或 Linux

如果您使用的是本书的数字版,我们建议您亲自输入代码,或访问本书的 GitHub 仓库(下一节提供了链接)。这样做可以帮助您避免与复制粘贴代码相关的潜在错误。

您可能更愿意在 Jupyter Notebook 中而不是简单的 Python 文件中处理本书中的代码示例。本书中有一两处,您可能需要重复绘图命令,因为绘图无法像此处展示的那样在后续单元中更新。

下载示例代码文件

您可以从 GitHub 上下载本书的示例代码文件:github.com/PacktPublishing/Applying-Math-with-Python-2nd-Edition。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他的代码包,来自我们丰富的书籍和视频目录,可以在 github.com/PacktPublishing/ 中查看!请查阅!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的截图和图表的彩色图片。您可以在这里下载:packt.link/OxkXD

使用的约定

本书中使用了多种文本约定。

文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号。以下是一个例子:“Decimal 包还提供了 Context 对象,它允许对 Decimal 对象的精度、显示和属性进行精细控制。”

代码块按以下方式设置:

from decimal import getcontext
ctx = getcontext()
num = Decimal('1.1')
num**4  # Decimal('1.4641')
ctx.prec=4  # set the 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.10 -m pip install numpy scipy

粗体:表示新术语、重要单词或屏幕上出现的单词。例如,菜单或对话框中的单词会以粗体显示。以下是一个例子:“从管理面板中选择系统信息。”

提示或重要说明

看起来是这样的。

联系我们

我们始终欢迎读者的反馈。

customercare@packtpub.com 并在邮件主题中提及书名。

勘误表:尽管我们已尽最大努力确保内容的准确性,但错误还是会发生。如果您在本书中发现错误,我们将非常感激您能向我们报告。请访问 www.packtpub.com/support/errata 并填写表格。

copyright@packt.com 并附上材料链接。

如果您有兴趣成为作者:如果您在某个领域具有专业知识,并且有兴趣写书或参与编写书籍,请访问 authors.packtpub.com

分享您的想法

阅读完《使用 Python 应用数学》后,我们很希望听到您的想法!请点击这里直接进入亚马逊评论页面并分享您的反馈。

您的评价对我们和技术社区非常重要,并将帮助我们确保提供优质的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢随时随地阅读,但无法随身携带纸质书籍吗?您的电子书购买是否与您选择的设备不兼容?

别担心,现在每本 Packt 书籍您都能免费获得该书的无 DRM PDF 版本。

无论在哪里,任何地方,任何设备上都能阅读。直接将您最喜欢的技术书籍中的代码搜索、复制并粘贴到您的应用程序中。

好处不仅如此,您还可以独家获取折扣、新闻通讯以及每天送到您邮箱的精彩免费内容。

按照以下简单步骤,您可以获得这些好处:

  1. 扫描二维码或访问以下链接

packt.link/free-ebook/9781804618370

  1. 提交您的购买证明

  2. 就这样!我们会直接将您的免费 PDF 和其他福利发送到您的电子邮件。

第一章:基本包、函数和概念简介

在开始任何实际的例程之前,我们将利用本章的开头部分介绍几个核心的数学概念和结构及其在 Python 中的表示。我们将讨论基本数值类型、基本数学函数(如三角函数、指数函数和对数函数)以及矩阵。矩阵在大多数计算应用中至关重要,因为矩阵与线性方程组的解之间有着密切的联系。我们将在本章中探索一些应用,但矩阵将在全书中扮演重要角色。

我们将按以下顺序讨论以下主要主题:

  • 探索 Python 数值类型

  • 理解基本数学函数

  • 深入 NumPy 的世界

  • 矩阵和线性代数的应用

本章中我们将看到的 NumPy 数组和基本数学函数将在本书的其余部分中反复使用——它们几乎出现在每个例程中。矩阵理论及本章讨论的其他主题为本书中讨论的包背后许多计算方法提供了基础。尽管我们不会在书中的例程中使用某些主题(例如,替代的数值类型),但它们仍然是需要了解的重要内容。

技术要求

在本章及全书中,我们将使用 Python 版本 3.10,这是写作时的最新版本。本书中的大部分代码将在 Python 3.6 及更高版本上运行。我们将在不同的部分使用 Python 3.6 中引入的特性,包括 f-string。因此,你可能需要将终端命令中出现的 python3.10 改为你所使用的 Python 版本。这可能是 Python 的另一个版本,如 python3.6python3.7,或者一个更通用的命令,如 python3python。对于后者的命令,你需要通过以下命令检查 Python 的版本是否至少为 3.6:

python --version

Python 具有内建的数值类型和基本数学函数,足以应对仅涉及小型计算的小型应用程序。NumPy 包提供了一个高性能的数组类型和相关的例程(包括在数组上高效运作的基本数学函数)。这个包将在本章以及本书剩余部分的许多例程中使用。我们还将在本章后面的例程中使用 SciPy 包。可以通过你喜欢的包管理工具(如 pip)来安装它们:

python3.10 -m pip install numpy scipy

按照惯例,我们以更简短的别名导入这些包。我们将 numpy 导入为 np,将 scipy 导入为 sp,并使用以下 import 语句:

import numpy as np
import scipy as sp

这些约定在官方文档中有所使用 (numpy.org/doc/stable/docs.scipy.org/doc/scipy/),以及使用这些包的许多教程和其他材料中。

本章的代码可以在 GitHub 仓库的 Chapter 01 文件夹中找到,地址为 github.com/PacktPublishing/Applying-Math-with-Python-2nd-Edition/tree/main/Chapter%2001

探索 Python 数值类型

Python 提供了基本的数值类型,如任意大小的整数和浮点数(双精度),作为标准类型,但它还提供了几个额外的类型,这些类型在精度尤其重要的特定应用中非常有用。Python 还提供了对复数的(内置)支持,这对于一些更高级的数学应用非常有用。让我们从 Decimal 类型开始,了解一下这些不同的数值类型。

Decimal 类型

对于需要精确算术运算的十进制数字的应用,可以使用 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 的四次方被四舍五入为四位有效数字。

通过使用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类型仅存储两个整数——分子和分母——并通过基本的分数加法和乘法规则执行算术运算。

复数类型

平方根函数对于正数是有效的,但对于负数并未定义。然而,我们可以通过正式添加一个符号 i——虚数单位——来扩展实数集,其平方为(即!)。复数是形如的数,其中实数(即我们通常所用的数字)。在这种形式下,数称为实部,而则称为虚部。复数具有自己的算术运算(加法、减法、乘法、除法),当虚部为零时,这些运算与实数的算术运算一致。例如,我们可以将复数相加得到,或者将它们相乘得到如下结果:

复数的出现频率可能比你想象的要高,它们通常出现在存在某种周期性或振荡性行为的场景中。这是因为三角函数分别是复指数的实部和虚部:

这里,是任意实数。关于复数的更多细节以及更多有趣的事实和理论可以在许多关于复数的资源中找到。以下的Wikipedia页面是一个很好的起点:https://en.wikipedia.org/wiki/Complex_number。

Python 支持复数,包括在代码中表示复数单位的字面量字符1j。这可能与你从其他关于复数的来源中所熟悉的复数单位表示法有所不同。大多数数学文献通常使用符号来表示复数单位:

z = 1 + 1j
z + 2  # 3 + 1j
z.conjugate()  # 1 - 1j

复数的共轭复数是将虚部取负的结果。这会产生方程的两个可能解之间的互换效果。

Python 标准库的cmath模块提供了特殊的复数数学函数。

现在我们已经了解了 Python 提供的一些基本数学类型,接下来可以开始探索它提供的一些数学函数。

理解基本的数学函数

基本的数学函数在许多应用中都有出现。例如,使用对数可以将指数增长的数据进行缩放,得到线性数据。指数函数和三角函数是处理几何信息时常见的工具,伽马函数在组合数学中出现,高斯误差函数在统计学中非常重要。

Python 标准库中的math模块提供了所有标准数学函数,以及常见的常数和一些工具函数,可以通过以下命令导入:

import math

一旦导入,我们就可以使用该模块中的任何数学函数。例如,要找出一个非负数的平方根,可以使用math中的sqrt函数:

import math
math.sqrt(4)  #  2.0

尝试使用sqrt函数并传入负数作为参数将引发值错误。负数的平方根对于这个sqrt函数是未定义的,因为它仅处理实数。负数的平方根——这将是一个复数——可以使用 Python 标准库中的cmath模块提供的替代sqrt函数来计算。

正弦、余弦和正切三角函数在math模块中分别以常见的缩写sincostan提供。常数pi表示π的值,约等于 3.1416:

theta = math.pi/4
math.cos(theta)  # 0.7071067811865476
math.sin(theta)  # 0.7071067811865475
math.tan(theta)  # 0.9999999999999999

反三角函数在math模块中命名为acosasinatan

math.asin(-1)  # -1.5707963267948966
math.acos(-1)  # 3.141592653589793
math.atan(1)  # 0.7853981633974483

Python 标准库中的math模块提供了对数函数log。它有一个可选参数来指定对数的底(注意,第二个参数是位置参数)。默认情况下,如果没有指定可选参数,它将计算自然对数,底数为。可以使用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模块还包含各种理论和组合函数。这些包括combfactorial函数,它们在多种应用中非常有用。调用comb函数,传入参数,返回从个项目中选择个项目的方式数,而顺序不重要。如果顺序不重要,选 1 再选 2 与选 2 再选 1 是一样的。这个数字有时会写作。调用factorial函数,传入参数,返回该整数的阶乘

math.comb(5, 2)  # 10
math.factorial(5)  # 120

对负数应用阶乘运算会引发ValueError。一个整数的阶乘等于伽马函数在处的值;即:

math模块还包含一个函数,返回其参数的最大公约数,该函数名为gcd的最大公约数是最大的整数,使得可以整除

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模块中的floorceil函数提供了其参数的下取整和上取整。一个数字下取整是最大的整数,满足;而上取整是最小的整数,满足。这些函数在将通过除法得到的浮点数转换为整数时非常有用。

math 模块包含用 C 实现的函数(假设你正在运行 CPython),因此比 Python 实现的函数要快得多。如果你需要对一个相对较小的数字集合应用某个函数,这个模块是一个不错的选择。如果你希望对大量数据同时应用这些函数,最好使用 NumPy 包中的等效函数,因为它在处理数组时更高效。一般来说,如果你已经导入了 NumPy 包,那么最好始终使用 NumPy 等效的函数,以减少出错的机会。考虑到这一点,现在让我们介绍 NumPy 包及其基本对象:多维数组。

深入了解 NumPy 的世界

NumPy 提供了高性能的数组类型和用于在 Python 中操作这些数组的函数。这些数组对于处理大型数据集(性能至关重要的场景)非常有用。NumPy 是 Python 数值计算和科学计算栈的基础。在底层,NumPy 利用诸如 基础线性代数子程序BLAS)包等低级库来加速计算。

通常,NumPy 包会以更短的别名 np 被导入,可以通过以下 import 语句实现:

import numpy as np

这个约定在 NumPy 文档中以及更广泛的科学 Python 生态系统(如 SciPy、pandas 等)中得到了应用。

NumPy 库提供的基本类型是 ndarray 类型(以下简称 NumPy 数组)。通常,你不会自己创建该类型的实例,而是会使用如 array 之类的辅助函数来正确地设置类型。array 函数从类似数组的对象创建 NumPy 数组,这个对象通常是一个数字列表或数字的列表列表。例如,我们可以通过提供包含所需元素的列表来创建一个简单的数组:

arr = np.array([1, 2, 3, 4])  # array([1, 2, 3, 4])

NumPy 数组类型(ndarray)是一个 Python 包装器,围绕着一个底层的 C 数组结构。数组操作是用 C 实现的,并进行了性能优化。NumPy 数组必须包含同质数据(所有元素具有相同的类型),尽管该类型可以是指向任意 Python 对象的指针。如果在创建时没有显式提供类型,NumPy 会通过 dtype 关键字参数推断出一个合适的数据类型:

np.array([1, 2, 3, 4], dtype=np.float32)
# array([1., 2., 3., 4.], dtype=float32)

NumPy 提供了许多 C 类型的类型说明符,可以传递到 dtype 参数中,例如之前使用的 np.float32。通常,这些类型说明符的格式为 namexx,其中 name 是类型的名称——例如 int、float 或 complex,而 xx 是位数——例如 8、16、32、64、128。通常情况下,NumPy 会很好地为给定的输入选择一个合适的类型,但有时你可能希望覆盖它。前述情况就是一个很好的例子——如果没有 dtype=np.float32 参数,NumPy 会假设类型为 int64

在底层,任何形状的 NumPy 数组都是一个缓冲区,包含作为扁平(单维)数组的原始数据和一组附加的元数据,后者指定了诸如元素类型等详细信息。

创建后,可以使用数组的 dtype 属性访问数据类型。修改 dtype 属性会导致不良后果,因为构成数组中数据的原始字节将被重新解释为新的数据类型。例如,如果我们使用 Python 整数创建一个数组,NumPy 会将这些整数转换为数组中的 64 位整数。更改 dtype 值会导致 NumPy 重新解释这些 64 位整数为新的数据类型:

arr = np.array([1, 2, 3, 4])
print(arr.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 协议,因此可以像访问列表一样访问数组中的元素,并支持所有算术运算,这些运算是逐元素执行的。这意味着我们可以使用索引符号和索引来从指定索引中检索元素,如下所示:

arr = np.array([1, 2, 3, 4])
arr[0]  # 1
arr[2]  # 3

这也包括用于从现有数组中提取数据的常规切片语法。数组的切片本身也是一个数组,包含切片指定的元素。例如,我们可以获取包含 ary 中前两个元素的数组,或包含偶数索引元素的数组,如下所示:

first_two = arr[:2]  # array([1, 2])
even_idx = arr[::2]  # array([1, 3])

切片的语法是 start:stop:step。我们可以省略 startstop 中的任意一个或两个,以分别从开头或结尾获取所有元素。我们还可以省略 step 参数,在这种情况下也不需要末尾的 :step 参数描述了应选择的选定范围内的元素。值为 1 时选择每个元素,或者像示例中,值为 2 时选择每隔一个的元素(从 0 开始则为偶数索引的元素)。该语法与切片 Python 列表时相同。

数组算术和函数

NumPy 提供了许多通用函数ufuncs),这些是能够高效操作 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]

如我们所见,打印出来的数组包含数字 2、4、6 和 8,这些是原始数组的元素乘以 2 得到的结果。

在下一节中,我们将介绍除了这里使用的 np.array 函数外,您还可以用来创建 NumPy 数组的其他方法。

有用的数组创建函数

要生成两个给定端点之间以固定间隔排列的数字数组,可以使用 arangelinspace 这两个函数。它们的区别在于,linspace 生成两个端点之间等距的多个值(默认是 50 个值),包括两个端点,而 arange 按给定步长生成数字,直到但不包括上限。linspace 函数生成的是闭区间 的值,而 arange 函数生成的是半开区间 的值:

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 生成的数组恰好有五个点,这是通过第三个参数指定的,包括两个端点 01。而使用 arange 生成的数组有四个点,不包括右端点 1;如果增加一个步长为 0.3,会得到 1.2,这大于 1

高维数组

NumPy 可以创建具有任意维度的数组,这些数组与创建简单的一维数组时使用的 array 函数相同。数组的维度数量由传递给 array 函数的嵌套列表的数量来指定。例如,我们可以通过提供一个列表的列表来创建二维数组,其中每个内部列表的成员都是一个数字,类似如下:

mat = np.array([[1, 2], [3, 4]])

NumPy 数组有一个 shape 属性,用来描述数组在每个维度上的排列方式。对于二维数组,shape 可以解释为数组的行数和列数。

具有三维或更多维度的数组有时被称为 张量。(事实上,任何维度的数组都可以称为张量:向量(一维数组)是一个 1-张量;二维数组是一个 2-张量或矩阵——请参见下一节。)常见的 机器学习 (ML) 框架,如 TensorFlow 和 PyTorch,都会实现自己的张量类,其行为通常与 NumPy 数组类似。

NumPy 将数组的形状存储为 array 对象上的 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,) 的一维数组,也有四个元素。如果尝试在元素总数不匹配的情况下进行重塑,将导致 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])

请注意,切片的结果是一个一维数组。

数组创建函数 zerosones 可以通过简单地指定多个维度参数来创建多维数组。

在接下来的部分中,我们将研究二维 NumPy 数组的特殊情况,它们在 Python 中作为矩阵使用。

处理矩阵和线性代数

NumPy 数组也作为矩阵,在数学和计算编程中具有基础性作用。矩阵仅仅是一个二维数组。矩阵在许多应用中都起着核心作用,例如几何变换和线性方程组,同时在统计学等其他领域也作为有用的工具出现。矩阵本身只有在我们赋予它们矩阵运算之后,才会与其他数组有所区别。矩阵具有逐元素加法和减法运算,和 NumPy 数组一样,还有一种称为标量乘法的第三种运算,即将矩阵中的每个元素与常数相乘,以及一种不同的矩阵乘法概念。矩阵乘法与其他乘法概念本质上是不同的,正如我们稍后将看到的。

矩阵的一个重要属性是它的形状,定义方式与 NumPy 数组相同。一个具有 行和 列的矩阵通常描述为 矩阵。如果矩阵的行数与列数相等,则称其为方阵,这些矩阵在向量和矩阵理论中扮演着特殊的角色。

单位矩阵(大小为 )是一个 矩阵,其中第 个元素为 1,而对于所有的 ,其他元素为零。存在一种数组创建方法,可以为指定的 值生成一个 单位矩阵:

np.eye(3)
# array([[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]])

正如名称所示,单位矩阵是一个特殊的矩阵,具有以下性质:

基本方法和属性

矩阵与许多术语和量有关。我们在此只提到其中两个属性,因为它们在稍后的内容中会有用。这些属性是矩阵的转置,即行列互换,以及方阵的,它是沿着主对角线的元素之和。主对角线由矩阵从左上角到右下角的元素组成

可以通过调用 transpose 方法对 array 对象轻松地进行转置。事实上,由于这是一个非常常见的操作,数组还具有一个便捷属性 T,该属性返回矩阵的转置。转置操作会逆转矩阵(数组)形状的顺序,使得行变成列,列变成行。例如,如果我们从一个 3 × 2 的矩阵(3 行 2 列)开始,则它的转置将是一个 2 × 3 的矩阵,如以下示例所示:

A = np.array([[1, 2], [3, 4]])
A.transpose()
# array([[1, 3],
#         [2, 4]])
A.T
# array([[1, 3],
#         [2, 4]])

注意

transpose 函数并不会实际修改底层数组中的数据,而是会改变形状并设置一个内部标志,指示存储值的顺序从按行连续(C 风格)转变为按列连续(F 风格)。这使得操作变得非常高效。

另一个偶尔有用的与矩阵相关的量是 。方阵 的迹,如前面的代码所示,是沿主对角线的元素之和,主对角线由从左上角到右下角的元素组成。迹的公式如下:

NumPy 数组有一个 trace 方法,它返回矩阵的迹:

A = np.array([[1, 2], [3, 4]])
A.trace()  # 5

也可以使用 np.trace 函数来访问迹,该函数不绑定到数组。

矩阵乘法

矩阵乘法是对两个矩阵进行的操作,它保持了两个矩阵的一些结构和特征。形式上,假设给定两个矩阵 ,一个 矩阵和 ,一个 矩阵,如下所示:

矩阵积 是一个 矩阵,其 -th 元素由以下方程给出:

请注意,第一个矩阵的列数 必须 与第二个矩阵的行数相匹配,以便定义矩阵乘法。我们通常写 表示 的矩阵积,前提是它已定义。矩阵乘法是一个特殊的操作。它不像其他大多数算术运算那样是 交换律 的:即使 都可以计算,它们也不一定相等。实际上,这意味着矩阵的乘法顺序很重要。这源于矩阵代数作为线性映射表示的起源,其中乘法对应于函数的组合。

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]])

单位矩阵是矩阵乘法下的 单位元素。也就是说,如果 是任何 矩阵,并且 单位矩阵,那么 ,同样地,如果 是一个 矩阵,则 。这可以通过使用 NumPy 数组检查具体的例子来验证:

A = np.array([[1, 2], [3, 4]])
I = np.eye(2)
A @ I
# array([[1., 2.],
#           [3., 4.]])

你可以看到,打印出的结果矩阵等于原始矩阵。如果我们反转!和!的顺序并执行乘法!,结果也是一样的。在接下来的部分,我们将研究矩阵的逆;一个矩阵!,当它与!相乘时,得到单位矩阵。

行列式和逆

一个矩阵的行列式在大多数应用中非常重要,因为它与求矩阵的逆有密切的关系。当矩阵的行数和列数相等时,该矩阵被称为方阵。特别地,一个行列式非零的矩阵具有(唯一的)逆矩阵,这意味着某些方程组有唯一解。矩阵的行列式是递归定义的。假设我们有一个通用的!矩阵,如下所示:

这个通用矩阵!行列式由以下公式定义:

对于一个一般的!矩阵,其中!,我们递归定义行列式。对于!,第!子矩阵!是通过删除!行和!列从!得到的。子矩阵!是一个!矩阵,因此我们可以计算它的行列式。然后,我们定义!的行列式为以下量:

事实上,前面方程中出现的索引 1 可以被任何!替换,结果将是相同的。

计算矩阵行列式的 NumPy 例程包含在一个名为linalg的单独模块中。这个模块包含许多常见的线性代数例程,线性代数是涵盖向量和矩阵代数的数学分支。计算方阵行列式的例程是det例程:

from numpy import linalg
linalg.det(A)  # -2.0000000000000004

注意,在计算行列式时发生了浮点舍入误差。

如果安装了 SciPy 包,它还提供了一个linalg模块,扩展了 NumPy 的linalg。SciPy 版本不仅包括额外的例程,而且始终编译了 BLAS 和线性代数包LAPACK)支持,而 NumPy 版本则是可选的。因此,根据 NumPy 的编译方式,如果对速度要求较高,SciPy 变体可能更为优选。

逆矩阵是一个 矩阵 ,它是(必定唯一的) 矩阵 ,使得 ,其中 表示 单位矩阵,并且这里执行的乘法是矩阵乘法。并非每个方阵都有逆矩阵;那些没有逆矩阵的矩阵有时被称为 奇异 矩阵。事实上,矩阵是非奇异的(即有逆矩阵的),当且仅当该矩阵的行列式不为 0。当 有逆矩阵时,通常表示为

linalg 模块中的 inv 函数计算一个矩阵的逆矩阵(如果存在的话):

linalg.inv(A)
# array([[-2\. , 1\. ],
#           [ 1.5, -0.5]])

我们可以通过矩阵乘法(在任一侧)与逆矩阵相乘,检查由 inv 函数给出的矩阵是否确实是 A 的逆矩阵,并确认得到的是 2 × 2 单位矩阵:

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 包中的标准 explogsincostan 函数不同,后者是按元素逐个应用对应的函数。相比之下,矩阵指数函数是通过矩阵的 幂级数 定义的:

这是为任何 矩阵 定义的,且 表示 n 次矩阵幂;即,矩阵 自乘 次。注意,这个“幂级数”总是以某种适当的方式收敛。根据惯例,我们取 ,其中 单位矩阵。这与通常用于实数或复数的指数函数幂级数定义完全相似,只不过用矩阵和矩阵乘法代替了数字和(常规)乘法。其他函数的定义也类似,但我们会跳过细节。

在下一节中,我们将看到一个可以利用矩阵及其理论来求解方程组的领域。

方程组

求解(线性)方程组是学习数学中矩阵的主要动机之一。此类问题在各种应用中频繁出现。我们从以下形式的线性方程组开始:

这里, 至少为 2, 是已知值, 值是我们希望找到的未知值。

在我们能解这样的方程组之前,我们需要将问题转化为一个矩阵方程。这是通过将系数 收集成一个 矩阵,并利用矩阵乘法的性质将此矩阵与方程组关联起来来实现的。因此,我们构建了以下矩阵,其中包含从方程中提取的系数:

然后,如果我们将 视为包含 值的未知(列)向量,且将 视为包含已知值 的(列)向量,那么我们可以将方程组重写为以下单一矩阵方程:

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

这些方程有三个未知值:。首先,我们创建一个系数矩阵和向量 。由于我们使用 NumPy 来处理矩阵和向量,我们为矩阵 创建一个二维 NumPy 数组,并为 创建一个一维数组:

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期望两个输入,分别是系数矩阵 和右侧向量 。它通过分解矩阵 到更简单的矩阵,快速减少到可以通过简单替换解决的问题,来解决方程组。这种解决矩阵方程的技术非常强大和高效,并且不太容易受到困扰一些其他方法的浮点舍入误差。例如,通过乘以(左侧的)矩阵 的逆来计算方程组的解,如果逆矩阵已知的话。然而,这通常不如使用solve例程好,因为它可能会更慢或者导致更大的数值误差。

在我们使用的例子中,系数矩阵 是方阵。也就是说,方程的未知数与方程数量相同。在这种情况下,如果(且仅当)矩阵 的行列式不是 时,方程组有唯一解。在行列式 的情况下,可以出现两种情况:系统可能没有解,此时我们称系统为不一致;或者可能有无限多个解。通常一致和不一致系统的区别由向量 决定。例如,考虑以下方程组:

左侧方程组是一致的,并且有无限多个解;例如,取 或者 都是解。右侧方程组不一致,并且没有解。在这两个方程组中,由于系数矩阵是奇异的,solve例程将失败。

系数矩阵不需要是方阵才能解决系统——例如,如果方程数量多于未知数(系数矩阵的行数多于列数)。这样的系统被称为过度规范,只要是一致的,它就会有解。如果方程数量少于未知数,则系统称为欠规范。如果是一致的话,欠规范方程组通常有无限多个解,因为没有足够的信息来唯一确定所有未知数值。不幸的是,即使系统有解,solve例程也无法找到系数矩阵不是方阵的系统的解。

在下一节中,我们将讨论特征值和特征向量,它们通过观察一种非常特定的矩阵方程得出,类似于之前看到的方程。

特征值和特征向量

考虑矩阵方程 ,其中 是一个方阵(), 是一个向量, 是一个数值。对于能解此方程的数字 ,它们称为特征值,而对应的向量 称为特征向量。特征值和对应特征向量的对编码了关于矩阵 的信息,因此在许多涉及矩阵的应用中非常重要。

我们将通过以下矩阵演示如何计算特征值和特征向量:

我们必须首先将其定义为一个 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 函数的返回类型有时会是 complex32complex64 等复数类型。在某些应用中,复特征值具有特殊含义,而在其他情况下,我们只考虑实特征值。

我们可以使用以下顺序从 eig 的输出中提取特征值/特征向量对:

i = 0 # first eigenvalue/eigenvector pair
lambda0 = v[i]
print(lambda0)
# 6.823156164525971
x0 = B[:, i] # ith column of B
print(x0)
# [ 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.

这里计算的范数表示方程 的左边(lhs)和右边(rhs)之间的距离。由于这个距离非常小(精确到 14 位小数),我们可以相当确信它们实际上是相同的。这个距离不为零,可能是由于浮动点精度误差。

寻找特征值和特征向量的理论过程是首先通过求解以下方程来找到特征值

这里, 是合适的单位矩阵。左边方程所确定的是一个关于 的多项式,称为 特征多项式。对应于特征值 的特征向量可以通过求解以下矩阵方程来找到:

在实践中,这个过程有些低效,存在其他更高效的计算特征值和特征向量的策略。

我们只能对方阵计算特征值和特征向量;对于非方阵,定义是没有意义的。特征值和特征向量的一个推广应用是用于非方阵的称为奇异值。为了做到这一点,我们必须计算两个向量 ,以及一个奇异值 ,它满足以下方程:

如果 是一个 矩阵,那么 将有 个元素,且 将有 个元素。感兴趣的 向量实际上是对称矩阵 的(正交规范化)特征向量,对应的特征值是 。根据这些值,我们可以使用之前的定义方程找到 向量。这将生成所有有趣的组合,但还存在其他向量 ,其中

奇异值(和奇异向量)的实用性来自于奇异值分解SVD),它将矩阵 写作一个乘积:

这里, 具有正交列, 具有正交行,且 是一个对角矩阵,通常写成沿主对角线的值递减。我们可以以稍微不同的方式写出这个公式,如下所示:

这意味着任何矩阵都可以分解为外积的加权和——假设 是具有 行和 列的矩阵,并且矩阵乘法 的转置相乘——这些是向量。

一旦完成了这种分解,我们可以寻找那些特别小的值,这些值对矩阵的值贡献很小。如果我们丢弃那些具有小值的项,那么就可以通过更简单的表示有效地逼近原始矩阵。这种技术被应用于主成分分析PCA)——例如,将复杂的高维数据集简化为几个对数据整体特征贡献最大的成分。

在 Python 中,我们可以使用linalg.svd函数来计算矩阵的 SVD。它的工作方式类似于之前描述的eig例程,不同之处在于它返回分解的三个组件:

mat = np.array([[0., 1., 2., 3.], [4., 5., 6., 7.]])
U, s, VT = np.linalg.svd(mat)

该函数返回的数组形状分别为(2, 2)(2,)(4, 4)。顾名思义,U矩阵和VT矩阵是出现在分解中的矩阵,s是一个包含非零奇异值的一维向量。我们可以通过重构矩阵并计算三个矩阵的乘积来检查分解是否正确:

Sigma = np.zeros(mat.shape)
Sigma[:len(s), :len(s)] = np.diag(s)
# array([[11.73352876, 0., 0., 0.],
#        [0., 1.52456641, 0.,  0.]])
reconstructed = U @ Sigma @ VT
# array([[-1.87949788e-15, 1., 2., 3.],
#           [4., 5., 6., 7.]])

注意到矩阵几乎完全被重构,唯一的不同是第一个元素。左上角的值非常接近零——在浮动点误差范围内——因此可以视为零。

我们构造矩阵的方法相当不方便。SciPy 版本的linalg模块包含一个特殊例程,可以通过奇异值的一维数组重构这个矩阵,叫做linalg.diagsvd。这个例程接受奇异值数组s和原始矩阵的形状,并构建具有适当形状的矩阵

Sigma = sp.linalg.diagsvd(s, *mat.shape)

(回顾一下,SciPy 包是通过别名sp导入的。)现在,让我们换个节奏,看看如何更高效地描述大多数条目为零的矩阵。这些就是所谓的稀疏矩阵。

稀疏矩阵

之前讨论的线性方程组在数学中极为常见,尤其是在数学计算中。在许多应用中,系数矩阵通常非常大,包含成千上万的行列,并且很可能不是手动输入的,而是从其他来源获得的。在许多情况下,它还将是一个稀疏矩阵,其中大多数条目为 0。

如果一个矩阵的大多数元素为零,则该矩阵是稀疏的。确切的零元素数量,用于判断一个矩阵是否稀疏,并没有明确的定义。稀疏矩阵可以更有效地表示——例如,简单地存储非零元素的索引和值。对于稀疏矩阵,有一整套算法可以显著提升性能,前提是矩阵确实足够稀疏。

稀疏矩阵出现在许多应用中,通常遵循某种模式。特别是,有几种技术用于求解sparse.csgraph模块。我们将在第五章中进一步讨论这些内容,与树 和网络的工作。

sparse模块包含几种不同的类,表示存储稀疏矩阵的不同方式。存储稀疏矩阵的最基本方式是存储三个数组,其中两个包含表示非零元素索引的整数,第三个数组包含相应元素的数据。这是coo_matrix类的格式。然后,还有csc_matrixcsr_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)
print(sp_A)
#  (0, 0)  1.0
#  (1, 1)  1.0
#  (2, 2)  1.0

如果你手动生成稀疏矩阵,矩阵可能遵循某种模式,例如下面的三对角矩阵:

在这里,非零元素出现在对角线上以及对角线的两侧,并且每行中的非零元素遵循相同的模式。为了创建这样的矩阵,我们可以使用sparse中的一个数组创建例程,如diags,它是一个方便的例程,用于创建具有对角线模式的矩阵:

T = sparse.diags([-1, 2, -1], (-1, 0, 1),   
    shape=(5, 5), format="csr")

这将创建一个矩阵 ,如前所述,并将其存储为 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 数组,如 eiginv

这就是我们对 Python 及其生态系统中可用的数学基本工具的简要介绍。让我们总结一下我们所看到的内容。

总结

Python 提供了内置的数学支持,包括一些基本的数值类型、算术运算、扩展精度数字、有理数、复数以及各种基本数学函数。然而,对于涉及大规模数值数组的更复杂计算,应该使用 NumPy 和 SciPy 包。NumPy 提供高性能的数组类型和基本操作,而 SciPy 提供了更专业的工具,用于求解方程和处理稀疏矩阵(还有许多其他功能)。

NumPy 数组可以是多维的。二维数组具有矩阵特性,可以通过 NumPy 或 SciPy 的 linalg 模块访问(前者是后者的一个子集)。此外,Python 中有一个特殊的矩阵乘法运算符 @,它已在 NumPy 数组中实现。SciPy 还通过 sparse 模块提供对稀疏矩阵的支持。我们还简要介绍了矩阵理论和线性代数,这些是本书中大多数数值方法的基础——通常是幕后工作。

在下一章中,我们将开始查看一些示例。

进一步阅读

有许多数学教材描述了矩阵和线性代数的基本性质,线性代数是研究向量和矩阵的学科。以下是适合线性代数入门的好书:

  • Strang, G. (2016). 线性代数导论马萨诸塞州韦尔斯利:韦尔斯利-剑桥出版社, 第五版

  • Blyth, T.Robertson, E. (2013). 基础线性代数伦敦:斯普林格 伦敦有限公司

NumPy 和 SciPy 是 Python 数学和科学计算生态系统的一部分,并且有着丰富的文档,可以从官方网站 scipy.org 访问。在本书中,我们将看到该生态系统中的其他几个包。

关于 NumPy 和 SciPy 在后台使用的 BLAS 和 LAPACK 库的更多信息,可以通过以下链接找到:

第二章:使用 Matplotlib 进行数学绘图

绘图是数学中一项基础工具。一张好的图表能够揭示隐藏的细节,提示未来的方向,验证结果,或增强论点。因此,科学 Python 堆栈中自然有一个功能强大且灵活的绘图库——Matplotlib。

在本章中,我们将使用多种样式绘制函数和数据,并创建完全标注和注释的图形。我们将创建三维图形,自定义图形的外观,使用子图创建包含多个绘图的图形,并将图形直接保存到文件中,适用于没有交互式环境的应用程序。

绘图是本书中最重要的内容之一。绘制数据、函数或解通常可以帮助你更好地理解问题,这对于推理方法非常有帮助。在本书的每一章中,我们都会看到绘图的应用。

在本章中,我们将涵盖以下内容:

  • 使用 Matplotlib 进行基本绘图

  • 添加子图

  • 绘制带误差条的图形

  • 保存 Matplotlib 图形

  • 曲面图和等高线图

  • 自定义三维图形

  • 绘制带箭头的向量场

技术要求

Python 的主要绘图库是 Matplotlib,可以通过你喜欢的包管理工具(如 pip)进行安装:

python3.10 -m pip install matplotlib

这将安装 Matplotlib 的最新版本,在编写本书时,版本为 3.5.2。

Matplotlib 包含了许多子包,但主要的 matplotlib.pyplot 包通常会被导入为 plt 别名。可以通过以下 import 语句实现:

import matplotlib.pyplot as plt

本章中的许多教程也需要使用 NumPy,通常通过 np 别名进行导入。

本章的代码可以在 GitHub 仓库的 Chapter 02 文件夹中找到,地址为 github.com/PacktPublishing/Applying-Math-with-Python-2nd-Edition/tree/main/Chapter%2002

使用 Matplotlib 进行基本绘图

绘图是理解行为的一个重要部分。仅通过绘制一个函数或数据,可以学习到许多原本隐藏的信息。在本教程中,我们将演示如何使用 Matplotlib 绘制简单的函数或数据,设置绘图风格,并为绘图添加标签。

Matplotlib 是一个非常强大的绘图库,这意味着它在执行简单任务时可能会让人感到有些复杂。对于习惯使用 MATLAB 和其他数学软件的用户,Matplotlib 提供了一个基于状态的接口,称为 pyplotpyplot 接口是创建基本对象的便捷方式。

准备工作

最常见的情况是,您希望绘制的数据将存储在两个独立的 NumPy 数组中,我们为了清晰起见将其标记为 xy(尽管在实际操作中这个命名并不重要)。我们将演示如何绘制一个函数的图形,因此我们将生成一组 x 值,并使用该函数生成相应的 y 值。我们将绘制三个不同的函数,并在相同的坐标系上覆盖它们,范围为

def f(x):
  return x*(x - 2)*np.exp(3 – x)
def g(x):
  return x**2
def h(x):
  return 1 - x

让我们使用 Matplotlib 在 Python 中绘制这三种函数。

如何实现...

在绘制函数之前,我们必须先生成要绘制的 xy 数据。如果你正在绘制现有数据,可以跳过这些命令。我们需要创建一组覆盖所需范围的 x 值,然后使用函数生成相应的 y 值:

  1. NumPy 中的 linspace 函数非常适合用于创建用于绘图的数值数组。默认情况下,它会在指定的参数之间创建 50 个等间距的点。可以通过提供额外的参数来自定义点的数量,但 50 个点对于大多数情况来说已经足够:

    x = np.linspace(-0.5, 3.0)  # 50 values between -0.5 and 3.0
    
  2. 一旦我们创建了 x 值,我们就可以生成 y 值:

    y1 = f(x)  # evaluate f on the x points
    
    y2 = g(x)  # evaluate g on the x points
    
    y3 = h(x)  # evaluate h on the x points
    
  3. 为了绘制数据,我们首先需要创建一个新图形并附加坐标轴对象,这可以通过调用没有任何参数的 plt.subplots 函数来实现:

    fig, ax = plt.subplots()
    

现在,我们在 ax 对象上使用 plot 方法绘制第一个函数。前两个参数是 要绘制的坐标,第三个(可选)参数指定线条颜色为黑色:

ax.plot(x, y1, "k")  # black solid line style

为了帮助区分其他函数的绘图,我们使用虚线和点划线绘制它们:

ax.plot(x, y2, "k--")  # black dashed line style
ax.plot(x, y3, "k.-")  # black dot-dashed line style

每个绘图应该有一个标题和坐标轴标签。在这种情况下,没有什么特别的内容可以用来标注坐标轴,所以我们只是将它们标记为 "x""y"

ax.set_title("Plot of the functions f, g, and h")
ax.set_xlabel("x")
ax.set_ylabel("y")

让我们再加上一个图例,帮助你区分不同函数的图形,而不必到处寻找哪条线代表哪个函数:

ax.legend(["f", "g", "h"])

最后,让我们在图形上加上注释,标出函数 的交点,并添加文字说明:

ax.text(0.4, 2.0, "Intersection")

这将把 y 值与 x 值绘制在一个新的图形中。如果你在 IPython 或 Jupyter notebook 中工作,那么绘图应该会在这一点自动出现;否则,你可能需要调用 plt.show 函数才能让图形显示出来:

plt.show()

如果使用 plt.show,图形应该会出现在一个新窗口中。我们在本章中将不会在进一步的示例中加入此命令,但你应该知道,如果你不在自动渲染图形的环境中工作(如 IPython 控制台或 Jupyter Notebook),你将需要使用此命令。生成的图形应该类似于 图 2.1

图 2.1 – 在同一坐标系中绘制三个不同风格的函数,带有标签、图例和注释

图 2.1 – 在一组坐标轴上绘制三个不同风格的函数,并添加标签、图例和注释

注意

如果你正在使用 Jupyter Notebook 和 subplots 命令,必须将 subplots 的调用放在同一个单元格中,否则图形将无法生成。

它是如何工作的…

在这里,我们使用 OOI(对象导向接口),因为它允许我们准确跟踪当前正在绘制的图形和坐标轴对象。在这里我们只有一个 figure 和一个 axes,这一点并不那么重要,但你可以很容易地设想出在同时有两个或多个图形和坐标轴的情况下,使用 OOI 的好处。遵循这个模式的另一个原因是当你添加多个子图时保持一致性——参见添加子图配方。

你可以通过以下一系列命令,在基于状态的接口中生成与配方中相同的图:

plt.plot(x, y1, "k", x, y2, "k--", x, y3, "k.-")
plt.title("Plot of the functions f, g, and h")
plt.xlabel("x")
plt.ylabel("y")
plt.legend(["f", "g", "h"])
plt.text(0.4, 2.0, "Intersection")

如果当前没有 FigureAxes 对象,plt.plot 例程将创建一个新的 Figure 对象,向该图形添加一个新的 Axes 对象,并用绘制的数据填充这个 Axes 对象。此函数将返回一个包含已绘制线条的句柄列表。这些句柄是 Lines2D 对象。在这种情况下,列表将包含一个 Lines2D 对象。我们可以使用这个 Lines2D 对象来进一步自定义线条的外观。

请注意,在上面的代码中,我们将所有 plot 例程的调用都结合在一起。如果你使用 OOI,这也是可能的;基于状态的接口将参数传递给它检索或创建的坐标轴方法。

Matplotlib 的对象层与一个较低级别的后端交互,后端负责执行绘制图形的繁重任务。plt.show函数向后端发出指令,要求其渲染当前的图形。Matplotlib 可以使用多种后端,可以通过设置 MPLBACKEND 环境变量、修改 matplotlibrc 文件,或者通过在 Python 中调用 matplotlib.use 并指定替代后端的名称来进行自定义。默认情况下,Matplotlib 会根据可用的后端和平台(Windows、macOS、Linux)以及用途(交互式或非交互式)选择一个合适的后端。例如,在作者的系统中,默认后端是 QtAgg,这是一个基于 QtCairo 后端的交互式后端,后者使用 Cairo 库进行渲染。

注意

plt.show 函数不仅仅是调用图形上的 show 方法。它还连接到事件循环,以正确显示图形。应该使用 plt.show 例程来显示图形,而不是直接调用 Figure 对象上的 show 方法。

用于快速指定线条样式的格式字符串有三个可选部分,每个部分由一个或多个字符组成。第一部分控制标记样式,即在每个数据点上打印的符号;第二部分控制连接数据点的线条样式;第三部分控制图形的颜色。在本示例中,我们只指定了线条样式。然而,也可以同时指定线条样式和标记样式,或者只指定标记样式。如果只提供标记样式,则不会在点之间绘制连接线。这对于绘制离散数据非常有用,因为不需要在点之间进行插值。

提供了四种线条样式参数:实线(-)、虚线(--)、点划线(-.)和点线(:)。格式字符串中只能指定有限的颜色;它们分别是红色、绿色、蓝色、青色、黄色、品红色、黑色和白色。格式字符串中使用的字符是每种颜色的首字母(黑色除外),因此相应的字符是rgbcymkw

在这个示例中,我们看到了三个格式字符串的例子:单一的k格式字符串只改变了线条的颜色,其他设置保持默认(小点标记和未断开的蓝线);k--k.-格式字符串都改变了颜色和线条样式。关于更改点样式的示例,请参见更多内容...部分和图 2.2

图 2.2 - 绘制三组数据,每组数据使用不同的标记样式

图 2.2 - 绘制三组数据,每组数据使用不同的标记样式

set_titleset_xlabelset_ylabel方法仅仅是将文本参数添加到Axes对象的相应位置。legend方法,如前述代码所示,将标签按添加顺序添加到数据集——在这个例子中是y1y2,然后是y3

可以向set_titleset_xlabelset_ylabel函数提供多个关键字参数,以控制文本的样式。例如,fontsize关键字可以用来指定标签字体的大小,通常以pt为单位。

annotate方法在Axes对象上将任意文本添加到图形中的指定位置。该函数接受两个参数——要显示的文本字符串和注释应放置的点的坐标。此函数还接受可以自定义注释样式的关键字参数。

更多内容...

plt.plot例程接受可变数量的位置输入。在前面的代码中,我们提供了两个位置参数,分别被解释为x值和y值(顺序如此)。如果我们只提供了一个数组,plot例程会将值绘制在数组中的位置;也就是说,x值被视为012等。

plot方法还接受许多关键字参数,这些参数也可以用于控制绘图的样式。如果同时存在关键字参数和格式字符串参数,则关键字参数优先。这些关键字参数适用于调用时绘制的所有数据集。控制标记样式的关键字是marker,控制线条样式的关键字是linestyle,颜色的关键字是colorcolor关键字参数接受许多不同的格式来指定颜色,包括 RGB 值作为(r, g, b)元组,其中每个字符是介于01之间的浮点数,或者是十六进制字符串。可以使用linewidth关键字来控制绘制的线条宽度,该关键字应提供一个float值。许多其他关键字参数可以传递给plot;在 Matplotlib 文档中列出了一个列表。许多这些关键字参数都有一个更短的版本,例如c代表colorlw代表linewidth

在这个示例中,我们绘制了通过在一些值上评估函数生成的大量坐标。在其他应用中,可能会有来自真实世界的数据(而不是生成的)。在这些情况下,最好不要连接线条,而只是在点上绘制标记。以下是如何实现的示例:

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])
fig, ax = plt.subplots()
ax.plot(y1, 'o', y2, 'x', y3, '*', color="k")

这些命令的结果显示在图 2**.2中。Matplotlib 有一个专门用于生成散点图的方法,称为scatter

可以通过在Axes对象上使用方法来自定义绘图的其他方面。可以使用Axes对象上的set_xticksset_yticks方法修改轴刻度,使用grid方法配置网格外观。在pyplot接口中还有方便的方法,将这些修改应用于当前轴(如果存在)。

例如,我们修改轴限制,在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

注意我们将限制设置得比图的范围稍大。这是为了避免标记放在图窗口的边界上。

除了这里描述的plot函数,Matplotlib 还有许多其他绘图函数。例如,有一些绘图方法使用不同的坐标轴尺度,包括对数坐标轴,分别是semilogxsemilogy,或者将它们一起使用(loglog)。这些内容在 Matplotlib 文档中有详细说明。如果你希望绘制离散数据,而不通过连线连接这些点,scatter绘图函数可能会很有用。它允许你对标记样式进行更多控制,例如,你可以根据某些附加信息来调整标记的大小。

我们可以通过使用fontfamily关键字来使用不同的字体,其值可以是字体名称,或者是serifsans-serifmonospace,这些将选择适当的内置字体。完整的修改器列表可以在 Matplotlib 的matplotlib.text.Text类文档中找到。

通过向函数提供usetex=True,文本参数还可以使用 TeX 进行额外的格式化。我们将在下面的示例中演示如何在图 2.3中使用 TeX 格式化标签。如果标题或坐标轴标签包含数学公式,这尤其有用。不幸的是,如果系统上没有安装 TeX,usetex关键字参数无法使用——这会导致错误。然而,仍然可以使用 TeX 语法在标签中格式化数学文本,但这将由 Matplotlib 进行排版,而不是由 TeX 排版。

添加子图

有时候,将多个相关的图表放置在同一图形中并排显示,但不在同一坐标轴上是很有用的。子图可以让我们在一个图形中生成一个由多个独立图表组成的网格。在这个实例中,我们将看到如何使用子图在单个图形中并排显示两个图表。

准备工作

你需要为每个子图准备要绘制的数据。作为示例,我们将在第一个子图中绘制应用于函数的牛顿法的前五次迭代,初始值为,而在第二个子图中,我们将绘制迭代的误差。我们首先定义一个生成器函数来获取这些迭代:

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)

如何操作...

以下步骤展示了如何创建包含多个子图的图形:

  1. 我们使用subplots函数来创建一个新的图形,并获取每个子图中Axes对象的引用,这些对象按一行两列的网格排列。我们还将tight_layout关键字参数设置为True,以修正结果图表的布局。这在严格意义上并不是必须的,但在这种情况下,它能比默认设置产生更好的效果:

    fig, (ax1, ax2) = plt.subplots(1, 2, 
    
    tight_layout=True) 
    
    #1 row, 2 columns
    
  2. 一旦创建了 FigureAxes 对象,我们就可以通过在每个 Axes 对象上调用相关的绘图方法来填充图形。对于第一个图(显示在左侧),我们在 ax1 对象上使用 plot 方法,其签名与标准的 plt.plot 函数相同。然后,我们可以在 ax1 上调用 set_titleset_xlabelset_ylabel 方法来设置标题以及 xy 标签。我们还通过提供 usetex 关键字参数使用 TeX 格式化轴标签;如果系统没有安装 TeX,可以忽略此部分。

    ax1.plot(iterates, "kx")
    
    ax1.set_title("Iterates")
    
    ax1.set_xlabel("$i$", usetex=True)
    
    ax1.set_ylabel("$x_i$", usetex=True)
    
  3. 现在,我们可以使用 ax2 对象在第二个图(显示在右侧)上绘制误差值。我们使用一种替代的绘图方法,该方法在 轴上使用对数刻度,称为 semilogy。该方法的签名与标准的 plot 方法相同。再次,我们设置轴标签和标题。如果你的系统没有安装 TeX,也可以省略使用 usetex

    ax2.semilogy(errors, "kx") # plot y on logarithmic scale
    
    ax2.set_title("Error")
    
    ax2.set_xlabel("$i$", usetex=True)
    
    ax2.set_ylabel("Error")
    

这组命令的结果如图所示:

图 2.3 - 在同一个 Matplotlib 图形上显示多个子图

图 2.3 - 在同一个 Matplotlib 图形上显示多个子图

左侧绘制了牛顿法的前五次迭代,右侧是以对数刻度绘制的近似误差。

工作原理...

在 Matplotlib 中,Figure 对象只是一个容器,用于存放诸如 Axes 等绘图元素,并具有一定的大小。Figure 对象通常只会包含一个 Axes 对象,该对象占据整个图形区域,但它也可以在同一区域内包含任意数量的 Axes 对象。subplots 函数做了几件事。它首先创建一个新的图形,然后在图形区域内创建一个指定形状的网格。接着,一个新的 Axes 对象会被添加到网格的每个位置。最后,新的 Figure 对象和一个或多个 Axes 对象会返回给用户。如果只请求一个子图(没有参数,即一行一列),则返回一个普通的 Axes 对象。如果请求一个单行或单列(分别有多个列或行),则返回一个 Axes 对象的列表。如果请求多个行和列,则返回一个列表的列表,其中行由内部列表表示,内部列表填充有 Axes 对象。然后,我们可以在每个 Axes 对象上使用绘图方法,以在图形中填充所需的图表。

在这个示例中,我们在左侧图中使用了标准的plot方法,就像我们在之前的示例中看到的那样。然而,在右侧图中,我们使用了一个将轴更改为对数刻度的绘图。这意味着轴上的每个单位代表一个数量级的变化,而不是一个单位的变化,因此0代表1代表 10,2代表 100,依此类推。轴标签会自动更改以反映这种比例变化。当值按数量级变化时,例如在逼近中的误差随着迭代次数的增加而变化时,这种缩放方式非常有用。我们还可以通过使用semilogx方法仅对使用对数刻度绘图,或者通过使用loglog方法使两个轴都使用对数刻度绘图。

还有更多...

在 Matplotlib 中创建子图有几种方法。如果您已经创建了一个Figure对象,则可以使用Figure对象的add_subplot方法添加子图。或者,您可以使用matplotlib.pyplot中的subplot例程将子图添加到当前图中。如果尚不存在,则在调用此例程时将创建一个。subplot例程是Figure对象上add_subplot方法的便利包装器。

在前面的示例中,我们创建了两个具有不同比例的轴的图。这展示了子图的许多可能用途之一。另一个常见用途是在矩阵中绘制数据,其中列具有共同的x标签,行具有共同的y标签,这在多元统计中特别常见,用于研究各组数据之间的相关性。用于创建子图的plt.subplots例程接受sharexsharey关键参数,允许轴在所有子图之间或在行或列之间共享。此设置会影响轴的比例和刻度。

另请参阅

Matplotlib 通过为subplots例程提供gridspec_kw关键字参数来支持更高级的布局。有关更多信息,请参阅matplotlib.gridspec的文档。

使用误差条绘图

从现实世界中收集的数据通常会带有一些不确定性;没有任何现实世界的量度是完全准确的。例如,如果我们使用卷尺测量距离,那么我们可以假设我们的结果在一定范围内是准确的,但超过这个范围后,我们就无法确保测量结果的有效性。在这种情况下,我们可能对精度有信心的范围大约是 1 毫米或稍小于 1/16 英寸。(当然,这是假设我们在完美的条件下进行测量。)这些值通常是典型卷尺上最小的分度线。假设我们收集了这样一组 10 个测量值(单位为厘米),并且我们希望将这些值与我们有信心的精度一起绘制出来。(误差范围是指实际测量值上下偏离的范围,通常称为 误差。)我们将在本节中处理这一问题。

准备工作

像往常一样,我们导入了 Matplotlib 的 pyplot 接口,别名为 plt。首先,我们需要在 NumPy 数组中生成我们的假设数据和假定的精度:

measurement_id = np.arange(1, 11)
measurements = np.array([2.3, 1.9, 4.4, 1.5, 3.0, 3.3, 2.9,    2.6, 4.1, 3.6]) # cm
err = np.array([0.1]*10)  # 1mm

让我们看看如何使用 Matplotlib 中的绘图函数,将这些带有误差条的测量值绘制出来,以表示每个测量值的不确定性。

如何操作…

以下步骤展示了如何在图形上绘制带有精度信息的测量值。

首先,我们需要像往常一样生成一个新的 figureaxis 对象:

fig, ax = plt.subplots()

接下来,我们使用 errorbar 方法在轴对象上绘制数据,并附上误差条。精度信息(即误差)通过 yerr 参数传递:

ax.errorbar(measurement_id,
    measurements, yerr=err, fmt="kx", 
     capsize=2.0)

像往常一样,我们应该始终为坐标轴添加有意义的标签,并为图形添加标题:

ax.set_title("Plot of measurements and their estimated error")
ax.set_xlabel("Measurement ID")
ax.set_ylabel("Measurement(cm)")

由于 Matplotlib 默认不会在每个值处绘制 xlabel 刻度,因此我们将 x 刻度值设置为测量 ID,以确保它们都能显示在图形上:

ax.set_xticks(measurement_id)

结果图形显示在 图 2.4 中。记录值显示在 x 标记处,误差条则在该值的上下延伸,精度为 0.1 厘米(即 1 毫米):

图 2.4 - 显示 10 个样本测量值(单位为厘米)及其测量误差的图形

图 2.4 - 显示 10 个样本测量值(单位为厘米)及其测量误差的图形

我们可以看到,每个标记都有一个垂直条,表示我们期望真实测量值(-值)所在的范围。

它是如何工作的…

errorbar方法的工作方式与其他绘图方法相似。前两个参数是要绘制的点的坐标。(请注意,两个坐标都必须提供,而其他绘图方法则不要求这样。)yerr参数表示要添加到图中的误差条的大小,并且所有的值应该是正值。传递给此参数的值的形式决定了误差条的性质。在该示例中,我们提供了一个包含 10 个条目的平坦 NumPy 数组——每个条目对应一个测量值——这导致每个点上方和下方都有大小相同的误差条(即来自该参数的相应值)。另外,我们还可以指定一个 2×10 的数组,其中第一行包含下误差,第二行包含上误差。(由于我们的误差值相同,我们也可以提供一个包含所有测量共同误差的浮动值。)

除了数据参数,还有通常的格式化参数,包括fmt格式化字符串。(我们在这里使用了它作为关键字参数,因为我们命名了之前的yerr参数。)除了其他绘图方法中的线条和点的格式化外,还有一些特殊参数可以用来定制误差条的外观。在该示例中,我们使用了capsize参数为误差条的两端添加“帽子”,以便我们可以轻松地识别这些条的端点;默认的样式是简单的线条。

还有更多...

在该示例中,我们只在轴上绘制了误差,因为值仅仅是 ID 值。如果两个值集都有不确定性,你也可以使用xerr参数来指定误差值。该参数的功能与之前使用的yerr参数相同。

如果你正在绘制大量符合某种趋势的数据点,可能希望更有选择性地绘制误差条。为此,你可以使用errorevery关键字参数,指示 Matplotlib 每隔第n个数据点添加误差条,而不是对所有数据点都添加误差条。这可以是一个正整数——表示选择有误差的数据点的“步长”——或者一个包含从第一个值起的偏移量和步长的元组。例如,errorevery=(2, 5)会在每五个数据点处添加误差条,且从第二个数据点开始。

你也可以用相同的方式在条形图中添加误差条(除了这里,xerryerr参数只是关键字)。我们可以用以下命令将示例中的数据绘制成条形图:

ax.bar(measurement_id, measurements, 
yerr=err, capsize=2.0, alpha=0.4)

如果在示例中使用这行代码代替调用errorbar,则我们将得到一个条形图,如图 2**.5所示:

图 2.5 - 带有误差条的测量值条形图

图 2.5 - 带有误差条的测量值条形图

如之前所述,测量条的顶部有一个指示器,表示我们期望真实测量值所在的范围

保存 Matplotlib 图形

当你在交互式环境中工作时,例如 IPython 控制台或 Jupyter 笔记本,运行时显示图形是完全正常的。然而,有很多情况下直接将图形保存到文件中比在屏幕上渲染它更为合适。在这个教程中,我们将学习如何直接将图形保存到文件,而不是在屏幕上显示它。

准备工作

你需要有待绘制的数据和希望存储输出的路径或文件对象。我们将结果保存在当前目录下的 savingfigs.png 文件中。在这个示例中,我们将绘制以下数据:

x = np.arange(1, 5, 0.1)
y = x*x

让我们看看如何使用 Matplotlib 绘制这条曲线,并将生成的图形保存到文件中(无需与图形界面交互)。

如何操作...

以下步骤展示了如何将 Matplotlib 图形直接保存到文件:

  1. 第一步是像往常一样创建一个图形,并添加任何必要的标签、标题和注释。图形将以当前状态写入文件,因此任何更改应在保存之前进行:

    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)
    
  2. 然后,我们使用 savefig 方法将 fig 保存到文件中。唯一需要的参数是输出路径或一个文件类对象,图形可以写入该对象。我们可以通过提供适当的关键字参数来调整输出格式的各种设置,例如分辨率。我们将设置 300,这是大多数应用程序的合理分辨率:

    fig.savefig("savingfigs.png", dpi=300)
    

Matplotlib 将推断我们希望以 format 关键字指定的格式保存图像,或者如果未指定,它将回退到配置文件中的默认格式。

它是如何工作的...

savefig 方法选择适当的后端输出格式,然后以该格式渲染当前图形。生成的图像数据会写入指定的路径或类文件对象。如果你手动创建了一个 Figure 实例,也可以通过在该实例上调用 savefig 方法来实现相同的效果。

还有更多...

savefig 例程接受许多额外的可选关键字参数,以自定义输出图像。例如,可以使用 dpi 关键字指定图像的分辨率。本章中的图形是通过将 Matplotlib 图形保存到文件中生成的。

可用的输出格式包括 PNG、qualityoptimize。可以通过 metadata 关键字传递一个图像元数据字典,该字典将在保存时作为图像元数据写入。

另见

Matplotlib 网站上的示例画廊包括了如何使用几种常见的 Python GUI 框架将 Matplotlib 图形嵌入到 GUI 应用程序中的示例。

表面图和轮廓图

Matplotlib 还可以以多种方式绘制三维数据。显示这类数据的两种常见选择是使用 表面图等高线图(可以类比为地图上的等高线)。在本食谱中,我们将看到一种从三维数据绘制表面图的方法,以及如何绘制三维数据的等高线。

准备工作

为了绘制三维数据,它需要被整理成二维数组,分别对应 组件,其中 组件必须与 组件形状相同。为了演示,我们将绘制以下函数对应的表面:

对于三维数据,我们不能仅仅使用 pyplot 接口中的例程。我们需要从 Matplotlib 的其他部分导入一些额外的功能。接下来我们将展示如何操作。

如何操作...

我们希望在 范围内绘制函数 。第一步是创建一个适合的 对网格,用于评估该函数:

  1. 我们首先使用 np.linspace 在这些范围内生成一个合理数量的点:

    X = np.linspace(-5, 5)
    
    Y = np.linspace(-5, 5)
    
  2. 现在,我们需要创建一个网格,用于生成我们的 值。为此,我们使用 np.meshgrid 例程:

    grid_x, grid_y = np.meshgrid(X, Y)
    
  3. 现在,我们可以创建 值来绘制,这些值表示函数在每个网格点的值:

    z = np.exp(-((grid_x-2.)**2 + (
    
        grid_y-3.)**2)/4) -  np.exp(-(
    
        (grid_x+3.)**2 + (grid_y+2.)**2)/3)
    
  4. 要绘制三维表面图,我们需要加载一个 Matplotlib 工具箱,mplot3d,它随 Matplotlib 包一起提供。虽然在代码中不会显式使用它,但在幕后,它使得三维绘图工具能够在 Matplotlib 中使用:

    from mpl_toolkits import mplot3d
    
  5. 接下来,我们为图形创建一个新的图形和一组三维坐标轴:

    fig = plt.figure()
    
    # declare 3d plot
    
    ax = fig.add_subplot(projection="3d")
    
  6. 现在,我们可以在这些坐标轴上调用 plot_surface 方法来绘制数据(我们将颜色图设置为灰色,以便在打印时更清晰;有关更详细的讨论,请参见下一个食谱):

    ax.plot_surface(grid_x, grid_y, z, cmap="gray")
    
  7. 为三维图添加坐标轴标签非常重要,因为在显示的图表中可能不清楚每个坐标轴代表的是哪个。此时我们还设置了标题:

    ax.set_xlabel("x")
    
    ax.set_ylabel("y")
    
    ax.set_zlabel("z")
    
    ax.set_title("Graph of the function f(x, y)")
    

你可以使用 plt.show 例程在新窗口中显示图形(如果你在 Python 中交互式使用,而不是在 Jupyter notebook 或 IPython 控制台中),或者使用 plt.savefig 将图形保存到文件。前面的代码序列结果如下所示:

图 2.6 - 使用 Matplotlib 绘制的三维表面图

图 2.6 - 使用 Matplotlib 绘制的三维表面图

  1. 等高线图不需要 mplot3d 工具包,pyplot 接口中有一个 contour 函数可以生成等高线图。然而,与通常的(二维)绘图函数不同,contour 函数需要与 plot_surface 方法相同的参数。我们使用以下步骤来生成图表:

    fig = plt.figure()  # Force a new figure
    
    plt.contour(grid_x, grid_y, z, cmap="gray")
    
    plt.title("Contours of f(x, y)")
    
    plt.xlabel("x")
    
    plt.ylabel("y")
    

结果显示在下面的图表中:

图 2.7 - 使用 Matplotlib 和默认设置生成的等高线图

图 2.7 - 使用 Matplotlib 和默认设置生成的等高线图

函数的峰值和盆地通过同心圆的环形清晰展示。在右上方,阴影较浅,表示函数在增加,而在左下方,阴影较深,表示函数在减少。区分函数增加和减少区域的曲线显示在它们之间。

它是如何工作的...

mplot3d 工具包提供了一个 Axes3D 对象,它是核心 Matplotlib 包中 Axes 对象的三维版本。当给定 projection="3d" 关键字参数时,可以通过 Figure 对象的 axes 方法访问它。通过在三维投影中绘制相邻点之间的四边形,可以获得表面图,就像通过连接相邻点的直线来近似二维曲线一样。

plot_surface 方法需要提供 数值作为二维数组,这些数值在 网格的 对应点上编码。我们创建了感兴趣的 数值范围,但如果我们仅在这些数组的对应点上计算我们的函数,我们将得到沿着一条线的 数值,而不是在网格上。相反,我们使用 meshgrid 函数,它接受两个 XY 数组,并从中创建一个包含 XY 中所有可能组合的网格。输出是两个二维数组,我们可以在上面计算我们的函数。然后,我们将这三个二维数组提供给 plot_surface 方法。

还有更多...

前面章节中描述的例程contourplot_surface仅适用于高度结构化的数据,其中!、!和!组件被排列成网格。不幸的是,现实生活中的数据很少是这样结构化的。在这种情况下,你需要在已知点之间进行某种插值,以近似在均匀网格上的值,然后绘制出来。执行这种插值的常见方法是通过三角剖分!对,然后利用每个三角形顶点上的函数值来估计网格点上的值。幸运的是,Matplotlib 提供了一种方法,能够完成这些步骤并绘制结果,这就是plot_trisurf例程。我们在这里简要解释如何使用它:

  1. 为了说明如何使用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])
    
  2. 这一次,我们将在同一图形上将曲面和轮廓(近似)作为两个单独的子图绘制。为此,我们向将包含曲面的子图提供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")
    
  3. 我们现在可以使用以下命令绘制三角化曲面的轮廓:

    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 - 使用三角剖分从非结构化数据生成的近似曲面和轮廓图

图 2.8 - 使用三角剖分从非结构化数据生成的近似曲面和轮廓图

除了曲面绘制例程,Axes3D对象还有一个plot(或plot3D)例程用于简单的三维绘图,工作原理与通常的plot例程相同,但是在三维坐标轴上。此方法也可用于在一个坐标轴上绘制二维数据。

另见

Matplotlib 是 Python 中首选的绘图库,但也存在其他选项。我们将在第六章中看到 Bokeh 库。还有其他库,如 Plotly(https://plotly.com/python/),它简化了创建某些类型的图形并添加更多功能(如交互式图表)的过程。

自定义三维图形

等高线图可能会隐藏它所表示的表面的一些细节,因为它们仅显示“高度”相似的地方,而不显示具体的值,即使是相对于周围的值也是如此。在地图上,通常会通过将高度打印到特定的等高线来解决这个问题。表面图则更加直观,但将三维物体投影到二维以便在屏幕上显示的过程中,某些细节可能会被掩盖。为了解决这些问题,我们可以自定义三维图(或等高线图)的外观,以增强图表并确保我们希望突出的细节清晰可见。最简单的方法是通过更改图表的颜色映射,如前一个方法中所示。(默认情况下,Matplotlib 会生成单色的表面图,这使得细节在印刷媒体中难以观察。)在这个方法中,我们将探讨一些可以自定义 3D 表面图的其他方法,包括更改显示的初始角度和更改应用于颜色映射的归一化处理。

准备工作

在这个方法中,我们将进一步自定义我们在前一个方法中绘制的函数:

我们生成应绘制的点,就像前一个方法中一样:

t = np.linspace(-5, 5)
x, y = np.meshgrid(t, t)
z = np.exp(-((x-2.)**2 + (y-3.)**2)/4) - np.exp(
    -((x+3.)**2 + (y+2.)**2)/3)

让我们看看如何自定义这些值的三维图表。

如何做...

以下步骤展示了如何自定义 3D 图表的外观:

和往常一样,我们的第一步是创建一个新的图形和坐标轴,在上面进行绘制。由于我们要自定义Axes3D对象的属性,因此我们将首先创建一个新的图形:

fig = plt.figure()

现在,我们需要向这个图形中添加一个新的Axes3D对象,并通过设置azimelev关键字参数,以及之前看到的projection="3d"关键字参数来改变初始视角:

ax = fig.add_subplot(projection="3d", azim=-80, elev=22)

完成这些后,我们现在可以绘制表面图了。我们将通过改变归一化的范围,使最大值和最小值不再位于颜色映射的极端位置。我们通过更改vminvmax参数来实现:

ax.plot_surface(x, y, z, cmap="gray", vmin=-1.2, vmax=1.2)

最后,我们可以像往常一样设置坐标轴标签和标题:

ax.set_title("Customized 3D surface plot")
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_zlabel("z")

结果图如图 2.9所示:

图 2.9 - 自定义的 3D 表面图,修改了归一化处理和初始视角

图 2.9 - 自定义的 3D 表面图,修改了归一化处理和初始视角

比较图 2.6图 2.9,我们可以看到后者相比前者通常包含了更深的阴影,并且视角提供了一个更好的视图,能够更好地看到该函数最小化的盆地。较暗的阴影是由于我们使用vminvmax关键字参数修改了颜色映射的归一化处理所导致的。

它是如何工作的...

色彩映射通过根据一个尺度分配 RGB 值来实现——01,这通常是通过线性变换完成的,最低值映射到0,最高值映射到1。然后将适当的颜色应用到表面图的每个面(或者在另一种类型的图中应用于线条)。

在本示例中,我们使用了vminvmax关键字参数,人工改变了分别映射到01的值,以便拟合色彩图。实际上,我们改变了应用到图表上的颜色范围的端点。

Matplotlib 附带了多个内置的色彩图,可以通过简单地将名称传递给cmap关键字参数来应用。色彩图的列表可以在文档中找到(matplotlib.org/tutorials/colors/colormaps.html),并且每个色彩图都有一个反向变体,可以通过在所选色彩图名称后添加_r后缀来获取。

3D 图的视角由两个角度描述:Axes3D的方位角是-60,仰角是 30。在本示例中,我们使用了plot_surfaceazim关键字参数将初始方位角改为-80 度(几乎是从负方向的轴来看),并使用elev参数将初始仰角改为 22 度。

还有更多...

在应用色彩图时,归一化步骤是由一个从Normalize类派生的对象执行的。Matplotlib 提供了多个标准的归一化方法,包括LogNormPowerNorm。当然,你也可以创建自己的Normalize子类来执行归一化。通过plot_surface或其他绘图函数的norm关键字参数,可以添加一个替代的Normalize子类。

对于更高级的使用,Matplotlib 提供了一个接口,通过光源创建自定义的阴影效果。这是通过从matplotlib.colors包中导入LightSource类来实现的,然后使用该类的实例根据值对表面元素进行阴影处理。这是通过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 画廊中有完整的示例。

除了视角,我们还可以改变用于表示 3D 数据为 2D 图像的投影类型。默认情况下是透视投影,但我们也可以通过将proj_type关键字参数设置为"ortho"来使用正交投影。

使用箭头图绘制矢量场

向量场是一个函数,它为区域中的每个点分配一个向量——它是一个在空间上定义的向量值函数。这在研究(系统)微分方程时特别常见,其中向量场通常作为方程的右侧出现。(有关更多详细信息,请参见第三章中的解微分方程组食谱。)因此,通常需要可视化一个向量场,并理解该函数在空间中的演变方式。目前,我们只打算通过箭头图来绘制向量场,箭头图接受一组坐标以及一组向量,并生成一个图形,其中每个点都有一个指向方向的箭头,箭头的长度等于该向量的长度。(希望当我们实际创建该图形时,这将变得更加清晰。)

准备工作

像往常一样,我们导入 Matplotlib 的pyplot接口,别名为plt。在开始之前,我们需要定义一个函数,该函数接受一个点并生成一个向量;我们稍后将使用这个函数来生成数据,这些数据将传递给绘图函数。

对于这个例子,我们将绘制以下向量场:

对于这个例子,我们将在区域内绘制向量场,其中包含

如何操作…

以下步骤展示了如何在指定区域内可视化上述向量场。

首先,我们需要定义一个 Python 函数,该函数在各点评估我们的向量场:

def f(x, y):
  v = x**2 +y**2
    return np.exp(-2*v)*(x+y), np.exp(
        -2*v)*(x-y)

接下来,我们需要创建一个覆盖区域的点网格。为此,我们首先创建一个临时的linspace函数,其值介于-11之间。然后,使用meshgrid生成一个点网格:

t = np.linspace(-1., 1.)
x, y = np.meshgrid(t, t)

接下来,我们使用我们的函数生成dxdy值,描述每个网格点的向量:

dx, dy = f(x, y)

现在,我们可以创建一个新的图形和坐标轴,并使用quiver方法生成图形:

fig, ax = plt.subplots()
ax.quiver(x, y, dx, dy)

结果图如图 2.10所示:

图 2.10 - 使用箭头图可视化向量场

图 2.10 - 使用箭头图可视化向量场

图 2.10中,我们可以看到值以箭头的形式表示,基于每个坐标。箭头的大小由向量场的大小决定。在原点,向量场的值为,因此附近的箭头非常小。

工作原理…

我们从食谱中的例子是一个数学构造,而不是来自真实数据的内容。在这个特定的例子中,箭头描述了某个量如何在按照我们指定的向量场流动时可能演变。

网格中的每个点都是一个箭头的起点。箭头的方向由对应的 值确定,箭头的长度根据长度进行归一化(因此,具有较小分量的向量 会产生较短的箭头)。可以通过更改 scale 关键字参数来进行自定义。图表的许多其他方面也可以自定义。

还有更多…

如果你想绘制一组遵循向量场的轨迹,可以使用 streamplot 方法。这个方法会绘制从不同点出发的轨迹,以指示在领域的不同部分的总体流动情况。每条流线都有一个箭头来表示流动的方向。例如,图 2.11 显示了使用食谱中的向量场和 streamplot 方法得到的结果:

图 2.11 – 由食谱中的向量场描述的轨迹图

图 2.11 – 由食谱中的向量场描述的轨迹图

在不同的场景中,你可能拥有一些关于风速(或类似量)在多个坐标上的数据——比如在地图上——你希望以标准的气象图表风格来绘制这些数据。然后,我们可以使用 barbs 绘图方法。参数与 quiver 方法类似。

进一步阅读

Matplotlib 包非常庞大,我们几乎无法在这么短的篇幅中充分讲解它。文档包含了比这里提供的更多的细节。此外,还有一个包含大量示例的图库 (matplotlib.org/gallery/index.html#),展示了该包比本书中更多的功能。

其他包是在 Matplotlib 的基础上构建的,提供了特定应用的高级绘图方法。例如,Seaborn 库提供了用于可视化数据的例程 (seaborn.pydata.org/)。

第三章:微积分与微分方程

本章将讨论与微积分相关的各种主题。微积分是数学的一个分支,涉及到微分和积分的过程。在几何上,函数的导数表示函数曲线的梯度,而函数的积分表示曲线下方的面积。当然,这些描述仅在某些情况下成立,但它们为本章提供了合理的基础。

我们将从研究一种简单类型的函数——多项式的微积分开始。在第一个实例中,我们将创建一个表示多项式的类,并定义求导和积分的方法。多项式之所以方便,是因为多项式的导数或积分仍然是一个多项式。接下来,我们将使用 SymPy 包对更一般的函数进行符号微分和积分。然后,我们将使用 SciPy 包中的方法求解方程。接着,我们将把注意力转向数值积分(求积)和求解微分方程。我们将使用 SciPy 包求解常微分方程ODEs)及常微分方程系统,并利用有限差分法求解简单的偏微分方程。最后,我们将使用快速傅里叶变换FFT)处理噪声信号并滤除噪声。

本章内容将帮助你解决涉及微积分的问题,例如求解微分方程的解,而微分方程在描述物理世界时经常出现。稍后在 第九章中,我们将进一步讨论优化问题。多个优化算法需要一定的导数知识,包括广泛应用于机器学习ML)中的反向传播。

本章将介绍以下内容:

  • 多项式与微积分运算

  • 使用 SymPy 进行符号微分和积分

  • 求解方程

  • 使用 SciPy 数值积分

  • 数值求解简单微分方程

  • 求解微分方程系统

  • 数值求解偏微分方程

  • 使用离散傅里叶变换进行信号处理

  • 使用 JAX 进行自动微分和微积分

  • 使用 JAX 求解微分方程

技术要求

除了科学计算 Python 包 NumPy 和 SciPy,我们还需要 SymPy、JAX 和 diffrax 包。你可以通过你喜欢的包管理器(如 pip)进行安装:

python3.10 -m pip install sympy jaxlib jax sympy diffrax

安装 JAX 有多种方式,请参阅官方文档了解更多详细信息:github.com/google/jax#installation

本章的代码可以在 GitHub 仓库的Chapter 03文件夹中找到,链接为github.com/PacktPublishing/Applying-Math-with-Python-2nd-Edition/tree/main/Chapter%2003

微积分入门

微积分是研究函数及其变化方式的学科。微积分中有两个主要过程:求导积分。求导将一个函数转化为一个新函数,称为导数,它是每个点处的最佳线性逼近。(你可能会看到这被描述为函数的梯度。积分通常被描述为反求导,确实,对函数的积分求导会得到原始函数,但也是对函数图形与轴之间区域的抽象描述,考虑曲线在轴上方或下方的位置。

抽象地说,函数在点处的导数被定义为一个极限(我们这里不描述),其数量为:

这是因为这个小数变得越来越小。这是差异除以的*差异,这就是为什么导数有时被写成如下形式:

有许多常见函数形式的求导规则:例如,在第一个公式中,我们将看到的导数是。指数函数的导数是,再次,的导数是的导数是。这些基本构件可以使用乘积法则链式法则以及导数的和是导数的和的事实来组合,以求导更复杂的函数。

在其无限形式中,积分是求导的相反过程。在其确定形式中,函数的积分是曲线轴之间的(带符号的)面积—注意这是一个简单的数值,不是一个函数。的不定积分通常写成这样:

在这里,这个函数的导数是之间的的定积分由以下方程给出:

这里, 的不定积分。我们当然可以通过使用近似曲线下方区域的和的极限来抽象地定义不定积分,然后用这个抽象量来定义不定积分。(我们在这里不详细探讨。)需要记住的最重要的事情是积分常数

有几个容易推导的定积分(反导数),我们可以快速推导出: 的积分是 (这是我们求导得到 的结果); 的积分是 的积分是 的积分是 。在所有这些例子中, 是积分常数。我们可以结合这些简单的规则,通过分部积分法或代换积分法(以及一些更复杂的技术,我们这里不作详细讨论)来对更有趣的函数进行积分。

操作多项式和微积分

多项式是数学中最简单的函数之一,它们被定义为一个和式:

这里, 代表一个要被替换的占位符(不确定量),而 是一个数字。由于多项式非常简单,它们为微积分的简要介绍提供了极好的手段。

在这个实例中,我们将定义一个表示多项式的简单类,并为该类编写微分和积分的方法。

准备开始

这个实例不需要额外的包。

如何实现...

以下步骤描述了如何创建一个表示多项式的类,并为该类实现微分和积分方法:

  1. 让我们从定义一个简单的类来表示多项式开始:

    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))
    
  2. 现在我们已经定义了一个表示多项式的基本类,我们可以继续实现该 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)
    
  3. 要实现积分方法,我们需要创建一个新的系数列表,其中包含由参数给出的新常数(为了保持一致性,转换为浮点数)。然后我们将旧系数除以它们在列表中的新位置,添加到这个系数列表中:

        def integrate(self, constant=0):
    
          """Integrate the polynomial and return the integral"""
    
            coeffs = [float(constant)]
    
            coeffs += [c/i for i, c in enumerate(
    
                self.coeffs, start=1)]
    
            return Polynomial(coeffs)
    
  4. 最后,为了确保这些方法按预期工作,我们应该通过一个简单的例子来测试这两种方法。我们可以使用一个非常简单的多项式来进行检查,例如

    p = Polynomial([1, -2, 1])
    
    p.differentiate()
    
    # Polynomial([-2, 2])
    
    p.integrate(constant=1)
    
    # Polynomial([1.0, 1.0, -1.0, 0.3333333333])
    

这里的导数给出了系数 ,它对应的多项式是 ,这实际上是 的导数。类似地,积分的系数对应的多项式是 ,这也是正确的(包含积分常数 )。

它是如何工作的...

多项式为微积分的基本运算提供了一个简单的介绍,但要为其他一般函数类构建 Python 类并不是那么容易。也就是说,多项式非常有用,因为它们已经被很好地理解,并且更重要的是,多项式的微积分运算非常简单。对于一个变量的幂 ,微分规则是将幂与该幂相乘并将幂减 1,结果是 变为 ,所以我们对多项式的微分规则是将每个系数乘以它的位置,并去掉第一个系数。

积分更加复杂,因为一个函数的积分不是唯一的。我们可以在积分中加上任何常数,从而得到第二个积分。对于一个变量的幂 ,积分规则是将幂加 1 并除以新的幂,结果是 变为 。因此,要对多项式进行积分,我们将每个幂的 加 1,并将相应的系数除以新的幂。因此,我们的规则是先插入新的积分常数作为第一个元素,然后将每个现有系数除以它在列表中的新位置。

我们在示例中定义的 Polynomial 类相当简化,但它代表了核心思想。一个多项式是由它的系数唯一决定的,我们可以将系数存储为数值列表。微分和积分是我们可以对这个系数列表执行的操作。我们包括了一个简单的 __repr__ 方法来帮助显示 Polynomial 对象,并且提供了一个 __call__ 方法来便于在特定数值下进行计算。这个方法主要是为了展示多项式如何被计算。

多项式对于解决某些涉及评估计算开销大的函数的问题非常有用。对于这类问题,我们有时可以使用某种多项式插值,将一个多项式拟合到另一个函数上,然后利用多项式的性质来帮助解决原始问题。评估多项式比原始函数要便宜得多,因此这可以显著提高速度。这通常会以牺牲一些精度为代价。例如,辛普森法则通过在由三个连续网格点定义的区间上,用二次多项式来逼近曲线下的面积。每个二次多项式下的面积可以通过积分轻松计算。

还有更多...

多项式在计算编程中比单纯展示微分和积分的效果要扮演更多重要角色。因此,numpy.polynomial NumPy 包中提供了一个更为丰富的Polynomial类。NumPy 的Polynomial类及其衍生的各种子类在各种数值问题中都非常有用,支持算术运算以及其他方法。特别地,这些方法可以用于将多项式拟合到数据集合上。

NumPy 还提供了从Polynomial类派生出的各种类,用于表示不同类型的特殊多项式。例如,Legendre类表示一种叫做勒让德多项式的特定多项式系统。勒让德多项式的定义为满足,并形成一个正交系统,这在数值积分和用于求解偏微分方程的有限元法等应用中非常重要。勒让德多项式通过递归关系来定义。我们将其定义如下:

此外,对于每个,我们定义第个勒让德多项式,使其满足递推关系:

还有几种所谓的正交(多项式)系统,包括拉盖尔多项式切比雪夫多项式厄尔米特多项式

另见

微积分在数学文献中有详细的记录,许多教科书覆盖了从基本方法到深层理论的内容。正交多项式系统也在数值分析的文献中得到了详细的记录。

使用 SymPy 进行符号微分和积分

到某个时候,你可能需要对一个非简单多项式的函数进行微分,并且你可能需要以某种自动化的方式来完成这项工作——例如,如果你正在编写教育类软件。Python 的科学计算堆栈包含一个名为 SymPy 的包,它允许我们在 Python 中创建和操作符号数学表达式。特别是,SymPy 能够像数学家一样进行符号函数的微分和积分。

在这个食谱中,我们将创建一个符号函数,然后使用 SymPy 库对该函数进行微分和积分。

准备工作

与一些其他科学 Python 包不同,文献中似乎没有标准别名用于导入 SymPy。相反,文档在多个地方使用了星号导入,这与PEP8 风格指南不符。这可能是为了让数学表达式看起来更自然。我们将简单地使用其名称 sympy 导入该模块,以避免与 scipy 包的标准缩写 sp(这也是 sympy 的自然选择)混淆:

import sympy

在这个食谱中,我们将定义一个符号表达式,表示以下函数:

然后,我们将看到如何对这个函数进行符号微分和积分。

如何做…

使用 SymPy 包进行符号微分和积分(就像你手动操作一样)非常简单。按照以下步骤操作,看看如何实现:

  1. 一旦导入 SymPy,我们就定义将在表达式中出现的符号。这是一个 Python 对象,没有特定值,就像数学变量一样,但可以在公式和表达式中同时表示多个不同的值。在这个食谱中,我们只需要定义一个符号用于 ,因为除了这个符号外,我们只需要常量(字面量)符号和函数。我们使用 sympy 中的 symbols 函数来定义一个新符号。为了简化符号表示,我们将这个新符号命名为 x

    x = sympy.symbols('x')
    
  2. 使用 symbols 函数定义的符号支持所有算术运算,因此我们可以直接使用刚刚定义的符号 x 构建表达式:

    f = (x**2 - 2*x)*sympy.exp(3 - x)
    
  3. 现在,我们可以利用 SymPy 的符号计算能力来计算 f 的导数——也就是对 f 进行微分。我们使用 sympy 中的 diff 函数来对符号表达式进行微分,并返回导数的表达式。由于结果通常不是最简形式,我们使用 sympy.simplify 函数来简化结果:

    fp = sympy.simplify(sympy.diff(f))
    
    print(fp)  # (-x**2 + 4*x - 2)*exp(3 - x)
    
  4. 我们可以检查使用 SymPy 进行符号微分的结果是否正确,并与手工使用乘积法则计算的导数进行比较,该导数作为 SymPy 表达式定义,如下所示:

    fp2 = (2*x - 2)*sympy.exp(3 - x) - (
    
        x**2 - 2*x)*sympy.exp(3 - x)
    
  5. SymPy 的等式测试检查两个表达式是否相等,但不检查它们是否在符号上等价。因此,我们必须首先简化我们希望测试的两个语句的差异,并测试是否等于 0

    print(sympy.simplify(fp2 - fp) == 0)  # True
    
  6. 我们可以使用 SymPy 的 integrate 函数对导数 fp 进行积分,并检查结果是否仍然等于 f。最好还通过提供第二个可选参数来指定进行积分的符号:

    F = sympy.integrate(fp, x)
    
    print(F)  # (x**2 - 2*x)*exp(3 - x)
    

如我们所见,积分导数 fp 的结果将返回原始函数 f(尽管我们在技术上缺少了积分常数 )。

它是如何工作的...

SymPy 定义了各种类来表示某些类型的表达式。例如,由 Symbol 类表示的符号是 原子表达式 的例子。表达式的构建方式与 Python 从源代码构建抽象语法树类似。这些表达式对象可以使用方法和标准算术操作进行操作。

SymPy 还定义了标准数学函数,这些函数可以作用于 Symbol 对象,以创建符号表达式。最重要的功能是能够执行符号微积分——与我们在本章剩余部分探索的数值微积分不同——并给出微积分问题的精确(有时称为 解析)解。

SymPy 包中的 diff 例程对这些符号表达式进行微分。该例程的结果通常不是其最简形式,这就是为什么我们在食谱中使用 simplify 例程来简化导数的原因。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,第二个是当调用此函数时要评估的表达式。例如,我们可以像普通 Python 函数一样评估之前定义的 lambdified SymPy 表达式:

lam_f(4)  # 2.9430355293715387
lam_fp(7)  # -0.4212596944408861

我们甚至可以在 NumPy 数组上评估这些 lambdified 表达式(和往常一样,先导入 NumPy,通常命名为 np):

lam_f(np.array([0, 1, 2]))  # array([ 0\. , -7.3890561, 0\. ])

注意

lambdify 例程使用 Python 的 exec 例程来执行代码,因此不应与未经清理的输入一起使用。

解方程

许多数学问题最终归结为求解形式为 的方程,其中 是单变量的函数。在这里,我们试图找到一个 的值,使得方程成立。使方程成立的 的值有时称为方程的 。对于这种形式的方程,有许多算法可以找到其解。在这个食谱中,我们将使用牛顿-拉弗森方法和割线法来求解形式为 的方程。

牛顿-拉弗森方法(牛顿法)和割线法是非常好的标准求根算法,可以在几乎任何情况下应用。这些是 迭代方法,从根的近似值开始,逐步改进这个近似值,直到它位于给定的容差范围内。

为了演示这些技术,我们将使用来自 使用 SymPy 进行符号微分和积分 这一食谱中的函数,定义如下公式:

该函数在所有实数值的 上都有定义,并且恰好有两个根,一个在 处,另一个在 处。

准备工作

SciPy 包含用于求解方程的例程(以及许多其他功能)。求根的例程可以在 scipy 包中的 optimize 模块找到。像往常一样,我们将 NumPy 导入为 np

如何做……

optimize 包提供了用于数值求解根的例程。以下说明描述了如何使用该模块中的 newton 例程:

  1. optimize 模块没有列在 scipy 命名空间中,因此必须单独导入:

    from scipy import optimize
    
  2. 然后,我们必须在 Python 中定义这个函数及其导数:

    from math import exp
    
    def f(x):
    
      return x*(x - 2)*exp(3 - x)
    
  3. 该函数的导数已在之前的食谱中计算过:

    def fp(x):
    
      return -(x**2 - 4*x + 2)*exp(3 - x)
    
  4. 对于牛顿-拉弗森方法和割线法,我们都使用 optimize 中的 newton 例程。无论是割线法还是牛顿-拉弗森方法,都需要将函数作为第一个参数,将第一个近似值 x0 作为第二个参数。使用牛顿-拉弗森方法时,我们必须提供 的导数,并使用 fprime 关键字参数:

    optimize.newton(f, 1, fprime=fp) # Using the Newton-Raphson method
    
    # 2.0
    
  5. 要使用割线法,只需要函数,但必须提供根的前两个近似值;第二个近似值通过 x1 关键字参数提供:

    optimize.newton(f, 1., x1=1.5) # Using x1 = 1.5 and the secant method
    
    # 1.9999999999999862
    

注意

牛顿-拉弗森方法和割线法都不能保证一定会收敛到一个根。方法的迭代过程有可能会在若干点之间循环(周期性)或剧烈波动(混沌)。

它是如何工作的……

对于一个函数 ,其导数为 ,初始近似值为 ,牛顿-拉弗森方法通过以下公式进行迭代定义:

对于每个整数,。从几何角度来看,这个公式通过考虑梯度为负(因此,函数在减小)时的方向(如果),或者梯度为正(因此,函数在增加)时的方向(如果)来得到。

割线法基于牛顿-拉夫森方法,但将第一导数替换为以下近似值:

足够小(这会在方法收敛时发生),那么这是一个很好的近似值。不需要函数的导数所付出的代价是我们需要额外的初始猜测来启动该方法。该方法的公式如下:

一般来说,如果给定任一方法一个足够接近根的初始猜测(割线法的猜测),则该方法将收敛到该根。牛顿-拉夫森方法也可能失败,如果在某次迭代中导数为零,此时公式将无法定义。

还有更多...

本食谱中提到的方法是通用方法,但在某些情况下,可能有其他方法更快或更准确。广义上讲,根求解算法可以分为两类:使用每次迭代时函数梯度信息的算法(如牛顿-拉夫森、割线法、哈雷法),以及需要知道根的区间范围的算法(如二分法、正则法、布伦特法)。到目前为止讨论的算法属于第一类,虽然通常非常快速,但它们可能会失败并无法收敛。

第二类算法是那些已知根存在于指定区间内的算法。我们可以通过检查的符号是否不同来判断根是否位于该区间内——也就是说,中的一个条件成立(当然,前提是函数是连续的,这在实际中通常是成立的)。这类算法中最基础的就是二分法,它通过反复二分区间直到找到足够精确的根的近似值。基本原理是将区间的中点分割,并选择函数符号变化的区间。该算法重复进行,直到区间非常小。以下是该算法的一个简单 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 = (a + b)/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

该方法保证收敛,因为在每一步,距离 都会减半。然而,该方法可能需要比牛顿-拉弗森方法或割线法更多的迭代次数。optimize 模块中也有一个版本的二分法,该版本用 C 实现,比这里介绍的版本效率要高得多,但在大多数情况下,二分法并不是最快的方法。

布伦特法是对二分法的改进,并且在 optimize 模块中作为 brentq 提供。它结合了二分法和插值法,能够快速找到方程的根:

optimize.brentq(f, 1.0, 3.0)  # 1.9999999999998792

需要注意的是,涉及括值法(如二分法、正割法、布伦特法)的技术不能用于求解复数变量的根函数,而不使用括值法的技术(如牛顿法、割线法、哈雷法)可以。

最后,有些方程的形式可能并非 ,但仍然可以使用这些技术进行求解。通过重新排列方程,使其符合所需的形式(如果必要,重新命名函数)来实现。这通常不太困难,只需要将右侧的任何项移到左侧。例如,如果你希望找到一个函数的固定点——也就是说,当 时——我们就可以应用该方法到相关函数 上。

使用 SciPy 进行数值积分

积分可以被解释为曲线与 轴之间的面积,面积的符号取决于该面积是在轴的上方还是下方。有些积分无法直接通过符号方式计算,而必须通过数值方法近似计算。一个经典的例子是高斯误差函数,它在《理解基本数学函数》一节中提到过,详见 第一章基本包、函数和概念简介。该函数通过以下公式定义:

此外,这里出现的积分无法通过符号方式计算。

在这个例子中,我们将演示如何使用 SciPy 包中的数值积分例程来计算一个函数的积分。

准备工作

我们使用 scipy.integrate 模块,其中包含了几个用于计算数值积分的例程。我们还将 NumPy 库作为 np 导入。我们可以通过以下方式导入该模块:

from scipy import integrate

如何实现...

以下步骤描述了如何使用 SciPy 进行数值积分:

  1. 我们计算在误差函数定义中出现的积分,计算的值为 。为此,我们需要在 Python 中定义被积函数(出现在积分符号内部的函数):

    def erf_integrand(t):
    
        return np.exp(-t**2)
    

scipy.integrate中有两种主要的例程可以用于执行数值积分(求积),分别是quad函数和quadrature。第一个是使用 QUADPACK 执行积分的quad函数,第二个是quadrature

  1. quad例程是一个通用的积分工具。它需要三个参数:要积分的函数(erf_integrand),下限(-1.0)和上限(1.0):

    val_quad, err_quad = integrate.quad(erf_integrand, -1.0, 1.0)
    
    # (1.493648265624854, 1.6582826951881447e-14)
    

第一个返回值是积分的值,第二个返回值是误差的估计。

  1. 使用quadrature例程重复计算,我们得到如下结果。其参数与quad例程相同:

    val_quadr, err_quadr =
    
        integrate.quadrature(
    
            erf_integrand, -1.0, 1.0)
    
    # (1.4936482656450039, 7.459897144457273e-10)
    

输出格式与代码相同,首先是积分值,然后是误差估计。请注意,quadrature例程的误差较大。这是由于该方法在估算误差小于给定容忍度时终止,而该容忍度可以在调用例程时进行修改。

它是如何工作的……

大多数数值积分技术遵循相同的基本过程。首先,我们选择积分区域内的点,然后使用这些值和来近似积分。例如,使用梯形法则时,我们使用以下公式来近似积分:

这里, 是相邻的值之间的(常见)差异,包括端点。这可以在 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))

quadquadrature使用的算法比这要复杂得多。使用此函数通过trapezium法则和 500 步来近似积分erf_integrand,得到的结果为 1.4936463036001209,与quadquadrature例程的五位小数近似值一致。

quadrature例程使用固定容忍度的高斯求积,而quad例程则使用在 Fortran 库 QUADPACK 中实现的自适应算法。对两个例程进行计时,我们发现quad例程比quadrature例程快大约五倍,尤其是在本食谱中描述的问题上。quad例程的执行时间约为 27 微秒,经过 100 万次执行的平均值,而quadrature例程的执行时间约为 134 微秒。(您的结果可能会因系统而异。)一般来说,除非您需要quadrature实现的高斯求积,否则应使用quad方法,因为它既更快又更准确。

还有更多……

本节提到的例程要求已知被积函数,这并不总是成立。相反,我们可能已知一些数对 ,但我们并不知道函数 ,也无法在额外的点上进行评估。在这种情况下,我们可以使用 scipy.integrate 中的某种采样求积技术。如果已知点的数量非常大且所有点间距相等,我们可以使用 Romberg 积分法进行良好的积分近似。为此,我们使用 romb 例程。否则,我们可以使用梯形规则的变体(如前所示),利用 trapz 例程,或者使用辛普森法则,通过 simps 例程进行计算。

数值求解简单微分方程

微分方程出现在某些量随着时间或其他因素变化的情形中,通常根据给定的关系式进行演化。它们在工程学和物理学中极为常见,且自然地出现。微分方程的一个经典(非常简单)的例子是牛顿提出的冷却定律。一个物体的温度以与当前温度成正比的速率降低。从数学角度看,这意味着我们可以使用以下微分方程来表示在时间 时刻物体的温度 的导数:

这里, 是一个正的常数,用于确定冷却速率。这个微分方程可以通过先分离变量,然后进行积分和重排来解析求解。经过这一过程,我们可以得到一般解:

这里, 是初始温度。

在本示例中,我们将使用 SciPy 的 solve_ivp 例程数值求解一个简单的常微分方程(ODE)。

准备工作

我们将演示如何在 Python 中使用先前描述的冷却方程来数值求解一个微分方程,因为在这种情况下我们可以计算真实解。我们将初始温度设为 。同时,我们还要找到 之间的解。

在这个示例中,我们需要导入 NumPy 库,命名为 np,导入 Matplotlib 的 pyplot 接口,命名为 plt,以及从 SciPy 导入 integrate 模块:

from scipy import integrate

一般的(一级)微分方程具有以下形式:

这里,(自变量)和 (因变量)的一些函数。在此公式中, 是因变量,。SciPy 包中求解微分方程的例程需要函数 以及初始值 和我们需要计算解的 值的范围。为了开始,我们需要在 Python 中定义我们的函数 并创建变量 的范围,以便提供给 SciPy 例程:

def f(t, y):
    return -0.2*y
t_range = (0, 5)

接下来,我们需要定义求解该初值问题所需的初始条件。由于技术原因,初始 值必须指定为一维 NumPy 数组:

T0 = np.array([50.])

由于在这种情况下,我们已经知道真实解,因此我们也可以在 Python 中定义该解,以便与我们将计算的数值解进行比较:

def true_solution(t):
    return 50.*np.exp(-0.2*t)

让我们看看如何使用 SciPy 求解这个初值问题。

如何做到...

按照以下步骤,数值求解微分方程并绘制解和误差:

  1. 我们使用 SciPy 中 integrate 模块的 solve_ivp 例程来数值求解微分方程。我们添加一个最大步长参数,值为 0.1,以确保解在合理数量的点处计算:

    sol = integrate.solve_ivp(f, t_range, T0, max_step=0.1)
    
  2. 接下来,我们从 solve_ivp 方法返回的 sol 对象中提取解的值:

    t_vals = sol.t
    
    T_vals = sol.y[0, :]
    
  3. 接下来,我们在一组坐标轴上绘制解,如下所示。由于我们还将绘制同一图形中的近似误差,因此我们使用 subplots 例程创建两个子图:

    fig, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True)
    
    ax1.plot(t_vals, T_valsm "k")
    
    ax1.set_xlabel("$t$")
    
    ax1.set_ylabel("$T$")
    
    ax1.set_title("Solution of the cooling equation")
    

这将把解绘制在 图 3**.1 左侧的一组坐标轴上。

  1. 为了做到这一点,我们需要计算从 solve_ivp 例程中获得的点处的真实解,然后计算真实解与近似解之间差异的绝对值:

    err = np.abs(T_vals - true_solution(t_vals))
    
  2. 最后,在 图 3**.1 右侧,我们使用对数坐标轴绘制近似误差,轴上显示的是 的对数比例。然后,我们可以使用 semilogy 绘图命令,按照在 第二章 中展示的 Matplotlib 数学绘图方法,将其绘制在右侧的对数坐标轴上

    ax2.semilogy(t_vals, err, "k")
    
    ax2.set_xlabel("$t$")
    
    ax2.set_ylabel("Error")
    
    ax2.set_title("Error in approximation")
    

图 3**.1 左侧的图展示了温度随时间下降的情况,而右侧的图显示了误差随着我们远离初始条件给定的已知值而增加:

图 3.1 – 冷却方程数值解的绘图

图 3.1 – 冷却方程数值解的绘图

请注意,右侧的图是对数坐标系,尽管增长速率看起来相当显著,但涉及的数值非常小(大约是 )。

它是如何工作的...

大多数解微分方程的方法都是时间步进方法。通过小的 步长生成的数对 ,并近似求得函数 的值。欧拉法作为最基本的时间步进方法,也许能最清晰地说明这一点。固定一个小的步长 ,我们使用以下公式在 步生成近似值:

我们从已知的初始值开始 。我们可以很容易地编写一个执行欧拉法的 Python 程序,代码如下(当然,欧拉法有很多不同的实现方式;这是一个非常简单的例子)。

首先,我们通过创建将存储 值的列表来设置该方法:

def euler(func, t_range, y0, step_size):
    """Solve a differential equation using Euler's method"""
    t = [t_range[0]]
    y = [y0]
    i = 0

欧拉法会持续进行,直到我们到达 范围的终点。在这里,我们使用 while 循环来完成这一过程。循环体非常简单;我们首先递增一个计数器 i,然后将新的 值添加到各自的列表中:

    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 例程默认使用 Runge-Kutta-Fehlberg (RKF45) 方法,该方法能够自适应调整步长,确保近似值的误差保持在给定的容忍度范围内。此例程需要三个位置参数:函数 ,求解所需的 范围,以及初始的 值(在我们的示例中是 )。可以提供可选的参数来更改求解器、计算点的数量以及其他设置。

传递给 solve_ivp 函数的函数必须有两个参数,如准备工作部分所描述的常规微分方程。该函数还可以有额外的参数,可以通过 solve_ivp 例程的 args 关键字传递,但这些参数必须位于两个必要参数之后。将我们之前定义的 euler 函数与 solve_ivp 函数进行比较,两个函数的最大步长为 0.1,我们发现 solve_ivp 解的最大真实误差约为 10-11,而 euler 解的误差为 0.19。euler 方法有效,但步长太大,导致误差不断积累。为了比较,图 3**.2 是欧拉法生成的解与误差的图表。将 图 3**.2图 3**.1 进行比较,注意误差图的比例尺有很大的不同:

图 3.2 – 使用欧拉方法(步长为 0.1)绘制的解与误差图

图 3.2 – 使用欧拉方法(步长为 0.1)绘制的解与误差图

solve_ivp 例程返回一个解对象,该对象存储有关已计算解的信息。这里最重要的是 ty 属性,它们包含计算解 值以及解本身 。我们使用这些值来绘制我们计算出的解。 值存储在一个形状为 (n, N) 的 NumPy 数组中,其中 n 是方程的组成部分数量(这里为 1),N 是计算得到的点的数量。存储在 sol 中的 值保存在一个二维数组中,在这个例子中有一行和多列。我们使用切片 y[0, :] 来提取这一行作为一个一维数组,可以用于在第 4 步中绘制解。

我们使用对数缩放的 坐标轴来绘制误差,因为这里有趣的是量级。将其绘制在未缩放的 坐标轴上会得到一条非常接近 坐标轴的直线,这样无法显示随着我们通过 值变化误差的增加。对数缩放的 坐标轴清晰地显示了这一增加。

还有更多…

solve_ivp 例程是一个方便的接口,提供多种微分方程求解器,默认使用 RKF45 方法。这些求解器各有不同的优点,但 RKF45 方法是一个良好的通用求解器。

另见

有关如何在 Matplotlib 中向图形添加子图的详细说明,请参见 第二章 中的 添加子图 方案,书名为 Matplotlib 数学绘图

求解微分方程组

微分方程有时出现在由两个或多个互相关联的微分方程组成的系统中。一个经典的例子是竞争物种种群的简单模型。这个竞争物种的简单模型标记为 (猎物)和 (捕食者),由以下方程给出:

第一个方程决定了猎物物种 的增长,如果没有捕食者,它将呈指数增长。第二个方程决定了捕食者物种 的增长,如果没有猎物,它将呈指数衰减。当然,这两个方程是 耦合的;每个人口变化都依赖于两个物种的数量。捕食者以与两者数量积成正比的速度捕食猎物,而捕食者的增长速度与猎物的相对丰度成正比(同样是两者数量的积)。

在这个食谱中,我们将分析一个简单的微分方程系统,并使用 SciPy integrate 模块来获得近似解。

准备工作

使用 Python 求解微分方程组的工具与求解单一方程的工具是相同的。我们再次使用 SciPy integrate 模块中的 solve_ivp 函数。然而,这只会给我们在给定起始种群下,预测的时间演化。因此,我们还将使用 Matplotlib 中的一些绘图工具,以便更好地理解种群演化。像往常一样,我们导入了 NumPy 库并将其命名为 np,同时导入了 Matplotlib 的 pyplot 接口并将其命名为 plt

如何做...

接下来的步骤将引导我们如何分析一个简单的微分方程系统:

  1. 我们的第一个任务是定义一个包含方程组的函数。这个函数需要接受两个参数,就像单一方程那样,唯一不同的是依赖变量 (来自 数值求解简单微分方程 食谱中的符号)将变成一个数组,数组的元素个数等于方程的个数。在这里,会有两个元素。我们为这个示例系统所需的函数定义如下:

    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]])
    
  2. 现在我们在 Python 中定义了系统,我们可以使用 Matplotlib 的 quiver 函数生成一个图表,描述在多个起始种群下,按照方程所给出的种群将如何演化。我们首先设置一个点的网格,在这个网格上我们将绘制这个演化。建议选择一个相对较少的点数来进行 quiver 绘图;否则,图表的细节将变得难以看清。对于这个示例,我们绘制种群值在 0 到 100 之间的情况:

    p = np.linspace(0, 100, 25)
    
    w = np.linspace(0, 100, 25)
    
    P, W = np.meshgrid(p, w)
    
  3. 现在,我们在每对这些点上计算系统的值。注意,系统中的任何方程都不是时间相关的(它们是自治的);时间变量 在计算中不重要。我们为 参数提供值 0

    dp, dw = predator_prey_system(0, np.array([P, W]))
    
  4. dpdw 变量现在分别表示如果我们从网格中的每个点开始,物种 将演化的 方向。我们可以使用 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.3,它提供了一个全局的视角,展示了解如何演变:

图 3.3 – 显示两种竞争物种种群动态的箭头图

图 3.3 – 显示两种竞争物种种群动态的箭头图

为了更具体地理解解,我们需要一些初始条件,以便能够使用前面食谱中描述的solve_ivp例程。

  1. 由于我们有两个方程,初始条件将有两个值。(回想在数值求解简单微分方程的食谱中,我们看到提供给solve_ivp的初始条件需要是一个 NumPy 数组。)让我们考虑初始值 。我们将它们定义在一个 NumPy 数组中,并小心地按正确的顺序放置它们:

    initial_conditions = np.array([85, 40])
    
  2. 现在,我们可以使用来自scipy.integrate模块的solve_ivp。我们需要提供max_step关键字参数,以确保我们在解中有足够的点,从而得到平滑的解曲线:

    from scipy import integrate
    
    t_range = (0.0, 5.0)
    
    sol = integrate.solve_ivp(predator_prey_system,
    
                              t_range,
    
                              initial_conditions,
    
                              max_step=0.01)
    
  3. 让我们将这个解绘制在现有图形上,以展示这个特定解如何与我们已经生成的方向图相关联。我们同时也绘制初始条件:

    ax.plot(initial_conditions[0],
    
        initial_conditions[1], "ko")
    
    ax.plot(sol.y[0, :], sol.y[1, :], "k", linewidth=0.5)
    

结果如图 3.4所示:

图 3.4 – 在箭头图上绘制的解轨迹,展示了总体行为

图 3.4 – 在箭头图上绘制的解轨迹,展示了总体行为

我们可以看到绘制出的轨迹是一个闭合的环路。这意味着种群之间有一个稳定且周期性的关系。这是解这些方程时常见的模式。

它是如何工作的……

用于系统 ODE 的方法与单一 ODE 完全相同。我们从将方程组写成单一的向量微分方程开始:

然后,可以使用时间步进方法来求解,就像 是一个简单的标量值一样。

使用quiver例程在平面上绘制方向箭头的技术是一种快速且简单的方式来了解一个系统如何从给定状态演变。一个函数的导数表示曲线的梯度 ,因此微分方程描述了解函数在位置 和时间 处的梯度。一个方程组描述了在给定位置 和时间 处的各个解函数的梯度。当然,位置现在是一个二维点,因此当我们绘制该点的梯度时,我们将其表示为一支箭头,箭头从该点开始,指向梯度的方向。箭头的长度表示梯度的大小;箭头越长,解曲线在该方向上移动得就越

当我们将解轨迹绘制在这个方向场上时,可以看到曲线(从某个点开始)沿着箭头指示的方向移动。解轨迹所表现的行为是一个极限环,其中每个变量的解是周期性的,随着两个物种种群的增长或下降。这种行为的描述在我们将每个种群与时间进行绘制时,或许会更加清晰,如图 3.5所示。从图 3.4中不容易立刻看出来的是解轨迹会绕几个圈,但这在图 3.5中清楚地展示了出来:

图 3.5 – 种群 P 和 W 随时间变化的图像

图 3.5 – 种群 P 和 W 随时间变化的图像

前面描述的周期关系在图 3.5中非常明显。此外,我们还可以看到两个物种的种群峰值之间的滞后关系。物种 的峰值种群大约在物种 后 0.3 个时间单位达到。

还有更多...

通过将变量彼此对比,针对不同的初始条件绘制出来,从而分析一个常微分方程系统的技术,称为相空间(平面)分析。在这个过程中,我们使用了 quiver 绘图方法来快速生成微分方程系统的相平面的近似图。通过分析微分方程系统的相平面,我们可以识别解的不同局部和全局特征,如极限环。

数值求解偏微分方程

偏微分方程是涉及多个变量的函数的偏导数的微分方程,而不是只有单一变量的普通导数。偏微分方程是一个广泛的主题,足以填满一系列的书籍。一个典型的偏微分方程的例子是(一维)热方程

这里, 是一个正的常数, 是一个函数。这个偏微分方程的解是一个函数 ,它表示在给定时间 时,处于范围 的杆的温度。为了简化问题,我们假设 ,这相当于说没有施加任何加热/冷却,,并且 。在实践中,我们可以重新调整问题的尺度,使常数 固定,所以这并不是一个限制性问题。在这个例子中,我们将使用边界条件:

这些等同于说杆的两端被保持在常温 0°。我们还将使用初始温度分布:

这个初始温度分布描述了一条平滑的曲线,介于 0 和 2 之间,峰值为 3,这可能是因为将杆的中心加热至温度 3 所导致的。

我们将使用一种叫做有限差分法的方法,先将杆分成若干等长的段,再将时间范围划分为若干离散的步长。然后,我们计算每个段和每个时间步的解的近似值。

在这个配方中,我们将使用有限差分法来求解一个简单的偏微分方程。

准备工作

对于这个配方,我们将需要 NumPy 和 Matplotlib 包,分别以npplt导入,和往常一样。我们还需要从mpl_toolkits中导入mplot3d模块,因为我们将绘制一个 3D 图:

from mpl_toolkits import mplot3d

我们还需要一些来自 SciPy 包的模块。

如何实现...

在接下来的步骤中,我们将通过有限差分法解决热方程:

  1. 首先,我们创建表示系统物理约束的变量——杆的长度和的值:

    alpha = 1
    
    x0 = 0 # Left hand x limit
    
    xL = 2 # Right hand x limit
    
  2. 我们首先将范围划分为等间隔——我们在这个例子中取——使用个点。我们可以使用 NumPy 的linspace函数生成这些点。我们还需要每个区间的公共长度

    N = 10
    
    x = np.linspace(x0, xL, N+1)
    
    h = (xL - x0) / N
    
  3. 接下来,我们需要设置时间方向上的步长。我们在这里采取稍微不同的方法;我们设置时间步长和步数(隐含假设从时间 0 开始):

    k = 0.01
    
    steps = 100
    
    t = np.array([i*k for i in range(steps+1)])
    
  4. 为了使方法正确运行,我们必须满足以下公式:

否则,系统可能会变得不稳定。我们将这个不等式的左边存储在一个变量中,以便在第 5 步使用,并用断言检查这个不等式是否成立:

r = alpha*k / h**2
assert r < 0.5, f"Must have r < 0.5, currently r={r}"
  1. 现在,我们可以构造一个矩阵来保存有限差分方案中的系数。为此,我们使用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")
    
  2. 接下来,我们创建一个空矩阵来保存解:

    u = np.zeros((steps+1, N+1), dtype=np.float64)
    
  3. 我们需要将初始分布添加到第一行。最佳方法是创建一个函数来表示初始分布,并将该函数在x数组上求值的结果存储在我们刚刚创建的矩阵u中:

    def initial_profile(x):
    
        return 3*np.sin(np.pi*x/2)
    
    u[0, :] = initial_profile(x)
    
  4. 现在,我们可以简单地遍历每一步,通过将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="gray")
ax.set_title("Solution of the heat equation")
ax.set_xlabel("t")
ax.set_ylabel("x")
ax.set_zlabel("u")

结果是如图 3.6所示的表面图:

图 3.6 - 热方程在该范围内的数值解

图 3.6 - 热方程在该范围内的数值解

沿着 轴,我们可以看到整体形状与初始轮廓相似,但随着时间的推移变得更加平坦。沿着 轴,表面展现出典型的冷却系统特征——指数衰减。

它是如何工作的...

有限差分法通过用只涉及函数值的简单分数来替代每个导数,这些函数值我们可以估计。为了实现此方法,我们首先将空间范围和时间范围分解为多个离散区间,由网格点分隔。这个过程称为离散化。然后,我们使用微分方程以及初始条件和边界条件形成连续的近似,方式与solve_ivp例程在数值求解简单微分方程中的时间步进方法非常相似。

为了解决像热方程这样的偏微分方程,我们至少需要三项信息。通常,对于热方程,这些信息将以边界条件的形式出现在空间维度上,告诉我们杆子两端的行为,以及初始条件的形式出现在时间维度上,表示杆子上的初始温度分布。

前面描述的有限差分法通常称为前向时间中心差分FTCS)法,因为我们使用前向有限差分来估计时间导数,使用中心有限差分来估计(二阶)空间导数。第一阶有限差分近似的公式如下:

类似地,二阶近似由以下公式给出:

将这些近似代入热方程,并使用近似公式 来表示在 时间步长后的 空间点的值,我们得到如下结果:

这可以重新排列得到以下公式:

粗略地说,这个方程表明给定点的下一个温度依赖于前一个时间步长周围的温度。这也说明了为什么r值的条件是必要的;如果这个条件不成立,右侧的中间项将变为负数。

我们可以将这个方程系统写成矩阵形式:

在这里,是一个向量,包含近似值和矩阵,该矩阵在步骤 4 中已定义。该矩阵是三对角矩阵,意味着非零项出现在主对角线或其邻近位置。我们使用来自 SciPy sparse模块的diag函数,这是定义这类矩阵的工具。这与本章求解方程的过程非常相似。该矩阵的首行和末行都为零,除了左上角和右下角分别表示(不变的)边界条件。其他行则包含通过有限差分法对微分方程两侧的导数进行近似得到的系数。我们首先创建对角线上的条目以及对角线上下的条目,然后使用diags函数创建一个稀疏矩阵。该矩阵应具有行和列,以匹配网格点的数量,并且我们将数据类型设置为双精度浮点数,并采用压缩稀疏行CSR)格式。

初始配置给出了向量,从这个初始点开始,我们可以通过简单地执行矩阵乘法来计算每个后续时间步,就像我们在步骤 7 中看到的那样。

还有更多内容...

我们在这里描述的方法相当粗糙,因为如我们所提到的,如果时间步长和空间步长的相对大小没有得到精确控制,近似可能会变得不稳定。这种方法是显式的,因为每个时间步都是显式计算的,仅使用前一个时间步的信息。也有隐式方法,它给出一个方程组,可以求解得到下一个时间步。不同的方案在解的稳定性上有不同的特点。

当函数不为 0 时,我们可以通过以下赋值来轻松处理这种变化:

在这里,函数已被适当向量化,使得该公式有效。就解决问题所用的代码而言,我们只需要包含函数的定义,然后更改解法中的循环,代码如下:

for i in range(steps):
       u[i+1, :] = A @ u[i, :] + f(t[i], x)

从物理角度来看,这个函数表示杆上每个点的外部热源(或热汇)。这个热源可能会随时间变化,这就是为什么通常情况下,该函数应该同时具有作为参数(尽管不一定都需要使用)。

我们在这个示例中给出的边界条件表示杆的两端保持在 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 包可以使用有限元方法来求解偏微分方程,比如FEniCSfenicsproject.org)。使用像 FEniCS 这样的包的优势在于,它们通常经过性能调优,这在求解高精度复杂问题时非常重要。

另见

FEniCS 文档提供了有限元方法的良好介绍,并给出了使用该包解决各种经典偏微分方程的多个示例。关于该方法和理论的更全面介绍可以参见以下书籍:Johnson, C.2009)。有限元法求解偏微分方程的数值解法Mineola, N.Y.: Dover Publications

有关如何使用 Matplotlib 生成三维表面图的更多详细信息,请参阅第二章中的表面和等高线图配方,Matplotlib 数学绘图

使用离散傅里叶变换进行信号处理

微积分中最有用的工具之一是 傅里叶变换FT)。粗略来说,傅里叶变换以可逆的方式改变某些函数的表示。这种表示的变化在处理以时间为函数的信号时尤为有用。在这种情况下,傅里叶变换将信号表示为频率的函数;我们可以将其描述为从信号空间到频率空间的转换。这样可以用于识别信号中存在的频率,进行识别和其他处理。实际上,我们通常会拥有一个离散的信号样本,因此我们必须使用 离散傅里叶变换DFT)来进行这种分析。幸运的是,有一个计算效率高的算法——称为 FFT——可以将 DFT 应用于样本。

我们将遵循一个常见的过程,使用 FFT 对噪声信号进行滤波。第一步是应用 FFT,并使用数据计算信号的 功率谱密度PSD)。然后,我们识别峰值,并滤除那些对信号贡献不足的频率。接下来,我们应用逆 FFT 来获取滤波后的信号。

在这个食谱中,我们使用 FFT 来分析信号的样本,识别其中的频率,并从信号中清除噪声。

准备工作

对于本食谱,我们只需要导入 NumPy 和 Matplotlib 包,分别作为 npplt。我们还需要创建一个默认的随机数生成器实例,如下所示:

rng = np.random.default_rng(12345)

现在,让我们看看如何使用 DFT。

如何做到...

按照以下说明使用 FFT 来处理噪声信号:

  1. 我们定义了一个函数来生成我们的基础信号:

    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)
    
  2. 接下来,我们通过向基础信号中添加一些高斯噪声来创建我们的样本信号。为了方便后续处理,我们还创建了一个数组来存储在样本值处的真实信号

    sample_size = 2**7 # 128
    
    sample_t = np.linspace(0, 4, sample_size)
    
    sample_y = signal(sample_t) + rng.standard_normal(
    
        sample_size)
    
    sample_d = 4./(sample_size - 1) # Spacing for linspace array
    
    true_signal = signal(sample_t)
    
  3. 我们使用 NumPy 的 fft 模块来计算 DFT。在开始分析之前,我们从 NumPy 导入它:

    from numpy import fft
    
  4. 为了查看噪声信号的样子,我们可以将样本信号的点与真实信号叠加起来进行绘制:

    fig1, ax1 = plt.subplots()
    
    fig1, ax1 = plt.subplots()
    
    ax1.plot(sample_t, sample_y, "k.",
    
             label="Noisy signal")
    
    ax1.plot(sample_t, true_signal, "k--",
    
             label="True signal")
    
    ax1.set_title("Sample signal with noise")
    
    ax1.set_xlabel("Time")
    
    ax1.set_ylabel("Amplitude")
    
    ax1.legend()
    

此处创建的图示显示在 图 3.7 中。如我们所见,噪声信号与真实信号(以虚线显示)几乎没有相似之处:

图 3.7 – 带有叠加真实信号的噪声信号样本

图 3.7 – 带有叠加真实信号的噪声信号样本

  1. 现在,我们将使用 DFT 提取样本信号中存在的频率。fft 模块中的 fft 例程执行 DFT:

    spectrum = fft.fft(sample_y)
    
  2. fft 模块提供了一个名为 fftfreq 的例程,用于构建适当的频率值。为了方便起见,我们还生成了一个包含正频率出现位置的整数数组:

    freq = fft.fftfreq(sample_size, sample_d)
    
    pos_freq_i = np.arange(1, sample_size//2, dtype=int)
    
  3. 接下来,计算信号的 PSD,如下所示:

    psd = np.abs(spectrum[pos_freq_i])**2 + np.abs(
    
        spectrum[-pos_freq_i])**2
    
  4. 现在,我们可以绘制信号的正频率部分的 PSD,并利用该图来识别频率:

    fig2, ax2 = plt.subplots()
    
    ax2.plot(freq[pos_freq_i], psd, "k")
    
    ax2.set_title("PSD of the noisy signal")
    
    ax2.set_xlabel("Frequency")
    
    ax2.set_ylabel("Density")
    

结果如图 3**.8所示。我们可以在这个图中看到,信号的频率大约在47附近,这就是我们之前定义的信号的频率:

图 3.8 – 使用 FFT 生成的信号的 PSD

图 3.8 – 使用 FFT 生成的信号的 PSD

  1. 我们可以识别这两个频率,尝试从噪声样本中重建真实信号。所有出现的微小峰值都没有超过 2,000,因此我们可以使用这个值作为过滤器的截止值。现在,让我们从所有正频率索引的列表中提取(希望是 2 个)与 PSD 中 2,000 以上的峰值对应的索引:

    filtered = pos_freq_i[psd > 2e3]
    
  2. 接下来,我们创建一个新的干净频谱,只包含我们从噪声信号中提取的频率。我们通过创建一个仅包含 0 的数组来实现,然后复制那些与过滤频率及其负频率相对应的索引的 spectrum 值:

    new_spec = np.zeros_like(spectrum)
    
    new_spec[filtered] = spectrum[filtered]
    
    new_spec[-filtered] = spectrum[-filtered]
    
  3. 现在,我们使用逆 FFT(使用 ifft 函数)将这个干净的频谱转换回原始样本的时域。我们使用 NumPy 的 real 函数提取实部,以消除错误的虚部:

    new_sample = np.real(fft.ifft(new_spec))
    
  4. 最后,我们将这个过滤后的信号与真实信号叠加绘制,并比较结果:

    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")
    

步骤 11 的结果如图 3**.9所示。我们可以看到,过滤后的信号与真实信号非常接近,除了有一些小的差异:

图 3.9 – 使用 FFT 过滤后的信号与真实信号叠加

图 3.9 – 使用 FFT 过滤后的信号与真实信号叠加

我们可以在图 3**.9中看到,过滤后的信号(虚线)与真实信号(较浅的实线)非常接近。它捕捉到了真实信号的大部分(但不是全部)振荡。

它是如何工作的...

一个函数的 FT 由以下积分给出:

DFT 由以下积分给出:

这里, 值是作为复数的样本值。可以使用上述公式计算 DFT,但在实际应用中,这样做效率不高。使用该公式进行计算是 。FFT 算法将复杂度提高到 ,这显著改善了计算效率。书籍《Numerical Recipes》(完整书目信息见 进一步阅读 部分)对 FFT 算法和 DFT 做了非常好的描述。

我们将对从已知信号(具有已知频率模式)生成的样本应用离散傅里叶变换(DFT),以便查看我们获得的结果与原始信号之间的关系。为了简化信号,我们创建了一个仅包含两个频率成分,值为 4 和 7 的信号。从这个信号中,我们生成了一个样本并进行了分析。由于快速傅里叶变换(FFT)的工作原理,最好是样本大小为 2 的幂;如果不是这种情况,我们可以通过在样本中填充零元素来使其符合要求。我们在样本信号中加入了一些高斯噪声,形式为正态分布的随机数。

fft 例程返回的数组包含 个元素,其中 是样本大小。索引为 0 的元素对应的是 0 频率或直流偏移(DC shift)。接下来的 个元素对应正频率的值,最后 个元素对应负频率的值。频率的实际值由采样点数 和样本间距决定,在这个例子中,样本间距存储在 sample_d 中。

在频率 处的 PSD 由以下公式给出:

这里, 代表了信号在频率 处的傅里叶变换(FT)。功率谱密度(PSD)衡量了各个频率对整体信号的贡献,这也是我们在大约 4 和 7 频率处看到峰值的原因。由于 Python 的索引允许我们使用负数索引来访问从序列末尾开始的元素,我们可以使用正索引数组来获取 spectrum 中的正频率和负频率元素。

在步骤 9 中,我们识别出了在图中峰值高于 2000 的两个频率的索引。这些索引对应的频率是 3.984375 和 6.97265625,虽然它们与 4 和 7 不完全相同,但非常接近。造成这种差异的原因是我们使用有限数量的采样点来对连续信号进行采样。(使用更多的点当然会得到更好的近似值。)

在步骤 11 中,我们取了逆 FFT 返回数据的实部。这是因为,从技术上讲,FFT 处理的是复数数据。由于我们的数据仅包含实数数据,我们预计这个新信号也应该只包含实数数据。然而,由于某些小误差,结果并非完全是实数。我们可以通过取逆 FFT 的实部来解决这个问题。这是合适的,因为我们可以看到虚部非常小。

我们可以在 图 3.9 中看到,经过滤波的信号与真实信号非常接近,但并不完全相同。这是因为,如前所述,我们正在用相对较小的样本来近似一个连续信号。

还有更多…

在生产环境中,信号处理可能会使用专门的包,比如 scipy 中的 signal 模块,或者一些低级代码或硬件来执行信号的过滤或清理。这个示例更多是展示如何使用 FFT 作为处理从某种周期性结构(即信号)中采样的数据的工具。FFT 在求解偏微分方程时非常有用,例如在 数值解偏微分方程 配方中看到的热方程。

另见

关于随机数和正态分布(高斯分布)的更多信息可以参考 第四章随机性概率

使用 JAX 进行自动求导和微积分

JAX 是一个由 Google 开发的线性代数和自动求导框架,专为机器学习(ML)设计。它结合了 Autograd 和其 加速线性代数XLA)优化编译器的功能,处理线性代数和机器学习。特别地,它允许我们轻松构建复杂的函数,自动计算梯度,并能在 图形处理单元GPU)或 张量处理单元TPU)上运行。最重要的是,它相对易于使用。在本示例中,我们将看到如何利用 JAX 的 即时编译JIT)编译器,计算一个函数的梯度,并使用不同的计算设备。

准备工作

对于本示例,我们需要安装 JAX 包。我们将使用 Matplotlib 包,并像往常一样通过 pyplot 接口导入为 plt。由于我们要绘制一个二元函数,我们还需要从 mpl_toolkits 包中导入 mplot3d 模块。

如何实现……

以下步骤展示了如何使用 JAX 定义一个 JIT 编译的函数,计算该函数的梯度,并使用 GPU 或 TPU 执行计算:

首先,我们需要导入我们将要使用的 JAX 库的部分模块:

import jax.numpy as jnp
from jax import grad, jit, vmap

现在,我们可以定义我们的函数,并应用 @jit 装饰器,告诉 JAX 在必要时对这个函数进行 JIT 编译:

@jit
def f(x, y):
    return jnp.exp(-(x**2 +y**2))

接下来,我们定义一个网格并绘制我们的函数:

t = jnp.linspace(-1.0, 1.0)
x, y = jnp.meshgrid(t, t)
fig = plt.figure()
ax = fig.add_subplot(projection="3d")
ax.plot_surface(x, y, f(x, y), cmap="gray")
ax.set_title("Plot of the function f(x, y)")
ax.set_xlabel("x")
ax.set_ylabel("y")
ax.set_zlabel("z")

结果图如 图 3.10 所示:

![图 3.10 – 使用 JAX 计算的二元函数图]

](https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/app-math-py-2e/img/3.10.jpg)

图 3.10 – 使用 JAX 计算的二元函数图

现在,我们使用 grad 函数(以及 jit 装饰器)来定义两个新函数,分别是相对于第一个和第二个参数的偏导数:

fx = jit(grad(f, 0))  # x partial derivative
fy = jit(grad(f, 1))  # y partial derivative

为了快速检查这些函数是否正常工作,我们在 处打印这些函数的值:

print(fx(1., -1.), fy(1., -1.))
# -0.27067056 0.27067056

最后,让我们绘制相对于 的偏导数:

zx = vmap(fx)(x.ravel(), y.ravel()).reshape(x.shape)
figpd = plt.figure()
axpd = figpd.add_subplot(projection="3d")
axpd.plot_surface(x, y, zx, cmap="gray")
axpd.set_title("Partial derivative with respect to x")
axpd.set_xlabel("x")
axpd.set_ylabel("y")
axpd.set_zlabel("z")

偏导数图如 图 3.11 所示:

![图 3.11 – 使用 JAX 自动求导计算的偏导数图]

](https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/app-math-py-2e/img/3.11.jpg)

图 3.11 – 使用 JAX 中的自动微分计算的函数的偏导数图

快速检查确认,这确实是函数相对于 的偏导数的图。

它是如何工作的…

JAX 是一个有趣的组合,结合了 JIT 编译器,专注于快速的线性代数操作,以及 Autograd 的强大功能,支持加速设备(以及我们这里没有使用的其他功能)。JIT 编译通过跟踪 JAX 版本的 NumPy 库上执行的线性代数操作,并构建一个可以被 XLA 编译器理解的函数的中间表示来工作。为了让这一切工作,你需要确保只使用 JAX 提供的 NumPy 模块(jax.numpy),而不是真正的 NumPy。JAX 还提供了 SciPy 包的版本。

这种方法的一个警告是,函数必须是纯粹的:它们不应该有超出返回值的副作用,并且不应依赖于任何未通过参数传递的数据。如果情况不是这样,它可能仍然有效,但你可能会得到意想不到的结果——记住,Python 版本的函数可能只会执行一次。另一个需要考虑的因素是,与 NumPy 数组不同,JAX NumPy 数组不能使用索引表示法和赋值进行就地更新。这个问题,以及其他几个当前重要的警告,都列在了 JAX 文档中(请参考以下部分,另见...)。

jit 装饰器指示 JAX 在适当的地方构建函数的编译版本。实际上,它可能会根据提供的参数类型生成多个编译版本(例如,针对标量值与数组值的不同编译函数)。

grad 函数接受一个函数并生成一个新函数,该函数计算相对于输入变量的导数。如果函数有多个输入变量,则这是相对于第一个参数的偏导数。第二个可选参数 argnums 用于指定计算哪些导数。在本例中,我们有一个两变量函数,并使用了 grad(f, 0)grad(f, 1) 命令来获取表示 f 函数两个偏导数的函数。

jax.numpy 中的大多数函数与 numpy 中的接口相同——我们在本例中看到了一些这样的函数。不同之处在于,JAX 版本会根据使用的加速器设备正确地存储数组。如果使用 NumPy 数组的上下文,例如绘图函数,我们可以毫无问题地使用这些数组。

在示例的第 5 步中,我们打印了两个偏导数的值。请注意,我们使用了1.-1.的值。重要的是要注意,使用整数等价物1-1会失败,因为 JAX 处理浮点数的方式。(由于大多数 GPU 设备不善于处理双精度浮点数,JAX 中的默认浮点类型是float32。)

在第 6 步中,我们在与函数相同的区域上计算了导数。为此,我们必须将数组展平,然后使用vmap函数对fx导数进行向量化,最后对结果进行重塑。grad的工作方式存在一个复杂性,这意味着fx不会以我们预期的方式进行向量化。

还有更多…

JAX 的设计考虑到需求变化时能够良好扩展,因此许多组件在设计时考虑了并发性。例如,随机数模块提供了一个能够有效分裂的随机数生成器,这样计算可以并发执行,而不会改变结果。例如,使用 Mersenne Twister 随机数生成器就无法实现这一点,因为它在统计上不可靠地分裂,并且可能会因为线程数量的不同而产生不同的结果。

另见

更多信息可以在 JAX 文档中找到:

jax.readthedocs.io/en/latest/

使用 JAX 求解微分方程

JAX 提供了一套用于解决广泛问题的工具。求解微分方程——如在数值求解简单微分方程一节中描述的初值问题——应该完全在该库的能力范围之内。diffrax包提供了多种微分方程求解器,利用了 JAX 的强大功能和便利性。

在前面的示例中,我们解决了一个相对简单的一阶常微分方程。在本例中,我们将解决一个二阶常微分方程,以展示该技巧。二阶常微分方程是涉及一个函数的一级和二级导数的微分方程。为了简单起见,我们将求解以下形式的线性二阶常微分方程:

这里,是待求解的的函数。特别地,我们将求解以下方程:

初始条件是。(请注意,这是一个二阶微分方程,因此我们需要两个初始条件。)

准备就绪

在开始求解方程之前,我们需要做一些纸上工作,将二阶方程化简为可以数值求解的一阶微分方程组。为此,我们做了替代,得到了 。通过这个替代,我们得到了如下的系统:

我们还得到初始条件

对于这个示例,我们需要安装 diffrax 包和 JAX。如往常一样,我们将 Matplotlib 的 pyplot 接口导入并指定别名为 plt。我们将 jax.numpy 导入并指定别名为 jnp,并导入 diffrax 包。

如何实现…

以下步骤展示了如何使用 JAX 和 diffrax 库来求解二阶线性微分方程:

首先,我们需要设置代表我们在 准备工作 部分中构建的常微分方程组的函数:

def f(x, y, args):
    u = y[...,0]
    v = y[...,1]
    return jnp.array([v, 3*x**2*v+(1.-x)*u])

接下来,我们设置了将用于求解方程的 diffrax 环境。我们将使用 diffrax 快速入门指南 中推荐的求解器 —— 详细内容请参见下面的 另见 部分。设置如下:

term = diffrax.ODETerm(f)
solver = diffrax.Dopri5()
save_at = diffrax.SaveAt(ts=jnp.linspace(0., 1.))
y0 = jnp.array([0., 1.]) # initial condition

现在,我们使用 diffrax 中的 diffeqsolve 例程来求解区间 上的微分方程:

solution = diffrax.diffeqsolve(term, solver, t0=0., t1=2.,
                               dt0=0.1, y0=y0, saveat=save_at)

现在我们已经求解了方程,接下来需要从 solution 对象中提取 的值:

x = solution.ts
y = solution.ys[:, 0]  # first column is y = u

最后,我们将在新图中绘制结果:

fig, ax = plt.subplots()
ax.plot(x, y, "k")
ax.set_title("Plot of the solution to the second order ODE")
ax.set_xlabel("x")
ax.set_ylabel("y")

结果图示如 图 3**.12 所示:

图 3.12 – 二阶线性常微分方程的数值解

图 3.12 – 二阶线性常微分方程的数值解

我们可以看到,当 接近 时,解大致是线性的,但之后解变得非线性。( 范围可能太小,无法看到该系统的有趣行为。)

它是如何工作的…

diffrax 是建立在 JAX 之上的,并提供了多种微分方程求解器。在这个示例中,我们使用了 Dormand-Prince 5(4) Dopri5 求解器类,这是另一种求解常微分方程的 Runge-Kutta 方法,类似于我们在早期示例中看到的 Runge-Kutta-Fehlberg 方法。

在幕后,diffrax 将常微分方程的初值问题转换为一个 diffrax,能够求解除这些简单常微分方程之外的其他类型的微分方程;该库的目标之一是为数值求解 随机微分方程SDEs)提供工具。由于它是基于 JAX 的,应该很容易将其集成到其他 JAX 工作流中。它还支持通过各种伴随方法进行反向传播。

另见

更多关于 diffrax 库及其所包含方法的信息可以在文档中找到:

docs.kidger.site/diffrax

)

进一步阅读

微积分是每个本科数学课程中非常重要的一部分。有许多优秀的微积分教材,包括 Spivak 的经典教材和 Adams 与 Essex 的更全面的课程:

  • Spivak, M. (2006)。微积分第 3 版剑桥:剑桥大学出版社

  • Adams, R.Essex, C. (2018)。微积分:完整课程第 9 版加拿大安大略省唐米尔斯:皮尔逊出版社

一个很好的数值微分和积分的来源是经典的数值计算法一书,它全面描述了如何在 C++中解决许多计算问题,并总结了相关理论:

  • Press, W., Teukolsky, S., Vetterling, WFlannery, B. (2007)。数值计算法:科学计算的艺术第 3 版剑桥:剑桥大学出版社

第四章:与随机性和概率打交道

在本章中,我们将讨论随机性和概率。我们将从简要探索概率的基本概念开始,通过从数据集中选择元素来学习概率。然后,我们将学习如何使用 Python 和 NumPy 生成(伪)随机数,并如何根据特定的概率分布生成样本。最后,我们将通过讨论一些高级主题,涵盖随机过程和贝叶斯技术,并使用马尔科夫链蒙特卡洛MCMC)方法来估计简单模型的参数,来结束本章。

概率是对特定事件发生的可能性进行量化。我们经常直观地使用概率,尽管有时正式的理论可能相当违反直觉。概率论旨在描述随机变量的行为,随机变量的值未知,但该随机变量的值的概率会在已知的某些(范围的)值中取值。这些概率通常呈现为几种概率分布之一。可以说,最著名的概率分布之一是正态分布,它例如可以描述某一特征在一个大群体中的分布情况。

第六章《数据与统计学应用》中,我们将再次讨论概率,并且将介绍统计学。在这里,我们将应用概率论来量化误差,并构建分析数据的系统化理论。

本章将涵盖以下实例:

  • 随机选择项目

  • 生成随机数据

  • 更改随机数生成器

  • 生成正态分布的随机数

  • 与随机过程打交道

  • 使用贝叶斯技术分析转化率

  • 使用蒙特卡洛模拟估计参数

技术要求

本章需要使用标准的科学 Python 包:NumPy、Matplotlib 和 SciPy。我们还需要 PyMC 包来完成最终的实例。你可以通过你喜欢的包管理工具安装,比如pip

python3.10 -m pip install pymc

此命令将安装最新版本的 PyMC,目前版本为 4.0.1\。此包提供了概率编程功能,涉及通过随机生成的数据进行多次计算,以了解解决方案的可能分布。

注意

在上一版中,当前版本的 PyMC 是 3.9.2,但自那时以来,PyMC 4.0 版本已经发布,并且在此更新中名称恢复为 PyMC,而不是 PyMC3\。

本章的代码可以在 GitHub 仓库的Chapter 04文件夹中找到,链接:github.com/PacktPublishing/Applying-Math-with-Python-2nd-Edition/tree/main/Chapter%2004

随机选择项目

概率和随机性的核心思想是从某种集合中选择一个项目。正如我们所知道的,从集合中选择一个项目的概率量化了该项目被选择的可能性。随机性描述了根据概率从集合中选择项目,而没有任何额外的偏差。随机选择的对立面可以被描述为确定性选择。一般来说,使用计算机复制一个纯随机过程是非常困难的,因为计算机及其处理本质上是确定性的。然而,我们可以生成伪随机数序列,当这些序列正确构造时,能够展示出一个合理的随机性近似。

在本配方中,我们将从一个集合中选择项目,并学习一些与概率和随机性相关的关键术语,这些术语将在本章中贯穿始终。

准备工作

Python 标准库包含一个用于生成(伪)随机数的模块,叫做 random,但在本配方和整个章节中,我们将使用 NumPy 的 random 模块。NumPy random 模块中的例程可以用于生成随机数数组,并且比标准库中的相应模块更具灵活性。像往常一样,我们会使用 np 别名导入 NumPy。

在继续之前,我们需要固定一些术语。样本空间是一个集合(一个没有重复元素的集合),事件是样本空间的一个子集。事件发生的概率 表示,记作 ,它是一个介于 0 和 1 之间的数字。概率为 0 表示事件永远不会发生,而概率为 1 表示事件肯定会发生。整个样本空间的概率必须为 1。

当样本空间是离散的时,概率只是介于 0 和 1 之间的数字,关联到每个元素,其中所有这些数字的总和为 1。这样就给出了从一个集合中选择单个项目(一个包含单一元素的事件)的概率的意义。我们将在这里考虑从离散集合中选择项目的方法,并将在生成正态分布的随机 的配方中处理连续的情况。

如何做...

执行以下步骤从容器中随机选择项目:

  1. 第一步是设置随机数生成器。此时,我们将使用 NumPy 的默认随机数生成器,这在大多数情况下都是推荐的。我们可以通过调用 NumPy random 模块中的 default_rng 例程来实现,这将返回一个随机数生成器实例。我们通常在不指定种子的情况下调用这个函数,但在本配方中,我们将添加一个 12345 种子,以便使我们的结果具有可重复性:

    rng = np.random.default_rng(12345) 
    
    # changing seed for repeatability
    
  2. 接下来,我们需要创建要选择的数据和概率。如果你已经存储了数据,或者希望选择具有相等概率的元素,可以跳过此步骤:

    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)

要从 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]])

我们可以看到,在采样数据中,0 和 1 的出现次数似乎更多,我们分别为其分配了 0.3 和 0.2 的概率。有趣的是,尽管 12 出现的概率是 2 的一半,结果我们只看到了一个 2,而出现了两个 12。这并不是问题;较大的概率并不保证某个特定数字在样本中出现的次数,只有在大量样本中,我们才会预期大约看到两倍于 12 的 2。

它是如何工作的…

default_rng 函数创建了一个新的 random 模块。然而,通常建议显式地使用 default_rng 创建生成器,或者自己创建一个 Generator 实例。以这种方式更加明确,这也更符合 Python 的风格,并且应该能产生更可重复的结果(在某种程度上)。

种子是传递给随机数生成器的一个值,用于生成随机值。生成器基于种子以完全确定的方式生成数字序列。这意味着,如果提供相同的种子,两个相同的 PRNG 实例将生成相同的随机数序列。如果没有提供种子,生成器通常会生成一个依赖于用户系统的种子。

Generator 类是 NumPy 中一个对低级伪随机比特生成器的封装,实际的随机数就是在这里生成的。在 NumPy 的最新版本中,默认的 PRNG 算法是 128 位的置换同余生成器。相比之下,Python 内置的 random 模块使用的是梅森旋转算法 PRNG。有关不同 PRNG 算法选项的更多信息,请参阅更改随机数 生成器的食谱。

Generator实例上的choice方法根据底层BitGenerator生成的随机数进行选择。可选的p关键字参数指定了与提供的数据中每个项目相关的概率。如果没有提供此参数,则假定均匀概率,即每个项目被选中的概率相等。replace关键字参数指定选择是否应有放回或无放回。我们启用了放回选项,以便同一个元素可以被选中多次。choice方法使用生成器提供的随机数来进行选择,这意味着两个使用相同种子并且类型相同的伪随机数生成器(PRNG)在使用choice方法时会选中相同的项目。

袋子中选择可能的点是理解离散概率的一个好方法。在这里,我们为每个有限数量的点分配一个特定的权重——例如,点数的倒数——这些权重的总和为 1。采样是根据概率分配的权重随机选择点的过程(我们也可以为无限集合分配离散概率,但这会更复杂,因为权重必须总和为 1,并且这在计算上也不实用)。

还有更多...

choice方法还可以通过传递replace=False参数来创建指定大小的随机样本。这可以确保从数据中选择不同的项目,这对于生成随机样本非常有用。例如,可能会用它来从整个用户组中选择用户来测试新版本的界面;大多数样本统计技术依赖于随机选择的样本。

生成随机数据

许多任务涉及生成大量的随机数,这些随机数在最基本的形式下,可能是整数或浮动精度(双精度)的浮点数,范围在 之间。理想情况下,这些数字应该均匀选择,这样如果我们绘制大量这些数字,它们将在范围 内大致均匀分布。

在这个食谱中,我们将展示如何使用 NumPy 生成大量随机整数和浮点数,并通过直方图展示这些数字的分布。

准备工作

在开始之前,我们需要从 NumPy 的random模块导入default_rng方法,并创建一个默认的随机数生成器实例来在食谱中使用:

from numpy.random import default_rng
rng = default_rng(12345) # changing seed for reproducibility

我们在随机选择项目食谱中已经讨论过这个过程。

我们还通过plt别名导入了 Matplotlib 的pyplot模块。

如何操作...

执行以下步骤以生成均匀的随机数据并绘制直方图以理解其分布:

  1. 要生成介于 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]])
    
  2. 要生成随机整数,我们使用 rng 对象上的 integers 方法。这样会返回指定范围内的整数:

    random_ints = rng.integers(1, 20, endpoint=True, size=10)
    
    # array([12, 17, 10, 4, 1, 3, 2, 2, 3, 12])
    
  3. 为了检查随机浮点数的分布,我们首先需要生成一个大数组的随机数,正如我们在步骤 1中所做的那样。虽然这并不是严格必要的,但较大的样本能更清楚地显示分布。我们按以下方式生成这些数字:

    dist = rng.random(size=1000)
    
  4. 为了显示我们生成的数字的分布,我们绘制了数据的直方图

    fig, ax = plt.subplots()
    
    ax.hist(dist, color="k", alpha=0.6)
    
    ax.set_title("Histogram of random numbers")
    
    ax.set_xlabel("Value")
    
    ax.set_ylabel("Density")
    

结果的图形如图 4.1所示。正如我们所看到的,数据在整个范围内大致均匀分布:

图 4.1 – 随机生成的介于 0 和 1 之间的随机数的直方图

](https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/app-math-py-2e/img/4.2.jpg)

图 4.1 – 随机生成的介于 0 和 1 之间的随机数的直方图

随着采样点数量的增加,我们预计这些条形图会“平整”并越来越像我们期望的均匀分布的平线。可以将其与图 4.2中的 10,000 个随机点的直方图进行比较:

图 4.2 – 10,000 个均匀分布的随机数的直方图

](https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/app-math-py-2e/img/4.1.jpg)

图 4.2 – 10,000 个均匀分布的随机数的直方图

我们可以看到,尽管分布并不是完全平坦的,但在整个范围内分布更加均匀。

它是如何工作的...

Generator 接口提供了三个简单的方法来生成基本的随机数,但不包括我们在随机选择项目一节中讨论的 choice 方法。除了用于生成随机浮点数的 random 方法和用于生成随机整数的 integers 方法外,还有一个用于生成原始随机字节的 bytes 方法。这些方法都会调用底层 BitGenerator 实例的相关方法。每个方法还可以改变生成的数字的数据类型,例如,从双精度浮点数变为单精度浮点数。

还有更多...

Generator 类上的 integers 方法通过添加 endpoint 可选参数,结合了旧版 RandomState 接口中的 randintrandom_integers 方法的功能(在旧版接口中,randint 方法排除了上限,而 random_integers 方法包括了上限)。Generator 上的所有随机数据生成方法都允许定制生成数据的数据类型,这在旧接口中是无法做到的(该接口在 NumPy 1.17 中引入)。

图 4.1中,我们可以看到我们生成的数据的直方图在范围内大致均匀分布 。也就是说,所有的条形图大致平齐(由于数据的随机性质,它们不会完全平齐)。这是我们期望的均匀分布随机数的特征,比如通过random方法生成的随机数。我们将在生成正态分布随机数的食谱中更详细地解释随机数的分布。

更换随机数生成器

NumPy 中的random模块提供了多个备用的伪随机数生成器,它们的默认 PRNG 使用 128 位置换同余生成器。虽然这是一个很好的通用随机数生成器,但它可能不足以满足你的特定需求。例如,这种算法与 Python 内部随机数生成器使用的算法差异很大。我们将遵循 NumPy 文档中设定的最佳实践指南,进行可重复但适当随机的仿真运行。

在这个食谱中,我们将向你展示如何切换到备用的伪随机数生成器(PRNG),以及如何在程序中有效地使用种子。

准备工作

像往常一样,我们通过np别名导入 NumPy。由于我们将使用random包中的多个项目,我们也通过以下代码从 NumPy 导入该模块:

from numpy import random

你需要选择 NumPy 提供的一个备用随机数生成器(或者定义你自己的;请参阅本食谱中的还有更多...部分)。在这个食谱中,我们将使用MT19937随机数生成器,它使用基于梅森旋转算法的生成器,类似于 Python 内部的随机数生成器所使用的算法。

如何操作...

以下步骤展示了如何以可重复的方式生成种子和不同的随机数生成器:

  1. 我们将生成一个SeedSequence对象,该对象可以从给定的熵源可重复地生成新的种子。我们可以像提供default_rng的种子一样,提供我们自己的整数作为熵,或者我们也可以让 Python 从操作系统收集熵。这里我们选择后一种方法来演示它的使用。为此,我们在创建SeedSequence对象时不提供任何额外的参数:

    seed_seq = random.SeedSequence()
    
  2. 现在我们有了为整个会话生成随机数生成器种子的手段,接下来记录熵,以便以后在必要时能够重现这个会话。以下是熵的示例;你的结果可能会有所不同:

    print(seed_seq.entropy)
    
    # 9219863422733683567749127389169034574
    
  3. 现在,我们可以创建底层的BitGenerator实例,它将为包装的Generator对象提供随机数:

    bit_gen = random.MT19937(seed_seq)
    
  4. 接下来,我们围绕这个BitGenerator实例创建一个包装的Generator对象,从而生成一个可用的随机数生成器:

    rng = random.Generator(bit_gen)
    

一旦创建,你就可以像我们在之前的食谱中看到的那样使用这个随机数生成器。

它是如何工作的...

如在 随机选择项 食谱中提到的,Generator 类是一个封装底层 BitGenerator 的类,后者实现了给定的伪随机数算法。NumPy 通过 BitGenerator 类的各种子类提供了几种伪随机数算法的实现:PCG64(默认);MT19937(如本食谱所示);Philox;和 SFC64。这些位生成器是在 Cython 中实现的。

PCG64 生成器应提供高性能的随机数生成,并且具有良好的统计质量(在 32 位系统上可能不是这样)。MT19937 生成器比更现代的伪随机数生成器要慢,并且生成的随机数不具有良好的统计属性。然而,这是 Python 标准库 random 模块使用的随机数生成算法。Philox 生成器相对较慢,但生成的随机数质量非常高,而 SFC64 生成器速度较快,质量也相对较好,但其统计属性不如其他生成器。

本食谱中创建的 SeedSequence 对象是一种以独立且可重复的方式为随机数生成器创建种子的方法。特别是,如果你需要为多个并行进程创建独立的随机数生成器,但仍需要能够在稍后重建每个会话以调试或检查结果时,这非常有用。该对象上存储的熵是一个 128 位整数,来自操作系统,并作为随机种子的来源。

SeedSequence 对象允许我们为每个独立的进程或线程创建一个独立的随机数生成器,从而消除可能导致结果不可预测的数据竞争问题。它还生成彼此非常不同的种子值,这有助于避免一些伪随机数生成器(例如 MT19937,它可能会使用两个相似的 32 位整数种子值生成非常相似的流)的问题。显然,当我们依赖这些值的独立性时,拥有两个生成相同或非常相似值的独立随机数生成器会导致问题。

还有更多...

BitGenerator 类作为生成原始随机整数的生成器的通用接口。前面提到的类是在 NumPy 中实现的,并采用 BitGenerator 接口。你也可以创建自己的 BitGenerator 子类,尽管这需要在 Cython 中实现。

注意

有关更多信息,请参考 NumPy 文档 numpy.org/devdocs/reference/random/extending.html#new-bit-generators

生成正态分布的随机数

生成随机数据的示例中,我们生成了遵循均匀分布(在 0 和 1 之间,但不包括 1)的随机浮点数。然而,在大多数需要随机数据的情况下,我们需要遵循几种不同的分布之一。粗略来说,分布函数是一个函数,,它描述了一个随机变量的值小于的概率。从实际应用上讲,分布描述了随机数据在一个范围内的分布情况。特别地,如果我们绘制一个遵循特定分布的数据的直方图,那么它应该大致与该分布函数的图形相似。通过实例来看,这一点最为明显。

最常见的分布之一是正态分布,它在统计学中经常出现,并且是我们在第六章《与数据和统计一起工作》中的许多统计方法的基础。在这个示例中,我们将展示如何生成遵循正态分布的数据,并绘制该数据的直方图,以查看分布的形态。

准备工作

生成随机数据示例一样,我们从 NumPy 的random模块中导入default_rng函数,并创建一个带有种子生成器的Generator实例用于演示:

from numpy.random import default_rng
rng = default_rng(12345)

像往常一样,我们导入 Matplotlib 的pyplot模块为plt,并导入 NumPy 为np

如何实现...

在接下来的步骤中,我们生成遵循正态分布的随机数据:

  1. 我们使用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)
    
  2. 接下来,我们绘制这个数据的直方图。我们增加了直方图中的bins数量。这并非严格必要,因为默认数量(10)已经足够,但它确实能稍微更好地展示数据分布:

    fig, ax = plt.subplots()
    
    ax.hist(rands, bins=20, color="k", alpha=0.6)
    
    ax.set_title("Histogram of normally distributed data")
    
    ax.set_xlabel("Value")
    
    ax.set_ylabel("Density")
    
  3. 接下来,我们创建一个函数,生成一系列值的预期密度。这是通过将正态分布的概率密度函数与样本数量(10,000)相乘来实现的:

    def normal_dist_curve(x):
    
        return 10000*np.exp(
    
            -0.5*((x-mu)/sigma)**2)/(sigma*np.sqrt(2*np.pi))
    
  4. 最后,我们将预期分布与数据的直方图一起绘制:

    x_range = np.linspace(-5, 15)
    
    y = normal_dist_curve(x_range)
    
    ax.plot(x_range, y, "k--")
    

结果如图 4.3所示。我们可以看到,样本数据的分布与正态分布曲线预期的分布非常接近:

图 4.3 – 从正态分布中抽取的数据的直方图,叠加了预期密度

图 4.3 – 从正态分布中抽取的数据的直方图,叠加了预期密度

再次强调,如果我们取更大更大的样本,我们会预期样本的粗糙度会开始平滑并接近预期的密度(如图 4.3中的虚线所示)。

它是如何工作的...

正态分布的概率密度函数由以下公式定义:

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

这个概率密度函数在均值处达到峰值,该均值与位置参数一致,而钟形曲线的宽度由尺度参数决定。我们可以在图 4.3中看到,Generator对象上使用normal方法生成的数据的直方图与预期分布非常吻合。

Generator类使用 256 步锯齿法生成正态分布的随机数据,这比 NumPy 中其他可用的 Box-Muller 或逆 CDF 实现要快。

还有更多...

正态分布是连续概率分布的一个例子,因为它是为实数定义的,且分布函数由积分(而非求和)定义。正态分布(以及其他连续概率分布)的一个有趣特性是,选择任何给定实数的概率为 0。这是合理的,因为只有在测量一个值是否位于给定范围内时,才有意义去计算这个分布中选中该值的概率。

正态分布在统计学中非常重要,主要得益于中心极限定理。简而言之,这个定理指出,具有共同均值和方差的独立同分布IID)随机变量的和,最终将趋近于一个具有共同均值和方差的正态分布。无论这些随机变量的实际分布如何,这一结论始终成立。这使得我们在许多情况下,即使变量的实际分布不一定是正态分布,仍然可以使用基于正态分布的统计检验(但我们在引用中心极限定理时需要非常谨慎)。

除了正态分布之外,还有许多其他连续概率分布。我们已经遇到过 0 到 1 范围内的均匀分布。更一般地,均匀分布在范围内,其概率密度函数由以下方程给出:

其他常见的连续概率密度函数包括指数分布、贝塔分布和伽马分布。每种分布在Generator类中都有对应的方法,用来从该分布生成随机数据。这些方法通常根据分布的名称命名,全部使用小写字母,因此对于上述分布,对应的方法是exponentialbetagamma。这些分布每个都有一个或多个参数,例如正态分布的地点和尺度参数,它们决定了分布的最终形状。你可能需要查阅 NumPy 文档(numpy.org/doc/1.18/reference/random/generator.html#numpy.random.Generator)或其他资料,以了解每种分布所需的参数。NumPy 文档还列出了可以生成随机数据的概率分布。

处理随机过程

在这个教程中,我们将探讨一个简单的随机过程示例,它模拟了公交车到达一个站点的数量随时间的变化。这个过程被称为泊松过程。泊松过程,,有一个单一的参数,,通常被称为强度速率,且在给定时间取值的概率由以下公式给出:

这个方程描述了到时间为止,辆公交车已经到达的概率。从数学上讲,这个方程意味着服从参数为的泊松分布。然而,有一种简单的方法可以通过对服从指数分布的到达间隔时间求和来构建泊松过程。例如,设为第()-次到达和第-次到达之间的时间,这些时间服从参数为的指数分布。现在,我们得到以下方程:

这里,数字是最大值,使得。这是我们将在这个教程中逐步构建的内容。我们还将通过取到达间隔时间的均值来估计这个过程的强度。

准备工作

在开始之前,我们从 NumPy 的random模块中导入default_rng函数,并创建一个新的随机数生成器,并为演示目的设置种子:

from numpy.random import default_rng
rng = default_rng(12345)

除了随机数生成器外,我们还导入了 NumPy 库作为np,并将 Matplotlib 的pyplot模块导入为plt。我们还需要确保安装了 SciPy 库。

如何操作…

以下步骤展示了如何使用泊松过程模拟公交车的到达:

  1. 我们的第一项任务是通过从指数分布中采样数据来创建样本到达时间间隔。NumPy Generator类中的exponential方法需要一个scale参数,公式为 ,其中 是速率。我们选择速率为 4,并创建 50 个样本到达时间间隔:

    rate = 4.0
    
    inter_arrival_times = rng.exponential(
    
        scale=1./rate, size=50)
    
  2. 接下来,我们使用 NumPy add通用函数的accumulate方法计算实际到达时间。我们还创建了一个包含整数 0 到 49 的数组,表示每个时刻的到达次数:

    arrivals = np.add.accumulate(inter_arrival_times)
    
    count = np.arange(50)
    
  3. 接下来,我们使用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.4所示,在图中每条水平线的长度表示到达时间间隔:

图 4.4 – 随时间变化的到达次数,且时间间隔服从指数分布

图 4.4 – 随时间变化的到达次数,且时间间隔服从指数分布

  1. 接下来,我们定义一个函数,用于评估单位时间内的计数概率分布,这里我们取1作为单位时间。这个函数使用了我们在本配方介绍中给出的泊松分布公式:

    def probability(events, time=1, param=rate):
    
        return ((param*time)**events/factorial(
    
            events))*np.exp(- param*time)
    
  2. 现在,我们绘制单位时间内的计数概率分布,因为在之前的步骤中我们选择了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")
    
  3. 现在,我们继续通过样本数据估计速率。我们通过计算到达时间间隔的均值来实现这一点,对于指数分布来说,均值是尺度参数 的估计值:

    estimated_scale = np.mean(inter_arrival_times)
    
    estimated_rate = 1.0/estimated_scale
    
  4. 最后,我们绘制基于这个估计速率的单位时间内计数的概率分布。我们将其绘制在我们在步骤 5中得到的真实概率分布之上:

    ax2.plot(N, probability(
    
        N, param=estimated_rate),
    
        "k--",label="Estimated distribution")
    
    ax2.legend()
    

结果图如图 4.5所示,在图中我们可以看到,除了一个小的偏差,估计的分布与真实分布非常接近:

图 4.5 – 每单位时间到达次数的分布,估计值与真实值

图 4.5 – 每单位时间到达次数的分布,估计值与真实值

图 4.5中展示的分布遵循了本配方介绍中描述的泊松分布。你可以看到,单位时间内的适中到达次数比大量到达次数更为常见。最可能的到达次数由速率参数 确定,在这个例子中速率为 4.0。

工作原理...

随机过程无处不在。大致来说,随机过程是相关随机变量的系统,通常根据时间对连续随机过程进行索引 ,或根据自然数对离散随机过程进行索引 。许多(离散)随机过程满足马尔可夫性质,这使它们成为马尔可夫链。马尔可夫性质的表述是,过程是无记忆的,即只有当前值对于下一个值的概率是重要的。

泊松过程是一种计数过程,用于计算在一段时间内发生的事件(例如公交车到达)的数量,前提是事件在时间上是随机分布的,并且具有固定参数的指数分布。我们通过从指数分布中抽样到达间隔时间来构建泊松过程,遵循我们在介绍中描述的构建方法。然而,事实证明,这一特性(即到达间隔时间是指数分布的)是所有泊松过程的属性,当它们按照概率的正式定义给出时。

在这个方法中,我们从具有给定rate参数的指数分布中抽取了 50 个点。我们必须做一个小的转换,因为 NumPy 的Generator方法用于从指数分布中抽样时,使用的是相关的scale参数,它是1除以rate参数。拿到这些点后,我们创建一个包含这些指数分布数值的累积和的数组。这就生成了我们的到达时间。实际的泊松过程是图 4**.4中显示的过程,它是到达时间与在该时间点发生的事件数量的组合。

指数分布的均值(期望值)与尺度参数相同,因此从指数分布中抽取样本的均值是一种估计尺度(rate)参数的方法。这个估计不会是完美的,因为我们的样本相对较小。这就是为什么在图 4**.5中的两幅图之间存在小的偏差。

还有更多内容...

随机过程有很多种类型,用于描述各种各样的现实场景。在这个方法中,我们使用泊松过程对到达时间进行了建模。泊松过程是一个连续随机过程,这意味着它是由一个连续变量 参数化的,而不是由一个离散变量 参数化的。泊松过程实际上是马尔可夫链,按照对马尔可夫链的适当推广定义,也是一种更新过程的例子。更新过程是描述在一段时间内发生的事件数量的过程。这里描述的泊松过程就是更新过程的一个例子。

许多马尔可夫链除了满足其定义的马尔可夫性质外,还满足一些其他性质。例如,如果下列等式对所有值成立,则该马尔可夫链是齐次的

简单来说,这意味着随着我们增加步骤数,从一个状态到另一个状态的单步转移概率不会改变。这对于检查马尔可夫链的长期行为非常有用。

构建简单的均匀马尔可夫链非常容易。假设我们有两个状态,。在任何给定的步骤中,我们可以处于状态或状态之一。我们根据一定的概率在这些状态之间移动。例如,假设从状态到状态的转移概率为 0.4,从状态到状态的转移概率为 0.6。同样,假设从状态到状态的转移概率为 0.2,从状态到状态的转移概率为 0.8。请注意,在这两种情况下,转移的概率和保持相同状态的概率之和都为 1。我们可以用矩阵形式表示从每个状态转移的概率,在这种情况下,使用以下方程:

这个矩阵叫做转移矩阵。这里的思路是,经过一步后处于特定状态的概率是通过将包含状态(分别是位置 0 和 1)的概率的向量相乘得到的。例如,如果我们从状态开始,则概率向量在索引 0 处为 1,在索引 1 处为 0。那么,经过一步后处于状态的概率为 0.4,处于状态的概率为 0.6。这是我们之前概述的概率所预期的结果。然而,我们也可以使用矩阵公式来写出这个计算:

要获取经过两步后处于任一状态的概率,我们再次将右侧向量与转移矩阵相乘,得到如下结果:

我们可以将这个过程继续进行无限次,以获得一系列状态向量,这些向量构成了我们的马尔可夫链。如果需要,可以通过增加更多的状态来应用这一构建方法,用以建模许多简单的现实世界问题。

使用贝叶斯技术分析转化率

贝叶斯概率允许我们通过考虑数据系统地更新我们对情况的理解(从概率的角度)。用更技术化的语言来说,我们使用数据更新先验分布(我们的当前理解),以获得后验分布。例如,在研究用户浏览网站后购买产品的比例时,这非常有用。我们从先验信念分布开始。为此,我们将使用beta分布,它根据观察到的成功次数(完成购买)与失败次数(未购买)来模拟成功的概率。在这个示例中,我们假设我们的先验信念是我们期望在 100 次浏览中有 25 次成功(75 次失败)。这意味着我们的先验信念遵循 beta(25, 75)分布。假设我们想计算真实成功率至少为 33%的概率。

我们的方法大致分为三个步骤。首先,我们需要理解我们对转化率的先验信念,我们已经决定它遵循 beta(25, 75)分布。通过对先验分布的概率密度函数从 0.33 到 1 进行数值积分,我们计算出转化率至少为 33%的概率。接下来的步骤是应用贝叶斯推理,通过新的信息更新我们的先验信念。然后,我们可以对后验(更新后的)信念进行相同的积分,查看在此新信息下转化率至少为 33%的概率。

在本示例中,我们将展示如何使用贝叶斯技术基于新信息更新我们假设网站的先验信念。

准备工作

和往常一样,我们需要导入 NumPy 和 Matplotlib 包,分别命名为npplt。我们还需要导入 SciPy 包,命名为sp

如何操作...

以下步骤展示了如何使用贝叶斯推理估算和更新转化率估算:

  1. 第一步是设置先验分布。为此,我们使用来自 SciPy stats 模块的beta分布对象,该对象提供了多种处理贝塔分布的方法。我们从stats模块导入beta分布对象,并将其命名为beta_dist,然后创建一个便捷函数,用于计算概率密度函数:

    from scipy.stats import beta as beta_dist
    
    beta_pdf = beta_dist.pdf
    
  2. 接下来,我们需要计算在先验信念分布下,成功率至少为 33%的概率。为此,我们使用 SciPy integrate模块中的quad例程,该例程执行函数的数值积分。我们使用它来对先前步骤中导入的贝塔分布的概率密度函数进行积分,结合我们的先验参数。我们根据先验分布将概率打印到控制台:

    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
    
  3. 现在,假设我们收到了关于新时间段内成功和失败的信息。例如,在这段时间内,我们观察到 122 次成功和 257 次失败。我们创建新的变量来反映这些值:

    observed_successes = 122
    
    observed_failures = 257
    
  4. 要获得使用贝塔分布的后验分布的参数值,我们只需将观察到的成功次数和失败次数分别加到prior_alphaprior_beta参数中:

    posterior_alpha = prior_alpha + observed_successes
    
    posterior_beta = prior_beta + observed_failures
    
  5. 现在,我们重复数值积分,以计算使用后验分布(使用我们先前计算的参数)成功率现在是否超过 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
    
  6. 我们可以看到,更新后的后验分布给出的新概率是 14%,而先验分布给出的概率是 4%。这是一个显著的差异,尽管根据这些值,我们仍然无法确信转化率超过 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)
    
  7. 最后,我们将第 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**.6所示,我们可以看到后验分布比先验分布更加狭窄,并且集中在先验分布的右侧:

图 4.6 – 成功率的先验和后验分布,遵循贝塔分布

图 4.6 – 成功率的先验和后验分布,遵循贝塔分布

我们可以看到,后验分布在约 0.3 处达到峰值,但分布的大部分质量都集中在这个峰值附近。

它是如何工作的……

贝叶斯技术通过采取一个先验信念(概率分布),然后利用贝叶斯定理将先验信念与在该先验信念下我们的数据的可能性结合起来,从而形成一个后验(更新后的)信念。这类似于我们在现实生活中的理解方式。例如,当你在某天早上醒来时,可能会有一个信念(来自天气预报或其他途径),认为外面有 40%的机会下雨。打开窗帘后,你看到外面非常多云,这可能表明雨水的可能性更大,因此我们根据这个新数据更新我们的信念,认为下雨的机会是 70%。

要理解这个过程,我们需要了解条件概率。条件概率处理的是给定另一个事件已经发生的情况下,一个事件发生的概率。用符号表示,事件 在事件 已经发生的条件下发生的概率可以写作:

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

概率 代表我们的先验信念。事件 代表我们已收集的数据,因此 是在我们的先验信念下,数据产生的可能性。概率 代表我们的数据产生的概率, 代表在给定数据后,我们的后验信念。实际上,概率 可能很难计算或估计,因此通常会将上面的强等式替换为贝叶斯定理的比例版本:

在这个例子中,我们假设我们的先验信念服从贝塔分布。贝塔分布的概率密度函数由以下公式给出:

这里, 是伽马函数。该似然性呈二项分布,其概率密度函数由以下公式给出:

这里, 是观察次数, 是成功的次数之一。在这个例子中,我们观察到 次成功和 次失败,这给出了 。为了计算后验分布,我们可以利用贝塔分布是二项分布的共轭先验这一事实,来观察贝叶斯定理的比例形式右侧是一个贝塔分布,参数为 。这就是我们在例子中使用的内容。贝塔分布作为二项随机变量的共轭先验,使其在贝叶斯统计中非常有用。

我们在这个例子中展示的方法是使用贝叶斯方法的一个相对基础的示例,但它仍然对我们在系统地获取新数据时更新先验信念非常有用。

还有更多…

贝叶斯方法可以用于各种任务,成为一个强大的工具。在这个例子中,我们使用贝叶斯方法根据我们对网站表现的先验信念和用户收集的额外数据来建模网站的成功率。这个例子比较复杂,因为我们将先验信念建模为贝塔分布。这里是另一个使用贝叶斯定理的例子,用简单的概率(介于 0 和 1 之间的数字)来检验两个竞争的假设。

假设你每天回家时都会把钥匙放在同一个地方,但有一天早晨你醒来发现钥匙不在这个地方。经过短暂的寻找后,你找不到它们,于是得出它们一定已经从这个世界消失的结论。我们称这个假设为。现在,无疑解释了数据,,即你找不到钥匙——因此,的可能性(如果你的钥匙消失了,那么你根本不可能找到它们)。另一个假设是你回家时把钥匙放到了别的地方。我们称这个假设为。现在,这个假设同样能解释数据,所以,但实际上,更为可信。假设你的钥匙完全消失的概率是百万分之一——这显然是个过高的估计,但我们需要保持数字的合理性——而你估计你把它们放到别处的概率是千分之一。计算后验概率,我们得到如下结果:

这突显了这样一个现实:你丢失钥匙的可能性比它们消失的可能性要大 10,000 倍。果不其然,你很快就发现钥匙已经在你的口袋里,因为你早晨曾把它放进过那里。

使用蒙特卡洛模拟估计参数

蒙特卡洛方法广泛描述了利用随机抽样来解决问题的技术。当问题涉及某种不确定性时,这些技术尤其强大。一般方法包括执行大量模拟,每次根据给定的概率分布抽取不同的输入,然后汇总结果,从而比任何单一的样本解提供更好的真实解近似。

MCMC(马尔可夫链蒙特卡洛)是一种特定类型的蒙特卡洛模拟,在这种方法中,我们构造一个马尔可夫链,逐步得到我们所寻求的真实分布的更好的近似。这个过程通过基于精心选择的接受概率,在每个阶段接受或拒绝一个随机采样的拟议状态,目的是构造一个其唯一平稳分布恰好是我们想要找到的未知分布的马尔可夫链。

在这个例子中,我们将使用 PyMC 包和 MCMC 方法来估计一个简单模型的参数。该包将处理运行模拟的大部分技术细节,因此我们无需进一步深入了解不同 MCMC 算法的实际工作原理。

准备工作

如常,我们导入 NumPy 包和 Matplotlib 的 pyplot 模块,分别命名为 npplt。我们还导入并创建一个默认的随机数生成器,使用种子来进行演示,代码如下:

from numpy.random import default_rng
rng = default_rng(12345)

我们还需要 SciPy 包中的一个模块,以及 PyMC 包,它是一个用于概率编程的包。我们将 PyMC 包导入,并命名为 pm

import pymc as pm

让我们来看一下如何使用 PyMC 包来估计给定观测到的噪声样本的模型参数。

如何做到...

执行以下步骤,使用 MCMC 模拟来估计简单模型的参数,基于样本数据:

  1. 我们的第一个任务是创建一个表示我们希望识别的基础结构的函数。在本例中,我们将估计一个二次方程(一个二次多项式)的系数。该函数接受两个参数,一个是固定的范围内的点,另一个是我们希望估计的变量参数:

    def underlying(x, params):
    
         return params[0]*x**2 + params[1]*x + params[2]
    
  2. 接下来,我们设置 true 参数和 size 参数,这将决定我们生成的样本中有多少个点:

    size = 100
    
    true_params = [2, -7, 6]
    
  3. 我们生成将用于估计参数的样本数据。这个样本由基础数据组成,基础数据是由我们在步骤 1中定义的 underlying 函数生成的,再加上一些符合正态分布的随机噪声。我们首先生成一个范围的 值,这些值在整个过程里保持不变,然后使用 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
    
  4. 在开始分析之前,最好先绘制采样数据,并将基础数据叠加在其上。我们使用 scatter 绘图方法仅绘制数据点(不连接线条),然后用虚线绘制基础的二次结构:

    fig1, ax1 = plt.subplots()
    
    ax1.scatter(x_vals, sample,
    
        label="Sampled data", color="k", 
    
        alpha=0.6)
    
    ax1.plot(x_vals, raw_model,
    
        "k--", label="Underlying model")
    
    ax1.set_title("Sampled data")
    
    ax1.set_xlabel("x")
    
    ax1.set_ylabel("y")
    

结果是 图 4.7,在图中可以看到,即使存在噪声,基础模型的形状仍然可见,尽管该模型的确切参数已不再明显:

图 4.7 – 采样数据与基础模型叠加

图 4.7 – 采样数据与基础模型叠加

  1. PyMC 编程的基本对象是 Model 类,通常使用上下文管理器接口来创建。我们还为参数创建了先验分布。在本例中,我们假设我们的先验参数服从均值为 1、标准差为 1 的正态分布。我们需要三个参数,因此提供了 shape 参数。Normal 类创建将用于蒙特卡洛模拟的随机变量:

    with pm.Model() as model:
    
        params = pm.Normal(
    
            "params", mu=1, sigma=1, shape=3)
    
  2. 我们为基础数据创建一个模型,可以通过将我们在步骤 6中创建的随机变量param传递给步骤 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)
    
  3. 为了运行模拟,我们只需要在Model上下文中调用sample例程。我们传递cores参数来加速计算,但保留所有其他参数为默认值:

        trace = pm.sample(cores=4)
    

这些模拟应该执行起来很快。

  1. 接下来,我们使用来自 PyMC 的plot_posterior例程绘制后验分布。该例程使用从采样步骤中获得的trace结果,我们已提前使用plt.subplots例程创建了自己的图形和坐标轴,但这不是严格必要的。我们在一个图形上使用了三个子图,并将axs2元组的Axes传递给绘图例程的ax关键字参数:

    fig2, axs2 = plt.subplots(1, 3, tight_layout=True)
    
    pm.plot_posterior(trace, ax=axs2, color="k")
    

结果的图示显示在图 4**.8中,您可以看到这些分布大致是正态分布,均值与真实参数值相似:

图 4.8 – 估计参数的后验分布

图 4.8 – 估计参数的后验分布

  1. 现在,从trace结果中提取每个估计参数的均值。我们通过访问trace上的后验属性来获取估计的参数,然后对params项目使用mean方法(使用axes=(0,1)在所有链和所有样本上进行平均),并将其转换为 NumPy 数组。我们在终端中打印这些估计的参数:

    estimated_params = trace.posterior["params"].mean(
    
        axis=(0, 1)). to_numpy()
    
    print("Estimated parameters", estimated_params)
    
    # Estimated parameters [ 2.03220667 -7.09727509  5.27548983]
    
  2. 最后,我们使用估计的参数生成我们的估计基础数据,通过将的值和估计的参数传递给步骤 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**.9中,在这个范围内,这两个模型之间只有一个小的差异:

图 4.9 – 真实模型和估计模型绘制在同一坐标轴上

图 4.9 – 真实模型和估计模型绘制在同一坐标轴上

图 4**.9中,我们可以看到真实模型和估计模型之间存在一个小的差异。

它是如何工作的……

这个配方中有趣的部分可以在Model上下文管理器中找到。这个对象跟踪随机变量,协调模拟并保持状态。上下文管理器为我们提供了一种方便的方式来将概率变量与周围的代码分离。

我们从为表示参数的随机变量(共有三个)的分布提出先验分布开始。我们提出了正态分布,因为我们知道这些参数不能偏离值 1 太远(例如,通过查看我们在步骤 4中生成的图形,我们可以看出这一点)。使用正态分布将给那些接近当前值的数值更高的概率。接下来,我们加入与观察数据相关的细节,这些数据用于计算接受概率,决定是接受还是拒绝某个状态。最后,我们使用sample例程启动采样器。这将构建马尔可夫链并生成所有的步长数据。

sample例程根据将要模拟的变量类型设置采样器。由于正态分布是一个连续变量,sample例程选择了无转弯采样器NUTS)。这是一种适用于连续变量的通用采样器。NUTS 的常见替代方法是 Metropolis 采样器,后者虽然在某些情况下比 NUTS 速度更快,但可靠性较差。PyMC 文档建议尽可能使用 NUTS。

一旦采样完成,我们绘制了马尔可夫链给出的状态(即后验分布)的图形,以查看我们生成的近似值的最终形态。我们可以看到,所有三个随机变量(参数)都呈现出大致正确值附近的正态分布。

在背后,PyMC 使用 Aesara——PyMC3 中 Theano 的继任者——来加速计算。这使得 PyMC 可以在图形处理单元GPU)上进行计算,而不是在中央处理单元CPU)上,从而大大提升计算速度。

还有更多内容...

蒙特卡罗方法非常灵活,我们这里给出的例子是它可以应用的一种特定情况。蒙特卡罗方法的一个更典型的基本应用例子是估算积分的值——通常被称为蒙特卡罗积分。一个非常有趣的蒙特卡罗积分应用是估算!的值。让我们简要看一下这是如何实现的。

首先,我们取一个单位圆,其半径为 1,因此面积为 。我们可以将这个圆形放置在一个边长为 2 的正方形内部,其顶点分别位于 。该正方形的面积为 4,因为边长为 2。现在,我们可以在这个正方形上均匀生成随机点。当我们这样做时,任何一个随机点位于给定区域内的概率与该区域的面积成正比。因此,可以通过将位于该区域内的随机生成点的比例乘以正方形的总面积来估算区域的面积。特别地,我们可以通过将位于圆内的随机生成点的数量乘以 4,并除以我们生成的点的总数,来估算该圆的面积(当半径为 1 时,这个面积是 )。

我们可以轻松地用 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 次,并计算结果的平均值(我们将使用并发未来(concurrent futures)来并行化这个过程,这样如果需要,我们可以运行更大数量的样本):

from statistics import mean
results = list(estimate_pi() for _ in  range(100))
print(mean(results))

运行一次此代码会打印出估计值 ,其值为 3.1415752,这是对真实值的一个更精确估计。

另见

PyMC 包含许多功能,这些功能通过大量示例进行了文档说明(docs.pymc.io/)。还有另一个基于 TensorFlow 的概率编程库(www.tensorflow.org/probability)。

进一步阅读

一本关于概率和随机过程的好书是:

  • Grimmett, G. 和 Stirzaker, D. (2009). Probability and random processes. 第 3 版. 牛津:牛津 大学出版社。

贝叶斯定理和贝叶斯统计的简单介绍如下:

  • Kurt, W. (2019). Bayesian statistics the fun way. San Francisco, CA: No Starch Press, Inc.

第五章:处理树与网络

网络是包含 节点 和节点对之间 的对象。它们可以用来表示各种现实世界的情况,如分配和调度。数学上,网络对于可视化组合问题非常有用,并且它有着丰富且迷人的理论。

当然,网络有多种类型。我们将主要处理简单网络,其中边连接两个不同的节点(因此没有自环),每两个节点之间最多只有一条边,并且所有边都是双向的。 是一种特殊的网络,其中没有环;也就是说,没有节点列表,每个节点都通过边连接到下一个节点,最后一个节点又连接到第一个节点。树在理论上特别简单,因为它用最少的边连接多个节点。完全网络 是一种每个节点都通过边与其他每个节点相连的网络。

网络可以是有向的,其中每条边都有一个源节点和一个目标节点,或者可以承载额外的属性,例如权重。加权网络在某些应用中尤其有用。还有一些网络允许在两个给定节点之间有多条边。

在本章中,我们将学习如何创建、操作和分析网络,并应用网络算法解决各种问题。

注意

在文献中,特别是在数学文本中,网络更常被称为 。节点有时被称为 顶点。我们更倾向于使用“网络”这一术语,以避免与更常见的将图表示为函数图像的用法混淆。

本章将涵盖以下内容:

  • 在 Python 中创建网络

  • 可视化网络

  • 获取网络的基本特征

  • 生成网络的邻接矩阵

  • 创建有向和加权网络

  • 查找网络中的最短路径

  • 定量化网络中的聚类

  • 给网络上色

  • 查找最小生成树和支配集

让我们开始吧!

技术要求

在本章中,我们将主要使用 NetworkX 包来处理树和网络。您可以通过您喜欢的包管理器(例如 pip)来安装此包:

python3.10 -m pip install networkx

我们通常使用 nx 别名导入此模块,遵循官方 NetworkX 文档中建立的惯例(networkx.org/documentation/stable/),使用以下 import 语句:

import networkx as nx

本章的代码可以在本书的 GitHub 仓库中的 Chapter 05 文件夹找到,链接为 github.com/PacktPublishing/Applying-Math-with-Python-2nd-Edition/tree/main/Chapter%2005

在 Python 中创建网络

为了解决可以表示为网络问题的各种问题,我们需要一种在 Python 中创建网络的方式。为此,我们将利用 NetworkX 包及其提供的例程和类来创建、操作和分析网络。

在本配方中,我们将创建一个表示网络的 Python 对象,并向该对象添加节点和边。

准备工作

正如我们在技术要求部分提到的,我们需要将 NetworkX 包以nx别名导入。我们可以使用以下import语句来完成:

import networkx as nx

如何实现...

按照以下步骤创建一个简单图的 Python 表示:

  1. 我们需要创建一个新的Graph对象,用于存储构成图的节点和边:

    G = nx.Graph()
    
  2. 接下来,我们需要使用add_node方法为网络添加节点:

    G.add_node(1)
    
    G.add_node(2)
    
  3. 为了避免重复调用该方法,我们可以使用add_nodes_from方法从可迭代对象(如列表)中添加节点:

    G.add_nodes_from([3, 4, 5, 6])
    
  4. 接下来,我们需要使用add_edgeadd_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)])
    
  5. 最后,我们必须通过访问nodesedges属性,分别获取图中当前节点和边的视图:

    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对象,我们就可以使用本配方中描述的方法添加新的节点和边。在本配方中,我们创建了包含整数值的节点。然而,节点可以包含任何可哈希的 Python 对象,除了None。此外,还可以通过传递关键字参数给add_node方法来为节点添加关联数据。在使用add_nodes_from方法时,可以通过提供包含节点对象和属性字典的元组列表来添加属性。add_nodes_from方法对于批量添加节点非常有用,而add_node方法则适用于将单个节点附加到现有网络中。

网络中的一条边是一个包含两个(不同)节点的元组。在一个简单的网络中,例如由基本的Graph类表示的网络,任何两个给定节点之间最多只能有一条边。这些边是通过add_edgeadd_edges_from方法添加的,分别是添加单个边或添加一组边。与节点类似,边也可以通过属性字典来保存任意的关联数据。特别是,当添加边时,可以通过提供weight属性来添加权重。我们将在创建有向和加权 网络的配方中提供有关加权图的更多细节。

nodesedges 属性分别包含构成网络的节点和边。nodes 属性返回一个 NodesView 对象,这是一个类似字典的接口,提供节点及其关联数据的访问。同样,edges 属性返回一个 EdgeView 对象。我们可以使用这个对象检查个别边及其相关数据。

还有更多...

Graph 类表示 简单网络,它是节点最多由一条边连接且边不具有方向的网络。我们将在 创建有向和加权网络 食谱中讨论有向网络。还有一个单独的类用于表示可以在一对节点之间有多条边的网络,称为 MultiGraph。所有网络类型都允许自环,这在文献中的 简单网络 中有时是不允许的,简单网络通常指的是没有自环的无向网络。

所有网络类型都提供了多种方法来添加节点和边,并检查当前的节点和边。还有方法可以将网络复制到其他类型的网络中,或提取子网络。此外,NetworkX 包还提供了多个实用例程,用于生成标准网络并将子网络添加到现有网络中。

NetworkX 还提供了多种例程,用于将网络读写到不同的文件格式中,例如 GraphML、JSON 和 YAML。例如,我们可以使用 nx.write_graphml 例程将网络写入 GraphML 文件,并使用 nx.read_graphml 例程读取它。

可视化网络

分析网络的常见第一步是绘制网络,这有助于我们识别网络的一些显著特征。(当然,图示可能会产生误导,因此我们不应过于依赖它们进行分析。)

在这个食谱中,我们将描述如何使用 NetworkX 包中的网络绘制功能来可视化网络。

准备工作

对于这个食谱,我们需要按 技术要求 部分的描述,导入 NetworkX 包并使用 nx 别名。我们还需要 Matplotlib 包。为此,我们通常需要使用以下 import 语句导入 pyplot 模块,并将其命名为 plt

import matplotlib.pyplot as plt

如何操作...

以下步骤概述了如何使用 NetworkX 的绘图例程绘制一个简单的网络对象:

  1. 首先,我们将创建一个简单的示例网络进行绘制:

    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)
    
    ])
    
  2. 接下来,我们将为它创建新的 Matplotlib FigureAxes 对象,准备好使用 plt 中的 subplots 例程来绘制网络:

    fig, ax = plt.subplots()
    
  3. 现在,我们可以创建一个布局,用于在图形中定位节点。对于这个图形,我们将使用 shell_layout 例程来创建一个壳布局:

    layout = nx.shell_layout(G)
    
  4. 我们可以使用draw函数将网络绘制到图形上。由于我们已经创建了一个 Matplotlib FigureAxes,我们可以提供ax关键字参数。我们还将使用with_labels关键字参数为节点添加标签,并通过pos参数指定我们刚刚创建的布局:

    nx.draw(G, ax=ax, pos=layout, with_labels=True)
    
    ax.set_title("Simple network drawing")
    

结果图像可以在以下图中看到:

图 5.1 – 使用 shell 布局排列的简单网络图

图 5.1 – 使用 shell 布局排列的简单网络图

由于本示例中的节点数量相对较少,它们被安排在一个圆圈内。边缘通过线条表示。

工作原理...

draw函数是一个专门的绘图函数,专门用于绘制网络。我们创建的布局指定了每个节点将被放置的坐标。我们使用了shell 布局,它将节点按同心圆的方式排列(此示例中仅使用了一个圆),该布局由网络的节点和边缘决定。默认情况下,draw函数会创建一个随机布局。

draw函数有多个关键字参数,用于定制绘制网络的外观。在本示例中,我们添加了with_labels关键字参数,根据节点持有的对象为节点添加标签。节点持有整数值,这就是为什么前面图中的节点按整数标注的原因。

我们还使用plt.subplots函数单独创建了一组坐标轴。严格来说,这不是必须的,因为draw函数会在没有提供坐标轴的情况下自动创建一个新的图形和坐标轴。

还有更多...

NetworkX 包提供了几个布局生成函数,类似于我们在本示例中使用的shell_layout函数。此布局实际上是一个字典,以节点为索引,其元素是节点应该绘制的位置的 x 和 y 坐标。NetworkX 的布局生成函数表示了适用于大多数情况的常见排列,但如果需要,您也可以创建自定义布局。NetworkX 文档中提供了不同布局创建函数的完整列表。此外,还有快捷的绘图函数,它们会使用特定的布局,而无需单独创建布局;例如,draw_shell函数会绘制使用 shell 布局的网络,这等同于本示例中draw函数的调用。

draw函数接受多个关键字参数来定制图形的外观。例如,您可以使用关键字参数控制节点的大小、颜色、形状和透明度。我们还可以添加箭头(用于有向边)和/或仅绘制网络中的特定节点和边。

获取网络的基本特征

网络除了节点和边的数量之外,还有许多有助于分析图的基本特性。例如,节点的度数是指从该节点出发(或到达)的边的数量。度数越高,表示该节点与网络的其他部分连接越紧密。

在本教程中,我们将学习如何访问网络的基本属性,并计算与网络相关的各种基本度量。

准备工作

和往常一样,我们需要导入 nx 别名下的 NetworkX 包。我们还需要导入 Matplotlib 的 pyplot 模块,别名为 plt

如何操作...

按照以下步骤访问网络的各种基本特征:

  1. 创建我们将在本教程中分析的示例网络,代码如下:

    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)
    
    ])
    
  2. 接下来,最好绘制网络并将节点排列成圆形布局:

    fig, ax = plt.subplots()
    
    nx.draw_circular(G, ax=ax, with_labels=True)
    
    ax.set_title("Simple network")
    

结果图可以在下图中看到。正如我们所见,网络被分成了两个不同的部分:

图 5.2 – 一个简单的网络,采用圆形排列,包含两个不同的组件

图 5.2 – 一个简单的网络,采用圆形排列,包含两个不同的组件

  1. 接下来,我们必须打印Graph对象以显示网络的基本信息:

    print(G)
    
    # Name:
    
    # Type: Graph
    
    # Number of nodes: 10
    
    # Number of edges: 12
    
    # Average degree: 2.4000
    
  2. 现在,我们可以使用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
    
  3. 我们可以使用 connected_components 例程获取网络的连通组件,该例程返回一个生成器,我们将其转换为列表:

    components = list(nx.connected_components(G))
    
    print(components)
    
    # [{0, 1, 2, 3, 4, 5}, {8, 9, 6, 7}]
    
  4. 我们可以使用密度例程计算网络的密度,该例程返回一个介于 0 和 1 之间的浮动值。它表示连接到该节点的边与该节点所有可能的边之间的比例:

    density = nx.density(G)
    
    print("Density", density)
    
    # Density 0.26666666666666666
    
  5. 最后,我们可以使用 check_planarity 例程来确定网络是否是平面的——即没有两条边需要交叉——:

    is_planar, _ = nx.check_planarity(G)
    
    print("Is planar", is_planar)
    
    # Is planar True
    

回头看看图 5.2,我们可以看到,确实可以在不交叉任何两条边的情况下绘制这个图。

它是如何工作的...

info 例程生成网络的小结,包括网络的类型(在本教程中是简单的 Graph 类型)、节点和边的数量以及网络中节点的平均度数。可以使用 degree 属性访问网络中节点的实际度数,它提供了一种类似字典的接口来查找每个节点的度数。

一组节点如果该组中的每个节点都通过一条边或一系列边与其他节点相连,则称这组节点为连通的。网络的连通分量是最大的一组相互连通的节点。任何两个不同的连通分量是相互不相交的。每个网络都可以分解为一个或多个连通分量。我们在这个示例中定义的网络有两个连通分量,分别是{0, 1, 2, 3, 4, 5}{8, 9, 6, 7}。这些连通分量在前面的图中可见,其中第一个连通分量位于第二个连通分量的上方。在这张图中,我们可以沿着网络的边从组件中的任何节点到达任何其他节点;例如,从 0 到 5。

网络的密度衡量的是网络中边的数量与网络中节点数量所能形成的边的总数之间的比例。完全网络的密度为 1,但通常情况下,密度会小于 1。

如果一个网络可以在平面上绘制而没有交叉边,则称该网络为平面网络。五节点的完全网络是最简单的非平面网络。最多包含四个节点的完全网络是平面网络。通过一些尝试绘制这些网络,你将发现一个没有交叉边的绘图。此外,任何包含至少五个节点的完全图的网络都不是平面网络。平面网络在理论中很重要,因为它们相对简单,但在实际应用中不太常见。

还有更多内容...

除了网络类中的方法外,NetworkX 包中还有其他多个常用函数,可以用来访问网络中节点和边的属性。例如,nx.get_node_attributes可以获取网络中每个节点的指定属性。

为网络生成邻接矩阵

分析图的一个强大工具是邻接矩阵,如果存在从节点 到节点 的边,则矩阵中的值为 ,否则为 0。对于大多数网络,邻接矩阵通常是稀疏的(大多数值为 0)。对于无向网络,邻接矩阵还会是对称的()。可以与网络关联的其他矩阵也有很多。我们将在这个示例的还有更多内容...部分简要讨论这些矩阵。

在这个示例中,我们将生成一个网络的邻接矩阵,并学习如何从这个矩阵中获取网络的一些基本属性。

准备工作

对于这个示例,我们将需要导入 NetworkX 包,并使用nx别名,同时导入 NumPy 模块,使用np别名。

如何实现...

以下步骤概述了如何为网络生成邻接矩阵,并从该矩阵推导出网络的一些简单属性:

  1. 首先,我们将生成一个网络,在整个示例中使用。我们将生成一个具有五个节点和五条边的随机网络,并使用种子确保可重复性:

    G = nx.dense_gnm_random_graph(5, 5, seed=12345)
    
  2. 为了生成邻接矩阵,我们可以使用 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]]
    
  3. 对邻接矩阵进行 次幂运算,可以得到从一个节点到另一个节点的路径数量,路径长度为

    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]]
    

步骤 2 中的邻接矩阵和步骤 3 中的四次幂都是对称矩阵。此外,注意到 paths_len_4 中非零条目的位置与邻接矩阵中的零位置相对应。这是因为存在两个不同的节点组,而奇数长度的路径在这两个组之间交换,偶数长度的路径则返回到起始组。

它是如何工作的……

dense_gnm_random_graph 函数生成一个(密集)随机网络,网络从所有具有 个节点和 条边的网络族中均匀选择。在这个示例中,dense 前缀表示该函数使用的算法应比替代的 gnm_random_graph 在边与节点比值较大的密集网络中速度更快。

网络的邻接矩阵很容易生成,特别是在图较小时,使用稀疏形式尤为方便。对于较大的网络,这可能是一个昂贵的操作,因此可能不太实际,特别是当你像本示例中那样将其转换为完整矩阵时。通常你不需要这样做,因为我们可以简单地使用 adjacency_matrix 函数生成的稀疏矩阵,并使用 SciPy sparse 模块中的稀疏线性代数工具。

矩阵的幂次提供了关于给定长度路径数量的信息。通过矩阵乘法的定义,可以很容易地看出这一点。记住,当两个节点之间存在边(路径长度为 1)时,邻接矩阵中的条目为 1。

还有更多……

网络的邻接矩阵的特征值提供了关于网络结构的额外信息,比如网络的着色数界限。(有关着色网络的更多信息,请参见 着色网络 这一部分。)有一个单独的函数用于计算邻接矩阵的特征值。例如,adjacency_spectrum 函数可以生成网络邻接矩阵的特征值。涉及到与网络相关的矩阵特征值的方法通常被称为 谱方法

网络中还有其他矩阵,如关联矩阵拉普拉斯矩阵。网络的关联矩阵是一个矩阵,其中是节点数,是边的数量。如果节点出现在边中,则该矩阵的项为 1,否则为 0。网络的拉普拉斯矩阵定义为矩阵,其中是包含网络中节点度数的对角矩阵,是网络的邻接矩阵。这两种矩阵对于网络分析非常有用。

创建有向加权网络

像之前食谱中描述的简单网络,对于描述边的方向不重要且边权相等的网络非常有用。实际上,大多数网络携带额外的信息,如权重或方向。

在本食谱中,我们将创建一个有向加权网络,并探索此类网络的一些基本属性。

准备工作

对于本食谱,我们需要使用 NetworkX 包,通过别名nx导入(如同往常一样),Matplotlib 的pyplot模块以plt导入,以及 NumPy 包以np导入。

如何操作…

以下步骤概述了如何创建一个带权重的有向网络,以及如何探索我们在之前的食谱中讨论的一些属性和技术:

  1. 要创建一个有向网络,我们可以使用 NetworkX 中的DiGraph类,而不是简单的Graph类:

    G = nx.DiGraph()
    
  2. 如同往常一样,我们必须使用add_nodeadd_nodes_from方法向网络中添加节点:

    G.add_nodes_from(range(5))
    
  3. 要添加带权重的边,我们可以使用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)
    
    ])
    
  4. 接下来,我们必须绘制网络,并用箭头表示每条边的方向。我们还必须为此图提供位置:

    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 – 一个带权有向网络

图 5.3 – 一个带权有向网络

  1. 有向矩阵的邻接矩阵与简单网络创建方式相同,但结果矩阵将不再是对称的:

    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

如何操作...

按照以下步骤在网络中找到两个节点之间的最短路径:

  1. 首先,我们将使用gnm_random_graph和一个seed来创建一个随机网络,作为本演示的基础:

    G = nx.gnm_random_graph(10, 17, seed=12345)
    
  2. 接下来,我们将使用圆形布局绘制网络,以便查看节点之间的连接方式:

    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 条边

图 5.4 – 一个随机生成的网络,包含 10 个节点和 17 条边。

  1. 现在,我们需要给每条边添加一个权重,以便在最短路径方面有些路线比其他路线更具优势:

    for u, v in G.edges:
    
        G.edges[u, v]["weight"] = rng.integers(5, 15)
    
  2. 接下来,我们将使用nx.shortest_path例程计算从节点 7 到节点 9 的最短路径:

    path = nx.shortest_path(G, 7, 9, weight="weight")
    
    print(path)
    
    # [7, 5, 2, 9]
    
  3. 我们可以使用nx.shortest_path_来找到这条最短路径的长度。

  4. length例程:

    length = nx.shortest_path_length(G, 7, 9,
    
        weight="weight")
    
    print("Length", length)
    
    # Length 32
    

这里路径的长度是沿最短路径遍历的边的权重总和。如果网络没有权重,那么它将等于沿路径遍历的边的数量。

它是如何工作的……

shortest_path例程计算每一对节点之间的最短路径。或者,当提供源节点和目标节点时(这就是我们在本示例中所做的),它计算两个指定节点之间的最短路径。我们提供了可选的weight关键字参数,这使得算法根据边的权重属性来寻找最短路径。这个参数改变了最短的定义,默认情况下是最少边数

查找两个节点之间最短路径的默认算法是 Dijkstra 算法,它是计算机科学和数学课程中的基础算法。它是一个通用的算法,但效率并不是特别高。其他的路线寻找算法包括 A算法。通过使用 A算法并加入额外的启发式信息来指导节点选择,可以提高效率。

还有更多……

有许多算法可以用来寻找网络中两个节点之间的最短路径。也有一些变体可以寻找最大权重路径。

关于网络中路径查找,有几个相关的问题,比如旅行推销员问题路径检查问题。在旅行推销员问题中,我们需要找到一个环路(一个从同一节点出发并返回的路径),它遍历网络中的每个节点,且总权重最小(或最大)。在路径检查问题中,我们寻找遍历网络中每条边并返回起点的最短环路(按权重计算)。旅行推销员问题是已知的 NP 难题,但路径检查问题可以在多项式时间内解决。

图论中的一个著名问题是哥尼斯堡的桥梁问题,它要求在网络中找到一条路径,使得每条边恰好经过一次。正如欧拉所证明的那样,哥尼斯堡桥问题中找到这样一条路径是不可能的。这样一条恰好经过每条边一次的路径被称为 欧拉回路。一个网络如果包含欧拉回路,则称为 欧拉网络。只有当每个节点的度数都是偶数时,网络才是欧拉网络。哥尼斯堡桥问题的网络表示可以在下图中看到。图中的边代表河流上的不同桥梁,而节点代表不同的陆地块。我们可以看到,所有四个节点的度数都是奇数,这意味着不存在一条路径能恰好经过每一条边一次:

图 5.5 – 代表哥尼斯堡桥问题的网络

图 5.5 – 代表哥尼斯堡桥问题的网络

这些边代表连接不同陆地块的桥梁,这些陆地块由节点表示。

网络中的聚类度量

网络中有各种量度用于衡量网络的特征。例如,节点的聚类系数衡量了附近节点之间的互联性(此处,附近指的是通过边连接的节点)。实际上,它衡量了相邻节点形成一个完整网络或 团体 的接近程度。

节点的聚类系数衡量了邻近节点通过边连接的比例;也就是说,两个相邻节点与给定节点形成一个三角形。我们计算三角形的数量,并将其除以根据节点的度数可以形成的三角形的总数。从数值上讲,在一个简单的无权网络中,节点的聚类系数 由以下公式给出:

这里, 是在 处的三角形数量,分母是 处可能形成的三角形总数。如果 的度数(即从 出发的边数)为 0 或 1,则我们将 设为 0。

在这个示例中,我们将学习如何计算网络中节点的聚类系数。

准备工作

对于这个示例,我们将需要导入 nx 别名下的 NetworkX 包和作为 plt 导入的 Matplotlib pyplot 模块。

如何进行...

以下步骤展示了如何计算网络中节点的聚类系数:

  1. 首先,我们需要创建一个样本网络来进行操作:

    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)])
    
  2. 接下来,我们必须绘制网络,以便我们能够比较我们将要计算的聚类系数。这样,我们可以看到这些节点在网络中的表现:

    fig, ax = plt.subplots()
    
    nx.draw_circular(G, ax=ax, with_labels=True)
    
    ax.set_title("Network with different clustering behavior")
    

结果图表可以在下图中看到:

图 5.6 – 用于测试聚类的样本网络

图 5.6 – 测试聚类的示例网络

  1. 现在,我们可以使用 nx.clustering 例程计算网络中节点的聚类系数:

    cluster_coeffs = nx.clustering(G)
    
  2. 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
    
  3. 所有网络节点的平均聚类系数可以通过 nx.average_clustering 例程计算:

    av_clustering = nx.average_clustering(G)
    
    print(av_clustering)
    
    # 0.3333333333333333
    

这个平均聚类系数表明,平均而言,节点大约拥有 1/3 的所有可能连接。

它是如何工作的...

节点的聚类系数衡量的是该节点的邻域有多接近于一个完整的网络(所有节点都相互连接)。在这个例子中,我们计算了三个不同的值:节点 0 的聚类系数为 0.5,节点 2 的聚类系数为 1.0,节点 6 的聚类系数为 0。这意味着与节点 2 相连的节点构成了一个完整的网络,这是因为我们设计网络时就是这样设计的。(节点 0 到 4 按设计形成了一个完整的网络。)节点 6 的邻域离完整网络非常远,因为它的两个邻居之间没有任何相互连接的边。

平均聚类值是对网络中所有节点的聚类系数的简单平均。它与全局聚类系数(通过 NetworkX 中的 nx.transitivity 例程计算)略有不同,但它确实能给我们一个网络整体接近完整网络的程度的概念。全局聚类系数衡量的是三角形的数量与三元组的数量之间的比率——三元组是由至少两条边连接的三个节点组成的集合——覆盖整个网络。

全局聚类和平均聚类之间的差异相当微妙。全局聚类系数衡量的是整个网络的聚类程度,而平均聚类系数衡量的是网络在局部的聚类程度。这个差异在风车型网络中最为明显,该网络由一个中心节点和一个偶数个节点围成的圆圈组成。所有节点都连接到中心,但圆圈上的节点只按交替的模式连接。外部节点的局部聚类系数为 1,而中心节点的局部聚类系数为 ,其中 表示连接中心节点的三角形数量。然而,全局聚类系数是

还有更多...

聚类系数与网络中的 团体(cliques)有关。团体是一个子网络,其中所有节点都通过边连接。网络理论中的一个重要问题是寻找网络中的最大团体,这通常是一个非常困难的问题(这里,最大意味着 不能更大)。

给网络着色

网络在调度问题中也非常有用,在这些问题中,你需要将活动安排到不同的时间段,以避免冲突。例如,我们可以使用网络来安排课程,以确保选择不同课程的学生不会同时上两门课。在这种情况下,节点将表示不同的课程,边将表示学生同时选修两门课程。我们用来解决这类问题的过程叫做网络着色。这个过程涉及为网络中的节点分配最少的颜色,以确保没有两个相邻节点有相同的颜色。

在这个配方中,我们将学习如何着色一个网络以解决简单的调度问题。

准备工作

对于这个配方,我们需要导入 NetworkX 包并使用nx别名,同时导入 Matplotlib 的pyplot模块并命名为plt

如何操作...

按照以下步骤来解决网络着色问题:

  1. 首先,我们将创建一个示例网络,以便在这个配方中使用:

    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) ])
    
  2. 接下来,我们将绘制网络,以便在生成着色时理解它。为此,我们将使用draw_circular例程:

    fig, ax = plt.subplots()
    
    nx.draw_circular(G, ax=ax, with_labels=True)
    
    ax.set_title("Scheduling network")
    

结果图可以在下图中看到:

图 5.7 – 简单调度问题的示例网络

](https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/app-math-py-2e/img/5.7.jpg)

图 5.7 – 简单调度问题的示例网络

  1. 我们将使用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}
    
  2. 为了查看实际使用的颜色,我们将从coloring字典中生成一组值:

    different_colors = set(coloring.values())
    
    print("Different colors", different_colors)
    
    # Different colors {0, 1, 2, 3}
    

请注意,着色中的颜色数量不能更少,因为节点 0、1、2 和 6 构成了一个完全网络——这些节点彼此相连,因此每个节点都需要一个单独的颜色。

它是如何工作的...

nx.greedy_color例程使用几种可能的策略之一来着色网络。默认情况下,它按度数从大到小的顺序工作。在我们的例子中,它首先将颜色 0 分配给度数为 6 的节点 2,然后将颜色 1 分配给度数为 4 的节点 0,以此类推。在这个序列中,为每个节点选择第一个可用的颜色。这不一定是最有效的着色算法。

任何网络都可以通过为每个节点分配不同的颜色来着色,但在大多数情况下,只需要更少的颜色。在这个配方中,网络有七个节点,但只需要四种颜色。所需的最小颜色数量称为网络的色度数

我们在这里描述的问题是节点着色问题。还有一个相关的问题叫做边着色。我们可以通过考虑一个网络,其中的节点是原始网络的边,并且当两个节点之间存在共同的原始网络节点时,在这两个节点之间添加一条边,从而将边着色问题转化为节点着色问题。

还有更多...

网络着色问题有多种变体。一个这样的变体是列表着色问题,其中我们为网络中的每个节点从预定义的颜色列表中选择一种颜色进行着色。这个问题比一般的着色问题更为复杂。

一般的着色问题有一些令人惊讶的结果。例如,每个平面网络最多可以用四种不同的颜色进行着色。这是图论中的一个著名定理,称为四色定理,由 Appel 和 Haken 在 1977 年证明。这个定理表明,每个平面图的色数不超过 4。

查找最小生成树和支配集

网络在许多问题中都有应用。通信和分配是两个显而易见的应用领域。例如,我们可能希望找到一种将商品分配到多个城市(节点)的方法,前提是它能覆盖从某个特定点出发的最小距离。对于此类问题,我们需要研究最小生成树和支配集。

在本食谱中,我们将找到网络中的最小生成树和支配集。

准备工作

对于本食谱,我们需要导入nx别名下的 NetworkX 包和 Matplotlib 的pyplot模块,别名为plt

如何操作...

按照以下步骤为网络查找最小生成树和支配集:

  1. 首先,我们将创建一个示例网络来进行分析:

    G = nx.gnm_random_graph(15, 22, seed=12345)
    
  2. 接下来,和往常一样,我们将在进行任何分析之前先绘制网络:

    fig, ax = plt.subplots()
    
    pos = nx.circular_layout(G)
    
    nx.draw(G, pos=pos, ax=ax, with_labels=True, style="--")
    
    ax.set_title("Network with minimum spanning tree overlaid")
    
  3. 最小生成树可以使用nx.minimum_``spanning_tree例程来计算:

    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)]
    
  4. 接下来,我们将把最小生成树的边叠加到图上:

    nx.draw_networkx_edges(min_span_tree, pos=pos,
    
                           ax=ax,width=2.)
    
  5. 最后,我们将使用nx.dominating_set例程为网络找到一个支配集——一个网络中每个节点都与集合中的至少一个节点相邻的集合:

    dominating_set = nx.dominating_set(G)
    
    print("Dominating set", dominating_set)
    
    # Dominating set {0, 1, 2, 4, 10, 14}
    

可以在以下图中看到叠加了最小生成树的网络图:

图 5.8 – 显示叠加最小生成树的网络

图 5.8 – 显示叠加最小生成树的网络

最小生成树中使用的边是粗体的未断开的线,而原始网络中的边是虚线。尽管最小生成树实际上是一个树,但由于布局的原因这一点稍显模糊,但我们可以轻松地追踪并看到没有任何两个节点连接到同一个父节点的情况。

它是如何工作的...

网络的生成树是包含网络中所有节点的树。最小生成树是包含最少边的生成树——或具有最低总权重的生成树。最小生成树在网络分发问题中非常有用。找到最小生成树的一个简单算法是选择边(如果网络是加权的,则优先选择最小权重的边),以避免形成环路,直到无法再避免为止。

网络的支配集是一个顶点集合,其中网络中的每个节点都与支配集中的至少一个节点相邻。支配集在通信网络中有应用。我们通常希望找到最小的支配集,但这在计算上是困难的。测试是否存在一个比给定大小更小的支配集是 NP 完全问题。然而,对于某些类型的图,存在一些高效的算法来找到最小的支配集。非正式地说,问题在于,一旦你找到了一个最小大小支配集的候选集,你还必须验证是否没有比它更小的支配集。如果你事先不知道所有可能的支配集,这会变得非常困难。

进一步阅读

关于图论,有几本经典的著作,包括 Bollobás 和 Diestel 的书:

  • Diestel, R., 2010. 图论。第 3 版。柏林:Springer。

  • Bollobás, B., 2010. 现代图论。纽约,NY:Springer。

第六章:数据和统计学的应用

对于需要分析数据的人来说,Python 最吸引人的特点之一是其庞大的数据处理和分析包生态系统,以及活跃的 Python 数据科学社区。Python 易于使用,同时提供了非常强大、快速的库,使得即使是相对初学的程序员也能够快速、轻松地处理大量数据。许多数据科学包和工具的核心是 pandas 库。pandas 提供了两种基于 NumPy 数组的数据容器类型,并且对标签(不仅仅是简单的整数)有很好的支持。这些数据容器使得处理大型数据集变得非常简单。

数据和统计学是现代世界中无处不在的一部分。Python 在试图理解每天产生的大量数据中处于领先地位,通常,这一切都从 pandas 开始——Python 用于处理数据的基础库。首先,我们将了解一些使用 pandas 处理数据的基本技巧。然后,我们将讨论统计学的基础知识,这将为我们提供通过观察一个小样本来理解整个群体的系统方法。

本章中,我们将学习如何利用 Python 和 pandas 处理大规模数据集并进行统计检验。

本章包括以下内容:

  • 创建 Series 和 DataFrame 对象

  • 从 DataFrame 加载和存储数据

  • 在 DataFrame 中操作数据

  • 从 DataFrame 中绘制数据

  • 从 DataFrame 中获取描述性统计数据

  • 使用抽样理解总体

  • 在 DataFrame 中对分组数据进行操作

  • 使用 t 检验测试假设

  • 使用 ANOVA 检验假设

  • 对非参数数据进行假设检验

  • 使用 Bokeh 创建交互式图表

什么是统计学?

统计学是通过数学——特别是概率——理论来系统地研究数据。统计学有两个主要方面。第一个方面是数据总结。在这里,我们找到描述一组数据的数值,包括数据的中心(均值或中位数)和分布(标准差或方差)等特征。这些值被称为描述性统计。我们在做的事情是拟合一个概率分布,用来描述某一特征在总体中出现的可能性。在这里,总体指的是某一特征的所有测量值的完整集合——例如,地球上所有活着的人的身高。

统计学的第二个——也是可以说是更重要的——方面是推断。在这里,我们通过计算该总体相对较小样本的数值来估计描述总体数据的分布。我们不仅尝试估计总体的分布,还试图量化我们的近似值有多准确。通常这以置信区间的形式呈现。置信区间是一个值的范围,我们可以有信心地认为真值位于该范围内,基于我们观察到的数据。我们通常会为估计值提供 95%或 99%的置信区间。

推断还包括测试两个或多个样本数据集是否来自同一总体。这就是假设检验的领域。在这里,我们比较两个数据集的可能分布,以确定它们是否可能是相同的。许多假设检验要求数据符合正态分布,或者更可能的是我们可以应用中心极限定理。这些检验有时被描述为参数检验,包括 t 检验和方差分析(ANOVA)。然而,如果你的数据不符合足够的正态性,以至于无法应用中心极限定理,那么一些检验不需要假设正态性。这些检验被称为非参数检验。

技术要求

对于本章,我们将主要使用 pandas 库进行数据处理,它提供了类似 R 的数据结构,例如用于存储、组织和操作数据的SeriesDataFrame对象。在本章的最后一节食谱中,我们还将使用 Bokeh 数据可视化库。这些库可以通过你喜欢的包管理器安装,例如pip

python3.10 -m pip install pandas bokeh

我们还将使用 NumPy 和 SciPy 包。

本章的代码可以在本书的 GitHub 仓库中的Chapter 06文件夹找到:github.com/PacktPublishing/Applying-Math-with-Python-2nd-Edition/tree/main/Chapter%2006

创建 Series 和 DataFrame 对象

在 Python 中,大多数数据处理是通过 pandas 库完成的,pandas 基于 NumPy 提供类似 R 的结构来存储数据。这些结构允许使用字符串或其他 Python 对象(除了整数)进行轻松的行列索引。一旦数据被加载到 pandas DataFrameSeries中,它就可以像在电子表格中一样轻松操作。这使得 Python 与 pandas 结合时,成为处理和分析数据的强大工具。

在本食谱中,我们将看到如何创建新的 pandas SeriesDataFrame对象,并访问其中的项目。

准备工作

对于本食谱,我们将使用以下命令将 pandas 库导入为pd

import pandas as pd

NumPy 包是np。我们还必须从 NumPy 中创建一个(带种子的)随机数生成器,如下所示:

from numpy.random import default_rng
rng = default_rng(12345)

如何操作...

以下步骤概述了如何创建保存数据的SeriesDataFrame对象:

  1. 首先,创建我们将存储在SeriesDataFrame对象中的随机数据:

    diff_data = rng.normal(0, 1, size=100)
    
    cumulative = diff_data.cumsum()
    
  2. 接下来,创建一个包含diff_dataSeries对象。我们将打印Series以查看数据:

    data_series = pd.Series(diff_data)
    
    print(data_series)
    
  3. 现在,创建一个包含两列的DataFrame对象:

    data_frame = pd.DataFrame({
    
        "diffs": data_series,
    
        "cumulative": cumulative
    
    })
    
  4. 打印DataFrame对象以查看它所包含的数据:

    print(data_frame)
    

打印出的对象如下;左边是Series对象,右边是DataFrame对象:

                                     diffs  cumulative
0    -1.423825                0  -1.423825   -1.423825
1     1.263728                1   1.263728   -0.160097
2    -0.870662                2  -0.870662   -1.030758
3    -0.259173                3  -0.259173   -1.289932
4    -0.075343                4  -0.075343   -1.365275
        ...                  ..       ...         ...
95   -0.061905               95 -0.061905   -1.107210
96   -0.359480               96 -0.359480   -1.466690
97   -0.748644               97 -0.748644   -2.215334
98   -0.965479               98 -0.965479   -3.180813
99    0.360035               99  0.360035   -2.820778
Length: 100, dtype: float64  [100 rows x 2 columns]

如预期的那样,SeriesDataFrame都包含 100 行。由于系列中的数据是单一类型的——这通过它只是一个 NumPy 数组来保证——数据类型显示为float64DataFrame有两列,通常这两列可能有不同的数据类型(尽管在这里,它们都有float64)。

它是如何工作的……

pandas 包提供了SeriesDataFrame类,它们的功能和特性与 R 语言中的类似对象相对应。Series用于存储一维数据,如时间序列数据,而DataFrame用于存储多维数据;你可以将DataFrame对象视为一个“电子表格”。

Series与简单的 NumPy ndarray的区别在于Series对其项的索引方式。NumPy 数组是通过整数索引的,这也是Series对象的默认索引方式。然而,Series可以通过任何可哈希的 Python 对象进行索引,包括字符串和datetime对象。这使得Series在存储时间序列数据时非常有用。Series可以通过多种方式创建。在这个示例中,我们使用了 NumPy 数组,但也可以使用任何 Python 可迭代对象,如列表。

DataFrame对象中的每一列都是包含行的系列,就像传统的数据库或电子表格一样。在这个示例中,DataFrame对象通过字典的键来构建时,列会被赋予标签。

DataFrameSeries对象在打印时会创建一个数据摘要。这包括列名、行数和列数,以及框架(序列)的前五行和后五行。这有助于快速获取对象及其数据分布的概况。

还有更多内容……

Series对象的单独行(记录)可以通过常规的索引符号访问,只需提供相应的索引。我们也可以通过特殊的iloc属性对象按其数字位置访问行。这允许我们像在 Python 列表或 NumPy 数组中一样,通过数字(整数)索引访问行。

DataFrame对象中的列可以通过常规的索引符号访问,提供列名即可。这样得到的结果是一个包含所选列数据的Series对象。DataFrame还提供了两个可以用于访问数据的属性。loc属性通过索引访问单独的行,无论该对象是什么。而iloc属性通过数字索引访问行,就像在Series对象中一样。

您可以向 loc 提供选择标准(或仅使用对象的索引表示法)来选择数据。这包括单个标签、标签列表、标签切片或布尔数组(大小适当)。iloc 选择方法接受类似的标准。

SeriesDataFrame 对象中选择数据的方式有很多,超出了我们在此所描述的简单方法。例如,我们可以使用 at 属性访问对象中指定行(和列)的单个值。

有时候,pandas 的 SeriesDataFrame 不能充分描述数据,因为它们本身是低维的。xarray 包基于 pandas 接口并提供对带标签的多维数组(即 NumPy 数组)的支持。在 第十章从 NetCDF 文件加载和存储数据 示例中,我们将学习关于 xarray 的内容。更多关于 xarray 的信息可以在文档中找到:docs.xarray.dev/en/stable/index.html

另见

pandas 文档包含了创建和索引 DataFrameSeries 对象的不同方式的详细描述:pandas.pydata.org/docs/user_guide/indexing.html

从 DataFrame 加载和存储数据

在 Python 会话中从原始数据创建 DataFrame 对象是相当不寻常的。实际上,数据通常来自外部来源,例如现有的电子表格或 CSV 文件、数据库或 API 接口。因此,pandas 提供了许多用于加载和存储数据到文件的工具。pandas 默认支持从 CSV、Excel(xlsxlsx)、JSON、SQL、Parquet 和 Google BigQuery 加载和存储数据。这使得将数据导入 pandas 并使用 Python 操作和分析这些数据变得非常简单。

在本示例中,我们将学习如何加载和存储 CSV 文件中的数据。加载和存储其他文件格式的数据的操作方法将类似。

准备工作

对于这个示例,我们需要导入 pandas 包并使用 pd 别名,同时导入 NumPy 库并命名为 np。我们还必须使用以下命令从 NumPy 创建一个默认的随机数生成器:

from numpy.random import default_rng
rng = default_rng(12345) # seed for example

让我们学习如何存储数据并从 DataFrame 中加载数据。

如何操作...

按照以下步骤将数据存储到文件中,然后将数据加载回 Python:

  1. 首先,我们将使用随机数据创建一个示例的 DataFrame 对象。然后,我们将打印这个 DataFrame 对象,以便我们可以将其与稍后读取的数据进行比较:

    diffs = rng.normal(0, 1, size=100)
    
    cumulative = diffs.cumsum()
    
    data_frame = pd.DataFrame({
    
        "diffs": diffs, 
    
        "cumulative": cumulative
    
    })
    
    print(data_frame)
    
  2. 我们将通过在 DataFrame 对象上使用 to_csv 方法,将数据存储到 sample.csv 文件中。我们将使用 index=False 关键字参数,以确保索引不被存储在 CSV 文件中:

    data_frame.to_csv("sample.csv", index=False)
    
  3. 现在,我们可以使用 pandas 中的read_csv方法将sample.csv文件读入一个新的DataFrame对象。我们将打印这个对象,以展示结果:

    df = pd.read_csv("sample.csv", index_col=False)
    
    print(df)
    

两个打印出来的 DataFrame 并排显示。第一步中的DataFrame对象在左侧,第三步中的DataFrame对象在右侧:

    diffs      cumulative          diffs       cumulative
0  -1.423825   -1.423825        0  -1.423825   -1.423825
1   1.263728   -0.160097        1   1.263728   -0.160097
2  -0.870662   -1.030758        2  -0.870662   -1.030758
3  -0.259173   -1.289932        3  -0.259173   -1.289932
4  -0.075343   -1.365275        4  -0.075343   -1.365275
..         ...            ...        ..         ...            ...
95 -0.061905   -1.107210        95 -0.061905   -1.107210
96 -0.359480   -1.466690        96 -0.359480   -1.466690
97 -0.748644   -2.215334        97 -0.748644   -2.215334
98 -0.965479   -3.180813        98 -0.965479   -3.180813
99  0.360035   -2.820778        99  0.360035   -2.820778
[100 rows x 2 columns]           [100 rows x 2 columns]

如我们从行中所看到的,这两个 DataFrame 是完全相同的。

它是如何工作的...

本教程的核心是 pandas 中的read_csv方法。该方法接受路径或类似文件的对象作为参数,并将文件内容读取为 CSV 数据。我们可以通过sep关键字参数定制分隔符,默认是逗号(,)。还可以定制列头以及每列的类型。

DataFrameSeries中的to_csv方法将内容存储到 CSV 文件中。我们在这里使用了index关键字参数,以确保索引不会被打印到文件中。这意味着 pandas 会根据 CSV 文件中的行号推断索引。如果数据是按整数索引的,这种行为是可取的,但如果数据是按时间或日期索引的,情况可能不同。我们还可以使用这个关键字参数指定 CSV 文件中作为索引的列。

另请参阅

请参阅 pandas 文档,了解支持的文件格式:pandas.pydata.org/docs/reference/io.html

操作 DataFrame 中的数据

一旦我们有了DataFrame中的数据,我们通常需要对数据进行一些简单的转换或筛选,然后才能进行分析。这可能包括,例如,筛选出缺失数据的行,或者对单独的列应用函数。

在这个教程中,我们将学习如何对DataFrame对象进行一些基本操作,以准备数据进行分析。

准备工作

对于这个教程,我们需要导入 pandas 库,别名为pd,导入 NumPy 库,别名为np,并通过以下命令创建一个默认的 NumPy 随机数生成器对象:

from numpy.random import default_rng
rng = default_rng(12345)

让我们学习如何对DataFrame中的数据进行一些简单的操作。

如何做到...

以下步骤展示了如何对 pandas DataFrame进行一些基本的筛选和操作:

  1. 首先,我们将使用随机数据创建一个示例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": rng.normal(0, 1, size=100).cumsum(),
    
        "three": three
    
    })
    
  2. 接下来,我们将从现有的列生成一个新列。这个新列将存储True,如果对应的"one"列的值大于0.5,否则存储False

    data_frame["four"] = data_frame["one"] > 0.5
    
  3. 现在,让我们创建一个新函数,我们将应用到我们的DataFrame上。这个函数将把行 "two" 的值乘以行 "one"0.5中的最大值(还有更简洁的方式来写这个函数):

    def transform_function(row):
    
        if row["four"]:
    
            return 0.5*row["two"]
    
            return row["one"]*row["two"]
    
  4. 现在,我们将把之前定义的函数应用到 DataFrame 的每一行,生成一个新列。我们还将打印更新后的 DataFrame,方便后续对比:

    data_frame["five"] = data_frame.apply(
    
        transform_function, axis=1)
    
    print(data_frame)
    
  5. 最后,我们需要过滤掉包含非数字NaN)值的 DataFrame 行。我们将打印出处理后的 DataFrame:

    df = data_frame.dropna()
    
    print(df)
    

步骤 4print 命令的输出如下:

         one       two     three   four      five
0   0.168629  1.215005  0.072803  False  0.204885
1   0.240144  1.431064  0.180110  False  0.343662
2   0.780008  0.466240  0.756839   True  0.233120
3   0.203768 -0.090367  0.611506  False -0.018414
4   0.552051 -2.388755  0.269331   True -1.194377
..         ...         ...         ...     ...         ...
95  0.437305  2.262327  0.254499  False  0.989326
96  0.143115  1.624691  0.131136  False  0.232517
97  0.422742  2.769187  0.959325  False  1.170652
98  0.764412  1.128285         NaN   True  0.564142
99  0.413983 -0.185719  0.290481  False -0.076885
[100 rows x 5 columns]

在第 98 行可见一个 NaN 值。正如预期的那样,我们总共有 100 行数据和 5 列数据。现在,我们可以将其与 步骤 6print 命令的输出进行对比:

         one       two     three   four      five
0   0.168629  1.215005  0.072803  False  0.204885
1   0.240144  1.431064  0.180110  False  0.343662
2   0.780008  0.466240  0.756839   True  0.233120
3   0.203768 -0.090367  0.611506  False -0.018414
4   0.552051 -2.388755  0.269331   True -1.194377
..         ...         ...         ...     ...         ...
94  0.475131  3.065343  0.330151  False  1.456440
95  0.437305  2.262327  0.254499  False  0.989326
96  0.143115  1.624691  0.131136  False  0.232517
97  0.422742  2.769187  0.959325  False  1.170652
99  0.413983 -0.185719  0.290481  False -0.076885
[88 rows x 5 columns]

正如预期的那样,行数减少了 12 行,因为我们已删除了所有包含 NaN 值的行。(请注意,第 98 行在第 3 列中不再包含 NaN。)

工作原理...

可以通过简单地将新列分配给新的列索引来向现有的 DataFrame 中添加新列。然而,这里需要特别注意。在某些情况下,pandas 会创建 DataFrame 对象的“视图”而不是复制对象,在这种情况下,将新列分配给 DataFrame 可能不会产生预期的效果。有关这一点,可以参阅 pandas 文档(pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy)。

pandas 的 Series 对象(即 DataFrame 中的列)支持丰富的比较运算符,比如等于、小于和大于(在本示例中,我们使用了大于运算符)。这些比较运算符返回一个 Series,其中包含与比较结果为真或假的位置相对应的布尔值。接着,可以利用这个布尔值 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,其中包含应用函数的名称作为行,所选轴(例如列标签)的标签作为列。

merge方法在两个 DataFrame 之间执行类似 SQL 的连接操作。这将生成一个新的DataFrame,包含连接操作的结果。可以通过how关键字参数指定要执行的连接类型,默认是inner。连接操作的列名或索引应通过on关键字参数传递——如果两个DataFrame对象包含相同的键——或者通过left_onright_on传递。以下是一个简单的 DataFrame 连接示例:

rng = default_rng(12345)
df1 = pd.DataFrame({
    "label": rng.choice(["A", "B", "C"], size=5),
    "data1": rng.standard_normal(size=5)
})
df2 = pd.DataFrame({
    "label": rng.choice(["A", "B", "C", "D"], size=4),
    "data2": rng.standard_normal(size=4)
})
df3 = df1.merge(df2, how="inner", on="label")

这将生成一个包含labeldata1data2DataFrame,这些数据对应于df1df2中具有相同标签的行。我们打印这三个 DataFrame 来查看结果:

>>> print(df1)                          >>> print(df2)
  label      data1                        label      data2
0      C -0.259173                     0      D  2.347410
1      A -0.075343                     1      A  0.968497
2      C -0.740885                     2      C -0.759387
3      A -1.367793                     3      C  0.902198
4      A  0.648893
>>> df3
  label      data1      data2
0      C -0.259173 -0.759387
1      C -0.259173  0.902198
2      C -0.740885 -0.759387
3      C -0.740885  0.902198
4      A -0.075343  0.968497
5      A -1.367793  0.968497
6      A  0.648893  0.968497

在这里,你可以看到每一组来自df1df2data1data2值组合,配有相应的标签,都会有一行出现在df3中。此外,df2中标签为D的行未被使用,因为df1中没有标签为D的行。

绘制来自 DataFrame 的数据

正如许多数学问题一样,找到一种可视化问题和所有信息的方法的第一步通常是制定策略。对于数据相关问题,这通常意味着生成数据的图表,并通过目视检查来寻找趋势、模式和潜在结构。由于这是一个常见的操作,pandas 提供了一个快速简单的接口,用于直接从SeriesDataFrame绘制数据,默认情况下使用 Matplotlib 作为后台。

在本教程中,我们将学习如何直接从DataFrameSeries绘制数据,以理解其背后的趋势和结构。

准备工作

对于本教程,我们将需要导入pandas库(作为pd),NumPy库(作为np),Matplotlibpyplot模块(作为plt),并使用以下命令创建默认的随机数生成器实例:

from numpy.random import default_rng
rng = default_rng(12345)

如何操作...

按照以下步骤,使用随机数据创建一个简单的DataFrame并绘制其包含的数据图表:

  1. 使用随机数据创建一个示例DataFrame

    diffs = rng.standard_normal(size=100)
    
    walk = diffs.cumsum()
    
    df = pd.DataFrame({
    
        "diffs": diffs,
    
        "walk": walk
    
    })
    
  2. 接下来,我们需要创建一个空白图形,包含两个子图以备绘制:

    fig, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True)
    
  3. 我们需要将walk列绘制为标准的折线图。这可以通过在Series(列)对象上使用plot方法,且不需要额外的参数来完成。我们将通过传递ax=ax1关键字参数强制在ax1上绘制:

    df["walk"].plot(ax=ax1, title="Random walk", color="k")
    
    ax1.set_xlabel("Index")
    
    ax1.set_ylabel("Value")
    
  4. 现在,我们需要通过将kind="hist"关键字参数传递给plot方法,绘制diffs列的直方图:

    df["diffs"].plot(kind="hist", ax=ax2, 
    
        title="Histogram of diffs", color="k", alpha=0.6)
    
    ax2.set_xlabel("Difference")
    

结果图形如下所示:

图 6.1 – 来自 DataFrame 的行走值和差异的直方图

图 6.1 – 来自 DataFrame 的行走值和差异的直方图

在这里,我们可以看到,差异的直方图接近标准正态分布(均值 0,方差 1)。随机游走图展示了差异的累计和,并且围绕 0 上下摆动(相当对称)。

它是如何工作的...

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 中的每一列生成描述性统计数据:

  1. 首先,我们将创建一些示例数据供我们分析:

    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
    
    })
    
  2. 接下来,我们将绘制数据的直方图,以便了解 DataFrame 对象中数据的分布:

    fig, (ax1, ax2, ax3) = plt.subplots(1, 3,
    
                                        tight_layout=True)
    
    df["uniform"].plot(kind="hist",
    
        title="Uniform", ax=ax1, color="k", alpha=0.6)
    
    df["normal"].plot(kind="hist",
    
        title="Normal", ax=ax2, color="k", alpha=0.6)
    
  3. 为了更好地观察 bimodal 数据的分布,我们将直方图的箱数更改为 20

    df["bimodal"].plot(kind="hist", title="Bimodal",
    
        ax=ax3, bins=20, color="k", alpha=0.6)
    
  4. pandas DataFrame 对象有一个方法,可以获取每一列的几种常见描述性统计数据。describe 方法会创建一个新的 DataFrame,该 DataFrame 的列标题与原始对象相同,每一行包含不同的描述性统计数据:

    descriptive = df.describe()
    
  5. 我们还必须计算 峰度 并将其添加到我们刚刚获得的新 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
    
  6. 最后,我们必须在直方图中添加垂直线,以显示每种情况下均值的值:

    uniform_mean = descriptive.loc["mean", "uniform"]
    
    normal_mean = descriptive.loc["mean", "normal"]
    
    bimodal_mean = descriptive.loc["mean", "bimodal"]
    
    ax1.vlines(uniform_mean, 0, 20, "k")
    
    ax2.vlines(normal_mean, 0, 25, "k")
    
    ax3.vlines(bimodal_mean, 0, 20, "k")
    

结果的直方图如下所示:

图 6.2 – 三组数据的直方图及其均值标示

图 6.2 – 三组数据的直方图及其均值标示

在这里,我们可以看到平均值对于正态分布的数据(中间)是中心的,但对于均匀分布的数据(左侧),分布的“质量”稍微偏向于左侧的较低值。对于双峰日(右侧),平均线恰好位于质量的两个组成部分之间。

工作原理...

describe 方法返回一个包含以下数据描述统计的 DataFrame:计数、平均值、标准差、最小值、25% 四分位数、中位数(50% 四分位数)、75% 四分位数和最大值。计数、最小值和最大值都很容易理解。平均值和中位数是数据的两种不同的平均值,大致代表数据的中心值。平均值的定义应该很熟悉,即所有值的总和除以值的数量。我们可以用以下公式表示这个数量:

这里, 值代表数据值, 是数值(计数)的数量。在这里,我们还采用了用横线表示平均值的常见符号。中位数是当所有数据排序时的“中间值”(如果值的数量为奇数,则取两个中间值的平均值)。25% 和 75% 处的四分位数同样定义,但是取有序值的 25%或 75%处的值。你也可以将最小值视为 0%四分位数,最大值视为 100%四分位数。

标准差是数据与平均值之间的分布范围的度量,与统计学中经常提到的另一个量方差有关。方差是标准差的平方,定义如下:

在这里你可能也会看到分数中出现的,这是在从样本估计总体参数时对偏差的校正。我们将在下一个示例中讨论总体参数及其估计。标准差、方差、四分位数、最大值和最小值描述了数据的分布。例如,如果最大值为 5,最小值为 0,25%四分位数为 2,75%四分位数为 4,则表示大部分(实际上至少 50%的值)的数据集中在 2 和 4 之间。

峰度是衡量数据在分布的“尾部”(远离均值的地方)集中程度的指标。这种指标不像我们在本教程中讨论的其他量那样常见,但它确实在某些分析中会出现。我们在这里主要是为了演示如何计算在describe方法返回的DataFrame对象中未出现的汇总统计量——这里是kurtosis方法。对于计算均值(mean)、标准差(std)以及describe方法中的其他量,当然也有单独的方法。

注意

当 pandas 计算本教程中描述的各项量时,它会自动忽略任何由 NaN 表示的“缺失值”。这也会反映在描述性统计中报告的计数中。

还有更多...

我们在统计中包含的第三个数据集说明了观察数据的重要性,以确保我们计算的数值合理。事实上,我们计算出的均值约为2.9,但从直方图来看,显然大多数数据远离这个值。我们应该始终检查我们计算的汇总统计量是否能准确地反映样本数据的特征。仅仅引用均值可能会对样本产生不准确的描述。

使用抽样理解总体

统计学中的一个核心问题是进行估计——并量化这些估计的准确性——即在只有一个小(随机)样本的情况下,估计整个总体的分布。一个经典的例子是估计一个国家所有人群的平均身高,方法是测量一个随机选取的人的身高。这类问题在真实的总体分布无法实测时特别有趣,通常我们指的总体均值就是指整体人口的均值。在这种情况下,我们必须依赖统计学知识和一个(通常要小得多的)随机选取的样本来估计总体的真实均值和标准差,同时也量化我们估计的准确性。正是后者导致了公众对统计学的困惑、误解和错误解读。

在本教程中,我们将学习如何估算总体均值,并为这些估算提供置信区间

准备工作

对于本教程,我们需要导入pandas包并使用别名pd,导入 Python 标准库中的math模块,并导入 SciPy 的stats模块,使用以下命令:

from scipy import stats

让我们学习如何使用 SciPy 的统计函数构建置信区间。

如何操作...

在接下来的步骤中,我们将基于随机选取的 20 人的样本,估算英国男性的平均身高:

  1. 首先,我们必须将样本数据加载到 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]
    
    )
    
  2. 接下来,我们必须计算样本的均值和标准差:

    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
    
  3. 然后,我们必须计算标准误差,如下所示:

    N = sample_data.count()
    
    std_err = sample_std/math.sqrt(N)
    
  4. 我们必须从学生 t 分布中计算出我们希望的置信度所需的临界值

    cv_95, cv_99 = stats.t.ppf([0.975, 0.995], df=N-1)
    
  5. 现在,我们可以使用以下代码计算真实总体均值的 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]
    

它是如何工作的...

参数估计的关键是正态分布,我们在第四章《与随机性和概率打交道》中讨论过它。如果我们找到标准正态分布的临界值!,使得一个标准正态分布随机数小于该值的概率为 97.5%,那么该随机数位于!和!之间的概率为 95%(每尾 2.5%)。这个临界值!的结果是 1.96,保留到小数点后两位。也就是说,我们可以 95%确定一个标准正态分布随机数的值介于!和!之间。类似地,99%置信度的临界值是 2.58(保留到小数点后两位)。

如果我们的样本是“大的”,我们可以调用 SciPy stats模块中的stats.t.ppf例程。

学生 t 分布与正态分布相关,但它有一个参数——自由度——改变分布的形态。随着自由度的增加,学生 t 分布将越来越像正态分布。你认为分布足够相似的临界点取决于你的应用和数据。一个常见的经验法则是样本量为 30 时,可以调用中心极限定理,直接使用正态分布,但这并不一定是一个好规则。你在基于样本做出推论时要非常小心,尤其是当样本相对于总体来说非常小的时候。

一旦我们得到了临界值,就可以通过将临界值乘以样本的标准误差,然后将结果加减到样本均值上来计算真实总体均值的置信区间。标准误差是给定样本量的样本均值分布的扩展的近似值,相对于真实总体均值。这就是为什么我们使用标准误差来提供我们对总体均值估计的置信区间。当我们将标准误差乘以从学生 t 分布(在此情况下)获得的临界值时,我们得到了观察到的样本均值与真实总体均值之间的最大差异估计值,该差异值对应于给定的置信水平。

在本节中,这意味着我们有 95% 的信心认为英国男性的平均身高位于 168.7 cm 和 175.6 cm 之间,且我们有 99% 的信心认为英国男性的平均身高位于 167.4 cm 和 176.9 cm 之间。我们的样本来自一个均值为 175.3 cm,标准差为 7.2 cm 的总体。这个真实的均值(175.3 cm)确实位于我们的两个置信区间内,但只是勉强。

另请参见

有一个非常有用的包叫做 uncertainties,用于处理涉及带有不确定性的值的计算。更多信息请参见 第十章 中的 计算中的不确定性处理 配方,位于 提高生产力 一章。

DataFrame 中对分组数据执行操作

pandas DataFrame 的一个重要特性是可以通过特定列中的值对数据进行分组。例如,我们可以按生产线 ID 和班次 ID 对装配线数据进行分组。能够以符合人体工程学的方式对这些分组数据进行操作非常重要,因为数据通常是为了分析而聚合的,但在预处理时需要进行分组。

在本节中,我们将学习如何对 DataFrame 中的分组数据执行操作。我们还将借此机会展示如何对(分组的)数据的滚动窗口进行操作。

准备工作

对于本节,我们需要导入 NumPy 库并命名为 np,导入 Matplotlib 的 pyplot 接口并命名为 plt,还需要导入 pandas 库并命名为 pd。我们还需要如下创建一个默认随机数生成器的实例:

rng = np.random.default_rng(12345)

在开始之前,我们还需要设置 Matplotlib 绘图设置,以便在本节中更改绘图样式。我们将改变在同一坐标轴上生成多个图形时,循环使用的绘图样式机制,这通常会导致不同的颜色。为了实现这一点,我们将其更改为生成黑色线条并使用不同的线条样式:

from matplotlib.rcsetup import cycler
plt.rc("axes", prop_cycle=cycler(
    c=["k"]*3, ls=["-", "--", "-."]))

现在,让我们学习如何使用 pandas DataFrame 的分组功能。

如何操作...

按照以下步骤,学习如何在 pandas DataFrame 中对分组数据执行操作:

  1. 首先,我们需要在 DataFrame 中生成一些示例数据。对于这个示例,我们将生成两个标签列和一个数值数据列:

    labels1 = rng.choice(["A", "B", "C"], size=50)
    
    labels2 = rng.choice([1, 2], size=50)
    
    data = rng.normal(0.0, 2.0, size=50)
    
    df = pd.DataFrame({"label1": labels1, "label2": labels2, "data": data})
    
  2. 接下来,让我们添加一个新列,该列由按第一个标签 "label1" 分组的 "data" 列的累积和组成:

    df[“first_group”] = df.groupby(“label1”)[“data”].cumsum()
    
    print(df.head())
    

现在,df 的前五行如下:

  label1  label2      data  first_group
0      C       2  0.867309     0.867309
1      A       2  0.554967     0.554967
2      C       1  1.060505     1.927814
3      A       1  1.073442     1.628409
4      A       1  1.236700     2.865109

在这里,我们可以看到 "first_group" 列包含了 "label1" 列中每个标签的累积和。例如,第 0 行和第 1 行的和只是 "data" 列中的值。第 2 行的新条目是第 0 行和第 2 行数据的总和,因为这两行是第一个带有标签 "C" 的行。

  1. 现在,让我们对 "label1""label2" 列同时进行分组:

    grouped = df.groupby(["label1", "label2"])
    
  2. 现在,我们可以使用 transformrolling 方法在分组数据上计算每组内连续条目的滚动均值:

    df["second_group"] = grouped["data"].transform(lambda d:
    
        d.rolling(2, min_periods=1).mean())
    
    print(df.head())
    
    print(df[df["label1"]=="C"].head())
    

前五行打印结果如下:

  label1  label2      data  first_group  second_group
0      C       2  0.867309     0.867309      0.867309
1      A       2  0.554967     0.554967      0.554967
2      C       1  1.060505     1.927814      1.060505
3      A       1  1.073442     1.628409      1.073442
4      A       1  1.236700     2.865109      1.155071

与之前一样,前几行都表示不同的组,因此 "second_group" 列中的值与 "data" 列中的相应值相同。第 4 行的值是第 3 行和第 4 行数据值的平均值。接下来的五行是标签为 C 的行:

  label1  label2      data  first_group  second_group
0      C       2  0.867309     0.867309      0.867309
2      C       1  1.060505     1.927814      1.060505
5      C       1 -1.590035     0.337779     -0.264765
7      C       1 -3.205403    -2.867624     -2.397719
8      C       1  0.533598    -2.334027     -1.335903

在这里,我们可以更清楚地看到滚动平均值和累计和。除了第一行外,其他行的标签都相同。

  1. 最后,让我们绘制由 "label1" 列分组的 "first_group" 列的值:

    fig, ax = plt.subplots()
    
    df.groupby("label1")["first_group"].plot(ax=ax)
    
    ax.set(title="Grouped data cumulative sums",     xlabel="Index", ylabel="value")
    
    ax.legend()
    

结果图如 图 6.3 所示:

图 6.3 – 按  组绘制的累计和图

图 6.3 – 按 label1 组绘制的累计和图

在这里,我们可以看到每个组在图上都产生了一条不同的线。这是一种快速简便的方法,可以从 DataFrame 中绘制分组数据的图。(请记住,在 准备工作 部分中我们更改了默认的样式周期,使得图表风格在页面上更加鲜明。)

它是如何工作的...

groupby 方法为 DataFrame 创建了一个代理,其索引是从请求的列中生成的。我们可以在这个代理对象上执行操作。在这种情况下,我们使用 cumsum 方法来生成每个组内 "data" 列的累计和。我们可以使用这种方法以相同的方式生成分组数据的汇总统计信息。这对于数据探索非常有用。

在这个食谱的第二部分,我们根据两个不同的标签列进行了分组,并对每个组计算了滚动平均值(窗口长度为 2)。请注意,我们使用 transform 方法“包装”了这个计算,而不是直接在分组的 DataFrame 上调用 rolling。这样做是为了确保结果具有正确的索引,可以放回到 df 中。否则,mean 的输出将继承分组索引,我们将无法将结果放回到 df 中。我们在 rolling 上使用了 min_periods 可选参数,以确保所有行都有值。否则,窗口大小之前的行将被分配为 NaN。

这个食谱的最后一部分使用了 plot 函数,绘制了按 "label1" 分组的数据。这是一种快速简便的方法,可以从同一个 DataFrame 对象中绘制多个数据流。不幸的是,在这种情况下,定制绘图稍显困难,尽管可以通过使用 Matplotlib 中的 rcparams 设置来完成。

使用 t 检验进行假设检验

统计学中最常见的任务之一是测试一个关于正态分布人口均值的假设有效性,前提是你已经从该人口中收集了样本数据。例如,在质量控制中,我们可能希望检验一个生产厂的板材厚度是否为 2 毫米。为了进行此测试,我们可以随机选择样本板材并测量其厚度,从而获得样本数据。然后,我们可以使用 stats 模块计算 t 统计量和 值。如果 值低于 0.05,则我们以 5% 的显著性水平(95% 的置信度)接受零假设。如果 值大于 0.05,则我们必须拒绝零假设,支持替代假设。

在这个食谱中,我们将学习如何使用 t 检验来检验给定样本的假设人口均值是否有效。

准备工作

对于这个食谱,我们需要导入 pandas 包并命名为 pd,同时导入 SciPy stats 模块,使用以下命令:

from scipy import stats

让我们学习如何使用 SciPy stats 模块进行 t 检验。

如何操作...

按照以下步骤使用 t 检验来检验给定一些样本数据的假设人口均值的有效性:

  1. 首先,我们必须将数据加载到 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.9, 2.5, 2.5, 3.2, 2\. , 2.3, 3\. , 1.5, 3.1,
    
        2.5, 2.2, 2.5, 2.1,1.8, 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
    
    ])
    
  2. 现在,让我们设置假设的人口均值和我们将进行检验的显著性水平:

    mu0 = 2.0
    
    significance = 0.05
    
  3. 接下来,我们将使用 SciPy stats 模块中的 ttest_1samp 例程来生成 t 统计量和 值:

    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
    
  4. 最后,让我们测试 值是否小于我们选择的显著性水平:

    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
    

我们可以有 95% 的信心得出结论:样本数据来源的人口均值不等于 2(由于样本中大多数数值大于 2,这并不令人惊讶)。鉴于这里的 值如此之小,我们可以非常有信心地认为这一点是正确的。

它是如何工作的...

t 统计量通过以下公式计算:

这里, 是假设的均值(来自零假设), 是样本均值, 是样本标准差, 是样本的大小。t 统计量是观察到的样本均值与假设人口均值 之间差异的估算值,经过标准误差标准化。如果假设人口呈正态分布,t 统计量将遵循 t 分布,具有 自由度。通过查看 t 统计量在相应的学生 t 分布中的位置,我们可以大致了解观察到的样本均值来自假设均值人口的可能性。这个值用 来表示。

值是观察到的样本均值比假设总体均值更极端的概率,前提是假设总体均值等于。如果值小于我们选择的显著性值,则不能期望真实的总体均值是我们假设的值。在这种情况下,我们接受备择假设,认为真实的总体均值不等于

还有更多……

本配方中演示的检验是 t 检验的最基本应用。在这里,我们将样本均值与假设的总体均值进行比较,以决定整个总体均值是否合理地是这个假设值。更一般来说,我们可以使用 t 检验来比较两个独立的总体,前提是从每个总体中抽取了样本,使用双样本 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 检验,以测试三个不同流程之间的差异:

  1. 首先,我们将创建一些样本数据,然后进行分析:

    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)
    
  2. 接下来,我们将设置我们检验的显著性水平:

    significance = 0.05
    
  3. 然后,我们将使用 SciPy stats 模块中的 f_oneway 例程生成 F 统计量和 值:

    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
    
  4. 现在,我们必须测试 值是否足够小,以判断我们是否应接受或拒绝原假设,即所有均值相等:

    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
    

这里, 值非常小(在 级别),因此差异在 95% 置信度下显著(即,),并且在 99% 置信度下也显著()。

工作原理…

ANOVA 是一种强大的技术,可以同时比较多个样本之间的差异。它通过比较样本之间的变异性与总体变异性来工作。ANOVA 在比较三个或更多样本时尤其强大,因为它不会因进行多次检验而产生累积误差。不幸的是,如果 ANOVA 检测到并非所有均值相等,则无法通过检验信息确定哪些样本之间存在显著差异。为此,您需要使用额外的检验来找出差异。

f_oneway SciPy stats 模块中的例程执行单因素方差分析(ANOVA)检验——ANOVA 中生成的检验统计量遵循 F 分布。再次强调, 值是检验中最关键的信息。如果 值小于我们预先设定的显著性水平(在本食谱中为 5%),我们接受原假设;否则拒绝原假设。

还有更多…

ANOVA 方法非常灵活。我们在这里介绍的一元 ANOVA 检验是最简单的情况,因为这里只有一个因子需要检验。二元 ANOVA 检验可以用来检验两个不同因子之间的差异。例如,在药物临床试验中,我们可以进行对照组的测试,同时还衡量性别(例如)对结果的影响。不幸的是,SciPy 在 stats 模块中没有执行二元 ANOVA 的例程。你需要使用其他包,如 statsmodels 包。

如前所述,ANOVA 只能检测是否存在差异。如果存在显著差异,它无法检测这些差异发生在哪里。例如,我们可以使用 Durnett 检验来测试其他样本的均值是否与对照样本的均值不同,或者使用 Tukey 范围检验来测试每个组的均值与其他所有组的均值。

非参数数据的假设检验

t 检验和 ANOVA 的一个主要缺点是:所采样的总体必须遵循正态分布。在许多应用中,这并不太具限制性,因为许多实际的总体值遵循正态分布,或者某些定理(例如中心极限定理)允许我们分析相关数据。然而,实际上并非所有的总体值都以合理的方式遵循正态分布。对于这些(幸运的是,较为少见的)情况,我们需要使用一些替代的检验统计量,作为 t 检验和 ANOVA 的替代方法。

在本方案中,我们将使用 Wilcoxon 秩和检验和 Kruskal-Wallis 检验来检验两个(或更多,在后一种情况下)总体之间的差异。

准备工作

对于此方案,我们将需要导入 pandas 包作为 pd,SciPy 的 stats 模块,以及使用以下命令创建一个默认的随机数生成器实例:

from numpy.random import default_rng
rng = default_rng(12345)

让我们学习如何使用 SciPy stats 中的非参数假设检验工具。

如何操作…

按照以下步骤比较两个或更多非正态分布的样本:

  1. 首先,我们将生成一些样本数据来进行分析:

    sample_A = rng.uniform(2.5, 3.5, size=25)
    
    sample_B = rng.uniform(3.0, 4.4, size=25)
    
    sample_C = rng.uniform(3.1, 4.5, size=25)
    
  2. 接下来,我们将设定分析中使用的显著性水平:

    significance = 0.05
    
  3. 现在,我们将使用 stats.kruskal 例程生成检验统计量和 值,用于检验原假设,即总体具有相同的中位数值:

    statistic, p_value = stats.kruskal(sample_A, sample_B,
    
        sample_C)
    
    print(f"Statistic: {statistic}, p value: {p_value}")
    
    # Statistic: 40.22214736842102, p value: 1.8444703308682906e-09
    
  4. 我们将使用条件语句打印测试结果的陈述:

    if p_value <= significance:
    
        print("There are differences between population  medians")
    
    else:
    
        print("Accept H0: all medians equal")
    
    # There are differences between population medians
    
  5. 现在,我们将使用 Wilcoxon 秩和检验获取每对样本比较的 值。这些检验的原假设是它们来自同一分布:

    _, 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)
    
  6. 接下来,我们将使用条件语句打印出那些表示显著差异的比较结果:

    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
    
    1.0035366080480683e-07
    
    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
    
    2.428534673701913e-08
    
    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.3271631660572756
    

这些打印出来的线条显示我们的测试发现,A 组和 B 组、A 组和 C 组之间存在显著差异,但 B 组和 C 组之间没有差异。

它是如何工作的...

我们说数据是非参数的,如果数据来源的总体不符合可以通过少量参数描述的分布。这通常意味着总体不是正态分布的,但范围比这更广。在本示例中,我们从均匀分布中抽样,但这仍然是一个比我们通常需要使用非参数检验时更为结构化的例子。非参数检验可以且应该用于任何我们不确定基础分布的情况。这样做的代价是,检验的效力稍微较弱。

任何(真实的)分析的第一步应该是绘制数据的直方图并直观地检查分布。如果你从一个正态分布的总体中抽取随机样本,你也可以预期样本呈正态分布(在本书中我们已经多次看到这一点)。如果你的样本显示出正态分布的特征性钟形曲线,那么该总体很可能是正态分布的。你还可以使用kind="kde"。如果你仍然不确定总体是否符合正态分布,可以使用统计检验,例如 D'Agostino 的 K-squared 检验或皮尔逊卡方检验来检验正态性。这两种检验被结合成一个名为normaltest的常规方法,用于在 SciPy 的stats模块中进行正态性检验,此外还包括其他几种正态性检验。

Wilcoxon 秩和检验是两样本 t 检验的非参数替代方法。与 t 检验不同,秩和检验不是通过比较样本均值来量化总体是否具有不同的分布。相反,它将样本数据合并并按大小排序。检验统计量由样本中元素最少的那一组的秩和生成。从这里开始,像往常一样,我们生成值,用于检验两个总体是否具有相同分布的原假设。

Kruskal-Wallis 检验是单因素方差分析(ANOVA)检验的非参数替代方法。与秩和检验类似,它利用样本数据的排序生成检验统计量和值,用以检验所有总体的中位数是否相同的原假设。与单因素方差分析一样,我们只能检测所有总体的中位数是否相同,而不能指出差异所在。要做这个,我们还需要使用额外的检验方法。

在本食谱中,我们使用了 Kruskal-Wallis 测试来判断我们的三个样本对应的群体之间是否存在显著差异。我们发现了一个差异,具有非常小的 值。接着我们使用秩和检验来确定群体之间差异的具体位置。在这里,我们发现样本 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 在浏览器中创建交互式图表:

  1. 首先,我们需要创建一些样本数据来绘制图表:

    date_range = pd.date_range("2020-01-01", periods=50)
    
    data = rng.normal(0, 3, size=50).cumsum()
    
    series = pd.Series(data, index=date_range)
    
  2. 接下来,我们必须使用 output_file 函数指定一个输出文件,用于存储图表的 HTML 代码:

    bk.output_file("sample.html")
    
  3. 现在,我们将创建一个新的图形,并设置标题和坐标轴标签,同时将 轴类型设置为 datetime,以便我们的日期索引能够正确显示:

    fig = bk.figure(title="Time series data", 
    
                           x_axis_label="date",
    
                           x_axis_type="datetime",
    
                           y_axis_label="value")
    
  4. 我们将数据作为一条线添加到图形中:

    fig.line(date_range, series)
    
  5. 最后,我们可以使用 show 函数或 save 函数来保存或更新指定输出文件中的 HTML。在这里我们使用 show 来使图表在浏览器中打开:

    bk.show(fig)
    

Bokeh 图表不是静态对象,应该通过浏览器进行交互。为了进行比较,这里使用matplotlib重新创建了 Bokeh 图表中将显示的数据:

图 6.4 – 使用 Matplotlib 创建的时间序列数据图

图 6.4 – 使用 Matplotlib 创建的时间序列数据图

图 6.4 – 使用 Matplotlib 创建的时间序列数据图

Bokeh 的真正优势在于其能够将动态、交互式的图表插入到网页和文档中(例如,Jupyter 笔记本),这样读者就可以深入查看绘制数据的细节。

它是如何工作的…

Bokeh 使用 JavaScript 库在浏览器中渲染图表,数据由 Python 后端提供。这样做的优点是,用户可以自行检查图表。例如,我们可以放大以查看可能隐藏的图表细节,或自然地平移数据。此食谱中给出的示例只是使用 Bokeh 可以实现的功能的一个尝试。

figure例程创建一个表示图表的对象,我们向其中添加元素——例如,通过数据点的线——就像我们在 Matplotlib Axes对象中添加图表一样。在这个食谱中,我们创建了一个简单的 HTML 文件,其中包含 JavaScript 代码来渲染数据。每次保存或调用show例程时,这段 HTML 代码会被导出到指定文件。实际上,值越小,我们就越能确信假设的总体均值是正确的。

还有更多…

Bokeh 的功能远远超出了这里的描述。Bokeh 图表可以嵌入到诸如 Jupyter 笔记本之类的文件中,而这些文件也会在浏览器中渲染,或者嵌入到现有的网站中。如果你使用的是 Jupyter 笔记本,应该使用output_notebook例程,而不是output_file例程,将图表直接打印到笔记本中。它具有多种不同的绘图样式,支持在图表之间共享数据(例如,数据可以在一个图表中选择并在其他图表中突出显示),并支持流数据。

进一步阅读

统计学和统计理论方面有大量的教材。以下书籍是本章所涉及的统计学内容的良好参考:

  • Mendenhall, W., Beaver, R., 和 Beaver, B.(2006 年),概率与统计导论。第 12 版,(加利福尼亚州贝尔蒙特:Thomson Brooks/Cole)。

  • Freedman, D., Pisani, R., 和 Purves, R.(2007 年),统计学。纽约:W.W. Norton。

pandas 文档(pandas.pydata.org/docs/index.html)和以下的 pandas 书籍是学习 pandas 的好参考:

  • McKinney, W.(2017 年),Python 数据分析。第二版,(Sebastopol:O'Reilly Media, Inc,美国)。

SciPy 文档 (docs.scipy.org/doc/scipy/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 包(prophet)用于自动建模时间序列数据。这些包可以通过你喜欢的包管理器安装,如pip

python3.10 -m pip install statsmodels sklearn prophet

由于依赖关系的原因,Prophet 包在某些操作系统上安装可能会遇到困难。如果安装prophet时出现问题,你可以尝试使用 Python 的 Anaconda 发行版及其包管理器conda,它更严格地处理依赖关系:

conda install prophet

注意

Prophet 库的早期版本(1.0 版本之前)被称为fbprophet,而较新的 Prophet 版本仅称为prophet

最后,我们还需要一个名为tsdata的小模块,它包含在本章的仓库中。该模块包含一系列用于生成示例时间序列数据的工具。

本章的代码可以在 GitHub 仓库的Chapter 07文件夹中找到,链接为github.com/PacktPublishing/Applying-Math-with-Python-2nd-Edition/tree/main/Chapter%2007

使用基础的线性回归

线性回归是一种用于建模两个数据集之间依赖关系的工具,从而最终能够利用这个模型进行预测。这个名称来源于我们基于第二组数据对第一组数据形成一个线性模型(直线)。在文献中,我们希望建模的变量通常被称为响应变量,而我们在这个模型中使用的变量则被称为预测变量。

在本章中,我们将学习如何使用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包进行基础的线性回归。

如何操作...

以下步骤概述了如何使用statsmodels包对两个数据集执行简单线性回归:

  1. 首先,我们生成一些可以分析的示例数据。我们将生成两组数据,展示一个良好的拟合和一个不太好的拟合:

    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)
    
  2. 执行回归分析的一个好起点是绘制数据集的散点图。我们将在同一坐标轴上进行此操作:

    fig, ax = plt.subplots()
    
    ax.scatter(x, y1, c="k", marker="x",
    
        label="Good correlation")
    
    ax.scatter(x, y2, c="k", marker="o",
    
        label="Bad correlation")
    
    ax.legend()
    
    ax.set_xlabel("X"),
    
    ax.set_ylabel("Y")
    
    ax.set_title("Scatter plot of data with best fit lines")
    
  3. 我们需要使用sm.add_constant实用程序例程,以便建模步骤包含常数值:

    pred_x = sm.add_constant(x)
    
  4. 现在,我们可以为我们的第一组数据创建一个OLS模型,并使用fit方法来拟合模型。然后,我们使用summary方法打印数据的摘要:

    model1 = sm.OLS(y1, pred_x).fit()
    
    print(model1.summary())
    
  5. 我们对第二组数据重复进行模型拟合,并打印摘要:

    model2 = sm.OLS(y2, pred_x).fit()
    
    print(model2.summary())
    
  6. 现在,我们使用linspace创建一个新的值范围,这些值可以用来在散点图上绘制趋势线。我们需要添加constant列,以便与我们已经创建的模型交互:

    model_x = sm.add_constant(np.linspace(0, 5))
    
  7. 接下来,我们对模型对象使用predict方法,这样我们就可以使用模型预测在之前步骤中生成的每个值对应的响应值:

    model_y1 = model1.predict(model_x)
    
    model_y2 = model2.predict(model_x)
    
  8. 最后,我们将之前两步中计算的模型数据绘制在散点图上:

    ax.plot(model_x[:, 1], model_y1, 'k')
    
    ax.plot(model_x[:, 1], model_y2, 'k--')
    

散点图以及我们添加的最佳拟合线(模型)可以在下图中看到:

图 7.1 - 使用最小二乘回归计算的最佳拟合线的散点图。

图 7.1 - 使用最小二乘回归计算的最佳拟合线的散点图。

实线表示拟合到相关性较好的数据(由x符号标记)的线,而虚线表示拟合到相关性较差的数据(由点标记)的线。从图中可以看出,两个最佳拟合线非常相似,但拟合到噪音较多的数据的线(虚线)已经偏离了在步骤 1中定义的真实模型

它是如何工作的...

初等数学告诉我们,直线方程由以下公式给出:

这里,是直线与-轴的交点,通常称为-截距,是直线的斜率。在线性回归的背景下,我们试图找到响应变量和预测变量之间的关系,其形式为一条直线,从而实现以下情况:

这里,是现在需要求解的参数。我们可以用另一种方式写出这个公式,如下所示:

在这里,是一个误差项,一般来说,它依赖于。为了找到“最佳”模型,我们需要找到参数的值,使得误差项最小化(在适当的意义上)。找到使得误差最小化的参数值的基本方法是最小二乘法,这也为这里使用的回归类型命名为:普通最小二乘法。一旦我们使用此方法建立了响应变量与预测变量之间的关系,接下来的任务是评估这个模型到底多么准确地表示了这种关系。为此,我们根据以下方程形成残差

我们对每个数据点都做了相同的处理,。为了对我们建模数据之间关系的准确性进行严格的统计分析,我们需要残差满足某些假设。首先,我们需要它们在概率上是独立的。其次,我们需要它们围绕 0 呈正态分布,并且具有相同的方差(在实践中,我们可以稍微放宽这些假设,仍然可以对模型的准确性做出合理的评估)。

在这个实例中,我们通过线性关系从预测数据生成了响应数据。我们创建的两个响应数据集之间的差异是每个值的误差“大小”。对于第一个数据集y1,残差呈正态分布,标准差为 0.5;而对于第二个数据集y2,残差的标准差为 5.0。我们可以在图 7.1的散点图中看到这种变化,其中y1的数据通常非常接近最佳拟合线——这与生成数据时使用的实际关系非常相符——而y2的数据则远离最佳拟合线。

来自statsmodels包的OLS对象是普通最小二乘回归的主要接口。我们将响应数据和预测数据作为数组提供。为了在模型中有一个常数项,我们需要在预测数据中添加一列 1。sm.add_constant函数是一个用于添加常数列的简单工具。OLS类的fit方法计算模型的参数,并返回一个结果对象(model1model2),该对象包含最佳拟合模型的参数。summary方法创建一个包含有关模型的信息和拟合优度统计的字符串。predict方法将模型应用于新数据。顾名思义,它可以用来基于模型进行预测。

在摘要中报告了两个基本统计量,它们为我们提供了拟合情况的信息。第一个是 值,或其调整版本,用来衡量模型解释的变异性与总变异性的比值。该数字定义如下。首先,定义以下量:

在这里, 是之前定义的残差, 是数据的均值。然后我们定义了 及其调整后的对应值:

在后面的方程中, 是样本的大小, 是模型中的变量数(包括 -截距 )。更高的值表示更好的拟合,最佳值为 1。请注意,普通的 值往往过于乐观,特别是当模型包含更多变量时,因此通常更好地查看调整后的版本。

第二个是 F 统计量的 p 值。这是一个假设检验,检验模型中至少有一个系数不为零。与 ANOVA 检验类似(见 使用 ANOVA 检验假设第六章),一个小的 p 值表明模型显著,意味着该模型更有可能准确地描述数据。

在这个模型中,第一个模型 model1 的调整后 值为 0.986,表明该模型与数据拟合得非常紧密,且 p 值为 6.43e-19,表明具有很高的显著性。第二个模型的调整后 值为 0.361,表示该模型与数据的拟合度较低,且 p 值为 0.000893,也表明具有较高的显著性。尽管第二个模型与数据的拟合度较低,但在统计学上,这并不意味着它没有用。该模型依然是显著的,虽然其显著性不如第一个模型,但它并没有解释数据中所有的变异性(或者至少没有解释其中的显著部分)。这可能表明数据中存在额外的(非线性)结构,或者数据之间的相关性较弱,这意味着响应数据与预测数据之间的关系较弱(由于我们构造数据的方式,我们知道后者是正确的)。

还有更多...

简单线性回归是统计学家工具箱中一个很好的通用工具。它非常适合用于发现两个已知(或怀疑)以某种方式相关的数据集之间的关系。统计学上衡量一个数据集依赖于另一个数据集的程度被称为相关性。我们可以使用相关系数来衡量相关性,如斯皮尔曼等级相关系数。一个高的正相关系数表示数据之间存在强的正相关关系,例如在这个示例中的情况,而高的负相关系数则表示强的负相关关系,在这种情况下,拟合数据的最佳直线的斜率为负值。相关系数为 0 表示数据不相关:数据之间没有关系。

如果数据集之间有明显的关系,但不是线性(直线)关系,那么它可能遵循多项式关系,例如,一个值与另一个值的平方相关。有时,你可以对其中一个数据集进行变换,比如使用对数变换,然后使用线性回归来拟合变换后的数据。当两个数据集之间存在幂律关系时,对数尤其有用。

scikit-learn包也提供了执行普通最小二乘回归的功能。然而,它们的实现没有提供一种生成拟合优度统计量的简便方法,而这些统计量在单独进行线性回归时通常非常有用。OLS对象的summary方法非常方便,可以生成所有所需的拟合信息,并提供估计的系数。

使用多元线性回归

如前面的示例中所见,简单线性回归非常适合用来建立一个响应变量与一个预测变量之间的简单模型。不幸的是,通常我们会有一个单一的响应变量,它依赖于多个预测变量。此外,我们可能并不知道哪些变量能够作为好的预测变量。为此,我们需要多元线性回归。

在这个示例中,我们将学习如何使用多元线性回归来探索响应变量与多个预测变量之间的关系。

准备工作

对于本示例,我们将需要导入 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

让我们看看如何将多元线性回归模型拟合到一些数据上。

如何操作...

以下步骤展示了如何使用多元线性回归来探索多个预测变量与响应变量之间的关系:

  1. 首先,我们需要创建要分析的预测数据。这将采用一个具有四个项的 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)
    
    })
    
  2. 接下来,我们将仅使用前两个变量生成响应数据:

    residuals = rng.normal(0.0, 12.0, size=100)
    
    Y = -10.0 + 5.0*p_vars["X1"] - 2.0*p_vars["X2"] +    residuals
    
  3. 现在,我们将生成响应数据与每个预测变量的散点图:

    fig, (ax1, ax2, ax3) = plt.subplots(1, 3, sharey=True,
    
        tight_layout=True)
    
    ax1.scatter(p_vars["X1"], Y, c="k")
    
    ax2.scatter(p_vars["X2"], Y, c="k")
    
    ax3.scatter(p_vars["X3"], Y, c="k")
    
  4. 然后,我们将为每个散点图添加坐标轴标签和标题,因为这是一个好的做法:

    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") 
    

结果图可以在以下图中看到:

图 7.2 - 响应数据与每个预测变量的散点图

图 7.2 - 响应数据与每个预测变量的散点图

正如我们所看到的,响应数据与前两个预测列X1X2之间似乎存在一些相关性。这是我们预期的,考虑到我们生成数据的方式。

  1. 我们使用相同的OLS类执行多元线性回归; 即,提供响应数组和预测器 DataFrame:

    model = sm.OLS(Y, p_vars).fit()
    
    print(model.summary())
    

print语句的输出的前半部分如下:

                   OLS Regression Results           
===========================================
Dep. Variable:        y            R-squared:0.769
Model:              OLS             Adj. R-squared:0.764
Method: Least Squares  F-statistic:161.5
Date: Fri, 25 Nov 2022 Prob (F-statistic):1.35e-31
Time: 12:38:40      Log-Likelihood:-389.48
No. Observations: 100 AIC:        785.0           
Df Residuals: 97              BIC:     792.8
Df Model: 2
Covariance Type:  nonrobust 

这给我们提供了模型摘要、各种参数以及各种拟合度特征,如R-squared值(0.77 和 0.762),这表明拟合是合理的但不是非常好的。输出的后半部分包含有关估计系数的信息:

=========================================
         coef           std err      t     P>|t|  [0.025    0.975]
-----------------------------------------------------------------------
const -11.1058  2.878  -3.859 0.000  -16.818  -5.393
X1      4.7245  0.301  15.672   0.00    4.126      5.323
X2     -1.9050  0.164 -11.644  0.000   -2.230   -1.580
=========================================
Omnibus:           0.259        Durbin-Watson:     1.875
Prob(Omnibus): 0.878       Jarque-Bera (JB):  0.260
Skew: 0.115             Prob(JB):         0.878
Kurtosis: 2.904         Cond. No          38.4
=========================================
Notes:
[1] Standard Errors assume that the covariance matrix of the errors is correctly specified.

在摘要数据中,我们可以看到X3变量不显著,因为其 p 值为 0.66。

  1. 由于第三个预测变量不显著,我们消除了这一列并再次进行回归:

    second_model = sm.OLS(
    
        Y, p_vars.loc[:, "const":"X2"]).fit()
    
    print(second_model.summary())
    

这导致拟合度统计数据略微增加。

工作原理...

多元线性回归的工作方式与简单线性回归基本相同。我们在这里遵循与上一个示例相同的步骤,使用statsmodels包将多元模型拟合到我们的数据中。当然,在幕后有一些差异。我们使用多元线性回归生成的模型在形式上与上一个示例中的简单线性模型非常相似。它具有以下形式:

这里, 是响应变量, 代表预测变量, 是误差项, 是要计算的参数。对于这个背景,同样需要满足以下要求:残差必须是独立的,并且服从均值为 0 和公共标准差的正态分布。

在这个示例中,我们将预测数据提供为 Pandas DataFrame,而不是普通的 NumPy 数组。请注意,在我们打印的摘要数据中采用了列名。与第一个示例不同,使用基本线性回归,我们在这个 DataFrame 中包含了常数列,而不是使用statsmodels中的add_constant实用程序。

在第一次回归的输出中,我们可以看到模型拟合得相当好,调整后的值为 0.762,并且显著性很高(我们可以通过查看回归 F 统计量的 p 值来判断)。然而,仔细查看各个参数,我们可以发现前两个预测值是显著的,但常数项和第三个预测变量则不太显著。特别是,第三个预测变量X3与 0 没有显著差异,p 值为 0.66。由于我们的响应数据在构建时没有使用该变量,这一点并不意外。在分析的最后一步,我们重复回归分析,去除预测变量X3,这对拟合结果略有改进。

使用对数回归进行分类

对数回归解决的问题与普通线性回归不同。它通常用于分类问题,通常我们希望根据多个预测变量将数据分类为两个不同的组。该技术背后进行的是一个通过对数运算实现的转换。原始的分类问题被转化为构建对数几率模型的问题。这个模型可以通过简单的线性回归完成。我们对线性模型应用逆转换,最终得到一个模型,表示给定预测数据的情况下,期望结果发生的概率。我们在这里应用的转换被称为逻辑函数,它为该方法命名。然后,我们获得的概率可以用于解决我们最初想要解决的分类问题。

在本教程中,我们将学习如何执行逻辑回归,并在分类问题中使用这种技术。

准备工作

对于本教程,我们需要导入 NumPy 包作为np,导入 Matplotlib 的pyplot模块作为plt,导入 Pandas 包作为pd,并使用以下命令创建 NumPy 默认的随机数生成器实例:

from numpy.random import default_rng
rng = default_rng(12345)

我们还需要从scikit-learn包中导入几个组件,以执行逻辑回归。可以按如下方式导入:

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import classification_report

如何操作...

按照以下步骤使用逻辑回归解决简单的分类问题:

  1. 首先,我们需要创建一些样本数据,用来演示如何使用逻辑回归。我们从创建预测变量开始:

    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. 现在,我们使用三个预测变量中的两个,创建一个响应变量,作为一系列布尔值:

    score = 4.0 + df["var1"] - df["var3"]
    
    Y = score >= 0
    
  3. 接下来,我们将根据响应变量的样式,在散点图中绘制var3数据与var1数据的关系,这些变量用于构造响应变量:

    fig1, ax1 = plt.subplots()
    
    ax1.plot(df.loc[Y, "var1"], df.loc[Y, "var3"],
    
        "ko", label="True data")
    
    ax1.plot(df.loc[~Y, "var1"], df.loc[~Y, "var3"],
    
        "kx", label="False data")
    
    ax1.legend()
    
    ax1.set_xlabel("var1")
    
    ax1.set_ylabel("var3")
    
    ax1.set_title("Scatter plot of var3 against var1")
    

生成的图表可以在下图中看到:

图 7.3 – 数据与数据的散点图,分类已标注

图 7.3 – var3 数据与 var1 数据的散点图,分类结果已标注

  1. 接下来,我们从scikit-learn包中创建一个LogisticRegression对象,并将模型拟合到我们的数据上:

    model = LogisticRegression()
    
    model.fit(df, Y)
    
  2. 接下来,我们准备一些与用于拟合模型的数据不同的额外数据,以测试我们模型的准确性:

    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
    
  3. 然后,我们基于逻辑回归模型生成预测结果:

    test_predicts = model.predict(test_df)
    
  4. 最后,我们使用scikit-learn中的classification_report工具,打印预测分类与已知响应值的摘要,以测试模型的准确性。我们将这个摘要打印到终端:

    print(classification_report(test_Y, test_predicts))
    

该程序生成的报告如下所示:

              precision    recall      f1-score   support
       False       0.82      1.00        0.90        18
        True        1.00      0.88        0.93        32
accuracy                     0.92                   50
   macro avg     0.91      0.94       0.92         50
weighted avg    0.93      0.92       0.92         50

这里的报告包含了分类模型在测试数据上的表现信息。我们可以看到报告的精确度和召回率都很好,表明假阳性和假阴性识别相对较少。

工作原理...

逻辑回归通过形成对数几率比率(或logit)的线性模型来工作,对于单一预测变量,,其形式如下:

在这里,表示给定预测变量!下,真实结果的概率。重新排列后,这给出了概率的逻辑函数变体:

对数几率的参数是通过最大似然法估计的。

scikit-learnlinear_model模块的LogisticRegression类是一个非常易于使用的逻辑回归实现。首先,我们创建这个类的新模型实例,并根据需要设置任何自定义参数,然后使用fit方法将此对象拟合(或训练)到样本数据上。一旦拟合完成,我们可以使用get_params方法访问已估计的参数。

在拟合模型上使用predict方法允许我们传入新的(未见过的)数据,并对每个样本的分类进行预测。我们还可以使用predict_proba方法获取逻辑函数实际给出的概率估计。

一旦我们建立了一个预测数据分类的模型,就需要对模型进行验证。这意味着我们需要用一些之前未见过的数据来测试模型,并检查它是否能正确分类新数据。为此,我们可以使用classification_report,它接受一组新数据和模型生成的预测值,并计算几个关于模型性能的总结值。第一个报告的值是精确度,它是正确预测的正例数与预测为正的总数之比。它衡量模型在避免错误标记为正例时的表现。第二个报告的值是召回率,它是正确预测的正例数与所有正例(正确预测的正例加上错误预测的负例)的总和之比。它衡量模型在数据集内找到正样本的能力。一个相关的评分(报告中未包含)是准确度,它是正确分类的样本数与总分类数之比。它衡量模型正确标记样本的能力。

我们使用scikit-learn工具生成的分类报告执行了预测结果与已知响应值之间的比较。这是一种常见的验证模型的方法,在实际预测之前进行使用。在这个教程中,我们看到每个类别(TrueFalse)的报告精确度为1.00,表明模型在用这些数据预测分类时表现得非常完美。实际上,模型的精确度达到 100%是非常不可能的。

还有更多...

有很多工具包提供用于分类问题的逻辑回归工具。statsmodels包提供了Logit类用于创建逻辑回归模型。在这个教程中,我们使用了scikit-learn包,它有一个类似的接口。scikit-learn是一个通用的机器学习库,并且为分类问题提供了多种其他工具。

使用 ARMA 建模时间序列数据

时间序列,顾名思义,是在一系列不同时间间隔内跟踪一个值。它们在金融行业中特别重要,因为股票价值随着时间的推移被追踪,并用来做出对未来某个时点值的预测——这被称为预测。来自这种数据的良好预测可以用于做出更好的投资决策。时间序列也出现在许多其他常见的情况中,如天气监测、医学以及任何从传感器中随时间获取数据的地方。

与其他类型的数据不同,时间序列的数据点通常不是独立的。这意味着我们用于建模独立数据的方法可能效果不佳。因此,我们需要使用替代技术来建模具有这种属性的数据。时间序列中的一个值可以依赖于前一个或多个值,依赖的方式有两种。第一种是值与一个或多个前值之间存在直接关系。这是自相关属性,并通过AR模型进行建模。第二种是加到值上的噪声依赖于一个或多个前噪声项。这通过MA模型进行建模。涉及到的项数称为模型的阶数

在本节中,我们将学习如何为平稳时间序列数据创建一个包含 ARMA 项的模型。

准备工作

对于这个食谱,我们需要导入 Matplotlib 的pyplot模块(命名为plt),以及statsmodels包中的api模块(命名为sm)。我们还需要从本书的仓库中的tsdata包导入generate_sample_data函数,该函数使用 NumPy 和 Pandas 生成用于分析的样本数据:

from tsdata import generate_sample_data

为了避免在绘图函数中反复设置颜色,我们在这里做一些一次性的设置来设置绘图颜色:

from matplotlib.rcsetup import cycler
plt.rc("axes", prop_cycle=cycler(c="k"))

通过这一设置,我们现在可以看到如何为一些时间序列数据生成 ARMA 模型。

如何做……

按照以下步骤为平稳时间序列数据创建一个 ARMA 模型:

  1. 首先,我们需要生成我们将分析的样本数据:

    sample_ts, _ = generate_sample_data()
    
  2. 和往常一样,分析的第一步是绘制数据图,以便我们可以直观地识别任何结构:

    ts_fig, ts_ax = plt.subplots()
    
    sample_ts.plot(ax=ts_ax, label="Observed",
    
        ls="--", alpha=0.4)
    
    ts_ax.set_title("Time series data")
    
    ts_ax.set_xlabel("Date")
    
    ts_ax.set_ylabel("Value")
    

结果图可以在下图中看到:

图 7.4 - 我们将分析的时间序列数据图(这些数据似乎没有趋势)

图 7.4 - 我们将分析的时间序列数据图(这些数据似乎没有趋势)

在这里,我们可以看到似乎没有明显的趋势,这意味着数据可能是平稳的(如果一个时间序列的统计特性不随时间变化,则称该序列为平稳的。这通常表现为上升或下降的趋势)。

  1. 接下来,我们计算扩展的 Dickey-Fuller 检验。这是一个检验时间序列是否平稳的假设检验。原假设是时间序列不是平稳的:

    adf_results = sm.tsa.adfuller(sample_ts)
    
    adf_pvalue = adf_results[1]
    
    print("Augmented Dickey-Fuller test:\nP-value:",
    
        adf_pvalue)
    

在这种情况下,报告的adf_pvalue为 0.000376,因此我们拒绝原假设,得出结论:该序列是平稳的。

  1. 接下来,我们需要确定应该拟合的模型阶数。为此,我们将绘制时间序列的自相关函数ACF)和偏自相关函数PACF):

    ap_fig, (acf_ax, pacf_ax) = plt.subplots(
    
        2, 1, 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")
    
    acf_ax.set_xlabel("Lags")
    
    pacf_ax.set_xlabel("Lags")
    
    pacf_ax.set_ylabel("Value")
    
    acf_ax.set_ylabel("Value")
    

我们的时间序列的 ACF 和 PACF 图可以在下图中看到。这些图表明存在 AR 和 MA 过程:

图 7.5 - 样本时间序列数据的 ACF 和 PACF 图

图 7.5 - 样本时间序列数据的 ACF 和 PACF

  1. 接下来,我们为数据创建一个 ARMA 模型,使用tsa模块中的ARIMA类。这个模型将包含 1 阶 AR 组件和 1 阶 MA 组件:

    arma_model = sm.tsa.ARIMA(sample_ts, order=(1, 0, 1))
    
  2. 现在,我们将模型拟合到数据上并得到结果模型。我们将这些结果的汇总打印到终端:

    arma_results = arma_model.fit()
    
    print(arma_results.summary())
    
  3. 对拟合模型给出的汇总数据如下:

                         ARMA Model Results
    
    =========================================
    
    Dep. Variable: y No.             Observations:         366
    
    Model: ARMA(1, 1)               Log Likelihood -513.038
    
    Method: css-mle S.D. of innovations        
    
                                                     0.982
    
    Date: Fri, 01 May 2020 AIC  1034.077
    
    Time: 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
    
    =========================================
    
                        Real  Imaginary  Modulus 
    
    Frequency 
    
    ---------------------------------------------------------
    
    AR.1        1.2059  +0.0000j  1.2059
    
    0.0000
    
    MA.1        1.9271  +0.0000j  1.9271
    
    0.0000
    
    ---------------------------------------------------
    

在这里,我们可以看到 AR 和 MA 组件的估计参数都显著不同于 0。这是因为P >|z|列中的值精确到小数点后 3 位。

  1. 接下来,我们需要验证模型预测的残差(误差)中没有额外的结构。为此,我们绘制残差的 ACF 和 PACF:

    residuals = arma_results.resid
    
    rap_fig, (racf_ax, rpacf_ax) = plt.subplots(
    
        2, 1, tight_layout=True)
    
    sm.graphics.tsa.plot_acf(residuals, ax=racf_ax, 
    
        title="Residual autocorrelation")
    
    sm.graphics.tsa.plot_pacf(residuals, ax=rpacf_ax, 
    
        title="Residual partial autocorrelation")
    
    racf_ax.set_xlabel("Lags")
    
    rpacf_ax.set_xlabel("Lags")
    
    rpacf_ax.set_ylabel("Value")
    
    racf_ax.set_ylabel("Value")
    

残差的 ACF 和 PACF 可以在下图中看到。在这里,我们可以看到除了滞后 0 之外,没有显著的峰值,因此我们可以得出结论,残差中没有剩余的结构:

图 7.6 - 我们模型残差的 ACF 和 PACF

图 7.6 - 我们模型残差的 ACF 和 PACF

  1. 现在我们已经验证了我们的模型没有缺失任何结构,我们将拟合的每个数据点的值绘制到实际的时间序列数据上,以查看该模型是否适合数据。我们在步骤 2中创建的图表中绘制了这个模型:

    fitted = arma_results.fittedvalues
    
    fitted.plot(ax=ts_ax, label="Fitted")
    
    ts_ax.legend()
    

更新后的图可以在下图中看到:

图 7.7 - 拟合的时间序列数据与观察到的时间序列数据的对比图

图 7.7 - 拟合的时间序列数据与观察到的时间序列数据的对比图

拟合值合理地近似了时间序列的行为,但减少了潜在结构中的噪声。

它是如何工作的……

我们在这个示例中使用的 ARMA 模型是建模平稳时间序列行为的一种基本方法。ARMA 模型的两部分分别是 AR 部分和 MA 部分,分别建模项和噪声对前一项和前一噪声的依赖关系。在实践中,时间序列通常不是平稳的,我们必须进行某种转化使其平稳,才能拟合 ARMA 模型。

1 阶 AR 模型具有以下形式:

这里,代表参数,是给定步骤的噪声。噪声通常假设服从均值为 0、标准差在所有时间步骤中保持不变的正态分布。值表示在时间步骤时的时间序列值。在这个模型中,每个值依赖于前一个值,尽管它也可以依赖于一些常数和噪声。当参数严格介于-1 和 1 之间时,模型将产生平稳的时间序列。

阶数为 1 的 MA 模型与 AR 模型非常相似,形式如下:

这里, 的变体是参数。将这两个模型结合起来,我们得到了一个 ARMA(1,1) 模型,其形式如下:

通常,我们可以拥有一个 ARMA(p, q) 模型,其中有一个阶数为 p 的 AR 组件和一个阶数为 q 的 MA 组件。我们通常将量 称为模型的阶数。

确定 AR 和 MA 组件的阶数是构建 ARMA 模型时最棘手的部分。自相关函数(ACF)和偏自相关函数(PACF)提供了一些信息,但即便如此,仍然可能相当困难。例如,AR 过程会在自相关图上表现出某种衰减或振荡模式,随着滞后增加,PACF 会显示出少数几个峰值,并且在此之后的值与零没有显著差异。PACF 图上的峰值数量可以视为过程的阶数。对于 MA 过程,情况正好相反。自相关图上通常会有少数几个显著的峰值,而偏自相关图则表现出衰减或振荡模式。当然,有时这并不显而易见。

在这个示例中,我们绘制了样本时间序列数据的 ACF 和 PACF。在自相关图的图 7.5(上方)中,我们可以看到,峰值迅速衰减,直到它们落入零的置信区间内(意味着它们不显著)。这表明存在 AR 组件。在偏自相关图的图 7.5(下方)中,我们可以看到,只有两个峰值被认为不是零,这表明存在一个阶数为 1 或 2 的 AR 过程。你应该尽量保持模型的阶数尽可能小。因此,我们选择了阶数为 1 的 AR 组件。在这个假设下,偏自相关图中的第二个峰值表明衰减(而不是孤立的峰值),这表明存在 MA 过程。为了简化模型,我们尝试了阶数为 1 的 MA 过程。这就是我们在这个示例中使用的模型的决定过程。请注意,这不是一个精确的过程,你可能做出不同的决定。

我们使用扩展的 Dickey-Fuller 检验来测试我们观察到的时间序列是否平稳。这是一个统计检验,类似于第六章中看到的,数据和统计处理,它通过数据生成一个检验统计量。这个检验统计量进一步生成一个 p 值,用于决定是否接受或拒绝原假设。对于这个检验,原假设是所采样的时间序列中存在单位根。备择假设——我们真正关心的是——是观察到的时间序列是(趋势)平稳的。如果 p 值足够小,那么我们可以在指定的置信度下得出结论,认为观察到的时间序列是平稳的。在这个步骤中,p 值为 0.000(精确到小数点后三位),这表明序列平稳的可能性很高。平稳性是使用 ARMA 模型处理数据时的一个基本假设。

一旦我们确定序列是平稳的,并决定了模型的阶数,我们就需要将模型拟合到我们所拥有的样本数据中。模型的参数通过最大似然估计量来估算。在这个步骤中,参数的学习通过fit方法完成,在第 6 步中。

statsmodels包提供了用于处理时间序列的各种工具,包括计算和绘制时间序列数据的自相关函数(ACF)和偏自相关函数(PACF)、各种统计检验量,以及为时间序列创建 ARMA 模型的工具。还有一些工具用于自动估算模型的阶数。

我们可以使用赤池信息量准则AIC)、贝叶斯信息量准则BIC)和汉南-奎因信息量准则HQIC)来将这个模型与其他模型进行比较,看看哪个模型最好地描述了数据。在每种情况下,较小的值表示更好。

注意

在使用 ARMA 模型对时间序列数据建模时,就像在所有类型的数学建模任务中一样,最好选择最简单的模型来描述数据,达到所需的精度。对于 ARMA 模型,这通常意味着选择最小阶数的模型来描述观察到的数据结构。

还有更多...

找到 ARMA 模型的最佳阶数组合可能相当困难。通常,拟合模型的最佳方法是测试多种不同的配置,选择能够产生最佳拟合的阶数。例如,我们可以在这个步骤中尝试 ARMA(0,1)或 ARMA(1, 0),并与我们使用的 ARMA(1,1)模型进行比较,看看哪个模型产生了最好的拟合,通过考虑摘要中报告的 AIC 统计量。事实上,如果我们构建这些模型,就会看到 ARMA(1,1)的 AIC 值——我们在这个步骤中使用的模型——是这三种模型中“最好的”。

使用 ARIMA 进行时间序列预测

在上一教程中,我们使用 ARMA 模型为平稳时间序列生成了一个模型,该模型由 AR 成分和 MA 成分组成。不幸的是,这个模型无法处理具有某些潜在趋势的时间序列;也就是说,它们不是平稳时间序列。我们通常可以通过对观测到的时间序列进行一次或多次差分,直到得到一个可以通过 ARMA 建模的平稳时间序列。将差分纳入 ARMA 模型中称为 ARIMA 模型。

差分是计算数据序列中连续项之间差异的过程——因此,应用一阶差分相当于从下一个步骤的值中减去当前步骤的值()。这可以去除数据中的上升或下降的线性趋势。这样有助于将任意时间序列转换为一个平稳的时间序列,这个平稳序列可以通过 ARMA 模型进行建模。更高阶的差分可以去除更高阶的趋势,从而达到类似的效果。

ARIMA 模型有三个参数,通常标记为 ,和 这两个顺序参数分别是 AR 成分和 MA 成分的阶数,就像 ARMA 模型一样。第三个参数,,是要应用的差分阶数。具有这些阶数的 ARIMA 模型通常写作 ARIMA ()。当然,在我们开始拟合模型之前,需要确定差分应该采用什么阶数。

在本教程中,我们将学习如何将 ARIMA 模型拟合到非平稳时间序列,并利用该模型生成未来值的预测。

准备工作

对于本教程,我们需要导入 NumPy 包,命名为np,导入 Pandas 包,命名为pd,导入 Matplotlib 的pyplot模块,命名为plt,以及导入statsmodels.api模块,命名为sm。我们还需要使用tsdata模块中的实用工具来生成样本时间序列数据,该模块包含在本书的代码库中:

from tsdata import generate_sample_data

和前面的教程一样,我们使用 Matplotlib 的rcparams来设置本教程中所有图表的颜色:

from matplotlib.rcsetup import cycler
plt.rc("axes", prop_cycle=cycler(c="k"))

如何实现…

以下步骤展示了如何为时间序列数据构建 ARIMA 模型,并利用该模型进行预测:

  1. 首先,我们使用generate_sample_data例程加载样本数据:

    sample_ts, test_ts = generate_sample_data(
    
        trend=0.2, undiff=True)
    
  2. 和往常一样,下一步是绘制时间序列图,以便我们可以直观地识别数据的趋势:

    ts_fig, ts_ax = plt.subplots(tight_layout=True)
    
    sample_ts.plot(ax=ts_ax, label="Observed")
    
    ts_ax.set_title("Training time series data")
    
    ts_ax.set_xlabel("Date")
    
    ts_ax.set_ylabel("Value")
    

结果图可以在下图中看到。如我们所见,数据中存在明显的上升趋势,因此时间序列肯定不是平稳的:

图 7.8 – 样本时间序列图

图 7.8 – 样本时间序列图

数据中存在明显的正趋势。

  1. 接下来,我们对序列进行差分,以查看一次差分是否足以去除趋势:

    diffs = sample_ts.diff().dropna()
    
  2. 现在,我们绘制差分时间序列的 ACF 和 PACF:

    ap_fig, (acf_ax, pacf_ax) = plt.subplots(2, 1, 
    
        tight_layout=True)
    
    sm.graphics.tsa.plot_acf(diffs, ax=acf_ax)
    
    sm.graphics.tsa.plot_pacf(diffs, ax=pacf_ax)
    
    acf_ax.set_ylabel("Value")
    
    acf_ax.set_xlabel("Lag")
    
    pacf_ax.set_xlabel("Lag")
    
    pacf_ax.set_ylabel("Value")
    

自相关函数(ACF)和偏自相关函数(PACF)可以在下图中看到。我们可以看到数据中似乎没有剩余趋势,并且似乎同时存在 AR 组件和 MA 组件:

图 7.9 - 差分时间序列的自相关函数(ACF)和偏自相关函数(PACF)

图 7.9 - 差分时间序列的 ACF 和 PACF

  1. 现在,我们构建一个 ARIMA 模型,使用 1 阶差分、AR 组件和 MA 组件,并将其拟合到观察到的时间序列中,然后打印模型摘要:

    model = sm.tsa.ARIMA(sample_ts, order=(1,1,1))
    
    fitted = model.fit()
    
    print(fitted.summary())
    

打印出的摘要信息如下所示:

             SARIMAX Results                   
===========================================
Dep. Variable:      y   No.  Observations:  366
Model:  ARIMA(1, 0, 1)         Log Likelihood  -513.038
Date:  Fri, 25 Nov 2022         AIC            1034.077
Time:              13:17:24         BIC            1049.687
Sample:      01-01-2020        HQIC           1040.280
                 - 12-31-2020                         
Covariance Type:                  opg           
=========================================
              coef    std err     z       P>|z|   [0.025   0.975]
-----------------------------------------------------------------------
const  -0.0242  0.144  -0.168  0.866   -0.307   0.258
ar.L1   0.8292  0.057  14.512  0.000   0.717    0.941
ma.L1  -0.5189  0.087  -5.954  0.000  -0.690  -0.348
sigma2  0.9653  0.075  12.902  0.000  0.819   1.112
=========================================
Ljung-Box (L1) (Q):      0.04   Jarque-Bera (JB):  0.59
Prob(Q):                        0.84  Prob(JB):                0.74
Heteroskedasticity (H): 1.15   Skew:                    -0.06
Prob(H) (two-sided):     0.44   Kurtosis:                 2.84
=========================================
Warnings:
[1] Covariance matrix calculated using the outer product of gradients (complex-step).

在这里,我们可以看到,我们估计的所有 3 个系数都显著不同于 0,因为它们在P>|z|列中都有 0 到 3 位小数。

  1. 现在,我们可以使用get_forecast方法生成未来值的预测,并从这些预测中生成摘要数据框。此方法还会返回预测的标准误差和置信区间:

    forecast =fitted.get_forecast(steps=50).summary_frame()
    
  2. 接下来,我们在包含时间序列数据的图中绘制预测值及其置信区间:

    forecast["mean"].plot(
    
        ax=ts_ax, label="Forecast", ls="--")
    
    ts_ax.fill_between(forecast.index,
    
                       forecast["mean_ci_lower"],
    
                       forecast["mean_ci_upper"],
    
                       alpha=0.4)
    
  3. 最后,我们将实际的未来值与步骤 1中的样本一起添加到图中进行生成(如果你从步骤 1重新执行绘图命令来重新生成整个图表,可能会更容易):

    test_ts.plot(ax=ts_ax, label="Actual", ls="-.")
    
    ts_ax.legend()
    

最终包含时间序列、预测值和实际未来值的图表如下所示:

图 7.10 - 包含预测值和实际未来值进行比较的示例时间序列

图 7.10 - 包含预测值和实际未来值进行比较的示例时间序列

在这里,我们可以看到,实际的未来值位于预测值的置信区间内。

它是如何工作的……

ARIMA 模型——其阶数为,和——实际上是一个应用于时间序列的 ARMA 模型()。这是通过对原始时间序列数据应用阶数为的差分得到的。这是一种相对简单的生成时间序列数据模型的方法。statsmodels中的ARIMA类用于创建模型,而fit方法则将此模型拟合到数据中。

该模型通过最大似然法拟合数据,最终估计参数——在此情况下,AR 组件的一个参数、MA 组件的一个参数、常数趋势参数和噪声的方差。这些参数在汇总中报告。从输出中可以看到,AR 系数(0.9567)和 MA 常数(-0.6407)的估计值与用于生成数据的真实估计值非常接近,真实值分别为 AR 系数的0.8和 MA 系数的-0.5。这些参数在代码库中的tsdata.py文件的generate_sample_data例程中设置。这在步骤 1中生成了样本数据。你可能注意到,常数参数(1.0101)并非在步骤 1generate_sample_data调用中指定的0.2。实际上,它与时间序列的实际漂移值并不远。

拟合模型上的get_forecast方法(即fit方法的输出)使用该模型对给定步数后的值进行预测。在本食谱中,我们对样本时间序列范围之外的最多 50 个时间步进行预测。在步骤 6中的命令输出是一个 DataFrame,包含预测值、预测的标准误差以及预测的置信区间的上下界(默认情况下为 95%的置信度)。

当为时间序列数据构建 ARIMA 模型时,您需要确保使用最小的差分阶数来去除基础趋势。应用过多的差分被称为过度差分,可能会导致模型出现问题。

使用 ARIMA 预测季节性数据

时间序列通常表现出周期性行为,使得数值的峰值或谷值出现在规律的时间间隔。这种行为在时间序列分析中称为季节性。到目前为止,我们在本章中使用的建模方法显然没有考虑季节性。幸运的是,将标准 ARIMA 模型调整为考虑季节性相对简单,结果就是有时被称为 SARIMA 模型。

在本食谱中,我们将学习如何对包含季节性行为的时间序列数据进行建模,并使用该模型进行预测。

准备工作

对于本食谱,我们需要导入 NumPy 包作为np,导入 Pandas 包作为pd,导入 Matplotlib 的pyplot模块作为plt,导入statsmodelsapi模块作为sm。我们还需要从本书的代码库中包含的tsdata模块中获取创建样本时间序列数据的工具:

from tsdata import generate_sample_data

让我们看看如何生成一个考虑季节性变动的 ARIMA 模型。

如何操作...

按照以下步骤,生成适用于样本时间序列数据的季节性 ARIMA 模型,并使用该模型进行预测:

  1. 首先,我们使用generate_sample_data例程生成一个样本时间序列进行分析:

    sample_ts, test_ts = generate_sample_data(undiff=True,
    
        seasonal=True)
    
  2. 如往常一样,我们的第一步是通过生成样本时间序列的绘图来直观检查数据:

    ts_fig, ts_ax = plt.subplots(tight_layout=True)
    
    sample_ts.plot(ax=ts_ax, title="Time series",
    
        label="Observed")
    
    ts_ax.set_xlabel("Date")
    
    ts_ax.set_ylabel("Value")
    

样本时间序列数据的绘图可以在下图中看到。在这里,我们可以看到数据中似乎存在周期性的峰值:

图 7.11 - 样本时间序列数据的绘图

图 7.11 - 样本时间序列数据的绘图

  1. 接下来,我们绘制样本时间序列的 ACF 和 PACF:

    ap_fig, (acf_ax, pacf_ax) = plt.subplots(2, 1,
    
        tight_layout=True)
    
    sm.graphics.tsa.plot_acf(sample_ts, ax=acf_ax)
    
    sm.graphics.tsa.plot_pacf(sample_ts, ax=pacf_ax)
    
    acf_ax.set_xlabel("Lag")
    
    pacf_ax.set_xlabel("Lag")
    
    acf_ax.set_ylabel("Value")
    
    pacf_ax.set_ylabel("Value")
    

样本时间序列的 ACF 和 PACF 可以在下图中看到:

图 7.12 - 样本时间序列的 ACF 和 PACF

图 7.12 - 样本时间序列的 ACF 和 PACF

这些图可能表明存在 AR 成分,但也存在 PACF 在滞后7时的显著峰值。

  1. 接下来,我们对时间序列进行差分,并生成差分序列的 ACF 和 PACF 图。这应该能让模型的阶数更加清晰:

    diffs = sample_ts.diff().dropna()
    
    dap_fig, (dacf_ax, dpacf_ax) = plt.subplots(
    
        2, 1, tight_layout=True)
    
    sm.graphics.tsa.plot_acf(diffs, ax=dacf_ax, 
    
        title="Differenced ACF")
    
    sm.graphics.tsa.plot_pacf(diffs, ax=dpacf_ax, 
    
        title="Differenced PACF")
    
    dacf_ax.set_xlabel("Lag")
    
    dpacf_ax.set_xlabel("Lag")
    
    dacf_ax.set_ylabel("Value")
    
    dpacf_ax.set_ylabel("Value")
    

差分时间序列的 ACF 和 PACF 可以在下图中看到。我们可以看到,确实存在一个季节性成分,滞后为 7:

图 7.13 - 差分时间序列的 ACF 和 PACF 绘图

图 7.13 - 差分时间序列的 ACF 和 PACF 绘图

  1. 现在,我们需要创建一个包含模型的SARIMAX对象,ARIMA 阶数为(1, 1, 1),SARIMA 阶数为(1, 0, 0, 7)。我们将此模型拟合到样本时间序列并打印摘要统计信息。然后,我们将预测值绘制在时间序列数据的顶部:

    model = sm.tsa.SARIMAX(sample_ts, order=(1, 1, 1), 
    
        seasonal_order=(1, 0, 0, 7))
    
    fitted_seasonal = model.fit()
    
    print(fitted_seasonal.summary())
    

打印到终端的摘要统计的前半部分如下:

             SARIMAX Results                   
===========================================
Dep. Variable:      y   No.  Observations:     366
Model:ARIMA(1, 0, 1)       Log Likelihood  -513.038
Date:   Fri, 25 Nov 2022      AIC    1027.881
Time:  14:08:54                   BIC    1043.481
Sample:01-01-2020            HQIC  1034.081
            - 12-31-2020    
Covariance Type:         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  2.783   0.000   0.795  1.083
=========================================
Ljung-Box (L1) (Q):     0.03        Jarque-Bera (JB): 0.47
Prob(Q):                0.86               Prob(JB): 0.79
Heteroskedasticity (H): 1.15        Skew: -0.03
Prob(H) (two-sided):    0.43         Kurtosis: 2.84
=========================================
Warnings:
[1] Covariance matrix calculated using the outer product of gradients (complex-step).
  1. 这个模型似乎是合理的拟合,所以我们继续预测50个时间步的未来值:

    forecast_result = fitted_seasonal.get_forecast(steps=50)
    
    forecast_index = pd.date_range("2021-01-01", periods=50)
    
    forecast = forecast_result.predicted_mean
    
  2. 最后,我们将预测值添加到样本时间序列的绘图中,并加上这些预测的置信区间:

    forecast.plot(ax=ts_ax, label="Forecasts", ls="--")
    
    conf = forecast_result.conf_int()
    
    ts_ax.fill_between(forecast_index, conf["lower y"],
    
        conf["upper y"], alpha=0.4)
    
    test_ts.plot(ax=ts_ax, label="Actual future", ls="-.")
    
    ts_ax.legend()
    

最终的时间序列绘图,包含预测值和预测的置信区间,可以在下图中看到:

图 7.14 - 样本时间序列的绘图,包含预测值和置信区间

图 7.14 - 样本时间序列的绘图,包含预测值和置信区间

如我们所见,预测演变大致遵循观察数据最后部分的上升轨迹,且预测的置信区间迅速扩展。我们可以看到,实际的未来值在观察数据结束后再次下降,但仍保持在置信区间内。

它是如何工作的……

调整 ARIMA 模型以纳入季节性成分是一个相对简单的任务。季节性成分类似于自回归(AR)成分,其中滞后开始时是一个大于 1 的数值。在这个例子中,时间序列展示了周期为 7(每周)的季节性,这意味着该模型大致由以下方程给出:

在这里, 是参数, 是时间步长 时的噪声。标准的 ARIMA 模型可以轻松调整,以包括这个额外的滞后项。

SARIMA 模型将额外的季节性因素纳入 ARIMA 模型中。它在原有的 ARIMA 模型的三个阶数基础上,增加了四个额外的阶数。这四个额外的参数包括季节性自回归(AR)、差分和移动平均(MA)成分,以及季节周期。在这个例子中,我们将季节性自回归设为阶数 1,没有季节性差分或 MA 成分(阶数为 0),季节周期为 7。这样,我们得到了额外的参数(1, 0, 0, 7),并在本食谱的步骤 5中使用了这些参数。

季节性在建模跨越天数、月份或年份的时间序列数据时显然非常重要。它通常包含基于所占时间框架的某种季节性成分。例如,测量几天内按小时记录的国家电力消耗的时间序列,可能会有一个 24 小时的季节性成分,因为电力消耗在夜间时间段内可能会下降。

如果你分析的时间序列数据没有覆盖足够长的时间段,以便让长期季节性模式显现出来,那么长期季节性模式可能会被隐藏。同样,数据中的趋势也存在这种情况。当你试图从相对较短的观察数据所代表的时间段中生成长期预测时,这可能会导致一些有趣的问题。

SARIMAX 类来自 statsmodels 包,提供了使用季节性 ARIMA 模型建模时间序列数据的方法。事实上,它还可以建模对模型有额外影响的外部因素,这些因素有时被称为外生回归量(我们在这里不进行介绍)。这个类的工作方式与我们在之前的例子中使用的 ARMAARIMA 类非常相似。首先,我们通过提供数据以及 ARIMA 过程和季节性过程的阶数来创建模型对象,然后使用 fit 方法对该对象进行拟合,从而创建一个拟合的模型对象。我们使用 get_forecasts 方法生成一个包含预测值和置信区间数据的对象,然后可以对其进行绘图,从而生成图 7.14

还有更多...

在本食谱中使用的SARIMAX类与之前食谱中使用的ARIMA类的接口略有不同。在写这篇文章时,statsmodels包(v0.11)包含了一个基于SARIMAX类的第二个ARIMA类,因此提供了相同的接口。然而,在写作时,这个新的ARIMA类并不提供与本食谱中使用的相同功能。

使用 Prophet 对时间序列数据进行建模

到目前为止,我们所看到的用于建模时间序列数据的工具是非常通用和灵活的方法,但它们需要一定的时间序列分析知识才能设置。构建一个能够用来做出合理未来预测的好模型所需的分析可能非常繁琐且耗时,可能不适用于您的应用。Prophet 库的设计是为了快速自动建模时间序列数据,无需用户输入,并能够做出未来的预测。

在这个食谱中,我们将学习如何使用 Prophet 从一个示例时间序列中生成预测。

准备工作

对于这个食谱,我们需要导入 Pandas 包作为pd,Matplotlib 的pyplot包作为plt,以及从 Prophet 库中导入Prophet对象,可以使用以下命令导入:

from prophet import Prophet

在 1.0 版本之前,prophet库被称为fbprophet

我们还需要从tsdata模块中导入generate_sample_data例程,该模块包含在本书的代码库中:

from tsdata import generate_sample_data

让我们看看如何使用 Prophet 包来快速生成时间序列数据模型。

如何操作...

以下步骤将向您展示如何使用 Prophet 包为示例时间序列生成预测:

  1. 首先,我们使用generate_sample_data生成示例时间序列数据:

    sample_ts, test_ts = generate_sample_data(
    
        undiffTrue,trend=0.2)
    
  2. 我们需要将示例数据转换为 Prophet 期望的 DataFrame 格式:

    df_for_prophet = pd.DataFrame({
    
        "ds": sample_ts.index,    # dates
    
        "y": sample_ts.values    # values
    
    })
    
  3. 接下来,我们使用Prophet类创建一个模型,并将其拟合到示例时间序列上:

    model = Prophet()
    
    model.fit(df_for_prophet)
    
  4. 现在,我们创建一个新的 DataFrame,其中包含原始时间序列的时间间隔,以及预测的额外时间段:

    forecast_df = model.make_future_dataframe(periods=50)
    
  5. 然后,我们使用predict方法来生成沿着我们刚创建的时间段的预测:

    forecast = model.predict(forecast_df)
    
  6. 最后,我们将预测结果绘制在示例时间序列数据上,并包含置信区间和真实的未来值:

    fig, ax = plt.subplots(tight_layout=True)
    
    sample_ts.plot(ax=ax, label="Observed", title="Forecasts", c="k")
    
    forecast.plot(x="ds", y="yhat", ax=ax, c="k", 
    
        label="Predicted", ls="--")
    
    ax.fill_between(forecast["ds"].values, forecast["yhat_lower"].values, 
    
        forecast["yhat_upper"].values, color="k", alpha=0.4)
    
    test_ts.plot(ax=ax, c="k", label="Future", ls="-.")
    
    ax.legend()
    
    ax.set_xlabel("Date")
    
    ax.set_ylabel("Value")
    

时间序列的图,以及预测,可以在下图中看到:

图 7.15 - 示例时间序列数据的图,包含预测和置信区间

图 7.15 - 示例时间序列数据的图,包含预测和置信区间

我们可以看到,数据到(大约)2020 年 10 月的拟合效果很好,但接着观测数据出现突然而剧烈的下降,导致预测值发生了剧烈变化,并一直持续到未来。这个问题可能通过调整 Prophet 预测的设置来修正。

它是如何工作的...

Prophet 是一个用于基于样本数据自动生成时间序列数据模型的包,几乎不需要额外的用户输入。实际上,它非常易于使用;我们只需要创建 Prophet 类的实例,调用 fit 方法,然后就可以准备好使用模型进行预测并理解我们的数据。

Prophet 类期望数据以特定格式呈现:一个包含名为 ds 的日期/时间索引列和名为 y 的响应数据列(即时间序列值)的 DataFrame。该 DataFrame 应该具有整数索引。一旦模型拟合完成,我们可以使用 make_future_dataframe 方法创建一个正确格式的 DataFrame,其中包含适当的日期间隔,并为未来的时间间隔添加额外的行。接着,predict 方法会接受这个 DataFrame,并利用模型填充这些时间间隔的预测值。我们还可以从这个预测的 DataFrame 中获得其他信息,如置信区间。

还有更多...

Prophet 在不需要用户输入的情况下,能相当好地建模时间序列数据。然而,模型可以通过 Prophet 类中的各种方法进行自定义。例如,我们可以在拟合模型之前,使用 Prophet 类的 add_seasonality 方法提供关于数据季节性的相关信息。

有一些替代的包可以自动生成时间序列数据的模型。例如,流行的机器学习库如 TensorFlow 可以用来建模时间序列数据。

使用签名方法总结时间序列数据

签名是源于粗糙路径理论的数学构造——这是由 Terry Lyons 在 1990 年代建立的数学分支。路径的签名是该路径变动性的抽象描述,并且根据“树状等价”的定义,路径的签名是唯一的(例如,两条通过平移相关的路径将具有相同的签名)。签名与参数化无关,因此签名能够有效处理不规则采样的数据。

最近,签名方法已逐渐进入数据科学领域,作为总结时间序列数据并传递给机器学习管道(以及其他应用)的手段。之所以有效,是因为路径的签名(截断到特定层级)始终是固定大小的,无论用于计算签名的样本数量有多少。签名的一个最简单的应用是分类(以及异常值检测)。为此,我们通常会计算期望签名——一个具有相同基本信号的采样路径族的分量均值,然后将新样本的签名与这个期望签名进行比较,以判断它们是否“接近”。

从实际使用的角度来看,有几个 Python 包可以计算从采样路径获得的签名。我们将在这个配方中使用esig包,它是由 Lyons 及其团队开发的参考包——作者在写作时是该包的维护者。还有其他包,如iisignaturesignatory(基于 PyTorch,但未积极开发)。在这个配方中,我们将计算一个包含噪声的路径集合的签名,将噪声添加到两个已知信号中,并将每个集合的预期签名与真实信号的签名以及彼此的签名进行比较。

准备工作

对于这个配方,我们将使用 NumPy 包(照常导入为np)和 Matplotlib 的pyplot接口,导入为plt。我们还需要esig包。最后,我们将创建 NumPy random库的默认随机数生成器实例,如下所示:

rng = np.random.default_rng(12345)

种子将确保生成的数据是可复现的。

如何操作…

按照以下步骤计算两个信号的签名,并使用这些签名区分每个信号的观察数据:

  1. 首先,让我们定义一些我们将在配方中使用的参数:

    upper_limit = 2*np.pi
    
    depth = 2
    
    noise_variance = 0.1
    
  2. 接下来,我们定义一个工具函数,用于向每个信号添加噪声。我们添加的噪声只是均值为 0、方差如前所定义的高斯噪声:

    def make_noisy(signal):
    
        return signal + rng.normal(0.0, noise_variance, size=signal.shape)
    
  3. 现在,我们定义描述真实信号的函数,这些信号在区间内,并且具有通过从指数分布中提取增量来定义的不规则参数值:

    def signal_a(count):
    
        t = rng.exponential(
    
            upper_limit/count, size=count).cumsum()
    
        return t, np.column_stack(
    
            [t/(1.+t)**2, 1./(1.+t)**2])
    
    def signal_b(count):
    
        t = rng.exponential(
    
            upper_limit/count, size=count).cumsum()
    
        return t, np.column_stack(
    
            [np.cos(t), np.sin(t)])
    
  4. 让我们生成一个示例信号并绘制这些图形,以查看我们的真实信号在平面上的样子:

    params_a, true_signal_a = signal_a(100)
    
    params_b, true_signal_b = signal_b(100)
    
    fig, ((ax11, ax12), (ax21, ax22)) = plt.subplots(
    
        2, 2,tight_layout=True)
    
    ax11.plot(params_a, true_signal_a[:, 0], "k")
    
    ax11.plot(params_a, true_signal_a[:, 1], "k--")
    
    ax11.legend(["x", "y"])
    
    ax12.plot(params_b, true_signal_b[:, 0], "k")
    
    ax12.plot(params_b, true_signal_b[:, 1], "k--")
    
    ax12.legend(["x", "y"])
    
    ax21.plot(true_signal_a[:, 0], true_signal_a[:, 1], "k")
    
    ax22.plot(true_signal_b[:, 0], true_signal_b[:, 1], "k")
    
    ax11.set_title("Components of signal a")
    
    ax11.set_xlabel("parameter")
    
    ax11.set_ylabel("value")
    
    ax12.set_title("Components of signal b")
    
    ax12.set_xlabel("parameter")
    
    ax12.set_ylabel("value")
    
    ax21.set_title("Signal a")
    
    ax21.set_xlabel("x")
    
    ax21.set_ylabel("y")
    
    ax22.set_title("Signal b")
    
    ax22.set_xlabel("x")
    
    ax22.set_ylabel("y")
    

结果图如图 7.16所示。在第一排,我们可以看到每个信号成分在参数区间内的图形。在第二排,我们可以看到将成分与成分绘制出来的图形:

图 7.16 - 信号 a 和 b 的组成部分(上排)以及平面上的信号(下排)

图 7.16 - 信号 a 和 b 的组成部分(上排)以及平面上的信号(下排)

  1. 现在,我们使用esig包中的stream2sig例程来计算两个信号的签名。这个例程将流数据作为第一个参数,深度(决定签名截断的级别)作为第二个参数。我们使用步骤 1中设置的深度作为这个参数:

    signature_a = esig.stream2sig(true_signal_a, depth)
    
    signature_b = esig.stream2sig(true_signal_b, depth)
    
    print(signature_a, signature_b, sep="\n")
    

这将输出两个签名(作为 NumPy 数组),如下所示:

[ 1\. 0.11204198 -0.95648657 0.0062767 -0.15236199 0.04519534 0.45743328]
[ 1.00000000e+00 7.19079669e-04 -3.23775977e-02 2.58537785e-07 3.12414826e+00 -3.12417155e+00 5.24154417e-04]
  1. 现在,我们使用步骤 2中的make_noisy例程生成几个带噪声的信号。我们不仅随机化了区间的参数化,还随机化了样本数量:

    sigs_a = np.vstack([esig.stream2sig(
    
        make_noisy(signal_a(
    
            rng.integers(50, 100))[1]), depth)
    
        for _ in range(50)])
    
    sigs_b = np.vstack([esig.stream2sig(
    
        make_noisy(signal_b(
    
            rng.integers(50, 100))[1]), depth)
    
        for _ in range(50)])
    
  2. 现在,我们计算每一组签名分量的均值,逐个分量生成一个“期望签名”。我们可以将这些与真实的信号签名以及彼此进行比较,以说明签名在区分这两种信号方面的能力:

    expected_sig_a = np.mean(sigs_a, axis=0)
    
    expected_sig_b = np.mean(sigs_b, axis=0)
    
    print(expected_sig_a, expected_sig_b, sep="\n")
    

这将输出两个期望签名,如下所示:

[ 1\. 0.05584373 -0.82468682 0.01351423 -0.1040297 0.0527106 0.36009198]
[ 1\. -0.22457304 -0.05130969 0.07368485 3.0923422 -3.09672887 0.17059484]
  1. 最后,我们打印每个期望签名与相应的真实信号签名之间的最大差异(绝对值),以及两个期望签名之间的最大差异:

    print("Signal a", np.max(
    
        np.abs(expected_sig_a - signature_a)))
    
    print("Signal b", np.max(
    
        np.abs(expected_sig_b -signature_b)))
    
    print("Signal a vs signal b", np.max(
    
        np.abs(expected_sig_a - expected_sig_b)))
    

结果如下所示:

Signal a 0.13179975589137582
Signal b 0.22529211936796972
Signal a vs signal b 3.1963719013938148

我们可以看到,每种情况下期望签名与真实签名之间的差异相对较小,而两个期望签名之间的差异则相对较大。

它是如何工作的…

路径的签名 (取值于 维实数空间)在区间 上是自由张量代数中的一个元素,作用在 上(在这个符号中, 表示路径在时间 时的值。你也可以理解为 )。我们将这个签名表示为 。不拘泥于形式,我们可以将签名理解为以下的一系列元素:

上标表示自由张量中的索引。例如,两个项的索引 (二阶)就像是矩阵的行和列。签名的第一个项总是 1。接下来的 项由每个分量方向的增量给出:如果我们将路径 写作一个向量 ,那么这些项由以下公式给出:

高阶项由这些分量函数的迭代积分给出:

一个路径的完整签名是一个无限序列——因此在实际应用中,我们通常会在特定的深度上截断,这个深度决定了索引的最大大小,例如这里的

这个迭代积分定义在实际中并不特别有用。幸运的是,当我们对路径进行采样并作出一个适度的假设,即假设路径在连续采样点之间是线性的时,我们可以通过计算增量的张量指数的乘积来计算签名。具体地说,如果 是从我们的路径 处采样得到的值,它们分别位于 之间,那么(假设 之间是线性的)签名由以下公式给出:

这里,符号表示自由张量代数中的乘法(这种乘法是由指标的连接定义的 - 因此,例如,左边的第个值和右边的第个值将贡献给结果中的第个值)。请记住,这些是自由张量对象的指数 - 不是通常的指数函数 - 它们是使用熟悉的幂级数定义的:

当一个张量的常数项为零,并且我们将张量代数截断到深度时,那么的值恰好等于这个和的前项之和,这是一个有限和,可以高效计算。

在数据科学领域,路径的签名的重要性在于签名代表了从函数的角度看路径。在路径上定义的任何连续函数都近似(在非常精确的意义上)是在签名上定义的线性函数。因此,任何关于路径的信息也可以从签名中学习到。

esig软件包是建立在用于涉及自由张量代数(和其他类型的代数对象)计算的libalgebra C++库之上的。esig中的stream2sig例程接受一个形式为N(样本数量)xd(维度数量)的 NumPy 数组的路径样本序列,并返回一个包含签名组件的平坦 NumPy 数组,按照这里描述的顺序排列。stream2sig的第二个参数是深度参数,在这个公式中我们选择为 2。签名数组的大小仅由空间的维度和深度确定,并由以下公式给出:

在这个公式中,我们的路径都是二维的,签名计算到深度为 2,因此签名有个元素(请注意,每种情况下样本数量不同,是随机和不规则生成的,但签名在每种情况下都是相同大小)。

现在理论已经讲完,让我们看看这个公式。我们定义了两条真实路径(信号),我们称之为信号 a信号 b。我们从每个信号中抽取样本,通过从指数分布中取参数值,使得(平均)。然后,我们将这些参数值输入到路径的公式中(参见步骤 3)。在后续步骤中,我们还向生成的路径添加均值为 0、方差为 0.1 的高斯噪声。这确保我们的 2 个信号是不规则采样和嘈杂的 - 以展示签名计算的稳健性。

信号 a 由以下公式定义:

因为这是一个在区间 上平滑(光滑)的路径,我们可以使用迭代积分精确计算签名,以(近似)得到序列:

这个结果与这里给出的信号计算签名非常接近:

[1\. 0.11204198 -0.95648657 0.0062767 -0.15236199 0.04519534 0.45743328]

我们预期会有合理的误差,因为我们的采样相对粗糙(只有 100 个点),并且由于随机化的方式,我们的参数值可能在 之前就结束了。

信号 b 由以下公式定义:

该信号的组件函数也是平滑的,因此我们可以通过计算迭代积分来计算签名。按照这个过程,我们发现真实信号的签名如下:

将此与计算值进行比较,我们可以看到我们非常接近:

[ 1.00000000e+00  7.19079669e-04 -3.23775977e-02  2.58537785e-07  3.12414826e+00 -3.12417155e+00  5.24154417e-04]

再次强调,由于采样粗糙且没有精确覆盖参数区间,我们预计会有一些误差(在图 7.16中,您可以看到某些地方的图表上有明显的“直线段”,这表示信号 b 的参数值在某些地方间隔较大)。

步骤 6中,我们为从两个信号中提取的噪声样本生成多个签名,这些样本具有不同且不规则的时间步长(其数量也是在 50 到 100 之间随机抽取的),并加入高斯噪声。这些签名被堆叠成一个数组,具有N = 50行和 7 列(即签名的大小)。我们使用np.mean函数按axis=0计算每个签名数组的行均值。这样为每个信号生成了一个期望签名。接着,我们将这些期望签名与步骤 5中计算的“真实签名”以及彼此之间进行比较。我们可以看到,这两个期望签名之间的差异显著大于每个信号的期望签名和真实签名之间的差异(这里的差异不是统计意义上的)。这说明签名在分类时间序列数据时具有辨别能力。

还有更多……

我们在本食谱中解决的示例问题非常简单。签名已被广泛应用于多个领域,包括败血症检测、手写识别、自然语言处理、人类动作识别和无人机识别。通常,签名与一系列“预处理步骤”结合使用,这些步骤解决了采样数据中的各种缺陷。例如,在本食谱中,我们故意选择了在相关区间内是有界的(并且相对较小的)信号。在实际应用中,数据很可能会更加分散,在这种情况下,签名中的高阶项会迅速增长,这可能对数值稳定性产生重要影响。这些预处理步骤包括领先滞后转换、笔上笔下转换、缺失数据转换和时间积分。每个步骤在使数据更适合基于签名的方法中都扮演了特定角色。

签名包含大量冗余信息。许多高阶项可以通过几何关系从其他项中计算出来。这意味着我们可以在不丢失路径信息的情况下减少所需的项数。这种减少涉及将签名(在自由张量代数中)投影到对数签名(在自由李代数中)。对数签名是一种替代路径表示方式,比签名包含的项数更少。许多性质在对数签名中仍然成立,除了我们在函数逼近的线性性上有所损失(这对于特定应用可能重要,也可能不重要)。

另见

粗糙路径和签名方法的理论显然过于广泛——且迅速扩展——无法在如此短的篇幅中涵盖。以下是一些可以找到有关签名的更多信息的资源:

  • Lyons, T. 和 McLeod, A., 2022. 机器学习中的签名方法 arxiv.org/abs/2206.14674

  • Lyons, T., Caruana, M., 和 Lévy, T., 2004. 由粗糙路径驱动的微分方程, Springer,圣弗卢尔概率暑期学校 XXXIV

  • 在 Datasig 网站上,有几个 Jupyter 笔记本展示了使用签名分析时间序列数据的过程:datasig.ac.uk/examples

深入阅读

一本关于回归统计学的好教材是 Mendenhall, Beaver 和 Beaver 所著的《概率与统计》,如在第六章《与数据和统计打交道》中所提到的。以下几本书为现代数据科学中的分类与回归提供了良好的入门:

  • James, G. 和 Witten, D., 2013. 统计学习导论:R 语言应用. 纽约:Springer。

  • Müller, A. 和 Guido, S., 2016. Python 机器学习导论. Sebastopol: O’Reilly Media。

一本很好的时间序列分析入门书籍可以在以下书籍中找到:

  • Cryer, J. 和 Chan, K., 2008 年。时间序列分析。纽约:Springer。

第八章:几何问题

本章描述了关于二维几何的若干问题的解决方案。几何学是研究点、线和其他图形(形状)特征的数学分支,关注这些图形之间的相互作用及其变换。在本章中,我们将重点讨论二维图形的特征以及这些对象之间的相互作用。

在使用 Python 处理几何对象时,我们必须克服几个问题。最大的问题是表示问题。大多数几何对象占据二维平面上的一个区域,因此不可能存储区域内的每个点。相反,我们必须找到一种更紧凑的方式来表示这个区域,这种方式可以存储较少的点数或其他属性。例如,我们可以存储一组沿对象边界的点,通过这些点重建边界和对象本身。我们还必须将问题重新表述为可以通过代表性数据回答的问题。

第二大问题是将纯粹的几何问题转化为能够被软件理解和解决的形式。这可能相对简单——例如,找出两条直线交点的位置只是解一个矩阵方程——或者它可能非常复杂,这取决于所提问题的类型。解决这些问题的常见技巧是使用更简单的对象来表示问题中的图形,然后通过每个简单对象来解决(希望)更容易的问题。这样,我们就能对原始问题的解决方案有一个大致的了解。

我们将从展示如何使用补丁可视化二维图形开始,然后学习如何判断一个点是否包含在另一个图形内。接下来,我们将继续探讨边缘检测、三角剖分和计算凸包。最后,我们将通过构建贝塞尔曲线来结束本章内容。

本章将介绍以下几个解决方案:

  • 可视化二维几何图形

  • 寻找内部点

  • 在图像中寻找边缘

  • 对平面图形进行三角剖分

  • 计算凸包

  • 构建贝塞尔曲线

让我们开始吧!

技术要求

本章需要使用 NumPy 包和 Matplotlib 包,和往常一样。我们还需要 Shapely 包和 scikit-image 包,它们可以通过你喜欢的包管理工具(如 pip)进行安装:

python3.10 -m pip install numpy matplotlib shapely scikit-image

本章的代码可以在 GitHub 仓库的 Chapter 08 文件夹中找到,地址为 github.com/PacktPublishing/Applying-Math-with-Python-2nd-Edition/tree/main/Chapter%2008

可视化二维几何图形

本章的重点是二维几何学,因此我们的第一项任务是学习如何可视化二维几何图形。这里提到的一些技术和工具可能适用于三维几何图形,但通常这需要更专业的包和工具。在平面上绘制区域的第一种方法可能是选择边界周围的一些点,并使用常规工具绘制它们。然而,这通常效率较低。相反,我们将实现使用 Matplotlib 补丁,这些补丁采用高效的表示方法(例如圆形(圆盘)的圆心和半径),Matplotlib 可以高效地将它们填充到图形中。

几何图形,至少在本书的上下文中,是指任何点、线、曲线或封闭区域(包括边界),其边界是由一组线和曲线组成的。简单的例子包括点和线(显然),矩形、多边形和圆形。

在本食谱中,我们将学习如何使用 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数据文件。

如何实现...

以下步骤展示了如何可视化一个二维几何图形:

  1. 首先,我们从本书的代码库中加载swisscheese-grid-10411.csv文件的数据:

    data = np.loadtxt("swisscheese-grid-10411.csv")
    
  2. 我们创建了一个新的补丁对象,用于表示图形中的一个区域。这个区域将是一个圆形(圆盘),圆心位于原点,半径为1。我们创建了一组新的坐标轴,并将这个补丁添加到坐标轴上:

    fig, ax = plt.subplots()
    
    outer = Circle((0.0, 0.0), 1.0, zorder=0, fc="k")
    
    ax.add_patch(outer)
    
  3. 接下来,我们从步骤 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)
    
  4. 最后,我们设置-和-轴范围,以便显示整个图像,然后关闭坐标轴:

    ax.set_xlim((-1.1, 1.1))
    
    ax.set_ylim((-1.1, 1.1))
    
    ax.set_axis_off()
    

生成的图像是一个瑞士奶酪,如图所示:

![图 8.1 – 瑞士奶酪的图形]

](https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/app-math-py-2e/img/B19085_08_01.jpg)

图 8.1 – 瑞士奶酪的图形

图 8.1中,你可以看到大部分原始的圆盘(黑色阴影部分)已经被后续的圆盘(白色阴影部分)覆盖。

如何运作...

本食谱的关键是CirclePatchCollection对象,它们表示 Matplotlib 坐标轴上图形区域。在本例中,我们创建了一个以原点为中心、半径为1的大圆形补丁,颜色为黑色面,并使用zorder=0将其放置在其他补丁的后面。这个补丁通过add_patch方法添加到Axes对象中。

下一步是创建一个对象,该对象将呈现由我们从 CSV 文件中加载的数据所表示的圆形,这些数据来自步骤 1。这些数据包括 这些关于圆心 和半径 的值(总共有 10,411 个圆)。PatchCollection对象将一系列的补丁合并成一个可以添加到Axes对象中的单一对象。在这里,我们为数据中的每一行添加一个Circle,然后通过add_collection方法将其添加到Axes对象中。请注意,我们将面颜色应用于整个集合,而不是每个单独的Circle元素。我们将面颜色设置为白色(使用facecolor="w"参数),边缘颜色设置为黑色(使用ec="k"),边缘线宽设置为 0.2(使用linewidth=0.2),并且边缘样式设置为连续线条。将这些设置结合在一起,最终就得到了我们的图像。

我们在这里创建的图像被称为瑞士奶酪。它最早在 1938 年由 Alice Roth 在有理逼近理论中使用,随后被重新发现,类似的构造方法自此被多次使用。我们使用这个例子是因为它由一个大的单独部分和一大堆小的单独部分组成。Roth 的瑞士奶酪是一个平面集合的例子,具有正的面积但没有拓扑内部。这意味着我们无法找到任何正半径的圆盘,完全包含在该集合内(真令人惊讶,竟然有这样的集合存在!)。更重要的是,定义在这个瑞士奶酪上的连续函数,无法通过有理函数进行一致逼近。这个特性使得类似的构造在一致代数理论中变得非常有用。

Circle类是更通用的Patch类的子类。还有许多其他Patch类,表示不同的平面图形,比如PolygonPathPatch,它们表示由路径(曲线或曲线集合)所围成的区域。这些可以用来生成复杂的补丁,并可以在 Matplotlib 图形中呈现。集合可以用来同时应用设置到多个补丁对象,尤其是在这个例子中,当你有大量对象需要以相同风格渲染时,这非常有用。

还有更多……

在 Matplotlib 中有许多不同类型的补丁可用。在这个例子中,我们使用了Circle补丁类,它表示坐标轴上的圆形区域。还有Polygon补丁类,它表示一个多边形(可以是规则多边形,也可以是其他形状)。此外,还有PatchPath对象,它表示由曲线围成的区域,曲线不一定是由直线段组成的。这与许多矢量图形软件中构造阴影区域的方式类似。

除了 Matplotlib 中的单一补丁类型外,还有一些集合类型,它们将多个补丁聚集在一起,作为一个单一对象使用。在本食谱中,我们使用了 PatchCollection 类来聚集大量的 Circle 补丁。还有一些更专业的补丁集合,可以自动生成这些内部补丁,而不是我们自己手动生成它们。

另请参阅

关于瑞士奶酪在数学中的更详细历史,可以参考以下传记文章:Daepp, U., Gauthier, P., Gorkin, P., 和 Schmieder, G., 2005. 爱丽丝在瑞士:爱丽丝·罗斯的生活与数学数学智能者,27(1),第 41-54 页。

寻找内点

在编程环境中处理二维图形的一个问题是,无法存储图形内部的所有点。相反,我们通常只存储少量点,这些点以某种方式表示该图形。在大多数情况下,这些点是由线条连接的,描述了图形的边界。这种方法在内存上更为高效,并且使得我们能够方便地通过 Matplotlib 补丁等工具将图形可视化。然而,这种方法使得确定一个点或另一个图形是否位于给定图形内部变得更加困难。这在许多几何问题中是一个至关重要的问题。

在本食谱中,我们将学习如何表示几何图形,并确定一个点是否位于图形内部。

准备工作

在本食谱中,我们将需要导入整个 matplotlib 包,并将其命名为 mpl,以及将 pyplot 模块导入为 plt

import matplotlib as mpl
import matplotlib.pyplot as plt

我们还需要从 Shapely 包的 geometry 模块导入 PointPolygon 对象。Shapely 包包含许多用于表示、操作和分析二维几何图形的例程和对象:

from shapely.geometry import Polygon, Point

这两个类将用于表示我们的二维几何图形。让我们看看如何使用这些类来判断一个多边形是否包含一个点。

如何实现...

以下步骤展示了如何创建一个 Shapely 表示的多边形,并测试一个点是否位于该多边形内:

  1. 创建一个示例多边形进行测试:

    polygon = Polygon(
    
        [(0, 2), (-1, 1), (-0.5, -1), (0.5, -1), (1, 1)],
    
    )
    
  2. 接下来,我们将多边形绘制到一个新图形中。首先,我们需要将多边形转换为一个 Matplotlib Polygon 补丁,以便将其添加到图形中:

    fig, ax = plt.subplots()
    
    poly_patch = mpl.patches.Polygon(
    
        polygon.exterior.coords,
    
        ec=(0,0,0,1), fc=(0.5,0.5,0.5,0.4))
    
    ax.add_patch(poly_patch)
    
    ax.set(xlim=(-1.05, 1.05), ylim=(-1.05, 2.05))
    
    ax.set_axis_off()
    
  3. 现在,我们需要创建两个测试点,其中一个点将在多边形内,另一个点将在多边形外:

    p1 = Point(0.0, 0.0)
    
    p2 = Point(-1.0, -0.75)
    
  4. 我们在多边形上绘制并标注这两个点,以显示它们的位置:

    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))
    
  5. 最后,我们使用 contains 方法测试每个点是否位于多边形内,然后将结果打印到终端:

    print("p1 inside polygon?", polygon.contains(p1))  # True
    
    print("p2 inside polygon?", polygon.contains(p2)) 
    
    # False
    

结果显示,第一个点 p1 位于多边形内,而第二个点 p2 不在其中。通过下图也可以看到这一点,图中清楚地显示一个点位于阴影多边形内,而另一个点则不在其中:

图 8.2 – 多边形区域内外的点

图 8.2 – 多边形区域内外的点

一旦我们绘制出点和多边形,就很容易(对我们来说)看到polygon对象上的contains方法也能正确分类这些点。

它是如何工作的...

Shapely 的Polygon类是多边形的表示,它将顶点存储为点。由外边界围成的区域——存储顶点之间的五条直线——对我们来说是显而易见的,眼睛很容易识别出来,但“在边界内”的概念很难以一种计算机容易理解的方式定义。甚至给出“位于给定曲线内”这一概念的正式数学定义也不是一件简单的事情。

有两种主要方法可以确定一个点是否位于一个简单的封闭曲线内——即一个从同一点开始并结束的曲线,且不包含自交点。第一种方法使用一个数学概念,即多边形的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

canny例程实现了边缘检测算法。让我们看看如何使用它。

如何操作…

按照以下步骤,学习如何使用scikit-image包在图像中找到边缘:

  1. 从源文件加载图像数据。这可以在本章的 GitHub 仓库中找到。关键的是,我们传入as_gray=True以将图像加载为灰度图:

    image = imread("mandelbrot."ng", as_gray=True)
    

以下是原始图像,供参考。集合本身由白色区域表示,正如你所看到的,边界由较暗的阴影表示,非常复杂:

图 8.3 – 使用 Python 生成的曼德尔布罗集合图

图 8.3 – 使用 Python 生成的曼德尔布罗集合图

  1. 接下来,我们使用canny例程,它需要从scikit-image包的features模块中导入。对于此图像,sigma值设置为0.5

    edges = canny(image, sigma=0.5)
    
  2. 最后,我们将edges图像添加到一个新图形中,并使用灰度(反向)颜色映射:

    fig, ax = plt.subplots()
    
    ax.imshow(edges, cmap="gray_r")
    
    ax.set_axis_off()
    

检测到的边缘可以在下图中看到。边缘查找算法已经识别出了曼德尔布罗集合边界的大部分可见细节,尽管它并不完美(毕竟这只是一个估计)。

图 8.4 – 使用 scikit-image 包的 Canny 边缘检测算法找到的曼德尔布罗集合的边缘

图 8.4 – 使用 scikit-image 包的 Canny 边缘检测算法找到的曼德尔布罗集合的边缘

我们可以看到,边缘检测成功识别出了曼德尔布罗集合边缘的复杂性。当然,真实曼德尔布罗集合的边界是一个分形,具有无限的复杂性。

它是如何工作的...

scikit-image包提供了各种工具和类型,用于操作和分析源自图像的数据。顾名思义,canny例程使用 Canny 边缘检测算法来检测图像中的边缘。该算法通过图像中的强度梯度来检测边缘,梯度较大的地方即为边缘。它还执行一些滤波操作,以减少在检测到的边缘中出现的噪声。

我们提供的sigma关键字值是应用于图像的高斯平滑的标准差,该平滑操作在计算边缘检测的梯度之前进行。这有助于我们去除图像中的一些噪声。我们设置的值(0.5)比默认值(1)小,但在这种情况下能给我们更好的分辨率。如果值过大,则会掩盖曼德尔布罗集合边界中一些细节。

三角剖分平面图形

正如我们在第三章《微积分与微分方程》中看到的那样,我们通常需要将一个连续区域拆分成更小、更简单的区域。在早期的配方中,我们将一个实数区间拆分成一组较小的区间,每个区间都有一个较小的长度。这个过程通常称为离散化。在本章中,我们处理的是二维图形,因此我们需要这个过程的二维版本。为此,我们将一个二维图形(在本例中是多边形)拆分成一组更小、更简单的多边形。所有多边形中最简单的是三角形,所以这是进行二维离散化的一个很好的起点。寻找一个可以“平铺”几何图形的三角形集合的过程称为三角剖分

在本例中,我们将学习如何使用 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

让我们看看如何使用 triangulate 例程对一个多边形进行三角剖分。

如何操作...

以下步骤展示了如何使用 Shapely 包对带孔的多边形进行三角剖分:

  1. 首先,我们需要创建一个表示我们希望进行三角剖分的图形的 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]])]
    
    )
    
  2. 现在,我们应该绘制图形,以便了解我们将要处理的区域:

    fig, ax = plt.subplots()
    
    plt_poly = mpl.patches.Polygon(polygon.exterior.coords,
    
        ec=(0,0,0,1), fc=(0.5,0.5,0.5,0.4), zorder=0)
    
    ax.add_patch(plt_poly)
    
    plt_hole = mpl.patches.Polygon(
    
        polygon.interiors[0].coords, 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 – 带孔的样本多边形

图 8.5 – 带孔的样本多边形

  1. 我们使用 triangulate 例程生成多边形的三角剖分。这个三角剖分包括外部边,而这是我们在这个例子中不希望出现的:

    triangles = triangulate(polygon)
    
  2. 为了去除位于原始多边形外部的三角形,我们需要使用内置的 filter 例程,并结合使用 contains 方法(在本章前面已经提到过):

    filtered = filter(lambda p: polygon.contains(p),
    
        triangles)
    
  3. 为了将三角形绘制到原始多边形上,我们需要将 Shapely 三角形转换为 Matplotlib Patch 对象,并将其存储在一个 PatchCollection 中:

    patches = map(lambda p: mpl.patches.Polygon(
    
        p.exterior.coords), filtered)
    
    col = mpl.collections.PatchCollection(
    
        patches, fc="none", ec="k")
    
  4. 最后,我们将三角形补丁集合添加到之前创建的图形中:

    ax.add_collection(col)
    

在下面的图中可以看到已绘制在原始多边形上的三角剖分。这里,我们可以看到每个顶点都与其他两个顶点连接,形成了一个覆盖整个原始多边形的三角形系统:

图 8.6 – 带孔样本多边形的三角剖分

图 8.6 – 带孔样本多边形的三角剖分

图 8.6 中,原始多边形的顶点之间的内部线条将多边形分割成 15 个三角形。

它是如何工作的...

triangulate 例程使用一种叫做 德劳内三角剖分 的技术,将一组点连接成一个三角形系统。在这个例子中,点集是多边形的顶点。德劳内方法以一种方式找到这些三角形,使得没有任何一个点位于任何一个三角形的外接圆内。这是该方法的一个技术性条件,但它意味着三角形的选择是高效的,因为它避免了非常长且细的三角形。最终的三角剖分利用了原始多边形中的边,并且还连接了一些外部边。

为了去除位于原多边形外部的三角形,我们使用内置的filter例程,它通过移除符合标准函数的项目来创建一个新的可迭代对象。这个方法与 Shapely Polygon对象上的contains方法结合使用,以确定每个三角形是否位于原始图形内部。正如我们之前提到的,在将这些 Shapely 对象添加到图表中之前,我们需要将它们转换为 Matplotlib 补丁。

还有更多…

三角剖分通常用于将复杂的几何图形简化为一组三角形,这对于计算任务来说要简单得多。然而,它们也有其他用途。三角剖分的一个特别有趣的应用是解决艺术画廊问题。这个问题涉及找到必要的最大守卫数,以守卫一个特定形状的艺术画廊。三角剖分是 Fisk 简单证明艺术画廊定理的关键部分,该定理最初由 Chvátal 证明。

假设这个教程中的多边形是一个艺术画廊的平面图,并且需要在顶点放置一些守卫。通过少量工作可以发现,你需要在多边形的顶点放置三个守卫,以便完全覆盖整个博物馆。在以下图像中,我们绘制了一种可能的布局:

图 8.7 – 一种可能的艺术画廊问题解决方案,其中守卫被安置在顶点

图 8.7 – 一种可能的艺术画廊问题解决方案,其中守卫被安置在顶点

图 8.7中,守卫通过X符号表示,并且它们对应的视野区域被阴影填充。这里,你可以看到整个多边形被至少一种颜色覆盖。这个变种的艺术画廊问题的解决方案告诉我们,我们最多需要四个守卫。

另请参见

有关艺术画廊问题的更多信息,可以参阅 O'Rourke 的经典著作:O’Rourke, J. (1987). 艺术画廊定理与算法。纽约:牛津大学出版社。

计算凸包

如果几何图形中的任意一对点可以通过一条直线连接,且该直线也完全位于图形内部,则该几何图形被称为的。凸体的简单例子包括点、直线、正方形、圆(圆盘)、正多边形等。图 8.5中所示的几何图形不是凸的,因为孔的对侧点无法通过一条直线连接,且该直线保持在图形内部。

从某种角度来看,凸图形比较简单,这意味着它们在多种应用中都非常有用。一个问题是找到包含一组点的最小凸集。这个最小的凸集被称为该点集的凸包

在这个教程中,我们将学习如何使用 Shapely 包找到一组点的凸包。

准备工作

对于本示例,我们将需要导入为np的 NumPy 包,导入为mpl的 Matplotlib 包,以及导入为pltpyplot模块:

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

如何操作……

按照以下步骤查找随机生成的点的凸包:

  1. 首先,我们生成一个二维的随机数数组:

    raw_points = rng.uniform(-1.0, 1.0, size=(50, 2))
    
  2. 接下来,我们创建一个新图形,并将这些原始样本点绘制在这个图形中:

    fig, ax = plt.subplots()
    
    ax.plot(raw_points[:, 0], raw_points[:, 1], "kx")
    
    ax.set_axis_off()
    

这些随机生成的点可以在下图中看到。点大致分布在一个方形区域内:

图 8.8 – 平面上一组点

图 8.8 – 平面上一组点

  1. 接下来,我们构建一个MultiPoint对象,将所有这些点收集到一个单独的对象中:

    points = MultiPoint(raw_points)
    
  2. 现在,我们通过convex_hull属性获取这个MultiPoint对象的凸包:

    convex_hull = points.convex_hull
    
  3. 然后,我们创建一个 Matplotlib Polygon补丁,可以将其绘制到图形上,以展示找到凸包的结果:

    patch = mpl.patches.Polygon(
    
        convex_hull.exterior.coords,
    
        ec=(0,0,0,1), fc=(0.5,0.5,0.5,0.4), lw=1.2)
    
  4. 最后,我们将Polygon补丁添加到图形中,以展示凸包:

    ax.add_patch(patch)
    

随机生成点的凸包可以在下图中看到:

图 8.9 – 平面上一组点的凸包

图 8.9 – 平面上一组点的凸包

图 8.9 中的多边形具有从原始点中选择的顶点,所有其他点都位于阴影区域内。

它是如何工作的……

Shapely 包是一个用于几何分析的 Python 封装库,基于 GEOS 库。Shapely 几何对象的convex_hull属性调用 GEOS 库中的凸包计算函数,结果是一个新的 Shapely 对象。从本示例可以看出,这些点的凸包是一个多边形,顶点位于距离中心最远的点上。

构建贝塞尔曲线

贝塞尔曲线,或B 样条,是一类在矢量图形中非常有用的曲线——例如,它们常用于高质量的字体包。这是因为它们由少数几个点定义,然后可以使用这些点来廉价地计算曲线上的大量点。这使得可以根据用户的需求来调整细节的规模。

在这个示例中,我们将学习如何创建一个简单的类,表示贝塞尔曲线,并计算曲线上的多个点。

准备工作

在这个示例中,我们将使用导入为np的 NumPy 包,导入为plt的 Matplotlib pyplot模块,以及从 Python 标准库math模块导入的comb函数,别名为binom

from math import comb as binom
import matplotlib.pyplot as plt
import numpy as np

如何操作……

按照以下步骤定义一个类,表示一个贝塞尔曲线,该曲线可用于计算曲线上的点:

  1. 第一步是设置基本类。我们需要提供控制点(节点)和一些相关数字作为实例属性:

    class Bezier:
    
        def __init__(self, *points):
    
            self.points = points
    
            self.nodes = n = len(points) - 1
    
            self.degree = l = points[0].size
    
  2. 仍然在__init__方法中,我们生成贝塞尔曲线的系数,并将它们存储在实例属性中的列表里:

    self.coeffs = [binom(n, i)*p.reshape(
    
        (l, 1)) for i, p in enumerate(points)]
    
  3. 接下来,我们定义一个__call__方法,使得这个类可以被调用。我们将实例中的节点数量加载到本地变量中以便清晰:

        def __call__(self, t):
    
            n = self.nodes
    
  4. 接下来,我们将输入数组重新调整形状,使其包含一行:

            t = t.reshape((1, t.size))
    
  5. 现在,我们使用实例中的coeffs属性中的每个系数生成一个值数组列表:

            vals = [c @ (t**i)*(1-t)**(n-i) for i,
    
               c in enumerate(self.coeffs)]
    

最后,我们将第 5 步中构造的所有数组相加,并返回结果数组:

       return np.sum(vals, axis=0)
  1. 现在,我们将使用一个示例来测试我们的类。我们将为这个示例定义四个控制点:

    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])
    
  2. 接下来,我们设置一个新的绘图图形,并使用虚线连接的方式绘制控制点:

    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")
    
  3. 然后,我们使用在第 7 步中定义的四个点创建一个新的Bezier类实例:

    b_curve = Bezier(p1, p2, p3, p4)
    
  4. 我们现在可以使用linspace创建一个从 0 到 1 均匀间隔的点数组,并计算贝塞尔曲线上的点:

    t = np.linspace(0, 1)
    
    v = b_curve(t)
    
  5. 最后,我们将在之前绘制的控制点上绘制这条曲线:

    ax.plot(v[0,:], v[1, :], "k")
    

我们绘制的贝塞尔曲线可以在下面的图中看到。如你所见,曲线从第一个点(0, 0)开始,到最终点(1, 3)结束:

图 8.10 – 使用四个节点构造的三次贝塞尔曲线

图 8.10 – 使用四个节点构造的三次贝塞尔曲线

图 8.10中的贝塞尔曲线在端点处与垂直线相切,并平滑地连接这些点。注意,我们只需要存储四个控制点,就能以任意精度重新构建这条曲线;这使得贝塞尔曲线在存储上非常高效。

它是如何工作的...

贝塞尔曲线由一系列控制点描述,我们通过递归构造曲线。一个只有一个点的贝塞尔曲线是一个常数曲线,始终保持在该点。两个控制点的贝塞尔曲线是这两点之间的线段:

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

这个构造过程可以在下图中看到:

图 8.11 – 使用递归定义构造二次贝塞尔曲线(两条线性贝塞尔曲线由虚线表示)

图 8.11 – 使用递归定义构造二次贝塞尔曲线(两条线性贝塞尔曲线由虚线表示)

这个构建过程继续进行,以定义任意数量控制点的贝塞尔曲线。幸运的是,在实际操作中,我们不需要处理这个递归定义,因为我们可以将公式简化为一个单一的曲线公式,公式如下:

这里,!元素是控制点,!是一个参数,每一项都涉及二项式系数:

记住,!参数是用于生成曲线点的变化量。我们可以将先前求和中的涉及!的项与不涉及的项分离开来。这定义了我们在第 2 步中定义的系数,每个系数通过以下代码片段给出:

binom(n, i)*p.reshape((l, 1))

在这一步中,我们重新排列每个点p,确保它作为列向量排列。这意味着每个系数都是一个列向量(作为 NumPy 数组),由通过二项式系数缩放的控制点组成。

现在,我们需要指定如何在不同的!值下评估贝塞尔曲线。这时我们利用了 NumPy 包中的高性能数组操作。我们在形成系数时将控制点重新排列为列向量。在第 4 步中,我们将输入的!值重新排列为行向量。这意味着我们可以使用矩阵乘法操作符,将每个系数与相应的(标量)值相乘,具体取决于输入的!。这就是在第 5 步中,列表推导式内部发生的事情。在下面这一行中,我们将!数组与!数组相乘,从而得到一个!数组:

c @ (t**i)*(1-t)**(n-i)

我们为每个系数得到一个这样的结果。然后,我们可以使用np.sum例程对每个!数组进行求和,从而获得贝塞尔曲线上的值。在本示例中,输出数组的顶行包含曲线的!值,底行包含曲线的!值。我们必须小心地指定axis=0关键字参数,以确保sum例程对我们创建的列表进行求和,而不是对该列表包含的数组进行求和。

我们定义的类通过使用贝塞尔曲线的控制点来初始化,然后用这些点来生成系数。曲线值的实际计算是通过使用 NumPy 进行的,因此这个实现应该具有相对较好的性能。一旦创建了该类的特定实例,它的功能就像一个函数,正如你所预期的那样。然而,这里没有进行类型检查,因此我们只能将 NumPy 数组作为参数来调用这个函数

还有更多...

贝塞尔曲线是使用迭代构造定义的,其中使用直线连接由第一个和最后一个控制点定义的曲线来定义具有点的曲线。使用此构造跟踪每个控制点的系数将迅速导致我们用于定义前述曲线的方程。此构造还导致贝塞尔曲线的有趣 - 和有用的 - 几何特性。

正如我们在本篇文章开头提到的,贝塞尔曲线出现在许多涉及矢量图形的应用程序中,如字体。它们还出现在许多常见的矢量图形软件包中。在这些软件包中,通常可以看到二次贝塞尔曲线,它们由三个点的集合定义。但是,您也可以通过提供这些点上的两个端点以及梯度线来定义二次贝塞尔曲线。这在图形软件包中更为常见。生成的贝塞尔曲线将沿着这些梯度线留下每个端点,并在这些点之间平滑连接曲线。

我们在这里构建的实现在小型应用中性能相对较好,但对于涉及在大量控制点上渲染曲线的应用来说不足够。对此,最好使用一个用编译语言编写的低级包。例如,bezier Python 包使用编译的 Fortran 后端进行计算,并提供比我们在这里定义的类更丰富的接口。

贝塞尔曲线当然可以以一种自然的方式扩展到更高的维度。结果是贝塞尔曲面,这使它们成为非常有用的通用工具,用于高质量、可扩展的图形。

进一步阅读

  • 计算几何中一些常见算法的描述可以在以下书籍中找到:Press, W.H., Teukolsky, S.A., Vetterling, W.T., and Flannery, B.P., 2007. 数值计算法: 科学计算的艺术. 第三版. 剑桥: 剑桥大学出版社。

  • 想要更详细地了解计算几何中的一些问题和技术,请参阅以下书籍:O’Rourke, J., 1994. C 语言中的计算几何. 剑桥: 剑桥大学出版社。

第九章:寻找最优解

在这一章中,我们将探讨寻找给定情境下最佳结果的各种方法。这被称为优化,通常涉及最小化或最大化目标函数。目标函数是一个包含一个或多个自变量的函数,返回一个标量值,表示给定参数选择的成本或收益。最小化和最大化函数的问题实际上是等价的,因此我们将在本章中只讨论最小化目标函数。最小化一个函数,,等同于最大化函数。关于这一点的更多细节将在我们讨论第一个食谱时提供。

用于最小化给定函数的算法取决于函数的性质。例如,包含一个或多个变量的简单线性函数与具有多个变量的非线性函数所适用的算法是不同的。线性函数的最小化属于线性规划的范畴,这是一个成熟的理论。线性函数可以通过标准的线性代数技术解决。对于非线性函数,我们通常利用函数的梯度来寻找最小点。我们将讨论几种用于最小化不同类型函数的方法。

寻找单变量函数的最小值和最大值尤其简单,如果已知函数的导数,则可以轻松完成。如果不知道导数,那么可以使用适当食谱中描述的方法。最小化非线性函数食谱中的备注提供了一些额外的细节。

我们还将简要介绍一下博弈论。广义来说,博弈论是围绕决策制定的理论,具有广泛的应用,尤其在经济学等学科中尤为重要。具体来说,我们将讨论如何将简单的双人博弈表示为 Python 中的对象,计算与某些选择相关的收益,并计算这些博弈的纳什均衡。

我们将首先学习如何最小化包含一个或多个变量的线性和非线性函数。然后,我们将继续探讨使用梯度下降法和最小二乘法的曲线拟合方法。最后,我们将通过分析双人博弈和纳什均衡来总结这一章。

本章将涵盖以下食谱:

  • 最小化简单线性函数

  • 最小化非线性函数

  • 在优化中使用梯度下降法

  • 使用最小二乘法对数据进行曲线拟合

  • 分析简单的双人博弈

  • 计算纳什均衡

让我们开始吧!

技术要求

在本章中,我们将需要 NumPy、SciPy 和 Matplotlib 包,和往常一样。对于最后两个食谱,我们还需要 Nashpy 包。可以使用你喜欢的包管理器(如pip)安装这些包:

python3.10 -m pip install numpy scipy matplotlib nashpy

本章节的代码可以在 GitHub 仓库中的Chapter 09文件夹找到,地址为 github.com/PacktPublishing/Applying-Math-with-Python-2nd-Edition/tree/main/Chapter%2009

最小化一个简单的线性函数

我们在优化中面临的最基本问题是找到一个函数的最小值所在的参数。通常,这个问题会受到一些关于参数可能值的约束,这增加了问题的复杂性。显然,如果我们要最小化的函数本身也很复杂,那么问题的复杂性会进一步增加。因此,我们必须首先考虑线性函数,其形式如下:

要解决这种问题,我们需要将约束转换成计算机能够处理的形式。在这种情况下,我们通常将其转换为线性代数问题(矩阵和向量)。完成这一步后,我们可以利用 NumPy 和 SciPy 中线性代数包的工具来找到我们需要的参数。幸运的是,由于这种问题非常常见,SciPy 已经有处理这种转换和后续求解的例程。

在本例中,我们将使用 SciPy optimize 模块中的例程解决以下约束线性最小化问题:

这将受到以下条件的限制:

让我们看看如何使用 SciPy 的optimize例程来解决这个线性规划问题。

准备就绪

对于这个示例,我们需要导入 NumPy 包(别名为np),Matplotlib 的 pyplot 模块(别名为plt),以及 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

让我们看看如何使用 optimize 模块中的例程来最小化一个有约束的线性系统。

如何实现...

按照以下步骤使用 SciPy 解决一个有约束的线性最小化问题:

  1. 将系统设置为 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])
    
  2. 接下来,我们需要定义一个例程来评估在线性函数值为 时的函数值,该值是一个向量(NumPy 数组):

    def func(x):
    
        return np.tensordot(c, x, axes=1)
    
  3. 然后,我们创建一个新图形,并添加一组 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")
    
  4. 接下来,我们创建一个覆盖问题区域的值网格,并在该区域上绘制函数值:

    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, cmap="gray",
    
        vmax=100.0, alpha=0.3)
    
  5. 现在,我们绘制函数值平面中对应于临界线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]]), 
    
        "k", lw=1.5, alpha=0.6)
    
  6. 我们对第二条临界线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]]), 
    
        "k", lw=1.5, alpha=0.6)
    
  7. 接下来,我们对位于两条临界线之间的区域进行着色,这对应于最小化问题的可行区域:

    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="k", alpha=0.5)
    

在下图中可以看到函数值在可行区域上的绘图:

图 9.1 – 线性函数的值,并突出显示了可行区域

图 9.1 – 线性函数的值,并突出显示了可行区域

如我们所见,位于这个阴影区域内的最小值出现在两条关键线的交点处。

  1. 接下来,我们使用linprog来解决在步骤 1中创建的约束最小化问题。我们在终端中打印出结果对象:

    res = optimize.linprog(c, A_ub=A, b_ub=b,
    
        bounds= (x0_bounds, x1_bounds))
    
    print(res)
    
  2. 最后,我们将在可行区域上方绘制最小函数值:

    ax.plot([res.x[0]], [res.x[1]], [res.fun], "kx")
    

更新后的图示可以在下图中看到:

图 9.2 – 最小值绘制在可行区域上

图 9.2 – 最小值绘制在可行区域上

在这里,我们可以看到linprog程序确实找到了最小值,它位于两条关键线的交点处。

它是如何工作的…

约束线性最小化问题在经济场景中非常常见,在这些问题中,你试图在保持其他参数的同时最小化成本。事实上,优化理论中的很多术语都反映了这一点。解决这些问题的一种非常简单的算法叫做单纯形法,它通过一系列阵列操作来找到最小解。从几何角度看,这些操作代表了在单纯形的不同顶点之间变化(我们在这里不会定义单纯形),正是这种变化赋予了算法这个名字。

在我们继续之前,我们将简要概述单纯形法用于求解约束线性优化问题的过程。该问题,呈现给我们的并不是一个矩阵方程问题,而是一个矩阵不等式问题。我们可以通过引入松弛变量来解决这个问题,将不等式转化为等式。例如,通过引入松弛变量,第一条约束不等式可以重写为如下形式:

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

从这里,我们可以构建一个矩阵,其列包含两个参数变量的系数,,以及两个松弛变量,。该矩阵的行表示两个边界方程和目标函数。现在,这个方程组可以通过对该矩阵进行基本的行操作来求解,以得到最小化目标函数的的值。由于求解矩阵方程既简单又快速,这意味着我们可以迅速高效地最小化线性函数。

幸运的是,我们不需要记住如何将我们的不等式系统化简为线性方程组,因为像linprog这样的例程已经为我们做了这件事。我们只需提供边界不等式作为一个矩阵和向量对,包括每个不等式的系数,并提供一个定义目标函数的单独向量。linprog例程会负责公式化并解决最小化问题。

实际上,linprog例程并未使用单纯形法来最小化函数。相反,linprog使用了一个内部点算法,这种算法效率更高。(该方法实际上可以通过提供适当的方法名称和method关键字参数设置为simplexrevised-simplex。在打印的结果输出中,我们可以看到,达到解决方案仅需五次迭代。)该例程返回的结果对象包含在最小值处的参数值,存储在x属性中,最小值处函数的值存储在fun属性中,此外还包含关于求解过程的各种信息。如果方法失败,则status属性将包含一个数值代码,描述方法失败的原因。

在本食谱的第 2 步中,我们创建了一个表示此问题目标函数的函数。该函数接受一个包含函数应评估的参数空间值的单一数组作为输入。在这里,我们使用了 NumPy 的tensordot例程(axes=1)来评估系数向量的点积,,与每个输入,。在这里我们必须非常小心,因为我们传入函数的值在后续步骤中将会是一个 2 × 50 × 50 的数组。普通的矩阵乘法(np.dot)在这种情况下无法给出我们想要的 50 × 50 的数组输出。

第 5 步第 6 步中,我们根据以下方程计算了临界线上的点:

然后我们计算了相应的值,以便绘制在目标函数定义的平面上的线条。我们还需要修剪这些值,确保只包含那些在问题中指定的范围内的值。通过在代码中构造标记为 I 的索引数组来完成这一步,该数组由位于边界值内的点组成。

还有更多...

本节介绍了受限最小化问题以及如何使用 SciPy 解决它。然而,相同的方法也可以用来解决受限的最大化问题。这是因为最大化和最小化是对偶的,从某种意义上来说,最大化一个函数与最小化函数并取其负值是一样的。事实上,我们在本节中就利用了这一点,将第二个约束不等式从 ≥ 改为 ≤。

在本节中,我们解决了一个只有两个参数变量的问题,但相同的方法也适用于涉及更多变量的问题(除了绘图步骤)。我们只需要为每个数组增加更多的行和列来处理增加的变量数量——这包括传递给函数的边界元组。当处理大量变量时,该函数还可以在适当的情况下与稀疏矩阵一起使用,以提高效率。

linprog 函数得名于线性规划,用于描述此类问题——找到满足某些矩阵不等式的的值,同时满足其他条件。由于矩阵理论与线性代数之间有着紧密的联系,因此线性规划问题有许多非常快速和高效的技术,而这些技术在非线性上下文中是不可用的。

最小化非线性函数

在上一节中,我们展示了如何最小化一个非常简单的线性函数。不幸的是,大多数函数并非线性,通常也不具备我们希望的良好特性。对于这些非线性函数,我们无法使用为线性问题开发的快速算法,因此我们需要设计出可以在这些更一般化情况下使用的新方法。我们将在这里使用的算法叫做 Nelder-Mead 算法,它是一种健壮的通用方法,用于寻找函数的最小值,并且不依赖于函数的梯度。

在本节中,我们将学习如何使用 Nelder-Mead 单纯形法来最小化一个包含两个变量的非线性函数。

准备工作

在本节中,我们将使用导入的 NumPy 包(命名为 np)、Matplotlib 的 pyplot 模块(命名为 plt)、从 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 单纯形法找到一般非线性目标函数的最小值:

  1. 定义我们要最小化的目标函数:

    def func(x):
    
        return ((x[0] - 0.5)**2 + (
    
            x[1] + 0.5)**2)*np.cos(0.5*x[0]*x[1])
    
  2. 接下来,创建一个值的网格,以便我们可以在上面绘制目标函数:

    x_r = np.linspace(-1, 1)
    
    y_r = np.linspace(-2, 2)
    
    x, y = np.meshgrid(x_r, y_r)
    
  3. 现在,我们在这个点阵上评估该函数:

    z = func([x, y])
    
  4. 接下来,我们创建一个包含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")
    
  5. 现在,我们可以将目标函数作为表面绘制在刚才创建的坐标轴上:

    ax.plot_surface(x, y, z, cmap="gray",
    
        vmax=8.0, alpha=0.5)
    
  6. 我们选择一个初始点,作为最小化例程开始迭代的起点,并将其绘制在表面上:

    x0 = np.array([-0.5, 1.0])
    
    ax.plot([x0[0]], [x0[1]], func(x0), "k*")
    

目标函数表面的绘图,以及初始点,可以在以下图示中看到。这里,我们可以看到最小值似乎出现在 x 轴大约 0.5,y 轴大约-0.5 的位置:

图 9.3 – 一个包含起始点的非线性目标函数

图 9.3 – 一个包含起始点的非线性目标函数

  1. 现在,我们使用optimize包中的minimize例程来寻找最小值,并打印出它生成的result对象:

    result = optimize.minimize(
    
        func, x0, tol=1e-6, method= "Nelder-Mead")
    
    print(result)
    
  2. 最后,我们将minimize例程找到的最小值绘制在目标函数的表面上:

    ax.plot([result.x[0]], [result.x[1]], [result.fun], "kx")
    

更新后的目标函数图,包含minimize例程找到的最小点,可以在以下图示中看到:

图 9.4 – 一个包含起始点和最小点的目标函数

图 9.4 – 一个包含起始点和最小点的目标函数

这表明该方法确实在从初始点(左上角)开始的区域内找到了最小点(右下角)。

它是如何工作的...

Nelder-Mead 单纯形法——与线性优化问题的单纯形法不同——是一种简单的算法,用于找到非线性函数的最小值,即使该目标函数没有已知的导数也能有效工作。(对于这个示例中的函数并非如此;使用基于梯度的方法唯一的收益是收敛速度的提升。)该方法通过比较单纯形顶点处目标函数的值来工作,单纯形在二维空间中是一个三角形。具有最大函数值的顶点会被反射至对边,并执行适当的扩展或收缩,从而使单纯形向下移动。

来自 SciPy optimize模块的minimize例程是许多非线性函数最小化算法的入口点。在这个例子中,我们使用了 Nelder-Mead 单纯形算法,但也有许多其他算法可供选择。许多这些算法需要了解函数的梯度,梯度可能由算法自动计算。通过提供适当的名称给method关键字参数,就可以使用该算法。

minimize例程返回的result对象包含了关于找到的解(如果没有找到解则为错误)的大量信息。特别地,计算出的最小值对应的目标参数存储在结果的x属性中,而函数值存储在fun属性中。

minimize例程需要目标函数和x0的起始值。在这个示例中,我们还提供了一个容差值,要求最小值使用tol关键字参数进行计算。更改该值将会修改计算解的精度。

还有更多内容……

Nelder-Mead 算法是一个无梯度最小化算法的例子,因为它不需要任何关于目标函数梯度(导数)的信息。有几种类似的算法,它们通常涉及在多个点上评估目标函数,然后利用这些信息向最小值移动。一般来说,无梯度方法的收敛速度通常比梯度下降模型慢。然而,它们可以用于几乎任何目标函数,即使在无法准确计算梯度或通过近似计算梯度的情况下也能使用。

优化单变量函数通常比多维情况更容易,并且在 SciPy 的optimize库中有一个专门的函数。minimize_scalar例程用于执行单变量函数的最小化,在这种情况下应替代minimize使用。

在优化中使用梯度下降方法

在之前的示例中,我们使用了 Nelder-Mead 单纯形算法来最小化一个包含两个变量的非线性函数。这是一种相当稳健的方法,即使对目标函数了解甚少,它也能有效工作。然而,在许多情况下,我们对目标函数有更多的了解,这使我们能够设计出更快速、更高效的算法来最小化该函数。我们可以通过利用诸如函数梯度等属性来实现这一点。

一个多变量函数的梯度描述了该函数在各个分量方向上的变化速率。这是该函数关于每个变量的偏导数向量。通过这个梯度向量,我们可以推断出函数增长最快的方向,反之,也可以推断出函数下降最快的方向。从任意给定位置出发,这为我们提供了梯度下降方法的基础,用于最小化一个函数。算法非常简单:给定一个起始位置,,我们计算在的梯度以及梯度下降最快的对应方向,然后朝着该方向迈出一个小步伐。经过几次迭代后,这将使我们从起始位置移动到函数的最小值。

在这个食谱中,我们将学习如何实现一个基于最速下降法的算法,在一个有界区域内最小化目标函数。

准备开始

对于这个食谱,我们需要导入 NumPy 包作为np,Matplotlib 的pyplot模块作为plt,以及从mpl_toolkits.mplot3d导入Axes3D对象:

import numpy as np
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

让我们实现一个简单的梯度下降算法,并用它来解决前面食谱中描述的最小化问题,看看它是如何工作的。

如何做…

在接下来的步骤中,我们将实现一个简单的梯度下降法,用来最小化一个已知梯度函数的目标函数(实际上我们将使用一个生成器函数,以便在方法运行时看到它的工作过程):

  1. 我们将从定义一个descend例程开始,它将执行我们的算法。函数声明如下:

    def descend(func,x0,grad,bounds,tol=1e-8,max_iter=100):
    
  2. 接下来,我们需要实现这个例程。我们从定义在方法运行过程中存储迭代值的变量开始:

        xn = x0
    
        previous = np.inf
    
        grad_xn = grad(x0)
    
  3. 然后我们开始我们的循环,它将执行迭代。在继续之前,我们立即检查是否在取得有意义的进展:

        for i in range(max_iter):
    
            if np.linalg.norm(xn - previous) < tol:
    
                break
    
  4. 方向是梯度向量的负值。我们计算一次并将其存储在direction变量中:

            direction = -grad_xn
    
  5. 现在,我们更新前一个和当前的值,分别是xnm1xn,为下一次迭代做好准备。这就结束了descend例程的代码:

            previous = xn
    
            xn = xn + 0.2*direction
    
  6. 现在,我们可以计算当前值的梯度并生成所有适当的值:

            grad_xn = grad(xn)
    
            yield i, xn, func(xn), grad_xn
    

这就结束了descend例程的定义。

  1. 我们现在可以定义一个示例目标函数进行最小化:

    def func(x):
    
        return ((x[0] - 0.5)**2 + (
    
            x[1] + 0.5)**2)*np.cos(0.5*x[0]*x[1])
    
  2. 接下来,我们创建一个网格,用于评估并绘制目标函数:

    x_r = np.linspace(-1, 1)
    
    y_r = np.linspace(-2, 2)
    
    x, y = np.meshgrid(x_r, y_r)
    
  3. 一旦创建了网格,我们可以评估我们的函数并将结果存储在z变量中:

    z = func([x, y])
    
  4. 接下来,我们创建目标函数的三维曲面图:

    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, cmap="gray", 
    
        vmax=8.0, alpha=0.5)
    
  5. 在开始最小化过程之前,我们需要定义一个初始点x0。我们将这个点绘制在我们之前创建的目标函数图上:

    x0 = np.array([-0.8, 1.3])
    
    surf_ax.plot([x0[0]], [x0[1]], func(x0), "k*")
    

目标函数的表面图以及初始值可以在下图中看到:

图 9.5 – 目标函数的表面图和初始位置

](https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/app-math-py-2e/img/9.5.jpg)

图 9.5 – 目标函数的表面图和初始位置

  1. 我们的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
    
        ])
    
  2. 我们将把迭代次数绘制在等高线图上,因此我们按以下方式进行设置:

    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=25, cmap="gray",
    
        vmax=8.0, opacity=0.6)
    
  3. 现在,我们创建一个变量,用来保存在方向上的边界,这些边界是一个元组的元组。这些边界与第 10 步中的linspace调用的边界相同:

    bounds = ((-1, 1), (-2, 2))
    
  4. 现在,我们可以使用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
    
  5. 循环完成后,我们将最终的值打印到终端:

    print(f"iterations={i}")
    
    print(f"min val at {xn}")
    
    print(f"min func value = {fxn}")
    

之前print语句的输出如下:

iterations=37
min val at [ 0.49999999 -0.49999999]
min func value = 2.1287163880894953e-16

在这里,我们可以看到我们的例程使用了 37 次迭代,找到了大约在(0.5, -0.5)的最小值,这是正确的。

带有迭代次数的等高线图可以在下图中看到:

图 9.6 – 目标函数的等高线图,梯度下降迭代到最小值

图 9.6 – 目标函数的等高线图,显示梯度下降迭代到最小值

在这里,我们可以看到每次迭代的方向——由虚线表示——是目标函数下降最迅速的方向。最终的迭代位于目标函数的的中心,即最小值所在的位置。

它是如何工作的...

这个食谱的核心是descend例程。该例程中定义的过程是梯度下降法的一个非常简单的实现。在给定点计算梯度由grad参数处理,然后通过direction = -grad来推导迭代的行进方向。我们将这个方向乘以一个固定的比例因子(有时称为0.2*direction),然后加到当前位置。

这个食谱中的解法在收敛时花费了 37 次迭代,相较于最小化非线性函数食谱中的 Nelder-Mead 单纯形算法(该算法花费了 58 次迭代),这是一个轻微的改进。(这不是一个完美的比较,因为我们更改了这个食谱的起始位置。)该性能在很大程度上依赖于我们选择的步长。在这种情况下,我们将最大步长固定为方向向量大小的 0.2 倍。这使得算法保持简单,但效率并不是特别高。

在这个例子中,我们选择将算法实现为生成器函数,这样可以在每一步迭代中看到输出,并将其绘制在等高线图上。当我们逐步执行迭代时,我们可以看到每一步的结果。实际上,我们可能不想这样做,而是希望在迭代完成后直接返回计算得到的最小值。为了实现这一点,我们只需移除yield语句,并在函数的最后(即主函数的缩进位置,不在循环内部)将其替换为return xn。如果你想防止不收敛的情况,可以使用for循环的else特性来捕捉循环因到达迭代器末尾而没有触发break关键字的情况。这个else块可以引发异常,表示算法未能收敛到一个解。在这个例子中,我们用来结束迭代的条件并不能保证方法已经达到最小值,但通常情况下它是有效的。

还有更多…

实际上,你通常不会自己实现梯度下降算法,而是会使用像 SciPy optimize 模块这样的库中的通用函数。我们可以使用在前一个例子中使用的相同minimize函数,通过多种不同的算法执行最小化,包括几种梯度下降算法。这些实现通常会比像这种自定义实现具有更高的性能和更强的鲁棒性。

我们在这个例子中使用的梯度下降方法是一个非常简单的实现,可以通过允许算法在每一步选择步长来大大改进。(允许自己选择步长的方法有时被称为自适应方法。)这种改进的难点在于如何选择在此方向上采取的步长。为此,我们需要考虑一个单变量的函数,该函数由以下方程给出:

在这里,表示当前点,表示当前方向,是一个参数。为了简便起见,我们可以使用一个名为minimize_scalar的最小化例程来处理标量值函数,来自 SciPy optimize 模块。不幸的是,这并不是像传入这个辅助函数并找到最小值那样简单。我们需要给![](https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/app-math-py-2e/img/Formula_09_036.png)的可能值设置边界,以便计算得到的最小化点,![](https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/app-math-py-2e/img/Formula_09_037.png),位于我们感兴趣的区域内。

要理解我们如何约束 的值,我们必须先从几何角度看构建过程。我们引入的辅助函数评估了沿给定方向的单条直线上的目标函数。我们可以将其想象为对表面进行单一的截面切割,切割面通过当前的 点,并沿 方向延伸。算法的下一步是寻找步长 ,它最小化沿该直线的目标函数值——这是一个标量函数,比较容易最小化。然后,边界值应该是 的值范围,在此范围内,直线将位于由 边界值定义的矩形内。我们确定这条直线与 边界线交叉的四个值,其中两个是负数,两个是正数。(这是因为当前点必须位于矩形内。)我们取两个正值中的最小值和两个负值中的最大值,并将这些边界传递给标量最小化程序。可以使用以下代码实现此过程:

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 numpy.random import default_rng
rng = default_rng(12345)

最后,我们需要 SciPy optimize 模块中的 curve_fit 函数:

from scipy.optimize import curve_fit

让我们看看如何使用这个函数将一条非线性曲线拟合到一些数据上。

如何实现...

以下步骤展示了如何使用 curve_fit 函数将一条曲线拟合到一组数据:

  1. 第一步是创建样本数据:

    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
    
  2. 接下来,我们生成数据的散点图,以查看是否能识别数据中的潜在趋势:

    fig, ax = plt.subplots()
    
    ax.scatter(x_data, y_data)
    
    ax.set(xlabel="x", ylabel="y",
    
        title="Scatter plot of sample data")
    

我们所生成的散点图如下所示。这里,我们可以看到数据显然不遵循线性趋势(直线)。既然我们知道趋势是多项式,那么我们接下来的猜测是二次趋势。这就是我们在这里使用的:

图 9.7 – 样本数据的散点图 – 我们可以看到数据并没有遵循线性趋势

图 9.7 – 样本数据的散点图——我们可以看到它没有遵循线性趋势

  1. 接下来,我们创建一个表示我们希望拟合的模型的函数:

    def func(x, a, b, c):
    
        return a*x**2 + b*x + c
    
  2. 现在,我们可以使用 curve_fit 例程将模型函数拟合到样本数据中:

    coeffs, _ = curve_fit(func, x_data, y_data)
    
    print(coeffs)
    
    # [ 1.99611157 -3.97522213 0.04546998]
    
  3. 最后,我们将最佳拟合曲线绘制到散点图上,以评估拟合曲线对数据的描述效果:

    x = np.linspace(-3.0, 3.0, SIZE)
    
    y = func(x, coeffs[0], coeffs[1], coeffs[2])
    
    ax.plot(x, y, "k--")
    

更新后的散点图可以在下图中看到:

图 9.8 – 一个使用叠加最小二乘法找到的最佳拟合曲线的散点图

图 9.8 – 一个使用叠加最小二乘法找到的最佳拟合曲线的散点图

在这里,我们可以看到我们找到的曲线拟合数据的效果相当好。系数并不完全等于真实模型——这是由于添加的噪声效应。

它是如何工作的...

curve_fit 例程执行最小二乘法拟合,将模型的曲线拟合到样本数据中。在实践中,这相当于最小化以下目标函数:

这里,配对的 是样本数据中的点。在这种情况下,我们在一个三维参数空间中进行优化,每个维度对应一个参数。例程返回估计的系数——在参数空间中最小化目标函数的点——以及一个包含拟合协方差矩阵估计值的第二个变量。在这个例子中,我们忽略了这一部分。

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)。这些参数会被传递到目标函数的xy参数中。总结来说,我们可以通过以下方式调用least_squares来估计参数:

results = least_squares(func, [1, 0, 0], args=(x_data, y_data))

least_squares例程返回的results对象实际上与本章描述的其他优化例程返回的对象相同。它包含诸如使用的迭代次数、过程是否成功、详细的错误信息、参数值以及目标函数在最小值处的值等细节。

分析简单的二人游戏

博弈论是研究决策和策略分析的数学分支。它在经济学、生物学和行为科学中有广泛应用。许多看似复杂的情境可以简化为一个相对简单的数学博弈,经过系统分析后能够找到最优解决方案。

博弈论中的经典问题是囚徒困境,其原始形式如下:两个同谋被抓获,必须决定是否保持沉默或指证对方。如果两人都保持沉默,他们都服刑 1 年;如果一个人作证而另一个人不作证,作证的人被释放,另一个人服刑 3 年;如果两人都相互作证,他们都服刑 2 年。那么每个同谋应该怎么做呢?事实证明,考虑到对对方的合理不信任,每个同谋做出的最佳选择是作证。采取这种策略,他们要么不服刑,要么最多服刑 2 年。

由于这本书是关于 Python 的,我们将用这个经典问题的变种来说明这个问题的普遍性。考虑以下问题:你和你的同事必须为客户编写一些代码。你认为你可以用 Python 写得更快,但你的同事认为他们可以用 C 语言写得更快。问题是,你们应该为项目选择哪种语言?

你认为自己可以写出比 C 快四倍的 Python 代码,所以你以速度 1 写 C,速度 4 写 Python。你的同事说他们写 C 稍微比写 Python 快一些,所以他们写 C 以速度 3,写 Python 以速度 2。如果你们都同意一个语言,那么你按照自己预测的速度写代码;但是如果你们意见不合,那么较快的程序员的生产力会减少 1。我们可以总结如下:

同事/你 C Python
C 3/1 3/2
Python 2/1 2/4

图 9.9 – 预测各种配置下的工作速度表

在这个教程中,我们将学习如何在 Python 中构建一个对象来表示这个简单的两人游戏,并对游戏的结果进行一些基础分析。

准备工作

对于这个教程,我们需要导入 NumPy 包为 np,以及导入 Nashpy 包为 nash

import numpy as np
import nashpy as nash

让我们看看如何使用 nashpy 包来分析一个简单的两人游戏。

如何实现...

以下步骤将向你展示如何使用 Nashpy 创建并进行一些简单的两人博弈分析:

  1. 首先,我们需要创建存储每个玩家回报信息的矩阵(在这个例子中是你和你的同事):

    you = np.array([[1, 3], [1, 4]])
    
    colleague = np.array([[3, 2], [2, 2]])
    
  2. 接下来,我们创建一个 Game 对象,该对象包含了由这些回报矩阵表示的游戏:

    dilemma = nash.Game(you, colleague)
    
  3. 我们通过索引符号计算给定选择的效用:

    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]
    
  4. 我们还可以根据选择某个特定决策的概率来计算期望效用:

    print(dilemma[[0.1, 0.9], [0.5, 0.5]]) # [2.45 2.05]
    

这些期望效用表示我们期望(平均来说)在多次重复游戏时看到的结果,按照指定的概率。

它是如何工作的...

在这个教程中,我们构建了一个 Python 对象,表示一个非常简单的两人战略游戏。这里的思路是有两个玩家需要做出决策,每种玩家选择的组合会给出一个特定的回报值。我们在这里的目标是找到每个玩家可以做出的最佳选择。假设两个玩家同时做出一个决定,在这个过程中,任何一个玩家都不知道对方的选择。每个玩家都有一个策略来决定他们的选择。

步骤 1中,我们为每个玩家创建两个矩阵,每个矩阵对应每种选择组合的回报值。这两个矩阵被 Nashpy 的 Game 类包装,提供了一个便捷且直观(从博弈论角度看)的界面来处理游戏。我们可以通过传入选择并使用索引符号,快速计算给定选择组合的效用。

我们还可以基于一种策略计算预期效用,其中根据某种概率分布随机选择。语法与前面描述的确定性情况相同,只是我们为每个选择提供一个概率向量。我们根据您 90%的时间选择 Python,而您的同事选择 Python 的概率为 50%来计算预期速度,分别为 2.45 和 2.05。

还有更多内容...

在 Python 中,计算游戏理论的另一种方法是使用 Gambit 项目。Gambit 项目是一个工具集,用于计算博弈论,具有 Python 接口(www.gambit-project.org/)。这是一个成熟的项目,构建在 C 库的基础上,并提供比 Nashpy 更高的性能。

计算纳什均衡

纳什均衡是一个两人策略游戏——类似于我们在分析简单的两人游戏配方中看到的——它代表了一个稳定状态,在这种状态下每个玩家都看到了最佳可能的结果。然而,这并不意味着与纳什均衡相关联的结果在总体上是最好的。纳什均衡比这更微妙。一个非正式的纳什均衡定义如下:在这个动作配置中,假设所有其他玩家遵循这个配置,没有一个个体玩家可以改善他们的结果。

我们将通过经典的剪刀石头布游戏探讨纳什均衡的概念。规则如下。每个玩家可以选择以下选项之一:剪刀,石头或布。石头打败剪刀,但输给布;纸打败石头,但输给剪刀;剪刀打败纸,但输给石头。如果两个玩家选择相同的选项,则为平局。数值上,我们用+1 表示赢,-1 表示输,0 表示平局。从中,我们可以构建一个两人游戏并计算该游戏的纳什均衡。

在这个配方中,我们将计算经典剪刀石头布游戏的纳什均衡。

准备工作

对于这个配方,我们需要导入 NumPy 包作为np,并导入 Nashpy 包作为nash

import numpy as np
import nashpy as nash

让我们看看如何使用nashpy包来计算两人策略游戏的纳什均衡。

怎么做...

下面的步骤展示了如何计算一个简单两人游戏的纳什均衡:

  1. 首先,我们需要为每个玩家创建一个收益矩阵。我们从第一个玩家开始:

    rps_p1 = np.array([
    
        [ 0, -1, 1], # rock payoff
    
        [ 1, 0, -1], # paper payoff
    
        [-1, 1, 0] # scissors payoff
    
    ])
    
  2. 第二个玩家的收益矩阵是rps_p1的转置:

    rps_p2 = rps_p1.transpose()
    
  3. 接下来,我们创建代表游戏的Game对象:

    rock_paper_scissors = nash.Game(rps_p1, rps_p2)
    
  4. 我们使用支持枚举算法计算游戏的纳什均衡:

    equilibria = rock_paper_scissors.support_enumeration()
    
  5. 我们遍历均衡,并打印每个玩家的策略:

    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]

工作原理...

纳什均衡在博弈论中极其重要,因为它们使我们能够分析战略博弈的结果,并识别有利的位置。它们最早由约翰·纳什在 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., 和 Flannery, B.P., 2017. 数值计算的艺术:数值算法。第三版。剑桥:剑桥大学出版社。

关于优化的更多具体信息可以在以下书籍中找到:

  • Boyd, S.P. 和 Vandenberghe, L., 2018. 凸优化。剑桥:剑桥大学出版社。

  • Griva, I., Nash, S., 和 Sofer, A., 2009. 线性和非线性优化。第二版。费城:工业与应用数学学会。

最后,以下书籍是博弈论的一个很好的入门书籍:

  • Osborne, M.J., 2017. 博弈论导论。牛津:牛津大学出版社。

第十章:提高生产力

本章将讨论一些不属于本书前几章分类的主题。这些主题大多数与简化计算过程和优化代码执行有关。还有一些涉及处理特定类型的数据或文件格式。

本章的目的是为您提供一些工具,虽然它们本质上并非严格的数学工具,但在数学问题中经常出现。这些工具包括分布式计算和优化等主题——它们帮助您更快速地解决问题,验证数据和计算结果,从常见的科学计算文件格式加载和存储数据,并且融入其他有助于提高代码生产力的主题。

在前两种配方中,我们将介绍帮助跟踪计算中的单位和不确定性的包。这些对处理具有直接物理应用的数据的计算非常重要。在接下来的配方中,我们将讨论如何从 网络通用数据格式NetCDF)文件中加载和存储数据。NetCDF 是一种通常用于存储天气和气候数据的文件格式。在第四个配方中,我们将讨论如何处理地理数据,如可能与天气或气候数据相关的数据。接下来,我们将讨论如何从终端运行 Jupyter 笔记本,而无需启动交互式会话。然后,我们将转向数据验证,并在接下来的配方中关注使用 Cython 和 Dask 等工具的性能。最后,我们将简要概述一些编写数据科学可重现代码的技术。

本章将涵盖以下配方:

  • 使用 Pint 跟踪单位

  • 计算中的不确定性

  • 从 NetCDF 文件加载和存储数据

  • 处理地理数据

  • 将 Jupyter 笔记本作为脚本执行

  • 数据验证

  • 使用 Cython 加速代码

  • 使用 Dask 分布式计算

  • 为数据科学编写可重现的代码

让我们开始吧!

技术要求

由于本章包含的内容类型,本章需要许多不同的包。我们需要的包清单如下:

  • Pint

  • uncertainties

  • netCDF4

  • xarray

  • Pandas

  • Scikit-learn

  • GeoPandas

  • Geoplot

  • Jupyter

  • Papermill

  • Cerberus

  • Cython

  • Dask

所有这些包都可以通过您喜欢的包管理工具(如 pip)进行安装:

python3.10 -m pip install pint uncertainties netCDF4 xarray pandas scikit-learn geopandas geoplot jupyter papermill cerberus cython

要安装 Dask 包,我们需要安装与该包相关的各种附加功能。可以通过在终端中使用以下 pip 命令来完成:

python3.10 -m pip install dask[complete]

除了这些 Python 包,我们还需要安装一些支持软件。对于处理地理数据配方,GeoPandas 和 Geoplot 库有许多较低级别的依赖项,可能需要单独安装。详细的安装说明请参考 GeoPandas 包文档,网址:geopandas.org/install.html

对于使用 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-2nd-Edition/tree/main/Chapter%2010

使用 Pint 追踪单位

正确追踪计算中的单位可能非常困难,特别是当不同单位可以互换使用时。例如,非常容易忘记转换不同的单位——英尺/英寸转化为米,或者公制前缀——例如将 1 千米转换为 1,000 米。

在这个配方中,我们将学习如何使用 Pint 包在计算中追踪单位。

准备工作

对于这个配方,我们需要安装 Pint 包,可以通过以下方式导入:

import pint

如何操作...

以下步骤展示了如何使用 Pint 包在计算中追踪单位:

  1. 首先,我们需要创建一个UnitRegistry对象:

    ureg = pint.UnitRegistry(system="mks")
    
  2. 要创建一个带有单位的量,我们将数字乘以注册对象的相应属性:

    distance = 5280 * ureg.feet
    
  3. 我们可以使用其中一个可用的转换方法来更改量的单位:

    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
  1. 我们封装一个例程,使其期望接受秒为单位的参数,并输出以米为单位的结果:

    @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
    
  2. 现在,当我们用minute单位调用calc_depth例程时,它会自动转换为秒进行计算:

    depth = calc_depth(0.05 * ureg.minute)
    
    print("Depth", depth)
    
    # Depth 44.144999999999996 meter
    

它是如何工作的...

Pint 包提供了一个包装类,用于数值类型,给类型添加了单位元数据。这个包装类型实现了所有标准的算术操作,并在整个计算过程中追踪单位。例如,当我们将长度单位除以时间单位时,我们会得到一个速度单位。这意味着,您可以使用 Pint 来确保在复杂计算后单位是正确的。

UnitRegistry对象追踪会话中所有存在的单位,并处理不同单位类型之间的转换等操作。它还维护着一个度量参考系统,在本配方中,是以米、千克和秒为基本单位的国际标准系统,称为mks

wraps 功能允许我们声明一个例程的输入和输出单位,这使得 Pint 能够对输入函数进行自动单位转换——在这个示例中,我们将分钟转换为秒。试图使用没有关联单位的数量,或者使用不兼容单位调用包装函数,会抛出异常。这使得在运行时可以验证参数,并自动转换成例程所需的正确单位。

还有更多内容...

Pint 包提供了一个包含大量预编程单位的测量单位列表,覆盖了大多数全球使用的系统。单位可以在运行时定义,也可以从文件中加载。这意味着你可以定义特定于应用程序的自定义单位或单位系统。

单位也可以在不同的上下文中使用,这使得在通常不相关的不同单位类型之间轻松转换成为可能。这可以在计算过程中需要频繁转换单位时节省大量时间。

在计算中考虑不确定性

大多数测量设备并非 100% 准确,而是具有一定的准确度,通常在 0 到 10% 之间。例如,一个温度计的准确度可能为 1%,而一把数显卡尺的准确度可能为 0.1%。在这两种情况下,真实值不太可能与报告值完全一致,尽管它们会非常接近。追踪一个值的不确定性是非常困难的,特别是当多个不确定性以不同的方式结合时。与其手动跟踪这些,不如使用一个一致的库来代替。这正是 uncertainties 包的功能。

在这个示例中,我们将学习如何量化变量的不确定性,并观察这些不确定性是如何在计算中传播的。

准备工作

对于这个示例,我们将需要 uncertainties 包,其中我们将导入 ufloat 类和 umath 模块:

from uncertainties import ufloat, umath

如何操作...

以下步骤展示了如何在计算中量化数值的不确定性:

  1. 首先,我们创建一个不确定浮动值 3.0 加减 0.4

    seconds = ufloat(3.0, 0.4)
    
    print(seconds)      # 3.0+/-0.4
    
  2. 接下来,我们进行涉及该不确定值的计算,以获得一个新的不确定值:

    depth = 0.5*9.81*seconds*seconds
    
    print(depth)      # 44+/-12
    
  3. 接下来,我们创建一个新的不确定浮动值,并应用 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
    

如我们所见,第一次计算(步骤 2)的结果是一个不确定浮动值,其数值为 44,并且存在 系统误差。这意味着真实值可能在 32 到 56 之间。根据我们现有的测量数据,我们无法得出更精确的结果。

它是如何工作的...

ufloat类封装了float对象,并在整个计算过程中追踪不确定性。该库利用线性误差传播理论,使用非线性函数的导数来估算计算中的误差传播。该库还正确处理相关性,因此从自身减去一个值时,结果为零且没有误差。

为了追踪标准数学函数中的不确定性,你需要使用umath模块中提供的版本,而不是使用 Python 标准库或第三方包如 NumPy 中定义的版本。

还有更多...

uncertainties包为 NumPy 提供支持,而之前例子中提到的 Pint 包可以与uncertainties结合使用,以确保单位和误差范围正确地归属于计算的最终值。例如,我们可以计算本例第2 步中计算的单位,如下所示:

import pint
from uncertainties import ufloat
ureg = pint.UnitRegistry(system="mks")
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 文件加载数据并将数据存储到 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 文件:

  1. 首先,我们需要创建一些随机数据。这些数据包含日期范围、位置代码列表以及随机生成的数字:

    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)
    
  2. 接下来,我们创建一个包含数据的 xarray Dataset对象。日期和位置是索引,而stepsaccumulated变量是数据:

    data_array = xr.Dataset({
    
        "steps": (("date", "location"), steps),
    
        "accumulated": (("date", "location"), accumulated)     },
    
        {"location": locations, "date": dates}
    
    )
    

这里显示了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
  1. 接下来,我们计算每个时间索引下所有位置的平均值:

    means = data_array.mean(dim="location")
    
  2. 现在,我们在新的坐标轴上绘制平均累计值:

    fig, ax = plt.subplots()
    
    means["accumulated"].to_dataframe().plot(ax=ax)
    
    ax.set(title="Mean accumulated values", 
    
        xlabel="date", ylabel="value")
    

生成的图像如下所示:

![图 10.1 - 累计均值随时间变化的图表]

](https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/app-math-py-2e/img/B19085_10_01.jpg)

图 10.1 - 随时间变化的累积均值图

  1. 使用to_netcdf方法将此数据集保存为一个新的 NetCDF 文件:

    data_array.to_netcdf("data.nc")
    
  2. 现在,我们可以使用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

输出显示加载的数组包含了我们在之前步骤中添加的所有数据。关键步骤是56,在这两个步骤中,我们存储并加载了这个"data.nc"数据。

它是如何工作的...

xarray包提供了DataArrayDataSet类,它们大致上是 Pandas SeriesDataFrame对象的多维等效物。在这个示例中,我们使用数据集,因为每个索引——一个包含日期和位置的元组——都关联着两条数据。这两个对象都暴露了与它们的 Pandas 等效物相似的接口。例如,我们可以使用mean方法计算某个轴上的均值。DataArrayDataSet对象还有一个方便的方法,可以将其转换为 Pandas DataFrame,这个方法叫做to_dataframe。我们在这个食谱中使用它,将means Dataset中的累积列转换为DataFrame以便绘图,虽然这并不是必须的,因为xarray本身就内置了绘图功能。

本食谱的真正重点是to_netcdf方法和load_dataset例程。前者将一个DataSet对象存储为 NetCDF 格式的文件。这需要安装netCDF4包,因为它允许我们访问解码 NetCDF 格式文件所需的相关 C 库。load_dataset例程是一个通用的例程,用于从各种文件格式加载数据到DataSet对象中,包括 NetCDF 格式(同样,这需要安装netCDF4包)。

还有更多...

xarray包除了支持 NetCDF 格式外,还支持多种数据格式,如 OPeNDAP、Pickle、GRIB 等,这些格式也被 Pandas 支持。

处理地理数据

许多应用涉及处理地理数据。例如,在跟踪全球天气时,我们可能想要绘制世界各地传感器测量的温度,并标注它们在地图上的位置。为此,我们可以使用 GeoPandas 包和 Geoplot 包,它们都允许我们操作、分析和可视化地理数据。

在本食谱中,我们将使用 GeoPandas 和 Geoplot 包加载并可视化一些示例地理数据。

准备工作

对于这个食谱,我们将需要导入 GeoPandas 包、Geoplot 包以及 Matplotlib 的pyplot包,命名为plt

import geopandas
import geoplot
import matplotlib.pyplot as plt

如何实现...

按照以下步骤,使用示例数据在世界地图上绘制首都城市的简单图:

  1. 首先,我们需要加载 GeoPandas 包中的示例数据,这些数据包含了全球的几何信息:

    world = geopandas.read_file(
    
        geopandas.datasets.get_path("naturalearth_lowres")
    
    )
    
  2. 接下来,我们需要加载包含全球每个首都城市名称和位置的数据:

    cities = geopandas.read_file(
    
        geopandas.datasets.get_path("naturalearth_cities")
    
    )
    
  3. 现在,我们可以创建一个新的图形,并使用 polyplot 例程绘制世界地理轮廓:

    fig, ax = plt.subplots()
    
    geoplot.polyplot(world, ax=ax, alpha=0.7)
    
  4. 最后,我们使用 pointplot 例程将首都城市的位置叠加到世界地图上。我们还设置了坐标轴的限制,以确保整个世界都能显示出来:

    geoplot.pointplot(cities, ax=ax, fc="k", marker="2")
    
    ax.axis((-180, 180, -90, 90))
    

最终得到的图像展示了世界首都城市的位置:

图 10.2 - 世界各国首都城市在地图上的分布

图 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 notebook 作为脚本执行

Jupyter notebook 是一种流行的工具,广泛用于编写科学计算和数据分析应用中的 Python 代码。Jupyter notebook 实际上是一系列存储在 .ipynb 格式文件中的代码块。每个代码块可以是不同类型的,比如代码块或 Markdown 块。这些 notebook 通常通过 Web 应用访问,Web 应用解释这些代码块并在后台的内核中执行代码,结果再返回给 Web 应用。如果你在个人电脑上工作,这种方式非常方便,但如果你想在远程服务器上执行 notebook 中的代码呢?在这种情况下,可能连 Jupyter Notebook 提供的 Web 界面都无法访问。papermill 包允许我们在命令行中对 notebook 进行参数化和执行。

在本节中,我们将学习如何使用 papermill 从命令行执行 Jupyter notebook。

准备工作

对于此方法,我们需要安装 papermill 包,并且在当前目录中有一个示例 Jupyter notebook。我们将使用本章代码库中存储的 sample.ipynb notebook 文件。

如何操作...

按照以下步骤使用 papermill 命令行界面远程执行 Jupyter notebook:

  1. 首先,我们从本章的代码库中打开示例 notebook 文件 sample.ipynb。该 notebook 包含三个代码单元,其中包含以下代码:

    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")
    
  2. 接下来,我们在终端中打开包含 Jupyter notebook 的文件夹,并使用以下命令:

    papermill --kernel python3 sample.ipynb output.ipynb
    
  3. 现在,我们打开输出文件 output.ipynb,该文件应该包含已经更新的 notebook,其中包含执行代码的结果。最终生成的散点图如下所示:

图 10.3 - 在 Jupyter notebook 内生成的随机数据的散点图

图 10.3 - 在 Jupyter notebook 内生成的随机数据的散点图

请注意,papermill 命令的输出是一个全新的 notebook,它复制了原始代码和文本内容,并填充了运行命令后的输出。这对于“冻结”用于生成结果的确切代码非常有用。

工作原理...

papermill 包提供了一个简单的命令行界面,它解释并执行 Jupyter notebook,并将结果存储在新的 notebook 文件中。在此方法中,我们将第一个参数——输入的 notebook 文件——设为 sample.ipynb,第二个参数——输出的 notebook 文件——设为 output.ipynb。然后,该工具执行 notebook 中的代码并生成输出。notebook 的文件格式会跟踪最后一次运行的结果,因此这些结果会被添加到输出 notebook 中并存储在指定位置。在这个示例中,我们将其存储为一个简单的本地文件,但 papermill 也可以将其存储在云端位置,如 Amazon Web Services (AWS) S3 存储或 Azure 数据存储。

步骤 2 中,我们在使用 papermill 命令行界面时添加了 --kernel python3 选项。该选项允许我们指定用于执行 Jupyter notebook 的内核。如果 papermill 尝试使用与编写 notebook 时不同的内核执行 notebook,可能会导致错误,因此此选项可能是必要的。可以通过在终端中使用以下命令来查看可用的内核列表:

jupyter kernelspec list

如果执行 notebook 时遇到错误,您可以尝试更换为其他内核。

还有更多内容...

Papermill 还提供了一个 Python 接口,使得你可以在 Python 应用程序中执行 notebook。这对于构建需要在外部硬件上执行长时间计算并且结果需要存储在云中的 Web 应用程序可能很有用。它还可以向 notebook 提供参数。为此,我们需要在 notebook 中创建一个标记为参数的块,并设置默认值。然后,可以通过命令行接口使用-p标志提供更新的参数,后跟参数名称和相应的值。

验证数据

数据通常以原始形式呈现,可能包含异常、错误或格式不正确的数据,这显然会给后续的处理和分析带来问题。通常,在处理管道中构建一个验证步骤是个好主意。幸运的是,Cerberus 包为 Python 提供了一个轻量级且易于使用的验证工具。

对于验证,我们必须定义一个模式,它是数据应该是什么样子以及应该执行哪些检查的技术描述。例如,我们可以检查类型并设定最大和最小值的边界。Cerberus 验证器还可以在验证步骤中执行类型转换,这使我们能够将直接从 CSV 文件加载的数据插入到验证器中。

在这个示例中,我们将学习如何使用 Cerberus 验证从 CSV 文件加载的数据。

准备就绪

对于这个示例,我们需要从 Python 标准库中导入csv模块(docs.python.org/3/library/csv.html),以及 Cerberus 包:

import csv
import cerberus

我们还需要此章节中的sample.csv文件,来自代码仓库(github.com/PacktPublishing/Applying-Math-with-Python/tree/master/Chapter%2010)。

如何操作...

在接下来的步骤中,我们将验证一组已从 CSV 加载的数据,使用 Cerberus 包进行验证:

  1. 首先,我们需要构建一个描述我们期望数据的模式。为此,我们必须定义一个简单的浮点数模式:

    float_schema = {"type": "float", "coerce": float, 
    
        "min": -1.0, "max": 1.0}
    
  2. 接下来,我们为单个项构建模式。这些将是我们数据的行:

    item_schema = {
    
        "type": "dict",
    
        "schema": {
    
            "id": {"type": "string"},
    
            "number": {"type": "integer",
    
            "coerce": int},
    
        "lower": float_schema,
    
        "upper": float_schema,
    
        }
    
    }
    
  3. 现在,我们可以为整个文档定义一个模式,该模式将包含一个项目列表:

    schema = {
    
        "rows": {
    
            "type": "list",
    
            "schema": item_schema
    
        }
    
    }
    
  4. 接下来,我们创建一个带有我们刚定义的模式的Validator对象:

    validator = cerberus.Validator(schema)
    
  5. 然后,我们使用csv模块中的DictReader加载数据:

    with open("sample.csv") as f:
    
        dr = csv.DictReader(f)
    
        document = {"rows": list(dr)}
    
  6. 接下来,我们使用validate方法在validator上验证文档:

    validator.validate(document)
    
  7. 然后,我们从validator对象中获取验证过程中的错误:

    errors = validator.errors["rows"][0]
    
  8. 最后,我们可以打印出现的任何错误信息:

    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']}]

这已识别出四行不符合我们设定的模式,这限制了“lower”和“upper”中的浮动值仅限于-1.01.0之间。

它是如何工作的...

我们创建的架构是所有需要检查数据标准的技术描述。通常,它会被定义为一个字典,字典的键是项目名称,值是一个包含属性(如类型或值范围)的字典。例如,在第 1 步中,我们为浮点数定义了一个架构,限制这些数字的值在-1 和 1 之间。请注意,我们包含了coerce键,它指定了在验证过程中值应该转换为的类型。这使得我们可以传入从 CSV 文档中加载的数据,尽管它仅包含字符串,而无需担心其类型。

validator对象负责解析文档,以便验证并检查它们包含的数据是否符合架构中描述的所有标准。在这个食谱中,我们在创建validator对象时提供了架构。然而,我们也可以将架构作为第二个参数传递给validate方法。错误信息被存储在一个嵌套的字典中,字典的结构与文档的结构相对应。

使用 Cython 加速代码

Python 经常被批评为一种慢编程语言——这一点常常被辩论。许多批评可以通过使用具有 Python 接口的高性能编译库来解决,比如科学 Python 栈,从而大大提高性能。然而,有些情况下,我们无法避免 Python 不是一种编译语言的事实。在这些(相对罕见)情况下,改善性能的一种方式是编写 C 扩展(甚至将代码完全用 C 重写),以加速关键部分。这肯定会让代码运行得更快,但可能会让维护该包变得更加困难。相反,我们可以使用 Cython,它是 Python 语言的扩展,通过将 Python 代码转换为 C 并编译,从而实现极大的性能提升。

例如,我们可以考虑一些用来生成曼德尔布罗集图像的代码。为了对比,我们假设纯 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 向量化这段代码。初步测试表明,使用这些函数生成曼德尔布罗集图像,使用 320 × 240 的点和 255 步大约需要 6.3 秒。你的测试时间可能会有所不同,取决于你的系统。

在这个食谱中,我们将使用 Cython 大幅提升前述代码的性能,以生成曼德尔布罗集图像。

准备工作

对于这个食谱,我们需要安装 NumPy 包和 Cython 包。你还需要在系统上安装一个 C 编译器,如 GCC。例如,在 Windows 上,你可以通过安装 MinGW 来获得 GCC 版本。

如何做到...

按照以下步骤使用 Cython 大幅提高生成曼德尔布罗特集图像代码的性能:

  1. 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
    
  2. 接下来,我们使用 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
    
  3. 其余部分与 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
    
  4. 接下来,我们定义compute_mandel函数的新版本。我们向该函数添加了两个来自 Cython 包的装饰器:

    @cython.boundscheck(False)
    
    @cython.wraparound(False)
    
    def compute_mandel(int N_x, int N_y, int N_iter):
    
  5. 然后,我们定义常量,就像在原始例程中一样:

        cdef double xlim_l = -2.5
    
        cdef double xlim_u = 0.5
    
        cdef double ylim_l = -1.2
    
        cdef double ylim_u = 1.2
    
  6. 我们使用与 Python 版本完全相同的方式调用 NumPy 包中的linspaceempty例程。唯一的不同是,我们声明了ij变量,它们是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
    
  7. 定义的其余部分与 Python 版本完全相同:

        for i in range(N_x):
    
            for j in range(N_y):
    
                height[i, j] = in_mandel(
    
                    xx_vals[i], y_vals[j], N_iter)
    
            return height
    
  8. 接下来,我们在mandelbrot文件夹中创建一个新的文件,命名为setup.py,并在文件顶部添加以下导入:

    # mandelbrot/setup.py
    
    import numpy as np
    
    from setuptools import setup, Extension
    
    from Cython.Build import cythonize
    
  9. 之后,我们定义一个扩展模块,源文件指向原始的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")]
    
    )
    
  10. 现在,我们定义第二个扩展模块,源文件设置为刚才创建的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")]
    
    )
    
  11. 接下来,我们将这两个扩展模块添加到一个列表中,并调用setup例程来注册这些模块:

    extensions = [hybrid, cython]
    
    setup(
    
        ext_modules = cythonize(
    
            extensions, compiler_directives={
    
    		    "language_level": "3"}),
    
    )
    
  12. mandelbrot文件夹中创建一个名为__init__.py的新空文件,使其成为一个可以导入到 Python 中的包。

  13. 打开mandelbrot文件夹中的终端,使用以下命令构建 Cython 扩展模块:

    python3.8 setup.py build_ext --inplace
    
  14. 现在,开始一个名为run.py的新文件,并添加以下import语句:

    # run.py
    
    from time import time
    
    from functools import wraps
    
    import matplotlib.pyplot as plt
    
  15. 从我们定义的每个模块中导入不同的compute_mandel例程:python_mandel为原始版本;hybrid_mandel为 Cython 化的 Python 代码;cython_mandel为编译后的纯 Cython 代码:

    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
    
  16. 定义一个简单的计时器装饰器,我们将用它来测试例程的性能:

    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
    
  17. 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
    
  18. 使用我们之前设置的常量运行每个已装饰的例程。将最后一次调用(Cython 版本)的输出记录在vals变量中:

    mandel_py(Nx, Ny, steps)
    
    mandel_hy(Nx, Ny, steps)
    
    vals = mandel_cy(Nx, Ny, steps)
    
  19. 最后,绘制 Cython 版本的输出,以检查该例程是否正确计算曼德尔布罗特集:

    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: 11.399756908416748
Time taken for Hybrid: 10.955225229263306
Time taken for Cython: 0.24534869194030762

注意

这些时间不如第一版那样好,这可能是由于作者的 PC 上 Python 安装的方式。你的时间可能会有所不同。

曼德尔布罗特集的图像可以在下图中看到:

图 10.4 - 使用 Cython 代码计算的曼德尔布罗特集图像

](https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/app-math-py-2e/img/B19085_10_04.jpg)

图 10.4 - 使用 Cython 代码计算的曼德尔布罗特集图像

这就是我们对曼德尔布罗集合的预期效果。细节部分在边界处有所显示。

它是如何工作的...

这个食谱中有很多内容需要说明,因此我们先从解释整体过程开始。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 标准库中提供的配置文件(如 cProfile)来找到代码中性能瓶颈发生的地方。在这个案例中,性能瓶颈的位置相当明显。Cython 是解决这个问题的一个好办法,因为它涉及到在(双重)for 循环中重复调用一个函数。然而,它并不是解决所有性能问题的万能方法,通常情况下,重构代码使其能够利用高性能库,往往能显著提高代码的性能。

Cython 与 Jupyter Notebook 紧密集成,可以无缝地在笔记本的代码块中使用。当 Cython 使用 Anaconda 发行版安装时,它也包含在 Anaconda 中,因此不需要额外的设置就可以在 Jupyter 笔记本中使用 Cython。

还有其他替代方案可以将 Python 代码编译为机器码。例如,Numba 包 (numba.pydata.org/) 提供了一个 即时编译JIT)编译器,通过简单地在特定函数上添加装饰器,在运行时优化 Python 代码。Numba 旨在与 NumPy 和其他科学 Python 库一起使用,也可以用来利用 GPU 加速代码。

通过 pyjion 包(www.trypyjion.com/)还可以使用一个通用的 JIT 编译器。这可以在各种场景中使用,不像 Numba 库主要用于数值代码。jax 库中也有一个内置的 JIT 编译器,如在第三章所讨论的,但它也仅限于数值代码。

使用 Dask 进行分布式计算

Dask 是一个用于在多个线程、进程甚至计算机之间分布式计算的库,旨在有效地进行大规模计算。即使在单台笔记本电脑上工作,它也能大幅提升性能和吞吐量。Dask 提供了 Python 科学计算栈中大多数数据结构的替代品,比如 NumPy 数组和 Pandas DataFrame。这些替代品具有非常相似的接口,但底层是为分布式计算而构建的,可以在多个线程、进程或计算机之间共享。在许多情况下,切换到 Dask 就像改变 import 语句那么简单,可能还需要添加几个额外的方法调用来启动并发计算。

在这个实例中,我们将学习如何使用 Dask 在 DataFrame 上进行一些简单的计算。

准备工作

对于这个实例,我们需要从 Dask 包中导入 dataframe 模块。按照 Dask 文档中的约定,我们将以 dd 别名导入此模块:

import dask.dataframe as dd

我们还需要本章代码库中的 sample.csv 文件。

如何操作...

按照以下步骤使用 Dask 对 DataFrame 对象执行一些计算:

  1. 首先,我们需要将数据从 sample.csv 加载到 Dask DataFrame 中。number 列的类型设置为 "object",因为否则 Dask 的类型推断会失败(因为此列包含 None,但其余部分为整数):

    data = dd.read_csv("sample.csv", dtype={
    
        "number":"object"})
    
  2. 接下来,我们对 DataFrame 的列执行标准计算:

    sum_data = data.lower + data.upper
    
    print(sum_data)
    

与 Pandas DataFrame 不同,结果不是一个新的 DataFrame。print 语句为我们提供了以下信息:

Dask Series Structure:
npartitions=1
             float64
                               ...
dtype: float64
Dask Name: add, 4 graph layers
  1. 要实际获取结果,我们需要使用 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
  1. 我们计算最后两列的均值,方法和使用 Pandas DataFrame 完全相同,只是需要调用 compute 方法来执行计算:

    means = data[["lower", "upper"]].mean().compute()
    
    print(means)
    

结果如我们所预期的那样打印出来:

lower -0.060393
upper -0.035192
dtype: float64

它是如何工作的...

Dask 为计算构建了一个任务图,该图描述了需要在数据集合上执行的各种操作和计算之间的关系。这将计算步骤拆解开来,以便可以在不同的工作节点上按照正确的顺序进行计算。这个任务图会被传递给调度器,调度器将实际任务发送到工作节点进行执行。Dask 提供了几种不同的调度器:同步、线程化、进程化和分布式。调度器的类型可以在调用 compute 方法时选择,或者全局设置。如果没有指定,Dask 会选择一个合理的默认值。

同步、线程化和进程化调度器适用于单台机器,而分布式调度器则用于集群。Dask 允许你以相对透明的方式在调度器之间进行切换,尽管对于小任务来说,由于设置更复杂的调度器所带来的开销,你可能不会获得任何性能提升。

compute 方法是本食谱的关键。通常会对 Pandas DataFrame 执行计算的方法,现在仅仅是设置一个通过 Dask 调度器执行的计算。直到调用 compute 方法,计算才会开始。这类似于 Future(如来自 asyncio 标准库的 Future)的方式,它作为异步函数调用结果的代理,直到计算完成时,才会返回实际结果。

还有更多...

Dask 提供了用于 NumPy 数组的接口,以及在本食谱中展示的 DataFrame 接口。还有一个名为 dask_ml 的机器学习接口,提供与 scikit-learn 包类似的功能。一些外部包,如 xarray,也有 Dask 接口。Dask 还可以与 GPU 协同工作,以进一步加速计算并从远程源加载数据,这在计算分布在集群中的时候非常有用。

编写可重复的数据科学代码

科学方法的基本原则之一是,结果应该是可重复的并且可以独立验证的。遗憾的是,这一原则常常被低估,反而更重视“新颖”的想法和结果。作为数据科学的从业者,我们有责任尽力使我们的分析和结果尽可能具有可重复性。

由于数据科学通常完全在计算机上进行——也就是说,它通常不涉及测量中的仪器误差——有些人可能会认为所有的数据科学工作本质上都是可重复的。但事实并非如此。在使用随机化的超参数搜索或基于随机梯度下降的优化时,容易忽略一些简单的细节,比如种子设置(参见第三章)。此外,一些更微妙的非确定性因素(例如使用线程或多进程)如果不加以注意,可能会显著改变结果。

在这个例程中,我们将展示一个基本数据分析管道的示例,并实施一些基本步骤,以确保您可以重复结果。

准备工作

对于这个例程,我们将需要 NumPy 包,通常以np导入,Pandas 包,以pd导入,Matplotlib 的pyplot接口,以plt导入,以及从scikit-learn包中导入以下内容:

from sklearn.metrics import ConfusionMatrixDisplay, accuracy_score
from sklearn.model_selection import train_test_split
from sklearn.tree import DecisionTreeClassifier

我们将模拟我们的数据(而不是从其他地方获取数据),因此需要使用一个具有种子值的默认随机数生成器实例(以确保可重复性):

rng = np.random.default_rng(12345)

为了生成数据,我们定义了以下例程:

def get_data():
	permute = rng.permutation(200)
	data = np.vstack([
		rng.normal((1.0, 2.0, -3.0), 1.0,
		size=(50, 3)),
		rng.normal((-1.0, 1.0, 1.0), 1.0,
		size=(50, 3)),
		rng.normal((0.0, -1.0, -1.0), 1.0,
		size=(50, 3)),
		rng.normal((-1.0, -1.0, -2.0), 1.0,
		size=(50, 3))
		])
	labels = np.hstack(
		[[1]*50, [2]*50, [3]*50,[4]*50])
	X = pd.DataFrame(
		np.take(data, permute, axis=0),
		columns=["A", "B", "C"])
	y = pd.Series(np.take(labels, permute, axis=0))
	return X, y

我们使用这个函数来代替其他将数据加载到 Python 中的方法,例如从文件中读取或从互联网上下载。

如何实现……

请按照以下步骤创建一个非常简单且可重复的数据科学管道:

  1. 首先,我们需要使用之前定义的get_data例程“加载”数据:

    data, labels = get_data()
    
  2. 由于我们的数据是动态获取的,最好将数据与我们生成的任何结果一起存储。

    data.to_csv("data.csv")
    
    labels.to_csv("labels.csv")
    
  3. 现在,我们需要使用scikit-learn中的train_test_split例程将数据分为训练集和测试集。我们将数据按 80/20 的比例进行划分,并确保设置了随机状态,以便可以重复这个过程(尽管我们将在下一步保存索引以供参考):

    X_train, X_test, y_train, y_test = train_test_split(
    
        data,labels, test_size=0.2, random_state=23456)
    
  4. 现在,我们确保保存训练集和测试集的索引,以便我们确切知道每个样本中的观测值。我们可以将这些索引与步骤 2中存储的数据一起使用,以便稍后完全重建这些数据集:

    X_train.index.to_series().to_csv("train_index.csv",
    
        index=False, header=False)
    
    X_test.index.to_series().to_csv("test_index.csv",
    
        index=False, header=False)
    
  5. 现在,我们可以设置并训练分类器。在这个示例中,我们使用一个简单的DecisionTreeClassifier,但这个选择并不重要。由于训练过程涉及一些随机性,请确保将random_state关键字参数设置为种子值,以便控制这种随机性:

    classifier = DecisionTreeClassifier(random_state=34567)
    
    classifer.fit(X_train, y_train)
    
  6. 在继续之前,最好先收集一些关于训练模型的信息,并将其与结果一起存储。不同模型的有趣信息会有所不同。对于这个模型,特征重要性信息可能很有用,因此我们将其记录在一个 CSV 文件中:

    feat_importance = pd.DataFrame(
    
    	classifier.feature_importances_,
    
    	index=classifier.feature_names_in_,
    
    	columns=["Importance"])
    
    feat_importance.to_csv("feature_importance.csv")
    
  7. 现在,我们可以继续检查模型的表现。我们将在训练数据和测试数据上评估模型,稍后我们会将其与真实标签进行比较:

    train_predictions = classifier.predict(X_train)
    
    test_predictions = classifier.predict(X_test)
    
  8. 始终保存此类预测任务(或回归任务,或其他将以某种方式成为报告一部分的最终结果)的结果。我们首先将它们转换为Series对象,以确保索引设置正确:

    pd.Series(train_predictions,index=X_train.index,
    
        name="Predicted label").to_csv(
    
    		"train_predictions.csv")
    
    pd.Series(test_predictions,index=X_test.index,
    
        name="Predicted label").to_csv(
    
    		"test_predictions.csv")
    
  9. 最后,我们可以生成任何图形或度量,以帮助我们决定如何继续进行分析。在这里,我们将为训练和测试队列分别生成一个混淆矩阵图,并打印出一些准确度总结评分:

    fig, (ax1, ax2) = plt.subplots(1, 2, tight_layout=True)
    
    ax1.set_title("Confusion matrix for training data")
    
    ax2.set_title("Confusion matrix for test data")
    
    ConfusionMatrixDisplay.from_predictions(
    
    	y_train, train_predictions,
    
    	ax=ax1 cmap="Greys", colorbar=False)
    
    ConfusionMatrixDisplay.from_predictions(
    
    	y_test, test_predictions,
    
    	ax=ax2 cmap="Greys", colorbar=False)
    
    print(f"Train accuracy {accuracy_score(y_train, train_predictions)}",
    
    	f"Test accuracy {accuracy_score(y_test, test_predictions)}",
    
    	sep="\n")
    
    # Train accuracy 1.0
    
    # Test accuracy 0.65
    

结果的混淆矩阵见图 10.5

图 10.5 - 简单分类任务的混淆矩阵

图 10.5 - 简单分类任务的混淆矩阵

这个例子的测试结果并不出色,这不应该令人惊讶,因为我们没有花时间选择最合适的模型或进行调优,并且我们的样本量相当小。为这些数据生成一个准确的模型并不是目标。在当前目录中(脚本运行所在的目录),应该会有一些新的 CSV 文件,包含我们写入磁盘的所有中间数据:data.csvlabels.csvtrain_index.csvtest_index.csvfeature_importance.csvtrain_predictions.csvtest_predictions.csv

它是如何工作的…

在可重复性方面没有明确的正确答案,但肯定有错误的答案。我们这里只触及了如何使代码更具可重复性的一些想法,但还有很多可以做的事情。(参见更多内容…)。在这个过程中,我们实际上更专注于存储中间值和结果,而不是其他任何东西。这一点常常被忽视,大家更倾向于生成图表和图形——因为这些通常是展示结果的方式。然而,我们不应该为了更改图表的样式而重新运行整个流程。存储中间值可以让你审计流程中的各个部分,检查你做的事情是否合理和适当,并确保你能从这些中间值中重现结果。

一般来说,数据科学流程包含五个步骤:

  1. 数据获取

  2. 数据预处理和特征选择

  3. 模型和超参数调优

  4. 模型训练

  5. 评估和结果生成

在本教程中,我们将数据获取步骤替换为一个随机生成数据的函数。如引言中所述,这一步通常会涉及从磁盘加载数据(来自 CSV 文件或数据库)、从互联网下载数据,或直接从测量设备采集数据。我们缓存了数据获取的结果,因为我们假设这是一个开销较大的操作。当然,这并非总是如此;如果你直接从磁盘加载所有数据(例如通过 CSV 文件),那么显然不需要存储这份数据的第二份副本。然而,如果你通过查询一个大型数据库生成数据,那么存储数据的平面副本将大大提高你在数据管道上迭代的速度。

我们的预处理仅包括将数据分割成训练集和测试集。再次说明,我们在这一步之后存储了足够的数据,以便稍后可以独立地重建这些数据集——我们只存储了与每个数据集对应的 ID。由于我们已经存储了这些数据集,因此在 train_test_split 函数中种子随机数并非绝对必要,但通常来说这是一个好主意。如果你的预处理涉及更为密集的操作,你可能会考虑缓存处理过的数据或在数据管道中使用的生成特征(我们稍后将更详细地讨论缓存)。如果你的预处理步骤涉及从数据的列中选择特征,那么你应该绝对保存这些选择的特征到磁盘,并与结果一起存储。

我们的模型非常简单,且没有任何(非默认)超参数。如果你做过超参数调整,你应该将这些参数以及任何可能需要用来重建模型的元数据进行存储。存储模型本身(通过序列化或其他方式)是有用的,但请记住,序列化后的模型可能无法被其他方读取(例如,如果他们使用的是不同版本的 Python)。

你应该始终存储来自模型的数值结果。当你检查后续运行时结果是否相同,比较图表和其他摘要图形几乎是不可能的。此外,这样做可以让你在之后快速重新生成图形或数值,如果有需要的话。例如,如果你的分析涉及二元分类问题,那么存储用于生成接收者操作特征ROC)曲线的值是个好主意,即使你已经绘制了 ROC 曲线的图形并报告了曲线下的面积。

还有更多……

我们在这里还没有讨论很多内容。首先,让我们处理一个显而易见的问题。Jupyter notebooks 是一种常用的数据科学工作流工具。这没问题,但用户应该了解,这种格式有几个缺点。首先,最重要的一点是,Jupyter notebooks 可以无序运行,后面的单元格可能会依赖于前面的单元格,这些依赖关系可能不是很直观。为了解决这个问题,确保你每次都在一个干净的内核上完整地运行整个 notebook,而不是仅仅在当前内核中重新运行每个单元格(例如,使用 Executing a Jupyter notebook as a script 这一配方中的 Papermill 工具)。其次,notebook 中存储的结果可能与代码单元格中编写的代码不一致。这种情况发生在 notebook 被运行过并且代码在事后被修改,但没有重新运行的情况下。一个好的做法是,保留一份没有任何存储结果的主副本,然后创建多个副本,其中包含结果,并且这些副本不再进行修改。最后,Jupyter notebooks 通常在一些难以正确缓存中间步骤结果的环境中执行。这部分问题由 notebook 内部的缓存机制部分解决,但它并不总是完全透明的。

现在,让我们讨论两个与可重复性相关的一般性问题:配置和缓存。配置是指用于控制工作流设置和执行的值的集合。在这个配方中,我们没有明显的配置值,除了在 train_test_split 例程中使用的随机种子和模型(以及数据生成,暂时不考虑这些),以及训练/测试集拆分时所取的百分比。这些值是硬编码在配方中的,但这可能不是最好的做法。至少,我们希望能够记录每次运行分析时使用的配置。理想情况下,配置应该从文件中加载(仅加载一次),然后在工作流运行之前被最终确定并缓存。这意味着,完整的配置应该从一个或多个来源(配置文件、命令行参数或环境变量)加载,汇总为一个“真实来源”,然后将其序列化为机器可读和人类可读的格式,如 JSON,并与结果一起存储。这样你就可以确切知道是使用了什么配置生成了这些结果。

缓存是存储中间结果的过程,以便在随后的运行中可以重用,从而减少运行时间。在本食谱中,我们确实存储了中间结果,但我们没有建立机制来重用已存储的数据(如果它存在且有效)。这是因为实际的检查和加载缓存值的机制较为复杂,并且有些依赖于具体的配置。由于我们的项目非常小,所以缓存值未必有意义。然而,对于有多个组件的大型项目来说,缓存确实能带来差异。在实现缓存机制时,您应该建立一个系统来检查缓存是否有效,例如,可以使用代码文件及其依赖的任何数据源的 SHA-2 哈希值。

在存储结果时,通常的好做法是将所有结果一起存储在一个带有时间戳的文件夹或类似的地方。我们在本食谱中没有这样做,但其实很容易实现。例如,使用标准库中的datetimepathlib模块,我们可以轻松创建一个用于存储结果的基础路径:

from pathlib import Path
from datetime import datetime
RESULTS_OUT = Path(datetime.now().isoformat())
...
results.to_csv(RESULTS_OUT / "name.csv")

如果您使用多进程并行运行多个分析任务,必须小心,因为每个新进程都会生成一个新的RESULTS_OUT全局变量。更好的选择是将其纳入配置过程,这样用户还可以自定义输出路径。

除了我们迄今为止讨论的脚本中的实际代码外,在项目层面上还有很多可以做的事情,以提高代码的可重现性。第一步,也是最重要的一步,是尽可能使代码可用,这包括指定代码可以共享的许可证(如果可以的话)。此外,好的代码应该足够健壮,以便可以用于分析多个数据集(显然,这些数据应该与最初使用的数据类型相同)。同样重要的是使用版本控制(如 Git、Subversion 等)来跟踪更改。这也有助于将代码分发给其他用户。最后,代码需要有良好的文档说明,并且理想情况下应有自动化测试,以检查管道在示例数据集上的预期表现。

参见...

以下是一些关于可重现编码实践的额外信息来源:

这结束了本书的第十章,也是最后一章。请记住,我们才刚刚触及了使用 Python 做数学时可能实现的表面,您应该阅读本书中提到的文档和资源,以了解这些包和技术能够实现的更多信息。

posted @ 2025-07-16 12:31  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报