用pandas处理大数据减少90%内存消耗的小贴士

2022-07-15 10:48:50

原文地址https://www.dataquest.io/blog/pandas-big-data/

一般来说,用pandas处理小于100兆的数据,性能不是问题。当用pandas来处理100兆至几个G的数据时,将会比较耗时,同时会导致程序因内存不足而运行失败。

当然,像Spark这类的工具能够胜任处理100G至几个T的大数据集,但要想充分发挥这些工具的优势,通常需要比较贵的硬件设备。而且,这些工具不像pandas那样具有丰富的进行高质量数据清洗、探索和分析的特性。对于中等规模的数据,我们的愿望是尽量让pandas继续发挥其优势,而不是换用其他工具。

本文我们讨论pandas的内存使用,展示怎样简单地为数据列选择合适的数据类型,就能够减少dataframe近90%的内存占用。

处理棒球比赛记录数据

我们将处理130年的棒球甲级联赛的数据,数据源于Retrosheet

原始数据放在127个csv文件中,我们已经用csvkit将其合并,并添加了表头。如果你想下载我们版本的数据用来运行本文的程序,我们提供了下载地址

我们从导入数据,并输出前5行开始:

import pandasas pd

gl = pd.read_csv('game_logs.csv')
gl.head()
datenumber_of_gameday_of_weekv_namev_leaguev_game_numberh_name
0187105040ThuCL1na1FW1
1187105050FriBS1na1WS3
2187105060SatCL1na2RC1
3187105080MonCL1na3CH1
4187105090TueBS1na2TRO

我们将一些重要的字段列在下面:

  • date - 比赛日期
  • v_name - 客队名
  • v_league - 客队联赛
  • h_name - 主队名
  • h_league - 主队联赛
  • v_score - 客队得分
  • h_score - 主队得分
  • v_line_score - 客队线得分, 如010000(10)00.
  • h_line_score- 主队线得分, 如010000(10)0X.
  • park_id - 主办场地的ID
  • attendance- 比赛出席人数

我们可以用Dataframe.info()方法来获得我们dataframe的一些高level信息,譬如数据量、数据类型和内存使用量。

这个方法默认情况下返回一个近似的内存使用量,现在我们设置参数memory_usage'deep'来获得准确的内存使用量:

gl.info(memory_usage='deep')
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 171907 entries, 0 to 171906
Columns: 161 entries, date to acquisition_info
dtypes: float64(77), int64(6), object(78)
memory usage: 861.6 MB

我们可以看到它有171907行和161列。pandas已经为我们自动检测了数据类型,其中包括83列数值型数据和78列对象型数据。对象型数据列用于字符串或包含混合数据类型的列。

由此我们可以进一步了解我们应该如何减少内存占用,下面我们来看一看pandas如何在内存中存储数据。

Dataframe对象的内部表示

在底层,pandas会按照数据类型将列分组形成数据块(blocks)。下图所示为pandas如何存储我们数据表的前十二列:

可以注意到,这些数据块没有保持对列名的引用,这是由于为了存储dataframe中的真实数据,这些数据块都经过了优化。有个BlockManager类会用于保持行列索引与真实数据块的映射关系。他扮演一个API,提供对底层数据的访问。每当我们查询、编辑或删除数据时,dataframe类会利用BlockManager类接口将我们的请求转换为函数和方法的调用。

每种数据类型在pandas.core.internals模块中都有一个特定的类。pandas使用ObjectBlock类来表示包含字符串列的数据块,用FloatBlock类来表示包含浮点型列的数据块。对于包含数值型数据(比如整型和浮点型)的数据块,pandas会合并这些列,并把它们存储为一个Numpy数组(ndarray)。Numpy数组是在C数组的基础上创建的,其值在内存中是连续存储的。基于这种存储机制,对其切片的访问是相当快的。

由于不同类型的数据是分开存放的,我们将检查不同数据类型的内存使用情况,我们先看看各数据类型的平均内存使用量:

for dtypein ['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("Average memory usage for {} columns: {:03.2f} MB".format(dtype,mean_usage_mb))
Average memory usage for float columns: 1.29 MB
Average memory usage for int columns: 1.12 MB
Average memory usage for object columns: 9.53 MB

我们可以看到内存使用最多的是78个object列,我们待会再来看它们,我们先来看看我们能否提高数值型列的内存使用效率。

理解子类型(Subtypes)

刚才我们提到,pandas在底层将数值型数据表示成Numpy数组,并在内存中连续存储。这种存储方式消耗较少的空间,并允许我们较快速地访问数据。由于pandas使用相同数量的字节来表示同一类型的每一个值,并且numpy数组存储了这些值的数量,所以pandas能够快速准确地返回数值型列所消耗的字节量。

pandas中的许多数据类型具有多个子类型,它们可以使用较少的字节去表示不同数据,比如,float型就有float16float32float64这些子类型。这些类型名称的数字部分表明了这种类型使用了多少比特来表示数据,比如刚才列出的子类型分别使用了2、4、8个字节。下面这张表列出了pandas中常用类型的子类型:

memory usagefloatintuintdatetimeboolobject
1 bytesint8uint8bool
2 bytesfloat16int16uint16
4 bytesfloat32int32uint32
8 bytesfloat64int64uint64datetime64
variableobject

一个int8类型的数据使用1个字节(8位比特)存储一个值,可以表示256(2^8)个二进制数值。这意味着我们可以用这种子类型去表示从-128到127(包括0)的数值。

我们可以用numpy.iinfo类来确认每一个整型子类型的最小和最大值,如下:

import numpyas np
int_types = ["uint8","int8","int16"]for itin int_types:
    print(np.iinfo(it))
Machine parameters for uint8
-----------------------------------------------------
min = 0
max = 255
-----------------------------------------------------

Machine parameters for int8
-----------------------------------------------------
min = -128
max = 127
-----------------------------------------------------

Machine parameters for int16
-----------------------------------------------------
min = -32768
max = 32767
-----------------------------------------------------

这里我们还可以看到uint(无符号整型)和int(有符号整型)的区别。两者都占用相同的内存存储量,但无符号整型由于只存正数,所以可以更高效的存储只含正数的列。

用子类型优化数值型列

我们可以用函数pd.to_numeric()来对数值型进行向下类型转换。我们用DataFrame.select_dtypes来只选择整型列,然后我们优化这种类型,并比较内存使用量。

# We're going to be calculating memory usage a lot,# so we'll create a function to save us some time!defmem_usage(pandas_obj):if isinstance(pandas_obj,pd.DataFrame):
        usage_b = pandas_obj.memory_usage(deep=True).sum()else:# we assume if not a df it's a series
        usage_b = pandas_obj.memory_usage(deep=True)
    usage_mb = usage_b /1024 **2# convert bytes to megabytesreturn"{: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.87 MB
1.48 MB
beforeafter
uint8NaN5.0
uint32NaN1.0
int646.0NaN

我们看到内存用量从7.9兆下降到1.5兆,降幅达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)
100.99 MB
50.49 MB
beforeafter
float32NaN77.0
float6477.0NaN

我们可以看到所有的浮点型列都从float64转换为float32,内存用量减少50%。

我们再创建一个原始dataframe的副本,将其数值列赋值为优化后的类型,再看看内存用量的整体优化效果。

optimized_gl = gl.copy()

optimized_gl[converted_int.columns] = converted_int
optimized_gl[converted_float.columns] = converted_float

print(mem_usage(gl))
print(mem_usage(optimized_gl))
861.57 MB
804.69 MB

可以看到通过我们显著缩减数值型列的内存用量,我们的dataframe的整体内存用量减少了7%。余下的大部分优化将针对object类型进行。

在这之前,我们先来研究下与数值型相比,pandas如何存储字符串。

对比数值与字符的存储

object类型用来表示用到了Python字符串对象的值,有一部分原因是Numpy缺少对缺失字符串值的支持。因为Python是一种高层、解析型语言,它没有提供很好的对内存中数据如何存储的细粒度控制。

这一限制导致了字符串以一种碎片化方式进行存储,消耗更多的内存,并且访问速度低下。在object列中的每一个元素实际上都是存放内存中真实数据位置的指针。

下图对比展示了数值型数据怎样以Numpy数据类型存储,和字符串怎样以Python内置类型进行存储的。


图示来源并改编自Why Python Is Slow

你可能注意到上文表中提到object类型数据使用可变(variable)大小的内存。由于一个指针占用1字节,因此每一个字符串占用的内存量与它在Python中单独存储所占用的内存量相等。我们用sys.getsizeof()来证明这一点,先来看看在Python单独存储字符串,再来看看使用pandas的series的情况。

from sysimport getsizeof

s1 ='working out'
s2 ='memory usage for'
s3 ='strings in python is fun!'
s4 ='strings in python is fun!'for sin [s1, s2, s3, s4]:
    print(getsizeof(s))
60
65
74
74
obj_series = pd.Series(['working out','memory usage for','strings in python is fun!','strings in python is fun!'])
obj_series.apply(getsizeof)
0    60
1    65
2    74
3    74
dtype: int64

你可以看到这些字符串的大小在pandas的series中与在Python的单独字符串中是一样的。

用类别(categoricals)类型优化object类型

Pandas在0.15版本中引入类别类型。category类型在底层使用整型数值来表示该列的值,而不是用原值。Pandas用一个字典来构建这些整型数据到原数据的映射关系。当一列只包含有限种值时,这种设计是很不错的。当我们把一列转换成category类型时,pandas会用一种最省空间的int子类型去表示这一列中所有的唯一值。

为了介绍我们何处会用到这种类型去减少内存消耗,让我们来看看我们数据中每一个object类型列中的唯一值个数。

gl_obj = gl.select_dtypes(include=['object']).copy()
gl_obj.describe()
day_of_weekv_namev_leagueh_nameh_leagueday_night
count171907171907171907171907171907140150
unique7148714872
topSatCHNNLCHNNLD
freq2889188708886690248886782724

可以看到在我们包含了近172000场比赛的数据集中,很多列只包含了少数几个唯一值。

我们先选择其中一个object列,开看看将其转换成类别类型会发生什么。这里我们选用第二列:day_of_week

我们从上表中可以看到,它只包含了7个唯一值。我们用.astype()方法将其转换为类别类型。

dow = gl_obj.day_of_week
print(dow.head())

dow_cat = dow.astype('category')
print(dow_cat.head())
0    Thu
1    Fri
2    Sat
3    Mon
4    Tue
Name: day_of_week, dtype: object
0    Thu
1    Fri
2    Sat
3    Mon
4    Tue
Name: day_of_week, dtype: category
Categories (7, object): [Fri, Mon, Sat, Sun, Thu, Tue, Wed]

可以看到,虽然列的类型改变了,但数据看上去好像没什么变化。我们来看看底层发生了什么。

下面的代码中,我们用Series.cat.codes属性来返回category类型用以表示每个值的整型数字。

dow_cat.head().cat.codes
0    4
1    0
2    2
3    1
4    5
dtype: int8

可以看到,每一个值都被赋值为一个整数,而且这一列在底层是int8类型。这一列没有任何缺失数据,但是如果有,category子类型会将缺失数据设为-1。

最后,我们来看看这一列在转换为category类型前后的内存使用量。

print(mem_usage(dow))
print(mem_usage(dow_cat))
9.84 MB
0.16 MB

内存用量从9.8兆降到0.16兆,近乎98%的降幅!注意这一特殊列可能代表了我们一个极好的例子——一个包含近172000个数据的列只有7个唯一值。

这样的话,我们把所有这种类型的列都转换成类别类型应该会很不错,但这里面也要权衡利弊。首要问题是转变为类别类型会丧失数值计算能力,在将类别类型转换成真实的数值类型前,我们不能对category列做算术运算,也不能使用诸如Series.min()Series.max()等方法。

对于唯一值数量少于50%的object列,我们应该坚持首先使用category类型。如果某一列全都是唯一值,category类型将会占用更多内存。这是因为这样做不仅要存储全部的原始字符串数据,还要存储整型类别标识。有关category类型的更多限制,参看pandas文档

下面我们写一个循环,对每一个object列进行迭代,检查其唯一值是否少于50%,如果是,则转换成类别类型。

converted_obj = pd.DataFrame()for colin gl_obj.columns:
    num_unique_values = len(gl_obj[col].unique())
    num_total_values = len(gl_obj[col])if num_unique_values / num_total_values <0.5:
        converted_obj.loc[:,col] = gl_obj[col].astype('category')else:
        converted_obj.loc[:,col] = gl_obj[col]

更之前一样进行比较:

print(mem_usage(gl_obj))
print(mem_usage(converted_obj))

compare_obj = pd.concat([gl_obj.dtypes,converted_obj.dtypes],axis=1)
compare_obj.columns = ['before','after']
compare_obj.apply(pd.Series.value_counts)
752.72 MB
51.67 MB
beforeafter
object78.0NaN
categoryNaN78.0

这本例中,所有的object列都被转换成了category类型,但其他数据集就不一定了,所以你最好还是得使用刚才的检查过程。

本例的亮点是内存用量从752.72兆降为51.667兆,降幅达93%。我们将其与我们dataframe的剩下部分合并,看看初始的861兆数据降到了多少。

optimized_gl[converted_obj.columns] = converted_obj

mem_usage(optimized_gl)
'103.64 MB'

耶,看来我们的进展还不错!我们还有一招可以做优化,如果你记得我们刚才那张类型表,会发现我们数据集第一列还可以用datetime类型来表示。

date = optimized_gl.date
print(mem_usage(date))
date.head()
0.66 MB
0    18710504
1    18710505
2    18710506
3    18710508
4    18710509
Name: date, dtype: uint32

你可能还记得这一列之前是作为整型读入的,并优化成了uint32。因此,将其转换成datetime会占用原来两倍的内存,因为datetime类型是64位比特的。将其转换为datetime的意义在于它可以便于我们进行时间序列分析。

转换使用pandas.to_datetime()函数,并使用format参数告之日期数据存储为YYYY-MM-DD格式。

optimized_gl['date'] = pd.to_datetime(date,format='%Y%m%d')

print(mem_usage(optimized_gl))
optimized_gl.date.head()
104.29 MB
0   1871-05-04
1   1871-05-05
2   1871-05-06
3   1871-05-08
4   1871-05-09
Name: date, dtype: datetime64[ns]

在数据读入的时候设定数据类型

目前为止,我们探索了一些方法,用来减少现有dataframe的内存占用。通过首先读入dataframe,再对其一步步进行内存优化,我们可以更好地了解这些优化方法能节省多少内存。然而,正如我们之前谈到,我们通常没有足够的内存去表达数据集中的所有数据。如果不能在一开始就创建dataframe,我们怎样才能应用内存节省技术呢?

幸运的是,我们可以在读入数据集的时候指定列的最优数据类型。pandas.read_csv()函数有一些参数可以做到这一点。dtype参数接受一个以列名(string型)为键字典、以Numpy类型对象为值的字典。

首先,我们将每一列的目标类型存储在以列名为键的字典中,开始前先删除日期列,因为它需要分开单独处理。

dtypes = optimized_gl.drop('date',axis=1).dtypes

dtypes_col = dtypes.index
dtypes_type = [i.namefor iin dtypes.values]

column_types = dict(zip(dtypes_col, dtypes_type))# rather than print all 161 items, we'll# sample 10 key/value pairs from the dict# and print it nicely using prettyprint

preview = first2pairs = {key:valuefor key,valuein list(column_types.items())[:10]}import pprint
pp = pp = pprint.PrettyPrinter(indent=4)
pp.pprint(preview)
{   'acquisition_info': 'category',
    'h_caught_stealing': 'float32',
    'h_player_1_name': 'category',
    'h_player_9_name': 'category',
    'v_assists': 'float32',
    'v_first_catcher_interference': 'float32',
    'v_grounded_into_double': 'float32',
    'v_player_1_id': 'category',
    'v_player_3_id': 'category',
    'v_player_5_id': 'category'}

现在我们使用这个字典,同时传入一些处理日期的参数,让日期以正确的格式读入。

read_and_optimized = pd.read_csv('game_logs.csv',dtype=column_types,parse_dates=['date'],infer_datetime_format=True)

print(mem_usage(read_and_optimized))
read_and_optimized.head()
104.28 MB
datenumber_of_gameday_of_weekv_namev_leaguev_game_numberh_name
01871-05-040ThuCL1na1FW1
11871-05-050FriBS1na1WS3
21871-05-060SatCL1na2RC1
31871-05-080MonCL1na3CH1
41871-05-090TueBS1na2TRO

通过对列的优化,我们是pandas的内存用量从861.6兆降到104.28兆,有效降低88%。

分析棒球比赛

现在我们有了优化后的数据,可以进行一些分析。我们先看看比赛日的分布情况。

optimized_gl['year'] = optimized_gl.date.dt.year
games_per_day = optimized_gl.pivot_table(index='year',columns='day_of_week',values='date',aggfunc=len)
games_per_day = games_per_day.divide(games_per_day.sum(axis=1),axis=0)

ax = games_per_day.plot(kind='area',stacked='true')
ax.legend(loc='upper right')
ax.set_ylim(0,1)
plt.show()

我们可以看到,1920年代之前,周日棒球赛很少是在周日的,随后半个世纪才逐渐增多。

我们也看到最后50年的比赛日分布变化相对比较平稳。

我们来看看比赛时长的逐年变化。

game_lengths = optimized_gl.pivot_table(index='year', values='length_minutes')
game_lengths.reset_index().plot.scatter('year','length_minutes')
plt.show()

看来棒球比赛时长从1940年代之后逐渐变长。

总结

我们学习了pandas如何存储不同的数据类型,并利用学到的知识将我们的pandas dataframe的内存用量降低了近90%,仅仅只用了一点简单的技巧:

  • 将数值型列降级到更高效的类型
  • 将字符串列转换为类别类型
  • 作者:wally21st
  • 原文链接:https://blog.csdn.net/wally21st/article/details/77688755
    更新时间:2022-07-15 10:48:50