实操 | 内存占用减少高达90%,还不用升级硬件?没错,这篇文章教你妙用Pandas轻松处理大规模数据
注:Pandas(Python Data Analysis Library) 是基于 NumPy 的一种工具,该工具是为了解决数据分析任务而创建的。此外,Pandas 纳入了大量库和一些标准的数据模型,提供了高效地操作大型数据集所需的工具。
相比较于 Numpy,Pandas 使用一个二维的数据结构 DataFrame 来表示表格式的数据, 可以存储混合的数据结构,同时使用 NaN 来表示缺失的数据,而不用像 Numpy 一样要手工处理缺失的数据,并且 Pandas 使用轴标签来表示行和列。
Pandas 通常用于处理小数据(小于 100Mb),而且对计算机的性能要求不高,但是当我们需要处理更大的数据时(100Mb到几千Gb),计算机性能就成了问题,如果配置过低就会导致更长的运行时间,甚至因为内存不足导致运行失败。
在处理大型数据集时(100Gb到几TB),我们通常会使用像 Spark 这样的工具,但是想要充分发挥 Spark 的功能,通常需要很高的硬件配置,导致成本过高。而且与 Pandas 不同,这些工具缺少可用于高质量数据清洗、勘测和分析的特征集。
因此对于中等规模的数据,我们最好挖掘 Pandas 的潜能,而不是转而使用其他工具。那么在不升级计算机配置的前提下,我们要怎么解决内存不足的问题呢?
在这篇文章中,我们将介绍 Pandas 的内存使用情况,以及如何通过为数据框(dataframe)中的列(column)选择适当的数据类型,将数据框的内存占用量减少近 90%。
棒球比赛日志
我们将要处理的是 130 年来的大型棒球联盟比赛数据,原始数据来源于 retrosheet。
最原始的数据是 127 个独立的 CSV 文件,不过我们已经使用 csvkit 合并了这些文件,并且在第一行中为每一列添加了名字。 如果读者想亲自动手操作,可下载网站上的数据实践下:https://data.world/dataquest/mlb-game-logs
首先让我们导入数据,看看前五行:
import pandas as pdgl = pd.read_csv('game_logs.csv')gl.head()
我们总结了一些重要的列,但是如果你想查看所有的列的指南,我们也为整个数据集创建了一个数据字典:
我们可以使用 DataFrame.info() 的方法为我们提供数据框架的更多高层次的信息,包括数据大小、类型、内存使用情况的信息。默认情况下,Pandas 会占用和数据框大小差不多的内存来节省时间。因为我们对准确度感兴趣,所以我们将 memory_usage 的参数设置为 ‘deep’,以此来获取更准确的数字。
我们可以看到,这个数据集共有 171,907 行、161 列。Pandas 已经自动检测了数据的类型:83 列数字(numeric),78 列对象(object)。对象列(object columns)主要用于存储字符串,包含混合数据类型。为了更好地了解怎样减少内存的使用量,让我们看看 Pandas 是如何将数据存储在内存中的。
数据框的内部表示
在底层,Pandas 按照数据类型将列分成不同的块(blocks)。这是 Pandas 如何存储数据框前十二列的预览。
你会注意到这些数据块不会保留对列名的引用。这是因为数据块对存储数据框中的实际值进行了优化,BlockManager class 负责维护行、列索引与实际数据块之间的映射。它像一个 API 来提供访问底层数据的接口。每当我们选择、编辑、或删除某个值时,dataframe class 会和 BlockManager class 进行交互,将我们的请求转换为函数和方法调用。
每个类型在 pandas.core.internals 模块中都有一个专门的类, Pandas 使用 ObjectBlock class 来代表包含字符串列的块,FloatBlock class 表示包含浮点型数据(float)列的块。对于表示数值(如整数和浮点数)的块,Pandas 将这些列组合在一起,并存储为 NumPy ndarry 数组。NumPy ndarry 是围绕 C array 构建的,而且它们的值被存储在连续的内存块中。由于采用这种存储方案,访问这些值的地址片段(slice)是非常快的。
因为不同的数据都是单独存储的,所以我们将检查不同类型的数据的内存使用情况。我们先来看看所有数据类型的平均内存使用情况。
可以看到,大部分的内存都被 78 个对象列占用了。我们稍后再来分析,首先看看我们是否可以提高数字列(numeric columns)的内存使用率。
了解子类型
正如前面介绍的那样,在底层,Pandas 将数值表示为 NumPy ndarrays,并将它存储在连续的内存块中。该存储模型消耗的空间较小,并允许我们快速访问这些值。因为 Pandas 中,相同类型的值会分配到相同的字节数,而 NumPy ndarray 里存储了值的数量,所以 Pandas 可以快速并准确地返回一个数值列占用的字节数。
Pandas 中的许多类型包含了多个子类型,因此可以使用较少的字节数来表示每个值。例如,float 类型就包含 float16、float32、float64 等子类型。类型名称的数字部分代表 了用于表示值类型的位数。例如,我们刚刚列出的子类型就分别使用了 2、4、8、16 个字节。下表显示了最常见的 Pandas 的子类型:
int8 使用 1 个字节(或者 8 位)来存储一个值,并且可以以二进制表示 256 个值。这意味着,我们可以使用这种子类型来表示从 -128 到 127 (包括0)的值。我们可以使用 numpy.iinfo class 来验证每个整数子类型的最小值和最大值,我们来看一个例子:
我们可以在这里看到 uint(无符号整数)和 int(有符号整数)之间的区别。这两种类型具有相同的存储容量,但如果只存储正数,无符号整数显然能够让我们更高效地存储只包含正值的列。
使用子类型优化数字列
我们可以使用函数 pd.to_numeric() 来 downcast(向下转型)我们的数值类型。我们将使用 DataFrame.select_dtypes 来选择整数列,然后优化这些列包含的类型,并比较优化前后内存的使用情况。
我们可以看到,内存的使用量从 7.9Mb 降到了 1.5 Mb,减少了 80% 以上。但这对原始数据框的影响并不大,因为本身整数列就非常少。
现在,让我们来对浮点型数列做同样的事情。
可以看到,我们所有的浮点型数列都从 float64 转换成 float32,使得内存的使用量减少了 50%。
让我们创建一个原始数据框的副本,然后分配这些优化后的数字列代替原始数据,并查看现在的内存使用情况。
虽然我们大大减少了数字列的内存使用量,但是从整体来看,我们只是将数据框的内存使用量降低了 7%。内存使用量降低的主要原因是我们对对象类型(object types)进行了优化。
在动手之前,让我们仔细看一下,与数字类型相比,字符串是怎样存在 Pandas 中的。
比较数字和字符串的存储方式
对象类型代表了 Python 字符串对象的值,部分原因是 NumPy 缺少对字符串值的支持。因为 Python 是一种高级的解释语言,它不能对数值的存储方式进行细粒度控制。
这种限制使得字符串以分散的方式存储在内存里,不仅占用了更多的内存,而且访问速度较慢。对象列表中的每一个元素都是一个指针(pointer),它包含了实际值在内存中位置的“地址”。
下面的图标展示了数字值是如何存储在 NumPy 数据类型中,以及字符串如何使用 Python 内置的类型存储。
你可能已经注意到,我们的图表之前将对象类型描述成使用可变内存量。当每个指针占用一字节的内存时,每个字符的字符串值占用的内存量与 Python 中单独存储时相同。让我们使用 sys.getsizeof() 来自证明这一点:先查看单个字符串,然后查看 Pandas 系列中的项目(items)。
你可以看到,存储在 Pandas 中的字符串的大小与作为 Python 中单独字符串的大小相同。
使用分类来优化对象类型板面的做法和配料
Pandas 在 0.15版引入了 Categoricals (分类)。category 类型在底层使用整数类型来表示该列的值,而不是原始值。Pandas 用一个单独的字典来映射整数值和相应的原始值之间的关系。当某一列包含的数值集有限时,这种设计是很有用的。当我们将列转换为 category dtype 时,Pandas 使用了最省空间的 int 子类型,来表示一列中所有的唯一值。
想要知道我们可以怎样使用这种类型来减少内存使用量。首先 ,让我们看看每一种对象类型的唯一值的数量。
可以看到,我们的数据集中一共有 17.2 万场比赛, 而唯一值的数量是非常少的。
在我们深入分析之前,我们首先选择一个对象列,当我们将其转换为 categorical type时,观察下会发生什么。我们选择了数据集中的第二列 day_of_week 来进行试验。
在上面的表格中,我们可以看到它只包含了七个唯一的值。我们将使用 .astype() 的方法将其转换为 categorical。
如你所见,除了列的类型已经改变,这些数据看起来完全一样。我们来看看发生了什么。
在下面的代码中,我们使用 Series.cat.codes 属性来返回 category 类型用来表示每个值的整数值。
你可以看到,每个唯一值都被分配了一个整数,并且该列的底层数据类型现在是 int8。该列没有任何缺失值,如果有的话,这个 category 子类型会将缺省值设置为 -1。最后,我们来看看这个列在转换到 category 类型之前和之后的内存使用情况。
可以看到,内存使用量从原来的 9.8MB 降到了 0.16MB,相当于减少了 98%!请注意,这一列可能代表我们最好的情况之一:一个具有 172,000 个项目的列,只有 7 个唯一的值。
将所有的列都进行同样的操作,这听起来很吸引人,但使我们要注意权衡。可能出现的最大问题是无法进行数值计算。我们不能在将其转换成真正的数字类型的前提下,对这些 category 列进行计算,或者使用类似 Series.min() 和 Series.max() 的方法。
当对象列中少于 50% 的值时唯一对象时,我们应该坚持使用 category 类型。但是如果这一列中所有的值都是唯一的,那么 category 类型最终将占用更多的内存。这是因为列不仅要存储整数 category 代码,还要存储所有的原始字符串的值。你可以阅读 Pandas 文档,了解 category 类型的更多限制。
我们将编写一个循环程序,遍历每个对象列,检查其唯一值的数量是否小于 50%。如果是,那么我们就将这一列转换为 category 类型。
和之前的相比
在这种情况下,我们将所有对象列都转换为 category 类型,但是这种情况并不符合所有的数据集,因此务必确保事先进行过检查。
此外,对象列的内存使用量已经从 752MB 将至 52MB,减少了 93%。现在,我们将其与数据框的其余部分结合起来,再与我们最开始的 861MB 的内存使用量进行对比。
可以看到,我们已经取得了一些进展,但是我们还有一个地方可以优化。回到我们的类型表,里面有一个日期(datetime)类型可以用来表示数据集的第一列。
你可能记得这一列之前是作为整数型读取的,而且已经被优化为 uint32。因此,将其转换为 datetime 时,内存的占用量会增加一倍,因为 datetime 的类型是 64 位。无论如何,将其转换成 datetime 是有价值的,因为它将让时间序列分析更加容易。
我们将使用 pandas.to_datetime() 函数进行转换,并使用 format 参数让日期数据按照 YYYY-MM-DD 的格式存储。
在读取数据时选择类型
到目前为止,我们已 经 探索了减少现有数 据框内存占用的方法。首先,读入阅读数据框,然后再反复迭代节省内存的方法,这让我们可以更好地了解每次优化可以节省的内存空间。然而,正如我们前面提到那样,我们经常没有足够的内存来表示数据集中所有的值。如果一开始就不能创建数据框,那么我们该怎样使用内存节省技术呢?
幸运的是,当我们读取数据集时,我们可以制定列的最优类型。pandas.read_csv() 函数有几个不同的参数可以让我们做到这一点。dtype 参数可以是一个以(字符串)列名称作为 keys、以 NumPy 类型对象作为值的字典。
首先,我们将每列的最终类型、以及列的名字的 keys 存在一个字典中。因为日期列需要单独对待,因此我们先要删除这一列。
现在,我们可以使用字典、以及几个日期的参数,通过几行代码,以正确的类型读取日期数据。
通过优化这些列,我们设法将 pandas 中的内存使用量,从 861.6MB 降到了 104.28MB,减少了 88%。
分析棒球比赛
我们已经优化了数据,现在我们可以开始对数据进行分析了。我们来看看比赛的时间分布。
可以看到,在二十世纪二十年代之前,棒球比赛很少在周日举行,一直到下半世纪才逐渐流行起来。此外,我们也可以清楚地看到,在过去的五十年里,比赛时间的分是相对静态的。我们来看看比赛时长多年来的变化。
看起来,棒球比赛的时长自 1940 年以来就一直处于增长状态。
总结和后续步骤
我们已经了解到 Pandas 是如何存储不同类型的数据的,然后我们使用这些知识将 Pandas 里的数据框的内存使用量降低了近 90%,而这一切只需要几个简单的技巧:
-
将数字列 downcast 到更节省空间的类型;
-
将字符串转换为分类类型(categorical type)。