连接

join将一个或多个DataFrame中的列组合成一个新的DataFrame, 不同类型的连接使用不同的**"连接策略"**和匹配条件会影响列的组合方式以及连接结果中包含的行

最常见的连接类型是"等值连接". Polars支持多种等值连接策略, 这些策略决定了如何处理行匹配. Polars还支持"非等值连接", 即匹配条件为不想等的连接类型, 以及一种通过键的接近性匹配行的连接类型, 成为"asof连接"

等值连接

等值连接中, 通过检查表达式的相等性来匹配行, 话不多说, 看代码简单易懂

1import polars as pl
2df = pl.DataFrame(
3    {
4        "foo": [1, 2, 3],
5        "bar": [6.0, 7.0, 8.0],
6        "ham": ["a", "b", "c"]
7    }
8)
9other_df = pl.DataFrame(
10    {
11        "apple": ["x", "y", "z"],
12        "ham": ["a", "b", "d"]
13    }
14)
15new_df = df.join(other_df, on="ham")
16
17print(new_df)

上面代码的第15行中的on参数指定了根据哪一列来进行匹配连接, 这里选择了ham列, 因为dfother_dfham列都有"a""b", 所以最后结果有两行

1shape: (2, 4)
2┌─────┬─────┬─────┬───────┐
3│ foo ┆ bar ┆ ham ┆ apple │
4│ --- ┆ --- ┆ --- ┆ ---   │
5│ i64 ┆ f64 ┆ str ┆ str   │
6╞═════╪═════╪═════╪═══════╡
7│ 1   ┆ 6.0 ┆ a   ┆ x     │
8│ 2   ┆ 7.0 ┆ b   ┆ y     │
9└─────┴─────┴─────┴───────┘

动态计算连接的键

如果我们想进行连接的两个列的列名不同, 然后其中一列还想做些处理再进行匹配, 可以看下面代码

1import polars as pl
2df = pl.DataFrame(
3    {
4        "foo": [1, 2, 3],
5        "bar": [6.0, 7.0, 8.0],
6        "ham": ["a", "b", "c"]
7    }
8)
9other_df = pl.DataFrame(
10    {
11        "apple": ["x", "y", "z"],
12        "ham_other": ["A", "B", "D"]
13    }
14)
15new_df = df.join(other_df,
16                     left_on="ham",
17                     right_on=pl.col("ham_other").str.to_lowercase()
18				 )
19
20print(new_df)

我们指定left_onright_on参数, left_on指定了df的列名, right_on指定了other_df的列名, pl.col("ham_other").str.to_lowercase()other_dfham_other列转换为小写然后再进行匹配

1shape: (2, 5)
2┌─────┬─────┬─────┬───────┬───────────┐
3│ foo ┆ bar ┆ ham ┆ apple ┆ ham_other │
4│ --- ┆ --- ┆ --- ┆ ---   ┆ ---       │
5│ i64 ┆ f64 ┆ str ┆ str   ┆ str       │
6╞═════╪═════╪═════╪═══════╪═══════════╡
7│ 1   ┆ 6.0 ┆ a   ┆ x     ┆ A         │
8│ 2   ┆ 7.0 ┆ b   ┆ y     ┆ B         │
9└─────┴─────┴─────┴───────┴───────────┘

连接策略

内连接

这是默认策略, 生成的DataFrame仅来自左右两个DataFrame中匹配的行, 我们还用第一个例子来展示, 结果只包含ham相等的. 结果不包含dfc这一行和other_dfd这一行

1import polars as pl
2df = pl.DataFrame(
3    {
4        "foo": [1, 2, 3],
5        "bar": [6.0, 7.0, 8.0],
6        "ham": ["a", "b", "c"]
7    }
8)
9other_df = pl.DataFrame(
10    {
11        "apple": ["x", "y", "z"],
12        "ham": ["a", "b", "d"]
13    }
14)
15new_df = df.join(other_df, on="ham", how="inner")
16
17print(new_df)
1shape: (2, 4)
2┌─────┬─────┬─────┬───────┐
3│ foo ┆ bar ┆ ham ┆ apple │
4│ --- ┆ --- ┆ --- ┆ ---   │
5│ i64 ┆ f64 ┆ str ┆ str   │
6╞═════╪═════╪═════╪═══════╡
7│ 1   ┆ 6.0 ┆ a   ┆ x     │
8│ 2   ┆ 7.0 ┆ b   ┆ y     │
9└─────┴─────┴─────┴───────┘

左连接

左连接生成的结果包含左边侧的所有行, 右侧匹配的行, 右侧没有的会使用null替代, 我们来看代码和结果

1import polars as pl
2df = pl.DataFrame(
3    {
4        "foo": [1, 2, 3],
5        "bar": [6.0, 7.0, 8.0],
6        "ham": ["a", "b", "c"]
7    }
8)
9other_df = pl.DataFrame(
10    {
11        "apple": ["x", "y", "z"],
12        "ham": ["a", "b", "d"]
13    }
14)
15new_df = df.join(other_df, on="ham", how="left")
16
17print(new_df)
1shape: (3, 4)
2┌─────┬─────┬─────┬───────┐
3│ foo ┆ bar ┆ ham ┆ apple │
4│ --- ┆ --- ┆ --- ┆ ---   │
5│ i64 ┆ f64 ┆ str ┆ str   │
6╞═════╪═════╪═════╪═══════╡
7│ 1   ┆ 6.0 ┆ a   ┆ x     │
8│ 2   ┆ 7.0 ┆ b   ┆ y     │
9│ 3   ┆ 8.0 ┆ c   ┆ null  │
10└─────┴─────┴─────┴───────┘

右连接

右连接和左连接相反, 不再赘述, 直接看代码

1import polars as pl
2df = pl.DataFrame(
3    {
4        "foo": [1, 2, 3],
5        "bar": [6.0, 7.0, 8.0],
6        "ham": ["a", "b", "c"]
7    }
8)
9other_df = pl.DataFrame(
10    {
11        "apple": ["x", "y", "z"],
12        "ham": ["a", "b", "d"]
13    }
14)
15new_df = df.join(other_df, on="ham", how="right")
16
17print(new_df)
1shape: (3, 4)
2┌──────┬──────┬───────┬─────┐
3│ foo  ┆ bar  ┆ apple ┆ ham │
4│ ---  ┆ ---  ┆ ---   ┆ --- │
5│ i64  ┆ f64  ┆ str   ┆ str │
6╞══════╪══════╪═══════╪═════╡
7│ 1    ┆ 6.0  ┆ x     ┆ a   │
8│ 2    ┆ 7.0  ┆ y     ┆ b   │
9│ null ┆ null ┆ z     ┆ d   │
10└──────┴──────┴───────┴─────┘

完全连接

完全连接将保留左侧和右侧数据框中的所有行, 即使在另一个DataFrame中没有匹配到的行

1import polars as pl
2df = pl.DataFrame(
3    {
4        "foo": [1, 2, 3],
5        "bar": [6.0, 7.0, 8.0],
6        "ham": ["a", "b", "c"]
7    }
8)
9other_df = pl.DataFrame(
10    {
11        "apple": ["x", "y", "z"],
12        "ham": ["a", "b", "d"]
13    }
14)
15new_df = df.join(other_df, on="ham", how="full")
16
17print(new_df)
1shape: (4, 5)
2┌──────┬──────┬──────┬───────┬───────────┐
3│ foo  ┆ bar  ┆ ham  ┆ apple ┆ ham_right │
4│ ---  ┆ ---  ┆ ---  ┆ ---   ┆ ---       │
5│ i64  ┆ f64  ┆ str  ┆ str   ┆ str       │
6╞══════╪══════╪══════╪═══════╪═══════════╡
7│ 1    ┆ 6.0  ┆ a    ┆ x     ┆ a         │
8│ 2    ┆ 7.0  ┆ b    ┆ y     ┆ b         │
9│ null ┆ null ┆ null ┆ z     ┆ d         │
10│ 3    ┆ 8.0  ┆ c    ┆ null  ┆ null      │
11└──────┴──────┴──────┴───────┴───────────┘

如果我们想把上面的hamham_right合并为一列, 可以指定参数coalesce=True, 来看代码

1import polars as pl
2df = pl.DataFrame(
3    {
4        "foo": [1, 2, 3],
5        "bar": [6.0, 7.0, 8.0],
6        "ham": ["a", "b", "c"]
7    }
8)
9other_df = pl.DataFrame(
10    {
11        "apple": ["x", "y", "z"],
12        "ham": ["a", "b", "d"]
13    }
14)
15new_df = df.join(other_df, on="ham", how="full",coalesce=True)
16
17print(new_df)
1shape: (4, 4)
2┌──────┬──────┬─────┬───────┐
3│ foo  ┆ bar  ┆ ham ┆ apple │
4│ ---  ┆ ---  ┆ --- ┆ ---   │
5│ i64  ┆ f64  ┆ str ┆ str   │
6╞══════╪══════╪═════╪═══════╡
7│ 1    ┆ 6.0  ┆ a   ┆ x     │
8│ 2    ┆ 7.0  ┆ b   ┆ y     │
9│ null ┆ null ┆ d   ┆ z     │
10│ 3    ┆ 8.0  ┆ c   ┆ null  │
11└──────┴──────┴─────┴───────┘

半连接

半连接和左连接类似, 但是只返回左侧数据框中的行, 忽略右侧数据框中的行

半连接充当基于第二个数据框的一种行过滤器

1import polars as pl
2df = pl.DataFrame(
3    {
4        "foo": [1, 2, 3],
5        "bar": [6.0, 7.0, 8.0],
6        "ham": ["a", "b", "c"]
7    }
8)
9other_df = pl.DataFrame(
10    {
11        "apple": ["x", "y", "z"],
12        "ham": ["a", "b", "d"]
13    }
14)
15new_df = df.join(other_df, on="ham", how="semi")
16
17print(new_df)
1shape: (2, 3)
2┌─────┬─────┬─────┐
3│ foo ┆ bar ┆ ham │
4│ --- ┆ --- ┆ --- │
5│ i64 ┆ f64 ┆ str │
6╞═════╪═════╪═════╡
7│ 1   ┆ 6.0 ┆ a   │
8│ 2   ┆ 7.0 ┆ b   │
9└─────┴─────┴─────┘

反连接

返回左侧数据中与右侧数据不匹配的行

1import polars as pl
2df = pl.DataFrame(
3    {
4        "foo": [1, 2, 3],
5        "bar": [6.0, 7.0, 8.0],
6        "ham": ["a", "b", "c"]
7    }
8)
9other_df = pl.DataFrame(
10    {
11        "apple": ["x", "y", "z"],
12        "ham": ["a", "b", "d"]
13    }
14)
15new_df = df.join(other_df, on="ham", how="anti")
16
17print(new_df)
1shape: (1, 3)
2┌─────┬─────┬─────┐
3│ foo ┆ bar ┆ ham │
4│ --- ┆ --- ┆ --- │
5│ i64 ┆ f64 ┆ str │
6╞═════╪═════╪═════╡
7│ 3   ┆ 8.0 ┆ c   │
8└─────┴─────┴─────┘

非等值连接

在非等值连接中, 左右数据框之间的匹配计算方式不同. 我们通过一个谓词来确定如何匹配行

使用场景

  1. 范围匹配
  2. 时间段匹配
  3. 薪资等级划分
  4. 区间重叠检测
  5. 排名和比较

下面代码中高亮的行是用来匹配的条件, 后面还可以写其他条件, 但是这里我们只用一个条件来演示.

1import polars as pl
2east = pl.DataFrame(
3    {
4        "id": [100, 101, 102],
5        "dur": [120, 140, 160],
6        "rev": [12, 14, 16],
7        "cores": [2, 8, 4],
8    }
9)
10west = pl.DataFrame(
11    {
12        "t_id": [404, 498, 676, 742],
13        "time": [90, 130, 150, 170],
14        "cost": [9, 13, 15, 16],
15        "cores": [4, 2, 1, 4],
16    }
17)
18res = east.join_where(
19    west,
20    pl.col("dur") < pl.col("time")
21)
22print(res)

观察下面的结果, 左侧条件dur的值是120,140,160, 右侧time值是90,130,150,170, 然后我们看匹配过程

  1. 左侧的120: 右侧的130,150,170
  2. 左侧的140: 右侧的150,170
  3. 左侧的160: 右侧的170

所以一共有6行, 整个连接过程类似于笛卡尔积, 然后再过滤

1shape: (6, 8)
2┌─────┬─────┬─────┬───────┬──────┬──────┬──────┬─────────────┐
3│ id  ┆ dur ┆ rev ┆ cores ┆ t_id ┆ time ┆ cost ┆ cores_right │
4│ --- ┆ --- ┆ --- ┆ ---   ┆ ---  ┆ ---  ┆ ---  ┆ ---         │
5│ i64 ┆ i64 ┆ i64 ┆ i64   ┆ i64  ┆ i64  ┆ i64  ┆ i64         │
6╞═════╪═════╪═════╪═══════╪══════╪══════╪══════╪═════════════╡
7│ 100 ┆ 120 ┆ 12  ┆ 2     ┆ 498  ┆ 130  ┆ 13   ┆ 2           │
8│ 100 ┆ 120 ┆ 12  ┆ 2     ┆ 676  ┆ 150  ┆ 15   ┆ 1           │
9│ 100 ┆ 120 ┆ 12  ┆ 2     ┆ 742  ┆ 170  ┆ 16   ┆ 4           │
10│ 101 ┆ 140 ┆ 14  ┆ 8     ┆ 676  ┆ 150  ┆ 15   ┆ 1           │
11│ 101 ┆ 140 ┆ 14  ┆ 8     ┆ 742  ┆ 170  ┆ 16   ┆ 4           │
12│ 102 ┆ 160 ┆ 16  ┆ 4     ┆ 742  ┆ 170  ┆ 16   ┆ 4           │
13└─────┴─────┴─────┴───────┴──────┴──────┴──────┴─────────────┘
NOTE

处于试验阶段, 还不稳定, 尚不支持任意布尔表达式作为谓词.

Asof连接

是一种特殊的非等值连接, 在时间序列数据分析中尤为有用. 它的核心思想是:对于左表中的每一行, 用右表中的指定键匹配,并且时间戳(或其他有序键)最接近(通常是小于或等于)左表时间戳的行 类似于左连接, 只不过我们匹配的是最接近的键, 而不是相等的, 在Polars中使用join_asof

特点:

  1. 基于有序键(通常是时间戳)
  2. 不精确匹配
  3. 单向性
  4. 通常用于回填或关联历史数据
  • on指定的字段是用于匹配的键, 两个DataFrame都必须按on键排序.
  • strategy指定匹配策略
    • backward: 搜索选择右侧DataFrame中 'on' 键小于或等于左侧键的最后一行
    • forward: 搜索选择右侧DataFrame 中'on' 键大于或等于左侧键的第一行
    • nearest: 搜索选择右侧DataFrame中值最接近左侧键的最后一行, 最近搜索当前不支持字符串键。
1import polars as pl
2from datetime import date
3population = pl.DataFrame(
4    {
5        "date": [date(2016, 3, 1), date(2018, 8, 1), date(2019, 1, 1)],
6        "population": [82.19, 82.66, 83.12],
7    }
8).sort("date")
9
10gdp = pl.DataFrame(
11    {
12        "date": [
13
14            date(2016, 1, 1),
15            date(2017, 1, 1),
16            date(2018, 1, 1),
17            date(2019, 1, 1),
18            date(2020, 1, 1),
19        ],
20        "gdp": [4164, 4411, 4566, 4696, 4827],
21    }
22)
23res = population.join_asof(gdp, on="date", strategy="backward")
24print(res)

我们来分析匹配过程: 针对左侧的三个时间点, 匹配过程如下: 针对左侧表的每一行数据, 都在右侧数据中找到最接近的值

  1. 2016-03-01: 右侧小于它的只有一个值, 即2016-01-01, gdp的值是4164
  2. 2018-08-01: 右侧小于它的有三个值, 即2016-01-01,2017-01-012018-01-01, 最近接的是2018-01-01, gdp的值是4566
  3. 2019-01-01: 右侧小于或等于它的有四个值, 即2016-01-01,2017-01-01,2018-01-012019-01-01, 最近接的是2019-01-01, gdp的值是4696
1shape: (3, 3)
2┌────────────┬────────────┬──────┐
3│ date       ┆ population ┆ gdp  │
4│ ---        ┆ ---        ┆ ---  │
5│ date       ┆ f64        ┆ i64  │
6╞════════════╪════════════╪══════╡
7│ 2016-03-01 ┆ 82.19      ┆ 4164 │
8│ 2018-08-01 ┆ 82.66      ┆ 4566 │
9│ 2019-01-01 ┆ 83.12      ┆ 4696 │
10└────────────┴────────────┴──────┘

下面是一个更简短的例子, 我们使用strategy="forward"策略

1df1 = pl.DataFrame({
2	"a":[1,2,3]
3})
4df2 = pl.DataFrame({
5	"a":[1,3,4],
6	"b":["x","y","z"],
7})
8res = df1.join_asof(df2,on=pl.col("a"), strategy="forward")
9print(res)

我们来写下匹配过程

  • 1 -> (1,3,4) , 最匹配的是1, 值是x
  • 3 -> (3,4) , 最匹配的是3, 值是y
  • 4 -> (3) , 最匹配的是5, 值是y
1shape: (3, 2)
2┌─────┬─────┐
3│ a   ┆ b   │
4│ --- ┆ --- │
5│ i64 ┆ str │
6╞═════╪═════╡
7│ 1   ┆ x   │
8│ 2   ┆ y   │
9│ 3   ┆ y   │
10└─────┴─────┘

笛卡尔积

只需要指定how="cross"即可

1import polars as pl
2df1 = pl.DataFrame({
3    "a":[1,2],
4	"b":["x1",'x2']
5})
6df2 = pl.DataFrame({
7    "c":["apple","peach","pear"]
8})
9res = df1.join(df2,how="cross")
10print(res)
1shape: (6, 3)
2┌─────┬─────┬───────┐
3│ a   ┆ b   ┆ c     │
4│ --- ┆ --- ┆ ---   │
5│ i64 ┆ str ┆ str   │
6╞═════╪═════╪═══════╡
7│ 1   ┆ x1  ┆ apple │
8│ 1   ┆ x1  ┆ peach │
9│ 1   ┆ x1  ┆ pear  │
10│ 2   ┆ x2  ┆ apple │
11│ 2   ┆ x2  ┆ peach │
12│ 2   ┆ x2  ┆ pear  │
13└─────┴─────┴───────┘