Pandas apply и векторизация: как писать быстрее и понятнее
Когда использовать apply, почему векторные операции быстрее и как переписать медленный построчный расчёт в pandas.
Содержание статьи
В pandas почти всегда можно написать расчёт через apply, и это удобно для первого прототипа. Но функция, вызываемая для каждой строки, часто становится медленным местом и скрывает типы данных. Векторизация переносит операцию на целый столбец и обычно делает код короче, быстрее и легче для проверки.
Коротко
apply(axis=1) — хороший инструмент для сложного правила, которое трудно выразить иначе. Для арифметики, строковых методов, условий и дат сначала ищи операцию над всей Series. map и replace удобны для словаря значений, np.select — для нескольких условий.
- Начните с векторной операции и переходите к apply только при необходимости.
- Не используйте
axis=1, если условие зависит от одной колонки. - Замеряйте время на репрезентативном объёме данных.
- После переписывания сравните результат старой и новой версии на выборке.
Простая арифметика не требует apply
Построчный код выглядит привычно, но pandas уже умеет выполнять операцию над всей колонкой. Векторная версия лучше показывает формулу и не создаёт Python-вызов на каждую строку.
# Медленнее и сложнее читать на больших таблицах
orders['net_revenue'] = orders.apply(
lambda row: row['revenue'] - row['discount'], axis=1
)
# Векторная версия
orders['net_revenue'] = orders['revenue'] - orders['discount']Условия: np.select вместо вложенных if
Когда сегмент зависит от нескольких условий, np.select делает порядок правил явным. Условия проверяются сверху вниз, поэтому сначала ставьте более специфичное правило. Значение default обязательно: иначе неизвестный случай легко потеряется.
import numpy as np
conditions = [
orders['revenue'].ge(5000),
orders['revenue'].ge(1500),
orders['revenue'].ge(0),
]
choices = ['high', 'medium', 'low']
orders['order_segment'] = np.select(conditions, choices, default='invalid')map и str работают над колонкой
Словарь каналов, нормализация регистра и поиск части строки не требуют построчной функции. Такие операции проще покрыть тестами и легче заметить, если неизвестное значение превратилось в пропуск.
channel_names = {'paid_social': 'paid', 'paid_search': 'paid'}
orders['channel_group'] = orders['channel'].map(channel_names)
orders['channel_group'] = orders['channel_group'].fillna('other')
orders['campaign'] = (
orders['campaign'].astype('string').str.strip().str.lower()
)transform возвращает итог группы к каждой исходной строке
Ещё одна причина лишнего apply — расчёт доли заказа в выручке своего канала. Нужно получить сумму revenue для каждого channel и присоединить её к исходным заказам. Обычный groupby().sum() сокращает таблицу до числа каналов. transform("sum") сразу возвращает Series длиной исходной таблицы, сохраняя соответствие строкам. После этого деление читается как обычная арифметика колонок, а промежуточный merge не требуется.
На четырёх учебных заказах organic содержит суммы 1 200 и 450 ₽: общий итог 1 650 ₽. Их доли равны примерно 0,7273 и 0,2727. У paid с одним заказом на 800 ₽ доля равна единице, как и у email с заказом на 1 500 ₽. Значение 1 650 повторяется у двух organic-строк намеренно — это знаменатель, а не новая аддитивная метрика. Складывать такую колонку поверх заказов нельзя.
Если написать apply по группам, форма результата зависит от того, что вернула функция: число, Series или таблицу. Может появиться дополнительный уровень индекса, который придётся согласовать перед присваиванием. Для преобразования с сохранением числа строк этот лишний контракт не нужен. Встроенный transform с именем агрегата также позволяет pandas использовать свою реализацию вместо пользовательской функции на каждую группу. Lambda внутри transform допустима, но не гарантирует ту же скорость.
Обработка неизвестных каналов должна остаться частью расчёта. Здесь dropna=False сохраняет их отдельной группой. Для полностью неизвестной revenue у группы min_count=1 оставляет неизвестный знаменатель; если сумма равна нулю, mask превращает его в пропуск перед делением. Доля не появляется там, где нет определённой базы. Когда revenue частично неизвестна, дополнительно контролируй число известных сумм: итог группы в таком случае неполный.
Проверьте, что сумма долей по каждому каналу равна единице только для групп с полной известной revenue и ненулевой суммой. При возвратах доли бывают отрицательными или больше единицы: не обрезайте их. Если задача требует долю только положительных продаж, отдельно определите подходящую базу. Оптимизация должна сохранить этот выбор, иначе она ускорит другой аналитический вопрос.
import numpy as np
import pandas as pd
orders = pd.DataFrame({
'channel': ['organic', 'paid', 'organic', 'email'],
'revenue': [1200., 800., 450., 1500.],
})
total = orders.groupby('channel', dropna=False)['revenue'].transform('sum', min_count=1)
orders['share'] = orders['revenue'] / total.mask(total.eq(0))
np.testing.assert_allclose(orders['share'], [1200/1650, 1, 450/1650, 1])
np.testing.assert_allclose(orders.groupby('channel')['share'].sum(), 1)Когда apply оправдан
Если правило использует несколько значений строки, вызывает сложную доменную функцию или строит небольшой текстовый объект, apply может быть самым понятным решением. Не нужно переписывать всё ради скорости. Сначала измерьте узкое место и зафиксируйте ожидаемый результат.
| Задача | Первый кандидат |
|---|---|
| арифметика колонок | векторные операции |
| словарь категорий | map / replace |
| несколько независимых условий | np.select |
| дата и строка | dt / str accessor |
| правило использует всю строку | apply(axis=1), затем замерить |
Векторный код, который никто не понимает, может быть хуже медленного, но проверяемого правила. Сначала сохраняйте бизнес-смысл, затем оптимизируйте подтверждённое узкое место.
iterrows переносит тот же цикл в другое место
Переписывание apply(axis=1) в цикл for по iterrows редко решает исходную проблему арифметики revenue − discount. Теперь цикл виден снаружи, но каждая строка по-прежнему представлена Series, а Python выполняет правило много раз. Вдобавок типы внутри строки могут отличаться от типов колонок: смесь целого идентификатора и дробной суммы способна превратить значения строки в float. Для больших целых ключей это риск точности, а не только скорости.
Если построчная обработка действительно нужна, попробуйте itertuples(index=False, name=None): он отдаёт кортежи без построения Series для каждой строки. Не изменяй обходящуюся таблицу через полученную строку. Собирайте результаты отдельно и присваивайте после проверки длины и порядка. При обращении к внешнему сервису обычно важнее пакетная отправка, таймауты и повторяемость запросов; переход на другой итератор не устраняет сетевую задержку и лимиты сервиса.
Numba имеет смысл рассматривать, когда измеренное узкое место — сложный числовой цикл над однородными массивами, который трудно выразить готовыми операциями. Компиляция создаёт стоимость первого запуска. Если ноутбук обрабатывает небольшой файл один раз, она может не окупиться. Отделите время первого вызова от прогретых повторов и сохраните понятную реализацию для проверки результата. Строковые категории, произвольные объекты и неподдерживаемые операции ограничивают применимость компилятора.
DataFrame.eval иногда уменьшает число промежуточных массивов в длинном арифметическом выражении с подходящим движком. Простое вычитание двух колонок из начала статьи уже ясно выражено обычным оператором. Добавление eval к такому примеру не является обязательным ускорением: есть затраты на разбор выражения, ограничения синтаксиса и зависимость от доступного engine. Измеряйте конкретную формулу вместе с памятью на рабочем размере, а не выбирайте метод по короткому названию.
Если map уже укладывается в требования отчёта, дополнительная зависимость ради миллисекунд не решает задачу. Если массив не помещается в память, сократи вход у источника или обрабатывай частями. Компиляция цикла не исправит архитектуру загрузки.
Как сравнить варианты на реальных данных
Разница между apply и векторизацией особенно заметна на сотнях тысяч строк, но измерять нужно на объёме, похожем на рабочий. Маленький DataFrame может быть слишком быстрым для обоих вариантов, а синтетические данные без пропусков не покажут цену обработки плохих строк. Зафиксируйте вход, прогоняйте каждую версию несколько раз и сравните не только время, но и результат.
Сначала напишите эталонную реализацию, которую легко прочитать. Затем сделайте векторную версию и сравните колонки через pd.testing.assert_series_equal или assert_frame_equal. Если значения различаются из-за округления, опишите допустимую погрешность явно. Нельзя считать переписывание успешным, если оно просто поменяло NaN на ноль или по-другому обработало отрицательную сумму.
Профилируйте весь pipeline, а не одну строку в вакууме. Иногда apply занимает 10% времени, а 90% уходит на чтение CSV или сортировку. В таком случае переписывание функции не изменит пользовательский опыт, зато усложнит код.
import pandas as pd
from time import perf_counter
started = perf_counter()
old = orders.apply(lambda row: row['revenue'] - row['discount'], axis=1)
old_seconds = perf_counter() - started
started = perf_counter()
new = orders['revenue'] - orders['discount']
new_seconds = perf_counter() - started
pd.testing.assert_series_equal(old, new, check_names=False)
print({'apply': old_seconds, 'vectorized': new_seconds})На 200 тысячах каналов map убирает вызов функции для каждой строки
Измерим правило группировки каналов из статьи: paid_social и paid_search превращаются в paid, остальные значения — в other. Создадим 200 000 строк повторением четырёх значений, включая пропуск. Одно и то же правило выразим через apply(axis=1), словарь в Series.map и булеву маску isin с np.where. У всех вариантов одинаковая обработка неизвестного канала. Замер относится к этому категориальному правилу.
Замер выполнен 15 сентября 2026 года на Apple M1 Pro, macOS 26.3.1, Python 3.12.4, pandas 3.0.0 и NumPy 2.4.6. Для каждого варианта сделан прогрев, затем пять отдельных запусков; в таблице приведена медиана. Создание DataFrame, импорт библиотек и проверка совпадения результатов в измеряемое время не входят. Время построения выходной Series входит, поскольку именно готовую колонку получает дальнейший расчёт.
В этом запуске apply занял 2,239 секунды, map — 0,015, маска — 0,016. Здесь почти нет вычислений внутри правила: основная разница связана с построчным вызовом и доступом к полю через Series. По такому примеру нельзя обещать то же ускорение сложной функции, которая большую часть времени выполняет внешний вызов или тяжёлый расчёт. Близкие времена map и маски тоже не дают универсального победителя.
Обратите внимание на границу измерения: map получает словарь, а не пользовательскую функцию. Запись map(lambda ...) снова вызывает Python-функцию для элементов и имеет другой профиль затрат. Маска годится здесь потому, что все известные ключи получают одну группу paid. Если каждому каналу нужно своё название, словарь лучше выражает соответствие и не требует набора отдельных условий.
Повторите замер на своей версии pandas с рабочим распределением каналов и пропусков. В проверке совпадения отключено только сравнение имени Series; значения, индекс и тип обязаны совпасть. Если весь отчёт почти не ускорился, ищи другую дорогую стадию.
| Вариант | Время |
|---|---|
| apply(axis=1) | 2,239 с |
| Series.map со словарём | 0,015 с |
| isin + np.where | 0,016 с |
import statistics
import timeit
import numpy as np
import pandas as pd
orders = pd.DataFrame({'channel': np.resize(np.array(
['paid_social', 'organic', 'paid_search', None], dtype=object,
), 200_000)})
lookup = {'paid_social': 'paid', 'paid_search': 'paid'}
def row_apply():
return orders.apply(lambda row: lookup.get(row['channel'], 'other'), axis=1)
def column_map():
return orders['channel'].map(lookup).fillna('other')
def column_mask():
return pd.Series(np.where(orders['channel'].isin(lookup), 'paid', 'other'),
index=orders.index)
reference = row_apply()
for fn in (row_apply, column_map, column_mask):
pd.testing.assert_series_equal(reference, fn(), check_names=False)
fn() # прогрев
print(fn.__name__, statistics.median(timeit.repeat(fn, number=1, repeat=5)))axis=1 скрывает контракт строки
apply(axis=1) передаёт в функцию Series, где значения могут иметь смешанные типы. Внутри лямбды легко получить строку вместо числа, а ошибка проявится только на конкретной строке. Кроме того, функция начинает зависеть от названий колонок и их наличия, но этот контракт часто нигде не записан. Если apply остаётся, вынесите функцию с понятной сигнатурой и проверьте обязательные поля до запуска.
Ещё одна ловушка — использование состояния снаружи: словаря, текущей даты или переменной, которая была изменена в предыдущей ячейке. Один и тот же DataFrame тогда даёт разные результаты в зависимости от порядка запуска ноутбука. Передавайте справочники явно или используйте map, merge и чистую функцию, чтобы результат зависел только от входных данных.
Векторизация не отменяет необходимость думать о типах. Выражение с числом и pd.NA может привести к nullable-типу, а сравнение строк с числом — вернуть неожиданный результат или ошибку. Проверка dtypes, доли пропусков и диапазона значений должна идти до сложных условий.
| Подход | Сильная сторона | Риск |
|---|---|---|
| векторная арифметика | формула видна и быстро работает | неочевидное поведение пропусков |
| map / replace | явное соответствие значениям | неизвестный ключ превращается в NA |
| np.select | порядок условий читается сверху вниз | пересекающиеся условия дают приоритет первому |
| apply(axis=1) | выражает сложное правило по строке | медленно и зависит от скрытого состояния |
| merge + lookup | контракт справочника можно проверить | неуникальный ключ размножает строки |
Условия должны быть взаимоисключающими
В np.select условия проверяются в заданном порядке. Если правило revenue >= 0 стоит перед revenue >= 5000, дорогой заказ попадёт в low и ошибка будет выглядеть правдоподобно. Пишите условия от специфичного к общему и добавляйте тесты на границы: ровно 1500, ровно 5000, отрицательное значение и пропуск.
Если условия пересекаются намеренно, объясните приоритет в комментарии или в тексте статьи. Иногда полезнее сначала создать отдельные boolean-колонки: is_large, is_returned, is_new_user, а затем собрать итоговый сегмент. Это занимает больше строк, зато позволяет проверить каждую часть правила и построить диагностический срез.
Для бизнес-сегмента обязательно оставьте значение unknown или invalid. Default low удобен для демонстрации, но в отчёте он смешивает реальные низкие значения с отрицательными и нераспознанными. Отдельная категория быстрее показывает, что источник прислал новый код.
conditions = [
orders['revenue'].ge(5000),
orders['revenue'].ge(1500),
orders['revenue'].ge(0),
]
choices = ['high', 'medium', 'low']
orders['segment'] = np.select(conditions, choices, default='invalid')
assert orders.loc[orders['revenue'].eq(5000), 'segment'].eq('high').all()
assert orders.loc[orders['revenue'].eq(-1), 'segment'].eq('invalid').all()Строки и даты: accessor лучше универсальной функции
У pandas есть специальные accessor-ы str и dt, потому что они знают о типе Series и работают над колонкой. Через str.strip, str.lower и str.contains нормализация становится последовательной. Но перед этим нужно привести пропуски и убедиться, что колонка действительно строковая. Иначе числа и None начнут обрабатываться не так, как ожидает лямбда.
С датами та же идея: pd.to_datetime выполняется один раз, после чего используются .dt.date, .dt.month, .dt.dayofweek или .dt.to_period. Не вызывайте to_datetime внутри apply для каждой строки. Это дороже и скрывает настройку часового пояса и ошибок парсинга.
Если строковое правило сложное, сначала приведите данные к каноническому виду, затем примените простые маски. Две-три именованные маски легче проверить в таблице, чем одна длинная лямбда с пятью тернарными операторами.
Практическое решение: понятный код против микросекунд
В аналитике оптимизация имеет смысл, когда она меняет время получения ответа или стоимость запуска. Для отчёта на 5 000 строк читаемый apply может быть разумным выбором, если правило действительно построчное. Для лога на 20 миллионов событий даже простая арифметика должна быть векторной или вынесенной в SQL. Ориентиром служит не культ скорости, а масштаб задачи и цена ожидания.
После переписывания оставьте рядом короткую проверку эквивалентности и ссылку на определение сегмента. Если правило поменяется, следующий аналитик сможет обновить обе версии или удалить старую эталонную реализацию. Важнее всего, чтобы ускоренный код оставался объяснимым: метрика должна пережить смену автора и ноутбука.
- Сначала определить зерно и типы входных колонок.
- Выбрать векторную операцию, если правило относится к одной колонке.
- Проверить границы и unknown на маленьком контрольном наборе.
- Замерить реальный объём и сравнить результат со старой версией.
- Оставить apply, если он честнее выражает сложное правило.
Материалы по теме

NumPy для аналитика: массивы, маски и векторные расчёты
Что нужно знать аналитику о NumPy: массивы, типы, boolean-маски, np.where и векторизация, которая лежит под многими операциями pandas.

Как тестировать аналитические расчёты на Python и pandas
Практический гайд по тестам для аналитика: проверить метрики на маленьком датасете, поймать регрессию и защитить расчёт от тихих изменений.

Как ускорить pandas: память, типы и обработка больших файлов
Что делать, если pandas медленно работает или не помещает файл в память: категории, downcast, chunksize, Parquet и контроль размера данных.