Коли я коли-небудь хочу використовувати панди, що застосовують () у своєму коді?


111

Я бачив багато відповідей, розміщених на запитання щодо переповнення стека, що стосуються використання методу Pandas apply. Я також бачив, як користувачі коментують їх, говорячи, що " applyповільно, і цього слід уникати".

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

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

1
returns.add(1).apply(np.log)vs. np.log(returns.add(1)- це випадок, коли applyзазвичай буде незначно швидше, що є зеленим полем знизу праворуч на діаграмі jpp нижче.
Олександр

@ Олександр дякую. Чи не вичерпно вказали на ці ситуації, але їх корисно знати!
cs95

Відповіді:


108

apply, функція зручності, яка вам ніколи не потрібна

Почнемо з вирішення питань в ОП, по одному.

" Якщо заявка настільки погана, то чому це в API? "

DataFrame.applyі Series.applyце функції зручності, визначені на об'єкті DataFrame та Series відповідно. applyприймає будь-яку визначену користувачем функцію, яка застосовує перетворення / агрегацію на DataFrame. applyНасправді срібна куля, яка не може виконувати будь-яку функцію панд.

Деякі речі apply:

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

...Серед інших. Для отримання додаткової інформації див. Додаток щодо функцій рядків або стовпців у документації.

Отже, з усіма цими особливостями, чому це applyпогано? Це тому apply, що повільно . Pandas не передбачає припущень щодо природи вашої функції, тому ітеративно застосовує вашу функцію до кожного рядка / стовпця, якщо це необхідно. Крім того, обробка всіх вищезазначених ситуацій означає, що applyкожен ітератор має значні накладні витрати. Крім того, applyвитрачається набагато більше пам’яті, що є проблемою для обмежених пам’яттю програм.

Є дуже мало ситуацій, коли applyдоцільно використовувати (докладніше про це нижче). Якщо ви не впевнені, чи варто вам користуватися apply, ви, мабуть, не повинні.


Давайте розглянемо наступне питання.

" Як і коли я можу зробити так, щоб мій код було застосовано безкоштовно? "

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

Числові дані

Якщо ви працюєте з числовими даними, ймовірно, вже є векторизована функція цитону, яка робить саме те, що ви намагаєтеся зробити (якщо ні, будь ласка, задайте питання щодо переповнення стека або відкрийте запит на функцію на 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

Це повинно повернути другий і третій рядки, оскільки "donald" і "minnie" присутні у відповідних стовпцях "Title".

Використовуючи застосувати, це можна зробити за допомогою

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через нижчі накладні витрати. Якщо вам потрібно обробити NaNs та недійсні типи, ви можете використовувати це за допомогою спеціальної функції, яку ви можете викликати з аргументами всередині списку.

Для отримання додаткової інформації про те, коли розуміння списків слід вважати хорошим варіантом, див. Моє написання: Для циклів із пандами - Коли мені потрібно піклуватися? .

Примітка.
Операції з датою та датою також мають векторні версії. Так, наприклад, вам слід віддати перевагу 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 добре? "

Застосування - це функція зручності, тому бувають ситуації, коли накладні витрати є незначними, щоб пробачити. Це дійсно залежить від того, скільки разів викликається функція.

Функції, які векторизовані для серії, але не DataFrames
Що робити, якщо ви хочете застосувати рядову операцію до кількох стовпців? Що робити, якщо ви хочете перетворити кілька стовпців на дату? Ці функції векторизовані лише для 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.

введіть тут опис зображення Графік був побудований за допомогою 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версіях панд <0,25 (було встановлено 0,25, див. Тут для отримання додаткової інформації .)


Я думаю, що нам потрібно бути обережними. %timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce')Звичайно, після першої ітерації це стане набагато швидше, оскільки ви переходите datetimeна ... datetime?
jpp

@jpp У мене була така ж турбота. Але вам все одно потрібно виконати лінійне сканування в будь-якому випадку, виклик to_datetime на рядках так само швидко, як і виклик їх на об'єкти дати, якщо не швидше. Графіки бальних парків однакові. Альтернативою може бути реалізація певного кроку попереднього копіювання для кожного тимчасового рішення, яке відходить від основної суті. Але це поважна турбота.
cs95

"Виклик to_datetimeна рядках так само швидко, як і на ... datetimeоб'єктах" .. дійсно? Я включив створення фрейму даних (фіксована вартість) в таймінги applyпроти forциклу, і різниця набагато менша.
jpp

@jpp Ну, ось що я отримав від мого (правда, обмеженого) тестування. Я впевнений, що це залежить від даних, але загальна думка полягає в тому, що для ілюстрації, різниця полягає в тому, що "серйозно, не турбуйся про це".
cs95

1
@ cs95, З новим роком!
jpp

49

Не всі applyподібні

Наведений нижче графік підказує, коли слід розглянути apply1 . Зелений означає можливо ефективний; червоний уникати.

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

Дещо з цього є інтуїтивно зрозумілим: pd.Series.applyце цикл рядків на рівні Python, ditto pd.DataFrame.applyrow-mud ( 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.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циклом.

2
Дякуємо за те, що ви
знайдете перевагу

1
@coldspeed, Спасибі, у вашій посаді немає нічого поганого (крім деяких суперечливих бенчмаркінгу проти моїх, але це може бути на основі введення чи налаштування). Просто відчув, що існує інший спосіб поглянути на проблему.
jpp

@jpp Я завжди використовував Ти відмінну схему в якості керівництва до тих пір , коли я побачив сьогодні , що через підрядникapply значно швидше , ніж моє рішення з any. Будь-які думки з цього приводу?
Стеф

1
@jpp: Ви маєте рацію: для 1mio рядків х 100 Cols anyприблизно в 100 разів швидше , ніж apply. Я зробив свої перші тести з 2000 рядків x 1000 cols, і тут applyбуло вдвічі швидшеany
Стеф

1
@jpp Я хотів би використати ваше зображення у презентації / статті. Ви з цим все гаразд? Я, очевидно, згадую джерело. Спасибі
Ерфан

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)

Я був дуже здивований, виявивши, що це дало мені кращу ефективність у деяких випадках. Це було особливо корисно, коли мені потрібно було робити кілька речей, кожна з яких відрізняється підмножиною значень стовпців. Відповідь "Усі заявки не схожі" може допомогти з'ясувати, коли це може допомогти, але перевірити на вибірці даних не надто складно.
денсон

Кілька покажчиків: за виконанням розуміння списку випереджає цикл for; zip(df, row[1:])тут достатньо; дійсно, на цьому етапі врахуйте, numbaчи функція є числовим обчисленням. Дивіться цю відповідь для пояснення.
jpp

@jpp - якщо у вас є краща функція, будь ласка, поділіться. Я думаю, що це досить близько до оптимального з мого аналізу. Так numba, швидше, faster_df_applyпризначений для людей, які просто хочуть чогось еквівалентного, але швидшого, ніж DataFrame.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.


Ну, ні. Як це краще, ніж [unidecode.unidecode(x) for x in s]чи list(map(unidecode.unidecode, s))?
jpp

1
Оскільки це вже була серія панд, я спокусився застосувати застосувати, так, ви праві, краще використовувати list-comp, ніж застосовувати, Але downvote був трохи суворим, я не виступав за це apply, я просто подумав, що це могло бути хорошим використання випадку.
astro123
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.