¿Cuándo debería querer usar pandas apply () en mi código?

110

He visto muchas respuestas publicadas a preguntas sobre Stack Overflow que involucran el uso del método Pandas apply. También he visto a usuarios comentar debajo de ellos diciendo que " applyes lento y debe evitarse".

He leído muchos artículos sobre el tema del rendimiento que explican que applyes lento. También he visto un descargo de responsabilidad en los documentos sobre cómo applyes simplemente una función de conveniencia para pasar UDF (parece que no puedo encontrar eso ahora). Entonces, el consenso general es que applydebe evitarse si es posible. Sin embargo, esto plantea las siguientes preguntas:

  1. Si applyes tan malo, ¿por qué está en la API?
  2. ¿Cómo y cuándo debo hacer que mi código sea applylibre?
  3. ¿Alguna vez hay situaciones en las que applyes bueno (mejor que otras posibles soluciones)?
cs95
fuente
1
returns.add(1).apply(np.log)vs. np.log(returns.add(1)es un caso en el applyque generalmente será ligeramente más rápido, que es el cuadro verde de la parte inferior derecha en el diagrama de jpp a continuación.
Alexander
@Alexander gracias. No señaló exhaustivamente estas situaciones, ¡pero es útil conocerlas!
cs95

Respuestas:

107

apply, la función de conveniencia que nunca necesitó

Comenzamos abordando las preguntas en el PO, una por una.

" Si la aplicación es tan mala, ¿por qué está en la API? "

DataFrame.applyy Series.applyson funciones de conveniencia definidas en el objeto DataFrame y Series respectivamente. applyacepta cualquier función definida por el usuario que aplique una transformación / agregación en un DataFrame. applyes efectivamente una bala de plata que hace lo que cualquier función panda existente no puede hacer.

Algunas de las cosas que se applypueden hacer:

  • Ejecute cualquier función definida por el usuario en un DataFrame o Series
  • Aplicar una función ya sea por filas ( axis=1) o por columnas ( axis=0) en un DataFrame
  • Realice la alineación del índice mientras aplica la función
  • Realizar agregación con funciones definidas por el usuario (sin embargo, solemos preferir aggo transformen estos casos)
  • Realiza transformaciones por elementos
  • Transmita los resultados agregados a las filas originales (consulte el result_typeargumento).
  • Acepte argumentos posicionales / de palabras clave para pasar a las funciones definidas por el usuario.

...Entre otros. Para obtener más información, consulte Aplicación de función de fila o columna en la documentación.

Entonces, con todas estas características, ¿por qué es applymalo? Es porque applyes lento . Pandas no hace suposiciones sobre la naturaleza de su función, por lo que aplica iterativamente su función a cada fila / columna según sea necesario. Además, manejar todas las situaciones anteriores significa que se applyincurre en una sobrecarga importante en cada iteración. Además, applyconsume mucha más memoria, lo que es un desafío para las aplicaciones limitadas a la memoria.

Hay muy pocas situaciones en las que applysea ​​apropiado usar (más sobre eso a continuación). Si no está seguro de si debería usarlo apply, probablemente no debería hacerlo.


Abordemos la siguiente pregunta.

" ¿Cómo y cuándo debo hacer que mi código se aplique gratis? "

Para reformular, aquí hay algunas situaciones comunes en las que querrá deshacerse de las llamadas a apply.

Datos numéricos

Si está trabajando con datos numéricos, es probable que ya exista una función cython vectorizada que hace exactamente lo que está tratando de hacer (de lo contrario, haga una pregunta en Stack Overflow o abra una solicitud de función en GitHub).

Contraste el rendimiento de applypara una operación de adición simple.

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

En cuanto al rendimiento, no hay comparación, el equivalente cythonizado es mucho más rápido. No es necesario un gráfico, porque la diferencia es obvia incluso para los datos de juguetes.

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

Incluso si habilita el paso de matrices sin procesar con el rawargumento, sigue siendo dos veces más lento.

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

Otro ejemplo:

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)

En general, busque alternativas vectorizadas si es posible.

Cadena / Regex

Pandas proporciona funciones de cadena "vectorizadas" en la mayoría de las situaciones, pero hay casos raros en los que esas funciones no ... "se aplican", por así decirlo.

Un problema común es comprobar si un valor de una columna está presente en otra columna de la misma fila.

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

Esto debería devolver la segunda y tercera fila, ya que "donald" y "minnie" están presentes en sus respectivas columnas de "Título".

Usando aplicar, esto se haría usando

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

Sin embargo, existe una mejor solución utilizando listas por comprensión.

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)

Lo que hay que tener en cuenta aquí es que las rutinas iterativas resultan ser más rápidas que apply, debido a la menor sobrecarga. Si necesita manejar NaNs y dtypes no válidos, puede construir sobre esto usando una función personalizada que luego puede llamar con argumentos dentro de la comprensión de la lista.

Para obtener más información sobre cuándo las listas por comprensión deben considerarse una buena opción, consulte mi artículo: Para bucles con pandas: ¿cuándo debería importarme? .

Nota Las
operaciones de fecha y hora también tienen versiones vectorizadas. Así, por ejemplo, se debe preferir pd.to_datetime(df['date']), sobre, digamos, df['date'].apply(pd.to_datetime).

Leer más en los documentos .

Un error común: columnas de listas explosivas

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

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

La gente se siente tentada a consumir apply(pd.Series). Esto es horrible en términos de rendimiento.

s.apply(pd.Series)

   0  1
0  1  2
1  1  2
2  1  2

Una mejor opción es enumerar la columna y pasarla a 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)

Por último,

" ¿Hay situaciones en las que apply sea ​​bueno? "

Aplicar es una función de conveniencia, por lo que no son situaciones en las que la sobrecarga es lo suficientemente insignificante para perdonar. Realmente depende de cuántas veces se llame a la función.

Funciones que están vectorizadas para series, pero no DataFrames
¿Qué sucede si desea aplicar una operación de cadena en varias columnas? ¿Qué sucede si desea convertir varias columnas a fecha y hora? Estas funciones están vectorizadas solo para Series, por lo que deben aplicarse sobre cada columna en la que desee convertir / operar.

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

Este es un caso admisible para apply:

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

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

Tenga en cuenta que también tendría sentido stack, o simplemente usar un bucle explícito. Todas estas opciones son un poco más rápidas que usar apply, pero la diferencia es lo suficientemente pequeña como para perdonar.

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

Puede presentar un caso similar para otras operaciones, como operaciones de cadena o conversión a categoría.

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)

Y así...

Conversión de series a str: astypeversusapply

Esto parece una idiosincrasia de la API. Usar applypara convertir enteros en una serie a cadena es comparable (y a veces más rápido) que usar astype.

ingrese la descripción de la imagen aquí El gráfico se trazó utilizando la perfplotbiblioteca.

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

Con flotadores, veo que astypees consistentemente tan rápido como, o un poco más rápido que apply. Entonces, esto tiene que ver con el hecho de que los datos en la prueba son de tipo entero.

GroupBy operaciones con transformaciones encadenadas

GroupBy.applyno se ha discutido hasta ahora, pero GroupBy.applytambién es una función de conveniencia iterativa para manejar cualquier cosa que las GroupByfunciones existentes no lo hagan.

Un requisito común es realizar un GroupBy y luego dos operaciones principales, como un "cumsum retrasado":

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

Necesitaría dos llamadas grupales sucesivas aquí:

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

Con apply, puede acortar esto a una sola llamada.

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

Es muy difícil cuantificar el rendimiento porque depende de los datos. Pero en general, applyes una solución aceptable si el objetivo es reducir una groupbyllamada (porque groupbytambién es bastante caro).


Otras advertencias

Aparte de las advertencias mencionadas anteriormente, también vale la pena mencionar que applyopera en la primera fila (o columna) dos veces. Esto se hace para determinar si la función tiene efectos secundarios. De lo contrario, es applyposible que pueda utilizar una vía rápida para evaluar el resultado; de lo contrario, recurre a una implementación lenta.

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

Este comportamiento también se ve en las GroupBy.applyversiones de pandas <0.25 (se corrigió para 0.25, consulte aquí para obtener más información ).

cs95
fuente
Creo que debemos tener cuidado ... ¿ %timeit for c in df.columns: df[c] = pd.to_datetime(df[c], errors='coerce')seguramente después de la primera iteración será mucho más rápido ya que te estás convirtiendo datetimea ... datetime?
jpp
@jpp Tuve la misma preocupación. Pero aún necesita hacer un escaneo lineal de cualquier manera, llamar a to_datetime en cadenas es tan rápido como llamarlos en objetos de fecha y hora, si no más rápido. Los tiempos del estadio de béisbol son los mismos. La alternativa sería implementar algún paso previo a la copia para cada solución cronometrada que quita el punto principal. Pero es una preocupación válida.
cs95
"Llamar to_datetimea las cuerdas es tan rápido como a ... datetimeobjetos" ... ¿en serio? Incluí la creación de marcos de datos (costo fijo) en tiempos de bucle applyvs fory la diferencia es mucho menor.
jpp
@jpp Bueno, eso es lo que obtuve de mis pruebas (ciertamente limitadas). Estoy seguro de que depende de los datos, pero la idea general es que, a modo de ilustración, la diferencia es "en serio, no te preocupes".
cs95
1
@ cs95, ¡Feliz año nuevo!
jpp
48

No todas las applys son iguales

La siguiente tabla sugiere cuándo considerar apply1 . Verde significa posiblemente eficiente; rojo evitar.

ingrese la descripción de la imagen aquí

Algo de esto es intuitivo: pd.Series.applyes un bucle de nivel de fila de Python, lo mismo ocurre con el de pd.DataFrame.applyfila ( axis=1). Los usos indebidos de estos son muchos y de gran alcance. La otra publicación los trata con más profundidad. Las soluciones más populares son utilizar métodos vectorizados, listas por comprensión (asume datos limpios) o herramientas eficientes como el pd.DataFrameconstructor (por ejemplo, para evitar apply(pd.Series)).

Si utiliza pd.DataFrame.applyfilas, especificar raw=True(cuando sea posible) suele ser beneficioso. En esta etapa, numbasuele ser una mejor opción.

GroupBy.apply: generalmente favorecido

La repetición de groupbyoperaciones para evitar applydañará el rendimiento. GroupBy.applygeneralmente está bien aquí, siempre que los métodos que use en su función personalizada estén vectorizados. A veces, no existe un método Pandas nativo para una agregación grupal que desee aplicar. En este caso, para una pequeña cantidad de grupos applycon una función personalizada, aún puede ofrecer un rendimiento razonable.

pd.DataFrame.apply columna-sabio: una bolsa mixta

pd.DataFrame.applycolumn -wise ( axis=0) es un caso interesante. Para una pequeña cantidad de filas frente a una gran cantidad de columnas, casi siempre es caro. Para una gran cantidad de filas en relación con las columnas, el caso más común, a veces puede ver mejoras de rendimiento significativas utilizando 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 Hay excepciones, pero suelen ser marginales o poco frecuentes. Un par de ejemplos:

  1. df['col'].apply(str)puede superar ligeramente df['col'].astype(str).
  2. df.apply(pd.to_datetime)trabajar con cadenas no se escala bien con filas frente a un forbucle normal .
jpp
fuente
2
Gracias por colaborar, aprecia las múltiples perspectivas :) +1
cs95
1
@coldspeed, Gracias, no hay nada malo en su publicación (aparte de algunas evaluaciones comparativas contradictorias frente a la mía, pero podría basarse en la entrada o la configuración). Simplemente sentí que hay una forma diferente de ver el problema.
jpp
@jpp Siempre usé tu excelente diagrama de flujo como guía hasta que vi hoy que una filaapply es significativamente más rápida que mi solución con any. Tiene alguna idea sobre esto?
Stef
1
@jpp: tienes razón: para 1 millón de filas x 100 columnas anyes aproximadamente 100 veces más rápido que apply. Hice mis primeras pruebas con 2000 filas x 1000 columnas y aquí applyfue dos veces más rápido queany
Stef
1
@jpp Me gustaría usar tu imagen en una presentación / artículo. ¿Estás de acuerdo con eso? Obviamente mencionaré la fuente. Gracias
Erfan
3

Para axis=1(es decir, funciones por filas), puede usar la siguiente función en lugar de apply. Me pregunto por qué este no es el pandascomportamiento. (No probado con índices compuestos, pero parece ser mucho más rápido que 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)
Pete Cacioppi
fuente
Me sorprendió mucho descubrir que esto me dio un mejor rendimiento en algunos casos. Fue especialmente útil cuando necesitaba hacer varias cosas, cada una con un subconjunto diferente de valores de columna. La respuesta "No todas las aplicaciones son iguales" puede ayudar a determinar cuándo es probable que ayude, pero no es muy difícil de probar en una muestra de sus datos.
denson
Algunas sugerencias: para el rendimiento, la comprensión de una lista superaría al ciclo for; zip(df, row[1:])es suficiente aquí; realmente, en esta etapa, considere numbasi func es un cálculo numérico. Consulte esta respuesta para obtener una explicación.
jpp
@jpp: si tiene una función mejor, compártala. Creo que esto está bastante cerca de lo óptimo de mi análisis. Sí numbaes más rápido, faster_df_applyestá destinado a personas que solo quieren algo equivalente a, pero más rápido que DataFrame.apply(que es extrañamente lento).
Pete Cacioppi
2

¿Alguna vez hay situaciones en las que applyes bueno? Sí a veces.

Tarea: decodificar cadenas 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

Actualización
De ninguna manera estaba abogando por el uso de apply, solo pensaba que, dado NumPyque no puede lidiar con la situación anterior, podría haber sido un buen candidato para pandas apply. Pero me estaba olvidando de la simple comprensión de la lista gracias al recordatorio de @jpp.

astro123
fuente
Bueno no. ¿Cómo es esto mejor que [unidecode.unidecode(x) for x in s]o list(map(unidecode.unidecode, s))?
jpp
1
Como ya era una serie de pandas, tuve la tentación de usar apply, sí, tienes razón, es mejor usar list-comp que aplicar, pero downvote fue un poco duro, no estaba defendiendo apply, solo pensé que esto podría haber sido un buen caso de uso.
astro123