利用Python开展数据分析——NumPy高级应用(十二)(3)

利用Python进行数据分析——NumPy高级应用(十二)(3)

1、结构化和记录式数组

结构化数组是一种特殊的ndarray,其中的各个元素可以被看作C语言中的结构体或SQL表中带有多个命名字段的行:

In [2]: import numpy as np

In [3]: dtype = [('x', np.float64), ('y', np.int32)]

In [4]: sarr = np.array([(1.5, 6), (np.pi, -2)], dtype=dtype)

In [5]: sarr
Out[5]: 
array([(1.5, 6), (3.141592653589793, -2)], 
      dtype=[('x', '<f8'), ('y', '<i4')])
定义结构化dtype的方式有很多,最典型的办法是元组列表,各元组的格式为(field_name, field_data_type)。这样,数组的元素就成了元组式的对象,该对象中各个元素可以像字典那样进行访问:

In [6]: sarr[0]
Out[6]: (1.5, 6)

In [7]: sarr[0]['y']
Out[7]: 6
字段名保存在dtype.names属性中。在访问结构化数组的某个字段时,返回的是该数据的视图,所以不会发生数据复制:

In [8]: sarr['x']
Out[8]: array([ 1.5       ,  3.14159265])
(1)嵌套dtype和多维字段

在定义结构化dtype时,你可以再设置一个形状(可以是一个整数,也可以是一个元组):

In [12]: dtype = [('x', np.int64, 3), ('y', np.int32)]

In [13]: arr = np.zeros(4, dtype=dtype)

In [14]: arr
Out[14]: 
array([([0L, 0L, 0L], 0), ([0L, 0L, 0L], 0), ([0L, 0L, 0L], 0),
       ([0L, 0L, 0L], 0)], 
      dtype=[('x', '<i8', (3,)), ('y', '<i4')])
在这种情况下,各个记录的x字段所表示的是一个长度为3的数组:

In [15]: arr[0]['x']
Out[15]: array([0, 0, 0], dtype=int64)
访问arr['x']即可得到一个二维数组,而不是前面那个例子中的一维数组:

In [16]: arr['x']
Out[16]: 
array([[0, 0, 0],
       [0, 0, 0],
       [0, 0, 0],
       [0, 0, 0]], dtype=int64)
这就使我们能用单个数组的内存块存放复杂的嵌套结构。既然dtype可以想怎么复杂就怎么复杂,那为什么不试试嵌套dtype呢?

In [17]: dtype = [('x', [('a', 'f8'), ('b', 'f4')]), ('y', np.int32)]

In [18]: data = np.array([((1, 2), 5), ((3, 4), 6)], dtype=dtype)

In [19]: data['x']
Out[19]: 
array([(1.0, 2.0), (3.0, 4.0)], 
      dtype=[('a', '<f8'), ('b', '<f4')])

In [20]: data['y']
Out[20]: array([5, 6])

In [21]: data['x']['a']
Out[21]: array([ 1.,  3.])
说明:

可变形状的字段和嵌套记录是一组非常强大的功能。与此相比,pandas的DataFrame并不直接支持该功能,但它的分层索引机制跟这个差不多。

(2)为什么要用结构化数组

跟pandas的DataFrame相比,NumPy的结构化数组是一种相对低级的工具。它可以将单个内存块解释为带有任意复杂嵌套列的表格型结构。由于数组中的每个元素在内存中都被表示为固定的字节数,所以结构化数组能够提供非常快速高效的磁盘数据读写(包括内存映像)、网络传输等功能。

结构化数组的另一个常见用法是,将数据文件写成定长记录字节流,这是C和C++代码中常见的数据序列化手段。只要知道文件的格式(记录的大小、元素的顺序、字节数以及数据类型等),就可以用np.fromfile将数据读入内存。

(3)结构化数组操作

适用于结构化数组的函数没有DataFrame那么多。NumPy模块numpy.lib.recfunctions中有一些用于增删字段或执行基本连接运算的工具。对于这些工具,我们需要记住的是:一般都需要创建一个新数组以便对dtype进行修改(比如添加或删除一列)。


2、更多有关排序的话题

跟Python内置的列表一样,ndarray的sort实例方法也是就地排序。即数组内容的重新排列是不会产生新数组的:

In [22]: arr = np.random.randn(6)

In [23]: arr.sort()

In [24]: arr
Out[24]: 
array([-0.88540185, -0.68787154, -0.24149138, -0.0818599 ,  0.4448765 ,
        1.03495026])

在对数组进行就地排序时要注意一点:如果目标数组只是一个视图,则原始数组将会被修改:

In [26]: arr
Out[26]: 
array([[ 0.88404594, -0.5440322 , -1.0660458 , -1.13186841,  1.13667419],
       [-0.34517296,  0.00569451, -0.80267699,  1.11007887,  0.65328746],
       [ 0.62523434, -0.5888504 , -0.26704211,  1.61265   ,  0.77891324]])

In [27]: arr[:, 0].sort()

In [28]: arr
Out[28]: 
array([[-0.34517296, -0.5440322 , -1.0660458 , -1.13186841,  1.13667419],
       [ 0.62523434,  0.00569451, -0.80267699,  1.11007887,  0.65328746],
       [ 0.88404594, -0.5888504 , -0.26704211,  1.61265   ,  0.77891324]])
相反,numpy.sort会为原数组创建一个已排序副本。它接受的参数(比如kind)跟ndarray.sort一样:

In [29]: arr = np.random.randn(5)

In [30]: arr
Out[30]: array([ 0.40729197, -0.89435947,  2.43884862, -0.6487866 ,  2.15865914])

In [31]: np.sort(arr)
Out[31]: array([-0.89435947, -0.6487866 ,  0.40729197,  2.15865914,  2.43884862])

In [32]: arr
Out[32]: array([ 0.40729197, -0.89435947,  2.43884862, -0.6487866 ,  2.15865914])
这两个排序方法都可以接受一个axis参数,以便沿指定轴向对各块数据进行单独排序:

In [33]: arr = np.random.randn(3, 5)

In [34]: arr
Out[34]: 
array([[-2.39415257,  1.46744156,  1.7505579 , -0.14523459,  2.5907082 ],
       [-0.8410895 ,  0.87287353, -1.43805371,  1.69613064, -0.545581  ],
       [-0.07252994, -0.24355622, -0.26503724,  0.86755228,  0.7502306 ]])

In [35]: arr.sort(axis=1)

In [36]: arr
Out[36]: 
array([[-2.39415257, -0.14523459,  1.46744156,  1.7505579 ,  2.5907082 ],
       [-1.43805371, -0.8410895 , -0.545581  ,  0.87287353,  1.69613064],
       [-0.26503724, -0.24355622, -0.07252994,  0.7502306 ,  0.86755228]])
说明:

上述两个排序方法都不会被设置为降序。

许多Python用户都很熟悉一个有关列表的小技巧:values[::-1]可以返回一个反序的列表,对ndarray也是如此:

In [36]: arr
Out[36]: 
array([[-2.39415257, -0.14523459,  1.46744156,  1.7505579 ,  2.5907082 ],
       [-1.43805371, -0.8410895 , -0.545581  ,  0.87287353,  1.69613064],
       [-0.26503724, -0.24355622, -0.07252994,  0.7502306 ,  0.86755228]])

In [37]: arr[:, ::-1]
Out[37]: 
array([[ 2.5907082 ,  1.7505579 ,  1.46744156, -0.14523459, -2.39415257],
       [ 1.69613064,  0.87287353, -0.545581  , -0.8410895 , -1.43805371],
       [ 0.86755228,  0.7502306 , -0.07252994, -0.24355622, -0.26503724]])
(1)间接排序

在数据分析工作中,常常需要根据一个或多个键对数据集进行排序。给定一个或多个键,你就可以得到一个由整数组成的索引数组,其中的索引值说明了数据在新顺序下的位置。argsort和numpy.lexsort就是实现该功能的两个主要方法。

In [38]: values = np.array([5, 0, 1, 3, 2])

In [39]: indexer = values.argsort()

In [40]: indexer
Out[40]: array([1, 2, 4, 3, 0])

In [41]: values[indexer]
Out[41]: array([0, 1, 2, 3, 5])

下面这段代码根据数组的第一行对其进行排序:

In [42]: arr = np.random.randn(3, 5)

In [43]: arr[0]
Out[43]: array([-2.05034255, -0.32090265,  1.35657355, -0.21909653,  1.08189777])

In [44]: arr[0] = values

In [45]: arr
Out[45]: 
array([[ 5.        ,  0.        ,  1.        ,  3.        ,  2.        ],
       [-0.60886821,  1.44306757,  1.13460899, -1.46344005, -1.0873651 ],
       [-1.37089681, -1.00542947,  2.65676116,  0.38771123, -0.22309729]])

In [46]: arr[:, arr[0].argsort()]
Out[46]: 
array([[ 0.        ,  1.        ,  2.        ,  3.        ,  5.        ],
       [ 1.44306757,  1.13460899, -1.0873651 , -1.46344005, -0.60886821],
       [-1.00542947,  2.65676116, -0.22309729,  0.38771123, -1.37089681]])
lexsort跟argsort差不多,只不过它可以一次性对多个键数组执行间接排序(字典序)。假设我们想对一些以姓和名标识的数据进行排序:

In [47]: first_name = np.array(['Bob', 'Jane', 'Steve', 'Bill', 'Barbara'])

In [48]: last_name = np.array(['Jones', 'Arnold', 'Arnold', 'Jones', 'Walters']) 
In [49]: sorter = np.lexsort((first_name, last_name))

In [50]: zip(last_name[sorter], first_name[sorter])
Out[50]: 
[('Arnold', 'Jane'),
 ('Arnold', 'Steve'),
 ('Jones', 'Bill'),
 ('Jones', 'Bob'),
 ('Walters', 'Barbara')]

注意:

Series和DataFrame的sort_index以及Series的order方法就是通过这些函数的变体(它们还必须考虑缺失值)实现的。

(2)其他排序算法

稳定的排序算法会保持等价元素的相对位置。对于相对位置具有实际意义的那些间接排序而言,这一点非常重要:

In [51]: values = np.array(['2:first', '2:second', '1:first', '1:second', '1:third'])

In [52]: key = np.array([2, 2, 1, 1, 1])

In [53]: indexer = key.argsort(kind='mergesort')

In [54]: indexer
Out[54]: array([2, 3, 4, 0, 1])

In [55]: values.take(indexer)
Out[55]: 
array(['1:first', '1:second', '1:third', '2:first', '2:second'], 
      dtype='|S8')
mergesort(合并排序)是稳定排序,它保证有O(nlogn)的性能(空间复杂度),但是其平均性能比默认的quicksort(快速排序)要差。下表列出了可用的排序算法及其相关的性能指标,如下所示:

利用Python开展数据分析——NumPy高级应用(十二)(3)

(3)numpy.searchsorted

searchsorted是一个在有序数组上执行二分查找的数组方法,只要将值插入到它返回的那个位置就能维持数组的有序性:

In [56]: arr = np.array([0, 1, 7, 12, 15])

In [57]: arr.searchsorted(9)
Out[57]: 3

传入一组值就能得到一组索引:

In [58]: arr.searchsorted([0, 8, 11, 16])
Out[58]: array([0, 3, 3, 5])
从上面的结果中可以看出,对于元素0,searchsorted会返回0。这是因为其默认行为是返回相等值组的左侧索引:

In [59]: arr = np.array([0, 0, 0, 1, 1, 1, 1])

In [60]: arr.searchsorted([0, 1])
Out[60]: array([0, 3])

In [61]: arr.searchsorted([0, 1], side='right')
Out[61]: array([3, 7])

再看searchsorted的另一个用法,假设我们有一个数据数组(其中的值在0到10000之间),还有一个表示“面元数组”的数组,我们希望用它将数据数组拆分开:

In [62]: data = np.floor(np.random.uniform(0, 10000, size=50))

In [63]: bins = np.array([0, 100, 1000, 5000, 10000])

In [64]: data
Out[64]: 
array([ 9559.,  7876.,  5324.,  5952.,  5650.,  4308.,  9746.,  1554.,
        8314.,    67.,  2797.,  1266.,  3823.,  8075.,  4684.,  3509.,
        7113.,  7917.,  7604.,  2897.,  2631.,  4427.,   413.,  9988.,
        8894.,  8465.,   244.,  4994.,  3868.,  4541.,  7190.,  9338.,
        1491.,  2975.,  5614.,  9041.,  8216.,  8956.,  1395.,  9139.,
        4944.,  4028.,  1544.,  5120.,  9590.,  1022.,  3262.,  7424.,
        6027.,  7142.])
然后,为了得到各数据点所属区间的编号(其中1表示面元[0, 100]),我们可以直接使用searchsorted:

In [65]: labels = bins.searchsorted(data)

In [66]: labels
Out[66]: 
array([4, 4, 4, 4, 4, 3, 4, 3, 4, 1, 3, 3, 3, 4, 3, 3, 4, 4, 4, 3, 3, 3, 2,
       4, 4, 4, 2, 3, 3, 3, 4, 4, 3, 3, 4, 4, 4, 4, 3, 4, 3, 3, 3, 4, 4, 3,
       3, 4, 4, 4])

通过pandas的groupby使用该结果即可非常轻松地对原数据集进行拆分:

In [68]: import pandas as pd

In [69]: pd.Series(data).groupby(labels).mean()
Out[69]: 
1      67.000000
2     328.500000
3    3140.952381
4    7818.230769
dtype: float64
注意,其实NumPy的digitize函数也可用于计算这种面元编号:

In [70]: np.digitize(data, bins)
Out[70]: 
array([4, 4, 4, 4, 4, 3, 4, 3, 4, 1, 3, 3, 3, 4, 3, 3, 4, 4, 4, 3, 3, 3, 2,
       4, 4, 4, 2, 3, 3, 3, 4, 4, 3, 3, 4, 4, 4, 4, 3, 4, 3, 3, 3, 4, 4, 3,
       3, 4, 4, 4])

3、NumPy的matrix类

(1)NumPy提供了一个matrix类,其索引行为更像MATLAB:单行或列会以二维形式返回,且使用星号(*)的乘法直接就是矩阵乘法。

(2)matrix还有一个特殊的属性I,其功能是返回矩阵的逆。

(3)不建议用numpy.matrix替代正规的ndarray,因为它们的应用面较窄。对于个别带有大量线性代数运算的函数,可以将函数参数转换为matrix类型,然后在返回之前用np.asarray(不会复制任何数据)将其转换回正规的ndarray。


4、高维数组输入输出

np.save和np.load可用于读写磁盘上以二进制格式存储的数组。其实还有一些工具可用于更为复杂的场景。尤其是内存映像(memory map),它使你能处理在内存中放不下的数据集。

(1)内存映像文件

内存映像文件是一种将磁盘上的非常大的二进制数据文件当做内存中的数组进行处理的方式。NumPy实现了一个类似于ndarray的memmap对象,它允许将大文件分成小段进行读写,而不是一次性将整个数组读入内存。memmap也拥有跟普通数组一样的方法,因此,基本上只要是能用于ndarray的算法就也能用于memmap。

使用函数np.memmap并传入一个文件路径、数据类型、形状以及文件模式,即可创建一个新的memmap:

In [71]: mmap = np.memmap('mymmap', dtype='float64', mode='w+', shape=(10000, 10000))

In [72]: mmap
Out[72]: 
memmap([[ 0.,  0.,  0., ...,  0.,  0.,  0.],
       [ 0.,  0.,  0., ...,  0.,  0.,  0.],
       [ 0.,  0.,  0., ...,  0.,  0.,  0.],
       ..., 
       [ 0.,  0.,  0., ...,  0.,  0.,  0.],
       [ 0.,  0.,  0., ...,  0.,  0.,  0.],
       [ 0.,  0.,  0., ...,  0.,  0.,  0.]])

对memmap切片将会返回磁盘上的数据的视图:

In [73]: section = mmap[:5]
如果将数据赋值给这些视图:数据会先被缓存在内存中(就像是Python的文件对象),调用flush即可将其写入磁盘。

In [74]: section[:] = np.random.randn(5, 10000)

In [75]: mmap.flush()

In [76]: mmap
Out[76]: 
memmap([[ 0.19975799,  0.18656236, -0.25229474, ..., -1.00030318,
         0.11016057, -0.27692659],
       [ 0.37246745,  0.1386001 , -1.29363578, ..., -0.35489559,
        -0.93295498, -0.6950323 ],
       [-0.92252046, -0.17794436,  0.34032151, ...,  0.5071473 ,
         1.18436926, -0.95704014],
       ..., 
       [ 0.        ,  0.        ,  0.        , ...,  0.        ,
         0.        ,  0.        ],
       [ 0.        ,  0.        ,  0.        , ...,  0.        ,
         0.        ,  0.        ],
       [ 0.        ,  0.        ,  0.        , ...,  0.        ,
         0.        ,  0.        ]])

In [77]: del mmap
只要某个内存映像超出了作用域,它就会被垃圾回收器回收,之前对其所做的任何修改都会被写入磁盘。当打开一个已经存在的内存映像时,仍然需要指明数据类型和形状,因为磁盘上的那个文件只是一块二进制数据而已,没有任何元数据:

In [78]: mmap = np.memmap('mymmap', dtype='float64', shape=(10000, 10000))

In [79]: mmap
Out[79]: 
memmap([[ 0.19975799,  0.18656236, -0.25229474, ..., -1.00030318,
         0.11016057, -0.27692659],
       [ 0.37246745,  0.1386001 , -1.29363578, ..., -0.35489559,
        -0.93295498, -0.6950323 ],
       [-0.92252046, -0.17794436,  0.34032151, ...,  0.5071473 ,
         1.18436926, -0.95704014],
       ..., 
       [ 0.        ,  0.        ,  0.        , ...,  0.        ,
         0.        ,  0.        ],
       [ 0.        ,  0.        ,  0.        , ...,  0.        ,
         0.        ,  0.        ],
       [ 0.        ,  0.        ,  0.        , ...,  0.        ,
         0.        ,  0.        ]])

由于内存映像其实就是一个存放在磁盘上的ndarray,所以完全可以使用前面介绍的结构化dtype。

(2)HDF5及其他数据存储方式

PyTables和h5py这两个Python项目可以将NumPy的数组数据存储为高效且可压缩的HDF5格式(层次化数据格式)。你可以安全地将好几百GB甚至TB的数据存储为HDF5格式。

PyTables提供了一些用于结构化数组的高级查询功能,而且还能添加列索引以提升查询速度,这跟关系型数据库所提供的表索引功能非常类似。


5、性能建议

使用NumPy的代码的性能一般都很不错,因为数组运算一般都比纯Python循环快得多。下面大致列出了一些需要注意的事项:

  • 将Python循环和条件逻辑转换为数组运算和布尔数组运算。
  • 尽量使用广播。
  • 避免复制数据,尽量使用数组视图(即切片)。
  • 利用ufunc及其各种方法。