Python-科学计算第二版-全-
Python 科学计算第二版(全)
原文:
annas-archive.org/md5/58a42b6d23877a7910fec539d6659c22译者:飞龙
前言
Python 在科学计算领域具有巨大的潜力。本书更新版《Python 科学计算》增加了关于图形用户界面、高效数据处理和并行计算的新章节,帮助你使用 Python 高效地进行数学和科学计算。
本书将帮助你探索新的 Python 语法特性,并利用科学计算原理创建不同的模型。本书将 Python 与数学应用相结合,展示了如何在计算中应用 Python 概念,并通过涉及 Python 3.8 的示例来说明。你将使用 pandas 进行基本的数据分析,以理解现代科学计算的需求,并涵盖数据模块的改进和内置特性。你还将探索像 NumPy 和 SciPy 这样的数值计算模块,它们可以快速访问高效的数值算法。通过学习使用绘图模块 Matplotlib,你将能够在报告和出版物中展示你的计算结果。特别章节介绍了 SymPy,这是一种用于桥接符号计算和数值计算的工具。本书还介绍了用于消息传递并行编程的 Python 包装器 mpi4py。
在本书结束时,你将对任务自动化有一个扎实的理解,并且能够在科学计算中实现和测试数学算法。
第一章:本书适合的人群
本书适用于具有数学背景的学生、设计现代编程课程的大学教师、数据科学家、研究人员、开发者以及任何希望在 Python 中进行科学计算的人。本书源自于 13 年的 Python 教学经验,涵盖了本科科学与工程项目中的课程、行业内专门的内部课程以及针对高中教师的专业化课程。典型的读者需要在数学、大数据处理、机器学习和仿真等领域使用 Python。因此,具备向量和矩阵的基本知识,以及收敛性和迭代过程等概念将是有益的。
本书所涵盖的内容
第一章,入门,介绍了 Python 的主要语言元素,而不深入细节。这里我们将对所有内容进行简要浏览。对于那些想要直接开始的人来说,这是一个很好的起点;对于那些想要复习函数等基础构造的读者来说,它是一个快速参考。
第二章,变量和基本类型,介绍了 Python 中最重要和最基础的类型。浮动类型是科学计算中最重要的数据类型,另外还有特殊的数字 nan 和 inf。布尔类型、整数、复合数据类型和字符串是本书中将会使用的其他基本数据类型。
第三章,容器类型,解释了如何使用容器类型,主要是列表。字典和元组也会被解释,包括索引和遍历容器对象。偶尔,我们还可以使用集合作为一种特殊的容器类型。
第四章,线性代数 - 数组,涵盖了线性代数中最重要的对象——向量和矩阵。本书选择了 NumPy 数组作为描述矩阵甚至更高阶张量的核心工具。数组具有许多高级特性,并且允许通用函数逐元素地作用于矩阵或向量。本书重点讨论了数组索引、切片以及点积,作为大多数计算任务中的基本操作。通过一些线性代数示例,展示了如何使用 SciPy 的linalg子模块。
第五章,高级数组概念,解释了一些数组的高级概念。详细解释了数组副本和视图之间的区别,因为视图使得使用数组的程序非常快速,但常常是难以调试的错误源。演示了如何使用布尔数组编写高效、紧凑且易于阅读的代码。最后,通过与函数上的操作进行比较,解释了数组广播的技术——这是 NumPy 数组的独特特性。
第六章,绘图,展示了如何制作图表,主要是经典的x/y图表,也包括 3D 图表和直方图。科学计算需要良好的工具来可视化结果。Python 的matplotlib模块被引入,从其pyplot子模块中的方便绘图命令开始。通过创建图形对象(如坐标轴),可以对图表进行微调和修改。我们将展示如何更改这些对象的属性,并如何进行标注。
第七章,函数,讨论了函数,函数是编程中的基本构建块,紧密关联于一些基本的数学概念。函数定义和函数调用被解释为设置函数参数的不同方式。匿名的 lambda 函数被引入,并在全书的多个示例中使用。
第八章,类,将对象定义为类的实例,我们为其提供方法和属性。在数学中,类属性通常相互依赖,这需要特殊的编程技术来处理 setter 和 getter 函数。可以为特殊的数学数据类型定义基本的数学操作,如加法。继承和抽象是反映面向对象编程的数学概念。我们通过使用一个简单的求解常微分方程的类来演示继承的使用。
第九章,迭代,介绍了使用循环和迭代器进行迭代。本书中没有一章不涉及循环和迭代,但在这一章,我们将讨论迭代器的原理,并创建自己的生成器对象。在本章中,你将学习为什么生成器可能会被耗尽,以及如何编写无限循环。Python 的itertools模块是本章的有用伴侣。
第十章,序列和数据框 – 使用 pandas,简要介绍了 pandas。本章将教你如何在 Python 中处理各种时间序列,DataFrames 的概念,以及如何访问和可视化数据。本章还将讲解 NumPy 数组概念如何扩展到 pandas DataFrames。
第十一章,通过图形用户界面进行通信,展示了 GUI 编程的基本原理。
Matplotlib。解释了事件、滑块移动或鼠标点击的作用,以及它们与所谓的回调函数的交互,附带了一些示例。
第十二章,错误和异常处理,讲解了错误和异常以及如何发现和修复它们。错误或异常是中断程序单元执行的事件。本章将展示遇到这种情况时应该怎么办,也就是说,如何处理异常。你将学习如何定义自己的异常类,并提供有价值的信息来捕获这些异常。错误处理不仅仅是打印错误信息。
第十三章,命名空间、作用域和模块,讲解了 Python 模块。什么是局部变量和全局变量?一个变量何时对程序单元可见,何时不可见?这一章讨论了这个问题。变量可以通过参数列表传递给函数,也可以通过利用其作用域隐式传递。当应该应用这种技术时,何时又不应该应用?本章尝试回答这个核心问题。
第十四章,输入和输出,讲解了处理数据文件的一些选项。数据文件用于存储和提供特定问题的数据,通常是大规模的测量数据。本章描述了如何使用不同的格式访问和修改这些数据。
第十五章,测试,专注于科学编程中的测试。关键工具是unittest,它允许自动化测试和参数化测试。通过考虑数值数学中的经典二分法算法,我们举例说明了设计有意义的测试的不同步骤,这些步骤还间接提供了代码使用的文档。仔细的测试提供了测试协议,在调试复杂代码时,这些协议可以提供帮助,尤其是当代码由许多不同的程序员编写时。
第十六章,符号计算 – SymPy,完全专注于符号计算。科学计算主要是针对不精确数据和近似结果的数值计算。这与符号计算的形式化操作形成对比,符号计算旨在通过封闭式表达式寻找精确解。在本章中,我们介绍了 Python 中的这一技术,它常用于推导和验证理论上的数学模型和数值结果。我们专注于符号表达式的高精度浮点运算。
第十七章,与操作系统的交互,展示了 Python 脚本与系统命令的交互。本章基于 Linux 系统,如 Ubuntu,仅作为概念和可能性的展示。它将科学计算任务放在应用上下文中,其中不同的软件通常需要结合使用,甚至硬件组件也可能参与其中。
第十八章,Python 并行计算,介绍了并行计算和mpi4py模块。本章展示了如何在不同处理器上并行执行相同脚本的副本。本章中提供的命令是由mpi4py Python 模块提供的,这是一个 Python 包装器,用于实现 C 语言中的 MPI 标准。通过学习本章内容,你将能够独立编写并行编程的脚本,并且会发现我们这里只描述了最基础的命令和概念。
第十九章,综合实例,展示了一些全面的、较长的示例,并简要介绍了它们的理论背景和完整实现。这些示例使用了本书迄今为止展示的所有构造,并将它们置于一个更大、更复杂的背景中。读者可以在此基础上进行扩展。
为了充分利用这本书
本书面向初学者或具有一定编程经验的读者。你可以从第一页读到最后一页,也可以选择自己感兴趣的部分。对 Python 的先验知识不是必须的。
| 本书涉及的软件/硬件 | 操作系统要求 |
|---|---|
| Python 3.8 | Windows/Linux/macOS |
你需要安装 Ubuntu(或其他 Linux 操作系统)系统,才能进行第十七章,与操作系统交互。
如果你使用的是本书的数字版,我们建议你亲自输入代码,或者通过 GitHub 仓库访问代码(链接在下一部分提供)。这样可以帮助你避免因复制粘贴代码而产生的潜在错误。
下载示例代码文件
你可以从 GitHub 上下载本书的示例代码文件,地址是:github.com/PacktPublishing/Scientific-Computing-with-Python-Second-Edition。如果代码有更新,GitHub 仓库中的现有代码会随时更新。
我们还有来自我们丰富书籍和视频目录的其他代码包,欢迎访问github.com/PacktPublishing/。快去看看吧!
下载彩色图片
我们还提供了一份 PDF 文件,其中包含本书中使用的截图/图表的彩色图片。你可以在这里下载: static.packt-cdn.com/downloads/9781838822323_ColorImages.pdf。
使用的约定
本书中使用了一些文本约定。
CodeInText:表示文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入以及 Twitter 用户名。例子:“for语句有两个重要的关键字:break和else。”
代码块设置如下:
t=symbols('t')
x=[0,t,1]
# The Vandermonde Matrix
V = Matrix([[0, 0, 1], [t**2, t, 1], [1, 1,1]])
y = Matrix([0,1,-1]) # the data vector
a = simplify(V.LUsolve(y)) # the coefficients
# the leading coefficient as a function of the parameter
a2 = Lambda(t,a[0])
获取联系
我们始终欢迎读者的反馈。
一般反馈:如果你对本书的任何方面有疑问,请在邮件主题中注明书名,并通过customercare@packtpub.com联系我们。
勘误:虽然我们已经尽力确保内容的准确性,但错误难免发生。如果你发现本书中有错误,感谢你向我们报告。请访问www.packtpub.com/support/errata,选择你的书籍,点击勘误提交表单链接,并填写详细信息。
盗版:如果你在互联网上发现任何形式的非法复制品,感谢你提供相关位置地址或网站名称。请通过copyright@packt.com与我们联系,并提供相关材料的链接。
如果你有兴趣成为一名作者:如果你在某个领域拥有专业知识,并且有兴趣编写或贡献书籍内容,请访问authors.packtpub.com。
评论
请留下评论。阅读并使用本书后,为什么不在您购买该书的网站上留下评论呢?潜在的读者可以看到并参考您的公正意见来做出购买决策,我们在 Packt 也能了解您对我们产品的看法,而我们的作者也能看到您对他们书籍的反馈。谢谢!
欲了解更多关于 Packt 的信息,请访问 packt.com。
开始使用
在本章中,我们将简要概述 Python 的主要语法元素。本章旨在引导刚开始学习编程的读者。每个主题都以 如何做 的方式呈现,并将在本书后续部分以更深入的概念性方式解释,并结合许多应用和扩展。
对于那些已经熟悉其他编程语言的读者,本章将介绍 Python 方式的经典语言构造。这将为他们提供快速入门 Python 编程的机会。
无论哪种类型的读者,都可以将本章作为参考指南,在阅读本书时随时查看。不过,在开始之前,我们需要确保一切准备就绪,确保你已安装正确版本的 Python,并配备好用于科学计算和工具的主要模块,例如一个好的编辑器和 Shell,这些工具有助于代码开发和测试。
在本章中,我们将介绍以下主题:
-
安装和配置说明
-
程序和程序流程
-
Python 中的基本数据类型
-
使用循环重复语句
-
条件语句
-
使用函数封装代码
-
理解脚本和模块
-
Python 解释器
即使你的计算机上已经安装了 Python,仍然建议阅读以下部分。你可能需要对环境进行调整,以符合本书中的工作环境。
第二章:1.1 安装和配置说明
在深入研究本书的主题之前,你应该已经在计算机上安装了所有相关工具。我们提供一些建议,并推荐你可能希望使用的工具。我们只描述公共领域和免费的工具。
1.1.1 安装
目前有两个主要版本的 Python:2.x 分支和新的 3.x 分支。两个分支之间存在语言不兼容性,你需要知道该使用哪个版本。本书基于 3.x 分支,考虑到语言已发布到 3.7 版本。
本书中,你需要安装以下内容:
-
解释器:Python 3.7(或更高版本)
-
用于科学计算的模块:SciPy 与 NumPy
-
用于数学结果图形表示的模块:matplotlib
-
Shell:IPython
-
与 Python 相关的编辑器:最好使用 Spyder(见 图 1.1)。
这些工具的安装通过所谓的发行包来简化。我们建议你使用 Anaconda。
1.1.2 Anaconda
即使你的计算机上已经预安装了 Python,我们仍然建议你创建个人的 Python 环境,这样你可以在不冒险影响计算机功能所依赖的软件的情况下进行工作。通过使用虚拟环境(例如 Anaconda),你可以自由地更改语言版本并安装软件包,而不会产生意外的副作用。
如果最糟糕的情况发生,并且你完全弄乱了,只需删除 Anaconda 目录并重新开始。运行 Anaconda 安装程序将安装 Python、Python 开发环境和编辑器(Spyder)、shell(IPython)以及最重要的数值计算包:SciPy、NumPy 和 matplotlib。
你可以通过在 Anaconda 创建的虚拟环境中使用conda install安装额外的包(另见官方文档*)。
1.1.3 Spyder
Spyder 的默认屏幕包括左侧的编辑器窗口,右下角的控制台窗口,它提供对 IPython shell 的访问,以及右上角的帮助窗口,如下图所示:

图 1.1:Spyder 的默认屏幕
1.1.4 配置
大多数 Python 代码会保存在文件中。我们建议你在所有 Python 文件中使用以下头部:
from numpy import *
from matplotlib.pyplot import *
通过这个,你可以确保本书中用于科学计算的所有基本数据类型和函数都已经导入。没有这个步骤,本书中的大多数示例都会抛出错误。
Spyder 会提供语法警告和语法错误指示。警告由黄色三角形标记;参见图 1.2。
语法警告表示语句是正确的,但由于某些原因,不建议使用它。前述语句from就会引发这样的警告。我们将在本书后面讨论这种警告的原因。在这种特定情况下,我们忽略此警告。

图 1.2:Spyder 中的警告三角形
许多编辑器,如 Spyder,提供为你的文件创建模板的功能。查找此功能并将前述头部放入模板中。
1.1.5 Python shell
Python shell 很好,但对于交互式脚本编写来说并不最优。因此,我们建议使用 IPython [25]。
IPython 可以通过不同的方式启动:
-
在终端 shell 中运行以下命令:
ipython -
直接点击名为 Jupyter QT Console 的图标:

- 在使用 Spyder 时,应该使用 IPython 控制台(参见图 1.1)。
1.1.6 执行脚本
你经常需要执行文件的内容。根据文件在计算机上的位置,执行文件内容之前,必须导航到正确的位置:
-
在 IPython 中使用命令
cd,以便切换到文件所在的目录。 -
要执行名为
myfile.py的文件内容,只需在 IPython shell 中运行以下命令:
run myfile
1.1.7 获取帮助
以下是一些使用 IPython 的提示:
-
要获取有关某个对象的帮助,只需在对象的名称后面键入
?,然后按下回车键。 -
使用箭头键来重复上次执行的命令。
-
你可以使用Tab键进行补全(即你输入一个变量或方法的首字母,IPython 会展示一个包含所有可能补全项的菜单)。
-
使用Ctrl+D退出。
-
使用 IPython 的魔法函数。你可以通过在命令提示符下输入
%%magic来查看函数列表和说明。
你可以在 IPython 的在线文档中了解更多信息。
1.1.8 Jupyter – Python notebook
Jupyter notebook 是一个非常棒的工具,用于展示你的工作。学生可能希望用它来制作和记录作业和练习,而老师则可以用它来准备讲座,甚至是幻灯片和网页。
如果你通过 Anaconda 安装了 Python,你已经具备了 Jupyter 所需的一切。你可以通过在终端窗口中运行以下命令来启动 notebook:
jupyter notebook
一个浏览器窗口将会打开,你可以通过网页浏览器与 Python 进行交互。
1.2 程序与程序流程
程序是一个按自上而下顺序执行的语句序列。这个线性执行顺序有一些重要的例外:
-
可能会有条件执行替代语句组(代码块),我们称之为分支。
-
有些代码块会被重复执行,这叫做循环(见图 1.3)。
-
有一些函数调用,它们是指向另一个代码片段的引用,该代码片段在主程序流程恢复之前被执行。函数调用打断了线性执行,并暂停程序单元的执行,同时将控制权传递给另一个单元——一个函数。当该函数执行完成后,控制权会返回给调用单元。

图 1.3:程序流程
Python 使用特殊的语法来标记语句块:一个关键字,一个冒号,以及一个缩进的语句序列,这些语句属于该代码块(见图 1.4)。

图 1.4:代码块命令
1.2.1 注释
如果程序中的一行包含符号 #,那么该行后面的内容会被视为注释:
# This is a comment of the following statement
a = 3 # ... which might get a further comment here
1.2.2 行连接
行末的反斜杠 \ 表示下一行是续行,即显式行连接。如果一行结束时所有括号没有闭合,下一行将自动被识别为续行,即隐式行连接。
1.3 Python 中的基本数据类型
让我们来看看你在 Python 中会遇到的基本数据类型。
1.3.1 数字
一个数字可以是整数、实数或复数。常见的运算如下:
-
加法和减法,
+和- -
乘法和除法,
*和/ -
幂运算,
**
这里是一个示例:
2 ** (2 + 2) # 16
1j ** 2 # -1
1\. + 3.0j
符号 j 表示复数的虚部。它是一个语法元素,不应与变量的乘法混淆。
1.3.2 字符串
字符串是由字符组成的序列,用单引号或双引号括起来:
'valid string'
"string with double quotes"
"you shouldn't forget comments"
'these are double quotes: ".." '
你还可以使用三引号来表示多行字符串:
"""This is
a long,
long string"""
1.3.3 变量
变量是对对象的引用。一个对象可以有多个引用。你使用赋值运算符=将值赋给一个变量:
x = [3, 4] # a list object is created
y = x # this object now has two labels: x and y
del x # we delete one of the labels
del y # both labels are removed: the object is deleted
可以通过print函数显示变量的值:
x = [3, 4] # a list object is created
print(x)
1.3.4 列表
列表是非常有用的结构,是 Python 的基本类型之一。Python 中的列表是由方括号包围的有序对象列表。你可以使用基于零的索引通过方括号访问列表的元素:
L1 = [5, 6]
L1[0] # 5
L1[1] # 6
L1[2] # raises IndexError
L2 = ['a', 1, [3, 4]]
L2[0] # 'a'
L2[2][0] # 3
L2[-1] # last element: [3,4]
L2[-2] # second to last: 1
元素的索引从零开始。你可以将任何类型的对象放入列表中,甚至是其他列表。一些基本的列表函数如下:
list(range(n))}创建一个包含n个元素的列表,元素从零开始:
print(list(range(5))) # returns [0, 1, 2, 3, 4]
len返回列表的长度:
len(['a', 1, 2, 34]) # returns 4
len(['a',[1,2]]) # returns 2
append用于向列表添加元素:
L = ['a', 'b', 'c']
L[-1] # 'c'
L.append('d')
L # L is now ['a', 'b', 'c', 'd']
L[-1] # 'd'
列表操作
- 运算符
+用于连接两个列表:
L1 = [1, 2]
L2 = [3, 4]
L = L1 + L2 # [1, 2, 3, 4]
- 正如你所预期的,使用整数乘以列表会将该列表与自身连接多次:
n*L等同于进行n次添加操作:
L = [1, 2]
3 * L # [1, 2, 1, 2, 1, 2]
1.3.6 布尔表达式
布尔表达式是一个值为True或False的表达式。一些常见的返回条件表达式的运算符如下:
-
相等:
== -
不等于:
!= -
严格小于、小于或等于:
<、<= -
严格大于、大于或等于:
>、>=
你可以使用or和and将不同的布尔值组合在一起。关键字not对其后的表达式进行逻辑取反。比较可以链式连接,例如,x < y < z等同于x < y and y < z。不同之处在于,第一个例子中y只会被计算一次。在这两种情况中,当第一个条件x < y为False时,z根本不会被计算:
2 >= 4 # False
2 < 3 < 4 # True
2 < 3 and 3 < 2 # False
2 != 3 < 4 or False # True
2 <= 2 and 2 >= 2 # True
not 2 == 3 # True
not False or True and False # True!
二元运算符<、>、<=、>=、!=和==的优先级高于一元运算符not。运算符and和or的优先级最低。优先级高的运算符会在优先级低的运算符之前被计算。
1.4 使用循环重复语句
循环用于重复执行一系列语句,同时在每次迭代时改变一个变量的值。这个变量被称为索引变量。它依次被赋值为列表的元素:
L = [1, 2, 10]
for s in L:
print(s * 2) # output: 2 4 20
for循环中需要重复的部分必须正确缩进:
my_list = [...] # define a list
for elt in my_list:
... #do_something
... #something_else
print("loop finished") # outside the for block
1.4.1 重复任务
for循环的一个典型用法是重复执行某个任务固定次数:
n = 30
for iteration in range(n):
... # a statement here gets executed n times
1.4.2 break 和 else
for语句有两个重要的关键字:break和else。关键字break可以在迭代列表未结束时退出for循环:
x_values=[0.5, 0.7, 1.2]
threshold = 0.75
for x in x_values:
if x > threshold:
break
print(x)
最后的else检查for循环是否通过break关键字被中断。如果没有被中断,那么else后面的代码块会被执行:
x_values=[0.5, 0.7]
threshold = 0.75
for x in x_values:
if x > threshold:
break
else:
print("all the x are below the threshold")
1.5 条件语句
本节内容介绍如何使用条件语句进行分支、跳出或以其他方式控制代码。
条件语句定义了一个代码块,如果条件为真则执行。一个可选的以关键字else开始的代码块将在条件未满足时执行(见图 1.4)。我们通过打印来演示这一点,
,即
的绝对值:

Python 等效代码如下:
x = ...
if x >= 0:
print(x)
else:
print(-x)
任何对象都可以测试其布尔值,用于if或while语句。如何获得布尔值的规则在第 2.3.2 节中有说明,布尔转换**。
1.6 使用函数封装代码
函数对于将相似的代码片段聚集在一起非常有用。考虑以下的数学函数:

Python 等效代码如下:
def f(x):
return 2*x + 1
在图 1.5中,函数块的各个元素已被解释:
-
关键字
def告诉 Python 我们正在定义一个函数。 -
f是函数的名称。 -
x是函数的参数或输入。 -
return后面的部分被称为函数的输出。

图 1.5:函数的结构
一旦函数定义完成,就可以使用以下代码调用它:
f(2) # 5
f(1) # 3
1.7 理解脚本和模块
一组语句保存在一个文件中(通常该文件具有py扩展名),称为脚本。假设我们将以下代码的内容放入名为smartscript.py的文件中:
def f(x):
return 2*x + 1
z = []
for x in range(10):
if f(x) > pi:
z.append(x)
else:
z.append(-1)
print(z)
在 Python 或 IPython shell 中,打开并读取文件后,可以使用exec命令执行这样的脚本。写成一行代码如下:
exec(open('smartscript.py').read())
IPython shell 提供了魔法命令%run,作为执行脚本的便捷替代方法:
%run smartscript
1.7.1 简单模块 - 收集函数
通常,你会在脚本中收集函数。这将创建一个具有额外 Python 功能的模块。为了演示这一点,我们通过将函数收集到一个文件中创建一个模块,例如,smartfunctions.py:
def f(x):
return 2*x + 1
def g(x):
return x**2 + 4*x - 5
def h(x):
return 1/f(x)
-
这些函数现在可以被任何外部脚本或直接在 IPython 环境中使用。
-
模块内的函数可以相互依赖。
-
将具有共同主题或目的的函数组合在一起,能够生成可共享和供他人使用的模块。
再次说明,命令exec(open('smartfunctions.py').read())使这些函数可以在你的 IPython 环境中使用(注意,IPython 还提供了魔法命令run)。在 Python 术语中,你可以说这些函数被放入了实际的命名空间中。
1.7.2 使用模块和命名空间
另外,也可以通过命令import导入模块。这将创建一个以文件名命名的命名空间.。命令from将函数导入到全局命名空间,而不创建单独的命名空间:
import smartfunctions
print(smartfunctions.f(2)) # 5
from smartfunctions import g #import just this function
print(g(1)) # 0
from smartfunctions import * #import all
print(h(2)*f(2)) # 1.0
导入命令import和from。仅将函数导入到相应的命名空间中。导入后更改函数对当前的 Python 会话没有影响。
1.8 Python 解释器
Python 解释器执行以下步骤:
-
首先,它检查语法。
-
然后,它逐行执行代码。
-
函数或类声明中的代码不会被执行,但其语法会被检查:
def f(x):
return y**2
a = 3 # here both a and f are defined
你可以运行前面的程序,因为没有语法错误。只有在调用函数f时才会出现错误。
在这种情况下,我们说的是运行时错误:
f(2) # error, y is not defined
总结
在本章中,我们简要介绍了 Python 的主要语言元素,而没有深入讨论。你现在应该能够开始玩一些小代码片段并测试不同的程序构造。所有这些都是为接下来的章节做的预热,我们将在那里为你提供细节、示例、练习以及更多的背景信息。
变量和基本类型
在本章中,我们将介绍 Python 中最重要和最基本的类型。什么是类型?它是由数据内容、其表示以及所有可能的操作组成的集合。在本书的后续部分,当我们在第八章:类中介绍类的概念时,我们将更精确地定义这一概念。
在本章中,我们将涵盖以下主题:
-
变量
-
数字类型
-
布尔值
-
字符串
第三章:2.1 变量
变量是 Python 对象的引用。它们通过赋值创建,例如:
a = 1
diameter = 3.
height = 5.
cylinder = [diameter, height] # reference to a list
变量的名称可以由大写字母、小写字母、下划线_和数字组成。变量名不能以数字开头。请注意,变量名是区分大小写的。良好的变量命名是文档化工作的重要部分,因此我们建议使用具有描述性的变量名。
Python 有 33 个保留关键字,不能作为变量名使用(见表 2.1)。如果尝试将这些关键字作为变量名,将会引发语法错误:

表 2.1:保留的 Python 关键字
与其他编程语言不同,Python 中的变量不需要声明类型。类型是自动推导的:
x = 3 # integer (int)
y = 'sunny' # string (str)
你可以通过多重赋值语句创建多个变量:
a = b = c = 1 # a, b and c get the same value 1
变量在定义后也可以被修改:
a = 1
a = a + 1 # a gets the value 2
a = 3 * a # a gets the value 6
最后两个语句可以通过结合这两种操作与赋值操作直接使用增量运算符来编写:
a += 1 # same as a = a + 1
a *= 3 # same as a = 3 * a
2.2 数字类型
在某些情况下,你将不得不处理数字,因此我们首先考虑 Python 中不同的数字类型形式。在数学中,我们区分自然数(ℕ)、整数(ℤ)、有理数(ℚ)、实数(ℝ)和复数(ℂ)。这些是无限集合。不同集合之间的运算有所不同,有时甚至没有定义。例如,通常在 ℤ 中进行除法操作可能不会得到一个整数——在 ℤ 中没有定义。
在 Python 中,像许多其他计算机语言一样,我们有数字类型:
-
数字类型
int,它至少在理论上是整个 ℤ -
数字类型
float,它是 ℝ 的一个有限子集 -
数字类型
complex,这是 ℂ 的一个有限子集
有限集合有最小值和最大值,并且两个数字之间有最小间隔;有关更多细节,请参见第 2.2.2 节,浮点数。
2.2.1 整数
最简单的数字类型是整数类型int。
整数
语句k = 3将变量k赋值为一个整数。
对整数应用+、-或*等运算符会返回一个整数。除法运算符//返回一个整数,而/返回一个float:
6 // 2 # 3 an integer value
7 // 2 # 3
7 / 2 # 3.5 a float value
Python 中的整数集合是无限的;没有最大的整数。这里的限制是计算机的内存,而不是语言给出的固定值。
如果在前面的示例中,除法运算符(/)返回 3,则说明你没有安装正确的 Python 版本。
2.2.2 浮动点数
如果你在 Python 中执行语句a = 3.0,你创建了一个浮动点数(Python 类型:float)。这些数字形成有理数的有限子集,ℚ。
另外,常量也可以用指数表示法给出,如 a = 30.0e-1 或简写为 a = 30.e-1。符号 e 将指数与尾数分开,该表达式在数学表示中读作
。浮动点数 这个名称指的是这些数字的内部表示,并反映了在考虑广泛范围内的数字时,小数点的浮动位置。
对两个浮动点数,或一个整数与一个浮动点数应用基本的数学运算,如 +、-、* 和 /,将返回一个浮动点数。
浮动点数之间的运算很少会返回与有理数运算中预期的精确结果:
0.4 - 0.3 # returns 0.10000000000000003
这个事实在比较浮动点数时非常重要:
0.4 - 0.3 == 0.1 # returns False
这背后的原因可以通过查看浮动点数的内部表示来显现;另请参见第 15.2.6 节,浮动点比较。
浮动点表示
一个浮动点数由三个量表示:符号、尾数和指数:

其中* ![]* 和 ![]。
![]被称为尾数,![]是基数,e 是指数,且![]。
被称为尾数长度。条件![]使得表示是唯一的,并在二进制情况下(![])节省了一个位。
存在两个浮动点零,![] 和 ![],它们都由尾数
表示。
在典型的英特尔处理器上,![]。为了表示一个float类型的数字,使用 64 位,即 1 位用于符号,
位用于尾数,
位用于指数
。因此,指数的上限
是![]。
对于此数据,最小的可表示正数是
^(![)],并且最大的 ^(![)].
请注意,浮点数在
中并非等间隔分布。特别地,零附近有一个间隙(另见 [29])。在
和第一个正数之间的距离是
,而第一个和第二个之间的距离则较小,缩小了一个因子
。这种由标准化
引起的效应在 图 2.1 中得到了可视化:

图 2.1:零处的浮点间隙。这里是 
这个间隙被等距填充,使用的是 非规范化 浮点数,并将此类结果四舍五入为这些数。非规范化浮点数具有最小的指数,并且不遵循标准化惯例
。
无限和非数字
总共有
个浮点数。有时,数值算法计算的浮点数超出了这个范围。
这会导致数字溢出或下溢。在 NumPy 中,溢出结果会被赋值为特殊的浮点数 inf:
exp(1000.) # inf
a = inf
3 - a # -inf
3 + a # inf
使用 inf 可能会导致数学上未定义的结果。Python 会通过将结果赋值给另一个特殊的浮点数 nan 来表示这一点。nan 代表 非数字,即数学运算的未定义结果。为证明这一点,我们继续前面的例子:
a + a # inf
a - a # nan
a / a # nan
对于与 nan 和 inf 的操作,有一些特殊规则。例如,nan 与任何数(甚至是它自己)比较时,总是返回 False:
x = nan
x < 0 # False
x > 0 # False
x == x # False
请参见 练习 4,它展示了 nan 永远不等于它自身的某些令人惊讶的后果。
浮点数 inf 的行为更符合预期:
0 < inf # True
inf <= inf # True
inf == inf # True
-inf < inf # True
inf - inf # nan
exp(-inf) # 0
exp(1 / inf) # 1
检查 nan 和 inf 的一种方法是使用 isnan 和 isinf 函数。通常,当变量的值为 nan 或 inf 时,您希望直接作出反应。可以通过使用 NumPy 命令 seterr 来实现这一点。以下命令
seterr(all = 'raise')
如果一个计算返回其中的某个值,则会引发 FloatingPointError 错误。
下溢 - 机器精度
下溢发生在操作结果是一个落入零附近间隙的有理数时;见 图 2.1。
机器精度,或称舍入单位,是使得
的最大数字,这样
。
请注意,
在当今大多数计算机上是这样的。您当前运行代码的机器上适用的值可以使用以下命令访问:
import sys
sys.float_info.epsilon # 2.220446049250313e-16
变量sys.float_info包含关于浮动点类型在你机器上内部表示的更多信息。
函数float将其他类型转换为浮动点数(如果可能)。这个函数在将适当的字符串转换为数字时特别有用:
a = float('1.356')
NumPy 中的其他浮动点类型:
NumPy 还提供了其他浮动点类型,这些类型在其他编程语言中被称为双精度和单精度数字,分别是float64和float32:
a = pi # returns 3.141592653589793
a1 = float64(a) # returns 3.141592653589793
a2 = float32(a) # returns 3.1415927
a - a1 # returns 0.0
a - a2 # returns -8.7422780126189537e-08
倒数第二行演示了a和a1在精度上没有差别。a与它的单精度对应物a2之间存在精度差异。
NumPy 函数finfo可以用来显示这些浮动点类型的信息:
f32 = finfo(float32)
f32.precision # 6 (decimal digits)
f64 = finfo(float64)
f64.precision # 15 (decimal digits)
f = finfo(float)
f.precision # 15 (decimal digits)
f64.max # 1.7976931348623157e+308 (largest number)
f32.max # 3.4028235e+38 (largest number)
help(finfo) # Check for more options
2.2.3 复数:
复数是实数的扩展,广泛应用于许多科学和工程领域。
数学中的复数:
复数由两个浮动点数组成,一个是该数的实部
,另一个是它的虚部
。在数学中,复数写作
,其中
由
定义,称为虚数单位。共轭复数对
是
。
如果实部
为零,则该数字称为虚数。
j 表示法:
在 Python 中,虚数通过在浮动点数后添加字母j来表示,例如,z = 5.2j。复数是由一个实数和一个虚数组成的,例如,z = 3.5 + 5.2j。
虽然在数学中,虚部表示为实数b与虚数单位
的乘积,但在 Python 中表示虚数并不是一个乘积:j只是一个后缀,用来表示该数是虚数。
这通过以下小实验展示:
b = 5.2
z = bj # returns a NameError
z = b*j # returns a NameError
z = b*1j # is correct
方法conjugate返回z的共轭:
z = 3.2 + 5.2j
z.conjugate() # returns (3.2-5.2j)
实部和虚部:
你可以使用real和imag属性访问复数
的实部和虚部。这些属性是只读的;换句话说,它们不能被改变:
z = 1j
z.real # 0.0
z.imag # 1.0
z.imag = 2 # AttributeError: readonly attribute
不可能将复数转换为实数:
z = 1 + 0j
z == 1 # True
float(z) # TypeError
有趣的是,real和imag属性以及共轭方法对复数数组同样适用;参见第 4.3.1 节,数组属性。我们通过计算第 N 次单位根来展示这一点,这些单位根是![],即方程
的
解:
from matplotlib.pyplot import *
N = 10
# the following vector contains the Nth roots of unity:
unity_roots = array([exp(1j*2*pi*k/N) for k in range(N)])
# access all the real or imaginary parts with real or imag:
axes(aspect='equal')
plot(unity_roots.real, unity_roots.imag, 'o')
allclose(unity_roots**N, 1) # True
结果图显示了 10 个单位根。在图 2.2中,它通过标题和坐标轴标签进行补充,并与单位圆一起显示。(有关如何绘制图表的更多细节,请参见第六章:绘图。)

图 2.2:单位根与单位圆
当然,也可以混合使用前述方法,如以下示例所示:
z = 3.2+5.2j
(z + z.conjugate()) / 2\. # returns (3.2+0j)
((z + z.conjugate()) / 2.).real # returns 3.2
(z - z.conjugate()) / 2\. # returns 5.2j
((z - z.conjugate()) / 2.).imag # returns 5.2
sqrt(z * z.conjugate()) # returns (6.1057350089894991+0j)
2.3 布尔值
布尔值是一种数据类型,得名于乔治·布尔(1815-1864)。布尔变量只能取两个值,True或False。这种类型的主要用途是在逻辑表达式中。以下是一些示例:
a = True
b = 30 > 45 # b gets the value False
布尔表达式常常与if语句结合使用:
x= 5
if x > 0:
print("positive")
else:
print("nonpositive")
2.3.1 布尔运算符
布尔操作通过关键字and、or和not来执行:
True and False # False
False or True # True
(30 > 45) or (27 < 30) # True
not True # False
not (3 > 4) # True
运算符遵循一些优先级规则(参见第 1.3.5 节,布尔表达式),这些规则使得第三行和最后一行的括号变得不必要。然而,无论如何,使用括号是一种良好的实践,可以提高代码的可读性。
请注意,and运算符在以下布尔表达式中是隐式链式连接的:
a < b < c # same as: a < b and b < c
a < b <= c # same as: a < b and b <= c (less or equal)
a == b == c # same as: a == b and b == c
2.3.2 布尔类型转换
大多数 Python 对象都可以转换为布尔值;这称为布尔类型转换。内置函数bool执行这种转换。需要注意的是,大多数对象都会转换为True,除了0、空元组、空列表、空字符串或空数组,这些都转换为False。

表 2.2:布尔值的类型转换规则
除非数组不含元素或仅包含一个元素,否则不可能将数组转换为布尔值;这一点在第 5.2.1 节,布尔数组中有进一步的解释。前面的表格(参见表 2.2:布尔值类型转换规则)总结了布尔类型转换的规则。
我们通过一些使用示例来演示这一点:
bool([]) # False
bool(0) # False
bool(' ') # True
bool('') # False
bool('hello') # True
bool(1.2) # True
bool(array([1])) # True
bool(array([1,2])) # Exception raised!
自动布尔类型转换
使用if语句时,如果是非布尔类型,将会自动将其转换为布尔值。换句话说,以下两个语句始终是等效的:
if a:
...
if bool(a): # exactly the same as above
...
一个典型的例子是测试列表是否为空:
# L is a list
if L:
print("list not empty")
else:
print("list is empty")
一个空列表或元组将返回False。
你也可以在if语句中使用变量,例如一个整数:
# n is an integer
if n % 2: # the modulo operator
print("n is odd")
else:
print("n is even")
请注意,我们使用了%进行取模运算,它返回整数除法后的余数。在这种情况下,它返回0或1作为除以 2 后的余数。
在这个最后的例子中,值0或1会被转换为bool;也请参见第 2.3.4 节,布尔值和整数。
布尔运算符or、and和not也会隐式地将其一些参数转换为布尔值。
2.3.3 and和or的返回值
请注意,运算符and和or并不一定会产生布尔值。这可以通过以下等式来解释:*x* and *y*等价于:
def and_as_function(x,y):
if not x:
return x
else:
return y
相应地,表达式x or y等价于:
def or_as_function(x,y):
if x:
return x
else:
return y
有趣的是,这意味着当执行语句True or x时,变量x甚至不需要被定义!False and x同样适用。
请注意,与数学逻辑中的对应运算符不同,这些运算符在 Python 中不再是交换律的。事实上,以下表达式并不等价:
1 or 'a' # produces 1
'a' or 1 # produces 'a'
2.3.4 布尔值和整数
实际上,布尔值和整数是相同的。唯一的区别在于0和1的字符串表示形式,在布尔值中,它们分别是False和True。这使得可以构造如下:
def print_ispositive(x):
possibilities = ['nonpositive or zero', 'positive']
return f"x is {possibilities[x>0]}"
这个例子中的最后一行使用了字符串格式化,具体解释见第 2.4.3 节,字符串格式化。
我们指出,对于已经熟悉子类概念的读者,bool类型是int类型的子类(请参见第八章:类)。实际上,所有四个查询——isinstance(True, bool)、isinstance(False, bool)、isinstance(True, int)和isinstance(False, int)都返回值True(请参见第 3.7 节,检查变量的类型)。
即使是像True+13这样很少使用的语句也是正确的。
2.4 字符串
string类型是用于文本的类型:
name = 'Johan Carlsson'
child = "Åsa is Johan Carlsson's daughter"
book = """Aunt Julia
and the Scriptwriter"""
字符串可以由单引号或双引号括起来。如果字符串包含多行,则必须用三个双引号"""或三个单引号'''括起来。
字符串可以通过简单的索引或切片来索引(请参见第三章:容器类型,了解关于切片的详细说明):
book[-1] # returns 'r'
book[-12:] # returns 'Scriptwriter'
字符串是不可变的;也就是说,项不能被更改。它们与元组共享这个特性。命令book[1] = 'a'返回:
TypeError: 'str' object does not support item assignment
2.4.1 转义序列和原始字符串
字符串'\n'用于插入换行符,'\t'用于在字符串中插入水平制表符(TAB)以对齐多行:
print('Temperature\t20\tC\nPressure\t5\tPa')
这些字符串是转义序列的例子。转义序列总是以反斜杠\开始。多行字符串会自动包含转义序列:
a="""
A multi-line
example"""
a # returns '\nA multi-line \nexample'
一个特殊的转义序列是"\\",它表示文本中的反斜杠符号:
latexfontsize="\\tiny"
print(latexfontsize) # prints \tiny
同样的结果可以通过使用原始字符串来实现:
latexfs=r"\tiny" # returns "\tiny"
latexfontsize == latexfs # returns True
请注意,在原始字符串中,反斜杠保持在字符串中并用于转义某些特殊字符:
print(r"\"") # returns \"
print(r"\\") # returns \
print(r"\") # returns an error (why?)
原始字符串是一种方便的工具,用于以可读的方式构建字符串。结果是相同的:
r"\"" == '\\"'
r"She: \"I am my dad's girl\"" == 'She: \\"I am my dad\'s girl\\"'
2.4.2 字符串操作和字符串方法
多个字符串的相加会导致它们的连接:
last_name = 'Carlsson'
first_name = 'Johanna'
full_name = first_name + ' ' + last_name
# returns 'Johanna Carlsson'
因此,整数的乘法是重复加法:
game = 2 * 'Yo' # returns 'YoYo'
对浮点数或复数的乘法未定义,并会导致TypeError。
当字符串进行比较时,采用字典顺序,大写形式排在相同字母的小写形式之前:
'Anna' > 'Arvi' # returns false
'ANNA' < 'anna' # returns true
'10B' < '11A' # returns true
在众多字符串方法中,我们这里只提及最重要的几种:
- 分割字符串:该方法通过使用一个或多个空格作为分隔符生成一个列表。或者,可以通过指定特定的子字符串作为分隔符来传递一个参数:
text = 'quod erat demonstrandum'
text.split() # returns ['quod', 'erat', 'demonstrandum']
table = 'Johan;Carlsson;19890327'
table.split(';') # returns ['Johan','Carlsson','19890327']
king = 'CarlXVIGustaf'
king.split('XVI') # returns ['Carl','Gustaf']
- 将列表连接到字符串:这是分割操作的反向操作:
sep = ';'
sep.join(['Johan','Carlsson','19890327'])
# returns 'Johan;Carlsson;19890327'
- 在字符串中搜索:该方法返回字符串中给定搜索子字符串开始的第一个索引位置:
birthday = '20101210'
birthday.find('10') # returns 2
如果搜索字符串未找到,方法的返回值是-1。
- 字符串格式化:该方法将变量的值或表达式的结果插入字符串中。它非常重要,以至于我们将以下小节专门讨论它。
2.4.3 字符串格式化
字符串格式化是将值插入给定字符串并确定其显示方式的过程。这可以通过多种方式实现。我们首先描述相关的字符串方法format,以及更现代的替代方法——所谓的f-string。
下面是一个关于使用 format 方法的例子:
course_code = "NUMA01"
print("Course code: {}".format(course_code)) # Course code: NUMA01
这里是使用f-string的变体例子:
course_code = "NUMA01"
print(f"Course code: {course_code}") # Course code: NUMA01
format函数是一个字符串方法;它扫描字符串以查找占位符,这些占位符由花括号括起来。这些占位符根据format方法的参数以指定的方式进行替换。它们如何被替换,取决于每个{}对中定义的格式规范。格式规范由冒号":"作为前缀表示。
format 方法提供了一系列的可能性,根据对象的类型定制其格式化方式。在科学计算中,float类型的格式化说明符尤为重要。你可以选择标准的定点表示法{:f}或指数表示法{:e}:
quantity = 33.45
print("{:f}".format(quantity)) # 33.450000
print("{:1.1f}".format(quantity)) # 33.5
print("{:.2e}".format(quantity)) # 3.35e+01
类似地,格式说明符也可以在 f-string 中使用:
quantity = 33.45
print(f"{quantity:1.1f}") # 33.5
格式说明符允许指定四舍五入精度(表示中小数点后的位数)。此外,还可以设置表示数字的符号总数,包括前导空格。
在这个例子中,获取其值的对象名称作为参数传递给format方法。第一个{}对会被第一个参数替换,后续的{}对会被后续的参数替换。或者,使用键值对语法也可能很方便:
print("{name} {value:.1f}".format(name="quantity",value=quantity))
# prints "quantity 33.5"
这里处理了两个值——一个没有格式说明符的字符串name,和一个浮点数value,它以固定点格式打印,保留小数点后一位。(详细内容请参考完整的字符串格式化文档。)
字符串中的大括号
有时候,一个字符串可能包含一对大括号,但不应被视为format方法的占位符。在这种情况下,使用双大括号:
r"we {} in LaTeX \begin{{equation}}".format('like')
这将返回以下字符串:'we like in LaTeX \\begin{equation}'。
2.5 小结
在本章中,你了解了 Python 中的基本数据类型,并看到了相应的语法元素。我们将主要处理整数、浮点数和复数等数值类型。
布尔值在设置条件时是必需的,且通过使用字符串,我们常常传达结果和消息。
2.6 练习

例 2: 根据德摩根公式,以下公式成立:

选择数字n和x并在 Python 中验证公式。
例 3: 复数。以同样的方式验证欧拉公式:

例 4: 假设我们正试图检查一个发散序列的收敛性(这里,序列由递归关系定义:
和
):
u = 1.0 # you have to use a float here!
uold = 10\.
for iteration in range(2000):
if not abs(u-uold) > 1.e-8:
print('Convergence')
break # sequence has converged
uold = u
u = 2*u
else:
print('No convergence')
-
由于序列不收敛,代码应打印
No convergence消息。执行它来看看会发生什么。 -
如果你替换掉这一行会发生什么?
if not abs(u-uold) > 1.e-8:
使用
if abs(u-uold) < 1.e-8:
它应该给出完全相同的结果,不是吗?再次运行代码查看会发生什么。
-
如果你将
u=1.0替换为u=1(没有小数点),会发生什么?运行代码来验证你的预测。 -
解释这个代码的意外行为。
例 5: 一个蕴含式 C = (A ⇒ B) 是一个布尔表达式,定义如下:
-
C当A为
False或A和B都为True时是True -
C在其他情况下是
False
编写一个 Python 函数implication(A, B)。
例 6: 这个练习是用来训练布尔运算的。两个二进制数字(位)通过一个称为半加器的逻辑装置相加。它生成一个进位位(下一个更高位的数字)和根据下表定义的和,半加器电路:
| p | q | sum | carry |
|---|---|---|---|
| 1 | 1 | 0 | 1 |
| 1 | 0 | 1 | 0 |
| 0 | 1 | 1 | 0 |
| 0 | 0 | 0 | 0 |
半加器操作的定义:

图 2.3:半加法器电路
全加法器由两个半加法器组成,它可以对两个二进制位和一个额外的进位位进行求和(另请参见下图):

图 2.4:全加法器电路
编写一个实现半加法器的函数,并编写另一个实现全加法器的函数。测试这些函数。
容器类型
容器类型用于将对象组合在一起。不同容器类型之间的主要区别在于如何访问单个元素以及如何定义操作。在本章中,我们讨论了诸如列表、元组、字典和集合等容器类型以及相关的概念,如索引技巧。更专业的容器,如 pandas DataFrame,将在第四章:线性代数–数组、第五章:高级数组概念,以及第十章:序列和数据框中介绍。
特别地,我们将涵盖以下主题:
-
列表
-
数组
-
元组
-
字典
-
集合
第四章:3.1 列表
在本节中,我们介绍列表——Python 中最常用的容器数据类型。使用列表,我们可以将多个甚至完全不同的 Python 对象放在一起。
列表,顾名思义,是由任何类型的对象组成的列表:
L = ['a', 20.0, 5]
M = [3,['a', -3.0, 5]]
本例中的第一个列表包含一个字符串、一个浮动数和一个整数对象。第二个列表M包含另一个列表作为它的第二个元素。
每个对象通过分配给每个元素一个索引来进行枚举。列表中的第一个元素获得索引 0。这种零基础索引在数学表示法中经常使用。以零基础索引为例,考虑多项式系数的常规索引。
索引使我们可以访问在前面示例中定义的两个列表中的以下对象:
L[1] # returns 20.0
L[0] # returns 'a'
M[1] # returns ['a',-3.0,5]
M[1][2] # returns 5
这里的括号表示法对应于数学公式中使用下标的方式。L是一个简单的列表,而M本身包含一个列表,因此你需要两个索引才能访问内列表的一个元素。
一个包含连续整数的列表可以通过命令range轻松生成:
L=list(range(4))
# generates a list with four elements: [0, 1, 2 ,3]
更一般的用法是为该命令提供起始、停止和步长参数:
L=list(range(17,29,4))
# generates [17, 21, 25]
命令len返回列表的长度:
len(L) # returns 3
3.1.1 切片
就像从一条面包中切下一片,列表也可以被切分成切片。将列表在i和j之间切割会创建一个新列表,其中包含从索引i开始、在j之前结束的元素。
对于切片,必须给出一个索引范围。L[i:j]意味着通过从L[i]开始到L[j-1]为止,创建一个新列表。换句话说,新列表是通过从L中删除前i个元素并取下一个j-i个元素来得到的。
这里,L[i:]表示移除第一个元素,L[:i]表示仅取前i个元素:
L = ['C', 'l', 'o', 'u', 'd', 's']
L[1:5] # remove one element and take four from there:
# returns ['l', 'o', 'u', 'd']
你可以省略切片的第一个或最后一个界限:
L = ['C', 'l', 'o', 'u', 'd', 's']
L[1:] # ['l', 'o', 'u', 'd', 's']
L[:5] # ['C', 'l', 'o', 'u', 'd']
L[:] # the entire list
Python 允许使用负数索引从右侧计数。特别地,元素L[-1]是列表L中的最后一个元素。类似地,L[:-i]表示移除最后i个元素,L[-i:]表示只取最后i个元素。这可以结合使用,如L[i:-j]表示移除前i个元素和最后j个元素。
这里是一个例子:
L = ['C', 'l', 'o', 'u', 'd', 's']
L[-2:] # ['d', 's']
L[:-2] # ['C', 'l', 'o', 'u']
在范围中省略一个索引对应于ℝ中的半开区间。半开区间(∞, a)表示取所有严格小于a的数;这类似于语法L[:j];更多示例请参见图 3.1:

图 3.1:一些典型的切片情况
请注意,越界切片时,你永远不会遇到索引错误。可能你会得到空列表:
L = list(range(4)) # [0, 1, 2, 3]
L[4] # IndexError: list index out of range
L[1:100] # same as L[1:]
L[-100:-1] # same as L[:-1]
L[-100:100] # same as L[:]
L[5:0] # empty list []
L[-2:2] # empty list []
当使用可能变成负数的变量进行索引时要小心,因为这会完全改变切片。这可能导致意外的结果:
a = [1,2,3]
for iteration in range(4):
print(sum(a[0:iteration-1]))
结果是3,0,1,3,而你期望的结果是0,0,1,3。
让我们总结一下切片的使用:
-
L[i:]表示取所有元素,除了前i个。 -
L[:i]表示取前i个元素。 -
L[-i:]表示取列表的最后i个元素。 -
L[:-i]表示取所有元素,除了最后i个。
步长
在计算切片时,你也可以指定步长,即从一个索引到另一个索引的步长。默认步长为1。
这里是一个例子:
L = list(range(100))
L[:10:2] # [0, 2, 4, 6, 8]
L[::20] # [0, 20, 40, 60, 80]
L[10:20:3] # [10, 13, 16, 19]
请注意,步长也可以是负数:
L[20:10:-3] # [20, 17, 14, 11]
也可以创建一个反转的新列表,使用负步长:
L = [1, 2, 3]
R = L[::-1] # L is not modified
R # [3, 2, 1]
另外,你也可以使用方法reverse,该方法在第 3.1.4 节:列表方法中有详细说明。
3.1.2 修改列表
列表的典型操作是插入和删除元素以及列表连接。使用切片表示法,列表的插入和删除变得非常直观;删除就是用空列表[]替换列表的一部分:
L = ['a', 1, 2, 3, 4]
L[2:3] = [] # ['a', 1, 3, 4]
L[3:] = [] # ['a', 1, 3]
插入意味着用要插入的列表替换空切片:
L[1:1] = [1000, 2000] # ['a', 1000, 2000, 1, 3]
两个列表通过加法运算符+连接:
L = [1, -17]
M = [-23.5, 18.3, 5.0]
L + M # gives [1, -17, 23.5, 18.3, 5.0]
将列表n次与自身连接,激发了使用乘法运算符*的需求:
n = 3
n * [1.,17,3] # gives [1., 17, 3, 1., 17, 3, 1., 17, 3]
[0] * 5 # gives [0,0,0,0,0]
列表上没有算术运算,比如逐元素求和或除法。对于这些操作,我们使用数组;请参见第 3.2 节:数组概念简览。
3.1.3 属于列表
你可以使用关键字in和not in来确定一个元素是否属于列表,这类似于数学中的
和
:
L = ['a', 1, 'b', 2]
'a' in L # True
3 in L # False
4 not in L # True
3.1.4 列表方法
一些有用的list类型方法汇集在以下表 3.1中:
| 命令 | 操作 |
|---|---|
list.append(x) |
将x添加到列表的末尾。 |
list.extend(L) |
将列表L的元素添加到列表末尾。 |
list.insert(i,x) |
将x插入到位置i。 |
list.remove(x) |
移除列表中第一个值为x的元素。 |
list.sort() |
对列表项进行排序。 |
list.reverse() |
反转列表中的元素。 |
list.pop() |
移除列表中的最后一个元素。 |
表 3.1:列表数据类型的原地方法
这些方法是原地操作,即它们直接修改列表。
其他方法,如表 3.2中给出的那些,不会修改列表,而是返回一些信息或创建一个新列表对象:
| 命令 | 操作 |
|---|---|
list.count(x) |
计算x在列表中出现的次数。 |
list.copy() |
创建列表的副本。 |
表 3.2:返回新对象的列表数据类型方法
原地操作
大多数生成列表的方法都是原地操作。这些操作直接改变 Python 对象,而不会创建相同类型的新对象。通过查看以下示例reverse可以最清楚地理解:
L = [1, 2, 3]
L.reverse() # the list L is now reversed
L # [3, 2, 1]
注意原地操作。你可能会忍不住写出以下代码:
L=[3, 4, 4, 5]
newL = L.sort()
这是正确的 Python 代码。但原地操作会返回None,并改变列表。因此,例如,像使用newL作为(排序后的)列表那样,
print(newL[0])
会导致错误:
TypeError: 'NoneType' object is not subscriptable
在这里,我们展示了原地列表操作:
L = [0, 1, 2, 3, 4]
L.append(5) # [0, 1, 2, 3, 4, 5]
L.reverse() # [5, 4, 3, 2, 1, 0]
L.sort() # [0, 1, 2, 3, 4, 5]
L.remove(0) # [1, 2, 3, 4, 5]
L.pop() # [1, 2, 3, 4]
L.pop() # [1, 2, 3]
L.extend(['a','b','c']) # [1, 2, 3, 'a', 'b', 'c']
L 被修改。方法count是一个生成新对象的示例:
L.count(2) # returns 1
3.1.5 合并列表 – zip
一个特别有用的列表函数是zip。它可以将两个给定的列表通过配对原列表中的元素合并成一个新列表。结果是一个元组列表(请参见第 3.3 节:元组):
ind = [0,1,2,3,4]
color = ["red", "green", "blue", "alpha"]
list(zip(color,ind))
# gives [('red', 0), ('green', 1), ('blue', 2), ('alpha', 3)]
本例还展示了如果列表长度不一致会发生什么:压缩后的列表长度是两个输入列表中较短的那个。
函数zip创建一个特殊的可迭代对象,可以通过应用list函数将其转化为列表,如前面的示例所示。有关可迭代对象的更多细节,请参见第 9.3 节:可迭代对象。
3.1.6 列表推导式
构建列表的便捷方法是使用列表推导式,可能还带有条件。列表推导式的语法如下:
[<expr> for <variable> in <list>]
或者更普遍地:
[<expr> for <variable> in <list> if <condition>]
以下是一些示例:
L = [2, 3, 10, 1, 5]
L2 = [x*2 for x in L] # [4, 6, 20, 2, 10]
L3 = [x*2 for x in L if 4 < x <= 10] # [20, 10]
可以在列表推导式中包含多个for循环:
M = [[1,2,3],[4,5,6]]
flat = [M[i][j] for i in range(2) for j in range(3)]
# returns [1, 2, 3, 4, 5, 6]
这在处理数组时尤其重要;详见第 3.2 节:快速了解数组概念。
列表推导式与集合的数学表示法紧密相关。比较![]和L2 = [2*x for x in L]。不过一个很大的区别是,列表是有序的,而集合不是;详见第 3.5 节:集合。
在完成对列表的理解后,我们将继续下一节,学习数组的相关内容。
3.2 快速了解数组的概念
NumPy 包提供了数组,它们是用于操作数学中的向量、矩阵或甚至更高阶张量的容器结构。在本节中,我们指出了数组与列表之间的相似性。但数组值得更广泛的介绍,这将在第四章:线性代数——数组,以及第五章:高级数组概念中详细讲解。
数组是通过函数array从列表构造的:
v = array([1.,2.,3.])
A = array([[1.,2.,3.],[4.,5.,6.]])
要访问向量的元素,我们需要一个索引,而访问矩阵的元素需要两个索引:
v[2] # returns 3.0
A[1,2] # returns 6.0
初看,数组与列表相似,但请注意,它们在一个基本方面是不同的,可以通过以下几点来解释:
- 对数组数据的访问与列表相似,使用方括号和切片。但对于表示矩阵的数组,使用双重索引。赋值给数组切片的列表可以用来修改数组:
M = array([[1.,2.],[3.,4.]])
v = array([1., 2., 3.])
v[0] # 1
v[:2] # array([1.,2.])
M[0,1] # 2
v[:2] = [10, 20] # v is now array([10., 20., 3.])
- 获取向量中的元素数量,或矩阵的行数,可以使用函数
len:
len(v) # 3
-
数组只存储相同数值类型的元素(通常是
float或complex,也可以是int)。 -
运算符
+、*、/和-都是逐元素操作。函数dot以及在 Python 版本≥3.5 中,使用中缀运算符@来进行标量积和相应的矩阵操作。 -
与列表不同,数组没有
append方法。尽管如此,有一些特殊方法可以通过堆叠较小的数组来构造数组;参见第 4.7 节:堆叠。一个相关的点是,数组不像列表那样具有弹性;你不能使用切片来改变它们的长度。 -
向量切片是视图,即它们可以用来修改原始数组;参见第 5.1 节:数组视图与副本。
在本节中,我们快速了解了容器类型array。它在科学计算中非常重要,以至于我们将专门用两章内容来详细讲解,它还将涉及更多的方面;参见第四章:线性代数——数组,以及第五章:高级数组概念。
3.3 元组
元组是不可变的列表。不可变意味着它不能被修改。元组是由逗号分隔的对象序列(没有括号的列表)。为了增加可读性,通常将元组括在一对圆括号中:
my_tuple = 1, 2, 3 # our first tuple
my_tuple = (1, 2, 3) # the same
my_tuple = 1, 2, 3, # again the same
len(my_tuple) # 3, same as for lists
my_tuple[0] = 'a' # error! tuples are immutable
省略圆括号可能会产生副作用;请看下面的示例:
1, 2 == 3, 4 # returns (1, False, 4)
(1, 2) == (3, 4) # returns False
逗号表示该对象是一个元组:
singleton = 1, # note the comma
len(singleton) # 1
singleton = (1,) # this creates the same tuple
元组在一组值需要一起使用时很有用;例如,它们用于从函数返回多个值。参见第 7.3 节:返回值。
3.3.1 打包与解包变量
你可以通过解包列表或元组来一次性赋值多个变量:
a, b = 0, 1 # a gets 0 and b gets 1
a, b = [0, 1] # exactly the same effect
(a, b) = 0, 1 # same
[a,b] = [0,1] # same thing
使用打包和解包来交换两个变量的内容:
a, b = b, a
3.4 字典
列表、元组和数组是有序的对象集合。单独的对象根据它们在列表中的位置被插入、访问和处理。另一方面,字典是无序的键值对集合。你通过键来访问字典数据。
3.4.1 创建和修改字典
例如,我们可以创建一个包含机械学中刚体数据的字典,如下所示:
truck_wheel = {'name':'wheel','mass':5.7,
'Ix':20.0,'Iy':1.,'Iz':17.,
'center of mass':[0.,0.,0.]}
键/值对由冒号:表示。这些对通过逗号分隔,并列在一对大括号{}内。
单个元素通过它们的键进行访问:
truck_wheel['name'] # returns 'wheel'
truck_wheel['mass'] # returns 5.7
新对象通过创建新键被添加到字典中:
truck_wheel['Ixy'] = 0.0
字典也用于向函数提供参数(更多信息请参见第七章,函数中的第 7.2 节:参数和实参)。
字典中的键可以是字符串、函数、包含不可变元素的元组以及类等。键不能是列表或数组。
dict命令从包含键/值对的列表中生成字典:
truck_wheel = dict([('name','wheel'),('mass',5.7),
('Ix',20.0), ('Iy',1.), ('Iz',17.),
('center of mass',[0.,0.,0.])])
zip函数在此情况下可能会很有用;见第 3.15 节:合并列表–zip。
3.4.2 遍历字典
遍历字典的方式主要有三种:
- 通过键:
for key in truck_wheel.keys():
print(key) # prints (in any order) 'Ix', 'Iy', 'name',...
或者等效地:
for key in truck_wheel:
print(key) # prints (in any order) 'Ix', 'Iy', 'name',...
- 通过值:
for value in truck_wheel.values():
print(value)
# prints (in any order) 1.0, 20.0, 17.0, 'wheel', ...
- 通过项目,即键/值对:
for item in truck_wheel.items():
print(item)
# prints (in any order) ('Iy', 1.0), ('Ix, 20.0),...
请参阅第 14.4 节:架子,了解用于文件访问的特殊字典对象。
3.5 集合
本节介绍的最后一个容器对象由数据类型set定义。
集合是与数学集合共享属性和操作的容器。数学集合是由不同对象组成的集合。就像数学中一样,在 Python 中,集合的元素也被列在一对大括号内。
这里有一些数学集合表达式:

这里是它们的 Python 对应项:
A = {1,2,3,4}
B = {5}
C = A.union(B) # returns{1,2,3,4,5}
D = A.intersection(C) # returns {1,2,3,4}
E = C.difference(A) # returns {5}
5 in C # returns True
集合中只能包含一个元素,这与上述定义一致:
A = {1,2,3,3,3}
B = {1,2,3}
A == B # returns True
此外,集合是无序的;也就是说,集合中元素的顺序是未定义的:
A = {1,2,3}
B = {1,3,2}
A == B # returns True
Python 中的集合可以包含所有类型的不可变对象,即数字对象、字符串和布尔值。
存在union和intersection方法,分别对应数学中的并集和交集操作:
A={1,2,3,4}
A.union({5})
A.intersection({2,4,6}) # returns {2, 4}
此外,集合可以使用issubset和issuperset方法进行比较:
{2,4}.issubset({1,2,3,4,5}) # returns True
{1,2,3,4,5}.issuperset({2,4}) # returns True
空集合在 Python 中通过empty_set=set([])定义,而不是通过{},后者会定义一个空字典!
3.6 容器转换
我们在以下表 3.3中总结了迄今为止介绍的容器类型的最重要属性。(数组将在第四章:线性代数–数组中单独讨论):
| 类型 | 访问方式 | 顺序 | 重复值 | 可变性 |
|---|---|---|---|---|
| 列表 | 通过索引 | 是 | 是 | 是 |
| 元组 | 通过索引 | 是 | 是 | 否 |
| 字典 | 通过键 | 否 | 是 | 是 |
| 集合 | 否 | 否 | 否 | 是 |
表 3.3: 容器类型
正如你在前面的表格中看到的,访问容器元素的方式是有区别的,集合和字典是无序的。
由于各种容器类型的属性不同,我们经常将一种类型转换为另一种类型(见 表 3.4):
| 容器类型 | 语法 |
|---|---|
| 列表 → 元组 | tuple([1, 2, 3]) |
| 元组 → 列表 | list((1, 2, 3)) |
| 列表,元组 → 集合 | set([1, 2]), set((1, )) |
| 集合 → 列表 | list({1, 2 ,3}) |
| 字典 → 列表 | {'a':4}.values() |
| 列表 → 字典 | - |
表 3.4: 容器类型转换规则
在本节中,我们了解了如何转换容器类型。在第二章:变量与基本类型,我们了解了如何转换更基础的数据类型,例如数字。所以,现在是时候考虑如何实际检查一个变量的数据类型,这将是下一节的主题。
3.7 检查变量类型
查看变量类型的直接方式是使用命令 type:
label = 'local error'
type(label) # returns str
x = [1, 2] # list
type(x) # returns list
然而,如果你想检查一个变量是否属于某种类型,应该使用 isinstance(而不是使用 type 比较类型):
isinstance(x, list) # True
使用 isinstance 的理由在阅读过第 8.5 节:子类化与继承后变得更加明显。简而言之,不同的类型往往与某个基础类型共享一些共同的属性。经典的例子是 bool 类型,它是通过从更通用的 int 类型继承得到的。在这种情况下,我们看到如何更通用地使用 isinstance 命令:
test = True
isinstance(test, bool) # True
isinstance(test, int) # True
type(test) == int # False
type(test) == bool # True
因此,为了确保变量 test 可以像整数一样使用——尽管具体类型可能无关紧要——你应该检查它是否是 int 的实例:
if isinstance(test, int): print("The variable is an integer")
Python 不是一种强类型语言。这意味着对象的识别依据其功能,而不是其类型。例如,如果你有一个字符串操作函数,该函数通过使用 len 方法作用于一个对象,那么你的函数可能对任何实现了 len 方法的对象都有效。
到目前为止,我们已经遇到了不同的数据类型:float,int,bool,complex,list,tuple,module,function,str,dict 和 array。
3.8 总结
在这一章中,你学习了如何使用容器类型,主要是列表。了解如何填充这些容器以及如何访问和管理其中的内容非常重要。我们看到,访问方式可以是通过位置或通过关键字。
我们将在下一章的数组部分再次遇到切片这一重要概念。这些是专门为数学运算设计的容器。
3.9 练习
例 1: 执行以下语句:
L = [1, 2]
L3 = 3*L
-
L3的内容是什么? -
尝试预测以下命令的结果:
L3[0]
L3[-1]
L3[10]
- 以下命令的作用是什么?
L4 = [k**2 for k in L3]
- 将
L3和L4连接成一个新的列表L5。
Ex. 2: 使用 range 命令和列表推导式生成一个包含 100 个等距值的列表,范围从 0 到 1。
Ex. 3: 假设以下信号存储在一个列表中:
L = [0,1,2,1,0,-1,-2,-1,0]
以下结果是什么?
L[0]
L[-1]
L[:-1]
L + L[1:-1] + L
L[2:2] = [-3]
L[3:4] = []
L[2:5] = [-5]
仅通过检查来做这个练习,也就是说,不使用 Python shell。
Ex. 4: 考虑以下 Python 语句:
L = [n-m/2 for n in range(m)]
ans = 1 + L[0] + L[-1]
并假设变量 m 已经被赋值为一个整数。ans 的值是多少?在不执行 Python 语句的情况下回答这个问题。
Ex. 5: 考虑递归公式:

-
创建一个列表
u。将其前面三个元素分别存储为三个值
和
。这些值表示给定公式中的起始值
和
。根据递归公式构建完整的列表。 -
构建第二个列表
td,将值存储为
,其中
。绘制 td与u的图(见 第 6.1 节:绘制图表)。再绘制第二张图,展示差异,即
,其中
表示 td向量中的值。设置轴标签和标题。
递归是一个多步公式,用于求解具有初始值
的微分方程
。
![] 近似于 ![]。
Ex. 6: 假设
和
是集合。集合
被称为这两个集合的对称差。编写一个函数来执行此操作。将你的结果与以下命令的结果进行比较:
A.symmetric_difference(B).
Ex. 7: 在 Python 中验证空集合是任何集合的子集这一说法。
Ex. 8: 研究集合的其他操作。你可以使用 IPython 的命令自动补全功能找到这些操作的完整列表。特别是,研究 update 和 intersection_update 方法。intersection 和 intersection_update 有什么区别?
线性代数 - 数组
线性代数是计算数学中的一个基本组成部分。线性代数的对象是向量和矩阵。NumPy 包包含了处理这些对象所需的所有工具。
第一个任务是构建矩阵和向量,或通过切片修改现有的矩阵和向量。另一个主要任务是点积运算,它包含了大多数线性代数运算(标量积、矩阵-向量积和矩阵-矩阵积)。最后,提供了多种方法来解决线性问题。
本章节将涵盖以下主题:
-
数组类型概述
-
数学预备知识
-
数组类型
-
访问数组元素
-
构造数组的函数
-
访问和更改形状
-
堆叠
-
对数组进行操作的函数
-
SciPy 中的线性代数方法
第五章:4.1 数组类型概述
对于急于了解的读者,以下是如何使用数组的简要介绍。不过需要注意的是,数组的行为一开始可能会让人感到惊讶,因此我们建议在阅读完本介绍部分后继续阅读。
再次提醒,本章节的呈现方式假设你已经导入了 NumPy 模块,正如本书其他地方所假设的那样:
from numpy import *
通过导入 NumPy,我们可以访问数据类型ndarray,将在接下来的章节中进行描述。
4.1.1 向量和矩阵
创建向量就像使用array函数将一个列表转换为数组一样简单:
v = array([1.,2.,3.])
对象v现在是一个向量,表现得很像线性代数中的向量。我们已经在第 3.2 节中强调了它与 Python 中的列表对象的区别:快速了解数组的概念。
这里是一些对向量进行基本线性代数运算的示例:
# two vectors with three components
v1 = array([1., 2., 3.])
v2 = array([2, 0, 1.])
# scalar multiplications/divisions
2*v1 # array([2., 4., 6.])
v1/2 # array([0.5, 1., 1.5])
# linear combinations
3*v1 # array([ 3., 6., 9.])
3*v1 + 2*v2 # array([ 7., 6., 11.])
# norm
from numpy.linalg import norm
norm(v1) # 3.7416573867739413
# scalar product
dot(v1, v2) # 5.0
v1 @ v2 # 5.0 ; alternative formulation
请注意,所有基本算术操作都是按元素进行的:
# elementwise operations:
v1 * v2 # array([2., 0., 3.])
v2 / v1 # array([2.,0.,.333333])
v1 - v2 # array([-1., 2., 2.])/
v1 + v2 # array([ 3., 2., 4.])
还有一些函数也按元素对数组进行操作:
cos(v1) # cosine, elementwise: array([ 0.5403, -0.4161, -0.9899])
本主题将在第 4.8 节中讲解:对数组进行操作的函数。
矩阵的创建与向量类似,只是它是从列表的列表中创建的:
M = array([[1.,2],[0.,1]])
请注意,向量并不是列矩阵或行矩阵。一个
向量、一个
,以及一个
矩阵是三种不同的对象,即使它们包含相同的数据。
要创建一个行矩阵,包含与向量v = array([1., 2., 1.])相同的数据,我们应用reshape方法:
R = v.reshape((1,3))
shape(R) # (1,3): this is a row matrix
对应的列矩阵通过reshape以相应的方式获得:
C = v.reshape((3, 1))
shape(C) # (3,1): this is a column matrix
在学习了如何创建数组并看到基本的数组操作后,我们现在将学习如何通过索引和切片来访问数组元素和子数组。
4.1.2 索引和切片
索引和切片与列表中的对应操作类似。主要区别在于,当数组是矩阵时,可能会有多个索引或切片。该主题将在第 4.4.1 节:基本数组切片中深入讨论;在这里,我们仅提供一些索引和切片的示例:
v = array([1., 2., 3])
M = array([[1., 2],[3., 4]])
v[0] # works as for lists
v[1:] # array([2., 3.])
M[0, 0] # 1.
M[1:] # returns the matrix array([[3., 4]])
M[1] # returns the vector array([3., 4.])
# access
v[0] # 1.
v[0] = 10
# slices
v[:2] # array([10., 2.])
v[:2] = [0, 1] # now v == array([0., 1., 3.])
v[:2] = [1, 2, 3] # error!
由于数组是所有计算线性代数任务的基本数据类型,本文本节将展示一些示例、点积及线性方程组的解法。
4.1.3 线性代数操作
执行大多数常见线性代数操作的关键运算符是 Python 函数dot。它用于矩阵-向量乘法(有关详细信息,请参阅第 4.2.4 节:点积操作):
dot(M, v) # matrix vector multiplication; returns a vector
M @ v # alternative formulation
它可以用来计算两个向量之间的标量积:
dot(v, w)
# scalar product; the result is a scalar
v @ w # alternative formulation
最后,它用于计算矩阵-矩阵乘积:
dot(M, N) # results in a matrix
M @ N # alternative formulation
求解线性系统
如果![]是矩阵,![]是向量,你可以求解线性方程组

使用线性代数子模块numpy.linalg中的solve函数:
from numpy.linalg import solve
x = solve(A, b)
例如,求解

执行以下 Python 语句:
from numpy.linalg import solve
A = array([[1., 2.], [3., 4.]])
b = array([1., 4.])
x = solve(A, b)
allclose(dot(A, x), b) # True
allclose(A @ x, b) # alternative formulation
命令allclose用于比较两个向量。如果它们足够接近,该命令返回True。可以选择设置容差值。有关与线性方程组相关的更多方法,请参阅第 4.9 节:SciPy 中的线性代数方法。
现在,你已经看到了在 Python 中使用数组的第一种基本方式。在接下来的章节中,我们将向你展示更多细节及其基本原理。
4.2 数学基础
为了理解数组在 NumPy 中的工作原理,了解通过索引访问张量(矩阵和向量)元素与通过提供参数评估数学函数之间的数学关系非常有用。我们还将在本节中介绍点积作为归约算子的推广。
4.2.1 将数组视为函数
数组可以从多个不同的角度进行考虑。如果你希望从数学角度理解这一概念,可能会通过将数组类比为多个变量的函数来获益。这个视角将在后续讲解广播概念时再次提到,第 5.5 节:广播。
例如,选择给定向量中的一个分量,在
中可能被视为从
到
的函数,其中我们定义集合:

在这里,集合
有 n 个元素。Python 函数 range 生成 ![]。
另一方面,选择一个给定矩阵的元素是一个有两个参数的函数,其值域为
。从一个
矩阵中选择特定元素,因此可以看作是一个从
到
的函数。
4.2.2 操作是逐元素的
NumPy 数组本质上被当作数学函数来处理。特别是对于操作来说是这样。考虑两个定义在同一域并且取实值的函数,
和
。这两个函数的乘积
被定义为逐点乘积,即:

请注意,这种构造对于两个函数之间的任何操作都是可能的。对于一个在两个标量之间定义的任意操作,我们这里用
表示,可以将
定义如下:

这一看似无害的言论让我们理解了 NumPy 对操作的立场;所有操作在数组中都是逐元素的。例如,两个矩阵之间的乘积,
和
,就像函数一样,其定义如下:

4.2.3 形状和维度数
这里有一个明确的区分:
-
标量:没有参数的函数
-
向量:一个具有一个参数的函数
-
矩阵:具有两个参数的函数
-
高阶张量:具有两个以上参数的函数
以下内容中,维度数是一个函数的参数个数。形状本质上对应于一个函数的定义域。
例如,一个大小为 n 的向量是一个从集合
到
的函数。因此,它的定义域是
。它的形状定义为单例 (n,)。类似地,大小为
的矩阵是一个定义在
上的函数。相应的形状就是一对 (m, n)。数组的形状由函数 numpy.shape 获取,维度数由函数 numpy.ndim 获取;请参见 第 4.6 节:访问和更改形状。
4.2.4 点积操作
将数组视为函数,虽然非常强大,但完全忽略了我们熟悉的线性代数结构,即矩阵-向量和矩阵-矩阵操作。幸运的是,这些线性代数操作可以都写成类似的统一形式:
向量-向量操作:

矩阵-向量操作:

矩阵-矩阵操作:

向量-矩阵操作:

本质的数学概念是“约简”(reduction)。对于矩阵-向量操作,约简由以下公式给出:

通常情况下,在两个张量之间定义的约简操作,分别是
和
,它们的维度分别是
和
,可以定义为:

显然,张量的形状必须与该操作兼容才能产生
这要求对于矩阵-矩阵乘法也很熟悉。乘法 
矩阵
和
之间的操作,只有当
的列数等于
的行数时才有意义。
约简操作的另一个结果是它生成了一个具有
维度的新张量。在表 4.1中,我们收集了涉及矩阵和向量的约简操作输出:
![]() |
![]() |
![]() |
|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
表 4.1:涉及矩阵和向量的约简操作输出
在 Python 中,所有的约简操作都可以通过 dot 函数或 @ 操作符来执行:
angle = pi/3
M = array([[cos(angle), -sin(angle)],
[sin(angle), cos(angle)]])
v = array([1., 0.])
y = dot(M, v)
在 Python 3.5 及更高版本中,点积可以用其运算符形式dot(M, v)表示,或者使用中缀符号表示M @ v。从现在起,我们将坚持使用更方便的中缀符号;如果需要其他形式,您可以修改示例。然而,我们需要注意的是,dot会在其参数类型为其他可以转换为数组的类型(如列表或浮点数)时执行类型转换。而中缀运算符@则不具有这个特性。
乘法运算符*始终是逐元素的。它与点积操作无关。即使
是矩阵,且
是向量,A*v仍然是合法的操作。这将在第 5.5 节中解释:广播*。
在本节中,我们介绍了在数学中使用数组与矩阵和向量的结合,并解释了相关的操作。特别地,我们解释了科学计算中最核心的操作——点积。接下来,我们将转向数组数据类型ndarray及其更一般的方法。
4.3 数组类型
用于操作向量、矩阵以及更一般张量的对象在 NumPy 中被称为 ndarray,简称数组。在本节中,我们将探讨它们的基本属性、如何创建它们以及如何访问其信息。
4.3.1 数组属性
数组本质上由三个属性来表征,这些属性在表 4.2中进行了描述。
| 名称 | 描述 |
|---|---|
shape |
该属性描述数据应如何解释,例如作为向量、矩阵或更高阶张量,并给出相应的维度。可以通过shape属性访问该值。 |
dtype |
该属性给出基础数据的类型(如浮点数、复数、整数等)。 |
strides |
此属性指定数据应该如何读取。例如,一个矩阵可以按照列顺序(FORTRAN 约定)或行顺序(C 约定)连续存储在内存中。该属性是一个元组,包含到达下一行和下一列时需要跳过的字节数。它甚至允许对内存中的数据进行更灵活的解释,这也使得数组视图成为可能。 |
表 4.2:数组的三个特征属性
例如,考虑以下数组:
A = array([[1, 2, 3], [3, 4, 6]])
A.shape # (2, 3)
A.dtype # dtype('int64')
A.strides # (24, 8)
它的元素类型为'int64',即它们在内存中占用 64 位或 8 个字节。整个数组按行存储在内存中。从A[0, 0]到下一行第一个元素A[1,0]的内存距离是 24 个字节(即三个矩阵元素)。相应地,A[0,0]和A[0,1]之间的内存距离是 8 个字节(即一个矩阵元素)。这些值存储在strides属性中。
4.3.2 从列表创建数组
创建数组的一般方法是使用 array 函数。创建实值向量的语法如下:
V = array([1., 2., 1.], dtype=float)
要创建一个具有相同数据的复数向量,可以使用:
V = array([1., 2., 1.], dtype=complex)
如果未指定类型,则会猜测类型。array 函数会选择允许存储所有指定值的类型:
V = array([1, 2]) # [1, 2] is a list of integers
V.dtype # int64
V = array([1., 2]) # [1., 2] mix float/integer
V.dtype # float64
V = array([1\. + 0j, 2.]) # mix float/complex
V.dtype # complex128
NumPy 会默默地将浮点数转换为整数,这可能会导致意外的结果:
a = array([1, 2, 3])
a[0] = 0.5
a # now: array([0, 2, 3])
同样,常见的、通常是意外的数组类型转换发生在从 complex 到 float 之间。
数组和 Python 括号
如我们在 第 1.2.2 节:行连接 中注意到,Python 允许在某些括号或圆括号没有关闭时换行。这为数组创建提供了方便的语法,使其更加符合人眼的审美:
# the identity matrix in 2D
Id = array([[1., 0.], [0., 1.]])
# Python allows this:
Id = array([[1., 0.],
[0., 1.]])
# which is more readable
到目前为止,你已经看到数组和列表在定义和使用上的许多区别。相比之下,访问数组元素似乎与访问列表元素非常相似。但尤其是多个索引的使用以及切片操作结果的对象,需要我们更加详细地研究这些问题。
4.4 访问数组条目
数组条目通过索引访问。与向量系数不同,访问矩阵系数需要两个索引。这些索引放在一对括号中。这使得数组语法与列表的列表有所区别。在后者中,需要两对括号来访问元素。
M = array([[1., 2.],[3., 4.]])
M[0, 0] # first row, first column: 1.0
M[-1, 0] # last row, first column: 3.0
现在我们更详细地来看一下双重索引和切片的使用。
4.4.1 基本数组切片
切片与列表的切片类似(另见 第 3.1.1 节:切片),不过它们现在可能存在多个维度:
-
M[i,:]是由行
填充的向量,来自
. -
M[:,j]是由列填充的向量
来自
. -
M[2:4,:]是对行的2:4切片。 -
M[2:4,1:4]是行和列的切片。
矩阵切片的结果见 图 4.1:

图 4.1:矩阵切片的结果
如果省略索引或切片,NumPy 会假定你只是在取行。M[3] 是一个向量,它是对 M 的第三行的视图,而 M[1:3] 是一个矩阵,它是对 M 的第二行和第三行的视图:
修改切片的元素会影响整个数组(另见 第 5.1 节:数组视图和副本):
v = array([1., 2., 3.])
v1 = v[:2] # v1 is array([1., 2.])
v1[0] = 0\. # if v1 is changed ...
v # ... v is changed too: array([0., 2., 3.])
一般切片规则见 表 4.3:
| 访问 | ndim | 类型 |
|---|---|---|
| 索引, 索引 | 0 | 标量 |
| 切片, 索引 | 1 | 向量 |
| 索引, 切片 | 1 | 向量 |
| 切片, 切片 | 2 | 矩阵 |
表 4.3:一般切片规则
数组 M 的切片操作结果(形状为 (4, 4))见 表 4.4:
| 访问 | 形状 | 维度 | 类型 |
|---|---|---|---|
M[:2, 1:-1] |
(2,2) | 2 | 矩阵 |
M[1,:] |
(4,) | 1 | 向量 |
M[1,1] |
() | 0 | 标量 |
M[1:2,:] |
(1,4) | 2 | 矩阵 |
M[1:2, 1:2] |
(1,1) | 2 | 矩阵 |
表 4.4:形状为 (4,4) 的数组 M 的切片操作结果
4.4.2 使用切片更改数组
你可以使用切片或直接访问来更改数组。以下示例仅更改一个元素,位于
矩阵
中:
M[1, 2] = 2.0 # scalar
同样,我们也可以更改矩阵中的一整行:
M[2, :] = [1., 2., 3.] # vector
同样,我们也可以替换整个子矩阵:
M[1:3, :] = array([[1., 2., 3.],[-1.,-2., -3.]])
列矩阵和向量之间是有区别的。以下使用列矩阵的赋值不会报错:
M[1:4, 1:2] = array([[1.],[0.],[-1.0]])
而使用向量赋值时会返回 ValueError:
M[1:4, 1:2] = array([1., 0., -1.0]) # error
一般的切片规则如 表 4.3 所示。前面的矩阵和向量必须具有适当的大小,以适应矩阵
。你还可以使用广播规则(参见 第 5.5 节:广播)来确定替换数组的允许大小。如果替换数组的形状不正确,将引发 ValueError 异常。
我们已经看到如何通过切片从其他数组中构造数组。在下一节中,我们将考虑一些直接创建和初始化数组的特殊 NumPy 函数。
4.5 构造数组的函数
设置数组的常见方法是通过列表。但也有一些方便的方法用于生成特殊数组,这些方法在 表 4.5 中给出:
| 方法 | 形状 | 生成的结果 |
|---|---|---|
zeros((n,m)) |
(n,m) | 填充了 0 的矩阵 |
ones((n,m)) |
(n,m) | 填充了 1 的矩阵 |
full((n,m),q) |
(n,m) | 填充了 的矩阵 |
diag(v,k) |
(n,n) | 来自向量的(下、上)对角矩阵 ![]() |
random.rand(n,m) |
(n,m) | 填充了均匀分布的随机数(在 (0,1) 之间)的矩阵 |
arange(n) |
(n,) | 前 n 个整数 ![]() |
linspace(a,b,n) |
(n,) | 向量,包含在 和 之间均匀分布的 n 个点 |
表 4.5:创建数组的命令
这些命令可能会接受额外的参数。特别地,命令 zeros、ones、full 和 arange 接受 dtype 作为可选参数。默认类型是 float,但 arange 除外。也有一些方法,如 zeros_like 和 ones_like,它们是前述命令的轻微变体。例如,命令 zeros_like(A) 等价于 zeros(shape(A))。
函数 identity 用于构造给定大小的单位矩阵:
I = identity(3)
该命令与以下命令相同:
I = array([[ 1., 0., 0.],
[ 0., 1., 0.],
[ 0., 0., 1.]])
4.6 访问和改变形状
维度数是区分向量和矩阵的标志。形状 是区分不同大小的向量或矩阵的标志。在这一节中,我们将研究如何获取和改变数组的形状。
4.6.1 shape 函数
矩阵的形状是其维度的元组。一个
矩阵的形状是元组 (n, m)。可以通过 shape 函数获得:
M = identity(3)
shape(M) # (3, 3)
或者,通过其属性来简单获取
M.shape # (3, 3)
然而,使用 shape 作为函数而不是属性的优点在于,函数也可以用于标量和列表。这在代码需要同时处理标量和数组时非常有用:
shape(1.) # ()
shape([1,2]) # (2,)
shape([[1,2]]) # (1,2)
对于一个向量,形状是一个包含该向量长度的单一元素:
v = array([1., 2., 1., 4.])
shape(v) # (4,) <- singleton (1-tuple)
4.6.2 维度数
数组的维度可以通过 ndim 函数或数组的 ndim 属性来获得:
ndim(A) # 2
A.ndim # 2
注意,给定张量 T(向量、矩阵或更高阶张量)的维度数是由 ndim 函数给出的,并且总是等于其形状的长度:
T = zeros((2,2,3)) # tensor of shape (2,2,3); three dimensions
ndim(T) # 3
len(shape(T)) # 3
4.6.3 重新塑形
方法 reshape 为数组提供了一个新的视图,具有新形状,而不复制数据:
v = array([0,1,2,3,4,5])
M = v.reshape(2,3)
shape(M) # returns (2,3)
M[0,0] = 10 # now v[0] is 10
reshape 对由 arange(6) 定义的数组的各种影响如 图 4.2 所示:

图 4.2:reshape 对数组的各种影响
reshape 不会创建一个新数组。它只是为现有数组提供一个新的视图。在前面的示例中,修改 M 的一个元素会自动导致 v 中相应元素的变化。当这种行为不可接受时,你需要复制数据,如在第 5.1 节中解释的那样:数组视图与副本。
如果你尝试重新塑形一个数组,而其形状不能与原始形状相乘,则会抛出错误:
ValueError: total size of new array must be unchanged.
有时候,指定一个 shape 参数并让 Python 自动计算出另一个参数,使得它们的乘积等于原始形状是很方便的。这可以通过将自由的 shape 参数设置为 -1 来实现:
v = array([1, 2, 3, 4, 5, 6, 7, 8])
M = v.reshape(2, -1)
shape(M) # returns (2, 4)
M = v.reshape(-1, 2)
shape(M) # returns (4,2 )
M = v.reshape(3,- 1) # returns error
转置
重新塑形的一种特殊形式是 转置。它仅交换矩阵的两个形状元素。矩阵
的转置是矩阵
,使得

这将通过以下方式解决:
A = ...
shape(A) # (3,4)
B = A.T # A transpose
shape(B) # (4,3)
transpose 不会复制:转置与重新塑形非常相似,尤其是它也不复制数据,而是仅返回同一数组的视图:
A= array([[ 1., 2.],[ 3., 4.]])
B=A.T
A[1,1]=5\.
B[1,1] # 5.0
转置一个向量没有意义,因为向量是一个一维的张量,也就是一个单变量的函数——索引。然而,NumPy 会执行转置并返回完全相同的对象:
v = array([1., 2., 3.])
v.T # exactly the same vector!
当你想转置一个向量时,你可能是想创建一个行矩阵或列矩阵。这可以通过reshape来实现:
v.reshape(-1, 1) # column matrix containing v
v.reshape(1, -1) # row matrix containing v
4.7 堆叠
从一对(匹配的)子矩阵构建矩阵的通用方法是concatenate。它的语法是:
concatenate((a1, a2, ...), axis = 0)
当指定axis=0时,这个命令会将子矩阵垂直堆叠(一个在另一个之上)。使用axis=1参数时,它们会水平堆叠,这个操作会根据更高维度的数组进行泛化。这个函数通过多个方便的函数来调用,如下所示:
-
hstack:用于水平堆叠数组 -
vstack:用于垂直堆叠数组 -
columnstack:用于将向量堆叠成列
4.7.1 向量堆叠
你可以使用vstack和column_stack按行或按列堆叠向量,如图 4.3所示:

图 4.3:vstack 和 column_stack 的区别
注意,hstack将会产生v1和v2的拼接。
让我们以辛普森排列为向量堆叠的例子:我们有一个大小为
的向量。我们想对具有偶数个分量的向量执行辛普森变换,即将向量的前半部分与后半部分交换,并且改变符号:

这个操作在 Python 中是这样解决的:
# v is supposed to have an even length.
def symp(v):
n = len(v) // 2 # use the integer division //
return hstack([v[-n:], -v[:n]])
4.8 对数组的函数
数组上有不同类型的函数。有些是逐元素作用的,它们返回一个形状相同的数组,这些被称为通用函数。其他数组函数返回形状不同的数组。在本节中,我们将接触这两种类型的函数,并学习如何将标量函数转换为通用函数。
4.8.1 通用函数
通用函数是对数组逐元素作用的函数。因此,它们的输出数组与输入数组具有相同的形状。这些函数允许我们一次性计算标量函数在整个数组上的结果。
内建通用函数
一个典型的例子是cos函数(由 NumPy 提供):
cos(pi) # -1
cos(array([[0, pi/2, pi]])) # array([[1, 0, -1]])
注意,通用函数是逐分量作用于数组的。操作符,如乘法或指数,也遵循这个规则:
2 * array([2, 4]) # array([4, 8])
array([1, 2]) * array([1, 8]) # array([1, 16])
array([1, 2])**2 # array([1, 4])
2**array([1, 2]) # array([2, 4])
array([1, 2])**array([1, 2]) # array([1, 4])
创建通用函数
如果你在函数中只使用通用函数,那么你的函数会自动变成通用函数。然而,如果你的函数使用了非通用函数,当你试图将它们应用于数组时,可能会得到标量结果,甚至出现错误:
def const(x):
return 1
const(array([0, 2])) # returns 1 instead of array([1, 1])
另一个例子如下:
def heaviside(x):
if x >= 0:
return 1.
else:
return 0.
heaviside(array([-1, 2])) # error
预期的行为是,将 heaviside 函数应用于一个向量 [a, b] 时,应该返回 [heaviside(*a*), heaviside(*b*)]。遗憾的是,这并不奏效,因为该函数总是返回一个标量,无论输入参数的大小如何。此外,使用数组输入时,if 语句会引发异常,具体细节可参见 第 5.2.1 节:布尔数组。
NumPy 函数 vectorize 使我们能够快速解决这个问题:
vheaviside = vectorize(heaviside)
vheaviside(array([-1, 2])) # array([0, 1]) as expected
该方法的典型应用是用于绘制函数时:
xvals = linspace(-1, 1, 100)
plot(xvals, vectorize(heaviside)(xvals))
axis([-1.5, 1.5, -0.5, 1.5])
图 4.4 显示了结果图:

图 4.4:Heaviside 函数
函数 vectorize 提供了一种方便的方式,可以快速地将一个函数转换,使其逐元素作用于列表和数组。
vectorize 也可以作为装饰器使用:
@vectorize
def heaviside(x):
if x >= 0:
return 1\.
else:
return 0\.
# and a call of this results in:
heaviside(array([-1, 2])) # array([0, 1])
装饰器将在 第 7.8 节 中介绍:作为装饰器的函数。
4.8.2 数组函数
有一些作用于数组的函数,并不是逐元素作用的。这些函数的例子包括 max、min 和 sum。这些函数可以作用于整个矩阵、按行作用或按列作用。当没有提供参数时,它们会作用于整个矩阵。
假设:

对该矩阵应用的 sum 函数返回一个标量:
sum(A) # 36
该命令有一个可选参数 axis。它允许我们选择沿哪个轴执行操作。例如,如果轴是
,意味着应该沿第一个轴计算和。沿轴
对形状为
的数组求和,将得到一个长度为
的向量。
假设我们计算 A 沿轴
的和:
sum(A, axis=0) # array([ 6, 8, 10, 12])
这相当于计算列上的和:

结果是一个向量:

现在假设我们计算沿轴 1 的和:
A.sum(axis=1) # array([10, 26])
这相当于计算行上的和:

结果是一个向量:

在本节中,我们已经介绍了作用于数组的函数,接下来我们将转向解决基础科学计算任务的函数。我们通过考虑一些线性代数中的标准任务来举例说明。
4.9 SciPy 中的线性代数方法
SciPy 提供了一系列数值线性代数方法,这些方法在其模块 scipy.linalg 中。许多这些方法是 Python 包装的 LAPACK 程序,LAPACK 是一组广泛认可的 FORTRAN 子程序,用于解决线性方程组和特征值问题,详见 [5]。线性代数方法是科学计算中任何方法的核心,SciPy 使用包装器而非纯 Python 代码使得这些核心方法极其快速。我们在这里详细展示了如何通过 Scipy 解决两个线性代数问题,旨在让你对该模块有所了解。
你之前接触过一些来自 numpy.linalg 模块的线性代数函数。NumPy 和 SciPy 两个包是兼容的,但 Scipy 更侧重于科学计算方法,并且功能更加全面,而 NumPy 更侧重于数组数据类型,仅提供了一些便捷的线性代数方法。
4.9.1 使用 LU 解多个线性方程组
设
是一个
矩阵,且
是一系列
向量。我们考虑求解问题,找到
向量
,使得:

我们假设向量
不是同时已知的。特别地,通常情况下,必须先解决
^(th) 问题,然后才能获得
,例如在简化的牛顿迭代法中,详见 [24]。
因式分解是一种组织经典高斯消元法的方法,能够将计算分为两步进行:
-
矩阵的因式分解步骤
,目的是将矩阵转换为三角形形式 -
一种相对廉价的向后和向前消元步骤,作用于
的实例,并且受益于更耗时的因式分解步骤
该方法还利用了这样一个事实:如果
是一个置换矩阵,使得
是原矩阵的行经过置换后的矩阵,那么两个系统
和
具有相同的解。
因式分解找到一个置换矩阵
,一个下三角矩阵
,以及一个上三角矩阵
,使得:
或等价地
。
这种因式分解总是存在的。此外,
可以以
的方式确定。因此,来自
的核心数据必须存储为
,同时
被保留。于是,
和
可以存储在一个
数组中,而关于置换矩阵
的信息只需要一个
整数向量——即主元向量。
在 SciPy 中,有两种方法可以计算 LU 因式分解。标准的方法是 scipy.linalg.lu,它返回三个矩阵 L、U 和 P。另一种方法是 lu_factor。我们在这里描述的是这种方法,因为它将方便地与 lu_solve 结合使用:
import scipy.linalg as sl
[LU,piv] = sl.lu_factor(A)
在这里,矩阵 A 被分解,并返回一个包含 L 和 U 信息的数组,同时返回主元向量。有了这些信息,通过根据主元向量中的信息对向量
进行行交换,再通过使用
的回代替换,最后使用
的前代替换,即可求解系统。这在 Python 中被打包为 lu_solve 方法。下面的代码片段展示了如何在执行 LU 因式分解并将其结果存储在元组 (LU, piv) 中后,解决系统
:
import scipy.linalg as sl
xi = sl.lu_solve((LU, piv), bi)
4.9.2 使用 SVD 求解最小二乘问题
一个线性方程组
,其中
为
矩阵,
,称为过度确定的线性系统。通常,它没有经典解,你需要寻找一个向量
,满足以下性质:

这里,
表示欧几里得向量范数
。
这个问题被称为最小二乘问题。解决此问题的稳定方法是基于对
, 进行分解,其中
是一个
正交矩阵,
是一个
正交矩阵,而
是一个具有属性
的矩阵,对于所有的
。这种分解被称为 奇异值分解(SVD)。
我们写作:

使用对角线
矩阵
。如果我们假设
是满秩的,那么 ![] 是可逆的,并且可以证明:

成立。
如果我们将
分割,其中
是一个
子矩阵,那么前面的方程可以简化为:

SciPy 提供了一个名为 svd 的函数,我们用它来解决这个任务:
import scipy.linalg as sl
[U1, Sigma_1, VT] = sl.svd(A, full_matrices = False,
compute_uv = True)
xast = dot(VT.T, dot(U1.T, b) / Sigma_1)
r = dot(A, xast) - b # computes the residual
nr = sl.norm(r, 2) # computes the Euclidean norm of r
关键字 full_matrices 指示是计算完整矩阵
还是仅计算其子矩阵
。由于你经常使用 svd 只计算奇异值,
,在我们的情况下,我们需要明确要求计算
和
,这可以通过使用关键字 compute_uv 来实现。
SciPy 函数 scipy.linalg.lstsq 通过内部使用 SVD 来直接求解最小二乘问题。
4.9.3 更多方法
到目前为止的例子中,你遇到了一些用于线性代数计算任务的方法,例如 solve。执行命令 import scipy.linalg as sl 后,可以使用更多的方法。最常用的方法列在 表 4.6 中:
| 方法 | 描述 |
|---|---|
sl.det |
矩阵的行列式 |
sl.eig |
矩阵的特征值和特征向量 |
sl.inv |
矩阵的逆 |
sl.pinv |
矩阵伪逆 |
sl.norm |
矩阵或向量的范数 |
sl.svd |
奇异值分解 |
sl.lu |
LU 分解 |
sl.qr |
QR 分解 |
sl.cholesky |
Cholesky 分解 |
sl.solve |
一般或对称线性系统的解:Ax = b |
sl.solve.banded |
带状矩阵的解法 |
sl.lstsq |
最小二乘解 |
表 4.6:scipy.linalg 模块的线性代数函数
首先执行 import scipy.linalg as sl。
4.10 总结
在本章中,我们处理了线性代数中最重要的对象——向量和矩阵。为此,我们学习了如何定义数组,并掌握了重要的数组方法。一个较小的部分展示了如何使用 scipy.linalg 中的模块来解决线性代数中的核心任务。
在接下来的一章中,我们将考虑数组的更高级和特殊方面。
4.11 练习
Ex. 1: 考虑一个
矩阵:

-
使用函数
array在 Python 中构造此矩阵。 -
使用函数
arange和适当的reshape构造相同的矩阵。 -
表达式
M[2,:]的结果是什么?类似表达式M[2:]的结果是什么?
Ex. 2: 给定一个向量 x,用 Python 构造如下矩阵:

这里,
是向量
的分量(从零开始编号)。给定向量
,用 Python 解决线性方程组
。让
的分量用
表示。编写一个函数 poly,其输入为
和
,计算多项式:

绘制该多项式,并在同一图中将点
表示为小星号。使用以下向量测试你的代码:

Ex. 3: 矩阵
在 Ex. 2 中称为范德蒙矩阵。可以直接使用命令 vander 在 Python 中设置它。用 Python 命令 polyval 评估由系数向量定义的多项式。使用这些命令重复 Ex. 2。
Ex. 4: 设
是一个一维数组。构造另一个数组
,其值为
。在统计学中,这个数组被称为 移动平均值。在逼近理论中,它扮演了三次样条函数的 Greville 点的角色。尝试在你的脚本中避免使用for循环。
Ex. 5:
-
从矩阵
(见Ex. 2)构造一个矩阵
,删除其中
的第一列。 -
形成矩阵
。 -
计算
,并使用Ex. 2中的y。 -
使用
和polyval绘制由
定义的多项式。再次在同一图中绘制点
。
例 6: 例 5 描述了最小二乘法。重复该练习,但改用 SciPy 的scipy.linalg.lstsq方法。
例 7: 设
是一个向量,以其坐标形式写成
矩阵
。构造投影矩阵:
和
实验表明,
是矩阵
和
的特征向量。相应的特征值是多少?
例 8: 在数值线性代数中,
矩阵
具有以下性质

被用作极端增长因子的例子,在执行 LU 分解时,见[36, p. 165]。
在 Python 中设置这个矩阵,对于不同的
值,使用命令scipy.linalg.lu计算其 LU 分解,并通过实验得出关于增长因子的陈述。

关于
。
高级数组概念
在本章中,我们将解释一些数组的高级概念。首先,我们将介绍数组视图的概念——这是每个 NumPy 程序员必须了解的,以避免难以调试的编程错误。然后将介绍布尔数组及比较数组的方法。此外,我们还将简要描述索引和向量化,解释一些特殊话题,比如广播和稀疏矩阵。
在本章中,我们将覆盖以下主题:
-
数组视图与副本
-
比较数组
-
数组索引
-
性能与向量化
-
广播
-
稀疏矩阵
第六章:5.1 数组视图与副本
为了精确控制内存的使用,NumPy 提供了数组视图的概念。视图是共享同一数据的大数组的较小数组。这就像是一个对单一对象的引用。
5.1.1 数组视图
最简单的视图示例是通过数组切片获得的:
M = array([[1.,2.],[3.,4.]])
v = M[0,:] # first row of M
前面的切片 v 是 M 的视图。它与 M 共享相同的数据。修改 v 将同时修改 M:
v[-1] = 0.
v # array([[1.,0.]])
M # array([[1.,0.],[3.,4.]]) # M is modified as well
可以通过数组属性 base 访问拥有数据的对象:
v.base # array([[1.,0.],[3.,4.]])
v.base is M # True
如果一个数组拥有其数据,那么属性 base 的值为 None:
M.base # None
5.1.2 切片作为视图
关于哪些切片返回视图,哪些切片返回副本,有明确的规则。只有基本切片(主要是使用 : 的索引表达式)返回视图,而任何高级选择(如用布尔值切片)都会返回数据的副本。例如,可以通过索引列表(或数组)来创建新的矩阵:
a = arange(4) # array([0.,1.,2.,3.])
b = a[[2,3]] # the index is a list [2,3]
b # array([2.,3.])
b.base is None # True, the data was copied
c = a[1:3]
c.base is None # False, this is just a view
在前面的例子中,数组 b 不是视图,而通过更简单的切片获得的数组 c 是视图。
有一种特别简单的数组切片,它返回整个数组的视图:
N = M[:] # this is a view of the whole array M
5.1.3 通过转置和重塑生成视图
其他一些重要操作也会返回视图。例如,转置一个数组返回的是一个视图:
M = random.random_sample((3,3))
N = M.T
N.base is M # True
同样的规则适用于所有的重塑操作:
v = arange(10)
C = v.reshape(-1,1) # column matrix
C.base is v # True
5.1.4 数组副本
有时需要显式地请求复制数据。这可以通过 NumPy 的 array 函数轻松实现:
M = array([[1.,2.],[3.,4.]])
N = array(M.T) # copy of M.T
我们可以验证数据确实已被复制:
N.base is None # True
在本节中,你了解了数组视图的概念。NumPy 通过使用视图而不是数组副本来节省内存,尤其对于大型数组来说,这一点至关重要。另一方面,无意中使用视图可能会导致难以调试的编程错误。
5.2 比较数组
比较两个数组并不像看起来那么简单。考虑以下代码,它旨在检查两个矩阵是否相近:
A = array([0.,0.])
B = array([0.,0.])
if abs(B-A) < 1e-10: # an exception is raised here
print("The two arrays are close enough")
这段代码在执行 if 语句时会引发以下异常:
ValueError: The truth value of an array with more than one element is ambiguous. Use a.any() or a.all()
在本节中,我们将解释为什么会这样,以及如何解决这种情况。
5.2.1 布尔数组
布尔数组对于高级数组索引非常有用(另见第 5.3.1 节:使用布尔数组索引)。布尔数组就是条目类型为bool的数组:
A = array([True,False]) # Boolean array
A.dtype # dtype('bool')
任何作用于数组的比较运算符都会生成一个布尔数组,而不是一个简单的布尔值:
M = array([[2, 3],
[1, 4]])
M > 2 # array([[False, True],
# [False, True]])
M == 0 # array([[False, False],
# [False, False]])
N = array([[2, 3],
[0, 0]])
M == N # array([[True, True],
# [False, False]])
注意,由于数组比较会创建布尔数组,因此不能直接在条件语句中使用数组比较,例如if语句。解决方法是使用all和any方法来创建一个简单的True或False:
A = array([[1,2],[3,4]])
B = array([[1,2],[3,3]])
A == B # creates array([[True, True], [True, False]])
(A == B).all() # False
(A != B).any() # True
if (abs(B-A) < 1e-10).all():
print("The two arrays are close enough")
在这里,使用all和any方法之一将导致一个“标量”布尔值,这现在允许在if语句中进行数组比较。
5.2.2 数组相等性检查
检查两个浮动数组的相等性并不简单,因为两个浮点数可能非常接近,但不完全相等。在 NumPy 中,可以通过allclose来检查相等性。这个函数检查两个数组在给定精度范围内的相等性:
data = random.rand(2)*1e-3
small_error = random.rand(2)*1e-16
data == data + small_error # False
allclose(data, data + small_error, rtol=1.e-5, atol=1.e-8) # True
容差是通过相对容差界限rtol和绝对误差界限atol来给定的。命令allclose是以下内容的简写:
(abs(A-B) < atol+rtol*abs(B)).all()
注意,allclose也可以应用于标量:
data = 1e-3
error = 1e-16
data == data + error # False
allclose(data, data + error, rtol=1.e-5, atol=1.e-8) #True
5.2.3 数组上的布尔操作
不能在布尔数组上使用and、or和not。实际上,这些运算符会强制将数组转换为布尔值,这是不允许的。相反,我们可以使用表 5.1 中给出的运算符来对布尔数组执行逐元素的逻辑操作:
| 逻辑运算符 | 布尔数组的替代方法 |
|---|---|
A and B |
A & B |
A or B |
A | B |
not A |
~ A |
表 5.1:按元素逻辑数组操作的逻辑运算符
这是使用布尔数组进行逻辑运算符的示例:
A = array([True, True, False, False])
B = array([True, False, True, False])
A and B # error!
A & B # array([True, False, False, False])
A | B # array([True, True, True, False])
~A # array([False, False, True, True])
假设我们有一系列数据,受到一些测量误差的影响。进一步假设我们进行回归分析,并且它给出了每个值的偏差。我们希望获得所有异常值和所有偏差小于给定阈值的值:
data = linspace(1,100,100) # data
deviation = random.normal(size=100) # the deviations
data = data + deviation
# do not forget the parentheses in the next statement!
exceptional = data[(deviation<-0.5)|(deviation>0.5)]
exceptional = data[abs(deviation)>0.5] # same result
small = data[(abs(deviation)<0.1)&(data<5.)] # small deviation and data
在这个示例中,我们首先创建了一个数据向量,然后用一些从正态分布随机数中抽样的偏差对其进行扰动。我们展示了两种替代方法,用于找到具有大扰动绝对值的数据元素,最后,我们收集了仅对小扰动的小数据值。在这里,我们在处理数组data时使用了布尔数组,而不是索引。这一技术将在下一节中进行解释。
5.3 数组索引
我们已经看到,我们可以使用切片和整数的组合来索引数组——这是一种基本的切片技术。然而,还有许多其他可能性,可以通过多种方式访问和修改数组元素。
5.3.1 使用布尔数组索引
根据数组的值,通常有必要仅访问和修改数组的部分内容。例如,你可能想要访问数组中所有的正元素。实际上,这是通过布尔数组实现的,布尔数组像掩码一样,仅选择数组中的部分元素。这样的索引结果总是一个向量。例如,考虑以下示例:
B = array([[True, False],
[False, True]])
M = array([[2, 3],
[1, 4]])
M[B] # array([2,4]), a vector
事实上,命令M[B]等同于M[B].flatten()。你可以用另一个向量替换结果向量。例如,你可以用零替换所有元素:
M[B] = 0
M # [[0, 3], [1, 0]]
或者,你可以用其他值替换所有选中的值:
M[B] = 10, 20
M # [[10, 3], [1, 20]]
通过结合布尔数组的创建(M > 2)、智能索引(使用布尔数组索引)和广播,你可以使用以下优雅的语法:
M[M>2] = 0 # all the elements > 2 are replaced by 0
这里提到的广播表达式是指标量0被巧妙地转换为适当形状的向量(另见第 5.5 节:广播)。
5.3.2 使用where命令
命令where提供了一个有用的构造,可以将布尔数组作为条件,返回满足条件的数组元素的索引,或者根据布尔数组中的值返回不同的值。
基本结构是:
where(condition, a, b)
当条件为True时,这将返回来自a的值,而当条件为False时,返回来自b的值。
例如,考虑一个海维赛德函数:

以及它与where命令的实现:
def H(x):
return where(x < 0, 0, 1)
x = linspace(-1,1,11) # [-1\. -0.8 -0.6 -0.4 -0.2 0\. 0.2 0.4 0.6 0.8 1\. ]
print(H(x)) # [0 0 0 0 0 1 1 1 1 1 1]
第二个和第三个参数可以是与条件(布尔数组)相同大小的数组,也可以是标量。我们将给出两个示例,以演示如何根据条件操作数组元素或标量:
x = linspace(-4,4,5)
# [-4\. -2\. 0\. 2\. 4.]
print(where(x < 0, -x, x))
# [ 4., 2., 0, 2., 4.] ]
print(where(x > 0, 1, -1)) # [-1 -1 -1 1 1]
如果省略第二个和第三个参数,则返回一个元组,其中包含满足条件的元素的索引。
例如,考虑在以下代码中仅使用一个参数的where:
a = arange(9)
b = a.reshape((3,3))
print(where(a > 5)) # (array([6, 7, 8]),)
print(where(b > 5)) # (array([2, 2, 2]), array([0, 1, 2]))
这个示例演示了如何找出布尔数组中值为True的元素的索引。命令where是一个非常实用的工具,用于在数组中查找满足给定条件的元素。
在本节中,你看到了布尔数组的各种使用场景。每当你的代码中包含基于条件和数组操作的for循环时,检查是否可以使用布尔数组的概念来帮助去除不必要的for循环,并至少提高代码的可读性。
5.4 性能与向量化
当谈到 Python 代码的性能时,它通常归结为解释型代码与编译型代码之间的差异。Python 是一种解释型编程语言,基本的 Python 代码是直接执行的,不需要经过中间的机器代码编译。而在编译型语言中,代码在执行前需要被转换成机器指令。
解释型语言有许多优点,但解释型代码在速度上无法与编译型代码竞争。为了加速代码,可以将某些部分用 FORTRAN、C 或 C++ 等编译语言编写。这正是 NumPy 和 SciPy 的做法。
因此,最好在可能的情况下使用 NumPy 和 SciPy 中的函数,而不是使用解释型版本。NumPy 数组操作,如矩阵乘法、矩阵-向量乘法、矩阵分解、标量积等,比任何纯 Python 等价物都要快得多。以标量积为例,标量积比编译后的 NumPy 函数dot(a,b)要慢得多(对于大约有 100 个元素的数组,它的速度慢超过 100 倍):
def my_prod(a,b):
val = 0
for aa, bb in zip(a,b):
val += aa*bb
return val
测量函数的速度是科学计算中的一个重要方面。有关测量执行时间的详细信息,请参见第 15.3 节:测量执行时间。
5.4.1 向量化
为了提高性能,通常需要将代码向量化。用 NumPy 的切片、操作和函数替代for循环和其他较慢的代码部分,能够显著提升性能。
例如,通过遍历元素将标量加到向量上的简单操作非常慢:
for i in range(len(v)):
w[i] = v[i] + 5
但使用 NumPy 的加法运算要快得多:
w = v + 5
使用 NumPy 的切片操作也能显著提高性能,优于使用for循环进行迭代。为了演示这一点,假设我们需要在一个二维数组中计算邻居的平均值:
def my_avg(A):
m,n = A.shape
B = A.copy()
for i in range(1,m-1):
for j in range(1,n-1):
B[i,j] = (A[i-1,j] + A[i+1,j] + A[i,j-1] + A[i,j+1])/4
return B
def slicing_avg(A):
A[1:-1,1:-1] = (A[:-2,1:-1] + A[2:,1:-1] +
A[1:-1,:-2] + A[1:-1,2:])/4
return A
这两个函数都将每个元素的值设为其四个邻居的平均值。使用切片的第二个版本速度要快得多。
除了用 NumPy 函数替换for循环和其他较慢的构造外,还有一个有用的函数叫做vectorized(参见第 4.8 节:作用于数组的函数)。它接收一个函数,并创建一个向量化版本,该版本使用函数对数组中的所有元素进行操作,尽可能使用函数。
考虑以下函数,我们将用它来演示如何将函数向量化:
def my_func(x):
y = x**3 - 2*x + 5
if y>0.5:
return y-0.5
else:
return 0def my_func(x):
y = x**3 - 2*x + 5
if y>0.5:
return y-0.5
else:
return 0
以非向量化的方式在一个包含 100 个元素的向量上应用此函数!:
[my_func(vk) for vk in v]
使用向量化方式时,它的速度几乎是传统方式的三倍:
vectorize(my_func)(v)
在本节中,我们展示了几个使用 NumPy 数组进行计算向量化的例子。强烈推荐积极使用这一概念,这不仅能加速代码的执行,还能提高代码的可读性。
5.5 广播
在 NumPy 中,广播指的是能够推测两个数组之间的共同兼容形状。例如,当向量(单维数组)和标量(零维数组)相加时,标量会被扩展为一个向量,以便进行加法操作。这个一般机制叫做广播。我们首先从数学角度回顾这一机制,然后给出 NumPy 中广播的精确规则。数学视角可能让受过数学训练的读者更容易理解广播,而其他读者可能想跳过数学细节,直接继续阅读 第 5.5.2 节: 广播数组。
5.5.1 数学视角
广播在数学中经常进行,通常是隐式的。例如,表达式如
或
就是广播的应用。我们将在这一节中详细描述该技术。
我们牢记函数与 NumPy 数组之间的密切关系,如 第 4.2.1 节所描述: 数组作为函数。
常数函数
广播的一个最常见的例子是函数与常数相加;如果
是一个标量,我们通常写作:

这是一种符号滥用,因为你不应该能将函数和常数相加。然而,常数会隐式地广播到函数。常数
的广播版本是函数
,由以下公式定义:

现在,将两个函数相加是有意义的:

我们并不是为了矫揉造作,而是因为数组也可能出现类似的情况,如下方的代码所示:
vector = arange(4) # array([0.,1.,2.,3.])
vector + 1\. # array([1.,2.,3.,4.])
在这个例子中,一切的发生就像标量 1. 被转换成与 vector 相同长度的数组,即 array([1.,1.,1.,1.]),然后再与 vector 相加。
这个例子极为简单,因此我们将展示一些不那么明显的情况。
多变量函数
当构建多个变量的函数时,会出现一个更为复杂的广播示例。例如,假设我们有两个单变量函数,
和
,我们希望根据以下公式构造一个新的函数,
:

这显然是一个有效的数学定义。我们希望将这个定义表示为两个变量的函数之和,定义为:
。
现在我们可以简单地写成:

这种情况类似于添加列矩阵和行矩阵时的情况:
C = arange(2).reshape(-1,1) # column
R = arange(2).reshape(1,-1) # row
C + R # valid addition: array([[0.,1.],[1.,2.]])
这在采样两个变量的函数时尤其有用,正如 5.5.3 节所示:典型示例。
通用机制
我们已经看到了如何将函数与标量相加,以及如何从两个单变量函数构建一个双变量函数。现在,让我们聚焦于使这一切成为可能的通用机制。这个通用机制包括两个步骤:重塑和扩展。
首先,函数
被重塑为函数
,它接受两个参数。其中一个参数是虚拟参数,我们约定将其视为零:

从数学角度看,
的定义域现在是
。然后,函数
以类似于下述方式重塑:

现在,
和
都接受两个参数,尽管其中一个始终是零。接下来我们进行扩展步骤。这与将常数转化为常数函数的步骤相同。
函数
被扩展为:

函数
被扩展为:

现在,两个变量的函数,
,它由
粗略定义,可以在不参考其参数的情况下定义:

例如,让我们描述之前的机制在常数情况下的表现。常数是标量,也就是零参数的函数。因此,重塑步骤是定义一个(一空)变量的函数:

现在,扩展步骤简单地进行如下:

约定
最后一个要素是关于如何将额外参数添加到函数中的约定,也就是如何自动执行重塑。根据约定,函数会通过在左边添加零来自动重塑。
例如,如果一个有两个参数的函数
需要重塑为三个参数,则新函数将由以下方式定义:

在看过广播的更数学化动机之后,我们现在展示它如何应用于 NumPy 数组。
5.5.2 广播数组
我们现在将重复观察,数组只是几个变量的函数(见第 4.2 节:数学基础)。因此,数组广播完全遵循上述为数学函数所解释的相同程序。广播在 NumPy 中是自动完成的。
在图 5.1 中,我们展示了将形状为(4, 3)的矩阵加到形状为(1, 3)的矩阵时发生了什么。结果矩阵的形状是(4, 3):

图 5.1:矩阵与向量之间的广播
广播问题
当 NumPy 给定两个形状不同的数组,并要求执行需要这两个形状相同的操作时,两个数组会广播到一个共同的形状。
假设我们想将一个形状为!的向量加到一个形状为!的矩阵中。该向量需要广播。第一个操作是重塑;将向量的形状从(3,)转换为(1, 3)。第二个操作是扩展;将形状从(1, 3)转换为(4, 3)。
-
会自动重塑为(1, n)。 -
被扩展到(m, n)。
为了演示这一点,我们考虑由以下定义的矩阵:
M = array([[11, 12, 13, 14],
[21, 22, 23, 24],
[31, 32, 33, 34]])
和一个向量给定:
v = array([100, 200, 300, 400])
现在我们可以直接将M和v相加:
M + v # works directly
结果是这个矩阵:

形状不匹配
不能自动将长度为n的向量v广播到形状(n, m)。这一点在图 5.2中有所说明。

图 5.2:由于形状不匹配导致广播失败
广播失败,因为形状(n,)可能无法自动广播到形状(m, n)。解决方案是手动将v重塑为形状(n, 1)。广播现在会像往常一样工作(仅通过扩展):
M + v.reshape(-1,1)
这通过以下示例来说明。
定义一个矩阵:
M = array([[11, 12, 13, 14],
[21, 22, 23, 24],
[31, 32, 33, 34]])
和一个向量:
v = array([100, 200, 300])
现在自动广播将失败,因为自动重塑不起作用:
M + v # shape mismatch error
因此,解决方案是手动处理重塑。我们想要在这种情况下右侧加 1,即将向量转换为列矩阵。然后,广播将直接起作用:
M + v.reshape(-1,1)
(关于形状参数-1,请参见第 4.6.3 节:重塑。)结果是这个矩阵:
array([[111, 112, 113, 114],
[221, 222, 223, 224],
[331, 332, 333, 334]])
5.5.3 典型示例
让我们看看一些典型示例,在这些示例中,广播可能会派上用场。
重标定行
假设M是一个
矩阵,我们希望将每行乘以一个系数。系数存储在长度为n的向量coeff中。在这种情况下,自动重塑将不起作用,我们必须执行:
rescaled = M*coeff.reshape(-1,1)
重标定列
这里的设置相同,但我们希望使用存储在长度为m的向量coeff中的系数来重新标定每一列。在这种情况下,自动重塑将起作用:
rescaled = M*coeff
显然,我们也可以手动进行重塑,并通过以下方式达到相同的结果:
rescaled = M*coeff.reshape(1,-1)
两个变量的函数
假设!和!是向量,我们希望形成矩阵!,其中的元素是!。这对应于函数!。矩阵!仅由以下方式定义:
W=u.reshape(-1,1) + v
array([[2, 3, 4],
[3, 4, 5]])
更一般地,假设我们希望采样函数!。假设向量!和!已经定义,采样值的矩阵!可以通过以下方式获得:
W = cos(x).reshape(-1,1) + sin(2*y)
请注意,这通常与ogrid结合使用。从ogrid获得的向量已经便于广播。这允许以下优雅的函数采样!:
x,y = ogrid[0:1:3j,0:1:3j]
# x,y are vectors with the contents of linspace(0,1,3)
w = cos(x) + sin(2*y)
ogrid的语法需要一些解释:首先,ogrid不是一个函数。它是一个类的实例,具有__getitem__方法(参见第 8.1.5 节:特殊方法)。因此,它是用方括号而不是圆括号来使用的。
这两个命令是等效的:
x,y = ogrid[0:1:3j, 0:1:3j]
x,y = ogrid.__getitem__((slice(0, 1, 3j),slice(0, 1, 3j)))
前面示例中的步长参数是一个复数。这表明它是步数而不是步长。步长参数的规则乍一看可能令人困惑:
-
如果步长是实数,则它定义了起始和结束之间的步伐大小,并且结束值不包括在列表中。
-
如果步幅是一个复数
s,那么s.imag的整数部分定义了从开始到结束的步数,并且结束值包括在列表中。
ogrid的输出是一个包含两个数组的元组,可以用于广播:
x,y = ogrid[0:1:3j, 0:1:3j]
这给出的是:
array([[ 0\. ],
[ 0.5],
[ 1\. ]])
array([[ 0\. , 0.5, 1\. ]])
这等同于:
x,y = ogrid[0:1.5:.5, 0:1.5:.5]
5.6 稀疏矩阵
非零元素较少的矩阵称为sparse matrices(稀疏矩阵)。稀疏矩阵通常出现在科学计算中,例如在数值求解偏微分方程时,用于描述离散的微分算子。
稀疏矩阵通常具有较大的维度,有时大到整个矩阵(包含零元素)甚至无法放入可用内存。这是为稀疏矩阵设计特殊数据类型的一个动机。另一个动机是能够避免零矩阵元素,从而提高操作的性能。
对于一般的非结构化稀疏矩阵,在线性代数中只有极少数的算法可用。大多数算法是迭代型的,基于稀疏矩阵的矩阵-向量乘法的高效实现。
稀疏矩阵的例子包括对角矩阵或带状矩阵。这些矩阵的简单模式使得存储策略变得直接;主对角线以及上下对角线存储在一维数组中。从稀疏表示转换为经典数组类型以及反向转换,可以通过 NumPy 命令diag完成。
一般来说,稀疏矩阵并没有如此简单的结构,因此描述稀疏矩阵需要特殊的技术和标准。
这种矩阵的例子如图 5.3 所示。在该图中,像素表示在 1250 × 1250 矩阵中的非零元素。
在本节中,我们介绍了两种稀疏矩阵的行和列导向类型,这两者都可以通过模块scipy.sparse使用。

图 5.3:来自弹性板有限元模型的刚度矩阵。
5.6.1 稀疏矩阵格式
模块scipy.sparse提供了多种不同的稀疏矩阵存储格式。在这里,我们只描述最重要的几种:CSR、CSC 和 LIL。LIL 格式应当用于生成和修改稀疏矩阵;CSR 和 CSC 是矩阵-矩阵和矩阵-向量运算的高效格式。
压缩稀疏行格式(CSR)
压缩稀疏行(CSR)格式使用三个数组:data、indptr和indices:
-
一维数组
data按顺序存储所有非零值。它的元素数量等于非零元素的数量,通常用变量nnz表示。 -
一维数组
indptr包含整数,indptr[i]是data中元素的索引,表示第i行的第一个非零元素。如果整行i都是零,那么indptr[i]==indptr[i+1]。如果原始矩阵有m行,那么len(indptr)==m+1。 -
1D 数组
indices包含列索引信息,使得indices[indptr[i]:indptr[i+1]]是一个整数数组,包含第 i 行非零元素的列索引。显然,len(indices)==len(data)==nnz。
让我们来看一个例子:
矩阵的 CSR 格式:

由以下三个数组给出:
data = array([1., 2., 3., 1., 4.])
indptr = array([0, 2, 2, 3, 5])
indices = array([0, 2, 0, 0, 3]
模块 scipy.sparse 提供了一种类型 csr_matrix,并提供了一个构造函数,可以通过以下方式使用:
-
使用二维数组作为参数
-
使用
scipy.sparse中的其他稀疏格式之一的矩阵 -
使用形状参数
(m,n)来生成 CSR 格式的零矩阵 -
通过一个 1D 数组
data和一个形状为(2,len(data))的整数数组ij,其中ij[0,k]是行索引,ij[1,k]是矩阵中data[k]的列索引 -
三个参数
data、indptr和indices可以直接传递给构造函数
前两个选项用于转换目的,而最后两个选项直接定义稀疏矩阵。
考虑上面的示例;在 Python 中,它看起来是这样的:
import scipy.sparse as sp
A = array([[1,0,2,0],[0,0,0,0],[3.,0.,0.,0.],[1.,0.,0.,4.]])
AS = sp.csr_matrix(A)
其中包括以下属性:
AS.data # returns array([ 1., 2., 3., 1., 4.])
AS.indptr # returns array([0, 2, 2, 3, 5])
AS.indices # returns array([0, 2, 0, 0, 3])
AS.nnz # returns 5
压缩稀疏列格式(CSC)
CSR 格式有一个列导向的对应物——压缩稀疏列(CSC)格式。与 CSR 格式相比,它唯一的不同是 indptr 和 indices 数组的定义,现在它们与列相关。CSC 格式的类型是 csc_matrix,它的使用方式与之前在本小节中解释的 csr_matrix 相同。
继续使用 CSC 格式的相同示例:
import scipy.sparse as sp
A = array([[1,0,2,0],[0,0,0,0],[3.,0.,0.,0.],[1.,0.,0.,4.]])
AS = sp.csc_matrix(A)
AS.data # returns array([ 1., 3., 1., 2., 4.])
AS.indptr # returns array([0, 3, 3, 4, 5])
AS.indices # returns array([0, 2, 3, 0, 3])
AS.nnz # returns 5
基于行的链表格式(LIL)
链表稀疏格式按行存储非零矩阵项,存储在一个列表 data 中,使得 data[k] 是第 k 行的非零项列表。如果该行的所有项均为 0,则该列表为空。
第二个列表 rows 在位置 k 处包含第 k 行中非零元素的列索引。以下是基于行的链表(LIL)格式的示例:
import scipy.sparse as sp
A = array([[1,0,2,0],[0,0,0,0], [3.,0.,0.,0.], [1.,0.,0.,4.]])
AS = sp.lil_matrix(A)
AS.data # returns array([[1.0, 2.0], [], [3.0], [1.0, 4.0]], dtype=object)
AS.rows # returns array([[0, 2], [], [0], [0, 3]], dtype=object)
AS.nnz # returns 5
修改和切片 LIL 格式的矩阵
LIL 格式最适合切片,即提取 LIL 格式的子矩阵,并通过插入非零元素来改变稀疏模式。切片操作通过以下示例演示:
BS = AS[1:3,0:2]
BS.data # returns array([[], [3.0]], dtype=object)
BS.rows # returns array([[], [0]], dtype=object)
插入新的非零元素会自动更新属性:
AS[0,1] = 17
AS.data # returns array([[1.0, 17.0, 2.0], [], [3.0], [1.0, 4.0]])
AS.rows # returns array([[0, 1, 2], [], [0], [0, 3]])
AS.nnz # returns 6
这些操作在其他稀疏矩阵格式中不建议使用,因为它们非常低效。
5.6.2 生成稀疏矩阵
NumPy 命令 eye、identity、diag 和 rand 都有它们的稀疏版本。它们需要一个额外的参数,指定结果矩阵的稀疏矩阵格式。
以下命令生成单位矩阵,但采用不同的稀疏矩阵格式:
import scipy.sparse as sp
sp.eye(20,20,format = 'lil')
sp.spdiags(ones((20,)),0,20,20, format = 'csr')
sp.identity(20,format ='csc')
命令 sp.rand 采用一个额外的参数,用于描述生成随机矩阵的密度。密集矩阵的密度为 1,而零矩阵的密度为 0:
import scipy.sparse as sp
AS=sp.rand(20,200,density=0.1,format='csr')
AS.nnz # returns 400
没有与 NumPy 命令zeroes直接对应的命令。完全填充零的矩阵可以通过实例化相应类型,并将形状参数作为构造参数来生成:
import scipy.sparse as sp
Z=sp.csr_matrix((20,200))
Z.nnz # returns 0
在研究了不同的稀疏矩阵格式之后,我们现在转向稀疏矩阵的特殊方法,主要是转换方法。
5.6.3 稀疏矩阵方法
有方法可以将一种稀疏类型转换为另一种类型或数组:
AS.toarray # converts sparse formats to a numpy array
AS.tocsr
AS.tocsc
AS.tolil
可以通过方法issparse、isspmatrix_lil、isspmatrix_csr和isspmatrix_csc来检查稀疏矩阵的类型。
稀疏矩阵上的逐元素操作+、*、/和**的定义与 NumPy 数组相同。无论操作数的稀疏矩阵格式如何,结果始终是csr_matrix。将逐元素操作函数应用于稀疏矩阵时,首先需要将其转换为 CSR 或 CSC 格式,并对其data属性应用函数,如下例所示。
稀疏矩阵的逐元素正弦可以通过对其data属性进行操作来定义:
import scipy.sparse as sp
def sparse_sin(A):
if not (sp.isspmatrix_csr(A) or sp.isspmatrix_csc(A)):
A = A.tocsr()
A.data = sin(A.data)
return A
对于矩阵-矩阵或矩阵-向量乘法,有一个稀疏矩阵方法dot。它返回一个csr_matrix或一个 1D 的 NumPyarray:
考虑以下函数,我们将在此展示如何向量化一个函数:
import scipy.sparse as sp
A = array([[1,0,2,0],[0,0,0,0],[3.,0.,0.,0.],[1.,0.,0.,4.]])
AS = sp.csr_matrix(A)
b = array([1,2,3,4])
c = AS.dot(b) # returns array([ 7., 0., 3., 17.])
C = AS.dot(AS) # returns csr_matrix
d = dot(AS,b) # does not return the expected result!
避免在稀疏矩阵上使用 NumPy 的dot命令,因为这可能会导致意外结果。应使用来自scipy.sparse的dot命令。
其他线性代数操作,如系统求解、最小二乘法、特征值和奇异值,由模块scipy.sparse.linalg提供。
5.7 小结
视图的概念是本章你应该学习的重要主题之一。缺少这一主题会使你在调试代码时遇到困难。布尔数组在本书的多个地方出现。它们是避免冗长的if语句和循环处理数组时的简洁工具。在几乎所有大型计算项目中,稀疏矩阵都会成为一个问题。你已经看到如何处理它们以及可用的相关方法。
绘图
在 Python 中,绘图可以通过 matplotlib 模块的 pyplot 部分来完成。使用 matplotlib,你可以创建高质量的图形和图表,并可视化你的结果。Matplotlib 是开源的、免费的软件。Matplotlib 网站还包含了优秀的文档和示例,详见 35。在本节中,我们将展示如何使用最常见的功能。接下来的示例假设你已经导入了该模块:
from matplotlib.pyplot import *
如果你希望在 IPython 中使用绘图命令,建议在启动 IPython shell 后立即运行 magic command %matplotlib。这将为 IPython 准备好交互式绘图功能。
第七章:6.1 使用基本绘图命令绘制图形
本节中,我们将通过基本命令创建图形。这是学习如何使用 Python 绘制数学对象和数据图形的入门点。
6.1.1 使用 plot 命令及其一些变体
标准的绘图函数是 plot。调用 plot(x,y) 会创建一个图形窗口,并绘制出
作为
的函数图像。输入参数是等长的数组(或列表)。也可以使用 plot(y),在这种情况下,
中的值将会根据其索引绘制,也就是说,plot(y) 是 plot(range(len(y)),y) 的简写。
下面是一个示例,展示如何使用 200 个样本点和每隔四个点设置标记来绘制
:
# plot sin(x) for some interval
x = linspace(-2*pi,2*pi,200)
plot(x,sin(x))
# plot marker for every 4th point
samples = x[::4]
plot(samples,sin(samples),'r*')
# add title and grid lines
title('Function sin(x) and some points plotted')
grid()
结果显示在下图中(图 6.1):

图 6.1:绘制函数 sin(x) 的图像,并显示网格线
如你所见,标准的绘图是一条实心的蓝色曲线。每个坐标轴都会自动缩放以适应数值,但也可以手动设置。颜色和绘图选项可以在前两个输入参数后提供。在这里,r* 表示红色星形标记。格式设置将在下一节中详细讨论。命令 title 在绘图区域上方添加标题文本字符串。
多次调用 plot 命令将会在同一窗口中叠加图形。若要获得一个新的干净的图形窗口,可以使用 figure()。命令 figure 可能包含一个整数,例如,figure(2),用于在图形窗口之间切换。如果没有该编号的图形窗口,将会创建一个新的窗口,否则该窗口将被激活进行绘制,所有后续的绘图命令都将应用于该窗口。
可以通过使用 legend 函数并为每个绘图调用添加标签来解释多个图形。以下示例使用 polyfit 和 polyval 命令拟合多项式并绘制结果,同时添加图例:
# —Polyfit example—
x = range(5)
y = [1,2,1,3,5]
p2 = polyfit(x,y,2) # coefficients of degree 2 polynomial
p4 = polyfit(x,y,4) # coefficients of degree 4 polynomial
# plot the two polynomials and points
xx = linspace(-1,5,200)
plot(xx, polyval(p2, xx), label='fitting polynomial of degree 2')
plot(xx, polyval(p4, xx),
label='interpolating polynomial of degree 4')
plot(x,y,'*')
# set the axis and legend
axis([-1,5,0,6])
legend(loc='upper left', fontsize='small')
在这里,你还可以看到如何使用axis([xmin,xmax,ymin,ymax])手动设置坐标轴的范围。命令legend接受关于位置和格式的可选参数;在这种情况下,图例被放置在左上角,并以小字体显示,如下图所示(图 6.2):

图 6.2:两个多项式拟合同一点集
作为基础绘图的最终示例,我们演示了如何绘制散点图和二维对数图。
这是一个二维点散点图的示例:
# create random 2D points
import numpy
x1 = 2*numpy.random.standard_normal((2,100))
x2 = 0.8*numpy.random.standard_normal((2,100)) + array([[6],[2]])
plot(x1[0],x1[1],'*')
plot(x2[0],x2[1],'r*')
title('2D scatter plot')

图 6.3(a):一个散点图示例
以下代码是使用loglog进行对数绘图的示例:
# log both x and y axis
x = linspace(0,10,200)
loglog(x,2*x**2, label = 'quadratic polynomial',
linestyle = '-', linewidth = 3)
loglog(x,4*x**4, label = '4th degree polynomial',
linestyle = '-.', linewidth = 3)
loglog(x,5*exp(x), label = 'exponential function', linewidth = 3)
title('Logarithmic plots')
legend(loc = 'best')

图 6.3(b):一个带有对数 x 和 y 坐标轴的图形示例
前面图中展示的示例(图 6.3(a) 和 图 6.3(b)) 使用了plot和loglog的一些参数,这些参数允许特殊的格式化。在下一节中,我们将更详细地解释这些参数。
6.1.2 格式设置
图形和绘图的外观可以通过样式和定制设置为你想要的效果。一些重要的变量包括linewidth(控制绘图线条的粗细),xlabel和ylabel(设置坐标轴标签),color(设置绘图颜色),以及transparent(设置透明度)。
本节将告诉你如何使用其中的一些变量。以下是一个包含更多关键词的示例:
k = 0.2
x = [sin(2*n*k) for n in range(20)]
plot(x, color='green', linestyle='dashed', marker='o',
markerfacecolor='blue', markersize=12, linewidth=6)
如果只需要进行基本样式修改,例如设置颜色和线型,可以使用简短的命令。下表(表 6.1)展示了这些格式化命令的一些示例。你可以使用简洁的字符串语法plot(...,'ro-'),或者使用更明确的语法plot(..., marker='o', color='r', linestyle='-')。

表 6.1:一些常见的绘图格式化参数
要将颜色设置为绿色并使用标记'o',我们写下如下代码:
plot(x,'go')
要绘制直方图而非常规图形,使用命令hist:
# random vector with normal distribution
sigma, mu = 2, 10
x = sigma*numpy.random.standard_normal(10000)+mu
hist(x,50,density=True)
z = linspace(0,20,200)
plot(z, (1/sqrt(2*pi*sigma**2))*exp(-(z-mu)**2/(2*sigma**2)),'g')
# title with LaTeX formatting
title(fr'Histogram with $\mu={mu}, \sigma={sigma}')

图 6.4:具有 50 个区间的正态分布图,绿色曲线表示真实分布
结果图形看起来与图 6.4类似。标题以及其他任何文本都可以使用 LaTeX 格式化来显示数学公式。LaTeX 格式化被包含在一对$符号之间。另请注意,使用format方法进行的字符串格式化;参见第 2.4.3 节,字符串格式化。
有时,字符串格式化的括号会与 LaTeX 的括号环境发生冲突。如果发生这种情况,可以用双括号替换 LaTeX 括号;例如,x_{1}应该替换为x_{{1}}。文本中可能包含与字符串转义序列重叠的序列,例如,\tau会被解释为制表符字符\t。一种简单的解决方法是在字符串前添加r,例如,r'\tau'。这会将其转换为原始字符串。
将多个图放置在一个图形窗口中,可以使用命令subplot。考虑以下示例,该示例逐步平均掉正弦曲线上的噪声。
def avg(x):
""" simple running average """
return (roll(x,1) + x + roll(x,-1)) / 3
# sine function with noise
x = linspace(-2*pi, 2*pi,200)
y = sin(x) + 0.4*numpy.random.standard_normal(200)
# make successive subplots
for iteration in range(3):
subplot(3, 1, iteration + 1)
plot(x,y, label = '{:d} average{}'.format(iteration, 's' if iteration > 1 else ''))
yticks([])
legend(loc = 'lower left', frameon = False)
y = avg(y) #apply running average
subplots_adjust(hspace = 0.7)

图 6.5:在同一图形窗口中绘制多个子图的示例
函数avg使用 NumPy 的roll函数来平移数组中的所有值。subplot需要三个参数:竖直子图的数量,水平子图的数量,以及表示绘制位置的索引(按行计数)。请注意,我们使用了命令subplots_adjust来添加额外的空间,以调整子图之间的距离。
一个有用的命令是savefig,它允许你将图形保存为图像(也可以通过图形窗口完成)。此命令支持多种图像和文件格式,它们通过文件名的扩展名来指定:
savefig('test.pdf') # save to pdf
或者
savefig('test.svg') # save to svg (editable format)
你可以将图像放置在非白色背景上,例如网页。为此,可以设置参数transparent,使得图形的背景透明:
savefig('test.pdf', transparent=True)
如果你打算将图形嵌入到 LaTeX 文档中,建议通过设置图形的边界框为紧密围绕绘图的方式来减少周围的空白,如下所示:
savefig('test.pdf', bbox_inches='tight')
6.1.3 使用 meshgrid 和等高线
一个常见的任务是对矩形区域内的标量函数进行图形化表示:

为此,我们首先需要在矩形上生成一个网格!。这是通过命令meshgrid来实现的:
n = ... # number of discretization points along the x-axis
m = ... # number of discretization points along the x-axis
X,Y = meshgrid(linspace(a,b,n), linspace(c,d,m))
X和Y是形状为(n,m)的数组,其中X[i,j]和Y[i,j]包含网格点的坐标![],如图 6.6所示:

图 6.6:一个由 meshgrid 离散化的矩形。
一个由meshgrid离散化的矩形将在下一节中用于可视化迭代过程,而我们将在这里用它绘制函数的等高线。这是通过命令contour来完成的。
作为示例,我们选择了罗森布鲁克香蕉函数:

它用于挑战优化方法,见[27]。函数值向着一个香蕉形的山谷下降,该山谷本身慢慢下降,最终到达函数的全局最小值(1, 1)。
首先,我们使用contour显示等高线:
rosenbrockfunction = lambda x,y: (1-x)**2+100*(y-x**2)**2
X,Y = meshgrid(linspace(-.5,2.,100), linspace(-1.5,4.,100))
Z = rosenbrockfunction(X,Y)
contour(X,Y,Z,logspace(-0.5,3.5,20,base=10),cmap='gray')
title('Rosenbrock Function: ')
xlabel('x')
ylabel('y')
这将绘制由第四个参数给定的级别的等高线,并使用颜色映射gray。此外,我们使用了从 10^(0.5)到 10³的对数间隔步长,使用函数logscale来定义级别,见图 6.7。

图 6.7:罗森布鲁克函数的等高线图
在前面的示例中,使用了lambda关键字表示的匿名函数来保持代码简洁。匿名函数的解释见第 7.7 节,匿名函数。如果未将级别作为参数传递给contour,该函数会自动选择合适的级别。
contourf函数执行与contour相同的任务,但根据不同的级别填充颜色。等高线图非常适合可视化数值方法的行为。我们通过展示优化方法的迭代过程来说明这一点。
我们继续前面的示例,并描绘了通过 Powell 方法生成的罗森布鲁克函数最小值的步骤,我们将应用该方法来找到罗森布鲁克函数的最小值:
import scipy.optimize as so
rosenbrockfunction = lambda x,y: (1-x)**2+100*(y-x**2)**2
X,Y=meshgrid(linspace(-.5,2.,100),linspace(-1.5,4.,100))
Z=rosenbrockfunction(X,Y)
cs=contour(X,Y,Z,logspace(0,3.5,7,base=10),cmap='gray')
rosen=lambda x: rosenbrockfunction(x[0],x[1])
solution, iterates = so.fmin_powell(rosen,x0=array([0,-0.7]),retall=True)
x,y=zip(*iterates)
plot(x,y,'ko') # plot black bullets
plot(x,y,'k:',linewidth=1) # plot black dotted lines
title("Steps of Powell's method to compute a minimum")
clabel(cs)
迭代方法fmin_powell应用 Powell 方法找到最小值。通过给定的起始值![]启动,并在选项retall=True时报告所有迭代结果。经过 16 次迭代后,找到了解决方案
。迭代过程在等高线图中以子弹点表示;见图 6.8。

图 6.8:罗森布鲁克函数的等高线图,展示了优化方法的搜索路径
contour函数还创建了一个轮廓集对象,我们将其赋值给变量cs。然后,clabel用来标注相应函数值的级别,如图 6.8所示。
6.1.4 生成图像和轮廓
让我们看一些将数组可视化为图像的示例。以下函数将为曼德尔布罗特分形创建一个颜色值矩阵,另见[20]。这里,我们考虑一个依赖于复数参数的固定点迭代,
:

根据此参数的选择,它可能会或可能不会创建一个有界的复数值序列,
。
对于每个
的值,我们检查![]是否超过了预定的界限。如果在maxit次迭代内仍然低于该界限,则认为序列是有界的。
请注意,在以下代码片段中,meshgrid用于生成一个复数参数值矩阵,
:
def mandelbrot(h,w, maxit=20):
X,Y = meshgrid(linspace(-2, 0.8, w), linspace(-1.4, 1.4, h))
c = X + Y*1j
z = c
exceeds = zeros(z.shape, dtype=bool)
for iteration in range(maxit):
z = z**2 + c
exceeded = abs(z) > 4
exceeds_now = exceeded & (logical_not(exceeds))
exceeds[exceeds_now] = True
z[exceeded] = 2 # limit the values to avoid overflow
return exceeds
imshow(mandelbrot(400,400),cmap='gray')
axis('off')
命令 imshow 将矩阵显示为图像。选定的颜色图显示了序列在白色区域的无界部分,其他部分为黑色。在这里,我们使用了 axis('off') 来关闭坐标轴,因为这对于图像来说可能不太有用。

图 6.9:使用 imshow 将矩阵可视化为图像的示例
默认情况下,imshow 使用插值来使图像看起来更漂亮。当矩阵较小时,这一点尤为明显。下图显示了使用以下方法的区别:
imshow(mandelbrot(40,40),cmap='gray')
和
imshow(mandelbrot(40,40), interpolation='nearest', cmap='gray')
在第二个示例中,像素值只是被复制了,见 [30]。

图 6.10:使用 imshow 的线性插值与使用最近邻插值的区别
有关使用 Python 处理和绘制图像的更多详细信息。
在了解了如何以“命令方式”制作图表之后,我们将在接下来的部分中考虑一种更面向对象的方法。虽然稍微复杂一些,但它打开了广泛的应用范围。
6.2 直接使用 Matplotlib 对象
到目前为止,我们一直在使用 matplotlib 的 pyplot 模块。这个模块使我们可以直接使用最重要的绘图命令。通常,我们感兴趣的是创建一个图形并立即显示它。然而,有时我们希望生成一个图形,稍后可以通过更改某些属性来修改它。这要求我们以面向对象的方式与图形对象进行交互。在这一节中,我们将介绍修改图形的一些基本步骤。要了解 Python 中更复杂的面向对象绘图方法,您需要离开 pyplot,直接进入 matplotlib,并参考其广泛的文档。
6.2.1 创建坐标轴对象
当创建一个需要稍后修改的图表时,我们需要引用一个图形和一个坐标轴对象。为此,我们必须先创建一个图形,然后定义一些坐标轴及其在图形中的位置,并且我们不能忘记将这些对象分配给一个变量:
fig = figure()
ax = subplot(111)
一幅图可以有多个坐标轴对象,具体取决于是否使用了 subplot。在第二步中,图表与给定的坐标轴对象相关联:
fig = figure(1)
ax = subplot(111)
x = linspace(0,2*pi,100) # We set up a function that modulates the
# amplitude of the sin function
amod_sin = lambda x: (1.-0.1*sin(25*x))*sin(x)
# and plot both...
ax.plot(x,sin(x),label = 'sin')
ax.plot(x, amod_sin(x), label = 'modsin')
在这里,我们使用了由关键字 lambda 表示的匿名函数。我们将在 第 7.7 节 中解释这个构造,匿名函数。实际上,这两个绘图命令将列表 ax.lines 填充了两个 Lines2D 对象:
ax.lines #[<matplotlib.lines.Line2D at ...>, <matplotlib.lines.Line2D at ...>]
使用标签是一个好习惯,这样我们可以稍后轻松地识别对象:
for il,line in enumerate(ax.lines):
if line.get_label() == 'sin':
break
我们现在已经将设置完成,以便进行进一步的修改。到目前为止我们得到的图如 图 6.11(左图) 所示。
6.2.2 修改线条属性
我们刚刚通过标签标识了一个特定的线条对象。它是列表 ax.lines 中的一个元素,索引为 il。它的所有属性都被收集在一个字典中:
dict_keys(['marker', 'markeredgewidth', 'data', 'clip_box', 'solid_capstyle', 'clip_on', 'rasterized', 'dash_capstyle', 'path', 'ydata', 'markeredgecolor', 'xdata', 'label', 'alpha', 'linestyle', 'antialiased', 'snap', 'transform', 'url', 'transformed_clip_path_and_affine', 'clip_path', 'path_effects', 'animated', 'contains', 'fillstyle', 'sketch_params', 'xydata', 'drawstyle', 'markersize', 'linewidth', 'figure', 'markerfacecolor', 'pickradius', 'agg_filter', 'dash_joinstyle', 'color', 'solid_joinstyle', 'picker', 'markevery', 'axes', 'children', 'gid', 'zorder', 'visible', 'markerfacecoloralt'])
这可以通过以下命令获得:
ax.lines[il].properties()
它们可以通过相应的 setter 方法进行修改。现在我们来改变正弦曲线的线条样式:
ax.lines[il].set_linestyle('-.')
ax.lines[il].set_linewidth(2)
我们甚至可以修改数据,如下所示:
ydata=ax.lines[il].get_ydata()
ydata[-1]=-0.5
ax.lines[il].set_ydata(ydata)
结果如图 6.11,(右)所示:

图 6.11:幅度调制正弦函数(左)和数据点被破坏的曲线(右)
6.2.3 制作注释
一个有用的坐标轴方法是annotate。它可以在给定位置设置注释,并用箭头指向图中的另一个位置。箭头可以通过字典来指定属性:
annot1=ax.annotate('amplitude modulated\n curve', (2.1,1.0),(3.2,0.5),
arrowprops={'width':2,'color':'k',
'connectionstyle':'arc3,rad=+0.5',
'shrink':0.05},
verticalalignment='bottom', horizontalalignment='left',fontsize=15,
bbox={'facecolor':'gray', 'alpha':0.1, 'pad':10})
annot2=ax.annotate('corrupted data', (6.3,-0.5),(6.1,-1.1),
arrowprops={'width':0.5,'color':'k','shrink':0.1},
horizontalalignment='center', fontsize=12)
在上面的第一个注释示例中,箭头指向坐标为(2.1, 1.0)的点,文本的左下坐标为(3.2, 0.5)。如果没有特别指定,坐标是以方便的数据坐标系给出的,即用于生成图形的数据坐标系。
此外,我们展示了通过字典arrowprop指定的几个箭头属性。你可以通过键shrink来缩放箭头。设置'shrink':0.05会将箭头大小减少 5%,以保持与其指向的曲线之间的距离。你还可以使用键connectionstyle让箭头呈现为样条曲线或其他形状。
文本属性,甚至是围绕文本的边框框,可以通过额外的关键字参数传递给annotate方法,见图 6.12,(左)。
尝试注释有时需要多次尝试,我们需要丢弃其中的一些。因此,我们将注释对象赋值给一个变量,这样就可以通过其remove方法移除注释:
annot1.remove()
6.2.4 填充曲线之间的区域
填充是一个理想的工具,用于突出曲线之间的差异,例如预期数据上的噪声和近似函数与精确函数之间的差异。
填充是通过坐标轴方法fill_between完成的:
ax.fill_between(x,y1,y2)
对于下一个图,我们使用了以下命令:
axf = ax.fill_between(x, sin(x), amod_sin(x), facecolor='gray')
在上一章中,我们已经了解了 NumPy 方法where。在这里的上下文中,where是一个非常方便的参数,需要一个布尔数组来指定额外的填充条件:
axf = ax.fill_between(x, sin(x), amod_sin(x),where=amod_sin(x)-sin(x) > 0, facecolor=’gray’)
选择要填充的区域的布尔数组由条件amod_sin(x)-sin(x) > 0给出。
下一个图显示了带有两种填充区域变体的曲线:

图 6.12:带有注释和填充区域的幅度调制正弦函数(左),以及仅通过使用where参数部分填充区域的修改图(右)
如果你自己测试这些命令,记得在尝试部分填充之前移除完整填充,否则你将看不到任何变化:
axf.remove()
相关的填充命令是fill用于填充多边形,fill_betweenx用于填充水平方向的区域。
6.2.5 定义刻度和刻度标签
在演讲、海报和出版物中的图形,如果没有过多不必要的信息,看起来会更美观。你希望引导观众关注那些包含信息的部分。在我们的例子中,我们通过去除x轴和y轴的刻度,并引入与问题相关的刻度标签来清理图像:

图 6.13:完成的振幅调制正弦函数示例,带有注释和填充区域,以及修改过的刻度和刻度标签
图 6.13中的刻度线是通过以下命令设置的。注意使用 LaTeX 方式设置带有希腊字母的标签:
ax.set_xticks(array([0,pi/2,pi,3/2*pi,2*pi]))
ax.set_xticklabels(('$0$','$\pi/2$','$\pi$','$3/2 \pi$','$2 \pi$'),fontsize=18)
ax.set_yticks(array([-1.,0.,1]))
ax.set_yticklabels(('$-1$','$0$','$1$'),fontsize=18)
请注意,我们在字符串中使用了 LaTeX 格式来表示希腊字母、正确设置公式并使用 LaTeX 字体。增加字体大小也是一个好习惯,这样生成的图形可以缩小到文本文档中而不影响坐标轴的可读性。本指导示例的最终结果如图 6.13所示。
6.2.6 设置脊柱使你的图更具启发性——一个综合示例
脊柱是显示坐标的带有刻度和标签的线条。如果不采取特别措施,Matplotlib 会将它们放置为四条线——底部、右侧、顶部和左侧,形成由坐标轴参数定义的框架。
通常,图像在没有框架时看起来更好,而且脊柱有时可以放置在更具教学意义的位置。在这一节中,我们展示了改变脊柱位置的不同方法。
让我们从一个指导示例开始,见图 6.14。

图 6.14:一个 Matplotlib 图,具有非自动的脊柱位置设置
在这个例子中,我们选择只显示四个脊柱中的两个。
我们通过使用set_visible方法取消选择了顶部和右侧的脊柱,并通过使用set_position方法将左侧和底部的脊柱放置在数据坐标中:
fig = figure(1)
ax = fig.add_axes((0.,0.,1,1))
ax.spines["left"].set_position(('data',0.))
ax.spines["bottom"].set_position(("data",0.))
ax.spines["top"].set_visible(False)
ax.spines["right"].set_visible(False)
x=linspace(-2*pi,2*pi,200)
ax.plot(x,arctan(10*x), label=r'$\arctan(10 x)$')
ax.legend()
脊柱携带刻度和刻度标签。通常,它们是自动设置的,但手动设置它们往往更有优势。
在以下示例中,我们甚至利用了两组刻度线的可能性,并且设置了不同的放置参数。Matplotlib 将这两组刻度线分别称为'minor'和'major'。其中一组用于水平对齐y轴左侧的刻度标签:
ax.set_xticks([-2*pi,-pi,pi,2*pi])
ax.set_xticklabels([r"$-2\pi$",r"$-\pi$",r"$\pi$", r"$2\pi$"])
ax.set_yticks([pi/4,pi/2], minor=True)
ax.set_yticklabels([r"$\pi/4$", r"$\pi/2$"], minor=True)
ax.set_yticks([-pi/4,-pi/2], minor=False,)
ax.set_yticklabels([r"$-\pi/4$", r"$-\pi/2$"], minor=False) # major label set
ax.tick_params(axis='both', which='major', labelsize=12)
ax.tick_params(axis='y', which='major',pad=-35) # move labels to the right
ax.tick_params(axis='both', which='minor', labelsize=12)
结果如图 6.15 所示。

图 6.15:改变刻度和标签的位置
这个例子可以通过添加更多的轴和注释来进一步展开。我们参考了练习 7和图 6.20。
到目前为止,我们考虑的是二维图。接下来,我们将在下一节讨论三维数学对象的可视化。
6.3 制作三维图
有一些有用的matplotlib工具包和模块,可以用于各种特殊目的。在这一节中,我们描述了一种生成三维图的方法。
工具包 mplot3d 提供了三维点、线、等高线、表面及其他基本组件的绘制功能,还支持三维旋转和缩放。通过向坐标轴对象添加关键字 projection='3d' 可以生成三维图,如下示例所示:
from mpl_toolkits.mplot3d import axes3d
fig = figure()
ax = fig.gca(projection='3d')
# plot points in 3D
class1 = 0.6 * random.standard_normal((200,3))
ax.plot(class1[:,0],class1[:,1],class1[:,2],'o')
class2 = 1.2 * random.standard_normal((200,3)) + array([5,4,0])
ax.plot(class2[:,0],class2[:,1],class2[:,2],'o')
class3 = 0.3 * random.standard_normal((200,3)) + array([0,3,2])
ax.plot(class3[:,0],class3[:,1],class3[:,2],'o')
如你所见,你需要从 mplot3d 导入 axes3D 类型。生成的图展示了散点三维数据,这可以在 图 6.16 中看到。

图 6.16:使用 mplot3d 工具包绘制三维数据
绘制表面同样简单。以下示例使用内建函数 get_test_data 创建样本数据,用于绘制表面。考虑以下具有透明度的表面图示例:
X,Y,Z = axes3d.get_test_data(0.05)
fig = figure()
ax = fig.gca(projection='3d')
# surface plot with transparency 0.5
ax.plot_surface(X,Y,Z,alpha=0.5)
alpha 值设置透明度。表面图如图 6.17所示。

图 6.17:绘制表面网格的示例
你还可以在任意坐标投影中绘制等高线,如以下示例所示:
fig = figure()
ax = fig.gca(projection = '3d')
ax.plot_wireframe(X,Y,Z,rstride = 5,cstride = 5)
# plot contour projection on each axis plane
ax.contour(X,Y,Z, zdir='z',offset = -100)
ax.contour(X,Y,Z, zdir='x',offset = -40)
ax.contour(X,Y,Z, zdir='y',offset = 40)
# set axis limits
ax.set_xlim3d(-40,40)
ax.set_ylim3d(-40,40)
ax.set_zlim3d(-100,100)
# set labels
ax.set_xlabel('X axis')
ax.set_ylabel('Y axis')
ax.set_zlabel('Z axis')
注意设置坐标轴范围的命令。设置坐标轴范围的标准 matplotlib 命令是 axis([-40, 40, -40, 40])。该命令适用于二维图。然而,axis([-40,40,-40,40,-40,40]) 则无效。对于三维图,你需要使用面向对象的命令版本 ax.set_xlim3d(-40,40)。同样,设置坐标轴标签时也有类似的命令。对于二维图,你可以使用 xlabel('X axis') 和 ylabel('Y axis'),但没有 zlabel 命令。对于三维图,你需要使用 ax.set_xlabel('X axis') 和类似的命令设置其他标签,如前面的示例所示。
这段代码生成的图形如下:

图 6.18:带有额外等高线图的三维图,在三个坐标投影中展示
有许多选项可供设置图形的外观,包括颜色和表面透明度。mplot3d 文档网站 [23] 中有详细信息。
数学对象有时通过一系列图片甚至电影来动态可视化效果更佳。这是下一节的主题。
6.4 从图形生成电影
如果你有演变的数据,可能希望将其保存为电影,并在图形窗口中显示,类似于命令 savefig。一种方法是使用模块 visvis,请参阅 [37]。
这里是一个使用隐式表示演变圆形的简单示例。令圆形由一个函数的零水平表示,
一个函数
的零水平。
另外,考虑到盘面
在
的零集内。如果
的值以速率
递减,则圆圈将以速率
向外移动。
这可以实现为:
import visvis.vvmovie as vv
# create initial function values
x = linspace(-255,255,511)
X,Y = meshgrid(x,x)
f = sqrt(X*X+Y*Y) - 40 #radius 40
# evolve and store in a list
imlist = []
for iteration in range(200):
imlist.append((f>0)*255)
f -= 1 # move outwards one pixel
vv.images2swf.writeSwf('circle_evolution.swf',imlist)
结果是一个黑色圆圈逐渐扩大的动画(*.swf 文件),如 图 6.19 所示。

图 6.19:演变圆圈的示例
在这个示例中,使用了一组数组来创建动画。模块 visvis 也可以保存 GIF 动画,并且在某些平台上,可以生成 AVI 动画(*.gif 和 *.avi 文件)。此外,还可以直接从图形窗口捕捉电影帧。然而,这些选项要求系统安装更多的包(例如,PyOpenGL 和 PIL,即 Python 图像库)。有关更多细节,请参阅 visvis 官方网页上的文档。
另一种选择是使用 savefig 创建图像,为每一帧生成一张图像。以下代码示例创建了一系列 200 张图片文件,这些文件可以合并成一个视频:
# create initial function values
x = linspace(-255,255,511)
X,Y = meshgrid(x,x)
f = sqrt(X*X+Y*Y) - 40 #radius 40
for iteration in range(200):
imshow((f>0)*255, aspect='auto')
gray()
axis('off')
savefig('circle_evolution_{:d}.png'.format(iteration))
f -= 1
这些图像可以使用标准视频编辑软件进行合并,例如 Mencoder 或 ImageMagick。该方法的优点是你可以通过保存高分辨率图像来制作高分辨率视频。
6.5 小结
图形表示是展示数学结果或算法行为最紧凑的形式。本章为你提供了绘图的基本工具,并介绍了一种更精细的面向对象的图形对象工作方式,例如图形、坐标轴和线条。
在本章中,你学习了如何绘制图形,不仅是经典的 x/y 图,还有 3D 图和直方图。我们还为你提供了制作影片的前菜。你还看到了如何修改图形,视其为图形对象,并使用相关的方法和属性进行设置、删除或修改。
6.6 练习
示例 1: 编写一个函数,给定椭圆的中心坐标 (x,y),半轴 a 和 b 以及旋转角度,绘制椭圆
。
示例 2: 编写一个简短的程序,接受一个二维数组,例如前面的曼德尔布罗特轮廓图,并迭代地将每个值替换为其邻居的平均值。在图形窗口中更新数组的轮廓图,以动画形式展示轮廓的演变。解释其行为。
示例 3: 考虑一个
矩阵或整数值图像。映射

是一个点阵网格映射到自身的示例。这个方法有一个有趣的特性,它通过剪切然后使用模函数mod将超出图像的部分移回图像内,进而使图像随机化,最终恢复到原始图像。依照以下顺序实施,
,
作为示例图像,你可以使用经典的 512×512 Lena 测试图像,该图像来自scipy.misc:
from scipy.misc import lena
I = lena()
结果应该如下所示:
![]() |
![]() |
… | ![]() |
… | ![]() |
… | ![]() |
![]() |
|---|---|---|---|---|---|---|---|---|
| 0 | 1 | 128 | 256 | 511 | 512 |
计算x和y映射,并使用数组索引(参见第 5.3 节:数组索引)来复制像素值。
Ex. 4: 读取并绘制图像。SciPy 提供了imread函数(位于scipy.misc模块中)来读取图像(参见第 14.6 节: 读取和写入图像)。编写一个简短的程序,从文件中读取图像,并在原始图像上叠加给定灰度值的图像轮廓。你可以通过像这样平均颜色通道来获得图像的灰度版本:mean(im,axis=2)
Ex. 5: 图像边缘。二维拉普拉斯算子的零交叉是图像边缘的一个很好指示。修改前一个练习中的程序,使用scipy.ndimage模块中的gaussian_laplace或laplace函数来计算二维拉普拉斯算子,并将边缘叠加到图像上。
Ex. 6: 通过使用orgid代替meshgrid,重新编写曼德博集合分形示例第 6.1.4 节:生成图像和轮廓。参见第 5.5.3 节对ogrid的解释,典型示例。orgid、mgrid和meshgrid之间有什么区别?
Ex. 7: 在图6.20 中,研究了使用反正切函数来近似跳跃函数(Heaviside 函数)。该曲线的一部分被放大以可视化近似的质量。通过你自己的代码重现这张图。

图 6.20:使用反正切函数近似 Heaviside 函数(跳跃函数)
函数
本章介绍了函数,这是编程中的一个基本构建块。我们展示了如何定义函数、如何处理输入和输出、如何正确使用它们以及如何将它们视为对象。
本章将涉及以下主题:
-
数学中的函数与 Python 中的函数
-
参数和参数值
-
返回值
-
递归函数
-
函数文档
-
函数是对象
-
匿名函数 – 关键字
lambda -
函数作为装饰器
第八章:7.1 数学中的函数与 Python 中的函数
在数学中,函数表示为一个映射,它唯一地将域!中的每个元素!与范围!中的对应元素!相联系。
这里,!被称为函数的名称,而!是其应用于!时的值。这里,!有时被称为!的参数。在考虑 Python 中的函数之前,让我们先看一个示例。
在数学中,函数可以接受数字、向量、矩阵,甚至其他函数作为参数。下面是一个带有混合参数的函数示例:
在这种情况下,返回的是一个实数。在处理函数时,我们需要区分两个不同的步骤:
第一步只需要执行一次,而第二步可以针对不同的参数执行多次。
编程语言中的函数遵循类似的概念,并将其应用于各种类型的输入参数,例如字符串、列表、浮动数或任何对象。我们通过再次考虑给定的示例来演示函数的定义:
def subtract(x1, x2):
return x1 - x2
关键字def表示我们将定义一个函数。subtract是函数的名称,x1和x2是它的参数。冒号表示我们正在使用一个代码块。函数返回的值跟在关键字return后面。
现在,我们可以评估这个函数。该函数在其参数被输入参数替代后被调用:
r = subtract(5.0, 4.3)
结果0.7被计算并赋值给变量r。
7.2 参数和参数值
在定义函数时,其输入变量称为函数的参数。在执行函数时使用的输入称为其参数值。
7.2.1 传递参数——通过位置和关键字
我们将再次考虑之前的例子,其中函数有两个参数,分别是x1和x2。
它们的名字用于区分这两个数,这两个数在此情况下不能互换,否则会改变结果。第一个参数定义了从中减去第二个参数的数字。当subtract函数被调用时,每个参数都被替换为一个参数值。参数的顺序很重要;参数可以是任何对象。例如,我们可以调用如下:
z = 3
e = subtract(5,z)
除了这种标准的调用函数方式,即通过位置传递参数,有时使用关键字传递参数可能会更方便。参数的名称就是关键字;考虑以下示例:
z = 3
e = subtract(x2 = z, x1 = 5)
在这里,参数是通过名称分配给参数的,而不是通过调用中的位置。两种调用函数的方式可以结合使用,使得位置参数排在前面,关键字参数排在后面。我们通过使用函数plot来演示这种方式,plot函数在第 6.1 节:基本绘图中有描述:
plot(xp, yp, linewidth = 2, label = 'y-values')
7.2.2 改变参数值
参数的目的是为函数提供必要的输入数据。在函数内部改变参数的值通常不会影响函数外部的值:
def subtract(x1, x2):
z = x1 - x2
x2 = 50.
return z
a = 20.
b = subtract(10, a) # returns -10
a # still has the value 20
这适用于所有不可变参数,如字符串、数字和元组。如果更改的是可变参数,如列表或字典,情况就不同了。
例如,将可变输入参数传递给函数,并在函数内更改它们,可能会改变函数外的值:
def subtract(x):
z = x[0] - x[1]
x[1] = 50.
return z
a = [10,20]
b = subtract(a) # returns -10
a # is now [10, 50.0]
这样的函数错误地使用其参数来返回结果。我们强烈劝阻使用这种构造,并建议你在函数内部不要更改输入参数(有关更多信息,请参见第 7.2.4 节:默认参数)。
7.2.3 访问定义在局部命名空间外的变量
Python 允许函数访问其任何封闭程序单元中定义的变量。这些变量称为全局变量,与局部变量相对。局部变量只能在函数内部访问。例如,考虑以下代码:
import numpy as np # here the variable np is defined
def sqrt(x):
return np.sqrt(x) # we use np inside the function
不应滥用此特性。以下代码是一个不该使用这种方式的示例:
a = 3
def multiply(x):
return a * x # bad style: access to the variable a defined outside
当修改变量a时,函数multiply默默地改变了其行为:
a=3
multiply(4) # returns 12
a=4
multiply(4) # returns 16
在这种情况下,更好的做法是通过参数列表提供该变量:
def multiply(x, a):
return a * x
全局变量在处理闭包时非常有用;请参见第 7.7 节中的相关示例:匿名函数—— 关键字 *lambda**。
7.2.4 默认参数
有些函数可能有很多参数,其中一些参数可能只在非标准情况下才有意义。如果参数可以自动设置为标准(默认)值,那将是非常实用的。
我们通过查看模块scipy.linalg中的命令norm来演示默认参数的使用。它计算矩阵和向量的各种范数。更多关于矩阵范数的信息,请参见[10 ,§2.3]。
以下用于计算 Frobenius 范数的调用是等效的:
import scipy.linalg as sl
sl.norm(identity(3))
sl.norm(identity(3), ord = 'fro')
sl.norm(identity(3), 'fro')
请注意,在第一次调用时,并没有提供关键字ord的任何信息。Python 是如何知道应该计算 Frobenius 范数而不是其他范数,比如欧几里得 2 范数的呢?
上一个问题的答案就是使用默认值。默认值是函数定义时已经给定的值。如果调用函数时没有提供该参数,Python 将使用函数定义时程序员提供的值。
假设我们调用subtract函数并只提供一个参数;我们将得到一个错误消息:
TypeError: subtract() takes exactly 2 arguments (1 given)
为了允许省略参数x2,函数的定义必须提供一个默认值,例如:
def subtract(x1, x2 = 0):
return x1 - x2
默认参数是在函数定义时,通过为参数赋值来指定的。
总结来说,参数可以作为位置参数和关键字参数提供。所有位置参数必须先给出。只要被省略的参数在函数定义中有默认值,就不需要提供所有的关键字参数。
小心可变的默认参数
默认参数是在函数定义时设置的。在函数内部修改可变参数会对使用默认值时产生副作用,例如:
def my_list(x1, x2 = []):
x2.append(x1)
return x2
my_list(1) # returns [1]
my_list(2) # returns [1,2]
回想一下,列表是可变对象。
7.2.5 可变数量的参数
列表和字典可以用来定义或调用具有可变数量参数的函数。我们可以定义一个列表和一个字典,如下所示:
data = [[1,2],[3,4]]
style = dict({'linewidth':3,'marker':'o','color':'green'})
然后我们可以使用星号(*)参数调用plot函数:
plot(*data,**style)
以*开头的变量名,例如前面示例中的*data,意味着将一个列表解包以向函数提供其参数。通过这种方式,列表生成位置参数。类似地,带有**前缀的变量名,例如示例中的**style,将解包一个字典为关键字参数;见 图 7.1:

图 7.1:函数调用中的星号参数
你也可能想要使用反向过程,在这种情况下,所有给定的位置参数会被打包成一个列表,所有的关键字参数会被打包成一个字典并传递给函数。在函数定义中,这通过分别以*和**作为前缀的参数来表示。你经常会在代码文档中看到*args和**kwargs这两个参数;参见图 7.2。

图 7.2:函数定义中的星号参数
7.3 返回值
Python 中的函数总是返回一个单一的对象。如果一个函数必须返回多个对象,它们会被打包并作为一个单一的元组对象返回。
例如,下面的函数接受一个复数!,并返回其极坐标表示形式,包含幅度!和角度!:
def complex_to_polar(z):
r = sqrt(z.real ** 2 + z.imag ** 2)
phi = arctan2(z.imag, z.real)
return (r,phi) # here the return object is formedcite
(另请参见欧拉公式,
)。
在这里,我们使用了 NumPy 函数sqrt(x)来计算数字x的平方根,并使用arctan2(x, y)来表示
。
让我们试一下我们的函数:
z = 3 + 5j # here we define a complex number
a = complex_to_polar(z)
r = a[0]
phi = a[1]
下面的三条语句可以在一行中更优雅地写出来:
r,phi = complex_to_polar(z)
我们可以通过调用在练习部分的练习 1中定义的polar_to_comp函数来测试我们的函数。
如果一个函数没有return语句,它将返回值None。有很多情况,函数不需要返回任何值。这可能是因为传递给函数的变量可能会被修改。例如,考虑下面的函数:
def append_to_list(L, x):
L.append(x)
前面的函数不返回任何内容,因为它修改了作为可变参数传递的一个对象。有很多方法的行为也是如此。仅列举列表方法,append、extend、reverse和sort这些方法都不返回任何内容(即它们返回None)。当一个对象通过这种方式被方法修改时,称为就地修改。很难知道一个方法是否会改变一个对象,除非查看代码或文档。
函数或方法不返回任何内容的另一个原因是,当它打印出一条信息或写入文件时。
执行会在第一个出现的return语句处停止。该语句之后的行是死代码,永远不会被执行:
def function_with_dead_code(x):
return 2 * x
y = x ** 2 # these two lines ...
return y # ... are never executed!
7.4 递归函数
在数学中,许多函数是递归定义的。在本节中,我们将展示如何在编写函数时使用这个概念。这使得程序与其数学对应物之间的关系变得非常清晰,这可能有助于提高程序的可读性。
然而,我们建议谨慎使用这种编程技巧,尤其是在科学计算中。在大多数应用中,更直接的迭代方法通常更高效。通过以下示例,这一点将立刻变得清晰。
切比雪夫多项式由三项递归定义:

这种递归需要初始化,即 ![]。
在 Python 中,这种 三项递归 可以通过以下函数定义来实现:
def chebyshev(n, x):
if n == 0:
return 1.
elif n == 1:
return x
else:
return 2\. * x * chebyshev(n - 1, x) \
- chebyshev(n - 2 ,x)
要计算
,函数可以像这样调用:
chebyshev(5, 0.52) # returns 0.39616645119999994
这个示例还展示了浪费计算时间的巨大风险。随着递归层级的增加,函数评估的数量呈指数增长,而且这些评估中的大多数只是先前计算的重复结果。虽然使用递归程序来展示代码与数学定义之间的紧密关系可能很诱人,但生产代码通常会避免使用这种编程技巧(参见练习6 节中的练习部分)。我们还提到了一种叫做记忆化(memoization)的技巧,它将递归编程与缓存技术相结合,以保存重复的函数评估,详见[22]。
递归函数通常有一个级别参数。在前面的例子中,它是 n. 它用来控制函数的两个主要部分:
-
基本情况;这里是前两个
if分支 -
递归主体,在这个主体中,函数本身会使用较小级别的参数一次或多次被调用
执行递归函数时经过的层数称为递归深度。这个值不应过大,否则计算可能变得低效,并且在极端情况下,会抛出以下错误:
RuntimeError: maximum recursion depth exceeded
最大的递归深度取决于你使用的计算机的内存。这个错误也会在函数定义缺少初始化步骤时发生。我们鼓励仅在非常小的递归深度下使用递归程序(更多信息,请参见第 9.7.2 节:递归)。
7.5 函数文档
你应该在函数开始时使用一个字符串来记录文档,这个字符串叫做 文档字符串:
def newton(f, x0):
"""
Newton's method for computing a zero of a function
on input:
f (function) given function f(x)
x0 (float) initial guess
on return:
y (float) the approximated zero of f
"""
...
当调用 help(newton) 时,你会看到这个文档字符串与函数调用一起显示出来:
Help on function newton in module __main__:
newton(f, x0)
Newton's method for computing a zero of a function
on input:
f (function) given function f(x)
x0 (float) initial guess
on return:
y (float) the approximated zero of f
文档字符串被内部保存为给定函数的一个属性,__doc__。在这个例子中,它是 newton.__doc__。你应该在文档字符串中提供的最基本信息是函数的目的以及输入和输出对象的描述。有一些工具可以通过收集程序中的所有文档字符串来自动生成完整的代码文档(更多信息,请参见 Sphinx 的文档,[32])。
7.6 函数是对象
函数是对象,就像 Python 中的其他一切。你可以将函数作为参数传递、修改其名称或删除它们。例如:
def square(x):
"""
Return the square of x
"""
return x ** 2
square(4) # 16
sq = square # now sq is the same as square
sq(4) # 16
del square # square doesn't exist anymore
print(newton(sq, .2)) # passing as argument
在科学计算中,传递函数作为参数是非常常见的。当应用算法时,scipy.optimize中的函数fsolve用于计算给定函数的零点,或者scipy.integrate中的quad用于计算积分,都是典型的例子。
一个函数本身可以具有不同数量和类型的参数。所以,当你将函数f作为参数传递给另一个函数g时,请确保f的形式与g的文档字符串中描述的完全一致。
fsolve的文档字符串提供了关于其参数func的信息:
fun c -- A Python function or method which takes at least one
(possibly vector) argument.
7.6.1 部分应用
让我们从一个具有两个变量的函数例子开始。
函数
可以看作是一个双变量的函数。通常,你会把
视为一个固定的参数,而不是一个自由变量,属于一族函数
:

这个解释将一个双变量函数简化为一个单变量函数
,其中固定了一个参数值
。通过固定(冻结)函数的一个或多个参数来定义一个新函数的过程称为部分应用。
使用 Python 模块functools可以轻松创建部分应用,它提供了一个名为partial的函数,专门用于这个目的。我们通过构造一个返回给定频率的正弦函数来说明这一点:
import functools
def sin_omega(t, freq):
return sin(2 * pi * freq * t)
def make_sine(frequency):
return functools.partial(sin_omega, freq = frequency)
fomega=make_sine(0.25)
fomega(3) # returns -1.0
在最后一行,新的函数在
被求值。
7.6.2 使用闭包
从函数是对象的角度出发,可以通过编写一个函数来实现部分应用,这个函数本身返回一个新的函数,并且输入参数的数量减少。例如,函数make_sine可以定义如下:
def make_sine(freq):
"Make a sine function with frequency freq"
def mysine(t):
return sin_omega(t, freq)
return mysine
在这个例子中,内嵌函数mysine可以访问变量freq;它既不是该函数的局部变量,也没有通过参数列表传递给它。Python 允许这样的构造,参见章节 13.1,命名空间。
7.7 匿名函数 —— 关键字 lambda
关键字lambda在 Python 中用于定义匿名函数,也就是没有名字、由单一表达式描述的函数。你可能只想对一个可以通过简单表达式表示的函数执行某个操作,而不需要给这个函数命名,也不需要通过冗长的def块来定义它。
名字lambda源自微积分和数学逻辑的一个特殊分支,即
-微积分。
我们通过数值评估以下积分来演示lambda函数的使用:

我们使用 SciPy 的quad函数,它的第一个参数是要积分的函数,接下来的两个参数是积分区间。这里,待积分的函数只是一个简单的一行代码,我们使用lambda关键字来定义它:
import scipy.integrate as si
si.quad(lambda x: x ** 2 + 5, 0, 1)
语法如下:
lambda parameter_list: expression
lambda函数的定义只能由一个单一的表达式组成,特别的是,不能包含循环。lambda函数与其他函数一样,都是对象,可以赋值给变量:
parabola = lambda x: x ** 2 + 5
parabola(3) # gives 14
7.7.1 lambda构造总是可以替换的
需要注意的是,lambda构造只是 Python 中的语法糖。任何lambda构造都可以被显式的函数定义所替代:
parabola = lambda x: x**2+5
# the following code is equivalent
def parabola(x):
return x ** 2 + 5
使用这种构造的主要原因是对于非常简单的函数来说,完整的函数定义会显得过于繁琐。
lambda函数提供了创建闭包的第三种方式,正如我们通过继续前面的例子![]所演示的那样。
我们使用第 7.6.1 节中的sin_omega函数,部分应用,来计算不同频率下正弦函数的积分:
import scipy.integrate as si
for iteration in range(3):
print(si.quad(lambda x: sin_omega(x, iteration*pi), 0, pi/2.) )
7.8 函数作为装饰器
在第 7.6.1 节:部分应用中,我们看到如何使用一个函数来修改另一个函数。装饰器是 Python 中的一个语法元素,它方便地允许我们改变函数的行为,而无需修改函数本身的定义。让我们从以下情况开始。
假设我们有一个函数用来确定矩阵的稀疏度:
def how_sparse(A):
return len(A.reshape(-1).nonzero()[0])
如果未以数组对象作为输入调用此函数,则会返回一个错误。更准确地说,它将无法与没有实现reshape方法的对象一起工作。例如,how_sparse函数无法与列表一起工作,因为列表没有reshape方法。以下辅助函数修改任何具有一个输入参数的函数,以便尝试将其类型转换为数组:
def cast2array(f):
def new_function(obj):
fA = f(array(obj))
return fA
return new_function
因此,修改后的函数how_sparse = cast2array(how_sparse)可以应用于任何可以转换为数组的对象。如果how_sparse的定义用这个类型转换函数进行装饰,也可以实现相同的功能:
@cast2array def how_sparse(A): return len(A.reshape(-1).nonzero()[0])
要定义一个装饰器,你需要一个可调用的对象,例如一个修改被装饰函数定义的函数。其主要目的包括:
-
通过将不直接服务于函数功能的部分分离来增加代码可读性(例如,记忆化)
-
将一组类似函数的公共前言和尾部部分放在一个共同的地方(例如,类型检查)
-
为了能够方便地开关函数的附加功能(例如,测试打印或追踪)
还建议考虑使用functools.wraps,详情请见[8]。
7.9 小结
函数不仅是使程序模块化的理想工具,还能反映出数学思维。你已经学习了函数定义的语法,并了解如何区分定义函数和调用函数。
我们将函数视为可以被其他函数修改的对象。在处理函数时,了解变量的作用域以及如何通过参数将信息传递到函数中是非常重要的。
有时,定义所谓的匿名函数非常方便。为此,我们引入了关键字lambda。
7.10 练习
例 1:编写一个函数 polar_to_comp,该函数接收两个参数
和
,并返回复数
。使用 NumPy 函数 exp 来计算指数函数。
例 2:在 Python 模块 functools 的描述中[8],你会找到以下 Python 函数:
def partial(func, *args, **keywords):
def newfunc(*fargs, **fkeywords):
newkeywords = keywords.copy()
newkeywords.update(fkeywords)
return func(*(args + fargs), **newkeywords)
newfunc.func = func
newfunc.args = args
newfunc.keywords = keywords
return newfunc
解释并测试此函数。
例 3:为函数 how_sparse 编写一个装饰器,该装饰器通过将小于 1.e-16 的元素设置为零来清理输入矩阵 A(参考 第 7.8 节:作为装饰器的函数)。
例 4:一个连续函数
,其
在区间
内改变符号,并且在该区间内至少有一个根(零)。可以通过二分法找到此根。该方法从给定区间开始,接着检查子区间中的符号变化。
和
。
如果第一个子区间内符号发生变化,则
将重新定义为:

否则,它将以相同的方式重新定义为:

该过程会重复进行,直到区间的长度
小于给定的容差。
-
将此方法实现为一个函数,接收以下参数:
-
函数
![]()
-
初始区间
![]()
-
容差
-
此函数
bisec应返回最终的区间及其中点。 -
使用函数
arctan测试该方法,并在区间![]内,以及在![]内,测试多项式![]。
例 5:可以使用欧几里得算法计算两个整数的最大公约数,该算法通过以下递归实现:

编写一个函数,用于计算两个整数的最大公约数。再编写一个函数,利用以下关系计算这两个数的最小公倍数:

例 6:研究切比雪夫多项式的递归实现。参考第 7.4 节中的示例:递归函数。将程序改写为非递归形式,并研究计算时间与多项式次数的关系(另见timeit模块)。
类
在数学中,当我们写
时,我们指的是一个数学对象,对于这个对象,我们知道很多初等微积分的方法。例如:
-
我们可能想在 ![] 处评估 ![],即计算
,其结果为一个实数。 -
我们可能想计算它的导数,这会给我们另一个数学对象,cos。
-
我们可能想计算其泰勒多项式的前三个系数。
这些方法不仅适用于 sin,还适用于其他足够光滑的函数。然而,也有其他数学对象,例如数字 5,对于这些对象,这些方法是没有意义的。
具有相同方法的对象被归为抽象类,例如函数。可以应用于函数的每个语句和方法,特别适用于 sin 或 cos。
此类的其他例子可能是有理数,存在分母和分子方法;区间,具有左边界和右边界方法;无限序列,我们可以询问它是否有极限,等等。
在这种情况下,
被称为实例。数学表达式 设 g 为一个函数... 在这种情况下被称为实例化。这里,
是函数的名称,这是可以分配给它的众多属性之一。另一个属性可能是它的定义域。
数学对象 ![] 就像正弦函数。每个函数方法都适用于
,但我们也可以为
定义特殊的方法。例如,我们可能会要求
's 的系数。这些方法可以用来定义多项式类。由于多项式是函数,它们还继承了函数类的所有方法。
在数学中,我们经常使用相同的运算符符号来表示完全不同的运算。例如,在
和
中,运算符符号 + 的含义是不同的。通过使用相同的符号,强调了与对应数学运算的相似性。我们通过将这些术语从面向对象编程引入数学示例中,来应用它们,如类、实例和实例化、继承、方法、属性以及运算符重载。
具体来说,在本章中,我们将涵盖以下主题:
-
类的简介
-
绑定方法和非绑定方法
-
类属性和类方法
-
子类和继承
-
封装
-
类作为装饰器
本章将展示这些概念如何在 Python 中使用,并从一些基础知识开始。
第九章:8.1 类简介
本节介绍了类的最常见术语及其在 Python 中的实现。
首先,我们设置一个指导性示例。
8.1.1 一个指导性示例:有理数
我们将通过有理数的示例来说明类的概念,即形如
的数字,其中
和
是整数。
以下图示给出了一个类声明的示例:

图 8.1:类声明示例
在这里,我们仅使用有理数作为类概念的示例。若要在 Python 中进行有理数运算,请使用 Python 模块 fractions。
8.1.2 定义类并创建实例
类的定义是通过一个块命令来完成的,该命令使用 class 关键字、类名以及块中的一些语句(见 图 8.1):
class RationalNumber:
pass
通过 r = RationalNumber() 创建该类的实例(或换句话说,创建一个 RationalNumber 类型的对象),并且查询 type(r) 返回的结果是 <class'__main__.RationalNumber'>。如果我们想检查一个对象是否是该类的实例,可以使用以下代码:
if isinstance(r, RationalNumber):
print('Indeed, it belongs to the class RationalNumber')
到目前为止,我们已经生成了一个类型为 RationalNumber 的对象,但该对象尚未包含任何数据。此外,尚未定义任何方法来对这些对象进行操作。接下来的章节将讨论这一主题。
8.1.3 __init__ 方法
现在,我们为示例类提供一些属性,即我们为其定义数据。在我们的例子中,这些数据将是分子和分母的值。为此,我们需要定义一个方法 __init__,用来使用这些值初始化类:
class RationalNumber:
def __init__(self, numerator, denominator):
self.numerator = numerator
self.denominator = denominator
在我们解释添加到类中的特殊函数 __init__ 之前,我们先演示 RationalNumber 对象的实例化:
q = RationalNumber(10, 20) # Defines a new object
q.numerator # returns 10
q.denominator # returns 20
通过将类名作为函数使用,可以创建一个新的 RationalNumber 类型的对象。这条语句做了两件事:
-
它首先创建一个空对象
q。 -
然后,它应用
__init__函数;也就是说,执行q.__init__(10, 20)。
__init__ 的第一个参数指的是新对象本身。在函数调用时,这个第一个参数被对象的实例替代。这适用于类的所有方法,而不仅仅是特殊方法 __init__。这个第一个参数的特殊作用体现在约定中,通常将其命名为 self。
在前面的示例中,__init__ 函数定义了新对象的两个属性,numerator 和 denominator。
8.1.4 属性和方法
使用类的一个主要原因是可以将对象归为一组并绑定到一个共同的对象上。当我们查看有理数时就看到了这一点;denominator 和 numerator 是两个我们绑定到 RationalNumber 类实例的对象。它们被称为实例的属性。一个对象作为类实例的属性这一事实,通过它们的引用方式变得显而易见,我们之前已经默契地使用过这种方式:
_<object>.attribute
下面是一些实例化和属性引用的示例:
q = RationalNumber(3, 5) # instantiation
q.numerator # attribute access
q.denominator
a = array([1, 2]) # instantiation
a.shape
z = 5 + 4j # instantiation*step*z.imag
一旦实例定义好,我们就可以设置、更改或删除该实例的属性。语法与普通变量相同:
q = RationalNumber(3, 5)
r = RationalNumber(7, 3)
q.numerator = 17
del r.denominator
更改或删除属性可能会产生不希望出现的副作用,甚至可能导致对象失效。我们将在第 8.2 节中学习更多内容:相互依赖的属性。由于函数也是对象,我们也可以将函数作为属性使用;它们被称为实例的方法:
<object>.method(<arguments...>)
例如,让我们向 RationalNumber 类添加一个方法,将数字转换为浮点数:
class RationalNumber:
...
def convert2float(self):
return float(self.numerator) / float(self.denominator)
同样,这个方法将 self 作为第一个(也是唯一的)参数,self 是对对象本身的引用。我们使用这个方法时像调用普通函数一样:
q = RationalNumber(10, 20) # Defines a new object
q.convert2float() # returns 0.5
这等价于以下调用:
RationalNumber.convert2float(q)
再次注意,对象实例作为函数的第一个参数插入。这种第一个参数的使用解释了如果该方法带有附加参数时所产生的错误消息。调用 q.convert2float(15) 会引发以下错误消息:
TypeError: convert2float() takes exactly 1 argument (2 given)
之所以不起作用,是因为 q.convert2float(15) 正好等同于 RationalNumber.convert2float(q,15),而这失败了,因为 RationalNumber.convert2float 只接受一个参数。
8.1.5 特殊方法
特殊方法 __repr__ 使我们能够定义对象在 Python 解释器中的表现方式。对于有理数,这个方法的可能定义如下:
class RationalNumber:
...
def __repr__(self):
return f'{self.numerator} / {self.denominator}'
定义了这个方法后,只需输入 q 就会返回 10 / 20。
我们希望有一个方法能执行两个有理数的加法。第一次尝试可能会得到如下方法:
class RationalNumber:
...
def add(self, other):
p1, q1 = self.numerator, self.denominator
if isinstance(other, int):
p2, q2 = other, 1
else:
p2, q2 = other.numerator, other.denominator
return RationalNumber(p1 * q2 + p2 * q1, q1 * q2)
调用此方法的形式如下:
q = RationalNumber(1, 2)
p = RationalNumber(1, 3)
q.add(p) # returns the RationalNumber for 5/6
如果我们能够写成 q + p 会更好。但到目前为止,加号对 RationalNumber 类型还没有定义。这是通过使用特殊方法 __add__ 来完成的。因此,只需将 add 重命名为 __add__,就可以对有理数使用加号:
q = RationalNumber(1, 2)
p = RationalNumber(1, 3)
q + p # RationalNumber(5, 6)
表达式 q + p 实际上是 q.__add__(p) 表达式的别名。在表 8.1 中,你可以找到二元运算符(如 +、- 或 *)的特殊方法:
| 运算符 | 方法 | 运算符 | 方法 |
|---|---|---|---|
+ |
__add__ |
+= |
__iadd__ |
* |
__mul__ |
*= |
__imul__ |
- |
__sub__ |
-= |
__isub__ |
/ |
__truediv__ |
/= |
__itruediv__ |
// |
__floordiv__ |
//= |
__ifloordiv__ |
** |
__pow__ |
||
== |
__eq__ |
!= |
qD__ne__ |
<= |
__le__ |
< |
__lt__ |
>= |
__ge__ |
> |
__gt__ |
() |
__call__ |
[] |
__getitem__ |
表 8.1:一些 Python 运算符及其对应的类方法
为新类实现这些运算符被称为运算符重载。运算符重载的另一个示例是一个方法,用于检查两个有理数是否相等:
class RationalNumber:
...
def __eq__(self, other):
return self.denominator * other.numerator == \
self.numerator * other.denominator
它的使用方式如下:
p = RationalNumber(1, 2) # instantiation
q = RationalNumber(2, 4) # instantiation
p == q # True
不同类之间的操作需要特别注意:
p = RationalNumber(1, 2) # instantiation
p + 5 # corresponds to p.__add__(5)
5 + p # returns an error
默认情况下,运算符+会调用左侧操作数的方法__add__。我们编写了代码,使其支持int类型和RationalNumber类型的对象。在语句5+p中,操作数会被交换,调用内建类型int的__add__方法。由于该方法不知道如何处理有理数,因此会返回错误。这个问题可以通过__radd__方法解决,我们现在将其添加到RationalNumber类中。方法__radd__被称为反向加法。
反向操作
如果对两个不同类型的操作数应用如+这样的操作,首先调用左侧操作数的相应方法(在此情况下为__add__)。如果此方法引发异常,则调用右侧操作数的反向方法(这里是__radd__)。如果该方法不存在,则会引发TypeError异常。
为了实现操作![],其中![]是RationalNumber的一个实例,我们将__radd__定义为:
class RationalNumber:
....
def __radd__(self, other):
return self + other
注意,__radd__会交换参数的顺序;self是RationalNumber类型的对象,而other是必须转换的对象。
模拟函数调用和可迭代对象的方法
使用类实例和括号或中括号()或[],调用的是特殊方法__call__或__getitem__,使得该实例表现得像函数或可迭代对象;另见表 8.1。
class Polynomial:
...
def __call__(self, x):
return self.eval(x)
现在可以按如下方式使用:
p = Polynomial(...) # Creating a polynomial object
p(3.) # value of p at 3.
特殊方法__getitem__是有意义的,如果类提供了迭代器(建议你在考虑以下示例之前,回顾第 9.2.1 节:生成器)。
递归![]被称为三项递归。它在应用数学中扮演着重要角色,尤其是在构造正交多项式时。我们可以通过以下方式将三项递归设置为一个类:
import itertools
class Recursion3Term:
def __init__(self, a0, a1, u0, u1):
self.coeff = [a1, a0]
self.initial = [u1, u0]
def __iter__(self):
u1, u0 = self.initial
yield u0 # (see also Iterators section in Chapter 9)
yield u1
a1, a0 = self.coeff
while True :
u1, u0 = a1 * u1 + a0 * u0, u1
yield u1
def __getitem__(self, k):
return list(itertools.islice(self, k, k + 1))[0]
这里,方法__iter__定义了一个生成器对象,使得我们可以将类的实例作为迭代器使用:
r3 = Recursion3Term(-0.35, 1.2, 1, 1)
for i, r in enumerate(r3):
if i == 7:
print(r) # returns 0.194167
break
方法__getitem__使我们能够像访问列表一样直接访问迭代结果,假设r3是一个列表:
r3[7] # returns 0.194167
请注意,在编写__getitem__时,我们使用了itertools.islice(有关更多信息,请参见第 9.3.2 节:“迭代器工具”)。
8.2 彼此依赖的属性
实例的属性可以通过简单地为其赋值来修改(或创建)。然而,如果其他属性依赖于刚刚改变的属性,最好同时修改它们。
为了证明这一点,我们考虑一个例子:假设我们定义一个类,该类通过三个给定点来定义平面三角形对象。第一次尝试建立这样一个类可能是如下所示:
class Triangle:
def __init__(self, A, B, C):
self.A = array(A)
self.B = array(B)
self.C = array(C)
self.a = self.C - self.B
self.b = self.C - self.A
self.c = self.B - self.A
def area(self):
return abs(cross(self.b, self.c)) / 2
这个三角形的实例通过以下方式创建:
tr = Triangle([0., 0.], [1., 0.], [0., 1.])
然后通过调用相应的方法计算其面积:
tr.area() # returns 0.5
如果我们改变一个属性,比如点B,对应的边a和c不会自动更新,计算出的面积也会出错:
tr.B = [12., 0.]
tr.area() # still returns 0.5, should be 6 instead.
一种解决方法是定义一个方法,该方法在属性改变时执行;这样的一个方法称为设置器方法。相应地,你可能需要一个在请求属性值时执行的方法;这样的一个方法称为获取器方法。我们现在将解释这两种方法是如何定义的。
8.2.1 函数属性
特殊函数property将属性与获取器、设置器和删除器方法关联。它也可以用来为属性分配文档字符串:
attribute = property(fget = get_attr, fset = set_attr,
fdel = del_attr, doc = string)
我们继续之前的例子,使用设置器方法,再次考虑Triangle类。如果在该类的定义中包括以下语句,那么命令tr.B = <某些值>会调用设置器方法set_B:
B = property(fget = get_B, fset = set_B, fdel = del_B,
doc = ’The point B of a triangle’)
让我们相应地修改Triangle类:
class Triangle:
def __init__(self, A, B, C):
self._A = array(A)
self._B = array(B)
self._C = array(C)
self._a = self._C - self._B
self._b = self._C - self._A
self._c = self._B - self._A
def area(self):
return abs(cross(self._c, self._b)) / 2.
def set_B(self, B):
self._B = B
self._a = self._C - self._B
self._c = self._B - self._A
def get_B(self):
return self._B
def del_Pt(self):
raise Exception('A triangle point cannot be deleted')
B = property(fget = get_B, fset = set_B, fdel = del_Pt)
如果属性B被改变,那么set_B方法会将新值存储到内部属性_B中,并改变所有依赖的属性:
tr.B = [12., 0.]
tr.area() # returns 6.0
这里使用删除器方法的方式是为了防止删除属性:
del tr.B # raises an exception
使用下划线作为属性名的前缀是一种约定,用来表示这些属性不打算被直接访问。它们用于存储由设置器和获取器处理的属性的数据。这些属性并不像其他编程语言中的私有属性那样真正私密,它们只是没有直接访问的设计意图。
8.3 绑定与未绑定的方法
现在我们将更详细地查看作为方法的属性。我们来看一个例子:
class A:
def func(self,arg):
pass
稍微检查一下,我们可以发现创建实例后,func的性质发生了变化:
A.func # <unbound method A.func>
instA = A() # we create an instance
instA.func # <bound method A.func of ... >
例如,调用A.func(3)会导致类似这样的错误信息:
TypeError: func() missing 1 required positional argument: 'arg'
instA.func(3)按预期执行。在实例创建时,方法func绑定到该实例。参数self被分配为实例的值。将方法绑定到实例上,使得该方法可以作为函数使用。在此之前,它没有任何用途。类方法(我们将在第 8.4.2 节:“类方法”中讨论)在这一点上与之不同。
8.4 类属性和类方法
到目前为止,我们已经看到了绑定到类实例上的属性和方法。在这一节中,我们介绍类属性和类方法。它们允许在实例创建之前访问方法和数据。
8.4.1 类属性
在类声明中指定的属性称为类属性。考虑以下示例:
class Newton:
tol = 1e-8 # this is a class attribute
def __init__(self,f):
self.f = f # this is not a class attribute
...
类属性对于模拟默认值非常有用,如果需要重置值时可以使用:
N1 = Newton(f)
N2 = Newton(g)
两个实例都有一个属性 tol,其值在类定义中初始化:
N1.tol # 1e-8
N2.tol # 1e-8
修改类属性会自动影响所有实例的相应属性:
Newton.tol = 1e-10
N1.tol # 1e-10
N2.tol # 1e-10
修改一个实例的 tol 不会影响另一个实例:
N2.tol = 1.e-4
N1.tol # still 1.e-10
但现在,N2.tol 已从类属性中分离出来。更改 Newton.tol 不再对 N2.tol 产生任何影响:
Newton.tol = 1e-5
# now all instances of the Newton classes have tol=1e-5
N1.tol # 1.e-5
N2.tol # 1.e-4
# N2.tol is now detached and therefore not altered
8.4.2 类方法
我们在第 8.3 节中看到过:绑定和未绑定的方法,方法要么绑定到类的实例,要么保持未绑定的方法状态。类方法则不同,它们始终是绑定方法。它们绑定到类本身。
我们将首先描述语法细节,然后给出一些示例,展示这些方法的用途。
为了表明一个方法是类方法,装饰器行应当出现在方法定义之前:
@classmethod
标准方法通过其第一个参数引用实例,而类方法的第一个参数引用的是类本身。按照约定,标准方法的第一个参数称为 self,类方法的第一个参数称为 cls。
以下是标准情况的示例:
class A:
def func(self,*args):
<...>
这与 classmethod 的示例对比:
class B:
@classmethod
def func(cls,*args):
<...>
实际上,类方法可能用于在创建实例之前执行命令,例如在预处理步骤中。请参阅以下示例。
在这个示例中,我们展示了如何使用类方法在创建实例之前准备数据:
class Polynomial:
def __init__(self, coeff):
self.coeff = array(coeff)
@classmethod
def by_points(cls, x, y):
degree = x.shape[0] - 1
coeff = polyfit(x, y, degree)
return cls(coeff)
def __eq__(self, other):
return allclose(self.coeff, other.coeff)
该类的设计使得通过指定系数可以创建一个多项式对象。或者,类方法 by_points 允许我们通过插值点定义一个多项式。
即使没有 Polynomial 的实例,我们也可以将插值数据转化为多项式系数:
p1 = Polynomial.by_points(array([0., 1.]), array([0., 1.]))
p2 = Polynomial([1., 0.])
print(p1 == p2) # prints True
第 8.7 节中展示了类方法的另一个示例:类作为装饰器。在那里,类方法用于访问与该类的多个(或所有)实例相关的信息。
8.5 子类和继承
在这一节中,我们介绍一些面向对象编程中的核心概念:抽象类、子类和继承。为了帮助你理解这些概念,我们考虑了另一个数学示例:求解微分方程的单步方法。
普通初值问题的通用形式如下:

数据包括右侧函数
,初始值
,以及感兴趣的区间
。
该问题的解是一个函数![]。一个数值算法将这个解表示为一个离散值的向量
,这些离散值
是对![]的近似值。在这里,![] 和 ![] 是独立变量
的离散化值,在物理模型中,通常表示时间。
一步法通过递归步骤构造解的值
:

在这里,
是一个步进函数,用于描述各个方法,详情请参见[28]:
-
显式欧拉法:
_ -
中点法则:
![]()
-
龙格-库塔 4 法:
与 ![]()
我们在这里所做的,是描述数学算法的典型方式。我们首先通过其思想描述了一种方法,以抽象的方式给出了步骤。要实际使用它,我们必须填写具体方法的参数,在这个例子中,就是函数![]。这也是面向对象编程中常见的解释方式。首先,我们设定一个类,提供方法的抽象描述:
class OneStepMethod:
def __init__(self, f, x0, interval, N):
self.f = f
self.x0 = x0
self.interval = [t0, te] = interval
self.grid = linspace(t0, te, N)
self.h = (te - t0) / N
def generate(self):
ti, ui = self.grid[0], self.x0
yield ti, ui
for t in self.grid[1:]:
ui = ui + self.h * self.step(self.f, ui, ti)
ti = t
yield ti, ui
def solve(self):
self.solution = array(list(self.generate()))
def plot(self):
plot(self.solution[:, 0], self.solution[:, 1])
def step(self, f, u, t):
raise NotImplementedError()
这个抽象类及其方法被用作个别方法的模板:
class ExplicitEuler(OneStepMethod):
def step(self, f, u, t):
return f(u, t)
class MidPointRule(OneStepMethod):
def step(self, f, u, t):
return f(u + self.h / 2 * f(u, t), t + self.h / 2)
请注意,在类定义中,我们作为模板使用的抽象类名称OneStepMethod被作为一个额外的参数给出:
class ExplicitEuler(OneStepMethod)
这个类被称为父类。父类的所有方法和属性都可以被子类继承,前提是这些方法没有被重写。如果在子类中重新定义了这些方法,就会覆盖父类中的方法。方法step在子类中被重写,而方法generate则是整个家族通用的,因此从父类继承。
在考虑进一步的细节之前,我们将演示如何使用这三种类:
def f(x, t):
return -0.5 * x
euler = ExplicitEuler(f, 15., [0., 10.], 20)
euler.solve()
euler.plot()
hold(True)
midpoint = MidPointRule(f, 15., [0., 10.], 20)
midpoint.solve()
midpoint.plot()
你可以通过使用星号操作符避免重复编写常见的参数列表(有关更多细节,请参见第 7.2.5 节: 可变参数数量):
...
argument_list = [f, 15., [0., 10.], 20]
euler = ExplicitEuler(*argument_list)
...
midpoint = MidPointRule(*argument_list)
...
注意,抽象类从未被用来创建实例。由于方法step没有完全定义,调用它会引发类型为NotImplementedError的异常。
有时你需要访问父类的方法或属性。这可以通过命令super来实现。当子类使用自己的__init__方法扩展父类的__init__方法时,这非常有用。
例如,假设我们想给每个求解器类提供一个包含求解器名称的字符串变量。为此,我们为求解器提供一个__init__方法,以覆盖父类的__init__方法。如果两者都需要使用,我们必须通过命令super来引用父类的方法:
class ExplicitEuler(OneStepMethod):
def __init__(self,*args, **kwargs):
self.name='Explicit Euler Method'
super(ExplicitEuler, self).__init__(*args,**kwargs)
def step(self, f, u, t):
return f(u, t)
注意,你本可以明确地使用父类的名称。使用super则允许我们在不更改所有父类引用的情况下更改父类的名称。
8.6 封装
有时使用继承是不实际的,甚至是不可能的。这促使我们使用封装。
我们将通过考虑 Python 函数来解释封装的概念,即我们将 Python 类型function的对象封装到一个新的类Function中,并为其提供一些相关的方法:
class Function:
def __init__(self, f):
self.f = f
def __call__(self, x):
return self.f(x)
def __add__(self, g):
def sum(x):
return self(x) + g(x)
return type(self)(sum)
def __mul__(self, g):
def prod(x):
return self.f(x) * g(x)
return type(self)(prod)
def __radd__(self, g):
return self + g
def __rmul__(self, g):
return self * g
注意,操作__add__和__mul__应该返回相同类的实例。这是通过语句return type(self)(sum)来实现的,在这种情况下,这比写return Function(sum)更为通用。我们现在可以通过继承来派生子类。
以切比雪夫多项式为例。它们可以在区间![]内计算:
![]
我们将切比雪夫多项式构建为Function类的一个实例:
T5 = Function(lambda x: cos(5 * arccos(x)))
T6 = Function(lambda x: cos(6 * arccos(x)))
切比雪夫多项式是正交的,意思是:

这可以通过这种构造轻松检查:
import scipy.integrate as sci
weight = Function(lambda x: 1 / sqrt((1 - x ** 2)))
[integral, errorestimate] = \
sci.quad(weight * T5 * T6, -1, 1)
# (6.510878470473995e-17, 1.3237018925525037e-14)
如果没有封装,像写weight * T5 * T6这样的乘法函数是不可能实现的。
8.7 类作为装饰器
在第 7.8 节:函数作为装饰器中,我们看到了如何通过将另一个函数作为装饰器来修改函数。在第 8.1.5 节:特殊方法中,我们看到只要类提供了__call__方法,类就可以像函数一样工作。我们将在这里使用这个方法来展示如何将类用作装饰器。
假设我们想改变某些函数的行为,使得在调用函数之前,所有的输入参数都被打印出来。这对于调试非常有用。我们以这个情况为例,来解释装饰器类的使用:
class echo:
text = 'Input parameters of {name}\n'+\
'Positional parameters {args}\n'+\
'Keyword parameters {kwargs}\n'
def __init__(self, f):
self.f = f
def __call__(self, *args, **kwargs):
print(self.text.format(name = self.f.__name__,
args = args, kwargs = kwargs))
return self.f(*args, **kwargs)
我们使用这个类来装饰函数定义:
@echo
def line(m, b, x):
return m * x + b
然后,像往常一样调用函数:
line(2., 5., 3.)
line(2., 5., x=3.)
在第二次调用时,我们得到以下输出:
Input parameters of line
Positional parameters (2.0, 5.0)
Keyword parameters {'x': 3.0}
11.0
这个例子表明,类和函数都可以用作装饰器。类提供了更多的可能性,因为它们还可以用来收集数据。
确实,我们观察到:
-
每个被装饰的函数都会创建一个新的装饰器类实例。
-
一个实例收集的数据可以通过类属性保存,并使另一个实例能够访问;请参见章节 8.4:类 属性和类方法。
最后一项强调了函数装饰器之间的区别。我们现在通过一个装饰器来展示,它能够计数函数调用次数,并将结果存储在一个以函数为键的字典中。
为了分析算法的性能,可能有用的是计算特定函数的调用次数。我们可以在不改变函数定义的情况下获取计数器信息:
class CountCalls:
"""
Decorator that keeps track of the number of times
a function is called.
"""
instances = {}
def __init__(self, f):
self.f = f
self.numcalls = 0
self.instances[f] = self
def __call__(self, *args, **kwargs):
self.numcalls += 1
return self.f(*args, **kwargs)
@classmethod
def counts(cls):
"""
Return a dict of {function: # of calls} for all
registered functions.
"""
return dict([(f.__name__, cls.instances[f].numcalls)
for f in cls.instances])
在这里,我们使用类属性CountCalls.instances来存储每个实例的计数器。
让我们看看这个装饰器是如何工作的:
@CountCalls
def line(m, b, x):
return m * x + b
@CountCalls
def parabola(a, b, c, x):_
return a * x ** 2 + b * x + c
line(3., -1., 1.)
parabola(4., 5., -1., 2.)
CountCalls.counts() # returns {'line': 1, 'parabola': 1}
parabola.numcalls # returns 1
8.8 总结
现代计算机科学中最重要的编程概念之一是面向对象编程。在本章中,我们学习了如何将对象定义为类的实例,并为其提供方法和属性。方法的第一个参数,通常表示为self,在其中扮演着重要且特殊的角色。你会看到一些方法,它们可以用来为自定义类定义基本运算,比如+和*。
虽然在其他编程语言中,属性和方法可以防止被意外使用,但 Python 允许一种技巧来隐藏属性,并通过特殊的 getter 和 setter 方法访问这些隐藏的属性。为此,你会遇到一个重要的函数property。
8.9 练习
-
为
RationalNumber类编写一个simplify方法。该方法应返回该分数的简化版本,形式为一个元组。 -
为了提供带有置信区间的结果,数值数学中引入了一种特殊的计算方法,称为区间算术。定义一个名为
Interval的类,并为其提供加法、减法、除法、乘法和幂运算(仅限正整数)的相关方法。这些运算遵循以下规则:

为这个类提供方法,使得可以进行a + I, a I, I + a, I a类型的运算,其中I是一个区间,a是整数或浮点数。首先,将整数或浮点数转换为区间[a,a]。(提示:你可能想使用函数装饰器来实现这一点;请参见章节 7.8:函数作为装饰器。)此外,实现__contains__方法,它使你能够使用x in I语法检查某个数字是否属于区间I,其中I是Interval类型的对象。通过将多项式f=lambda x: 25*x**2-4*x+1应用于一个区间来测试你的类。
-
考虑第 8.7 节中的示例:类作为装饰器。扩展这个示例,创建一个函数装饰器,用于统计某个函数被调用的次数;另见第 7.8 节:函数作为装饰器。
-
比较两种在
RationalNumber类中实现反向加法__radd__的方法:一种是第 8.1.5 节中的示例:特殊方法,另一种是此处给出的实现:
class RationalNumber:
....
def __radd__(self, other):
return other + self
你预期这个版本会出错吗?错误是什么,你如何解释它?通过执行以下代码测试你的答案:
q = RationalNumber(10, 15)
5 + q
- 考虑装饰器类
CountCalls,如第 8.7 节中的示例:类作为装饰器。为这个类提供一个方法reset,该方法将字典CountCalls.instances中所有函数的计数器重置为0。如果将字典替换为空字典,会发生什么?
迭代
在本章中,我们将展示使用循环和迭代器进行迭代的示例。我们将展示如何在列表和生成器中使用它们。迭代是计算机的基本操作之一。传统上,通过 for 循环实现迭代。for 循环是对一组指令块的重复执行。在循环内部,你可以访问一个循环变量,其中存储了迭代的次数。
Python 中的 for 循环主要设计用于枚举一个列表,也就是对该列表的每个元素重复相同的命令序列。如果你使用包含前 ![] 个整数的列表,效果类似于刚刚描述的重复效果。
for 循环一次只需要列表的一个元素。因此,最好使用能够按需生成这些元素的对象,而不是提供一个完整列表。这就是 Python 中迭代器的作用。
本章涵盖以下主题:
-
for语句 -
控制循环内部的流程
-
可迭代对象
-
填充列表的模式
-
当迭代器像列表一样工作时
-
迭代器对象
-
无限迭代
第十章:9.1 for 语句
for 语句的主要目的是遍历一个列表,也就是对给定列表的每个元素应用相同的一系列命令:
for s in ['a', 'b', 'c']:
print(s) # a b c
在此示例中,循环变量 s 依次赋值为列表的每个元素。请注意,循环结束后仍然可以访问循环变量。有时这可能很有用;例如,参见第 9.2 节:控制循环内部的流程。
for 循环最常见的用途之一是重复,也就是对给定列表的每个元素应用相同的一系列命令:使用函数 range,详见第 1.3.1 节:列表。
for iteration in range(n): # repeat the following code n times
...
如果循环的目的是遍历一个列表,许多语言(包括 Python)提供了以下模式:
for k in range(...):
...
element = my_list[k]
如果该代码的目的是遍历列表 my_list,那么前面的代码就不是很清晰。因此,更好的表达方式如下:
for element in my_list:
...
现在一眼就能看出前面的代码是在遍历列表 my_list。请注意,如果确实需要索引变量 ![],你可以用这段代码替换前面的代码(另见第 9.3.3 节:迭代器工具):
for k, element in enumerate(my_list):
...
此段代码的意图是在遍历 my_list 的同时保持索引变量 k 可用。对数组而言,类似的构造是使用命令 ndenumerate:
a=ones((3,5))
for k,el in ndenumerate(a):
print(k,el)
# prints something like this: (1, 3) 1.0
9.2 在循环内部控制流程
有时需要跳出循环或直接进入下一个循环迭代。这两个操作通过 break 和 continue 命令来执行。
break 关键字用于在循环完全执行之前终止循环——它打破了循环。
在包含 break 语句的循环中可能会发生两种情况:
-
循环被完全执行。
-
当到达
break时,循环在没有完全执行之前被跳出。
对于第一种情况,可以在 else 块中定义特殊操作,当整个列表遍历完毕时执行。如果 for 循环的目的是找到某个元素并停止,这在一般情况下很有用。例如,搜索一个满足特定属性的元素。如果没有找到这样的元素,则执行 else 块。
这里是科学计算中的常见用法:我们经常使用一个不保证成功的迭代算法。在这种情况下,最好使用一个(大)有限循环,以防程序陷入无限循环。for/else 结构可以实现这种方式:
maxIteration = 10000
for iteration in range(maxIteration):
residual = compute() # some computation
if residual < tolerance:
break
else: # only executed if the for loop is not broken
raise Exception("The algorithm did not converge")
print(f"The algorithm converged in {iteration + 1} steps")
9.3 可迭代对象
for 循环主要用于遍历一个列表,但它一次处理列表中的一个元素。特别是,循环正常工作时并不需要将整个列表存储在内存中。使 for 循环在没有列表的情况下仍能工作的机制就是迭代器。
一个可迭代对象会生成要传递给循环的对象。这样的对象可以像列表一样在循环中使用:
for element in obj:
...
可迭代对象的概念从而扩展了列表的思想。
最简单的可迭代对象示例是列表。产生的对象就是存储在列表中的对象:
L = ['A', 'B', 'C']
for element in L:
print(element)
一个可迭代对象不一定要产生已存在的对象。相反,对象可以在需要时即时产生。
一个典型的可迭代对象是 range 函数返回的对象。这个函数看起来好像会生成一个整数列表,但实际上是按需即时生成连续的整数:
for iteration in range(100000000):
# Note: the 100000000 integers are not created at once
if iteration > 10:
break
如果你真的需要一个包含从 0 到 100,000,000 之间所有整数的列表,那么必须显式地构造它:
l=list(range(100000000))
可迭代对象有一个名为 __iter__ 的方法。这就是你可以检查某个对象是否是可迭代对象的方法。
到目前为止,我们已经遇到以下数据类型,它们是可迭代对象:
-
lists -
tuples -
strings -
range对象 -
dictionaries -
arrays -
enumerate和ndenumerate对象
通过在可迭代对象上执行 __iter__ 方法,可以创建一个迭代器。当调用 for 循环时,这一操作是默认执行的。迭代器有一个 __next__ 方法,用来返回序列中的下一个元素:
l=[1,2]
li=l.__iter__()
li.__next__() # returns 1
li.__next__() # returns 2
li.__next__() # raises StopIteration exception
9.3.1 生成器
你可以通过使用 yield 关键字创建自己的迭代器。例如,定义一个生成小于
的奇数的生成器:
def odd_numbers(n):
"generator for odd numbers less than n"
for k in range(n):
if k % 2 == 1:
yield k
然后你可以像这样使用它:
g = odd_numbers(10)
for k in g:
... # do something with k
甚至像这样:
for k in odd_numbers(10):
... # do something with k
9.3.2 迭代器是一次性的
迭代器的一个显著特点是它们只能使用一次。为了再次使用迭代器,你必须创建一个新的迭代器对象。请注意,可迭代对象可以根据需要创建新的迭代器。让我们来看一个列表的例子:
L = ['a', 'b', 'c']
iterator = iter(L)
list(iterator) # ['a', 'b', 'c']
list(iterator) # [] empty list, because the iterator is exhausted
new_iterator = iter(L) # new iterator, ready to be used
list(new_iterator) # ['a', 'b', 'c']
每次调用生成器对象时,它会创建一个新的迭代器。因此,当该迭代器耗尽时,你必须再次调用生成器以获得新的迭代器:
g = odd_numbers(10)
for k in g:
... # do something with k
# now the iterator is exhausted:
for k in g: # nothing will happen!!
...
# to loop through it again, create a new one:
g = odd_numbers(10)
for k in g:
...
9.3.3 迭代器工具
现在,我们将介绍几个常用的迭代器工具:
enumerate用于枚举另一个迭代器。它生成一个新的迭代器,返回一个(迭代,元素)对,其中迭代存储迭代的索引:
A = ['a', 'b', 'c']
for iteration, x in enumerate(A):
print(iteration, x) # result: (0, 'a') (1, 'b') (2, 'c')
reversed通过倒序遍历列表来创建迭代器。注意,这与创建一个反转的列表不同:
A = [0, 1, 2]
for elt in reversed(A):
print(elt) # result: 2 1 0
itertools.count是一个可能无限的整数迭代器:
for iteration in itertools.count():
if iteration > 100:
break # without this, the loop goes on forever
print(f'integer: {iteration}')
# prints the 100 first integer
intertools.islice使用熟悉的slicing语法截断迭代器;请参见 第 3.1.1 节:切片。一个应用是从无限迭代器创建有限迭代器:
from itertools import count, islice
for iteration in islice(count(), 10):
# same effect as range(10)
...
例如,结合 islice 与无限生成器,我们可以找出一些奇数。首先,我们修改奇数生成器,使其成为无限生成器:
def odd_numbers():
k=-1
while True: # this makes it an infinite generator
k+=1
if k%2==1:
yield k
然后,我们使用 islice 获取一些奇数的列表:
list(itertools.islice(odd_numbers(),10,30,8))
# returns [21, 37, 53]
这个命令从假设的所有奇数列表中提取,从索引 10 到索引 29,以步长 8 递增的数值。
9.3.4 递归序列的生成器
假设某个序列是由归纳公式给定的。例如,考虑斐波那契数列,它由递归公式定义:
。
该序列依赖于两个初始值,即 ![] 和 ![],尽管对于标准斐波那契数列,这两个数分别取值为 0 和 1。生成这样一个序列的巧妙方法是使用生成器,如下所示:
def fibonacci(u0, u1):
"""
Infinite generator of the Fibonacci sequence.
"""
yield u0
yield u1
while True:
u0, u1 = u1, u1 + u0
# we shifted the elements and compute the new one
yield u1
然后,可以像这样使用它:
# sequence of the 100 first Fibonacci numbers:
list(itertools.islice(fibonacci(0, 1), 100))
9.3.5 数学中迭代器的示例
算术几何平均数
一个更复杂的生成器示例是它在迭代计算算术和几何平均数时的使用——即所谓的 AGM 迭代法,参见 [1]:

我们在计算椭圆积分以确定数学摆的周期时,在这里演示了这个迭代过程。
当开始时使用值
,AGM 迭代生成具有以下(惊人)特性的数字序列:

右侧的积分被称为第一类完全椭圆积分。我们现在将继续计算这个椭圆积分。我们使用一个生成器来描述迭代:
def arithmetic_geometric_mean(a, b):
"""
Generator for the arithmetic and geometric mean
a, b initial values
"""
while True: # infinite loop
a, b = (a+b)/2, sqrt(a*b)
yield a, b
当序列 ![] 收敛到相同的值时,序列 ![] 由 ![] 定义,并且收敛到零——这一事实将在程序中用于终止迭代以计算椭圆积分:
def elliptic_integral(k, tolerance=1.e-5):
"""
Compute an elliptic integral of the first kind.
"""
a_0, b_0 = 1., sqrt(1-k**2)
for a, b in arithmetic_geometric_mean(a_0, b_0):
if abs(a-b) < tolerance:
return pi/(2*a)
我们必须确保算法停止。请注意,这段代码完全依赖于算术几何平均迭代收敛(快速)的数学声明。在实际计算中,我们在应用理论结果时必须小心,因为在有限精度算术中,它们可能不再有效。确保前述代码安全的正确方法是使用 itertools.islice。安全代码如下:
from itertools import islice
def elliptic_integral(k, tolerance=1e-5, maxiter=100):
"""
Compute an elliptic integral of the first kind.
"""
a_0, b_0 = 1., sqrt(1-k**2)
for a, b in islice(arithmetic_geometric_mean(a_0, b_0), maxiter):
if abs(a-b) < tolerance:
return pi/(2*a)
else:
raise Exception("Algorithm did not converge")
作为应用,椭圆积分可以用来计算长度为 ![] 的摆钟的周期 ![],该摆钟从角度 ![] 开始,使用以下公式:
。
使用此公式,摆钟的周期可以轻松得到,见 [18]:
def pendulum_period(L, theta, g=9.81):
return 4*sqrt(L/g)*elliptic_integral(sin(theta/2))
收敛加速
我们将给出一个生成器加速收敛的应用示例。此演示紧密跟随 [9] 中给出的例子。
请注意,生成器可以将另一个生成器作为输入参数。例如,假设我们定义了一个生成器来生成一个收敛序列的元素。然后,可以通过加速技术来改善收敛,该技术源于 欧拉 和 艾特金,通常称为艾特金的 Δ² 方法,见 [33]。 它通过定义将序列 ![] 转换为另一个序列。

两个序列有相同的极限,但序列 ![] 收敛得更快。一种可能的实现如下:
def Euler_accelerate(sequence):
"""
Accelerate the iterator in the variable `sequence`.
"""
s0 = sequence.__next__() # Si
s1 = sequence.__next__() # Si+1
s2 = sequence.__next__() # Si+2
while True:
yield s0 - ((s1 - s0)**2)/(s2 - 2*s1 + s0)
s0, s1, s2 = s1, s2, sequence.__next__()
作为一个例子,我们使用序列
,它收敛于
。
我们在以下代码中实现这个序列作为生成器:
def pi_series():
sum = 0.
j = 1
for i in itertools.cycle([1, -1]):
yield sum
sum += i/j
j += 2
我们现在可以使用加速版本的该序列:
Euler_accelerate(pi_series())
因此,加速序列的前N个元素可以通过以下公式得到:
list(itertools.islice(Euler_accelerate(pi_series()), N))
请注意,这里我们堆叠了三个生成器:pi_series、Euler_accelerate 和 itertools.islice。
图 9.1 显示了使用前述公式定义的标准版本序列及其加速版本的误差对数收敛速度:

图 9.1:序列与其加速版本的比较
9.4 列表填充模式
在这一节中,我们将比较不同的列表填充方法。它们在计算效率和代码可读性上有所不同。
9.4.1 使用 append 方法填充列表
一个普遍的编程模式是计算元素并将其存储在列表中:
L = []
for k in range(n):
# call various functions here
# that compute "result"
L.append(result)
这种方法有一些缺点:
-
迭代次数是预先决定的。如果有
break指令,前面的代码会处理生成值和决定何时停止的问题。这是不可取的,并且缺乏灵活性。 -
它假设用户想要计算的整个历史记录,涵盖所有迭代。假设我们只对所有计算值的总和感兴趣。如果有很多计算值,存储它们没有意义,因为逐个相加效率更高。
9.4.2 来自迭代器的列表
迭代器为我们提供了一个优雅的解决方案来解决之前讨论的问题:
def result_iterator():
for k in itertools.count(): # infinite iterator
# call various functions here
# that t lists compute "result"
...
yield result
使用迭代器时,我们将生成计算值的任务与停止条件和存储分开处理。
- 如果该代码的用户想要存储![]的第一个值,可以使用
list构造器轻松完成:
L = list(itertools.islice(result_iterator(), n)) # no append needed!
- 如果用户想要前n个生成值的总和,推荐使用这种构造:
# make sure that you do not use numpy.sum here
s = sum(itertools.islice(result_iterator(), n))
- 如果用户希望生成所有元素直到满足某个条件,可以使用函数
itertools.takewhile:
L=list(itertools.takewhile(lambda x: abs(x) > 1.e-8, result_iterator()))
函数takewhile的第一个参数是一个返回布尔值的函数。第二个参数是一个生成器。只要该函数的返回值为True,生成器就会继续迭代。
我们在这里做的事情是将元素的生成与元素的存储分开处理。
- 如果目标确实是构建一个列表,并且每一步的结果不依赖于先前计算的元素,可以使用列表推导语法(参见第 3.1.6 节:列表推导):
L = [some_function(k) for k in range(n)]
当迭代计算依赖于先前计算的值时,列表推导无法提供帮助。
9.4.3 存储生成的值
使用迭代器来填充列表通常能很好地工作,但当计算新值的算法可能抛出异常时,这种模式会有一些复杂性;如果迭代器在过程中抛出异常,列表将无法使用!下面的示例展示了这个问题。
假设我们生成了由![]定义的递归序列。如果初始数据![]大于 1,这个序列会迅速发散到无穷大。让我们用生成器来生成它:
import itertools
def power_sequence(u0):
u = u0
while True:
yield u
u = u**2
如果你尝试通过执行以下操作来获取序列的前20个元素(由![]初始化):
list(itertools.islice(power_sequence(2.), 20))
如果发生OverflowError异常,将会抛出异常,并且无法获取任何列表,甚至不能获取异常抛出之前的元素列表。目前没有方法从可能有问题的生成器中获取部分填充的列表。唯一的解决办法是使用append方法,并将其包装在一个捕获异常的代码块中(参见第 12.1 节:什么是异常?,获取更多细节):
generator = power_sequence(2.)
L = []
for iteration in range(20):
try:
L.append(next(generator))
except Exception:
break
9.5 当迭代器表现得像列表一样
一些列表操作也可以在迭代器上使用。我们现在将探讨列表推导和列表合并的等效操作(参见第 3.1.6 节:列表推导,以及第 3.1.5 节:合并列表)。
9.5.1 生成器表达式
生成器有一个等效于列表推导的方式。这样的构造被称为生成器表达式:
g = (n for n in range(1000) if not n % 100)
# generator for 100, 200, ... , 900
这对于计算和积累求和或求积特别有用,因为这些操作是递增的;它们每次只需要一个元素:
sum(n for n in range(1000) if not n % 100)
# returns 4500 (sum is here the built-in function)
在这段代码中,你会注意到sum函数只接收了一个参数,这个参数是一个生成器表达式。请注意,Python 语法允许我们省略生成器外部的圆括号,当生成器作为函数的唯一参数时。
让我们计算黎曼 zeta 函数![],其表达式为

使用生成器表达式,我们可以在一行中计算该序列的部分和:
sum(1/n**s for n in itertools.islice(itertools.count(1), N))
请注意,我们也可以像下面这样定义序列![]的生成器:
def generate_zeta(s):
for n in itertools.count(1):
yield 1/n**s
然后我们简单地通过以下方法获得前N项的和:
def zeta(N, s):
# make sure that you do not use the scipy.sum here
return sum(itertools.islice(generate_zeta(s), N))
我们指出,我们使用这种方式计算 zeta 函数(
)是为了演示生成器的优雅使用方式。它肯定不是评估该函数最准确和计算效率最高的方式。
9.5.2 迭代器合并
我们在第 3.1.5 节:合并列表中看到,确实可以通过将两个或更多列表合并来创建一个新的列表。对迭代器来说,也有类似的操作:
xg = x_iterator() # some iterator
yg = y_iterator() # another iterator
for x, y in zip(xg, yg):
print(x, y)
一旦其中一个迭代器耗尽,合并的迭代器就会停止。这与列表上的 zip 操作行为相同。
9.6 迭代器对象
正如我们之前提到的,for循环只需要一个可迭代对象。特别地,列表是可迭代对象。这意味着列表能够从其内容创建一个迭代器。事实上,这对任何对象(不仅仅是列表)都成立:任何对象都可以是可迭代的。
这是通过__iter__方法实现的,该方法应该返回一个迭代器。这里我们给出一个例子,其中__iter__方法是一个生成器:
class OdeStore:
"""
Class to store results of ode computations
"""
def __init__(self, data):
"data is a list of the form [[t0, u0], [t1, u1],...]"
self.data = data
def __iter__(self):
"By default, we iterate on the values u0, u1,..."
for t, u in self.data:
yield u
store = OdeStore([[0, 1], [0.1, 1.1], [0.2, 1.3]])
for u in store:
print(u)
# result: 1, 1.1, 1.3
list(store) # [1, 1.1, 1.3]
如果你尝试使用迭代器的功能处理一个不可迭代的对象,将会引发异常:
>>> list(3)
TypeError: 'int' object is not iterable
在这个例子中,函数 list 尝试通过调用方法 __iter__ 遍历对象 3。但是这个方法并没有为整数实现,因此引发了异常。如果我们尝试遍历一个不可迭代的对象,也会发生相同的情况:
>>> for iteration in 3: pass
TypeError: 'int' object is not iterable
9.7 无限迭代
无限迭代通过无限迭代器、while 循环或递归得到。显然,在实际情况下,某些条件会停止迭代。与有限迭代的不同之处在于,无法通过粗略查看代码判断迭代是否会停止。
9.7.1 while 循环
while 循环可用于重复执行代码块,直到满足某个条件:
while condition:
<code>
while 循环等价于以下代码:
for iteration in itertools.count():
if not condition:
break
<code>
所以,while 循环用于重复执行代码块,直到满足某个条件时,它等价于一个无限迭代器,可能会在条件满足时停止。此类结构的危险显而易见:如果条件永远不会满足,代码可能会陷入无限循环。
科学计算中的问题是,你不能总是确定一个算法是否会收敛。例如,牛顿迭代法可能根本不收敛。如果该算法被实现为 while 循环,那么对于某些初始条件的选择,相应的代码可能会陷入无限循环。
因此,我们建议在这种任务中,有限迭代器通常更为合适。以下结构通常可以更好地替代 while 循环的使用:
maxit = 100
for nb_iterations in range(maxit):
...
else:
raise Exception(f"No convergence in {maxit} iterations")
第一个优点是,无论发生什么,代码都能保证在有限的时间内执行完毕。第二个优点是,变量 nb_iterations 存储了算法收敛所需的迭代次数。
9.7.2 递归
递归发生在一个函数调用自身时(参见第 7.4 节:递归函数)。
在进行递归时,限制计算机的因素是递归深度,也就是迭代的次数。我们通过考虑一个简单的递归来展示这一点,实际上这个递归没有任何计算操作。它仅仅将零赋值给迭代变量:
def f(N):
if N == 0:
return 0
return f(N-1)
根据你的系统,程序可能会因为 而卡住。结果是 Python 解释器崩溃,而不会抛出进一步的异常。Python 提供了一种机制,当检测到过高的递归深度时,能够引发异常。这个最大递归深度可以通过执行以下命令来改变:
import sys
sys.setrecursionlimit(1000)
但请注意,选择过高的数值可能会危及代码的稳定性,因为 Python 可能会在达到最大深度之前崩溃。因此,通常明智的做法是保持递归限制不变。实际的递归限制值可以通过 sys.getrecursionlimit() 获得。
相比之下,以下非递归程序可以无问题地运行数千万次迭代:
for iteration in range(10000000):
pass
我们主张在 Python 中,如果可能的话,避免使用递归。当然,这仅适用于有合适替代的迭代算法。第一个原因是深度为 N 的递归涉及同时进行 N 次函数调用,这可能会导致显著的开销。第二个原因是递归是一个无限迭代,即很难给出完成递归所需步骤数的上限。
请注意,在某些特殊情况下(树遍历),递归是不可避免的。此外,在某些情况下(递归深度较小),由于可读性,可能更倾向于使用递归程序。
9.8 小结
在本章中,我们研究了迭代器,这是一种与迭代方法的数学描述非常接近的编程构造。你看到了关键字 yield,并接触了有限和无限迭代器。
我们展示了迭代器可以被耗尽。更多特殊的方面,如迭代器推导和递归迭代器,也通过示例进行了介绍和演示。
9.9 练习
例 1: 计算求和的值:

例 2: 创建一个生成器,计算由关系定义的序列:

例 3: 生成所有偶数。
例 4: 设 ![]。在微积分中,已证明 ![]。实验确定最小的数字
,使得 ![]。使用生成器来完成此任务。
例 5: 生成小于给定整数的所有质数。使用称为 厄拉多塞筛法 的算法。
例 6: 通过应用显式欧拉法解微分方程 ![] 结果得到递归:

编写一个生成器,计算给定初始值
和给定时间步长值
的解值
。
例 7: 使用以下公式计算 π:

该积分可以使用复合梯形法则进行近似,即使用以下公式:

其中
。
编写一个 生成器 用于计算值 ![],并通过逐项相加来评估公式。将结果与 SciPy 的 quad 函数进行比较。
习题 8: 设 x = [1, 2, 3] 和 y = [-1, -2, -3]。代码 zip(*zip(x, y)) 有什么作用?请解释其原理。
习题 9: 完全椭圆积分可以通过 scipy.special.ellipk 函数计算。编写一个函数,通过 AGM 迭代来计算所需的迭代次数,直到结果在给定的容差内与实际结果一致(注意,在 ellipk 中输入参数 m 对应于 第 9.3.5 节中的
:数学中迭代器的例子)。
习题 10: 考虑由以下式子定义的序列
:

它单调收敛到零:
。通过分部积分,我们可以证明序列
满足以下递推关系:

使用合适的生成器计算递推的前 20 项,并将结果与通过 scipy.integrate.quad 进行的数值积分结果进行比较。反转递推关系后进行相同的操作:

使用函数 exp 来计算指数函数。你观察到了什么?你有什么解释吗?请参见 [29]。

图 9.2:一个关于逼近函数的收敛性研究 ![]
习题 11: 由于欧拉公式,正弦函数可以表示为:

编写一个生成器,生成函数值 Pk。设定 x=linspace(-1,3.5*pi,200),并通过图示展示在 图 9.2 中,如何随着 k 增大,Pk 如何逼近 sin。可以参考 [11] 中的定理 5.2,第 65 页。
Series 和 Dataframes - 使用 Pandas
在本章中,我们简要介绍 pandas——Python 中用于数据分析和数据处理的核心工具。你将学习如何在 Python 中使用各种时间序列,了解数据框的概念,并学习如何访问和可视化数据。你还会看到一些示例,展示了 pandas 如何与本书中的其他核心模块(即 NumPy 和 Matplotlib)顺畅地互动。
但请注意,本章内容在本书的范围内只能作为开胃菜。它的目的是为你提供基本概念。pandas 中全套的可视化、数据分析和数据转换工具非常强大。
pandas 提供了许多导入数据的方法。本章将介绍其中的一些方法,并提供指导性示例。
本章将涵盖以下主题:
-
一个指导性示例:太阳能电池
-
NumPy 数组和 pandas 数据框
-
创建和修改数据框
-
使用数据框
第十一章:10.1 一个指导性示例:太阳能电池
为了最好地描述 pandas,我们需要数据。因此,在本章中,我们将使用瑞典南部一座私人住宅屋顶上太阳能电池板的生产数据。
在文件solarWatts.dat中,包含了每分钟的电力生产数据(单位:瓦特)。使用分号作为数据分隔符,文件的第一行是标题行,解释了数据列的内容:
Date;Watt
:
2019-10-20 08:22:00 ; 44.0
2019-10-20 08:23:00 ; 61.0
2019-10-20 08:24:00 ; 42.0
:
在另一个文件price.dat中,我们找到每小时电力生产价格(单位:瑞典克朗)。文件的结构与之前相同:
Date;SEK
2019-10-20 01:00 ; 0.32
2019-10-20 02:00 ; 0.28
2019-10-20 03:00 ; 0.29
:
最后,在第三个文件rates.dat中,我们找到了从瑞典克朗到欧元(€)的每日汇率:
Date;Euro_SEK
2019-10-21 ; 10.7311
2019-10-22 ; 10.7303
2019-10-23 ; 10.7385
:
我们希望从这些数据中提取每天的最大和最小产量、每月的日照小时数、迄今为止最阳光明媚的一天、日出和日落时间以及一些经济信息。我们还打算以图形方式呈现数据。
请注意,数据不是在相同的时间点收集的,可能会有缺失数据。
每个文件包含一个所谓的时间序列,也就是依赖于时间的数据或时间依赖函数的离散采样。
我们现在介绍 pandas 中的数据框概念,并将其与 NumPy 数组进行比较。
10.2 NumPy 数组和 pandas 数据框
让我们从仅仅看一个![]的 NumPy 数组示例开始:
A=array( [[ 1., 2., 3.],
[4., 5., 6.]])
它显示为:
[[1\. 2\. 3.]
[4\. 5\. 6.]]
它的元素可以通过简单地按行和列计数生成的索引进行访问,例如,A[0,1]。
这个矩阵可以通过保持相同的数据和顺序,但以不同的方式表示和访问,转换为 pandas 的数据类型DataFrame:
import pandas as pd
A=array( [[ 1., 2., 3.],
[ 4., 5., 6.]] )
AF = pd.DataFrame(A)
这个DataFrame对象,我们将在本章中更详细地解释,显示为:
0 1 2
0 1.0 2.0 3.0
1 4.0 5.0 6.0
我们看到,pandas 数据框有额外的行和列标签,称为index(索引)和columns(列)。这些是数据框的元数据。
在这里,它们与 NumPy 的索引方法一致,但并非总是如此。索引和列元数据使得 pandas 数据框能够以传统表格设计中已知的方式标记数据:
AF.columns = ['C1','C2','C3']
AF.index = ['R1', 'R2']
这给出了如下输出:
C1 C2 C3
R1 1.0 2.0 3.0
R2 4.0 5.0 6.0
现在我们将看到如何使用这些标签来访问子框架或数据框中的单个值。
10.2.1 索引规则
类似于字典通过键访问值的方式,pandas 数据框通过行标签——数据框索引——和列标签来访问单个值:
AF.loc['R1', 'C2'] # this returns 2.0
或者生成一个子框架:
AF.loc[['R1','R2'],['C1','C2']]
结果为:
C1 C2
R1 1 2.0
R2 4 5.0
你也可以通过使用索引标签来访问完整的行:
AF.loc['R1']
这将返回一个 pandas Series对象:
C1 1.0
C2 2.0
C3 3.0
Name: R1, dtype: float64
如果loc或iloc使用列表参数或切片调用,结果将是一个数据框。
以这种方式,单个数据框元素也可以如下访问:
AF.loc['R1'].loc['C1'] # returns 1.0
可以通过以下方式直接访问整列:
AF['C1']
这又会返回一个 pandas Series对象:
R1 1.0
R2 4.0
Name: C1, dtype: float64
另外,列标签可以作为属性使用,AF.C1。
单列是 pandas 数据类型Series的一个实例。
type(AF.C1) == pd.Series # True
注意,pandas 系列没有列标签。它只是对应于单一类型测量数据的单列。
仍然可以通过应用数据框方法iloc使用经典索引:
AF.iloc[[0],[1]]
这将返回:
C2
R1 2.0
如果loc或iloc使用列表参数或切片调用,结果将是一个数据框:
AF.loc['R1':,'C2':]
或者等效地:
AF.loc[['R1','R2'], ['C2','C2']]
当使用一对单一标签进行调用时,只会返回数据框中的一个元素:
AF.loc['R1','C2'] # returns 2.0
这与 NumPy 处理数组索引的方式完全一致。回想一下,使用切片索引返回一个数组,而使用单个整数索引返回被索引数组的单个元素。
需要注意的是,loc和iloc不是数据框方法。它们是具有__getitem__方法的属性;参见第 8.1.5 节:特殊方法。这解释了为什么使用方括号而不是圆括号。
10.3 创建和修改数据框
现在我们回到太阳能电池数据,并解释如何从数据文件创建数据框。给定数据的文件格式为 CSV。文件中的每一行包含一条数据记录,数据分隔符是逗号或其他字符字符串。这里,我们使用分号作为分隔符,因为在许多国家,逗号用于表示小数点。
10.3.1 从导入数据创建数据框
我们希望以这样的方式组织数据框,即使用日期作为数据框的索引。为了更好地操作日期,我们还希望数据导入过程能自动将日期字符串转换为 pandas Timestamp对象。最后,你可能已经注意到,数据文件中日期的书写方式是 ISO 格式YY-MM-DD,而不是美国的MM-DD-YY格式或欧洲的DD-MM-YY格式。我们可以把它列入愿望清单,期望 pandas 能够自动识别日期格式并执行正确的转换:
solarWatts = pd.read_csv("solarWatts.dat",
sep=';',
index_col='Date',
parse_dates=[0], infer_datetime_format=True)
pandas 命令 read_csv 是中心工具。它具有比我们在此处使用的更多参数,并仔细研究它们的功能可以节省大量编程工作。
现在我们有一个包含超过 200,000 条数据记录的 pandas 数据帧 solarWatts。让我们直接检查第一个:
solarWatts.iloc[0]
这将返回以下输出:
Watt 7893.0
Name: 2019-10-06 13:23:00, dtype: float64
我们还可以询问最后一个日期。为此,我们使用数据帧的 index 属性:
solarWatts.index[-1] # asking for the last index
这返回一个 pandas Timestamp 对象 Timestamp('2020-06-27 17:54:00')。可以使用该对象或其字符串表示进行索引。
Timestamp 对象使得在处理日期时能够轻松进行计算、定义时间范围以及比较日期。我们可以检查测量之间经过了多少时间:
# returns: Timedelta('0 days 00:01:00')
solarWatts.index[1]-solarWatts.index[0]
生成的 Timedelta 对象告诉我们,第一条和第二条记录之间经过了一分钟。
但是所有数据都是每分钟收集的吗?由于 pandas 兼容 NumPy,我们可以应用 NumPy 的 diff 命令,它返回一个带有 timedelta64[ns] 数据类型的数组,即差异以纳秒显示。我们直接将结果转换为分钟,并查询最大差异:
max(numpy.diff(solarWatts.index).astype('timedelta64[m]'))
使用 numpy.argmax,我们找到了对应的日期:
solarWatts.iloc[np.argmax(np.diff(solarWatts.index))
在这段代码中,我们首先形成一个时间差数组 (timedelta)。我们将其用作索引来定位 pandas 数据帧中的数据记录。
10.3.2 设置索引
数据帧的默认索引是行号。当创建数据帧且未指定索引时,这些索引将自动生成。这里是一个例子。
我们从一个列表列表创建一个数据帧:
towns=[['Stockholm', 'Sweden', 188,975904],
['Malmö', 'Sweden', 322, 316588],
['Oslo', 'Norway', 481, 693491],
['Bergen', 'Norway', 464, 28392]]
town=pd.DataFrame(towns, columns=['City','Country','area','population'])
这将生成一个带有按其行号标记的行的数据帧:
City Country area population
0 Stockholm Sweden 188 975904
1 Malmö Sweden 322 316588
2 Oslo Norway 481 693491
3 Bergen Norway 464 28392
通过选择一个列作为索引来更改此行为。该列可以复制,一个用作索引,另一个属于数据部分的数据帧,或者将其移动以替换默认索引列:
town.set_index('City', drop=False) # duplicating
# droping the column and making an index out of it
town.set_index('City', drop=True)
当 drop 参数设置为 True(默认)时,生成一个新的数据帧,其外观如下:
Country area population
City
Stockholm Sweden 188 975904
Malmö Sweden 322 316588
Oslo Norway 481 693491
Bergen Norway 464 283929
Trondheim Norway 322 199039
附加参数 inplace 允许直接更改数据帧,即原地,而不生成新对象。
pandas 不仅限于单一索引;事实上,可以选择多个列作为索引。这种多重索引打开了 pandas 的分层索引特性,我们将在 第 10.4.3 节 中再次遇到它:数据分组。
通过列列表指定多个索引:
town.set_index(['Country','City'], inplace=True)
这给出了以下输出:
area population
Country City
Sweden Stockholm 188 975904
Malmö 322 316588
Norway Oslo 481 693491
注意数据帧当前的显示方式:第一个索引 Country 被视为比第二个索引 City 更高的层次。
我们可以像这样处理数据帧中的所有瑞典城镇:
town.loc['Sweden']
我们甚至可以针对特定的索引进行操作:
town.loc[('Sweden','Malmö')]
10.3.3 删除条目
数据帧中的条目通过 drop 方法删除。
再次使用前一节的数据帧:
town=pd.DataFrame(towns, columns=['City','Country','area','population'])
town.set_index('City', inplace=True)
通过以下方法删除整行:
town.drop('Bergen', axis=0)
参数axis在此指定我们查找的是一行。删除一行需要列标签和正确的参数axis:
town.drop('area', axis=1)
10.3.4 合并数据框
从我们为本章提供的三个数据文件中,我们使用第一个文件solarwatts.dat来建立数据框solarWatts;参见第 10.3.1 节,从导入数据创建数据框。以类似的方式,我们可以从其他两个文件中创建数据框price和rates。
现在我们展示如何将这三个数据框合并成一个,并处理结果数据框中缺失数据的行。
首先,我们将solarWatts与price合并。为此,我们使用 pandas 命令merge:
solar_all=pd.merge(solarWatts, price, how='outer', sort=True, on='Date')
solar_all=pd.merge(solar_all, rates, how='outer', sort=True, on='Date')
它将两个数据框中都存在的列Date设置为新数据框的索引。参数how定义了如何设置新的索引列。通过指定outer,我们选择了两个索引列的并集。最后,我们希望对索引进行排序。
由于solarWatts的数据是每分钟都有的,而价格是每小时变化一次,我们将在新的数据框中获得如下行:
Watt SEK Euro_SEK
Date
2019-10-06 15:03:00 4145.0 NaN NaN
2019-10-06 15:04:00 5784.0 NaN NaN
缺失数据会自动填充为NaN(意味着不是数字;参见第 2.2 节:数值类型)。
现在我们将研究如何处理缺失数据。
10.3.5 数据框中的缺失数据
我们在上一节中看到,缺失数据通常由NaN表示。缺失数据的表示方式取决于列的数据类型。缺失的时间戳由 pandas 对象NaT表示,而缺失的其他非数值类型数据则由None表示。
数据框方法isnull返回一个布尔型数据框,在所有缺失数据的地方显示True。
我们将在返回到太阳能电池数据示例之前,研究处理缺失数据的各种方法。
让我们在一个小数据框上演示这些方法:
frame = pd.DataFrame(array([[1., -5., 3., NaN],
[3., 4., NaN, 17.],
[6., 8., 11., 7.]]),
columns=['a','b','c','d'])
该数据框显示如下:
a b c d
0 1.0 -5.0 3.0 NaN
1 3.0 4.0 NaN 17.0
2 6.0 8.0 11.0 7.0
可以通过不同方式处理包含缺失数据的数据框:
- 删除所有包含缺失数据的行,
frame.dropna(axis=0):
a b c d
2 6.0 8.0 11.0 7.0
- 删除所有包含缺失数据的列,
frame.dropna(axis=1):
a b
0 1.0 -5.0
1 3.0 4.0
2 6.0 8.0
- 通过使用前一行的数据填充缺失数据,
frame.fillna(method='pad', axis=0):
a b c d
0 1.0 -5.0 3.0 NaN
1 3.0 4.0 3.0 17.0
2 6.0 8.0 11.0 7.0
在这种情况下,如果没有可用的数据进行填充,则NaN将保持不变。
- 按列插值数值数据,
frame.interpolate(axis=0, method='linear'):
a b c
0 1.0 -5.0 3.0 NaN
1 3.0 4.0 7.0 17.0
2 6.0 8.0 11.0 7.0
再次强调,无法通过插值计算的值将保持为NaN。
我们使用插值方法的方式假设数据是在等距网格上收集的。如果索引是数字或日期时间对象,它可以作为
-轴来使用。例如,使用参数值method='polynomial'即可实现这一点。
通过使用inplace参数,可以在不同的列上使用不同的方法:
frame['c'].fillna(method='pad', inplace=True)
frame['d'].fillna(method='bfill',inplace=True)
现在我们回到太阳能电池的例子。电价按小时变化,货币汇率按日变化,而太阳能电池板的能量生产则在白天时段每分钟记录一次。这就是数据框合并步骤引入许多 NaN 值的原因(参见第 10.3.4 节,合并数据框)。
我们通过填充来替换这些缺失值:
solar_all['SEK'].fillna(method='pad', axis=0, inplace=True)
solar_all['Euro_SEK'].fillna(method='pad', axis=0, inplace=True)
表中仍然存在NaN值。太阳能电池仅在白天有足够光照时产生能量。在这些时段之外,Watt 列的值为NaN。
在下一节中,我们将使用 pandas 的 dataframe 绘图功能来可视化数据,并且我们会看到NaN值在图中被简单地忽略。
10.4 使用 dataframe
到目前为止,我们已经看到了如何创建和修改 dataframe。现在,我们转向数据解释部分。我们将查看可视化的示例,展示如何进行简单的计算,并看到如何对数据进行分组。这些都是进入 pandas 世界的垫脚石。这个模块的强大之处在于其广泛的统计工具。我们将这些工具的介绍留给实用统计学教材,而在这里我们关注 pandas 编程的基本原则。我们不追求完整性。再一次,让我们先品尝一下开胃菜。
10.4.1 从 dataframe 绘图
为了演示绘图功能,我们绘制了 2020 年 5 月 16 日的能源价格变化。为此,我们从那一天的数据中构建了一个子数据框:
solar_all.loc['2020-05-16'].plot(y='SEK')
你可以看到,我们这里使用的是完整的日期索引。这是切片的简短形式:
solar_all.loc['2020-05-16 00:00':'2020-05-16 23:59']
结果图(图 10.1)展示了电价在典型一年中的小时变化,单位为瑞典克朗。

图 10.1:绘制 dataframe 的一列;2020 年 5 月 16 日每千瓦时的瑞典克朗(SEK)每小时价格
pandas 的绘图命令是基于 matplotlib.pyplot 模块的 plot 函数构建的,我们在第六章,绘图中见过它。
它接受相同的参数,例如,线型或标记。
x 轴的数据来自 dataframe 的索引,除非另有指定。或者,你可以绘制一个 dataframe 列与另一个列的关系。
折线图在数据缺失的地方会留下空白。你可以在下图中看到这一点,该图展示了 2020 年 6 月第一周太阳能电池的功率。由于在白天时段外没有太阳能电池数据,图中会有空白。见图 10.2。

图 10.2:具有缺失数据(NaN)的数据序列图:2020 年 6 月第一周太阳能电池的瓦特功率。你可以清楚地看到没有能量产生的时段。
我们用来绘制此图的命令如下:
ax1=solar_all.loc['2020-06-20':'2020-06-21'].plot(None,'Watt')
ax1.set_ylabel('Power')
在这里,你可以看到使用轴对象(在本例中为 ax1)的优势。这使得我们可以修改轴标签或图例,例如,ax1.legend(['功率 [W]))。
在接下来的章节中,我们将提供更多的绘图示例,展示如何在数据框内进行一些计算,以及如何对数据进行分组。
10.4.2 数据框内的计算
我们可以通过对数据框列中的每个元素应用函数来进行简单的计算,即对函数的元素逐一应用。这些函数可以是内置的 Python 函数、NumPy 函数或用户定义的函数,如 lambda 函数(请参见 第 7.7 节,匿名函数)。
最简单的方式是直接对列进行操作。在以下示例中,我们将瓦特转换为千瓦,并使用当天的汇率将瑞典克朗(SEK)转换为欧元:
solar_converted=pd.DataFrame()
solar_converted['kW']=solar_all['Watt']/1000
solar_converted['Euro']=solar_all['SEK']/solar_all['Euro_SEK']
默契地,我们还调整了列标签以符合转换后的单位。
命令solar_converted.loc['2020-07-01 7:00':'2020-07-01 7:04']然后返回了 2020 年 7 月 1 日的数据:
kW Euro
Date
2020-07-01 07:00:00 2.254 0.037147
2020-07-01 07:01:00 1.420 0.037147
2020-07-01 07:02:00 2.364 0.037147
2020-07-01 07:03:00 0.762 0.037147
2020-07-01 07:04:00 2.568 0.037147
我们还可以将 NumPy 的(通用)函数应用于整个行或列。以下示例计算了太阳能电池板提供的最大功率:
import numpy as np
np.max(solar_all['Watt']) # returns 12574
为了打印对应的日期,我们使用了函数 argmax:
print(solar_all.index[np.argmax(solar_all['Watt'])])
打印的日期是:
2020-05-16 10:54:00
从前面的示例中可以看出,缺失数据用 NaN 标记,实际上它被视为缺失数据,也就是说,仿佛它根本不存在。由于并非所有计算方法都具备这个特性,因此在这些情况下,将 NaN 替换为 0 可能更为安全:
solar_all['Watt'].fillna(value=0., inplace=True)
对于应用通用的用户定义函数,有一个数据框方法 apply。它对整个数据框进行按行或按列的操作。
10.4.3 数据分组
数据分组的能力是 pandas 数据框的基本功能之一。在太阳能电池板示例中,您看到我们有每分钟一次的测量频率。如果您想按小时或按日报告数据怎么办?我们只需将数据分成组,并以规定的方式对数据进行聚合。
以下示例形成了一个新的数据框,包含标记为 Watt 和 SEK 的两列,分别报告了每日太阳能电池板的峰值功率和平均价格(以 SEK 为单位):
solar_day=solar_all.groupby(solar_all.index.date).agg({'Watt':'max',
'SEK':'mean'})
同样,我们可以使用数据框方法 plot 来可视化结果:
solar_day.index=pd.to_datetime(solar_day.index,format='%Y-%m-%d')
ax=solar_day.loc['2020-06-01':'2020-06-30'].plot.bar('Watt')
注意,我们创建了一个轴对象 ax,以便更改
轴上的刻度标签:
ax.set_xticklabels([tf.strftime("%m-%d")
for tf in solarday.loc['2020-06-01':'2020-06-30'].index])
这产生了图 10.3:

图 10.3:2020 年 6 月每日太阳能电池板的峰值功率
在这里,我们将一个月内的所有天数进行了分组。
我们还可以在分组时跳过层级:在前面的示例中,我们按月分组了天数,但我们也可以按月内的小时进行分组,甚至可以从整个数据集中进行分组。例如,若要查看电能价格是否通常每天有两个峰值,我们可以按小时对数据进行分组并计算平均值:
solar_hour=solar_all.groupby(solar_all.index.hour).agg({'SEK':mean})
ax=solar_hour.plot()
ax=solar_hour.plot(marker='*')
ax.set_title('The average energy price change on a day')
ax.set_xlabel('hour of day')
ax.set_ylabel('SEK/kWh')
这些命令产生了图 10.4:

图 10.4:按小时分组的数据结果
数据分组通常是解决需要对分组数据进行计算步骤的特殊问题的起点。例如,在我们的示例中,我们有太阳能电池的逐分钟功率(以瓦特为单位),但这个系统的每小时能量输出(以千瓦时为单位)是多少?要回答这个问题,我们必须:
-
以层次化的方式按小时对数据进行分组。
-
形成基于 60 分钟间隔的离散数据积分。
-
将其存储在一个新的数据框或序列对象中。
对于第一个任务,我们利用 pandas 的层次索引功能。我们按年份、月份、日期和小时进行层次分组:
grouping_list=[solar_all.index.year, solar_all.index.month,
solar_all.index.day, solar_all.index.hour]
solar_hour=solar_all.groupby(grouping_list)
在这种情况下可以进行积分,因为我们从每分钟数据开始,只需对数据进行求和:
# integrating by summing up the data
solar_hour=solar_hour.agg({'Watt':sum})
solar_hour=solar_hour/(1000*60) # Conversion from Wmin to kWh
然后我们以常规方式可视化结果:
ax=solar_hour['Watt'].loc[(2020,6,19)].plot.bar()
ax.set_title('Energy production on June, 19 2020')
ax.set_xlabel('Hour')
ax.set_ylabel('Energy [kWh]')
这将给我们带来图 10.5:

图 10.5:通过层次分组生成的数据框示例图
或者,我们也可以使用命令 scipy.integrate.simps 来对离散数据进行积分,将其作为聚合方法 agg 的一个参数。由于此函数不处理缺失数据,因此在第 10.4.2 节中的备注——数据框中的计算——适用,我们需要在开始之前将所有 NaN 值替换为 0。
10.5 小结
在这一章中,你简要了解了 pandas,并看到了 NumPy 数组概念如何扩展到数据框。我们没有对数据框的所有可能性进行详尽的解释,而是通过一个太阳能电池能量数据的示例,带你完成了使用 pandas 的第一步:从文件中设置数据框、合并数据框、分组数据并进行计算。
通过图形用户界面进行通信
图形用户界面(GUIs) 是一种便捷的工具,用于将用户数据输入到 Python 程序中。很可能,你已经使用过诸如 选择列表、单选按钮 或 滑块 等工具与应用程序进行交互。在本章中,我们将展示如何将这些工具添加到程序中。本章基于 Matplotlib 模块提供的工具,我们已经在 第 6.2 节:“直接操作 Matplotlib 对象”中见过它们。
尽管有像 Tkinter 这样的替代模块可以用来设计更复杂的图形用户界面(GUI),但 Matplotlib 作为一种理想的入门工具,门槛较低,是与代码进行交互的一种便捷方式。
本章的目的是演示 Matplotlib 中 GUI 编程的基本原理。解释事件、滑块动作或鼠标点击及其与所谓回调函数的交互,并提供一些示例。
显然,我们并不追求完整性。在理解了基本原理之后,Matplotlib 文档是一个关于各种小部件及其参数的详细信息宝库。
本章将涵盖以下主要主题:
-
一个指导小部件的示例
-
按钮小部件和鼠标事件
第十二章:11.1 小部件的指导示例
本节中,我们展示了 小部件 的基本组件 11.1 小部件指导示例及其在 Python 中的对应部分。我们通过以下图示的指导示例来实现这一点:

图 11.1:一个小部件,用于显示用户给定频率的

在这个图形中,我们可以看到顶部有一个滑块条。使用计算机鼠标,可以将蓝色条从左到右移动,右侧会显示一个值,范围在 1 到 5 之间,表示
。
相应地,显示在绘图窗口中的正弦波频率发生变化。
该小部件由三个部分组成:
-
一个包含坐标轴对象和绘图的图形对象
-
包含滑块对象的坐标轴对象
-
一个回调函数,用于在滑块值变化时更新绘图
我们在 第 6.2 节 中讨论了如何编写第一部分:“直接操作 Matplotlib 对象”。
在下面的代码片段中,我们首先创建一个指定大小的图形,然后创建一个足够大的坐标轴对象,并将其放置到图形中,使得坐标轴的左下角与图形坐标
对齐。然后,要求用户输入一个介于 1 到 5 之间的浮动数字,用于表示频率
:
from matplotlib.pyplot import *
fig = figure(figsize = (4,2))
ax = axes([0.1, 0.15, 0.8, 0.7]) # axes for the plot
omega=float(input('Give a value for $\omega$ between 1 and 5:\n'))
x = linspace(-2*pi, 2*pi, 800)
ax.set_ylim(-1.2, 1.2)
lines, = ax.plot(x, sin(2.*pi*omega*x))
现在,在下一步中,我们添加了第二个轴对象,并在其中放入一个滑块:
from matplotlib.widgets import Slider
sld_ax = axes([0.2, 0.9, 0.6, 0.05]) # axes for slider
sld = Slider(sld_ax, '$\omega$ [Hz]', 1., 5., valinit=1.)
omega=sld.val
滑块的轴对象sld_ax是通过给定其尺寸和左下角点在图形坐标系统中的位置来定义的。
新的构建元素是Slider对象。它的构造函数使用滑块轴、标签以及显示在滑块左侧和右侧的最大值和最小值。滑块对象有一个属性val,它包含由滑块位置给出的值。
最初,滑块的位置设置为valinit。
最后部分是程序的核心部分——回调函数和更新图表的操作,每当滑块值发生变化时:
def update_frequency(omega):
lines.set_ydata(np.sin(2.*pi*omega*x))
sld.on_changed(update_frequency)
回调函数是指在滑块(或其他小部件对象)发生变化时被调用的函数。在我们的例子中,它是函数update_frequency。滑块方法on_changed定义了每当滑块值发生变化时要执行的操作。在这里,update_frequency函数被调用,传入滑块值val作为其唯一参数。
我们将通过将各个部分结合起来来结束本节介绍。注意,已经不再需要最初使用的输入函数,因为我们现在使用了更为优雅的 GUI 方法来输入值。我们还为图表提供了一个图例,用以显示滑块值的使用情况。注意字符串格式化和 LaTeX 命令是如何结合使用的:
from matplotlib.pyplot import *
from matplotlib.widgets import Slider
fig = figure(figsize = (4,2))
sld_ax = axes([0.2, 0.9, 0.6, 0.05]) # axes for slider
ax = axes([0.1, 0.15, 0.8, 0.7]) # axes for the plot
ax.xaxis.set_label_text('Time [s]')
ax.yaxis.set_label_text('Amplitude [m]')
sld = Slider(sld_ax, '$\omega$ [Hz]', 1., 5., valinit=1.5)
omega=sld.val
x = linspace(-2*pi, 2*pi, 800)
ax.set_ylim(-1.2, 1.2)
# Plot of the initial curve
# Note, how LaTeX commands and string formatting is combined in the
# next command
lines, = ax.plot(x, sin(2.*pi*omega*x), label=f'$\sin(2\pi\; {omega} x)$ ')
ax.legend()
def update_frequency(omega):
lines.set_ydata(np.sin(2.*pi*omega*x))
# A legend is updated by p text box widget set_varroviding tuples
# with line objects and tuples with labels
ax.legend((lines,),(f'$\sin(2\pi\; {omega} x)$',))
sld.on_changed(update_frequency)
在本节中,我们展示了使用小部件进行用户输入的方法。这是一种用户友好的方式来请求参数并显示相关结果。
11.1.1 使用滑块条改变值
在上一节中,我们介绍了滑块的使用。滑块最重要的属性是其值,val。这个值会传递给回调函数。
其他属性包括滑块值的限制valmin和valmax,以及一个步进功能valstep,使得值的变化变得离散。格式化属性valfmt允许我们指定如何显示valmin和valmax。
在下一个示例中,我们修改了上面的滑块定义,并为它提供了这些更具体的属性:
sld = Slider(sld_ax, label='$\omega$ [Hz]', valmin=1., valmax=5.,
valinit=1.5, valfmt='%1.1f', valstep=0.1)
在这个例子中,格式化参数%1.1f表示值应作为浮动小数显示,左侧有一位数字,右侧也有一位数字。
一个包含两个滑块的示例
我们通过提供两个滑块来扩展前面的示例,一个用于振幅,另一个用于频率,并将滑块设置为垂直模式。
首先,我们定义了两个滑块轴:
sldo_ax = axes([0.95, 0.15, 0.01, 0.6]) # axes for frequency slider
slda_ax = axes([0.85, 0.15, 0.01, 0.6]) # axes for amplitude slider
然后,我们定义了两个滑块,分别具有不同的最小值和最大值,以及一个方向参数:
sld_omega = Slider(sldo_ax, label='$\omega$ [Hz]', valmin=1.,
valmax=5., valinit=1.5, valfmt='%1.1f',
valstep=0.1, orientation='vertical')
sld_amp = Slider(slda_ax, label='$a$ [m]', valmin=0.5,
valmax=2.5, valinit=1.0, valfmt='%1.1f',
valstep=0.1, orientation='vertical')
两个滑块有不同的回调函数。它们使用相关滑块的值作为参数,并将另一个滑块的值作为全局变量:
def update_frequency(omega):
lines.set_ydata(sld_amp.val*sin(2.*pi*omega*x))
ax.legend((lines,),(f'${sld_amp.val} \sin(2\pi\; {omega} x)$',))
def update_amplitude(amp):
lines.set_ydata(amp*sin(2.*pi*sld_omega.val*x))
ax.legend((lines,),(f'${amp} \sin(2\pi\; {sld_omega.val} x)$',))
ax.set_ylim(-(amp+0.2), amp+0.2)
sld_omega.on_changed(update_frequency)
sld_amp.on_changed(update_amplitude)
在下图中,显示了 GUI:

图 11.2:由两个垂直滑块给定的曲线参数
一些操作要求用户等待,直到看到变化的结果。通常,将更改收集起来后再进行更新会更加方便和用户友好。可以通过一个特殊的按钮小部件来实现这一点,接下来的部分将介绍它。
11.2 按钮小部件与鼠标事件
按钮小部件是一个简单的小工具,具有广泛的实用应用。我们通过继续前一个例子并向 GUI 添加一个更新按钮来介绍它。然后我们使用按钮从曲线中提取数据。
11.2.1 使用按钮更新曲线参数
到目前为止,我们已经在滑块值改变时更新了曲线,并使用了on_changed方法。复杂的图形输出可能需要一些计算时间来更新。在这种情况下,您希望设计 GUI,使得首先通过滑块设置曲线参数,然后按下一个按钮以启动曲线更新。
这可以通过Button小部件实现:
from matplotlib.widgets import Button
button_ax = axes([0.85, 0.01, 0.05, 0.05]) # axes for update button
btn = Button(button_ax, 'Update', hovercolor='red')
在这个例子中,坐标设置的方式使得按钮位于两个滑块下方。按钮上标有“更新”字样,当鼠标悬停在按钮上时,按钮的颜色会变为红色。
这个小部件有一个方法,on_clicked,它代替了滑块方法on_changed:
def update(event):
lines.set_ydata(sld_amp.val*sin(2.*pi*sld_omega.val*x))
ax.legend((lines,),
(f'${sld_amp.val:1.1f} \sin(2\pi\; \
{sld_omega.val:1.1f} x)$',))
btn.on_clicked(update)
回调函数有一个参数,event。在这个例子中没有使用它。它可以用来根据鼠标点击的方式(单击、双击、右键点击或左键点击)为按钮分配不同的操作。我们将在下一节更详细地讨论事件。
11.2.2 鼠标事件与文本框
在上一个例子中,我们遇到了按钮小部件的鼠标事件。我们也可以在不使用按钮的情况下捕捉鼠标事件。为此,我们需要将一个普通的按钮点击事件连接到回调函数。
为了演示这一点,我们再次考虑之前生成的正弦波图,并通过鼠标点击选取点,显示其坐标在图中的文本框中。如果右键点击,我们还通过一个红色圆圈在图中显示所选的点。
首先,我们准备一个文本框小部件。我们已经知道,首先必须通过定义一个坐标轴对象来定位小部件,然后为小部件提供所需的属性:
from matplotlib.widgets import TextBox
textbox_ax=axes([0.85,0.6,0.1,0.15])
txtbx=TextBox(textbox_ax, label='', initial='Clicked on:\nx=--\ny=--')
我们为文本框提供了没有标签但有一些初始文本的框。文本框有一个包含文本的val属性。现在我们将根据鼠标点击的位置改变这个属性:
points, = ax.plot([], [], 'ro')
def onclick(event):
if event.inaxes == ax:
txtbx.set_val(
f'clicked on:\nx={event.xdata:1.1f}\ny={event.ydata:1.1f}')
if event.button==3: # Mouse button right
points.set_xdata([event.xdata])
points.set_ydata([event.ydata])
else:
txtbx.set_val(f'clicked on:\noutside axes\n area')
fig.canvas.draw_idle()
cid = fig.canvas.mpl_connect('button_press_event', onclick)
由于没有像按钮小部件那样的控件,我们必须将事件与回调函数关联。通过画布方法mpl_connect实现这一点。回调函数onclick响应鼠标点击的位置。通过事件属性inaxes,我们知道鼠标点击发生在哪个坐标轴对象上。通过这个,我们甚至可以获取关于按下的按钮的信息,并且鼠标点击的精确坐标也能获得。回调函数使用了一个Line2D对象,points,在回调函数首次使用之前,它已用空数据列表进行初始化。这个初始化定义了绘图样式,在这个例子中是红色圆圈:

图 11.3:通过鼠标点击在曲线上显示一个值
11.3 总结
在本章中,我们学习了 Matplotlib 中 GUI 编程的基本原理。我们还考虑了一个示例,帮助我们更好地理解小部件。在下一章中,我们将学习错误和异常处理。
错误和异常处理
在这一章中,我们将讨论错误和异常,以及如何查找和修复它们。处理异常是编写可靠且易用代码的重要部分。我们将介绍基本的内置异常,并展示如何使用和处理异常。我们还将介绍调试,并展示如何使用内置的 Python 调试器。
在本章中,我们将讨论以下主题:
-
什么是异常?
-
查找错误:调试
第十三章:12.1 什么是异常?
程序员(即使是有经验的程序员)最先遇到的错误是代码语法不正确,即代码指令格式不正确。
考虑这个语法错误的示例:
>>> for i in range(10)
File “<stdin>”, line 1
for i in range(10)
^
SyntaxError: invalid syntax
错误发生是因为 for 声明末尾缺少冒号。这是引发异常的一个示例。在 SyntaxError 的情况下,它告诉程序员代码语法错误,并且还会打印出发生错误的行,箭头指向该行中问题所在的位置。
Python 中的异常是从一个基类 Exception 派生(继承)而来的。Python 提供了许多内置异常。一些常见的异常类型列在 表 12.1 中。
这里有两个常见的异常示例。如你所料,ZeroDivisionError 是在尝试除以零时引发的:
def f(x):
return 1/x
>>> f(2.5)
0.4
>>> f(0)
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "exception_tests.py", line 3, in f
return 1/x
ZeroDivisionError: integer division or modulo by zero
| 异常 | 描述 |
|---|---|
IndexError |
索引超出范围,例如,当 v 只有五个元素时,尝试访问 v[10]。 |
KeyError |
引用未定义的字典键。 |
NameError |
未找到名称,例如,未定义的变量。 |
LinAlgError |
linalg 模块中的错误,例如,在求解含有奇异矩阵的系统时。 |
ValueError |
不兼容的数据值,例如,使用不兼容的数组进行 dot 运算。 |
IOError |
I/O 操作失败,例如,文件未找到。 |
ImportError |
模块或名称在导入时未找到。 |
表 12.1:一些常用的内置异常及其含义
除以零时会引发 ZeroDivisionError 并打印出文件名、行号和发生错误的函数名。
如我们之前所见,数组只能包含相同数据类型的元素。如果你尝试赋值为不兼容的类型,将引发 ValueError。一个值错误的示例是:
>>> a = arange(8.0)
>>> a
array([ 0., 1., 2., 3., 4., 5., 6., 7.])
>>> a[3] = 'string'
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: could not convert string to float: string
这里,ValueError 被引发,因为数组包含浮点数,而元素不能赋值为字符串。
12.1.1 基本原则
让我们看看如何通过使用 raise 引发异常并通过 try 语句捕获异常的基本原则。
引发异常
创建错误称为引发异常。你在前一部分中看到了异常的一些示例。你也可以定义自己的异常,使用预定义类型的异常或使用未指定类型的异常。引发异常的命令如下:
raise Exception("Something went wrong")
这里引发了一个未指定类型的异常。
当某些事情出错时,可能会诱使你打印出错误信息,例如,像这样:
print("The algorithm did not converge.")
这不推荐使用,原因有多个。首先,打印输出很容易被忽视,特别是当信息被埋在控制台打印的其他许多消息中时。其次,更重要的是,这会让你的代码无法被其他代码使用。调用代码不会读取你打印的内容,并且无法知道发生了错误,因此也无法处理它。
由于这些原因,最好改为引发异常。异常应该总是包含描述性消息,例如:
raise Exception("The algorithm did not converge.")
这个消息会清晰地显示给用户。它还为调用代码提供了一个机会,使其知道发生了错误,并可能找到解决方法。
这里是一个典型的示例,用于在函数内检查输入,确保它在继续之前是可用的。简单地检查负值和正确的数据类型,确保函数的输入符合计算阶乘的预期:
def factorial(n):
if not (isinstance(n, (int, int32, int64))):
raise TypeError("An integer is expected")
if not (n >=0):
raise ValueError("A positive number is expected")
如果给定了不正确的输入,函数的用户会立即知道是什么错误,而用户有责任处理该异常。请注意,在抛出预定义异常类型时,使用异常名称,在这个例子中是ValueError,后面跟着消息。通过指定异常类型,调用代码可以根据引发的错误类型决定如何不同地处理错误。
总结来说,抛出异常总比打印错误信息更好。
捕获异常
处理异常被称为捕获异常。检查异常是通过try和except命令来完成的。
异常会停止程序的执行流程,并查找最近的try封闭块。如果异常没有被捕获,程序单元会被跳出,并继续向调用栈中更高层的程序单元查找下一个封闭的try块。如果没有找到任何块并且异常没有被处理,程序执行会完全停止,并显示标准的回溯信息。
让我们看看之前的阶乘示例,并用try语句来使用它:
n=-3
try:
print(factorial(n))
except ValueError:
print(factorial(-n)) # Here we catch the error
在这种情况下,如果try块中的代码引发了ValueError类型的错误,异常将会被捕获,并且执行except块中的操作。如果try块中没有发生异常,except块会完全跳过,程序继续执行。
except语句可以捕获多个异常。这是通过将它们简单地组合成一个元组来完成的,例如:
except (RuntimeError, ValueError, IOError):
try块也可以有多个except语句。这使得可以根据异常类型不同来分别处理异常。让我们看一下另一个多个异常类型的示例:
try:
f = open('data.txt', 'r')
data = f.readline()
value = float(data)
except FileNotFoundError as FnF:
print(f'{FnF.strerror}: {FnF.filename}')
except ValueError:
print("Could not convert data to float.")
在这里,如果文件不存在,FileNotFoundError会被捕获;如果文件的第一行数据与浮动数据类型不兼容,ValueError会被捕获。
在这个示例中,我们通过关键字as将FileNotFoundError赋值给变量FnF。这允许在处理此异常时访问更多的详细信息。在这里,我们打印了错误字符串FnF.strerror和相关文件的名称FnF.filename。每种错误类型可以根据类型有自己的属性集。如果名为data.txt的文件不存在,在上面的示例中,消息将是:
No such file or directory: data.txt
这是在捕获异常时格式化输出的一个有用方法。
try-except组合可以通过可选的else和finally块进行扩展。
使用else的一个示例可以在第 15.2.1 节中看到:测试二分法算法。将try与finally结合使用,在需要在结束时进行清理工作的情况下,提供了一个有用的结构。通过一个确保文件正确关闭的示例来说明:
try:
f = open('data.txt', 'r')
# some function that does something with the file
process_file_data(f)
except:
...
finally:
f.close()
这将确保无论在处理文件数据时抛出什么异常,文件都会在结束时关闭。try语句内部未处理的异常会在finally块之后保存并抛出。这个组合在with语句中使用;参见第 12.1.3 节:上下文管理器——with语句。
12.1.2 用户定义异常
除了内置的 Python 异常外,还可以定义自己的异常。这样的用户定义异常应继承自基类Exception。当你定义自己的类时,这会非常有用,例如在第 19.1 节中定义的多项式类。
看看这个简单的用户定义异常的小示例:
class MyError(Exception):
def __init__(self, expr):
self.expr = expr
def __str__(self):
return str(self.expr)
try:
x = random.rand()
if x < 0.5:
raise MyError(x)
except MyError as e:
print("Random number too small", e.expr)
else:
print(x)
生成一个随机数。如果该数字小于0.5,则会抛出一个异常,并打印一个值太小的消息。如果没有抛出异常,则打印该数字。
在这个示例中,你还看到了在try语句中使用else的一个例子。如果没有发生异常,else下的代码块将会被执行。
建议你为你的异常定义以Error结尾的名称,就像标准内置异常的命名一样。
12.1.3 上下文管理器——with语句
Python 中有一个非常有用的结构,在处理文件或数据库等上下文时简化异常处理。该语句将try ... finally结构封装为一个简单的命令。以下是使用with读取文件的示例:
with open('data.txt', 'w') as f:
process_file_data(f)
这将尝试打开文件,在文件上运行指定的操作(例如,读取),然后关闭文件。如果在执行process_file_data期间出现任何问题,文件将被正确关闭,然后抛出异常。这等同于:
f = open('data.txt', 'w')
try:
# some function that does something with the file
process_file_data(f)
except:
...
finally:
f.close()
我们将在第 14.1 节中使用此选项:文件处理,在读取和写入文件时使用。
前面的文件读取示例是使用上下文管理器的一个例子。上下文管理器是具有两个特殊方法__enter__和__exit__的 Python 对象。任何实现了这两个方法的类的对象都可以用作上下文管理器。在此示例中,文件对象f是一个上下文管理器,因为它具有方法f.__enter__和f.__exit__。
方法__enter__应该实现初始化指令,例如打开文件或数据库连接。如果此方法包含返回语句,则通过构造as来访问返回的对象。否则,省略关键字as。方法__exit__包含清理指令,例如关闭文件或提交事务并关闭数据库连接。有关更多解释和自定义上下文管理器的示例,请参见第 15.3.3 节:使用上下文管理器进行计时。
有一些 NumPy 函数可以用作上下文管理器。例如,函数load支持某些文件格式的上下文管理器。NumPy 的函数errstate可以作为上下文管理器,用于在代码块中指定浮点错误处理行为。
下面是使用errstate和上下文管理器的示例:
import numpy as np # note, sqrt in NumPy and SciPy
# behave differently in that example
with errstate(invalid='ignore'):
print(np.sqrt(-1)) # prints 'nan'
with errstate(invalid='warn'):
print(np.sqrt(-1)) # prints 'nan' and
# 'RuntimeWarning: invalid value encountered in sqrt'
with errstate(invalid='raise'):
print(np.sqrt(-1)) # prints nothing and raises FloatingPointError
请参见第 2.2.2 节:浮动点数,了解更多此示例的详细信息,并查看第 15.3.3 节:使用上下文管理器进行计时以获得另一个示例。
12.2 查找错误:调试
软件代码中的错误有时被称为 bug。调试是找到并修复代码中的 bug 的过程。这个过程可以在不同的复杂度下进行。最有效的方式是使用名为调试器的工具。提前编写单元测试是识别错误的好方法;请参见第 15.2.2 节:使用 unittest 包。当问题所在和问题是什么不明显时,调试器非常有用。
12.2.1 Bugs
通常有两种类型的 bug:
-
异常被引发,但未被捕获。
-
代码无法正常运行。
第一个情况通常比较容易修复。第二种情况可能更难,因为问题可能是一个错误的想法或解决方案、错误的实现,或两者的结合。
接下来我们只关注第一个情况,但同样的工具也可以帮助找出为什么代码没有按预期执行。
12.2.2 栈
当异常被引发时,你会看到调用栈。调用栈包含所有调用异常发生代码的函数的追踪信息。
一个简单的栈示例是:
def f():
g()
def g():
h()
def h():
1//0
f()
在这种情况下,栈是f,g和h。运行这段代码生成的输出如下所示:
Traceback (most recent call last):
File "stack_example.py", line 11, in <module>
f()
File "stack_example.py", line 3, in f
g()
File "stack_example.py", line 6, in g
h() File "stack_example.py", line 9, in h
1//0
ZeroDivisionError: integer division or modulo by zero
错误已打印。导致错误的函数序列已显示。line 11上的函数f被调用,接着调用了g,然后是h。这导致了ZeroDivisionError。
堆栈跟踪报告程序执行某一时刻的活动堆栈。堆栈跟踪可以让你追踪到某一时刻调用的函数序列。通常这是在抛出未捕获的异常之后。这有时被称为事后分析,堆栈跟踪点就是异常发生的位置。另一种选择是手动调用堆栈跟踪来分析你怀疑有错误的代码片段,可能是在异常发生之前。
在以下示例中,引发异常以引发堆栈跟踪的生成:
def f(a):
g(a)
def g(a):
h(a)
def h(a):
raise Exception(f'An exception just to provoke a strack trace and a value a={a}')
f(23)
这将返回以下输出:
Traceback (most recent call last):
File ".../Python_experiments/manual_trace.py", line 17, in <module>
f(23)
File "../Python_experiments/manual_trace.py", line 11, in f
g(a)
File "../Python_experiments/manual_trace.py", line 13, in g
h(a)
File "/home/claus/Python_experiments/manual_trace.py", line 15, in h
raise Exception(f'An exception just to provoke a strack trace and a value a={a}')
Exception: An exception just to provoke a strack trace and a value a=23
12.2.3 Python 调试器
Python 自带有一个内置调试器,叫做pdb。一些开发环境中集成了调试器。即使在这些情况下,以下过程依然适用。
使用调试器的最简单方法是在代码中你想调查的地方启用堆栈跟踪。这里是一个基于第 7.3 节中的示例触发调试器的简单示例:返回值:
import pdb
def complex_to_polar(z):
pdb.set_trace()
r = sqrt(z.real ** 2 + z.imag ** 2)
phi = arctan2(z.imag, z.real)
return (r,phi)
z = 3 + 5j
r,phi = complex_to_polar(z)
print(r,phi)
命令pdb.set_trace()启动调试器并启用后续命令的跟踪。前面的代码将显示如下:
> debugging_example.py(7)complex_to_polar()
-> r = sqrt(z.real ** 2 + z.imag ** 2)
(Pdb)
调试器提示符由(Pdb)表示。调试器暂停程序执行,并提供一个提示符,允许你检查变量、修改变量、逐步执行命令等。
每一步都会打印当前行,因此你可以跟踪当前所在的位置以及接下来会发生什么。逐步执行命令可以通过命令n(下一个)完成,像这样:
> debugging_example.py(7)complex_to_polar()
-> r = sqrt(z.real ** 2 + z.imag ** 2)
(Pdb) n
> debugging_example.py(8)complex_to_polar()
-> phi = arctan2(z.imag, z.real)
(Pdb) n
> debugging_example.py(9)complex_to_polar()
-> return (r,phi)
(Pdb)
...
命令n(下一个)将继续到下一行并打印该行。如果你需要同时查看多行,命令l(列出)将显示当前行及其周围的代码:
> debugging_example.py(7)complex_to_polar()
-> r = sqrt(z.real ** 2 + z.imag ** 2)
(Pdb) l
2
3 import pdb
4
5 def complex_to_polar(z):
6 pdb.set_trace()
7 -> r = sqrt(z.real ** 2 + z.imag ** 2)
8 phi = arctan2(z.imag, z.real)
9 return (r,phi)
10
11 z = 3 + 5j
12 r,phi = complex_to_polar(z)
(Pdb)
变量的检查可以通过使用命令p(打印)后跟变量名,将其值打印到控制台来完成。打印变量的示例是:
> debugging_example.py(7)complex_to_polar()
-> r = sqrt(z.real ** 2 + z.imag ** 2)
(Pdb) p z
(3+5j)
(Pdb) n
> debugging_example.py(8)complex_to_polar()
-> phi = arctan2(z.imag, z.real)
(Pdb) p r
5.8309518948453007
(Pdb) c
(5.8309518948453007, 1.0303768265243125)
命令p(打印)将打印变量;命令c(继续)将继续执行。
在执行过程中修改变量是很有用的。只需在调试器提示符下分配新值,并逐步执行或继续执行:
> debugging_example.py(7)complex_to_polar()
-> r = sqrt(z.real ** 2 + z.imag ** 2)
(Pdb) z = 2j
(Pdb) z
2j
(Pdb) c
(2.0, 1.5707963267948966)
在这里,变量z被赋予一个新值,并在剩余的代码中使用。请注意,最终的打印输出已发生变化。
12.2.4 概述 – 调试命令
在表 12.2中,显示了最常用的调试命令。有关完整的命令列表和描述,请参阅文档以获取更多信息[24]。请注意,任何 Python 命令也都有效,例如,为变量赋值。
如果你想检查一个与调试器短命令重名的变量,例如h,你必须使用!h来显示该变量。
| 命令 | 操作 |
|---|---|
h |
帮助(不带参数时,显示可用的命令) |
l |
列出当前行周围的代码 |
q |
退出(退出调试器,停止执行) |
c |
继续执行 |
r |
继续执行,直到当前函数返回 |
n |
继续执行,直到下一行 |
p <expression> |
计算并打印当前上下文中的表达式 |
表 12.2:调试器中最常用的调试命令
12.2.5 IPython 中的调试
IPython 自带一个调试器版本,称为 ipdb。在撰写本书时,ipdb 与 pdb 之间的差异非常小,但这可能会发生变化。
在 IPython 中有一个命令 %pdb,在出现异常时自动启动调试器。当你在实验新想法或代码时,这非常有用。如何在 IPython 中自动启动调试器的一个示例如下:
In [1]: %pdb # this is a so - called IPython magic command
Automatic pdb calling has been turned ON
In [2]: a = 10
In [3]: b = 0
In [4]: c = a/b
___________________________________________________________________
ZeroDivisionError Traceback (most recent call last)
<ipython-input-4-72278c42f391> in <module>()
—-> 1 c = a/b
ZeroDivisionError: integer division or modulo by zero
> <ipython-input-4-72278c42f391>(1)<module>()
-1 c = a/b
ipdb>
在 IPython 提示符下,IPython 魔法命令 %pdb 会在抛出异常时自动启用调试器。在此,调试器提示符会显示 ipdb,以表明调试器正在运行。
12.3 总结
本章的关键概念是异常和错误。我们展示了如何抛出异常并在另一个程序单元中捕获它。你可以定义自己的异常,并为它们配上消息和当前变量的值。
代码可能会返回意外结果而没有抛出异常。定位错误结果来源的技巧叫做调试。我们介绍了调试方法,并希望能鼓励你训练这些技巧,以便在需要时随时使用。严重的调试需求可能比你预想的更早出现。
命名空间、作用域和模块
在本章中,我们将介绍 Python 模块。模块是包含函数和类定义的文件。本章还解释了命名空间和跨函数和模块的变量作用域的概念。
本章将涵盖以下主题:
-
命名空间
-
变量的作用域
-
模块
第十四章:13.1 命名空间
Python 对象的名称,如变量、类、函数和模块的名称,都集中在命名空间中。模块和类具有它们自己的命名空间,与这些对象的名称相同。这些命名空间在导入模块或实例化类时创建。模块的命名空间的生存期与当前 Python 会话一样长。类实例的命名空间的生存期是直到实例被删除。
当函数被执行(调用)时,函数会创建一个局部命名空间。当函数通过常规返回或异常停止执行时,局部命名空间将被删除。局部命名空间是无名的。
命名空间的概念将变量名放置在其上下文中。例如,有几个名为sin的函数,它们通过所属的命名空间进行区分,如下面的代码所示:
import math
import numpy
math.sin
numpy.sin
它们确实不同,因为numpy.sin是一个通用函数,接受列表或数组作为输入,而math.sin仅接受浮点数。可以使用命令dir(<name of the namespace>)获取特定命名空间中所有名称的列表。它包含两个特殊名称,__name__指的是模块的名称,__doc__指的是其文档字符串:
math.__name__ # returns math
math.__doc__ # returns 'This module provides access to .....'
有一个特殊的命名空间,__builtin__,其中包含在 Python 中无需任何导入即可使用的名称。它是一个命名空间,但是在引用内置对象时不需要给出其名称:
'float' in dir(__builtin__) # returns True
float is __builtin__.float # returns True
让我们在下一节学习变量的作用域。
13.2 变量的作用域
程序的一部分定义的变量不需要在其他部分中知道。已知某个变量的所有程序单元被称为该变量的作用域。我们先举一个例子。让我们考虑两个嵌套函数:
e = 3
def my_function(in1):
a = 2 * e
b = 3
in1 = 5
def other_function():
c = a
d = e
return dir()
print(f"""
my_function's namespace: {dir()}
other_function's namespace: {other_function()}
""")
return a
执行my_function(3)的结果是:
my_function's namespace: ['a', 'b', 'in1', 'other_function']
other_function's namespace: ['a', 'c', 'd']
变量e位于包围函数my_function的程序单元的命名空间中。变量a位于该函数的命名空间中,该函数本身包围最内层的函数other_function。对于这两个函数,e是一个全局变量,即它不在本地命名空间中,也不会被dir()列出,但其值是可用的。
通过参数列表将信息传递给函数,而不使用前面示例中的构造是一种良好的实践。一个例外可以在第 7.7 节找到:匿名函数,在这里全局变量用于闭包。
通过为其分配一个值,变量自动成为局部变量:
e = 3
def my_function():
e = 4
a = 2
print(f"my_function's namespace: {dir()}")
执行以下代码块时可以看到这一点:
e = 3
my_function()
e # has the value 3
上述代码的输出显示了 my_function 的局部变量:
my_function's namespace: ['a', 'e']
现在,e 变成了一个局部变量。事实上,这段代码现在有两个 e 变量,分别属于不同的命名空间。
通过使用 global 声明语句,可以将函数中定义的变量变为全局变量,也就是说,它的值即使在函数外部也可以访问。以下是使用 global 声明的示例:
def fun():
def fun1():
global a
a = 3
def fun2():
global b
b = 2
print(a)
fun1()
fun2() # prints a
print(b)
建议避免使用这种构造以及 global 的使用。使用 global 的代码难以调试和维护。类的使用基本上使得 global 变得过时。
13.3 模块
在 Python 中,模块只是一个包含类和函数的文件。通过在会话或脚本中导入该文件,函数和类就可以被使用。
13.3.1 介绍
Python 默认带有许多不同的库。你可能还希望为特定目的安装更多的库,如优化、绘图、读写文件格式、图像处理等。NumPy 和 SciPy 是这类库的重要例子,Matplotlib 是用于绘图的另一个例子。在本章结束时,我们将列出一些有用的库。
使用库的方法有两种:
- 只从库中加载某些对象,例如,从 NumPy 中:
from numpy import array, vander
- 加载整个库:
from numpy import *
- 或者通过创建一个与库名相同的命名空间来访问整个库:
import numpy
...
numpy.array(...)
在库中的函数前加上命名空间,可以访问该函数,并将其与其他同名对象区分开来。
此外,可以在 import 命令中指定命名空间的名称:
import numpy as np
...
np.array(...)
你选择使用这些替代方式的方式会影响代码的可读性以及出错的可能性。一个常见的错误是变量覆盖(shadowing):
from scipy.linalg import eig
A = array([[1,2],[3,4]])
(eig, eigvec) = eig(A)
...
(c, d) = eig(B) # raises an error
避免这种无意的效果的一种方法是使用 import 而不是 from,然后通过引用命名空间来访问命令,例如 sl:
import scipy.linalg as sl
A = array([[1,2],[3,4]])
(eig, eigvec) = sl.eig(A) # eig and sl.eig are different objects
...
(c, d) = sl.eig(B)
本书中,我们使用了许多命令、对象和函数。这些通过类似以下语句被导入到本地命名空间中:
from scipy import *
以这种方式导入对象不会显现它们来自的模块。以下表格给出了几个例子(表 13.1):
| 库 | 方法 |
|---|---|
numpy |
array、arange、linspace、vstack、hstack、dot、eye、identity 和 zeros。 |
scipy.linalg |
solve、lstsq、eig 和 det。 |
matplotlib.pyplot |
plot、legend 和 cla。 |
scipy.integrate |
quad。 |
copy |
copy 和 deepcopy。 |
表 13.1:模块及其对应导入函数的示例
13.3.2 IPython 中的模块
IPython 用于代码开发。一个典型的场景是,你在一个文件中工作,文件中有一些函数或类定义,你在开发周期中对其进行更改。为了将该文件的内容加载到 Shell 中,你可以使用 import,但文件只会加载一次。更改文件对后续的导入没有影响。这时,IPython 的 魔法命令 run 就显得非常有用。
IPython 魔法命令 – run
IPython 有一个特殊的 魔法命令 run,它会像直接在 Python 中运行一样执行文件。这意味着文件会独立于 IPython 中已经定义的内容执行。这是推荐的在 IPython 中执行文件的方法,特别是当你想要测试作为独立程序的脚本时。你必须像从命令行执行文件一样,在被执行的文件中导入所有需要的内容。运行 myfile.py 文件的典型示例如下:
from numpy import array
...
a = array(...)
该脚本文件在 Python 中通过 exec(open('myfile.py').read()) 执行。或者,在 IPython 中可以使用 魔法命令 run myfile,如果你想确保脚本独立于之前的导入运行。文件中定义的所有内容都会被导入到 IPython 工作空间中。
13.3.3 变量 __name__
在任何模块中,特殊变量 __name__ 被定义为当前模块的名称。在命令行(在 IPython 中)中,此变量被设置为 __main__。这个特性使得以下技巧成为可能:
# module
import ...
class ...
if __name__ == "__main__":
# perform some tests here
测试只有在文件直接运行时才会执行,而不是在被导入时执行,因为当被导入时,变量 __name__ 会取模块名,而不是 __main__。
13.3.4 一些有用的模块
有用的 Python 模块列表非常庞大。下表展示了这样一个简短的列表,专注于与数学和工程应用相关的模块 (表 13.2):
| 模块 | 描述 |
|---|---|
scipy |
科学计算中使用的函数 |
numpy |
支持数组及相关方法 |
matplotlib |
绘图和可视化 |
functools |
函数的部分应用 |
itertools |
提供特殊功能的迭代器工具,例如切片生成器 |
re |
用于高级字符串处理的正则表达式 |
sys |
系统特定函数 |
os |
操作系统接口,如目录列表和文件处理 |
datetime |
表示日期及日期增量 |
time |
返回壁钟时间 |
timeit |
测量执行时间 |
sympy |
计算机算术包(符号计算) |
pickle |
Pickling,一种特殊的文件输入输出格式 |
shelves |
Shelves,一种特殊的文件输入输出格式 |
contextlib |
用于上下文管理器的工具 |
表 13.2:用于工程应用的有用 Python 包的非详尽列表
我们建议不要使用数学模块math,而是推荐使用numpy。原因是 NumPy 的许多函数,例如sin,是作用于数组的,而math中的对应函数则不支持。
13.4 总结
我们从告诉你需要导入 SciPy 和其他有用的模块开始。现在你已经完全理解了导入的含义。我们介绍了命名空间,并讨论了import和from ... import *之间的区别。变量的作用域已在第 7.2.3 节中介绍:访问定义在局部之外的变量
命名空间,但现在你对该概念的重要性有了更完整的理解。
输入与输出
在本章中,我们将介绍一些处理数据文件的选项。根据数据和所需的格式,有几种读取和写入的选项。我们将展示一些最有用的替代方案。
本章将涵盖以下主题:
-
文件处理
-
NumPy 方法
-
序列化
-
保持存储
-
读取和写入 Matlab 数据文件
-
读取和写入图像
第十五章:14.1 文件处理
文件输入与输出(I/O)在许多场景中是至关重要的,例如:
-
处理测量或扫描数据。测量结果存储在文件中,需要读取这些文件以进行分析。
-
与其他程序的交互。将结果保存到文件中,以便可以导入到其他应用程序中,反之亦然。
-
存储信息以备将来参考或比较。
-
与他人共享数据和结果,可能是在其他平台上使用其他软件。
本节将介绍如何在 Python 中处理文件 I/O。
14.1.1 与文件的交互
在 Python 中,file 类型的对象表示存储在磁盘上的物理文件的内容。可以使用以下语法创建一个新的 file 对象:
# creating a new file object from an existing file
myfile = open('measurement.dat','r')
文件内容可以通过以下命令访问:
print(myfile.read())
使用文件对象需要小心。问题在于,文件必须在重新读取或由其他应用程序使用之前关闭,这是通过以下语法完成的:
myfile.close() # closes the file object
事情并没有那么简单,因为在执行 close 调用之前可能会触发异常,这将跳过关闭代码(考虑以下示例)。确保文件正确关闭的简单方法是使用上下文管理器。使用 with 关键字的这种结构将在第 12.1.3 节:上下文管理器 – with 语句中进行更详细的说明。以下是如何与文件一起使用它:
with open('measurement.dat','r') as myfile:
... # use myfile here
这确保了即使在块内引发异常时,文件也会在退出 with 块时关闭。该命令适用于上下文管理器对象。我们建议您阅读更多关于上下文管理器的内容,见第 12.1.3 节:上下文
管理器 – with 语句。以下是一个示例,展示了为什么 with 结构是值得推荐的:
myfile = open(name,'w')
myfile.write('some data')
a = 1/0
myfile.write('other data')
myfile.close()
在文件关闭之前引发了异常。文件保持打开状态,并且无法保证数据何时以及如何写入文件。因此,确保达到相同结果的正确方法是:
with open(name,'w') as myfile:
myfile.write('some data')
a = 1/0
myfile.write('other data')
在这种情况下,文件会在异常(此处为ZeroDivisionError)被触发后干净地关闭。还需要注意的是,无需显式地关闭文件。
14.1.2 文件是可迭代的
文件尤其是可迭代的(见第 9.3 节:可迭代对象)。文件会迭代它们的每一行:
with open(name,'r') as myfile:
for line in myfile:
data = line.split(';')
print(f'time {data[0]} sec temperature {data[1]} C')
文件的每一行会作为字符串返回。split 字符串方法是将字符串转换为字符串列表的一个可能工具,例如:
data = 'aa;bb;cc;dd;ee;ff;gg'
data.split(';') # ['aa', 'bb', 'cc', 'dd', 'ee', 'ff', 'gg']
data = 'aa bb cc dd ee ff gg'
data.split(' ') # ['aa', 'bb', 'cc', 'dd', 'ee', 'ff', 'gg']
由于对象 myfile 是可迭代的,我们也可以直接提取到列表中,示例如下:
data = list(myfile)
14.1.3 文件模式
如你在这些文件处理的示例中看到的,函数 open 至少需要两个参数。第一个显然是文件名,第二个是描述文件使用方式的字符串。打开文件有几种模式,基本模式如下:
with open('file1.dat','r') as ... # read only
with open('file2.dat','r+') as ... # read/write
with open('file3.dat','rb') as ... # read in byte mode
with open('file4.dat','a') as ... # append (write to the end of the file)
with open('file5.dat','w') as ... # (over-)write the file
with open('file6.dat','wb') as ... # (over-)write the file in byte mode
模式 'r'、'r+' 和 'a' 要求文件已存在,而 'w' 会在没有该文件的情况下创建一个新文件。使用 'r' 和 'w' 进行读写是最常见的,正如你在前面的示例中看到的那样。
这里是一个例子,展示了如何使用追加模式 'a' 打开文件并在文件末尾添加数据,而不修改文件中已存在的内容。注意换行符 \n:
_with open('file3.dat','a') as myfile:
myfile.write('something new\n')
14.2 NumPy 方法
NumPy 提供了用于将 NumPy 数组数据读取和写入文本文件的内置方法。这些方法是 numpy.loadtxt 和 numpy.savetxt。
14.2.1 savetxt
将一个数组写入文本文件非常简单:
savetxt(filename,data)
有两个有用的参数作为字符串给出,fmt 和 delimiter,它们控制列之间的格式和分隔符。默认值为分隔符为空格,格式为%.18e,即对应于具有所有数字的指数格式。格式化参数的使用方式如下:
x = range(100) # 100 integers
savetxt('test.txt',x,delimiter=',') # use comma instead of space
savetxt('test.txt',x,fmt='%d') # integer format instead of float with e
14.2.3 loadtxt
从文本文件读取到数组使用以下语法:
filename = 'test.txt'
data = loadtxt(filename)
由于数组中的每一行必须具有相同的长度,因此文本文件中的每一行必须有相同数量的元素。与 savetxt 类似,默认值为 float,分隔符为空格。可以使用 dtype 和 delimiter 参数进行设置。另一个有用的参数是 comments,可以用来标记数据文件中用于注释的符号。使用格式化参数的示例如下:
data = loadtxt('test.txt',delimiter=';') # data separated by semicolons
# read to integer type, comments in file begin with a hash character
data = loadtxt('test.txt',dtype=int,comments='#')
14.3 Pickling
你刚刚看到的读写方法会在写入之前将数据转换为字符串。复杂类型(如对象和类)不能以这种方式写入。使用 Python 的模块 pickle,你可以将任何对象以及多个对象保存到文件中。
数据可以保存为纯文本(ASCII)格式或使用稍微高效一些的二进制格式。主要有两种方法:dump,它将一个 Python 对象的 pickle 表示保存到文件中,和 load,它从文件中检索一个 pickle 对象。基本用法如下:
import pickle
with open('file.dat','wb') as myfile:
a = random.rand(20,20)
b = 'hello world'
pickle.dump(a,myfile) # first call: first object
pickle.dump(b,myfile) # second call: second object
import pickle
with open('file.dat','rb') as myfile:
numbers = pickle.load(myfile) # restores the array
text = pickle.load(myfile) # restores the string
注意返回的两个对象的顺序。除了这两种主要方法,有时将 Python 对象序列化为字符串而不是文件也是很有用的。这可以通过 dumps 和 loads 来实现。以下是序列化数组和字典的一个例子:
a = [1,2,3,4]
pickle.dumps(a) # returns a bytes object
b = {'a':1,'b':2}
pickle.dumps(b) # returns a bytes object
使用dumps的一个好例子是当你需要将 Python 对象或 NumPy 数组写入数据库时。数据库通常支持存储字符串,这使得无需特殊模块就可以轻松地写入和读取复杂数据和对象。除了pickle模块,还有一个优化版,称为cPickle。它是用 C 语言编写的,若需要快速的读写操作,可以使用它。pickle和*cPickle*产生的数据是相同的,可以互换使用。
14.4 文件架构
字典中的对象可以通过键来访问。类似地,可以通过先为文件分配一个键来访问特定的数据。这可以通过使用shelve模块来实现:
from contextlib import closing
import shelve as sv
# opens a data file (creates it before if necessary)
with closing(sv.open('datafile')) as data:
A = array([[1,2,3],[4,5,6]])
data['my_matrix'] = A # here we created a key
在第 14.1.1 节:与文件交互中,我们看到内置命令open会生成一个上下文管理器,我们也了解了这对于处理外部资源(如文件)为什么很重要。与此命令不同,sv.open本身不会创建上下文管理器。contextlib模块中的命令closing需要将其转变为合适的上下文管理器。
考虑以下恢复文件的示例:
from contextlib import closing
import shelve as sv
with closing(sv.open('datafile')) as data: # opens a data file
A = data['my_matrix'] # here we used the key
...
shelve对象具有所有字典方法,例如键和值,可以像字典一样使用。请注意,只有在调用了close或sync等方法后,文件中的更改才会被写入。
14.5 读取和写入 Matlab 数据文件
SciPy 能够使用模块\pyth!scipy.io!读取和写入 Matlab 的.mat文件格式。相关命令是loadmat和savemat。
要加载数据,请使用以下语法:
import scipy.io
data = scipy.io.loadmat('datafile.mat')
变量数据现在包含一个字典,字典的键对应于保存在.mat文件中的变量名。变量以 NumPy 数组格式存储。保存到.mat文件时,需要创建一个包含所有要保存的变量(变量名和对应的值)的字典。然后使用命令savemat:
data = {}
data['x'] = x
data['y'] = y
scipy.io.savemat('datafile.mat',data)
这将x和y这两个 NumPy 数组保存为 Matlab 的内部文件格式,从而保留变量名。
14.6 读取和写入图像
PIL.Image模块提供了一些用于处理图像的函数。以下代码将读取一张JPEG图像,打印其形状和类型,然后创建一张调整过大小的图像,并将新图像写入文件:
import PIL.Image as pil # imports the Pillow module
# read image to array
im=pil.open("test.jpg")
print(im.size) # (275, 183)
# Number of pixels in horizontal and vertical directions
# resize image
im_big = im.resize((550, 366))
im_big_gray = im_big.convert("L") # Convert to grayscale
im_array=array(im)
print(im_array.shape)
print(im_array.dtype) # unint 8
# write result to new image file
im_big_gray.save("newimage.jpg")
PIL 创建了一个可以轻松转换为 NumPy 数组的图像对象。作为数组对象,图像以 8 位无符号整数(unint8)的形式存储像素值,范围为0...255。第三个形状值表示图像的颜色通道数。在此情况下,3表示这是一个彩色图像,其值按照以下顺序存储:红色im_array[:,:,0],绿色im_array[:,:,1],蓝色im_array[:,:,2]。灰度图像只有一个通道。
对于处理图像,PIL模块包含许多有用的基本图像处理功能,包括滤波、变换、度量以及从 NumPy 数组转换为PIL图像对象:
new_image = pil.from_array(ima_array)
14.7 总结
文件处理在处理测量数据和其他大量数据源时是不可避免的。此外,与其他程序和工具的通信也是通过文件处理完成的。
你已经学会将文件视为一个 Python 对象,就像其他对象一样,具有重要的方法,如readlines和write。我们展示了如何通过特殊属性保护文件,这些属性可能只允许读取或写入访问。
你写入文件的方式往往会影响处理的速度。我们看到数据是通过序列化(pickling)或使用shelve方法来存储的。
测试
在本章中,我们将重点讨论科学编程中的两个方面的测试。第一个方面是科学计算中经常遇到的测试什么的问题。第二个方面涉及如何测试的问题。我们将区分手动测试和自动化测试。手动测试是每个程序员用来快速检查程序是否按预期工作的方式。自动化测试则是这一概念的精细化和自动化版本。我们将介绍一些适用于自动化测试的工具,并特别关注科学计算中的应用。
第十六章:15.1 手动测试
在代码开发过程中,你会做很多小的测试,以测试其功能。这可以称为手动测试。通常,你会通过在交互式环境中手动测试函数,来验证给定的函数是否做了它应该做的事。例如,假设你实现了二分法算法。它是一个找到标量非线性函数零点(根)的方法。为了启动该算法,必须给定一个区间,且该区间的边界上的函数值具有不同符号(详情见第 7.10 节:习题,了解更多信息)。
然后,你将测试该算法的实现,通常是通过检查:
-
当函数在区间边界处具有不同符号时,问题就得到了解决。
-
当函数在区间边界处具有相同符号时,是否抛出异常。
手动测试,尽管它看起来是必要的,但并不令人满意。一旦你确信代码做了它应该做的事,你会编写相对较少的示范示例来说服他人代码的质量。在那个阶段,你通常会对开发过程中进行的测试失去兴趣,它们可能会被遗忘或甚至删除。每当你更改了某个细节,导致事情不再正常工作时,你可能会后悔之前的测试已经不再可用。
15.2 自动化测试
开发任何代码的正确方法是使用自动化测试。其优势在于:
-
每次代码重构后,以及在发布任何新版本之前,自动化地重复大量测试。
-
对代码使用的静默文档记录。
-
记录代码的测试覆盖率:在更改之前,事情是否正常工作?某个特定方面是否从未经过测试?
程序中的变化,特别是其结构上的变化,但不影响其功能,称为代码重构。
我们建议在编码的同时开发测试。良好的测试设计本身就是一门艺术,而且很少有投资能像投资于良好测试那样,保证在开发时间节省方面获得如此好的回报。
现在,我们将通过考虑自动化测试方法来实现一个简单的算法。
15.2.1 测试二分法算法
让我们来研究一下二分法算法的自动化测试。通过该算法,可以找到一个实值函数的零点。它在第 7.10 节的练习 4中有所描述:练习。该算法的实现可以具有以下形式:
def bisect(f, a, b, tol=1.e-8):
"""
Implementation of the bisection algorithm
f real valued function
a,b interval boundaries (float) with the property
f(a) * f(b) <= 0
tol tolerance (float)
"""
if f(a) * f(b)> 0:
raise ValueError("Incorrect initial interval [a, b]")
for i in range(100):
c = (a + b) / 2.
if f(a) * f(c) <= 0:
b = c
else:
a = c
if abs(a - b) < tol:
return (a + b) / 2
raise Exception('No root found within the given tolerance {tol}')
我们假设该内容存储在名为bisection.py的文件中。作为第一个测试用例,我们测试该函数的零点是否能够找到!。
def test_identity():
result = bisect(lambda x: x, -1., 1.)
expected = 0\.
assert allclose(result, expected),'expected zero not found'
test_identity()
在这段代码中,您第一次接触到 Python 关键字assert。如果它的第一个参数返回False,它将引发AssertionError异常。它的可选第二个参数是一个包含附加信息的字符串。我们使用allclose函数来测试浮点数的相等性。
让我们对测试函数的某些特性进行评论。我们使用断言来确保如果代码没有按预期行为执行,异常将被抛出。我们必须手动运行测试,在test_identity()这一行中。
有许多工具可以自动化这种调用;我们将在第 15.2.2 节中看到其中的一种:使用 unittest 模块。
现在我们设置第二个测试,检查当函数在区间两端具有相同符号时,bisect是否会抛出异常。现在,我们假设抛出的异常是ValueError。在以下示例中,我们将检查初始区间!。对于二分法算法,它应该满足符号条件:
def test_badinput():
try:
bisect(lambda x: x,0.5,1)
except ValueError:
pass
else:
raise AssertionError()
test_badinput()
在这种情况下,如果抛出的异常不是ValueError类型,将引发AssertionError。有一些工具可以简化之前的构造,以检查是否抛出了异常。
另一个有用的测试是边界情况测试。在这里,我们测试可能会产生数学上未定义的情况或程序员未预见的程序状态的参数或用户输入。例如,如果两个边界相等,会发生什么?如果!,会发生什么?
以下代码是此类边界测试的示例:
def test_equal_boundaries():
result = bisect(lambda x: x, 0., 0.)
expected = 0.
assert allclose(result, expected), \
'test equal interval bounds failed'
def test_reverse_boundaries():
result = bisect(lambda x: x, 1., -1.)
expected = 0.
assert allclose(result, expected),\
'test reverse int_erval bounds failed'
test_equal_boundaries()
test_reverse_boundaries()
测试检查程序单元是否符合其规范的要求。在前面的例子中,我们假设规范要求在情况下!,这两个值应该默默地交换。并且这就是我们测试的内容。另一种方式是指定这种情况被视为错误输入,用户必须进行修正。在这种情况下,我们将测试是否抛出了适当的异常,例如ValueError。
15.2.2 使用 unittest 模块
Python 模块unittest大大简化了自动化测试。该模块要求我们重写之前的测试以保持兼容性。
第一个测试需要重写成一个class,如下所示:
from bisection import bisect
import unittest
class TestIdentity(unittest.TestCase):
def test(self):
result = bisect(lambda x: x, -1.2, 1.,tol=1.e-8)
expected = 0.
self.assertAlmostEqual(result, expected)
if __name__=='__main__':
unittest.main()
让我们看看与之前实现的区别。首先,测试现在是一个方法,并且是类的一部分。类必须继承自 unittest.TestCase。测试方法的名称必须以 test 开头。请注意,我们现在可以使用 unittest 包中的一个断言工具,即 assertAlmostEqual。最后,使用 unittest.main 运行测试。我们建议将测试写在与要测试的代码分开的文件中。因此,它以 import 开头。测试通过并返回如下:
Ran 1 test in 0.002s
OK
如果我们使用一个宽松的容差参数,例如 1.e-3,测试失败时将会报告:
F
======================================================================
FAIL: test (__main__.TestIdentity)
----------------------------------------------------------------------
Traceback (most recent call last):
File "<ipython-input-11-e44778304d6f>", line 5, in test
self.assertAlmostEqual(result, expected)
AssertionError: 0.00017089843750002018 != 0.0 within 7 places
----------------------------------------------------------------------
Ran 1 test in 0.004s
FAILED (failures=1)
测试可以并且应该作为测试类的方法进行分组,如下例所示:
import unittest
from bisection import bisect
class TestIdentity(unittest.TestCase):
def identity_fcn(self,x):
return x
def test_functionality(self):
result = bisect(self.identity_fcn, -1.2, 1.,tol=1.e-8)
expected = 0.
self.assertAlmostEqual(result, expected)
def test_reverse_boundaries(self):
result = bisect(self.identity_fcn, 1., -1.)
expected = 0.
self.assertAlmostEqual(result, expected)
def test_exceeded_tolerance(self):
tol=1.e-80
self.assertRaises(Exception, bisect, self.identity_fcn,
-1.2, 1.,tol)
if __name__=='__main__':
unittest.main()
在这里,在最后一个测试中我们使用了 unittest.TestCase.assertRaises 方法。它测试是否正确引发了异常。它的第一个参数是异常类型,例如 ValueError、Exception,第二个参数是预期引发异常的函数的名称。其余的参数是该函数的参数。
命令 unittest.main() 创建了一个 TestIdentity 类的实例,并执行那些以 test 开头的方法。
15.2.3 测试的 setUp 和 tearDown 方法
unittest.TestCase 类提供了两个特殊方法:setUp 和 tearDown,它们在每次调用测试方法之前和之后执行。这在测试生成器时很有用,因为生成器在每次测试后会被消耗完。我们通过测试一个程序来演示这一点,该程序检查文件中给定字符串首次出现的行:
class StringNotFoundException(Exception):
pass
def find_string(file, string):
for i,lines in enumerate(file.readlines()):
if string in lines:
return i
raise StringNotFoundException(
f'String {string} not found in File {file.name}.')
我们假设这段代码保存在名为 find_in_file.py 的文件中。
一个测试必须准备一个文件,打开并在测试后删除,如下例所示:
import unittest
import os # used for, for example, deleting files
from find_in_file import find_string, StringNotFoundException
class TestFindInFile(unittest.TestCase):
def setUp(self):
file = open('test_file.txt', 'w')
file.write('bird')
file.close()
self.file = open('test_file.txt', 'r')
def tearDown(self):
self.file.close()
os.remove(self.file.name)
def test_exists(self):
line_no=find_string(self.file, 'bird')
self.assertEqual(line_no, 0)
def test_not_exists(self):
self.assertRaises(StringNotFoundException, find_string,
self.file, 'tiger')
if __name__=='__main__':
unittest.main()
在每个测试之前执行 setUp,在每个测试之后执行 tearDown。
在创建测试用例时设置测试数据
方法 setUp 和 tearDown 在每个测试方法的前后执行。这在测试方法会改变数据时是必需的。它们保证在执行下一个测试之前,测试数据能够恢复到原始状态。
然而,也经常会有一种情况,测试不改变测试数据,你希望通过仅一次设置数据来节省时间。这可以通过类方法 setUpClass 来完成。
以下代码块简要说明了 setUpClass 方法的使用。你也许想再次查看 第 8.4 节:类属性和类方法。
import unittest
class TestExample(unittest.Testcase):
@classmethod
def setUpClass(cls):
cls.A=....
def Test1(self):
A=self.A
# assert something
....
def Test2(self):
A=self.A
# assert something else
15.2.4 测试参数化
我们经常希望用不同的数据集重复相同的测试。当使用 unittest 功能时,这要求我们自动生成带有相应方法注入的测试用例。
为此,我们首先构建一个测试用例,其中包含一个或多个将在后续设置测试方法时使用的方法。我们将再次考虑二分法,并检查它返回的值是否真的是给定函数的零点。
我们首先构建测试用例和将用于测试的方法,如下所示:
class Tests(unittest.TestCase):
def checkifzero(self,fcn_with_zero,interval):
result = bisect(fcn_with_zero,*interval,tol=1.e-8)
function_value=fcn_with_zero(result)
expected=0.
self.assertAlmostEqual(function_value, expected)
然后,我们动态创建测试函数作为该类的属性:
test_data=[
{'name':'identity', 'function':lambda x: x,
'interval' : [-1.2, 1.]},
{'name':'parabola', 'function':lambda x: x**2-1,
'interval' :[0, 10.]},
{'name':'cubic', 'function':lambda x: x**3-2*x**2,
'interval':[0.1, 5.]},
]
def make_test_function(dic):
return lambda self :\
self.checkifzero(dic['function'],dic['interval'])
for data in test_data:
setattr(Tests, f"test_{data['name']}", make_test_function(data))
if __name__=='__main__':
unittest.main()
在此示例中,数据以字典列表的形式提供。函数make_test_function动态生成一个测试函数,使用特定的数据字典与之前定义的checkifzero方法进行测试。最后,使用命令setattr将这些测试函数作为Tests类的方法。
15.2.5 断言工具
在本节中,我们收集了用于引发AssertionError的最重要工具。我们看到了命令assert和unittest中的三个工具,分别是assertAlmostEqual、assertEqual和assertRaises。以下表格(表 15.1)总结了最重要的断言工具及相关模块:
| 断言工具和应用示例 | 模块 |
|---|---|
assert 5 == 5 |
– |
assertEqual(5.27, 5.27) |
unittest.TestCase |
assertAlmostEqual(5.24, 5.2, places = 1) |
unittest.TestCase |
assertTrue(5 > 2) |
unittest.TestCase |
assertFalse(2 < 5) |
unittest.TestCase |
assertRaises(ZeroDivisionError, lambda x: 1/x, 0.) |
unittest.TestCase |
assertIn(3, {3, 4}) |
unittest.TestCase |
assert_array_equal(A, B) |
numpy.testing |
assert_array_almost_equal(A, B, decimal=5) |
numpy.testing |
assert_allclose(A, B, rtol=1.e-3, atol=1.e-5) |
numpy.testing |
表 15.1:Python、unittest 和 NumPy 中的断言工具
15.2.6 浮点数比较
两个浮点数不应使用==运算符进行比较,因为计算结果通常由于舍入误差略有偏差。为测试目的,存在许多用于测试浮点数相等性的工具。
首先,allclose检查两个数组是否几乎相等。它可以在测试函数中使用,如下所示:
self.assertTrue(allclose(computed, expected))
这里,self指的是一个unittest.Testcase实例。numpy包中的testing模块也有测试工具。可以通过以下方式导入:
import numpy.testing
测试两个标量或两个数组是否相等,可以使用numpy.testing.assert_array_allmost_equal或numpy.testing.assert_allclose。这两种方法在描述所需精度的方式上有所不同,如上表表 15.1所示。
因式分解将给定矩阵分解为一个正交矩阵
和一个上三角矩阵
,如下所示的例子所示:
import scipy.linalg as sl
A=rand(10,10)
[Q,R]=sl.qr(A)
方法应用是否正确?我们可以通过验证
确实是一个正交矩阵来检查:
import numpy.testing as npt
npt.assert_allclose(
Q.T @ self.Q,identity(Q.shape[0]),atol=1.e-12)
此外,我们可能会通过检查是否进行了一项基本检查来执行一个合理性测试,方法是检查
:
import numpy.testing as npt
npt.assert_allclose(Q @ R,A))
所有这些可以通过如下方式汇总到测试用例unittest中:
import unittest
import numpy.testing as npt
from scipy.linalg import qr
from scipy import *
class TestQR(unittest.TestCase):
def setUp(self):
self.A=rand(10,10)
[self.Q,self.R]=qr(self.A)
def test_orthogonal(self):
npt.assert_allclose(
self.Q.T @ self.Q,identity(self.Q.shape[0]),
atol=1.e-12)
def test_sanity(self):
npt.assert_allclose(self.Q @ self.R,self.A)
if __name__=='__main__':
unittest.main()
请注意,assert_allclose中的参数atol默认为零,这在处理具有小元素的矩阵时常常会导致问题。
15.2.7 单元测试与功能性测试
到目前为止,我们只使用了功能性测试。功能性测试检查功能是否正确。对于二分法算法,当存在零时,该算法实际上会找到它。在这个简单的例子中,什么是单元测试并不完全清楚。虽然看起来有点牵强,但仍然可以为二分法算法编写单元测试。它将展示单元测试如何通常导致更具模块化的实现。
因此,在二分法方法中,我们想要检查,例如,在每一步是否正确选择了区间。如何做到这一点呢?请注意,由于当前实现将算法隐藏在函数内部,这实际上是不可能的。一个可能的解决方法是仅运行一次二分法算法的步骤。由于所有步骤相似,我们可以认为我们已经测试了所有可能的步骤。我们还需要能够检查算法当前步骤中的当前边界a和b。因此,我们必须将要运行的步骤数作为参数添加,并改变函数的返回接口。我们将按如下方式进行:
def bisect(f,a,b,n=100):
...
for iteration in range(n):
...
return a,b
注意,我们必须更改现有的单元测试以适应这一变化。现在我们可以添加一个单元测试,如下所示:
def test_midpoint(self):
a,b = bisect(identity,-2.,1.,1)
self.assertAlmostEqual(a,-0.5)
self.assertAlmostEqual(b,1.)
15.2.8 调试
在测试时,有时需要调试,特别是当不能立即明确为何某个测试未通过时。在这种情况下,能够在交互式会话中调试特定测试是非常有用的。然而,由于unittest.TestCase类的设计,这使得测试用例对象的实例化变得不容易。解决方案是只为调试目的创建一个特殊实例。
假设在之前的TestIdentity类的示例中,我们想要测试test_functionality方法。可以按如下方式实现:
test_case = TestIdentity(methodName='test_functionality')
现在这个测试可以单独运行,命令是:
test_case.debug()
这将运行这个单独的测试,并允许进行调试。
15.2.9 测试发现
如果你编写一个 Python 包,多个测试可能分散在包的各个部分。模块discover会找到、导入并运行这些测试用例。命令行中的基本调用方式是:
python -m unittest discover
它开始在当前目录查找测试用例,并递归目录树向下查找名称中包含'test'字符串的 Python 对象。该命令接受可选参数。最重要的参数是-s来修改起始目录,-p来定义识别测试的模式:
python -m unittest discover -s '.' -p 'Test*.py'
15.3 测量执行时间
为了做出代码优化决策,通常需要比较几种代码替代方案,并根据执行时间决定优先使用哪种代码。此外,在比较不同算法时,讨论执行时间是一个重要问题。在本节中,我们展示了一种简单易用的计时方法。
15.3.1 使用魔法函数进行计时
测量单个语句的执行时间最简单的方法是使用 IPython 的魔法函数 %timeit。
IPython 外壳为标准 Python 添加了额外的功能。这些额外的功能被称为魔法函数。
由于单个语句的执行时间可能非常短,因此将语句放入循环中并执行多次。通过取最小的测量时间,确保计算机上其他任务不会对测量结果产生过多影响。
让我们考虑提取数组中非零元素的四种替代方法,如下所示:
A=zeros((1000,1000))
A[53,67]=10
def find_elements_1(A):
b = []
n, m = A.shape
for i in range(n):
for j in range(m):
if abs(A[i, j]) > 1.e-10:
b.append(A[i, j])
return b
def find_elements_2(A):
return [a for a in A.reshape((-1, )) if abs(a) > 1.e-10]
def find_elements_3(A):
return [a for a in A.flatten() if abs(a) > 1.e-10]
def find_elements_4(A):
return A[where(0.0 != A)]
使用 IPython 的魔法函数 %timeit 测量时间的结果如下:
In [50]: %timeit -n 50 -r 3 find_elements_1(A)
50 loops, best of 3: 585 ms per loop
In [51]: %timeit -n 50 -r 3 find_elements_2(A)
50 loops, best of 3: 514 ms per loop
In [52]: %timeit -n 50 -r 3 find_elements_3(A)
50 loops, best of 3: 519 ms per loop
In [53]: %timeit -n 50 -r 3 find_elements_4(A)
50 loops, best of 3: 7.29 ms per loop
参数 -n 控制在计时前语句执行的次数,-r 参数控制重复次数。
15.3.2 使用 Python 模块 timeit 进行计时
Python 提供了 timeit 模块,可用于测量执行时间。它要求首先构造一个时间对象。该对象是通过两个字符串构造的:一个包含设置命令的字符串和一个包含要执行命令的字符串。
我们采用与前面示例中相同的四种替代方案。现在,数组和函数的定义写入一个名为 setup_statements 的字符串,并按照如下方式构造四个计时对象:
import timeit
setup_statements="""
from scipy import zeros
from numpy import where
A=zeros((1000,1000))
A[57,63]=10.
def find_elements_1(A):
b = []
n, m = A.shape
for i in range(n):
for j in range(m):
if abs(A[i, j]) > 1.e-10:
b.append(A[i, j])
return b
def find_elements_2(A):
return [a for a in A.reshape((-1,)) if abs(a) > 1.e-10]
def find_elements_3(A):
return [a for a in A.flatten() if abs(a) > 1.e-10]
def find_elements_4(A):
return A[where( 0.0 != A)]
"""
experiment_1 = timeit.Timer(stmt = 'find_elements_1(A)',
setup = setup_statements)
experiment_2 = timeit.Timer(stmt = 'find_elements_2(A)',
setup = setup_statements)
experiment_3 = timeit.Timer(stmt = 'find_elements_3(A)',
setup = setup_statements)
experiment_4 = timeit.Timer(stmt = 'find_elements_4(A)',
setup = setup_statements)
定时器对象有一个 repeat 方法。它接受两个参数 repeat 和 number。该方法在一个循环中执行定时器对象的语句,测量时间,并根据 repeat 参数重复该实验。
我们继续前面的示例,并按照如下方式测量执行时间:
t1 = experiment_1.repeat(3,5)
t2 = experiment_2.repeat(3,5)
t3 = experiment_3.repeat(3,5)
t4 = experiment_4.repeat(3,5)
# Results per loop in ms
min(t1)*1000/5 # 615 ms
min(t2)*1000/5 # 543 ms
min(t3)*1000/5 # 546 ms
min(t4)*1000/5 # 7.26 ms
与使用 timeit 方法的示例不同,我们获取了所有测量结果的列表。由于计算时间可能根据计算机的整体负载而有所不同,因此该列表中的最小值可以视为执行语句所需计算时间的良好近似。
15.3.3 使用上下文管理器进行计时
最后,我们介绍第三种方法。它展示了上下文管理器的另一种应用。我们首先构造一个用于测量经过时间的上下文管理器对象,如下所示:
import time
class Timer:
def __enter__(self):
self.start = time.time()
# return self
def __exit__(self, ty, val, tb):
end = time.time()
self.elapsed=end-self.start
print(f'Time elapsed {self.elapsed} seconds')
return False
回顾一下,__enter__ 和 __exit__ 方法使得这个类成为一个上下文管理器。__exit__ 方法的参数 ty、val 和 tb 在正常情况下为 None。如果在执行过程中抛出异常,它们将分别包含异常类型、异常值和追踪信息。返回值 False 表示异常尚未被捕获。
我们将展示如何使用上下文管理器来测量前一个例子中四种方法的执行时间:
with Timer(): find_elements_1(A)
这样将显示类似 Time elapsed 15.0129795074 ms 的消息。
如果需要将计时结果存储在变量中,enter 方法必须返回 Timer 实例(取消注释 return 语句),并且必须使用 with ... as ... 结构:
with Timer() as t1:
find_elements_1(A)
t1.elapsed # contains the result
15.4 总结
没有测试就没有程序开发!我们展示了组织良好且有文档的测试的重要性。一些专业人士甚至通过首先指定测试来开始开发。一个有用的自动测试工具是 unittest 模块,我们已详细解释。虽然测试可以提高代码的可靠性,但性能提升需要通过分析代码的瓶颈来实现。不同的编码方法可能导致性能差异很大。我们展示了如何测量计算时间以及如何定位代码中的瓶颈。
15.5 习题
例 1: 如果存在一个矩阵
,使得
,则两个矩阵
被称为相似。矩阵
和
具有相同的特征值。编写一个测试,检查两个矩阵是否相似,通过比较它们的特征值。这个是功能测试还是单元测试?
例 2: 创建两个大维度的向量。比较计算它们点积的不同方法的执行时间:
-
SciPy 函数:
v @ w -
生成器和求和:
sum((x*y for x,y in zip(v,w))) -
综合列表和求和:
sum([x*y for x,y in zip(v,w)])

被称为
的移动平均。确定计算
的两个方法中哪个更快:
v = (u[:-2] + u[1:-1] + u[2:]) / 3
或者
v = array([(u[i] + u[i + 1] + u[i + 2]) / 3
for i in range(len(u)-3)])
符号计算 - SymPy
本章将简要介绍如何使用 Python 进行符号计算。市场上有许多强大的软件可以执行符号计算,例如 Maple™或 Mathematica™。但有时,可能更倾向于在自己熟悉的语言或框架中进行符号计算。在本书的这一阶段,我们假设这种语言是 Python,因此我们寻找一个 Python 工具——模块 SymPy。
对 SymPy 的完整描述,如果有可能的话,将填满整本书,而这并非本章的目的。相反,我们将通过一些指导性示例来为您指明通向该工具的道路,展示其作为 NumPy 和 SciPy 的补充工具的潜力。
第十七章:16.1 什么是符号计算?
迄今为止,本书中所有的计算都属于所谓的数值计算。这些是主要基于浮动点数的一系列操作。数值计算的特点是,结果是精确解的近似值。
符号计算通过对公式或符号进行变换,像代数或微积分中所教的那样,将其转化为其他公式。这些变换的最后一步可能需要插入数字并进行数值计算。
我们通过计算这个定积分来说明两者之间的区别:

从符号上看,通过考虑被积函数的原始函数,可以对这个表达式进行变换:

我们现在通过插入积分界限来获得定积分的公式:

这被称为积分的封闭形式表达式。极少数数学问题有解可以用封闭形式表达式表示。这是积分的精确值,没有任何近似。此外,通过将实数表示为浮动点数,也不会引入误差,否则会产生舍入误差。
近似和舍入误差在最后一刻发挥作用,当需要对这个表达式进行求值时。平方根和arctan只能通过数值方法近似求值。这样的求值给出的是最终结果,精度达到某个特定的(通常是未知的)程度:

另一方面,数值计算会直接通过某种近似方法(例如辛普森法则)来近似定积分,并给出数值结果,通常还会给出误差估计。在 Python 中,这可以通过以下命令完成:
from scipy.integrate import quad
quad(lambda x : 1/(x**2+x+1),a=0, b=4)
它们返回值![]和误差边界的估计![]*。
以下图(图 16.1)展示了数值和符号近似的比较:

图 16.1:符号和数值求积
16.1.1 在 SymPy 中展开一个示例
首先,我们将展开之前的示例,并解释步骤。
首先,我们必须导入模块:
from sympy import *
init_printing()
第二个命令确保公式如果可能的话以图形方式呈现。接着,我们生成一个符号并定义被积函数:
x = symbols('x')
f = Lambda(x, 1/(x**2 + x + 1))
x 现在是一个类型为 Symbol 的 Python 对象,f 是一个 SymPy Lambda 函数(注意命令以大写字母开头)。
现在我们开始进行积分的符号计算:
integrate(f(x),x)
根据你的工作环境,结果的呈现方式不同;见下图 (图 16.2),展示了在不同环境下 SymPy 公式的两种不同结果:

图 16.2:在两种不同环境下,SymPy 公式的两张截图
我们可以通过微分来检查结果是否正确。为此,我们为原始函数分配一个名称,并对其关于
进行微分:
pf = Lambda(x, integrate(f(x),x))
diff(pf(x),x)
获得的结果如下:

这可以通过以下命令进行简化:
simplify(diff(pf(x),x))
到

这是我们预期的结果。
定积分通过以下命令获得:
pf(4) - pf(0)
简化后通过 simplify 命令得到的输出是:

为了获得数值结果,我们最终将这个表达式求值为一个浮动点数:
(pf(4)-pf(0)).evalf() # returns 0.9896614396123
16.2 SymPy 的基本元素
在这里,我们介绍了 SymPy 的基本元素。你会发现,如果已经熟悉 Python 中的类和数据类型,会更有利于理解。
16.2.1 符号 – 所有公式的基础
在 SymPy 中构建公式的基本构建元素是符号。正如我们在引言示例中看到的,符号是通过命令 symbols 创建的。这个 SymPy 命令通过给定的字符串生成符号对象:
x, y, mass, torque = symbols('x y mass torque')
这实际上是以下命令的简写:
symbol_list=[symbols(l) for l in 'x y mass torque'.split()]
然后进行解包操作以获取变量:
x, y, mass, torque = symbol_list
命令的参数定义了符号的字符串表示形式。所选符号的变量名通常与其字符串表示相同,但这并不是语言要求的:
row_index=symbols('i',integer=True)
print(row_index**2) # returns i**2
在这里,我们也定义了符号假定为整数。
可以通过非常紧凑的方式定义一整套符号:
integervariables = symbols('i:l', integer=True)
dimensions = symbols('m:n', integer=True)
realvariables = symbols('x:z', real=True)
类似地,可以通过以下方式定义索引变量的符号:
A = symbols('A1:3(1:4)')
这给出了一个符号元组:

索引的范围规则是我们在本书中使用切片时看到的规则(参见第 3.1.1 节:*切片**,了解更多详细信息)。
16.2.2 数字
Python 直接对数字进行运算,并且引入了不可避免的舍入误差。这些误差会妨碍所有的符号计算。通过sympify数字可以避免这一问题:
1/3 # returns 0.3333333333333333
sympify(1)/sympify(3) # returns '1/3'
sympify命令将整数转换为类型为sympy.core.numbers.Integer的对象。
代替将1/3写作两个整数的运算,它也可以通过Rational(1,3)直接表示为一个有理数。
16.2.3 函数
SymPy 区分已定义函数和未定义函数。未定义函数(这一术语可能有些误导)指的是那些没有特殊性质的通用函数,虽然它们是已定义的 Python 对象。
一个具有特殊性质的函数例子是atan或本章入门示例中使用的Lambda函数。
请注意,同一数学函数的不同实现有不同的名称:sympy.atan和scipy.arctan。
未定义函数
通过给symbols命令一个额外的类参数,可以创建未定义函数的符号:
f, g = symbols('f g', cls=Function)
同样的效果可以通过使用构造函数Function来实现:
f = Function('f')
g = Function('g')
对于未定义的函数,我们可以评估微积分的通用规则。
例如,让我们评估以下表达式:

这是通过以下命令在 Python 中符号计算得到的:
x = symbols('x')
f, g = symbols('f g', cls=Function)
diff(f(x*g(x)),x)
执行时,前面的代码返回以下输出:

这个例子展示了如何应用乘积法则和链式法则。
我们甚至可以使用一个未定义的函数作为多个变量的函数,例如:
x = symbols('x:3')
f(*x)
这将返回以下输出:

请注意使用星号操作符来解包元组以形成带有参数的f;请参见第 7.2.5 节:可变数量的参数。
通过列表推导,我们可以构造一个包含所有偏导数的列表![]:
[diff(f(*x),xx) for xx in x]
这将返回一个列表,包含:

该命令也可以通过使用Function对象的diff方法来重写:
[f(*x).diff(xx) for xx in x]
另一种方法是泰勒级数展开:
x = symbols('x')
f(x).series(x,0,n=4)
这将返回泰勒公式,并且包含通过兰道符号表示的余项:

16.2.4 基本函数
SymPy 中的基本函数例子包括三角函数及其反函数。以下例子展示了simplify如何作用于包含基本函数的表达式:
x = symbols('x')
simplify(cos(x)**2 + sin(x)**2) # returns 1
这是另一个使用基本函数的例子:
atan(x).diff(x) - 1./(x**2+1) # returns 0
如果你同时使用 SciPy 和 SymPy,我们强烈建议你将它们放在不同的命名空间中:
import numpy as np
import sympy as sym
# working with numbers
x=3
y=np.sin(x)
# working with symbols
x=sym.symbols('x')
y=sym.sin(x)
16.2.5 Lambda 函数
在第 7.7 节:匿名函数中,我们看到如何在 Python 中定义所谓的匿名函数。SymPy 的对应命令是Lambda。请注意两者的区别;lambda是一个关键字,而Lambda是一个构造函数。
命令Lambda接受两个参数,一个是函数的自变量符号,另一个是用于求值的 SymPy 表达式。
这里有一个示例,定义了空气阻力(也叫做拖力)作为速度的函数:
C,rho,A,v=symbols('C rho A v')
# C drag coefficient, A coss-sectional area, rho density
# v speed
f_drag = Lambda(v,-Rational(1,2)*C*rho*A*v**2)
f_drag以图形表达式的形式显示:

这个函数可以通过提供一个参数来以常规方式进行求值:
x = symbols('x')
f_drag(2)
f_drag(x/3)
这将得到以下表达式:

还可以通过仅提供Lambda的第一个参数为一个元组,来创建多个变量的函数,例如如下所示:
x,y=symbols('x y')
t=Lambda((x,y),sin(x) + cos(2*y))
调用这个函数有两种方式,可以通过直接提供多个参数来完成:
t(pi,pi/2) # returns -1
或者通过解包元组或列表:
p=(pi,pi/2)
t(*p) # returns -1
SymPy 中的矩阵对象甚至使我们能够定义向量值函数:
F=Lambda((x,y),Matrix([sin(x) + cos(2*y), sin(x)*cos(y)]))
这使得我们能够计算雅可比矩阵:
F(x,y).jacobian((x,y))
这将输出以下表达式:

如果有更多变量,使用更紧凑的形式来定义函数会更加方便:
x=symbols('x:2')
F=Lambda(x,Matrix([sin(x[0]) + cos(2*x[1]),sin(x[0])*cos(x[1])]))
F(*x).jacobian(x)
16.3 符号线性代数
符号线性代数由 SymPy 的matrix数据类型支持,我们将首先介绍它。然后我们将展示一些线性代数方法,作为符号计算在这一领域广泛应用的示例。
16.3.1 符号矩阵
我们在讨论向量值函数时简要介绍了matrix数据类型。在那里,我们看到了它最简单的形式,能够将一个列表的列表转换为矩阵。为了举个例子,让我们构造一个旋转矩阵:
phi=symbols('phi')
rotation=Matrix([[cos(phi), -sin(phi)],
[sin(phi), cos(phi)]])
在使用 SymPy 矩阵时,我们需要注意,操作符*执行的是矩阵乘法,而不是像 NumPy 数组那样的逐元素乘法。
之前定义的旋转矩阵可以通过使用矩阵乘法和矩阵的转置来检查其正交性:
simplify(rotation.T*rotation -eye(2)) # returns a 2 x 2 zero matrix
前面的示例展示了如何转置一个矩阵以及如何创建单位矩阵。或者,我们本可以检查其逆矩阵是否为其转置,方法如下:
simplify(rotation.T - rotation.inv())
设置矩阵的另一种方法是通过提供符号列表和形状:
M = Matrix(3,3, symbols('M:3(:3)'))
这将创建以下矩阵:

创建矩阵的第三种方法是通过给定函数生成其条目。语法如下:
Matrix(number of rows,number of colums, function)
我们通过考虑一个 Toeplitz 矩阵来举例说明前面的矩阵。它是一个具有常数对角线的矩阵。给定一个![]数据向量!,其元素定义为:

在 SymPy 中,可以直接使用此定义来定义矩阵:
def toeplitz(n):
a = symbols('a:'+str(2*n))
f = lambda i,j: a[i-j+n-1]
return Matrix(n,n,f)
执行前面的代码会得到toeplitz(5):
。
我们可以清楚地看到所需的结构;所有沿副对角线和超对角线的元素都相同。我们可以根据 Python 语法(见第 3.1.1 节:切片)通过索引和切片访问矩阵元素:
M[0,2]=0 # changes one element
M[1,:]=Matrix(1,3,[1,2,3]) # changes an entire row
16.3.2 SymPy 中线性代数方法的示例
线性代数中的基本任务是求解线性方程组:

让我们用符号方法处理一个
矩阵:
A = Matrix(3,3,symbols('A1:4(1:4)'))
b = Matrix(3,1,symbols('b1:4'))
x = A.LUsolve(b)
这个相对较小问题的输出已经只是可读的,我们可以在以下的图形表达式中看到:

同样,使用simplify命令有助于我们检测抵消项并收集公共因子:
simplify(x)
这将导致以下输出,结果看起来更好:

当矩阵维度增加时,符号计算变得非常慢。对于大于 15 的维度,甚至可能出现内存问题。
下一幅图(图 16.3)展示了符号求解和数值求解线性系统之间 CPU 时间的差异:

图 16.3:数值和符号求解线性系统的 CPU 时间
16.4 替代
我们首先考虑一个简单的符号表达式:
x, a = symbols('x a')
b = x + a
如果我们设置 x = 0 会发生什么?我们观察到 b 并没有改变。我们所做的是改变了 Python 变量 x,它现在不再引用符号对象,而是引用了整数对象 0。由字符串 'x' 表示的符号保持不变,b 也没有变化。
另一方面,通过将符号替换为数字、其他符号或表达式,来改变表达式是通过一种特殊的替代方法完成的,以下代码展示了这一点:
x, a = symbols('x a')
b = x + a
c = b.subs(x,0)
d = c.subs(a,2*a)
print(c, d) # returns (a, 2a)
此方法接受一个或两个参数。以下两个语句是等价的:
b.subs(x,0)
b.subs({x:0}) # a dictionary as argument
将字典作为参数允许我们一步完成多个替代:
b.subs({x:0, a:2*a}) # several substitutions in one
由于字典中的项目没有固定顺序——我们永远无法知道哪个是第一个——因此需要确保对项目的排列不会影响替代结果。因此,在 SymPy 中,替代操作首先在字典中进行,然后再在表达式中进行。以下示例演示了这一点:
x, a, y = symbols('x a y')
b = x + a
b.subs({a:a*y, x:2*x, y:a/y})
b.subs({y:a/y, a:a*y, x:2*x})
两种替代方法返回相同的结果:
![]
定义多个替代的第三种方法是使用旧值/新值对的列表:
b.subs([(y,a/y), (a,a*y), (x,2*x)])
也可以将整个表达式替换为其他表达式:
n, alpha = symbols('n alpha')
b = cos(n*alpha)
b.subs(cos(n*alpha), 2*cos(alpha)*cos((n-1)*alpha)-cos((n-2)*alpha))
为了说明矩阵元素的替代,我们再次取
Toeplitz 矩阵:

考虑替换T.subs(T[0,2],0)。它改变了位置[0, 2]处的符号对象,即符号
。它还出现在其他两个地方,这些地方会被这个替换自动影响。
给定的表达式是结果矩阵:

或者,我们可以为该符号创建一个变量并在替换中使用它:
a2 = symbols('a2')
T.subs(a2,0)
作为一个更复杂的替换示例,让我们考虑如何将 Toeplitz 矩阵转换为三对角 Toeplitz 矩阵. 这可以通过以下方式完成:
首先,我们生成一个符号列表,选择我们要替换的符号;然后使用zip命令生成一对对的列表。最后,我们通过给出旧值/新值对的列表进行替换,如前所述:
symbs = [symbols('a'+str(i)) for i in range(19) if i < 3 or i > 5]
substitutions=list(zip(symbs,len(symbs)*[0]))
T.subs(substitutions)
这会得到以下矩阵作为结果:

16.5 评估符号表达式
在科学计算中,通常需要先进行符号运算,然后将符号结果转换为浮点数。
评估符号表达式的核心工具是evalf。它通过使用以下方法将符号表达式转换为浮点数:
pi.evalf() # returns 3.14159265358979
结果对象的数据类型是Float(注意大写),这是一个 SymPy 数据类型,允许使用任意位数(任意精度)的浮点数。
默认精度对应于 15 位数字,但可以通过给evalf一个额外的正整数参数来改变它。
通过指定所需精度的数字位数:
pi.evalf(30) # returns 3.14159265358979323846264338328
使用任意精度的一个结果是,数字可以非常小,也就是说,打破了经典浮点数表示的限制;请参见第 2.2.2 节:浮点数。
有趣的是,用Float类型的输入来评估 SymPy 函数会返回一个与输入精度相同的Float。我们将在一个来自数值分析的更复杂示例中演示这一事实的使用。
16.5.1 示例:牛顿法收敛阶的研究
一个迭代方法,如果迭代![],被称为以阶数
收敛,并且存在一个正的常数
,使得:

牛顿法,当从一个好的初值开始时,其收敛阶为
,对于某些问题,甚至可以达到
。应用牛顿法解决问题
时,给出以下迭代方案:

该过程的收敛速度是立方收敛;也就是说,q = 3。
这意味着正确数字的数量会随着每次迭代从上一轮迭代中三倍增加。为了演示立方收敛并数值求解常数,![]使用标准的 16 位数字float数据类型几乎无法实现。
以下代码使用 SymPy 并结合高精度求值,将立方收敛研究推向极致:
import sympy as sym
x = sym.Rational(1,2)
xns=[x]
for i in range(1,9):
x = (x - sym.atan(x)*(1+x**2)).evalf(3000)
xns.append(x)
结果如下图所示(图 16.4),显示了每次迭代正确数字的数量是如何从上一轮迭代中三倍增加的:

图 16.4:对应用于
的牛顿法收敛性的研究
这种极高精度要求(3,000 位数字!)使我们能够以如下方式评估前面序列的七项,从而演示立方收敛:
import numpy as np
# Test for cubic convergence
print(np.array(np.abs(np.diff(xns[1:]))/np.abs(np.diff(xns[:-1]))**3,
dtype=np.float64))
[ 0.41041618, 0.65747717, 0.6666665, 0.66666667, 0.66666667, 0.66666667, 0.66666667]}
16.5.2 将符号表达式转换为数值函数
正如我们所见,符号表达式的数值求解分为三个步骤:首先,我们进行一些符号计算,然后通过数字替换变量,最后使用evalf进行浮动点数的求值。
进行符号计算的原因通常是我们希望进行参数研究。这要求在给定的参数范围内修改参数。这要求符号表达式最终被转换为数值函数。
对多项式系数的参数依赖性研究
我们通过一个插值示例展示了符号/数值参数研究,以介绍 SymPy 命令lambdify。
让我们考虑任务,即对数据
和
进行插值。在这里,
是一个自由参数,我们将在区间
上变化。
二次插值多项式具有依赖于该参数的系数:

使用 SymPy 和练习 3中描述的单项式方法,如第 4.11 节中的练习给出了这些系数的封闭公式:
t=symbols('t')
x=[0,t,1]
# The Vandermonde Matrix
V = Matrix([[0, 0, 1], [t**2, t, 1], [1, 1,1]])
y = Matrix([0,1,-1]) # the data vector
a = simplify(V.LUsolve(y)) # the coefficients
# the leading coefficient as a function of the parameter
a2 = Lambda(t,a[0])
我们为插值多项式的主系数![]获得了一个符号函数:

现在是将表达式转换为数值函数的时候了,例如,为了生成一个图形。这是通过lamdify函数完成的。该函数接受两个参数,一个是自变量,另一个是 SymPy 函数。
在我们的 Python 示例中,我们可以编写:
leading_coefficient = lambdify(t,a2(t))
现在可以通过以下命令绘制该函数,例如:
import numpy as np
import matplotlib.pyplot as mp
t_list= np.linspace(-0.4,1.4,200)
ax=mp.subplot(111)
lc_list = [leading_coefficient(t) for t in t_list]
ax.plot(t_list, lc_list)
ax.axis([-.4,1.4,-15,10])
ax.set_xlabel('Free parameter $t$')
ax.set_ylabel('$a_2(t)$')
图 16.5 是此参数研究的结果,我们可以清楚地看到由于多个插值点(这里在
或
)而产生的奇点:

图 16.5:多项式系数依赖于插值点位置的关系
16.6 小结
在本章中,你初步了解了符号计算的世界,并领略了 SymPy 的强大功能。通过学习这些例子,你掌握了如何设置符号表达式、如何操作符号矩阵,并学习了如何进行简化处理。通过处理符号函数并将其转化为数值计算,你最终建立了与科学计算和浮点结果之间的联系。你体验了 SymPy 的强大,它通过与 Python 的完美集成,提供了强大的构造和易读的语法。
将最后一章视为开胃菜,而非完整菜单。我们希望你对未来在科学计算和数学中的精彩编程挑战产生兴趣。
与操作系统交互
本节是 Python 简介的附加内容。它将 Python 放入计算机操作系统的上下文中,并展示了如何在命令窗口(也称为控制台)中使用 Python。我们演示系统命令和 Python 命令如何互动以及如何创建新应用程序。
在许多其他操作系统和各种 方言 中,桌面计算机和笔记本电脑上使用的三个主要操作系统是:Windows 10、macOS 和 Linux。在本章中,我们讨论如何在 Linux 环境中使用 Python。所有示例均在广泛分发的 Ubuntu 环境中进行测试。这里介绍的原则同样适用于其他大型操作系统。但本章内容仅限于 Linux,作为一个完全自由获取的环境。
首先,假设我们已经编写并测试了一个 Python 程序,并且现在希望直接从控制台窗口而不是从诸如 IPython 的 Python shell 运行它。
本章涵盖以下主题:
-
在 Linux shell 中运行 Python 程序
-
模块
sys -
如何从 Python 执行 Linux 命令
第十八章:17.1 在 Linux shell 中运行 Python 程序
打开终端应用程序时,会获得一个带有命令提示符的窗口;参见 图 17.1:

图 17.1:Ubuntu 20.04 中的终端窗口
终端窗口带有命令提示符,通常由用户名和计算机名称前缀,后跟目录名称。这取决于个人设置。
要执行名为 myprogram.py 的 Python 命令文件,您有两个选择:
-
执行命令
python myprogram.py -
直接执行命令
myprogram.py
第二种变体需要一些准备工作。首先,您必须允许执行该文件,其次,您必须告诉系统需要执行该文件的命令。通过以下命令来允许执行该文件:
chmod myprogram.py o+x
chmod 代表更改文件模式。该命令后跟文件名,最后是所需的新模式,在这里是 o+x。
在该示例中给出的模式代表“授予(+)文件所有者(o)执行(x)该文件的权限”。我们假设您是文件的所有者,并且它位于当前目录中。
然后,我们必须找到计算机上命令 python 的位置。通过运行以下命令来完成:
which python
如果您像在 第 1.1 节 中描述的那样通过 Anaconda 安装 Python,则会获得有关命令 python 在您的系统上位置的信息,例如 /home/claus/anaconda3/bin/python。
这些信息必须写在 Python 脚本的第一行,以告知系统通过哪个程序可以将文本文件转换为可执行文件。以下是一个示例:
#! /home/claus/anaconda3/bin/python
a=3
b=4
c=a+b
print(f'Summing up {a} and {b} gives {c}')
Python 视角中的第一行只是一个注释,会被忽略。但是 Linux 会将第一行中shebang组合符号#!之后的部分视为将文件转化为可执行文件所需的命令。这里,它学习使用位于目录/home/claus/anaconda3/bin/中的python命令来实现这一点。
我们可以通过逻辑路径来定义 Python 解释器的位置,而不是通过绝对路径,这样可以使代码更具可移植性。你可以将其提供给使用 Linux 系统的同事,而无需修改:
#! /usr/bin/env python # a logical path to Python (mind the blank)
现在你可以直接在控制台执行示例代码;见 图 17.2:

图 17.2:在 Linux 终端中执行示例文件 example.py
我们需要在命令前加上./,这告诉操作系统在当前目录中查找该命令。如果没有这个前缀,Linux 会在搜索路径中列出的某个目录中查找文件example.py。
在接下来的部分中,我们将展示如何直接从命令行传递参数给 Python 程序。
17.2 sys 模块
模块sys提供了与系统命令进行通信的工具。我们可以直接从命令行传递带有参数的 Python 脚本,并将结果输出到控制台。
17.2.1 命令行参数
为了说明命令行参数的使用,我们考虑以下代码段,我们将其保存到名为demo_cli.py的文件中:
#! /usr/bin/env python
import sys
text=f"""
You called the program{sys.argv[0]}
with the {len(sys.argv)-1} arguments, namely {sys.argv[1:]}"""
print(text)
在通过chmod o+x demo_cli.py给予文件执行权限后,我们可以在 Shell 中带参数执行它;见 图 17.3:

图 17.3:在终端命令行上带三个参数执行 Python 脚本
控制台中给出的三个参数可以通过列表sys.argv在 Python 脚本中访问。这个列表中的第一个元素——索引为0的元素——是脚本的名称。其他元素是给定的参数,作为字符串形式。
参数是传递给 Python 脚本的调用。它们不应与脚本执行过程中用户输入的内容混淆。
17.2.2 输入与输出流
在前面的示例中,我们使用了命令print来显示终端中生成的消息(甚至是在 Python Shell 中)。脚本的先前输入通过参数和变量sys.argv获取。与print相对的命令是input,它提示从终端(或 Python Shell)获取数据。
在第 14.1 节:文件处理中,我们看到了如何通过文件对象和相关方法向脚本提供数据以及如何输出数据。模块sys使得可以将键盘当作输入的文件对象(例如,readline、readlines),而将控制台当作输出的文件对象(例如,write、writelines)。
在 UNIX 中,信息流通过三个流组织:
-
标准输入流:
STDIN -
标准输出流:
STDOUT -
标准错误流:
STDERR
这些流对应于文件对象,可以通过sys.stdin、sys.stdout和sys.stderr在 Python 中访问。
为了举个例子,我们考虑一个小脚本pyinput.py,它计算一些数字的总和:
#!/usr/bin/env python3
from numpy import *
import sys
# Terminate input by CTRL+D
a=array(sys.stdin.readlines()).astype(float)
print(f'Input: {a}, Sum {sum(a)}'
语句sys.stdin.readlines()创建了一个生成器。命令array会迭代这个生成器,直到用户输入结束符号,该符号在 Linux 系统上是CTRL-D,在 Windows 系统上是CTRL-Z。请参见图 17.4中的截图:

图 17.4:使用sys.stdin执行脚本的终端截图。
请注意,给定的结束符号 CTRL-D 是不可见的。
重定向流
标准输入正在等待来自键盘的数据流。但输入可以通过文件进行重定向。通过在 Linux 中使用重定向符号<可以实现这一点。我们通过使用与之前相同的脚本,但这次由数据文件intest.txt提供数据来展示这一点;请参见图 17.5:

图 17.5:展示输入重定向(sys.stdin)的截图
脚本本身无需修改。无论哪种方式都可以使用。
输出也是如此。默认情况下,输出会显示在终端中,但这里也有将输出重定向到文件的选项。在这种情况下,重定向符号是>;请参见图 17.6:

图 17.6:重定向输入和重定向输出的截图
现在,创建了一个名为result.txt的文件,并将脚本的输出写入该文件。如果该文件已经存在,其内容将被覆盖。如果输出应该附加到已有文件的末尾,则必须使用重定向符号>>。
最后,有时可能希望将输出与错误或警告信息分开。这就是 Linux 提供两个输出流通道的原因,分别是sys.stdout和sys.stderr。默认情况下,两者都指向同一个位置,即终端。但通过使用重定向符号,错误信息可以例如写入文件,而主输出则显示在屏幕上,或者反之亦然。
为了演示这一点,我们修改示例pyinput.py,以便在没有提供输入时生成错误信息:
#!/usr/bin/env python3
from numpy import *
import sys
# Terminate input by CTRL+D
a=array(sys.stdin.readlines()).astype(float)
print(f'Input: {a}, Sum {sum(a)}')
if a.size == 0:
sys.stderr.write('No input given\n')
在终端窗口中,重定向输入和错误输出的脚本典型调用呈现在图 17.7中:

图 17.7:带有标准输入和标准错误输出重定向的终端窗口截图
如果输入文件为空,错误信息会写入文件 error.txt,而输出则显示在终端窗口中。
错误消息是来自未捕获异常的消息,包括语法错误和显式写入 sys.stderr 的文本。
在表 17.1中,汇总了不同的重定向情况:
| 任务 | Python 对象 | 重定向符号 | 替代符号 |
|---|---|---|---|
| 数据输入 | sys.stdin |
< | |
| 数据输出 | sys.stdout |
> | 1> |
| 数据输出附加到文件 | sys.stdout |
>> | 1>> |
| 错误输出 | sys.stderr |
2> | |
| 错误输出附加到文件 | sys.stderr |
2>> | |
| 所有输出 | sys.stdout, sys.stderr |
&> | |
| 所有输出附加到文件 | sys.stdout, sys.stderr |
&>> |
表 17.1:不同重定向场景的汇总
在 Linux 命令与 Python 脚本之间建立管道
在上一节中,我们展示了如何将 Python 程序的输入和输出重定向到文件。当不同 Python 程序之间,或者 Python 程序与 Linux 命令之间的数据流时,数据通常通过文件传递。如果数据不在其他地方使用或应当保留供以后使用,这个过程就显得冗长:仅仅为了直接将信息从一段代码传递到另一段代码,需要创建、命名和删除文件。替代方案是使用 Linux 管道,让数据从一个命令直接流向另一个命令。
让我们从一个纯 Linux 示例开始,然后将管道构造应用于 Python。
Linux 命令 ifconfig 显示有关 Linux 计算机当前网络配置的大量信息。在这些信息中,你可以找到 IP 地址(即当前使用的网络地址)。例如,要自动判断计算机(如笔记本电脑)是否通过某个网络单元连接到家庭网络,而不是连接到外部网络,你可能需要扫描 ifconfig 的输出,查找包含网络适配器标识符(例如 wlp0s20f3)的行,并在其下几行中查找包含网络前缀的字符串(如 192.168)。如果找到这个字符串,输出应仅显示包含该字符串的行;即行数计数应返回 1。如果计算机未连接到家庭网络,则行数计数返回 0。
我们使用三个命令:
-
ifconfig用于显示完整的网络配置信息。 -
grep用于查找包含特定模式的行。该行及根据参数-A的要求,显示一些后续行。 -
wc用于对文件执行各种计数操作。参数-l指定计数行数。
这些命令的输出会直接传递给下一个命令。这是通过使用管道符号 | 来实现的:
ifconfig|grep -A1 wlp0s20f3 | grep 192.168|wc -l
此命令行在家庭网络中执行时仅在屏幕上显示 1。所有中间输出直接传递到下一个命令,而无需显示任何内容,并且不使用任何文件来临时存放信息,直到下一个命令读取它。一个命令的标准输出,stdout,成为下一个命令的标准输入,stdin。
这也适用于 Python 脚本直接调用。
我们通过在管道中演示 Python 脚本来继续上一个示例。在这里,我们简单地使用 Python 生成一个友好的消息:
#!/usr/bin/env python3
import sys
count = sys.stdin.readline()[0]
status = '' if count == '1' else 'not'
print(f"I am {status} at home")
现在,我们可以通过添加此脚本扩展管道;请参见 图 17.8 中的截图:

图 17.8:带有五个管道和一个 Python 脚本的命令链
在此示例中,我们在终端窗口中执行了一系列 Linux 程序和一个 Python 脚本。或者,可以让 Python 脚本调用 UNIX 命令。这将在下一节中演示。
17.3 如何从 Python 执行 Linux 命令
在上一节中,我们看到如何从 Linux 终端执行 Python 命令。在本节中,我们考虑如何在 Python 程序中执行 Linux 命令的重要情况。
17.3.1 模块 subprocess 和 shlex
要在 Python 中执行系统命令,首先需要导入模块 subprocess。此模块提供的高级工具是 run。使用此工具,您可以快速访问 Python 中的 Linux 命令,并处理它们的输出。
更复杂的工具是 Popen,我们将在解释如何在 Python 中模拟 Linux 管道时进行简要介绍。
完整的过程:subprocess.run
我们将通过最标准和简单的 UNIX 命令 ls 来演示此工具——列出目录内容的命令。它带有各种可选参数;例如,ls -l 以扩展信息显示列表。
要在 Python 脚本中执行此命令,我们使用 subprocess.run。最简单的用法是仅使用一个参数,将 Linux 命令拆分为几个文本字符串的列表:
import subprocess as sp
res = sp.run(['ls','-l'])
模块 shlex 提供了一个特殊工具来执行此分割:
_import shlex
command_list = shlex.split('ls -l') # returns ['ls', '-l']
它还尊重文件名中的空格,并且不将其用作分隔符。
命令 run 显示 Linux 命令的结果和 subprocess.CompletedProcess 对象 res。
以这种方式执行 UNIX 命令是相当无用的。大多数情况下,您希望处理输出。因此,必须将输出提供给 Python 脚本。为此,必须将可选参数 capture_output 设置为 True。通过这种方式,UNIX 命令的 stdout 和 stderr 流将作为返回对象的属性可用:
import subprocess as sp
import shlex
command_list = shlex.split('ls -l') # returns ['ls', '-l']
res = sp.run(command_list, capture_output=True)
print(res.stdout.decode())
注意,此处使用方法 decode 将字节字符串解码为标准字符串格式。
如果 Linux 命令返回错误,属性 res.returncode 将获得非零值,并且 res.stderr 包含错误消息。
Python 的做法是抛出一个错误。可以通过将可选参数check设置为True来实现:
import subprocess as sp
import shlex
command ='ls -y'
command_list = shlex.split(command) # returns ['ls', '-y']
try:
res = sp.run(command_list, capture_output=True, check=True)
print(res.stdout.decode())
except sp.CalledProcessError:
print(f"{command} is not a valid command")
创建进程:subprocess.Popen
当你对一个需要用户输入才能终止的进程应用subprocess.run时会发生什么?
这样一个程序的简单示例是xclock。它打开一个新窗口,显示一个时钟,直到用户关闭窗口。
由于命令subprocess.run创建了一个CompletedProcess对象,下面的 Python 脚本:
import subprocess as sp
res=sp.run(['xclock'])
启动一个进程并等待它结束,也就是说,直到有人关闭带有时钟的窗口;参见图 17.9:

图 17.9:xclock 窗口
这对subprocess.Popen有影响。它创建了一个 _Popen对象。进程本身变成了一个 Python 对象。它不需要完成才能成为一个可访问的 Python 对象:
import subprocess as sp
p=sp.Popen(['xclock'])
进程通过用户在时钟窗口上的操作或通过显式终止进程来完成,命令如下:
p.terminate()
使用Popen,我们可以在 Python 中构建 Linux 管道。下面的脚本:
import subprocess as sp_
p1=sp.Popen(['ls', '-l'],stdout=sp.PIPE)
cp2=sp.run(['grep','apr'], stdin=p1.stdout, capture_output=True)
print(cp2.stdout.decode())
对应于 UNIX 管道:
ls -l |grep 'apr
它显示了在四月最后访问的目录中的所有文件。
模块subprocess有一个对象PIPE,它接受第一个进程p1的输出。然后,它被作为stdin传递给命令run。该命令然后返回一个带有stdout属性(类型为bytes)的CompletedProcess对象cp2。最后,调用方法decode可以很好地打印结果。
另外,也可以通过使用两个Popen进程来实现相同的效果。第二个进程也使用管道,可以通过方法communicate进行显示:
import subprocess as sp
p1=sp.Popen(['ls', '-l'],stdout=sp.PIPE)
p2=sp.Popen(['grep','apr'], stdin=p1.stdout, stdout=sp.PIPE)
print(p2.communicate()[0].decode)
方法communicate返回一个元组,其中包含stdout和stderr上的输出。
17.4 小结
在本章中,我们演示了 Python 脚本与系统命令的交互。Python 脚本可以像系统命令一样被调用,或者 Python 脚本本身可以创建系统进程。本章基于 Linux 系统,如 Ubuntu,仅作为概念和可能性的演示。它允许将科学计算任务置于应用场景中,在这些场景中,通常需要将不同的软件结合起来。甚至硬件组件可能也会涉及其中。
用于并行计算的 Python
本章涉及并行计算和模块mpi4py。复杂且耗时的计算任务通常可以拆分为子任务,如果有足够的计算能力,这些子任务可以同时执行。当这些子任务彼此独立时,进行并行计算尤其高效。需要等待另一个子任务完成的情况则不太适合并行计算。
考虑通过求积法则计算一个函数的积分任务:

使用![]。如果评估![]非常耗时,且![]很大,那么将问题拆分为两个或多个较小的子任务会更有利:

我们可以使用几台计算机,并将必要的信息提供给每台计算机,使其能够执行各自的子任务,或者我们可以使用一台带有所谓多核架构的计算机。
一旦子任务完成,结果会传送给控制整个过程并执行最终加法的计算机或处理器。
我们将在本章中以此为指导示例,涵盖以下主题:
-
多核计算机和计算机集群
-
消息传递接口(MPI)
第十九章:18.1 多核计算机和计算机集群
大多数现代计算机都是多核计算机。例如,本书写作时使用的笔记本电脑配备了 Intel® i7-8565U 处理器,具有四个核心,每个核心有两个线程。
这意味着什么?处理器上的四个核心允许并行执行四个计算任务。四个核心,每个核心有两个线程,通常被系统监视器计为八个 CPU。在本章中,只有核心数量才是重要的。
这些核心共享一个公共内存——你的笔记本的 RAM——并且每个核心有独立的缓存内存:

图 18.1:具有共享和本地缓存内存的多核架构
缓存内存由核心最优使用,并以高速访问,而共享内存可以被一个 CPU 的所有核心访问。在其上方是计算机的 RAM 内存,最后是硬盘,也是共享内存。
在下一部分,我们将看到如何将计算任务分配到各个核心,以及如何接收结果并进一步处理,例如,存储到文件中。
另一种并行计算的设置是使用计算机集群。在这里,一个任务被划分为可以并行化的子任务,这些子任务被发送到不同的计算机,有时甚至跨越长距离。在这种情况下,通信时间可能会非常重要。只有当处理子任务的时间相对于通信时间较长时,使用计算机集群才有意义。
18.2 消息传递接口(MPI)
在多核计算机或分布式内存的计算机集群上编程需要特殊的技术。我们在这里描述了消息传递以及 MPI 标准化的相关工具。这些工具在不同的编程语言中相似,例如 C、C++和 FORTRAN,并通过mpi4py模块在 Python 中实现。
18.2.1 前提条件
你需要先通过在终端窗口执行以下命令来安装此模块:
conda install mpi4py
可以通过在你的 Python 脚本中添加以下一行来导入该模块:
import mpi4py as mpi
并行化代码的执行是通过终端使用命令mpiexec完成的。假设你的代码存储在文件script.py中,在一台具有四核 CPU 的计算机上执行此代码,可以在终端窗口通过运行以下命令来实现:
mpiexec -n 4 python script.py
或者,为了在一个包含两台计算机的集群上执行相同的脚本,可以在终端窗口运行以下命令:
mpiexec --hostfile=hosts.txt python script.py
你需要提供一个文件hosts.txt,其中包含你想绑定到集群的计算机的名称或 IP 地址,以及它们的核心数:
# Content of hosts.txt
192.168.1.25 :4 # master computer with 4 cores
192.168.1.101:2 # worker computer with 2 cores
Python 脚本(这里是script.py)必须被复制到集群中的所有计算机上。
18.3 将任务分配到不同的核心
当在多核计算机上执行时,我们可以认为mpiexec将给定的 Python 脚本复制到相应数量的核心上,并运行每个副本。例如,考虑一下包含命令print("Hello it's me")的单行脚本print_me.py,当通过mpiexec -n 4 print_me.py执行时,它会在屏幕上显示相同的消息四次,每次来自不同的核心。
为了能够在不同的核心上执行不同的任务,我们必须能够在脚本中区分这些核心。
为此,我们创建了一个所谓的通信实例,它组织了世界之间的通信,即输入输出单元,如屏幕、键盘或文件,与各个核心之间的通信。此外,每个核心都会被分配一个标识编号,称为 rank:
from mpi4py import MPI
comm=MPI.COMM_WORLD # making a communicator instance
rank=comm.Get_rank() # querrying for the numeric identifyer of the core
size=comm.Get_size() # the total number of cores assigned
通信器属性 size 指的是在mpiexec语句中指定的进程总数。
现在我们可以为每个核心分配一个独立的计算任务,就像在下一个脚本中那样,我们可以称之为basicoperations.py:
from mpi4py import MPI
comm=MPI.COMM_WORLD # making a communicator instance
rank=comm.Get_rank() # querrying for the numeric identifyer of the core
size=comm.Get_size() # the total number of cores assigned
a=15
b=2
if rank==0:
print(f'Core {rank} computes {a}+{b}={a+b}')
if rank==1:
print(f'Core {rank} computes {a}*{b}={a*b}')
if rank==2:
print(f'Core {rank} computes {a}**{b}={a**b}')
这个脚本可以通过在终端输入以下命令来执行:
mpiexec -n 3 python basicoperations.py
我们得到三个消息:
Core 0 computes 15+2=17
Core 2 computes 15**2=225
Core 1 computes 15*2=3
所有三个进程都有各自的任务,并且是并行执行的。显然,将结果打印到屏幕上是一个瓶颈,因为屏幕是所有三个进程共享的。
在下一节中,我们将看到进程间是如何进行通信的。
18.3.1 进程间信息交换
进程间有不同的发送和接收信息的方法:
-
点对点通信
-
单对多和多对单
-
多对多
在本节中,我们将介绍点对点、单对多和多对单通信。
向邻居讲话并让信息沿街道传递,这是前面列出的第一种通信类型的日常生活示例,而第二种可以通过新闻广播来说明,一人讲话并广播给一大群听众。单对多和多对单通信

图 18.2:点对点通信和单对多通信
在接下来的子节中,我们将研究这些不同的通信类型在计算上下文中的应用。
18.3.2 点对点通信
点对点通信将信息流从一个进程引导到指定的接收进程。我们首先通过考虑乒乓情况和电话链情况来描述方法和特点,并解释阻塞的概念。
点对点通信应用于科学计算,例如在分布域上的随机游走或粒子追踪应用,这些域被划分为多个子域,每个子域对应一个可以并行执行的进程数。
在这个乒乓示例中,我们假设有两个处理器相互发送一个整数,并将其值增加一。
我们从创建一个通信对象并检查是否有两个可用的进程开始:
from mpi4py import MPI
comm=MPI.COMM_WORLD # making a communicator instance
rank=comm.Get_rank() # querying for the numeric identifier of the core
size=comm.Get_size() # the total number of cores assigned
if not (size==2):
raise Exception(f"This examples requires two processes. \
{size} processes given.")
然后我们在两个进程之间来回发送信息:
count = 0
text=['Ping','Pong']
print(f"Rank {rank} activities:\n==================")
while count < 5:
if rank == count%2:
print(f"In round {count}: Rank {rank} says {text[count%2]}""
"and sends the ball to rank {(rank+1)%2}")
count += 1
comm.send(count, dest=(rank+1)%2)
elif rank == (count+1)%2:
count = comm.recv(source=(rank+1)%2)
信息通过通信器的send方法发送。在这里,我们提供了要发送的信息以及目的地。通信器确保将目的地信息转换为硬件地址;可以是你计算机的一个 CPU 核心,或主机的一个 CPU 核心。
另一台机器通过通信方法comm.recv接收信息。它需要知道信息来自哪里。在后台,它通过释放数据通道上的信息缓冲区,告诉发送方信息已经被接收。发送方在继续操作之前,需要等待此信号。
两个语句if rank == count%2和elif rank == (count+1)%2确保处理器交替进行发送和接收任务。
这是我们保存为pingpong.py文件并使用以下命令执行的短脚本输出:
mpiexec -n 2 python pingpong.py
在终端中,这会生成以下输出:
Rank 0 activities:
==================
In round 0: Rank 0 says Ping and sends the ball to rank 1
In round 2: Rank 0 says Ping and sends the ball to rank 1
In round 4: Rank 0 says Ping and sends the ball to rank 1
Rank 1 activities:
==================
In round 1: Rank 1 says Pong and sends the ball to rank 0
In round 3: Rank 1 says Pong and sends the ball to rank 0
可以发送或接收什么类型的数据?由于命令 send 和 recv 以二进制形式传递数据,因此它们首先会将数据进行 pickle(参见 第 14.3 节:Pickling)。大多数 Python 对象都可以被 pickled,但例如 lambda 函数不能。也可以 pickle 缓冲数据,例如 NumPy 数组,但直接发送缓冲数据更高效,正如我们将在下一小节中看到的那样。
请注意,在进程之间发送和接收函数可能有其原因。由于方法 send 和 recv 仅传递对函数的引用,因此这些引用必须在发送和接收的处理器上存在。因此,以下 Python 脚本会返回一个错误:
from mpi4py import MPI
comm=MPI.COMM_WORLD # making a communicator instance
rank=comm.Get_rank() # querying for the numeric identifier of the core
size=comm.Get_size() # the total number of cores assigned
if rank==0:
def func():
return 'Function called'
comm.send(func, dest=1)
if rank==1:
f=comm.recv(source=0) # <<<<<< This line reports an error
print(f())One-to-all and all-to-one communication
由语句 recv 抛出的错误信息是 AttributeError: Can't get attribute 'func'。这是由于 f 引用了 func 函数,而该函数在秩为 1 的处理器上没有定义。正确的做法是为两个处理器都定义该函数:
from mpi4py import MPI
comm=MPI.COMM_WORLD # making a communicator instance
rank=comm.Get_rank() # querying for the numeric identifier of the core
size=comm.Get_size() # the total number of cores assigned
def func():
return 'Function called'
if rank==0:
comm.send(func, dest=1)
if rank==1:
f=comm.recv(source=0)
print(f())
18.3.3 发送 NumPy 数组
命令 send 和 recv 是高级命令。这意味着它们在幕后执行工作,节省了程序员的时间并避免了可能的错误。它们会在内部推导出数据类型和所需通信缓冲区数据量后分配内存。这是在较低层次上基于 C 结构完成的。
NumPy 数组是对象,它们本身利用了类似 C 缓冲区的对象,因此在发送和接收 NumPy 数组时,可以通过在底层通信对等方 Send 和 Recv 中使用它们来提高效率(注意大小写!)。
在以下示例中,我们从一个处理器发送数组到另一个处理器:
from mpi4py import MPI
comm=MPI.COMM_WORLD # making a communicator instance
rank=comm.Get_rank() # querying for the numeric identifier of the core
size=comm.Get_size() # the total number of cores assigned
import numpy as np
if rank==0:
A = np.arange(700)
comm.Send(A, dest=1)
if rank==1:
A = np.empty(700, dtype=int) # This is needed for memory allocation
# of the buffer on Processor 1
comm.Recv(A, source=0) # Note, the difference to recv in
# providing the data.
print(f'An array received with last element {A[-1]}')
需要注意的是,在两个处理器上,必须分配缓冲区的内存。在这里,通过在处理器 0 上创建一个包含数据的数组以及在处理器 1 上创建一个具有相同大小和数据类型但包含任意数据的数组来完成这项工作。
此外,我们可以看到命令 recv 在输出中的区别。命令 Recv 通过第一个参数返回缓冲区。这是可能的,因为 NumPy 数组是可变的。
18.3.4 阻塞和非阻塞通信
命令 send 和 recv 及其缓冲区对应的 Send 和 Recv 是所谓的阻塞命令。这意味着,当相应的发送缓冲区被释放时,命令 send 才算完成。释放的时机取决于多个因素,例如系统的特定通信架构和要传输的数据量。最终,命令 send 在相应的命令 recv 接收到所有信息后才被认为是已释放的。如果没有这样的命令 recv,它将永远等待。这就形成了死锁情况。
以下脚本演示了可能发生死锁的情况。两个进程同时发送。如果要传输的数据量太大,无法存储,命令 send 就会等待相应的 recv 来清空管道,但由于等待状态,recv 永远不会被调用。这就是死锁。
from mpi4py import MPI
comm=MPI.COMM_WORLD # making a communicator instance
rank=comm.Get_rank() # querrying for the numeric identifier of the core
size=comm.Get_size() # the total number of cores assigned
if rank==0:
msg=['Message from rank 0',list(range(101000))]
comm.send(msg, dest=1)
print(f'Process {rank} sent its message')
s=comm.recv(source=1)
print(f'I am rank {rank} and got a {s[0]} with a list of \
length {len(s[1])}')
if rank==1:
msg=['Message from rank 1',list(range(-101000,1))]
comm.send(msg,dest=0)
print(f'Process {rank} sent its message')
s=comm.recv(source=0)
print(f'I am rank {rank} and got a {s[0]} with a list of \
length {len(s[1])}')
注意,执行这段代码可能不会导致你的计算机死锁,因为要通信的数据量非常小。
在这种情况下,避免死锁的直接解决办法是交换命令 recv 和 send 在 一个 处理器上的执行顺序:
from mpi4py import MPI
comm=MPI.COMM_WORLD # making a communicator instance
rank=comm.Get_rank() # querrying for the numeric identifier of the core
size=comm.Get_size() # the total number of cores assigned
if rank==0:
msg=['Message from rank 0',list(range(101000))]
comm.send(msg, dest=1)
print(f'Process {rank} sent its message')
s=comm.recv(source=1)
print(f'I am rank {rank} and got a {s[0]} with a list of \
length {len(s[1])}')
if rank==1:
s=comm.recv(source=0)
print(f'I am rank {rank} and got a {s[0]} with a list of \
length {len(s[1])}')
msg=['Message from rank 1',list(range(-101000,1))]
comm.send(msg,dest=0)
print(f'Process {rank} sent its message')
print(f'I am rank {rank} and got a {s[0]} with a list of \
length {len(s[1])}')
18.3.5 一对多与多对一通信
当一个依赖于大量数据的复杂任务被分解为子任务时,数据也必须分成与相关子任务相关的部分,并且结果必须汇总并处理成最终结果。

使用
所有子任务在初始数据的各个部分上执行相同的操作,结果必须汇总,并可能执行剩余的操作。
我们需要执行以下步骤:
-
创建向量
u和v -
将它们分成 m 个子向量,且每个子向量的元素个数平衡,即当
N能被m整除时,每个子向量包含 ![] 元素,否则一些子向量会包含更多元素。 -
将每个子向量传递给 "它的" 处理器
-
在每个处理器上执行子向量的标量积
-
收集所有结果
-
汇总结果
步骤 1、2 和 6 在一个处理器上运行,即所谓的 根 处理器。在以下示例代码中,我们选择排名为 0 的处理器来执行这些任务。步骤 3、4 和 5 在所有处理器上执行,包括根处理器。对于步骤 3 中的通信,mpi4py 提供了命令 scatter,而用于收集结果的命令是 gather。
准备通信数据
首先,我们来看看步骤 2。编写一个脚本,将一个向量分成 m 个平衡元素的部分,是一个不错的练习。这里有一个建议的脚本实现,当然还有很多其他的实现方式:
def split_array(vector, n_processors):
# splits an array into a number of subarrays
# vector one dimensional ndarray or a list
# n_processors integer, the number of subarrays to be formed
n=len(vector)
n_portions, rest = divmod(n,n_processors) # division with remainder
# get the amount of data per processor and distribute the res on
# the first processors so that the load is more or less equally
# distributed
# Construction of the indexes needed for the splitting
counts = [0]+ [n_portions + 1 \
if p < rest else n_portions for p in range(n_processors)]
counts=numpy.cumsum(counts)
start_end=zip(counts[:-1],counts[1:]) # a generator
slice_list=(slice(*sl) for sl in start_end) # a generator comprehension
return [vector[sl] for sl in slice_list] # a list of subarrays
由于本章是本书中的最后几章之一,我们已经看到很多可以用于这段代码的工具。我们使用了 NumPy 的累积和 cumsum。我们使用了生成器 zip,通过运算符 * 解包参数,以及生成器推导式。我们还默默地引入了数据类型 slice,它允许我们在最后一行以非常简洁的方式执行分割步骤。
命令——scatter 和 gather
现在我们已经准备好查看整个脚本,来解决我们的演示问题——标量积:
from mpi4py import MPI
import numpy as np
comm = MPI.COMM_WORLD
rank = comm.Get_rank()
nprocessors = comm.Get_size()
import splitarray as spa
if rank == 0:
# Here we generate data for the example
n = 150
u = 0.1*np.arange(n)
v = - u
u_split = spa.split_array(u, nprocessors)
v_split = spa.split_array(v, nprocessors)
else:
# On all processor we need variables with these names,
# otherwise we would get an Exception "Variable not defined" in
# the scatter command below
u_split = None
v_split = None
# These commands run now on all processors
u_split = comm.scatter(u_split, root=0) # the data is portion wise
# distributed from root
v_split = comm.scatter(v_split, root=0)
# Each processor computes its part of the scalar product
partial_dot = u_split@v_split
# Each processor reports its result back to the root
partial_dot = comm.gather(partial_dot,root=0)
if rank==0:
# partial_dot is a list of all collected results
total_dot=np.sum(partial_dot)
print(f'The parallel scalar product of u and v'
f'on {nprocessors} processors is {total_dot}.\n'
f'The difference to the serial computation is \
{abs(total_dot-u@v)}')
如果此脚本存储在文件parallel_dot.py中,使用五个处理器执行的命令如下:
mexec -n 5 python parallel_dot.py
在这种情况下,结果如下:
The parallel scalar product of u and v on 5 processors is -11137.75.
The difference to the serial computation is 0.0
本示例演示了使用scatter将特定信息发送到每个处理器。要使用此命令,根处理器必须提供一个包含与可用处理器数量相同元素的列表。每个元素包含要传送到某个处理器的数据,包括根处理器本身。
反向过程是gather。当所有处理器完成此命令时,根处理器将得到一个包含与可用处理器数量相同元素的列表,每个元素包含其对应处理器的结果数据。
在最后一步,根处理器再次独自工作,后处理此结果列表。上面的示例将所有列表元素求和并显示结果。
并行编程的艺术在于避免瓶颈。理想情况下,所有处理器都应保持忙碌,并且应该同时开始和结束。这就是为什么我们前面描述的脚本splitarray将工作负载大致平均分配给处理器的原因。此外,代码应以这样的方式组织,即根处理器独自工作的开始和结束阶段,相对于所有处理器同时执行的计算密集型部分来说是很短的。
最终数据归约操作——命令 reduce
并行标量积示例是许多其他任务的典型示例,展示了结果处理方式:来自所有处理器的数据量在最后一步被归约为一个单一的数字。在这里,根处理器将所有处理器的部分结果相加。命令reduce可以有效地用于此任务。我们通过让reduce在一步中完成聚集和求和来修改之前的代码。以下是修改后的前几行代码:
......... modification of the script above .....
# Each processor reports its result back to the root
# and these results are summed up
total_dot = comm.reduce(partial_dot, op=MPI.SUM, root=0)
if rank==0:
print(f'The parallel scalar product of u and v'
f' on {nprocessors} processors is {total_dot}.\n'
f'The difference to the serial computation \
is {abs(total_dot-u@v)}')
其他常用的归约操作有:
-
MPI.MAX或MPI.MIN:部分结果的最大值或最小值 -
MPI.MAXLOC或MPI.MINLOC:部分结果的最大值位置或最小值位置 -
MPI.PROD:部分结果的乘积 -
MPI.LAND或MPI.LOR:部分结果的逻辑与/逻辑或
向所有处理器发送相同的消息
另一个集体命令是广播命令bcast。与scatter不同,它用于将相同的数据发送到所有处理器。它的调用方式与scatter类似:
data = comm.bcast(data, root=0)
但是,发送的是总数据,而不是分割数据的列表。同样,根处理器可以是任何处理器。它是准备广播数据的处理器。
缓冲数据
类似地,mpi4py为类似 NumPy 数组的缓冲数据提供了相应的集体命令,通过大写命令来实现:scatter/Scatter、gather/Gather、reduce/Reduce、bcast/Bcast。
18.4 总结
在本章中,我们了解了如何在不同的处理器上并行执行相同脚本的副本。消息传递允许这些不同进程之间进行通信。我们看到了点对点通信,以及两种不同的分布式集体通信类型:一对多和多对一。本章中展示的命令是由 Python 模块mpi4py提供的,这是一个 Python 封装器,用于实现 C 语言中的 MPI 标准。
经过本章的学习后,你现在能够编写自己的并行编程脚本,并且你会发现我们这里只描述了最基本的命令和概念。进程分组和信息标记只是我们遗漏的两个概念。许多这些概念对于特殊和具有挑战性的应用非常重要,但它们对于本介绍来说过于具体。
综合示例
在本章中,我们将提供一些综合性和较长的示例,并简要介绍理论背景以及示例的完整实现。在这里,我们希望向您展示本书中定义的概念如何在实践中应用。
本章将涵盖以下主题:
-
多项式
-
多项式类
-
谱聚类
-
求解初值问题
第二十章:19.1 多项式
首先,我们将通过设计一个多项式类来展示迄今为止所介绍的 Python 构造的强大功能。
注意,这个类在概念上与类 numpy.poly1d 不同。
我们将提供一些理论背景,这将引导我们列出需求,然后给出代码并附带一些注释。
19.1.1 理论背景
多项式 ![] 由其阶数、表示法和系数定义。前面方程中展示的多项式表示法称为单项式表示法。在这种表示法中,多项式作为单项式的线性组合书写
。
或者,可以将多项式写成:
- 带有系数的牛顿表示法 ![] 和 ![] 点,
:

- 带有系数的拉格朗日表示 ![] 和 ![] 点,
:

使用基函数:

有无穷多种表示方法,但我们这里只限制于这三种典型表示。
多项式可以通过插值条件确定:

给定不同的值
和任意值
作为输入。在拉格朗日公式中,插值多项式是直接可用的,因为其系数即为插值数据。牛顿表示法中的插值多项式系数可以通过递推公式获得,称为分差公式:

和

然后我们通过
获得系数。
单项式表示法中插值多项式的系数通过解线性系统获得:

一个具有给定多项式
(或其倍数)作为特征多项式的矩阵被称为伴随矩阵。伴随矩阵的特征值即为多项式的零点(根)。通过先建立其伴随矩阵,再使用scipy.linalg.eig计算特征值,可以构建一个计算
零点的算法。牛顿表示下的多项式的伴随矩阵如下所示:

19.1.2 任务
我们现在可以制定一些编程任务:
- 编写一个名为
PolyNomial的类,具有points、degree、coeff和basis属性,其中:
-
为类提供一个方法,用于在给定点上评估多项式。
-
为类提供一个名为
plot的方法,用于在给定区间内绘制多项式。 -
编写一个名为
__add__的方法,返回两个多项式的和。需要注意的是,只有在单项式情况下,才能通过简单地将系数相加来计算和。 -
编写一个方法,计算表示为单项式形式的多项式的系数。
-
编写一个方法,计算多项式的伴随矩阵。
-
编写一个方法,通过计算伴随矩阵的特征值来计算多项式的零点。
-
编写一个方法,计算给定多项式的
^(th) 导数。 -
编写一个方法,检查两个多项式是否相等。可以通过比较所有系数来检查相等性(零的首项系数不应影响结果)。
19.1.3 多项式类
现在我们基于单项式形式设计一个多项式基类。多项式可以通过给定其相对于单项式基的系数,或通过给定插值点列表来初始化,如下所示:
import scipy.linalg as sl
import matplotlib.pyplot as mp
class PolyNomial:
base='monomial'
def __init__(self,**args):
if 'points' in args:
self.points = array(args['points'])
self.xi = self.points[:,0]
self.coeff = self.point_2_coeff()
self.degree = len(self.coeff)-1
elif 'coeff' in args:
self.coeff = array(args['coeff'])
self.degree = len(self.coeff)-1
self.points = self.coeff_2_point()
else:
self.points = array([[0,0]])
self.xi = array([1.])
self.coeff = self.point_2_coeff()
self.degree = 0
新类的__init__方法使用了构造**args,如第 7.2.5 节中讨论的:可变参数个数。如果没有给定参数,则默认假定为零多项式。如果多项式由插值点给出,计算系数的方法是通过求解范德蒙矩阵系统,方法如下:
def point_2_coeff(self):
return sl.solve(vander(self.x),self.y)
从![] 给定的系数,![] 插值点由以下方式构造:
def coeff_2_point(self):
points = [[x,self(x)] for x in linspace(0,1,self.degree+1)]
return array(points)
命令self(x)进行多项式评估,这通过提供__call__方法来实现:
def __call__(self,x):
return polyval(self.coeff,x)
(参见第 8.1.5 节中的__call__方法示例:特殊方法。)这里,该方法使用了 NumPy 命令polyval。接下来的步骤,我们只需为了方便添加两个方法,并用property装饰器装饰它们(参见第 7.8 节:作为装饰器的函数**):
@property
def x(self):
return self.points[:,0]
@property
def y(self):
return self.points[:,1]
让我们解释一下这里发生了什么。我们定义了一个方法来提取定义多项式时使用的数据的
值。类似地,也定义了一个提取数据
值的方法。通过property装饰器,调用该方法的结果呈现得就像它是多项式的一个属性一样。这里有两种编码选择:
- 我们使用方法调用:
def x(self):
return self.points[:,0]
这通过调用:p.x()提供对
值的访问。
- 我们使用
property装饰器。它允许我们通过以下语句轻松访问
值:p.x。我们在这里选择第二种变体。
定义__repr__方法始终是一个好习惯(参见第 8.1.5 节:特殊方法)。至少对于快速检查结果,这个方法非常有用:
def __repr__(self):
txt = f'Polynomial of degree {self.degree} \n'
txt += f'with coefficients {self.coeff} \n in {self.base} basis.'
return txt
现在,我们提供一个用于绘制多项式的方法,如下所示:
margin = .05
plotres = 500
def plot(self,ab=None,plotinterp=True):
if ab is None: # guess a and b
x = self.x
a, b = x.min(), x.max()
h = b-a
a -= self.margin*h
b += self.margin*h
else:
a,b = ab
x = linspace(a,b,self.plotres)
y = vectorize(self.__call__)(x)
mp.plot(x,y)
mp.xlabel('$x$')
mp.ylabel('$p(x)$')
if plotinterp:
mp.plot(self.x, self.y, 'ro')
请注意使用vectorize命令(参见第 4.8 节:作用于数组的函数)。__call__方法是特定于单项式表示的,如果多项式以其他方式表示,则必须进行更改。这对于计算多项式的伴随矩阵也是如此:
def companion(self):
companion = eye(self.degree, k=-1)
companion[0,:] -= self.coeff[1:]/self.coeff[0]
return companion
一旦伴随矩阵可用,给定多项式的零点就是它的特征值:
def zeros(self):
companion = self.companion()
return sl.eigvals(companion)
为此,必须首先导入模块scipy.linalg,并命名为sl。
19.1.4 多项式类的使用示例
让我们给出一些使用示例。
首先,我们从给定的插值点创建一个多项式实例:
p = PolyNomial(points=[(1,0),(2,3),(3,8)])
相对于单项式基,多项式的系数作为p的一个属性提供:
p.coeff # returns array([ 1., 0., -1.]) (rounded)
这对应于多项式
。通过p.plot((-3.5,3.5))获得的多项式默认图形如下所示(图 19.1):

图 19.1:多项式绘图方法的结果
最后,我们计算多项式的零点,在此案例中是两个实数:
pz = p.zeros() # returns array([-1.+0.j, 1.+0.j])
结果可以通过在这些点处评估多项式来验证:
p(pz) # returns array([0.+0.j, 0.+0.j])
19.1.5 牛顿多项式
类 NewtonPolyNomial 定义了一个基于牛顿基的多项式。我们通过使用命令 super 让它继承自多项式基类的一些常用方法,例如 polynomial.plot、polynomial.zeros,甚至 __init__ 方法的部分内容(参见 第 8.5 节:子类与继承):
class NewtonPolynomial(PolyNomial):
base = 'Newton'
def __init__(self,**args):
if 'coeff' in args:
try:
self.xi = array(args['xi'])
except KeyError:
raise ValueError('Coefficients need to be given'
'together with abscissae values xi')
super(NewtonPolynomial, self).__init__(**args)
一旦给定插值点,系数的计算就通过以下方式进行:
def point_2_coeff(self):
return array(list(self.divdiff()))
我们使用了分割差分来计算多项式的牛顿表示,这里编程为生成器(参见 第 9.3.1 节:生成器 和 第 9.4 节:列表填充模式):
def divdiff(self):
xi = self.xi
row = self.y
yield row[0]
for level in range(1,len(xi)):
row = (row[1:] - row[:-1])/(xi[level:] - xi[:-level])
if allclose(row,0): # check: elements of row nearly zero
self.degree = level-1
break
yield row[0]
让我们简单检查一下这个是如何工作的:
# here we define the interpolation data: (x,y) pairs
pts = array([[0.,0],[.5,1],[1.,0],[2,0.]])
pN = NewtonPolynomial(points=pts) # this creates an instance of the
# polynomial class
pN.coeff # returns the coefficients array([0\. , 2\. , -4\. , 2.66666667])
print(pN)
函数 print 执行基类的 __repr__ 方法并返回以下文本:
Polynomial of degree 3
with coefficients [ 0\. 2\. -4\. 2.66666667]
in Newton basis.
多项式评估与基类的相应方法不同。方法 NewtonPolyNomial.__call__ 需要重写 Polynomial.__call__:
def __call__(self,x):
# first compute the sequence 1, (x-x_1), (x-x_1)(x-x_2),...
nps = hstack([1., cumprod(x-self.xi[:self.degree])])
return self.coeff@nps
最后,我们给出了伴随矩阵的代码,该代码重写了父类的相应方法,如下所示:
def companion(self):
degree = self.degree
companion = eye(degree, k=-1)
diagonal = identity(degree,dtype=bool)
companion[diagonal] = self.x[:degree]
companion[:,-1] -= self.coeff[:degree]/self.coeff[degree]
return companion
请注意布尔数组的使用。练习将进一步在此基础上构建。
19.2 谱聚类
特征向量的一个有趣应用是用于数据聚类。通过使用由距离矩阵导出的矩阵的特征向量,可以将未标记的数据分成不同的组。谱聚类方法因使用该矩阵的谱而得名。对于
元素(例如,数据点之间的成对距离)的距离矩阵是一个
对称矩阵。给定这样的
距离矩阵
,具有距离值 ![],我们可以按以下方式创建数据点的拉普拉斯矩阵:

这里,
是单位矩阵,
是包含
行和列和的对角矩阵:

数据聚类是通过 L 的特征向量得到的。在只有两类数据点的最简单情况下,第一个特征向量(即对应最大特征值的那个)通常足以将数据分开。
这是一个简单的二类聚类示例。以下代码创建了一些二维数据点,并基于拉普拉斯矩阵的第一个特征向量对其进行聚类:
import scipy.linalg as sl
# create some data points
n = 100
x1 = 1.2 * random.randn(n, 2)
x2 = 0.8 * random.randn(n, 2) + tile([7, 0],(n, 1))
x = vstack((x1, x2))
# pairwise distance matrix
M = array([[ sqrt(sum((x[i] - x[j])**2))
for i in range(2*n)]
for j in range(2 * n)])
# create the Laplacian matrix
D = diag(1 / sqrt( M.sum(axis = 0) ))
L = identity(2 * n) - dot(D, dot(M, D))
# compute eigenvectors of L
S, V = sl.eig(L)
# As L is symmetric the imaginary parts
# in the eigenvalues are only due to negligible numerical errors S=S.real
V=V.real
对应最大特征值的特征向量给出了分组(例如,通过在
处进行阈值化)并可以通过以下方式展示:
largest=abs(S).argmax()
plot(V[:,largest])
以下图 (图 19.2) 显示了简单两类数据集的谱聚类结果:

图 19.2:简单两类聚类的结果
对于更难的数据集和更多的类别,通常会采用与最大特征值对应的
特征向量,然后使用其他方法对数据进行聚类,但使用的是特征向量而不是原始数据点。常见的选择是
-均值聚类算法,这是下一个示例的主题。
特征向量作为输入用于
-均值聚类,如下所示:
import scipy.linalg as sl
import scipy.cluster.vq as sc
# simple 4 class data
x = random.rand(1000,2)
ndx = ((x[:,0] < 0.4) | (x[:,0] > 0.6)) & \
((x[:,1] < 0.4) | (x[:,1] > 0.6))
x = x[ndx]
n = x.shape[0]
# pairwise distance matrix
M = array([[ sqrt(sum((x[i]-x[j])**2)) for i in range(n) ]
for j in range(n)])
# create the Laplacian matrix
D = diag(1 / sqrt( M.sum(axis=0) ))
L = identity(n) - dot(D, dot(M, D))
# compute eigenvectors of L
_,_,V = sl.svd(L)
k = 4
# take k first eigenvectors
eigv = V[:k,:].T
# k-means
centroids,dist = sc.kmeans(eigv,k)
clust_id = sc.vq(eigv,centroids)[0]
请注意,我们在此使用奇异值分解 sl.svd 计算了特征向量。由于 L 是对称的,结果与使用 sl.eig 得到的结果相同,但 svd 已经按照特征值的顺序给出了特征向量。我们还使用了临时变量。svd 返回一个包含三个数组的列表:左奇异向量 U、右奇异向量 V 和奇异值 S,如下所示:
U, S, V = sl.svd(L)
由于我们在这里不需要 U 和 S,因此可以在解包 svd 返回值时丢弃它们:
_, _, V = sl.svd(L)
结果可以通过以下方式绘制:
for i in range(k):
ndx = where(clust_id == i)[0]
plot(x[ndx, 0], x[ndx, 1],'o')
axis('equal')
以下图显示了简单 多类数据集 的谱聚类结果:

图 19.3:简单四类数据集的谱聚类示例
19.3 求解初值问题
在本节中,我们将考虑数值求解给定初值的常微分方程组的数学任务:
。
该问题的解是一个函数
。数值方法在离散的通信点
计算近似值,
,位于感兴趣的区间
内。我们将描述问题的数据收集到一个类中,如下所示:
class IV_Problem:
"""
Initial value problem (IVP) class
"""
def __init__(self, rhs, y0, interval, name='IVP'):
"""
rhs 'right hand side' function of the ordinary differential
equation f(t,y)
y0 array with initial values
interval start and end value of the interval of independent
variables often initial and end time
name descriptive name of the problem
"""
self.rhs = rhs
self.y0 = y0
self.t0, self.tend = interval
self.name = name
微分方程:

描述了一个数学摆;* ![] 是其相对于竖直轴的角度,g* 是重力常数,l 是摆长。初始角度是
,初始角速度为零。
摆锤问题成为问题类的一个实例,如下所示:
def rhs(t,y):
g = 9.81
l = 1.
yprime = array([y[1], g / l * sin(y[0])])
return yprime
pendulum = IV_Problem(rhs, array([pi / 2, 0.]), [0., 10.] ,
'mathem. pendulum')
对当前问题可能会有不同的看法,这会导致不同的类设计。例如,有人可能希望将自变量的区间视为解过程的一部分,而不是问题定义的一部分。考虑初值时也是如此。我们在这里将其视为数学问题的一部分,而其他作者可能希望将初值作为解过程的一部分,从而允许初值的变化。
解决过程被建模为另一个类:
class IVPsolver:
"""
IVP solver class for explicit one-step discretization methods
with constant step size
"""
def __init__(self, problem, discretization, stepsize):
self.problem = problem
self.discretization = discretization
self.stepsize = stepsize
def one_stepper(self):
yield self.problem.t0, self.problem.y0
ys = self.problem.y0
ts = self.problem.t0
while ts <= self.problem.tend:
ts, ys = self.discretization(self.problem.rhs, ts, ys,
self.stepsize)
yield ts, ys
def solve(self):
return list(self.one_stepper())
我们接下来首先定义两个离散化方案:
- 显式欧拉方法:
def expliciteuler(rhs, ts, ys, h):
return ts + h, ys + h * rhs(ts, ys)
- 经典龙格-库塔四阶法(RK4):
def rungekutta4(rhs, ts, ys, h):
k1 = h * rhs(ts, ys)
k2 = h * rhs(ts + h/2., ys + k1/2.)
k3 = h * rhs(ts + h/2., ys + k2/2.)
k4 = h * rhs(ts + h, ys + k3)
return ts + h, ys + (k1 + 2*k2 + 2*k3 + k4)/6.
使用这些工具,我们可以创建实例以获得相应的摆锤常微分方程的离散化版本:
pendulum_Euler = IVPsolver(pendulum, expliciteuler, 0.001)
pendulum_RK4 = IVPsolver(pendulum, rungekutta4, 0.001)
我们可以解决这两个离散模型并绘制解与角度差:
sol_Euler = pendulum_Euler.solve()
sol_RK4 = pendulum_RK4.solve()
tEuler, yEuler = zip(*sol_Euler)
tRK4, yRK4 = zip(*sol_RK4)
subplot(1,2,1), plot(tEuler,yEuler),\
title('Pendulum result with Explicit Euler'),\
xlabel('Time'), ylabel('Angle and angular velocity')
subplot(1,2,2), plot(tRK4,abs(array(yRK4)-array(yEuler))),\
title('Difference between both methods'),\
xlabel('Time'), ylabel('Angle and angular velocity')

图 19.4:使用显式欧拉方法的摆锤模拟,并与更精确的龙格-库塔四阶法的结果进行比较
讨论替代的类设计是值得的。什么应该放在独立的类中,什么应该归并到同一个类中?
-
我们严格将数学问题与数值方法分开。初值应该放在哪里?它们应该是问题的一部分还是解算器的一部分?或者它们应该作为解算器实例的求解方法的输入参数?甚至可以设计程序,允许多种可能性。选择使用这些替代方案之一取决于未来程序的使用。像参数识别中的各种初值循环,留初值作为求解方法的输入参数会更方便。另一方面,模拟具有相同初值的不同模型变体时,将初值与问题绑定起来会更为合理。
-
为简化起见,我们仅展示了使用常定步长的求解器。
IVPsolver类的设计是否适用于未来扩展自适应方法,其中给定的是容差而非步长? -
我们之前建议使用生成器构造来实现步进机制。自适应方法需要时不时地拒绝某些步长。这种需求是否与
IVPsolver.onestepper中步进机制的设计冲突? -
我们鼓励你检查两个 SciPy 工具的设计,这些工具用于解决初值问题,分别是
scipy.integrate.ode和scipy.integrate.odeint。
19.4 小结
本书中大部分内容已经整合到本章的三个较长示例中。这些示例模拟了代码开发并提供了原型,鼓励你根据自己的想法进行修改和对比。
你会发现,科学计算中的代码因其与数学定义的算法有着紧密关系,往往具有其独特风格,并且通常明智的做法是保持代码与公式之间的关系可见。Python 提供了实现这一点的技巧,正如你所看到的。
19.5 练习
例 1: 实现一个方法 __add__,通过将两个给定的多项式
和
相加,构造一个新的多项式
。在单项式形式中,多项式的相加只是将系数相加,而在牛顿形式中,系数依赖于插值点的横坐标
。在相加两个多项式的系数之前,多项式
必须获得新的插值点,这些插值点的横坐标
必须与
的横坐标一致,并且必须提供方法 __changepoints__ 来实现这一点。该方法应更改插值点,并返回一组新的系数。
例 2: 编写转换方法,将一个多项式从牛顿形式转换为单项式形式,反之亦然。
例 3: 编写一个名为 add_point 的方法,该方法接受一个多项式 q 和一个元组 ![] 作为参数,并返回一个新的多项式,该多项式插值 self.points 和 ![]。
例 4: 编写一个名为 LagrangePolynomial 的类,该类实现拉格朗日形式的多项式,并尽可能多地继承多项式基类。
例 5: 编写多项式类的测试。


和
。这些值表示给定公式中的起始值
和
。根据递归公式构建完整的列表。
,其中
。绘制
,其中
表示 














填充的向量,来自
.
来自
.
的矩阵

和
之间均匀分布的 n 个点
,目的是将矩阵转换为三角形形式
的实例,并且受益于更耗时的因式分解步骤
(见Ex. 2)构造一个矩阵
,删除其中
的第一列。
。
,并使用Ex. 2中的y。
和
定义的多项式。再次在同一图中绘制点
。
会自动重塑为(1, n)。
被扩展到(m, n)。
和!







,其结果为一个实数。
_
与 
的分量为
:
:
^(th) 导数。
值:
浙公网安备 33010602011771号