Python 数据分析(PYDA)第三版(一)(3)https://developer.aliyun.com/article/1482370
3.2 函数
函数是 Python 中代码组织和重用的主要和最重要的方法。作为一个经验法则,如果您预计需要重复相同或非常相似的代码超过一次,编写可重用的函数可能是值得的。函数还可以通过给一组 Python 语句命名来使您的代码更易读。
函数使用def
关键字声明。函数包含一个代码块,可选使用return
关键字:
In [173]: def my_function(x, y): .....: return x + y
当到达带有return
的行时,return
后的值或表达式将发送到调用函数的上下文,例如:
In [174]: my_function(1, 2) Out[174]: 3 In [175]: result = my_function(1, 2) In [176]: result Out[176]: 3
有多个return
语句是没有问题的。如果 Python 在函数结尾处没有遇到return
语句,将自动返回None
。例如:
In [177]: def function_without_return(x): .....: print(x) In [178]: result = function_without_return("hello!") hello! In [179]: print(result) None
每个函数可以有 位置 参数和 关键字 参数。关键字参数最常用于指定默认值或可选参数。在这里,我们将定义一个带有默认值 1.5
的可选 z
参数的函数:
def my_function2(x, y, z=1.5): if z > 1: return z * (x + y) else: return z / (x + y)
虽然关键字参数是可选的,但在调用函数时必须指定所有位置参数。
您可以向 z
参数传递值,可以使用关键字也可以不使用关键字,但建议使用关键字:
In [181]: my_function2(5, 6, z=0.7) Out[181]: 0.06363636363636363 In [182]: my_function2(3.14, 7, 3.5) Out[182]: 35.49 In [183]: my_function2(10, 20) Out[183]: 45.0
对函数参数的主要限制是关键字参数 必须 跟在位置参数(如果有的话)后面。您可以以任何顺序指定关键字参数。这使您不必记住函数参数的指定顺序。您只需要记住它们的名称。
命名空间、作用域和本地函数
函数可以访问函数内部创建的变量以及函数外部在更高(甚至 全局)作用域中的变量。在 Python 中描述变量作用域的另一种更具描述性的名称是 命名空间。在函数内部分配的任何变量默认分配给本地命名空间。本地命名空间在函数调用时创建,并立即由函数的参数填充。函数完成后,本地命名空间将被销毁(有一些例外情况超出了本章的范围)。考虑以下函数:
def func(): a = [] for i in range(5): a.append(i)
当调用 func()
时,将创建空列表 a
,附加五个元素,然后在函数退出时销毁 a
。假设我们改为这样声明 a
:
In [184]: a = [] In [185]: def func(): .....: for i in range(5): .....: a.append(i)
每次调用 func
都会修改列表 a
:
In [186]: func() In [187]: a Out[187]: [0, 1, 2, 3, 4] In [188]: func() In [189]: a Out[189]: [0, 1, 2, 3, 4, 0, 1, 2, 3, 4]
在函数范围之外分配变量是可能的,但这些变量必须使用 global
或 nonlocal
关键字显式声明:
In [190]: a = None In [191]: def bind_a_variable(): .....: global a .....: a = [] .....: bind_a_variable() .....: In [192]: print(a) []
nonlocal
允许函数修改在非全局高级作用域中定义的变量。由于它的使用有些神秘(我在这本书中从未使用过它),我建议您查阅 Python 文档以了解更多信息。
注意
我通常不鼓励使用 global
关键字。通常,全局变量用于在系统中存储某种状态。如果您发现自己使用了很多全局变量,这可能表明需要使用面向对象编程(使用类)
返回多个值
当我在 Java 和 C++ 中编程后第一次在 Python 中编程时,我最喜欢的功能之一是能够以简单的语法从函数中返回多个值。这里有一个例子:
def f(): a = 5 b = 6 c = 7 return a, b, c a, b, c = f()
在数据分析和其他科学应用中,您可能经常这样做。这里发生的是函数实际上只返回一个对象,一个元组,然后将其解包为结果变量。在前面的例子中,我们可以这样做:
return_value = f()
在这种情况下,return_value
将是一个包含三个返回变量的 3 元组。与之前返回多个值的一个潜在有吸引力的替代方法可能是返回一个字典:
def f(): a = 5 b = 6 c = 7 return {"a" : a, "b" : b, "c" : c}
这种替代技术可以根据您尝试做什么而有用。
函数是对象
由于 Python 函数是对象,许多构造可以很容易地表达,而在其他语言中很难做到。假设我们正在进行一些数据清理,并需要对以下字符串列表应用一系列转换:
In [193]: states = [" Alabama ", "Georgia!", "Georgia", "georgia", "FlOrIda", .....: "south carolina##", "West virginia?"]
任何曾经处理过用户提交的调查数据的人都会看到这样混乱的结果。需要做很多事情才能使这个字符串列表统一并准备好进行分析:去除空格、删除标点符号,并标准化适当的大写。其中一种方法是使用内置的字符串方法以及 re
标准库模块进行正则表达式:
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
结果如下:
In [195]: clean_strings(states) Out[195]: ['Alabama', 'Georgia', 'Georgia', 'Georgia', 'Florida', 'South Carolina', 'West Virginia']
您可能会发现有用的另一种方法是制作一个要应用于特定字符串集的操作列表:
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
然后我们有以下内容:
In [197]: clean_strings(states, clean_ops) Out[197]: ['Alabama', 'Georgia', 'Georgia', 'Georgia', 'Florida', 'South Carolina', 'West Virginia']
像这样的更函数式模式使您能够轻松修改字符串在非常高级别上的转换方式。clean_strings
函数现在也更具可重用性和通用性。
您可以将函数用作其他函数的参数,比如内置的map
函数,它将一个函数应用于某种序列:
In [198]: for x in map(remove_punctuation, states): .....: print(x) Alabama Georgia Georgia georgia FlOrIda south carolina West virginia
map
可以作为替代方案用于列表推导而不需要任何过滤器。
匿名(Lambda)函数
Python 支持所谓的匿名或lambda函数,这是一种编写由单个语句组成的函数的方式,其结果是返回值。它们使用lambda
关键字定义,该关键字除了“我们正在声明一个匿名函数”之外没有其他含义:
In [199]: def short_function(x): .....: return x * 2 In [200]: equiv_anon = lambda x: x * 2
我通常在本书的其余部分中将这些称为 lambda 函数。它们在数据分析中特别方便,因为正如您将看到的,有许多情况下,数据转换函数将接受函数作为参数。与编写完整函数声明或甚至将 lambda 函数分配给本地变量相比,传递 lambda 函数通常更少输入(更清晰)。考虑这个例子:
In [201]: def apply_to_list(some_list, f): .....: return [f(x) for x in some_list] In [202]: ints = [4, 0, 1, 5, 6] In [203]: apply_to_list(ints, lambda x: x * 2) Out[203]: [8, 0, 2, 10, 12]
您也可以写成[x * 2 for x in ints]
,但在这里我们能够简洁地将自定义运算符传递给apply_to_list
函数。
举个例子,假设你想按每个字符串中不同字母的数量对字符串集合进行排序:
In [204]: strings = ["foo", "card", "bar", "aaaa", "abab"]
在这里,我们可以将一个 lambda 函数传递给列表的sort
方法:
In [205]: strings.sort(key=lambda x: len(set(x))) In [206]: strings Out[206]: ['aaaa', 'foo', 'abab', 'bar', 'card']
生成器
Python 中的许多对象支持迭代,例如列表中的对象或文件中的行。这是通过迭代器协议实现的,这是一种使对象可迭代的通用方法。例如,对字典进行迭代会产生字典键:
In [207]: some_dict = {"a": 1, "b": 2, "c": 3} In [208]: for key in some_dict: .....: print(key)
当您写for key in some_dict
时,Python 解释器首先尝试从some_dict
创建一个迭代器:
In [209]: dict_iterator = iter(some_dict) In [210]: dict_iterator Out[210]: <dict_keyiterator at 0x17d60e020>
迭代器是任何对象,在上下文中像for
循环中使用时,将向 Python 解释器产生对象。大多数期望列表或类似列表的对象的方法也将接受任何可迭代对象。这包括内置方法如min
、max
和sum
,以及类构造函数如list
和tuple
:
In [211]: list(dict_iterator) Out[211]: ['a', 'b', 'c']
生成器是一种方便的方式,类似于编写普通函数,来构造一个新的可迭代对象。普通函数一次执行并返回一个结果,而生成器可以通过暂停和恢复执行每次使用生成器时返回多个值的序列。要创建一个生成器,请在函数中使用yield
关键字而不是return
:
def squares(n=10): print(f"Generating squares from 1 to {n ** 2}") for i in range(1, n + 1): yield i ** 2
当您实际调用生成器时,不会立即执行任何代码:
In [213]: gen = squares() In [214]: gen Out[214]: <generator object squares at 0x17d5fea40>
直到您请求生成器的元素时,它才开始执行其代码:
In [215]: for x in gen: .....: print(x, end=" ") Generating squares from 1 to 100 1 4 9 16 25 36 49 64 81 100
注意
由于生成器一次产生一个元素的输出,而不是一次产生整个列表,这可以帮助您的程序使用更少的内存。
生成器表达式
另一种生成器的方法是使用生成器表达式。这是列表、字典和集合推导的生成器类比。要创建一个,将否则是列表推导的内容括在括号中而不是方括号中:
In [216]: gen = (x ** 2 for x in range(100)) In [217]: gen Out[217]: <generator object <genexpr> at 0x17d5feff0>
这等同于以下更冗长的生成器:
def _make_gen(): for x in range(100): yield x ** 2 gen = _make_gen()
生成器表达式可以在某些情况下用作函数参数,而不是列表推导:
In [218]: sum(x ** 2 for x in range(100)) Out[218]: 328350 In [219]: dict((i, i ** 2) for i in range(5)) Out[219]: {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
根据推导表达式产生的元素数量,生成器版本有时可以更有意义地更快。
itertools 模块
标准库itertools
模块具有许多常见数据算法的生成器集合。例如,groupby
接受任何序列和一个函数,通过函数的返回值对序列中的连续元素进行分组。这里是一个例子:
In [220]: import itertools In [221]: def first_letter(x): .....: return x[0] In [222]: names = ["Alan", "Adam", "Wes", "Will", "Albert", "Steven"] In [223]: for letter, names in itertools.groupby(names, first_letter): .....: print(letter, list(names)) # names is a generator A ['Alan', 'Adam'] W ['Wes', 'Will'] A ['Albert'] S ['Steven']
查看表 3.2 以获取我经常发现有用的其他一些itertools
函数列表。您可能想查看官方 Python 文档以获取有关这个有用的内置实用程序模块的更多信息。
表 3.2:一些有用的itertools
函数
函数 | 描述 |
chain(*iterables) |
通过将迭代器链接在一起生成序列。一旦第一个迭代器的元素用尽,将返回下一个迭代器的元素,依此类推。 |
combinations(iterable, k) |
生成可迭代对象中所有可能的k 元素元组的序列,忽略顺序且不重复(另请参阅伴随函数combinations_with_replacement )。 |
permutations(iterable, k) |
生成可迭代对象中所有可能的k 元素元组的序列,保持顺序。 |
groupby(iterable[, keyfunc]) |
为每个唯一键生成(key, sub-iterator) 。 |
| product(*iterables, repeat=1)
| 生成输入可迭代对象的笛卡尔积作为元组,类似于嵌套的for
循环。 |
错误和异常处理
处理 Python 错误或异常的优雅是构建健壮程序的重要部分。在数据分析应用中,许多函数只对特定类型的输入有效。例如,Python 的float
函数能够将字符串转换为浮点数,但在不当输入时会引发ValueError
异常:
In [224]: float("1.2345") Out[224]: 1.2345 In [225]: float("something") --------------------------------------------------------------------------- ValueError Traceback (most recent call last) <ipython-input-225-5ccfe07933f4> in <module> ----> 1 float("something") ValueError: could not convert string to float: 'something'
假设我们想要一个版本的float
,它能够优雅地失败,返回输入参数。我们可以通过编写一个函数,在其中将对float
的调用封装在try
/except
块中来实现这一点(在 IPython 中执行此代码):
def attempt_float(x): try: return float(x) except: return x
块中的except
部分的代码只有在float(x)
引发异常时才会执行:
In [227]: attempt_float("1.2345") Out[227]: 1.2345 In [228]: attempt_float("something") Out[228]: 'something'
您可能会注意到float
可能引发除ValueError
之外的异常:
In [229]: float((1, 2)) --------------------------------------------------------------------------- TypeError Traceback (most recent call last) <ipython-input-229-82f777b0e564> in <module> ----> 1 float((1, 2)) TypeError: float() argument must be a string or a real number, not 'tuple'
您可能只想抑制ValueError
,因为TypeError
(输入不是字符串或数值)可能表明程序中存在合法错误。要做到这一点,请在except
后面写上异常类型:
def attempt_float(x): try: return float(x) except ValueError: return x
然后我们有:
In [231]: attempt_float((1, 2)) --------------------------------------------------------------------------- TypeError Traceback (most recent call last) <ipython-input-231-8b0026e9e6b7> in <module> ----> 1 attempt_float((1, 2)) <ipython-input-230-6209ddecd2b5> in attempt_float(x) 1 def attempt_float(x): 2 try: ----> 3 return float(x) 4 except ValueError: 5 return x TypeError: float() argument must be a string or a real number, not 'tuple'
您可以通过编写异常类型的元组来捕获多个异常类型(括号是必需的):
def attempt_float(x): try: return float(x) except (TypeError, ValueError): return x
在某些情况下,您可能不想抑制异常,但您希望无论try
块中的代码是否成功,都执行一些代码。要做到这一点,请使用finally
:
f = open(path, mode="w") try: write_to_file(f) finally: f.close()
在这里,文件对象f
将始终被关闭。同样,您可以使用else
来执行仅在try:
块成功时执行的代码:
f = open(path, mode="w") try: write_to_file(f) except: print("Failed") else: print("Succeeded") finally: f.close()
在 IPython 中的异常
如果在%run
脚本或执行任何语句时引发异常,默认情况下 IPython 将打印完整的调用堆栈跟踪(traceback),并在堆栈中的每个位置周围显示几行上下文:
In [10]: %run examples/ipython_bug.py --------------------------------------------------------------------------- AssertionError Traceback (most recent call last) /home/wesm/code/pydata-book/examples/ipython_bug.py in <module>() 13 throws_an_exception() 14 ---> 15 calling_things() /home/wesm/code/pydata-book/examples/ipython_bug.py in calling_things() 11 def calling_things(): 12 works_fine() ---> 13 throws_an_exception() 14 15 calling_things() /home/wesm/code/pydata-book/examples/ipython_bug.py in throws_an_exception() 7 a = 5 8 b = 6 ----> 9 assert(a + b == 10) 10 11 def calling_things(): AssertionError:
仅仅通过附加上下文本身就是与标准 Python 解释器相比的一个巨大优势(标准 Python 解释器不提供任何额外上下文)。您可以使用%xmode
魔术命令来控制显示的上下文量,从Plain
(与标准 Python 解释器相同)到Verbose
(内联函数参数值等)。正如您将在附录 B:更多关于 IPython 系统中看到的,您可以在错误发生后进行交互式事后调试,进入堆栈(使用%debug
或%pdb
魔术)。
本书的大部分内容使用高级工具如pandas.read_csv
从磁盘读取数据文件到 Python 数据结构中。然而,了解如何在 Python 中处理文件的基础知识是很重要的。幸运的是,这相对简单,这也是 Python 在文本和文件处理方面如此受欢迎的原因。
要打开一个文件进行读取或写入,请使用内置的open
函数,使用相对或绝对文件路径以及可选的文件编码:
In [233]: path = "examples/segismundo.txt" In [234]: f = open(path, encoding="utf-8")
在这里,我传递 encoding="utf-8"
作为最佳实践,因为默认的 Unicode 编码读取文件在不同平台上有所不同。
默认情况下,文件以只读模式 "r"
打开。然后我们可以像处理列表一样处理文件对象 f
并迭代文件行:
for line in f: print(line)
行从文件中出来时保留了行尾(EOL)标记,因此您经常会看到代码以获取文件中无行尾的行列表,如下所示:
In [235]: lines = [x.rstrip() for x in open(path, encoding="utf-8")] In [236]: lines Out[236]: ['Sueña el rico en su riqueza,', 'que más cuidados le ofrece;', '', 'sueña el pobre que padece', 'su miseria y su pobreza;', '', 'sueña el que a medrar empieza,', 'sueña el que afana y pretende,', 'sueña el que agravia y ofende,', '', 'y en el mundo, en conclusión,', 'todos sueñan lo que son,', 'aunque ninguno lo entiende.', '']
当使用 open
创建文件对象时,建议在完成后关闭文件。关闭文件会将其资源释放回操作系统:
In [237]: f.close()
使得清理打开文件更容易的一种方法是使用 with
语句:
In [238]: with open(path, encoding="utf-8") as f: .....: lines = [x.rstrip() for x in f]
当退出 with
块时,这将自动关闭文件 f
。确保关闭文件在许多小程序或脚本中不会导致问题,但在需要与大量文件交互的程序中可能会出现问题。
如果我们输入 f = open(path, "w")
,examples/segismundo.txt 将会创建一个新文件(小心!),覆盖原来的任何文件。还有 "x"
文件模式,它创建一个可写文件,但如果文件路径已经存在则失败。查看 Table 3.3 获取所有有效的文件读写模式列表。
Table 3.3: Python 文件模式
模式 | 描述 |
r |
只读模式 |
w |
只写模式;创建一个新文件(擦除同名文件的数据) |
x |
只写模式;创建一个新文件,但如果文件路径已经存在则失败 |
a |
追加到现有文件(如果文件不存在则创建文件) |
r+ |
读取和写入 |
b |
用于二进制文件的附加模式(即 "rb" 或 "wb" ) |
t |
文件的文本模式(自动将字节解码为 Unicode);如果未指定,则为默认模式 |
对于可读文件,一些最常用的方法是 read
、seek
和 tell
。read
从文件返回一定数量的字符。什么构成一个“字符”取决于文件编码,或者如果文件以二进制模式打开,则是原始字节:
In [239]: f1 = open(path) In [240]: f1.read(10) Out[240]: 'Sueña el r' In [241]: f2 = open(path, mode="rb") # Binary mode In [242]: f2.read(10) Out[242]: b'Sue\xc3\xb1a el '
read
方法通过读取的字节数推进文件对象位置。tell
给出当前位置:
In [243]: f1.tell() Out[243]: 11 In [244]: f2.tell() Out[244]: 10
即使我们从以文本模式打开的文件 f1
中读取了 10 个字符,位置也是 11,因为使用默认编码解码 10 个字符需要这么多字节。您可以在 sys
模块中检查默认编码:
In [245]: import sys In [246]: sys.getdefaultencoding() Out[246]: 'utf-8'
为了在各个平台上获得一致的行为,最好在打开文件时传递一个编码(例如 encoding="utf-8"
,这是广泛使用的)。
seek
将文件位置更改为文件中指定的字节:
In [247]: f1.seek(3) Out[247]: 3 In [248]: f1.read(1) Out[248]: 'ñ' In [249]: f1.tell() Out[249]: 5
最后,我们记得关闭文件:
In [250]: f1.close() In [251]: f2.close()
要将文本写入文件,可以使用文件的 write
或 writelines
方法。例如,我们可以创建一个没有空行的 examples/segismundo.txt 版本如下:
In [252]: path Out[252]: 'examples/segismundo.txt' In [253]: with open("tmp.txt", mode="w") as handle: .....: handle.writelines(x for x in open(path) if len(x) > 1) In [254]: with open("tmp.txt") as f: .....: lines = f.readlines() In [255]: lines Out[255]: ['Sueña el rico en su riqueza,\n', 'que más cuidados le ofrece;\n', 'sueña el pobre que padece\n', 'su miseria y su pobreza;\n', 'sueña el que a medrar empieza,\n', 'sueña el que afana y pretende,\n', 'sueña el que agravia y ofende,\n', 'y en el mundo, en conclusión,\n', 'todos sueñan lo que son,\n', 'aunque ninguno lo entiende.\n']
查看 Table 3.4 获取许多最常用的文件方法。
Table 3.4: 重要的 Python 文件方法或属性
方法/属性 | 描述 |
read([size]) |
根据文件模式返回文件数据作为字节或字符串,可选的 size 参数指示要读取的字节数或字符串字符数 |
readable() |
如果文件支持 read 操作则返回 True |
readlines([size]) |
返回文件中行的列表,带有可选的 size 参数 |
write(string) |
将传递的字符串写入文件 |
writable() |
如果文件支持 write 操作则返回 True |
writelines(strings) |
将传递的字符串序列写入文件 |
close() |
关闭文件对象 |
flush() |
刷新内部 I/O 缓冲区到磁盘 |
seek(pos) |
移动到指定的文件位置(整数) |
seekable() |
如果文件对象支持寻找并且随机访问则返回 True (某些类似文件的对象不支持) |
tell() |
返回当前文件位置作为整数 |
closed |
如果文件已关闭则为True |
encoding |
用于将文件中的字节解释为 Unicode 的编码(通常为 UTF-8) |
字节和 Unicode 与文件
Python 文件的默认行为(无论是可读还是可写)是文本模式,这意味着您打算使用 Python 字符串(即 Unicode)。这与二进制模式相反,您可以通过在文件模式后附加b
来获得。重新访问上一节中包含 UTF-8 编码的非 ASCII 字符的文件,我们有:
In [258]: with open(path) as f: .....: chars = f.read(10) In [259]: chars Out[259]: 'Sueña el r' In [260]: len(chars) Out[260]: 10
UTF-8 是一种可变长度的 Unicode 编码,因此当我从文件请求一些字符时,Python 会读取足够的字节(可能少至 10 个或多至 40 个字节)来解码相应数量的字符。如果我以"rb"
模式打开文件,read
请求确切数量的字节:
In [261]: with open(path, mode="rb") as f: .....: data = f.read(10) In [262]: data Out[262]: b'Sue\xc3\xb1a el '
根据文本编码,您可能可以自己将字节解码为str
对象,但前提是每个编码的 Unicode 字符都是完整形式的:
In [263]: data.decode("utf-8") Out[263]: 'Sueña el ' In [264]: data[:4].decode("utf-8") --------------------------------------------------------------------------- UnicodeDecodeError Traceback (most recent call last) <ipython-input-264-846a5c2fed34> in <module> ----> 1 data[:4].decode("utf-8") UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc3 in position 3: unexpecte d end of data
文本模式,结合open
的encoding
选项,提供了一种方便的方法来将一个 Unicode 编码转换为另一个:
In [265]: sink_path = "sink.txt" In [266]: with open(path) as source: .....: with open(sink_path, "x", encoding="iso-8859-1") as sink: .....: sink.write(source.read()) In [267]: with open(sink_path, encoding="iso-8859-1") as f: .....: print(f.read(10)) Sueña el r
在除了二进制模式之外的任何模式下打开文件时要小心使用seek
。如果文件位置落在定义 Unicode 字符的字节中间,那么后续的读取将导致错误:
In [269]: f = open(path, encoding='utf-8') In [270]: f.read(5) Out[270]: 'Sueña' In [271]: f.seek(4) Out[271]: 4 In [272]: f.read(1) --------------------------------------------------------------------------- UnicodeDecodeError Traceback (most recent call last) <ipython-input-272-5a354f952aa4> in <module> ----> 1 f.read(1) ~/miniforge-x86/envs/book-env/lib/python3.10/codecs.py in decode(self, input, fin al) 320 # decode input (taking the buffer into account) 321 data = self.buffer + input --> 322 (result, consumed) = self._buffer_decode(data, self.errors, final ) 323 # keep undecoded input until the next call 324 self.buffer = data[consumed:] UnicodeDecodeError: 'utf-8' codec can't decode byte 0xb1 in position 0: invalid s tart byte In [273]: f.close()
如果您经常在非 ASCII 文本数据上进行数据分析,掌握 Python 的 Unicode 功能将会很有价值。查看Python 的在线文档获取更多信息。
3.4 结论
随着 Python 环境和语言的一些基础知识现在掌握,是时候继续学习 Python 中的 NumPy 和面向数组的计算了。