非常好的一篇文章,解决问题的方式和思路层层递进,透彻深刻。
Pandas是个好工具,好工具要用正确高效的方式使用,才能发挥出万钧之力。
英文水平较高可直接阅读原文。Fast, Flexible, Easy and Intuitive: How to Speed Up Your pandas Projects – Real Python
逛了一下,这个原版国外网站内容很丰富,挺不错。
这里有个 Data Science 合集,点击可进入看更多文章:Python Data Science – Real Python
其核心观点是使用Pandas的矢量化操作(也可翻译为:向量化计算)特性,而非使用极其原始的for循环,可大幅提升Pandas操作数据的性能。
本博客翻译如下:(意译为主,省略废话,代码全部保留)
前言
本文是一个使用Pandas的指南,以充分利用其强大且易于使用的内置功能。此外,您将学习一些实用的加快处理的技巧。
Python风格代码可能不是最有效率的。和NumPy库一样,pandas被设计用来进行向量化操作,一次处理整列或者一整个数据集。不要再最开始的时候,就考虑如何处理每一个单元格或每一行,而应该再试过其他全部方法之后。
本文主要涉及以下三个内容:
-
使用 datetime 类型处理时间序列的优势
-
批量计算的最有效途径
-
使用HDFStore存储数据来节省时间
使用Pandas,有很多种方法可以实现从A到B,但是并不是所有方法都能高效的扩大至更大的数据量。
阅读本文的前提是需熟练掌握Pandas库的数据选择和切片等操作。(译者注:若对某些Pandas方法不熟,可使用Kimi.ai)
案例
这个例子的目标是应用分时电价来计算一年的能源消耗总成本。也就是说,在一天中的不同时间,电价是不同的,所以任务是将每小时消耗的电量乘以消耗时的正确价格。
从CSV文件中读取数据,A列是时间,B列是耗电量(度)。
每一行包含每个小时使用的电量,因此全年有365 x 24=8760行。每行表示当时“起始小时”的使用情况,因此1/1/13 0:00表示2013年1月1日第一个小时的使用情况。
使用Datetime类型加速
>>> import pandas as pd
>>> pd.__version__
'0.23.1'
# Make sure that `demand_profile.csv` is in your
# current working directory.
>>> df = pd.read_csv('demand_profile.csv')
>>> df.head()
date_time energy_kwh
0 1/1/13 0:00 0.586
1 1/1/13 1:00 0.580
2 1/1/13 2:00 0.572
3 1/1/13 3:00 0.596
4 1/1/13 4:00 0.592
初看没有问题,但其实有个小毛病。Pandas和Numpy有数据类型(dtypes)的概念。如果没有指定类型, date_time 会使用 object 类型:
>>> df.dtypes
date_time object
energy_kwh float64
dtype: object
>>> type(df.iat[0, 0])
str
这不是最佳选择。 object 不仅是 str 类型的容器,任何没有合适数据类型的列都会被存放到 object 中。将日期数据作为字符串处理是非常低效的。(同时也很消耗内存)。
处理时间序列数据,更好的方法是将 date_time 列格式化为 datetime 对象的数组。(Pandas中称之为 Timestamp。)Pandas处理的更加简洁:
>>> df['date_time'] = pd.to_datetime(df['date_time'])
>>> df['date_time'].dtype
datetime64[ns]
(注意:本例中你还可以使用 Pandas 的 PeriodIndex 索引。)
df 如下:
>>> df.head()
date_time energy_kwh
0 2013-01-01 00:00:00 0.586
1 2013-01-01 01:00:00 0.580
2 2013-01-01 02:00:00 0.572
3 2013-01-01 03:00:00 0.596
4 2013-01-01 04:00:00 0.592
如何测试代码耗时?可以使用一个 timing decorator,这里我们称之为 @timeit。这个 decorator 最大程度的模仿了 Python 标准库中的 timeit.repeat() ,但是它能返回函数执行结果,并打印多次试验的平均运行时长。(Python标准库的 timeit.repeat() 只能返回时间结果,而不含函数结果)。
>>> @timeit(repeat=3, number=10)
... def convert(df, column_name):
... return pd.to_datetime(df[column_name])
>>> # Read in again so that we have `object` dtype to start
>>> df['date_time'] = convert(df, 'date_time')
Best of 3 trials with 10 function calls per trial:
Function `convert` ran in average of 1.610 seconds.
8760行数据耗费1.6秒。还不错,但是如果要处理更大的数据量,比如数据采集频率加快到每分钟采集一次数据,那么此时的全年用电量数据,将是现在数据量的60倍多,这个代码将运行1分钟30秒。
实际上,我最近分析了来自330个网站的共10年的每小时电量数据。如果只是用来转换时间数据的类型,就要等待88分钟,那难以想象!
那该如何加速呢?只需使用 format 参数告诉 Pandas 你的时间数据的具体格式即可。
>>> @timeit(repeat=3, number=100)
>>> def convert_with_format(df, column_name):
... return pd.to_datetime(df[column_name],
... format='%d/%m/%y %H:%M')
Best of 3 trials with 100 function calls per trial:
Function `convert_with_format` ran in average of 0.032 seconds.
现在的耗时是0.032秒,效率是前面的50倍。如果你要从330个网页中处理数据,你就能节省86分钟,很大的进步。
还有一点需要说明,CSV中的时间格式不是 ISO 8601 格式:YYYY-MM-DD HH:MM。如果你不指定格式,Pandas 会使用 dateutil 包将字符串转为日期。
相反,如果时间数据已经是 ISO 8601 格式,Pandas 可以立即解析为日期。这就是为什么格式化时间后效率大幅提升的一个原因。另一个方式是传递 infer_datetime_format = True 参数。
注意:Pandas 的read_csv()允许在读写文件的同时解析日期。请参阅parse_dates、infer_datetime_format和date_parser参数。
简化对Pandas数据的循环
现在日期和时间已经转成正确的格式,现在可以开始计算电费了。请记住,电费因小时而异,因此您需要根据每个小时的不同电费单价计算总电费。在本例中,每小时的电费定义如下:
电费类型 | 美分/度 | 时间段 |
---|---|---|
高峰 | 28 | 17:00-24:00 |
平时 | 20 | 7:00-17:00 |
低谷 | 12 | 0:00-7:00 |
如果电价永远都是 28 美分/度,则代码就很简单:
>>> df['cost_cents'] = df['energy_kwh'] * 28
这样 df 中会产生一个新的列,代表每个小时的电费:
date_time energy_kwh cost_cents
0 2013-01-01 00:00:00 0.586 16.408
1 2013-01-01 01:00:00 0.580 16.240
2 2013-01-01 02:00:00 0.572 16.016
3 2013-01-01 03:00:00 0.596 16.688
4 2013-01-01 04:00:00 0.592 16.576
# ...
但我们要计算的电费因时间段不同而单价不同。我们会看到大多数人会这么思考如何写这段代码:写一个循环针对不同时间分别计算。
本文的后面,我们将从一个简陋的方案直到一个最能发挥 Pandas 特性的方案。
先看看循环方法,循环对于不熟悉 Pandas 设计原则的初学者时最喜欢的方案。
写一个普通的循环方法。
def apply_tariff(kwh, hour):
"""Calculates cost of electricity for given hour."""
if 0 <= hour < 7:
rate = 12
elif 7 <= hour < 17:
rate = 20
elif 17 <= hour < 24:
rate = 28
else:
raise ValueError(f'Invalid hour: {hour}')
return rate * kwh
应用到 df 中:
>>> # NOTE: Don't do this!
>>> @timeit(repeat=3, number=100)
... def apply_tariff_loop(df):
... """Calculate costs in loop. Modifies `df` inplace."""
... energy_cost_list = []
... for i in range(len(df)):
... # Get electricity used and hour of day
... energy_used = df.iloc[i]['energy_kwh']
... hour = df.iloc[i]['date_time'].hour
... energy_cost = apply_tariff(energy_used, hour)
... energy_cost_list.append(energy_cost)
... df['cost_cents'] = energy_cost_list
...
>>> apply_tariff_loop(df)
Best of 3 trials with 100 function calls per trial:
Function `apply_tariff_loop` ran in average of 3.152 seconds.
上面这段代码看着似乎没有大问题,但是这个循环显得很呆。也可以说时反模式的,反“Pandas”的模式。它有几个问题。
首先,它需要初始化一个列表,这个列表用来存储结果。
其次,它使用不准确(opaque)的对象 range(0,len(df)) 来作为循环计算,然后在应用apply_tariff() 之后,它必须将结果附加到用于创建新DataFrame列的列表中。它还使用df.iloc[i]['date_time']进行所谓的链式索引,这通常会导致意想不到的结果。
(译者注:在循环遍历 df 的时候,又修改 df ,可能导致意外错误)
但是最大的问题还是费时,8760行数据花费了3秒钟。下面看改进后的方法。
使用itertuples() 和 iterrows()方法循环
未完待续。