Москва: проверенные б/у ноутбуки с гарантией и проверкой перед оплатой
+7 906 722-94-45

Главная → Статьи → Производительность pandas: векторизация вместо apply

Производительность pandas: векторизация, dtypes и память вместо циклов

Как ускорить pandas-код в десятки раз: векторные операции вместо apply и iterrows, экономные dtypes, чейнинг операций, когда выходить за пределы pandas — с измеримыми правилами.

Прямой ответ

Три причины медленного pandas-кода, по частоте: циклы там, где есть векторные операции (apply по строкам, iterrows — на порядок медленнее встроенных), расточительные типы (object-строки и int64/float64, где хватило бы category/int32 — втрое больше памяти и промахов кэша), и лишние промежуточные копии (длинный чейнинг с присваиваниями). Исправление в этом порядке обычно даёт 10–100× ускорения без смены инструмента. Профиль до оптимизации обязателен: иногда узкое место — чтение CSV, а не вычисления.

Правило 1. Векторизация: операции над колонками, а не строками

Внутренности pandas построены на NumPy-массивах, и арифметика/сравнения над целой колонкой выполняются одним C-циклом без интерпретатора Python на элемент — отсюда разница в порядки величин. Практический словарь замен: вместо df.apply(lambda r: r['a'] + r['b'], axis=1) — просто df['a'] + df['b']; вместо цикла с условиями — np.where(cond, x, y) или pd.Series.mask/where; вместо apply для строковых преобразований — векторный df['s'].str.lower().str.strip() (медленнее numpy, но быстрее Python-лямбд); вместо ручного перебора групп — groupby().agg() с именованными агрегациями. Apply остаётся законным для genuinely-сложной логики, которую нельзя выразить над колонками — но это редкость, и сначала стоит честно поискать векторную форму.

Правило 2. Типы: память — это скорость

Каждый лишний байт на строку — лишняя работа кэша и GC. Приёмы по убыванию эффекта: строковые колонки с малым числом значений → astype('category') (экономия памяти часто в 5–10 раз); числа → самый узкий честный тип (astype('int32'), 'float32' — с проверкой, что диапазон/точность подходят; pd.to_numeric(downcast='integer')); nullable-типы pandas (Int64, string) вместо object с NaN; при чтении — явные dtype= и usecols= в read_csv (не читать колонки, которые не нужны). Проверка эффекта — df.info(memory_usage='deep') до и после: разница в разы на миллионах строк обычна. Побочный выигрыш — файл влезает в память ноутбука без свапа.

Правило 3. Меньше копий, меньше проходов

Длинный чейнинг df = df[df.a>0] затем df = df.sort... затем df['x']=... создаёт серию промежуточных кадров — на больших данных это гигабайты мусора. Метод .pipe() и чейнинг с assign/query позволяют выразить пайплайн одним выражением, которое читается сверху вниз и меньше плодит именованных промежуточных состояний (копии всё равно создаются, но код короче и яснее). Отдельные дорогие операции: merge больших кадров — проверяйте ключи на тип (object-ключ против int64 заставит pandas привести типы и сломает индексы — сортировка ключей и sort=False экономят); groupby(..., sort=False), когда порядок групп не нужен; фильтрация до мержа, а не после — самая частая и самая дешёвая оптимизация. Индекс: установка осмысленного индекса (set_index) ускоряет выборки по нему, но не злоупотребляйте multiindex там, где хватит колонки.

Когда pandas — не тот инструмент

Честные границы: данные не влезают в память даже после оптимизации типов → DuckDB/Polars читают и агрегируют большие файлы потоково; критичная по скорости числовая петля, не выражаемая векторно → numba/cython или numpy-руками (основы — в статье про NumPy для разработчика); параллельная обработка партиций → modin/dask (ценой сложности). Переход оправдан измерением: если после векторизации и dtypes код работает секунды — миграция на новый движок ради миллисекунд не стоит ни дня работы, ни нового бага.

Практика измерения

Оптимизация без замера — гадание. Микроуровень — %timeit в ноутбуке для одной операции; уровень пайплайна — простой тайминг по шагам (time.perf_counter вокруг блоков) или cProfile для поиска неожиданных горячих мест (приёмы — в статье про профилирование Python-приложений); уровень памяти — df.info(memory_usage='deep') и трейсер memory_profiler для пиков. Полезная привычка: после каждого изменения — фиксация «было/стало» в комментариях ноутбука; оптимизации без цифр забываются и откатываются.

Практический сценарий: ускорение расчёта в сорок раз

Реальный по профилю кейс: датасет на три миллиона строк, колонка-вычисление df["score"] = df.apply(lambda r: r["amount"] * (1 + r["bonus"]) if r["active"] else 0, axis=1) считается девяносто секунд. Что происходит: apply по строкам создаёт Series на каждой итерации, интерпретатор Python выполняется три миллиона раз. Первый шаг — переписать векторно: df["score"] = np.where(df["active"], df["amount"] * (1 + df["bonus"]), 0) — та же логика одним выражением над колонками; время — две секунды. Второй шаг — типы: amount и bonus приводятся к float32 (точности хватает, диапазон проверяется), active — к bool (не object со строками «yes/no» — их унифицировали на этапе очистки); память кадра падает втрое, выражение ускоряется ещё на треть за счёт кеша. Третий — чтение: read_csv(usecols=[...], dtype={...}) не тянет восемь ненужных колонок и не угадывает типы; минус половина времени загрузки. Итог: полторы минуты превратились в четыре секунды без смены инструмента — только переформулировка. Замер каждого шага — через perf_counter с фиксацией «до/после» в ноутбуке; профиль в конце подтверждает, что узкое место переехало на чтение файла, — значит, вычисления оптимизировать больше незачем, работа закончена.

FAQ

iterrows — всегда зло? Почти: это самый медленный способ обхода строк (создаёт Series на каждой итерации). Если векторной формы нет, быстрее itertuples() (namedtuples, без Series), а ещё быстрее — to_numpy() и работа с массивом. Но сначала честно поищите векторизацию — она находится чаще, чем кажется.

Почему .apply иногда нормален? Для агрегаций по группе с нестандартной логикой (groupby().apply) и для редких операций на малых данных читаемость важнее микросекунд. Плох не apply как таковой, а axis=1 на миллионах строк.

Что быстрее: много мелких колонок или одна object-колонка с JSON? Колонки: JSON в object-ячейках требует парсинга на каждой операции и убивает векторизацию. Если поле действительно нужно — разверните в отдельные колонки на этапе очистки.

Стоит ли сразу брать Polars вместо pandas? Если текущий код работает и помещается в память — нет: экосистема, документация и ваши навыки в pandas окупаются. Polars/DuckDB — осознанный шаг, когда упрётесь в память/скорость на реальных данных, с миграцией одного горячего пайплайна.

Источники: pandas «Enhancing performance» и basics; справочник dtypes NumPy — ссылки в шапке.