Привет, Хабр!

Сегодня рассмотрим почему DataFrame.apply() — это так себе инструмент в 2025 году, чем его заменять и как писать dataframe‑логику так, чтобы она летала вместо того, чтобы жечь CPU и бюджет.

Почему apply() был крут, а стал ловушкой

apply() обожают за экспрессию:

df["total"] = df.apply(lambda r: r.price * r.qty, axis=1)

Однако под всем этим — чистый Python‑цикл: каждый вызов попадает под GIL, тащит за собой объекты Series, ломает кэш‑линию, а значит — влияет на производительность. Даже свежий engine="numba" компилирует лямбду, но сперва тратит десятки миллисекунд на JIT и ещё больше памяти на boxing/unboxing данных.

На дата‑фрейме 10 млн строк (pandas 2.2.3, Python 3.12, x86–64):

Метод

Время

Пиковая RAM

apply (python)

13.4 с

+480 МБ

apply + numba

4.6 с

+520 МБ

NumPy vectorize

0.9 с

+120 МБ

Умножение столбцов

0.07 с

+40 МБ

Разрыв виден.

Типичные проблемы с apply()

axis=1 + datetime.strptime

# Да, вы это видели:
df['date'] = df.apply(lambda r: datetime.strptime(r['raw_date'], '%Y-%m-%d'), axis=1)

На первый взгляд — всё норм. Дата парсится, результат в колонке, что может пойти не так?

apply(axis=1) делает покадровый вызов Python‑функции — то есть на каждый чёртовый ряд создаётся Series, потом передаётся в вашу лямбду. datetime.strptime() — это не NumPy, это чистый Python, без векторизации. И он тяжёлый: парсит строку вручную, символ за символом, байт за байтом.

Это тормоза на ровном месте. Как сделать лучше:

df['date'] = pd.to_datetime(df['raw_date'], format='%Y-%m-%d', utc=True)

pd.to_datetime() вызывает C‑level парсер (часто из dateutil или ciso8601), и обрабатывает всю колонку разом, без GIL, без overhead'а на создание объектов. Плюс: можно парсить в UTC, кастить к timezone, и даже делать errors='coerce'.

apply(dict.get) для маппинга

country_codes = {'Germany': 'DE', 'Russia': 'RU', 'France': 'FR'}
df['code'] = df.apply(lambda r: country_codes.get(r['country']), axis=1)

Кажется, просто: маппим страну на код. Но вот нюанс:

  • apply(lambda row: ...) — опять axis=1, а значит: на каждый ряд — создание объекта Series и вызов Python‑функции.

  • dict.get — это быстрый доступ… но если вы вызываете его миллион раз внутри лямбды, получаете миллион отдельных lookup'ов.

То есть: инициализация структуры Series + доступ к словарю + вызов функции — всё в чистом Python.

Как надо:

df['code'] = df['country'].map(country_codes)

Series.map(dict) — это векторизированный проход по Series: pandas сам делает всё через C/CPython, быстро, нативно

А еще и работает и с NaN, и с отсутствующими значениями, возвращает NaN при miss.

if r.a > 0 else ... внутри lambda

df['label'] = df.apply(lambda r: 'positive' if r.a > 0 else 'non-positive', axis=1)

Наивная векторизация — это невекторизация. С виду — норм: логика читаемая, if работает. Но:

  • Вызов lambda опять же идёт на каждый ряд.

  • Python if — это чисто интерпретируемая логика — тормозит.

  • При 10+ миллионов строк — вы получите тысячу «if»ов в секунду» вместо миллиона.

А можно было просто довериться NumPy.

df['label'] = np.where(df['a'] > 0, 'positive', 'non-positive')

np.where() — это векторизированная альтернатива if. Работает моментально — один проход по памяти, использует SIMD‑инструкции. Можно писать вложенные условия, работать с числовыми масками, делать касты.

re.sub в apply

df['cleaned'] = df.apply(lambda r: re.sub(r'[^a-zA-Z0-9]', '', r['name']), axis=1)

Надо почистить строку от спецсимволов, но в чем проблема?

Каждый вызов re.sub компилирует паттерн заново (если он не предварительно скомпилирован), работает в Python, и вызывается снова и снова в lambda.

Если строк — миллионы, это не очень. И это даже без учета ошибок — re.sub может взорваться на плохом юникоде, на NaN, и так далее.

Как надо:

df['cleaned'] = df['name'].str.replace(r'[^a-zA-Z0-9]', '', regex=True)

Series.str.replace() — это обёртка над C/CPython строковыми функциями. Она:

  • сама кеширует паттерн;

  • не падает на NaN;

  • быстрее в 10–100 раз;

  • и выглядит чище.

Можно ещё использовать .str.extract(), .str.contains(), .str.lower(), .str.split() — всё это внутри Pandas работает нативно, в разы быстрее.

Если вы встречаете apply() — всегда задайте себе вопрос: «А нельзя ли это сделать с .map(), .where(), .str. или .dt.

В 80–90% случаев — можно. И это даст немедленный прирост производительности, без переписывания логики.

Если нельзя — скорее всего, вы:

  • либо зовёте внешний сервис (ML API, storage и т. п.),

  • либо делаете что‑то сильно condition‑based.

В таком случае — вынесите это в отдельный микросервис, с async очередь и батчевым вызовом.

Убираем apply()

Векторизуйте в лоб

Самый частый патч выглядит просто:

# Было
df["fee"] = df.apply(lambda r: r.sum * 0.035, axis=1)

# Стало
df["fee"] = df["sum"] * 0.035

Используйте готовые аксессоры

Series.str.* — строки на C.

Series.dt.* — даты.

Series.cat.* — категории.

query() и eval() — JIT‑парсер выражений (в pandas 3.0 наконец умеет Arrow‑бэкенд).

map()

iso = {"Russia": "RU", "Germany": "DE"}
df["iso"] = df["country"].map(iso)          # dict-lookup на C
df["bucket"] = df["price"].map(lambda x: x // 50)

Спасает engine="numba"

Numba‑движок действительно даёт ×5…×10 на числовых массивах, если:

def hyp(row):
    return (row.x ** 2 + row.y ** 2) ** 0.5

df["r"] = df.apply(hyp, axis=1, raw=True,
                   engine="numba",
                   engine_kwargs={"parallel": True})

Но: не работает c object‑dtype, строками, Arrow‑колонками и поджирает RAM.


Делитесь в комментариях своим опытом работы с apply(). Уверен, есть много интересных кейсов.

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

Больше актуальных навыков по аналитике вы можете получить в рамках практических онлайн-курсов от экспертов отрасли.

Комментарии (5)


  1. Margutoop
    30.05.2025 16:16

    Если вы хотите, чтобы ваш код летал - думайте в терминах массивов, а не строк


  1. Kingas
    30.05.2025 16:16

    Выглядит как Чем опасен Python, и как использовать готовые C-ые костыли, чтобы он работал хорошо.

    Я, если честно, не понимаю, почему Python суют во все места, где он не предназначен. А затем создают C-ые костыли, начинку, чтобы решить проблему производительности.

    Примерно такая же ситуация и у NodeJS.


    1. kneaded
      30.05.2025 16:16

      А это ж классика, вспомните про треугольник "дёшево, качественно и быстро" и выбирайте 2 из 3.

      Если дёшево и быстро - появляются костыли на знакомом языке (в данном случае Python)

      Если хотим качественно и быстро - будет дорогой специалист, который сможет реализовывать это всё (не все готовы условный лям в месяц платить разработчику)

      Если хотим дёшево и качественно - на знакомом языке появляются обёртывание и вуаля - соблюдается 2 из 3 и всё работает! Реализация не быстрая, но дешёвая)


  1. KLA
    30.05.2025 16:16

    Возможно для того чтобы ваш код работал быстро не стоит использовать pandas, которые изначально не направлен для обработки больших массивов данных. Pandas не позволяет оптимизировать цепочки вычислений и не обладает возможностью ленивых вычислений. Взгляните на аналоги polars, dask, vaex, pyspark и используйте их для увеличения производительности.


  1. ptrue
    30.05.2025 16:16

    "покадрово" сделали машинный перевод и довольны