2  python 内置数据结构、函数与文件

本章将讨论 Python 语言内置的功能,这些功能在我们学习金融和经济数据分析的过程中将被广泛使用。虽然像 pandas 和 NumPy 这样强大的第三方库为更大数据集提供了先进的计算功能,但它们的设计初衷是与 Python 灵活的内置数据操作工具协同工作。

我们将从 Python 的核心数据结构——元组 (tuple)、列表 (list)、字典 (dict) 和集合 (set) 开始。然后,我们将讨论如何创建可复用的自定义 Python 函数。最后,我们将研究 Python 文件对象的机制以及如何与本地硬盘交互。在构建复杂的量化模型之前,牢固掌握这些基础知识至关重要。

2.1 数据结构与序列

Python 的数据结构简洁而强大。精通其用法是成为一名高效的经济分析程序员的关键。我们将从元组、列表和字典开始,它们是一些最常用的序列类型。

2.1.1 元组 (Tuple)

元组 (tuple) 是一个固定长度、不可变的 Python 对象序列,一旦被赋值,就不能被更改。这种不可变性并非限制,而是一种特性;它保证了一条数据记录,例如股票交易的价格、数量和时间戳,能够保持恒定,不会被意外修改。创建元组最简单的方法是用括号包裹一个逗号分隔的值序列。

列表 2.1
tup = (4, 5, 6)
tup

在许多情况下,括号可以省略:

列表 2.2
tup = 4, 5, 6
tup

你可以通过调用 tuple() 构造函数将任何序列或迭代器转换为元组。这对于“冻结”一个列表的内容非常有用。

列表 2.3
print(tuple(['foo',, True]))
tup_from_string = tuple('string')
print(tup_from_string)

元素可以通过方括号 [] 访问,就像在大多数其他编程语言中一样。按照计算机科学的惯例,Python 中的序列是0索引的。

列表 2.4
tup_from_string

当你在更复杂的表达式中定义元组时,通常需要将值用括号括起来,例如下面这个创建元组的元组(嵌套元组)的例子:

列表 2.5
nested_tup = (4, 5, 6), (7, 8)
print(nested_tup)
print(nested_tup)
print(nested_tup)
关于“不可变性 (Immutability)”的辨析

元组的“不可变性”指的是元组本身所包含的对象的引用是不可更改的,即你不能将元组某个位置上的对象替换成另一个对象。然而,如果元组中的某个对象本身是“可变的”(比如一个列表),那么这个对象的内容是可以被修改的。这个特性在处理复杂数据结构时非常重要。

尽管存储在元组中的对象本身可能是可变的,但一旦元组被创建,就不可能修改每个槽位中存储的对象:

列表 2.6
tup = tuple(['foo',, True])
# 下面这行会引发 TypeError,因为你不能给元组的一个槽位赋一个新的对象
tup = False

但是,如果元组内部的一个对象是可变的,比如一个列表,你可以就地修改它。这是一个至关重要的区别。

列表 2.7
tup = tuple(['foo',, True])
tup.append(3)
tup

你可以使用 + 运算符连接元组以产生更长的元组。请注意,这将创建一个的元组;它不会修改原始元组。

列表 2.8
(4, None, 'foo') + (6, 0) + ('bar',)

将元组乘以一个整数,效果是连接该元组的多个副本。对象本身不会被复制,只有它们的引用会被复制。在处理大型数据集时,这对内存管理来说是一个重要的细节。

列表 2.9
('foo', 'bar') * 4

2.1.1.1 元组解包 (Unpacking tuples)

元组一个非常方便的特性是解包 (unpacking)。如果你试图将一个值赋给一个元组式的变量表达式,Python 会尝试解包等号右侧的值:

列表 2.10
tup = (4, 5, 6)
a, b, c = tup
print(f'b is: {b}')

即使是带有嵌套元组的序列也可以被解包,这对于迭代结构化数据非常有用。

列表 2.11
tup = 4, 5, (6, 7)
a, b, (c, d) = tup
print(f'd is: {d}')

这个功能为交换变量值提供了一种优雅的方式,这个任务在许多其他语言中需要一个临时变量。

变量交换的底层机制

b, a = a, b 这种看似神奇的语法,其背后原理正是元组的创建和解包。 1. 创建元组: Python首先计算等号右边的表达式 a, b,这会创建一个临时的、未命名的元组,比如 (1, 2)(假设 a=1, b=2)。 2. 解包元组: 然后,Python将这个临时元组 (1, 2) 的元素解包到等号左边的变量中。第一个元素 1 赋给 b,第二个元素 2 赋给 a。 整个过程是原子性的,确保了交换的正确性,即使变量名相同也不会出错。

列表 2.12
a, b = 1, 2
print(f'Before swap: a={a}, b={b}')
b, a = a, b
print(f'After swap: a={a}, b={b}')

变量解包的一个常见用途是迭代元组或列表的序列:

列表 2.13
seq = [(1, 2, 3), (4, 5, 6), (7, 8, 9)]
for a, b, c in seq:
  print(f'a={a}, b={b}, c={c}')

有时,你可能想从序列的开头“摘取”几个元素,并收集其余的元素。*rest 语法可以用于此目的。在经济时间序列分析中,这对于将最新的数据点与历史序列分开非常有用。

列表 2.14
values = 1, 2, 3, 4, 5
a, b, *rest = values
print(f'a = {a}')
print(f'b = {b}')
print(f'rest = {rest}')

按照惯例,许多 Python 程序员在解包时会使用下划线 (_) 来表示不想要的变量。

列表 2.15
a, b, *_ = values

2.1.1.2 元组方法 (Tuple methods)

由于元组的大小和内容不能被修改,它的实例方法非常少。一个特别有用的方法是 count(也适用于列表),它计算一个值出现的次数。

列表 2.16
a = (1, 2, 2, 2, 3, 4, 2)
a.count(2)

2.1.2 列表 (List)

与元组相反,列表 (list) 的长度是可变的,其内容可以就地修改。它们是可变的 (mutable)。在数据分析中,你可能会用列表来累积模拟结果,或者存放一个正在增量更新的经济时间序列数据。你可以用方括号 []list 类型函数来定义它们:

列表 2.17
a_list = [2, 3, 7, None]
tup = ('foo', 'bar', 'baz')
b_list = list(tup)
b_list
列表 2.18
b_list = 'peekaboo'
b_list

列表和元组在语义上相似,在许多函数中可以互换使用。list 函数在数据处理中经常被用来物化 (materialize) 一个迭代器或生成器表达式(我们稍后会探讨这些概念)。

列表 2.19
gen = range(10)
print(gen)
list(gen)

2.1.2.1 添加和删除元素

可以使用 append 方法将元素添加到列表的末尾:

列表 2.20
b_list.append('dwarf')
b_list

使用 insert 可以在列表的特定位置插入一个元素。插入索引必须在 0 和列表长度(包含)之间。

列表 2.21
b_list.insert(1, 'red')
b_list
appendinsert 的性能考量

在处理大规模数据集时,理解基本操作的计算成本至关重要。 - append(value): 将元素添加到列表末尾是一个O(1)操作,即其执行时间与列表大小无关,非常高效。 - insert(i, value): 在列表的任意位置 i 插入元素是一个O(n)操作,其中 n 是列表的长度。这是因为插入点之后的所有元素都需要向右移动一位来为新元素腾出空间。对于大型列表,这可能是一个非常耗时的操作。

因此,在构建列表时,如果可能,应优先使用 append。如果需要在序列的两端进行高效的插入和删除,Python标准库中的 collections.deque(双端队列)是更好的选择。

insert 的逆操作是 pop,它会移除并返回特定索引处的元素:

列表 2.22
b_list.pop(2)
print(b_list)

可以使用 remove 按值移除元素,它会找到第一个这样的值并从列表中移除:

列表 2.23
b_list.append('foo')
print(f'移除前: {b_list}')
b_list.remove('foo')
print(f'移除后: {b_list}')

你可以使用 in 关键字检查列表中是否包含某个值:

列表 2.24
'dwarf' in b_list

not 关键字可以用来否定 in

列表 2.25
'dwarf' not in b_list
列表搜索的计算复杂度

检查一个值是否存在于列表中(value in my_list)是一个O(n)操作。Python必须从头到尾对列表进行线性扫描 (linear scan),逐一比较元素,直到找到匹配项或到达列表末尾。对于一个包含数百万个元素的列表(例如,一长串交易记录),这种搜索可能会明显变慢。

相比之下,我们稍后将学习的字典 (dictionary) 和集合 (set) 使用了基于哈希表的数据结构,它们的查找操作平均时间复杂度为O(1),即常数时间 (constant time)。这意味着无论数据量多大,查找速度都非常快。在需要频繁进行成员资格检查的算法中,将列表转换为集合通常能带来巨大的性能提升。

2.1.2.2 连接和组合列表

与元组类似,用 + 将两个列表相加会连接它们:

列表 2.26
[4, None, 'foo'] + [7, 8, (2, 3)]

如果你已经定义了一个列表,你可以使用 extend 方法向其追加多个元素:

列表 2.27
x = [4, None, 'foo']
x.extend([7, 8, (2, 3)])
x

请注意,通过加法进行列表连接是一个相对昂贵的操作,因为必须创建一个新列表并将对象复制过去。使用 extend 向现有列表追加元素,尤其是在构建大列表时,通常是更好的选择,因为它会就地修改列表。

2.1.2.3 排序

你可以通过调用列表的 sort 方法对其进行就地排序(不创建新对象):

列表 2.28
a =
a.sort()
a

sort 有一些偶尔会派上用场的选项。其中之一是传递一个次要排序 (key)——即一个函数,它为每个元素生成一个用于排序的值。例如,我们可以按字符串的长度对一个字符串集合进行排序:

列表 2.29
b = ['saw', 'small', 'He', 'foxes', 'six']
b.sort(key=len)
b

稍后,我们将学习 sorted 函数,它可以从一个通用序列生成一个排好序的副本。

2.1.3 切片 (Slicing)

你可以通过使用切片表示法来选择大多数序列类型的部分,其基本形式是 start:stop,传递给索引运算符 []

列表 2.30
seq =
seq[1:5]

切片也可以被赋值:

列表 2.31
seq[3:5] =
seq

虽然 start 索引处的元素被包含在内,但 stop 索引处的元素不被包含,因此结果中的元素数量是 stop - startstartstop 都可以省略,此时它们分别默认为序列的开头或结尾:

列表 2.32
print(seq[:5])
print(seq[3:])

负数索引表示从序列末尾开始切片:

列表 2.33
print(seq[-4:])
print(seq[-6:-2])

切片的语义需要一些时间来适应,特别是如果你之前使用过 R 或 MATLAB。图 fig-slicing 提供了一个有用的图示,说明了使用正整数和负整数进行切片。在图中,索引显示在“箱格”的边缘,以帮助说明切片选择的开始和停止位置。

在第二个冒号后还可以使用一个 step,例如,可以用来取每隔一个元素:

列表 2.34
seq[::2]

一个巧妙的用法是传递 -1,这有一个有用的效果,就是反转一个列表或元组:

列表 2.35
seq[::-1]

2.1.4 字典 (Dictionary)

字典 (dictionary)dict 可能是最重要的内置 Python 数据结构。在其他编程语言中,字典有时被称为哈希映射 (hash map)关联数组 (associative array)。字典存储键值对的集合。在经济建模中,这是一个非常有用的结构,用于将唯一标识符(如国家的ISO代码或公司的股票代码)映射到相关数据(如GDP、通货膨胀率或市值)。

创建字典的一种方法是使用花括号 {} 和冒号来分隔键和值:

列表 2.36
empty_dict = {}
d1 = {'a' : 'some value', 'b' :}
d1

你可以使用与访问列表或元组元素相同的语法来访问、插入或设置元素:

列表 2.37
d1 = 'an integer'
print(d1)
print(d1['b'])

你可以使用与检查列表或元组是否包含某个值相同的语法来检查字典是否包含某个键:

列表 2.38
'b' in d1

你可以使用 del 关键字或 pop 方法(它会同时返回值并删除键)来删除值:

列表 2.39
d1 = 'some value'
d1['dummy'] = 'another value'
print(f'del 之前: {d1}')
del d1
print(f'del 之后: {d1}')
ret = d1.pop('dummy')
print(f'弹出的值: {ret}')
print(f'pop 之后: {d1}')

keysvalues 方法分别提供字典键和值的迭代器。

列表 2.40
print(list(d1.keys()))
print(list(d1.values()))

如果你需要同时迭代键和值,可以使用 items 方法来迭代键值对(作为2元组):

列表 2.41
list(d1.items())

你可以使用 update 方法将一个字典合并到另一个字典中。这将就地修改字典,因此传递给 update 的数据中任何已存在的键,其旧值都将被丢弃。

列表 2.42
d1.update({'b' : 'foo', 'c' : 12})
d1

2.1.4.1 从序列创建字典

通常情况下,你可能会得到两个序列,并希望在字典中将它们按元素配对。zip 函数与 dict 构造函数结合使用,可以轻松实现这一点。

列表 2.43
key_list = ['AAPL', 'GOOG', 'MSFT']
# 2024年5月10日的近似收盘价,用于演示
value_list = [183.05, 170.68, 414.74] 
mapping = dict(zip(key_list, value_list))
mapping

2.1.4.2 默认值

下面的逻辑很常见:

if key in some_dict:
    value = some_dict[key]
else:
    value = default_value

字典的 get 方法提供了一种更简洁的写法。如果键不存在,get 将返回 None 或一个指定的默认值。

列表 2.44
# 获取IBM的价格,如果找不到则默认为 0.0
value = mapping.get('IBM', 0.0) 
print(value)
value = mapping.get('AAPL', 0.0)
print(value)

一个常见的用例是让字典的值是其他集合,比如列表。例如,按首字母对一个单词列表进行分类。

列表 2.45
words = ['apple', 'bat', 'bar', 'atom', 'book']
by_letter = {}
for word in words:
  letter = word
  if letter not in by_letter:
    by_letter[letter] = [word]
  else:
    by_letter[letter].append(word)
by_letter

setdefault 方法可以简化这个工作流程。前面的 for 循环可以重写为:

列表 2.46
by_letter = {}
for word in words:
  letter = word
  by_letter.setdefault(letter, []).append(word)
by_letter

内置的 collections 模块有一个有用的类 defaultdict,这使得分组更加容易。

列表 2.47
from collections import defaultdict
by_letter = defaultdict(list)
for word in words:
  by_letter[word].append(word)
dict(by_letter) # 为了显示,转换回常规字典

2.1.4.3 有效的字典键类型

虽然字典的值可以是任何 Python 对象,但键通常必须是不可变对象,如标量类型(int、float、string)或元组(并且元组中的所有对象也必须是不可变的)。这里的技术术语是可哈希性 (hashability)

关键概念:可哈希性 (Hashability)

字典在内部使用哈希表来存储数据,这使得键的查找速度极快 (平均O(1))。哈希函数将一个对象(键)转换为一个整数(哈希值),这个哈希值被用作对象在内存中的存储地址索引。

为了让这个系统有效工作,一个关键要求是:一个对象的哈希值在其生命周期内必须永远不变。如果一个对象的哈希值会改变,那么就无法可靠地找到它了。

  • 不可变对象(如字符串、数字、元组)的值不会改变,因此它们的哈希值也不会改变。它们是“可哈希的”。
  • 可变对象(如列表、字典)的内容可以随时改变。如果允许它们作为键,它们的值改变后,哈希值也会随之改变,这将导致字典无法再找到这个键。因此,它们是“不可哈希的”。

这就是为什么字典的键必须是不可变类型的原因。

你可以使用 hash 函数检查一个对象是否是可哈希的:

列表 2.48
print(hash('string'))
print(hash((1, 2, (2, 3))))
# 下面这行会失败,因为列表是可变的,因此不可哈希。
hash((1, 2,))

要使用列表作为键,一个选择是将其转换为元组:

列表 2.49
d = {}
d[tuple()] = 5
d

2.1.5 集合 (Set)

集合 (set) 是一个无序的唯一元素集合。它们在成员资格测试、从序列中移除重复项以及数学运算(如并集、交集和差集)方面非常有用。在经济数据集中,你可以使用集合来查找所代表的唯一国家或行业列表。

集合可以通过两种方式创建:通过 set 函数或通过带有花括号的集合字面量:

列表 2.50
print(set())
print({2, 2, 2, 1, 3, 3})

集合支持数学集合运算。考虑这两个示例集合:

列表 2.51
a = {1, 2, 3, 4, 5}
b = {3, 4, 5, 6, 7, 8}

这两个集合的并集 (union) 是出现在任一集合中的不同元素的集合。这可以通过 union 方法或 | 二元运算符来计算:

列表 2.52
print(a.union(b))
print(a | b)

交集 (intersection) 包含同时出现在两个集合中的元素。可以使用 & 运算符或 intersection 方法:

列表 2.53
print(a.intersection(b))
print(a & b)

表 tbl-set-ops 列出了常用的集合方法。

表 2.1: Python 集合运算
函数 替代语法 描述
a.add(x) N/A 将元素 x 添加到集合 a
a.clear() N/A 将集合 a 重置为空状态。
a.remove(x) N/A 从集合 a 中移除元素 x
a.pop() N/A 从集合 a 中移除一个任意元素。
a.union(b) a \| b ab 中的所有唯一元素。
a.update(b) a \|= b a 设置为 ab 的并集。
a.intersection(b) a & b ab 中的所有共同元素。
a.intersection_update(b) a &= b a 设置为 ab 的交集。
a.difference(b) a - b a 中存在但 b 中不存在的元素。
a.difference_update(b) a -= b a 设置为 ab 的差集。
a.symmetric_difference(b) a ^ b 存在于 ab 中但不同时存在的元素。
a.symmetric_difference_update(b) a ^= b a 设置为对称差集。
a.issubset(b) <= b 如果 ab 的子集,则为 True。
a.issuperset(b) >= b 如果 ab 的超集,则为 True。
a.isdisjoint(b) N/A 如果 ab 没有共同元素,则为 True。

所有的逻辑集合运算都有就地 (in-place) 版本,对于非常大的集合,这可能更高效。

列表 2.54
c = a.copy()
c |= b
print(f'就地并集: {c}')

d = a.copy()
d &= b
print(f'就地交集: {d}')

与字典键一样,集合元素也必须是不可变的。要存储类似列表的元素,你必须将它们转换为元组:

列表 2.55
my_data =
my_set = {tuple(my_data)}
my_set

你还可以检查子集和超集:

列表 2.56
a_set = {1, 2, 3, 4, 5}
print({1, 2, 3}.issubset(a_set))
print(a_set.issuperset({1, 2, 3}))

当且仅当集合的内容相等时,它们才相等;顺序无关紧要。

列表 2.57
{1, 2, 3} == {3, 2, 1}

2.2 内置序列函数

Python 有一些有用的序列函数,你应该熟悉它们。

2.2.1 enumerate

在迭代序列时,通常需要跟踪当前项的索引。enumerate 返回一个 (i, value) 元组的序列:

列表 2.58
collection = ['Econ', 'Finance', 'Stats']
for index, value in enumerate(collection):
  print(f'{index}: {value}')

2.2.2 sorted

sorted 函数从任何序列的元素中返回一个的排好序的列表。这与 list.sort() 方法形成对比,后者是就地对列表进行排序。

列表 2.59
print(sorted())
print(sorted('horse race'))

2.2.3 zip

zip 将多个列表、元组或其他序列的元素“配对”起来,创建一个元组的列表。

列表 2.60
seq1 = ['foo', 'bar', 'baz']
seq2 = ['one', 'two', 'three']
zipped = zip(seq1, seq2)
list(zipped)

zip 可以接受任意数量的序列,它产生的元素数量由最短的序列决定。

列表 2.61
seq3 = [False, True]
list(zip(seq1, seq2, seq3))

zip 的一个常见用途是同时迭代多个序列,可能还会与 enumerate 结合使用:

列表 2.62
for index, (a, b) in enumerate(zip(seq1, seq2)):
  print(f'{index}: {a}, {b}')

2.2.4 reversed

reversed 以相反的顺序迭代序列的元素。

列表 2.63
list(reversed(range(10)))

请记住,reversed 是一个生成器 (generator)(稍后讨论),所以它在被物化(例如,用 list()for 循环)之前不会创建反转的序列。

2.3 列表、集合与字典推导式

列表推导式 (List comprehensions) 是一个方便且被广泛使用的 Python 语言特性。它们允许你通过一个简洁的表达式,从一个集合中筛选元素并进行转换,从而简洁地形成一个新列表。基本形式是: [expr for value in collection if condition]

这等同于以下 for 循环:

result = []
for value in collection:
    if condition:
        result.append(expr)

if condition 部分是可选的。例如,给定一个字符串列表,我们可以过滤掉长度为2或更短的字符串,并将它们转换为大写:

列表 2.64
strings = ['a', 'as', 'bat', 'car', 'dove', 'python']
[x.upper() for x in strings if len(x) > 2]

集合和字典推导式是一个自然的扩展。集合推导式看起来与等效的列表推导式相似,只是用花括号代替:

列表 2.65
unique_lengths = {len(x) for x in strings}
unique_lengths

字典推导式看起来像这样: {key-expr: value-expr for value in collection if condition} 例如,我们可以创建一个这些字符串到它们在列表中位置的查找映射:

列表 2.66
loc_mapping = {val: index for index, val in enumerate(strings)}
loc_mapping

2.3.1 嵌套列表推导式

假设我们有一个包含一些英文和西班牙文名字的列表的列表。

列表 2.67
all_data = [['John', 'Emily', 'Michael', 'Mary', 'Steven'],
            ['Maria', 'Juan', 'Javier', 'Natalia', 'Pilar']]

假设我们想得到一个包含所有名字中含有两个或更多”a”的单个列表。我们可以用嵌套的 for 循环来做到这一点,但嵌套列表推导式更简洁。

列表 2.68
result = [name for names in all_data for name in names if name.count('a') >= 2]
result

列表推导式的 for 部分是根据嵌套的顺序排列的。这里是另一个例子,我们将一个元组列表“扁平化”为一个简单的整数列表:

列表 2.69
some_tuples = [(1, 2, 3), (4, 5, 6), (7, 8, 9)]
flattened = [x for tup in some_tuples for x in tup]
flattened

重要的是要区分刚才展示的语法和列表推导式内部的列表推导式,后者会产生一个列表的列表:

列表 2.70
[[x for x in tup] for tup in some_tuples]

2.4 函数 (Functions)

函数是 Python 中代码组织和复用的主要且最重要的方法。根据经验,如果你预计需要重复相同或非常相似的代码超过一次,那么编写一个可复用的函数是值得的。函数用 def 关键字声明。

列表 2.71
def my_function(x, y):
  return x + y

每个函数可以有位置参数 (positional arguments)关键字参数 (keyword arguments)。关键字参数最常用于指定默认值或可选参数。

列表 2.72
def my_function2(x, y, z=1.5):
  if z > 1:
    return z * (x + y)
  else:
    return z / (x + y)

虽然关键字参数是可选的,但在调用函数时必须指定所有位置参数。主要的限制是关键字参数必须跟在位置参数(如果有的话)之后。

列表 2.73
print(my_function2(5, 6, z=0.7))
print(my_function2(10, 20))

2.4.1 命名空间、作用域和局部函数

函数可以访问不同命名空间 (namespaces) 中的变量。默认情况下,在函数内部赋的任何变量都赋给了局部命名空间 (local namespace)。局部命名空间在函数被调用时创建,并立即由函数的参数填充。函数结束后,局部命名空间被销毁。 考虑这个函数:

列表 2.74
def func():
  a = []
  for i in range(5):
    a.append(i)
# 当 func() 被调用时,列表 `a` 被创建、填充,然后被销毁。
# `a` 在函数外部不存在。

现在,假设我们在函数外部声明了 a。函数可以从更高的作用域访问这个变量。

列表 2.75
a = []
def func():
  for i in range(5):
    a.append(i)

func()
print(a)
func()
print(a)

对函数作用域之外的变量进行赋值是可能的,但它们必须使用 global 关键字声明。我通常不鼓励使用 global 关键字。

列表 2.76
a = None
def bind_a_variable():
  global a
  a = []
bind_a_variable()
print(a)

2.4.2 返回多个值

Python 函数一个非常方便的特性是能够返回多个值。

列表 2.77
def f():
  a = 5
  b = 6
  c = 7
  return a, b, c

a, b, c = f()
print(f'a={a}, b={b}, c={c}')

这里发生的是,函数实际上只返回一个对象,一个元组,然后这个元组被解包到结果变量中。

列表 2.78
return_value = f()
return_value

一个可能有吸引力的替代方案是返回一个字典:

列表 2.79
def f():
  a = 5
  b = 6
  c = 7
  return {'a': a, 'b': b, 'c': c}
f()

2.4.3 函数是对象

由于 Python 函数是对象,你可以将它们作为参数传递给其他函数。这是函数式编程的一个基本概念,对于数据分析非常强大。假设我们正在对一个美国州名列表进行数据清洗。

列表 2.80
states = [' Alabama ', 'Georgia!', 'Georgia', 'georgia', 'FlorIda',
          'south carolina##', 'West virginia?']

为了使这个列表统一,我们需要去除空白、移除标点符号并统一大小写。

列表 2.81
import re

def clean_strings(strings):
  result = []
  for value in strings:
    value = value.strip()
    value = re.sub('[!#?]', '', value)
    value = value.title()
    result.append(value)
  return result

clean_strings(states)

一种更灵活、函数式的方法是定义一个你想要应用的操作列表。

列表 2.82
def remove_punctuation(value):
  return re.sub('[!#?]', '', value)

clean_ops = [str.strip, remove_punctuation, str.title]

def clean_strings(strings, ops):
  result = []
  for value in strings:
    for func in ops:
      value = func(value)
    result.append(value)
  return result

clean_strings(states, clean_ops)

你还可以将函数用作其他内置函数的参数,比如 map,它将一个函数应用于一个序列。

列表 2.83
for x in map(remove_punctuation, states):
  print(x)

2.4.4 匿名 (Lambda) 函数

Python 支持所谓的匿名 (anonymous)lambda 函数,这是一种编写只包含单个语句的函数的方式,该语句的结果就是返回值。

列表 2.84
def short_function(x):
  return x * 2

equiv_anon = lambda x: x * 2

它们在数据分析中特别方便,因为许多数据转换函数都接受其他函数作为参数。传递一个 lambda 函数通常比编写一个完整的函数声明更清晰。

列表 2.85
def apply_to_list(some_list, f):
  return [f(x) for x in some_list]

ints =
apply_to_list(ints, lambda x: x * 2)

再举一个例子,假设你想按每个字符串中不同字母的数量对一个字符串集合进行排序:

列表 2.86
strings = ['foo', 'card', 'bar', 'aaaa', 'abab']
strings.sort(key=lambda x: len(set(x)))
strings

2.4.5 生成器 (Generators)

Python 中的许多对象都支持迭代。这是通过迭代器协议 (iterator protocol) 实现的。例如,迭代一个字典会产生字典的键。

列表 2.87
some_dict = {'a': 1, 'b': 2, 'c': 3}
for key in some_dict:
  print(key)

当你写 for key in some_dict 时,Python 解释器首先尝试从 some_dict 创建一个迭代器。

列表 2.88
dict_iterator = iter(some_dict)
print(dict_iterator)
list(dict_iterator)

生成器 (generator) 是构建新可迭代对象的一种便捷方式。普通函数执行并返回单个结果,而生成器可以通过暂停和恢复执行来返回一系列多个值。要创建一个生成器,使用 yield 关键字而不是 return

关键概念: 迭代器与生成器

对于处理大规模经济或金融数据集,理解迭代器和生成器的“惰性求值 (lazy evaluation)”特性至关重要。

  • 列表 (List): 当你创建一个列表时,它的所有元素都会被立即计算并存储在内存中。如果数据集非常大(例如,数百万条记录),这会消耗大量内存。
  • 生成器 (Generator): 生成器不会一次性生成所有结果。相反,它只在被请求时(例如在 for 循环中)才“屈服 (yield)”一个值。它会记住自己的状态,下次被请求时从上次离开的地方继续。

这种机制使得生成器在内存使用上极为高效,因为它们在任何时刻都只需要在内存中保存一个元素。在数据处理管道中,使用生成器可以让你以极低的内存成本处理几乎无限大的数据流。

列表 2.89
def squares(n=10):
  print(f'生成从1到{n}的平方数')
  for i in range(1, n + 1):
    yield i ** 2

# 当你调用生成器时,没有代码会立即执行
gen = squares()
print(gen)

# 直到你请求元素时,它才开始执行
for x in gen:
  print(x, end=' ')

2.4.5.1 生成器表达式 (Generator expressions)

制作生成器的另一种方法是使用生成器表达式 (generator expression)。这是列表推导式的生成器版本。要创建一个,将本应是列表推导式的内容用圆括号括起来。

列表 2.90
gen = (x ** 2 for x in range(100))
gen

这等同于更冗长的生成器:

def _make_gen():
    for x in range(100):
        yield x ** 2
gen = _make_gen()

生成器表达式可以用作函数参数,代替列表推导式,这样可以更节省内存,也可能更快。

列表 2.91
print(sum(x ** 2 for x in range(100)))
print(dict((i, i ** 2) for i in range(5)))

2.4.5.2 itertools 模块

标准库 itertools 模块有许多常见数据算法的生成器集合。例如,groupby 接受一个序列和一个函数,按函数的返回值对连续的元素进行分组。

列表 2.92
import itertools
names = ['Alan', 'Adam', 'Wes', 'Will', 'Albert', 'Steven']
def first_letter(x):
  return x
for letter, names_group in itertools.groupby(names, first_letter):
  print(letter, list(names_group)) # names_group 是一个生成器

表 tbl-itertools 列出了一些其他有用的 itertools 函数。

表 2.2: 一些有用的 itertools 函数
函数 描述
chain(*iterables) 通过将迭代器链接在一起来生成一个序列。
combinations(iterable, k) 生成所有可能的k元组,忽略顺序且无放回。
permutations(iterable, k) 生成所有可能的k元组,考虑顺序。
groupby(iterable[, keyfunc]) 为每个唯一键生成 (键, 子迭代器)。
product(*iterables, repeat=1) 生成输入可迭代对象的笛卡尔积(作为元组)。

2.5 错误与异常处理

优雅地处理 Python 错误或异常 (exceptions) 是构建健壮程序的重要部分。在数据分析中,许多函数只对特定类型的输入有效,而真实世界的数据往往很杂乱。例如,float() 在输入不当时会失败,并引发 ValueError

列表 2.93
float('something')

假设我们想要一个能够优雅失败的 float 版本。我们可以通过编写一个函数,将对 float 的调用封装在 try/except 块中来实现。

列表 2.94
def attempt_float(x):
  try:
    return float(x)
  except:
    return x

print(attempt_float('1.2345'))
print(attempt_float('something'))

你可能只想抑制 ValueError,因为 TypeError 可能表明你的程序中存在一个真正的 bug。要做到这一点,在 except 后面写上异常类型:

列表 2.95
def attempt_float_specific(x):
  try:
    return float(x)
  except ValueError:
    return x

# 这现在会引发 TypeError,正如预期的那样
attempt_float_specific((1, 2))

你可以通过编写一个异常类型的元组来捕获多种异常类型:

列表 2.96
def attempt_float_multiple(x):
  try:
    return float(x)
  except (TypeError, ValueError):
    return x

在某些情况下,你可能希望无论 try 块是否成功,都执行一些代码。为此,使用 finally。类似地,你可以使用 else 来执行仅在 try 块成功时才执行的代码。

# 虚构的文件写入示例
# f = open(path, mode='w')
# try:
#     write_to_file(f)
# except:
#     print('失败')
# else:
#     print('成功')
# finally:
#     f.close()

2.6 文件与操作系统

本书大部分内容使用像 pandas.read_csv 这样的高级工具来读取数据文件。然而,理解如何在 Python 中处理文件的基础知识很重要。要打开一个文件进行读写,使用内置的 open 函数。一个最佳实践是传递一个 encoding 参数。

# path = 'examples/segismundo.txt' # 假设这个文件存在
# f = open(path, encoding='utf-8')

默认情况下,文件以只读模式('r')打开。然后我们可以像处理列表一样处理文件对象 f,并迭代其行。行内容会带有行尾(EOL)标记,所以你通常会用 rstrip() 来移除它们。

列表 2.97
# 这段代码假设在指定路径存在一个文件
path = 'examples/segismundo.txt'
lines = [x.rstrip() for x in open(path, encoding='utf-8')]

当你使用 open 创建文件对象时,建议在完成后关闭文件。with 语句通过在退出块时自动关闭文件,使这变得更容易。

列表 2.98
path = 'examples/segismundo.txt'
with open(path, encoding='utf-8') as f:
  lines = [x.rstrip() for x in f]

表 tbl-file-modes 列出了有效的文件读/写模式。

表 2.3: Python 文件模式
模式 描述
r 只读模式
w 只写模式;创建一个新文件(擦除现有数据)
x 只写模式;创建一个新文件,但如果路径已存在则失败
a 追加到现有文件(如果文件不存在则创建)
r+ 读写
b 添加到模式中用于二进制文件(例如,'rb''wb'
t 文本模式文件(默认);自动将字节解码为 Unicode

对于可读文件,一些最常用的方法是 readseektellread 从文件中返回一定数量的字符。

列表 2.99
# 假设 path 指向一个 utf-8 编码的文件
with open(path, encoding='utf-8') as f:
    # 读取前10个字符
    content_text = f.read(10)
    # 获取当前位置
    position_text = f.tell()
    # 将位置移动到第3个字节
    f.seek(3)
    # 从新位置读取一个字符
    char_after_seek = f.read(1)

with open(path, mode='rb') as f: # 二进制模式
    # 读取前10个字节
    content_binary = f.read(10)
    # 获取当前位置
    position_binary = f.tell()

# 用于演示的虚构输出
print(f'文本内容: "{content_text}", 位置: {position_text}')
print(f'二进制内容: {content_binary}, 位置: {position_binary}')
print(f'移动后读取的字符: "{char_after_seek}"')

表 tbl-file-methods 总结了许多最常用的文件方法。

表 2.4: 重要的 Python 文件方法
方法/属性 描述
read([size]) 以字符串或字节形式返回文件数据,可选大小。
readlines([size]) 返回文件中的行列表,可选大小参数。
write(string) 将传递的字符串写入文件。
writelines(strings) 将传递的字符串序列写入文件。
close() 关闭文件对象。
flush() 将内部 I/O 缓冲区刷新到磁盘。
seek(pos) 移动到指定的文件位置(整数)。
tell() 以整数形式返回当前文件位置。
closed 如果文件已关闭,则为 True。
encoding 用于将字节解释为 Unicode 的编码。

2.7 结论

现在你已经掌握了 Python 环境及其核心数据结构的一些基础知识,是时候继续学习 NumPy 和 Python 中的面向数组计算了。这将为我们在后续章节中探索的更高级的数据分析和计量经济学建模技术奠定基础。