数据框#

数据框(DataFrame)是 Polars 的核心数据结构,表示由行和列组成的二维数据:每列内部类型一致,列与列之间可以不同。

这种“按列存储、按列管理类型”的设计是 Polars 高效的基础,也决定了它一贯的用法:尽量按列操作,尽量不逐行取值。这一章先讲如何创建数据框,再讲如何从中取列、取行和取单个值。

读这一章时可以把 DataFrame 想成“一组等长的列”,而不是“一组行”。列是存储和计算的基本单位,行只是各列在同一位置上的组合;后面的取数方法、性能差异,都从这个结构来。

import polars as pl
from helper.jupyter import row

这一节说明如何从 Python 的其他对象创建数据框。大部分时候数据来自文件,不必手写,但测试和小样例总要先创建表。

创建方式决定了两件事:列名从哪里来,类型如何推断。用 dict[list] 时,列名就是字典的键,每个列表是一列;用 list[dict] 时,每个字典是一行,键是列名。用 list[list] 时,列名默认是 column_0、column_1 这样的占位名,需要自定义时要另外传入 schema,并用 orient 说明内层列表是行还是列。

类型由 Polars 从值推断:一列里只有整数和 None 时仍然是 Int64,整数和字符串混在一列则会直接报错,需要 strict=False 才允许,此时整列会变成 String。如果类型很重要,最好在创建时用 schema 显式指定,而不是依赖推断。

列表与字典的组合#

下面的代码用三种不同的 Python 数据结构创建 DataFrame,三者的区别在于“哪个方向是行、哪个方向是列”。

  1. 字典的列表(list[dict]):字典的键成为列名,列表里的每个字典是一行数据。

  2. 列表的字典(dict[list]):字典的键成为列名,每个列表的元素就是该列的数据。

  3. 列表的列表(list[list]):列名由 schema 参数指定。

    • orient 为 'row' 时,内层列表是一行。

    • orient 为 'col' 时,内层列表是一列;这时各列类型可能不一致,需要 strict=False 允许自动转换。

# list[dict]
dict_in_list = [
    {"name": "Alice", "age": 30},
    {"name": "Bob", "age": 25},
    {"name": "Charlie", "age": 35}
]
df1 = pl.DataFrame(dict_in_list)

# dict[list]
list_in_dict = {
    "name": ["Alice", "Bob", "Charlie"],
    "age": [30, 25, 35],
}
df2 = pl.DataFrame(list_in_dict)

# list[list]
list_in_list = [
    ["Alice", 30],
    ["Bob", 25],
    ["Charlie", 35]
]
columns = ["name", "age"]  # 指定列名
df3 = pl.DataFrame(list_in_list, schema=columns, orient='row')
df4 = pl.DataFrame(list_in_list, schema=['p1', 'p2', 'p3'], orient='col', strict=False)
row(df1, df2, df3, df4)
shape: (3, 2)
nameage
stri64
"Alice"30
"Bob"25
"Charlie"35
shape: (3, 2)
nameage
stri64
"Alice"30
"Bob"25
"Charlie"35
shape: (3, 2)
nameage
stri64
"Alice"30
"Bob"25
"Charlie"35
shape: (2, 3)
p1p2p3
strstrstr
"Alice""Bob""Charlie"
"30""25""35"

字典里还可以嵌套字典:下面的 data 就是这种混合结构。

  • "point" 键的值是字典的列表(list[dict]),每个字典包含 x 和 y 两个键。

  • "weight" 键的值是整数的列表(list[int])。

转换为数据框时,外层字典的键成为列名,point 列的元素会变成 Struct 类型(结构体),嵌套结构被保留下来,没有被展平。

data = {
    "point": [{"x": 1, "y": 2}, {"x": 3, "y": 4}, {"x": 5, "y": 6}],
    "weight": [5, 4, 8],
}
pl.DataFrame(data)
shape: (3, 2)
pointweight
struct[2]i64
{1,2}5
{3,4}4
{5,6}8

NumPy 数组#

NumPy 数组可以像列表一样使用,Polars 会按数组的维度决定方向。

  • dict[list] 与 dict[一维数组] 等价。

  • list[list] 与二维数组等价。

区别在于数组自带类型信息,转换时不必再推断。

import numpy as np
array_in_dict = {
    "x": np.array([1, 3, 5]),
    "y": np.array([2, 4, 6]),
}

df1 = pl.DataFrame(array_in_dict)

array_2d = np.array([[1, 2], [3, 4], [5, 6]])
df2 = pl.DataFrame(array_2d, schema=['x', 'y'], orient='row')
df3 = pl.DataFrame(array_2d, schema=['p1', 'p2', 'p3'], orient='col')
row(df1, df2, df3)
shape: (3, 2)
xy
i64i64
12
34
56
shape: (3, 2)
xy
i64i64
12
34
56
shape: (2, 3)
p1p2p3
i64i64i64
135
246

把一维结构化数组转换为数据框时,数组的每个字段成为数据框的一列,字段名直接作为列名,这是从 NumPy 过渡到 Polars 最直接的方式。

arr = np.array([
    (1, 30),
    (2, 25),
    (3, 35)], dtype=[('x', 'i2'), ('y', 'i2')])

pl.DataFrame(arr)
shape: (3, 2)
xy
i16i16
130
225
335

含 Series 的数据#

处理 pl.Series 时,常把 list[Series] 或 dict[Series] 转换为数据框。在两种情况中,每个 Series 都成为一列,但列名来源不同。

  • list[Series]:列名直接使用 Series 的名字。

  • dict[Series]:列名使用字典的键。

第二种方式更明确,改名时只需修改字典的键,不必修改 Series 本身。

sx = pl.Series('x', [1, 2, 3])
sy = pl.Series('y', [4, 5, 6])

df1 = pl.DataFrame([sx, sy])
df2 = pl.DataFrame({'A':sx, 'B':sy})
row(df1, df2)
shape: (3, 2)
xy
i64i64
14
25
36
shape: (3, 2)
AB
i64i64
14
25
36

pl.from_*() 函数#

以 from_ 开头的函数用来把各种数据类型转换为数据框,参数和语义都写得很明确。

使用这些函数而不是直接调用 pl.DataFrame,好处是不会发生意料之外的转换,类型对不上时立刻报错,代码更稳健。

  • pl.from_dict():从 dict[list] 的数据转换。

  • pl.from_dicts():从 list[dict] 的数据转换。

  • pl.from_numpy():从 NumPy 数组转换。

  • pl.from_records():从 list[list] 的数据转换。

  • pl.from_pandas():从 Pandas 的 DataFrame 转换。

  • pl.from_arrow():从 pyarrow 的 Array 或 Table 转换。

数据框的属性#

df = pl.DataFrame(
    {
        "a": [3, 3, 3, 4],
        "b": [4.0, 12, 6, 7],
        "g": ['A', 'B', 'A', 'B']
    }
)

shape 属性给出数据框的形状(高、宽),返回一个二元组,例如 (4, 3) 表示 4 行 3 列。

height 和 width 分别取高和宽,len() 也等于高。三者给出的是同一组数字,按可读性选择一个即可:遍历时用 len(df),需要同时取得两个维度时用 df.shape。

这些属性都是 \(O(1)\) 的,只读取元数据,不会扫描数据。

print(f"{df.shape = }")
print(f"{df.height = }")
print(f"{df.width = }")
print(f"{len(df) = }")
df.shape = (4, 3)
df.height = 4
df.width = 3
len(df) = 4

columns 属性给出列名列表,顺序与表中一致。要按名字遍历或拼接新列名时,通常先获取它。

df.columns
['a', 'b', 'g']

schema 属性给出“列名 → 类型”的字典,比 dtypes 额外包含列名。它返回的是一个 Schema 对象,可以像字典一样按键取值,例如 df.schema['g']。

排查类型问题时先查看它,可以直接看出某列被读成了字符串还是整数。dtypes 只给类型列表,适合按位置处理;需要知道“哪一列是什么类型”时,schema 更直接。

df.schema
Schema([('a', Int64), ('b', Float64), ('g', String)])
df.schema['g']
String

dtypes 属性给出各列类型的列表,顺序与 columns 一致。只需要类型、不需要名字时使用它。

df.dtypes
[Int64, Float64, String]

flags 属性给出各列的排序状态,也就是 Polars 是否“知道这列已经排好序”。它不是对数据做检查,而是记录一个声明:set_sorted() 会把列标记为已排序,之后的排序、范围过滤、join 都可能利用这个信息。

这个标记会影响优化:某列标记为已排序后,范围查询和归并就能省掉一次排序。反过来,如果数据实际上没有排好序却标了 set_sorted(),结果就可能出错。

df.flags
{'a': {'SORTED_ASC': False, 'SORTED_DESC': False},
 'b': {'SORTED_ASC': False, 'SORTED_DESC': False},
 'g': {'SORTED_ASC': False, 'SORTED_DESC': False}}

从数据框取数据#

这一节说明如何从数据框取出列、行和单个值。

原则和前面一致:能用列操作就不要逐行取值。取列通常只是换一层包装,底层数据仍然共享;取行或取单个值则要跨越列式存储,把分散在各列的值组合成 Python 对象,既慢又会丢失类型信息。下面的方法按“取列、取行、取单个值”的顺序介绍。

取列#

在 Polars 里把数据框的列取为 Series 有几种方法,区别在于“用什么定位”和“取一列还是多列”。

  • DataFrame.to_series():按下标取列。

  • DataFrame.get_column():按列名取列。

  • DataFrame.get_columns():取出所有列。

  • DataFrame.iter_columns():得到列的迭代器。

按名字取列最常用,因为列的顺序可能随上游变化;按下标取只适合顺序固定的场合。

s1 = df.to_series(0)
s2 = df.get_column('b')
s3 = df['g']
row(s1, s2, s3)
shape: (4,)
a
i64
3
3
3
4
shape: (4,)
b
f64
4.0
12.0
6.0
7.0
shape: (4,)
g
str
"A"
"B"
"A"
"B"

DataFrame.get_columns() 把数据框的所有列取为 Series 的列表,顺序与 columns 相同,可以直接解包成多个变量。

sa, sb, sg = df.get_columns()
row(sa, sb, sg)
shape: (4,)
a
i64
3
3
3
4
shape: (4,)
b
f64
4.0
12.0
6.0
7.0
shape: (4,)
g
str
"A"
"B"
"A"
"B"

DataFrame.iter_columns() 逐个返回数据框的所有列,适合“对每列做同一件事”的循环,也比 get_columns() 少创建一个列表。

for col in df.iter_columns():
    print(col.name, col.to_list())
a [3, 3, 3, 4]
b [4.0, 12.0, 6.0, 7.0]
g ['A', 'B', 'A', 'B']

Series 对象#

print(f"{s1.name = }")
print(f"{s1.dtype = }")
print(f"{s1.flags = }")
print(f"{s1.shape = }")
print(f"{len(s1) = }")
s1.name = 'a'
s1.dtype = Int64
s1.flags = {'SORTED_ASC': False, 'SORTED_DESC': False}
s1.shape = (4,)
len(s1) = 4

to_numpy() 方法#

用 Series.to_numpy() 或 Series.to_list() 可以把 Series 转换为 NumPy 数组或列表。

to_numpy() 通常不复制数据(零拷贝),得到的是与原 Series 共享底层内存的数组;只有类型无法直接映射到 NumPy 时,才会退回 object 数组并复制。to_list() 则一定会新建 Python 对象,所以只在需要与 Python 生态交互时使用后者。

零拷贝意味着数组与原 Series 共享内存;如果需要一个独立的副本,应显式调用 .copy()。

print(f'{sa.to_numpy() = }')
print(f'{sa.to_list() = }')
sa.to_numpy() = array([3, 3, 3, 4])
sa.to_list() = [3, 3, 3, 4]

DataFrame.to_numpy() 把整张表转换为 NumPy 数组,默认把所有值统一到最宽的类型。纯数值的表会得到二维数组,类型由所有列的共同上位类型决定。

数值与字符串混在一起时,得到的是 dtype=object 的数组,类型信息随之丢失,后面的计算会很慢,所以混合类型的表最好不要整体转换为 NumPy。需要保留各列类型时,用 to_numpy(structured=True) 或先把列分开处理。

df.to_numpy()
array([[3, 4.0, 'A'],
       [3, 12.0, 'B'],
       [3, 6.0, 'A'],
       [4, 7.0, 'B']], dtype=object)

structured 参数设为 True 时,转换为结构化数组:每个字段一列,字段名保留,类型也不会丢失。

df.to_numpy(structured=True)
array([(3,  4., 'A'), (3, 12., 'B'), (3,  6., 'A'), (4,  7., 'B')],
      dtype=[('a', '<i8'), ('b', '<f8'), ('g', '<U1')])

Series 的元素是列表时,用 to_numpy() 得到 dtype=object 的数组,每个元素又是一个数组。

这种结构既没有列式存储的缓存优势,也没有 NumPy 的向量化,需要数值计算时,先用 list.to_array() 或 explode() 把结构展平。

s_list = pl.Series('s_list', [[1, 2, 3], [4, 5, 6], [7, 8, 9]])
s_list.to_numpy()
array([array([1, 2, 3]), array([4, 5, 6]), array([7, 8, 9])], dtype=object)

Series 的元素是 Array 时,to_numpy() 得到二维数组:长度固定,所以能直接组成矩阵。

s_arr = pl.Series('s_arr', [[1, 2, 3], [4, 5, 6], [7, 8, 9]], dtype=pl.Array(pl.Int8, 3))
s_arr.to_numpy()
array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]], dtype=int8)

Series 的元素是 Struct 时,to_numpy() 得到二维数组,元素类型统一为最宽的类型;下面的示例中 Int64 和 Float64 都统一为 Float64。

s_struct = pl.Series('s_struct', [{"x": 1, "y": 2.0}, {"x": 3, "y": 4.0}, {"x": 5, "y": 6.0}])
s_struct.to_numpy()
array([[1., 2.],
       [3., 4.],
       [5., 6.]])

要保留字段名和各自的类型,就转换为 NumPy 结构化数组:先用 struct.unnest() 转换为 DataFrame,再用 to_numpy(structured=True)。

s_struct.struct.unnest().to_numpy(structured=True)
array([(1, 2.), (3, 4.), (5, 6.)], dtype=[('x', '<i8'), ('y', '<f8')])

to_dict() 把数据框转换为 dict[Series] 类型的字典:键是列名,值是列本身,与 Pandas 的字典视图不同,它不会复制数据。

df.to_dict()
{'a': shape: (4,)
 Series: 'a' [i64]
 [
 	3
 	3
 	3
 	4
 ],
 'b': shape: (4,)
 Series: 'b' [f64]
 [
 	4.0
 	12.0
 	6.0
 	7.0
 ],
 'g': shape: (4,)
 Series: 'g' [str]
 [
 	"A"
 	"B"
 	"A"
 	"B"
 ]}

to_dicts() 方法#

to_dicts() 转换为 list[dict] 类型的列表,每行一个字典。

这是最贴近 Python 的形式,适合交给 JSON 序列化或第三方库。代价是每行都要新建对象,在大表上应谨慎使用。

df.to_dicts()
[{'a': 3, 'b': 4.0, 'g': 'A'},
 {'a': 3, 'b': 12.0, 'g': 'B'},
 {'a': 3, 'b': 6.0, 'g': 'A'},
 {'a': 4, 'b': 7.0, 'g': 'B'}]

to_arrow() 方法#

to_arrow() 转换为 pyarrow 的 Table 对象。

这个 Table 与 DataFrame 共用同一块内存,所以转换很快,适合把数据交给 Arrow 生态(DuckDB、pandas、Spark)时使用。

df.to_arrow()
pyarrow.Table
a: int64
b: double
g: large_string
----
a: [[3,3,3,4]]
b: [[4,12,6,7]]
g: [["A","B","A","B"]]

取行#

取行的几个方法如下。它们都会离开列式存储、回到 Python 对象,所以只适合少量读取。行式访问要把同一位置上的各列值组合成一个元组或字典,列越多、行越大,开销越明显;需要批量处理时,应尽量留在表达式里完成。

  • DataFrame.row(index):把指定下标的那一行取为元组。

  • DataFrame.rows():把所有行取为元组的列表。

  • DataFrame.iter_rows():逐行迭代,每行作为元组返回。

  • DataFrame.rows_by_key():按指定的键对行分组,把各组内的行作为元组的列表返回。

DataFrame.row(index) 取出指定下标(行号)对应的行,以元组形式返回。下标从 0 开始,与 [] 的整数下标一致。

df.row(2)
(3, 6.0, 'A')

named 参数设为 True 时返回带列名的字典,调试时比元组可读性更好。

df.row(2, named=True)
{'a': 3, 'b': 6.0, 'g': 'A'}

给 by_predicate 传入条件表达式,可以取出条件为 True 的行。符合条件的行有多条时会报错,因为一个元组放不下。

df.row(by_predicate=pl.col.a == pl.col.a.max())
(4, 7.0, 'B')

DataFrame.rows() 把所有行取为元组的列表,想按行处理整个数据框时很方便。

DataFrame.iter_rows() 则逐行迭代,不必一次把所有行读入内存,处理大表时使用后者。

df.rows()
[(3, 4.0, 'A'), (3, 12.0, 'B'), (3, 6.0, 'A'), (4, 7.0, 'B')]
df.rows(named=True)
[{'a': 3, 'b': 4.0, 'g': 'A'},
 {'a': 3, 'b': 12.0, 'g': 'B'},
 {'a': 3, 'b': 6.0, 'g': 'A'},
 {'a': 4, 'b': 7.0, 'g': 'B'}]
for row_ in df.iter_rows():
    print(row_)
(3, 4.0, 'A')
(3, 12.0, 'B')
(3, 6.0, 'A')
(4, 7.0, 'B')

DataFrame.rows_by_key() 按指定的键(列)对行分组,把每个键对应的行作为元组的列表返回。

适合“按主键取几条记录”的查表操作,但比 join 慢得多,批处理时仍应使用 join。

df.rows_by_key('g')
defaultdict(list, {'A': [(3, 4.0), (3, 6.0)], 'B': [(3, 12.0), (4, 7.0)]})
df.rows_by_key('g', named=True)
defaultdict(list,
            {'A': [{'a': 3, 'b': 4.0}, {'a': 3, 'b': 6.0}],
             'B': [{'a': 3, 'b': 12.0}, {'a': 4, 'b': 7.0}]})

单个值#

DataFrame.item() 从数据框取出单个值,参数是行、列的位置或名称。

df.item(2, 'a')
3

数据框里只有一个值时,参数可以省略。常见用法是先算出统计量,再取出来参与其他计算,例如算完平均值后把它作为条件。

df.select(pl.col('a').mean()).item() / 100
0.0325

用 [] 做下标操作#

Polars 的 DataFrame 和 NumPy、Pandas 一样支持用 [] 做下标操作。写法简短,但语义取决于下标的类型:整数走行,字符串走列,切片在行和列上的开闭规则还不一样。

[] 适合交互式地查看数据,不适合写进正式的处理流程。

警告

[] 虽然能用,但并不推荐:filter()、select() 这类方法配合表达式更清楚,也不容易因为“整数当行、字符串当列”的规则写错。

下面的例子把各种下标组合逐一演示,目的不是列出全部规则,而是看清它们为什么容易出错。

df = pl.DataFrame({
    "a": [1, 2, 3, 4],
    "b": [5, 6, 7, 8],
    "c": ["x", "y", "z", "w"],
    "d": ["a", "b", "c", "d"]
})

取元素#

下标只有 1 个时

只给一个下标时,行为因类型而异:整数走“行”,字符串走“列”。这是最容易混淆的地方,df[0] 取第一行,df["a"] 取 a 列。

  1. 整数、整数列表、整数切片:选行。切片不含结束值,与 Python 的 range 一致。

row(df[0], df[[0, 2]], df[1:3])
shape: (1, 4)
abcd
i64i64strstr
15"x""a"
shape: (2, 4)
abcd
i64i64strstr
15"x""a"
37"z""c"
shape: (2, 4)
abcd
i64i64strstr
26"y""b"
37"z""c"
  1. 字符串、字符串列表、字符串切片:选列。

注意这里的切片包含结束值:df["a":"c"] 会取到 a、b、c 三列,与行切片的规则相反。

row(df["a"], df[["a", "d"]], df["a":"c"])
shape: (4,)
a
i64
1
2
3
4
shape: (4, 2)
ad
i64str
1"a"
2"b"
3"c"
4"d"
shape: (4, 3)
abc
i64i64str
15"x"
26"y"
37"z"
48"w"

下标有 2 个时

给两个下标时,第一个用于选行,第二个用于选列,写法接近 NumPy。

  • 行的下标使用整数、整数列表或切片。

  • 列的下标可以使用整数、字符串、整数列表、字符串列表或切片。

  1. 两个下标都是单个值:取出特定元素,结果是 Python 的基本类型(不是 Series)。

print(f"{df[0, 'b'] = }")  # 取出第 0 行、列 "b" 的值
print(f"{df[0, 1] = }")    # 取出第 0 行、列下标 1 的值
df[0, 'b'] = 5
df[0, 1] = 5
  1. 列的下标是列表或切片:结果仍是 DataFrame 对象,因为选择了多列,形状仍是二维的。

row(df[0, ["a", "c"]], df[1, "a":"c"], df[2, [0, 1, 3]], df[3, ::2])
shape: (1, 2)
ac
i64str
1"x"
shape: (1, 3)
abc
i64i64str
26"y"
shape: (1, 3)
abd
i64i64str
37"c"
shape: (1, 2)
ac
i64str
4"w"
row(df[[1, 2], ["a", "c"]], df[[1, 2], "a":"c"], df[2:, [0, 1, 3]], df[2:, ::2])
shape: (2, 2)
ac
i64str
2"y"
3"z"
shape: (2, 3)
abc
i64i64str
26"y"
37"z"
shape: (2, 3)
abd
i64i64str
37"c"
48"d"
shape: (2, 2)
ac
i64str
3"z"
4"w"
  1. 行的下标是列表或切片、列的下标是单个元素:返回 Series 对象,内容是所选那一列的行数据。

记住这条分界:列只有一个值时返回 Series,多个值时返回 DataFrame。

row(df[[1, 2], "a"], df[1:, 2])
shape: (2,)
a
i64
2
3
shape: (3,)
c
str
"y"
"z"
"w"

[] 也能修改数据,方法有两种。

  1. 整列替换:指定列名的列表,赋入形状相同的 NumPy 数组。这是“替换整列”而不是“逐格修改”,所以形状必须对齐。

# 设置列 "a" 的数据
df[["a"]] = np.array([[10], [20], [30], [40]])
df
shape: (4, 4)
abcd
i64i64strstr
105"x""a"
206"y""b"
307"z""c"
408"w""d"
# 同时设置列 "a" 和列 "b" 的数据
df[["a", "b"]] = np.array([[100, 200, 300, 400], [50, 60, 70, 80]]).T
df
shape: (4, 4)
abcd
i64i64strstr
10050"x""a"
20060"y""b"
30070"z""c"
40080"w""d"
  1. 指定行列,修改特定元素。

  • 行的下标:使用整数或整数列表。

  • 列的下标:使用整数或字符串。

这种写法只适合少量修改:每次赋值都可能复制整列,在循环中修改几千个值会明显变慢。真正需要批量修改时,应使用 with_columns() 配合 when().then()。

# 修改单个行列的值
df[1, "a"] = -1
df
shape: (4, 4)
abcd
i64i64strstr
10050"x""a"
-160"y""b"
30070"z""c"
40080"w""d"
# 修改多行的数据
df[[1, 3], "b"] = -1, -2
df
shape: (4, 4)
abcd
i64i64strstr
10050"x""a"
-1-1"y""b"
30070"z""c"
400-2"w""d"