跳到内容

分类数据和枚举

一个列如果包含只能取有限个可能值之一的字符串值,则该列包含分类数据。通常,可能值的数量远小于列的长度。一些典型示例包括你的国籍、电脑的操作系统,或者你最喜欢的开源项目使用的许可证。

处理分类数据时,你可以使用 Polars 的专用类型 CategoricalEnum 来提高查询性能。接下来,我们将了解这两种数据类型 CategoricalEnum 之间的区别,以及何时使用其中一种。本用户指南部分的末尾还包含一些关于为什么数据类型 CategoricalEnum 比使用普通字符串值更高效的说明。

EnumCategorical 的对比

简而言之,只要可能,就应该优先选择 Enum 而非 Categorical。当类别是固定且预先已知时,使用 Enum。如果你不知道类别或者类别不固定,那么必须使用 Categorical。如果你的需求在过程中发生变化,你总能从一种类型转换为另一种类型。

数据类型 Enum

创建 Enum

数据类型 Enum 是一种有序的分类数据类型。要使用数据类型 Enum,你必须提前指定类别,以创建一个新的 Enum 变体数据类型。然后,在创建新的 Series、新的 DataFrame 或转换字符串列时,你可以使用该 Enum 变体。

Enum

import polars as pl

bears_enum = pl.Enum(["Polar", "Panda", "Brown"])
bears = pl.Series(["Polar", "Panda", "Brown", "Brown", "Polar"], dtype=bears_enum)
print(bears)

shape: (5,)
Series: '' [enum]
[
    "Polar"
    "Panda"
    "Brown"
    "Brown"
    "Polar"
]

无效值

如果你尝试指定的数据类型 Enum 不包含所有现有值,Polars 将会引发错误

Enum

from polars.exceptions import InvalidOperationError

try:
    bears_kind_of = pl.Series(
        ["Polar", "Panda", "Brown", "Polar", "Shark"],
        dtype=bears_enum,
    )
except InvalidOperationError as exc:
    print("InvalidOperationError:", exc)

InvalidOperationError: conversion from `str` to `enum` failed in column '' for 1 out of 5 values: ["Shark"]

Ensure that all values in the input column are present in the categories of the enum datatype.

如果你无法预先知道所有可能的值,并且对未知值报错在语义上是错误的,你可能需要使用数据类型 Categorical

类别排序与比较

数据类型 Enum 是有序的,其顺序由你指定类别的顺序决定。下面的示例使用日志级别作为有序 Enum 有用的例子

Enum

log_levels = pl.Enum(["debug", "info", "warning", "error"])

logs = pl.DataFrame(
    {
        "level": ["debug", "info", "debug", "error"],
        "message": [
            "process id: 525",
            "Service started correctly",
            "startup time: 67ms",
            "Cannot connect to DB!",
        ],
    },
    schema_overrides={
        "level": log_levels,
    },
)

non_debug_logs = logs.filter(
    pl.col("level") > "debug",
)
print(non_debug_logs)

shape: (2, 2)
┌───────┬───────────────────────────┐
│ level ┆ message                   │
│ ---   ┆ ---                       │
│ enum  ┆ str                       │
╞═══════╪═══════════════════════════╡
│ info  ┆ Service started correctly │
│ error ┆ Cannot connect to DB!     │
└───────┴───────────────────────────┘

这个例子表明我们可以将 Enum 值与字符串进行比较,但这仅在字符串与某个 Enum 值匹配时才有效。如果我们将“level”列与 "debug""info""warning""error" 以外的任何字符串进行比较,Polars 将会引发异常。

数据类型为 Enum 的列也可以与其他具有相同 Enum 数据类型的列或包含字符串的列进行比较,但前提是所有字符串都是有效的 Enum 值。

数据类型 Categorical

数据类型 Categorical 可以看作是 Enum 的更灵活版本。

创建 Categorical Series

要使用数据类型 Categorical,你可以转换一个字符串列,或者将 Categorical 指定为 Series 或 DataFrame 列的数据类型

Categorical

bears_cat = pl.Series(
    ["Polar", "Panda", "Brown", "Brown", "Polar"], dtype=pl.Categorical
)
print(bears_cat)

shape: (5,)
Series: '' [cat]
[
    "Polar"
    "Panda"
    "Brown"
    "Brown"
    "Polar"
]

让 Polars 为你推断类别可能听起来比预先列出类别更好,但这种推断会带来性能开销。因此,只要可能,你就应该使用 Enum。你可以通过阅读关于数据类型 Categorical 及其编码的小节了解更多信息。

与字符串的词法比较

Categorical 列与字符串进行比较时,Polars 将执行词法比较

Categorical

print(bears_cat < "Cat")

shape: (5,)
Series: '' [bool]
[
    false
    false
    true
    true
    false
]

你也可以将字符串列与你的 Categorical 列进行比较,并且比较也将是词法的

Categorical

bears_str = pl.Series(
    ["Panda", "Brown", "Brown", "Polar", "Polar"],
)
print(bears_cat == bears_str)

shape: (5,)
Series: '' [bool]
[
    false
    false
    true
    false
    true
]

虽然可以将字符串列与分类列进行比较,但比较两个分类列通常更高效。接下来我们将看到如何做到这一点。

比较 Categorical 列与字符串缓存

你被告知,比较数据类型为 Categorical 的列比其中一个列是字符串列更高效。于是,你修改了代码,将第二个列也设为分类列,然后执行比较……但 Polars 却引发了异常

Categorical

from polars.exceptions import StringCacheMismatchError

bears_cat2 = pl.Series(
    ["Panda", "Brown", "Brown", "Polar", "Polar"],
    dtype=pl.Categorical,
)

try:
    print(bears_cat == bears_cat2)
except StringCacheMismatchError as exc:
    exc_str = str(exc).splitlines()[0]
    print("StringCacheMismatchError:", exc_str)

StringCacheMismatchError: cannot compare categoricals coming from different sources, consider setting a global StringCache.

默认情况下,数据类型为 Categorical 的列中的值按它们在列中出现的顺序进行编码,并且独立于其他列,这意味着 Polars 无法高效地比较两个独立创建的分类列。

启用 Polars 字符串缓存并创建启用缓存的列可以解决此问题

StringCache · Categorical

with pl.StringCache():
    bears_cat = pl.Series(
        ["Polar", "Panda", "Brown", "Brown", "Polar"], dtype=pl.Categorical
    )
    bears_cat2 = pl.Series(
        ["Panda", "Brown", "Brown", "Polar", "Polar"], dtype=pl.Categorical
    )

print(bears_cat == bears_cat2)

shape: (5,)
Series: '' [bool]
[
    false
    false
    true
    false
    true
]

请注意,使用字符串缓存会带来性能开销

组合 Categorical 列

字符串缓存对于任何以任何方式组合或混合两个数据类型为 Categorical 的列的操作也很有用。一个例子是垂直连接两个 DataFrame

StringCache · Categorical

import warnings

from polars.exceptions import CategoricalRemappingWarning

male_bears = pl.DataFrame(
    {
        "species": ["Polar", "Brown", "Panda"],
        "weight": [450, 500, 110],  # kg
    },
    schema_overrides={"species": pl.Categorical},
)
female_bears = pl.DataFrame(
    {
        "species": ["Brown", "Polar", "Panda"],
        "weight": [340, 200, 90],  # kg
    },
    schema_overrides={"species": pl.Categorical},
)

with warnings.catch_warnings():
    warnings.filterwarnings("ignore", category=CategoricalRemappingWarning)
    bears = pl.concat([male_bears, female_bears], how="vertical")

print(bears)

shape: (6, 2)
┌─────────┬────────┐
│ species ┆ weight │
│ ---     ┆ ---    │
│ cat     ┆ i64    │
╞═════════╪════════╡
│ Polar   ┆ 450    │
│ Brown   ┆ 500    │
│ Panda   ┆ 110    │
│ Brown   ┆ 340    │
│ Polar   ┆ 200    │
│ Panda   ┆ 90     │
└─────────┴────────┘

在这种情况下,Polars 会发出警告,抱怨昂贵的重新编码会导致性能损失。Polars 随后建议如果可能,使用数据类型 Enum,或者使用字符串缓存。要理解此操作的问题以及 Polars 为何会引发错误,请阅读关于使用分类数据类型的性能考量的最后部分。

Categorical 列之间的比较不是词法比较

默认情况下,比较两个数据类型为 Categorical 的列时,Polars 不会对其值进行词法比较。如果你需要词法排序,则需要在创建列时指定。

StringCache · Categorical

with pl.StringCache():
    bears_cat = pl.Series(
        ["Polar", "Panda", "Brown", "Brown", "Polar"],
        dtype=pl.Categorical(ordering="lexical"),
    )
    bears_cat2 = pl.Series(
        ["Panda", "Brown", "Brown", "Polar", "Polar"], dtype=pl.Categorical
    )

print(bears_cat > bears_cat2)

shape: (5,)
Series: '' [bool]
[
    true
    true
    false
    false
    false
]

否则,顺序将与值一起被推断

StringCache · Categorical

with pl.StringCache():
    bears_cat = pl.Series(
        # Polar <  Panda <  Brown
        ["Polar", "Panda", "Brown", "Brown", "Polar"],
        dtype=pl.Categorical,
    )
    bears_cat2 = pl.Series(
        ["Panda", "Brown", "Brown", "Polar", "Polar"], dtype=pl.Categorical
    )

print(bears_cat > bears_cat2)

shape: (5,)
Series: '' [bool]
[
    false
    false
    false
    true
    false
]

分类数据类型的性能考量

本用户指南部分解释了

  • 为什么分类数据类型比字符串字面量更具性能;以及
  • 为什么 Polars 在对数据类型 Categorical 进行某些操作时需要字符串缓存。

编码

分类数据表示字符串数据,其中列中的值具有有限的取值集合(通常远小于列的长度)。将这些值存储为普通字符串会浪费内存和性能,因为我们将重复相同的字符串。此外,在连接等操作中,我们必须执行昂贵的字符串比较。

EnumCategorical 等分类数据类型允许你以更经济的方式编码字符串值,在经济的编码值与原始字符串字面量之间建立关系。

作为一个合理的编码示例,Polars 可以选择将有限的类别集合表示为正整数。考虑到这一点,下图显示了一个常规字符串列和使用分类数据类型的 Polars 列的可能表示

字符串列分类列
Series
Polar
Panda
Brown
Panda
Brown
Brown
Polar
物理值
0
1
2
1
2
2
0
类别
Polar
Panda
Brown

在这种情况下,物理值 0 编码(或映射)到值 'Polar',值 1 编码到 'Panda',值 2 编码到 'Brown'。这种编码的好处是只存储字符串值一次。此外,当我们执行操作(例如排序、计数)时,我们可以直接在物理表示上工作,这比处理字符串数据快得多。

数据类型 Enum 的编码是全局的

使用数据类型 Enum 时,我们预先指定类别。这样,Polars 可以确保不同的列甚至不同的数据集具有相同的编码,从而无需昂贵的重新编码或缓存查找。

数据类型 Categorical 与编码

数据类型 Categorical 的类别是推断出来的,这会带来一定的成本。主要成本在于我们无法控制我们的编码。

考虑以下场景,我们追加以下两个分类 Series

Polars 会按照字符串值出现的顺序进行编码。因此,Series 将如下所示:

cat_seriescat2_series
物理值
0
1
2
2
0
类别
Polar
Panda
Brown
物理值
0
1
1
2
2
类别
Panda
Brown
Polar

组合 Series 变成了一项不平凡的任务,而且代价昂贵,因为物理值 0 在两个 Series 中代表不同的含义。为了方便,Polars 确实支持这些类型的操作,但由于其性能较慢,应避免使用它们,因为它需要在进行任何合并操作之前,首先使两种编码兼容。

使用全局字符串缓存

解决这种重新编码问题的一种方法是启用字符串缓存。在字符串缓存下,图表将如下所示:

Series字符串缓存
cat_seriescat2_series
物理值
0
1
2
2
0
物理值
1
2
2
0
0
类别
Polar
Panda
Brown

当你启用字符串缓存时,字符串不再按列出现的顺序进行编码。相反,编码在列之间共享。值 'Polar' 对于在字符串缓存下创建的所有分类列,将始终由相同的值进行编码。合并操作(例如追加、连接)再次变得廉价,因为无需首先使编码兼容,从而解决了我们上面遇到的问题。

然而,在 Series 构建过程中,字符串缓存确实会带来轻微的性能损失,因为我们需要在缓存中查找或插入字符串值。因此,如果你预先知道类别,则更推荐使用数据类型 Enum