扩展 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 对象的情况下扩展它们。如果您编写了自定义访问器,请提交拉取请求,将其添加到我们的生态系统页面。

我们强烈建议在访问器的 __init__ 中验证数据。在我们的 GeoAccessor 中,我们验证数据是否包含预期的列,如果验证失败则引发 AttributeError。对于 Series 访问器,如果访问器仅适用于某些数据类型,则应验证 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 的注册字符串访问器。

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

ExtensionArray#

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

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

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

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

ExtensionArray 运算符支持#

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

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

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

注意

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

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

第二种方法假设 ExtensionArray 的底层元素(即标量类型)已经定义了单个运算符。换句话说,如果您的 ExtensionArray 名为 MyExtensionArray,并且其实现使得每个元素都是 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 ndarray 之间的操作保持一致,我们建议 不要 在您的二元运算中处理 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 fixture 并继承基础测试类。所需的 fixture 可以在 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 中包含的可空整数和字符串扩展 dtypes 中实现,并确保到 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__ 语义,如果 other 对象具有更高值的 __pandas_priority__ 属性,则 DataFrameSeriesIndex 对象上的算术方法将委托给 other

默认情况下,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