open atlas
↑ К треку
Python для JS/TS-разработчиков PY · 09 · 01

pandas и Polars прагматично: колоночные блоки, eager против lazy и ловушки, кусающие в продакшене

DataFrame — колоночные массивы: pandas оборачивает numpy-блоки, строки — массивы указателей; polars поверх Arrow с ленивым оптимизатором. Разбираем predicate/projection pushdown, SettingWithCopyWarning и Copy-on-Write, тихое повышение int до float и когда какой инструмент прав.

PY Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Ночной свод выручки дорос до 47 минут pandas, и дежурные знали его наизусть: стартовал поздно — дашборды в 7 утра пустые. Команда переписала его на polars, ожидая обещанного выигрыша на порядок, — первый прогон занял 44 минуты. Переписывание добросовестно перенесло df.apply(lambda row: ..., axis=1) в map_elements, так что новый движок тратил время ровно там же, где pandas: вызывал Python-функцию десять миллионов раз, по разу на строку, под присмотром интерпретатора. Вторая переделка заменила построчную лямбду колоночными выражениями — pl.when(...).then(...), арифметика над целыми колонками — и поменяла read_parquet на scan_parquet, чтобы оптимизатор увидел весь запрос до обращения к диску. Четыре минуты. Та же машина, те же данные, та же бизнес-логика. Ускорение никогда не пришло бы от того, что Rust быстрее исполняет вашу Python-лямбду; оно пришло от того, что Python вообще перестал выполняться на каждую строку, а план запроса прочитал две колонки вместо сорока.

Что такое DataFrame на самом деле

Снимите API — и DataFrame окажется набором именованных колоночных массивов плюс метки строк. В pandas колонки живут внутри BlockManager: соседние колонки одного dtype консолидируются в двумерные numpy-блоки (все ваши float64-колонки могут быть одной непрерывной матрицей), а индекс — отдельная размеченная ось, которая выравнивает, джойнит и иногда удивляет. Числовые колонки — настоящие машинные массивы: восемь байт на float, векторизуемо. Строковые исторически — нет: классический dtype object — это массив указателей на отдельные Python-объекты str, разбросанные по куче, каждый с ~50-байтовыми накладными расходами на объект, которые вы измеряли в уроке про память юнита 08. Десять миллионов коротких строк стоят примерно 700–900 МБ как объекты против ~150 МБ как Arrow-колонка строк (один непрерывный байтовый буфер плюс смещения) — разрыв в 5 раз ещё до каких-либо вычислений.

Polars стартует с другого конца: каждая колонка — массив Arrow (типизированный, непрерывный, null-битмап вместо NaN-сентинелей), движок — многопоточный Rust поверх этой памяти, индекса нет — строки суть позиции, выравнивание — явные джойны. Та же раскладка Arrow — это то, что pandas 2+ выставляет как Arrow-backed dtypes и что pandas 3 принимает для строк по умолчанию; поэтому обмен между ними (и DuckDB) может быть передачей указателей, а не сериализацией.

Eager против lazy: механизм, а не маркетинг

pandas исполняет строку за строкой. read_parquet материализует всё; булев фильтр строит второй полный DataFrame; groupby — третий. Пиковая память — сумма промежуточных результатов, и ни один шаг не знает, что нужно следующему. Polars в eager-режиме ведёт себя так же — структурное отличие в LazyFrame. pl.scan_parquet(...) не читает ничего; он возвращает узел плана. Каждый последующий .filter(), .select(), .group_by() наращивает логический план, а .collect() отдаёт план оптимизатору до исполнения. Основную работу делают два переписывания: projection pushdown — читаются только колонки, которые запрос реально использует, и 2 из 40 колонок — это примерно 5% IO, — и predicate pushdown — фильтры переезжают в сам скан, где статистики row groups в Parquet позволяют пропускать целые куски файла. Добавьте устранение общих подвыражений и потоковый движок, обрабатывающий батчи, когда данные больше RAM, — вот настоящий механизм: меньше прочитано, меньше материализаций, все ядра заняты.

Честные числа для groupby-mean по одному ключу на 10 млн строк на 8-ядерной машине: pandas — около 2–3 с, polars — около 0,3–0,5 с, то есть 5–10 раз. Сцепите фильтры, джойны и выбор колонок над широкой таблицей — и ленивый план растянет разрыв до 10–50 раз, потому что pandas платит полную материализацию на каждой строке, а polars — один раз, за урезанный план.

Викторина

Конвейер LazyFrame над Parquet-файлом из 40 колонок заканчивается фильтром по country и выбором одной колонки amount. Что collect() реально прочитает с диска?

Ловушки pandas, кусающие в продакшене

Три класса багов отвечают за большинство инцидентов в pandas-кодовых базах. Когда вы смотрите на испорченные идентификаторы, пропавшие записи или джоб, который 98% времени проводит в apply, — скорее всего, перед вами одна из них.

Цепное присваивание. df[df.qty > 0]["price"] = 0 выполняет две операции индексации: первая может вернуть view или копию в зависимости от внутренней раскладки блоков, и присваивание попадает в то, что досталось, — иногда мутируя оригинал, иногда временный объект, который испаряется. SettingWithCopyWarning — это признание pandas, что он не может сказать, какой случай у вас. Семантика Copy-on-Write (опциональная в pandas 2.x, поведение по умолчанию с pandas 3) снимает неоднозначность: каждый производный фрейм ведёт себя как копия, материализуемая лениво только при записи, поэтому цепное присваивание детерминированно никогда не распространяется — поддерживаемое написание — один .loc[mask, "price"] = 0 на оригинале.

Тихое повышение dtype. У numpy int64 нет пропущенного значения, поэтому в момент, когда merge, reindex или выравнивание создают дыру, вся колонка повышается до float64, а в дыре оказывается NaN. Два следствия: идентификаторы начинают печататься как 1.0, и — дорогое — у float64 53-битная мантисса, так что целые больше 2^53 (около 9 × 10^15; любой snowflake-id подходит) тихо округляются до ближайшего представимого. Это и есть merge, испортивший идентификаторы: ничего не упало, числа просто стали отличаться на единицу. Лекарство — расширенный nullable-тип Int64 или Arrow-backed dtypes, где пропуск — бит маски, а не float.

Обрыв на object-dtype. Всё в колонке object — и любой df.apply(..., axis=1) — исполняется циклом уровня Python: кадр интерпретатора, трафик refcount, ни SIMD, ни параллелизма. Ожидайте 10–100 раз против векторизованного пути. Сигнатура в профайлере безошибочна: джоб, который на 95% состоит из apply.

Все три ловушки объединяет одна черта: pandas делает что-то законное и даже корректное по собственным правилам, ничего не поднимает и оставляет вас с тихой порчей данных или десятиминутным джобом, который вы считали починенным.

Где остаётся pandas, где выигрывает polars и как мигрировать

pandas остаётся прав там, где суть — его экосистема: scikit-learn, statsmodels, склейка с графиками, ноутбуки, где данные помещаются в память с запасом и цена пошаговой материализации — шум. Polars выигрывает конвейеры: регулярные джобы над данными около или больше RAM, где pushdown, параллелизм и строгая по умолчанию семантика (нет выравнивания по индексу, нет тихого повышения — пропущенные целые остаются целыми, ошибки громкие) окупаются каждую ночь. Миграция — не транслитерация: pandas мыслит вызовами методов на материализованных фреймах, polars — выражениямиpl.col("amount") * pl.col("fx"), скомпонованными внутри select, with_columns, group_by().agg, — и построчные лямбды обязаны стать выражениями, иначе движок вам не поможет; это Хук в одном предложении. Зато граница дешёвая: оба говорят на Arrow, так что polars-конвейер может вызвать to_pandas() в самом конце — zero-copy для Arrow-backed dtypes — и передать последнюю милю графической склейке.

Викторина

Колонка account_id типа int64 приходит через left merge, в котором у части строк нет совпадения. Затем джоб пишет идентификаторы во внешнюю систему. Каков режим отказа?

Вспомните перед уходом
  1. 01
    Объясните механизм ленивого ускорения polars — почему честный ответ не сводится к тому, что Rust быстрый?
  2. 02
    Разберите три продакшен-ловушки pandas: цепное присваивание, повышение dtype и обрыв на object — механизм и лекарство для каждой.
Итог

DataFrame — именованные колоночные массивы. pandas реализует их numpy-блоками за BlockManager плюс размеченный индекс: числовые колонки — настоящие машинные массивы, а классические строковые — массивы указателей на объекты в куче, отсюда ~5-кратный налог памяти против Arrow-строк и причина, по которой здесь важны пообъектные накладные расходы из юнита 08. Polars реализует их массивами Arrow под многопоточным Rust-движком и вовсе без индекса. Более глубокий раскол — исполнение: pandas работает жадно, по полной материализации на строку, а LazyFrame у polars накапливает логический план, который оптимизатор переписывает — projection pushdown читает только нужные колонки, predicate pushdown заносит фильтры в скан, где статистики row groups пропускают данные, — до того как что-либо исполнится; именно план, а не язык реализации, превратил 47 минут в 4 — и только после того, как построчные лямбды стали выражениями. В проде pandas кусает трижды: цепное присваивание пишет в «может-быть-view» (Copy-on-Write, по умолчанию с pandas 3, делает производные фреймы детерминированными копиями), пропуски повышают int64 до float64 и тихо портят id за 2^53 (лечится nullable Int64 или Arrow-типами), а object-dtype с построчным apply — обрыв в 10–100 раз. Держите pandas там, где живут экосистема и малые данные; гоняйте конвейеры на polars; пусть Arrow делает границу передачей указателей без копий. Теперь, когда встретите конвейер, перенесённый на polars, но почти не ускорившийся, — ищите прежде всего построчную лямбду: именно её замена на выражения меняет сорок семь минут на четыре.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.