Python中的pandas性能提升
(实际工作中,将 >25000 个文件平均大小为 1.2G 的数据 推理时间由 72h 降低到了 4h 以内,速度提升了18倍;统计结果:大批量细小文件多参数统计的时间由 15h 降低到了3h以内。精度无损。)
在无法增多服务器,或者只有本地pc的情况下;想要加快python的处理性能,有以下几个方面可以考虑:
一,节约内存
1,工具
① 实时内存
如果需要知道执行脚本运行时的实时内存和IO等使用情况,参考以下方案。
使用linux中的top、iostat可以展示代码运行时的内存和磁盘等使用情况。
top命令显示处理器的活动状况。缺省情况下,显示占用CPU最多的任务,并且每隔5秒钟做一次刷新。使用iostat 命令展示的显示CPU占用情况和显示磁盘使用含义为下表所示:
%user | 显示user level (applications)时,CPU的占用情况 |
---|---|
%nice | 显示user level在nice priority时,CPU的占用情况 |
%nice | 显示user level在nice priority时,CPU的占用情况 |
%sys | 显示system level (kernel)时,CPU的占用情况 |
%idle | 显示CPU空闲时间所占比例 |
Device | 块设备的名字 |
---|---|
tps | 该设备每秒 I/O 传输的次数 |
%nice | 显示user level在nice priority时,CPU的占用情况 |
Blk read/s Blk wrtn/s | 该设备每秒读写的数据块数量 |
更多其他命令可以参考: https://blog.51cto.com/zener/324981
②,变量内存
如果需要知道执行脚本内部各变量的内存使用情况,参考以下方案。
objgraph 包可以展示当前内存中存在的对象, 使用方法:
https://www.oschina.net/translate/python-memory-issues-tips-and-tricks
psutil 包可以实时地查看当前某个变量所占用的内存空间。
objgraph和psutil可以找出当前代码运行中是否存在循环引用!一定要手动去掉循环引用,否则会造成内存泄露!无法触发python的垃圾回收机制。而尤其在处理多文件内容时,如果有大量循环引用的存在,内存在代码运行未结束之前一直无法释放,会导致内存一直增加,最后占满,导致宕机。
③,执行过程整体内存使用记录
优化内存后,使用memory_profiler 检测内存随着时间的膨胀变化,并检验最终内存最高达多少。首先安装memory_profiler和 psutil (主要用于提高memory_profile的性能,建议安装)。
conda install -c conda-forge memory_profiler
conda install -c conda-forge psutil
mprof run test_memory_look.py <# 生成 .dat文件 #>
mprof plot <# 根据最新生成的 .dat 展示内存过程变化图片#>
mprof clean <# 清除所有的 .dat 文件 #>
效果如下图所示:
优化后,内存最高占用由>17500M降低到了>15000 M。由图可以看出,优化前的内存增大趋势为持续稳定增长,优化后是瞬时增大后迅速回落。而且,整体运行时间也变短了。
2,方法
①读取文件时
A 只读取需要的部分
比如在读取 csv 文件时,使用 usecols 参数指定列,可以大大减少内存消耗。
B 读取函数参数设置
最好不要将 read_csv() 的参数 low_memory 设置为 False,否则会一次性读入所有数据,非常消耗内存
pandas 读入 csv 时,对于 csv 的列数据格式识别可以参照此博客 https://zhuanlan.zhihu.com/p/34420427 也就是说,pandas 是以一列中大部分的数据可能的格式作为一列的总格式。
如果设置 low_memory=True,一般再输出的时候会遇到告警信息:
DtypeWarning: Columns (1,5,7,16,…) have mixed types. Specify dtype option on import or set low_memory=False.
意思就是:列 1,5,7,16… 的数据类型不一样。调试进去发现 pandas 在读取的时候确实把同一列数据中同一个数值识别为不同的类型,比如:2000 行第 3 列值为 0 的数据识别为 int 类型,而在 4000 行第 3 列值为 0 的数据识别为 str 类型。但如果 low_memory=False,最终很可能爆内存。所以不推荐设置为 False。
即使是混合类型,也可以在后来按列转换,经过实验,对最终精度造成的影响几乎可以忽略不计。
C 读取完数据之后,将数据尽量保存为 float 和 int 格式。
尤其对于被读进来,却识别为object列要重点关注,需要清洗数据,然后转为float/int/str。可以使用 pandas.to_numeric 函数。
对于实在无法处理的 object 类型,如果列中不同值的数量少于50%,可以转换为 category 类型。但是如果列中的所有值都是不同的,那么 category 类型所使用的内存将会更多。
对于我司的大部分数据来说,一般将其整列转换为 datetime/float/int/str 类型就已经足够用了。
②计算中
A 不用的临时变量及时删掉
B 抽取函数
这一步主要是为了减少临时变量的使用,函数返回后临时申请的内存都会被释放掉,这点和C语言没有太大区别。
但是如果抽取为函数,那么长期不用的变量会触发python的垃圾回收机制,可以将内存及时回收。
详情可以移步至:https://zhuanlan.zhihu.com/p/83251959,此链接里面也说明了及时删除不用的变量的重要性!
这里需要注意的是: 一般我们使用DataFrame删除列的时候,delete会改变DataFrame的存储空间,但是drop函数在丢弃指定项时返回的是视图,并不会改变DataFrame本身的存储空间。因此建议使用delete 或者指定 inplace=True。
# 不推荐
dataframe = dataframe.drop(['a_col', 'b_col'], axis=1)
# 推荐
del dataframe['a_col']
del dataframe['b_col']
dataframe.drop(['a_col', 'b_col'], axis=1, inplace=True)
C 传递参数只传递尽量小的部分
D 不要return很大的变量
常用函数都有这个参数设置:尽量使用 inplace=True 在指定的原始对象上进行修改!否则会进行很多不必要的拷贝操作,占用内存巨大!!
# 不推荐
df = df.applymap(lambda x: np.nan if x in ('dropped_fe', 'dropped_ff') else x)
df = df.dropnan(axix = 0)
# 推荐
df.replace('dropped_fe', np.nan, inplace = True)
df.replace('dropped_ff', np.nan, inplace = True)
df.dropnan(axis = 0, inplace = True)
③保存文件时
A 只保存增加的部分
比如保存到 csv 时,可以使用 to_csv(mode=‘a’) 模式按需追加行、追加列
B 保存成不会损失格式的文件格式
比如使用 hdfstore包存储为 .hdf5 , 用空间换取时间,读写速度非常快。
二,加快速度
1,工具
使用日志和时间包打印过程时间,选取瓶颈期进行优化。例如cProfile包等
关于 多进程与多线程:https://www.cnblogs.com/yuanchenqi/articles/6755717.html
关于 CPU密集型与IO密集型: https://developer.aliyun.com/article/439231
如何知道自己的程序是CPU密集还是IO密集?有一个简单方法,打印出CPU计算时间和IO等待时间:
如果IO等待时间 >> CPU计算时间,就是IO密集。
如果CPU计算时间 >> IO等待时间,就是CPU密集。
什么是CPU计算时间和IO等待时间?
CPU计算消耗比如:DataFrame列计算、对视频进行解码、图片处理等。
IO等待比如:网络请求、磁盘读写等。
2,方法
① numpy向量化
python 属于一种解释性语言,相比于 c 写的 numpy 来说,速度非常慢。大批量的计算最好使用 numpy 工具进行向量化计算。或者直接使用cpython。
除了尽量完全使用 numpy 替换 pandas 外, 还有以下一些 tricks 可以使用:
A 尽量使用内置函数、第三方包中的函数而不是自定义函数
python 将复杂的数据结构隐藏在内置函数中,用c语言来实现,所以只要写出自己的业务逻辑,python会自动得出想要的结果,比普通的python实现速度要快1倍左右。
python内置函数: https://www.runoob.com/python/python-built-in-functions.html
再比如使用pandas时,当你想要替换DataFrame中的某些字符串时,请不要使用applymap函数进行全局替换。可以使用replace。
# 不推荐
df = df.applymap(lambda x: np.nan if x in ('dropped_fe', 'dropped_ff') else x)
df = df.dropnan(axix = 0)
# 推荐
df.replace('dropped_fe', np.nan, inplace = True)
df.replace('dropped_ff', np.nan, inplace = True)
df.dropnan(axis = 0, inplace = True)
B 使用 np.vectorize 调用函数
而尽量不使用迭代器 iterrows、for 循环、apply、applymap、lambda等
def myfunc(a, b):
if a > b:
return a - b
else:
return a + b
if __name__ == "__main__":
label = [1, 2, 3, 4]
label = (np.vectorize(myfunc))(label, 2)
print(label)
# 输出结果为:
# [3, 0, 1, 2]
C 将某列转换为矩阵计算
可以使用as_matrix(), np.array(), .values等。
# 不推荐
df['hello'] = df['xishu'] * df['danjia']
# 推荐
df['hello'] = df['xishu'].values * df['danjia'].values
D 转换格式使用apply还是astype?
使用apply和astype都可以将数据中的某列转换为想要的格式。它们的速度与怎么去使用相关。
如果转换为str则apply更快一些,如果是转换为float等则astype更快一些。原因就在于 str 是 python 的一个内置函数。详情为什么会发生这种现象可以参照这篇博客: https://www.cnpython.com/qa/228803
② 多进程 + zip
进程是系统进行资源分配和运行调用的独立单位,可以理解为操作系统中正在执行的一个程序;多进程共享物理内存、磁盘、打印机以及其他资源。使用多进程方法可以大大加快 cpu密集型任务。
可以使用 multiprocessing 包:
def call_func(x):
print(x * x)
if __name__ == '__main__':
from multiprocessing import Pool
with Pool(10) as p:
p.map(call_func, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
# 输出结果为1-10的平方:1,4,9,16,25,36,49,64,81,100
也可以使用 concurrent.futures 包:
def call_func(x):
return x * x
if __name__ == '__main__':
from concurrent.futures import ProcessPoolExecutor
x_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
with ProcessPoolExecutor(max_workers=10) as executor:
for one_x, res in zip(x_list, executor.map(call_func, x_list)):
print("one_x: {} one_x * one_x: {} ".format(one_x, res))
# 输出结果为1-10的平方:1,4,9,16,25,36,49,64,91,100
multiprocessing 和 concurrent.futures 在使用中没有本质上的区别,只是接口和使用方法不同,速度相当。简单的并发应用可以使用 concurrent.futures ,复杂的需要使用 multiprocessing。
在遇到大量的图像文件、数据文件、表格文件等等需要处理时,可以首先获取路径下的所有文件:
# 获取仅文件名
car_path_list = [f for f in os.listdir(path) if \
os.path.isfile(os.path.join(path, f))]
# 获取全路径名称
car_path_list = [os.path.join(path, f) for f in car_path_list]
再使用多进程处理。也就是将 x_list 替换为 car_path_list,再将 call_func 替换为针对每个路径需要进行的处理函数:读取文件,进行某些操作等等。
使用 zip 的一个好处是可以使用解压的方式传入多个参数,以使用 mutiprocessing 为例进行一个简单的乘法:
def call_func(first_number, second_number):
print(first_number, second_number)
def job(args):
return call_func(*args)
if __name__ == "__main__":
import multiprocessing
first_list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
second_list = [5, 5, 5, 5, 5, 5, 5, 5, 5, 5,
10, 10, 10, 10, 10, 10, 10, 10, 10, 10,
15, 15, 15, 15, 15, 15, 15, 15, 15, 15]
assert len(first_list) == len(second_list)
zip_args = list(zip(first_list, second_list))
with multiprocessing.Pool(10) as p:
p.map(job, zip_args)
# 输出为first_list和second_list每项的乘积
当输入为 3 个或者更多参数时,只需要在 zip 函数里面继续打包即可。concurrent.futures 的使用同理。这种情况适用于需要进行大批量的数据计算时,被并行的任务有很多个设定参数的情况。
③ 多线程
线程是一个基本的 cpu 执行单元,必须依存于进程。多线程共享同个地址空间、打开的文件以及其他资源。使用多线程可以大大加快 IO密集型任务。
多进程用到的包都包装了多线程模块:
from multiprocessing.dummy import Pool as ThreadPool
from concurrent.futures import ThreadPoolExecuto
④ 进程或线程数量
python 的多线程只能运行在单核上;多进程可以利用 CPU 的多核。
进程数取决于 CPU 的处理器个数,但是在大数据计算中,事实是往往要小于 CPU 核数,内存往往是瓶颈。在使用多进程的过程中,要注意的最大问题就是必须要尽可能地降低内存,否则机器会在中途断掉,得不偿失。
线程数取决于业务的需求,如果是网络类请求,大部分时间在等待中,那么线程数可以高达 1000。大部分本地的计算 IO 往往是磁盘的读写,这部分 IO 时间往往不会占用太高的百分比,也可以使用改变存储方法加快。
线程的数量制定有个法则:https://blog.csdn.net/xlgen157387/article/details/90738491
因此多进程是常用方法
⑤ 多进程 + 多进程
这种使用方法类似于在第一层计算并行基础上,加深一层计算并行。一般是在父进程调用的函数里面,如上图的call_func里面再增加一个进程并行,子进程的使用方法和第一层一模一样。
值得注意的是,python3中multiprocessing的Process创建子进程在windows和Linux下是有区别的。windows是全代码重头运行,无法共享父进程全局变量,全局变量会重新建立,linux是按照规矩仅仅从函数段运行。windows下使用多进程,将全局变量作为参数传递,可以解决由于代码执行控制造成的子进程和父进程无法共享一个对象的问题。
父进程和子进程的数量要根据业务的情况而定;太多则会增加进程切换时间,太少则会无法充分利用CPU。
这种使用方式将大批量的结果文件统计时间由 10h 减少到了 3h 以内。
# 如果当前代码块换行没有自动对齐;点击代码块右上角的<>可以查看格式完好的代码
from concurrent.futures import ProcessPoolExecutor
def multi_threshold_cal_func(in_path, threshold, out_path):
# 在此函数中可以对文件进行读取,根据输入参数threshold进行计算,然后保存
if successful:
return 1
else:
return 0
# 如果成功返回1,如果失败返回0;作为一个状态记录返回给父进程
def job(args):
# 解引用
return multi_threshold_cal_func(*args)
def call_func(threshold):
path = "D:/data/input_files/"
out_path = "D:/data/output_files/"
inpath_file_list = [f for f in os.listdir(path) if os.path.isfile(os.path.join(path, f))]
inpath_file_list = [os.path.join(path, f) for f in inpath_file_list]
file_num = len(inpath_file_list)
threshold_sub_list = [threshold] * file_num
out_path_sub_list = [out_path] * file_num
# 因为有多个参数进行传递,所以使用zip进行打包,可以传递更多
zip_args = list(zip(inpath_file_list, threshold_sub_list, out_path_sub_list))
file_count = 0
# 开辟子进程
with ProcessPoolExecutor(max_workers = 5) as subexecutor:
for one_zip_args, subres in zip(zip_args, subexecutor.map(job, zip_args)):
file_count += subres
print("successful file count: {}".format(file_count))
if __name__ == '__main__':
threshold_list = [200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100]
with ProcessPoolExecutor(max_workers=10) as executor:
for one_threshold, res in zip(threshold_list, executor.map(call_func, threshold_list)):
print("successful threshold: {}".format(one_threshold))
⑥多进程 + 多线程
这种方式适用于在第一层计算并行基础上,加深一层IO并行。一般是在父进程调用的函数里面,如上图的call_func里面再增加一个线程并行,多线程的使用和第一层一模一样。
进程和线程的数量也要视业务的情况而定。
值得注意的是,如果不是时间非常多的IO等待,一般不会用到多线程。
⑦注意其他函数使用方式
A 时间转换格式指定 ISO 8601 格式
如转换时间时:
import pandas as pd
df['time'] = pd.to_datetime(df['time'], format='%Y/%m%d %H:%M:%S')
to_datetime函数耗时并不长,但是函数strftime函数却很耗时,我们看看这个函数里面到底做了些什么:
def strftime(self, fmt):
"Format using strftime()."
return _wrap_strftime(self, fmt, self.timetuple())
# Correctly substitute for %z and %Z escapes in strftime formats.
def _wrap_strftime(object, format, timetuple):
# Don't call utcoffset() or tzname() unless actually needed.
freplace = None # the string to use for %f
zreplace = None # the string to use for %z
Zreplace = None # the string to use for %Z
# Scan format for %z and %Z escapes, replacing as needed.
newformat = []
push = newformat.append
i, n = 0, len(format)
while i < n:
ch = format[i]
i += 1
if ch == '%':
if i < n:
ch = format[i]
i += 1
if ch == 'f':
if freplace is None:
freplace = '%06d' % getattr(object,
'microsecond', 0)
newformat.append(freplace)
elif ch == 'z':
if zreplace is None:
zreplace = ""
if hasattr(object, "utcoffset"):
offset = object.utcoffset()
if offset is not None:
sign = '+'
if offset.days < 0:
offset = -offset
sign = '-'
h, rest = divmod(offset, timedelta(hours=1))
m, rest = divmod(rest, timedelta(minutes=1))
s = rest.seconds
u = offset.microseconds
if u:
zreplace = '%c%02d%02d%02d.%06d' % (sign, h, m, s, u)
elif s:
zreplace = '%c%02d%02d%02d' % (sign, h, m, s)
else:
zreplace = '%c%02d%02d' % (sign, h, m)
assert '%' not in zreplace
newformat.append(zreplace)
elif ch == 'Z':
if Zreplace is None:
Zreplace = ""
if hasattr(object, "tzname"):
s = object.tzname()
if s is not None:
# strftime is going to have at this: escape %
Zreplace = s.replace('%', '%%')
newformat.append(Zreplace)
else:
push('%')
push(ch)
else:
push('%')
else:
push(ch)
newformat = "".join(newformat)
return _time.strftime(newformat, timetuple)
可以看出这个函数里面用到了一个while循环和许多的if语句来匹配 %Y 和 %m。如果要转换格式,最好不要直接使用这个函数,可以先将年和月切分开,然后再合并起来。
# 不推荐
df['year_month'] = df['total_time'].apply(lambda x: x.strftime('%Y-%m'))
# 不推荐
df['year_month'] = df['total_time'].dt.strftime('%Y-%m')
# 推荐
df['month'] = df['total_time'].dt.month.astype(str)
df['year'] = df['total_time'].dt.year.astype(str)
df['year_month'] = pd.to_datetime(df['year'].str.cat(df['month'], sep='-'), format='%Y-%m')
在一个1229009行的时间序列上实验,转换时间减少为了原来的1/3,由5.8秒减少为了1.6秒。在 >2500 个文件上面实验,统计时间由 15h 降低为了10h。
B 条件If 语句中对于前序判断的值应该有更大的false概率
if a > 0 and b > 0 and c > 0:
# do something
在这样的条件语句中,a > 0 为 false 的概率应该更大,这样就不会进行后面的判断了。
C 路径拼接使用 .join(),打印使用 .format()而不是 +
if __name__ == "__main__":
total_pro_num = 900
print("total_pro_num: {}".format(total_pro_num))
D 避免类属性访问
如 self.value 访问速度不如局部变量,如果频繁使用,可以将 class 属性分配给局部变量以加快速度。
E 局部变量 替换 全局变量
全局变量访问速度不如局部变量。在使用机器学习和深度学习算法中,特征工程占绝大部分代码量。如果全局变量太多,也会导致访问变慢。
如果替换为局部变量后,害怕自己不能及时去修改局部变量,可以在局部变量前加上注释 #TODO, pycharm 可以提醒整个工程或者当前文件中有多少 #TODO,可以直接点击进去到需要修改的地方。
F 使用 pycham/vscode 进行 import 优化。
不用的包不加载。pycharm: 右键在不用变灰的包上->ShowContext Actions->Optimize imports,就可以对整个文件的imports进行优化:删掉不用的包,并且排序
vscode:右键在不用变灰的包上->SourceAction…->Sort imports,就可以对整个文件的imports进行优化:删掉不用的包,并且排序
G 慎用 merge!
merge操作非常耗时,在非必要情况下尽量不要对很大的 df 序列使用