学习Python中的itertools库和 functools 库以及Map-reduce 设计模式

149 阅读13分钟

Python是一种神奇的编程语言。它很可能是你开发机器学习或数据科学应用程序的首选。Python很有趣,因为它是一种多范式的编程语言,可以用于面向对象和命令式编程。它有一个简单的语法,易于阅读和理解。

在计算机科学和数学领域,使用函数式编程风格可以更容易、更自然地表达许多问题的解决方案。在本教程中,我们将讨论 Python 对函数式编程范式的支持,以及帮助你用这种风格编程的 Python 的类和模块。

完成本教程后,你将知道。

  • 函数式编程的基本思想
  • itertools
  • functools
  • Map-reduce 设计模式及其在 Python 中的可能实现

让我们开始吧。

教程概述

本教程分为5个部分;它们是:

  1. 函数式编程的思想
  2. 高阶函数。筛选、映射和减少
  3. 迭代工具
  4. 函数工具
  5. Map-reduce模式

函数式编程的理念

如果你有编程经验,很可能你学过命令式编程。它是用语句和操作变量建立的。函数式编程是一种声明式范式。它与命令式编程不同,命令式编程是通过应用和组合函数来构建程序。这里的函数应该更接近数学函数的定义,没有副作用,或者简单地说,没有对外部变量的访问,当你用相同的参数调用它们时,它们总是给你相同的结果。

函数式编程的好处是使你的程序不容易出错。没有了副作用,它更容易预测,更容易看到结果。我们也不需要担心程序的一个部分会干扰另一个部分。

许多库采用了函数式编程范式。比如下面这个使用pandas和pandas-datareader的例子。

import pandas_datareader as pdr
import pandas_datareader.wb

df = (
    pdr.wb
    .download(indicator="SP.POP.TOTL", country="all", start=2000, end=2020)
    .reset_index()
    .filter(["country", "SP.POP.TOTL"])
    .groupby("country")
    .mean()
)
print(df)

这给了你下面的输出。

SP.POP.TOTL
country                                  
Afghanistan                  2.976380e+07
Africa Eastern and Southern  5.257466e+08
Africa Western and Central   3.550782e+08
Albania                      2.943192e+06
Algeria                      3.658167e+07
...                                   ...
West Bank and Gaza           3.806576e+06
World                        6.930446e+09
Yemen, Rep.                  2.334172e+07
Zambia                       1.393321e+07
Zimbabwe                     1.299188e+07

pandas-datareader是一个有用的库,它可以帮助你实时地从互联网上下载数据。上面的例子是要从世界银行下载人口数据。其结果是一个以国家和年份为索引的pandas数据框架,以及一个名为 "SP.POP.TOTL "的人口单列。然后我们一步一步地操作这个数据框架,最后,我们找到所有国家历年的平均人口。

我们可以这样写,因为在pandas中,大多数关于数据框架的函数不是在改变数据框架,而是要产生一个新的数据框架来反映函数的结果。我们称这种行为为不可改变的,因为输入的数据框架从未改变。其结果是,我们可以将函数连锁起来,一步一步地操作数据框架。如果我们必须使用命令式编程的风格来分解它,上述程序与以下程序相同。

import pandas_datareader as pdr
import pandas_datareader.wb

df = pdr.wb.download(indicator="SP.POP.TOTL", country="all", start=2000, end=2020)
df = df.reset_index()
df = df.filter(["country", "SP.POP.TOTL"])
groups = df.groupby("country")
df = groups.mean()

print(df)

高阶函数。筛选,映射,和减少

Python不是一种严格的函数式编程语言。但是用函数式写Python是很容易的。在迭代器上有三个基本函数,可以让我们以非常简单的方式写出强大的程序:过滤器、地图和还原。

过滤是选择迭代表中的一些元素,比如说列表。Map是对元素进行逐一的转换。最后,reduce是将整个可迭代的元素转换成不同的形式,比如所有元素的总和,或者将一个列表中的子串串联成一个更长的字符串。为了说明它们的用途,让我们考虑一个简单的任务。给出一个Apache网络服务器的日志文件,找出发送错误代码为404的请求最多的IP地址。如果你不知道Apache网络服务器的日志文件是什么样子的,下面是一个例子。

89.170.74.95 - - [17/May/2015:16:05:27 +0000] "HEAD /projects/xdotool/ HTTP/1.1" 200 - "-" "Mozilla/5.0 (Windows NT 6.1; WOW64; rv:24.0) Gecko/20100101 Firefox/24.0" 
95.82.59.254 - - [19/May/2015:03:05:19 +0000] "GET /images/jordan-80.png HTTP/1.1" 200 6146 "http://www.semicomplete.com/articles/dynamic-dns-with-dhcp/" "Mozilla/5.0 (Windows NT 6.1; rv:27.0) Gecko/20100101 Firefox/27.0"
155.140.133.248 - - [19/May/2015:06:05:34 +0000] "GET /images/jordan-80.png HTTP/1.1" 200 6146 "http://www.semicomplete.com/blog/geekery/debugging-java-performance.html" "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)"
68.180.224.225 - - [20/May/2015:20:05:02 +0000] "GET /blog/tags/documentation HTTP/1.1" 200 12091 "-" "Mozilla/5.0 (compatible; Yahoo! Slurp; http://help.yahoo.com/help/us/ysearch/slurp)"

上面的内容来自一个更大的文件,位于这里。这些是日志中的几行。每一行都以客户端(即浏览器)的IP地址开始,"HTTP/1.1 "后面的代码是响应状态代码。通常情况下,如果请求得到满足,它就是200。但如果浏览器请求的东西在服务器中不存在,代码将是404。要找到对应于最多404请求的IP地址,我们可以简单地逐行扫描日志文件,找到那些有404的请求,然后计算IP地址,找到出现次数最多的那个。

在Python代码中,我们可以做以下工作。首先,我们看到我们如何读取日志文件,并从一行中提取IP地址和状态代码。

import urllib.request
import re

# Read the log file, split into lines
logurl = "https://raw.githubusercontent.com/elastic/examples/master/Common%20Data%20Formats/apache_logs/apache_logs"
logfile = urllib.request.urlopen(logurl).read().decode("utf8")
lines = logfile.splitlines()

# using regular expression to extract IP address and status code from a line
def ip_and_code(logline):
    m = re.match(r'([\d\.]+) .*? \[.*?\] ".*?" (\d+) ', logline)
    return (m.group(1), m.group(2))

print(ip_and_code(lines[0]))

然后我们可以使用几个map()和filter()以及其他一些函数来找到IP地址。

...

import collections

def is404(pair):
    return pair[1] == "404"
def getIP(pair):
    return pair[0]
def count_ip(count_item):
    ip, count = count_item
    return (count, ip)

# transform each line into (IP address, status code) pair
ipcodepairs = map(ip_and_code, lines)
# keep only those with status code 404
pairs404 = filter(is404, ipcodepairs)
# extract the IP address part from each pair
ip404 = map(getIP, pairs404)
# count the occurrences, the result is a dictionary of IP addresses map to the count
ipcount = collections.Counter(ip404)
# convert the (IP address, count) tuple into (count, IP address) order
countip = map(count_ip, ipcount.items())
# find the tuple with the maximum on the count
print(max(countip))

这里我们没有使用reduce()函数,因为我们内置了一些专门的reduce操作,比如max() 。但事实上,我们可以用列表理解的符号来做一个更简单的程序。

...

ipcodepairs = [ip_and_code(x) for x in lines]
ip404 = [ip for ip,code in ipcodepairs if code=="404"]
ipcount = collections.Counter(ip404)
countip = [(count,ip) for ip,count in ipcount.items()]
print(max(countip))

或者甚至把它写成一个单一的语句(但可读性较差)。

import urllib.request
import re
import collections

logurl = "https://raw.githubusercontent.com/elastic/examples/master/Common%20Data%20Formats/apache_logs/apache_logs"
print(
    max(
        [(count,ip) for ip,count in
            collections.Counter([
                ip for ip, code in
                [ip_and_code(x) for x in
                     urllib.request.urlopen(logurl)
                     .read()
                     .decode("utf8")
                     .splitlines()
                ]
                if code=="404"
            ]).items()
        ]
    )
)

Python中的Itertools

上面关于 filter、map 和 reduce 的例子说明了 Python 中遍地都是迭代变量。这包括列表、图元、字典、集合,甚至是生成器,因为所有这些都可以使用 for-loop 来迭代。在 Python 中,我们有一个名为itertools 的模块,它带来了更多的函数来操作(但不是变异)迭代变量。来自Python的官方文档

该模块标准化了一组核心的快速、高效的内存工具,这些工具本身或结合起来都很有用。它们共同组成了一个 "迭代器代数",使得用纯 Python 简洁有效地构建专门的工具成为可能。

我们将在本教程中讨论itertools 的几个功能。在尝试下面给出的例子时,一定要把itertoolsoperator 作为导入。

import itertools
import operator

无限迭代器

无限迭代器可以帮助你创建无限长的序列,如下图所示。

构造+例子输出
count()
start = 0
step = 100
for i in itertools.count(start, step):
    print(i)
    if i>=1000:
        break
0
100
200
300
400
500
600
700
800
900
1000
cycle()
counter = 0
cyclic_list = [1, 2, 3, 4, 5]
for i in itertools.cycle(cyclic_list):
    print(i)
    counter = counter+1
    if counter>10:
        break
1
2
3
4
5
1
2
3
4
5
1
repeat()
for i in itertools.repeat(3,5):
    print(i)
3
3
3
3
3

组合式迭代器

你可以用这些迭代器创建排列、组合等。

构造 + 例子输出
product()
x = [1, 2, 3]
y = ['A', 'B']
print(list(itertools.product(x, y)))
[(1, 'A'), (1, 'B'), (2, 'A'), (2, 'B'), 
 (3, 'A'), (3, 'B')]
permutations()
x = [1, 2, 3]
print(list(itertools.permutations(x)))
[(1, 2, 3), (1, 3, 2), (2, 1, 3), 
 (2, 3, 1), (3, 1, 2), (3, 2, 1)]
combinations()
y = ['A', 'B', 'C', 'D']
print(list(itertools.combinations(y, 3)))
[('A', 'B', 'C'), ('A', 'B', 'D'), 
 ('A', 'C', 'D'), ('B', 'C', 'D')]
combinations_with_replacement()
z = ['A', 'B', 'C']
print(list(itertools.combinations_with_replacement(z, 2)))
[('A', 'A'), ('A', 'B'), ('A', 'C'), 
 ('B', 'B'), ('B', 'C'), ('C', 'C')]

更多有用的迭代器

还有其他的迭代器,它们在作为参数传递的两个列表中较短的一个结束时停止。 下面将介绍其中的一些。这并不是一个详尽的列表,你可以在这里看到完整的列表

Accumulate()

自动创建一个迭代器,累积给定运算符或函数的结果,并返回结果。你可以从Python的operator 库中选择一个运算符,或者编写你自己的自定义运算符。

# Custom operator
def my_operator(a, b):
    return a+b if a>5 else a-b
    
x = [2, 3, 4, -6]
mul_result = itertools.accumulate(x, operator.mul)
print("After mul operator", list(mul_result))
pow_result = itertools.accumulate(x, operator.pow)
print("After pow operator", list(pow_result))
my_operator_result = itertools.accumulate(x, my_operator)
print("After customized my_operator", list(my_operator_result))
After mul operator [2, 6, 24, -144]
After pow operator [2, 8, 4096, 2.117582368135751e-22]
After customized my_operator [2, -1, -5, 1]

Starmap()

对成对的项目应用相同的运算符。

pair_list = [(1, 2), (4, 0.5), (5, 7), (100, 10)]

starmap_add_result = itertools.starmap(operator.add, pair_list)
print("Starmap add result: ", list(starmap_add_result))

x1 = [2, 3, 4, -6]
x2 = [4, 3, 2, 1] 

starmap_mul_result = itertools.starmap(operator.mul, zip(x1, x2))
print("Starmap mul result: ", list(starmap_mul_result))
Starmap add result:  [3, 4.5, 12, 110]
Starmap mul result:  [8, 9, 8, -6]

filterfalse()

根据一个特定的标准,过滤掉数据。

my_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
even_result = itertools.filterfalse(lambda x: x%2, my_list)
small_terms = itertools.filterfalse(lambda x: x>=5, my_list)                               
print('Even result:', list(even_result))
print('Less than 5:', list(small_terms))
Even result: [2, 4, 6, 8, 10]
Less than 5: [1, 2, 3, 4]

Python中的函数工具

在大多数编程语言中,将函数作为参数传递或一个函数返回另一个函数可能会让人困惑或难以操作。Python 包含了functools 库,它使这些函数的工作变得容易。来自 Python 的官方文档functools

functools 模块是用于高阶函数:作用于或返回其他函数的函数。一般来说,任何可调用的对象都可以被当作一个函数。

这里我们解释一下这个库的几个不错的功能。你可以在这里看一下functools 的完整函数列表

使用lru_cache

在命令式编程语言中,递归是非常昂贵的。每次调用一个函数,都要对其进行评估,即使它是用同一组参数调用的。在Python中,lru_cache 是一个装饰器,可以用来缓存函数评估的结果。当该函数以相同的参数集再次被调用时,将使用存储的结果,避免了与递归相关的额外开销。

让我们看一下下面的例子。我们有相同的计算第n个斐波那契数的实现,有和没有lru_cache 。我们可以看到,fib(30)有31次函数评估,正如我们所期望的那样,因为lru_cachefib() 函数只在n=0,1,2...30时被调用,结果被存储在内存中,以后再使用。这与fib_slow(30) ,2692537次的评估相比,要少得多。

import functools
@functools.lru_cache
def fib(n):
    global count
    count = count + 1
    return fib(n-2) + fib(n-1) if n>1 else 1

def fib_slow(n):
    global slow_count
    slow_count = slow_count + 1
    return fib_slow(n-2) + fib_slow(n-1) if n>1 else 1

count = 0
slow_count = 0
fib(30)
fib_slow(30)

print('With lru_cache total function evaluations: ', count)
print('Without lru_cache total function evaluations: ', slow_count)
With lru_cache total function evaluations:  31
Without lru_cache total function evaluations:  2692537

值得注意的是,当你在Jupyter笔记本中实验机器学习问题时,lru_cache 装饰器特别有用。如果你有一个从互联网上下载数据的函数,用lru_cache 来包装它,可以将你的下载保留在内存中,避免再次下载同一个文件,即使你多次调用下载函数。

使用reduce()

Reduce类似于itertools.accumulate() 。它对一个列表的元素重复应用一个函数,并返回结果。下面是几个带注释的例子来解释这个函数的工作原理。

# Evaluates ((1+2)+3)+4
list_sum = functools.reduce(operator.add, [1, 2, 3, 4])
print(list_sum)

# Evaluates (2^3)^4
list_pow = functools.reduce(operator.pow, [2, 3, 4])
print(list_pow)
10
4096

reduce() 函数可以接受任何 "运算符",也可以选择接受一个初始值。例如,前面例子中的collections.Counter 函数可以这样实现

import functools

def addcount(counter, element):
    if element not in counter:
        counter[element] = 1
    else:
        counter[element] += 1
    return counter

items = ["a", "b", "a", "c", "d", "c", "b", "a"]

counts = functools.reduce(addcount, items, {})
print(counts)
{'a': 3, 'b': 2, 'c': 2, 'd': 1}

使用partial()

有些情况下,你有一个接受多个参数的函数,而且它的一些参数会反复出现。函数partial() ,返回同一个函数的新版本,参数数量减少。

例如,如果你必须重复计算2的幂,你可以创建一个新版本的numpy的power() ,如下图所示。

import numpy

power_2 = functools.partial(np.power, 2)
print('2^4 =', power_2(4))
print('2^6 =', power_2(6))
2^4 = 16
2^6 = 64

Map-Reduce模式

我们在上一节中提到了过滤器、地图和还原函数作为高阶函数。使用map-reduce设计模式确实可以帮助我们轻松做出一个高度可扩展的程序。map-reduce模式是许多处理列表或对象集合的计算类型的抽象表示。map 阶段接收输入的集合,并将其映射到一个中间表示。reduce 阶段接受这个中间表示,并从中计算出一个单一的输出。这种设计模式在函数式编程语言中非常流行。Python也提供了有效实现这种设计模式的结构。

Python中的Map-Reduce

作为map-reduce设计模式的说明,让我们举一个简单的例子。假设我们想计算一个列表中能被3整除的数字。我们将使用lambda 来定义一个匿名函数,并根据列表中的所有项目是否通过我们的可除性测试,用它来map() 所有项目为 1 或 0。函数map() 的参数是一个函数和一个迭代器。接下来我们将使用reduce() 来累积总的结果。

# All numbers from 1 to 20
input_list = list(range(20))
# Use map to see which numbers are divisible by 3
bool_list = map(lambda x: 1 if x%3==0 else 0, input_list)
# Convert map object to list
bool_list = list(bool_list)
print('bool_list =', bool_list)

total_divisible_3 = functools.reduce(operator.add, bool_list)
print('Total items divisible by 3 = ', total_divisible_3)
bool_list = [1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0]
Total items divisible by 3 =  7

前面的例子虽然非常简单,但说明了在Python中实现map-reduce 设计模式是多么容易。你可以用Python中令人惊讶的简单的构造来解决复杂而冗长的问题。

摘要

在本教程中,你发现了支持函数式编程的 Python 特性

具体来说,你学到了

  • 在 Python 中使用返回有限或无限序列的迭代器itertools
  • 支持的高阶函数functools
  • map-reduce 设计模式在 Python 中的实现