第4章 NumPy基础:数组和矢量计算
NumPy(Numerical Python的简称)是Python数值计算最重要的基础包。大多数提供科学计算的包都是用NumPy的数组作为构建基础。
NumPy的部分功能如下:
由于NumPy提供了一个简单易用的C API,因此很容易将数据传递给由低级语言编写的外部库,外部库也能以NumPy数组的形式将数据返回给Python。这个功能使Python成为一种包装C/C++/Fortran历史代码库的选择,并使被包装库拥有一个动态的、易用的接口。
对于大部分数据分析应用而言,我最关注的功能主要集中在:
NumPy之于数值计算特别重要的原因之一,是因为它可以高效处理大数组的数据。这是因为:
搞明白具体性能差距,考察一个包含一百万整数的数组,和一个等价的Python列表:
各个序列分别乘以2:
基于NumPy的算法要比纯Python快10到100倍(甚至更快),并且使用的内存更少。
4.1 NumPy的ndarray:一种多维数组对象
NumPy最重要的一个特点就是其N维数组对象(即ndarray),该对象是一个快速而灵活的大数据集容器。可以利用这种数组对整块数据执行一些数学运算,其语法跟标量元素之间的运算一样。
要明白Python是如何利用与标量值类似的语法进行批次计算,先引入NumPy,然后生成一个包含随机数据的小数组:
然后进行数学运算:
笔记:在本章及全书中,会使用标准的NumPy惯用法import numpy as np。当然也可以在代码中使用from numpy import *,但不建议这么做。numpy的命名空间很大,包含许多函数,其中一些的名字与Python的内置函数重名(比如min和max)。
ndarray是一个通用的同构数据多维容器,也就是说,其中的所有元素必须是相同类型的。每个数组都有一个shape(一个表示各维度大小的元组)和一个dtype(一个用于说明数组类型的对象):
创建数组最简单的办法就是使用array函数。它接受一切序列型的对象(包括其他数组),然后产生一个新的含有传入数据的NumPy数组。以一个列表的转换为例:
嵌套序列(比如由一组等长列表组成的列表)将会被转换为一个多维数组:
因为data2是列表的列表,NumPy数组arr2的两个维度的shape是从data2引入的。可以用属性ndim和shape验证:
除非特别说明,np.array会尝试为新建的这个数组推断出一个较为合适的数据类型。数据类型保存在一个特殊的dtype对象中。比如说,在上面的两个例子中,我们有:
除np.array之外,还有一些函数也可以新建数组。比如,zeros和ones分别可以创建指定长度或形状的全0或全1数组。empty可以创建一个没有任何具体值的数组。要用这些方法创建多维数组,只需传入一个表示形状的元组即可:
注意:认为np.empty会返回全0数组的想法是不安全的。很多情况下(如前所示),它返回的都是一些未初始化的垃圾值。
arange是Python内置函数range的数组版:
下表列出了一些数组创建函数。由于NumPy关注的是数值计算,因此,如果没有特别指定,数据类型基本都是float64(浮点数)。
dtype(数据类型)是一个特殊的对象,它含有ndarray将一块内存解释为特定数据类型所需的信息:
dtype是Numpy灵活交互其它系统的源泉之一。多数情况下,它们直接映射到相应的机器表示,这使得“读写磁盘上的二进制数据流”以及“集成低级语言代码(如C、Fortran)”等工作变得更加简单。数值型dtype的命名方式相同:一个类型名(如float或int),后面跟一个用于表示各元素位长的数字。标准的双精度浮点值(即Python中的float对象)需要占用8字节(即64位)。因此,该类型在NumPy中就记作float64。
下表列出了NumPy所支持的全部数据类型:
可以通过ndarray的astype方法明确地将一个数组从一个dtype转换成另一个dtype:
如果某字符串数组表示的全是数字,也可以用astype将其转换为数值形式:
注意:使用numpy.string_类型时,一定要小心,因为NumPy的字符串数据是大小固定的,发生截取时,不会发生警告。pandas提供了更多非数值数据的便利的处理方法。
数组的dtype还有另一个属性:
还可以用简洁的类型代码来表示dtype:
笔记:调用astype总会创建一个新的数组(一个数据的备份),即使新的dtype与旧的dtype相同。
数组很重要,因为它使你不用编写循环即可对数据执行批量运算。NumPy用户称其为矢量化(vectorization)。大小相等的数组之间的任何算术运算都会将运算应用到元素级:
大小相同的数组之间的比较会生成布尔值数组:
不同大小的数组之间的运算叫做广播(broadcasting)。
跟列表最重要的区别在于,数组切片是原始数组的视图。这意味着数据不会被复制,视图上的任何修改都会直接反映到源数组上。
作为一个例子,先创建一个arr的切片:
修改arr_slice中的值,变动也会体现在原始数组arr中:
切片[ : ]会给数组中的所有值赋值:
注意:若想得到的是ndarray切片的一份副本而非视图,就需要明确地进行复制操作,例如arr[5:8].copy( )。
在一个二维数组中,各索引位置上的元素不再是标量而是一维数组:
可以对各个元素进行递归访问,可以传入一个以逗号隔开的索引列表来选取单个元素。下面两种方式是等价的:
在多维数组中,如果省略了后面的索引,则返回对象会是一个维度低一点的ndarray(它含有高一级维度上的所有数据)。因此,在2×2×3数组arr3d中:
arr3d[0]是一个2×3数组:
标量值和数组都可以被赋值给arr3d[0]:
相似的,arr3d[1,0]可以访问索引以(1,0)开头的那些值(以一维数组的形式返回):
虽然是用两步进行索引的,表达式是相同的:
在上面所有这些选取数组子集的例子中,返回的数组都是视图。
ndarray的切片语法跟Python列表这样的一维对象差不多:
对于之前的二维数组arr2d,其切片方式稍显不同:
还可以一次传入多个切片,就像传入多个索引那样:
像这样进行切片时,只能得到相同维数的数组视图。通过将整数索引和切片混合,可以得到低维度的切片。
例如,我可以选取第二行的前两列:
相似的,还可以选择第三列的前两行:
注意,“只有冒号”表示选取整个轴,因此可以像下面这样只对高维轴进行切片:
自然,对切片表达式的赋值操作也会被扩散到整个选区:
若有一个用于存储数据的数组以及一个存储姓名的数组(含有重复项)。在这里,我将使用numpy.random中的randn函数生成一些正态分布的随机数据:
假设每个名字都对应data数组中的一行,若要选出对应于名字“Bob”的所有行。跟算术运算一样,数组的比较运算(如==)也是矢量化的。因此,对names和字符串“Bob”的比较运算将会产生一个布尔型数组:
这个布尔型数组可用于数组索引:
布尔型数组的长度必须跟被索引的轴长度一致。此外,还可以将布尔型数组跟切片、整数混合使用。
下面例子选取names == ‘Bob’的行,并索引了列:
要选择除“Bob”以外的其他值,既可以使用不等于符号(!=),也可以通过~对条件进行否定:
~操作符用来反转条件很好用:
选取这三个名字中的两个需要组合应用多个布尔条件,使用&(和)、|(或)之类的布尔算术运算符即可:
注意:Python关键字and和or在布尔型数组中无效。要使用&与|。
花式索引(Fancy indexing)是一个NumPy术语,它指的是利用整数数组进行索引。假设有一个8×4数组:
为了以特定顺序选取行子集,只需传入一个用于指定顺序的整数列表或ndarray即可:
使用负数索引将会从末尾开始选取行:
一次传入多个索引数组会有一点特别。它返回的是一个一维数组,其中的元素对应各个索引元组:
无论数组是多少维的,花式索引总是一维的。选取矩阵的行列子集应该是矩形区域的形式才对。
花式索引跟切片不一样,它总是将数据复制到新数组中。
转置是重塑的一种特殊形式,它返回的是源数据的视图(不会进行任何复制操作)。数组不仅有transpose方法,还有一个特殊的T属性:
在进行矩阵计算时,经常需要用到该操作,比如利用np.dot计算矩阵内积:
简单的转置可以使用.T,它其实就是进行轴对换而已。ndarray还有一个swapaxes方法,它需要接受一对轴编号:
swapaxes也是返回源数据的视图(不会进行任何复制操作)。
4.2 通用函数:快速的元素级数组函数
通用函数(即ufunc)是一种ndarray中的数据执行元素级运算的函数。可以将其看做简单函数(接受一个或多个标量值,并产生一个或多个标量值)的矢量化包装器。
许多ufunc都是简单的元素级变体,如sqrt和exp:
这些都是一元(unary)ufunc。另外一些(如add或maximum)接受2个数组(因此也叫二元(binary)ufunc),并返回一个结果数组:
这里,numpy.maximum计算了x和y中元素级别最大的元素。
虽然并不常见,但有些ufunc的确可以返回多个数组。modf就是一个例子,它是Python内置函数divmod的矢量化版本,它会返回浮点数数组的小数和整数部分:
Ufuncs可以接受一个out可选参数,这样就能在数组原地进行操作:
4.3 利用数组进行数据处理
NumPy数组使你可以将许多种数据处理任务表述为简洁的数组表达式(否则需要编写循环)。用数组表达式代替循环的做法,通常被称为矢量化。一般来讲,矢量化数组运算要比等价的纯Python方式快上一两个数量级,尤其是各种数值计算。
一个简单的例子,假设我们想要在一组值(网格型)上计算函数sqrt(x^2+y^2)。np.meshgrid函数接受两个一维数组,并产生两个二维矩阵(对应于两个数组中所有的(x,y)对):
现在,对该函数的求值运算就好办了,把这两个数组当做两个浮点数那样编写表达式即可:
numpy.where函数是三元表达式x if condition else y的矢量化版本。假设我们有一个布尔数组和两个值数组:
假设我们想要根据cond中的值选取xarr和yarr的值:当cond中的值为True,选取xarr的值,否则从yarr中选取。列表推导式的写法如下:
这有几个问题。1、它对大数组的处理速度不是很快(因为所有工作都是由纯Python完成的)。2、无法用于多维数组。若使用np.where,则可以将该功能写得非常简洁:
np.where的第二个和第三个参数不必是数组,它们都可以是标量值。在数据分析工作中,where通常用于根据另一个数组而产生一个新的数组。假设有一个由随机数据组成的矩阵,你希望将所有正值替换为2,将所有负值替换为-2.若利用np.where,则会非常简单:
使用np.where,可以将标量和数组结合起来。例如,可用常数2替换arr中所有正的值:
传递给where的数组大小可以不相等,甚至可以是标量值。
可以通过数组上的一组数学函数对整个数组或某个轴向的数据进行统计计算。sum、mean以及标准差std等聚合计算(aggregation,通常叫做约简(reduction))既可以当做数组的实例方法调用,也可以当做顶级NumPy函数使用。
这里,生成一些正态分布随机数据,然后做了聚类统计:
mean和sum这类的函数可以接受一个axis选项参数,用于计算该轴向上的统计值,最终结果是一个少一维的数组:
在多维数组中,累加函数(如cumsum)返回的是同样大小的数组,但是会根据每个低维的切片沿着标记轴计算部分聚类:
在上面这些方法中,布尔值会被强制转换为1(True)和0(False)。因此,sum经常被用来对布尔型数组中的True值计数:
另外还有两个方法any和all,它们对布尔型数组非常有用。any用于测试数组中是否存在一个或多个True,而all则检查数组中所有值是否都是True:
这两个方法也能用于非布尔型数组,所有非0元素将会被当做True。
跟Python内置的列表类型一样,NumPy数组也可以通过sort方法就地排序:
多维数组可以在任何一个轴向上进行排序,只需将轴编号传给sort即可:
顶级方法np.sort返回的是数组的已排序副本,而就地排序则会修改数组本身。计算数组分位数最简单的办法是对其进行排序,然后选取特定位置的值:
NumPy提供了一些针对一维ndarray的基本集合运算。最常用的是np.unique了,它用于找出数组中的唯一值并返回已排序的结果。
另一个函数np.in1d用于测试一个数组中的值在另一个数组中的成员资格,返回一个布尔型数组:
4.4 用于数组的文件输入输出
np.save和np.load是读写磁盘数组数据的两个主要函数。默认情况下,数组是以未压缩的原始二进制格式保存在扩展名为.npy的文件中的:
若文件路径末尾没有扩展名.npy,则该扩展名会被自动加上。然后就通过np.load读取磁盘上的数组:
通过np.savez可以将多个数组保存到一个未压缩文件中,将数组以关键字参数的形式传入即可:
加载.npz文件时,会得到一个类似字典的对象,该对象会对各个数组进行延迟加载:
4.5 线性代数
NumPy提供了一个用于矩阵乘法的dot函数(即是一个数组方法也是numpy命名空间中的一个函数):
x.dot(y)等价于np.dot(x,y):
一个二维数组跟一个大小合适的一维数组的矩阵点积运算之后将会得到一个一维数组:
@符(类似Python3.5)也可以用作中辍运算符,进行矩阵乘法:
numpy.linalg中有一组标准的矩阵分解运算以及诸如求逆和行列式之类的东西。它们跟MATLAB和R等语言所使用的是相同的行业标准线性代数库,如BLAS、LAPACK、intelMKL等:
表达式X.T.dot(X)计算X和它的转置X.T的点积。
下表是最常用的线性代数函数:
4.6 伪随机数生成
numpy.random模块对Python内置的random进行了补充,增加了一些用于高效生成多种概率分布的样本值的函数。例如,可以用normal来得到一个标准正态分布的4×4样本数组:
而Python内置的random模块则只能一次生成一个样本值。从下面的测试结果中可以看出,若需要产生大量样本值,numpy.random快了不止一个数量级:
我们说这些都是伪随机数,是因为它们都是通过算法基于随机数生成器种子,在确定性的条件下生成的。可以用NumPy的np.random.seed更改随机数生成种子:
numpy.random的数据生成函数使用了全局的随机种子。要避免全局状态,可以使用numpy.random.RandomState,创建一个与其它隔离的随机数生成器:
4.7 示例:随机漫步
通过模拟随机漫步来说明如何运用数组运算。一个简单的随机漫步的例子:从0开始,步长1和-1出现的概率相等。
下面是一个通过内置的random模块以纯Python的方式实现1000步的随机漫步:
这其实就是随机漫步各步的累计和,可以用一个数组运算来实现。因此,用np.random模块一次性随机产生1000个“掷硬币”结果(即两个数中任选一个),将其分别设置为1或-1,然后计算累计和:
首次穿越时间——即随机漫步过程中第一次到达某个特定值的时间。假设我们想要知道本次随机漫步需要多久才能距离初始0点至少10步远(任一方向均可)。np.ads(walk)>=10可以得到一个布尔型数组,它表示的是距离是否达到或超过10,而我们想要知道的是第一个10或-10的索引。可以用argmax来解决这个问题。它返回的是该布尔型数组第一个最大值的索引(True就是最大值):
注意,这里使用argmax并不是很高效,因为它无论如何都会对数组进行完全扫描。在本例中,只要发现一个True,那就知道它是个最大值了。
生成多个随机漫步过程(比如5000个),只要给numpy.random的函数传入一个二元元组就可以产生一个二维数组,然后就可以一次性计算5000个随机漫步过程(一行一个)的累计和了:
计算所有随机漫步过程的最大值和最小值:
计算30或-30的最小穿越时间。这里不是5000个过程都到达了30,可以用any方法来对此进行检查:
利用布尔型数组选出那些穿越了30(绝对值)的随机漫步(行),并调用argmax在轴1上获取穿越时间: