本篇介绍如何降低运行内存的一些方法。
当使用pandas操作小规模数据(低于100MB)时,性能一般不是问题。而当面对更大规模的数据(100MB到数GB)时,性能问题会让运行时间变得更漫长,而且会因为内存不足导致运行完全失败。
尽管Spark这样的工具可以处理大型数据集(100GB到数TB),但要完全利用它们的能力,往往需要更加昂贵的硬件。而且和pandas不同,它们缺少丰富的用于高质量数据清理、探索和分析的功能集。对于中等规模的数据,我们最好能更充分地利用pandas,而不是换成另一种工具。
在这篇文章中,我们将了解pandas的内存使用,以及如何只需通过为列选择合适的数据类型就能将dataframe的内存占用减少近90%。
处理棒球比赛日志
我们将处理130年之久的美国职业棒球大联盟(MLB)比赛数据,这些数据来自Retrosheet:http://www.retrosheet.org/gamelogs/index.html。
这些数据原来分成了127个不同的CSV文件,但我们已经使用csvkit合并了这些数据,并在第一行增加了列名称。如果你想下载本文所用的这个数据版本,请访问:https://data.world/dataquest/mlb-game-logs。
让我们首先导入数据,并看看其中的前五行:
importpandasaspd
gl=pd.read_csv('game_logs.csv')
gl.head
下面我们总结了一些重要的列,但如果你想了解所有的列,我们也为整个数据集创建了一个数据词典:https://data.world/dataquest/mlb-game-logs/workspace/data-dictionary。
我们可以使用DataFrame.info方法为我们提供关于dataframe的高层面信息,包括它的大小、数据类型的信息和内存使用情况。
默认情况下,pandas会近似dataframe的内存用量以节省时间。因为我们也关心准确度,所以我们将memory_usage参数设置为'deep',以便得到准确的数字。
gl.info(memory_usage='deep')
<class'pandas.core.frame.DataFrame'>
RangeIndex:171907entries,0to171906
Columns:161entries,datetoacquisition_info
dtypes:float64(77),int64(6),object(78)
memoryusage:861.6MB
我们可以看到,我们有171,907行和161列。pandas会自动为我们检测数据类型,发现其中有83列数据是数值,78列是object。object是指有字符串或包含混合数据类型的情况。
为了更好地理解如何减少内存用量,让我们看看pandas是如何将数据存储在内存中的。
dataframe的内部表示
在pandas内部,同样数据类型的列会组织成同一个值块(blocksofvalues)。这里给出了一个示例,说明了pandas对我们的dataframe的前12列的存储方式。
你可以看到这些块并没有保留原有的列名称。这是因为这些块为存储dataframe中的实际值进行了优化。pandas的BlockManager类则负责保留行列索引与实际块之间的映射关系。它可以作为一个API使用,提供了对底层数据的访问。不管我们何时选择、编辑或删除这些值,dataframe类和BlockManager类的接口都会将我们的请求翻译成函数和方法的调用。
在pandas.core.internals模块中,每一种类型都有一个专门的类。pandas使用ObjectBlock类来表示包含字符串列的块,用FloatBlock类表示包含浮点数列的块。对于表示整型数和浮点数这些数值的块,pandas会将这些列组合起来,存储成NumPyndarray。NumPyndarray是围绕C语言的数组构建的,其中的值存储在内存的连续块中。这种存储方案使得对值的访问速度非常快。
因为每种数据类型都是分开存储的,所以我们将检查不同数据类型的内存使用情况。首先,我们先来看看各个数据类型的平均内存用量。
fordtypein['float','int','object']:
selected_dtype=gl.select_dtypes(include=[dtype])
mean_usage_b=selected_dtype.memory_usage(deep=True).mean
mean_usage_mb=mean_usage_b/1024**2
print("Averagememoryusagefor{}columns:{:03.2f}MB".format(dtype,mean_usage_mb))
Averagememoryusageforfloatcolumns:1.29MB
Averagememoryusageforintcolumns:1.12MB
Averagememoryusageforobjectcolumns:9.53MB
可以看出,78个object列所使用的内存量最大。我们后面再具体谈这个问题。首先我们看看能否改进数值列的内存用量。
理解子类型(subtype)
正如我们前面简单提到的那样,pandas内部将数值表示为NumPyndarrays,并将它们存储在内存的连续块中。这种存储模式占用的空间更少,而且也让我们可以快速访问这些值。因为pandas表示同一类型的每个值时都使用同样的字节数,而NumPyndarray可以存储值的数量,所以pandas可以快速准确地返回一个数值列所消耗的字节数。
pandas中的许多类型都有多个子类型,这些子类型可以使用更少的字节来表示每个值。比如说float类型就包含float16、float32和float64子类型。类型名称中的数字就代表该类型表示值的位(bit)数。比如说,我们刚刚列出的子类型就分别使用了2、4、8、16个字节。下面的表格给出了pandas中最常用类型的子类型:
一个int8类型的值使用1个字节的存储空间,可以表示256(2^8)个二进制数。这意味着我们可以使用这个子类型来表示从-128到127(包括0)的所有整数值。
我们可以使用numpy.iinfo类来验证每个整型数子类型的最大值和最小值。举个例子:
importnumpyasnp
int_types=["uint8","int8","int16"]
foritinint_types:
print(np.iinfo(it))
Machineparametersforuint8
---------------------------------------------------------------
min=0
max=255
---------------------------------------------------------------
Machineparametersforint8
---------------------------------------------------------------
min=-128
max=127
---------------------------------------------------------------
Machineparametersforint16
---------------------------------------------------------------
min=-32768
max=32767
---------------------------------------------------------------
这里我们可以看到uint(无符号整型)和int(有符号整型)之间的差异。这两种类型都有一样的存储能力,但其中一个只保存0和正数。无符号整型让我们可以更有效地处理只有正数值的列。
使用子类型优化数值列
我们可以使用函数pd.to_numeric来对我们的数值类型进行downcast(向下转型)操作。我们会使用DataFrame.select_dtypes来选择整型列,然后我们会对其数据类型进行优化,并比较内存用量。
#We'regoingtobecalculatingmemoryusagealot,
#sowe'llcreateafunctiontosaveussometime!
defmem_usage(pandas_obj):
ifisinstance(pandas_obj,pd.DataFrame):
usage_b=pandas_obj.memory_usage(deep=True).sum
else:#weassumeifnotadfit'saseries
usage_b=pandas_obj.memory_usage(deep=True)
usage_mb=usage_b/1024**2#convertbytestomegabytes
return"{:03.2f}MB".format(usage_mb)
gl_int=gl.select_dtypes(include=['int'])
converted_int=gl_int.apply(pd.to_numeric,downcast='unsigned')
print(mem_usage(gl_int))
print(mem_usage(converted_int))
compare_ints=pd.concat([gl_int.dtypes,converted_int.dtypes],axis=1)
compare_ints.columns=['before','after']
compare_ints.apply(pd.Series.value_counts)
7.87MB
1.48MB
我们可以看到内存用量从7.9MB下降到了1.5MB,降低了80%以上。但这对我们原有dataframe的影响并不大,因为其中的整型列非常少。
让我们对其中的浮点型列进行一样的操作。
gl_float=gl.select_dtypes(include=['float'])
converted_float=gl_float.apply(pd.to_numeric,downcast='float')
print(mem_usage(gl_float))
print(mem_usage(converted_float))
compare_floats=pd.concat([gl_float.dtypes,converted_float.dtypes],axis=1)
compare_floats.columns=['before','after']
compare_floats.apply(pd.Series.value_counts)
gl.info(memory_usage='deep')
0
我们可以看到浮点型列的数据类型从float64变成了float32,让内存用量降低了50%。
让我们为原始dataframe创建一个副本,并用这些优化后的列替换原来的列,然后看看我们现在的整体内存用量。
gl.info(memory_usage='deep')
1
gl.info(memory_usage='deep')
2
gl.info(memory_usage='deep')
3
尽管我们极大地减少了数值列的内存用量,但整体的内存用量仅减少了7%。我们的大部分收获都将来自对object类型的优化。
在我们开始行动之前,先看看pandas中字符串的存储方式与数值类型的存储方式的比较。
数值存储与字符串存储的比较
object类型表示使用Python字符串对象的值,部分原因是NumPy不支持缺失(missing)字符串类型。因为Python是一种高级的解释性语言,它对内存中存储的值没有细粒度的控制能力。
这一限制导致字符串的存储方式很碎片化,从而会消耗更多内存,而且访问速度也更慢。object列中的每个元素实际上都是一个指针,包含了实际值在内存中的位置的「地址」。
下面这幅图给出了以NumPy数据类型存储数值数据和使用Python内置类型存储字符串数据的方式。
图片来源:https://jakevdp.github.io/blog/2014/05/09/why-python-is-slow/
在前面的表格中,你可能已经注意到object类型的内存使用是可变的。尽管每个指针仅占用1字节的内存,但如果每个字符串在Python中都是单独存储的,那就会占用实际字符串那么大的空间。我们可以使用sys.getsizeof函数来证明这一点,首先查看单个的字符串,然后查看pandasseries中的项。
gl.info(memory_usage='deep')
4
gl.info(memory_usage='deep')
5
gl.info(memory_usage='deep')
6
gl.info(memory_usage='deep')
7
你可以看到,当存储在pandasseries时,字符串的大小与用Python单独存储的字符串的大小是一样的。
使用Categoricals优化object类型
pandas在0.15版引入了Categorials。category类型在底层使用了整型值来表示一个列中的值,而不是使用原始值。pandas使用一个单独的映射词典将这些整型值映射到原始值。只要当一个列包含有限的值的集合时,这种方法就很有用。当我们将一列转换成categorydtype时,pandas就使用最节省空间的int子类型来表示该列中的所有不同值。
为了了解为什么我们可以使用这种类型来减少内存用量,让我们看看我们的object类型中每种类型的不同值的数量。
gl.info(memory_usage='deep')
8
上图完整图像详见原文
大概看看就能发现,对于我们整个数据集的172,000场比赛,其中不同(unique)值的数量可以说非常少。
为了了解当我们将其转换成categorical类型时究竟发生了什么,我们拿出一个object列来看看。我们将使用数据集的第二列day_of_week.
看看上表,可以看到其仅包含7个不同的值。我们将使用.astype方法将其转换成categorical类型。
gl.info(memory_usage='deep')
9
<class'pandas.core.frame.DataFrame'>
RangeIndex:171907entries,0to171906
Columns:161entries,datetoacquisition_info
dtypes:float64(77),int64(6),object(78)
memoryusage:861.6MB
0
如你所见,除了这一列的类型发生了改变之外,数据看起来还是完全一样。让我们看看这背后发生了什么。
在下面的代码中,我们使用了Series.cat.codes属性来返回category类型用来表示每个值的整型值。
<class'pandas.core.frame.DataFrame'>
RangeIndex:171907entries,0to171906
Columns:161entries,datetoacquisition_info
dtypes:float64(77),int64(6),object(78)
memoryusage:861.6MB
1
<class'pandas.core.frame.DataFrame'>
RangeIndex:171907entries,0to171906
Columns:161entries,datetoacquisition_info
dtypes:float64(77),int64(6),object(78)
memoryusage:861.6MB
2
你可以看到每个不同值都被分配了一个整型值,而该列现在的基本数据类型是int8。这一列没有任何缺失值,但就算有,category子类型也能处理,只需将其设置为-1即可。
最后,让我们看看在将这一列转换为category类型前后的内存用量对比。
<class'pandas.core.frame.DataFrame'>
RangeIndex:171907entries,0to171906
Columns:161entries,datetoacquisition_info
dtypes:float64(77),int64(6),object(78)
memoryusage:861.6MB
3
<class'pandas.core.frame.DataFrame'>
RangeIndex:171907entries,0to171906
Columns:161entries,datetoacquisition_info
dtypes:float64(77),int64(6),object(78)
memoryusage:861.6MB
4
9.8MB的内存用量减少到了0.16MB,减少了98%!注意,这个特定列可能代表了我们最好的情况之一——即大约172,000项却只有7个不同的值。
尽管将所有列都转换成这种类型听起来很吸引人,但了解其中的取舍也很重要。最大的坏处是无法执行数值计算。如果没有首先将其转换成数值dtype,那么我们就无法对category列进行算术运算,也就是说无法使用Series.min和Series.max等方法。
我们应该坚持主要将category类型用于不同值的数量少于值的总数量的50%的object列。如果一列中的所有值都是不同的,那么category类型所使用的内存将会更多。因为这一列不仅要存储所有的原始字符串值,还要额外存储它们的整型值代码。你可以在pandas文档中了解category类型的局限性:http://pandas.pydata.org/pandas-docs/stable/categorical.html。
我们将编写一个循环函数来迭代式地检查每一object列中不同值的数量是否少于50%;如果是,就将其转换成category类型。
<class'pandas.core.frame.DataFrame'>
RangeIndex:171907entries,0to171906
Columns:161entries,datetoacquisition_info
dtypes:float64(77),int64(6),object(78)
memoryusage:861.6MB
5
和之前一样进行比较:
<class'pandas.core.frame.DataFrame'>
RangeIndex:171907entries,0to171906
Columns:161entries,datetoacquisition_info
dtypes:float64(77),int64(6),object(78)
memoryusage:861.6MB
6
<class'pandas.core.frame.DataFrame'>
RangeIndex:171907entries,0to171906
Columns:161entries,datetoacquisition_info
dtypes:float64(77),int64(6),object(78)
memoryusage:861.6MB
7
在这个案例中,所有的object列都被转换成了category类型,但并非所有数据集都是如此,所以你应该使用上面的流程进行检查。
object列的内存用量从752MB减少到了52MB,减少了93%。让我们将其与我们dataframe的其它部分结合起来,看看从最初861MB的基础上实现了多少进步。
<class'pandas.core.frame.DataFrame'>
RangeIndex:171907entries,0to171906
Columns:161entries,datetoacquisition_info
dtypes:float64(77),int64(6),object(78)
memoryusage:861.6MB
8
<class'pandas.core.frame.DataFrame'>
RangeIndex:171907entries,0to171906
Columns:161entries,datetoacquisition_info
dtypes:float64(77),int64(6),object(78)
memoryusage:861.6MB
9
Wow,进展真是不错!我们还可以执行另一项优化——如果你记得前面给出的数据类型表,你知道还有一个datetime类型。这个数据集的第一列就可以使用这个类型。
fordtypein['float','int','object']:
selected_dtype=gl.select_dtypes(include=[dtype])
mean_usage_b=selected_dtype.memory_usage(deep=True).mean
mean_usage_mb=mean_usage_b/1024**2
print("Averagememoryusagefor{}columns:{:03.2f}MB".format(dtype,mean_usage_mb))
0
fordtypein['float','int','object']:
selected_dtype=gl.select_dtypes(include=[dtype])
mean_usage_b=selected_dtype.memory_usage(deep=True).mean
mean_usage_mb=mean_usage_b/1024**2
print("Averagememoryusagefor{}columns:{:03.2f}MB".format(dtype,mean_usage_mb))
1
fordtypein['float','int','object']:
selected_dtype=gl.select_dtypes(include=[dtype])
mean_usage_b=selected_dtype.memory_usage(deep=True).mean
mean_usage_mb=mean_usage_b/1024**2
print("Averagememoryusagefor{}columns:{:03.2f}MB".format(dtype,mean_usage_mb))
2
你可能记得这一列开始是一个整型,现在已经优化成了unint32类型。因此,将其转换成datetime类型实际上会让内存用量翻倍,因为datetime类型是64位的。将其转换成datetime类型是有价值的,因为这让我们可以更好地进行时间序列分析。
pandas.to_datetime函数可以帮我们完成这种转换,使用其format参数将我们的日期数据存储成YYYY-MM-DD形式。
fordtypein['float','int','object']:
selected_dtype=gl.select_dtypes(include=[dtype])
mean_usage_b=selected_dtype.memory_usage(deep=True).mean
mean_usage_mb=mean_usage_b/1024**2
print("Averagememoryusagefor{}columns:{:03.2f}MB".format(dtype,mean_usage_mb))
3
fordtypein['float','int','object']:
selected_dtype=gl.select_dtypes(include=[dtype])
mean_usage_b=selected_dtype.memory_usage(deep=True).mean
mean_usage_mb=mean_usage_b/1024**2
print("Averagememoryusagefor{}columns:{:03.2f}MB".format(dtype,mean_usage_mb))
4
fordtypein['float','int','object']:
selected_dtype=gl.select_dtypes(include=[dtype])
mean_usage_b=selected_dtype.memory_usage(deep=True).mean
mean_usage_mb=mean_usage_b/1024**2
print("Averagememoryusagefor{}columns:{:03.2f}MB".format(dtype,mean_usage_mb))
5
在读入数据的同时选择类型
现在,我们已经探索了减少现有dataframe的内存占用的方法。通过首先读入dataframe,然后在这个过程中迭代以减少内存占用,我们了解了每种优化方法可以带来的内存减省量。但是正如我们前面提到的一样,我们往往没有足够的内存来表示数据集中的所有值。如果我们一开始甚至无法创建dataframe,我们又可以怎样应用节省内存的技术呢?
幸运的是,我们可以在读入数据的同时指定最优的列类型。pandas.read_csv函数有几个不同的参数让我们可以做到这一点。dtype参数接受具有(字符串)列名称作为键值(key)以及NumPy类型object作为值的词典。
首先,我们可将每一列的最终类型存储在一个词典中,其中键值表示列名称,首先移除日期列,因为日期列需要不同的处理方式。
fordtypein['float','int','object']:
selected_dtype=gl.select_dtypes(include=[dtype])
mean_usage_b=selected_dtype.memory_usage(deep=True).mean
mean_usage_mb=mean_usage_b/1024**2
print("Averagememoryusagefor{}columns:{:03.2f}MB".format(dtype,mean_usage_mb))
6
fordtypein['float','int','object']:
selected_dtype=gl.select_dtypes(include=[dtype])
mean_usage_b=selected_dtype.memory_usage(deep=True).mean
mean_usage_mb=mean_usage_b/1024**2
print("Averagememoryusagefor{}columns:{:03.2f}MB".format(dtype,mean_usage_mb))
7
现在我们可以使用这个词典了,另外还有几个参数可用于按正确的类型读入日期,而且仅需几行代码:
fordtypein['float','int','object']:
selected_dtype=gl.select_dtypes(include=[dtype])
mean_usage_b=selected_dtype.memory_usage(deep=True).mean
mean_usage_mb=mean_usage_b/1024**2
print("Averagememoryusagefor{}columns:{:03.2f}MB".format(dtype,mean_usage_mb))
8
fordtypein['float','int','object']:
selected_dtype=gl.select_dtypes(include=[dtype])
mean_usage_b=selected_dtype.memory_usage(deep=True).mean
mean_usage_mb=mean_usage_b/1024**2
print("Averagememoryusagefor{}columns:{:03.2f}MB".format(dtype,mean_usage_mb))
9
上图完整图像详见原文
通过优化这些列,我们成功将pandas的内存占用从861.6MB减少到了104.28MB——减少了惊人的88%!
分析棒球比赛
现在我们已经优化好了我们的数据,我们可以执行一些分析了。让我们先从了解这些比赛的日期分布开始。
Averagememoryusageforfloatcolumns:1.29MB
Averagememoryusageforintcolumns:1.12MB
Averagememoryusageforobjectcolumns:9.53MB
0
我们可以看到在1920年代以前,星期日的棒球比赛很少,但在上个世纪后半叶就变得越来越多了。
我们也可以清楚地看到过去50年来,比赛的日期分布基本上没什么大变化了。
让我们再看看比赛时长的变化情况:
Averagememoryusageforfloatcolumns:1.29MB
Averagememoryusageforintcolumns:1.12MB
Averagememoryusageforobjectcolumns:9.53MB
1
从1940年代以来,棒球比赛的持续时间越来越长。
总结和下一步
我们已经了解了pandas使用不同数据类型的方法,然后我们使用这种知识将一个pandasdataframe的内存用量减少了近90%,而且也仅使用了一些简单的技术:
将数值列向下转换成更高效的类型
将字符串列转换成categorical类型
福利
入门Python的最强三件套《ThinkPython》、《简明Python教程》、《Python进阶》的PDF电子版已打包提供给大家,「P3」即可获取。