进阶:自定义扩展Pandas (Pandas Extending)

Pandas Extending

本博客是对Pandas Extending中文翻译。原文地址如下link。主要介绍如何自定义扩展Pandas库,包括如何对Pandas主要类DataFrame, Series自义定接口;如何自定义扩展DataFrame, Series基本数据类型;在用户需要继承DataFrame, Series类时官方提出的建议。本博客除了翻译官方文本之外,还结合笔者正在进行扩展pandas的相关工作给出了粗浅的点评。

导言:你可能遇到的场景 - Why Extending Pandas

Pandas库提供了丰富的方法、容器和数据类型,但您的需求可能无法完全满足。Pandas提供了一些自定义扩展pandas的方法。
这些场景例如但不仅限于:

  1. 自定义接口 ,当遇到如下场景时可以考虑扩展接口,需要转换函数调用方式myfunction(pands_obj)为类方法调用方式pandas_obj.myfunction (Series.myfunction)时;
  2. 自定义数据类型,例如pandas_obj中需要自定义储存数据类型为IP地址类型ipaddress.IPv4Address,用户可以自定义这种基本类型格式,并且定义其操作方法;
  3. 继承pandas_obj,继承往往不是最佳的选择。但用户决定有必要这么做时,pandas提供了继承pandas_obj类的建议;

自定义接口 - Registering custom accessors

可以使用的装饰类库pandas.api.extensions.register_dataframe_accessor(), pandas.api.extensions.register_series_accessor()pandas.api.extensions.register_index_accessor()将其他“命名空间”添加到pandas对象。这些方法思路为:使用装饰器对类进行装饰,在装饰器添加的属性的名称。具体为,装饰类的__init__方法获取要修饰的对象,例如如下例子给出自定义的经纬度类接口:

@pd.api.extensions.register_dataframe_accessor("geo")
class GeoAccessor:
    def __init__(self, pandas_obj):
        self._validate(pandas_obj)
        self._obj = pandas_obj

    @staticmethod
    def _validate(obj):
        # verify there is a column latitude and a column longitude
        if 'latitude' not in obj.columns or 'longitude' not in obj.columns:
            raise AttributeError("Must have 'latitude' and 'longitude'.")

    @property
    def center(self):
        # return the geographic center point of this DataFrame
        lat = self._obj.latitude
        lon = self._obj.longitude
        return (float(lon.mean()), float(lat.mean()))

    def plot(self):
        # plot this array's data on a map, e.g., using Cartopy
        pass

用户调用自定义的命名空间:

ds = pd.DataFrame({'longitude': np.linspace(0, 10),
...                    'latitude': np.linspace(0, 20)})
>>> ds.geo.center
(5.0, 10.0)
>>> ds.geo.plot()
# plots data on a map

·点评

非常出色的实现方式。以pandas提供的原生接口方式实现:@pd.api.extensions.register_dataframe_accessor("自定义")。调用方式为实例.自定义.属性/方法。这种非侵入式的实现方式有效的分离了原pandas_obj和自定义接口自定义的作用域。本质上采用了组合的思想,核心实现方式为accessor系列函数,笔者会在后面的博客中进行介绍。
但是不得不说这种实现方式的遗憾是,当采用这种方式进行接口的扩展,在某些IDE上(例如在笔者所用的IDE Spyder)调用自定义接口时不会给出提示和自动代码补全。

扩展数据类型 - Extension types

Pandas定义了一个接口用于实现扩展Numpy生态中的数据类型和数组类型。而Pandas自身生态包含了扩展对一些没有内置到Numpy中的类型进行处理(categorical、period、interval、datetime和timezone)。
库允许用户自定义数组和数据类型。并且pandas可以正确处理(例如,不会被转换成ndarray实例)用户定义的类或实例,因为很多方法例如pandas.isna()考虑了扩展类型的实现。
如果您正在构建实现接口的库,pandas希望您在扩展数据类型上公开它。
接口由两个类组成,ExtensionDtypeExtensionArray

扩展数据类型 - ExtensionDtype

pandas.api.extensions.ExtensionDtype类似于numpy.dtype类。它用于描述数据类型。在调用过程中,用户需专注于一些特定的唯一属性,比如名称。
type是一个特别重要的属性。表示用户自定义数据所属的类型。例如,如果您正在为IP地址编写扩展类型,应当是ipaddress.IPv4Address类型。
有关接口定义,请参见extension dtype source
版本0.24.0中的新功能。
pandas.api.extension.ExtensionDtype用于创建数据类型字符名称。通过字符串名实例化Series 和.astype()进行实现,例如 'category'是一个已被使用的CategoricalDtype的访问字符。
有关如何自定义数据类型的详细信息,请参阅extension dtype dtypes

扩展数组 - ExtensionArray

这个类提供了类数组(array-like)类的全部功能。ExtensionArrays类仅限于1维。ExtensionArray类通过dtype属性链接到ExtensionDtype
Pandas不限制如何通过newinit方法创建扩展数组,也不限制如何存储数据。但是非常重要的一点是,Pandas要求用户自定义的数组可以转换为Numpy数组,即使它非常复杂(因为它会被用于Categorical类)。
它们可能由空、一个或多个Numpy数组支持。例如, pandas.Categorical的是由两个数组支持的扩展数组,一个用于编码,一个用于分类;IPv6地址数组可以由具有两个字段的Numpy结构数组支持,一个用于低位64位,另一个用于高位64位;或者它们可能有其他存储类型的支持,比如Python的列表类。
有关接口定义,请参见extension array source。文档和注释将指导用户正确实现接口。

·点评

扩展数组中存放的数据类型可以是基本类型,也可以是用户自定义的,扩展数组支持自定义的结构和操作方法。例如在IPv6例子中,扩展数组由低位64位,和高位64位两个自定义数据类型元素ipaddress.IPv6Address组合而成。这个自定义数组应包含特定的属性和方法用于解释IPv6的低位和高位特性,并给出常规的操作方法。
但是要注意的是,自定义的数组不管怎么变来变去,应该始终是Numpy所支持的数组。(始终不离核心,即Pandas本质的数据结构是矩阵和数组)。

扩展数组操作方法 - ExtensionArray Operator Support

版本0.24.0中的新功能。
默认情况下,没有为类ExtensionArray定义运算。有两种方法可以为类 ExtensionArray提供操作:

  1. 定义 ExtensionArray子类的类方法。
  2. 使用pandas中定义的运算实现,该实现依赖于已在ExtensionArray的底层定义的运算方法。

无论采用何种方法,如果希望在使用NumPy数组进行二进制操作时调用实现,则可能需要设置__array_priority__
对于第一种方法,用户需定义操作符例如 __add____le__等,以提供自定义类对这些操作的支持。
第二种方法基于 ExtensionArray的底层元素(即标量类型)已经定义了各个运算符。换言之,如果定义为MyExtensionArrayExtensionArray被实现为每个元素都是MyExtensionElement类的实例,则在第二种方法中如果为MyExtensionElement定义运算符,也将自动为MyExtensionArray定义运算符。
作为mixin类,ExtensionScalarOpsMixin支持第二种方法。如果开发一个ExtensionArray子类,例如MyExtensionArray,可以简单地将 ExtensionScalarOpsMixin包含为MyExtensionArray的父类,然后调用方法_add_arithmetic_ops()和/或_add_comparison_ops()将运算符挂接到MyExtensionArray类中,如下所示:

from pandas.api.extensions import ExtensionArray, ExtensionScalarOpsMixin

class MyExtensionArray(ExtensionArray, 
                       ExtensionScalarOpsMixin):
    pass


MyExtensionArray._add_arithmetic_ops()
MyExtensionArray._add_comparison_ops()

·官方建议

由于Pandas自动逐个调用每个元素上的底层运算符,因此这可能不如直接在ExtensionArray上实现自定义版本的关联运算符更有效。
对于算术操作,此实现将尝试使用元素操作的结果重建新的ExtensionArray。是否成功取决于操作是否返回对ExtensionArray合法的结果。如果无法重建ExtensionArray,则返回包含标量的ndarray。
为了实现并协同pandas和numPy ndarrays之间的操作保持一致,Pandas建议不要在二进制操作中处理序列和索引。相反,您应该检测这些情况并返回NotImplemented。当pandas遇到类似 op(Series, ExtensionArray)的操作时,pandas 将

  1. 从序列中释放Series (Series.array)
  2. 调用result = op(values, ExtensionArray)
  3. 将结果重新打包成Series

·点评

直白的来说,如果要实现自定义数组的操作方式,要么是自己写,要么就从已有的方式进行继承。一种mixin方法(以Mixin方式命名的类实质上实现接口的功能)ExtensionScalarOpsMixin包含了一些定义的操作,如果需要请从父类继承。
Pandas说明在底层实现是逐个调用每个元素上的底层运算符,换句话来说,继承的方式只能保证代码是可以工作的,但是无法保证运行效率。如果需要用户保持一定的运行效率,还是要自己写。

NumPy通用函数 - NumPy Universal Functions

Series实现了__array_ufunc__方法。作为执行的一部分,Pandas从Series中解绑ExtensionArray,执行自定义方法,并在必要时重新装箱。
如果适用,我们强烈建议用户在自定义数组中实现 __array_ufunc__,以避免强制转为多维数组(ndarray)类型。有关示例,请参见 the numpy documentation。
作为实现的一部分,要求在自定义的输入inputs中检测到pandas容器(Series, DataFrame, Index)时委托给pandas。如果其中任何一个存在,则应返回NotImplemented。Pandas将负责从容器中解除数组的绑定,并使用解绑的输入重新调用自定义的函数。

·点评

这部分的功能笔者并不是很熟悉简单理解到为元素级运算,希望有高人不吝赐教。科普一下numpy提供了一些诸如三角函数这样的常用函数,在numpy中,将其称为“universal functions(ufunc)”
在numpy内部,这些函数在array中执行elementwise(元素级)的运算,生成一个新的array作为输出。

测试 - Testing extension arrays

pandas提供了一个测试套件来确保用户的自定义数组满足预期的行为。要使用测试套件,您必须提供几个Python测试用例(pytest fixture)并继承基本测试类。所需见fixture。通过子类化使用

from pandas.tests.extension import base


class TestConstructors(base.BaseConstructorsTests):
    pass

所有可用测试的列表见link

与Apache Arrow格式兼容 - Compatibility with Apache Arrow

通过实现两个方法,ExtensionArray 可以支持向/从pyarrow数组的转换(从而支持例序列化到Parquet文件格式):ExtensionArray.__arrow_array__
ExtensionDtype.__from_arrow__
ExtensionArray.__arrow_array__数组确保pyarrow定义如何将特定的扩展数组转换为pyarrow.Array(当作为列包含在pandas的DataFrame时):

class MyExtensionArray(ExtensionArray):
    ...

    def __arrow_array__(self, type=None):
        # convert the underlying array values to a pyarrow Array
        import pyarrow
        return pyarrow.array(..., type=type)

ExtensionDtype.__from_arrow__方法将控制从Python参数到pandas定义的扩展数组的转换。此方法仅接收Python数组ArrayChunkedArray作为参数,并将此数据类型和传递的值返回给相应ExtensionArray

class ExtensionDtype:
    ...

    def __from_arrow__(self, array: pyarrow.Array/ChunkedArray) -> ExtensionArray:


更多见Arrow documentation
这些方法已经为pandas中包含的可为空的整数和字符串扩展类型实现,并确保到Python数组和Parquet文件格式的相互转换。

·点评

简单来说ExtensionArray中的__from_arrow____arrow_array__方法用于与Parquet文件格式兼容。如果需要,请实现它们。

子类化Pandas数据结构 - Subclassing pandas data structures

来自官方的凝视(Warning):在考虑对pandas数据结构进行子类化之前,有一些更简单的选择。

  1. pipe可扩展方法链
  2. 使用组合。
  3. 通过注册访问器registering an accessor进行扩展
  4. 按扩展类型 extension type扩展

本节描述如何对pandas数据结构进行子类化以满足更具体的需求。有两点需要注意:

  1. 重写构造函数属性。
  2. 定义基本属性

覆写构造属性 - Override constructor properties

每个数据结构都有几个构造函数属性,用于作为操作的结果返回新的数据结构。通过重写这些属性,可以通过pandas数据操作保留子类。
要定义3个构造函数属性:
_constructor:当操作结果具有与原始结果相同的维度时使用。
_constructor_sliced:当操作结果比原始维度低时使用,例如DataFrame 单列切片。
_constructor_expanddim:当操作结果与原始结果有一个更高的维度时使用,例如Series.to_frame().

下表列出了pandas数据结构默认的构造属性

属性SeriesDataFrame
_constructorSeriesDataFrame
_constructor_slicedNotImplementedErrorSeries
_constructor_expanddimDataFrameNotImplementedError

下列例子示例如何复写SubclassedSeriesSubclassedDataFrame的构造属性

class SubclassedSeries(pd.Series):

    @property
    def _constructor(self):
        return SubclassedSeries

    @property
    def _constructor_expanddim(self):
        return SubclassedDataFrame


class SubclassedDataFrame(pd.DataFrame):

    @property
    def _constructor(self):
        return SubclassedDataFrame

    @property
    def _constructor_sliced(self):
        return SubclassedSeries

>>> s = SubclassedSeries([1, 2, 3])
>>> type(s)
<class '__main__.SubclassedSeries'>

>>> to_framed = s.to_frame()
>>> type(to_framed)
<class '__main__.SubclassedDataFrame'>

>>> df = SubclassedDataFrame({'A': [1, 2, 3], 'B': [4, 5, 6], 'C': [7, 8, 9]})
>>> df
   A  B  C
0  1  4  7
1  2  5  8
2  3  6  9

>>> type(df)
<class '__main__.SubclassedDataFrame'>

>>> sliced1 = df[['A', 'B']]
>>> sliced1
   A  B
0  1  4
1  2  5
2  3  6

>>> type(sliced1)
<class '__main__.SubclassedDataFrame'>

>>> sliced2 = df['A']
>>> sliced2
0    1
1    2
2    3
Name: A, dtype: int64

>>> type(sliced2)
<class '__main__.SubclassedSeries'>


定义基本属性 - Define original properties

要让原始数据结构具有其他属性,您应该让pandas知道添加了哪些属性。pandas将未知属性映射到覆写__getattribute__的数据名。可以通过以下两种方法之一定义原始属性:
为不会传递给操作结果的临时属性定义_internal_names_internal_names_set
为将传递给操作结果的常规属性定义_metadata
下面是定义两个原始属性的示例,“internal_cache”作为临时属性,“added_property”作为普通属性。

class SubclassedDataFrame2(pd.DataFrame):

    # temporary properties
    _internal_names = pd.DataFrame._internal_names + ['internal_cache']
    _internal_names_set = set(_internal_names)

    # normal properties
    _metadata = ['added_property']

    @property
    def _constructor(self):
        return SubclassedDataFrame2

>>> df = SubclassedDataFrame2({'A': [1, 2, 3], 'B': [4, 5, 6], 'C': [7, 8, 9]})
>>> df
   A  B  C
0  1  4  7
1  2  5  8
2  3  6  9

>>> df.internal_cache = 'cached'
>>> df.added_property = 'property'

>>> df.internal_cache
cached
>>> df.added_property
property

# properties defined in _internal_names is reset after manipulation
>>> df[['A', 'B']].internal_cache
AttributeError: 'SubclassedDataFrame2' object has no attribute 'internal_cache'

# properties defined in _metadata are retained
>>> df[['A', 'B']].added_property
property


·点评

子类化SeriesDataFrame有以下注意点:

  1. 若非必要,不推荐子类化继承方式。
  2. 子类化需要注意覆写构造函数属性,还要注意基本属性的构造方式。
  3. 子类化的构造函数属性实现Series子类DataFrame子类的相互转换——无需手动编写代码。
  4. 基本的属性定义有临时属性和普通属性两种。
    另外,需要注意的是。经过笔者测试,通过本文第一章自定义接口 - Registering custom accessors方式扩展的接口,在子类化中也会得到继承。
# -*- coding: utf-8 -*-
"""
Created on Thu Jun 18 11:31:36 2020

@author: sixing liu, yaru chen
"""
import pandas as pd

class SubSeries(pd.Series):

    @property
    def _constructor(self):
        return SubSeries

    @property
    def _constructor_expanddim(self):
        return SubDataFrame

class SubDataFrame(pd.DataFrame):

    _metadata = ['freq']
    
    @property
    def _constructor(self):
        return SubDataFrame

    @property
    def _constructor_sliced(self):
        return SubSeries

@pd.api.extensions.register_dataframe_accessor("myclass")
class MyAccessor:
    def __init__(self, pandas_obj):
        self._validate(pandas_obj)
        self._obj = pandas_obj

    @staticmethod
    def _validate(obj):
      pass

    def myfunction(self):
        # plot this array's data on a map, e.g., using Cartopy
        print('this is Accessor function')
>>>mydf = SubDataFrame({'A': [1, 2, 3], 'B': [4, 5, 6], 'C': [7, 8, 9]})

>>>type(mydf)
Out: __main__.SubDataFrame

>>>mydf.myclass.myfunction()
Out: this is Accessor function

完整测试:

>>> myseries = SubSeries([1, 2, 3])

>>> type(myseries)
Out: __main__.SubSeries

>>>mydf = SubDataFrame({'A': [1, 2, 3], 'B': [4, 5, 6], 'C': [7, 8, 9]})

>>>type(mydf)
Out: __main__.SubDataFrame

>>>sliced = mydf['A']

>>>type(sliced)
Out: __main__.SubSeries

>>>mydf.myclass.myfunction()
Out: this is Accessor function

>>>sliced.myclass.myfunction()
Out: AttributeError: 'SubSeries' object has no attribute 'myclass'

绘图后台 - Plotting backends

从0.25版本开始,pandas可以通过第三方绘制后端来扩展。其主要思想是让用户选择一个不同于基于Matplotlib提供的绘图后端。例如:

>>> pd.set_option('plotting.backend', 'backend.module')
>>> pd.Series([1, 2, 3]).plot()

这或多或少相当于:

>>> import backend.module
>>> backend.module.plot(pd.Series([1, 2, 3]))

然后,后端模块可以使用其他可视化工具(Bokeh、Altair等)生成绘图。
实现绘图后端的库应使用入口点,使其后端对pandas可见。主键是 "pandas_plotting_backends"。例如,以下默认方式为“matplotlib”后端。

# in setup.py
setup(  # noqa: F821
    ...,
    entry_points={
        "pandas_plotting_backends": [
            "matplotlib = pandas:plotting._matplotlib",
        ],
    },
)

更多第三方绘图工具后台

·点评

plot方法作为pandas的第三方接入接口,核心实现方式为CachedAccessor类,笔者会在后面的博客中进行介绍。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值