조인 (Joins)
조인 (Joins)
조인(join)은 하나 이상의 데이터프레임에서 열(column)들을 합쳐 새로운 데이터프레임을 만드는 연산이에요. 어떤 조인 방식을 쓰고, 어떤 기준으로 행을 매칭하느냐에 따라 열이 어떻게 합쳐지는지, 그리고 결과에 어떤 행이 포함되는지가 달라집니다.
가장 흔한 조인은 "동등 조인(equi join)"인데, 행들을 키(key) 표현식의 값이 같은지로 매칭해요. Polars는 동등 조인에 대해 여러 조인 전략(joining strategy)을 지원하는데, 이 전략이 행 매칭을 정확히 어떻게 처리할지를 결정합니다. Polars는 또 "비동등 조인(non-equi join)"도 지원해요. 이건 매칭 기준이 같음(equality)이 아닌 조인이고, 그리고 행들을 키의 근접도로 매칭하는 "asof 조인(asof join)"이라는 유형도 있죠.
빠른 참조표
아래 표는 이미 무엇을 찾고 있는지 아는 분들을 위한 빠른 참조예요. 조인을 일반적으로 어떻게 배우고 Polars에서 어떻게 쓰는지 알고 싶다면, 이 표는 건너뛰고 아래를 계속 읽어도 좋습니다.
| 유형 | 함수 | 간단한 설명 |
|---|---|---|
| 동등 내부 조인 (equi inner join) | join(..., how="inner") |
왼쪽과 오른쪽 양쪽에서 매칭된 행만 유지합니다. |
| 동등 왼쪽 외부 조인 (equi left outer join) | join(..., how="left") |
왼쪽의 모든 행과, 오른쪽에서 매칭된 행을 유지합니다. 매칭되지 않은 왼쪽 행은 오른쪽 열에 null이 채워집니다. |
| 동등 오른쪽 외부 조인 (equi right outer join) | join(..., how="right") |
오른쪽의 모든 행과, 왼쪽에서 매칭된 행을 유지합니다. 매칭되지 않은 오른쪽 행은 왼쪽 열에 null이 채워집니다. |
| 동등 전체 조인 (equi full join) | join(..., how="full") |
매칭 여부와 관계없이 어느 쪽 데이터프레임의 모든 행을 유지합니다. 매칭되지 않은 한쪽 행은 다른 쪽 열에 null이 채워집니다. |
| 동등 반 조인 (equi semi join) | join(..., how="semi") |
오른쪽에 매칭이 있는 왼쪽 행만 유지합니다. |
| 동등 안티 조인 (equi anti join) | join(..., how="anti") |
오른쪽에 매칭이 없는 왼쪽 행만 유지합니다. |
| 비동등 내부 조인 (non-equi inner join) | join_where |
주어진 조건(predicate)을 만족하는 왼쪽과 오른쪽 행의 가능한 모든 짝을 찾습니다. |
| Asof 조인 (asof join) | join_asof / join_asof_by |
왼쪽 외부 조인과 비슷하지만, 키가 정확히 일치하는 대신 가장 가까운 키로 매칭합니다. |
| 카테시안 곱 (Cartesian product) | join(..., how="cross") |
두 데이터프레임의 카테시안 곱을 계산합니다. |
동등 조인 (Equi joins)
동등 조인에서는 행들이 키 표현식의 값이 같은지 확인해서 매칭돼요. 키로 사용할 열의 이름을 지정해서 join 함수로 동등 조인을 할 수 있고요. 예제를 위해 (약간 변형된) 모노폴리(Monopoly) 부동산 데이터를 불러와 볼게요.
먼저, 게임에서 부동산 이름과 색깔 그룹을 담은 데이터프레임을 불러와요:
import polars as pl
props_groups = pl.read_csv("docs/assets/data/monopoly_props_groups.csv").head(5)
print(props_groups)
shape: (5, 2)
┌──────────────────────┬────────────┐
│ property_name ┆ group │
│ --- ┆ --- │
│ str ┆ str │
╞══════════════════════╪════════════╡
│ Old Ken Road ┆ brown │
│ Whitechapel Road ┆ brown │
│ The Shire ┆ fantasy │
│ Kings Cross Station ┆ stations │
│ The Angel, Islington ┆ light_blue │
└──────────────────────┴────────────┘
다음으로, 게임에서 부동산 이름과 가격을 담은 데이터프레임을 불러와요:
props_prices = pl.read_csv("docs/assets/data/monopoly_props_prices.csv").head(5)
print(props_prices)
shape: (5, 2)
┌──────────────────────┬──────┐
│ property_name ┆ cost │
│ --- ┆ --- │
│ str ┆ i64 │
╞══════════════════════╪══════╡
│ Old Ken Road ┆ 60 │
│ Whitechapel Road ┆ 60 │
│ Sesame Street ┆ 100 │
│ Kings Cross Station ┆ 200 │
│ The Angel, Islington ┆ 100 │
└──────────────────────┴──────┘
이제 두 데이터프레임을 조인해서 부동산 이름, 색깔 그룹, 가격을 모두 담은 데이터프레임을 만들어 볼게요:
result = props_groups.join(props_prices, on="property_name")
print(result)
shape: (4, 3)
┌──────────────────────┬────────────┬──────┐
│ property_name ┆ group ┆ cost │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ i64 │
╞══════════════════════╪════════════╪══════╡
│ Old Ken Road ┆ brown ┆ 60 │
│ Whitechapel Road ┆ brown ┆ 60 │
│ Kings Cross Station ┆ stations ┆ 200 │
│ The Angel, Islington ┆ light_blue ┆ 100 │
└──────────────────────┴────────────┴──────┘
결과는 4행인데, 조인에 쓰인 두 데이터프레임은 각각 5행이었어요. Polars는 조인 전략을 써서 여러 매칭이 있는 행이나 전혀 매칭이 없는 행이 어떻게 처리될지를 결정합니다. 기본적으로 Polars는 "내부 조인(inner join)"을 계산하는데, 다른 조인 전략들도 아래에서 차례로 보여드릴게요.
위 예제에서는 두 데이터프레임이 운 좋게도 키로 쓰고 싶은 열의 이름이 같고, 값의 형식도 정확히 같았어요. 만약 어느 한쪽 데이터프레임의 열 이름이 다르고, 다른 쪽 부동산 이름이 소문자라면 어떨까요? 잠깐 그 상황을 가정해 볼게요.
props_groups2 = props_groups.with_columns(
pl.col("property_name").str.to_lowercase(),
)
print(props_groups2)
shape: (5, 2)
┌──────────────────────┬────────────┐
│ property_name ┆ group │
│ --- ┆ --- │
│ str ┆ str │
╞══════════════════════╪════════════╡
│ old ken road ┆ brown │
│ whitechapel road ┆ brown │
│ the shire ┆ fantasy │
│ kings cross station ┆ stations │
│ the angel, islington ┆ light_blue │
└──────────────────────┴────────────┘
props_prices2 = props_prices.select(
pl.col("property_name").alias("name"), pl.col("cost")
)
print(props_prices2)
shape: (5, 2)
┌──────────────────────┬──────┐
│ name ┆ cost │
│ --- ┆ --- │
│ str ┆ i64 │
╞══════════════════════╪══════╡
│ Old Ken Road ┆ 60 │
│ Whitechapel Road ┆ 60 │
│ Sesame Street ┆ 100 │
│ Kings Cross Station ┆ 200 │
│ The Angel, Islington ┆ 100 │
└──────────────────────┴──────┘
이렇게 이전과 같은 조인을 수행하고 싶은 상황에서, 우리는 join의 유연성을 활용해서 왼쪽과 오른쪽의 조인 키를 계산할 임의의 표현식을 지정할 수 있어요. 그러면 행 키를 동적으로 계산할 수 있죠:
result = props_groups2.join(
props_prices2,
left_on="property_name",
right_on=pl.col("name").str.to_lowercase(),
)
print(result)
shape: (4, 4)
┌──────────────────────┬────────────┬──────────────────────┬──────┐
│ property_name ┆ group ┆ name ┆ cost │
│ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ str ┆ str ┆ i64 │
╞══════════════════════╪════════════╪══════════════════════╪══════╡
│ old ken road ┆ brown ┆ Old Ken Road ┆ 60 │
│ whitechapel road ┆ brown ┆ Whitechapel Road ┆ 60 │
│ kings cross station ┆ stations ┆ Kings Cross Station ┆ 200 │
│ the angel, islington ┆ light_blue ┆ The Angel, Islington ┆ 100 │
└──────────────────────┴────────────┴──────────────────────┴──────┘
오른쪽을 표현식으로 조인했기 때문에, Polars는 왼쪽의 "property_name" 열과 오른쪽의 "name" 열을 그대로 보존해요. 그래서 키 표현식이 적용되기 전의 원래 값에 계속 접근할 수 있답니다.
조인 전략 (Join strategies)
df1.join(df2, ...)로 조인을 계산할 때 우리는 여러 조인 전략 중 하나를 지정할 수 있어요. 조인 전략은 각 데이터프레임에서 어떤 행을 유지할지를, 그 행이 상대 데이터프레임의 행과 매칭되는지에 따라 정해줍니다.
내부 조인 (Inner join)
내부 조인은 결과 데이터프레임에 왼쪽과 오른쪽 데이터프레임에서 매칭된 행만 포함해요. 이것이 join이 쓰는 기본 전략이고, 위에서 그 예를 볼 수 있었죠. 그 예를 반복해서 조인 전략을 명시적으로 지정해 볼게요:
result = props_groups.join(props_prices, on="property_name", how="inner")
print(result)
shape: (4, 3)
┌──────────────────────┬────────────┬──────┐
│ property_name ┆ group ┆ cost │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ i64 │
╞══════════════════════╪════════════╪══════╡
│ Old Ken Road ┆ brown ┆ 60 │
│ Whitechapel Road ┆ brown ┆ 60 │
│ Kings Cross Station ┆ stations ┆ 200 │
│ The Angel, Islington ┆ light_blue ┆ 100 │
└──────────────────────┴────────────┴──────┘
결과에 props_groups에서 "The Shire"를 담고 있는 행이 포함되지 않고, props_prices에서 "Sesame Street"를 담고 있는 행도 포함되지 않아요.
왼쪽 조인 (Left join)
왼쪽 외부 조인은 결과에 왼쪽 데이터프레임의 모든 행과, 왼쪽의 어느 행과도 매칭된 오른쪽 데이터프레임의 행들을 포함하는 조인이에요.
result = props_groups.join(props_prices, on="property_name", how="left")
print(result)
shape: (5, 3)
┌──────────────────────┬────────────┬──────┐
│ property_name ┆ group ┆ cost │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ i64 │
╞══════════════════════╪════════════╪══════╡
│ Old Ken Road ┆ brown ┆ 60 │
│ Whitechapel Road ┆ brown ┆ 60 │
│ The Shire ┆ fantasy ┆ null │
│ Kings Cross Station ┆ stations ┆ 200 │
│ The Angel, Islington ┆ light_blue ┆ 100 │
└──────────────────────┴────────────┴──────┘
왼쪽 데이터프레임에 오른쪽과 매칭되는 행이 없는 행이 있다면, 그 행은 새 열에 null 값을 받아요.
오른쪽 조인 (Right join)
계산적으로 말하면 오른쪽 외부 조인은 왼쪽 외부 조인과 정확히 같지만, 인자(argument)가 서로 바뀌었을 뿐이에요. 예를 보여드릴게요:
result = props_groups.join(props_prices, on="property_name", how="right")
print(result)
shape: (5, 3)
┌────────────┬──────────────────────┬──────┐
│ group ┆ property_name ┆ cost │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ i64 │
╞════════════╪══════════════════════╪══════╡
│ brown ┆ Old Ken Road ┆ 60 │
│ brown ┆ Whitechapel Road ┆ 60 │
│ null ┆ Sesame Street ┆ 100 │
│ stations ┆ Kings Cross Station ┆ 200 │
│ light_blue ┆ The Angel, Islington ┆ 100 │
└────────────┴──────────────────────┴──────┘
df1.join(df2, how="right", ...)가 df2.join(df1, how="left", ...)와 결과 열의 순서만 다를 뿐 같다는 것을 아래 계산으로 보여줄게요:
print(
result.equals(
props_prices.join(
props_groups,
on="property_name",
how="left",
# Reorder the columns to match the order from above.
).select(pl.col("group"), pl.col("property_name"), pl.col("cost"))
)
)
True
전체 조인 (Full join)
전체 외부 조인은 두 데이터프레임의 모든 행을 유지해요, 상대 데이터프레임에 매칭되는 행이 없더라도요:
result = props_groups.join(props_prices, on="property_name", how="full")
print(result)
shape: (6, 4)
┌──────────────────────┬────────────┬──────────────────────┬──────┐
│ property_name ┆ group ┆ property_name_right ┆ cost │
│ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ str ┆ str ┆ i64 │
╞══════════════════════╪════════════╪══════════════════════╪══════╡
│ Old Ken Road ┆ brown ┆ Old Ken Road ┆ 60 │
│ Whitechapel Road ┆ brown ┆ Whitechapel Road ┆ 60 │
│ null ┆ null ┆ Sesame Street ┆ 100 │
│ Kings Cross Station ┆ stations ┆ Kings Cross Station ┆ 200 │
│ The Angel, Islington ┆ light_blue ┆ The Angel, Islington ┆ 100 │
│ The Shire ┆ fantasy ┆ null ┆ null │
└──────────────────────┴────────────┴──────────────────────┴──────┘
이 경우 property_name과 property_name_right 두 개의 열이 생기는 걸 볼 수 있어요. 두 데이터프레임의 property_name 열로 매칭하고 있는데, 매칭이 없는 이름들이 있기 때문이죠. 이 두 열 덕분에 각 행의 데이터가 어느 쪽에서 왔는지 구분할 수 있어요. 만약 이 두 property_name 열을 하나의 열로 합치도록 강제하고 싶다면, coalesce=True를 명시적으로 설정하면 됩니다:
result = props_groups.join(
props_prices,
on="property_name",
how="full",
coalesce=True,
)
print(result)
shape: (6, 3)
┌──────────────────────┬────────────┬──────┐
│ property_name ┆ group ┆ cost │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ i64 │
╞══════════════════════╪════════════╪══════╡
│ Old Ken Road ┆ brown ┆ 60 │
│ Whitechapel Road ┆ brown ┆ 60 │
│ Sesame Street ┆ null ┆ 100 │
│ Kings Cross Station ┆ stations ┆ 200 │
│ The Angel, Islington ┆ light_blue ┆ 100 │
│ The Shire ┆ fantasy ┆ null │
└──────────────────────┴────────────┴──────┘
coalesce 파라미터는 설정하지 않으면 조인 전략과 지정된 키에 따라 자동으로 결정돼요. 그래서 내부, 왼쪽, 오른쪽 조인은 우리가 설정하지 않았는데도 마치 coalesce=True인 것처럼 동작했던 거예요.
반 조인 (Semi join)
반 조인은 오른쪽 데이터프레임에 매칭이 있는 왼쪽 데이터프레임의 행들을 반환하지만, 실제로 매칭된 행들은 조인하지 않아요:
result = props_groups.join(props_prices, on="property_name", how="semi")
print(result)
shape: (4, 2)
┌──────────────────────┬────────────┐
│ property_name ┆ group │
│ --- ┆ --- │
│ str ┆ str │
╞══════════════════════╪════════════╡
│ Old Ken Road ┆ brown │
│ Whitechapel Road ┆ brown │
│ Kings Cross Station ┆ stations │
│ The Angel, Islington ┆ light_blue │
└──────────────────────┴────────────┘
반 조인은 두 번째 데이터프레임에 기반한 일종의 행 필터처럼 동작해요.
안티 조인 (Anti join)
반대로, 안티 조인은 오른쪽 데이터프레임에 매칭이 없는 왼쪽 데이터프레임의 행들을 반환해요:
result = props_groups.join(props_prices, on="property_name", how="anti")
print(result)
shape: (1, 2)
┌───────────────┬─────────┐
│ property_name ┆ group │
│ --- ┆ --- │
│ str ┆ str │
╞═══════════════╪═════════╡
│ The Shire ┆ fantasy │
└───────────────┴─────────┘
비동등 조인 (Non-equi joins)
비동등 조인에서는 왼쪽과 오른쪽 데이터프레임 사이의 매칭이 다르게 계산돼요. 키 표현식에서 매칭을 찾는 대신, 왼쪽 데이터프레임의 어떤 행이 오른쪽 데이터프레임의 어떤 행과 짝지어질 수 있는지 결정하는 조건(predicate) 하나를 제공하는 거죠.
예를 들어, 다음 모노폴리 플레이어들과 그들의 현재 현금을 봅시다:
players = pl.DataFrame(
{
"name": ["Alice", "Bob"],
"cash": [78, 135],
}
)
print(players)
shape: (2, 2)
┌───────┬──────┐
│ name ┆ cash │
│ --- ┆ --- │
│ str ┆ i64 │
╞═══════╪══════╡
│ Alice ┆ 78 │
│ Bob ┆ 135 │
└───────┴──────┘
비동등 조인을 쓰면 각 플레이어가 구매에 관심을 가질 수 있는 가능한 모든 부동산을 담은 데이터프레임을 쉽게 만들 수 있어요. 비동등 조인을 계산할 때는 join_where 함수를 사용합니다:
result = players.join_where(props_prices, pl.col("cash") > pl.col("cost"))
print(result)
shape: (6, 4)
┌───────┬──────┬──────────────────────┬──────┐
│ name ┆ cash ┆ property_name ┆ cost │
│ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ i64 ┆ str ┆ i64 │
╞═══════╪══════╪══════════════════════╪══════╡
│ Bob ┆ 135 ┆ Sesame Street ┆ 100 │
│ Bob ┆ 135 ┆ The Angel, Islington ┆ 100 │
│ Bob ┆ 135 ┆ Old Ken Road ┆ 60 │
│ Bob ┆ 135 ┆ Whitechapel Road ┆ 60 │
│ Alice ┆ 78 ┆ Old Ken Road ┆ 60 │
│ Alice ┆ 78 ┆ Whitechapel Road ┆ 60 │
└───────┴──────┴──────────────────────┴──────┘
여러 표현식을 조건으로 제공할 수도 있는데, 그 경우 AND로 결합돼요. OR이나 XOR 같은 다른 결합이 필요하다면 표현식들을 하나의 표현식 안에서 조합할 수도 있습니다.
Asof 조인 (Asof join)
asof 조인은 왼쪽 조인과 비슷하지만, 서로 같은 키가 아니라 가장 가까운 키로 매칭해요. Polars에서 asof 조인은 join_asof 메서드로 할 수 있습니다.
asof 조인을 위해 주식 시장에서 영감을 받은 시나리오를 생각해볼게요. 주식 브로커가 자기가 다른 주식들에 대해 실행한 거래를 담은 df_trades라는 데이터프레임을 가지고 있다고 해봅시다.
from datetime import datetime
df_trades = pl.DataFrame(
{
"time": [
datetime(2020, 1, 1, 9, 1, 0),
datetime(2020, 1, 1, 9, 1, 0),
datetime(2020, 1, 1, 9, 3, 0),
datetime(2020, 1, 1, 9, 6, 0),
],
"stock": ["A", "B", "B", "C"],
"trade": [101, 299, 301, 500],
}
)
print(df_trades)
shape: (4, 3)
┌─────────────────────┬───────┬───────┐
│ time ┆ stock ┆ trade │
│ --- ┆ --- ┆ --- │
│ datetime[μs] ┆ str ┆ i64 │
╞═════════════════════╪═══════╪═══════╡
│ 2020-01-01 09:01:00 ┆ A ┆ 101 │
│ 2020-01-01 09:01:00 ┆ B ┆ 299 │
│ 2020-01-01 09:03:00 ┆ B ┆ 301 │
│ 2020-01-01 09:06:00 ┆ C ┆ 500 │
└─────────────────────┴───────┴───────┘
브로커는 이 주식들에 대해 자기가 호가한 가격을 담은 df_quotes라는 또 다른 데이터프레임을 갖고 있어요:
df_quotes = pl.DataFrame(
{
"time": [
datetime(2020, 1, 1, 9, 0, 0),
datetime(2020, 1, 1, 9, 2, 0),
datetime(2020, 1, 1, 9, 4, 0),
datetime(2020, 1, 1, 9, 6, 0),
],
"stock": ["A", "B", "C", "A"],
"quote": [100, 300, 501, 102],
}
)
print(df_quotes)
shape: (4, 3)
┌─────────────────────┬───────┬───────┐
│ time ┆ stock ┆ quote │
│ --- ┆ --- ┆ --- │
│ datetime[μs] ┆ str ┆ i64 │
╞═════════════════════╪═══════╪═══════╡
│ 2020-01-01 09:00:00 ┆ A ┆ 100 │
│ 2020-01-01 09:02:00 ┆ B ┆ 300 │
│ 2020-01-01 09:04:00 ┆ C ┆ 501 │
│ 2020-01-01 09:06:00 ┆ A ┆ 102 │
└─────────────────────┴───────┴───────┘
여러분은 각 거래에 대해 거래 시점 이전 또는 그 시점에 제공된 가장 최근 호가를 보여주는 데이터프레임을 만들고 싶어해요. 이걸 join_asof로 하면 됩니다 (기본 전략은 "backward"). 한 주식의 거래가 다른 주식의 호가와 조인되는 것을 막으려면, by="stock"으로 stock 열에 대한 정확한 예비 조인(preliminary join)을 지정해야 해요.
df_asof_join = df_trades.join_asof(
df_quotes, on="time", by="stock", check_sortedness=False
)
print(df_asof_join)
shape: (4, 4)
┌─────────────────────┬───────┬───────┬───────┐
│ time ┆ stock ┆ trade ┆ quote │
│ --- ┆ --- ┆ --- ┆ --- │
│ datetime[μs] ┆ str ┆ i64 ┆ i64 │
╞═════════════════════╪═══════╪═══════╪═══════╡
│ 2020-01-01 09:01:00 ┆ A ┆ 101 ┆ 100 │
│ 2020-01-01 09:01:00 ┆ B ┆ 299 ┆ null │
│ 2020-01-01 09:03:00 ┆ B ┆ 301 ┆ 300 │
│ 2020-01-01 09:06:00 ┆ C ┆ 500 ┆ 501 │
└─────────────────────┴───────┴───────┴───────┘
특정 시간 범위 안의 호가만 거래에 조인되게 하고 싶다면 tolerance 인자를 지정하면 돼요. 여기서는 가장 최근의 선행 호가가 거래의 1분 이내에 있어야 한다고 가정하므로 tolerance = "1m"으로 설정합니다.
df_asof_tolerance_join = df_trades.join_asof(
df_quotes, on="time", by="stock", tolerance="1m", check_sortedness=False
)
print(df_asof_tolerance_join)
shape: (4, 4)
┌─────────────────────┬───────┬───────┬───────┐
│ time ┆ stock ┆ trade ┆ quote │
│ --- ┆ --- ┆ --- ┆ --- │
│ datetime[μs] ┆ str ┆ i64 ┆ i64 │
╞═════════════════════╪═══════╪═══════╪═══════╡
│ 2020-01-01 09:01:00 ┆ A ┆ 101 ┆ 100 │
│ 2020-01-01 09:01:00 ┆ B ┆ 299 ┆ null │
│ 2020-01-01 09:03:00 ┆ B ┆ 301 ┆ 300 │
│ 2020-01-01 09:06:00 ┆ C ┆ 500 ┆ null │
└─────────────────────┴───────┴───────┴───────┘
카테시안 곱 (Cartesian product)
Polars는 두 데이터프레임의 카테시안 곱을 계산할 수 있어요. 이 연산은 왼쪽 데이터프레임의 모든 행이 오른쪽 데이터프레임의 모든 행과 짝지어진 데이터프레임을 만들어내죠. 두 데이터프레임의 카테시안 곱을 계산하려면 on, left_on, right_on을 아무것도 지정하지 않은 채 join 함수에 전략 how="cross"를 넘기면 됩니다:
tokens = pl.DataFrame({"monopoly_token": ["hat", "shoe", "boat"]})
result = players.select(pl.col("name")).join(tokens, how="cross")
print(result)
shape: (6, 2)
┌───────┬────────────────┐
│ name ┆ monopoly_token │
│ --- ┆ --- │
│ str ┆ str │
╞═══════╪════════════════╡
│ Alice ┆ hat │
│ Alice ┆ shoe │
│ Alice ┆ boat │
│ Bob ┆ hat │
│ Bob ┆ shoe │
│ Bob ┆ boat │
└───────┴────────────────┘