原文:
annas-archive.org/md5/58a42b6d23877a7910fec539d6659c22
译者:飞龙
迭代
在本章中,我们将展示使用循环和迭代器进行迭代的示例。我们将展示如何在列表和生成器中使用它们。迭代是计算机的基本操作之一。传统上,通过 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
关键字创建自己的迭代器。例如,定义一个生成小于 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/4348dffe-7c52-4c93-b2ed-4ebcba289e27.png 的奇数的生成器:
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]:
我们在计算椭圆积分以确定数学摆的周期时,在这里演示了这个迭代过程。
当开始时使用值 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/3f4b7ed9-ab08-4774-8cce-cb9ff9ac209b.png,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__()
作为一个例子,我们使用序列 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/1b9d4037-f4c4-4c8c-a638-aa4362709ad7.png,它收敛于 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/131e9ae0-b279-4681-8a12-69d0eb6ca6e1.png。
我们在以下代码中实现这个序列作为生成器:
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 函数(https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/4e60ab18-0e48-49d9-9e83-1d599478085e.png)是为了演示生成器的优雅使用方式。它肯定不是评估该函数最准确和计算效率最高的方式。
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)
根据你的系统,程序可能会因为 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/使用了过多的内存 而卡住。结果是 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: 设 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/c07910ea-e646-4704-b8cf-570ab0119311.png,使得 ![]。使用生成器来完成此任务。
例 5: 生成小于给定整数的所有质数。使用称为 厄拉多塞筛法 的算法。
例 6: 通过应用显式欧拉法解微分方程 ![] 结果得到递归:
编写一个生成器,计算给定初始值 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/2c063d73-e6ca-4631-b913-13da8ed5304f.png 和给定时间步长值 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/0968c1ee-c6bc-4419-971d-41367ac5c23e.png 的解值 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/ba1d483f-8eeb-4757-b16a-41b8eebb06f3.png。
例 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 节中的 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/133737ff-4444-4eea-be8a-fd8203c3c069.png:数学中迭代器的例子)。
习题 10: 考虑由以下式子定义的序列 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/75a5ddd1-5690-43e0-b573-1cefd1970ee0.png:
它单调收敛到零:https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/afb2bccf-36b3-4806-9e84-8584f4e91519.png。通过分部积分,我们可以证明序列 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/2c8e757e-4b77-48b0-bab8-042394496e33.png 满足以下递推关系:
使用合适的生成器计算递推的前 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
。
我们使用插值方法的方式假设数据是在等距网格上收集的。如果索引是数字或日期时间对象,它可以作为https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/8cf5b441-997d-4736-ace9-01b5b1b415c8.png-轴来使用。例如,使用参数值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,以便更改 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/82a1cecf-66e2-4cc1-8b3f-f212f89db8fb.png 轴上的刻度标签:
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:一个小部件,用于显示用户给定频率的 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/b7925514-4664-4123-958a-9553478a9f75.png https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/9517305e-55a6-4f64-b1c6-3abe53043510.png
在这个图形中,我们可以看到顶部有一个滑块条。使用计算机鼠标,可以将蓝色条从左到右移动,右侧会显示一个值,范围在 1 到 5 之间,表示 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/591c783c-9836-44ff-8973-c7f37f8af6f9.png。
相应地,显示在绘图窗口中的正弦波频率发生变化。
该小部件由三个部分组成:
-
一个包含坐标轴对象和绘图的图形对象
-
包含滑块对象的坐标轴对象
-
一个回调函数,用于在滑块值变化时更新绘图
我们在 第 6.2 节 中讨论了如何编写第一部分:“直接操作 Matplotlib 对象”。
在下面的代码片段中,我们首先创建一个指定大小的图形,然后创建一个足够大的坐标轴对象,并将其放置到图形中,使得坐标轴的左下角与图形坐标 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/4eb07a74-351a-45dd-b7d9-ebfdbdbdfd6a.png 对齐。然后,要求用户输入一个介于 1 到 5 之间的浮动数字,用于表示频率 https://github.com/OpenDocCN/freelearn-ds-pt3-zh/raw/master/docs/sci-comp-py-2e/img/01537198-cd5f-4422-b047-3271dda4542a.png:
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
方法来存储的。