Когда я должен когда-либо использовать pandas apply () в своем коде?

115

Я видел много ответов на вопросы о переполнении стека, связанные с использованием метода Pandas apply. Я также видел, как пользователи комментируют под ними, говоря, что « applyработает медленно, и этого следует избегать».

Я прочитал много статей на тему производительности, которые объясняют applyмедленно. Я также видел заявление об отказе от ответственности в документации о том, что applyэто просто удобная функция для передачи UDF (сейчас не могу найти этого). Итак, по общему мнению, этого applyследует избегать по возможности. Однако здесь возникают следующие вопросы:

  1. Если applyтак плохо, то почему это в API?
  2. Как и когда мне следует освободить свой код от кода apply?
  3. Есть ли когда-нибудь ситуации, когда applyэто хорошо (лучше других возможных решений)?
cs95
источник
1
returns.add(1).apply(np.log)vs. np.log(returns.add(1)- это случай, когда applyобычно будет немного быстрее, что показано в правом нижнем зеленом поле на диаграмме jpp ниже.
Александр
@ Александр, спасибо. Не указал исчерпывающе на эти ситуации, но о них полезно знать!
cs95

Ответы:

112

apply, функция комфорта, которая вам никогда не нужна

Мы начнем с рассмотрения вопросов в OP, один за другим.

« Если apply настолько плох, то почему он в API? »

DataFrame.applyи Series.apply- вспомогательные функции, определенные для объекта DataFrame и Series соответственно. applyпринимает любую определяемую пользователем функцию, которая применяет преобразование / агрегирование к DataFrame. applyПо сути, это серебряная пуля, которая делает то, что не может сделать любая существующая функция pandas.

Некоторые из вещей applyмогут:

  • Запустить любую пользовательскую функцию в DataFrame или Series
  • Примените функцию по строкам ( axis=1) или по столбцам ( axis=0) к DataFrame
  • Выполнять выравнивание индекса при применении функции
  • Выполняйте агрегирование с помощью пользовательских функций (однако в этих случаях мы обычно предпочитаем aggили transform)
  • Выполняйте поэлементные преобразования
  • Трансляция агрегированных результатов в исходные строки (см. result_typeАргумент).
  • Принимайте позиционные / ключевые аргументы для передачи пользовательским функциям.

... Среди прочего. Дополнительные сведения см. В разделе « Применение функций для строк или столбцов» в документации.

Итак, со всеми этими функциями, почему это applyплохо? Это потому, что applyидет медленно . Pandas не делает никаких предположений о природе вашей функции и поэтому итеративно применяет вашу функцию к каждой строке / столбцу по мере необходимости. Кроме того, обработка всех вышеперечисленных ситуаций applyтребует значительных накладных расходов на каждой итерации. Кроме того, applyпотребляет намного больше памяти, что является проблемой для приложений с ограничением памяти.

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


Обратимся к следующему вопросу.

" Как и когда я должен сделать мой код свободным от применения ? "

Перефразируя, вот несколько распространенных ситуаций, в которых вы захотите избавиться от любых вызовов apply.

Числовые данные

Если вы работаете с числовыми данными, вероятно, уже существует векторизованная функция cython, которая делает именно то, что вы пытаетесь сделать (если нет, задайте вопрос о переполнении стека или откройте запрос функции на GitHub).

Сравните производительность applyдля простой операции сложения.

df = pd.DataFrame({"A": [9, 4, 2, 1], "B": [12, 7, 5, 4]})
df

   A   B
0  9  12
1  4   7
2  2   5
3  1   4

df.apply(np.sum)

A    16
B    28
dtype: int64

df.sum()

A    16
B    28
dtype: int64

С точки зрения производительности, сравнения нет, цитонизированный эквивалент намного быстрее. В графике нет необходимости, потому что разница очевидна даже для игрушечных данных.

%timeit df.apply(np.sum)
%timeit df.sum()
2.22 ms ± 41.2 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
471 µs ± 8.16 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Даже если вы разрешите передачу необработанных массивов с rawаргументом, это все равно вдвое медленнее.

%timeit df.apply(np.sum, raw=True)
840 µs ± 691 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Другой пример:

df.apply(lambda x: x.max() - x.min())

A    8
B    8
dtype: int64

df.max() - df.min()

A    8
B    8
dtype: int64

%timeit df.apply(lambda x: x.max() - x.min())
%timeit df.max() - df.min()

2.43 ms ± 450 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
1.23 ms ± 14.7 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

В общем, искать векторизованные альтернативы , если это возможно.

Строка / регулярное выражение

Pandas предоставляет "векторизованные" строковые функции в большинстве ситуаций, но есть редкие случаи, когда эти функции не ... "применяются", так сказать.

Распространенная проблема - проверить, присутствует ли значение в столбце в другом столбце той же строки.

df = pd.DataFrame({
    'Name': ['mickey', 'donald', 'minnie'],
    'Title': ['wonderland', "welcome to donald's castle", 'Minnie mouse clubhouse'],
    'Value': [20, 10, 86]})
df

     Name  Value                       Title
0  mickey     20                  wonderland
1  donald     10  welcome to donald's castle
2  minnie     86      Minnie mouse clubhouse

Это должно вернуть вторую и третью строки, поскольку «дональд» и «минни» присутствуют в своих соответствующих столбцах «Заголовок».

Используя apply, это можно сделать с помощью

df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)

0    False
1     True
2     True
dtype: bool

df[df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)]

     Name                       Title  Value
1  donald  welcome to donald's castle     10
2  minnie      Minnie mouse clubhouse     86

Однако существует лучшее решение, использующее понимание списков.

df[[y.lower() in x.lower() for x, y in zip(df['Title'], df['Name'])]]

     Name                       Title  Value
1  donald  welcome to donald's castle     10
2  minnie      Minnie mouse clubhouse     86

%timeit df[df.apply(lambda x: x['Name'].lower() in x['Title'].lower(), axis=1)]
%timeit df[[y.lower() in x.lower() for x, y in zip(df['Title'], df['Name'])]]

2.85 ms ± 38.4 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
788 µs ± 16.4 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Здесь следует отметить, что итеративные процедуры оказываются быстрее, чем applyиз-за меньших накладных расходов. Если вам нужно обрабатывать NaN и недопустимые типы данных, вы можете использовать это с помощью специальной функции, которую затем можно вызвать с аргументами внутри понимания списка.

Для получения дополнительной информации о том, когда составление списков следует считать хорошим вариантом, см. Мою рецензию: Для циклов с пандами - когда мне это нужно? .

Примечание.
Операции с датой и датой и временем также имеют векторизованные версии. Так, к примеру, следует отдавать предпочтение pd.to_datetime(df['date']), более, скажем, df['date'].apply(pd.to_datetime).

Подробнее читайте в документации .

Распространенная ошибка: растущие столбцы списков

s = pd.Series([[1, 2]] * 3)
s

0    [1, 2]
1    [1, 2]
2    [1, 2]
dtype: object

Люди испытывают искушение использовать apply(pd.Series). Это ужасно с точки зрения производительности.

s.apply(pd.Series)

   0  1
0  1  2
1  1  2
2  1  2

Лучше всего просмотреть столбец и передать его в pd.DataFrame.

pd.DataFrame(s.tolist())

   0  1
0  1  2
1  1  2
2  1  2

%timeit s.apply(pd.Series)
%timeit pd.DataFrame(s.tolist())

2.65 ms ± 294 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
816 µs ± 40.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Наконец,

" Есть ли ситуации, когда apply хорошо? "

Apply - это удобная функция, поэтому бывают ситуации, когда накладные расходы достаточно незначительны, чтобы простить. Это действительно зависит от того, сколько раз вызывается функция.

Функции, которые векторизованы для Series, но не DataFrames
Что делать, если вы хотите применить строковую операцию к нескольким столбцам? Что, если вы хотите преобразовать несколько столбцов в datetime? Эти функции векторизованы только для серии, поэтому они должны применяться к каждому столбцу, который вы хотите преобразовать / обработать.

df = pd.DataFrame(
         pd.date_range('2018-12-31','2019-01-31', freq='2D').date.astype(str).reshape(-1, 2), 
         columns=['date1', 'date2'])
df

       date1      date2
0 2018-12-31 2019-01-02
1 2019-01-04 2019-01-06
2 2019-01-08 2019-01-10
3 2019-01-12 2019-01-14
4 2019-01-16 2019-01-18
5 2019-01-20 2019-01-22
6 2019-01-24 2019-01-26
7 2019-01-28 2019-01-30

df.dtypes

date1    object
date2    object
dtype: object

Это допустимый случай для apply:

df.apply(pd.to_datetime, errors='coerce').dtypes

date1    datetime64[ns]
date2    datetime64[ns]
dtype: object

Обратите внимание, что также имеет смысл stackили просто использовать явный цикл. Все эти параметры немного быстрее, чем при использованииapply , но разница достаточно мала, чтобы простить.

%timeit df.apply(pd.to_datetime, errors='coerce')
%timeit pd.to_datetime(df.stack(), errors='coerce').unstack()
%timeit pd.concat([pd.to_datetime(df[c], errors='coerce') for c in df], axis=1)
%timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce')

5.49 ms ± 247 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
3.94 ms ± 48.1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
3.16 ms ± 216 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
2.41 ms ± 1.71 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

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

u = df.apply(lambda x: x.str.contains(...))
v = df.apply(lambda x: x.astype(category))

v / s

u = pd.concat([df[c].str.contains(...) for c in df], axis=1)
v = df.copy()
for c in df:
    v[c] = df[c].astype(category)

И так далее...

Преобразование серии в str:astype противapply

Это похоже на идиосинкразию API. Использование applyдля преобразования целых чисел в серии в строку сопоставимо (а иногда и быстрее), чем использованиеastype .

введите описание изображения здесь График построен с использованием perfplotбиблиотеки.

import perfplot

perfplot.show(
    setup=lambda n: pd.Series(np.random.randint(0, n, n)),
    kernels=[
        lambda s: s.astype(str),
        lambda s: s.apply(str)
    ],
    labels=['astype', 'apply'],
    n_range=[2**k for k in range(1, 20)],
    xlabel='N',
    logx=True,
    logy=True,
    equality_check=lambda x, y: (x == y).all())

Что касается поплавков, я вижу, что astypeон всегда так же или немного быстрее, чемapply . Это связано с тем, что данные в тесте имеют целочисленный тип.

GroupBy операции с цепными преобразованиями

GroupBy.applyне обсуждался до сих пор, но GroupBy.applyэто также итеративная удобная функция для обработки всего, что существующиеGroupBy чего не делают функции.

Одним из распространенных требований является выполнение GroupBy, а затем двух простых операций, таких как «запаздывающий cumsum»:

df = pd.DataFrame({"A": list('aabcccddee'), "B": [12, 7, 5, 4, 5, 4, 3, 2, 1, 10]})
df

   A   B
0  a  12
1  a   7
2  b   5
3  c   4
4  c   5
5  c   4
6  d   3
7  d   2
8  e   1
9  e  10

Здесь вам понадобятся два последовательных вызова groupby:

df.groupby('A').B.cumsum().groupby(df.A).shift()

0     NaN
1    12.0
2     NaN
3     NaN
4     4.0
5     9.0
6     NaN
7     3.0
8     NaN
9     1.0
Name: B, dtype: float64

Используя apply, вы можете сократить это до одного вызова.

df.groupby('A').B.apply(lambda x: x.cumsum().shift())

0     NaN
1    12.0
2     NaN
3     NaN
4     4.0
5     9.0
6     NaN
7     3.0
8     NaN
9     1.0
Name: B, dtype: float64

Оценить производительность очень сложно, потому что она зависит от данных. Но в целом applyэто приемлемое решение, если цель состоит в том, чтобы уменьшить количество groupbyзвонков (потому что groupbyэто тоже довольно дорого).


Другие предостережения

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

df = pd.DataFrame({
    'A': [1, 2],
    'B': ['x', 'y']
})

def func(x):
    print(x['A'])
    return x

df.apply(func, axis=1)

# 1
# 1
# 2
   A  B
0  1  x
1  2  y

Это поведение также наблюдается в GroupBy.applyверсиях pandas <0.25 (оно было исправлено для 0.25, см. Здесь для получения дополнительной информации ).

cs95
источник
Я думаю, нам нужно быть осторожными ... %timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce')конечно же, после первой итерации это будет намного быстрее, так как вы конвертируете datetimeв ... datetime?
jpp
@jpp У меня было такое же беспокойство. Но вам все равно нужно выполнять линейное сканирование в любом случае, вызов to_datetime для строк выполняется так же быстро, как и их вызов для объектов datetime, если не быстрее. Примерные тайминги такие же. Альтернативой могло бы быть выполнение некоторого шага перед копированием для каждого временного решения, которое уводит от основной мысли. Но это серьезная проблема.
cs95
"Вызов to_datetimeстрок такой же быстрый, как и ... datetimeобъекты" .. правда? Я включил создание фрейма данных (фиксированная стоимость) в тайминги цикла applyvs, forи разница намного меньше.
jpp
@jpp Ну, это то, что я получил в результате моего (правда, ограниченного) тестирования. Я уверен, что это зависит от данных, но общая идея заключается в том, что для целей иллюстрации разница заключается в том, «серьезно, не беспокойтесь об этом».
cs95
1
@ cs95, с новым годом!
jpp
52

Не все apply одинаковы

В приведенной ниже таблице показано, когда следует учитывать apply1 . Зеленый означает, возможно, эффективный; красный избегать.

введите описание изображения здесь

Кое-что из этого интуитивно понятно: pd.Series.applyэто построчный цикл на уровне Python, то же самое, что и построчный pd.DataFrame.apply( axis=1). Их злоупотребления многочисленны и разнообразны. В другом посте они рассматриваются более подробно. Популярные решения - использовать векторизованные методы, списки (предполагает чистые данные) или эффективные инструменты, такие как pd.DataFrameконструктор (например, чтобы избежать apply(pd.Series)).

Если вы используете pd.DataFrame.applyпострочно, raw=Trueчасто бывает полезно указать (где возможно). На этом этапе numbaэто обычно лучший выбор.

GroupBy.apply: в целом одобрено

Повторение groupbyопераций, которых следует избегать apply, снизит производительность. GroupBy.applyздесь обычно хорошо, при условии, что методы, которые вы используете в своей пользовательской функции, сами векторизованы. Иногда нет собственного метода Pandas для групповой агрегации, которую вы хотите применить. В этом случае для небольшого количества группapply с настраиваемой функцией все же может быть предложена приемлемая производительность.

pd.DataFrame.apply по столбцам: смешанная сумка

pd.DataFrame.applycolumn -wise ( axis=0) - интересный случай. Для небольшого количества строк по сравнению с большим количеством столбцов это почти всегда дорого. Для большого количества строк относительно столбцов, что является более распространенным случаем, вы иногда можете увидеть значительное улучшение производительности, используя apply:

# Python 3.7, Pandas 0.23.4
np.random.seed(0)
df = pd.DataFrame(np.random.random((10**7, 3)))     # Scenario_1, many rows
df = pd.DataFrame(np.random.random((10**4, 10**3))) # Scenario_2, many columns

                                               # Scenario_1  | Scenario_2
%timeit df.sum()                               # 800 ms      | 109 ms
%timeit df.apply(pd.Series.sum)                # 568 ms      | 325 ms

%timeit df.max() - df.min()                    # 1.63 s      | 314 ms
%timeit df.apply(lambda x: x.max() - x.min())  # 838 ms      | 473 ms

%timeit df.mean()                              # 108 ms      | 94.4 ms
%timeit df.apply(pd.Series.mean)               # 276 ms      | 233 ms

1 Есть исключения, но обычно они незначительны или редки. Пара примеров:

  1. df['col'].apply(str)может немного превзойти df['col'].astype(str).
  2. df.apply(pd.to_datetime)работа со строками плохо масштабируется со строками по сравнению с обычным forциклом.
jpp
источник
2
Спасибо за участие, цените разные перспективы :) +1
cs95
1
@coldspeed, спасибо, в вашем сообщении нет ничего плохого (кроме некоторых противоречивых сравнений с моим, но может быть введено или настроено). Просто почувствовал, что есть другой взгляд на проблему.
jpp
@jpp Я всегда использовал Ты отличную схему в качестве руководства до тех пор , когда я увидел сегодня , что построчноapply значительно быстрее , чем мое решение с any. Есть мысли по этому поводу?
Стеф
1
@jpp: вы правы: для 1 миллиона строк x 100 столбцов anyпримерно в 100 раз быстрее, чем apply. Он провел мои первые тесты с 2000 строк на 1000 столбцов, и здесь он applyбыл в два раза быстрее, чемany
Стеф
1
@jpp Я хотел бы использовать ваше изображение в презентации / статье. Ты с этим согласен? Я обязательно упомяну источник. Спасибо
Эрфан
4

Для axis=1(то есть строковых функций) вы можете просто использовать следующую функцию вместо apply. Интересно, почему это не так pandas? (Не тестировалось с составными индексами, но, похоже, намного быстрее, чем apply)

def faster_df_apply(df, func):
    cols = list(df.columns)
    data, index = [], []
    for row in df.itertuples(index=True):
        row_dict = {f:v for f,v in zip(cols, row[1:])}
        data.append(func(row_dict))
        index.append(row[0])
    return pd.Series(data, index=index)
Пит Качиоппи
источник
Я был очень удивлен, обнаружив, что в некоторых случаях это дает мне лучшую производительность. Это было особенно полезно, когда мне нужно было сделать несколько вещей, каждая с разными подмножествами значений столбцов. Ответ «Все заявки не одинаковы» может помочь выяснить, когда это может помочь, но проверить на выборке данных не так уж сложно.
denson
1
Несколько указателей: с точки зрения производительности понимание списка будет превосходить цикл for; zip(df, row[1:])здесь достаточно; действительно, на этом этапе подумайте, является numbaли func числовым вычислением. См. Этот ответ для объяснения.
jpp 09
@jpp - если у вас есть лучшая функция, поделитесь. По моему мнению, это довольно близко к оптимальному значению. Да numba, быстрее, faster_df_applyпредназначено для людей, которым просто нужно что-то эквивалентное, но быстрее, чем DataFrame.apply(что до странности медленнее).
Пит Качиоппи,
На самом деле это очень похоже на то, как .applyреализовано, но делает одну вещь, которая значительно замедляет его, по сути, делает: row = pd.Series({f:v for f,v in zip(cols, row[1:])})что добавляет много сопротивления. Я написал ответ, в котором описывалась реализация, хотя я думаю, что она устарела, в последних версиях пытались использовать Cython .apply, я полагаю (не
цитируйте
2

Бывают ли ситуации, когда applyхорошо? Иногда да.

Задача: расшифровать строки Unicode.

import numpy as np
import pandas as pd
import unidecode

s = pd.Series(['mañana','Ceñía'])
s.head()
0    mañana
1     Ceñía


s.apply(unidecode.unidecode)
0    manana
1     Cenia

Обновление
Я ни в коем случае не выступал за использование apply, просто подумал, что, поскольку он NumPyне может справиться с вышеуказанной ситуацией, он мог бы быть хорошим кандидатом pandas apply. Но я забыл о простом понимании списка благодаря напоминанию @jpp.

Astro123
источник
Ну нет. Чем это лучше [unidecode.unidecode(x) for x in s]или list(map(unidecode.unidecode, s))?
jpp
1
Так как это уже была серия pandas, у меня возник соблазн использовать apply. Да, вы правы, лучше использовать list-comp, чем apply. Но отрицательное голосование было немного резким, я не защищал его apply, просто подумал, что это могло быть хорошо. вариант использования.
astro123