原文:
zh.annas-archive.org/md5/0A7865EB133E2D9D03688623C60BD998
译者:飞龙
第五章:高阶函数
函数式编程范式的一个非常重要的特性是高阶函数。这些是接受函数作为参数或返回函数作为结果的函数。Python 提供了几种这种类型的函数。我们将看看它们和一些逻辑扩展。
正如我们所看到的,有三种高阶函数,它们如下:
-
接受函数作为其参数之一的函数
-
返回函数的函数
-
接受函数并返回函数的函数
Python 提供了几种第一种高阶函数。我们将在本章中查看这些内置的高阶函数。我们将在后面的章节中查看一些提供高阶函数的库模块。
一个发出函数的函数的概念可能看起来有点奇怪。然而,当我们看一个 Callable 类对象时,我们看到一个返回 Callable 对象的函数。这是一个创建另一个函数的函数的例子。
接受函数并创建函数的函数包括复杂的 Callable 类以及函数装饰器。我们将在本章介绍装饰器,但将深入考虑装饰器直到第十一章装饰器设计技术。
有时我们希望 Python 具有前一章中集合函数的高阶版本。在本章中,我们将展示使用 reduce(extract())
设计模式从较大的元组中提取特定字段执行缩减。我们还将看看如何定义我们自己版本的这些常见的集合处理函数。
在这一章中,我们将看一下以下函数:
-
max()
和min()
-
我们可以使用的
Lambda
形式来简化使用高阶函数 -
map()
-
filter()
-
iter()
-
sorted()
itertools
模块中有许多高阶函数。我们将在第八章Itertools 模块和第九章更多 Itertools 技术中查看这个模块。
此外,functools
模块提供了一个通用的reduce()
函数。我们将在第十章Functools 模块中看到这一点。我们将推迟这个问题,因为它不像本章中的其他高阶函数那样普遍适用。
max()
和min()
函数是缩减函数;它们从集合中创建一个单个值。其他函数是映射函数。它们不会将输入减少到单个值。
注意
max()
、min()
和sorted()
函数也有默认行为和高阶函数行为。函数是通过key=
参数提供的。map()
和filter()
函数将函数作为第一个位置参数。
使用 max()和 min()查找极值
max()
和min()
函数有双重作用。它们是应用于集合的简单函数。它们也是高阶函数。我们可以看到它们的默认行为如下:
>>> max(1, 2, 3)
3
>>> max((1,2,3,4))
4
这两个函数都将接受无限数量的参数。这些函数也被设计为接受序列或可迭代对象作为唯一参数,并定位该可迭代对象的最大值(或最小值)。
它们还做一些更复杂的事情。假设我们有来自第四章与集合一起工作示例中的旅行数据。我们有一个将生成元组序列的函数,如下所示:
(((37.54901619777347, -76.33029518659048), (37.840832, -76.273834), 17.7246), ((37.840832, -76.273834), (38.331501, -76.459503), 30.7382), ((38.331501, -76.459503), (38.845501, -76.537331), 31.0756), ((36.843334, -76.298668), (37.549, -76.331169), 42.3962), ((37.549, -76.331169), (38.330166, -76.458504), 47.2866), ((38.330166, -76.458504), (38.976334, -76.473503), 38.8019))
每个tuple
有三个值:起始位置、结束位置和距离。位置以纬度和经度对的形式给出。东纬是正数,所以这些点位于美国东海岸,大约西经 76°。距离以海里为单位。
我们有三种方法可以从这个值序列中获取最大和最小距离。它们如下:
-
使用生成器函数提取距离。这将只给我们距离,因为我们丢弃了每个 leg 的其他两个属性。如果我们有任何额外的处理要求,这不会很好地工作。
-
使用
unwrap(process(wrap()))
模式。这将给我们具有最长和最短距离的 legs。从这些中,我们可以提取距离,如果那是所有需要的话。其他两个将给我们包含最大和最小距离的 leg。 -
使用
max()
和min()
函数作为高阶函数。
为了提供上下文,我们将展示前两种解决方案。以下是一个构建旅程并使用前两种方法来找到最长和最短距离的脚本:
from ch02_ex3 import float_from_pair, lat_lon_kml, limits, haversine, legs
path= float_from_pair(lat_lon_kml())
trip= tuple((start, end, round(haversine(start, end),4))for start,end in legs(iter(path)))
这一部分根据从 KML 文件中读取的path
构建的每个leg
的haversine
距离创建了trip
对象作为tuple
。
一旦我们有了trip
对象,我们就可以提取距离并计算这些距离的最大值和最小值。代码如下所示:
long, short = max(dist for start,end,dist in trip), min(dist for start,end,dist in trip)
print(long, short)
我们使用了一个生成器函数来从trip
元组的每个leg
中提取相关项目。我们不得不重复生成器函数,因为每个生成器表达式只能被消耗一次。
以下是结果:
129.7748 0.1731
以下是带有unwrap(process(wrap()))
模式的版本。我们实际上声明了名为wrap()
和unwrap()
的函数,以清楚地说明这种模式的工作原理:
def wrap(leg_iter):
**return ((leg[2],leg) for leg in leg_iter)
def unwrap(dist_leg):
**distance, leg = dist_leg
**return leg
long, short = unwrap(max(wrap(trip))), unwrap(min(wrap(trip)))
print(long, short)
与之前的版本不同,这个版本定位了具有最长和最短距离的legs
的所有属性。而不仅仅是提取距离,我们首先将距离放在每个包装的元组中。然后,我们可以使用min()
和max()
函数的默认形式来处理包含距离和 leg 详情的两个元组。处理后,我们可以剥离第一个元素,只留下leg
详情。
结果如下所示:
((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)
((35.505665, -76.653664), (35.508335, -76.654999), 0.1731)
最终且最重要的形式使用了max()
和min()
函数的高阶函数特性。我们将首先定义一个helper
函数,然后使用它来通过执行以下代码片段来将 legs 的集合减少到所需的摘要:
def by_dist(leg):
**lat, lon, dist= leg
**return dist
long, short = max(trip, key=by_dist), min(trip, key=by_dist)
print(long, short)
by_dist()
函数拆分了每个leg
元组中的三个项目,并返回距离项目。我们将在max()
和min()
函数中使用这个函数。
max()
和min()
函数都接受一个可迭代对象和一个函数作为参数。关键字参数key=
被 Python 所有高阶函数使用,以提供一个用于提取必要键值的函数。
我们可以使用以下内容来帮助概念化max()
函数如何使用key
函数:
wrap= ((key(leg),leg) for leg in trip)
return max(wrap)[1]
max()
和min()
函数的行为就好像给定的key
函数被用来将序列中的每个项目包装成一个两元组,处理两元组,然后解构两元组以返回原始值。
使用 Python 的 lambda 形式
在许多情况下,定义一个helper
函数需要太多的代码。通常,我们可以将key
函数简化为一个单一表达式。必须编写def
和return
语句来包装一个单一表达式似乎是浪费的。
Python 提供了 lambda 形式作为简化使用高阶函数的一种方式。lambda 形式允许我们定义一个小的匿名函数。函数的主体限制在一个单一表达式中。
以下是使用简单的lambda
表达式作为 key 的示例:
long, short = max(trip, key=lambda leg: leg[2]), min(trip, key=lambda leg: leg[2])
print(long, short)
我们使用的lambda
将从序列中获得一个项目;在这种情况下,每个 leg 三元组将被传递给lambda
。lambda
参数变量leg
被赋值,表达式leg[2]
被评估,从三元组中取出距离。
在极少数情况下,lambda
从未被重复使用,这种形式是理想的。然而,通常需要重复使用lambda
对象。由于复制粘贴是一个坏主意,那么有什么替代方案呢?
我们总是可以定义一个函数。
我们还可以将 lambda 分配给变量,做法如下:
start= lambda x: x[0]
end = lambda x: x[1]
dist = lambda x: x[2]
lambda
是一个callable
对象,可以像函数一样使用。以下是一个交互提示的示例:
>>> leg = ((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)
>>> start= lambda x: x[0]
>>> end = lambda x: x[1]
>>> dist = lambda x: x[2]
>>> dist(leg)
129.7748
Python 为元组的元素分配有意义的名称提供了两种方法:命名元组和一组 lambda。两者是等效的。
为了扩展这个例子,我们将看看如何获取起点或终点的纬度
或经度
值。这是通过定义一些额外的 lambda 来完成的。
以下是交互会话的继续:
>>> start(leg)
(27.154167, -80.195663)
>>>**
>>> lat = lambda x: x[0]
>>> lon = lambda x: x[1]
>>> lat(start(leg))
27.154167
lambda 与命名元组相比没有明显的优势。一组lambda
用于提取字段需要更多的代码行来定义比一个命名元组。另一方面,我们可以使用前缀函数表示法,在函数编程上下文中可能更容易阅读。更重要的是,正如我们将在稍后的sorted()
示例中看到的,lambdas
可以比namedtuple
属性名称更有效地被sorted()
、min()
和max()
使用。
Lambda 和 lambda 演算
在一本纯函数式编程语言的书中,有必要解释 lambda 演算和 Haskell Curry 发明的我们称之为柯里化的技术。然而,Python 并没有严格遵循这种类型的lambda
演算
。函数不是柯里化的,以将它们减少为单参数lambda
形式
。
我们可以使用functools.partial
函数实现柯里化。我们将在第十章Functools 模块中保存这个。
使用 map()函数将函数应用于集合
标量函数将域中的值映射到范围中。当我们看math.sqrt()
函数时,例如,我们正在看一个从float
值x到另一个float
值*y = sqrt(x)*的映射,使得。域限制为正值。映射可以通过计算或表插值来完成。
map()
函数表达了一个类似的概念;它将一个集合映射到另一个集合。它确保给定的函数被用来将域集合中的每个单独项映射到范围集合——这是将内置函数应用于数据集合的理想方式。
我们的第一个例子涉及解析一块文本以获取数字序列。假设我们有以下文本块:
>>> text= """\
... 2 3 5 7 11 13 17 19 23 29**
... 31 37 41 43 47 53 59 61 67 71**
... 73 79 83 89 97 101 103 107 109 113**
... 127 131 137 139 149 151 157 163 167 173**
... 179 181 191 193 197 199 211 223 227 229**
... """
我们可以使用以下生成器函数重新构造这个文本:
>>> data= list(v for line in text.splitlines() for v in line.split())
这将文本分割成行。对于每一行,它将行分割成以空格分隔的单词,并迭代每个结果字符串。结果如下所示:
['2', '3', '5', '7', '11', '13', '17', '19', '23', '29', '31', '37', '41', '43', '47', '53', '59', '61', '67', '71', '73', '79', '83', '89', '97', '101', '103', '107', '109', '113', '127', '131', '137', '139', '149', '151', '157', '163', '167', '173', '179', '181', '191', '193', '197', '199', '211', '223', '227', '229']
我们仍然需要将int()
函数应用于每个string
值。这就是map()
函数的优势所在。看一下以下代码片段:
>>> list(map(int,data))
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103, 107, 109, 113, 127, 131, 137, 139, 149, 151, 157, 163, 167, 173, 179, 181, 191, 193, 197, 199, 211, 223, 227, 229]
map()
函数将int()
函数应用于集合中的每个值。结果是一系列数字而不是一系列字符串。
map()
函数的结果是可迭代的。map()
函数可以处理任何类型的可迭代对象。
这里的想法是,任何 Python 函数都可以使用map()
函数应用于集合的项。有很多内置函数可以在这种 map 处理上下文中使用。
使用 lambda 表达式和 map()
假设我们想要将我们的航程距离从海里转换为英里。我们想要将每个航段的距离乘以 6076.12/5280,即 1.150780。
我们可以使用map()
函数进行这个计算:
map(lambda x: (start(x),end(x),dist(x)*6076.12/5280), trip)
我们已经定义了一个lambda
,它将被map()
函数应用于航程中的每个航段。lambda
将使用其他lambdas
从每个航段中分离起点、终点和英里距离值。它将计算修订后的距离,并从起点、终点和英里距离组装一个新的航段元组。
这与以下生成器表达式完全相同:
((start(x),end(x),dist(x)*6076.12/5280) for x in trip)
我们对生成器表达式中的每个项目进行了相同的处理。
map()
函数和生成器表达式之间的重要区别在于,map()
函数往往比生成器表达式更快。加速大约减少了 20%的时间。
使用多个序列进行 map()处理
有时,我们会有两个需要相互对应的数据集合。在第四章,处理集合中,我们看到zip()
函数如何交错两个序列以创建一系列成对。在许多情况下,我们真的想做这样的事情:
map(function, zip(one_iterable, another_iterable))
我们正在从两个(或更多)并行可迭代对象创建参数元组,并将函数应用于参数tuple
。我们也可以这样看待:
(function(x,y) for x,y in zip(one_iterable, another_iterable))
在这里,我们用等效的生成器表达式替换了map()
函数。
我们可能会有将整个事情概括到这样的想法:
def star_map(function, *iterables)
**return (function(*args) for args in zip(*iterables))
有一个更好的方法已经可用于我们。实际上我们并不需要这些技术。让我们看一个替代方法的具体例子。
在第四章,处理集合中,我们看到了我们从 XML 文件中提取的一系列航路点的行程数据。我们需要从这些航路点列表中创建腿,显示每条腿的起点和终点。
以下是一个简化版本,使用了zip()
函数应用于一种特殊类型的可迭代对象:
>>> waypoints= range(4)
>>> zip(waypoints, waypoints[1:])
<zip object at 0x101a38c20>
>>> list(_)
[(0, 1), (1, 2), (2, 3)]
我们创建了一个从单个平面列表中提取的成对序列。每对将有两个相邻的值。zip()
函数在较短的列表用尽时会正确停止。这种zip( x, x[1:])
模式只适用于实现的序列和range()
函数创建的可迭代对象。
我们创建了成对,以便我们可以对每对应用haversine()
函数来计算路径上两点之间的距离。以下是它在一个步骤序列中的样子:
from ch02_ex3 import lat_lon_kml, float_from_pair, haversine
path= tuple(float_from_pair(lat_lon_kml()))
distances1= map( lambda s_e: (s_e[0], s_e[1], haversine(*s_e)), zip(path, path[1:]))
我们已经将关键的航路点序列加载到path
变量中。这是一个有序的纬度-经度对序列。由于我们将使用zip(path, path[1:])
设计模式,我们必须有一个实现的序列而不是一个简单的可迭代对象。
zip()
函数的结果将是具有起点和终点的对。我们希望我们的输出是具有起点、终点和距离的三元组。我们正在使用的lambda
将分解原始的两元组,并从起点、终点和距离创建一个新的三元组。
如前所述,我们可以通过使用map()
函数的一个巧妙特性来简化这个过程,如下所示:
distances2= map(lambda s, e: (s, e, haversine(s, e)), path, path[1:])
请注意,我们已经向map()
函数提供了一个函数和两个可迭代对象。map()
函数将从每个可迭代对象中取出下一个项目,并将这两个值作为给定函数的参数应用。在这种情况下,给定函数是一个lambda
,它从起点、终点和距离创建所需的三元组。
map()
函数的正式定义规定,它将使用无限数量的可迭代对象进行星图处理。它将从每个可迭代对象中取出项目,以创建给定函数的参数值元组。
使用 filter()函数来传递或拒绝数据
filter()
函数的作用是使用并应用称为谓词的决策函数到集合中的每个值。True
的决策意味着该值被传递;否则,该值被拒绝。itertools
模块包括filterfalse()
作为这一主题的变体。参考第八章,迭代工具模块,了解itertools
模块的filterfalse()
函数的用法。
我们可以将这个应用到我们的行程数据中,以创建超过 50 海里长的腿的子集,如下所示:
long= list(filter(lambda leg: dist(leg) >= 50, trip)))
lambda
谓词对长腿将为True
,将被传递。短腿将被拒绝。输出是通过这个距离测试的 14 条腿。
这种处理清楚地将filter
规则(lambda leg: dist(leg) >= 50
)与创建trip
对象或分析长腿的任何其他处理分开。
再举一个简单的例子,看下面的代码片段:
>>> filter(lambda x: x%3==0 or x%5==0, range(10))
<filter object at 0x101d5de50>
>>> sum(_)
23
我们定义了一个简单的lambda
来检查一个数字是否是 3 的倍数或 5 的倍数。我们将这个函数应用到一个可迭代对象range(10)
上。结果是一个可迭代的数字序列,通过决策规则传递。
lambda
为True
的数字是[0, 3, 5, 6, 9]
,所以这些值被传递。由于lambda
对所有其他数字都为False
,它们被拒绝。
这也可以通过执行以下代码来使用生成器表达式来完成:
>>> list(x for x in range(10) if x%3==0 or x%5==0)
[0, 3, 5, 6, 9]
我们可以使用以下集合推导符号来形式化这个过程:
这意味着我们正在构建一个x值的集合,使得x在range(10)
中,且x%3==0 or x%5==0
。filter()
函数和正式的数学集合推导之间有非常优雅的对称性。
我们经常希望使用已定义的函数而不是lambda
forms
来使用filter()
函数。以下是重用先前定义的谓词的示例:
>>> from ch01_ex1 import isprimeg
>>> list(filter(isprimeg, range(100)))
[2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97]
在这个例子中,我们从另一个模块中导入了一个名为isprimeg()
的函数。然后我们将这个函数应用到一组值上,以传递素数并拒绝集合中的非素数。
这可能是生成素数表的一种非常低效的方法。这种表面上的简单性是律师所说的一种有吸引力的危险物。看起来可能很有趣,但它的扩展性非常差。更好的算法是埃拉托斯特尼筛法;这个算法保留了先前找到的素数,并使用它们来防止大量低效的重新计算。
使用 filter()来识别异常值
在上一章中,我们定义了一些有用的统计函数来计算平均值和标准偏差,并对值进行标准化。我们可以使用这些函数来定位我们旅行数据中的异常值。我们可以将mean()
和stdev()
函数应用到旅行中每个leg
的距离值上,以获得人口平均值和标准偏差。
然后我们可以使用z()
函数来计算每个leg
的标准化值。如果标准化值大于 3,数据就远离了平均值。如果我们拒绝这些异常值,我们就有了一个更统一的数据集,不太可能存在报告或测量错误。
以下是我们可以解决这个问题的方法:
from stats import mean, stdev, z
dist_data = list(map(dist, trip))
μ_d = mean(dist_data)
σ_d = stdev(dist_data)
outlier = lambda leg: z(dist(leg),μ_d,σ_d) > 3
print("Outliers", list(filter(outlier, trip)))
我们将距离函数映射到trip
集合中的每个leg
。由于我们将对结果进行几项操作,因此必须实现一个list
对象。我们不能依赖迭代器,因为第一个函数会消耗它。然后我们可以使用这个提取来计算人口统计学μ_d
和σ_d
,即平均值和标准偏差。
根据统计数据,我们使用异常值 lambda 来filter
我们的数据。如果标准化值太大,数据就是异常值。
list(filter(outlier, trip))
的结果是两条腿的列表,与人群中其他腿相比相当长。平均距离约为 34 纳米,标准偏差为 24 纳米。没有一次旅行的标准化距离可以小于-1.407。
注意
我们能够将一个相当复杂的问题分解为许多独立的函数,每个函数都可以很容易地独立测试。我们的处理是由更简单的函数组成的。这可以导致简洁、表达力强的函数式编程。
使用带有哨兵值的 iter()函数
内置的iter()
函数在collection
对象上创建一个迭代器。我们可以使用这个来在collection
周围包装一个iterator
对象。在许多情况下,我们将允许for
语句隐式处理这一点。在一些情况下,我们可能希望显式地创建一个迭代器,以便我们可以将collection
的头部与尾部分开。这个函数还可以通过可调用的or
函数迭代直到找到一个sentinel
值。这个特性有时与文件的read()
函数一起使用,以消耗行直到找到某个sentinel
值。在这种情况下,给定的函数可能是某个文件的readline()
方法。向iter()
提供一个callable
函数对我们来说有点困难,因为这个函数必须在内部维护状态。这个隐藏的状态是一个开放文件的特性,例如,每个read()
或readline()
函数都会将一些内部状态推进到下一个字符或下一行。
另一个例子是可变集合对象的pop()
方法如何对对象进行有状态的更改。以下是使用pop()
方法的示例:
>>> tail= iter([1, 2, 3, None, 4, 5, 6].pop, None)
>>> list(tail)
[6, 5, 4]
tail
变量设置为一个迭代器,该迭代器在列表[1, 2, 3, None, 4, 5, 6]
上进行遍历,该列表将由pop()
函数遍历。pop()
的默认行为是pop(-1)
,即元素以相反顺序弹出。当找到sentinel
值时,iterator
停止返回值。
我们尽可能地想要避免这种内部状态。因此,我们不会试图创造这个特性的用途。
使用 sorted()对数据进行排序
当我们需要按照定义的顺序产生结果时,Python 给了我们两种选择。我们可以创建一个list
对象,并使用list.sort()
方法对项目进行排序。另一种选择是使用sorted()
函数。该函数适用于任何可迭代对象,但它会创建一个最终的list
对象作为排序操作的一部分。
sorted()
函数可以以两种方式使用。它可以简单地应用于集合。它也可以作为一个高阶函数使用key=
参数。
假设我们有来自第四章示例中的旅行数据,与集合一起工作。我们有一个函数,它将为trip
的每个leg
生成一个包含起点、终点和距离的元组序列。数据如下:
(((37.54901619777347, -76.33029518659048), (37.840832, -76.273834), 17.7246), ((37.840832, -76.273834), (38.331501, -76.459503), 30.7382), ((38.331501, -76.459503), (38.845501, -76.537331), 31.0756), ((36.843334, -76.298668), (37.549, -76.331169), 42.3962), ((37.549, -76.331169), (38.330166, -76.458504), 47.2866), ((38.330166, -76.458504), (38.976334, -76.473503), 38.8019))
我们可以看到sorted()
函数的默认行为,使用以下交互:
>>> sorted(dist(x) for x in trip)
[0.1731, 0.1898, 1.4235, 4.3155, ... 86.2095, 115.1751, 129.7748]
我们使用了一个生成器表达式(dist(x) for x in trip
)从我们的旅行数据中提取距离。然后对这个可迭代的数字集合进行排序,以获得从 0.17 nm 到 129.77 nm 的距离。
如果我们想要保持原始的三个元组中的leg
和距离在一起,我们可以让sorted()
函数应用一个key()
函数来确定如何对元组进行排序,如下面的代码片段所示:
>>> sorted(trip, key=dist)
[((35.505665, -76.653664), (35.508335, -76.654999), 0.1731), ((35.028175, -76.682495), (35.031334, -76.682663), 0.1898), ((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)]
我们已经对旅行数据进行了排序,使用了一个dist lambda
来从每个元组中提取距离。dist
函数如下:
dist = lambda leg: leg[2]
这展示了使用简单的lambda
将复杂的元组分解为组成元素的能力。
编写高阶函数
我们可以识别三种高阶函数;它们如下:
-
接受函数作为其参数的函数。
-
返回函数的函数。
Callable
类是一个常见的例子。返回生成器表达式的函数可以被认为是一个高阶函数。 -
接受并返回函数的函数。
functools.partial()
函数是一个常见的例子。我们将这个保存在第十章中,Functools 模块。装饰器是不同的;我们将这个保存在第十一章中,装饰器设计技术。
我们将使用一个高阶函数来扩展这些简单的模式,以转换数据的结构。我们可以进行一些常见的转换,比如以下几种:
-
包装对象以创建更复杂的对象
-
将复杂对象解包成其组件
-
扁平化结构
-
结构化一个扁平序列
Callable
类对象是一个常用的函数返回callable
对象的示例。我们将把它看作一种编写灵活函数的方式,可以向其中注入配置参数。
在本章中,我们还将介绍简单的装饰器。我们将把对装饰器的更深入考虑推迟到第十一章,“装饰器设计技术”中。
编写高阶映射和过滤
Python 的两个内置高阶函数map()
和filter()
通常可以处理几乎我们想要处理的所有内容。很难以一般方式优化它们以实现更高的性能。我们将在 Python 3.4 的函数中查看这些函数,比如imap()
、ifilter()
和ifilterfalse()
,在第八章,“itertools 模块”中。
我们有三种基本等效的表达映射的方式。假设我们有一些函数f(x)
和一些对象集合C
。我们有三种完全等效的表达映射的方式,它们如下:
map()
函数:
map(f, C)
- 生成器表达式:
(f(x) for x in C)
- 生成器函数:
def mymap(f, C):
for x in C:
yield f(x)
mymap(f, C)
同样,我们有三种将filter
函数应用于collection
的方式,它们都是等效的:
filter()
函数:
filter(f, C)
- 生成器表达式:
(x for x in C if f(x))
- 生成器函数:
def myfilter(f, C):
for x in C:
if f(x):
yield x
myfilter(f, C)
有一些性能差异;map()
和filter()
函数最快。更重要的是,有不同类型的扩展适用于这些映射和过滤设计,它们如下:
-
我们可以创建一个更复杂的函数
g(x)
,它应用于每个元素,或者我们可以在处理之前将函数应用于集合C
。这是最一般的方法,适用于所有三种设计。这是我们的函数式设计能量的主要投入点。 -
我们可以微调
for
循环。一个明显的调整是通过在生成器表达式中添加if
子句来将映射和过滤合并为单个操作。我们还可以合并mymap()
和myfilter()
函数,以合并映射和过滤。
我们可以做出的深刻改变是改变循环处理的数据结构。我们有许多设计模式,包括包装、解包(或提取)、扁平化和结构化。我们在之前的章节中已经看过了其中一些技术。
在设计结合太多转换的映射时,我们需要谨慎行事。尽可能地,我们希望避免创建不够简洁或表达单一思想的函数。由于 Python 没有优化编译器,我们可能被迫通过组合函数来手动优化慢应用程序。我们需要在对性能表现不佳的程序进行分析后,才会不情愿地进行这种优化。
在映射时解包数据
当我们使用这样的构造(f(x) for x, y in C)
时,我们在for
语句中使用了多重赋值来解包一个多值元组,然后应用一个函数。整个表达式是一个映射。这是一种常见的 Python 优化,用于改变结构并应用函数。
我们将使用来自第四章,“处理集合”的旅行数据。以下是一个解包映射的具体示例:
def convert(conversion, trip):
**return (conversion(distance) for start, end, distance in trip)
这个高阶函数将由我们可以应用于原始数据的转换函数支持,如下所示:
to_miles = lambda nm: nm*5280/6076.12
to_km = lambda nm: nm*1.852
to_nm = lambda nm: nm
然后可以如下使用该函数提取距离并应用转换函数:
convert(to_miles, trip)
当我们解包时,结果将是一系列浮点值。结果如下:
[20.397120559090908, 35.37291511060606, ..., 44.652462240151515]
这个convert()
函数对我们的起点-终点-距离行程数据结构非常具体,因为for
循环分解了那个三元组。
我们可以构建一个更一般的解决方案,用于在映射设计模式中进行解包。它有点复杂。首先,我们需要像下面的代码片段一样的通用分解函数:
fst= lambda x: x[0]
snd= lambda x: x[1]
sel2= lambda x: x[2]
我们希望能够表示f(sel2(s_e_d)) for s_e_d in trip
。这涉及到函数组合;我们正在组合一个像to_miles()
这样的函数和一个像sel2()
这样的选择器。我们可以使用另一个 lambda 在 Python 中表示函数组合,如下所示:
to_miles= lambda s_e_d: to_miles(sel2(s_e_d))
这给我们一个更长但更一般的解包版本,如下所示:
to_miles(s_e_d) for s_e_d in trip
虽然这个第二个版本有点更一般化,但似乎并不是特别有用。然而,当与特别复杂的元组一起使用时,它可能会很方便。
关于我们的高阶convert()
函数需要注意的是,我们接受一个函数作为参数,并返回一个函数作为结果。convert()
函数不是一个生成器函数;它不会yield
任何东西。convert()
函数的结果是一个必须进行评估以累积个别值的生成器表达式。
相同的设计原则适用于创建混合过滤器而不是映射。我们会在返回的生成器表达式的if
子句中应用过滤器。
当然,我们可以结合映射和过滤来创建更复杂的函数。创建更复杂的函数来限制处理的数量似乎是个好主意。但这并不总是正确的;一个复杂的函数可能无法超越简单的map()
和filter()
函数的嵌套使用性能。通常,我们只想创建一个更复杂的函数,如果它封装了一个概念,并且使软件更容易理解。
在映射时包装额外的数据
当我们使用这样的结构((f(x), x) for x in C)
时,我们进行了包装以创建一个多值元组,同时应用了映射。这是一种常见的技术,可以保存派生结果以创建具有避免重新计算的好处的构造,而不会产生复杂的状态更改对象的责任。
这是第四章处理集合中显示的示例的一部分,用于从点的路径创建行程数据。代码如下:
from ch02_ex3 import float_from_pair, lat_lon_kml, limits, haversine, legs
path= float_from_pair(lat_lon_kml())
trip= tuple((start, end, round(haversine(start, end),4)) for start,end in legs(iter(path)))
我们可以稍微修改这个来创建一个将wrapping
与其他函数分离的高阶函数。我们可以定义一个这样的函数:
def cons_distance(distance, legs_iter):
**return ((start, end, round(distance(start,end),4)) for start, end in legs_iter)
这个函数将每个leg
分解为两个变量,start
和end
。这些将与给定的distance()
函数一起用于计算点之间的距离。结果将构建一个更复杂的三元组,其中包括原始的两个leg
,以及计算出的结果。
然后,我们可以重写我们的行程分配,应用haversine()
函数来计算距离,如下所示:
path= float_from_pair(lat_lon_kml())
trip2= tuple(cons_distance(haversine, legs(iter(path))))
我们用高阶函数cons_distance()
替换了一个生成器表达式。这个函数不仅接受一个函数作为参数,还返回一个生成器表达式。
这个稍微不同的表述如下:
def cons_distance3(distance, legs_iter):
**return ( leg+(round(distance(*leg),4),) for leg in legs_iter)
这个版本使得从旧对象构建新对象的过程更加清晰。我们正在迭代行程的各个部分。我们正在计算leg
上的距离。我们正在用leg
和距离连接起来构建新的结构。
由于这两个cons_distance()
函数都接受一个函数作为参数,我们可以利用这个特性来提供另一种距离公式。例如,我们可以使用math.hypot(lat(start)-lat(end), lon(start)-lon(end))
方法来计算每个leg
上的不太准确的平面距离。
在第十章,“Functools 模块”中,我们将展示如何使用partial()
函数为haversine()
函数的R
参数设置一个值,从而改变计算距离的单位。
在映射时扁平化数据
在第四章,“处理集合”中,我们看了将嵌套的元组结构扁平化为单个可迭代对象的算法。当时我们的目标只是重新构造一些数据,而不进行任何真正的处理。我们可以创建混合解决方案,将函数与扁平化操作结合起来。
假设我们有一块文本,我们想将其转换为数字的平面序列。文本如下所示:
text= """\
**2 3 5 7 11 13 17 19 23 29
**31 37 41 43 47 53 59 61 67 71
**73 79 83 89 97 101 103 107 109 113
**127 131 137 139 149 151 157 163 167 173
**179 181 191 193 197 199 211 223 227 229
"""
每行是一个 10 个数字的块。我们需要解除行以创建数字的平面序列。
这是一个两部分生成器函数,如下所示:
data= list(v for line in text.splitlines() for v in line.split())
这将把文本分割成行,并遍历每一行。它将把每一行分割成单词,并遍历每一个单词。这样的输出是一个字符串列表,如下所示:
['2', '3', '5', '7', '11', '13', '17', '19', '23', '29', '31', '37', '41', '43', '47', '53', '59', '61', '67', '71', '73', '79', '83', '89', '97', '101', '103', '107', '109', '113', '127', '131', '137', '139', '149', '151', '157', '163', '167', '173', '179', '181', '191', '193', '197', '199', '211', '223', '227', '229']
要将字符串转换为数字,我们必须应用转换函数,并解开其原始格式的阻塞结构,使用以下代码片段:
def numbers_from_rows(conversion, text):
**return (conversion(v) for line in text.splitlines() for v in line.split())
此函数具有conversion
参数,该参数是应用于将被发出的每个值的函数。这些值是通过使用上面显示的算法进行扁平化而创建的。
我们可以在以下类型的表达式中使用numbers_from_rows()
函数:
print(list(numbers_from_rows(float, text)))
在这里,我们使用内置的float()
从文本块中创建一个浮点数
值列表。
我们有许多选择,可以使用混合高阶函数和生成器表达式。例如,我们可以将其表示如下:
map(float, v for line in text.splitlines() for v in line.split())
如果这有助于我们理解算法的整体结构,那可能会有所帮助。这个原则被称为分块;具有有意义名称的函数的细节可以被抽象化,我们可以在新的上下文中使用该函数。虽然我们经常使用高阶函数,但有时生成器表达式可能更清晰。
在过滤数据的同时构造数据
前三个示例将额外处理与映射结合在一起。将处理与过滤结合起来似乎不像与映射结合那样具有表现力。我们将详细查看一个示例,以表明,尽管它很有用,但似乎没有与映射和处理结合的用例那么引人注目。
在第四章,“处理集合”中,我们看了算法的结构。我们可以将过滤器与结构算法轻松地合并为单个复杂函数。以下是我们首选函数的版本,用于对可迭代对象的输出进行分组:
def group_by_iter(n, iterable):
**row= tuple(next(iterable) for i in range(n))
**while row:
**yield row
**row= tuple(next(iterable) for i in range(n))
这将尝试从可迭代对象中获取n
个项目的元组。如果元组中有任何项目,则它们将作为结果可迭代对象的一部分产生。原则上,该函数然后对原始可迭代对象中剩余的项目进行递归操作。由于递归在 Python 中相对低效,我们已将其优化为显式的while
循环。
我们可以按以下方式使用此函数:
**group_by_iter(7, filter( lambda x: x%3==0 or x%5==0, range(100)))
这将对由range()
函数创建的可迭代对象应用filter()
函数的结果进行分组。
我们可以将分组和过滤合并为一个单一函数,在单个函数体中执行这两个操作。对group_by_iter()
的修改如下:
def group_filter_iter(n, predicate, iterable):
**data = filter(predicate, iterable)
**row= tuple(next(data) for i in range(n))
**while row:
**yield row
**row= tuple(next(data) for i in range(n))
此函数将过滤谓词函数应用于源可迭代对象。由于过滤器输出本身是非严格可迭代对象,因此data
变量不会提前计算;数据的值将根据需要创建。这个函数的大部分与上面显示的版本相同。
我们可以稍微简化我们使用此函数的上下文,如下所示:
group_filter_iter(7, lambda x: x%3==0 or x%5==0, range(1,100))
在这里,我们应用了过滤谓词,并将结果分组在一个函数调用中。在filter()
函数的情况下,将过滤器与其他处理一起应用很少是一个明显的优势。似乎一个单独的、可见的filter()
函数比一个组合函数更有帮助。
编写生成器函数
许多函数可以被表达为生成器表达式。事实上,我们已经看到几乎任何一种映射或过滤都可以作为生成器表达式来完成。它们也可以使用内置的高阶函数,比如map()
或filter()
,或者作为生成器函数来完成。在考虑多语句生成器函数时,我们需要小心,不要偏离函数式编程的指导原则:无状态函数评估。
在 Python 中进行函数式编程意味着在纯函数式编程和命令式编程之间走一条很窄的路。我们需要确定并隔离必须诉诸命令式 Python 代码的地方,因为没有纯函数式的替代方案可用。
当我们需要 Python 的语句特性时,我们有义务编写生成器函数。像下面这样的特性在生成器表达式中是不可用的:
-
使用
with
上下文来处理外部资源。我们将在第六章递归和归约中讨论文件解析时看到这一点。 -
while
语句可以比for
语句更灵活地进行迭代。这个例子在在映射时展开数据部分中已经展示过。 -
使用
break
或return
语句来实现提前终止循环的搜索。 -
使用
try-except
结构来处理异常。 -
内部函数定义。我们在第一章介绍函数式编程和第二章介绍一些函数式特性中已经看过了这一点。我们还将在第六章递归和归约中重新讨论它。
-
一个非常复杂的
if-elif
序列。试图通过if-else
条件表达式来表达多个选择可能会变得复杂。 -
在 Python 的边缘,我们有一些不常用的特性,比如
for-else
、while-else
、try-else
和try-else-finally
。这些都是语句级别的特性,不适用于生成器表达式。
break
语句最常用于提前结束集合的处理。我们可以在满足某些条件的第一项后结束处理。这是我们正在查看的any()
函数的一个版本,用于查找具有给定属性的值的存在。我们也可以在处理一些较大的项目后结束,但不是全部。
找到单个值可以简洁地表示为min(some-big-expression)
或max(something big)
。在这些情况下,我们承诺要检查所有的值,以确保我们已经正确地找到了最小值或最大值。
在一些情况下,我们可以使用first(function, collection)
函数,其中第一个值为True
就足够了。我们希望尽早终止处理,节省不必要的计算。
我们可以定义一个函数如下:
def first(predicate, collection):
**for x in collection:
**if predicate(x): return x
我们已经遍历了collection
,应用了给定的谓词函数。如果谓词为True
,我们将返回相关的值。如果我们耗尽了collection
,将返回None
的默认值。
我们也可以从PyPi
下载这个版本。第一个模块包含了这个想法的一个变种。更多详情请访问:pypi.python.org/pypi/first
。
这可以作为一个辅助函数,用于确定一个数字是否是质数。以下是一个测试数字是否为质数的函数:
import math
def isprimeh(x):
**if x == 2: return True
**if x % 2 == 0: return False
**factor= first( lambda n: x%n==0, range(3,int(math.sqrt(x)+.5)+1,2))
**return factor is None
这个函数处理了关于数字 2 是质数以及每个其他偶数是合数的一些边缘情况。然后,它使用上面定义的first()
函数来定位给定集合中的第一个因子。
当first()
函数返回因子时,实际数字并不重要。对于这个特定的例子来说,它的存在才是重要的。因此,如果没有找到因子,isprimeh()
函数将返回True
。
我们可以做类似的事情来处理数据异常。以下是map()
函数的一个版本,它还过滤了不良数据:
def map_not_none(function, iterable):
**for x in iterable:
**try:
**yield function(x)
**except Exception as e:
**pass # print(e)
这个函数遍历可迭代对象中的项目。它尝试将函数应用于项目;如果没有引发异常,则产生新值。如果引发异常,则默默地丢弃有问题的值。
在处理包含不适用或缺失值的数据时,这可能很方便。我们尝试处理它们并丢弃无效的值,而不是制定复杂的过滤器来排除这些值。
我们可以使用map()
函数将非 None
值映射为以下形式:
data = map_not_none(int, some_source)
我们将int()
函数应用于some_source
中的每个值。当some_source
参数是一个字符串的可迭代集合时,这可以是一个拒绝不表示数字的字符串
的方便方法。
使用可调用对象构建高阶函数
我们可以将高阶函数定义为Callable
类的实例。这建立在编写生成器函数的想法上;我们将编写可调用对象,因为我们需要 Python 的语句特性。除了使用语句外,我们在创建高阶函数时还可以应用静态配置。
Callable
类定义的重要之处在于,由class
语句创建的类对象本质上定义了一个发出函数的函数。通常,我们将使用callable
对象来创建一个复合函数,将两个其他函数组合成相对复杂的东西。
为了强调这一点,考虑以下类:
from collections.abc import Callable
class NullAware(Callable):
**def __init__(self, some_func):
**self.some_func= some_func
**def __call__(self, arg):
**return None if arg is None else self.some_func(arg)
这个类创建了一个名为NullAware()
的函数,它是一个高阶函数,用于创建一个新的函数。当我们评估NullAware(math.log)
表达式时,我们正在创建一个可以应用于参数值的新函数。__init__()
方法将保存给定的函数在结果对象中。
__call__()
方法是对结果函数进行评估的方法。在这种情况下,创建的函数将优雅地容忍None
值而不会引发异常。
常见的方法是创建新函数并将其保存以备将来使用,方法是给它分配一个名称,如下所示:
null_log_scale= NullAware(math.log)
这将创建一个新的函数并分配名称null_log_scale()
。然后我们可以在另一个上下文中使用该函数。看一下以下示例:
>>> some_data = [10, 100, None, 50, 60]
>>> scaled = map(null_log_scale, some_data)
>>> list(scaled)
[2.302585092994046, 4.605170185988092, None, 3.912023005428146, 4.0943445622221]
一个不太常见的方法是在一个表达式中创建并使用发出的函数,如下所示:
>>> scaled= map(NullAware( math.log ), some_data)
>>> list(scaled)
[2.302585092994046, 4.605170185988092, None, 3.912023005428146, 4.0943445622221]
对NullAware( math.log )
的评估创建了一个函数。然后,这个匿名函数被map()
函数用于处理一个可迭代的some_data
。
这个例子的__call__()
方法完全依赖于表达式评估。这是一种优雅而整洁的方式,用于定义由低级组件函数构建而成的复合函数。在处理标量
函数时,有一些复杂的设计考虑。当我们处理可迭代集合时,我们必须更加小心。
确保良好的函数设计
无状态函数式编程的概念在使用 Python 对象时需要一些小心。对象通常是有状态的。事实上,可以说,面向对象编程的整个目的是将状态变化封装到类定义中。因此,当使用 Python 类定义来处理集合时,我们发现自己在函数式编程和命令式编程之间被拉向相反的方向。
使用Callable
创建复合函数的好处在于,当使用生成的复合函数时,语法会稍微简单一些。当我们开始使用可迭代的映射或缩减时,我们必须意识到我们如何以及为什么引入有状态的对象。
我们将回到上面显示的sum_filter_f()
复合函数。这是一个基于Callable
类定义构建的版本:
from collections.abc import Callable
class Sum_Filter(Callable):
**__slots__ = ["filter", "function"]
**def __init__(self, filter, function):
**self.filter= filter
**self.function= function
**def __call__(self, iterable):
**return sum(self.function(x) for x in iterable ifself.filter(x))
我们已经导入了抽象超类Callable
,并将其用作我们类的基础。我们在这个对象中定义了确切的两个插槽;这对我们使用函数作为有状态对象施加了一些限制。这并不会阻止对生成的对象进行所有修改,但它限制了我们只能使用两个属性。尝试添加属性会导致异常。
初始化方法__init__()
将两个函数名filter
和function
存储在对象的实例变量中。__call__()
方法返回一个基于使用两个内部函数定义的生成器表达式的值。self.filter()
函数用于传递或拒绝项目。self.function()
函数用于转换由filter()
函数传递的对象。
这个类的一个实例是一个具有两个策略函数的函数。我们可以按照以下方式创建一个实例:
count_not_none = Sum_Filter(lambda x: x is not None, lambda x: 1)
我们构建了一个名为count_not_none()
的函数,用于计算序列中的non-None
值。它通过使用lambda
传递non-None
值和一个使用常量 1 而不是实际值的函数来实现这一点。
通常,这个count_not_none()
对象会像任何其他 Python 函数一样行为。使用起来比我们之前的sum_filter_f()
例子要简单一些。
我们可以这样使用count_not_None()
函数:
N= count_not_none(data)
不使用sum_filter_f()
函数:
N= sum_filter_f(valid, count_, data)
基于Callable
的count_not_none()
函数不需要像传统函数那样多的参数。这使得它表面上更容易使用。然而,这也可能使它有些更加晦涩,因为函数工作的细节在源代码的两个地方:一个是函数作为Callable
类的实例创建的地方,另一个是函数被使用的地方。
看一些设计模式
max()
、min()
和sorted()
函数在没有key=
函数的情况下有默认行为。它们可以通过提供一个定义如何从可用数据计算键的函数来进行自定义。在我们的许多例子中,key()
函数是对可用数据的简单提取。这不是必须的;key()
函数可以做任何事情。
想象一下以下方法:max(trip, key=random.randint())
。通常,我们尽量不要使用做一些晦涩操作的key()
函数。
使用key=
函数是一种常见的设计模式。我们的函数可以轻松地遵循这种模式。
我们还看过可以用来简化使用高阶函数的lambda
forms
。使用lambda
forms
的一个重要优势是它非常贴近函数式范式。当编写更传统的函数时,我们可能会创建命令式程序,这可能会使本来简洁和表达力强的函数式设计变得混乱。
我们已经看过几种与值集合一起工作的高阶函数。在前几章中,我们已经暗示了几种不同的高阶collection
和scalar
函数的设计模式。以下是一个广泛的分类:
-
返回一个生成器。高阶函数可以返回一个生成器表达式。我们认为这个函数是高阶的,因为它没有返回
scalar
值或值的collections
。其中一些高阶函数也接受函数作为参数。 -
充当生成器。一些函数示例使用
yield
语句使它们成为一流的生成器函数。生成器函数的值是一个惰性评估的可迭代值集合。我们认为生成器函数本质上与返回生成器表达式的函数没有区别。两者都是非严格的。两者都可以产生一系列值。因此,我们也将考虑生成器函数为高阶函数。内置函数如map()
和filter()
属于这一类。 -
创建一个集合。一些函数必须返回一个实例化的集合对象:
list
、tuple
、set
或mapping
。如果这些函数的参数中包含一个函数,那么这些函数可以是高阶函数。否则,它们只是普通的函数,恰好可以与collections
一起使用。 -
减少集合。一些函数与可迭代对象(或
collection
对象)一起工作,并创建一个scalar
结果。len()
和sum()
函数就是这样的例子。当我们接受一个函数作为参数时,我们可以创建高阶减少。我们将在下一章中回顾这一点。 -
标量。一些函数作用于单个数据项。如果它们接受另一个函数作为参数,那么它们可以是高阶函数。
在设计我们自己的软件时,我们可以在这些已建立的设计模式中进行选择。
总结
在本章中,我们看到了两个高阶函数:max()
和min()
。我们还研究了两个核心的高阶函数,map()
和filter()
。我们还看了sorted()
。
我们还看了如何使用高阶函数来转换数据的结构。我们可以执行几种常见的转换,包括包装、解包、扁平化和不同类型的结构序列。
我们看了三种定义自己的高阶函数的方法,如下所示:
-
def
语句。类似的是将lambda
form
分配给一个变量。 -
将
Callable
类定义为一种发出复合函数的函数。 -
我们还可以使用装饰器来发出复合函数。我们将在第十一章装饰器设计技术中回顾这一点。
在下一章中,我们将探讨通过递归实现纯函数迭代的概念。我们将使用 Python 结构对纯函数技术进行几种常见的改进。我们还将探讨将集合减少到单个值的相关问题。
第六章:递归和归约
在之前的章节中,我们已经看过几种相关的处理设计;其中一些如下:
-
从集合中创建集合的映射和过滤
-
从集合中创建标量值的归约
这种区别体现在诸如map()
和filter()
之类的函数中,这些函数完成了第一种集合处理。还有几个专门的归约函数,包括min()
、max()
、len()
和sum()
。还有一个通用的归约函数,functools.reduce()
。
我们还将考虑collections.Counter()
函数作为一种归约运算符。它本身并不产生单个标量值,但它确实创建了数据的新组织形式,消除了一些原始结构。从本质上讲,它是一种计数分组操作,与计数归约更类似于映射。
在本章中,我们将更详细地研究归约函数。从纯粹的功能角度来看,归约是递归地定义的。因此,我们将首先研究递归,然后再研究归约算法。
一般来说,函数式编程语言编译器会优化递归函数,将函数尾部的调用转换为循环。这将大大提高性能。从 Python 的角度来看,纯递归是有限的,因此我们必须手动进行尾调用优化。Python 中可用的尾调用优化技术是使用显式的for
循环。
我们将研究许多归约算法,包括sum()
、count()
、max()
和min()
。我们还将研究collections.Counter()
函数和相关的groupby()
归约。我们还将研究解析(和词法扫描)是适当的归约,因为它们将标记序列(或字符序列)转换为具有更复杂属性的高阶集合。
简单的数值递归
我们可以认为所有数值运算都是通过递归定义的。要了解更多,请阅读定义数字的基本特征的皮亚诺公理。en.wikipedia.org/wiki/Peano_axioms
是一个开始的地方。
从这些公理中,我们可以看到加法是使用更原始的下一个数字或数字的后继n的概念递归地定义的,。
为了简化演示,我们假设我们可以定义一个前驱函数,,使得
,只要
。
两个自然数之间的加法可以递归地定义如下:
如果我们使用更常见的和
而不是
和
,我们可以看到
。
这在 Python 中可以很好地转换,如下面的命令片段所示:
def add(a,b):
**if a == 0: return b
**else: return add(a-1, b+1)
我们只是将常见的数学符号重新排列成 Python。if
子句放在左边而不是右边。
通常,我们不会在 Python 中提供自己的函数来进行简单的加法。我们依赖于 Python 的底层实现来正确处理各种类型的算术。我们的观点是,基本的标量算术可以递归地定义。
所有这些递归定义都包括至少两种情况:非递归情况,其中函数的值直接定义,以及递归情况,其中函数的值是从对具有不同值的函数的递归评估中计算出来的。
为了确保递归会终止,重要的是要看递归情况如何计算接近定义的非递归情况的值。我们在这里的函数中通常省略了参数值的约束。例如,前面命令片段中的add()
函数可以包括assert a>= and b>=0
来建立输入值的约束。
在没有这些约束的情况下,a-1
不能保证接近a == 0
的非递归情况。
在大多数情况下,这是显而易见的。在少数情例中,可能难以证明。一个例子是 Syracuse 函数。这是终止不明确的病态情况之一。
实现尾递归优化
在某些函数的情况下,递归定义是经常被提及的,因为它简洁而富有表现力。最常见的例子之一是factorial()
函数。
我们可以看到,这可以被重写为 Python 中的一个简单递归函数,从以下公式:
实现尾递归优化
前面的公式可以通过以下命令在 Python 中执行:
def fact(n):
**if n == 0: return 1
**else: return n*fact(n-1)
这样做的好处是简单。在 Python 中,递归限制人为地限制了我们;我们不能计算大约 fact(997)以上的任何值。1000!的值有 2568 位数,通常超出了我们的浮点容量;在某些系统上,这大约是。从实用的角度来看,通常会切换到
log gamma
函数,它在处理大浮点值时效果很好。
这个函数演示了典型的尾递归。函数中的最后一个表达式是对具有新参数值的函数的调用。优化编译器可以用一个很快执行的循环替换函数调用堆栈管理。
由于 Python 没有优化编译器,我们必须着眼于标量递归并对其进行优化。在这种情况下,函数涉及从n到n-1的增量变化。这意味着我们正在生成一系列数字,然后进行缩减以计算它们的乘积。
走出纯粹的函数处理,我们可以定义一个命令式的facti()
计算如下:
def facti(n):
**if n == 0: return 1
**f= 1
**for i in range(2,n):
**f= f*i
**return f
这个阶乘函数的版本将计算超过 1000!的值(例如,2000!有 5733 位数)。它并不是纯粹的函数。我们已经将尾递归优化为一个有状态的循环,取决于i
变量来维护计算的状态。
总的来说,我们在 Python 中被迫这样做,因为 Python 无法自动进行尾递归优化。然而,有些情况下,这种优化实际上并不会有所帮助。我们将看几种情况。
保留递归
在某些情况下,递归定义实际上是最优的。一些递归涉及分而治之的策略,可以将工作量最小化从到
。其中一个例子是平方算法的指数运算。我们可以正式地将其陈述如下:
保留递归
我们将这个过程分成三种情况,可以很容易地在 Python 中写成递归。看一下以下命令片段:
def fastexp(a, n):
**if n == 0: return 1
**elif n % 2 == 1: return a*fastexp(a,n-1)
**else:
**t= fastexp(a,n//2)
**return t*t
这个函数有三种情况。基本情况,fastexp(a, 0)
方法被定义为值为 1。另外两种情况采取了两种不同的方法。对于奇数,fastexp()
方法被递归定义。指数n减少了 1。简单的尾递归优化对这种情况有效。
然而,对于偶数,fastexp()
递归使用n/2
,将问题分成原始大小的一半。由于问题规模减小了一半,这种情况会显著加快处理速度。
我们不能简单地将这种函数重新构建为尾递归优化循环。由于它已经是最优的,我们实际上不需要进一步优化。Python 中的递归限制将强加约束,这是一个宽松的上限。
处理困难的尾递归优化
我们可以递归地查看斐波那契数的定义。以下是一个广泛使用的第n个斐波那契数的定义:
给定的斐波那契数,,被定义为前两个数的和,
。这是一个多重递归的例子:它不能简单地优化为简单的尾递归。然而,如果我们不将其优化为尾递归,我们会发现它太慢而无法使用。
以下是一个天真的实现:
def fib(n):
**if n == 0: return 0
**if n == 1: return 1
**return fib(n-1) + fib(n-2)
这遭受了多重递归问题。在计算fib(n)
方法时,我们必须计算fib(n-1)
和fib(n-2)
方法。计算fib(n-1)
方法涉及重复计算fib(n-2)
方法。斐波那契函数的两个递归使用将使得计算量翻倍。
由于 Python 的从左到右的评估规则,我们可以计算到大约fib(1000)
的值。然而,我们必须要有耐心。非常有耐心。
以下是一个替代方案,它重新陈述了整个算法,使用有状态变量而不是简单的递归:
def fibi(n):
**if n == 0: return 0
**if n == 1: return 1
**f_n2, f_n1 = 1, 1
**for i in range(3, n+1):
**f_n2, f_n1 = f_n1, f_n2+f_n1
**return f_n1
注意
我们的有状态版本的这个函数从 0 开始计数,不像递归,递归是从初始值n开始计数。它保存了用于计算和
的值。这个版本比递归版本快得多。
重要的是,我们无法轻松地通过明显的重写来优化递归。为了用命令式版本替换递归,我们必须仔细研究算法,确定需要多少个有状态的中间变量。
通过递归处理集合
在处理集合时,我们也可以递归地定义处理。例如,我们可以递归地定义map()
函数。形式主义如下所示:
我们已经将函数映射到空集合定义为一个空序列。我们还指定了将函数应用于集合可以通过三个步骤的表达式进行递归定义。首先,将函数应用于除最后一个元素之外的所有集合,创建一个序列对象。然后将函数应用于最后一个元素。最后,将最后的计算附加到先前构建的序列中。
以下是较旧的map()
函数的纯递归函数版本:
def mapr(f, collection):
**if len(collection) == 0: return []
**return mapr(f, collection[:-1]) + [f(collection[-1])]
mapr(f,[])
方法的值被定义为一个空的list
对象。mapr()
函数对非空列表的值将应用函数到列表的最后一个元素,并将其附加到从mapr()
函数递归应用到列表头部构建的列表中。
我们必须强调这个mapr()
函数实际上创建了一个list
对象,类似于 Python 中较旧的map()
函数。Python 3 中的map()
函数是可迭代的,并不是尾递归优化的很好的例子。
虽然这是一个优雅的形式主义,但它仍然缺乏所需的尾递归优化。尾递归优化允许我们超过 1000 的递归深度,并且比这种天真的递归执行得更快。
集合的尾递归优化
我们有两种处理集合的一般方法:我们可以使用一个返回生成器表达式的高阶函数,或者我们可以创建一个使用for
循环来处理集合中的每个项目的函数。这两种基本模式非常相似。
以下是一个行为类似于内置map()
函数的高阶函数:
def mapf(f, C):
**return (f(x) for x in C)
我们返回了一个生成器表达式,它产生了所需的映射。这使用了一个显式的for
循环作为一种尾调用优化。
以下是一个具有相同值的生成器函数:
def mapg(f, C):
**for x in C:
**yield f(x)
这使用了一个完整的for
语句进行所需的优化。
在这两种情况下,结果是可迭代的。我们必须在此之后做一些事情来实现一个序列对象:
>>> list(mapg(lambda x:2**x, [0, 1, 2, 3, 4]))
[1, 2, 4, 8, 16]
为了性能和可伸缩性,在 Python 程序中基本上需要这种尾调用优化。它使代码不纯粹功能。然而,好处远远超过了纯度的缺失。为了获得简洁和表达式功能设计的好处,有助于将这些不纯粹的函数视为适当的递归。
从实用的角度来看,这意味着我们必须避免用额外的有状态处理来使集合处理函数混乱。即使我们程序的一些元素不纯粹,函数式编程的核心原则仍然有效。
减少和折叠 - 从多个到一个
我们可以认为sum()
函数具有以下类型的定义:
我们可以说一个集合的总和对于一个空集合是 0。对于一个非空集合,总和是第一个元素加上剩余元素的总和。
同样地,我们可以使用两种情况递归地计算一组数字的乘积:
基本情况将空序列的乘积定义为 1。递归情况将乘积定义为第一个项目乘以剩余项目的乘积。
我们在序列的每个项目之间有效地折叠了×
或+
运算符。此外,我们对项目进行了分组,以便处理将从右到左进行。这可以称为将集合减少为单个值的右折叠方式。
在 Python 中,可以递归地定义乘积函数如下:
def prodrc(collection):
**if len(collection) == 0: return 1
**return collection[0] * prodrc(collection[1:])
从技术上讲,这是正确的。这是从数学符号转换为 Python 的一个微不足道的重写。然而,它不够优化,因为它倾向于创建大量中间的list
对象。它也仅限于与显式集合一起使用;它不能轻松地与iterable
对象一起使用。
我们可以稍微修改这个函数,使其适用于可迭代对象,从而避免创建任何中间的collection
对象。以下是一个可以与可迭代数据源一起使用的适当递归乘积函数:
def prodri(iterable):
**try:
**head= next(iterable)
**except StopIteration:
**return 1
**return head*prodri(iterable)
我们不能使用len()
函数来查询可迭代对象有多少个元素。我们所能做的就是尝试提取iterable
序列的头部。如果序列中没有项目,那么任何获取头部的尝试都将引发StopIteration
异常。如果有一个项目,那么我们可以将该项目乘以序列中剩余项目的乘积。对于演示,我们必须明确地使用iter()
函数从一个具体化的sequence
对象中创建一个可迭代对象。在其他情境中,我们可能会有一个可迭代的结果可以使用。以下是一个例子:
>>> prodri(iter([1,2,3,4,5,6,7]))
5040
这个递归定义不依赖于 Python 的显式状态或其他命令式特性。虽然它更加纯粹功能,但它仍然局限于处理少于 1000 个项目的集合。从实用的角度来看,我们可以使用以下类型的命令式结构来进行减少函数:
def prodi(iterable):
**p= 1
**for n in iterable:
**p *= n
**return p
这缺乏递归限制。它包括所需的尾调用优化。此外,这将同样适用于sequence
对象或可迭代对象。
在其他函数式语言中,这被称为foldl
操作:运算符从左到右折叠到可迭代的值集合中。这与通常称为foldr
操作的递归公式不同,因为在集合中的评估是从右到左进行的。
对于具有优化编译器和惰性评估的语言,fold-left 和 fold-right 的区别决定了中间结果的创建方式。这可能具有深远的性能影响,但这种区别可能并不明显。例如,fold-left 可能会立即消耗和处理序列中的第一个元素。然而,fold-right 可能会消耗序列的头部,但在整个序列被消耗之前不进行任何处理。
分组缩减-从多到少
一个非常常见的操作是通过某个键或指示器对值进行分组的缩减。在SQL中,这通常称为SELECT GROUP BY
操作。原始数据按某些列的值分组,然后对其他列应用缩减(有时是聚合函数)。SQL 聚合函数包括SUM
、COUNT
、MAX
和MIN
。
统计摘要称为模式,是按独立变量分组的计数。Python 为我们提供了几种在计算分组值的缩减之前对数据进行分组的方法。我们将首先看两种获取分组数据的简单计数的方法。然后我们将看看计算分组数据的不同摘要的方法。
我们将使用我们在第四章与集合一起工作中计算的行程数据。这些数据最初是一系列纬度-经度航点。我们重新构造它以创建由leg
的起点、终点和距离表示的航段。数据如下所示:
(((37.5490162, -76.330295), (37.840832, -76.273834), 17.7246), ((37.840832, -76.273834), (38.331501, -76.459503), 30.7382), ((38.331501, -76.459503), (38.845501, -76.537331), 31.0756), ... ((38.330166, -76.458504), (38.976334, -76.473503), 38.8019))
一个常见的操作,可以作为有状态的映射或作为一个实现、排序的对象来处理,就是计算一组数据值的模式。当我们查看我们的行程数据时,变量都是连续的。要计算模式,我们需要量化所覆盖的距离。这也被称为分箱:我们将数据分组到不同的箱中。分箱在数据可视化应用中很常见。在这种情况下,我们将使用 5 海里作为每个箱的大小。
可以使用生成器表达式生成量化距离:
quantized= (5*(dist//5) for start,stop,dist in trip)
这将把每个距离除以 5-丢弃任何小数-然后乘以 5 来计算代表四舍五入到最近 5 海里的距离的数字。
使用 Counter 构建映射
像collections.Counter
方法这样的映射是进行创建计数(或总数)的优化的好方法,这些计数(或总数)是按集合中的某个值分组的。对于分组数据的更典型的函数式编程解决方案是对原始集合进行排序,然后使用递归循环来识别每个组的开始。这涉及将原始数据实现化,执行排序,然后进行缩减以获得每个键的总和或计数。
我们将使用以下生成器创建一个简单的距离序列,转换为箱:
quantized= (5*(dist//5) for start,stop,dist in trip)
我们使用截断的整数除法将每个距离除以 5,然后乘以 5,以创建一个四舍五入到最近 5 英里的值。
以下表达式创建了一个从距离到频率的映射
:
from collections import Counter
Counter(quantized)
这是一个有状态的对象,由技术上的命令式面向对象编程创建。然而,由于它看起来像一个函数,它似乎很适合基于函数式编程思想的设计。
如果我们打印Counter(quantized).most_common()
函数,我们将看到以下结果:
[(30.0, 15), (15.0, 9), (35.0, 5), (5.0, 5), (10.0, 5), (20.0, 5), (25.0, 5), (0.0, 4), (40.0, 3), (45.0, 3), (50.0, 3), (60.0, 3), (70.0, 2), (65.0, 1), (80.0, 1), (115.0, 1), (85.0, 1), (55.0, 1), (125.0, 1)]
最常见的距离约为 30 海里。记录的最短leg
是 4 个 0 的实例。最长的航段是 125 海里。
请注意,你的输出可能与此略有不同。most_common()
函数的结果按频率排序;相同频率的箱可能以任何顺序出现。这 5 个长度可能不总是按照所示的顺序排列:
(35.0, 5), (5.0, 5), (10.0, 5), (20.0, 5), (25.0, 5)
通过排序构建映射
如果我们想要在不使用Counter
类的情况下实现这一点,我们可以使用更多基于函数的排序和分组方法。以下是一个常见的算法:
def group_sort(trip):
**def group(data):
**previous, count = None, 0
**for d in sorted(data):
**if d == previous:
**count += 1
**elif previous is not None: # and d != previous
**yield previous, count
**previous, count = d, 1
**elif previous is None:
**previous, count = d, 1
**else:
**raise Exception("Bad bad design problem.")
**yield previous, count
**quantized= (5*(dist//5) for start,stop,dist in trip)
**return dict(group(quantized))
内部的group()
函数遍历排序后的数据项序列。如果给定项已经被看到 - 它与previous
中的值匹配 - 那么计数器可以递增。如果给定项与前一个值不匹配,并且前一个值不是None
,那么我们就有了值的变化;我们可以输出前一个值和计数,并开始对新值进行新的累积计数。第三个条件只适用一次:如果前一个值从未被设置过,那么这是第一个值,我们应该保存它。
函数的最后一行从分组的项中创建一个字典。这个字典将类似于一个 Counter 字典。主要的区别在于Counter()
函数有一个most_common()
方法函数,而默认字典则没有。
elif previous is None
方法是一个让人讨厌的开销。摆脱这个elif
子句(并看到轻微的性能改进)并不是非常困难。
为了去掉额外的elif
子句,我们需要在内部的group()
函数中使用稍微更复杂的初始化:
**def group(data):
**sorted_data= iter(sorted(data))
**previous, count = next(sorted_data), 1
**for d in sorted_data:
**if d == previous:
**count += 1
**elif previous is not None: # and d != previous
**yield previous, count
**previous, count = d, 1
**else:
**raise Exception("Bad bad design problem.")
**yield previous, count
这会从数据集中挑选出第一个项目来初始化previous
变量。然后剩下的项目通过循环进行处理。这种设计与递归设计有一定的相似之处,其中我们使用第一个项目初始化递归,每次递归调用都提供下一个项目或None
来指示没有剩余项目需要处理。
我们也可以使用itertools.groupby()
来实现这一点。我们将在第八章Itertools 模块中仔细研究这个函数。
按键值对数据进行分组或分区
我们可能想要对分组数据应用的归约类型没有限制。我们可能有一些独立和因变量的数据。我们可以考虑通过一个独立变量对数据进行分区,并计算每个分区中值的最大值、最小值、平均值和标准差等摘要。
进行更复杂的归约的关键是将所有数据值收集到每个组中。Counter()
函数仅仅收集相同项的计数。我们想要基于关键值创建原始项的序列。
从更一般的角度来看,每个 5 英里的箱都将包含该距离的所有腿,而不仅仅是腿的计数。我们可以将分区视为递归,或者作为defaultdict(list)
对象的有状态应用。我们将研究groupby()
函数的递归定义,因为它很容易设计。
显然,对于空集合C
,groupby(C, key)
方法返回的是空字典dict()
。或者更有用的是空的defaultdict(list)
对象。
对于非空集合,我们需要处理项C[0]
,即头,然后递归处理序列C[1:]
,即尾。我们可以使用head, *tail = C
命令来解析集合,如下所示:
>>> C= [1,2,3,4,5]
>>> head, *tail= C
>>> head
1
>>> tail
[2, 3, 4, 5]
我们需要执行dict[key(head)].append(head)
方法来将头元素包含在结果字典中。然后我们需要执行groupby(tail,key)
方法来处理剩余的元素。
我们可以创建一个如下的函数:
def group_by(key, data):
**def group_into(key, collection, dictionary):
**if len(collection) == 0:**
**return dictionary
**head, *tail= collection
**dictionary[key(head)].append(head)
**return group_into(key, tail, dictionary)
**return group_into(key, data, defaultdict(list))
内部函数处理我们的基本递归定义。一个空集合返回提供的字典。非空集合被解析为头和尾。头用于更新字典。然后使用尾递归地更新字典中的所有剩余元素。
我们无法轻松地使用 Python 的默认值将其合并为一个函数。我们不能使用以下命令片段:
def group_by(key, data, dictionary=defaultdict(list)):
如果我们尝试这样做,group_by()
函数的所有用法都共享一个defaultdict(list)
对象。Python 只构建默认值一次。可变对象作为默认值很少能实现我们想要的效果。与其尝试包含更复杂的决策来处理不可变的默认值(如None
),我们更喜欢使用嵌套函数定义。wrapper()
函数正确地初始化了内部函数的参数。
我们可以按距离对数据进行分组,如下所示:
binned_distance = lambda leg: 5*(leg[2]//5)
by_distance= group_by(binned_distance, trip)
我们定义了一个简单的可重用的lambda
,将我们的距离放入 5 纳米的箱中。然后使用提供的lambda
对数据进行分组。
我们可以按以下方式检查分箱数据:
import pprint
for distance in sorted(by_distance):
**print(distance)
**pprint.pprint(by_distance[distance])
以下是输出的样子:
0.0
[((35.505665, -76.653664), (35.508335, -76.654999), 0.1731), ((35.028175, -76.682495), (35.031334, -76.682663), 0.1898), ((25.4095, -77.910164), (25.425833, -77.832664), 4.3155), ((25.0765, -77.308167), (25.080334, -77.334), 1.4235)]
5.0
[((38.845501, -76.537331), (38.992832, -76.451332), 9.7151), ((34.972332, -76.585167), (35.028175, -76.682495), 5.8441), ((30.717167, -81.552498), (30.766333, -81.471832), 5.103), ((25.471333, -78.408165), (25.504833, -78.232834), 9.7128), ((23.9555, -76.31633), (24.099667, -76.401833), 9.844)] ... 125.0
[((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)]
这也可以写成迭代,如下所示:
def partition(key, data):
**dictionary= defaultdict(list)
**for head in data:
**dictionary[key(head)].append(head)
**return dictionary
在进行尾递归优化时,命令式版本中的关键代码行将与递归定义相匹配。我们已经突出显示了该行以强调重写的目的是具有相同的结果。其余结构代表了我们采用的尾递归优化,这是一种常见的解决 Python 限制的方法。
编写更一般的分组约简
一旦我们对原始数据进行了分区,我们就可以对每个分区中的数据元素进行各种类型的约简。例如,我们可能希望每个距离箱的起始点是每个腿的最北端。
我们将介绍一些辅助函数来分解元组,如下所示:
start = lambda s, e, d: s
end = lambda s, e, d: e
dist = lambda s, e, d: d
latitude = lambda lat, lon: lat
longitude = lambda lat, lon: lon
这些辅助函数中的每一个都期望提供一个tuple
对象,使用*
运算符将元组的每个元素映射到lambda
的单独参数。一旦元组扩展为s
、e
和p
参数,通过名称返回正确的参数就变得相当明显。这比尝试解释tuple_arg[2]
方法要清晰得多。
以下是我们如何使用这些辅助函数:
>>> point = ((35.505665, -76.653664), (35.508335, -76.654999), 0.1731)
>>> start(*point)
(35.505665, -76.653664)
>>> end(*point)
(35.508335, -76.654999)
>>> dist(*point)
0.1731
>>> latitude(*start(*point))
35.505665
我们的初始点对象是一个嵌套的三元组,包括(0)
- 起始位置,(1)
- 结束位置和(2)
- 距离。我们使用我们的辅助函数提取了各种字段。
有了这些辅助函数,我们可以找到每个箱中腿的最北端起始位置:
for distance in sorted(by_distance):
**print(distance, max(by_distance[distance], key=lambda pt: latitude(*start(*pt))))
我们按距离分组的数据包括给定距离的每条腿。我们将每个箱中的所有腿提供给max()
函数。我们提供给max()
函数的key
函数仅提取了腿的起始点的纬度。
这给我们一个关于每个距离的最北端腿的简短列表,如下所示:
0.0 ((35.505665, -76.653664), (35.508335, -76.654999), 0.1731)
5.0 ((38.845501, -76.537331), (38.992832, -76.451332), 9.7151)
10.0 ((36.444168, -76.3265), (36.297501, -76.217834), 10.2537)
...**
125.0 ((27.154167, -80.195663), (29.195168, -81.002998), 129.7748)
编写高阶约简
我们将在这里看一个高阶约简算法的示例。这将介绍一个相当复杂的主题。最简单的约简类型是从一组值中生成一个值。Python 有许多内置的约简,包括any()
、all()
、max()
、min()
、sum()
和len()
。
正如我们在第四章中所指出的,处理集合,如果我们从一些简单的约简开始,我们可以进行大量的统计计算,例如以下内容:
def s0(data):
**return sum(1 for x in data) # or len(data)
def s1(data):
**return sum(x for x in data) # or sum(data)
def s2(data):
**return sum(x*x for x in data)
这使我们能够使用几个简单的函数来定义均值、标准差、归一化值、校正,甚至最小二乘线性回归。
我们的最后一个简单约简s2()
显示了我们如何应用现有的约简来创建高阶函数。我们可能会改变我们的方法,使其更像以下内容:
def sum_f(function, data):
**return sum(function(x) for x in data)
我们添加了一个函数,用于转换数据。我们将计算转换值的总和。
现在我们可以以三种不同的方式应用此函数来计算三个基本总和,如下所示:
N= sum_f(lambda x: 1, data) # x**0
S= sum_f(lambda x: x, data) # x**1
S2= sum_f( lambda x: x*x, data ) # x**2
我们插入了一个小的lambda
来计算,即计数,
,即总和,以及
,即平方和,我们可以用它来计算标准偏差。
这通常包括一个过滤器,用于拒绝某种方式未知或不合适的原始数据。我们可以使用以下命令来拒绝错误的数据:
def sum_filter_f(filter, function, data):
**return sum(function(x) for x in data if filter(x))
执行以下命令片段允许我们以简单的方式拒绝None
值:
count_= lambda x: 1
sum_ = lambda x: x
valid = lambda x: x is not None
N = sum_filter_f(valid, count_, data)
这显示了我们如何向sum_filter_f()
函数提供两个不同的lambda
。filter
参数是一个拒绝None
值的lambda
,我们称之为valid
以强调其含义。function
参数是一个实现count
或sum
方法的lambda
。我们可以轻松地添加一个lambda
来计算平方和。
重要的是要注意,这个函数与其他示例类似,因为它实际上返回一个函数而不是一个值。这是高阶函数的定义特征之一,在 Python 中实现起来非常简单。
编写文件解析器
我们经常可以将文件解析器视为一种缩减。许多语言有两个级别的定义:语言中的低级标记和从这些标记构建的高级结构。当查看 XML 文件时,标签、标签名称和属性名称形成了这种低级语法;由 XML 描述的结构形成了高级语法。
低级词法扫描是一种将单个字符组合成标记的缩减。这与 Python 的生成器函数设计模式非常匹配。我们经常可以编写如下的函数:
Def lexical_scan( some_source ):
**for char in some_source:
**if some_pattern completed: yield token
**else: accumulate token
对于我们的目的,我们将依赖于低级文件解析器来处理这些问题。我们将使用 CSV、JSON 和 XML 包来管理这些细节。我们将基于这些包编写高级解析器。
我们仍然依赖于两级设计模式。一个低级解析器将产生原始数据的有用的规范表示。它将是一个文本元组的迭代器。这与许多种类的数据文件兼容。高级解析器将产生对我们特定应用程序有用的对象。这些可能是数字元组,或者是命名元组,或者可能是一些其他类的不可变 Python 对象。
我们在第四章处理集合中提供了一个低级解析器的示例。输入是一个 KML 文件;KML 是地理信息的 XML 表示。解析器的基本特征看起来类似于以下命令片段:
def comma_split(text):
**return text.split(",")
def row_iter_kml(file_obj):
**ns_map={
**"ns0": "http://www.opengis.net/kml/2.2",
**"ns1": "http://www.google.com/kml/ext/2.2"}
**doc= XML.parse(file_obj)
**return (comma_split(coordinates.text)
**for coordinates in doc.findall("./ns0:Document/ns0:Folder/ns0:Placemark/ns0:Point/ns0:coordinates", ns_map)
row_iter_kml()
函数的主要部分是 XML 解析,它允许我们使用doc.findall()
函数来迭代文档中的<ns0:coordinates>
标签。我们使用了一个名为comma_split()
的函数来解析这个标签的文本为一个三元组的值。
这专注于使用规范化的 XML 结构。文档大部分符合数据库设计师对第一范式的定义,也就是说,每个属性都是原子的,只有一个值。XML 数据中的每一行都具有相同的列,数据类型一致。数据值并不是完全原子的;我们需要将经度、纬度和海拔分割成原子字符串值。
大量数据——xml 标签、属性和其他标点——被缩减为一个相对较小的体积,其中只包括浮点纬度和经度值。因此,我们可以将解析器视为一种缩减。
我们需要一个更高级别的转换来将文本的元组映射为浮点数。此外,我们希望丢弃海拔,并重新排列经度和纬度。这将产生我们需要的特定于应用程序的元组。我们可以使用以下函数进行此转换:
def pick_lat_lon(lon, lat, alt):
**return lat, lon
def float_lat_lon(row_iter):
**return (tuple(map(float, pick_lat_lon(*row)))for row in row_iter)
关键工具是float_lat_lon()
函数。这是一个返回生成器表达式的高阶函数。生成器使用map()
函数将float()
函数转换应用到pick_lat_lon()
类的结果上。我们使用*row
参数将行元组的每个成员分配给pick_lat_lon()
函数的不同参数。然后该函数以所需顺序返回所选项目的元组。
我们可以按以下方式使用此解析器:
with urllib.request.urlopen("file:./Winter%202012-2013.kml") as source:
**trip = tuple(float_lat_lon(row_iter_kml(source)))
这将为原始 KML 文件中路径上的每个航路点构建一个元组表示。它使用低级解析器从原始表示中提取文本数据行。它使用高级解析器将文本项转换为更有用的浮点值元组。在这种情况下,我们没有实现任何验证。
解析 CSV 文件
在第三章,“函数,迭代器和生成器”中,我们看到了另一个例子,我们解析了一个不是规范化形式的 CSV 文件:我们不得不丢弃标题行才能使其有用。为了做到这一点,我们使用了一个简单的函数,提取了标题并返回了剩余行的迭代器。
数据如下:
Anscombe's quartet
I II III IV
x y x y x y x y
10.0 8.04 10.0 9.14 10.0 7.46 8.0 6.58
8.0 6.95 8.0 8.14 8.0 6.77 8.0 5.76
...**
5.0 5.68 5.0 4.74 5.0 5.73 8.0 6.89
列由制表符分隔。另外还有三行标题,我们可以丢弃。
以下是基于 CSV 的解析器的另一个版本。我们将其分为三个函数。第一个row_iter()
函数返回制表符分隔文件中行的迭代器。函数如下所示:
def row_iter_csv(source):
**rdr= csv.reader(source, delimiter="\t")
**return rdr
这是围绕 CSV 解析过程的简单包装。当我们回顾以前用于 XML 和纯文本的解析器时,这是那些解析器缺少的东西。生成可迭代的行元组可以是规范化数据解析器的常见特征。
一旦我们有了一行元组,我们可以传递包含可用数据的行,并拒绝包含其他元数据的行,例如标题和列名。我们将介绍一个辅助函数,我们可以使用它来执行一些解析,以及一个filter()
函数来验证数据行。
以下是转换:
def float_none(data):
**try:
**data_f= float(data)
**return data_f
**except ValueError:
**return None
此函数处理将单个string
转换为float
值,将错误数据转换为None
值。我们可以将此函数嵌入到映射中,以便将行的所有列转换为float
或None
值。lambda
如下所示:
float_row = lambda row: list(map(float_none, row))
以下是基于使用all()
函数的行级验证器,以确保所有值都是float
(或没有值是None
):
all_numeric = lambda row: all(row) and len(row) == 8
以下是一个高阶函数,它结合了行级转换和过滤:
def head_filter_map(validator, converter, validator, row_iter):
**return filter(all_validator, map(converter, row_iter))
此函数为我们提供了一个稍微更完整的解析输入文件的模式。基础是一个低级函数,它迭代文本元组。然后我们可以将其包装在函数中以转换和验证转换后的数据。对于文件要么处于第一正规形式(所有行都相同),要么简单验证器可以拒绝其他行的情况,这种设计非常有效。
然而,并非所有解析问题都如此简单。一些文件的重要数据位于必须保留的标题或尾随行中,即使它与文件的其余部分的格式不匹配。这些非规范化文件将需要更复杂的解析器设计。
解析带有标题的纯文本文件
在第三章,“函数,迭代器和生成器”中,Crayola.GPL
文件是在没有显示解析器的情况下呈现的。该文件如下所示:
GIMP Palette
Name: Crayola
Columns: 16
#
239 222 205 Almond
205 149 117 Antique Brass
我们可以使用正则表达式解析文本文件。我们需要使用过滤器来读取(和解析)标题行。我们还希望返回一个可迭代的数据行序列。这种相当复杂的两部分解析完全基于两部分 - 头部和尾部 - 文件结构。
以下是处理头部和尾部的低级解析器:
def row_iter_gpl(file_obj):
**header_pat= re.compile(r"GIMP Palette\nName:\s*(.*?)\nColumns:\s*(.*?)\n#\n", re.M)
**def read_head(file_obj):
**match= header_pat.match("".join( file_obj.readline() for _ in range(4)))
**return (match.group(1), match.group(2)), file_obj
**def read_tail(headers, file_obj):
**return headers, (next_line.split() for next_line in file_obj)
**return read_tail(*read_head(file_obj))
我们已经定义了一个正则表达式,用于解析标题的所有四行,并将其分配给header_pat
变量。有两个内部函数用于解析文件的不同部分。read_head()
函数解析标题行。它通过读取四行并将它们合并成一个长字符串来实现这一点。然后使用正则表达式对其进行解析。结果包括标题中的两个数据项以及一个准备处理额外行的迭代器。
read_tail()
函数接受read_head()
函数的输出,并解析剩余行的迭代器。标题行的解析信息形成一个两元组,与剩余行的迭代器一起传递给read_tail()
函数。剩余行仅仅是按空格分割,因为这符合 GPL 文件格式的描述。
注意
有关更多信息,请访问以下链接:
code.google.com/p/grafx2/issues/detail?id=518
。
一旦我们将文件的每一行转换为规范的字符串元组格式,我们就可以对这些数据应用更高级别的解析。这涉及转换和(如果必要)验证。
以下是一个更高级别的解析器命令片段:
def color_palette(headers, row_iter):
**name, columns = headers
**colors = tuple(Color(int(r), int(g), int(b), " ".join(name))for r,g,b,*name in row_iter)
**return name, columns, colors
这个函数将使用低级row_iter_gpl()
解析器的输出:它需要标题和迭代器。这个函数将使用多重赋值将color
数字和剩余单词分成四个变量,r
、g
、b
和name
。使用*name
参数确保所有剩余值都将被分配给名字作为一个tuple
。然后" ".join(name)
方法将单词连接成一个以空格分隔的字符串。
以下是我们如何使用这个两层解析器:
with open("crayola.gpl") as source:
**name, columns, colors = color_palette(*row_iter_gpl(source))
**print(name, columns, colors)
我们将高级解析器应用于低级解析器的结果。这将返回标题和从Color
对象序列构建的元组。
总结
在这一章中,我们已经详细讨论了两个重要的函数式编程主题。我们详细讨论了递归。许多函数式编程语言编译器将优化递归函数,将函数尾部的调用转换为循环。在 Python 中,我们必须通过使用显式的for
循环而不是纯函数递归来手动进行尾调用优化。
我们还研究了包括sum()
、count()
、max()
和min()
函数在内的归约算法。我们研究了collections.Counter()
函数和相关的groupby()
归约。
我们还研究了解析(和词法扫描)如何类似于归约,因为它们将标记序列(或字符序列)转换为具有更复杂属性的高阶集合。我们研究了一种将解析分解为尝试生成原始字符串元组的较低级别和创建更有用的应用对象的较高级别的设计模式。
在下一章中,我们将研究一些适用于使用命名元组和其他不可变数据结构的技术。我们将研究一些使有状态对象不必要的技术。虽然有状态的对象并不是纯粹的函数式,但类层次结构的概念可以用来打包相关的方法函数定义。
第七章:其他元组技术
我们所看到的许多示例要么是scalar
函数,要么是从小元组构建的相对简单的结构。我们经常可以利用 Python 的不可变namedtuple
来构建复杂的数据结构。我们将看看我们如何使用以及如何创建namedtuples
。我们还将研究不可变的namedtuples
可以用来代替有状态对象类的方法。
面向对象编程的一个有益特性是逐步创建复杂数据结构的能力。在某些方面,对象只是函数结果的缓存;这通常与功能设计模式很匹配。在其他情况下,对象范式提供了包括复杂计算的属性方法。这更适合功能设计思想。
然而,在某些情况下,对象类定义被用于有状态地创建复杂对象。我们将研究一些提供类似功能的替代方案,而不涉及有状态对象的复杂性。我们可以识别有状态的类定义,然后包括元属性以对方法函数调用的有效或必需排序。诸如如果在调用 X.q()之前调用 X.p(),结果是未定义的之类的陈述是语言形式主义之外的,是类的元属性。有时,有状态的类包括显式断言和错误检查的开销,以确保方法按正确的顺序使用。如果我们避免有状态的类,我们就消除了这些开销。
我们还将研究一些在任何多态类定义之外编写通用函数的技术。显然,我们可以依赖Callable
类来创建多态类层次结构。在某些情况下,这可能是功能设计中不必要的开销。
使用不可变的命名元组作为记录
在第三章,“函数、迭代器和生成器”中,我们展示了处理元组的两种常见技术。我们也暗示了处理复杂结构的第三种方法。根据情况,我们可以执行以下任一操作:
-
使用
lambdas
(或函数)通过索引选择一个命名项目 -
使用
lambdas
(或函数)与*parameter
通过参数名称选择一个项目,该参数名称映射到一个索引 -
使用
namedtuples
通过属性名称或索引选择项目
我们的旅行数据,介绍在第四章,“与集合一起工作”,有一个相当复杂的结构。数据最初是一个普通的时间序列位置报告。为了计算覆盖的距离,我们将数据转换为一个具有起始位置、结束位置和距离的嵌套三元组的序列。
序列中的每个项目如下所示为一个三元组:
first_leg= ((37.54901619777347, -76.33029518659048), (37.840832, -76.273834), 17.7246)
这是在切萨皮克湾上两点之间的短途旅行。
嵌套元组可能相当难以阅读;例如,诸如first_leg[0][0]
之类的表达式并不是很有信息量。
让我们看看从tuple
中选择值的三种替代方案。第一种技术涉及定义一些简单的选择函数,可以按索引位置从tuple
中选择项目:
start= lambda leg: leg[0]
end= lambda leg: leg[1]
distance= lambda leg: leg[2]
latitude= lambda pt: pt[0]
longitude= lambda pt: pt[1]
有了这些定义,我们可以使用latitude(start(first_leg))
来引用特定的数据片段。
这些定义并没有提供有关所涉及的数据类型的指导。我们可以使用简单的命名约定来使这一点更加清晰。以下是一些使用后缀的选择函数的示例:
start_point = lambda leg: leg[0]
distance_nm= lambda leg: leg[2]
latitude_value= lambda point: point[0]
当使用得当时,这可能是有帮助的。它也可能退化为一个复杂的匈牙利符号,作为每个变量的前缀(或后缀)。
第二种技术使用*parameter
符号来隐藏索引位置的一些细节。以下是一些使用*
符号的选择函数:
start= lambda start, end, distance: start
end= lambda start, end, distance: end
distance= lambda start, end, distance: distance
latitude= lambda lat, lon: lat
longitude= lambda lat, lon: lon
有了这些定义,我们可以使用latitude(*start(*first_leg))
来引用特定的数据。这有清晰度的优势。在这些选择函数的tuple
参数前面看到*
运算符可能有点奇怪。
第三种技术是namedtuple
函数。在这种情况下,我们有嵌套的命名元组函数,如下所示:
Leg = namedtuple("Leg", ("start", "end", "distance"))
Point = namedtuple("Point", ("latitude", "longitude"))
这使我们可以使用first_leg.start.latitude
来获取特定的数据。从前缀函数名称到后缀属性名称的变化可以被视为一种有用的强调。也可以被视为语法上的混乱转变。
我们还将在构建原始数据的过程中,用适当的Leg()
或Point()
函数调用替换tuple()
函数。我们还必须找到一些隐式创建元组的return
和yield
语句。
例如,看一下以下代码片段:
def float_lat_lon(row_iter):
**return (tuple(map(float, pick_lat_lon(*row))) for row in row_iter)
前面的代码将被更改为以下代码片段:
def float_lat_lon(row_iter):
**return (Point(*map(float, pick_lat_lon(*row))) for row in row_iter)
这将构建Point
对象,而不是浮点
坐标的匿名元组。
同样,我们可以引入以下内容来构建Leg
对象的完整行程:
with urllib.request.urlopen("file:./Winter%202012-2013.kml") as source:
**path_iter = float_lat_lon(row_iter_kml(source))
**pair_iter = legs(path_iter)
**trip_iter = (Leg(start, end, round(haversine(start, end),4)) for start,end in pair_iter)
**trip= tuple(trip_iter)
这将遍历基本路径点,将它们配对以为每个Leg
对象创建start
和end
。然后使用这些配对使用start
点、结束点和来自第四章的haversine()
函数构建Leg
实例,与集合一起工作。
当我们尝试打印trip
对象时,它将如下所示:
(Leg(start=Point(latitude=37.54901619777347, longitude=-76.33029518659048), end=Point(latitude=37.840832, longitude=-76.273834), distance=17.7246), Leg(start=Point(latitude=37.840832, longitude=-76.273834), end=Point(latitude=38.331501, longitude=-76.459503), distance=30.7382),...
Leg(start=Point(latitude=38.330166, longitude=-76.458504), end=Point(latitude=38.976334, longitude=-76.473503), distance=38.8019))
注意
重要的是要注意,haversine()
函数是用简单的元组编写的。我们已经将这个函数与namedtuples
一起重用。由于我们仔细保留了参数的顺序,Python 优雅地处理了这种表示上的小改变。
在某些情况下,namedtuple
函数增加了清晰度。在其他情况下,namedtuple
是从前缀到后缀的语法不必要的变化。
使用功能构造函数构建命名元组
我们可以使用三种方法构建namedtuple
实例。我们选择使用的技术通常取决于在对象构建时有多少额外的信息可用。
在前一节的示例中,我们展示了三种技术中的两种。我们将在这里强调设计考虑因素。它包括以下选择:
- 我们可以根据它们的位置提供参数值。当我们评估一个或多个表达式时,这种方法非常有效。我们在将
haversine()
函数应用于start
和end
点以创建Leg
对象时使用了它。
Leg(start, end, round(haversine(start, end),4))
- 我们可以使用
*argument
符号根据元组中的位置分配参数。当我们从另一个可迭代对象或现有元组中获取参数时,这种方法非常有效。我们在使用map()
将float()
函数应用于latitude
和longitude
值时使用了它。
Point(*map(float, pick_lat_lon(*row)))
- 我们可以使用显式的关键字赋值。虽然在前面的示例中没有使用,但我们可能会看到类似以下的东西,以使关系更加明显:
Point(longitude=float(row[0]), latitude=float(row[1]))
拥有多种创建namedtuple
实例的灵活性是有帮助的。这使我们更容易地转换数据结构。我们可以强调与阅读和理解应用程序相关的数据结构特性。有时,索引号 0 或 1 是需要强调的重要事项。其他时候,start
、end
和distance
的顺序是重要的。
通过使用元组族避免有状态的类
在之前的几个示例中,我们展示了Wrap-Unwrap设计模式的概念,它允许我们使用不可变的元组和namedtuples
。这种设计的重点是使用包装其他不可变对象的不可变对象,而不是可变的实例变量。
两组数据之间的常见统计相关度测量是 Spearman 等级相关度。这比较了两个变量的排名。我们将比较相对顺序,而不是尝试比较可能具有不同规模的值。有关更多信息,请访问en.wikipedia.org/wiki/Spearman%27s_rank_correlation_coefficient
。
计算 Spearman 等级相关性需要为每个观察分配一个排名值。我们似乎应该能够使用enumerate(sorted())
来做到这一点。给定两组可能相关的数据,我们可以将每组转换为一系列排名值,并计算相关度的度量。
我们将应用 Wrap-Unwrap 设计模式来做到这一点。我们将为了计算相关系数而将数据项与其排名wrap
起来。
在第三章中,函数、迭代器和生成器,我们展示了如何解析一个简单的数据集。我们将从该数据集中提取四个样本,如下所示:
from ch03_ex5 import series, head_map_filter, row_iter
with open("Anscombe.txt") as source:
**data = tuple(head_map_filter(row_iter(source)))
**series_I= tuple(series(0,data))
**series_II= tuple(series(1,data))
**series_III= tuple(series(2,data))
**series_IV= tuple(series(3,data))
这些系列中的每一个都是Pair
对象的tuple
。每个Pair
对象都有x
和y
属性。数据如下所示:
(Pair(x=10.0, y=8.04), Pair(x=8.0, y=6.95), …, Pair(x=5.0, y=5.68))
我们可以应用enumerate()
函数来创建值序列,如下所示:
y_rank= tuple(enumerate(sorted(series_I, key=lambda p: p.y)))
xy_rank= tuple(enumerate(sorted(y_rank, key=lambda rank: rank[1].x)))
第一步将创建简单的两元组,(0)
是排名数字,(1)
是原始的Pair
对象。由于数据是按每对中的y
值排序的,排名值将反映这种排序。
序列将如下所示:
((0, Pair(x=8.0, y=5.25)), (1, Pair(x=8.0, y=5.56)), ..., (10, Pair(x=19.0, y=12.5)))
第二步将把这两个元组再包装一层。我们将按照原始原始数据中的x值进行排序。第二个枚举将按照每对中的x值进行排序。
我们将创建更深层次的嵌套对象,应该如下所示:
((0, (0, Pair(x=4.0, y=4.26))), (1, (2, Pair(x=5.0, y=5.68))), ..., (10, (9, Pair(x=14.0, y=9.96))))
原则上,我们现在可以使用x和y的排名来计算两个变量之间的秩序相关。然而,提取表达式相当尴尬。对于数据集中的每个排名样本r
,我们必须比较r[0]
和r[1][0]
。
为了克服这些尴尬的引用,我们可以编写选择器函数如下:
x_rank = lambda ranked: ranked[0]
y_rank= lambda ranked: ranked[1][0]
raw = lambda ranked: ranked[1][1]
这样我们就可以使用x_rank(r)
和y_rank(r)
来计算相关性,使得引用值不那么尴尬。
我们已经两次wrapped
原始的Pair
对象,创建了带有排名值的新元组。我们避免了有状态的类定义来逐步创建复杂的数据结构。
为什么要创建深度嵌套的元组?答案很简单:懒惰。解包tuple
并构建新的平坦tuple
所需的处理只是耗时的。在现有的tuple
上“wrap”涉及的处理更少。放弃深度嵌套结构有一些令人信服的理由。
我们希望做两个改进;它们如下:
我们希望有一个更扁平的数据结构。使用嵌套的(x rank, (y rank, Pair()))
的tuple
并不感觉表达或简洁:
enumerate()
函数不能正确处理并列。如果两个观察结果具有相同的值,则它们应该获得相同的排名。一般规则是对相等的观察位置进行平均。序列[0.8, 1.2, 1.2, 2.3, 18]
应该具有排名值1, 2.5, 2.5, 4
。在位置 2 和 3 上的两个并列具有它们的共同排名的中点值2.5
。
分配统计排名
我们将把排名排序问题分为两部分。首先,我们将研究一个通用的高阶函数,我们可以用它来为Pair
对象的x或y值分配排名。然后,我们将使用这个函数来创建一个wrapper
,包含x和y的排名。这将避免深度嵌套的结构。
以下是一个将为数据集中的每个观察创建一个等级顺序的函数:
from collections import defaultdict
def rank(data, key=lambda obj:obj):**
**def rank_output(duplicates, key_iter, base=0):
**for k in key_iter:
**dups= len(duplicates[k])
**for value in duplicates[k]:
**yield (base+1+base+dups)/2, value
**base += dups
**def build_duplicates(duplicates, data_iter, key):
**for item in data_iter:
**duplicates[key(item)].append(item)
**return duplicates
**duplicates= build_duplicates(defaultdict(list), iter(data), key)
**return rank_output(duplicates, iter(sorted(duplicates)), 0)
我们创建排名顺序的函数依赖于创建一个类似于Counter
的对象,以发现重复值。我们不能使用简单的Counter
函数,因为它使用整个对象来创建一个集合。我们只想使用应用于每个对象的键函数。这使我们可以选择Pair
对象的x或y值。
在这个例子中,duplicates
集合是一个有状态的对象。我们本来可以编写一个适当的递归函数。然后我们需要进行尾递归优化,以允许处理大量数据的工作。我们在这里展示了该递归的优化版本。
作为对这种递归的提示,我们提供了build_duplicates()
的参数,以暴露状态作为参数值。显然,递归的基本情况是当data_iter
为空时。当data_iter
不为空时,从旧集合和头部next(data_iter)
构建一个新集合。build_duplicates()
的递归评估将处理data_iter
的尾部中的所有项目。
同样,我们可以编写两个适当的递归函数来发出分配了排名值的集合。同样,我们将该递归优化为嵌套的for
循环。为了清楚地说明我们如何计算排名值,我们包括了范围的低端(base+1
)和范围的高端(base+dups
),并取这两个值的中点。如果只有一个duplicate
,我们评估(2*base+2)/2
,这有一个通用解决方案的优势。
以下是我们如何测试这个确保它工作。
>>> list(rank([0.8, 1.2, 1.2, 2.3, 18]))
[(1.0, 0.8), (2.5, 1.2), (2.5, 1.2), (4.0, 2.3), (5.0, 18)]
>>> data= ((2, 0.8), (3, 1.2), (5, 1.2), (7, 2.3), (11, 18))
>>> list(rank(data, key=lambda x:x[1]))
[(1.0, (2, 0.8)), (2.5, (3, 1.2)), (2.5, (5, 1.2)), (4.0, (7, 2.3)), (5.0, (11, 18))]
示例数据包括两个相同的值。结果排名将位置 2 和 3 分开,以分配位置 2.5 给两个值。这是计算两组值之间的 Spearman 秩相关性的常见统计做法。
注意
rank()
函数涉及重新排列输入数据以发现重复值。如果我们想在每对中的x
和y
值上进行排名,我们需要两次重新排序数据。
重新包装而不是状态改变
我们有两种一般策略来进行包装;它们如下:
-
并行性:我们可以创建数据的两个副本并对每个副本进行排名。然后,我们需要重新组合这两个副本,以便最终结果包括两个排名。这可能有点尴尬,因为我们需要以某种方式合并两个可能按不同顺序排列的序列。
-
串行性:我们可以计算一个变量的排名,并将结果保存为一个包含原始原始数据的包装器。然后,我们可以对另一个变量上的这些包装数据进行排名。虽然这可能会创建一个复杂的结构,但我们可以稍微优化它,以创建最终结果的一个更平坦的包装器。
以下是我们如何创建一个对象,该对象使用基于y
值的排名顺序包装一对:
Ranked_Y= namedtuple("Ranked_Y", ("r_y", "raw",))
def rank_y(pairs):
**return (Ranked_Y(*row) for row in rank(pairs, lambda pair: pair.y))
我们定义了一个包含y
值排名加上原始(raw
)值的namedtuple
函数。我们的rank_y()
函数将通过使用一个lambda
选择每个pairs
对象的y
值来应用rank()
函数,从而创建这个元组的实例。然后我们创建了结果的两个元组的实例。
我们可以提供以下输入:
>>> data = (Pair(x=10.0, y=8.04), Pair(x=8.0, y=6.95), ..., Pair(x=5.0, y=5.68))
我们可以得到以下输出:
>>> list(rank_y(data))
[Ranked_Y(r_y=1.0, raw=Pair(x=4.0, y=4.26)), Ranked_Y(r_y=2.0, raw=Pair(x=7.0, y=4.82)), ... Ranked_Y(r_y=11.0, raw=Pair(x=12.0, y=10.84))]
原始的Pair
对象已经被包装在一个包含排名的新对象中。这还不够;我们需要再次包装一次,以创建一个既有 x 排名信息又有 y 排名信息的对象。
重新包装而不是状态改变
我们可以使用一个名为Ranked_X
的namedtuple
,其中包含两个属性:r_x
和ranked_y
。ranked_y
属性是Ranked_Y
的一个实例,它具有两个属性:r_y
和raw
。虽然这看起来很简单,但由于r_x
和r_y
值在一个平坦结构中不是简单的对等项,因此生成的对象令人讨厌。我们将引入一个稍微更复杂的包装过程,以产生一个稍微更简单的结果。
我们希望输出看起来像这样:
Ranked_XY= namedtuple("Ranked_XY", ("r_x", "r_y", "raw",))
我们将创建一个带有多个对等属性的平面namedtuple
。这种扩展通常比深度嵌套的结构更容易处理。在某些应用中,我们可能有许多转换。对于这个应用程序,我们只有两个转换:x 排名和 y 排名。我们将把这分为两个步骤。首先,我们将看一个类似之前所示的简单包装,然后是一个更一般的解包-重新包装。
以下是x-y
排名建立在 y 排名的基础上:
def rank_xy(pairs):
**return (Ranked_XY(r_x=r_x, r_y=rank_y_raw[0], raw=rank_y_raw[1])
**for r_x, rank_y_raw in rank(rank_y(pairs), lambda r: r.raw.x))
我们使用rank_y()
函数构建了Rank_Y
对象。然后,我们将rank()
函数应用于这些对象,以便按照原始的x
值对它们进行排序。第二个排名函数的结果将是两个元组,其中(0)
是x
排名,(1)
是Rank_Y
对象。我们从x
排名(r_x
)、y
排名(rank_y_raw[0]
)和原始对象(rank_y_raw[1]
)构建了一个Ranked_XY
对象。
在这第二个函数中,我们展示了一种更一般的方法来向tuple
添加数据。Ranked_XY
对象的构造显示了如何从数据中解包值并重新包装以创建第二个更完整的结构。这种方法通常可以用来向tuple
引入新变量。
以下是一些样本数据:
>>> data = (Pair(x=10.0, y=8.04), Pair(x=8.0, y=6.95), ..., Pair(x=5.0, y=5.68))
这使我们可以创建以下排名对象:
>>> list(rank_xy(data))
[Ranked_XY(r_x=1.0, r_y=1.0, raw=Pair(x=4.0, y=4.26)), Ranked_XY(r_x=2.0, r_y=3.0, raw=Pair(x=5.0, y=5.68)), ...,**
Ranked_XY(r_x=11.0, r_y=10.0, raw=Pair(x=14.0, y=9.96))]
一旦我们有了适当的x和y排名的数据,我们就可以计算 Spearman 秩相关值。我们可以从原始数据计算 Pearson 相关性。
我们的多排名方法涉及分解一个tuple
并构建一个新的、平坦的tuple
,其中包含我们需要的附加属性。当从源数据计算多个派生值时,我们经常需要这种设计。
计算 Spearman 秩相关
Spearman 秩相关是两个变量排名之间的比较。它巧妙地绕过了值的大小,甚至在关系不是线性的情况下,它通常也能找到相关性。公式如下:
这个公式告诉我们,我们将对观察值的所有对的排名差异进行求和,和
。这个 Python 版本依赖于
sum()
和len()
函数,如下所示:
def rank_corr(pairs):
**ranked= rank_xy(pairs)
**sum_d_2 = sum((r.r_x - r.r_y)**2 for r in ranked)
**n = len(pairs)
**return 1-6*sum_d_2/(n*(n**2-1))
我们为每对pair
创建了Rank_XY
对象。有了这个,我们就可以从这些对中减去r_x
和r_y
的值来比较它们的差异。然后我们可以对差异进行平方和求和。
关于统计学的一篇好文章将提供关于系数含义的详细指导。约为 0 的值意味着两个数据点系列的数据排名之间没有相关性。散点图显示了点的随机分布。约+1 或-1 的值表示两个值之间的强关系。图表显示了明显的线条或曲线。
以下是基于安斯库姆四重奏系列 I 的一个例子:
>>> data = (Pair(x=10.0, y=8.04), Pair(x=8.0, y=6.95), …, Pair(x=5.0, y=5.68))
>>> round(rank_corr( data ), 3)
0.818
对于这个特定的数据集,相关性很强。
在第四章中,处理集合,我们展示了如何计算 Pearson 相关系数。我们展示的corr()
函数与两个独立的值序列一起工作。我们可以将它与我们的Pair
对象序列一起使用,如下所示:
import ch04_ex4
def pearson_corr(pairs):
**X = tuple(p.x for p in pairs)
**Y = tuple(p.y for p in pairs)
**return ch04_ex4.corr(X, Y)
我们已经解开了Pair
对象,得到了我们可以与现有的corr()
函数一起使用的原始值。这提供了一个不同的相关系数。Pearson 值基于两个序列之间标准化值的比较。对于许多数据集,Pearson 和 Spearman 相关性之间的差异相对较小。然而,对于一些数据集,差异可能相当大。
要了解对探索性数据分析具有多个统计工具的重要性,请比较 Anscombe’s Quartet 中四组数据的 Spearman 和 Pearson 相关性。
多态和 Pythonic 模式匹配
一些函数式编程语言提供了处理静态类型函数定义的巧妙方法。问题在于,我们想要编写的许多函数对于数据类型来说是完全通用的。例如,我们的大多数统计函数对于integer
或floating-point
数字来说是相同的,只要除法返回的值是numbers.Real
的子类(例如Decimal
,Fraction
或float
)。为了使单个通用定义适用于多种数据类型,编译器使用了复杂的类型或模式匹配规则。
与静态类型的函数式语言的(可能)复杂特性不同,Python 通过动态选择基于正在使用的数据类型的操作符的最终实现来改变问题。这意味着编译器不会验证我们的函数是否期望和产生正确的数据类型。我们通常依赖单元测试来解决这个问题。
在 Python 中,我们实际上是在编写通用定义,因为代码不绑定到任何特定的数据类型。Python 运行时将使用一组简单的匹配规则来定位适当的操作。语言参考手册中的3.3.7 强制规则部分和库中的numbers
模块提供了关于操作到特殊方法名称映射的详细信息。
在罕见的情况下,我们可能需要根据数据元素的类型有不同的行为。我们有两种方法来解决这个问题,它们如下:
-
我们可以使用
isinstance()
函数来区分不同的情况。 -
我们可以创建自己的
numbers.Number
或tuple
的子类,并实现适当的多态特殊方法名称。
在某些情况下,我们实际上需要两者都做,以便包含适当的数据类型转换。
当我们回顾前一节中的排名示例时,我们紧密地与将排名应用于简单对的想法联系在一起。虽然这是 Spearman 相关性的定义方式,但我们可能有一个多变量数据集,并且需要对所有变量进行排名相关性。
我们需要做的第一件事是概括我们对排名信息的想法。以下是一个处理排名元组和原始数据元组的namedtuple
:
Rank_Data = namedtuple("Rank_Data", ("rank_seq", "raw"))
对于任何特定的Rank_Data
,比如r
,我们可以使用r.rank_seq[0]
来获取特定的排名,使用r.raw
来获取原始观察值。
我们将为我们的排名函数添加一些语法糖。在许多以前的例子中,我们要求要么是一个可迭代对象,要么是一个集合。for
语句在处理任一种情况时都很优雅。但是,我们并不总是使用for
语句,对于一些函数,我们不得不明确使用iter()
来使一个集合成为一个iterable
。我们可以通过简单的isinstance()
检查来处理这种情况,如下面的代码片段所示:
def some_function(seq_or_iter):
**if not isinstance(seq_or_iter,collections.abc.Iterator):
**yield from some_function(iter(seq_or_iter), key)
**return
**# Do the real work of the function using the iterable
我们已经包含了一个类型检查,以处理两个集合之间的小差异,它不适用于next()
和一个支持next()
的可迭代对象。
在我们的排名函数的上下文中,我们将使用这种设计模式的变体:
def rank_data(seq_or_iter, key=lambda obj:obj):
**# Not a sequence? Materialize a sequence object
**if isinstance(seq_or_iter, collections.abc.Iterator):
**yield from rank_data(tuple(seq_or_iter), key)
**data = seq_or_iter
**head= seq_or_iter[0]
**# Convert to Rank_Data and process.
**if not isinstance(head, Rank_Data):
**ranked= tuple(Rank_Data((),d) for d in data)
**for r, rd in rerank(ranked, key):
**yield Rank_Data(rd.rank_seq+(r,), rd.raw)
**return
**# Collection of Rank_Data is what we prefer.
**for r, rd in rerank(data, key):
**yield Rank_Data(rd.rank_seq+(r,), rd.raw)
我们已经将排名分解为三种不同类型数据的三种情况。当不同类型的数据不是共同超类的多态子类时,我们被迫这样做。以下是三种情况:
-
给定一个(没有可用的
__getitem__()
方法的)可迭代对象,我们将实现一个我们可以使用的tuple
。 -
给定一组某种未知类型的数据,我们将未知对象包装成
Rank_Data
元组。 -
最后,给定一组
Rank_Data
元组,我们将在每个Rank_Data
容器内部的排名元组中添加另一个排名。
这依赖于一个rerank()
函数,它在Rank_Data
元组中插入并返回另一个排名。这将从原始数据值的复杂记录中构建一个单独的排名集合。rerank()
函数的设计与之前显示的rank()
函数的示例略有不同。
这个算法的这个版本使用排序而不是在对象中创建分组,比如Counter
对象:
def rerank(rank_data_collection, key):
**sorted_iter= iter(sorted( rank_data_collection, key=lambda obj: key(obj.raw)))
**head = next(sorted_iter)
**yield from ranker(sorted_iter, 0, [head], key)
我们首先从头部和数据迭代器重新组装了一个可排序的集合。在使用的上下文中,我们可以说这是一个坏主意。
这个函数依赖于另外两个函数。它们可以在rerank()
的主体内声明。我们将分开展示它们。以下是 ranker,它接受一个可迭代对象,一个基本排名数字,一个具有相同排名的值的集合,以及一个键:
def ranker(sorted_iter, base, same_rank_seq, key):
**"""Rank values from a sorted_iter using a base rank value.
**If the next value's key matches same_rank_seq, accumulate those.
**If the next value's key is different, accumulate same rank values
**and start accumulating a new sequence.
**"""
**try:
**value= next(sorted_iter)
**except StopIteration:
**dups= len(same_rank_seq)
**yield from yield_sequence((base+1+base+dups)/2, iter(same_rank_seq))
**return
**if key(value.raw) == key(same_rank_seq[0].raw):
**yield from ranker(sorted_iter, base, same_rank_seq+[value], key)
**else:
**dups= len(same_rank_seq)
**yield from yield_sequence( (base+1+base+dups)/2, iter(same_rank_seq))
**yield from ranker(sorted_iter, base+dups, [value], key)
我们从已排序值的iterable
集合中提取了下一个项目。如果这失败了,就没有下一个项目,我们需要发出same_rank_seq
序列中相等值项目的最终集合。如果这成功了,那么我们需要使用key()
函数来查看下一个项目,即一个值,是否与相同排名项目的集合具有相同的键。如果键相同,则整体值被递归地定义;重新排名是其余的排序项目,排名的相同基值,一个更大的same_rank
项目集合,以及相同的key()
函数。
如果下一个项目的键与相等值项目的序列不匹配,则结果是相等值项目的序列。这将在对其余排序项目进行重新排名之后,一个基值增加了相等值项目的数量,一个只有新值的相同排名项目的新列表,以及相同的key
提取函数。
这取决于yield_sequence()
函数,其如下所示:
def yield_sequence(rank, same_rank_iter):
**head= next(same_rank_iter)
**yield rank, head
**yield from yield_sequence(rank, same_rank_iter)
我们以一种强调递归定义的方式编写了这个。我们实际上不需要提取头部,发出它,然后递归发出其余的项目。虽然单个for
语句可能更短,但有时更清晰地强调已经优化为for
循环的递归结构。
以下是使用此函数对数据进行排名(和重新排名)的一些示例。我们将从一个简单的标量值集合开始:
>>> scalars= [0.8, 1.2, 1.2, 2.3, 18]
>>> list(ranker(scalars))
[Rank_Data(rank_seq=(1.0,), raw=0.8), Rank_Data(rank_seq=(2.5,), raw=1.2), Rank_Data(rank_seq=(2.5,), raw=1.2), Rank_Data(rank_seq=(4.0,), raw=2.3), Rank_Data(rank_seq=(5.0,), raw=18)]
每个值都成为Rank_Data
对象的raw
属性。
当我们处理稍微复杂的对象时,我们也可以有多个排名。以下是两个元组的序列:
>>> pairs= ((2, 0.8), (3, 1.2), (5, 1.2), (7, 2.3), (11, 18))
>>> rank_x= tuple(ranker(pairs, key=lambda x:x[0] ))
>>> rank_x
(Rank_Data(rank_seq=(1.0,), raw=(2, 0.8)), Rank_Data(rank_seq=(2.0,), raw=(3, 1.2)), Rank_Data(rank_seq=(3.0,), raw=(5, 1.2)), Rank_Data(rank_seq=(4.0,), raw=(7, 2.3)), Rank_Data(rank_seq=(5.0,), raw=(11, 18)))
>>> rank_xy= (ranker(rank_x, key=lambda x:x[1] ))
>>> tuple(rank_xy)
(Rank_Data(rank_seq=(1.0, 1.0), raw=(2, 0.8)),Rank_Data(rank_seq=(2.0, 2.5), raw=(3, 1.2)), Rank_Data(rank_seq=(3.0, 2.5), raw=(5, 1.2)), Rank_Data(rank_seq=(4.0, 4.0), raw=(7, 2.3)), Rank_Data(rank_seq=(5.0, 5.0), raw=(11, 18)))
在这里,我们定义了一组对。然后,我们对这两个元组进行了排名,将Rank_Data
对象的序列分配给rank_x
变量。然后,我们对这个Rank_Data
对象的集合进行了排名,创建了第二个排名值,并将结果分配给rank_xy
变量。
生成的序列可以用于稍微修改的rank_corr()
函数,以计算Rank_Data
对象的rank_seq
属性中任何可用值的排名相关性。我们将把这个修改留给读者作为练习。
总结
在本章中,我们探讨了使用namedtuple
对象实现更复杂的数据结构的不同方法。namedtuple
的基本特性与函数式设计非常匹配。它们可以通过创建函数创建,并且可以按位置和名称访问。
我们研究了如何使用不可变的namedtuples
而不是有状态的对象定义。核心技术是将对象包装在不可变的tuple
中,以提供额外的属性值。
我们还研究了如何处理 Python 中的多种数据类型。对于大多数算术运算,Python 的内部方法分派会找到合适的实现。然而,对于处理集合,我们可能希望稍微不同地处理迭代器和序列。
在接下来的两章中,我们将看一下itertools
模块。这个库
模块提供了许多函数,帮助我们以复杂的方式处理迭代器。其中许多工具都是高阶函数的例子。它们可以帮助使函数式设计保持简洁和表达力。