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

Я видел много ответов на вопросы о Кару, связанные с использованием метода Панд apply. Я также видел, как пользователи комментировали под ними, говоря, что "apply медленный, и его следует избегать".

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

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

Ответ 1

apply, удобная функция, в которой вы никогда не нуждались

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

"Если применять это так плохо, то почему это в API?"

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

apply может кое-что сделать:

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

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

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

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


Давайте ответим на следующий вопрос.

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

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

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

Если вы работаете с числовыми данными, вероятно, уже есть векторизованная функция Cython, которая делает именно то, что вы пытаетесь сделать (если нет, пожалуйста, задайте вопрос о Qaru или откройте запрос функции на 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)

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

Строка /Regex

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

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

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

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

Это должно вернуть строку второй и третьей строки, так как "donald" и "minnie" присутствуют в соответствующих столбцах "Title".

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

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

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

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

в/с

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.

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, а затем две основные операции, такие как "запаздывающая сумма":

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

Вам понадобятся два последовательных групповых звонка здесь:

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 в версиях для панд & lt; 0,25 (исправлено для 0,25, смотрите здесь для получения дополнительной информации.)

Ответ 2

Все apply не похожи

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

enter image description here

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

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

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

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

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.

Ответ 3

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

Ответ 4

Были ли ситуации, когда 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.