【Python】为Pandas加速(适合Pandas中级开发者)

非常好的一篇文章,解决问题的方式和思路层层递进,透彻深刻。

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()方法循环

    未完待续。

    作者:qilei2010

    物联沃分享整理
    物联沃-IOTWORD物联网 » 【Python】为Pandas加速(适合Pandas中级开发者)

    发表回复