分类数据和枚举
一个列如果包含只能取有限个可能值之一的字符串值,则该列包含分类数据。通常,可能值的数量远小于列的长度。一些典型示例包括你的国籍、电脑的操作系统,或者你最喜欢的开源项目使用的许可证。
处理分类数据时,你可以使用 Polars 的专用类型 Categorical 和 Enum 来提高查询性能。接下来,我们将了解这两种数据类型 Categorical 和 Enum 之间的区别,以及何时使用其中一种。本用户指南部分的末尾还包含一些关于为什么数据类型 Categorical 和 Enum 比使用普通字符串值更高效的说明。
Enum 与 Categorical 的对比
简而言之,只要可能,就应该优先选择 Enum 而非 Categorical。当类别是固定且预先已知时,使用 Enum。如果你不知道类别或者类别不固定,那么必须使用 Categorical。如果你的需求在过程中发生变化,你总能从一种类型转换为另一种类型。
数据类型 Enum
创建 Enum
数据类型 Enum 是一种有序的分类数据类型。要使用数据类型 Enum,你必须提前指定类别,以创建一个新的 Enum 变体数据类型。然后,在创建新的 Series、新的 DataFrame 或转换字符串列时,你可以使用该 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 将会引发错误
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 有用的例子
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 列的数据类型
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 将执行词法比较
print(bears_cat < "Cat")
shape: (5,)
Series: '' [bool]
[
false
false
true
true
false
]
你也可以将字符串列与你的 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 却引发了异常
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 字符串缓存并创建启用缓存的列可以解决此问题
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 时
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 不会对其值进行词法比较。如果你需要词法排序,则需要在创建列时指定。
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
]
否则,顺序将与值一起被推断
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进行某些操作时需要字符串缓存。
编码
分类数据表示字符串数据,其中列中的值具有有限的取值集合(通常远小于列的长度)。将这些值存储为普通字符串会浪费内存和性能,因为我们将重复相同的字符串。此外,在连接等操作中,我们必须执行昂贵的字符串比较。
Enum 和 Categorical 等分类数据类型允许你以更经济的方式编码字符串值,在经济的编码值与原始字符串字面量之间建立关系。
作为一个合理的编码示例,Polars 可以选择将有限的类别集合表示为正整数。考虑到这一点,下图显示了一个常规字符串列和使用分类数据类型的 Polars 列的可能表示
| 字符串列 | 分类列 | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
在这种情况下,物理值 0 编码(或映射)到值 'Polar',值 1 编码到 'Panda',值 2 编码到 'Brown'。这种编码的好处是只存储字符串值一次。此外,当我们执行操作(例如排序、计数)时,我们可以直接在物理表示上工作,这比处理字符串数据快得多。
数据类型 Enum 的编码是全局的
使用数据类型 Enum 时,我们预先指定类别。这样,Polars 可以确保不同的列甚至不同的数据集具有相同的编码,从而无需昂贵的重新编码或缓存查找。
数据类型 Categorical 与编码
数据类型 Categorical 的类别是推断出来的,这会带来一定的成本。主要成本在于我们无法控制我们的编码。
考虑以下场景,我们追加以下两个分类 Series
Polars 会按照字符串值出现的顺序进行编码。因此,Series 将如下所示:
| cat_series | cat2_series | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
组合 Series 变成了一项不平凡的任务,而且代价昂贵,因为物理值 0 在两个 Series 中代表不同的含义。为了方便,Polars 确实支持这些类型的操作,但由于其性能较慢,应避免使用它们,因为它需要在进行任何合并操作之前,首先使两种编码兼容。
使用全局字符串缓存
解决这种重新编码问题的一种方法是启用字符串缓存。在字符串缓存下,图表将如下所示:
| Series | 字符串缓存 | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
当你启用字符串缓存时,字符串不再按列出现的顺序进行编码。相反,编码在列之间共享。值 'Polar' 对于在字符串缓存下创建的所有分类列,将始终由相同的值进行编码。合并操作(例如追加、连接)再次变得廉价,因为无需首先使编码兼容,从而解决了我们上面遇到的问题。
然而,在 Series 构建过程中,字符串缓存确实会带来轻微的性能损失,因为我们需要在缓存中查找或插入字符串值。因此,如果你预先知道类别,则更推荐使用数据类型 Enum。