理解Polars数据类型

我们来探索Polars支持的各种非简单数据类型,理解它为什么支持这些类型,以及应在什么时候使用它们。其中许多类型你已经熟悉,或者能够凭直觉理解。本文将进一步拓展这种直觉,让你在Polars中处理数据时,能够自信地选择合适的数据类型。

不计类型变体,在本文写作时(Polars 1.14.0),Polars支持18种数据类型:

  1. Boolean:采用高效位打包存储的布尔类型;
  2. Int8、Int16、Int32和Int64:不同精度的整数类型;
  3. UInt8、UInt16、UInt32和UInt64:不同精度的无符号整数类型;
  4. Float32和Float64:不同精度的浮点类型;
  5. Decimal:128位十进制类型,可选择精度和非负小数位数,让你精细控制数值的精度;
  6. String:变长UTF-8编码字符串,通常用于人类可读的数据;
  7. Binary:变长的任意原始二进制数据;
  8. Date:表示日历日期;
  9. Time:表示一天中的时刻;
  10. Datetime:表示日历日期和一天中的时刻;
  11. Duration:表示一段时长;
  12. Array:形状固定、元素类型一致的任意维数组;
  13. List:长度可变、元素类型一致的一维容器;
  14. Categorical:字符串数据的高效编码,其类别在运行时推断;
  15. Enum:预先确定的字符串类别集合的高效有序编码;
  16. Struct:能够存储多个字段的复合积类型;
  17. Object:封装任意Python对象;
  18. Null:表示空值。

较常见的类型

布尔值

布尔类型pl.Boolean的特点是“高效位打包”。表示真(1)或假(0)只需要一个比特,而一个字节有8个比特,因此Polars可以用一个字节表示8个布尔值。下面的简单实验可以验证这一点:

import random
import polars as pl

bools = [random.choice([True, False]) for _ in range(8 * 127)]
s = pl.Series(bools, dtype=pl.Boolean)
print(s.estimated_size())  # 127

这说明,当一个Series包含n个布尔值时,Polars使用n / 8个字节存储这个Series。

缺失数据

数据类型Null对应Polars中的null值,与Python的None有些相似。null表示缺失值:

s = pl.Series([1, None, 3, 4, 5, None])
print(s.count())  # 4
print(s.is_null().sum())  # 2

任何Polars Series或DataFrame列都可以在某个位置包含null,表示该位置的值缺失。Polars对所有数据类型都使用null表示缺失数据,数值类型也不例外。

一个有趣的细节是:在Polars中,使用is_null检查一列中哪些值缺失,几乎是“免费的”。这是因为Polars存储了一份有效性掩码,用布尔值指示Series中哪些元素缺失。这份掩码和布尔列一样采用高效位打包,内存开销很小:

s1 = pl.Series([1, 2, 3, 4, 5, 6, 7, 8], dtype=pl.Int64)  # 8 bytes x 8 integers = 64 bytes
print(s1.estimated_size())  # 64

# 64 bytes for the 8 integers Int64, plus the validity mask:
# 1 bit x 8 integers = 1 byte; total: 64 + 1 = 65
s2 = pl.Series([1, 2, 3, 4, None, 6, 7, 8], dtype=pl.Int64)
print(s2.estimated_size())  # 65
print(s2.is_null())
64
65
shape: (8,)
Series: '' [bool]
[
        false
        false
        false
        false
        true
        false
        false
        false
]

有符号与无符号整数类型

Int8、Int16、Int32、Int64、UInt8、UInt16、UInt32和UInt64在许多编程语言和DataFrame库中都很常见。但Python用户可能会对这些区别感到意外,因为Python能够轻松处理任意大的整数:

googol = 10 ** 100
print(googol % 99999999977)  # 11526618770

Polars不处理任意大的整数。它的有符号整数类型从Int8到Int64,Int后面的数字表示存储整数所用的比特数。由于符号需要占用一个比特,n位整数类型能够表示从−2n−1到2n−1−1的整数:

数据类型 下限 上限
Int8 -128 127
Int16 -32768 32767
Int32 -2147483648 2147483647
Int64 -9223372036854775808 9223372036854775807

你应选择能够满足需要的最低精度变体,因为精度较低的类型更节省内存。如果确定某个变量不会为负数,例如一个人的子女数量或年龄,可以使用无符号整数。无符号整数具有相同的位宽,但只表示非负数,因此其上限约为对应有符号整数的两倍1:

数据类型 上限
UInt8 255
UInt16 65535
UInt32 4294967295
UInt64 18446744073709551615

在适用时,使用无符号整数还能为代码增添一层保护:如果试图在无符号整数列中使用负整数,代码会报错。这有助于发现程序缺陷或数据问题。

整数类型有上下限,因此计算结果超出范围时,数值会发生回绕:

s = pl.Series([0, 255], dtype=pl.UInt8)  # Integers between 0 and 255.

print(s + 1)  # 255 + 1 = 256 wraps around to 0.
print("---")
print(s - 1)  # 0 - 1 = -1 wraps around to 255.
shape: (2,)
Series: '' [u8]
[
        1
        0
]
---
shape: (2,)
Series: '' [u8]
[
        255
        254
]

这种回绕通常被称为上溢或下溢,具体取决于数值变得太大还是太小。

浮点数

在Python中处理浮点数时需要注意的问题,同样适用于Polars的Float32和Float64类型。Python的float通常对应Polars的Float64。Python和Polars遵循IEEE 754标准;如果想深入了解浮点运算的限制,可以阅读该标准的介绍。

Float32和Float64列也使用null表示缺失数据。特殊浮点值NaN则用于表示某些在数学上不确定的运算结果。下面是几个例子:

pl.Series([0]) / 0

inf = float("inf")
pl.Series([inf]) - inf
pl.Series([inf]) / inf

这三种计算产生相同的输出:

shape: (1,)
Series: '' [f64]
[
        NaN
]

字符串

在Polars中处理字符串很高效,因为它在str命名空间下提供了许多实用的字符串专用函数:

print(pl.Series(["Hello, world!", "Polars is great"]).str.slice(0, 6))
shape: (2,)
Series: '' [str]
[
        "Hello,"
        "Polars"
]

由于Polars的数据类型推断机制,有时数据会被存为字符串列,但其他专用类型其实更合适。例如从文件读取时间数据时,如果不明确要求,Polars不会将其解析为时间数据类型。处理类别数据时也自然会出现字符串列,这时类别数据类型可能更合适。

时间数据类型

Polars的时间数据类型使用起来很直观,而且都与标准模块datetime中的类型十分相似:

时间数据类型 datetime中的相似类型
Date datetime.date
Time datetime.time
Datetime datetime.datetime
Duration datetime.timedelta

Polars支持数十种专门的时间表达式,可以通过dt命名空间访问。

日期、时刻与日期时间

Date表示日历日期,即日、月、年。例如,一个人的出生日期就适合用Date表示。可以从字符串解析,也可以直接用datetime.date对象创建:

from datetime import date

df = pl.DataFrame({
    "superhero": ["Superman", "Batman", "Deadpool"],
    "first_appearance": [  # Source: respective Wikipedia articles
        date(1938, 4, 18),
        date(1939, 3, 30),
        date(1990, 12, 11),
    ]
})
print(df)
shape: (3, 2)
┌───────────┬──────────────────┐
│ superhero ┆ first_appearance │
│ ---       ┆ ---              │
│ str       ┆ date             │
╞═══════════╪══════════════════╡
│ Superman  ┆ 1938-04-18       │
│ Batman    ┆ 1939-03-30       │
│ Deadpool  ┆ 1990-12-11       │
└───────────┴──────────────────┘

Time则表示一天中的时刻,即小时、分钟、秒,有时也包括小数秒。例如闹钟设定的响铃时刻适合用Time表示。与日期类似,时刻可以从字符串解析,也可以直接从datetime.time对象创建:

from datetime import time

df = pl.DataFrame({
    "superhero": ["Superman", "Batman", "Deadpool"],
    "avg_wake_up_time": [  # Source: made up numbers
        time(5, 30, 0),
        time(13, 0, 0),
        time(11, 27, 56),
    ]
})
print(df)
shape: (3, 2)
┌───────────┬──────────────────┐
│ superhero ┆ avg_wake_up_time │
│ ---       ┆ ---              │
│ str       ┆ time             │
╞═══════════╪══════════════════╡
│ Superman  ┆ 05:30:00         │
│ Batman    ┆ 13:00:00         │
│ Deadpool  ┆ 11:27:56         │
└───────────┴──────────────────┘

概括来说,Date与Time是相互独立的,因为它们没有共同单位。如果需要同时表达两者,例如下一次预约医生的日期和时间,就使用Datetime。这个类型组合了Date与Time的单位,还提供了处理棘手时区的功能。同样,可以通过解析字符串或使用datetime.datetime对象创建值:

from datetime import datetime, timedelta, timezone

now = datetime.now()
datetimes = [
    now,
    now.replace(tzinfo=timezone(timedelta(hours=1))),
    now.replace(tzinfo=timezone(timedelta(hours=-3))),
]

s = pl.Series(datetimes)
print(s)  # All values are converted to UTC.
shape: (3,)
Series: '' [datetime[μs]]
[
        2024-11-22 19:14:25.468051
        2024-11-22 18:14:25.468051
        2024-11-22 22:14:25.468051
]

Polars会将所有时刻转换为UTC,使一个Series或列中的时区保持一致。

如果为Series或列设置时区,你会看到数据类型中出现时区信息:

print(s.dt.convert_time_zone("Europe/Amsterdam"))
shape: (3,)
Series: '' [datetime[μs, Europe/Amsterdam]]  # <-- new TZ shows in the data type
[
        2024-11-25 11:23:55.322912 CET  # <-- times are adjusted to new TZ
        2024-11-25 10:23:55.322912 CET
        2024-11-25 14:23:55.322912 CET
]

数据类型中显示的另一项信息,这里是µs,表示日期时间的存储单位。如果需要更高或更低的精度,可以调整这个单位。

虽然dt命名空间提供了数十种时间专用操作,但你很可能经常使用str命名空间中将字符串解析为时间类型的函数:

表达式 目标数据类型 文档链接
.str.to_date Date 🔗
.str.to_datetime Datetime 🔗
.str.to_time Time 🔗
.str.strptime 上述三种类型之一 🔗

注意,将字符串解析为时间类型时,必须使用Rust的chrono库中的格式说明符。常用说明符与Python的datetime库相同,但两套规范并不完全一致。将时间类型格式化为字符串时也应注意这一点。

时长

对其他时间数据类型进行算术运算时,Duration类型会自然出现:

bedtime = pl.Series([
    datetime(2024, 11, 22, 23, 56),
    datetime(2024, 11, 24, 0, 23),
    datetime(2024, 11, 24, 23, 37),
])

wake_up = pl.Series([
    datetime(2024, 11, 23, 7, 30),
    datetime(2024, 11, 24, 7, 30),
    datetime(2024, 11, 25, 8, 0),
])

sleep = wake_up - bedtime
print(sleep)
shape: (3,)
Series: '' [duration[μs]]
[
        7h 34m
        7h 7m
        8h 23m
]

Binary数据类型

如果要在Series或DataFrame中表示原始二进制数据,Binary很合适。创建Binary Series的一种简单方式是传入Python的bytes对象:

s = pl.Series([b"binary", b"data", b"here"])
print(s.dtype)  # Binary

Polars在bin命名空间中提供了少量Binary专用表达式。例如,.bin.size可以返回Binary列中每个值的大小:

print(s.bin.size())
shape: (3,)
Series: '' [u32]
[
        6
        4
        4
]

Polars为许多数据类型提供了丰富的专用表达式,但Binary主要是一种便于你将数据保存在DataFrame中的类型。适用场景包括图像、音频、专有格式文件或序列化数据。

Decimal数据类型

可以将Decimal理解为Float32和Float64的一种变体,不过它允许你控制数值的小数位数。Decimal不能避免所有舍入误差,但能够防止其中一部分。

例如,下面两次加法的结果都是1.0,但这只是舍入误差造成的:

tiny = pow(10, -16)
print(f"{tiny + 1 = }")
print("With Float64:")
print(pl.Series([tiny], dtype=pl.Float64) + 1)
tiny + 1 = 1.0
With Float64:
shape: (1,)
Series: '' [f64]
[
        1.0
]

使用Decimal并设置足够的小数位数,就可以得到准确结果:

print("With Decimal(None, 24):")
print(pl.Series([tiny], dtype=pl.Decimal(None, 24)) + 1)
With Decimal(None, 24):
shape: (1,)
Series: '' [decimal[*,24]]
[
        1.000000000000000100000000
]

Decimal的第二个参数是小数点后的位数,上面的代码中设为24。第一个参数指定每个数允许的最大总位数。设为None时,Polars会推断所需的值。

Decimal没有包含专用表达式的独立命名空间,在本文写作时它被视为不稳定功能。也要理解,Decimal并不能彻底解决所有舍入误差,因为它的精度同样有限。

类别数据

类别变量只能从预先确定的一组值中取值。我一直认为,类别变量就是填写表单时适合用下拉框呈现的那些变量。例如,若要求你从下拉框中选择精确工资数额,那会很荒唐。但如果询问国籍,你就会期待一个可以快速定位自己国家的下拉列表。

在Polars中,类别变量始终源自字符串数据。Polars提供了两种相似的数据类型用于处理类别数据,下面分别介绍。

Enum数据类型

处理类别数据时,Enum是优先选择的数据类型。将一个列或Series转换为Enum需要三个步骤:

  1. 确定哪些类别是有效的,可以静态定义,也可以在可行时通过程序计算;
  2. 实例化Enum,创建它的一个“变体”;
  3. 最后,将Series或列转换为该类型:
valid_values = ["panda", "polar", "brown"]  # 1.
bear_enum = pl.Enum(valid_values)  # 2.

s = pl.Series(["panda", "polar", "panda", "brown", "panda"], dtype=bear_enum)  # 3.
print(s)
shape: (5,)
Series: '' [enum]
[
        "panda"
        "polar"
        "panda"
        "brown"
        "panda"
]

你可能觉得打印出来的Series与字符串Series毫无区别。确实,打印或目视检查时,它们看起来完全一样。

但在底层,Polars知道只有一组固定的字符串才是合法值,因此能够更高效地操作Enum Series。这样既能节省内存,通常也能提高操作速度。

如果包含不属于枚举集合的值,Polars也会报错:

s = pl.Series(["pand", "snake"], dtype=bear_enum)
# InvalidOperationError: conversion from `str` to `enum` failed in column '' for 2 out of 2 values: ["pand", "snake"]

这可以帮助发现数据问题,从拼写错误到完全错误的值。

Enum还有一个优点:可以将类别作为有序数据使用。上面熊的类别没有合理的顺序,但如果类别变量代表完成的正规教育程度,就存在有意义的顺序:

education_level = ["High school", "BSc", "MSc", "PhD"]
education_enum = pl.Enum(education_level)

people = pl.DataFrame({
    "name": pl.Series(["A", "B", "C", "D"]),
    "degree": pl.Series(["High school", "MSc", "PhD", "MSc"], dtype=education_enum),
})

print(people.filter(pl.col("degree") >= "MSc"))
shape: (3, 2)
┌──────┬────────┐
│ name ┆ degree │
│ ---  ┆ ---    │
│ str  ┆ enum   │
╞══════╪════════╡
│ B    ┆ MSc    │
│ C    ┆ PhD    │
│ D    ┆ MSc    │
└──────┴────────┘

Categorical数据类型

处理类别数据时,也可以使用Categorical。

使用Categorical时,不必提前指定有效值,Polars会替你推断。这听起来似乎全面优于Enum,但这种推断是有代价的。

与Enum相比,Categorical的缺点包括:

  • 当操作两个本应具有相同类别、却独立创建的列时,性能较低;
  • 不能发现无效值。

当然并非全是缺点,有时Categorical正是你需要的类型。经验法则是:只有在实践中无法合理使用Enum时,才使用Categorical。

无论使用Enum还是Categorical,都可以使用cat命名空间及其唯一函数get_categories,获取当前用作类别的唯一值:

s = pl.Series(["panda", "polar", "pand", "snake", "panda"], dtype=pl.Categorical)
print(s.cat.get_categories().to_list())
# ['panda', 'polar', 'pand', 'snake']

嵌套数据类型

嵌套数据类型类似Python容器,其内部包含其他数据。Polars支持三种嵌套类型:

  1. Struct类似键为固定字符串的Python类型化字典;
  2. List类似Python列表,但要求所有元素类型相同;
  3. Array类似NumPy数组,所有元素类型相同,而且数组本身的形状固定。

Struct数据类型

可以大致把Struct看作字符串键的Python字典。如果一列的类型是Struct,所有行都具有相同的字符串键,因此typing.TypedDict比内置的dict更接近它。

看到一个自然产生Struct的场景,就能理解为什么需要它:

df = pl.DataFrame({
    "name": ["A", "B", "C", "D"],
    "favourite_sport": ["basketball", "baseball", "soccer", "basketball"],
})
print(df.select(pl.col("favourite_sport").value_counts()))
shape: (3, 1)
┌──────────────────┐
│ favourite_sport  │
│ ---              │
│ struct[2]        │
╞══════════════════╡
│ {"baseball",1}   │
│ {"soccer",1}     │
│ {"basketball",2} │
└──────────────────┘

{"baseball",1}表示"baseball"在"favourite_sport"列中出现了1次。之所以把值和对应计数放在一起,是因为单个表达式应只输出一列。我们在select上下文中只写了一个表达式pl.col("favourite_sport").value_counts(),因此输出也应该是单列。

由于Struct类似字典,可以通过字段“键”提取不同值:

df = pl.DataFrame({
    "name": ["A", "B", "C", "D"],
    "favourite_sport": ["basketball", "baseball", "soccer", "basketball"],
})
counts = df.select(pl.col("favourite_sport").value_counts())

print(counts.select(
    pl.col("favourite_sport").struct.field("favourite_sport"),
    pl.col("favourite_sport").struct.field("count"),
))
shape: (3, 2)
┌─────────────────┬───────┐
│ favourite_sport ┆ count │
│ ---             ┆ ---   │
│ str             ┆ u32   │
╞═════════════════╪═══════╡
│ soccer          ┆ 1     │
│ basketball      ┆ 2     │
│ baseball        ┆ 1     │
└─────────────────┴───────┘

struct命名空间提供了专门处理Struct的函数。使用field可以访问其中某个值。如果想把一个Struct列的所有字段提取成各自的列,可以使用.struct.unnest:

df = pl.DataFrame({
    "name": ["A", "B", "C", "D"],
    "favourite_sport": ["basketball", "baseball", "soccer", "basketball"],
})
print(
    df.select(
        pl.col("favourite_sport").value_counts()
        .struct.unnest()
    )
)
shape: (3, 2)
┌─────────────────┬───────┐
│ favourite_sport ┆ count │
│ ---             ┆ ---   │
│ str             ┆ u32   │
╞═════════════════╪═══════╡
│ baseball        ┆ 1     │
│ basketball      ┆ 2     │
│ soccer          ┆ 1     │
└─────────────────┴───────┘

一个表达式只能输出一列。类似地,在某些场景中,特别是使用自定义函数时,表达式也会期待单个表达式作为输入。这时可能需要把多个列打包进一个Struct列中。

(既然单个表达式只能输出一列,为什么上例中以.struct.unnest结尾的单个表达式却生成了两列DataFrame?答案见这篇介绍表达式展开的博客文章。)

List数据类型

只要使用过Python列表,就很容易理解List:它是长度可变的一维容器。Python内置列表与Polars List的主要区别是,Polars要求List中的元素类型一致:

list_example = pl.Series([
    [1, 2, 3],
    [],
    [4, 5],
    [6],
])
print(list_example)

failed = pl.Series([
    [1, 2, 3],
    [],
    ["four", "five"],
    [6],
])
shape: (4,)
Series: '' [list[i64]]
[
        [1, 2, 3]
        []
        [4, 5]
        [6]
]

TypeError: unexpected value while building Series of type List(Int64)

Polars在list命名空间中提供了数十个函数,专门处理List列。List相当灵活,但更多灵活性通常意味着较低性能。Array较不灵活,因此性能更好,下面介绍它。

Array数据类型

Array与NumPy数组有两点相似:

  1. 所有元素必须具有相同类型;
  2. 数组形状必须固定。

Array的潜在用途包括图像集合、矩阵以及井字棋棋盘。

创建Array列或Series时,所有数组必须具有相同形状。可以是长度始终相同的简单一维列表:

print(
    pl.Series(
        [
            ["Harry", "Potter"],
            ["Hermione", "Granger"],
            ["Ron", "Weasley"],
        ],
        dtype=pl.Array(pl.String, (2,)),
    )
)
shape: (3,)
Series: '' [array[str, 2]]
[
        ["Harry", "Potter"]
        ["Hermione", "Granger"]
        ["Ron", "Weasley"]
]

也可以在列表中嵌套列表,只要所有列表的嵌套结构相同。

当值保存在Python列表中时,即使它们长度都相同,也必须明确指定Array类型。第一个参数是元素的数据类型,第二个参数通常是指定形状的元组。

只有当传入一个NumPy数组作为所有值时,Polars才会推断Array类型。这时Polars知道这个数组的所有子数组形状相同,因此可以安全推断。

例如,如果希望用下面三个三维数组创建Array(pl.Int64, (2, 2, 2))类型的Polars Series:

import numpy as np

np.array(range(8)).reshape((2, 2, 2))
np.array(range(8, 16)).reshape((2, 2, 2))
np.array(range(16, 24)).reshape((2, 2, 2))

就需要创建一个四维数组,把它们作为第一维上的子数组:

major = np.array(range(24)).reshape(3, 2, 2, 2)
print(pl.Series(major))
shape: (3,)
Series: '' [array[i64, (2, 2, 2)]]
[
        [[[0, 1], [2, 3]], [[4, 5], [6, 7]]]
        [[[8, 9], [10, 11]], [[12, 13], [14, 15]]]
        [[[16, 17], [18, 19]], [[20, 21], [22, 23]]]
]

Polars在推断Array时如此保守,是因为检查所有值是否具有完全相同的形状需要时间。

Polars在arr命名空间中实现了许多数组专用函数。arr中所有函数都在list中有对应版本,唯一例外是arr.to_list,它将Array列转换为List列。不过,由于数组的形状固定且一致,arr函数通常比对应的list函数更高效。

Object数据类型

如果读完全文仍找不到适合的类型,Polars可能确实不支持你需要的类型。即便如此,它仍允许通过Object在Series中保存任意Python对象。这是一种兜底类型,由于过于通用,Polars无法提供专门处理任意对象的函数。这与Python的object类似:除了所有对象共有的极为通用的行为外,object本身没有实现其他行为。

下面是Object类型Series的示例:

functions = pl.Series([enumerate, zip, max, min, all, any, sorted])
print(functions)
shape: (7,)
Series: '' [o][object]
[
        <class 'enumerate'>
        <class 'zip'>
        <built-in function max>
        <built-in function min>
        <built-in function all>
        <built-in function any>
        <built-in function sorted>
]

汇总表

下面建立Polars数据类型与部分Python类型的对应关系。这并不表示两者始终等价,而是可以借助Python中的相近类型理解Polars类型。

Polars数据类型…… 类似Python的……
Boolean bool
Int8、Int16、Int32和Int64 有取值限制的int
UInt8、UInt16、UInt32和UInt64 有取值限制的int
Float32和Float64 float
Decimal decimal.Decimal
String str
Binary bytes
Date datetime.date
Time datetime.time
Datetime datetime.datetime
Duration datetime.timedelta
Array numpy.array
List list
Categorical enum.StrEnum
Enum enum.StrEnum
Struct typing.TypedDict
Object object
Null None

版本范围:Polars 1.14.0。

PyData Global 2024

2024年12月3日,我们将在线上会议PyData Global 2024中演讲“理解Polars数据类型”。查看日程可以了解你所在时区的演讲时间;如果参加该会议,欢迎来听!(此为原文发布时的活动信息。)

脚注

  1. 还要加或减一,具体是哪种情况,留给你判断。↩


原文:理解Polars数据类型;作者:Rodrigo Girão Serrão;日期:2024-11-26。原文及源码权利归原作者和相应权利人所有。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容