我们来探索Polars支持的各种非简单数据类型,理解它为什么支持这些类型,以及应在什么时候使用它们。其中许多类型你已经熟悉,或者能够凭直觉理解。本文将进一步拓展这种直觉,让你在Polars中处理数据时,能够自信地选择合适的数据类型。
不计类型变体,在本文写作时(Polars 1.14.0),Polars支持18种数据类型:
Boolean:采用高效位打包存储的布尔类型;Int8、Int16、Int32和Int64:不同精度的整数类型;UInt8、UInt16、UInt32和UInt64:不同精度的无符号整数类型;Float32和Float64:不同精度的浮点类型;Decimal:128位十进制类型,可选择精度和非负小数位数,让你精细控制数值的精度;String:变长UTF-8编码字符串,通常用于人类可读的数据;Binary:变长的任意原始二进制数据;Date:表示日历日期;Time:表示一天中的时刻;Datetime:表示日历日期和一天中的时刻;Duration:表示一段时长;Array:形状固定、元素类型一致的任意维数组;List:长度可变、元素类型一致的一维容器;Categorical:字符串数据的高效编码,其类别在运行时推断;Enum:预先确定的字符串类别集合的高效有序编码;Struct:能够存储多个字段的复合积类型;Object:封装任意Python对象;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需要三个步骤:
- 确定哪些类别是有效的,可以静态定义,也可以在可行时通过程序计算;
- 实例化
Enum,创建它的一个“变体”; - 最后,将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支持三种嵌套类型:
Struct类似键为固定字符串的Python类型化字典;List类似Python列表,但要求所有元素类型相同;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数组有两点相似:
- 所有元素必须具有相同类型;
- 数组形状必须固定。
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数据类型”。查看日程可以了解你所在时区的演讲时间;如果参加该会议,欢迎来听!(此为原文发布时的活动信息。)
脚注
-
还要加或减一,具体是哪种情况,留给你判断。↩
原文:理解Polars数据类型;作者:Rodrigo Girão Serrão;日期:2024-11-26。原文及源码权利归原作者和相应权利人所有。











暂无评论内容