2025, Oct 18 13:15
cum_sum_horizontal и unnest в Polars: баг literal и фикс 1.32.0
Разбираем баг с «призрачным» столбцом literal при связке cum_sum_horizontal и unnest в Polars 1.31.0, фикс в 1.32.0 и решения через распаковку и cum_fold.
Горизонтальные кумулятивные суммы в Polars удобны, пока незаметно не добавят в схему «призрачный» столбец. Если связать cum_sum_horizontal с unnest, можно столкнуться с застрявшим столбцом literal, который не исчезает и вызывает ColumnNotFoundError даже после явного удаления. В этом материале — воспроизводимый пример, что изменилось в Polars 1.32.0 и как адаптировать код.
Воспроизведение проблемы
Ниже — сниппет, демонстрирующий поведение в Polars 1.31.0: после горизонтальной кумулятивной суммы и unnest в схеме появляется столбец literal, который сохраняется даже после удаления.
import polars as pl
def run_schema_anomaly():
    print("Polars version:", pl.__version__)
    table = pl.DataFrame({
        "A": [1, 2, 3],
        "T0": [0.1, 0.2, 0.3],
        "T1": [0.4, 0.5, 0.6],
        "T2": [0.7, 0.8, 0.9],
    })
    steps = ["T0", "T1", "T2"]
    print("Original columns:", table.columns)
    print("Time columns:", steps)
    lf = table.lazy()
    print("Schema before cumsum:", lf.collect_schema().names())
    stage = (
        lf.select(pl.cum_sum_horizontal(steps))
          .unnest("cum_sum")
          .rename({name: f"C{name}" for name in steps})
    )
    print("Schema after cumsum:", stage.collect_schema().names())
    try:
        _ = stage.collect()
        print("v1: No bug reproduced")
    except pl.exceptions.ColumnNotFoundError as err:
        print(f"v1: BUG REPRODUCED: {err}")
    stage2 = stage.drop("literal")
    stage2 = pl.concat([pl.LazyFrame({"B": [1, 2, 3]})], how="horizontal").hstack(stage2)
    print("Schema after drop and concat:", stage2.collect_schema().names())
    try:
        _ = stage2.collect()
        print("v2: No bug reproduced")
    except pl.exceptions.ColumnNotFoundError as err:
        print(f"v2: BUG REPRODUCED: {err}")
if __name__ == "__main__":
    run_schema_anomaly()
Результат показывает схему с неожиданной записью literal после кумулятивной операции и шага unnest. Даже после удаления и горизонтальной конкатенации другого фрейма сборка всё равно падает с ColumnNotFoundError.
Что на самом деле происходит
Это баг. В Polars 1.31.0 сочетание cum_sum_horizontal и unnest могло порождать «призрачный» столбец literal, который оставался в выводимой схеме и приводил к сбоям при выполнении плана. Схема выглядела корректно на первый взгляд, но не совпадала с тем, что удавалось материализовать при collect, отсюда и ColumnNotFoundError.
Исправление в Polars 1.32.0 и изменение поведения
В Polars 1.32.0 ошибку со столбцом literal исправили. После обновления проблема с «призраком» исчезает. Однако есть сопутствующее изменение в способе передачи аргументов в cum_sum_horizontal. Передача списка имён напрямую теперь приводит к InvalidOperationError; список нужно распаковывать.
# Теперь это приводит к ошибке в 1.32.0
lf.select(pl.cum_sum_horizontal(steps)).collect()
# InvalidOperationError: не удалось сложить столбцы: dtype не был списком на всех уровнях вложенности: 
# (левый: list[str], правый: f64)
Распаковка столбцов работает как ожидается:
lf.select(pl.cum_sum_horizontal(*steps)).collect()
Чтобы вернуть кумулятивные значения по каждому столбцу, как и прежде, выполните unnest и переименование:
fixed = (
    lf.select(pl.cum_sum_horizontal(*steps))
      .unnest("cum_sum")
      .rename({name: f"C{name}" for name in steps})
)
# fixed.collect()  # успешно выполняется в 1.32.0
В исходниках cum_sum_horizontal — это обёртка над cum_fold. В 1.32.0 cum_fold по‑прежнему принимает список. Если вам удобнее API на списках, используйте cum_fold напрямую, а затем сделайте unnest результата.
(
    lf
      .select(pl.cum_fold(0, lambda x, y: x + y, steps))
      .unnest(pl.all())
      .collect()
)
Почему это важно
Тонкие несоответствия схемы в ленивых конвейерах сложно отлаживать. Когда столбец есть в логическом плане, но не материализуется при выполнении, ошибка всплывает только на этапе collect и часто отрывается от исходного преобразования. Зная, что проблема со столбцом literal — это баг 1.31.0, а в 1.32.0 изменился способ передачи аргументов в cum_sum_horizontal, можно избежать траты времени на «призрачные» столбцы и неожиданную форму аргументов.
Практические выводы
Обновитесь до Polars 1.32.0 или новее, чтобы не получать артефакт схемы со столбцом literal после горизонтальных кумулятивных сумм. Если используете cum_sum_horizontal, передавайте столбцы как распакованные аргументы, а не списком. Если в кодовой базе предпочитается список выражений, cum_fold со списком по‑прежнему работает в 1.32.0 и может сопровождаться unnest для получения развёрнутых столбцов.
Итог
Поведение со столбцом literal при связке cum_sum_horizontal и unnest в 1.31.0 — это реальный баг, вызывавший путаницу в схеме и ошибки выполнения. Релиз 1.32.0 его исправляет и одновременно подталкивает к передаче аргументов в cum_sum_horizontal через распаковку. Если столкнулись с новым InvalidOperationError, переходите на pl.cum_sum_horizontal(*cols) или используйте pl.cum_fold со списком и unnest. Учитывая эти детали, горизонтальные кумулятивные агрегирования становятся предсказуемыми, а ленивые планы — устойчивыми.
Статья основана на вопросе на StackOverflow от Nicolò Cavalleri и ответе от jqurious.