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

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

  1. Если apply настолько плох, то почему он в API?
  2. Как и когда мне сделать мой код применить-бесплатно?
  3. Существуют ли ситуации, когда применить будет хорошо (лучше, чем другие возможные решения)?

cs95

Ответы (4)

применить, функция удобства, которая вам никогда не нужна

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

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

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

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

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

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

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

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



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

«Как и когда мне сделать мой код применить-бесплатно?»

To rephrase, here are some common situations where you will want to get rid of any calls to apply.

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

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

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

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)

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

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

Примечание
Операции с датой и датой / временем также имеют векторизованные версии. Так, например, вы должны предпочесть 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

People are tempted to use apply(pd.Series). This is horrible in terms of performance.

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 - это удобная функция, поэтому возможны ситуации, когда накладные расходы достаточно незначительны, чтобы простить. Это действительно зависит от того, сколько раз вызывается функция.

Функции, векторизованные для 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
    

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

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

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

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

%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))

в / с

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 по сравнению с применить

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

enter image description here График построен с использованием библиотеки 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, см. Здесь для получения дополнительной информации.)

Существуют ли ситуации, когда 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.

Для 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)

Не все применимыодинаковы

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

enter image description here

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

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

GroupBy.apply: обычно рекомендуется

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

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

pd.DataFrame.apply по столбцам (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.

2022 WebDevInsider