《利用Python进行数据分析·第2版》附录B精读:IPython 高级特性与数据分析开发实战

原创2026-10-01 18:46:221,855 阅读
文章标签:文档教程数据分析

《利用Python进行数据分析·第2版》附录B精读:IPython 高级特性与数据分析开发实战

本篇文章聚焦《利用Python进行数据分析·第2版》附录B,系统讲解 IPython 在命令历史、系统交互、调试、计时与性能分析、高效开发技巧及配置文件管理等方面的高级能力。无论你是在 IPython shell 中逐行探索数据,还是在 Jupyter notebook 中编写分析流程,掌握这些特性都能显著提升数据准备、清洗、聚合与建模(对应本仓库第4~14章)各环节的开发效率。读完本文,你将能够熟练复用命令历史、无缝调用 Shell、用交互调试器定位异常、用 %timeit/%prun/%lprun 量化代码性能,并自定义属于自己的 IPython 环境。

从第2章基础到附录B进阶:IPython 的能力全景

在第2章中,我们已经学习了 IPython shell 和 Jupyter notebook 的基础用法:通过 ipython 命令启动交互环境,用 %run 执行脚本并保留结果对象,使用 In [1]:/Out[1]: 形式的提示符等。附录B则是在此基础上,探索 IPython 更深层次的功能,这些功能既可以在控制台使用,也可以在 Jupyter 中使用。

值得强调的是,本仓库作为《利用Python进行数据分析·第2版》的中文译本(见 README.md),书中大量数据实操(如第4章 NumPy 数组计算、第5章 pandas 入门、第10章数据聚合与分组运算)都建议在 IPython/Jupyter 环境中亲自实践。附录B提供的这些"生产力工具",正是为了让这类反复试错、逐步逼近正确结果的数据分析工作流更顺畅。

附录B覆盖的五大主题:

  • B.1 使用命令历史:搜索、补全并复用历史命令,管理输入/输出变量;
  • B.2 与操作系统交互:在 IPython 中执行 Shell 命令、定义别名、使用目录书签;
  • B.3 软件开发工具:交互调试器、代码计时(%time/%timeit)与性能分析(%prun/%lprun);
  • B.4 高效开发技巧:模块重载与面向交互的代码设计原则;
  • B.5 IPython 高级功能:让自定义类对 IPython 友好,以及深度配置系统。

B.1 使用命令历史:让每条指令都可检索、可复用

IPython 会在磁盘上维护一个小型数据库,保存你执行的每一条指令。它的用途有三:

  • 只用最少的输入,就能搜索、补全并重新执行先前运行过的指令;
  • 在不同 session(会话)之间保存命令历史;
  • 将输入/输出的日志历史导出到文件。

这些功能在 shell 中比在 notebook 中更为实用,因为 notebook 从设计上就把输入和输出代码放在各自的代码格子中,天然具备"可回溯"的属性。

搜索和重复使用命令历史

命令历史最典型的应用场景是重复执行同样的命令。例如你经常需要重新运行数据处理脚本:

In[7]: %run first/second/third/data_script.py

运行成功后检查结果,发现计算有误;修改完 data_script.py 之后,你只需输入几个 %run 字符,然后按 Ctrl-P 或 上箭头,即可搜索历史中匹配输入字符的命令。多次按 Ctrl-P 或上箭头会继续向上搜索更早的命令;按 Ctrl-N 或 下箭头 则向前(向下)移动历史命令。这样练习几次之后,这些按键就会变成肌肉记忆。

Ctrl-R 则带来类似 Unix 风格 shell(如 bash)的 readline 部分增量搜索功能。在 Windows 上,readline 功能由 IPython 自行模拟。用法是先按 Ctrl-R,再输入包含于目标输入行的字符:

In [1]: a_command = foo(x, y, z)

(reverse-i-search)`com': a_command = foo(x, y, z)

Ctrl-R 会循环历史,依次找到匹配字符的每一行。

输入和输出变量:_、__、_iX 与 _X

忘记把函数调用的结果赋给变量是很常见的事。IPython 的每个 session 都会在特殊变量中存储输入和输出 Python 对象的引用:前面两次输出分别存放在 _(一个下划线)和 __(两个下划线)变量中:

In [24]: 2 ** 27
Out[24]: 134217728

In [25]: _
Out[25]: 134217728

输入变量存放在形如 _iX 的变量中,其中 X 是输入行的编号;每个输入变量都对应一个输出变量 _X。因此在输入第 27 行之后,会同时产生 _27(输出)和 _i27(输入)两个新变量:

In [26]: foo = 'bar'

In [27]: foo
Out[27]: 'bar'

In [28]: _i27
Out[28]: u'foo'

In [29]: _27
Out[29]: 'bar'

由于输入变量是字符串,它们可以用 Python 的 exec 关键字再次执行:

In [30]: exec(_i27)

这里 _i27 正是 In [27] 输入的代码 foo。

几个魔术函数可以帮你利用输入输出历史:

  • %hist:打印全部或部分的输入历史,可选择是否带编号;
  • %reset:清理交互命名空间,或输入/输出缓存;
  • %xdel:去除 IPython 中某个对象的所有引用。

警告:当处理非常大的数据集时,要记住 IPython 的输入输出历史会导致被引用的对象不被垃圾回收(释放内存),即使你使用 del 关键字从交互命名空间删除了变量。这种情况下,谨慎使用 %xdel 和 %reset 可以帮助你避免陷入内存问题。

B.2 与操作系统交互:让 Shell 成为 IPython 的一部分

IPython 的另一个特色功能是无缝连接文件系统和操作系统:无需退出 IPython,就能像在 Windows 或 Unix 命令行中那样操作,包括执行 Shell 命令、更改目录、用 Python 对象(列表或字符串)存储命令结果;此外还提供了简单的命令别名和目录书签功能。

原书中"表B-1 IPython系统相关命令"以截图形式给出,这里整理为文字表格以便检索引用:

命令 / 语法 作用
!cmd 在系统 shell 中执行命令 cmd
!!cmd / %sx 在 shell 中执行命令,并把标准输出捕获为 Python 对象
%cd 更改当前工作目录
%pwd 返回当前工作目录
%bookmark 使用 IPython 的目录书签系统
%alias 定义系统命令的别名
%env 查看或设置环境变量

Shell 命令与输出捕获

用**叹号(!)**开始一行,就是告诉 IPython 执行叹号后面的所有内容。这意味着你可以删除文件(取决于操作系统,用 rm 或 del)、改变目录或执行任何其他命令。

通过给变量加上叹号,你可以在一个变量中存储命令的控制台输出。例如在联网的 Linux 主机上,可以把 IP 地址保存为 Python 变量:

In [1]: ip_info = !ifconfig wlan0 | grep "inet "

In [2]: ip_info[0].strip()
Out[2]: 'inet addr:10.0.0.11  Bcast:10.0.0.255  Mask:255.255.255.0'

返回的 ip_info 实际上是一个自定义的列表类型对象,它保存着控制台输出的多种版本。

使用 ! 时,IPython 还可以替换定义在当前环境中的 Python 值:只需在变量名前面加上 $ 符号:

In [3]: foo = 'test*'

In [4]: !ls $foo
test4.py  test.py  test.xml

这里 $foo 被展开为当前环境中的字符串 test*,作为 ls 的参数。

%alias:自定义 Shell 命令快捷方式

%alias 魔术函数可以自定义 shell 命令的快捷方式。看一个简单例子:

In [1]: %alias ll ls -l

In [2]: ll /usr
total 332
drwxr-xr-x   2 root root  69632 2012-01-29 20:36 bin/
drwxr-xr-x   2 root root   4096 2010-08-23 12:05 games/
drwxr-xr-x 123 root root  20480 2011-12-26 18:08 include/
drwxr-xr-x 265 root root 126976 2012-01-29 20:36 lib/
drwxr-xr-x  44 root root  69632 2011-12-26 18:08 lib32/
lrwxrwxrwx   1 root root      3 2010-08-23 16:02 lib64 -> lib/
drwxr-xr-x  15 root root   4096 2011-10-13 19:03 local/
drwxr-xr-x   2 root root  12288 2012-01-12 09:32 sbin/
drwxr-xr-x 387 root root  12288 2011-11-04 22:53 share/
drwxrwsr-x  24 root src    4096 2011-07-17 18:38 src/

你还可以像在命令行中一样,用分号分隔、一次执行多条命令:

In [558]: %alias test_alias (cd examples; ls; cd ..)

In [559]: test_alias
macrodata.csv  spx.csv	tips.csv

注意:当 session 结束时,你定义的别名就会失效;要创建恒久的别名,需要使用 IPython 配置(见 B.5 节)。

目录书签系统:%bookmark

IPython 有一个简单的目录书签系统,可以保存常用目录的别名,让你在目录之间跳转时非常方便。例如,创建一个指向本书补充内容的书签:

In [6]: %bookmark py4da /home/wesm/code/pydata-book

之后使用 %cd 魔术命令时,就可以引用定义的书签:

In [7]: cd py4da
(bookmark:py4da) -> /home/wesm/code/pydata-book
/home/wesm/code/pydata-book

如果书签名字与当前工作目录中的某个目录重名,可以用 -b 标志强制优先使用书签位置。用 %bookmark -l 可以列出所有书签:

In [8]: %bookmark -l
Current bookmarks:
py4da -> /home/wesm/code/pydata-book-source

书签与别名不同,它们在 session 之间是保持的。

B.3 软件开发工具:调试、计时与性能分析

除了作为优秀的交互式计算和数据探索环境,IPython 也是有效的 Python 软件开发工具。在数据分析中,最重要的是写出正确的代码——为此 IPython 紧密集成并加强了 Python 内置的 pdb 调试器;其次是代码要够快——IPython 提供了易于使用的代码计时和分析工具。

交互调试器:%debug 与 %pdb

IPython 的调试器用 tab 补全、语法增强和逐行异常追踪增强了 pdb。调试代码的最佳时机就是刚刚发生错误的时候。异常发生之后输入 %debug,即可启动调试器,进入抛出异常的堆栈框架:

In [2]: 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:

In [3]: %debug
> /home/wesm/code/pydata-book/examples/ipython_bug.py(9)throws_an_exception()
      8     b = 6
----> 9     assert(a + b == 10)
     10

ipdb>

一旦进入调试器,就可以执行任意 Python 代码,并在每个堆栈框架中检查所有对象和数据(解释器会保持它们活跃)。默认从错误发生的最低层级开始,通过 u(up)和 d(down)可以在不同层级的堆栈踪迹之间切换:

ipdb> u
> /home/wesm/code/pydata-book/examples/ipython_bug.py(13)calling_things()
     12     works_fine()
---> 13     throws_an_exception()
     14

执行 %pdb 命令,可以让 IPython 在发生任何异常时自动启动调试器,许多用户会发现这个功能非常好用。

用调试器辅助开发代码也很容易,尤其是希望设置断点或在函数和脚本之间移动、检查每个阶段状态的时候。第一种方法是 %run -d:它会在执行传入脚本的任何代码之前先调用调试器,你必须马上按 s(step)进入脚本:

In [5]: run -d examples/ipython_bug.py
Breakpoint 1 at /home/wesm/code/pydata-book/examples/ipython_bug.py:1
NOTE: Enter 'c' at the ipdb>  prompt to start your script.
> <string>(1)<module>()

ipdb> s
--Call--
> /home/wesm/code/pydata-book/examples/ipython_bug.py(1)<module>()
1---> 1 def works_fine():
      2     a = 5
      3     b = 6

然后,你可以决定如何继续。例如在刚才的异常场景中,我们可以在调用 works_fine 之前设置断点,然后运行脚本,在遇到断点时按 c(continue):

ipdb> b 12
ipdb> c
> /home/wesm/code/pydata-book/examples/ipython_bug.py(12)calling_things()
     11 def calling_things():
2--> 12     works_fine()
     13     throws_an_exception()

此时可以 step 进入 works_fine(),或按 n(next)执行 works_fine() 并跳到下一行:

ipdb> n
> /home/wesm/code/pydata-book/examples/ipython_bug.py(13)calling_things()
2    12     works_fine()
---> 13     throws_an_exception()
     14

然后进入 throws_an_exception,逐行走到出错行,查看变量。注意:调试器命令与变量名可能冲突时,在变量名前面加叹号 ! 即可查看内容:

ipdb> s
--Call--
> /home/wesm/code/pydata-book/examples/ipython_bug.py(6)throws_an_exception()
      5
----> 6 def throws_an_exception():
      7     a = 5

ipdb> n
> /home/wesm/code/pydata-book/examples/ipython_bug.py(7)throws_an_exception()
      6 def throws_an_exception():
----> 7     a = 5
      8     b = 6

ipdb> n
> /home/wesm/code/pydata-book/examples/ipython_bug.py(8)throws_an_exception()
      7     a = 5
----> 8     b = 6
      9     assert(a + b == 10)

ipdb> n
> /home/wesm/code/pydata-book/examples/ipython_bug.py(9)throws_an_exception()
      8     b = 6
----> 9     assert(a + b == 10)
     10

ipdb> !a
5
ipdb> !b
6

原书中"表B-2 IPython调试器命令"同样以截图给出,这里整理为文字表格,命令与 Python 标准库 pdb 一致:

命令 全称 作用
a args 打印当前函数的参数
b break 设置断点(可带行号,如 b 12)
c continue 继续执行到下一个断点
d down 在当前堆栈帧中向下移动
h help 显示调试器帮助
l list 列出当前行附近的源码
n next 执行当前行并停在下一行
p print 打印表达式的值
q quit 退出调试器
r return 执行到当前函数返回
s step 单步进入函数内部
u up 在当前堆栈帧中向上移动
w where 打印当前堆栈跟踪

提高交互式调试器的熟练度需要练习和经验。如果你习惯了 IDE,终端调试器一开始可能不顺手,但会越来越好用;当然,一些 Python IDE 提供了很好的 GUI 调试器,选择顺手的即可。

使用调试器的其它方式:set_trace 与 debug

第一个技巧是使用特殊的 set_trace 函数(根据 pdb.set_trace 命名),它是一个简装的断点。可以将下面两个辅助函数加入 IPython 配置:

from IPython.core.debugger import Pdb

def set_trace():
    Pdb(color_scheme='Linux').set_trace(sys._getframe().f_back)

def debug(f, *args, **kwargs):
    pdb = Pdb(color_scheme='Linux')
    return pdb.runcall(f, *args, **kwargs)

set_trace 非常简单:如果你想暂时停下来仔细检查(比如在异常发生之前),可以在代码的任何位置调用 set_trace:

In [7]: run examples/ipython_bug.py
> /home/wesm/code/pydata-book/examples/ipython_bug.py(16)calling_things()
     15     set_trace()
---> 16     throws_an_exception()
     17

按 c(continue)可以让代码继续正常行进。

而 debug 函数让你可以在调用任何函数时方便地使用调试器。假设我们写了一个函数,想逐步分析它的逻辑:

def f(x, y, z=1):
    tmp = x + y
    return tmp / z

普通地使用 f 就是 f(1, 2, z=3);若要进入 f,把 f 作为第一个参数传给 debug,再把位置和关键字参数传给 f:

In [6]: debug(f, 1, 2, z=3)
> <ipython-input>(2)f()
      1 def f(x, y, z):
----> 2     tmp = x + y
      3     return tmp / z

ipdb>

这两个简单方法可以节省大量时间。

最后,调试器可以和 %run 一起使用:脚本通过 %run -d 直接进入调试器,随意设置断点并启动脚本:

In [1]: %run -d examples/ipython_bug.py
Breakpoint 1 at /home/wesm/code/pydata-book/examples/ipython_bug.py:1
NOTE: Enter 'c' at the ipdb>  prompt to start your script.
> <string>(1)<module>()

ipdb>

加上 -b 和行号,还可以预设断点:

In [2]: %run -d -b2 examples/ipython_bug.py

Breakpoint 1 at /home/wesm/code/pydata-book/examples/ipython_bug.py:2
NOTE: Enter 'c' at the ipdb>  prompt to start your script.
> <string>(1)<module>()

ipdb> c
> /home/wesm/code/pydata-book/examples/ipython_bug.py(2)works_fine()
      1 def works_fine():
1---> 2     a = 5
      3     b = 6

ipdb>

代码计时:%time 和 %timeit

对于大型和长时间运行的数据分析应用,你可能希望测量不同组件或单个函数调用的执行时间,找出哪个函数占用时间最长。手动用 time 模块及其函数 time.clock 和 time.time 给代码计时既单调又重复,因为必须写模板化代码:

import time
start = time.time()
for i in range(iterations):
    # some code to run here
elapsed_per = (time.time() - start) / iterations

IPython 为此提供了两个魔术函数:%time 和 %timeit。

%time 会运行一次语句,报告总执行时间。假设我们有一个大的字符串列表,想比较两种挑选特定开头字符串的方法:

# a very large list of strings
strings = ['foo', 'foobar', 'baz', 'qux',
           'python', 'Guido Van Rossum'] * 100000

method1 = [x for x in strings if x.startswith('foo')]

method2 = [x for x in strings if x[:3] == 'foo']

用 %time 进行测量:

In [561]: %time method1 = [x for x in strings if x.startswith('foo')]
CPU times: user 0.19 s, sys: 0.00 s, total: 0.19 s
Wall time: 0.19 s

In [562]: %time method2 = [x for x in strings if x[:3] == 'foo']
CPU times: user 0.09 s, sys: 0.00 s, total: 0.09 s
Wall time: 0.09 s

Wall time(wall-clock time 的简写)是主要关注的指标。第一个方法耗时是第二个方法的两倍多,但这种单次测量并不准确——多次运行 %time 会发现结果有波动。要得到更准确的结果,使用 %timeit:它会对给定语句多次运行,得到一个更稳定的时间:

In [563]: %timeit [x for x in strings if x.startswith('foo')]
10 loops, best of 3: 159 ms per loop

In [564]: %timeit [x for x in strings if x[:3] == 'foo']
10 loops, best of 3: 59.3 ms per loop

(%timeit 默认执行若干轮循环并取 best of 3 的结果,也可用 -n 指定每轮循环次数、-r 指定重复轮数。)这个例子说明,了解 Python 标准库、NumPy、pandas 和其它库的性能特性很有价值——在大型数据分析中,这些毫秒级差异会累积起来!

%timeit 特别适合分析执行时间短的语句和函数,即使只有微秒或纳秒级。这些时间看似微不足道,但一个 20 微秒的函数执行 100 万次,比一个 5 微秒的函数要多花 15 秒:

In [565]: x = 'foobar'

In [566]: y = 'foo'

In [567]: %timeit x.startswith(y)
1000000 loops, best of 3: 267 ns per loop

In [568]: %timeit x[:3] == y
10000000 loops, best of 3: 147 ns per loop

基础分析:%prun 和 %run -p

代码分析与计时关系紧密,只是它关注的是"时间花在了哪里"。Python 主要的分析工具是 cProfile 模块,它并不局限于 IPython:cProfile 会执行一个程序或任意代码块,并跟踪每个函数执行的时间。

使用 cProfile 的通常方式是在命令行中运行一整段程序,输出每个函数的累积时间。假设有一个在循环中进行线性代数运算的脚本(计算一系列 100×100 矩阵的最大绝对特征值):

import numpy as np
from numpy.linalg import eigvals

def run_experiment(niter=100):
    K = 100
    results = []
    for _ in xrange(niter):
        mat = np.random.randn(K, K)
        max_eigenvalue = np.abs(eigvals(mat)).max()
        results.append(max_eigenvalue)
    return results
some_results = run_experiment()
print 'Largest one we saw: %s' % np.max(some_results)

可以用 cProfile 运行这个脚本:

python -m cProfile cprof_example.py

默认输出按函数名排序,不容易看出谁耗时多,最好用 -s 指定排序方式为 cumulative(累积时间):

$ python -m cProfile -s cumulative cprof_example.py
Largest one we saw: 11.923204422
    15116 function calls (14927 primitive calls) in 0.720 seconds

Ordered by: cumulative time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1    0.001    0.001    0.721    0.721 cprof_example.py:1(<module>)
   100    0.003    0.000    0.586    0.006 linalg.py:702(eigvals)
   200    0.572    0.003    0.572    0.003 {numpy.linalg.lapack_lite.dgeev}
     1    0.002    0.002    0.075    0.075 __init__.py:106(<module>)
   100    0.059    0.001    0.059    0.001 {method 'randn')
     1    0.000    0.000    0.044    0.044 add_newdocs.py:9(<module>)
     2    0.001    0.001    0.037    0.019 __init__.py:1(<module>)
     2    0.003    0.002    0.030    0.015 __init__.py:2(<module>)
     1    0.000    0.000    0.030    0.030 type_check.py:3(<module>)
     1    0.001    0.001    0.021    0.021 __init__.py:15(<module>)
     1    0.013    0.013    0.013    0.013 numeric.py:1(<module>)
     1    0.000    0.000    0.009    0.009 __init__.py:6(<module>)
     1    0.000    0.000    0.008    0.008 __init__.py:45(<module>)
   262    0.005    0.000    0.007    0.000 function_base.py:3178(add_newdoc)
   100    0.003    0.000    0.005    0.000 linalg.py:162(_assertFinite)

以上只显示前 15 行。扫描 cumtime 列,可以容易地看出每个函数占用了多少时间:如果一个函数调用了其它函数,计时并不会停止——cProfile 记录每个函数的起始和结束时间,并用它们计算耗时。

除了命令行使用,cProfile 也可以在程序中用于分析任意代码块,而无需运行新进程。IPython 的 %prun 和 %run -p 提供了便捷接口。%prun 使用类似 cProfile 的命令行选项,但可以分析任意 Python 语句,而不必是完整的 py 文件:

In [4]: %prun -l 7 -s cumulative run_experiment()
         4203 function calls in 0.643 seconds

Ordered by: cumulative time
List reduced from 32 to 7 due to restriction <7>
ncalls  tottime  percall  cumtime  percall filename:lineno(function)
     1    0.000    0.000    0.643    0.643 <string>:1(<module>)
     1    0.001    0.001    0.643    0.643 cprof_example.py:4(run_experiment)
   100    0.003    0.000    0.583    0.006 linalg.py:702(eigvals)
   200    0.569    0.003    0.569    0.003 {numpy.linalg.lapack_lite.dgeev}
   100    0.058    0.001    0.058    0.001 {method 'randn'}
   100    0.003    0.000    0.005    0.000 linalg.py:162(_assertFinite)
   200    0.002    0.000    0.002    0.000 {method 'all' of 'numpy.ndarray'}

这里 -l 7 表示把结果列表缩减到前 7 行,-s cumulative 按累积时间排序。类似地,%run -p -s cumulative cprof_example.py 与命令行方式作用相同,只是你无需离开 IPython。

在 Jupyter notebook 中,可以使用 %%prun 魔术方法(两个百分号)分析一整段代码,这会弹出一个带分析输出的独立窗口,便于快速回答"为什么这段代码花了这么长时间"这类问题。

提示:IPython/Jupyter 生态中还有一些工具能让分析结果更易理解,例如 SnakeViz 会生成分析结果的交互式可视化界面(原书给出了其项目主页,此处不展开)。

逐行分析函数:%lprun 与 line_profiler

有些情况下,%prun(或其它基于 cProfile 的分析方法)给出的信息粒度太粗:要么无法完整反映函数执行时间的过程,要么结果过于复杂、加上函数名后很难解读。对于这种情况,有一个小库 line_profiler(可通过 PyPI 或包管理工具获得),它包含 IPython 插件,可以启用新的魔术函数 %lprun,对单个或多个函数进行逐行分析。

启用插件有两种方式。一是修改 IPython 配置(见 B.5 节)加入下面这行:

# A list of dotted module names of IPython extensions to load.
c.TerminalIPythonApp.extensions = ['line_profiler']

二是直接运行命令:

%load_ext line_profiler

line_profiler 也可以在程序中使用,但在 IPython 中使用最为强大。假设你有一个模块 prof_mod,包含一些 NumPy 数组操作:

from numpy.random import randn

def add_and_sum(x, y):
    added = x + y
    summed = added.sum(axis=1)
    return summed

def call_function():
    x = randn(1000, 1000)
    y = randn(1000, 1000)
    return add_and_sum(x, y)

如果只想了解 add_and_sum 函数的性能,%prun 给出的是函数级结果:

In [569]: %run prof_mod

In [570]: x = randn(3000, 3000)

In [571]: y = randn(3000, 3000)

In [572]: %prun add_and_sum(x, y)
         4 function calls in 0.049 seconds
   Ordered by: internal time
   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.036    0.036    0.046    0.046 prof_mod.py:3(add_and_sum)
        1    0.009    0.009    0.009    0.009 {method 'sum' of 'numpy.ndarray'}
        1    0.003    0.003    0.049    0.049 <string>:1(<module>)

上面的结果启发性不大。激活 line_profiler 插件后,新的 %lprun 命令就可用了。与 %prun 不同的是,我们必须告诉 %lprun 要分析哪些函数,语法为:

%lprun -f func1 -f func2 statement_to_profile

分析 add_and_sum:

In [573]: %lprun -f add_and_sum add_and_sum(x, y)
Timer unit: 1e-06 s
File: prof_mod.py
Function: add_and_sum at line 3
Total time: 0.045936 s
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     3                                           def add_and_sum(x, y):
     4         1        36510  36510.0     79.5      added = x + y
     5         1         9425   9425.0     20.5      summed = added.sum(axis=1)
     6         1            1      1.0      0.0      return summed

结果一目了然:added = x + y 占了 79.5% 的时间。我们还可以同时分析 call_function 和 add_and_sum,得到完整的性能画像:

In [574]: %lprun -f add_and_sum -f call_function call_function()
Timer unit: 1e-06 s
File: prof_mod.py
Function: add_and_sum at line 3
Total time: 0.005526 s
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     3                                           def add_and_sum(x, y):
     4         1         4375   4375.0     79.2      added = x + y
     5         1         1149   1149.0     20.8      summed = added.sum(axis=1)
     6         1            2      2.0      0.0      return summed
File: prof_mod.py
Function: call_function at line 8
Total time: 0.121016 s
Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
     8                                           def call_function():
     9         1        57169  57169.0     47.2      x = randn(1000, 1000)
    10         1        58304  58304.0     48.2      y = randn(1000, 1000)
    11         1         5543   5543.0      4.6      return add_and_sum(x, y)

经验做法是:用 %prun(cProfile)做宏观分析,用 %lprun(line_profiler)做微观分析,两个工具都值得掌握。

笔记:使用 %lprun 必须指明函数名,是因为逐行追踪执行时间的开销过大,追踪无用的函数会显著改变测量结果。

B.4 使用 IPython 高效开发的技巧

方便快捷地写代码、调试和使用是每个人的目标。除了代码风格,流程细节(比如代码重载)也需要调整。这一节的内容更像是一门艺术而非科学,需要你不断试验以达成高效。目标是把代码结构优化到能省时省力地检查程序或函数结果的程度——尤其是当发生错误、需要检查自己或别人数月甚至数年前写的代码时,IPython 比起命令行更适合这类工作。

重载模块依赖

在 Python 中,当你输入 import some_lib,some_lib 中的代码就会被执行,所有变量、函数和定义的引用会被存入新创建的 some_lib 模块命名空间;下一次再输入 some_lib,得到的只是已存在模块命名空间的引用。潜在的问题是:当你用 %run 运行一个依赖其它模块的脚本,而该模块后来被修改过,就会产生"旧代码"问题。假设 test_script.py 中有如下代码:

import some_lib

x = 5
y = [1, 2, 3, 4]
result = some_lib.get_answer(x, y)

如果你运行过 %run test_script.py,随后修改了 some_lib.py,再执行 %run test_script.py 时仍然得到旧版本的 some_lib.py——因为 Python 模块系统是"一次加载"机制。这一点区分了 Python 与其它数据分析环境(如 MATLAB,它会自动传播代码修改)。

解决这个问题有多种方法。第一种是使用标准库 importlib 模块中的 reload 函数:

import some_lib
import importlib

importlib.reload(some_lib)

这可以保证每次运行 test_script.py 时加载最新的 some_lib.py。但如果依赖层级很深,到处使用 reload 会非常麻烦。为此 IPython 提供了一个特殊的 dreload 函数(注意它不是魔术函数),用于深层重载模块:运行过 some_lib.py 之后输入 dreload(some_lib),就会尝试重载 some_lib 及其依赖。这个方法不适用于所有场景,但比重启 IPython 强多了。

代码设计技巧

针对交互式开发,原书总结了几条在实践中很好用的原则:

保持相关对象和数据活跃。 为命令行编写的程序常常是这种形态:

from my_functions import g

def f(x, y):
    return g(x + y)

def main():
    x = 6
    y = 7.5
    result = x + y

if __name__ == '__main__':
    main()

在 IPython 中运行这个程序会出问题:运行之后,任何定义在 main 函数中的结果和对象都无法在 IPython 中被访问到。更好的方法是将 main 中的代码直接放在模块的命名空间中执行(如果想保留模块可导入性,再放进 if __name__ == '__main__': 分支)。这样当你 %run 之后,就可以查看所有定义在 main 中的变量——这等价于在 Jupyter notebook 的代码格中定义顶级变量。

扁平优于嵌套。 深层嵌套的代码总让人联想到洋葱皮:测试或调试一个函数时,需要剥多少层洋葱皮才能到达目标代码?"扁平优于嵌套"是 Python 之禅的一部分,同样适用于交互式代码开发。尽量将函数和类解耦、模块化,有利于测试、调试和交互式使用。

克服对大文件的恐惧。 如果你之前写 Java(或类似语言),可能被告知要让文件保持简短。在多数语言中这是合理建议:太长的文件常被视为坏代码的信号,意味着需要重构和重组。但在用 IPython 开发时,运行 10 个相关联的小文件(每个小于 100 行),往往比维护两三个长文件更让人头疼——更少的文件意味着更少的模块重载和更少的编辑时文件跳转。维护每个都紧密组织的大模块,会更实用也更 Pythonic;经过方案迭代后,偶尔也会把大文件拆成小文件。当然不建议走向极端形成单个超大文件,关键是找到合理、直观的模块库与封装结构,这在团队工作中尤其重要:每个模块都应结构紧密,并能直观地找到负责各功能领域的函数和类。

B.5 IPython 高级功能

要全面地使用 IPython 系统,需要用一种稍微不同的方式写代码,或深入 IPython 的配置系统。

让类对 IPython 友好:实现 __repr__

IPython 会尽可能地在控制台美化展示每个对象的字符串表示。对于许多对象(字典、列表、元组),内置的 pprint 模块可以用来美化格式;但对于用户自定义类,你必须自己生成字符串。假设有一个简单的类:

class Message:
    def __init__(self, msg):
        self.msg = msg

默认的输出不够美观:

In [576]: x = Message('I have a secret')

In [577]: x
Out[577]: <__main__.Message instance at 0x60ebbd8>

IPython 会接收 __repr__ 魔术方法返回的字符串(通过 output = repr(obj))并在控制台打印。因此,添加一个简单的 __repr__ 方法即可得到更有用的输出:

class Message:
    def __init__(self, msg):
        self.msg = msg

    def __repr__(self):
        return 'Message: %s' % self.msg

再次展示:

In [579]: x = Message('I have a secret')

In [580]: x
Out[580]: Message: I have a secret

文件和配置:定制你的 IPython 与 Jupyter

通过扩展配置系统,大多数 IPython 和 Jupyter notebook 的外观(颜色、提示符、行间距等)和动作都是可配置的。通过配置,你可以做到:

  • 改变颜色主题;
  • 改变输入/输出提示符,或删除输出之后、输入之前的空行;
  • 执行任意 Python 语句(例如导入总是要使用的代码,或每次加载 IPython 都要运行的内容);
  • 启用 IPython 总是要运行的插件,比如 line_profiler 的 %lprun 魔术函数;
  • 启用 Jupyter 插件;
  • 定义自己的魔术函数或系统别名。

IPython 的配置存储在特殊的 ipython_config.py 文件中,通常位于用户 home 目录的 .ipython/ 文件夹下。启动 IPython 时,会默认加载存储在 profile_default 文件夹中的默认配置文件。在 Linux 系统上,完整路径形如:

/home/wesm/.ipython/profile_default/ipython_config.py

要生成这个文件,运行:

ipython profile create

该文件内部有大量注释,解释了每个配置选项的作用,内容留给读者自行探索。此外,你可以有多个配置文件。假如想为另一个应用或项目单独建一个配置,创建新 profile 很简单:

ipython profile create secret_project

之后,编辑新创建的 profile_secret_project 目录下的配置文件,并按如下方式启动 IPython:

$ ipython --profile=secret_project
Python 3.5.1 | packaged by conda-forge | (default, May 20 2016, 05:22:56)
Type "copyright", "credits" or "license" for more information.

IPython 5.1.0 -- An enhanced Interactive Python.
?         -> Introduction and overview of IPython's features.
%quickref -> Quick reference.
help      -> Python's own help system.
object?   -> Details about 'object', use 'object??' for extra details.

IPython profile: secret_project

配置 Jupyter 则有些不同,因为 Jupyter 可以使用 Python 之外的其它语言。要创建 Jupyter 配置文件,运行:

jupyter notebook --generate-config

这会在 home 目录的 .jupyter/jupyter_notebook_config.py 创建配置文件。编辑完成后,可以把它重命名:

$ mv ~/.jupyter/jupyter_notebook_config.py ~/.jupyter/my_custom_config.py

打开 Jupyter 时添加 --config 参数即可使用自定义配置:

jupyter notebook --config=~/.jupyter/my_custom_config.py

B.6 总结与后续学习建议

学习完本书中的代码案例,你的 Python 技能已经得到了一定提升。附录B的立场是:持续学习 IPython 和 Jupyter 是值得的,因为这两个项目的设计初衷就是提高生产率;你很可能还会发现更多工具,让 Python 和计算库用起来更便捷。

回顾附录B的核心产出:

  • 命令历史(Ctrl-P/Ctrl-N/Ctrl-R)与输入输出变量(_、_iX、_X)让重复实验、找回结果变得零成本;
  • Shell 集成(!、$ 插值、%alias、%bookmark)让文件与数据操作不离开交互环境;
  • 调试三件套(%debug/%pdb、set_trace/debug、%run -d -b)让异常定位和断点式检查如虎添翼;
  • 性能三板斧(%time、%timeit、%prun 与 %lprun)分别覆盖单次计时、稳定计时、函数级分析和逐行分析;
  • 开发流程(importlib.reload/dreload、保持对象活跃、扁平优于嵌套、拥抱大模块)让交互式迭代更顺畅;
  • 配置系统(ipython profile create、--profile、jupyter --generate-config)让环境按需定制。

建议你在阅读本仓库第4~14章(NumPy、pandas、数据规整、时间序列、建模与案例分析)的过程中,把附录B的工具作为日常配套:脚本反复跑就用 %run 加命令历史,结果不对就用 %debug,性能存疑就用 %timeit 与 %prun,代码要复用就用 dreload 与自定义配置。让这些能力成为肌肉记忆,你的数据分析效率会有质的提升。

登录后查看全文
python_for_data_analysis_2nd_chinese_version