Skip to content

第 11 章 数据分析工具箱

本章内容提要:

  • 辅助函数与工具
  • 作用域与求值计算
  • 异常捕获
  • 函数式编程
  • 生成器与装饰器
  • 正则表达式

本章的涉及的内容非常广泛,相对本书其他章节而言较为驳杂,主旨是介绍数据分析可能会利用上的工具函数和方法,也包含 Python 高级编程相关的理论知识。

11.1 辅助函数与工具

11.1.1 序列解包

在 Python 中,多个赋值操作可以同时进行,这可以通过元组实现:

x, y, z = 1, 2, 3
print(x, y, z)

利用该方法可以交换多个变量:

x, y = y, x

这精简了下面代码:

temp = x
x = y
y = temp

这种特性叫做序列解包,将多个值的序列解开,然后放到左侧的变量序列中。当函数或者方法返回元组(或其他可迭代对象)时,这个操作尤为有用。

def func():
    a = 1
    b = 2
    c = 3
    return a, b, c

# 序列解包操作:
# 将函数结果直接赋值到多个变量中
# 按顺序一一对应
# d <- a
# e <- b
# f <- c
d, e, f = func()

上述序列解包操作简化了下面的代码:

# 获取一个元组结果
tup_res = func()
# 分别赋值
d = tup_res[0]
e = tup_res[1]
f = tup_res[2]

注意,等号两侧元素数量要一致,否则会报错。

11.1.2 断言

与其让程序在晚些时候崩溃,不如在错误条件出现时直接让它崩溃,这样可以尽早锁定程序中的错误,节省时间和调试成本。这种情况下,assert 语句可以派上用场。

assert 会检查一个表达式,如果返回逻辑值 False,就会生成断言错误。assert 也可以带第二个参数用来详细地描述错误。

In [1]: a = 10
In [2]: assert a > 10
---------------------------------------------------------------------
AssertionError                      Traceback (most recent call last)
<ipython-input-2-92ef20669630> in <module>
----> 1 assert a > 10

AssertionError:

In [3]: assert a > 10, 'a 不大于 10'
---------------------------------------------------------------------
AssertionError                      Traceback (most recent call last)
<ipython-input-3-d19bab11044a> in <module>
----> 1 assert a > 10, 'a 不大于 10'

AssertionError: a 不大于 10

程序员经常将断言放到函数的开始部分用于检查输入是否合法,以及在函数调用后使用以检查输出是否合法。

11.1.3 常用字符串方法

本小节主要通过代码示例简要介绍常用的字符串方法,其中一些函数在前面章节中有过介绍和使用。

In [4]: print(", ".join(["spam", "eggs", "ham"]))  # 字符串拼接
spam, eggs, ham
In [5]: print("Hello ME".replace("ME", "wrold"))   # 字符串替换
Hello wrold
In [6]: print("This is a sentence.".startswith("This"))  # 判断字符串是否以 This 起始
True
In [7]: print("This is a sentence.".endswith("sentence."))  # 判断字符串是否以 sentence. 结束
True
In [8]: print("This is a sentence.".upper())  # 字母全部转换为大写
THIS IS A SENTENCE.
In [9]: print("THIS IS A SENTENCE.".lower())  # 字母全部转换为小写
this is a sentence.
In [10]: print("spam, eggs, ham".split())  # 字符串拆分
['spam,', 'eggs,', 'ham']

11.2 作用域与求值计算

11.2.1 作用域

我们已经知道,在函数内部命名的变量不会影响函数外部的变量,也就是说,每个变量都有自己作用的范围。这不仅仅适用于函数,也适用于其他 Python 对象。

In [11]: x = 1
In [12]: scope = vars()
In [13]: scope['x']
Out[13]: 1
In [14]: scope['x'] += 2
In [15]: scope['x']
Out[15]: 3
In [16]: type(scope)
Out[16]: dict

上述代码在执行 x = 1 赋值语句后,符号 x 引用到值 1。这就像字典一样,键引用值。当然,变量和所对应的值用的是一个不可见的字典,实际上这已经很接近真实情况,内建的 vars() 函数可以返回这个字典。这个字典就叫做命名空间或作用域。除了全局作用域外,每个函数调用都会创建一个新的作用域。

由于同名变量的存在产生了屏蔽问题:当局部变量名和全局变量名同名时,前者会屏蔽后者。这种情况下如果想要使用全局变量,可以使用 globals() 函数。globals 函数返回全局变量字典,而 locals() 返回局部变量的字典。

In [26]: a = 10
In [27]: def masking():
    ...:     a = 1
    ...:     print(a)
    ...:     los = locals()
    ...:     glo = globals()
    ...:     print(los['a'])
    ...:     print(glo['a'])
    ...:

In [28]: masking()
1
1
10

如果想要将局部变量 x 声明为全局变量,使用代码 global x。

In [36]: a = 10
In [37]: def change_global():
    ...:     global a
    ...:     a = 5
    ...:

In [38]: change_global()
In [39]: a
Out[39]: 5

11.2.2 使用 exec() 和 eval() 执行计算

有时候,我们想要从字符串中创建 Python 代码,这可以用于动态编程。exec() 和 eval() 函数提供了这方面的支持。

In [40]: exec("print('Hello world')")
Hello world

它可能会干扰命名空间(作用域)。

In [41]: from math import sqrt
In [42]: exec('sqrt = 1')
In [43]: sqrt(4)
---------------------------------------------------------------------
TypeError                           Traceback (most recent call last)
<ipython-input-43-317e033d29d5> in <module>
----> 1 sqrt(4)

TypeError: 'int' object is not callable

上述代码报错的原因在于我们在全局空间中创建了一个变量 sqrt 并赋值 1,它屏蔽了先前从 math 模块中导入的 sqrt 函数。

我们可以指定命名空间来避免上述问题。

In [44]: scope = {}
In [45]: exec('sqrt = 1', scope)
In [46]: scope['sqrt']
    ...: 1
Out[46]: 1

exec() 函数会执行一系列 Python 语句,而 eval() 函数计算以字符串形式书写的表达式,并返回结果值。

In [47]: eval('sqrt = 1')
Traceback (most recent call last):

  File "/home/shixiang/miniconda3/lib/python3.7/site-packages/IPython/core/interactiveshell.py", line 3326, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)

  File "<ipython-input-47-e7321eeaf096>", line 1, in <module>
    eval('sqrt = 1')

  File "<string>", line 1
    sqrt = 1
         ^
SyntaxError: invalid syntax

In [48]: eval('sqrt + 3')
Out[48]: 4

eval() 函数也可以使用命名空间。

In [48]: eval('sqrt + 3')
Out[48]: 4
In [49]: scope = {}
In [50]: scope['x'] = 3
In [51]: scope['y'] = 5
In [52]: eval('x * y', scope)
Out[52]: 15

11.3 异常的捕获和处理

11.3.1 捕获异常

在第 4 章中我们其实已经学习过如何捕获异常,即使用 try…finally 读/写文件,确保无论发生什么都要对文件进行关闭操作。这里本书会更加详细地介绍异常、异常捕获和处理的方法。

异常是由于不同的原因产生的出乎意料的结果,有以下几个常见类型:

  • ImportError - 导入失败
  • IndexError - 索引超出序列范围
  • NameError - 使用了未知的变量
  • SyntaxError - 代码不能被正确解析
  • TypeError - 函数参数输入了错误的数据类型
  • ValueError - 函数调用正常,但返回值有问题

当 Python 程序抛出这些异常后,我们很容易就能够通过异常类型理解其原因,困难之处在于如何锁定异常发生的位置以及对异常进行处理。而对于小型程序和常见的数据分析任务,锁定异常的发生地点也通常比较容易,一般通过逐行输入代码运行即可找到。我们下面聚焦于异常的处理。

处理异常的基本语句是 try/except,我们将可能产生异常的代码放入 try 语句块中,而将处理语句放入 except 语句块中。如果运行代码时真的产生异常,Python 会停止执行错误代码块,而跳转到执行 except 语句块。

下面看一个简单的例子:

try:
    num1 = 7
    num2 = 0
    print(num1 / num2)
    print("完成计算!")
except ZeroDivisionError:
    print("因为除以 0 导致错误!")

try 语句块中可以有多条不同的 except 语句用于处理不同的异常情况。另外,多种异常也可以通过括号放入到单个 except 语句中:

try:
    var = 10
    print(var + "hello")
    print(var / 2)
except ZeroDivisionError:
    print("除数为0!")
except (ValueError, TypeError):
    print("错误发生了!")

如果一条 except 语句没有指明任何的异常类型,那么所有的错误都将被捕获。请读者尽量少用这样的操作,因为会捕获到意想不到的错误并导致程序处理失败。

try:
    wd = "hello world"
    print(wd / 0)
except:
    print("发生了一个错误")

我们可以使用 finally 语句保证无论发生什么错误,都会运行一些代码,如正确关闭文件。finally 语句放置在 try/except 语句之后,无论前面执行了 try 语句块的代码还是执行了 except 语句的代码,finally 语句总是会被运行。

try:
    print("Hello World!")
    print(1 / 0)
except: ZeroDivisionError:
    print("不能被0整除!")
finally:
    print("无论上面干啥,我都会运行!")

11.3.2 产生异常

通过使用 raise 语句,我们可以生成异常信息。

print(1)
raise ValueError
print(2)

异常都可以带描述性的参数:

name = '123'
raise NameError("Invalid name!")

在 except 语句块中,不带参数的 raise 语句可以用来重新生成已经发生的异常。

try:
    5 / 0
except:
    print("发生了一个异常")
    raise

11.4 函数式编程

函数式编程是一种以函数为基础的编程方式,函数的使用方法和其他对象基本一样,可以分配给变量,作为参数传递以及从其他函数返回。尽管 Python 不倚重函数,但也可以进行函数式程序设计。

11.4.1 高阶函数

函数式编程的一个关键部分是高阶函数。高阶函数以函数作为参数,或者以函数作为返回结果。

def do_twice(func, args):
    return fun(func(args))

def add_two(x):
    return x + 2

print(do_twice(add_two, 1))
def multiplier(factor):
    def multiplyByFactor(number):
        return number*factor
    return multiplyByFactor

函数式编程需要使用纯函数。纯函数没有副作用并且返回值只依赖于它的参数。

# Pure function
def pure_func(x, y):
    return x + y * x

# Impure function
# 这个函数改变了 a_list 的状态
# 所以不是纯函数
a_list = []
def impure_func(args):
    a_list.append(args)

使用纯函数有一些优点,也有一些缺点。

  • 更容易推断和测试
  • 更高效
  • 更容易并行

不过有时候比较难写,另外一些情况需要函数的副作用而纯函数无法提供该特性。

lambda 表达式可以创建匿名函数,这在第 8 章有简单使用过。lambda 函数可以赋给变量,并且可以向正常函数一样使用。不过这种情况使用 def 定义函数更好。

double = lambda x: x * 2
print(double(4))

11.4.2 常用高阶函数

内置函数 map()、filter() 以及 reduce() 都是非常实用的用于操作可迭代对象(如列表、元组)的高阶函数。

map() 函数将序列中的元素全部传递给一个函数,并返回一个可迭代对象。

In [54]: def double(x):
    ...:     return x * 2
    ...:
    ...: data = [11, 22, 33, 44]
    ...: res = map(double, data)
    ...: print(list(res))
    ...:
[22, 44, 66, 88]

这里可以直接使用匿名函数:

In [55]: print(list(map(lambda x: x* 2, data)))
[22, 44, 66, 88]

filter() 基于一个返回布尔值的函数对元素进行过滤

In [56]: list(filter(lambda x: x % 2 == 0, data))
Out[56]: [22, 44]

reduce() 可以进行聚合。

In [3]: from functools import reduce
In [4]: def add(x, y):
   ...:     return x+y
   ...:

In [5]: reduce(add, [1,2,3,4,5])
Out[5]: 15

11.4.3 itertools 模块

itertools 是 Python 的一个标准库,提供了许多用于函数式编程的函数。

其中一类函数用于生成无限迭代器,包括 count()、cycle() 和 repeat()。

  • count() 函数从一个数开始计数到无限
  • cycle() 函数无限迭代一个可迭代对象(如列表或字符串)
  • repeat() 函数重复一个序列有限或无限次

下面以 count() 作为简单示例:

In [22]: for i in count(11):
    ...:     print(i)
    ...:     if i > 20:
    ...:         break
    ...:
11
12
13
14
15
16
17
18
19
20
21

上面代码输出了序列 11-21,因为是无限迭代器,所以需要通过 break 辅助跳出循环。

itertools 库中也有一些类似 map() 和 filter() 的函数,如 takewhile() 函数可以从可迭代对象中根据预测函数提取元素,chain() 函数可以将多个可迭代对象串联为一个,accumulate() 函数可以对可迭代对象求和。下面代码仅作简单的示例。

In [23]: from itertools import chain, takewhile, accumulate
In [24]: list(chain(list(range(1,5)), list(range(6,10))))
Out[24]: [1, 2, 3, 4, 6, 7, 8, 9]
In [25]: nms = list(accumulate(range(20)))
In [26]: nms
Out[26]:
[0,
 1,
 3,
 6,
 10,
 15,
 21,
 28,
 36,
 45,
 55,
 66,
 78,
 91,
 105,
 120,
 136,
 153,
 171,
 190]
In [27]: print(list(takewhile(lambda x: x <= 10, nms)))
[0, 1, 3, 6, 10]

11.5 生成器与装饰器

11.5.1 生成器

生成器是一类像列表、元组的可迭代对象。生成器不像列表支持索引,但是同样可以使用 for 循环进行迭代(可迭代对象都可以使用 for 循环迭代,这是迭代器的一个特性)。

创建生成器的方式比较特别,需要使用函数和一个新的关键字 yield。下面我们看一个生成 1-9 序列的例子。

In [1]: def range2(i):
   ...:     while i > 0:
   ...:         yield i
   ...:         i -= 1
   ...:
In [2]: for x in range2(9):
   ...:     print(x)
   ...:
9
8
7
6
5
4
3
2
1
In [3]: range2(9)
Out[3]: <generator object range2 at 0x7fde103f1f50>
In [4]: range(1, 10)
Out[4]: range(1, 10)

从 for 循环中的使用来看,跟列表和元组完全没有差别,但 range2() 的结果跟我们学习过的 range() 是相似的,它们返回的是对象而非实际的序列。我们可以直接使用 list() 显式地将生成器转换为列表。

In [5]: list(range(1, 10))
Out[5]: [1, 2, 3, 4, 5, 6, 7, 8, 9]

In [6]: list(range2(9))
Out[6]: [9, 8, 7, 6, 5, 4, 3, 2, 1]

这里读者可能会有点困惑,生成器和列表到底有什么区别呢?这里的关键在于理解生成器的一个特性:它是惰性求值的。我们再来观察下 range2() 函数:

def range2(i):
    while i > 0:
        yield i
        i -= 1

相比于直接返回要生成的序列,这里我们定义了计算下一个值的规则,即 i -= 1,在调用该生成器后,计算机不会立马执行所有的计算,而是存储该规则,等待我们需要时再执行,这一点我们可以利用 next() 函数进行验证。

In [7]: a = range2(10)
In [8]: next(a)
Out[8]: 10
In [9]: next(a)
Out[9]: 9

这种按需计算的方式显著地提升了计算的性能,一方面生成器降低了内存的使用(文件不需要一次性读入),另一方面我们不必等待所有的序列生成后才能开始使用。读者可能没有发现,文件的读取使用的就是生成器,open() 函数读入的对象需要逐行存储或计算,并非一次性存储到内存中。

11.5.2 利用生成器读入大型数据集

Python open() 函数本身就返回迭代器,所以我们可以非常容易地创建生成器以读入大的数据集。下面的代码可以用于参考模板:

def read_large_file(file_object):
    """A generator function to read a large file lazily."""

    # 循环直到文件尾部
    while True:
        data = file_object.readline()
        if not data:
            break
        # 生成数据行
        yield data

with open('xxx.csv') as file:
    gen_file = read_large_file(file)

    # 打印文件的第一行
    print(next(gen_file))

Pandas 库的 read_csv() 函数更方便,使用 chunksize 选项,会生成 reader 生成器。

import pandas as pd
df_reader = pd.read_csv('xxx.csv', chunksize=10)
print(next(df_reader))

11.5.3 装饰器

装饰器是一种可以修饰(改)其他函数的函数,这在不更改原函数的情况下拓展原函数的特性非常好用。例如,我们想要在一个函数调用运行前后添加信息输出。

我们创建一个函数 hello() 代表实际的工作函数,创建装饰器 add_text() 用来完成对 hello() 的额外修饰。

In [16]: def add_text(func):
    ...:     def wrap():
    ...:         print("== This is head of function ==")
    ...:         func()
    ...:         print("== This is the end of function ==")
    ...:     return wrap
    ...:
    ...: def hello():
    ...:     print("Hello world!")
    ...:

下面看看我们增加对 hello() 的修饰会让它有什么不同。

In [17]: hello = add_text(hello)

In [18]: hello()
== This is head of function ==
Hello world!
== This is the end of function ==

我们在 add_text() 中添加的信息成功在运行时输出了。现在我们关注装饰器的创建,从逻辑上理解它的结构:它以一个函数作为输入,并在内部定义一个嵌套函数作为返回值。这样,当实际上一个函数被作为参数传入时,该函数被重塑为一个新的函数 wrap() 并被作为结果返回,完成了一个新的函数构建,但从外观来看,我们感觉到原函数被“修饰”了。

为了简化装饰器的分配,Python 允许在原函数定义前使用符号 @ 指派装饰器,从而简化了代码的编写。

In [19]: def add_text(func):
    ...:     def wrap():
    ...:         print("== This is head of function ==")
    ...:         func()
    ...:         print("== This is the end of function ==")
    ...:     return wrap
    ...:
    ...: @add_text
    ...: def hello():
    ...:     print("Hello world!")
    ...:

In [20]: hello()
== This is head of function ==
Hello world!
== This is the end of function ==

11.6 正则表达式

正则表达式是一种操作字符串的强大工具。它是一种领域专属语言(domain specific language, DSL),意思是它以一种库的形式呈现在各类现代编程语言中,而不仅仅 Python 中。这与结构化查询语句 SQL 是类似的。

正则表达式通常有两大用处:

  • 验证字符串匹配莫种模式,如验证邮箱格式、电话号码
  • 对字符串执行替换,如将美式英语转换为英式英语

Python 提供了一个标准库 re 用于操作正则表达式。在我们定义好正则表达式后,函数 re.match() 可以用来查看是否它匹配一个字符串的起始。如果匹配成功,则返回一个匹配对象;如果匹配失败,返回 None。为了避免混淆,我们这里都使用原生字符串 r’string’ 创建正则表达式。

In [1]: import re
In [2]: pattern = r'spam'
In [3]: if re.match(pattern, 'spamxxx'):
   ...:     print('匹配成功')
   ...: else:
   ...:     print('匹配失败')
   ...:
匹配成功
In [5]: print(re.match(pattern, 'xspamxx'))
None

另外有函数 re.search() 用于在字符串任意之处寻找匹配的模式,re.findall() 寻找匹配一个模式的所有子串。

In [6]: print(re.search(pattern, 'xspamxx'))
<re.Match object; span=(1, 5), match='spam'>
In [7]: print(re.findall(pattern, 'xspamxxspamspam'))
['spam', 'spam', 'spam']

我们可以看到上面 re.search() 返回的结果是一个 Match 对象,有几个常用的方法可以获取匹配的信息。

In [8]: match = re.search(pattern, 'xspamxx')
In [9]: match.group()
Out[9]: 'spam'
In [10]: match.start()
Out[10]: 1
In [11]: match.end()
Out[11]: 5
In [12]: match.span()
Out[12]: (1, 5)

re 模块最常用的函数之一可能就是 sub() 了,它可以基于正则表达式实现字符串部分内容的替换。

In [13]: re.sub?
Signature: re.sub(pattern, repl, string, count=0, flags=0)
Docstring:
Return the string obtained by replacing the leftmost
non-overlapping occurrences of the pattern in string by the
replacement repl.  repl can be either a string or a callable;
if a string, backslash escapes in it are processed.  If it is
a callable, it's passed the Match object and must return
a replacement string to be used.
File:      ~/miniconda3/lib/python3.7/re.py
Type:      function

当不修改 count 时,默认会替换字符串中所有匹配的模式。

In [14]: to_sub = 'apple orange apple'
In [16]: re.sub(r'apple', 'juice', to_sub)
Out[16]: 'juice orange juice'
In [17]: re.sub(r'apple', 'juice', to_sub, count=1)
Out[17]: 'juice orange apple'

元字符是一类特殊的字符,它们在正则表达式中有特别的含义和用处,是正则表达式的核心,常见常用的主要有下面一些:

  • 锚定符
    • ^ —— 用于锚定行首
    • $ —— 用于锚定行尾
  • 数目符
    • . —— 任意一个字符
    • ? —— 0 个或 1 个
    • + —— 一个或以上
    • * —— 任意个(包括 0 个)
    • {m, n} —— 至少 m 个,至多 n 个
  • 可选符
    • [abc] —— a b c 三个中任意一个
    • [^abc] —— 不能是 a b c 中任意一个(即排除 a b c)
    • [a-z] —— 所有小写字母
    • [A-Z] —— 所有大写字母
    • [0-9] —— 所有数字

锚定符用于定义正则表达式的起始和结尾。

In [22]: print(re.search(r'^apple', ' apple'))  # 限定必须以 a 起始
None
In [23]: print(re.search(r'apple$', 'apple '))  # 限定必须以 e 结束
None
In [24]: print(re.search(r'apple', ' apple'))
<re.Match object; span=(1, 6), match='apple'>
In [25]: print(re.search(r'apple', 'apple '))
<re.Match object; span=(0, 5), match='apple'>

数目符和可选符用于占位、筛选和模糊匹配。

In [26]: print(re.search(r'[a-z]', 'happy new year'))
<re.Match object; span=(0, 1), match='h'>
In [27]: print(re.search(r'[a-z]', 'HAPPY NEW YEAR'))
None
In [28]: print(re.search(r'[A-Z]', 'HAPPY NEW YEAR'))
<re.Match object; span=(0, 1), match='H'>
In [29]: print(re.search(r'[A-Za-z]', 'HAPPY new YEar'))
<re.Match object; span=(0, 1), match='H'>

In [30]: print(re.search(r'[A-Z]', 'happy new year'))
None

假设我们需要匹配 11 位的手机号码,格式如下:

TEL: 12345678912

正则表达式可以写为

r'^TEL: [0-9]{11}$'

下面测试看看:

In [31]: print(re.match(r'^TEL: [0-9]{11}$', 'TEL: 12345678912'))
<re.Match object; span=(0, 16), match='TEL: 12345678912'>
In [32]: print(re.match(r'^TEL: [0-9]{11}$', 'TEL: 1234567891'))
None
In [33]: print(re.match(r'^TEL: [0-9]{11}$', 'TEL: 12345678912 '))
None
In [34]: print(re.match(r'^TEL: [0-9]{11}$', 'EL: 12345678912'))
None
In [35]: print(re.match(r'^TEL: [0-9]{11}$', 'TEL:12345678912'))
None

第一次当我们输入正确格式的数据时,返回了匹配。后面所有的字符串都有所不同,因此都不能匹配。不知道读者是否感受到了正则表达式的强大,如果工作中有遇到可以用正则表达式解决的问题,赶紧用起来吧。

11.7 章末小结

本章的涉及的内容非常广泛,相对本书其他章节而言较为驳杂,主旨是介绍数据分析可能会利用上的工具函数和方法,也包含 Python 高级编程相关的理论知识。