Строки .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.
Проверьте себя
1. Почему df.apply(func, axis=1) обычно намного медленнее векторной операции?
Aapply копирует весь DataFrame
BPython-функция вызывается для каждой строки в интерпретаторе, тогда как векторная операция считается на C за один проход
Caxis=1 запрещён в новых версиях
Dapply всегда работает в одном потоке, а векторизация — в нескольких
2. Как привести весь столбец 'имя' к нижнему регистру?
Adf['имя'].lower()
Bdf['имя'].apply(str.lower) — единственный способ
Cdf['имя'].str.lower()
Ddf.lower('имя')
3. Как лучше всего вычислить столбец 'итог' = 'цена' * 'шт' для большого DataFrame?
Adf.apply(lambda r: r['цена']*r['шт'], axis=1)
Bdf['итог'] = df['цена'] * df['шт']
CЦикл for по строкам с at
Ddf.map(lambda x: x*2)
Поддержать проект