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.apply
y Series.apply
son funciones de conveniencia definidas en el objeto DataFrame y Series respectivamente. apply
acepta cualquier función definida por el usuario que aplique una transformación / agregación en un DataFrame. apply
es efectivamente una bala de plata que hace lo que cualquier función panda existente no puede hacer.
Algunas de las cosas que se apply
pueden 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
agg
o transform
en estos casos)
- Realiza transformaciones por elementos
- Transmita los resultados agregados a las filas originales (consulte el
result_type
argumento).
- 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 apply
malo? Es porque apply
es 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 apply
incurre en una sobrecarga importante en cada iteración. Además, apply
consume mucha más memoria, lo que es un desafío para las aplicaciones limitadas a la memoria.
Hay muy pocas situaciones en las que apply
sea 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 apply
para 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 raw
argumento, 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
: astype
versusapply
Esto parece una idiosincrasia de la API. Usar apply
para convertir enteros en una serie a cadena es comparable (y a veces más rápido) que usar astype
.
El gráfico se trazó utilizando la perfplot
biblioteca.
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 astype
es 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.apply
no se ha discutido hasta ahora, pero GroupBy.apply
también es una función de conveniencia iterativa para manejar cualquier cosa que las GroupBy
funciones 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, apply
es una solución aceptable si el objetivo es reducir una groupby
llamada (porque groupby
también es bastante caro).
Otras advertencias
Aparte de las advertencias mencionadas anteriormente, también vale la pena mencionar que apply
opera en la primera fila (o columna) dos veces. Esto se hace para determinar si la función tiene efectos secundarios. De lo contrario, es apply
posible 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.apply
versiones de pandas <0.25 (se corrigió para 0.25, consulte aquí para obtener más información ).
returns.add(1).apply(np.log)
vs.np.log(returns.add(1)
es un caso en elapply
que 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.