Строки .str и почему apply медленный
Текст обрабатывают через .str, а пользовательские функции через apply — но apply почти всегда медленнее векторных операций, и важно понимать почему.
Аксессор .str даёт векторные строковые методы для всей Series сразу. apply применяет произвольную Python-функцию к каждому элементу или строке — гибко, но медленно.
Аксессор .str: строковые методы для всего столбца
Чтобы применить строковую операцию ко всему столбцу, не нужен цикл — есть аксессор .str. Имена методов совпадают со встроенными методами строк Python, но работают поэлементно над всей Series:
df["имя"].str.lower() # в нижний регистр
df["имя"].str.strip() # убрать пробелы по краям
df["имя"].str.replace("ё", "е") # замена подстроки
df["email"].str.contains("@") # булева маска: содержит ли
df["телефон"].str.startswith("+7") # начинается с
df["фио"].str.split(" ") # разбить на список
df["фио"].str.split(" ").str[0] # взять первое слово (фамилию)
df["код"].str.extract(r"(\d+)") # вытащить по регулярке
df["имя"].str.len() # длина каждой строки
Это и быстро (операция уходит в оптимизированный код), и читается как одна формула. .str.contains возвращает булеву маску — её можно сразу передать в loc для фильтра: df[df["email"].str.contains("@")].
Цепочки .str — обычное дело: нормализация в одну строку.
names = [" Иванов И.И.", "петров П.", "СИДОРОВ С.С. "]
# то, что .str.strip().str.title() делает над всей Series:
cleaned = [n.strip().title() for n in names]
for n in cleaned:
print(repr(n))
Вывод:
'Иванов И.И.' 'Петров П.' 'Сидоров С.С.'
В pandas это df["фио"].str.strip().str.title() — каждый метод аксессора применяется ко всей Series, как наш список-генератор применяет операции ко всем элементам.
apply, map, applymap: произвольные функции
Когда готового векторного метода нет, на помощь приходят функции применения:
| Метод | К чему применяется |
Series.map(func) | к каждому элементу Series |
Series.apply(func) | к каждому элементу Series |
DataFrame.apply(func) | к каждому столбцу (axis=0) или строке (axis=1) |
DataFrame.map(func) | к каждой ячейке (бывший applymap) |
# применить функцию к каждой строке (axis=1)
df["итог"] = df.apply(lambda row: row["цена"] * row["шт"], axis=1)
Почему apply медленный
Ключевая идея всего pandas: векторные операции выполняются на уровне C над целым массивом за один проход, тогда как apply с Python-функцией вызывает её для каждой строки в интерпретаторе Python. На миллионе строк это миллион вызовов функции, миллион упаковок/распаковок значений — и интерпретатор становится узким местом. Векторная же операция передаёт весь массив в numpy и считает на C без участия Python в цикле.
Поэтому пример выше переписывают без apply — простым умножением столбцов, которое уходит в C:
# МЕДЛЕННО: Python-функция на каждую строку
df["итог"] = df.apply(lambda row: row["цена"] * row["шт"], axis=1)
# БЫСТРО: векторное умножение столбцов (на C, за один проход)
df["итог"] = df["цена"] * df["шт"]
Оба дают одинаковый результат, но векторная версия на больших данных быстрее в десятки и сотни раз. Промоделируем разницу «вызов на каждый элемент» против «одна операция над всеми» по числу шагов интерпретатора:
prices = [990, 2500, 18000, 300, 150]
qty = [3, 1, 1, 5, 10]
calls = 0
# имитация apply(axis=1): функция вызывается на КАЖДУЮ строку
def row_total(p, q):
global calls
calls += 1
return p * q
totals_apply = [row_total(p, q) for p, q in zip(prices, qty)]
print("apply: вызовов Python-функции =", calls)
# векторно: одна операция "над всем массивом" — концептуально 1 шаг
totals_vec = [p * q for p, q in zip(prices, qty)]
print("результаты совпадают:", totals_apply == totals_vec)
print(totals_vec)
Вывод:
apply: вызовов Python-функции = 5 результаты совпадают: True [2970, 2500, 18000, 1500, 1500]
На пяти строках это 5 вызовов, на миллионе — миллион, и каждый платит за вход в Python-функцию. Векторная операция этих вызовов не делает: numpy проходит массив на C. Отсюда правило: сначала ищите векторное решение, и только если его нет — apply.
Условная логика без apply: np.where и np.select
Самый частый соблазн взять apply — это «категоризация»: присвоить метку в зависимости от условия. Почти всегда это делается векторно. Для двух исходов есть np.where(условие, если_да, если_нет); для нескольких — np.select со списком условий и выборов:
import numpy as np
# два исхода
df["ценник"] = np.where(df["цена"] >= 5000, "дорого", "дёшево")
# несколько исходов
условия = [df["цена"] < 1000, df["цена"] < 5000]
выборы = ["дёшево", "средне"]
df["ценник"] = np.select(условия, выборы, default="дорого")
Обе конструкции вычисляются векторно над всем столбцом и на больших данных кратно быстрее apply с if/else внутри. Ещё один частый случай — биннинг числовой величины в категории (возраст → возрастная группа): для него есть pd.cut (по границам) и pd.qcut (по квантилям), и это тоже векторно. Прежде чем писать apply с ветвлением, спросите себя: «нет ли тут np.where, np.select или cut?»
Когда apply всё-таки оправдан
- Логика действительно сложная и не выражается арифметикой/строковыми методами/масками.
- Данных немного, и читаемость важнее скорости.
- Нужен доступ сразу к нескольким столбцам строки в нетривиальной комбинации (но и тут часто помогает
np.where,np.selectили маски).
Подводные камни
- apply(axis=1) — самый медленный режим: функция зовётся на каждую строку. Прежде чем писать его, спросите себя, нет ли векторного пути.
- .str и пропуски. Строковые методы на
NaNвозвращаютNaN, а не падают, — но это значит, что пропуски «протекают» в результат; учитывайте их. - applymap переименован. В новых версиях используйте
DataFrame.mapвместо устаревшегоapplymap. - Маски вместо apply для условий. «Категоризацию» (если цена > X, то ...) почти всегда лучше делать через
np.where/np.selectилиloc, а неapply.
Лучшие практики
- Для текста используйте
.str-методы — они векторные и читаемые. - Прежде чем писать
apply, ищите векторную операцию: арифметику столбцов, маски,np.where,mapпо словарю. - Если без
applyне обойтись, предпочитайте поэлементныйSeries.map/Series.applyпострочномуDataFrame.apply(axis=1). - Помните: ускорение от векторизации на больших данных — это не «чуть быстрее», а порядки величины.
Итог
.strдаёт векторные строковые методы для всей Series.applyприменяет Python-функцию поэлементно — гибко, но медленно.- Векторные операции считаются на C за один проход;
applyвызывает функцию на каждую строку в интерпретаторе. - Правило: сначала векторизация (арифметика, маски,
.str,np.where), и только потомapply.