Производительность, best practices и что дальше

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

Производительность pandas определяется тремя вещами: правильными типами данных, векторизацией вместо циклов и разумным объёмом данных для одной машины.

Рычаг 1: правильные dtypes и память

Память — частое узкое место. Грамотный выбор типов сокращает её в разы и заодно ускоряет операции:

  • category для строковых столбцов с низкой кардинальностью (город, статус) — экономия в разы.
  • Уменьшение разрядности: int8/int16 вместо int64, float32 вместо float64, где диапазон позволяет (pd.to_numeric(..., downcast=...)).
  • Чтение только нужных столбцов (usecols) — не грузите лишнее в память.

Проверять расход помогает df.memory_usage(deep=True) и df.info(memory_usage="deep"). Часто простой перевод нескольких столбцов в category снижает память кратно.

Рычаг 2: векторизация вместо циклов

Это главный закон pandas, который мы выводили в разделе про apply: операция над всем массивом на C в десятки-сотни раз быстрее, чем Python-цикл по строкам. Иерархия скорости от быстрого к медленному:

  1. Векторные операции (df["a"] * df["b"], маски, .str, .dt) — почти всегда выбор №1.
  2. np.where / np.select для условной логики «если-то».
  3. Встроенные groupby.agg/transform с именованными функциями.
  4. apply — гибко, но медленно; только когда иначе нельзя.
  5. Явный цикл for / iterrows() — почти всегда антипаттерн.

Сравним «условную категоризацию» циклом и векторно (через идею np.where) по числу шагов:

prices = [990, 2500, 18000, 300, 7500]

# антипаттерн: Python-цикл, ветвление на каждой строке
labels_loop = []
for p in prices:
    labels_loop.append("дорого" if p >= 5000 else "дёшево")

# векторная идея (np.where): одно выражение над всем массивом
labels_vec = ["дорого" if p >= 5000 else "дёшево" for p in prices]

print(labels_loop)
print("совпадают:", labels_loop == labels_vec)

Вывод:

['дёшево', 'дёшево', 'дорого', 'дёшево', 'дорого']
совпадают: True

В pandas это np.where(df["цена"] >= 5000, "дорого", "дёшево") — одно векторное выражение вместо цикла. На больших данных разница в скорости огромна.

Рычаг 3: eval и query для тяжёлых выражений

Для сложных арифметических выражений над большими DataFrame df.eval("...") и df.query("...") используют движок numexpr, который вычисляет выражение за один проход без создания множества промежуточных временных массивов:

df["итог"] = df.eval("цена * шт - скидка")   # без промежуточных Series
big = df.query("цена > 1000 and город == 'Москва'")

Выигрыш заметен именно на больших данных и длинных выражениях; на маленьких разницы почти нет.

Антипаттерны, которых надо избегать

АнтипаттернКак правильно
iterrows() / цикл по строкамвекторная операция или apply в крайнем случае
Рост DataFrame через concat в циклесобрать список и один concat в конце
Цепочечное присваивание df[m]["c"]=…df.loc[m, "c"] = …
Многократное чтение одного файлапрочитать раз, сохранить в parquet
apply там, где есть векторный путьарифметика столбцов, маски, np.where

«Рост в цикле» особенно коварен: каждый concat копирует всю накопленную таблицу, и сложность становится квадратичной. Соберите куски в список и склейте одним вызовом.

Почему именно iterrows() так плох? Он не просто медленно итерирует — на каждой строке он создаёт новую Series со смешанным типом (ведь у разных столбцов разные dtypes), упаковывая и распаковывая значения. Это сотни тактов на строку там, где векторная операция тратит единицы. На миллионе строк разница между iterrows-циклом и векторной операцией легко достигает сотен раз — минуты против долей секунды. Если уж совсем нельзя без построчного прохода (редкий случай), itertuples() заметно быстрее iterrows(), потому что отдаёт лёгкие именованные кортежи без накладных расходов на Series. Но и это — последнее средство.

Профилирование: сначала измерь, потом оптимизируй

Прежде чем что-то ускорять, узнайте, что именно медленно. В интерактивном анализе для этого есть простой инструмент — измерение времени ячейки. Оптимизировать «на глазок» — частая ошибка: люди вылизывают код, который занимает 1% времени, и не замечают одного медленного merge или apply, съедающего остальные 99%. Типичный порядок действий: измерить общее время пайплайна, найти самый дорогой шаг, проверить, нет ли там скрытого цикла (apply, iterrows) или неоптимальных типов (строки вместо category), и точечно переписать именно его. Память профилируйте через df.info(memory_usage="deep") — часто оказывается, что половину занимают два-три строковых столбца, которые просятся в category.

Визуализация: быстрый взгляд через .plot

Для быстрой проверки данных у pandas есть встроенный .plot поверх matplotlib — не для финальных графиков, но идеально, чтобы «глазами» увидеть тренд или распределение:

df["продажи"].plot()                    # линия по индексу
df["цена"].plot(kind="hist", bins=30)   # гистограмма распределения
df.groupby("город")["чек"].sum().plot(kind="bar")  # столбцы по группам

Это часть рабочего цикла исследования: посчитал агрегат — тут же построил, увидел аномалию — пошёл разбираться.

Когда pandas не тянет: что дальше

pandas рассчитан на данные, помещающиеся в память одной машины (ориентир — до нескольких гигабайт). Когда данных больше или нужна параллельность, есть наследники и альтернативы:

ИнструментКогда
Polarsданные всё ещё на одной машине, но pandas медленный; ленивые вычисления, многопоточность, схожий API
Daskданные больше памяти, но хочется pandas-подобный API; разбивает на части и считает параллельно
Spark (PySpark)по-настоящему большие данные на кластере из многих машин
DuckDBаналитический SQL прямо по файлам parquet/csv, не загружая всё в память

Хорошая новость: навыки переносятся. Polars и Spark используют те же идеи — столбцы, выравнивание, group by, join, ленивость, — поэтому, освоив pandas, вы освоите и их быстрее.

Лучшие практики (сводка курса)

  • Задавайте типы рано и точно; используйте category и nullable-типы осознанно.
  • Векторизуйте: маски, арифметика столбцов, .str/.dt, np.where — раньше, чем apply.
  • Присваивайте через loc одной операцией; включайте Copy-on-Write.
  • Защищайте merge через validate и проверку shape.
  • Храните промежуточные данные в parquet; читайте только нужные столбцы.
  • Профилируйте память (info, memory_usage) и не растите DataFrame в цикле.

Итог

  • Три рычага скорости: правильные dtypes, векторизация, разумный объём данных.
  • eval/query ускоряют тяжёлые выражения на больших данных.
  • Главные антипаттерны — циклы по строкам и рост DataFrame в цикле.
  • Когда pandas не тянет — Polars, Dask, Spark, DuckDB; навыки переносятся.
Проверьте себя
1. Почему наращивание DataFrame через concat в цикле — антипаттерн?
Aconcat не работает в циклах
BКаждый concat копирует всю накопленную таблицу, давая квадратичную сложность; лучше собрать список и склеить один раз
CЭто вызывает SettingWithCopyWarning
Dconcat теряет данные
2. Какой инструмент уместен, когда данные не помещаются в память одной машины, но хочется pandas-подобный API?
AПросто увеличить chunksize
BDask — разбивает данные на части и считает параллельно с похожим API
CExcel
DУменьшить число столбцов до одного
3. Что быстрее всего для условной категоризации столбца по порогу на большом DataFrame?
AЦикл for с append
Bdf.apply(axis=1)
Cnp.where(df['цена'] >= 5000, 'дорого', 'дёшево')
Diterrows() с условием
Поддержать проект