扩展 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.ExtensionDtype 和 pandas.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 提供运算符支持
在您的
ExtensionArray子类上定义每个运算符。使用 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 会
从
Series中解包数组(Series.array)调用
result = op(values, ExtensionArray)将结果重新装入
Series
NumPy 通用函数#
Series 实现 __array_ufunc__。作为实现的一部分,pandas 会从 Series 中解包 ExtensionArray,应用 ufunc,并在必要时重新装入。
如果适用,我们强烈建议您在扩展数组中实现 __array_ufunc__,以避免强制转换为 ndarray。有关示例,请参阅 NumPy 文档。
作为实现的一部分,我们要求您在 inputs 中检测到 pandas 容器(Series、DataFrame、Index)时,将调用委托给 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 Array 或 ChunkedArray 作为参数,并应返回适合该 dtype 和传入值的 pandas ExtensionArray。
class ExtensionDtype:
...
def __from_arrow__(self, array: pyarrow.Array/ChunkedArray) -> ExtensionArray:
...
有关更多信息,请参阅 Arrow 文档。
这些方法已为 pandas 中包含的可空整数和字符串扩展 dtype 实现,并确保了与 pyarrow 和 Parquet 文件格式的往返转换。
子类化 pandas 数据结构#
本节介绍如何子类化 pandas 数据结构以满足更具体的需求。有两点需要注意
覆盖构造函数属性。
定义原始属性
注意
您可以在 geopandas 项目中找到一个很好的例子。
覆盖构造函数属性#
每个数据结构都有几个构造函数属性,用于将新数据结构作为操作的结果返回。通过覆盖这些属性,您可以在 pandas 数据操作中保留子类。
子类中可以定义 3 个构造函数属性
DataFrame/Series._constructor:当操作结果的维度与原始维度相同时使用。DataFrame._constructor_sliced:当DataFrame(子)类的操作结果应为Series(子)类时使用。Series._constructor_expanddim:当Series(子)类的操作结果应为DataFrame(子)类时使用,例如Series.to_frame()。
以下示例展示了如何通过覆盖构造函数属性来定义 SubclassedSeries 和 SubclassedDataFrame。
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__ 来实现。定义原始属性可以通过以下两种方式之一完成
为临时属性定义
_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
绘图后端#
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__ 语义,DataFrame、Series 和 Index 对象的算术方法将委托给 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 对象更高的值,现在就可以实现这一点。
DataFrame、Series 和 Index 的 __pandas_priority__ 分别是 4000、3000 和 2000。基础 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