2025, Nov 01 19:46
Равенство null в Polars: is_in, struct и join на практике
Узнайте, почему в Polars сравнение строк через is_in, struct и join расходится на null/None, и как фиксировать поведение, близкое к pandas, с примерами кода.
Построчные сравнения между DataFrame кажутся обманчиво простыми, пока в дело не вмешиваются null-значения. В Polars поведение по умолчанию для None может отличаться в зависимости от того, используете ли вы is_in со struct или многоключевое соединение. Если вы рассчитываете на «пандасовскую» семантику, где None никогда не совпадает, это нужно явно задать. В этом материале мы покажем рассинхрон, разберём, что происходит, и продемонстрируем, как закрепить единое поведение.
Воспроизводим расхождение
В примере есть полная таблица и её подмножество. Мы сравним строки между ними, включая случаи, когда ключевой столбец содержит None.
import polars as pl
rec1 = {"foo": "a", "bar": "b", "baz": "c"}
rec2 = {"foo": "x", "bar": "y", "baz": "z"}
rec3 = {"foo": "a", "bar": "b", "baz": None}
rec4 = {"foo": "m", "bar": "n", "baz": "o"}
rec5 = {"foo": "x", "bar": "y", "baz": None}
rec6 = {"foo": "a", "bar": "b", "baz": None}
all_rows = [rec1, rec2, rec3, rec4, rec5, rec6]
subset_rows = [rec1, rec2, rec3]
key_cols = ["foo", "bar", "baz"]
df_all = pl.DataFrame(all_rows)
df_sub = pl.DataFrame(subset_rows)
key_struct = (
df_sub
.select(pl.struct(pl.col(key_cols)).alias("key_struct"))
.get_column("key_struct")
)
Применение is_in к struct из всех столбцов помечает, принадлежит ли строка подмножеству. Обратите внимание: строки с None в baz могут считаться совпадениями.
df_all.with_columns(
pl.struct(pl.all()).is_in(key_struct.implode()).alias("hit")
)
Обычное соединение по нескольким ключам, напротив, не сопоставляет null-значения и возвращает только полностью ненулевые совпадения.
df_all.join(df_sub, on=key_cols)
Соединение по единственному ключу-struct ведёт себя так же, как is_in, и может совпадать со строками, где составной ключ содержит None.
df_all.join(df_sub, on=pl.struct(key_cols))
Почему результаты расходятся
Разница идёт от того, как операции трактуют равенство с null. Соединение по нескольким отдельным столбцам не считает null равным null. Напротив, использование составного ключа-struct — и в is_in, и в join — оценивает принадлежность или равенство для всего struct целиком, и в показанном примере это приводит к тому, что строки с None могут считаться совпадениями. Поэтому при наличии null эти два подхода взаимозаменяемыми не являются.
Замечание по версиям о is_in и обработке null
Недавно is_in изменил поведение в части распространения null. В минимальном примере ниже скалярный null, проверяемый на вхождение в список с null, раньше давал true, а теперь — null.
import polars as pl
tab = pl.select(a=None, b=[None])
tab = tab.cast({"a": pl.String, "b": pl.List(pl.String)})
print(tab.with_columns(c=pl.col.a.is_in("b")))
В polars 1.27.1 результат был таким:
shape: (1, 3)
┌──────┬───────────┬──────┐
│ a ┆ b ┆ c │
│ str ┆ list[str] ┆ bool │
╞══════╪═══════════╪══════╡
│ null ┆ [null] ┆ true │
└──────┴───────────┴──────┘
В polars 1.28.0 стал таким:
shape: (1, 3)
┌──────┬───────────┬──────┐
│ a ┆ b ┆ c │
│ str ┆ list[str] ┆ bool │
╞══════╪═══════════╪══════╡
│ null ┆ [null] ┆ null │
└──────┴───────────┴──────┘
Для вложенного левого операнда — например, список с null против списка списков с null — пример даёт true:
import polars as pl
tab2 = pl.select(a=[None], b=[[None]])
tab2 = tab2.cast({"a": pl.List(pl.String), "b": pl.List(pl.List(pl.String))})
print(tab2.with_columns(c=pl.col.a.is_in("b")))
shape: (1, 3)
┌───────────┬─────────────────┬──────┐
│ a ┆ b ┆ c │
│ list[str] ┆ list[list[str]] ┆ bool │
╞═══════════╪═════════════════╪══════╡
│ [null] ┆ [[null]] ┆ true │
└───────────┴─────────────────┴──────┘
Эти примеры показывают, что семантика null в is_in тонкая и зависит от версии. Если корректность опирается на конкретную трактовку, задавайте её явно.
Как получить поведение, похожее на pandas, в Polars
По умолчанию pandas считает, что None не совпадает, в типичной связке DataFrame.isin с последующим all(axis=1). Чтобы повторить это в Polars, нужно исключить строки с null до проверки построчной принадлежности.
import polars as pl
rec1 = {"foo": "a", "bar": "b", "baz": "c"}
rec2 = {"foo": "x", "bar": "y", "baz": "z"}
rec3 = {"foo": "a", "bar": "b", "baz": None}
rec4 = {"foo": "m", "bar": "n", "baz": "o"}
rec5 = {"foo": "x", "bar": "y", "baz": None}
rec6 = {"foo": "a", "bar": "b", "baz": None}
all_rows = [rec1, rec2, rec3, rec4, rec5, rec6]
subset_rows = [rec1, rec2, rec3]
key_cols = ["foo", "bar", "baz"]
df_all = pl.DataFrame(all_rows)
df_sub = pl.DataFrame(subset_rows)
key_struct = (
df_sub
.select(pl.struct(pl.col(key_cols)).alias("key_struct"))
.get_column("key_struct")
)
result = df_all.with_columns(
pl.all_horizontal(
pl.all().is_not_null(),
pl.struct(pl.all()).is_in(key_struct.implode())
).alias("hit")
)
print(result)
Если хотите сравнить с базовым поведением pandas, эквивалентная конструкция не требует дополнительной проверки: в этом паттерне значения None по умолчанию не совпадают.
import pandas as pd
pd_all = pd.DataFrame(all_rows)
pd_sub = pd.DataFrame(subset_rows)
pd_all["hit_like_pandas"] = pd_all[key_cols].isin(pd_sub).all(1)
print(pd_all)
Выражение Polars использует all_horizontal, объединяя два условия: во‑первых, все столбцы строки не равны null; во‑вторых, вся строка как struct входит в серию struct из подмножества. На данных из примера это даёт тот же результат, что и фрагмент для pandas.
Почему это важно
Сравнения между фреймами лежат в основе дедупликации, фильтрации и проверок целостности. Небольшие различия в семантике None могут оборачиваться неожиданными несовпадениями или пропущенными совпадениями — в зависимости от того, применяете ли вы многоключевой join, соединение по struct или is_in. Поскольку поведение is_in вокруг null менялось между версиями, опора на неявные значения по умолчанию со временем может давать разные итоги. Явно фиксируйте нужную семантику — должны ли null совпадать или нет — чтобы сделать логику устойчивой.
Выводы
Используйте struct, когда требуется настоящее построчное сопоставление по нескольким столбцам. Ожидайте, что многоключевой join отбросит совпадения при любом null в ключах, а подход на основе struct поведёт себя иначе. Если вам нужно поведение в духе pandas, где None никогда не считается совпадением в таком сценарии, объединяйте проверку на отсутствие null со struct‑ориентированным is_in — как показано выше. Когда результат зависит от обработки null, формулируйте правило явно, а не полагайтесь на умолчания.
Статья основана на вопросе с StackOverflow от dewser_the_board и ответе jqurious.