扩展 pandas#

虽然 pandas 提供了丰富的函数、容器和数据类型,但您的需求可能无法完全满足。pandas 提供了几种扩展 pandas 的选项。

注册自定义访问器#

库可以使用装饰器 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

现在用户可以通过 geo 命名空间访问您的函数

>>> 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 对象而不对其进行子类化的方法。如果您编写了一个自定义访问器,请提交一个 pull request 将其添加到我们的 生态系统 页面。

我们强烈建议验证您访问器 __init__ 中的数据。在我们的 GeoAccessor 中,我们验证数据是否包含预期的列,并在验证失败时引发 AttributeError。对于 Series 访问器,如果访问器仅适用于某些 dtype,则应验证 dtype

扩展类型#

注意

在 pandas 1.5 之前,pandas.api.extensions.ExtensionDtypepandas.api.extensions.ExtensionArray API 是实验性的。从 1.5 版本开始,未来的更改将遵循 pandas 弃用策略

pandas 定义了一个接口,用于实现扩展 NumPy 类型的类型和数组。pandas 本身使用扩展系统来处理一些 NumPy 未内置的类型(分类、周期、区间、带时区的日期时间)。

库可以定义自定义数组和数据类型。当 pandas 遇到这些对象时,它们将被正确处理(即不会转换为对象 ndarray)。许多函数,如 pandas.isna(),将分派到扩展类型的实现。

如果您正在构建一个实现该接口的库,请在 生态系统页面 上进行宣传。

该接口包含两个类。

ExtensionDtype#

一个 pandas.api.extensions.ExtensionDtype 类似于 numpy.dtype 对象。它描述了数据类型。实现者负责一些独特的项,如名称。

一个特别重要的项是 type 属性。这应该是您数据的标量类型的类。例如,如果您正在为 IP 地址数据编写扩展数组,这可能是 ipaddress.IPv4Address

有关接口定义的更多信息,请参阅 扩展 dtype 源码

可以将 pandas.api.extensions.ExtensionDtype 注册到 pandas,以便通过字符串 dtype 名称进行创建。这允许人们实例化 Series 和使用注册的字符串名称进行 .astype(),例如 'category'CategoricalDtype 的注册字符串访问器。

有关如何注册 dtype 的更多信息,请参阅 扩展 dtype 源码

ExtensionArray#

此类提供了所有类数组的功能。ExtensionArrays 仅限于 1 维。ExtensionArray 通过 dtype 属性链接到 ExtensionDtype。

pandas 对如何通过 __new____init__ 创建扩展数组没有任何限制,并且对如何存储数据也没有任何限制。我们要求您的数组可转换为 NumPy 数组,即使这相对昂贵(如 Categorical)。

它们可以由零个、一个或多个 NumPy 数组支持。例如,pandas.Categorical 是由两个数组支持的扩展数组,一个用于代码,一个用于类别。IPv6 地址数组可以由一个包含两个字段(一个用于低 64 位,一个用于高 64 位)的 NumPy 结构化数组支持。或者它们可以由其他存储类型支持,例如 Python 列表。

有关接口定义的更多信息,请参阅 扩展数组源码。文档字符串和注释包含有关正确实现接口的指导。

ExtensionArray 运算符支持#

默认情况下,ExtensionArray 类没有定义运算符。有两种方法可以为您的 ExtensionArray 提供运算符支持

  1. 在您的 ExtensionArray 子类上定义每个运算符。

  2. 使用 pandas 中一个依赖于 ExtensionArray 底层元素(标量)上已定义的运算符的运算符实现。

注意

无论采用哪种方法,如果您希望在与 NumPy 数组进行二元运算时调用您的实现,您可能需要设置 __array_priority__

对于第一种方法,您定义选定的运算符,例如 __add____le__ 等,您希望您的 ExtensionArray 子类支持这些运算符。

第二种方法假定 ExtensionArray 的底层元素(即标量类型)已经定义了单独的运算符。换句话说,如果您的名为 MyExtensionArray 的 ExtensionArray 实现方式是每个元素都是 MyExtensionElement 类的实例,那么如果为 MyExtensionElement 定义了运算符,第二种方法将自动为 MyExtensionArray 定义运算符。

混合类 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 之间的操作保持一致,我们建议在您的二元运算中不要处理 Series 和 Indexes。相反,您应该检测这些情况并返回 NotImplemented。当 pandas 遇到类似 op(Series, ExtensionArray) 的操作时,pandas 会

  1. Series 中解包数组(Series.array

  2. 调用 result = op(values, ExtensionArray)

  3. 将结果重新装入 Series

NumPy 通用函数#

Series 实现 __array_ufunc__。作为实现的一部分,pandas 会从 Series 中解包 ExtensionArray,应用 ufunc,并在必要时重新装入。

如果适用,我们强烈建议您在扩展数组中实现 __array_ufunc__,以避免强制转换为 ndarray。有关示例,请参阅 NumPy 文档

作为实现的一部分,我们要求您在 inputs 中检测到 pandas 容器(SeriesDataFrameIndex)时,将调用委托给 pandas。如果其中任何一个存在,您应该返回 NotImplemented。pandas 将负责从容器中解包数组,并使用解包后的输入重新调用 ufunc。

测试扩展数组#

我们提供了一个测试套件,用于确保您的扩展数组满足预期行为。要使用该测试套件,您必须提供几个 pytest 夹具并继承自基测试类。所需的夹具可以在 pandas-dev/pandas 中找到。

要使用测试,请继承它

from pandas.tests.extension import base


class TestConstructors(base.BaseConstructorsTests):
    pass

有关所有可用测试的列表,请参阅 pandas-dev/pandas

与 Apache Arrow 的兼容性#

通过实现两个方法:ExtensionArray.__arrow_array__ExtensionDtype.__from_arrow__ExtensionArray 可以支持与 pyarrow 数组的双向转换(从而支持例如序列化为 Parquet 文件格式)。

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__ 方法控制从 pyarrow 转换回 pandas ExtensionArray。此方法仅接收一个 pyarrow ArrayChunkedArray 作为参数,并应返回适合该 dtype 和传入值的 pandas ExtensionArray

class ExtensionDtype:
    ...

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

有关更多信息,请参阅 Arrow 文档

这些方法已为 pandas 中包含的可空整数和字符串扩展 dtype 实现,并确保了与 pyarrow 和 Parquet 文件格式的往返转换。

子类化 pandas 数据结构#

警告

在考虑子类化 pandas 数据结构之前,有一些更简单的替代方法。

  1. 使用 pipe 进行可扩展的方法链

  2. 使用组合。请参阅 此处

  3. 通过注册访问器进行扩展

  4. 通过扩展类型进行扩展

本节介绍如何子类化 pandas 数据结构以满足更具体的需求。有两点需要注意

  1. 覆盖构造函数属性。

  2. 定义原始属性

注意

您可以在 geopandas 项目中找到一个很好的例子。

覆盖构造函数属性#

每个数据结构都有几个构造函数属性,用于将新数据结构作为操作的结果返回。通过覆盖这些属性,您可以在 pandas 数据操作中保留子类。

子类中可以定义 3 个构造函数属性

  • DataFrame/Series._constructor:当操作结果的维度与原始维度相同时使用。

  • DataFrame._constructor_sliced:当 DataFrame(子)类的操作结果应为 Series(子)类时使用。

  • Series._constructor_expanddim:当 Series(子)类的操作结果应为 DataFrame(子)类时使用,例如 Series.to_frame()

以下示例展示了如何通过覆盖构造函数属性来定义 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'>

定义原始属性#

为了让原始数据结构具有附加属性,您应该让 pandas 知道添加了哪些属性。 pandas 将未知属性映射到数据名称,通过覆盖 __getattribute__ 来实现。定义原始属性可以通过以下两种方式之一完成

  1. 为临时属性定义 _internal_names_internal_names_set,这些属性不会传递到操作结果。

  2. 为普通属性定义 _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

绘图后端#

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"。例如,pandas 注册了默认的“matplotlib”后端,如下所示。

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

有关如何实现第三方绘图后端的更多信息,请参阅 pandas-dev/pandas

与第三方类型的算术运算#

为了控制自定义类型和 pandas 类型之间的算术运算方式,请实现 __pandas_priority__。类似于 NumPy 的 __array_priority__ 语义,DataFrameSeriesIndex 对象的算术方法将委托给 other,前提是它具有一个值更高的属性 __pandas_priority__

默认情况下,pandas 对象会尝试与其他对象进行运算,即使它们不是 pandas 已知的类型。

>>> pd.Series([1, 2]) + [10, 20]
0    11
1    22
dtype: int64

在上例中,如果 [10, 20] 是一个可以被理解为列表的自定义类型,pandas 对象仍将以相同的方式与它进行运算。

在某些情况下,将运算委托给其他类型很有用。例如,考虑我实现了一个自定义列表对象,并且我希望将我的自定义列表与 pandas Series 相加的结果是我的列表实例,而不是像前一个示例中看到的 Series。通过定义我的自定义列表的 __pandas_priority__ 属性并将其设置为比我想要与之运算的 pandas 对象更高的值,现在就可以实现这一点。

DataFrameSeriesIndex__pandas_priority__ 分别是 400030002000。基础 ExtensionArray.__pandas_priority__1000

class CustomList(list):
    __pandas_priority__ = 5000

    def __radd__(self, other):
        # return `self` and not the addition for simplicity
        return self

custom = CustomList()
series = pd.Series([1, 2, 3])

# Series refuses to add custom, since it's an unknown type with higher priority
assert series.__add__(custom) is NotImplemented

# This will cause the custom class `__radd__` being used instead
assert series + custom is custom