相关文章推荐
千杯不醉的牙膏  ·  flask ...·  2 月前    · 
安静的梨子  ·  AWK - 孙愚 - 博客园·  1 年前    · 
唠叨的猴子  ·  C语言main()函数·  1 年前    · 
首发于 数据小哥
Pandas面对大数据集,如何将内存占用降低90% (附实例)

Pandas面对大数据集,如何将内存占用降低90% (附实例)

第一部分:无从下手的数据集

六个月前,我看到一个不错的“英语学习关键期”的数据集:

但是面对几百兆的文件,用Python的Pandas跑起来时,机器显得力不从心。于是,这块数据就暂时搁置在我的收藏夹里。


最近由于工作中的实际需要,对类似大小的文件做数据分析,成为了一个逃避不了任务。趁着周末搜了一些资料和方法,发现一篇不错的文章:

如何在使用Pandas库时,将内存占用降低90%。

该文在知乎上也有纯翻译的版本: 简单实用的pandas技巧:如何将内存占用降低90%

我自己结合了感兴趣的数据集,将该文章中的代码和方法又细细过了一遍。不仅学了一个新技能,对于Pandas存储数据类型又有了新的认识。


嘿喂狗。


先提一下,怎么才算大数据(Big Data)?

比较粗浅地分类:以100 MB为界限,大于此,我们就归为大数据。单个机器在跑的时候,速度通常会比较慢 — — 甚至会出现由于内存不足,而导致代码运行故障。

虽然有像Spark这样的工具,专门对付超大的数据集(从100 GB到TB级),但是要充分利用好,还需要配套较贵的硬件。况且,这种工具在清理、分析数据时没有Pandas那么好用。如果只是面对中等量的数据,不建议换装备。

以下文章的核心思想,是通过调整数据类型,来大幅度(90%)降低数据运行时对内存的占用。


先来看一下,我手上的数据集:

这是一项对英语学习做的研究。数据存储为csv文件(data.csv),320.4MB。

我们来看一下,

# 导入Python库;导入数据文件为 sll
import csv
import pandas as pd
import numpy as np
sll = pd.read_csv('...data.csv')
# 瞥一眼数据集的前5列
sll.head()
列数就有128,不少,列举其中比较重要的:

date 实验开始日期

time 实验开始时间

gender 性别

age 年龄

natlangs 测试者母语

dyslexia 测试者是否有阅读障碍?

education 最高教育水平

Eng_start 开始学习英语的年龄

Eng_country_yrs 在英语国家生活年数

countries 生活的国家

correct 题目正确比例

q1, q2, etc.: 题号, “1”回答正确;“0”回答错误

……

有兴趣的,可以在这里看到对所有行的注释:

具体的每一题长什么样,请见在线测试网址:

接下来,我们用 DataFrame.info ( ) 来对整个数据集的形态,数据类型,占据内存大小做一个宏观的了解:

默认情况下,为了节省时间,Pandas在这里会对内存占用做一个粗略估计,但是这正是我们关心的一个特征,所以将memory_usage 设置为“deep”,以得到精确的数值。

sll.info(memory_usage = 'deep')
这个数据文件有669498行,128列;数据类型分别为:数值型(110列),字符型或混合型(18列);内存占用为1.2GB


第二部分 Pandas存储数据的类型(开始动手!)

重头戏来了 — —

通过了解Pandas如何存储数据,我们来理解如何调整数据类型,从而降低内存的使用率。

对Python熟悉的读者会知道,数据主要存储为数值型和字符型。前者又分为 int 和 float两种。

以刚才提到的举例:

int型:年龄,是否阅读障碍(0或1),开始学习英语的年龄,等等……

float型:正确题数比例,参与者id,等等……

object型:性别,最高教育水平,生活国家,等等……

在Pandas中,由不同的Block来存储上述三种数据类型:IntBlock, FloatBlock 和 ObjectBlock —— 这样可以加速数据处理的效率。

既然不同的数据类型是独立存储的,那我们来看一下,现有的数据集三种类型的数据(int,float,object)平均占用内存是多少?

for dtype in ['float','int','object']:
    selected_dtype = sll.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))
很清楚地看到,object类型的平均占用量最大,但是int类型的列数相当多(104),亦不可小觑。

首先,来看一下如何调整数值型数据:Int和Float

正如上图所示,不同的数据类型包含各自的子类:

比如,一个int8数据占用1byte的内存。在二进制下,它可以代表256,这意味着可以用它来表示十进制下-128到127(包含 0)的数值。

我们用numpy.iinfo看一下不同int子类所能代表的数值范围:

int_types = ["uint8", "int8", "uint16","int16"]
for it in int_types:
    print(np.iinfo(it))

我们发现,uint(unsigned integers) 和 int(singed integers)所能覆盖的数值范围是一样的,但是当我们的数值只有正数时,uint类型更有效率。

通过以上知识,我们先对int数据进行调整,看看调整之后的内存占用量有无明显下降:

# 写个function,因为后面还可以重复用到。
def mem_usage(pandas_obj):
    if isinstance(pandas_obj,pd.DataFrame):
        usage_b = pandas_obj.memory_usage(deep=True).sum()
    else: # 我们假设这不是一个df,而是一个 Series
        usage_b = pandas_obj.memory_usage(deep=True)
    usage_mb = usage_b / 1024 ** 2 # 将 bytes 转化成 megabytes
    return "{:03.2f} MB".format(usage_mb)
sll_int = sll.select_dtypes(include=['int']) # 用 DataFrame.select_dtypes 来选中表中的 int数据
converted_int = sll_int.apply(pd.to_numeric,downcast='unsigned') # 用pd.to_numeric()来降低我们的数据类型
print(mem_usage(sll_int))
print(mem_usage(converted_int))
compare_ints = pd.concat([sll_int.dtypes,converted_int.dtypes],axis=1)
compare_ints.columns = ['before','after']
compare_ints.apply(pd.Series.value_counts)
我们可以看到内存占用量从531.22MB 降到了 68.32MB!降了将近87%。

接下来,将刚才的方法应用到float类数据:

sll_float = sll.select_dtypes(include=['float'])
converted_float = sll_float.apply(pd.to_numeric,downcast='float')
print(mem_usage(sll_float))
print(mem_usage(converted_float))
compare_floats = pd.concat([sll_float.dtypes,converted_float.dtypes],axis=1)
compare_floats.columns = ['before','after']
compare_floats.apply(pd.Series.value_counts)
(点击查看大图)所有的float64全部变成了float32,减少了将近一半的内存占用


下面,我们把调整完的数据复制,替换原先表中的位置。

看看整体内存占用下降了多少:

optimized_sll = sll.copy()
optimized_sll[converted_int.columns] = converted_int
optimized_sll[converted_float.columns] = converted_float
print(mem_usage(sll))
print(mem_usage(optimized_sll))
(点击查看大图)从1200.64 MB 降到了 722.41MB

真不错!不过降低的比例不到50% — — 那是因为我们还没对object类数据动手呢!


在此之前,我们先看看Pandas是如何存储Object数据的:

Python对字符串的存储方式很碎片化,从而会占用更多内存,访问速度也更慢。

下面这幅图给出了以 NumPy 数据类型存储数值数据和使用 Python 内置类型存储字符串数据的方式:

解决思路:在之前的表格中,你可能已经注意到,和数值类数据不同, object 类型的内存使用是可变的。尽管每个指针仅占用 1 字节的内存,但如果每个字符串在 Python 中都是单独存储的,那就会占用实际字符串那么大的空间。我们可以使用 sys.getsizeof() 函数来证明这一点。

from sys import getsizeof
s1 = 'working out'
s2 = 'memory usage for'
s3 = 'strings in python is fun!'
s4 = 'strings in python is fun!'
for s in [s1, s2, s3, s4]:
    print(getsizeof(s))

上面的四个数是字符串 s1, s2, s3, s4 占用的内存。我们再看,

obj_series = pd.Series(['working out',
                          'memory usage for',
                          'strings in python is fun!',
                          'strings in python is fun!'])
obj_series.apply(getsizeof)

可以看到,存储在 pandas series 时,字符串的大小与用 Python 单独存储的字符串的大小是一样的。

解决方法:

pandas 在 0.15 版引入了 Categorials。category 类型在底层使用了int值来表示一个列中的值,而不是使用原始值。pandas 使用一个单独的映射词典将这些int值映射到原始值。只要当一个列包含有限的值的集合时,这种方法就很有用。当我们将一列转换成 category dtype 时,pandas 就使用最节省空间的 int 子类型来表示该列中的所有不同值。

(点击查看大图)int值来表示一个列中的值,而不是使用原始值

这个方法是否适用于我们手上的数据,要看各个object列内,独立(unique)值的比例如何。

sll_obj = sll.select_dtypes(include=['object']).copy()
sll_obj.describe()
(部分数据)大量的列,只有极小的unique值。举例:在gender(性别)列的669498条数据里,只有3个unique值。(点击看大图)

在我们将所有object数据都做调整之前,先挑“gender”列试一下,看看内存改善的效果如何:

我们用.astype()方法将gender列中的数据转换成category。

gd = sll_obj.gender
print(gd.head())
gd_cat = gd.astype('category')
print(gd_cat.head())

你会发现,数据类型变了,但是数值似乎没有任何变化。我们接着用Series.cat.codes看看category用什么样的int类数值来代表原先的object数据:

gd_cat.head().cat.codes
原先的“male”变成了int类数据“1”,底层的数据类型为int8。

顺便说一句,如果这一列中有空缺值,category会将其处理为-1。

最后,激动人心的时间到了,我们来看看这种调整能降低多少内存空间的占用。

print(mem_usage(gd))
print(mem_usage(gd_cat))
(点击看大图)从39.63MB 降低到了 0.64MB!降低了整整98%!

不过,别太得意,该列近66万条数据只有3个值,所以转化后的效果非常漂亮。但是,假设一种极端情况,某一列的所有数值都是独立的,那么如果再转化成category,占用的内存空间反而会更大。这是因为除了原始数据之外,计算机还需要存储int类数据。关于category的局限性,可以看它的文档:


综上,我们遍历所有的object列,如果某一列的unique值少于总数值量的50%,我们就将该列转化为category:

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


print(mem_usage(sll_obj))
print(mem_usage(converted_obj))
compare_obj = pd.concat([sll_obj.dtypes,converted_obj.dtypes],axis=1)