利用Python进行数据分析笔记
第1章 准备工作
1.1 本书的内容
本书讲的是利用Python进行数据控制、处理、整理、分析等方面的具体细节和基本要点。我的目标是介绍Python编程和用于数据处理的库和工具环境,掌握这些,可以让你成为一个数据分析专家。虽然本书的标题是“数据分析”,重点却是Python编程、库,以及用于数据分析的工具。这就是数据分析要用到的Python编程。
什么样的数据?
当书中出现“数据”时,究竟指的是什么呢?主要指的是结构化数据(structured data),这个故意含糊其辞的术语代指了所有通用格式的数据,例如:
- 表格型数据,其中各列可能是不同的类型(字符串、数值、日期等)。比如保存在关系型数据库中或以制表符/逗号为分隔符的文本文件中的那些数据。
- 多维数组(矩阵)。
- 通过关键列(对于SQL用户而言,就是主键和外键)相互联系的多个表。
- 间隔平均或不平均的时间序列。
这绝不是一个完整的列表。大部分数据集都能被转化为更加适合分析和建模的结构化形式,虽然有时这并不是很明显。如果不行的话,也可以将数据集的特征提取为某种结构化形式。例如,一组新闻文章可以被处理为一张词频表,而这张词频表就可以用于情感分析。
大部分电子表格软件(比如Microsoft Excel,它可能是世界上使用最广泛的数据分析工具了)的用户不会对此类数据感到陌生。
1.2 为什么要使用Python进行数据分析
许许多多的人(包括我自己)都很容易爱上Python这门语言。自从1991年诞生以来,Python现在已经成为最受欢迎的动态编程语言之一,其他还有Perl、Ruby等。由于拥有大量的Web框架(比如Rails(Ruby)和Django(Python)),自从2005年,使用Python和Ruby进行网站建设工作非常流行。这些语言常被称作脚本(scripting)语言,因为它们可以用于编写简短而粗糙的小程序(也就是脚本)。我个人并不喜欢“脚本语言”这个术语,因为它好像在说这些语言无法用于构建严谨的软件。在众多解释型语言中,由于各种历史和文化的原因,Python发展出了一个巨大而活跃的科学计算(scientific computing)社区。在过去的10年,Python从一个边缘或“自担风险”的科学计算语言,成为了数据科学、机器学习、学界和工业界软件开发最重要的语言之一。
在数据分析、交互式计算以及数据可视化方面,Python将不可避免地与其他开源和商业的领域特定编程语言/工具进行对比,如R、MATLAB、SAS、Stata等。近年来,由于Python的库(例如pandas和scikit-learn)不断改良,使其成为数据分析任务的一个优选方案。结合其在通用编程方面的强大实力,我们完全可以只使用Python这一种语言构建以数据为中心的应用。
Python作为胶水语言
Python成为成功的科学计算工具的部分原因是,它能够轻松地集成C、C++以及Fortran代码。大部分现代计算环境都利用了一些Fortran和C库来实现线性代数、优选、积分、快速傅里叶变换以及其他诸如此类的算法。许多企业和国家实验室也利用Python来“粘合”那些已经用了多年的遗留软件系统。
大多数软件都是由两部分代码组成的:少量需要占用大部分执行时间的代码,以及大量不经常执行的“胶水代码”。大部分情况下,胶水代码的执行时间是微不足道的。开发人员的精力几乎都是花在优化计算瓶颈上面,有时更是直接转用更低级的语言(比如C)。
解决“两种语言”问题
很多组织通常都会用一种类似于领域特定的计算语言(如SAS和R)对新想法做研究、原型构建和测试,然后再将这些想法移植到某个更大的生产系统中去(可能是用Java、C#或C++编写的)。人们逐渐意识到,Python不仅适用于研究和原型构建,同时也适用于构建生产系统。为什么一种语言就够了,却要使用两个语言的开发环境呢?我相信越来越多的企业也会这样看,因为研究人员和工程技术人员使用同一种编程工具将会给企业带来非常显著的组织效益。
为什么不选Python
虽然Python非常适合构建分析应用以及通用系统,但它对不少应用场景适用性较差。
由于Python是一种解释型编程语言,因此大部分Python代码都要比用编译型语言(比如Java和C++)编写的代码运行慢得多。由于程序员的时间通常都比CPU时间值钱,因此许多人也愿意对此做一些取舍。但是,在那些延迟要求非常小或高资源利用率的应用中(例如高频交易系统),耗费时间使用诸如C++这样更低级、更低生产率的语言进行编程也是值得的。
对于高并发、多线程的应用程序而言(尤其是拥有许多计算密集型线程的应用程序),Python并不是一种理想的编程语言。这是因为Python有一个叫做全局解释器锁(Global Interpreter Lock,GIL)的组件,这是一种防止解释器同时执行多条Python字节码指令的机制。有关“为什么会存在GIL”的技术性原因超出了本书的范围。虽然很多大数据处理应用程序为了能在较短的时间内完成数据集的处理工作都需要运行在计算机集群上,但是仍然有一些情况需要用单进程多线程系统来解决。
这并不是说Python不能执行真正的多线程并行代码。例如,Python的C插件使用原生的C或C++的多线程,可以并行运行而不被GIL影响,只要它们不频繁地与Python对象交互。
1.3 重要的Python库
考虑到那些还不太了解Python科学计算生态系统和库的读者,下面我先对各个库做一个简单的介绍。
NumPy
NumPy(Numerical Python的简称)是Python科学计算的基础包。本书大部分内容都基于NumPy以及构建于其上的库。它提供了以下功能(不限于此):
快速高效的多维数组对象ndarray。
用于对数组执行元素级计算以及直接对数组执行数学运算的函数。
用于读写硬盘上基于数组的数据集的工具。
线性代数运算、傅里叶变换,以及随机数生成。
-成熟的C API, 用于Python插件和原生C、C++、Fortran代码访问NumPy的数据结构和计算工具。
除了为Python提供快速的数组处理能力,NumPy在数据分析方面还有另外一个主要作用,即作为在算法和库之间传递数据的容器。对于数值型数据,NumPy数组在存储和处理数据时要比内置的Python数据结构高效得多。此外,由低级语言(比如C和Fortran)编写的库可以直接操作NumPy数组中的数据,无需进行任何数据复制工作。因此,许多Python的数值计算工具要么使用NumPy数组作为主要的数据结构,要么可以与NumPy进行无缝交互操作。
pandas
pandas提供了快速便捷处理结构化数据的大量数据结构和函数。自从2010年出现以来,它助使Python成为强大而高效的数据分析环境。本书用得最多的pandas对象是DataFrame,它是一个面向列(column-oriented)的二维表结构,另一个是Series,一个一维的标签化数组对象。
pandas兼具NumPy高性能的数组计算功能以及电子表格和关系型数据库(如SQL)灵活的数据处理功能。它提供了复杂精细的索引功能,能更加便捷地完成重塑、切片和切块、聚合以及选取数据子集等操作。因为数据操作、准备、清洗是数据分析最重要的技能,pandas是本书的重点。
作为背景,我是在2008年初开始开发pandas的,那时我任职于AQR Capital Management,一家量化投资管理公司,我有许多工作需求都不能用任何单一的工具解决:
- 有标签轴的数据结构,支持自动或清晰的数据对齐。这可以防止由于数据不对齐,或处理来源不同的索引不同的数据,所造成的错误。
- 集成时间序列功能。
- 相同的数据结构用于处理时间序列数据和非时间序列数据。
- 保存元数据的算术运算和压缩。
- 灵活处理缺失数据。
- 合并和其它流行数据库(例如基于SQL的数据库)的关系操作。
我想只用一种工具就实现所有功能,并使用通用软件开发语言。Python是一个不错的候选语言,但是此时没有集成的数据结构和工具来实现。我一开始就是想把pandas设计为一款适用于金融和商业分析的工具,pandas专注于深度时间序列功能和工具,适用于时间索引化的数据。
对于使用R语言进行统计计算的用户,肯定不会对DataFrame这个名字感到陌生,因为它源自于R的data.frame对象。但与Python不同,data frames是构建于R和它的标准库。因此,pandas的许多功能不属于R或它的扩展包。
pandas这个名字源于panel data(面板数据,这是多维结构化数据集在计量经济学中的术语)以及Python data analysis(Python数据分析)。
matplotlib
matplotlib是最流行的用于绘制图表和其它二维数据可视化的Python库。它最初由John D.Hunter(JDH)创建,目前由一个庞大的开发团队维护。它非常适合创建出版物上用的图表。虽然还有其它的Python可视化库,matplotlib却是使用最广泛的,并且它和其它生态工具配合也非常完美。我认为,可以使用它作为默认的可视化工具。
IPython和Jupyter
IPython项目起初是Fernando Pérez在2001年的一个用以加强和Python交互的子项目。在随后的16年中,它成为了Python数据栈最重要的工具之一。虽然IPython本身没有提供计算和数据分析的工具,它却可以大大提高交互式计算和软件开发的生产率。IPython鼓励“执行-探索”的工作流,区别于其它编程软件的“编辑-编译-运行”的工作流。它还可以方便地访问系统的shell和文件系统。因为大部分的数据分析代码包括探索、试错和重复,IPython可以使工作更快。
2014年,Fernando和IPython团队宣布了Jupyter项目,一个更宽泛的多语言交互计算工具的计划。IPython web notebook变成了Jupyter notebook,现在支持40种编程语言。IPython现在可以作为Jupyter使用Python的内核(一种编程语言模式)。
IPython变成了Jupyter庞大开源项目(一个交互和探索式计算的高效环境)中的一个组件。它最老也是最简单的模式,现在是一个用于编写、测试、调试Python代码的强化shell。你还可以使用通过Jupyter Notebook,一个支持多种语言的交互式网络代码“笔记本”,来使用IPython。IPython shell 和Jupyter notebooks特别适合进行数据探索和可视化。
Jupyter notebooks还可以编写Markdown和HTML内容,它提供了一种创建代码和文本的富文本方法。其它编程语言也在Jupyter中植入了内核,好让在Jupyter中可以使用Python以外的语言。
对我个人而言,我的大部分Python工作都要用到IPython,包括运行、调试和测试代码。
在本书的GitHub页面,你可以找到包含各章节所有代码实例的Jupyter notebooks。
SciPy
SciPy是一组专门解决科学计算中各种标准问题域的包的集合,主要包括下面这些包:
- scipy.integrate:数值积分例程和微分方程求解器。
- scipy.linalg:扩展了由numpy.linalg提供的线性代数例程和矩阵分解功能。
- scipy.optimize:函数优化器(最小化器)以及根查找算法。
- scipy.signal:信号处理工具。
- scipy.sparse:稀疏矩阵和稀疏线性系统求解器。
- scipy.special:SPECFUN(这是一个实现了许多常用数学函数(如伽玛函数)的Fortran库)的包装器。
- scipy.stats:标准连续和离散概率分布(如密度函数、采样器、连续分布函数等)、各种统计检验方法,以及更好的描述统计法。
NumPy和SciPy结合使用,便形成了一个相当完备和成熟的计算平台,可以处理多种传统的科学计算问题。
scikit-learn
2010年诞生以来,scikit-learn成为了Python的通用机器学习工具包。仅仅七年,就汇聚了全世界超过1500名贡献者。它的子模块包括:
- 分类:SVM、近邻、随机森林、逻辑回归等等。
- 回归:Lasso、岭回归等等。
- 聚类:k-均值、谱聚类等等。
- 降维:PCA、特征选择、矩阵分解等等。
- 选型:网格搜索、交叉验证、度量。
- 预处理:特征提取、标准化。
与pandas、statsmodels和IPython一起,scikit-learn对于Python成为高效数据科学编程语言起到了关键作用。虽然本书不会详细讲解scikit-learn,我会简要介绍它的一些模型,以及用其它工具如何使用这些模型。
statsmodels
statsmodels是一个统计分析包,起源于斯坦福大学统计学教授Jonathan Taylor,他设计了多种流行于R语言的回归分析模型。Skipper Seabold和Josef Perktold在2010年正式创建了statsmodels项目,随后汇聚了大量的使用者和贡献者。受到R的公式系统的启发,Nathaniel Smith发展出了Patsy项目,它提供了statsmodels的公式或模型的规范框架。
与scikit-learn比较,statsmodels包含经典统计学和经济计量学的算法。包括如下子模块:
- 回归模型:线性回归,广义线性模型,健壮线性模型,线性混合效应模型等等。
- 方差分析(ANOVA)。
- 时间序列分析:AR,ARMA,ARIMA,VAR和其它模型。
- 非参数方法: 核密度估计,核回归。
- 统计模型结果可视化。
statsmodels更关注与统计推断,提供不确定估计和参数p-值。相反的,scikit-learn注重预测。
同scikit-learn一样,我也只是简要介绍statsmodels,以及如何用NumPy和pandas使用它。
1.4 安装和设置
由于人们用Python所做的事情不同,所以没有一个普适的Python及其插件包的安装方案。由于许多读者的Python科学计算环境都不能完全满足本书的需要,所以接下来我将详细介绍各个操作系统上的安装方法。我推荐免费的Anaconda安装包。写作本书时,Anaconda提供Python 2.7和3.6两个版本,以后可能发生变化。本书使用的是Python 3.6,因此推荐选择Python 3.6或更高版本。
Windows
要在Windows上运行,先下载Anaconda安装包。推荐跟随Anaconda下载页面的Windows安装指导,安装指导在写作本书和读者看到此文的的这段时间内可能发生变化。
现在,来确认设置是否正确。打开命令行窗口(cmd.exe
),输入python
以打开Python解释器。可以看到类似下面的Anaconda版本的输出:
1 | C:\Users\wesm>python |
要退出shell,按Ctrl-D(Linux或macOS上),Ctrl-Z(Windows上),或输入命令exit()
,再按Enter。
Apple (OS X, macOS)
下载OS X Anaconda安装包,它的名字类似Anaconda3-4.1.0-MacOSX-x86_64.pkg。双击.pkg文件,运行安装包。安装包运行时,会自动将Anaconda执行路径添加到.bash_profile
文件,它位于/Users/$USER/.bash_profile
。
为了确认成功,在系统shell打开IPython:
1 | $ ipython |
要退出shell,按Ctrl-D,或输入命令exit()
,再按Enter。
GNU/Linux
Linux版本很多,这里给出Debian、Ubantu、CentOS和Fedora的安装方法。安装包是一个脚本文件,必须在shell中运行。取决于系统是32位还是64位,要么选择x86 (32位)或x86_64 (64位)安装包。随后你会得到一个文件,名字类似于Anaconda3-4.1.0-Linux-x86_64.sh
。用bash进行安装:
1 | $ bash Anaconda3-4.1.0-Linux-x86_64.sh |
笔记:某些Linux版本在包管理器中有满足需求的Python包,只需用类似apt的工具安装就行。这里讲的用Anaconda安装,适用于不同的Linux安装包,也很容易将包升级到最新版本。
接受许可之后,会向你询问在哪里放置Anaconda的文件。我推荐将文件安装到默认的home目录,例如/home/$USER/anaconda
。
Anaconda安装包可能会询问你是否将bin/
目录添加到$PATH
变量。如果在安装之后有任何问题,你可以修改文件.bashrc
(或.zshrc
,如果使用的是zsh shell)为类似以下的内容:
1 | export PATH=/home/$USER/anaconda/bin:$PATH |
做完之后,你可以开启一个新窗口,或再次用~/.bashrc
执行.bashrc
。
安装或升级Python包
在你阅读本书的时候,你可能想安装另外的不在Anaconda中的Python包。通常,可以用以下命令安装:
1 | conda install package_name |
如果这个命令不行,也可以用pip包管理工具:
1 | pip install package_name |
你可以用conda update
命令升级包:
1 | conda update package_name |
pip可以用--upgrade
升级:
1 | pip install --upgrade package_name |
本书中,你有许多机会尝试这些命令。
注意:当你使用conda和pip二者安装包时,千万不要用pip升级conda的包,这样会导致环境发生问题。当使用Anaconda或Miniconda时,最好首先使用conda进行升级。
Python 2 和 Python 3
第一版的Python 3.x出现于2008年。它有一系列的变化,与之前的Python 2.x代码有不兼容的地方。因为从1991年Python出现算起,已经过了17年,Python 3 的出现被视为吸取一些列教训的更优结果。
2012年,因为许多包还没有完全支持Python 3,许多科学和数据分析社区还是在使用Python 2.x。因此,本书第一版使用的是Python 2.7。现在,用户可以在Python 2.x和Python 3.x间自由选择,二者都有良好的支持。
但是,Python 2.x在2020年就会到期(包括重要的安全补丁),因此再用Python 2.7就不是好的选择了。因此,本书使用了Python 3.6,这一广泛使用、支持良好的稳定版本。我们已经称Python 2.x为“遗留版本”,简称Python 3.x为“Python”。我建议你也是如此。
本书基于Python 3.6。你的Python版本也许高于3.6,但是示例代码应该是向前兼容的。一些示例代码可能在Python 2.7上有所不同,或完全不兼容。
集成开发环境(IDEs)和文本编辑器
当被问到我的标准开发环境,我几乎总是回答“IPython加文本编辑器”。我通常在编程时,反复在IPython或Jupyter notebooks中测试和调试每条代码。也可以交互式操作数据,和可视化验证数据操作中某一特殊集合。在shell中使用pandas和NumPy也很容易。
但是,当创建软件时,一些用户可能更想使用特点更为丰富的IDE,而不仅仅是原始的Emacs或Vim的文本编辑器。以下是一些IDE:
- PyDev(免费),基于Eclipse平台的IDE;
- JetBrains的PyCharm(商业用户需要订阅,开源开发者免费);
- Visual Studio(Windows用户)的Python Tools;
- Spyder(免费),Anaconda附带的IDE;
- Komodo IDE(商业)。
因为Python的流行,大多数文本编辑器,比如Atom和Sublime Text 3,对Python的支持也非常好。
1.5 社区和会议
除了在网上搜索,各式各样的科学和数据相关的Python邮件列表是非常有帮助的,很容易获得回答。包括:
- pydata:一个Google群组列表,用以回答Python数据分析和pandas的问题;
- pystatsmodels: statsmodels或pandas相关的问题;
- scikit-learn和Python机器学习邮件列表,scikit-learn@python.org;
- numpy-discussion:和NumPy相关的问题;
- scipy-user:SciPy和科学计算的问题;
因为这些邮件列表的URLs可以很容易搜索到,但因为可能发生变化,所以没有给出。
每年,世界各地会举办许多Python开发者大会。如果你想结识其他有相同兴趣的人,如果可能的话,我建议你去参加一个。许多会议会对无力支付入场费和差旅费的人提供财力帮助。下面是一些会议:
- PyCon和EuroPython:北美和欧洲的两大Python会议;
- SciPy和EuroSciPy:北美和欧洲两大面向科学计算的会议;
- PyData:世界范围内,一些列的地区性会议,专注数据科学和数据分析;
- 国际和地区的PyCon会议(http://pycon.org有完整列表) 。
1.6 本书导航
如果之前从未使用过Python,那你可能需要先看看本书的第2章和第3章,我简要介绍了Python的特点,IPython和Jupyter notebooks。这些知识是为本书后面的内容做铺垫。如果你已经掌握Python,可以选择跳过。
接下来,简单地介绍了NumPy的关键特性,附录A中是更高级的NumPy功能。然后,我介绍了pandas,本书剩余的内容全部是使用pandas、NumPy和matplotlib处理数据分析的问题。我已经尽量让全书的结构循序渐进,但偶尔会有章节之间的交叉,有时用到的概念还没有介绍过。
尽管读者各自的工作任务不同,大体可以分为几类:
与外部世界交互
阅读编写多种文件格式和数据存储;
数据准备
清洗、修改、结合、标准化、重塑、切片、切割、转换数据,以进行分析;
转换数据
对旧的数据集进行数学和统计操作,生成新的数据集(例如,通过各组变量聚类成大的表);
建模和计算
将数据绑定统计模型、机器学习算法、或其他计算工具;
展示
创建交互式和静态的图表可视化和文本总结。
代码示例
本书大部分代码示例的输入形式和输出结果都会按照其在IPython shell或Jupyter notebooks中执行时的样子进行排版:
1 | In [5]: CODE EXAMPLE |
但你看到类似的示例代码,就是让你在in
的部分输入代码,按Enter键执行(Jupyter中是按Shift-Enter)。然后就可以在out
看到输出。
示例数据
各章的示例数据都存放在GitHub上:http://github.com/pydata/pydata-book。 下载这些数据的方法有二:使用git版本控制命令行程序;直接从网站上下载该GitHub库的zip文件。如果遇到了问题,可以到我的个人主页,http://wesmckinney.com/, 获取最新的指导。
为了让所有示例都能重现,我已经尽我所能使其包含所有必需的东西,但仍然可能会有一些错误或遗漏。如果出现这种情况的话,请给我发邮件:wesmckinn@gmail.com。报告本书错误的最好方法是O’Reilly的errata页面,http://www.bit.ly/pyDataAnalysis_errata。
引入惯例
Python社区已经广泛采取了一些常用模块的命名惯例:
1 | import numpy as np |
也就是说,当你看到np.arange时,就应该想到它引用的是NumPy中的arange函数。这样做的原因是:在Python软件开发过程中,不建议直接引入类似NumPy这种大型库的全部内容(from numpy import *)。
行话
由于你可能不太熟悉书中使用的一些有关编程和数据科学方面的常用术语,所以我在这里先给出其简单定义:
数据规整(Munge/Munging/Wrangling) 指的是将非结构化和(或)散乱数据处理为结构化或整洁形式的整个过程。这几个词已经悄悄成为当今数据黑客们的行话了。Munge这个词跟Lunge押韵。
伪码(Pseudocode) 算法或过程的“代码式”描述,而这些代码本身并不是实际有效的源代码。
语法糖(Syntactic sugar) 这是一种编程语法,它并不会带来新的特性,但却能使代码更易读、更易写。
第2章 Python语法基础,IPython和Jupyter Notebooks
当我在2011年和2012年写作本书的第一版时,可用的学习Python数据分析的资源很少。这部分上是一个鸡和蛋的问题:我们现在使用的库,比如pandas、scikit-learn和statsmodels,那时相对来说并不成熟。2017年,数据科学、数据分析和机器学习的资源已经很多,原来通用的科学计算拓展到了计算机科学家、物理学家和其它研究领域的工作人员。学习Python和成为软件工程师的优秀书籍也有了。
因为这本书是专注于Python数据处理的,对于一些Python的数据结构和库的特性难免不足。因此,本章和第3章的内容只够你能学习本书后面的内容。
在我来看,没有必要为了数据分析而去精通Python。我鼓励你使用IPython shell和Jupyter试验示例代码,并学习不同类型、函数和方法的文档。虽然我已尽力让本书内容循序渐进,但读者偶尔仍会碰到没有之前介绍过的内容。
本书大部分内容关注的是基于表格的分析和处理大规模数据集的数据准备工具。为了使用这些工具,必须首先将混乱的数据规整为整洁的表格(或结构化)形式。幸好,Python是一个理想的语言,可以快速整理数据。Python使用得越熟练,越容易准备新数据集以进行分析。
最好在IPython和Jupyter中亲自尝试本书中使用的工具。当你学会了如何启动Ipython和Jupyter,我建议你跟随示例代码进行练习。与任何键盘驱动的操作环境一样,记住常见的命令也是学习曲线的一部分。
笔记:本章没有介绍Python的某些概念,如类和面向对象编程,你可能会发现它们在Python数据分析中很有用。 为了加强Python知识,我建议你学习官方Python教程,https://docs.python.org/3/,或是通用的Python教程书籍,比如:
- Python Cookbook,第3版,David Beazley和Brian K. Jones著(O’Reilly)
- 流畅的Python,Luciano Ramalho著 (O’Reilly)
- 高效的Python,Brett Slatkin著 (Pearson)
2.1 Python解释器
Python是解释性语言。Python解释器同一时间只能运行一个程序的一条语句。标准的交互Python解释器可以在命令行中通过键入python
命令打开:
1 | $ python |
>>>
提示输入代码。要退出Python解释器返回终端,可以输入exit()
或按Ctrl-D。
运行Python程序只需调用Python的同时,使用一个.py
文件作为它的第一个参数。假设创建了一个hello_world.py
文件,它的内容是:
1 | print('Hello world') |
你可以用下面的命令运行它(hello_world.py
文件必须位于终端的工作目录):
1 | $ python hello_world.py |
一些Python程序员总是这样执行Python代码的,从事数据分析和科学计算的人却会使用IPython,一个强化的Python解释器,或Jupyter notebooks,一个网页代码笔记本,它原先是IPython的一个子项目。在本章中,我介绍了如何使用IPython和Jupyter,在附录A中有更深入的介绍。当你使用%run
命令,IPython会同样执行指定文件中的代码,结束之后,还可以与结果交互:
1 | $ ipython |
IPython默认采用序号的格式In [2]:
,与标准的>>>
提示符不同。
2.2 IPython基础
在本节中,我们会教你打开运行IPython shell和jupyter notebook,并介绍一些基本概念。
运行IPython Shell
你可以用ipython
在命令行打开IPython Shell,就像打开普通的Python解释器:
1 | $ ipython |
你可以通过输入代码并按Return(或Enter),运行任意Python语句。当你只输入一个变量,它会显示代表的对象:
1 | In [5]: import numpy as np |
前两行是Python代码语句;第二条语句创建一个名为data
的变量,它引用一个新创建的Python字典。最后一行打印data
的值。
许多Python对象被格式化为更易读的形式,或称作pretty-printed
,它与普通的print
不同。如果在标准Python解释器中打印上述data
变量,则可读性要降低:
1 | >>> from numpy.random import randn |
IPython还支持执行任意代码块(通过一个华丽的复制-粘贴方法)和整段Python脚本的功能。你也可以使用Jupyter notebook运行大代码块,接下来就会看到。
运行Jupyter Notebook
notebook是Jupyter项目的重要组件之一,它是一个代码、文本(有标记或无标记)、数据可视化或其它输出的交互式文档。Jupyter Notebook需要与内核互动,内核是Jupyter与其它编程语言的交互编程协议。Python的Jupyter内核是使用IPython。要启动Jupyter,在命令行中输入jupyter notebook
:
1 | $ jupyter notebook |
在多数平台上,Jupyter会自动打开默认的浏览器(除非指定了--no-browser
)。或者,可以在启动notebook之后,手动打开网页http://localhost:8888/
。图2-1展示了Google Chrome中的notebook。
笔记:许多人使用Jupyter作为本地的计算环境,但它也可以部署到服务器上远程访问。这里不做介绍,如果需要的话,鼓励读者自行到网上学习。
要新建一个notebook,点击按钮New,选择“Python3”或“conda[默认项]”。如果是第一次,点击空格,输入一行Python代码。然后按Shift-Enter执行。
当保存notebook时(File目录下的Save and Checkpoint),会创建一个后缀名为.ipynb
的文件。这是一个自包含文件格式,包含当前笔记本中的所有内容(包括所有已评估的代码输出)。可以被其它Jupyter用户加载和编辑。要加载存在的notebook,把它放到启动notebook进程的相同目录内。你可以用本书的示例代码练习,见图2-3。
虽然Jupyter notebook和IPython shell使用起来不同,本章中几乎所有的命令和工具都可以通用。
Tab补全
从外观上,IPython shell和标准的Python解释器只是看起来不同。IPython shell的进步之一是具备其它IDE和交互计算分析环境都有的tab补全功能。在shell中输入表达式,按下Tab,会搜索已输入变量(对象、函数等等)的命名空间:
1 | In [1]: an_apple = 27 |
在这个例子中,IPython呈现出了之前两个定义的变量和Python的关键字和内建的函数any
。当然,你也可以补全任何对象的方法和属性:
1 | In [3]: b = [1, 2, 3] |
同样也适用于模块:
1 | In [1]: import datetime |
在Jupyter notebook和新版的IPython(5.0及以上),自动补全功能是下拉框的形式。
笔记:注意,默认情况下,IPython会隐藏下划线开头的方法和属性,比如魔术方法和内部的“私有”方法和属性,以避免混乱的显示(和让新手迷惑!)这些也可以tab补全,但是你必须首先键入一个下划线才能看到它们。如果你喜欢总是在tab补全中看到这样的方法,你可以IPython配置中进行设置。可以在IPython文档中查找方法。
除了补全命名、对象和模块属性,Tab还可以补全其它的。当输入看似文件路径时(即使是Python字符串),按下Tab也可以补全电脑上对应的文件信息:
1 | In [7]: datasets/movielens/<Tab> |
结合%run
,tab补全可以节省许多键盘操作。
另外,tab补全可以补全函数的关键词参数(包括等于号=)。见图2-4。
后面会仔细地学习函数。
自省
在变量前后使用问号?,可以显示对象的信息:
1 | In [8]: b = [1, 2, 3] |
这可以作为对象的自省。如果对象是一个函数或实例方法,定义过的文档字符串,也会显示出信息。假设我们写了一个如下的函数:
1 | def add_numbers(a, b): |
然后使用?符号,就可以显示如下的文档字符串:
1 | In [11]: add_numbers? |
使用??会显示函数的源码:
1 | In [12]: add_numbers?? |
?还有一个用途,就是像Unix或Windows命令行一样搜索IPython的命名空间。字符与通配符结合可以匹配所有的名字。例如,我们可以获得所有包含load的顶级NumPy命名空间:
1 | In [13]: np.*load*? |
%run命令
你可以用%run
命令运行所有的Python程序。假设有一个文件ipython_script_test.py
:
1 | def f(x, y, z): |
可以如下运行:
1 | In [14]: %run ipython_script_test.py |
这段脚本运行在空的命名空间(没有import和其它定义的变量),因此结果和普通的运行方式python script.py
相同。文件中所有定义的变量(import、函数和全局变量,除非抛出异常),都可以在IPython shell中随后访问:
1 | In [15]: c |
如果一个Python脚本需要命令行参数(在sys.argv
中查找),可以在文件路径之后传递,就像在命令行上运行一样。
笔记:如果想让一个脚本访问IPython已经定义过的变量,可以使用
%run -i
。
在Jupyter notebook中,你也可以使用%load
,它将脚本导入到一个代码格中:
1 | >>> %load ipython_script_test.py |
中断运行的代码
代码运行时按Ctrl-C,无论是%run或长时间运行命令,都会导致KeyboardInterrupt
。这会导致几乎所有Python程序立即停止,除非一些特殊情况。
警告:当Python代码调用了一些编译的扩展模块,按Ctrl-C不一定将执行的程序立即停止。在这种情况下,你必须等待,直到控制返回Python解释器,或者在更糟糕的情况下强制终止Python进程。
从剪贴板执行程序
如果使用Jupyter notebook,你可以将代码复制粘贴到任意代码格执行。在IPython shell中也可以从剪贴板执行。假设在其它应用中复制了如下代码:
1 | x = 5 |
最简单的方法是使用%paste
和%cpaste
函数。%paste
可以直接运行剪贴板中的代码:
1 | In [17]: %paste |
%cpaste
功能类似,但会给出一条提示:
1 | In [18]: %cpaste |
使用%cpaste
,你可以粘贴任意多的代码再运行。你可能想在运行前,先看看代码。如果粘贴了错误的代码,可以用Ctrl-C中断。
键盘快捷键
IPython有许多键盘快捷键进行导航提示(类似Emacs文本编辑器或UNIX bash Shell)和交互shell的历史命令。表2-1总结了常见的快捷键。图2-5展示了一部分,如移动光标。
Jupyter notebooks有另外一套庞大的快捷键。因为它的快捷键比IPython的变化快,建议你参阅Jupyter notebook的帮助文档。
魔术命令
IPython中特殊的命令(Python中没有)被称作“魔术”命令。这些命令可以使普通任务更便捷,更容易控制IPython系统。魔术命令是在指令前添加百分号%前缀。例如,可以用%timeit
(这个命令后面会详谈)测量任何Python语句,例如矩阵乘法,的执行时间:
1 | In [20]: a = np.random.randn(100, 100) |
魔术命令可以被看做IPython中运行的命令行。许多魔术命令有“命令行”选项,可以通过?查看:
1 | In [21]: %debug? |
魔术函数默认可以不用百分号,只要没有变量和函数名相同。这个特点被称为“自动魔术”,可以用%automagic
打开或关闭。
一些魔术函数与Python函数很像,它的结果可以赋值给一个变量:
1 | In [22]: %pwd |
IPython的文档可以在shell中打开,我建议你用%quickref
或%magic
学习下所有特殊命令。表2-2列出了一些可以提高生产率的交互计算和Python开发的IPython指令。
集成Matplotlib
IPython在分析计算领域能够流行的原因之一是它非常好的集成了数据可视化和其它用户界面库,比如matplotlib。不用担心以前没用过matplotlib,本书后面会详细介绍。%matplotlib
魔术函数配置了IPython shell和Jupyter notebook中的matplotlib。这点很重要,其它创建的图不会出现(notebook)或获取session的控制,直到结束(shell)。
在IPython shell中,运行%matplotlib
可以进行设置,可以创建多个绘图窗口,而不会干扰控制台session:
1 | In [26]: %matplotlib |
在JUpyter中,命令有所不同(图2-6):
1 | In [26]: %matplotlib inline |
2.3 Python语法基础
在本节中,我将概述基本的Python概念和语言机制。在下一章,我将详细介绍Python的数据结构、函数和其它内建工具。
语言的语义
Python的语言设计强调的是可读性、简洁和清晰。有些人称Python为“可执行的伪代码”。
使用缩进,而不是括号
Python使用空白字符(tab和空格)来组织代码,而不是像其它语言,比如R、C++、JAVA和Perl那样使用括号。看一个排序算法的for
循环:
1 | for x in array: |
冒号标志着缩进代码块的开始,冒号之后的所有代码的缩进量必须相同,直到代码块结束。不管是否喜欢这种形式,使用空白符是Python程序员开发的一部分,在我看来,这可以让python的代码可读性大大优于其它语言。虽然期初看起来很奇怪,经过一段时间,你就能适应了。
笔记:我强烈建议你使用四个空格作为默认的缩进,可以使用tab代替四个空格。许多文本编辑器的设置是使用制表位替代空格。某些人使用tabs或不同数目的空格数,常见的是使用两个空格。大多数情况下,四个空格是大多数人采用的方法,因此建议你也这样做。
你应该已经看到,Python的语句不需要用分号结尾。但是,分号却可以用来给同在一行的语句切分:
1 | a = 5; b = 6; c = 7 |
Python不建议将多条语句放到一行,这会降低代码的可读性。
万物皆对象
Python语言的一个重要特性就是它的对象模型的一致性。每个数字、字符串、数据结构、函数、类、模块等等,都是在Python解释器的自有“盒子”内,它被认为是Python对象。每个对象都有类型(例如,字符串或函数)和内部数据。在实际中,这可以让语言非常灵活,因为函数也可以被当做对象使用。
注释
任何前面带有井号#的文本都会被Python解释器忽略。这通常被用来添加注释。有时,你会想排除一段代码,但并不删除。简便的方法就是将其注释掉:
1 | results = [] |
也可以在执行过的代码后面添加注释。一些人习惯在代码之前添加注释,前者这种方法有时也是有用的:
1 | print("Reached this line") # Simple status report |
函数和对象方法调用
你可以用圆括号调用函数,传递零个或几个参数,或者将返回值给一个变量:
1 | result = f(x, y, z) |
几乎Python中的每个对象都有附加的函数,称作方法,可以用来访问对象的内容。可以用下面的语句调用:
1 | obj.some_method(x, y, z) |
函数可以使用位置和关键词参数:
1 | result = f(a, b, c, d=5, e='foo') |
后面会有更多介绍。
变量和参数传递
当在Python中创建变量(或名字),你就在等号右边创建了一个对这个变量的引用。考虑一个整数列表:
1 | In [8]: a = [1, 2, 3] |
假设将a赋值给一个新变量b:
1 | In [9]: b = a |
在有些方法中,这个赋值会将数据[1, 2, 3]也复制。在Python中,a和b实际上是同一个对象,即原有列表[1, 2, 3](见图2-7)。你可以在a中添加一个元素,然后检查b:
1 | In [10]: a.append(4) |
理解Python的引用的含义,数据是何时、如何、为何复制的,是非常重要的。尤其是当你用Python处理大的数据集时。
笔记:赋值也被称作绑定,我们是把一个名字绑定给一个对象。变量名有时可能被称为绑定变量。
当你将对象作为参数传递给函数时,新的局域变量创建了对原始对象的引用,而不是复制。如果在函数里绑定一个新对象到一个变量,这个变动不会反映到上一层。因此可以改变可变参数的内容。假设有以下函数:
1 | def append_element(some_list, element): |
然后有:
1 | In [27]: data = [1, 2, 3] |
动态引用,强类型
与许多编译语言(如JAVA和C++)对比,Python中的对象引用不包含附属的类型。下面的代码是没有问题的:
1 | In [12]: a = 5 |
变量是在特殊命名空间中的对象的名字,类型信息保存在对象自身中。一些人可能会说Python不是“类型化语言”。这是不正确的,看下面的例子:
1 | In [16]: '5' + 5 |
在某些语言中,例如Visual Basic,字符串‘5’可能被默许转换(或投射)为整数,因此会产生10。但在其它语言中,例如JavaScript,整数5会被投射成字符串,结果是联结字符串‘55’。在这个方面,Python被认为是强类型化语言,意味着每个对象都有明确的类型(或类),默许转换只会发生在特定的情况下,例如:
1 | In [17]: a = 4.5 |
知道对象的类型很重要,最好能让函数可以处理多种类型的输入。你可以用isinstance
函数检查对象是某个类型的实例:
1 | In [21]: a = 5 |
isinstance
可以用类型元组,检查对象的类型是否在元组中:
1 | In [23]: a = 5; b = 4.5 |
属性和方法
Python的对象通常都有属性(其它存储在对象内部的Python对象)和方法(对象的附属函数可以访问对象的内部数据)。可以用obj.attribute_name
访问属性和方法:
1 | In [1]: a = 'foo' |
也可以用getattr
函数,通过名字访问属性和方法:
1 | In [27]: getattr(a, 'split') |
在其它语言中,访问对象的名字通常称作“反射”。本书不会大量使用getattr
函数和相关的hasattr
和setattr
函数,使用这些函数可以高效编写原生的、可重复使用的代码。
鸭子类型
经常地,你可能不关心对象的类型,只关心对象是否有某些方法或用途。这通常被称为“鸭子类型”,来自“走起来像鸭子、叫起来像鸭子,那么它就是鸭子”的说法。例如,你可以通过验证一个对象是否遵循迭代协议,判断它是可迭代的。对于许多对象,这意味着它有一个__iter__
魔术方法,其它更好的判断方法是使用iter
函数:
1 | def isiterable(obj): |
这个函数会返回字符串以及大多数Python集合类型为True
:
1 | In [29]: isiterable('a string') |
我总是用这个功能编写可以接受多种输入类型的函数。常见的例子是编写一个函数可以接受任意类型的序列(list、tuple、ndarray)或是迭代器。你可先检验对象是否是列表(或是NUmPy数组),如果不是的话,将其转变成列表:
1 | if not isinstance(x, list) and isiterable(x): |
引入
在Python中,模块就是一个有.py
扩展名、包含Python代码的文件。假设有以下模块:
1 | # some_module.py |
如果想从同目录下的另一个文件访问some_module.py
中定义的变量和函数,可以:
1 | import some_module |
或者:
1 | from some_module import f, g, PI |
使用as
关键词,你可以给引入起不同的变量名:
1 | import some_module as sm |
二元运算符和比较运算符
大多数二元数学运算和比较都不难想到:
1 | In [32]: 5 - 7 |
表2-3列出了所有的二元运算符。
要判断两个引用是否指向同一个对象,可以使用is
方法。is not
可以判断两个对象是不同的:
1 | In [35]: a = [1, 2, 3] |
因为list
总是创建一个新的Python列表(即复制),我们可以断定c是不同于a的。使用is
比较与==
运算符不同,如下:
1 | In [40]: a == c |
is
和is not
常用来判断一个变量是否为None
,因为只有一个None
的实例:
1 | In [41]: a = None |
可变与不可变对象
Python中的大多数对象,比如列表、字典、NumPy数组,和用户定义的类型(类),都是可变的。意味着这些对象或包含的值可以被修改:
1 | In [43]: a_list = ['foo', 2, [4, 5]] |
其它的,例如字符串和元组,是不可变的:
1 | In [46]: a_tuple = (3, 5, (4, 5)) |
记住,可以修改一个对象并不意味就要修改它。这被称为副作用。例如,当写一个函数,任何副作用都要在文档或注释中写明。如果可能的话,我推荐避免副作用,采用不可变的方式,即使要用到可变对象。
标量类型
Python的标准库中有一些内建的类型,用于处理数值数据、字符串、布尔值,和日期时间。这些单值类型被称为标量类型,本书中称其为标量。表2-4列出了主要的标量。日期和时间处理会另外讨论,因为它们是标准库的datetime
模块提供的。
数值类型
Python的主要数值类型是int
和float
。int
可以存储任意大的数:
1 | In [48]: ival = 17239871 |
浮点数使用Python的float
类型。每个数都是双精度(64位)的值。也可以用科学计数法表示:
1 | In [50]: fval = 7.243 |
不能得到整数的除法会得到浮点数:
1 | In [52]: 3 / 2 |
要获得C-风格的整除(去掉小数部分),可以使用底除运算符//:
1 | In [53]: 3 // 2 |
字符串
许多人是因为Python强大而灵活的字符串处理而使用Python的。你可以用单引号或双引号来写字符串:
1 | a = 'one way of writing a string' |
对于有换行符的字符串,可以使用三引号,’’’或”””都行:
1 | c = """ |
字符串c
实际包含四行文本,”””后面和lines后面的换行符。可以用count
方法计算c
中的新的行:
1 | In [55]: c.count('\n') |
Python的字符串是不可变的,不能修改字符串:
1 | In [56]: a = 'this is a string' |
经过以上的操作,变量a
并没有被修改:
1 | In [60]: a |
许多Python对象使用str
函数可以被转化为字符串:
1 | In [61]: a = 5.6 |
字符串是一个序列的Unicode字符,因此可以像其它序列,比如列表和元组(下一章会详细介绍两者)一样处理:
1 | In [64]: s = 'python' |
语法s[:3]
被称作切片,适用于许多Python序列。后面会更详细的介绍,本书中用到很多切片。
反斜杠是转义字符,意思是它备用来表示特殊字符,比如换行符\n或Unicode字符。要写一个包含反斜杠的字符串,需要进行转义:
1 | In [67]: s = '12\\34' |
如果字符串中包含许多反斜杠,但没有特殊字符,这样做就很麻烦。幸好,可以在字符串前面加一个r,表明字符就是它自身:
1 | In [69]: s = r'this\has\no\special\characters' |
r表示raw。
将两个字符串合并,会产生一个新的字符串:
1 | In [71]: a = 'this is the first half ' |
字符串的模板化或格式化,是另一个重要的主题。Python 3拓展了此类的方法,这里只介绍一些。字符串对象有format
方法,可以替换格式化的参数为字符串,产生一个新的字符串:
1 | In [74]: template = '{0:.2f} {1:s} are worth US${2:d}' |
在这个字符串中,
{0:.2f}
表示格式化第一个参数为带有两位小数的浮点数。{1:s}
表示格式化第二个参数为字符串。{2:d}
表示格式化第三个参数为一个整数。
要替换参数为这些格式化的参数,我们传递format
方法一个序列:
1 | In [75]: template.format(4.5560, 'Argentine Pesos', 1) |
字符串格式化是一个很深的主题,有多种方法和大量的选项,可以控制字符串中的值是如何格式化的。推荐参阅Python官方文档。
这里概括介绍字符串处理,第8章的数据分析会详细介绍。
字节和Unicode
在Python 3及以上版本中,Unicode是一级的字符串类型,这样可以更一致的处理ASCII和Non-ASCII文本。在老的Python版本中,字符串都是字节,不使用Unicode编码。假如知道字符编码,可以将其转化为Unicode。看一个例子:
1 | In [76]: val = "español" |
可以用encode
将这个Unicode字符串编码为UTF-8:
1 | In [78]: val_utf8 = val.encode('utf-8') |
如果你知道一个字节对象的Unicode编码,用decode
方法可以解码:
1 | In [81]: val_utf8.decode('utf-8') |
虽然UTF-8编码已经变成主流,但因为历史的原因,你仍然可能碰到其它编码的数据:
1 | In [82]: val.encode('latin1') |
工作中碰到的文件很多都是字节对象,盲目地将所有数据编码为Unicode是不可取的。
虽然用的不多,你可以在字节文本的前面加上一个b:
1 | In [85]: bytes_val = b'this is bytes' |
布尔值
Python中的布尔值有两个,True和False。比较和其它条件表达式可以用True和False判断。布尔值可以与and和or结合使用:
1 | In [89]: True and True |
类型转换
str、bool、int和float也是函数,可以用来转换类型:
1 | In [91]: s = '3.14159' |
None
None是Python的空值类型。如果一个函数没有明确的返回值,就会默认返回None:
1 | In [97]: a = None |
None也常常作为函数的默认参数:
1 | def add_and_maybe_multiply(a, b, c=None): |
另外,None不仅是一个保留字,还是唯一的NoneType的实例:
1 | In [101]: type(None) |
日期和时间
Python内建的datetime
模块提供了datetime
、date
和time
类型。datetime
类型结合了date
和time
,是最常使用的:
1 | In [102]: from datetime import datetime, date, time |
根据datetime
实例,你可以用date
和time
提取出各自的对象:
1 | In [106]: dt.date() |
strftime
方法可以将datetime格式化为字符串:
1 | In [108]: dt.strftime('%m/%d/%Y %H:%M') |
strptime
可以将字符串转换成datetime
对象:
1 | In [109]: datetime.strptime('20091031', '%Y%m%d') |
表2-5列出了所有的格式化命令。
当你聚类或对时间序列进行分组,替换datetimes的time字段有时会很有用。例如,用0替换分和秒:
1 | In [110]: dt.replace(minute=0, second=0) |
因为datetime.datetime
是不可变类型,上面的方法会产生新的对象。
两个datetime对象的差会产生一个datetime.timedelta
类型:
1 | In [111]: dt2 = datetime(2011, 11, 15, 22, 30) |
结果timedelta(17, 7179)
指明了timedelta
将17天、7179秒的编码方式。
将timedelta
添加到datetime
,会产生一个新的偏移datetime
:
1 | In [115]: dt |
控制流
Python有若干内建的关键字进行条件逻辑、循环和其它控制流操作。
if、elif和else
if是最广为人知的控制流语句。它检查一个条件,如果为True,就执行后面的语句:
1 | if x < 0: |
if
后面可以跟一个或多个elif
,所有条件都是False时,还可以添加一个else
:
1 | if x < 0: |
如果某个条件为True,后面的elif
就不会被执行。当使用and和or时,复合条件语句是从左到右执行:
1 | In [117]: a = 5; b = 7 |
在这个例子中,c > d
不会被执行,因为第一个比较是True:
也可以把比较式串在一起:
1 | In [120]: 4 > 3 > 2 > 1 |
for循环
for循环是在一个集合(列表或元组)中进行迭代,或者就是一个迭代器。for循环的标准语法是:
1 | for value in collection: |
你可以用continue使for循环提前,跳过剩下的部分。看下面这个例子,将一个列表中的整数相加,跳过None:
1 | sequence = [1, 2, None, 4, None, 5] |
可以用break
跳出for循环。下面的代码将各元素相加,直到遇到5:
1 | sequence = [1, 2, 0, 4, 6, 5, 2, 1] |
break只中断for循环的最内层,其余的for循环仍会运行:
1 | In [121]: for i in range(4): |
如果集合或迭代器中的元素序列(元组或列表),可以用for循环将其方便地拆分成变量:
1 | for a, b, c in iterator: |
While循环
while循环指定了条件和代码,当条件为False或用break退出循环,代码才会退出:
1 | x = 256 |
pass
pass是Python中的非操作语句。代码块不需要任何动作时可以使用(作为未执行代码的占位符);因为Python需要使用空白字符划定代码块,所以需要pass:
1 | if x < 0: |
range
range函数返回一个迭代器,它产生一个均匀分布的整数序列:
1 | In [122]: range(10) |
range的三个参数是(起点,终点,步进):
1 | In [124]: list(range(0, 20, 2)) |
可以看到,range产生的整数不包括终点。range的常见用法是用序号迭代序列:
1 | seq = [1, 2, 3, 4] |
可以使用list来存储range在其他数据结构中生成的所有整数,默认的迭代器形式通常是你想要的。下面的代码对0到99999中3或5的倍数求和:
1 | sum = 0 |
虽然range可以产生任意大的数,但任意时刻耗用的内存却很小。
三元表达式
Python中的三元表达式可以将if-else语句放到一行里。语法如下:
1 | value = true-expr if condition else false-expr |
true-expr
或false-expr
可以是任何Python代码。它和下面的代码效果相同:
1 | if condition: |
下面是一个更具体的例子:
1 | In [126]: x = 5 |
和if-else一样,只有一个表达式会被执行。因此,三元表达式中的if和else可以包含大量的计算,但只有True的分支会被执行。因此,三元表达式中的if和else可以包含大量的计算,但只有True的分支会被执行。
虽然使用三元表达式可以压缩代码,但会降低代码可读性。
第3章 Python的数据结构、函数和文件
本章讨论Python的内置功能,这些功能本书会用到很多。虽然扩展库,比如pandas和Numpy,使处理大数据集很方便,但它们是和Python的内置数据处理工具一同使用的。
我们会从Python最基础的数据结构开始:元组、列表、字典和集合。然后会讨论创建你自己的、可重复使用的Python函数。最后,会学习Python的文件对象,以及如何与本地硬盘交互。
3.1 数据结构和序列
Python的数据结构简单而强大。通晓它们才能成为熟练的Python程序员。
元组
元组是一个固定长度,不可改变的Python序列对象。创建元组的最简单方式,是用逗号分隔一列值:
1 | In [1]: tup = 4, 5, 6 |
当用复杂的表达式定义元组,最好将值放到圆括号内,如下所示:
1 | In [3]: nested_tup = (4, 5, 6), (7, 8) |
用tuple
可以将任意序列或迭代器转换成元组:
1 | In [5]: tuple([4, 0, 2]) |
可以用方括号访问元组中的元素。和C、C++、JAVA等语言一样,序列是从0开始的:
1 | In [8]: tup[0] |
元组中存储的对象可能是可变对象。一旦创建了元组,元组中的对象就不能修改了:
1 | In [9]: tup = tuple(['foo', [1, 2], True]) |
如果元组中的某个对象是可变的,比如列表,可以在原位进行修改:
1 | In [11]: tup[1].append(3) |
可以用加号运算符将元组串联起来:
1 | In [13]: (4, None, 'foo') + (6, 0) + ('bar',) |
元组乘以一个整数,像列表一样,会将几个元组的复制串联起来:
1 | In [14]: ('foo', 'bar') * 4 |
对象本身并没有被复制,只是引用了它。
拆分元组
如果你想将元组赋值给类似元组的变量,Python会试图拆分等号右边的值:
1 | In [15]: tup = (4, 5, 6) |
即使含有元组的元组也会被拆分:
1 | In [18]: tup = 4, 5, (6, 7) |
使用这个功能,你可以很容易地替换变量的名字,其它语言可能是这样:
1 | tmp = a |
但是在Python中,替换可以这样做:
1 | In [21]: a, b = 1, 2 |
变量拆分常用来迭代元组或列表序列:
1 | In [27]: seq = [(1, 2, 3), (4, 5, 6), (7, 8, 9)] |
另一个常见用法是从函数返回多个值。后面会详解。
Python最近新增了更多高级的元组拆分功能,允许从元组的开头“摘取”几个元素。它使用了特殊的语法*rest
,这也用在函数签名中以抓取任意长度列表的位置参数:
1 | In [29]: values = 1, 2, 3, 4, 5 |
rest
的部分是想要舍弃的部分,rest的名字不重要。作为惯用写法,许多Python程序员会将不需要的变量使用下划线:
1 | In [33]: a, b, *_ = values |
tuple方法
因为元组的大小和内容不能修改,它的实例方法都很轻量。其中一个很有用的就是count
(也适用于列表),它可以统计某个值得出现频率:
1 | In [34]: a = (1, 2, 2, 2, 3, 4, 2) |
列表
与元组对比,列表的长度可变、内容可以被修改。你可以用方括号定义,或用list
函数:
1 | In [36]: a_list = [2, 3, 7, None] |
列表和元组的语义接近,在许多函数中可以交叉使用。
list
函数常用来在数据处理中实体化迭代器或生成器:
1 | In [42]: gen = range(10) |
添加和删除元素
可以用append
在列表末尾添加元素:
1 | In [45]: b_list.append('dwarf') |
insert
可以在特定的位置插入元素:
1 | In [47]: b_list.insert(1, 'red') |
插入的序号必须在0和列表长度之间。
警告:与
append
相比,insert
耗费的计算量大,因为对后续元素的引用必须在内部迁移,以便为新元素提供空间。如果要在序列的头部和尾部插入元素,你可能需要使用collections.deque
,一个双尾部队列。
insert的逆运算是pop,它移除并返回指定位置的元素:
1 | In [49]: b_list.pop(2) |
可以用remove
去除某个值,remove
会先寻找第一个值并除去:
1 | In [51]: b_list.append('foo') |
如果不考虑性能,使用append
和remove
,可以把Python的列表当做完美的“多重集”数据结构。
用in
可以检查列表是否包含某个值:
1 | In [55]: 'dwarf' in b_list |
否定in
可以再加一个not:
1 | In [56]: 'dwarf' not in b_list |
在列表中检查是否存在某个值远比字典和集合速度慢,因为Python是线性搜索列表中的值,但在字典和集合中,在同样的时间内还可以检查其它项(基于哈希表)。
串联和组合列表
与元组类似,可以用加号将两个列表串联起来:
1 | In [57]: [4, None, 'foo'] + [7, 8, (2, 3)] |
如果已经定义了一个列表,用extend
方法可以追加多个元素:
1 | In [58]: x = [4, None, 'foo'] |
通过加法将列表串联的计算量较大,因为要新建一个列表,并且要复制对象。用extend
追加元素,尤其是到一个大列表中,更为可取。因此:
1 | everything = [] |
要比串联方法快:
1 | everything = [] |
排序
你可以用sort
函数将一个列表原地排序(不创建新的对象):
1 | In [61]: a = [7, 2, 5, 1, 3] |
sort
有一些选项,有时会很好用。其中之一是二级排序key,可以用这个key进行排序。例如,我们可以按长度对字符串进行排序:
1 | In [64]: b = ['saw', 'small', 'He', 'foxes', 'six'] |
稍后,我们会学习sorted
函数,它可以产生一个排好序的序列副本。
二分搜索和维护已排序的列表
bisect
模块支持二分查找,和向已排序的列表插入值。bisect.bisect
可以找到插入值后仍保证排序的位置,bisect.insort
是向这个位置插入值:
1 | In [67]: import bisect |
注意:
bisect
模块不会检查列表是否已排好序,进行检查的话会耗费大量计算。因此,对未排序的列表使用bisect
不会产生错误,但结果不一定正确。
切片
用切边可以选取大多数序列类型的一部分,切片的基本形式是在方括号中使用start:stop
:
1 | In [73]: seq = [7, 2, 3, 7, 5, 6, 0, 1] |
切片也可以被序列赋值:
1 | In [75]: seq[3:4] = [6, 3] |
切片的起始元素是包括的,不包含结束元素。因此,结果中包含的元素个数是stop - start
。
start
或stop
都可以被省略,省略之后,分别默认序列的开头和结尾:
1 | In [77]: seq[:5] |
负数表明从后向前切片:
1 | In [79]: seq[-4:] |
需要一段时间来熟悉使用切片,尤其是当你之前学的是R或MATLAB。图3-1展示了正整数和负整数的切片。在图中,指数标示在边缘以表明切片是在哪里开始哪里结束的。
在第二个冒号后面使用step
,可以隔一个取一个元素:
1 | In [81]: seq[::2] |
一个聪明的方法是使用-1
,它可以将列表或元组颠倒过来:
1 | In [82]: seq[::-1] |
序列函数
Python有一些有用的序列函数。
enumerate函数
迭代一个序列时,你可能想跟踪当前项的序号。手动的方法可能是下面这样:
1 | i = 0 |
因为这么做很常见,Python内建了一个enumerate
函数,可以返回(i, value)
元组序列:
1 | for i, value in enumerate(collection): |
当你索引数据时,使用enumerate
的一个好方法是计算序列(唯一的)dict
映射到位置的值:
1 | In [83]: some_list = ['foo', 'bar', 'baz'] |
sorted函数
sorted
函数可以从任意序列的元素返回一个新的排好序的列表:
1 | In [87]: sorted([7, 1, 2, 6, 0, 3, 2]) |
sorted
函数可以接受和sort
相同的参数。
zip函数
zip
可以将多个列表、元组或其它序列成对组合成一个元组列表:
1 | In [89]: seq1 = ['foo', 'bar', 'baz'] |
zip
可以处理任意多的序列,元素的个数取决于最短的序列:
1 | In [93]: seq3 = [False, True] |
zip
的常见用法之一是同时迭代多个序列,可能结合enumerate
使用:
1 | In [95]: for i, (a, b) in enumerate(zip(seq1, seq2)): |
给出一个“被压缩的”序列,zip
可以被用来解压序列。也可以当作把行的列表转换为列的列表。这个方法看起来有点神奇:
1 | In [96]: pitchers = [('Nolan', 'Ryan'), ('Roger', 'Clemens'), |
reversed函数
reversed
可以从后向前迭代一个序列:
1 | In [100]: list(reversed(range(10))) |
要记住reversed
是一个生成器(后面详细介绍),只有实体化(即列表或for循环)之后才能创建翻转的序列。
字典
字典可能是Python最为重要的数据结构。它更为常见的名字是哈希映射或关联数组。它是键值对的大小可变集合,键和值都是Python对象。创建字典的方法之一是使用尖括号,用冒号分隔键和值:
1 | In [101]: empty_dict = {} |
你可以像访问列表或元组中的元素一样,访问、插入或设定字典中的元素:
1 | In [104]: d1[7] = 'an integer' |
你可以用检查列表和元组是否包含某个值的方法,检查字典中是否包含某个键:
1 | In [107]: 'b' in d1 |
可以用del
关键字或pop
方法(返回值的同时删除键)删除值:
1 | In [108]: d1[5] = 'some value' |
keys
和values
是字典的键和值的迭代器方法。虽然键值对没有顺序,这两个方法可以用相同的顺序输出键和值:
1 | In [117]: list(d1.keys()) |
用update
方法可以将一个字典与另一个融合:
1 | In [119]: d1.update({'b' : 'foo', 'c' : 12}) |
update
方法是原地改变字典,因此任何传递给update
的键的旧的值都会被舍弃。
用序列创建字典
常常,你可能想将两个序列配对组合成字典。下面是一种写法:
1 | mapping = {} |
因为字典本质上是2元元组的集合,dict可以接受2元元组的列表:
1 | In [121]: mapping = dict(zip(range(5), reversed(range(5)))) |
后面会谈到dict comprehensions
,另一种构建字典的优雅方式。
默认值
下面的逻辑很常见:
1 | if key in some_dict: |
因此,dict的方法get和pop可以取默认值进行返回,上面的if-else语句可以简写成下面:
1 | value = some_dict.get(key, default_value) |
get默认会返回None,如果不存在键,pop会抛出一个例外。关于设定值,常见的情况是在字典的值是属于其它集合,如列表。例如,你可以通过首字母,将一个列表中的单词分类:
1 | In [123]: words = ['apple', 'bat', 'bar', 'atom', 'book'] |
setdefault
方法就正是干这个的。前面的for循环可以改写为:
1 | for word in words: |
collections
模块有一个很有用的类,defaultdict
,它可以进一步简化上面。传递类型或函数以生成每个位置的默认值:
1 | from collections import defaultdict |
有效的键类型
字典的值可以是任意Python对象,而键通常是不可变的标量类型(整数、浮点型、字符串)或元组(元组中的对象必须是不可变的)。这被称为“可哈希性”。可以用hash
函数检测一个对象是否是可哈希的(可被用作字典的键):
1 | In [127]: hash('string') |
要用列表当做键,一种方法是将列表转化为元组,只要内部元素可以被哈希,它也就可以被哈希:
1 | In [130]: d = {} |
集合
集合是无序的不可重复的元素的集合。你可以把它当做字典,但是只有键没有值。可以用两种方式创建集合:通过set函数或使用尖括号set语句:
1 | In [133]: set([2, 2, 2, 1, 3, 3]) |
集合支持合并、交集、差分和对称差等数学集合运算。考虑两个示例集合:
1 | In [135]: a = {1, 2, 3, 4, 5} |
合并是取两个集合中不重复的元素。可以用union
方法,或者|
运算符:
1 | In [137]: a.union(b) |
交集的元素包含在两个集合中。可以用intersection
或&
运算符:
1 | In [139]: a.intersection(b) |
表3-1列出了常用的集合方法。
所有逻辑集合操作都有另外的原地实现方法,可以直接用结果替代集合的内容。对于大的集合,这么做效率更高:
1 | In [141]: c = a.copy() |
与字典类似,集合元素通常都是不可变的。要获得类似列表的元素,必须转换成元组:
1 | In [147]: my_data = [1, 2, 3, 4] |
你还可以检测一个集合是否是另一个集合的子集或父集:
1 | In [150]: a_set = {1, 2, 3, 4, 5} |
集合的内容相同时,集合才对等:
1 | In [153]: {1, 2, 3} == {3, 2, 1} |
列表、集合和字典推导式
列表推导式是Python最受喜爱的特性之一。它允许用户方便的从一个集合过滤元素,形成列表,在传递参数的过程中还可以修改元素。形式如下:
1 | [expr for val in collection if condition] |
它等同于下面的for循环;
1 | result = [] |
filter条件可以被忽略,只留下表达式就行。例如,给定一个字符串列表,我们可以过滤出长度在2及以下的字符串,并将其转换成大写:
1 | In [154]: strings = ['a', 'as', 'bat', 'car', 'dove', 'python'] |
用相似的方法,还可以推导集合和字典。字典的推导式如下所示:
1 | dict_comp = {key-expr : value-expr for value in collection if condition} |
集合的推导式与列表很像,只不过用的是尖括号:
1 | set_comp = {expr for value in collection if condition} |
与列表推导式类似,集合与字典的推导也很方便,而且使代码的读写都很容易。来看前面的字符串列表。假如我们只想要字符串的长度,用集合推导式的方法非常方便:
1 | In [156]: unique_lengths = {len(x) for x in strings} |
map
函数可以进一步简化:
1 | In [158]: set(map(len, strings)) |
作为一个字典推导式的例子,我们可以创建一个字符串的查找映射表以确定它在列表中的位置:
1 | In [159]: loc_mapping = {val : index for index, val in enumerate(strings)} |
嵌套列表推导式
假设我们有一个包含列表的列表,包含了一些英文名和西班牙名:
1 | In [161]: all_data = [['John', 'Emily', 'Michael', 'Mary', 'Steven'], |
你可能是从一些文件得到的这些名字,然后想按照语言进行分类。现在假设我们想用一个列表包含所有的名字,这些名字中包含两个或更多的e。可以用for循环来做:
1 | names_of_interest = [] |
可以用嵌套列表推导式的方法,将这些写在一起,如下所示:
1 | In [162]: result = [name for names in all_data for name in names |
嵌套列表推导式看起来有些复杂。列表推导式的for部分是根据嵌套的顺序,过滤条件还是放在最后。下面是另一个例子,我们将一个整数元组的列表扁平化成了一个整数列表:
1 | In [164]: some_tuples = [(1, 2, 3), (4, 5, 6), (7, 8, 9)] |
记住,for表达式的顺序是与嵌套for循环的顺序一样(而不是列表推导式的顺序):
1 | flattened = [] |
你可以有任意多级别的嵌套,但是如果你有两三个以上的嵌套,你就应该考虑下代码可读性的问题了。分辨列表推导式的列表推导式中的语法也是很重要的:
1 | In [167]: [[x for x in tup] for tup in some_tuples] |
这段代码产生了一个列表的列表,而不是扁平化的只包含元素的列表。
3.2 函数
函数是Python中最主要也是最重要的代码组织和复用手段。作为最重要的原则,如果你要重复使用相同或非常类似的代码,就需要写一个函数。通过给函数起一个名字,还可以提高代码的可读性。
函数使用def
关键字声明,用return
关键字返回值:
1 | def my_function(x, y, z=1.5): |
同时拥有多条return语句也是可以的。如果到达函数末尾时没有遇到任何一条return语句,则返回None。
函数可以有一些位置参数(positional)和一些关键字参数(keyword)。关键字参数通常用于指定默认值或可选参数。在上面的函数中,x和y是位置参数,而z则是关键字参数。也就是说,该函数可以下面这两种方式进行调用:
1 | my_function(5, 6, z=0.7) |
函数参数的主要限制在于:关键字参数必须位于位置参数(如果有的话)之后。你可以任何顺序指定关键字参数。也就是说,你不用死记硬背函数参数的顺序,只要记得它们的名字就可以了。
笔记:也可以用关键字传递位置参数。前面的例子,也可以写为:
1
2 >my_function(x=5, y=6, z=7)
>my_function(y=6, x=5, z=7)这种写法可以提高可读性。
命名空间、作用域,和局部函数
函数可以访问两种不同作用域中的变量:全局(global)和局部(local)。Python有一种更科学的用于描述变量作用域的名称,即命名空间(namespace)。任何在函数中赋值的变量默认都是被分配到局部命名空间(local namespace)中的。局部命名空间是在函数被调用时创建的,函数参数会立即填入该命名空间。在函数执行完毕之后,局部命名空间就会被销毁(会有一些例外的情况,具体请参见后面介绍闭包的那一节)。看看下面这个函数:
1 | def func(): |
调用func()之后,首先会创建出空列表a,然后添加5个元素,最后a会在该函数退出的时候被销毁。假如我们像下面这样定义a:
1 | a = [] |
虽然可以在函数中对全局变量进行赋值操作,但是那些变量必须用global关键字声明成全局的才行:
1 | In [168]: a = None |
注意:我常常建议人们不要频繁使用global关键字。因为全局变量一般是用于存放系统的某些状态的。如果你发现自己用了很多,那可能就说明得要来点儿面向对象编程了(即使用类)。
返回多个值
在我第一次用Python编程时(之前已经习惯了Java和C++),最喜欢的一个功能是:函数可以返回多个值。下面是一个简单的例子:
1 | def f(): |
在数据分析和其他科学计算应用中,你会发现自己常常这么干。该函数其实只返回了一个对象,也就是一个元组,最后该元组会被拆包到各个结果变量中。在上面的例子中,我们还可以这样写:
1 | return_value = f() |
这里的return_value将会是一个含有3个返回值的三元元组。此外,还有一种非常具有吸引力的多值返回方式——返回字典:
1 | def f(): |
取决于工作内容,第二种方法可能很有用。
函数也是对象
由于Python函数都是对象,因此,在其他语言中较难表达的一些设计思想在Python中就要简单很多了。假设我们有下面这样一个字符串数组,希望对其进行一些数据清理工作并执行一堆转换:
1 | In [171]: states = [' Alabama ', 'Georgia!', 'Georgia', 'georgia', 'FlOrIda', |
不管是谁,只要处理过由用户提交的调查数据,就能明白这种乱七八糟的数据是怎么一回事。为了得到一组能用于分析工作的格式统一的字符串,需要做很多事情:去除空白符、删除各种标点符号、正确的大写格式等。做法之一是使用内建的字符串方法和正则表达式re
模块:
1 | import re |
结果如下所示:
1 | In [173]: clean_strings(states) |
其实还有另外一种不错的办法:将需要在一组给定字符串上执行的所有运算做成一个列表:
1 | def remove_punctuation(value): |
然后我们就有了:
1 | In [175]: clean_strings(states, clean_ops) |
这种多函数模式使你能在很高的层次上轻松修改字符串的转换方式。此时的clean_strings也更具可复用性!
还可以将函数用作其他函数的参数,比如内置的map函数,它用于在一组数据上应用一个函数:
1 | In [176]: for x in map(remove_punctuation, states): |
匿名(lambda)函数
Python支持一种被称为匿名的、或lambda函数。它仅由单条语句组成,该语句的结果就是返回值。它是通过lambda关键字定义的,这个关键字没有别的含义,仅仅是说“我们正在声明的是一个匿名函数”。
1 | def short_function(x): |
本书其余部分一般将其称为lambda函数。它们在数据分析工作中非常方便,因为你会发现很多数据转换函数都以函数作为参数的。直接传入lambda函数比编写完整函数声明要少输入很多字(也更清晰),甚至比将lambda函数赋值给一个变量还要少输入很多字。看看下面这个简单得有些傻的例子:
1 | def apply_to_list(some_list, f): |
虽然你可以直接编写[x *2for x in ints],但是这里我们可以非常轻松地传入一个自定义运算给apply_to_list函数。
再来看另外一个例子。假设有一组字符串,你想要根据各字符串不同字母的数量对其进行排序:
1 | In [177]: strings = ['foo', 'card', 'bar', 'aaaa', 'abab'] |
这里,我们可以传入一个lambda函数到列表的sort方法:
1 | In [178]: strings.sort(key=lambda x: len(set(list(x)))) |
笔记:lambda函数之所以会被称为匿名函数,与def声明的函数不同,原因之一就是这种函数对象本身是没有提供名称__name__属性。
柯里化:部分参数应用
柯里化(currying)是一个有趣的计算机科学术语,它指的是通过“部分参数应用”(partial argument application)从现有函数派生出新函数的技术。例如,假设我们有一个执行两数相加的简单函数:
1 | def add_numbers(x, y): |
通过这个函数,我们可以派生出一个新的只有一个参数的函数——add_five,它用于对其参数加5:
1 | add_five = lambda y: add_numbers(5, y) |
add_numbers的第二个参数称为“柯里化的”(curried)。这里没什么特别花哨的东西,因为我们其实就只是定义了一个可以调用现有函数的新函数而已。内置的functools模块可以用partial函数将此过程简化:
1 | from functools import partial |
生成器
能以一种一致的方式对序列进行迭代(比如列表中的对象或文件中的行)是Python的一个重要特点。这是通过一种叫做迭代器协议(iterator protocol,它是一种使对象可迭代的通用方式)的方式实现的,一个原生的使对象可迭代的方法。比如说,对字典进行迭代可以得到其所有的键:
1 | In [180]: some_dict = {'a': 1, 'b': 2, 'c': 3} |
当你编写for key in some_dict时,Python解释器首先会尝试从some_dict创建一个迭代器:
1 | In [182]: dict_iterator = iter(some_dict) |
迭代器是一种特殊对象,它可以在诸如for循环之类的上下文中向Python解释器输送对象。大部分能接受列表之类的对象的方法也都可以接受任何可迭代对象。比如min、max、sum等内置方法以及list、tuple等类型构造器:
1 | In [184]: list(dict_iterator) |
生成器(generator)是构造新的可迭代对象的一种简单方式。一般的函数执行之后只会返回单个值,而生成器则是以延迟的方式返回一个值序列,即每返回一个值之后暂停,直到下一个值被请求时再继续。要创建一个生成器,只需将函数中的return替换为yeild即可:
1 | def squares(n=10): |
调用该生成器时,没有任何代码会被立即执行:
1 | In [186]: gen = squares() |
直到你从该生成器中请求元素时,它才会开始执行其代码:
1 | In [188]: for x in gen: |
生成器表达式
另一种更简洁的构造生成器的方法是使用生成器表达式(generator expression)。这是一种类似于列表、字典、集合推导式的生成器。其创建方式为,把列表推导式两端的方括号改成圆括号:
1 | In [189]: gen = (x ** 2 for x in range(100)) |
它跟下面这个冗长得多的生成器是完全等价的:
1 | def _make_gen(): |
生成器表达式也可以取代列表推导式,作为函数参数:
1 | In [191]: sum(x ** 2 for x in range(100)) |
itertools模块
标准库itertools模块中有一组用于许多常见数据算法的生成器。例如,groupby可以接受任何序列和一个函数。它根据函数的返回值对序列中的连续元素进行分组。下面是一个例子:
1 | In [193]: import itertools |
表3-2中列出了一些我经常用到的itertools函数。建议参阅Python官方文档,进一步学习。
错误和异常处理
优雅地处理Python的错误和异常是构建健壮程序的重要部分。在数据分析中,许多函数函数只用于部分输入。例如,Python的float函数可以将字符串转换成浮点数,但输入有误时,有ValueError
错误:
1 | In [197]: float('1.2345') |
假如想优雅地处理float的错误,让它返回输入值。我们可以写一个函数,在try/except中调用float:
1 | def attempt_float(x): |
当float(x)抛出异常时,才会执行except的部分:
1 | In [200]: attempt_float('1.2345') |
你可能注意到float抛出的异常不仅是ValueError:
1 | In [202]: float((1, 2)) |
你可能只想处理ValueError,TypeError错误(输入不是字符串或数值)可能是合理的bug。可以写一个异常类型:
1 | def attempt_float(x): |
然后有:
1 | In [204]: attempt_float((1, 2)) |
可以用元组包含多个异常:
1 | def attempt_float(x): |
某些情况下,你可能不想抑制异常,你想无论try部分的代码是否成功,都执行一段代码。可以使用finally:
1 | f = open(path, 'w') |
这里,文件处理f总会被关闭。相似的,你可以用else让只在try部分成功的情况下,才执行代码:
1 | f = open(path, 'w') |
IPython的异常
如果是在%run一个脚本或一条语句时抛出异常,IPython默认会打印完整的调用栈(traceback),在栈的每个点都会有几行上下文:
1 | In [10]: %run examples/ipython_bug.py |
自身就带有文本是相对于Python标准解释器的极大优点。你可以用魔术命令%xmode
,从Plain(与Python标准解释器相同)到Verbose(带有函数的参数值)控制文本显示的数量。后面可以看到,发生错误之后,(用%debug或%pdb magics)可以进入stack进行事后调试。
3.3 文件和操作系统
本书的代码示例大多使用诸如pandas.read_csv之类的高级工具将磁盘上的数据文件读入Python数据结构。但我们还是需要了解一些有关Python文件处理方面的基础知识。好在它本来就很简单,这也是Python在文本和文件处理方面的如此流行的原因之一。
为了打开一个文件以便读写,可以使用内置的open函数以及一个相对或绝对的文件路径:
1 | In [207]: path = 'examples/segismundo.txt' |
默认情况下,文件是以只读模式(’r’)打开的。然后,我们就可以像处理列表那样来处理这个文件句柄f了,比如对行进行迭代:
1 | for line in f: |
从文件中取出的行都带有完整的行结束符(EOL),因此你常常会看到下面这样的代码(得到一组没有EOL的行):
1 | In [209]: lines = [x.rstrip() for x in open(path)] |
如果使用open创建文件对象,一定要用close关闭它。关闭文件可以返回操作系统资源:
1 | In [211]: f.close() |
用with语句可以可以更容易地清理打开的文件:
1 | In [212]: with open(path) as f: |
这样可以在退出代码块时,自动关闭文件。
如果输入f =open(path,’w’),就会有一个新文件被创建在examples/segismundo.txt,并覆盖掉该位置原来的任何数据。另外有一个x文件模式,它可以创建可写的文件,但是如果文件路径存在,就无法创建。表3-3列出了所有的读/写模式。
对于可读文件,一些常用的方法是read、seek和tell。read会从文件返回字符。字符的内容是由文件的编码决定的(如UTF-8),如果是二进制模式打开的就是原始字节:
1 | In [213]: f = open(path) |
read模式会将文件句柄的位置提前,提前的数量是读取的字节数。tell可以给出当前的位置:
1 | In [217]: f.tell() |
尽管我们从文件读取了10个字符,位置却是11,这是因为用默认的编码用了这么多字节才解码了这10个字符。你可以用sys模块检查默认的编码:
1 | In [219]: import sys |
seek将文件位置更改为文件中的指定字节:
1 | In [221]: f.seek(3) |
最后,关闭文件:
1 | In [223]: f.close() |
向文件写入,可以使用文件的write或writelines方法。例如,我们可以创建一个无空行版的prof_mod.py:
1 | In [225]: with open('tmp.txt', 'w') as handle: |
表3-4列出了一些最常用的文件方法。
文件的字节和Unicode
Python文件的默认操作是“文本模式”,也就是说,你需要处理Python的字符串(即Unicode)。它与“二进制模式”相对,文件模式加一个b。我们来看上一节的文件(UTF-8编码、包含非ASCII字符):
1 | In [230]: with open(path) as f: |
UTF-8是长度可变的Unicode编码,所以当我从文件请求一定数量的字符时,Python会从文件读取足够多(可能少至10或多至40字节)的字节进行解码。如果以“rb”模式打开文件,则读取确切的请求字节数:
1 | In [232]: with open(path, 'rb') as f: |
取决于文本的编码,你可以将字节解码为str对象,但只有当每个编码的Unicode字符都完全成形时才能这么做:
1 | In [234]: data.decode('utf8') |
文本模式结合了open的编码选项,提供了一种更方便的方法将Unicode转换为另一种编码:
1 | In [236]: sink_path = 'sink.txt' |
注意,不要在二进制模式中使用seek。如果文件位置位于定义Unicode字符的字节的中间位置,读取后面会产生错误:
1 | In [240]: f = open(path) |
如果你经常要对非ASCII字符文本进行数据分析,通晓Python的Unicode功能是非常重要的。更多内容,参阅Python官方文档。
3.4 结论
我们已经学过了Python的基础、环境和语法,接下来学习NumPy和Python的面向数组计算。
第4章 NumPy基础:数组和矢量计算
NumPy(Numerical Python的简称)是Python数值计算最重要的基础包。大多数提供科学计算的包都是用NumPy的数组作为构建基础。
NumPy的部分功能如下:
- ndarray,一个具有矢量算术运算和复杂广播能力的快速且节省空间的多维数组。
- 用于对整组数据进行快速运算的标准数学函数(无需编写循环)。
- 用于读写磁盘数据的工具以及用于操作内存映射文件的工具。
- 线性代数、随机数生成以及傅里叶变换功能。
- 用于集成由C、C++、Fortran等语言编写的代码的A C API。
由于NumPy提供了一个简单易用的C API,因此很容易将数据传递给由低级语言编写的外部库,外部库也能以NumPy数组的形式将数据返回给Python。这个功能使Python成为一种包装C/C++/Fortran历史代码库的选择,并使被包装库拥有一个动态的、易用的接口。
NumPy本身并没有提供多么高级的数据分析功能,理解NumPy数组以及面向数组的计算将有助于你更加高效地使用诸如pandas之类的工具。因为NumPy是一个很大的题目,我会在附录A中介绍更多NumPy高级功能,比如广播。
对于大部分数据分析应用而言,我最关注的功能主要集中在:
- 用于数据整理和清理、子集构造和过滤、转换等快速的矢量化数组运算。
- 常用的数组算法,如排序、唯一化、集合运算等。
- 高效的描述统计和数据聚合/摘要运算。
- 用于异构数据集的合并/连接运算的数据对齐和关系型数据运算。
- 将条件逻辑表述为数组表达式(而不是带有if-elif-else分支的循环)。
- 数据的分组运算(聚合、转换、函数应用等)。。
虽然NumPy提供了通用的数值数据处理的计算基础,但大多数读者可能还是想将pandas作为统计和分析工作的基础,尤其是处理表格数据时。pandas还提供了一些NumPy所没有的领域特定的功能,如时间序列处理等。
笔记:Python的面向数组计算可以追溯到1995年,Jim Hugunin创建了Numeric库。接下来的10年,许多科学编程社区纷纷开始使用Python的数组编程,但是进入21世纪,库的生态系统变得碎片化了。2005年,Travis Oliphant从Numeric和Numarray项目整合出了NumPy项目,进而所有社区都集合到了这个框架下。
NumPy之于数值计算特别重要的原因之一,是因为它可以高效处理大数组的数据。这是因为:
- NumPy是在一个连续的内存块中存储数据,独立于其他Python内置对象。NumPy的C语言编写的算法库可以操作内存,而不必进行类型检查或其它前期工作。比起Python的内置序列,NumPy数组使用的内存更少。
- NumPy可以在整个数组上执行复杂的计算,而不需要Python的for循环。
要搞明白具体的性能差距,考察一个包含一百万整数的数组,和一个等价的Python列表:
1 | In [7]: import numpy as np |
各个序列分别乘以2:
1 | In [10]: %time for _ in range(10): my_arr2 = my_arr * 2 |
基于NumPy的算法要比纯Python快10到100倍(甚至更快),并且使用的内存更少。
4.1 NumPy的ndarray:一种多维数组对象
NumPy最重要的一个特点就是其N维数组对象(即ndarray),该对象是一个快速而灵活的大数据集容器。你可以利用这种数组对整块数据执行一些数学运算,其语法跟标量元素之间的运算一样。
要明白Python是如何利用与标量值类似的语法进行批次计算,我先引入NumPy,然后生成一个包含随机数据的小数组:
1 | In [12]: import numpy as np |
然后进行数学运算:
1 | In [15]: data * 10 |
第一个例子中,所有的元素都乘以10。第二个例子中,每个元素都与自身相加。
笔记:在本章及全书中,我会使用标准的NumPy惯用法
import numpy as np
。你当然也可以在代码中使用from numpy import *
,但不建议这么做。numpy
的命名空间很大,包含许多函数,其中一些的名字与Python的内置函数重名(比如min和max)。
ndarray是一个通用的同构数据多维容器,也就是说,其中的所有元素必须是相同类型的。每个数组都有一个shape(一个表示各维度大小的元组)和一个dtype(一个用于说明数组数据类型的对象):
1 | In [17]: data.shape |
本章将会介绍NumPy数组的基本用法,这对于本书后面各章的理解基本够用。虽然大多数数据分析工作不需要深入理解NumPy,但是精通面向数组的编程和思维方式是成为Python科学计算牛人的一大关键步骤。
笔记:当你在本书中看到“数组”、“NumPy数组”、”ndarray”时,基本上都指的是同一样东西,即ndarray对象。
创建ndarray
创建数组最简单的办法就是使用array函数。它接受一切序列型的对象(包括其他数组),然后产生一个新的含有传入数据的NumPy数组。以一个列表的转换为例:
1 | In [19]: data1 = [6, 7.5, 8, 0, 1] |
嵌套序列(比如由一组等长列表组成的列表)将会被转换为一个多维数组:
1 | In [22]: data2 = [[1, 2, 3, 4], [5, 6, 7, 8]] |
因为data2是列表的列表,NumPy数组arr2的两个维度的shape是从data2引入的。可以用属性ndim和shape验证:
1 | In [25]: arr2.ndim |
除非特别说明(稍后将会详细介绍),np.array会尝试为新建的这个数组推断出一个较为合适的数据类型。数据类型保存在一个特殊的dtype对象中。比如说,在上面的两个例子中,我们有:
1 | In [27]: arr1.dtype |
除np.array之外,还有一些函数也可以新建数组。比如,zeros和ones分别可以创建指定长度或形状的全0或全1数组。empty可以创建一个没有任何具体值的数组。要用这些方法创建多维数组,只需传入一个表示形状的元组即可:
1 | In [29]: np.zeros(10) |
注意:认为np.empty会返回全0数组的想法是不安全的。很多情况下(如前所示),它返回的都是一些未初始化的垃圾值。
arange是Python内置函数range的数组版:
1 | In [32]: np.arange(15) |
表4-1列出了一些数组创建函数。由于NumPy关注的是数值计算,因此,如果没有特别指定,数据类型基本都是float64(浮点数)。
ndarray的数据类型
dtype(数据类型)是一个特殊的对象,它含有ndarray将一块内存解释为特定数据类型所需的信息:
1 | In [33]: arr1 = np.array([1, 2, 3], dtype=np.float64) |
dtype是NumPy灵活交互其它系统的源泉之一。多数情况下,它们直接映射到相应的机器表示,这使得“读写磁盘上的二进制数据流”以及“集成低级语言代码(如C、Fortran)”等工作变得更加简单。数值型dtype的命名方式相同:一个类型名(如float或int),后面跟一个用于表示各元素位长的数字。标准的双精度浮点值(即Python中的float对象)需要占用8字节(即64位)。因此,该类型在NumPy中就记作float64。表4-2列出了NumPy所支持的全部数据类型。
笔记:记不住这些NumPy的dtype也没关系,新手更是如此。通常只需要知道你所处理的数据的大致类型是浮点数、复数、整数、布尔值、字符串,还是普通的Python对象即可。当你需要控制数据在内存和磁盘中的存储方式时(尤其是对大数据集),那就得了解如何控制存储类型。
你可以通过ndarray的astype方法明确地将一个数组从一个dtype转换成另一个dtype:
1 | In [37]: arr = np.array([1, 2, 3, 4, 5]) |
在本例中,整数被转换成了浮点数。如果将浮点数转换成整数,则小数部分将会被截取删除:
1 | In [41]: arr = np.array([3.7, -1.2, -2.6, 0.5, 12.9, 10.1]) |
如果某字符串数组表示的全是数字,也可以用astype将其转换为数值形式:
1 | In [44]: numeric_strings = np.array(['1.25', '-9.6', '42'], dtype=np.string_) |
注意:使用numpy.string_类型时,一定要小心,因为NumPy的字符串数据是大小固定的,发生截取时,不会发出警告。pandas提供了更多非数值数据的便利的处理方法。
如果转换过程因为某种原因而失败了(比如某个不能被转换为float64的字符串),就会引发一个ValueError。这里,我比较懒,写的是float而不是np.float64;NumPy很聪明,它会将Python类型映射到等价的dtype上。
数组的dtype还有另一个属性:
1 | In [46]: int_array = np.arange(10) |
你还可以用简洁的类型代码来表示dtype:
1 | In [49]: empty_uint32 = np.empty(8, dtype='u4') |
笔记:调用astype总会创建一个新的数组(一个数据的备份),即使新的dtype与旧的dtype相同。
NumPy数组的运算
数组很重要,因为它使你不用编写循环即可对数据执行批量运算。NumPy用户称其为矢量化(vectorization)。大小相等的数组之间的任何算术运算都会将运算应用到元素级:
1 | In [51]: arr = np.array([[1., 2., 3.], [4., 5., 6.]]) |
数组与标量的算术运算会将标量值传播到各个元素:
1 | In [55]: 1 / arr |
大小相同的数组之间的比较会生成布尔值数组:
1 | In [57]: arr2 = np.array([[0., 4., 1.], [7., 2., 12.]]) |
不同大小的数组之间的运算叫做广播(broadcasting),将在附录A中对其进行详细讨论。本书的内容不需要对广播机制有多深的理解。
基本的索引和切片
NumPy数组的索引是一个内容丰富的主题,因为选取数据子集或单个元素的方式有很多。一维数组很简单。从表面上看,它们跟Python列表的功能差不多:
1 | In [60]: arr = np.arange(10) |
如上所示,当你将一个标量值赋值给一个切片时(如arr[5:8]=12),该值会自动传播(也就说后面将会讲到的“广播”)到整个选区。跟列表最重要的区别在于,数组切片是原始数组的视图。这意味着数据不会被复制,视图上的任何修改都会直接反映到源数组上。
作为例子,先创建一个arr的切片:
1 | In [66]: arr_slice = arr[5:8] |
现在,当我修改arr_slice中的值,变动也会体现在原始数组arr中:
1 | In [68]: arr_slice[1] = 12345 |
切片[ : ]会给数组中的所有值赋值:
1 | In [70]: arr_slice[:] = 64 |
如果你刚开始接触NumPy,可能会对此感到惊讶(尤其是当你曾经用过其他热衷于复制数组数据的编程语言)。由于NumPy的设计目的是处理大数据,所以你可以想象一下,假如NumPy坚持要将数据复制来复制去的话会产生何等的性能和内存问题。
注意:如果你想要得到的是ndarray切片的一份副本而非视图,就需要明确地进行复制操作,例如
arr[5:8].copy()
。
对于高维度数组,能做的事情更多。在一个二维数组中,各索引位置上的元素不再是标量而是一维数组:
1 | In [72]: arr2d = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) |
因此,可以对各个元素进行递归访问,但这样需要做的事情有点多。你可以传入一个以逗号隔开的索引列表来选取单个元素。也就是说,下面两种方式是等价的:
1 | In [74]: arr2d[0][2] |
图4-1说明了二维数组的索引方式。轴0作为行,轴1作为列。
在多维数组中,如果省略了后面的索引,则返回对象会是一个维度低一点的ndarray(它含有高一级维度上的所有数据)。因此,在2×2×3数组arr3d中:
1 | In [76]: arr3d = np.array([[[1, 2, 3], [4, 5, 6]], [[7, 8, 9], [10, 11, 12]]]) |
arr3d[0]是一个2×3数组:
1 | In [78]: arr3d[0] |
标量值和数组都可以被赋值给arr3d[0]:
1 | In [79]: old_values = arr3d[0].copy() |
相似的,arr3d[1,0]可以访问索引以(1,0)开头的那些值(以一维数组的形式返回):
1 | In [84]: arr3d[1, 0] |
虽然是用两步进行索引的,表达式是相同的:
1 | In [85]: x = arr3d[1] |
注意,在上面所有这些选取数组子集的例子中,返回的数组都是视图。
切片索引
ndarray的切片语法跟Python列表这样的一维对象差不多:
1 | In [88]: arr |
对于之前的二维数组arr2d,其切片方式稍显不同:
1 | In [90]: arr2d |
可以看出,它是沿着第0轴(即第一个轴)切片的。也就是说,切片是沿着一个轴向选取元素的。表达式arr2d[:2]可以被认为是“选取arr2d的前两行”。
你可以一次传入多个切片,就像传入多个索引那样:
1 | In [92]: arr2d[:2, 1:] |
像这样进行切片时,只能得到相同维数的数组视图。通过将整数索引和切片混合,可以得到低维度的切片。
例如,我可以选取第二行的前两列:
1 | In [93]: arr2d[1, :2] |
相似的,还可以选择第三列的前两行:
1 | In [94]: arr2d[:2, 2] |
图4-2对此进行了说明。注意,“只有冒号”表示选取整个轴,因此你可以像下面这样只对高维轴进行切片:
1 | In [95]: arr2d[:, :1] |
自然,对切片表达式的赋值操作也会被扩散到整个选区:
1 | In [96]: arr2d[:2, 1:] = 0 |
布尔型索引
来看这样一个例子,假设我们有一个用于存储数据的数组以及一个存储姓名的数组(含有重复项)。在这里,我将使用numpy.random中的randn函数生成一些正态分布的随机数据:
1 | In [98]: names = np.array(['Bob', 'Joe', 'Will', 'Bob', 'Will', 'Joe', 'Joe']) |
假设每个名字都对应data数组中的一行,而我们想要选出对应于名字”Bob”的所有行。跟算术运算一样,数组的比较运算(如==)也是矢量化的。因此,对names和字符串”Bob”的比较运算将会产生一个布尔型数组:
1 | In [102]: names == 'Bob' |
这个布尔型数组可用于数组索引:
1 | In [103]: data[names == 'Bob'] |
布尔型数组的长度必须跟被索引的轴长度一致。此外,还可以将布尔型数组跟切片、整数(或整数序列,稍后将对此进行详细讲解)混合使用:
1 | In [103]: data[names == 'Bob'] |
注意:如果布尔型数组的长度不对,布尔型选择就会出错,因此一定要小心。
下面的例子,我选取了names == 'Bob'
的行,并索引了列:
1 | In [104]: data[names == 'Bob', 2:] |
要选择除”Bob”以外的其他值,既可以使用不等于符号(!=),也可以通过~对条件进行否定:
1 | In [106]: names != 'Bob' |
~操作符用来反转条件很好用:
1 | In [108]: cond = names == 'Bob' |
选取这三个名字中的两个需要组合应用多个布尔条件,使用&(和)、|(或)之类的布尔算术运算符即可:
1 | In [110]: mask = (names == 'Bob') | (names == 'Will') |
通过布尔型索引选取数组中的数据,将总是创建数据的副本,即使返回一模一样的数组也是如此。
注意:Python关键字and和or在布尔型数组中无效。要使用&与|。
通过布尔型数组设置值是一种经常用到的手段。为了将data中的所有负值都设置为0,我们只需:
1 | In [113]: data[data < 0] = 0 |
通过一维布尔数组设置整行或列的值也很简单:
1 | In [115]: data[names != 'Joe'] = 7 |
后面会看到,这类二维数据的操作也可以用pandas方便的来做。
花式索引
花式索引(Fancy indexing)是一个NumPy术语,它指的是利用整数数组进行索引。假设我们有一个8×4数组:
1 | In [117]: arr = np.empty((8, 4)) |
为了以特定顺序选取行子集,只需传入一个用于指定顺序的整数列表或ndarray即可:
1 | In [120]: arr[[4, 3, 0, 6]] |
这段代码确实达到我们的要求了!使用负数索引将会从末尾开始选取行:
1 | In [121]: arr[[-3, -5, -7]] |
一次传入多个索引数组会有一点特别。它返回的是一个一维数组,其中的元素对应各个索引元组:
1 | In [122]: arr = np.arange(32).reshape((8, 4)) |
附录A中会详细介绍reshape方法。
最终选出的是元素(1,0)、(5,3)、(7,1)和(2,2)。无论数组是多少维的,花式索引总是一维的。
这个花式索引的行为可能会跟某些用户的预期不一样(包括我在内),选取矩阵的行列子集应该是矩形区域的形式才对。下面是得到该结果的一个办法:
1 | In [125]: arr[[1, 5, 7, 2]][:, [0, 3, 1, 2]] |
记住,花式索引跟切片不一样,它总是将数据复制到新数组中。
数组转置和轴对换
转置是重塑的一种特殊形式,它返回的是源数据的视图(不会进行任何复制操作)。数组不仅有transpose方法,还有一个特殊的T属性:
1 | In [126]: arr = np.arange(15).reshape((3, 5)) |
在进行矩阵计算时,经常需要用到该操作,比如利用np.dot计算矩阵内积:
1 | In [129]: arr = np.random.randn(6, 3) |
对于高维数组,transpose需要得到一个由轴编号组成的元组才能对这些轴进行转置(比较费脑子):
1 | In [132]: arr = np.arange(16).reshape((2, 2, 4)) |
这里,第一个轴被换成了第二个,第二个轴被换成了第一个,最后一个轴不变。
简单的转置可以使用.T,它其实就是进行轴对换而已。ndarray还有一个swapaxes方法,它需要接受一对轴编号:
1 | In [135]: arr |
swapaxes也是返回源数据的视图(不会进行任何复制操作)。
4.2 通用函数:快速的元素级数组函数
通用函数(即ufunc)是一种对ndarray中的数据执行元素级运算的函数。你可以将其看做简单函数(接受一个或多个标量值,并产生一个或多个标量值)的矢量化包装器。
许多ufunc都是简单的元素级变体,如sqrt和exp:
1 | In [137]: arr = np.arange(10) |
这些都是一元(unary)ufunc。另外一些(如add或maximum)接受2个数组(因此也叫二元(binary)ufunc),并返回一个结果数组:
1 | In [141]: x = np.random.randn(8) |
这里,numpy.maximum计算了x和y中元素级别最大的元素。
虽然并不常见,但有些ufunc的确可以返回多个数组。modf就是一个例子,它是Python内置函数divmod的矢量化版本,它会返回浮点数数组的小数和整数部分:
1 | In [146]: arr = np.random.randn(7) * 5 |
Ufuncs可以接受一个out可选参数,这样就能在数组原地进行操作:
1 | In [151]: arr |
表4-3和表4-4分别列出了一些一元和二元ufunc。
4.3 利用数组进行数据处理
NumPy数组使你可以将许多种数据处理任务表述为简洁的数组表达式(否则需要编写循环)。用数组表达式代替循环的做法,通常被称为矢量化。一般来说,矢量化数组运算要比等价的纯Python方式快上一两个数量级(甚至更多),尤其是各种数值计算。在后面内容中(见附录A)我将介绍广播,这是一种针对矢量化计算的强大手段。
作为简单的例子,假设我们想要在一组值(网格型)上计算函数sqrt(x^2+y^2)
。np.meshgrid函数接受两个一维数组,并产生两个二维矩阵(对应于两个数组中所有的(x,y)对):
1 | In [155]: points = np.arange(-5, 5, 0.01) # 1000 equally spaced points |
现在,对该函数的求值运算就好办了,把这两个数组当做两个浮点数那样编写表达式即可:
1 | In [158]: z = np.sqrt(xs ** 2 + ys ** 2) |
作为第9章的先导,我用matplotlib创建了这个二维数组的可视化:
1 | In [160]: import matplotlib.pyplot as plt |
见图4-3。这张图是用matplotlib的imshow函数创建的。
将条件逻辑表述为数组运算
numpy.where函数是三元表达式x if condition else y的矢量化版本。假设我们有一个布尔数组和两个值数组:
1 | In [165]: xarr = np.array([1.1, 1.2, 1.3, 1.4, 1.5]) |
假设我们想要根据cond中的值选取xarr和yarr的值:当cond中的值为True时,选取xarr的值,否则从yarr中选取。列表推导式的写法应该如下所示:
1 | In [168]: result = [(x if c else y) |
这有几个问题。第一,它对大数组的处理速度不是很快(因为所有工作都是由纯Python完成的)。第二,无法用于多维数组。若使用np.where,则可以将该功能写得非常简洁:
1 | In [170]: result = np.where(cond, xarr, yarr) |
np.where的第二个和第三个参数不必是数组,它们都可以是标量值。在数据分析工作中,where通常用于根据另一个数组而产生一个新的数组。假设有一个由随机数据组成的矩阵,你希望将所有正值替换为2,将所有负值替换为-2。若利用np.where,则会非常简单:
1 | In [172]: arr = np.random.randn(4, 4) |
使用np.where,可以将标量和数组结合起来。例如,我可用常数2替换arr中所有正的值:
1 | In [176]: np.where(arr > 0, 2, arr) # set only positive values to 2 |
传递给where的数组大小可以不相等,甚至可以是标量值。
数学和统计方法
可以通过数组上的一组数学函数对整个数组或某个轴向的数据进行统计计算。sum、mean以及标准差std等聚合计算(aggregation,通常叫做约简(reduction))既可以当做数组的实例方法调用,也可以当做顶级NumPy函数使用。
这里,我生成了一些正态分布随机数据,然后做了聚类统计:
1 | In [177]: arr = np.random.randn(5, 4) |
mean和sum这类的函数可以接受一个axis选项参数,用于计算该轴向上的统计值,最终结果是一个少一维的数组:
1 | In [182]: arr.mean(axis=1) |
这里,arr.mean(1)是“计算行的平均值”,arr.sum(0)是“计算每列的和”。
其他如cumsum和cumprod之类的方法则不聚合,而是产生一个由中间结果组成的数组:
1 | In [184]: arr = np.array([0, 1, 2, 3, 4, 5, 6, 7]) |
在多维数组中,累加函数(如cumsum)返回的是同样大小的数组,但是会根据每个低维的切片沿着标记轴计算部分聚类:
1 | In [186]: arr = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]]) |
表4-5列出了全部的基本数组统计方法。后续章节中有很多例子都会用到这些方法。
用于布尔型数组的方法
在上面这些方法中,布尔值会被强制转换为1(True)和0(False)。因此,sum经常被用来对布尔型数组中的True值计数:
1 | In [190]: arr = np.random.randn(100) |
另外还有两个方法any和all,它们对布尔型数组非常有用。any用于测试数组中是否存在一个或多个True,而all则检查数组中所有值是否都是True:
1 | In [192]: bools = np.array([False, False, True, False]) |
这两个方法也能用于非布尔型数组,所有非0元素将会被当做True。
排序
跟Python内置的列表类型一样,NumPy数组也可以通过sort方法就地排序:
1 | In [195]: arr = np.random.randn(6) |
多维数组可以在任何一个轴向上进行排序,只需将轴编号传给sort即可:
1 | In [199]: arr = np.random.randn(5, 3) |
顶级方法np.sort返回的是数组的已排序副本,而就地排序则会修改数组本身。计算数组分位数最简单的办法是对其进行排序,然后选取特定位置的值:
1 | In [203]: large_arr = np.random.randn(1000) |
更多关于NumPy排序方法以及诸如间接排序之类的高级技术,请参阅附录A。在pandas中还可以找到一些其他跟排序有关的数据操作(比如根据一列或多列对表格型数据进行排序)。
唯一化以及其它的集合逻辑
NumPy提供了一些针对一维ndarray的基本集合运算。最常用的可能要数np.unique了,它用于找出数组中的唯一值并返回已排序的结果:
1 | In [206]: names = np.array(['Bob', 'Joe', 'Will', 'Bob', 'Will', 'Joe', 'Joe']) |
拿跟np.unique等价的纯Python代码来对比一下:
1 | In [210]: sorted(set(names)) |
另一个函数np.in1d用于测试一个数组中的值在另一个数组中的成员资格,返回一个布尔型数组:
1 | In [211]: values = np.array([6, 0, 0, 3, 2, 5, 6]) |
NumPy中的集合函数请参见表4-6。
4.4 用于数组的文件输入输出
NumPy能够读写磁盘上的文本数据或二进制数据。这一小节只讨论NumPy的内置二进制格式,因为更多的用户会使用pandas或其它工具加载文本或表格数据(见第6章)。
np.save和np.load是读写磁盘数组数据的两个主要函数。默认情况下,数组是以未压缩的原始二进制格式保存在扩展名为.npy的文件中的:
1 | In [213]: arr = np.arange(10) |
如果文件路径末尾没有扩展名.npy,则该扩展名会被自动加上。然后就可以通过np.load读取磁盘上的数组:
1 | In [215]: np.load('some_array.npy') |
通过np.savez可以将多个数组保存到一个未压缩文件中,将数组以关键字参数的形式传入即可:
1 | In [216]: np.savez('array_archive.npz', a=arr, b=arr) |
加载.npz文件时,你会得到一个类似字典的对象,该对象会对各个数组进行延迟加载:
1 | In [217]: arch = np.load('array_archive.npz') |
如果要将数据压缩,可以使用numpy.savez_compressed:
1 | In [219]: np.savez_compressed('arrays_compressed.npz', a=arr, b=arr) |
4.5 线性代数
线性代数(如矩阵乘法、矩阵分解、行列式以及其他方阵数学等)是任何数组库的重要组成部分。不像某些语言(如MATLAB),通过*对两个二维数组相乘得到的是一个元素级的积,而不是一个矩阵点积。因此,NumPy提供了一个用于矩阵乘法的dot函数(既是一个数组方法也是numpy命名空间中的一个函数):
1 | In [223]: x = np.array([[1., 2., 3.], [4., 5., 6.]]) |
x.dot(y)等价于np.dot(x, y):
1 | In [228]: np.dot(x, y) |
一个二维数组跟一个大小合适的一维数组的矩阵点积运算之后将会得到一个一维数组:
1 | In [229]: np.dot(x, np.ones(3)) |
@符(类似Python 3.5)也可以用作中缀运算符,进行矩阵乘法:
1 | In [230]: x @ np.ones(3) |
numpy.linalg中有一组标准的矩阵分解运算以及诸如求逆和行列式之类的东西。它们跟MATLAB和R等语言所使用的是相同的行业标准线性代数库,如BLAS、LAPACK、Intel MKL(Math Kernel Library,可能有,取决于你的NumPy版本)等:
1 | In [231]: from numpy.linalg import inv, qr |
表达式X.T.dot(X)计算X和它的转置X.T的点积。
表4-7中列出了一些最常用的线性代数函数。
4.6 伪随机数生成
numpy.random模块对Python内置的random进行了补充,增加了一些用于高效生成多种概率分布的样本值的函数。例如,你可以用normal来得到一个标准正态分布的4×4样本数组:
1 | In [238]: samples = np.random.normal(size=(4, 4)) |
而Python内置的random模块则只能一次生成一个样本值。从下面的测试结果中可以看出,如果需要产生大量样本值,numpy.random快了不止一个数量级:
1 | In [240]: from random import normalvariate |
我们说这些都是伪随机数,是因为它们都是通过算法基于随机数生成器种子,在确定性的条件下生成的。你可以用NumPy的np.random.seed更改随机数生成种子:
1 | In [244]: np.random.seed(1234) |
numpy.random的数据生成函数使用了全局的随机种子。要避免全局状态,你可以使用numpy.random.RandomState,创建一个与其它隔离的随机数生成器:
1 | In [245]: rng = np.random.RandomState(1234) |
表4-8列出了numpy.random中的部分函数。在下一节中,我将给出一些利用这些函数一次性生成大量样本值的范例。
4.7 示例:随机漫步
我们通过模拟随机漫步来说明如何运用数组运算。先来看一个简单的随机漫步的例子:从0开始,步长1和-1出现的概率相等。
下面是一个通过内置的random模块以纯Python的方式实现1000步的随机漫步:
1 | In [247]: import random |
图4-4是根据前100个随机漫步值生成的折线图:
1 | In [249]: plt.plot(walk[:100]) |
不难看出,这其实就是随机漫步中各步的累计和,可以用一个数组运算来实现。因此,我用np.random模块一次性随机产生1000个“掷硬币”结果(即两个数中任选一个),将其分别设置为1或-1,然后计算累计和:
1 | In [251]: nsteps = 1000 |
有了这些数据之后,我们就可以沿着漫步路径做一些统计工作了,比如求取最大值和最小值:
1 | In [255]: walk.min() |
现在来看一个复杂点的统计任务——首次穿越时间,即随机漫步过程中第一次到达某个特定值的时间。假设我们想要知道本次随机漫步需要多久才能距离初始0点至少10步远(任一方向均可)。np.abs(walk)>=10可以得到一个布尔型数组,它表示的是距离是否达到或超过10,而我们想要知道的是第一个10或-10的索引。可以用argmax来解决这个问题,它返回的是该布尔型数组第一个最大值的索引(True就是最大值):
1 | In [257]: (np.abs(walk) >= 10).argmax() |
注意,这里使用argmax并不是很高效,因为它无论如何都会对数组进行完全扫描。在本例中,只要发现了一个True,那我们就知道它是个最大值了。
一次模拟多个随机漫步
如果你希望模拟多个随机漫步过程(比如5000个),只需对上面的代码做一点点修改即可生成所有的随机漫步过程。只要给numpy.random的函数传入一个二元元组就可以产生一个二维数组,然后我们就可以一次性计算5000个随机漫步过程(一行一个)的累计和了:
1 | In [258]: nwalks = 5000 |
现在,我们来计算所有随机漫步过程的最大值和最小值:
1 | In [264]: walks.max() |
得到这些数据之后,我们来计算30或-30的最小穿越时间。这里稍微复杂些,因为不是5000个过程都到达了30。我们可以用any方法来对此进行检查:
1 | In [266]: hits30 = (np.abs(walks) >= 30).any(1) |
然后我们利用这个布尔型数组选出那些穿越了30(绝对值)的随机漫步(行),并调用argmax在轴1上获取穿越时间:
1 | In [269]: crossing_times = (np.abs(walks[hits30]) >= 30).argmax(1) |
请尝试用其他分布方式得到漫步数据。只需使用不同的随机数生成函数即可,如normal用于生成指定均值和标准差的正态分布数据:
1 | In [271]: steps = np.random.normal(loc=0, scale=0.25, |
4.8 结论
虽然本书剩下的章节大部分是用pandas规整数据,我们还是会用到相似的基于数组的计算。在附录A中,我们会深入挖掘NumPy的特点,进一步学习数组的技巧。
第5章 pandas入门
pandas是本书后续内容的首选库。它含有使数据清洗和分析工作变得更快更简单的数据结构和操作工具。pandas经常和其它工具一同使用,如数值计算工具NumPy和SciPy,分析库statsmodels和scikit-learn,和数据可视化库matplotlib。pandas是基于NumPy数组构建的,特别是基于数组的函数和不使用for循环的数据处理。
虽然pandas采用了大量的NumPy编码风格,但二者最大的不同是pandas是专门为处理表格和混杂数据设计的。而NumPy更适合处理统一的数值数组数据。
自从2010年pandas开源以来,pandas逐渐成长为一个非常大的库,应用于许多真实案例。开发者社区已经有了800个独立的贡献者,他们在解决日常数据问题的同时为这个项目提供贡献。
在本书后续部分中,我将使用下面这样的pandas引入约定:
1 | In [1]: import pandas as pd |
因此,只要你在代码中看到pd.,就得想到这是pandas。因为Series和DataFrame用的次数非常多,所以将其引入本地命名空间中会更方便:
1 | In [2]: from pandas import Series, DataFrame |
5.1 pandas的数据结构介绍
要使用pandas,你首先就得熟悉它的两个主要数据结构:Series和DataFrame。虽然它们并不能解决所有问题,但它们为大多数应用提供了一种可靠的、易于使用的基础。
Series
Series是一种类似于一维数组的对象,它由一组数据(各种NumPy数据类型)以及一组与之相关的数据标签(即索引)组成。仅由一组数据即可产生最简单的Series:
1 | In [11]: obj = pd.Series([4, 7, -5, 3]) |
Series的字符串表现形式为:索引在左边,值在右边。由于我们没有为数据指定索引,于是会自动创建一个0到N-1(N为数据的长度)的整数型索引。你可以通过Series 的values和index属性获取其数组表示形式和索引对象:
1 | In [13]: obj.values |
通常,我们希望所创建的Series带有一个可以对各个数据点进行标记的索引:
1 | In [15]: obj2 = pd.Series([4, 7, -5, 3], index=['d', 'b', 'a', 'c']) |
与普通NumPy数组相比,你可以通过索引的方式选取Series中的单个或一组值:
1 | In [18]: obj2['a'] |
[‘c’, ‘a’, ‘d’]是索引列表,即使它包含的是字符串而不是整数。
使用NumPy函数或类似NumPy的运算(如根据布尔型数组进行过滤、标量乘法、应用数学函数等)都会保留索引值的链接:
1 | In [21]: obj2[obj2 > 0] |
还可以将Series看成是一个定长的有序字典,因为它是索引值到数据值的一个映射。它可以用在许多原本需要字典参数的函数中:
1 | In [24]: 'b' in obj2 |
如果数据被存放在一个Python字典中,也可以直接通过这个字典来创建Series:
1 | In [26]: sdata = {'Ohio': 35000, 'Texas': 71000, 'Oregon': 16000, 'Utah': 5000} |
如果只传入一个字典,则结果Series中的索引就是原字典的键(有序排列)。你可以传入排好序的字典的键以改变顺序:
1 | In [29]: states = ['California', 'Ohio', 'Oregon', 'Texas'] |
在这个例子中,sdata中跟states索引相匹配的那3个值会被找出来并放到相应的位置上,但由于”California”所对应的sdata值找不到,所以其结果就为NaN(即“非数字”(not a number),在pandas中,它用于表示缺失或NA值)。因为‘Utah’不在states中,它被从结果中除去。
我将使用缺失(missing)或NA表示缺失数据。pandas的isnull和notnull函数可用于检测缺失数据:
1 | In [32]: pd.isnull(obj4) |
Series也有类似的实例方法:
1 | In [34]: obj4.isnull() |
我将在第7章详细讲解如何处理缺失数据。
对于许多应用而言,Series最重要的一个功能是,它会根据运算的索引标签自动对齐数据:
1 | In [35]: obj3 |
数据对齐功能将在后面详细讲解。如果你使用过数据库,你可以认为是类似join的操作。
Series对象本身及其索引都有一个name属性,该属性跟pandas其他的关键功能关系非常密切:
1 | In [38]: obj4.name = 'population' |
Series的索引可以通过赋值的方式就地修改:
1 | In [41]: obj |
DataFrame
DataFrame是一个表格型的数据结构,它含有一组有序的列,每列可以是不同的值类型(数值、字符串、布尔值等)。DataFrame既有行索引也有列索引,它可以被看做由Series组成的字典(共用同一个索引)。DataFrame中的数据是以一个或多个二维块存放的(而不是列表、字典或别的一维数据结构)。有关DataFrame内部的技术细节远远超出了本书所讨论的范围。
笔记:虽然DataFrame是以二维结构保存数据的,但你仍然可以轻松地将其表示为更高维度的数据(层次化索引的表格型结构,这是pandas中许多高级数据处理功能的关键要素,我们会在第8章讨论这个问题)。
建DataFrame的办法有很多,最常用的一种是直接传入一个由等长列表或NumPy数组组成的字典:
1 | data = {'state': ['Ohio', 'Ohio', 'Ohio', 'Nevada', 'Nevada', 'Nevada'], |
结果DataFrame会自动加上索引(跟Series一样),且全部列会被有序排列:
1 | In [45]: frame |
如果你使用的是Jupyter notebook,pandas DataFrame对象会以对浏览器友好的HTML表格的方式呈现。
对于特别大的DataFrame,head方法会选取前五行:
1 | In [46]: frame.head() |
如果指定了列序列,则DataFrame的列就会按照指定顺序进行排列:
1 | In [47]: pd.DataFrame(data, columns=['year', 'state', 'pop']) |
如果传入的列在数据中找不到,就会在结果中产生缺失值:
1 | In [48]: frame2 = pd.DataFrame(data, columns=['year', 'state', 'pop', 'debt'], |
通过类似字典标记的方式或属性的方式,可以将DataFrame的列获取为一个Series:
1 | In [51]: frame2['state'] |
笔记:IPython提供了类似属性的访问(即frame2.year)和tab补全。
frame2[column]适用于任何列的名,但是frame2.column只有在列名是一个合理的Python变量名时才适用。
注意,返回的Series拥有原DataFrame相同的索引,且其name属性也已经被相应地设置好了。
行也可以通过位置或名称的方式进行获取,比如用loc属性(稍后将对此进行详细讲解):
1 | In [53]: frame2.loc['three'] |
列可以通过赋值的方式进行修改。例如,我们可以给那个空的”debt”列赋上一个标量值或一组值:
1 | In [54]: frame2['debt'] = 16.5 |
将列表或数组赋值给某个列时,其长度必须跟DataFrame的长度相匹配。如果赋值的是一个Series,就会精确匹配DataFrame的索引,所有的空位都将被填上缺失值:
1 | In [58]: val = pd.Series([-1.2, -1.5, -1.7], index=['two', 'four', 'five']) |
为不存在的列赋值会创建出一个新列。关键字del用于删除列。
作为del的例子,我先添加一个新的布尔值的列,state是否为’Ohio’:
1 | In [61]: frame2['eastern'] = frame2.state == 'Ohio' |
注意:不能用frame2.eastern创建新的列。
del方法可以用来删除这列:
1 | In [63]: del frame2['eastern'] |
注意:通过索引方式返回的列只是相应数据的视图而已,并不是副本。因此,对返回的Series所做的任何就地修改全都会反映到源DataFrame上。通过Series的copy方法即可指定复制列。
另一种常见的数据形式是嵌套字典:
1 | In [65]: pop = {'Nevada': {2001: 2.4, 2002: 2.9}, |
如果嵌套字典传给DataFrame,pandas就会被解释为:外层字典的键作为列,内层键则作为行索引:
1 | In [66]: frame3 = pd.DataFrame(pop) |
你也可以使用类似NumPy数组的方法,对DataFrame进行转置(交换行和列):
1 | In [68]: frame3.T |
内层字典的键会被合并、排序以形成最终的索引。如果明确指定了索引,则不会这样:
1 | In [69]: pd.DataFrame(pop, index=[2001, 2002, 2003]) |
由Series组成的字典差不多也是一样的用法:
1 | In [70]: pdata = {'Ohio': frame3['Ohio'][:-1], |
表5-1列出了DataFrame构造函数所能接受的各种数据。
如果设置了DataFrame的index和columns的name属性,则这些信息也会被显示出来:
1 | In [72]: frame3.index.name = 'year'; frame3.columns.name = 'state' |
跟Series一样,values属性也会以二维ndarray的形式返回DataFrame中的数据:
1 | In [74]: frame3.values |
如果DataFrame各列的数据类型不同,则值数组的dtype就会选用能兼容所有列的数据类型:
1 | In [75]: frame2.values |
索引对象
pandas的索引对象负责管理轴标签和其他元数据(比如轴名称等)。构建Series或DataFrame时,所用到的任何数组或其他序列的标签都会被转换成一个Index:
1 | In [76]: obj = pd.Series(range(3), index=['a', 'b', 'c']) |
Index对象是不可变的,因此用户不能对其进行修改:
1 | index[1] = 'd' # TypeError |
不可变可以使Index对象在多个数据结构之间安全共享:
1 | In [80]: labels = pd.Index(np.arange(3)) |
注意:虽然用户不需要经常使用Index的功能,但是因为一些操作会生成包含被索引化的数据,理解它们的工作原理是很重要的。
除了类似于数组,Index的功能也类似一个固定大小的集合:
1 | In [85]: frame3 |
与python的集合不同,pandas的Index可以包含重复的标签:
1 | In [89]: dup_labels = pd.Index(['foo', 'foo', 'bar', 'bar']) |
选择重复的标签,会显示所有的结果。
每个索引都有一些方法和属性,它们可用于设置逻辑并回答有关该索引所包含的数据的常见问题。表5-2列出了这些函数。
5.2 基本功能
本节中,我将介绍操作Series和DataFrame中的数据的基本手段。后续章节将更加深入地挖掘pandas在数据分析和处理方面的功能。本书不是pandas库的详尽文档,主要关注的是最重要的功能,那些不大常用的内容(也就是那些更深奥的内容)就交给你自己去摸索吧。
重新索引
pandas对象的一个重要方法是reindex,其作用是创建一个新对象,它的数据符合新的索引。看下面的例子:
1 | In [91]: obj = pd.Series([4.5, 7.2, -5.3, 3.6], index=['d', 'b', 'a', 'c']) |
用该Series的reindex将会根据新索引进行重排。如果某个索引值当前不存在,就引入缺失值:
1 | In [93]: obj2 = obj.reindex(['a', 'b', 'c', 'd', 'e']) |
对于时间序列这样的有序数据,重新索引时可能需要做一些插值处理。method选项即可达到此目的,例如,使用ffill可以实现前向值填充:
1 | In [95]: obj3 = pd.Series(['blue', 'purple', 'yellow'], index=[0, 2, 4]) |
借助DataFrame,reindex可以修改(行)索引和列。只传递一个序列时,会重新索引结果的行:
1 | In [98]: frame = pd.DataFrame(np.arange(9).reshape((3, 3)), |
列可以用columns关键字重新索引:
1 | In [102]: states = ['Texas', 'Utah', 'California'] |
表5-3列出了reindex函数的各参数及说明。
丢弃指定轴上的项
丢弃某条轴上的一个或多个项很简单,只要有一个索引数组或列表即可。由于需要执行一些数据整理和集合逻辑,所以drop方法返回的是一个在指定轴上删除了指定值的新对象:
1 | In [105]: obj = pd.Series(np.arange(5.), index=['a', 'b', 'c', 'd', 'e']) |
对于DataFrame,可以删除任意轴上的索引值。为了演示,先新建一个DataFrame例子:
1 | In [110]: data = pd.DataFrame(np.arange(16).reshape((4, 4)), |
用标签序列调用drop会从行标签(axis 0)删除值:
1 | In [112]: data.drop(['Colorado', 'Ohio']) |
通过传递axis=1或axis=’columns’可以删除列的值:
1 | In [113]: data.drop('two', axis=1) |
许多函数,如drop,会修改Series或DataFrame的大小或形状,可以就地修改对象,不会返回新的对象:
1 | In [115]: obj.drop('c', inplace=True) |
小心使用inplace,它会销毁所有被删除的数据。
索引、选取和过滤
Series索引(obj[…])的工作方式类似于NumPy数组的索引,只不过Series的索引值不只是整数。下面是几个例子:
1 | In [117]: obj = pd.Series(np.arange(4.), index=['a', 'b', 'c', 'd']) |
利用标签的切片运算与普通的Python切片运算不同,其末端是包含的:
1 | In [125]: obj['b':'c'] |
用切片可以对Series的相应部分进行设置:
1 | In [126]: obj['b':'c'] = 5 |
用一个值或序列对DataFrame进行索引其实就是获取一个或多个列:
1 | In [128]: data = pd.DataFrame(np.arange(16).reshape((4, 4)), |
这种索引方式有几个特殊的情况。首先通过切片或布尔型数组选取数据:
1 | In [132]: data[:2] |
选取行的语法data[:2]十分方便。向[ ]传递单一的元素或列表,就可选择列。
另一种用法是通过布尔型DataFrame(比如下面这个由标量比较运算得出的)进行索引:
1 | In [134]: data < 5 |
这使得DataFrame的语法与NumPy二维数组的语法很像。
用loc和iloc进行选取
对于DataFrame的行的标签索引,我引入了特殊的标签运算符loc和iloc。它们可以让你用类似NumPy的标记,使用轴标签(loc)或整数索引(iloc),从DataFrame选择行和列的子集。
作为一个初步示例,让我们通过标签选择一行和多列:
1 | In [137]: data.loc['Colorado', ['two', 'three']] |
然后用iloc和整数进行选取:
1 | In [138]: data.iloc[2, [3, 0, 1]] |
这两个索引函数也适用于一个标签或多个标签的切片:
1 | In [141]: data.loc[:'Utah', 'two'] |
所以,在pandas中,有多个方法可以选取和重新组合数据。对于DataFrame,表5-4进行了总结。后面会看到,还有更多的方法进行层级化索引。
笔记:在一开始设计pandas时,我觉得用frame[:, col]选取列过于繁琐(也容易出错),因为列的选择是非常常见的操作。我做了些取舍,将花式索引的功能(标签和整数)放到了ix运算符中。在实践中,这会导致许多边缘情况,数据的轴标签是整数,所以pandas团队决定创造loc和iloc运算符分别处理严格基于标签和整数的索引。
ix运算符仍然可用,但并不推荐。
整数索引
处理整数索引的pandas对象常常难住新手,因为它与Python内置的列表和元组的索引语法不同。例如,你可能不认为下面的代码会出错:
1 | ser = pd.Series(np.arange(3.)) |
这里,pandas可以勉强进行整数索引,但是会导致小bug。我们有包含0,1,2的索引,但是引入用户想要的东西(基于标签或位置的索引)很难:
1 | In [144]: ser |
另外,对于非整数索引,不会产生歧义:
1 | In [145]: ser2 = pd.Series(np.arange(3.), index=['a', 'b', 'c']) |
为了进行统一,如果轴索引含有整数,数据选取总会使用标签。为了更准确,请使用loc(标签)或iloc(整数):
1 | In [147]: ser[:1] |
算术运算和数据对齐
pandas最重要的一个功能是,它可以对不同索引的对象进行算术运算。在将对象相加时,如果存在不同的索引对,则结果的索引就是该索引对的并集。对于有数据库经验的用户,这就像在索引标签上进行自动外连接。看一个简单的例子:
1 | In [150]: s1 = pd.Series([7.3, -2.5, 3.4, 1.5], index=['a', 'c', 'd', 'e']) |
将它们相加就会产生:
1 | In [154]: s1 + s2 |
自动的数据对齐操作在不重叠的索引处引入了NA值。缺失值会在算术运算过程中传播。
对于DataFrame,对齐操作会同时发生在行和列上:
1 | In [155]: df1 = pd.DataFrame(np.arange(9.).reshape((3, 3)), columns=list('bcd'), |
把它们相加后将会返回一个新的DataFrame,其索引和列为原来那两个DataFrame的并集:
1 | In [159]: df1 + df2 |
因为’c’和’e’列均不在两个DataFrame对象中,在结果中以缺省值呈现。行也是同样。
如果DataFrame对象相加,没有共用的列或行标签,结果都会是空:
1 | In [160]: df1 = pd.DataFrame({'A': [1, 2]}) |
在算术方法中填充值
在对不同索引的对象进行算术运算时,你可能希望当一个对象中某个轴标签在另一个对象中找不到时填充一个特殊值(比如0):
1 | In [165]: df1 = pd.DataFrame(np.arange(12.).reshape((3, 4)), |
将它们相加时,没有重叠的位置就会产生NA值:
1 | In [170]: df1 + df2 |
使用df1的add方法,传入df2以及一个fill_value参数:
1 | In [171]: df1.add(df2, fill_value=0) |
表5-5列出了Series和DataFrame的算术方法。它们每个都有一个副本,以字母r开头,它会翻转参数。因此这两个语句是等价的:
1 | In [172]: 1 / df1 |
与此类似,在对Series或DataFrame重新索引时,也可以指定一个填充值:
1 | In [174]: df1.reindex(columns=df2.columns, fill_value=0) |
DataFrame和Series之间的运算
跟不同维度的NumPy数组一样,DataFrame和Series之间算术运算也是有明确规定的。先来看一个具有启发性的例子,计算一个二维数组与其某行之间的差:
1 | In [175]: arr = np.arange(12.).reshape((3, 4)) |
当我们从arr减去arr[0],每一行都会执行这个操作。这就叫做广播(broadcasting),附录A将对此进行详细讲解。DataFrame和Series之间的运算差不多也是如此:
1 | In [179]: frame = pd.DataFrame(np.arange(12.).reshape((4, 3)), |
默认情况下,DataFrame和Series之间的算术运算会将Series的索引匹配到DataFrame的列,然后沿着行一直向下广播:
1 | In [183]: frame - series |
如果某个索引值在DataFrame的列或Series的索引中找不到,则参与运算的两个对象就会被重新索引以形成并集:
1 | In [184]: series2 = pd.Series(range(3), index=['b', 'e', 'f']) |
如果你希望匹配行且在列上广播,则必须使用算术运算方法。例如:
1 | In [186]: series3 = frame['d'] |
传入的轴号就是希望匹配的轴。在本例中,我们的目的是匹配DataFrame的行索引(axis=’index’ or axis=0)并进行广播。
函数应用和映射
NumPy的ufuncs(元素级数组方法)也可用于操作pandas对象:
1 | In [190]: frame = pd.DataFrame(np.random.randn(4, 3), columns=list('bde'), |
另一个常见的操作是,将函数应用到由各列或行所形成的一维数组上。DataFrame的apply方法即可实现此功能:
1 | In [193]: f = lambda x: x.max() - x.min() |
这里的函数f,计算了一个Series的最大值和最小值的差,在frame的每列都执行了一次。结果是一个Series,使用frame的列作为索引。
如果传递axis=’columns’到apply,这个函数会在每行执行:
1 | In [195]: frame.apply(f, axis='columns') |
许多最为常见的数组统计功能都被实现成DataFrame的方法(如sum和mean),因此无需使用apply方法。
传递到apply的函数不是必须返回一个标量,还可以返回由多个值组成的Series:
1 | In [196]: def f(x): |
元素级的Python函数也是可以用的。假如你想得到frame中各个浮点值的格式化字符串,使用applymap即可:
1 | In [198]: format = lambda x: '%.2f' % x |
之所以叫做applymap,是因为Series有一个用于应用元素级函数的map方法:
1 | In [200]: frame['e'].map(format) |
排序和排名
根据条件对数据集排序(sorting)也是一种重要的内置运算。要对行或列索引进行排序(按字典顺序),可使用sort_index方法,它将返回一个已排序的新对象:
1 | In [201]: obj = pd.Series(range(4), index=['d', 'a', 'b', 'c']) |
对于DataFrame,则可以根据任意一个轴上的索引进行排序:
1 | In [203]: frame = pd.DataFrame(np.arange(8).reshape((2, 4)), |
数据默认是按升序排序的,但也可以降序排序:
1 | In [206]: frame.sort_index(axis=1, ascending=False) |
若要按值对Series进行排序,可使用其sort_values方法:
1 | In [207]: obj = pd.Series([4, 7, -3, 2]) |
在排序时,任何缺失值默认都会被放到Series的末尾:
1 | In [209]: obj = pd.Series([4, np.nan, 7, np.nan, -3, 2]) |
当排序一个DataFrame时,你可能希望根据一个或多个列中的值进行排序。将一个或多个列的名字传递给sort_values的by选项即可达到该目的:
1 | In [211]: frame = pd.DataFrame({'b': [4, 7, -3, 2], 'a': [0, 1, 0, 1]}) |
要根据多个列进行排序,传入名称的列表即可:
1 | In [214]: frame.sort_values(by=['a', 'b']) |
排名会从1开始一直到数组中有效数据的数量。接下来介绍Series和DataFrame的rank方法。默认情况下,rank是通过“为各组分配一个平均排名”的方式破坏平级关系的:
1 | In [215]: obj = pd.Series([7, -5, 7, 4, 2, 0, 4]) |
也可以根据值在原数据中出现的顺序给出排名:
1 | In [217]: obj.rank(method='first') |
这里,条目0和2没有使用平均排名6.5,它们被设成了6和7,因为数据中标签0位于标签2的前面。
你也可以按降序进行排名:
1 | # Assign tie values the maximum rank in the group |
表5-6列出了所有用于破坏平级关系的method选项。DataFrame可以在行或列上计算排名:
1 | In [219]: frame = pd.DataFrame({'b': [4.3, 7, -3, 2], 'a': [0, 1, 0, 1], |
带有重复标签的轴索引
直到目前为止,我所介绍的所有范例都有着唯一的轴标签(索引值)。虽然许多pandas函数(如reindex)都要求标签唯一,但这并不是强制性的。我们来看看下面这个简单的带有重复索引值的Series:
1 | In [222]: obj = pd.Series(range(5), index=['a', 'a', 'b', 'b', 'c']) |
索引的is_unique属性可以告诉你它的值是否是唯一的:
1 | In [224]: obj.index.is_unique |
对于带有重复值的索引,数据选取的行为将会有些不同。如果某个索引对应多个值,则返回一个Series;而对应单个值的,则返回一个标量值:
1 | In [225]: obj['a'] |
这样会使代码变复杂,因为索引的输出类型会根据标签是否有重复发生变化。
对DataFrame的行进行索引时也是如此:
1 | In [227]: df = pd.DataFrame(np.random.randn(4, 3), index=['a', 'a', 'b', 'b']) |
5.3 汇总和计算描述统计
pandas对象拥有一组常用的数学和统计方法。它们大部分都属于约简和汇总统计,用于从Series中提取单个值(如sum或mean)或从DataFrame的行或列中提取一个Series。跟对应的NumPy数组方法相比,它们都是基于没有缺失数据的假设而构建的。看一个简单的DataFrame:
1 | In [230]: df = pd.DataFrame([[1.4, np.nan], [7.1, -4.5], |
调用DataFrame的sum方法将会返回一个含有列的和的Series:
1 | In [232]: df.sum() |
传入axis=’columns’或axis=1将会按行进行求和运算:
1 | In [233]: df.sum(axis=1) |
NA值会自动被排除,除非整个切片(这里指的是行或列)都是NA。通过skipna选项可以禁用该功能:
1 | In [234]: df.mean(axis='columns', skipna=False) |
表5-7列出了这些约简方法的常用选项。
有些方法(如idxmin和idxmax)返回的是间接统计(比如达到最小值或最大值的索引):
1 | In [235]: df.idxmax() |
另一些方法则是累计型的:
1 | In [236]: df.cumsum() |
还有一种方法,它既不是约简型也不是累计型。describe就是一个例子,它用于一次性产生多个汇总统计:
1 | In [237]: df.describe() |
对于非数值型数据,describe会产生另外一种汇总统计:
1 | In [238]: obj = pd.Series(['a', 'a', 'b', 'c'] * 4) |
表5-8列出了所有与描述统计相关的方法。
相关系数与协方差
有些汇总统计(如相关系数和协方差)是通过参数对计算出来的。我们来看几个DataFrame,它们的数据来自Yahoo!Finance的股票价格和成交量,使用的是pandas-datareader包(可以用conda或pip安装):
1 | conda install pandas-datareader |
我使用pandas_datareader模块下载了一些股票数据:
1 | import pandas_datareader.data as web |
注意:此时Yahoo! Finance已经不存在了,因为2017年Yahoo!被Verizon收购了。参阅pandas-datareader文档,可以学习最新的功能。
现在计算价格的百分数变化,时间序列的操作会在第11章介绍:
1 | In [242]: returns = price.pct_change() |
Series的corr方法用于计算两个Series中重叠的、非NA的、按索引对齐的值的相关系数。与此类似,cov用于计算协方差:
1 | In [244]: returns['MSFT'].corr(returns['IBM']) |
因为MSTF是一个合理的Python属性,我们还可以用更简洁的语法选择列:
1 | In [246]: returns.MSFT.corr(returns.IBM) |
另一方面,DataFrame的corr和cov方法将以DataFrame的形式分别返回完整的相关系数或协方差矩阵:
1 | In [247]: returns.corr() |
利用DataFrame的corrwith方法,你可以计算其列或行跟另一个Series或DataFrame之间的相关系数。传入一个Series将会返回一个相关系数值Series(针对各列进行计算):
1 | In [249]: returns.corrwith(returns.IBM) |
传入一个DataFrame则会计算按列名配对的相关系数。这里,我计算百分比变化与成交量的相关系数:
1 | In [250]: returns.corrwith(volume) |
传入axis=’columns’即可按行进行计算。无论如何,在计算相关系数之前,所有的数据项都会按标签对齐。
唯一值、值计数以及成员资格
还有一类方法可以从一维Series的值中抽取信息。看下面的例子:
1 | In [251]: obj = pd.Series(['c', 'a', 'd', 'a', 'a', 'b', 'b', 'c', 'c']) |
第一个函数是unique,它可以得到Series中的唯一值数组:
1 | In [252]: uniques = obj.unique() |
返回的唯一值是未排序的,如果需要的话,可以对结果再次进行排序(uniques.sort())。相似的,value_counts用于计算一个Series中各值出现的频率:
1 | In [254]: obj.value_counts() |
为了便于查看,结果Series是按值频率降序排列的。value_counts还是一个顶级pandas方法,可用于任何数组或序列:
1 | In [255]: pd.value_counts(obj.values, sort=False) |
isin用于判断矢量化集合的成员资格,可用于过滤Series中或DataFrame列中数据的子集:
1 | In [256]: obj |
与isin类似的是Index.get_indexer方法,它可以给你一个索引数组,从可能包含重复值的数组到另一个不同值的数组:
1 | In [260]: to_match = pd.Series(['c', 'a', 'b', 'b', 'c', 'a']) |
表5-9给出了这几个方法的一些参考信息。
有时,你可能希望得到DataFrame中多个相关列的一张柱状图。例如:
1 | In [263]: data = pd.DataFrame({'Qu1': [1, 3, 4, 3, 4], |
将pandas.value_counts传给该DataFrame的apply函数,就会出现:
1 | In [265]: result = data.apply(pd.value_counts).fillna(0) |
这里,结果中的行标签是所有列的唯一值。后面的频率值是每个列中这些值的相应计数。
5.4 总结
在下一章,我们将讨论用pandas读取(或加载)和写入数据集的工具。
之后,我们将更深入地研究使用pandas进行数据清洗、规整、分析和可视化工具。
第6章 数据加载、存储与文件格式
访问数据是使用本书所介绍的这些工具的第一步。我会着重介绍pandas的数据输入与输出,虽然别的库中也有不少以此为目的的工具。
输入输出通常可以划分为几个大类:读取文本文件和其他更高效的磁盘存储格式,加载数据库中的数据,利用Web API操作网络资源。
6.1 读写文本格式的数据
pandas提供了一些用于将表格型数据读取为DataFrame对象的函数。表6-1对它们进行了总结,其中read_csv和read_table可能会是你今后用得最多的。
我将大致介绍一下这些函数在将文本数据转换为DataFrame时所用到的一些技术。这些函数的选项可以划分为以下几个大类:
- 索引:将一个或多个列当做返回的DataFrame处理,以及是否从文件、用户获取列名。
- 类型推断和数据转换:包括用户定义值的转换、和自定义的缺失值标记列表等。
- 日期解析:包括组合功能,比如将分散在多个列中的日期时间信息组合成结果中的单个列。
- 迭代:支持对大文件进行逐块迭代。
- 不规整数据问题:跳过一些行、页脚、注释或其他一些不重要的东西(比如由成千上万个逗号隔开的数值数据)。
因为工作中实际碰到的数据可能十分混乱,一些数据加载函数(尤其是read_csv)的选项逐渐变得复杂起来。面对不同的参数,感到头痛很正常(read_csv有超过50个参数)。pandas文档有这些参数的例子,如果你感到阅读某个文件很难,可以通过相似的足够多的例子找到正确的参数。
其中一些函数,比如pandas.read_csv,有类型推断功能,因为列数据的类型不属于数据类型。也就是说,你不需要指定列的类型到底是数值、整数、布尔值,还是字符串。其它的数据格式,如HDF5、Feather和msgpack,会在格式中存储数据类型。
日期和其他自定义类型的处理需要多花点工夫才行。首先我们来看一个以逗号分隔的(CSV)文本文件:
1 | In [8]: !cat examples/ex1.csv |
笔记:这里,我用的是Unix的cat shell命令将文件的原始内容打印到屏幕上。如果你用的是Windows,你可以使用type达到同样的效果。
由于该文件以逗号分隔,所以我们可以使用read_csv将其读入一个DataFrame:
1 | In [9]: df = pd.read_csv('examples/ex1.csv') |
我们还可以使用read_table,并指定分隔符:
1 | In [11]: pd.read_table('examples/ex1.csv', sep=',') |
并不是所有文件都有标题行。看看下面这个文件:
1 | In [12]: !cat examples/ex2.csv |
读入该文件的办法有两个。你可以让pandas为其分配默认的列名,也可以自己定义列名:
1 | In [13]: pd.read_csv('examples/ex2.csv', header=None) |
假设你希望将message列做成DataFrame的索引。你可以明确表示要将该列放到索引4的位置上,也可以通过index_col参数指定”message”:
1 | In [15]: names = ['a', 'b', 'c', 'd', 'message'] |
如果希望将多个列做成一个层次化索引,只需传入由列编号或列名组成的列表即可:
1 | In [17]: !cat examples/csv_mindex.csv |
有些情况下,有些表格可能不是用固定的分隔符去分隔字段的(比如空白符或其它模式)。看看下面这个文本文件:
1 | In [20]: list(open('examples/ex3.txt')) |
虽然可以手动对数据进行规整,这里的字段是被数量不同的空白字符间隔开的。这种情况下,你可以传递一个正则表达式作为read_table的分隔符。可以用正则表达式表达为\s+,于是有:
1 | In [21]: result = pd.read_table('examples/ex3.txt', sep='\s+') |
这里,由于列名比数据行的数量少,所以read_table推断第一列应该是DataFrame的索引。
这些解析器函数还有许多参数可以帮助你处理各种各样的异形文件格式(表6-2列出了一些)。比如说,你可以用skiprows跳过文件的第一行、第三行和第四行:
1 | In [23]: !cat examples/ex4.csv |
缺失值处理是文件解析任务中的一个重要组成部分。缺失数据经常是要么没有(空字符串),要么用某个标记值表示。默认情况下,pandas会用一组经常出现的标记值进行识别,比如NA及NULL:
1 | In [25]: !cat examples/ex5.csv |
na_values可以用一个列表或集合的字符串表示缺失值:
1 | In [29]: result = pd.read_csv('examples/ex5.csv', na_values=['NULL']) |
字典的各列可以使用不同的NA标记值:
1 | In [31]: sentinels = {'message': ['foo', 'NA'], 'something': ['two']} |
表6-2列出了pandas.read_csv和pandas.read_table常用的选项。
逐块读取文本文件
在处理很大的文件时,或找出大文件中的参数集以便于后续处理时,你可能只想读取文件的一小部分或逐块对文件进行迭代。
在看大文件之前,我们先设置pandas显示地更紧些:
1 | In [33]: pd.options.display.max_rows = 10 |
然后有:
1 | In [34]: result = pd.read_csv('examples/ex6.csv') |
如果只想读取几行(避免读取整个文件),通过nrows进行指定即可:
1 | In [36]: pd.read_csv('examples/ex6.csv', nrows=5) |
要逐块读取文件,可以指定chunksize(行数):
1 | In [874]: chunker = pd.read_csv('ch06/ex6.csv', chunksize=1000) |
read_csv所返回的这个TextParser对象使你可以根据chunksize对文件进行逐块迭代。比如说,我们可以迭代处理ex6.csv,将值计数聚合到”key”列中,如下所示:
1 | chunker = pd.read_csv('examples/ex6.csv', chunksize=1000) |
然后有:
1 | In [40]: tot[:10] |
TextParser还有一个get_chunk方法,它使你可以读取任意大小的块。
将数据写出到文本格式
数据也可以被输出为分隔符格式的文本。我们再来看看之前读过的一个CSV文件:
1 | In [41]: data = pd.read_csv('examples/ex5.csv') |
利用DataFrame的to_csv方法,我们可以将数据写到一个以逗号分隔的文件中:
1 | In [43]: data.to_csv('examples/out.csv') |
当然,还可以使用其他分隔符(由于这里直接写出到sys.stdout,所以仅仅是打印出文本结果而已):
1 | In [45]: import sys |
缺失值在输出结果中会被表示为空字符串。你可能希望将其表示为别的标记值:
1 | In [47]: data.to_csv(sys.stdout, na_rep='NULL') |
如果没有设置其他选项,则会写出行和列的标签。当然,它们也都可以被禁用:
1 | In [48]: data.to_csv(sys.stdout, index=False, header=False) |
此外,你还可以只写出一部分的列,并以你指定的顺序排列:
1 | In [49]: data.to_csv(sys.stdout, index=False, columns=['a', 'b', 'c']) |
Series也有一个to_csv方法:
1 | In [50]: dates = pd.date_range('1/1/2000', periods=7) |
处理分隔符格式
大部分存储在磁盘上的表格型数据都能用pandas.read_table进行加载。然而,有时还是需要做一些手工处理。由于接收到含有畸形行的文件而使read_table出毛病的情况并不少见。为了说明这些基本工具,看看下面这个简单的CSV文件:
1 | In [54]: !cat examples/ex7.csv |
对于任何单字符分隔符文件,可以直接使用Python内置的csv模块。将任意已打开的文件或文件型的对象传给csv.reader:
1 | import csv |
对这个reader进行迭代将会为每行产生一个元组(并移除了所有的引号):对这个reader进行迭代将会为每行产生一个元组(并移除了所有的引号):
1 | In [56]: for line in reader: |
现在,为了使数据格式合乎要求,你需要对其做一些整理工作。我们一步一步来做。首先,读取文件到一个多行的列表中:
1 | In [57]: with open('examples/ex7.csv') as f: |
然后,我们将这些行分为标题行和数据行:
1 | In [58]: header, values = lines[0], lines[1:] |
然后,我们可以用字典构造式和zip(*values),后者将行转置为列,创建数据列的字典:
1 | In [59]: data_dict = {h: v for h, v in zip(header, zip(*values))} |
CSV文件的形式有很多。只需定义csv.Dialect的一个子类即可定义出新格式(如专门的分隔符、字符串引用约定、行结束符等):
1 | class my_dialect(csv.Dialect): |
各个CSV语支的参数也可以用关键字的形式提供给csv.reader,而无需定义子类:
1 | reader = csv.reader(f, delimiter='|') |
可用的选项(csv.Dialect的属性)及其功能如表6-3所示。
笔记:对于那些使用复杂分隔符或多字符分隔符的文件,csv模块就无能为力了。这种情况下,你就只能使用字符串的split方法或正则表达式方法re.split进行行拆分和其他整理工作了。
要手工输出分隔符文件,你可以使用csv.writer。它接受一个已打开且可写的文件对象以及跟csv.reader相同的那些语支和格式化选项:
1 | with open('mydata.csv', 'w') as f: |
JSON数据
JSON(JavaScript Object Notation的简称)已经成为通过HTTP请求在Web浏览器和其他应用程序之间发送数据的标准格式之一。它是一种比表格型文本格式(如CSV)灵活得多的数据格式。下面是一个例子:
1 | obj = """ |
除其空值null和一些其他的细微差别(如列表末尾不允许存在多余的逗号)之外,JSON非常接近于有效的Python代码。基本类型有对象(字典)、数组(列表)、字符串、数值、布尔值以及null。对象中所有的键都必须是字符串。许多Python库都可以读写JSON数据。我将使用json,因为它是构建于Python标准库中的。通过json.loads即可将JSON字符串转换成Python形式:
1 | In [62]: import json |
json.dumps则将Python对象转换成JSON格式:
1 | In [65]: asjson = json.dumps(result) |
如何将(一个或一组)JSON对象转换为DataFrame或其他便于分析的数据结构就由你决定了。最简单方便的方式是:向DataFrame构造器传入一个字典的列表(就是原先的JSON对象),并选取数据字段的子集:
1 | In [66]: siblings = pd.DataFrame(result['siblings'], columns=['name', 'age']) |
pandas.read_json可以自动将特别格式的JSON数据集转换为Series或DataFrame。例如:
1 | In [68]: !cat examples/example.json |
pandas.read_json的默认选项假设JSON数组中的每个对象是表格中的一行:
1 | In [69]: data = pd.read_json('examples/example.json') |
第7章中关于USDA Food Database的那个例子进一步讲解了JSON数据的读取和处理(包括嵌套记录)。
如果你需要将数据从pandas输出到JSON,可以使用to_json方法:
1 | In [71]: print(data.to_json()) |
XML和HTML:Web信息收集
Python有许多可以读写常见的HTML和XML格式数据的库,包括lxml、Beautiful Soup和html5lib。lxml的速度比较快,但其它的库处理有误的HTML或XML文件更好。
pandas有一个内置的功能,read_html,它可以使用lxml和Beautiful Soup自动将HTML文件中的表格解析为DataFrame对象。为了进行展示,我从美国联邦存款保险公司下载了一个HTML文件(pandas文档中也使用过),它记录了银行倒闭的情况。首先,你需要安装read_html用到的库:
1 | conda install lxml |
如果你用的不是conda,可以使用pip install lxml
。
pandas.read_html有一些选项,默认条件下,它会搜索、尝试解析
1 | In [73]: tables = pd.read_html('examples/fdic_failed_bank_list.html') |
因为failures有许多列,pandas插入了一个换行符\。
这里,我们可以做一些数据清洗和分析(后面章节会进一步讲解),比如计算按年份计算倒闭的银行数:
1 | In [77]: close_timestamps = pd.to_datetime(failures['Closing Date']) |
利用lxml.objectify解析XML
XML(Extensible Markup Language)是另一种常见的支持分层、嵌套数据以及元数据的结构化数据格式。本书所使用的这些文件实际上来自于一个很大的XML文档。
前面,我介绍了pandas.read_html函数,它可以使用lxml或Beautiful Soup从HTML解析数据。XML和HTML的结构很相似,但XML更为通用。这里,我会用一个例子演示如何利用lxml从XML格式解析数据。
纽约大都会运输署发布了一些有关其公交和列车服务的数据资料(http://www.mta.info/developers/download.html)。这里,我们将看看包含在一组XML文件中的运行情况数据。每项列车或公交服务都有各自的文件(如Metro-North Railroad的文件是Performance_MNR.xml),其中每条XML记录就是一条月度数据,如下所示:
1 | <INDICATOR> |
我们先用lxml.objectify解析该文件,然后通过getroot得到该XML文件的根节点的引用:
1 | from lxml import objectify |
root.INDICATOR返回一个用于产生各个
1 | data = [] |
最后,将这组字典转换为一个DataFrame:
1 | In [81]: perf = pd.DataFrame(data) |
XML数据可以比本例复杂得多。每个标记都可以有元数据。看看下面这个HTML的链接标签(它也算是一段有效的XML):
1 | from io import StringIO |
现在就可以访问标签或链接文本中的任何字段了(如href):
1 | In [84]: root |
6.2 二进制数据格式
实现数据的高效二进制格式存储最简单的办法之一是使用Python内置的pickle序列化。pandas对象都有一个用于将数据以pickle格式保存到磁盘上的to_pickle方法:
1 | In [87]: frame = pd.read_csv('examples/ex1.csv') |
你可以通过pickle直接读取被pickle化的数据,或是使用更为方便的pandas.read_pickle:
1 | In [90]: pd.read_pickle('examples/frame_pickle') |
注意:pickle仅建议用于短期存储格式。其原因是很难保证该格式永远是稳定的;今天pickle的对象可能无法被后续版本的库unpickle出来。虽然我尽力保证这种事情不会发生在pandas中,但是今后的某个时候说不定还是得“打破”该pickle格式。
pandas内置支持两个二进制数据格式:HDF5和MessagePack。下一节,我会给出几个HDF5的例子,但我建议你尝试下不同的文件格式,看看它们的速度以及是否适合你的分析工作。pandas或NumPy数据的其它存储格式有:
- bcolz:一种可压缩的列存储二进制格式,基于Blosc压缩库。
- Feather:我与R语言社区的Hadley Wickham设计的一种跨语言的列存储文件格式。Feather使用了Apache Arrow的列式内存格式。
使用HDF5格式
HDF5是一种存储大规模科学数组数据的非常好的文件格式。它可以被作为C标准库,带有许多语言的接口,如Java、Python和MATLAB等。HDF5中的HDF指的是层次型数据格式(hierarchical data format)。每个HDF5文件都含有一个文件系统式的节点结构,它使你能够存储多个数据集并支持元数据。与其他简单格式相比,HDF5支持多种压缩器的即时压缩,还能更高效地存储重复模式数据。对于那些非常大的无法直接放入内存的数据集,HDF5就是不错的选择,因为它可以高效地分块读写。
虽然可以用PyTables或h5py库直接访问HDF5文件,pandas提供了更为高级的接口,可以简化存储Series和DataFrame对象。HDFStore类可以像字典一样,处理低级的细节:
1 | In [92]: frame = pd.DataFrame({'a': np.random.randn(100)}) |
HDF5文件中的对象可以通过与字典一样的API进行获取:
1 | In [97]: store['obj1'] |
HDFStore支持两种存储模式,’fixed’和’table’。后者通常会更慢,但是支持使用特殊语法进行查询操作:
1 | In [98]: store.put('obj2', frame, format='table') |
put是store[‘obj2’] = frame方法的显示版本,允许我们设置其它的选项,比如格式。
pandas.read_hdf函数可以快捷使用这些工具:
1 | In [101]: frame.to_hdf('mydata.h5', 'obj3', format='table') |
笔记:如果你要处理的数据位于远程服务器,比如Amazon S3或HDFS,使用专门为分布式存储(比如Apache Parquet)的二进制格式也许更加合适。Python的Parquet和其它存储格式还在不断的发展之中,所以这本书中没有涉及。
如果需要本地处理海量数据,我建议你好好研究一下PyTables和h5py,看看它们能满足你的哪些需求。。由于许多数据分析问题都是IO密集型(而不是CPU密集型),利用HDF5这样的工具能显著提升应用程序的效率。
注意:HDF5不是数据库。它最适合用作“一次写多次读”的数据集。虽然数据可以在任何时候被添加到文件中,但如果同时发生多个写操作,文件就可能会被破坏。
读取Microsoft Excel文件
pandas的ExcelFile类或pandas.read_excel函数支持读取存储在Excel 2003(或更高版本)中的表格型数据。这两个工具分别使用扩展包xlrd和openpyxl读取XLS和XLSX文件。你可以用pip或conda安装它们。
要使用ExcelFile,通过传递xls或xlsx路径创建一个实例:
1 | In [104]: xlsx = pd.ExcelFile('examples/ex1.xlsx') |
存储在表单中的数据可以read_excel读取到DataFrame(原书这里写的是用parse解析,但代码中用的是read_excel,是个笔误:只换了代码,没有改文字):
1 | In [105]: pd.read_excel(xlsx, 'Sheet1') |
如果要读取一个文件中的多个表单,创建ExcelFile会更快,但你也可以将文件名传递到pandas.read_excel:
1 | In [106]: frame = pd.read_excel('examples/ex1.xlsx', 'Sheet1') |
如果要将pandas数据写入为Excel格式,你必须首先创建一个ExcelWriter,然后使用pandas对象的to_excel方法将数据写入到其中:
1 | In [108]: writer = pd.ExcelWriter('examples/ex2.xlsx') |
你还可以不使用ExcelWriter,而是传递文件的路径到to_excel:
1 | In [111]: frame.to_excel('examples/ex2.xlsx') |
6.3 Web APIs交互
许多网站都有一些通过JSON或其他格式提供数据的公共API。通过Python访问这些API的办法有不少。一个简单易用的办法(推荐)是requests包(http://docs.python-requests.org)。
为了搜索最新的30个GitHub上的pandas主题,我们可以发一个HTTP GET请求,使用requests扩展库:
1 | In [113]: import requests |
响应对象的json方法会返回一个包含被解析过的JSON字典,加载到一个Python对象中:
1 | In [117]: data = resp.json() |
data中的每个元素都是一个包含所有GitHub主题页数据(不包含评论)的字典。我们可以直接传递数据到DataFrame,并提取感兴趣的字段:
1 | In [119]: issues = pd.DataFrame(data, columns=['number', 'title', |
花费一些精力,你就可以创建一些更高级的常见的Web API的接口,返回DataFrame对象,方便进行分析。
6.4 数据库交互
在商业场景下,大多数数据可能不是存储在文本或Excel文件中。基于SQL的关系型数据库(如SQL Server、PostgreSQL和MySQL等)使用非常广泛,其它一些数据库也很流行。数据库的选择通常取决于性能、数据完整性以及应用程序的伸缩性需求。
将数据从SQL加载到DataFrame的过程很简单,此外pandas还有一些能够简化该过程的函数。例如,我将使用SQLite数据库(通过Python内置的sqlite3驱动器):
1 | In [121]: import sqlite3 |
然后插入几行数据:
1 | In [126]: data = [('Atlanta', 'Georgia', 1.25, 6), |
从表中选取数据时,大部分Python SQL驱动器(PyODBC、psycopg2、MySQLdb、pymssql等)都会返回一个元组列表:
1 | In [130]: cursor = con.execute('select * from test') |
你可以将这个元组列表传给DataFrame构造器,但还需要列名(位于光标的description属性中):
1 | In [133]: cursor.description |
这种数据规整操作相当多,你肯定不想每查一次数据库就重写一次。SQLAlchemy项目是一个流行的Python SQL工具,它抽象出了SQL数据库中的许多常见差异。pandas有一个read_sql函数,可以让你轻松的从SQLAlchemy连接读取数据。这里,我们用SQLAlchemy连接SQLite数据库,并从之前创建的表读取数据:
1 | In [135]: import sqlalchemy as sqla |
6.5 总结
访问数据通常是数据分析的第一步。在本章中,我们已经学了一些有用的工具。在接下来的章节中,我们将深入研究数据规整、数据可视化、时间序列分析和其它主题。