Привет, Хабр!
Сегодня рассмотрим почему 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 |
---|---|---|
|
13.4 с |
+480 МБ |
|
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 и работу с высокоразмерными признаками.
2 июня, 18:00
Популярные методы кластеризации — разберём подходы, алгоритмы и подводные камни сегментации данных11 июня, 20:00
Spark ML — применяем машинное обучение на больших объёмах и разбираем, как это устроено под капотом16 июня, 20:00
Методы уменьшения размерности — от PCA до t-SNE: когда признаков слишком много, но важны не все
Больше актуальных навыков по аналитике вы можете получить в рамках практических онлайн-курсов от экспертов отрасли.
Комментарии (5)
Kingas
30.05.2025 16:16Выглядит как Чем опасен Python, и как использовать готовые C-ые костыли, чтобы он работал хорошо.
Я, если честно, не понимаю, почему Python суют во все места, где он не предназначен. А затем создают C-ые костыли, начинку, чтобы решить проблему производительности.
Примерно такая же ситуация и у NodeJS.
kneaded
30.05.2025 16:16А это ж классика, вспомните про треугольник "дёшево, качественно и быстро" и выбирайте 2 из 3.
Если дёшево и быстро - появляются костыли на знакомом языке (в данном случае Python)
Если хотим качественно и быстро - будет дорогой специалист, который сможет реализовывать это всё (не все готовы условный лям в месяц платить разработчику)
Если хотим дёшево и качественно - на знакомом языке появляются обёртывание и вуаля - соблюдается 2 из 3 и всё работает! Реализация не быстрая, но дешёвая)
KLA
30.05.2025 16:16Возможно для того чтобы ваш код работал быстро не стоит использовать pandas, которые изначально не направлен для обработки больших массивов данных. Pandas не позволяет оптимизировать цепочки вычислений и не обладает возможностью ленивых вычислений. Взгляните на аналоги polars, dask, vaex, pyspark и используйте их для увеличения производительности.
Margutoop
Если вы хотите, чтобы ваш код летал - думайте в терминах массивов, а не строк