pandaspythonвекторизацияпроизводительностьоптимизация

pandas apply медленный: векторизация вместо apply

2026-07-10 11 мин

Коротко: .apply() и особенно iterrows() медленные, потому что гоняют питоновский цикл по строкам и на каждой итерации создают Python-объекты вместо работы с C-массивами. Замена на векторные операции (арифметика по колонкам, np.where, .map, .str-методы) убирает цикл из интерпретатора и ускоряет обработку в 10-100 раз на тех же данных. Правило простое: если операцию можно выразить через операции над целыми колонками — векторизуй; .apply оставляй только для сложной построчной логики, которую иначе не записать.

Разберём, откуда берётся тормоз, как переписать типовые случаи и когда .apply всё-таки оправдан — с замерами до и после.

Почему apply и iterrows так тормозят в pandas?

DataFrame внутри хранит данные не как список строк, а как набор колонок — каждая колонка это непрерывный C-массив (numpy ndarray). Быстрые операции pandas работают сразу над всем массивом в скомпилированном коде на C, минуя интерпретатор Python.

iterrows() делает ровно противоположное: он проходит по строкам питоновским циклом и на каждой итерации собирает из строки объект Series. Создание Series — это аллокация объекта, копирование значений, разбор типов. Для таблицы на миллион строк вы получаете миллион созданных и выброшенных объектов. Отсюда и катастрофическая медленность. .apply(func, axis=1) немного умнее, но принципиально то же самое: он вызывает вашу питоновскую функцию func для каждой строки. Даже если тело функции — одно сложение, накладные расходы на вызов Python-функции миллион раз перевешивают саму работу.

Иерархия скорости для построчной обработки, от худшего к лучшему:

Если хотите с нуля разобраться, как устроены Series и DataFrame и почему колонки — это массивы, начните с pandas с нуля: Series и DataFrame. А проверить себя на построчных преобразованиях удобно в Python-тренажёре.

Что такое векторизация и почему она в разы быстрее?

Векторизация — это выполнение операции сразу над всем массивом одной командой, а не поэлементно в цикле Python. Когда вы пишете df['a'] + df['b'], pandas не проходит по строкам на Python — он передаёт два C-массива в numpy, где сложение выполняется скомпилированным циклом на уровне процессора, часто с SIMD-инструкциями (одна инструкция обрабатывает несколько чисел за такт).

Разница в накладных расходах. В питоновском цикле на каждый элемент тратится: разбор байткода, проверка типов, разыменование объектов, boxing/unboxing чисел. В векторной операции этого нет — цикл выполняется в C, типы известны заранее, числа лежат подряд в памяти и хорошо кэшируются.

Грубая интуиция по стоимости одной операции:

ПодходГде крутится циклОтносительная скорость
iterrows + Pythonинтерпретатор Python1x (база)
itertuples / apply(axis=1)интерпретатор Python3-10x
Series.apply / .mapинтерпретатор Python5-20x
Векторная операция (numpy/pandas)скомпилированный C50-300x

Числа в таблице — порядковые ориентиры, конкретика зависит от данных и операции. Но вывод устойчив: перенос цикла из Python в C даёт выигрыш на один-два порядка. Механику ufunc и векторизации в numpy подробно разбирали в numpy с нуля: векторизация и ufunc, а базовый курс — numpy с нуля.

Как переписать apply на векторные операции?

Самый частый антипаттерн — арифметика через .apply(axis=1). Возьмём расчёт выручки строки и наценки:

import pandas as pd
import numpy as np

df = pd.DataFrame({
    'price': np.random.randint(100, 1000, size=1_000_000),
    'qty': np.random.randint(1, 10, size=1_000_000),
    'discount': np.random.rand(1_000_000),
})

# Медленно: питоновская функция на каждую строку
def revenue_row(row):
    return row['price'] * row['qty'] * (1 - row['discount'])

df['revenue_slow'] = df.apply(revenue_row, axis=1)

Здесь apply(axis=1) вызывает revenue_row миллион раз. Векторная версия делает то же самое одной строкой над колонками:

# Быстро: операции над целыми колонками
df['revenue_fast'] = df['price'] * df['qty'] * (1 - df['discount'])

Результат идентичен, но вторая версия работает в десятки раз быстрее, потому что умножение и вычитание идут по всему массиву в numpy. Проверить равенство результатов:

assert np.allclose(df['revenue_slow'], df['revenue_fast'])

Такая построчная выручка потом сворачивается в продуктовые агрегаты — AOV (средний чек) или ARPU на пользователя. Если базовый расчёт по строкам делать через apply, то агрегация по десяткам миллионов строк встанет колом ещё до groupby, поэтому именно этот нижний слой обязан быть векторным.

Тот же принцип работает для условий, сравнений, логических операций, округления. Почти любую формулу «взять несколько колонок и что-то с ними сделать» можно записать без apply. Если внутри был if/else, его заменяет np.where (следующий раздел). Больше практики по преобразованиям — в разборе pandas с нуля: apply, str, dt, векторизация.

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

Как использовать np.where и .map вместо apply?

Условная логика — второй по частоте повод для apply. Классика:

# Медленно: apply с if/else построчно
def segment(row):
    if row['revenue_fast'] > 5000:
        return 'high'
    elif row['revenue_fast'] > 2000:
        return 'mid'
    else:
        return 'low'

df['seg_slow'] = df.apply(segment, axis=1)

Одно условие переписывается через np.where(условие, значение_если_да, значение_если_нет):

# Быстро: одно условие
df['is_big'] = np.where(df['revenue_fast'] > 5000, 'big', 'small')

Несколько условий — через np.select, который принимает список масок и список значений:

# Быстро: несколько условий без apply
conditions = [
    df['revenue_fast'] > 5000,
    df['revenue_fast'] > 2000,
]
choices = ['high', 'mid']
df['seg_fast'] = np.select(conditions, choices, default='low')
np.select проверяет условия по порядку, как if/elif/else — первое сработавшее выигрывает. Это остаётся векторным: маски считаются над целыми колонками.

Отдельный случай — маппинг значений по словарю. Если у вас есть справочник «код → название», не пишите apply(lambda x: mapping.get(x)), используйте .map:

region_map = {1: 'Москва', 2: 'СПб', 3: 'Казань'}
df['region_code'] = np.random.randint(1, 4, size=len(df))

# Медленно
df['region_slow'] = df['region_code'].apply(lambda x: region_map.get(x, 'other'))

# Быстро: векторный маппинг по словарю
df['region_fast'] = df['region_code'].map(region_map).fillna('other')
.map со словарём реализован эффективно и обгоняет apply заметно, а читается чище. Такие приёмы постоянно всплывают на собеседованиях — типовые задачи собраны в разделе вопросы для собеседования, а живой прогон — в AI-интервьюере.

Как ускорить обработку текста через .str-методы?

Строки — та область, где apply кажется неизбежным, но чаще всего нет. У колонки-строки в pandas есть аксессор .str с векторизованными версиями почти всех строковых операций: .lower(), .strip(), .contains(), .replace(), .split(), .extract() с регулярками.

s = pd.Series(['  Ivan@MAIL.ru ', 'PETR@yandex.ru', ' Olga@gmail.com '] * 300_000)

# Медленно: питоновская обработка строки на каждую ячейку
clean_slow = s.apply(lambda x: x.strip().lower())

# Быстро: цепочка .str-методов
clean_fast = s.str.strip().str.lower()
.str-методы всё ещё выполняют часть работы на уровне Python (обработка строк в CPython не так тривиально векторизуется, как числа), поэтому выигрыш тут скромнее, чем на арифметике — но он есть, а код заметно чище и меньше подвержен ошибкам с NaN (.str корректно пропускает пропуски вместо падения).

Извлечение по регулярке — тоже без apply:

# Достать домен из email одной операцией
domains = s.str.strip().str.extract(r'@(.+)$')

Для тяжёлой текстовой логики, которую .str не покрывает (сложный парсинг с состоянием), apply остаётся разумным выбором — об этом ниже. Строковые функции SQL и их аналоги в pandas удобно держать под рукой; см. шпаргалку по pandas.

Насколько быстрее векторизация — замеры %timeit?

Замерять надо самому: цифры зависят от размера данных, типов и версии pandas. Инструмент — %timeit в Jupyter (или timeit в скрипте). Возьмём миллион строк и сравним три версии расчёта выручки.

import pandas as pd
import numpy as np

n = 1_000_000
df = pd.DataFrame({
    'price': np.random.randint(100, 1000, size=n),
    'qty': np.random.randint(1, 10, size=n),
    'discount': np.random.rand(n),
})

# 1. iterrows — самый медленный
def with_iterrows(df):
    out = []
    for _, row in df.iterrows():
        out.append(row['price'] * row['qty'] * (1 - row['discount']))
    return out

# 2. apply по строкам
def with_apply(df):
    return df.apply(lambda r: r['price'] * r['qty'] * (1 - r['discount']), axis=1)

# 3. векторизация
def vectorized(df):
    return df['price'] * df['qty'] * (1 - df['discount'])

В ячейках Jupyter:

%timeit with_iterrows(df)   # порядка десятков секунд
%timeit with_apply(df)      # порядка нескольких секунд
%timeit vectorized(df)      # порядка единиц-десятков миллисекунд

Типичная картина по порядкам величины на такой задаче:

МетодВремя на 1 млн строкВо сколько раз медленнее векторной
iterrows~30-60 с~1000-3000x
apply(axis=1)~5-12 с~300-800x
.map / Series.apply~0.3-0.8 с~20-60x
Векторизация~10-20 мс1x

Конкретные числа у вас будут другими — важен порядок. На чистой арифметике векторизация легко даёт ×100 и больше против apply(axis=1) и ×1000 против iterrows. На строках разрыв меньше (×2-×10), потому что и .str частично работает в Python.

Практический совет по замерам:

Если данные перестали влезать в память или векторизация в pandas упирается в потолок на десятках миллионов строк, стоит посмотреть в сторону колоночных движков — сравнение есть в Polars против pandas.

В каких случаях apply всё-таки оправдан?

apply — не абсолютное зло. Он оправдан, когда векторной альтернативы нет или она сложнее и хуже читается. Реальные случаи:

Правило принятия решения:

Ещё нюанс: даже когда apply нужен, apply по одной колонке (df['x'].apply(f)) почти всегда быстрее, чем apply(axis=1) по всей строке, потому что не собирает Series из строки. Старайтесь сузить область до конкретных колонок.

Хотите закрепить это руками — в Python-тренажёре первые 5 задач открыты бесплатно: там есть кейсы на переписывание apply в векторную форму с проверкой результата прямо в браузере через Pyodide. Дальше, если зайдёт, открывается Pro с полным банком из 402 задач. Никакого давления — просто решаете и видите, где ваш код тормозит. Параллельно можно подтянуть базу в SQL-тренажёре и разложить теорию по полочкам в курсе pandas с нуля.

Короткий итог: тормозит не pandas, а питоновский цикл поверх него. Уберите цикл из интерпретатора — и те же данные обрабатываются на один-два порядка быстрее без единой новой строчки инфраструктуры. А apply держите как аккуратный инструмент для случаев, где векторизация правда не помогает.

Смежные материалы: pandas с нуля: groupby, agg, transform, pivot, шпаргалка по SQL для аналитика, Python на собеседовании: вопросы и задачи.

Отработай векторизацию на реальных задачах
Первые 5 Python-задач бесплатно — разбор с проверкой в браузере через Pyodide. Дальше открывается Pro с полным банком из 402 задач.
Открыть Python-тренажёр →