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, см. Здесь для получения дополнительной информации ).
returns.add(1).apply(np.log)
vs.np.log(returns.add(1)
- это случай, когдаapply
обычно будет немного быстрее, что показано в правом нижнем зеленом поле на диаграмме jpp ниже.