Devolver múltiples columnas de pandas apply ()

103

Tengo una trama de datos pandas, df_test. Contiene una columna 'tamaño' que representa el tamaño en bytes. Calculé KB, MB y GB con el siguiente código:

df_test = pd.DataFrame([
    {'dir': '/Users/uname1', 'size': 994933},
    {'dir': '/Users/uname2', 'size': 109338711},
])

df_test['size_kb'] = df_test['size'].astype(int).apply(lambda x: locale.format("%.1f", x / 1024.0, grouping=True) + ' KB')
df_test['size_mb'] = df_test['size'].astype(int).apply(lambda x: locale.format("%.1f", x / 1024.0 ** 2, grouping=True) + ' MB')
df_test['size_gb'] = df_test['size'].astype(int).apply(lambda x: locale.format("%.1f", x / 1024.0 ** 3, grouping=True) + ' GB')

df_test


             dir       size       size_kb   size_mb size_gb
0  /Users/uname1     994933      971.6 KB    0.9 MB  0.0 GB
1  /Users/uname2  109338711  106,776.1 KB  104.3 MB  0.1 GB

[2 rows x 5 columns]

He ejecutado esto en más de 120,000 filas y el tiempo toma aproximadamente 2.97 segundos por columna * 3 = ~ 9 segundos según% timeit.

¿Hay alguna forma de que pueda hacer esto más rápido? Por ejemplo, ¿puedo en lugar de devolver una columna a la vez después de aplicarla y ejecutarla 3 veces, puedo devolver las tres columnas en una sola pasada para volver a insertarlas en el marco de datos original?

Todas las otras preguntas que he encontrado quieren tomar varios valores y devolver un solo valor . Quiero tomar un solo valor y devolver varias columnas .

PaulMest
fuente

Respuestas:

119

Esta es una pregunta antigua, pero para completar, puede devolver una Serie de la función aplicada que contiene los nuevos datos, evitando la necesidad de iterar tres veces. Pasar axis=1a la función de aplicación aplica la función sizesa cada fila del marco de datos, devolviendo una serie para agregar a un nuevo marco de datos. Esta serie, s, contiene los nuevos valores, así como los datos originales.

def sizes(s):
    s['size_kb'] = locale.format("%.1f", s['size'] / 1024.0, grouping=True) + ' KB'
    s['size_mb'] = locale.format("%.1f", s['size'] / 1024.0 ** 2, grouping=True) + ' MB'
    s['size_gb'] = locale.format("%.1f", s['size'] / 1024.0 ** 3, grouping=True) + ' GB'
    return s

df_test = df_test.append(rows_list)
df_test = df_test.apply(sizes, axis=1)
Nelz11
fuente
11
Me sorprende que haya pasado casi 2 años sin la respuesta correcta. Estaba buscando algo más y me encontré con esto. ¡Espero que no sea demasiado tarde para ser útil!
Nelz11
10
¿Qué hay rows_listen esta respuesta?
David Stansby
Es solo una lista de Series para construir el Dataframe.
Nelz11
1
Si el pd.Series necesita un índice, debe proporcionarlo pd.Series(data, index=...). De lo contrario, obtendrá errores crípticos cuando intente volver a asignar el resultado al marco de datos principal.
smci
96

Use aplicar y sellar 3 veces más rápido que en serie.

def sizes(s):    
    return locale.format("%.1f", s / 1024.0, grouping=True) + ' KB', \
        locale.format("%.1f", s / 1024.0 ** 2, grouping=True) + ' MB', \
        locale.format("%.1f", s / 1024.0 ** 3, grouping=True) + ' GB'
df_test['size_kb'],  df_test['size_mb'], df_test['size_gb'] = zip(*df_test['size'].apply(sizes))

Los resultados de la prueba son:

Separate df.apply(): 

    100 loops, best of 3: 1.43 ms per loop

Return Series: 

    100 loops, best of 3: 2.61 ms per loop

Return tuple:

    1000 loops, best of 3: 819 µs per loop
Jesse
fuente
Me sorprende que esto no haya recibido más votos a favor. Gracias por compartir la variante adicional y los datos de tiempo.
gumption
¿Podría explicar cómo devolvió la tupla? Parece ser la opción más rápida
Camilo
Consulte mi código de muestra, esa es la forma de tupla.
Jesse
parece el más rápido y fácil también. sorprendido de no poder encontrarlo yo mismo.
Shahir Ansari
59

Algunas de las respuestas actuales funcionan bien, pero quiero ofrecer otra opción, tal vez más "pandificada". Esto funciona para mí con los pandas actuales 0.23 (no estoy seguro de si funcionará en versiones anteriores):

import pandas as pd

df_test = pd.DataFrame([
  {'dir': '/Users/uname1', 'size': 994933},
  {'dir': '/Users/uname2', 'size': 109338711},
])

def sizes(s):
  a = locale.format("%.1f", s['size'] / 1024.0, grouping=True) + ' KB'
  b = locale.format("%.1f", s['size'] / 1024.0 ** 2, grouping=True) + ' MB'
  c = locale.format("%.1f", s['size'] / 1024.0 ** 3, grouping=True) + ' GB'
  return a, b, c

df_test[['size_kb', 'size_mb', 'size_gb']] = df_test.apply(sizes, axis=1, result_type="expand")

Tenga en cuenta que el truco está en el result_typeparámetro de apply, que expandirá su resultado a un DataFrameque se puede asignar directamente a columnas nuevas / antiguas.

jaumebonet
fuente
1
Así es ... lo siento ... después de algunas comprobaciones, funciona con 0.22 en algunos casos, pero estaba en un entorno virtual y en realidad ejecutaba 0.23 cuando lo intenté ...: /
jaumebonet
5
Ésta es la respuesta más óptima. Gracias
AdR
17

Solo otra forma legible. Este código agregará tres columnas nuevas y sus valores, devolviendo series sin parámetros de uso en la función de aplicación.

def sizes(s):

    val_kb = locale.format("%.1f", s['size'] / 1024.0, grouping=True) + ' KB'
    val_mb = locale.format("%.1f", s['size'] / 1024.0 ** 2, grouping=True) + ' MB'
    val_gb = locale.format("%.1f", s['size'] / 1024.0 ** 3, grouping=True) + ' GB'
    return pd.Series([val_kb,val_mb,val_gb],index=['size_kb','size_mb','size_gb'])

df[['size_kb','size_mb','size_gb']] = df.apply(lambda x: sizes(x) , axis=1)

Un ejemplo general de: https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.apply.html

df.apply(lambda x: pd.Series([1, 2], index=['foo', 'bar']), axis=1)

#foo  bar
#0    1    2
#1    1    2
#2    1    2
alvaro nortes
fuente
9

¡Respuestas realmente geniales! ¡Gracias Jesse y jaumebonet! Solo una observación con respecto a:

  • zip(* ...
  • ... result_type="expand")

Aunque expandir es un poco más elegante ( pandificado ), zip es al menos ** 2 veces más rápido . En este simple ejemplo a continuación, obtuve 4 veces más rápido .

import pandas as pd

dat = [ [i, 10*i] for i in range(1000)]

df = pd.DataFrame(dat, columns = ["a","b"])

def add_and_sub(row):
    add = row["a"] + row["b"]
    sub = row["a"] - row["b"]
    return add, sub

df[["add", "sub"]] = df.apply(add_and_sub, axis=1, result_type="expand")
# versus
df["add"], df["sub"] = zip(*df.apply(add_and_sub, axis=1))
famaral42
fuente
8

El desempeño entre las respuestas principales es significativamente variado, y Jesse y famaral42 ya han discutido esto, pero vale la pena compartir una comparación justa entre las respuestas principales y desarrollar un detalle sutil pero importante de la respuesta de Jesse: el argumento pasó a la función, también afecta el rendimiento .

(Python 3.7.4, Pandas 1.0.3)

import pandas as pd
import locale
import timeit


def create_new_df_test():
    df_test = pd.DataFrame([
      {'dir': '/Users/uname1', 'size': 994933},
      {'dir': '/Users/uname2', 'size': 109338711},
    ])
    return df_test


def sizes_pass_series_return_series(series):
    series['size_kb'] = locale.format_string("%.1f", series['size'] / 1024.0, grouping=True) + ' KB'
    series['size_mb'] = locale.format_string("%.1f", series['size'] / 1024.0 ** 2, grouping=True) + ' MB'
    series['size_gb'] = locale.format_string("%.1f", series['size'] / 1024.0 ** 3, grouping=True) + ' GB'
    return series


def sizes_pass_series_return_tuple(series):
    a = locale.format_string("%.1f", series['size'] / 1024.0, grouping=True) + ' KB'
    b = locale.format_string("%.1f", series['size'] / 1024.0 ** 2, grouping=True) + ' MB'
    c = locale.format_string("%.1f", series['size'] / 1024.0 ** 3, grouping=True) + ' GB'
    return a, b, c


def sizes_pass_value_return_tuple(value):
    a = locale.format_string("%.1f", value / 1024.0, grouping=True) + ' KB'
    b = locale.format_string("%.1f", value / 1024.0 ** 2, grouping=True) + ' MB'
    c = locale.format_string("%.1f", value / 1024.0 ** 3, grouping=True) + ' GB'
    return a, b, c

Aquí están los resultados:

# 1 - Accepted (Nels11 Answer) - (pass series, return series):
9.82 ms ± 377 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

# 2 - Pandafied (jaumebonet Answer) - (pass series, return tuple):
2.34 ms ± 48.6 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

# 3 - Tuples (pass series, return tuple then zip):
1.36 ms ± 62.8 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

# 4 - Tuples (Jesse Answer) - (pass value, return tuple then zip):
752 µs ± 18.5 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Observe cómo devolver tuplas es el método más rápido, pero lo que se pasa en un argumento, también afecta al rendimiento. La diferencia en el código es sutil pero la mejora del rendimiento es significativa.

La prueba n. ° 4 (pasar un solo valor) es dos veces más rápida que la prueba n. ° 3 (pasar una serie), aunque la operación realizada es aparentemente idéntica.

Pero hay más ...

# 1a - Accepted (Nels11 Answer) - (pass series, return series, new columns exist):
3.23 ms ± 141 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

# 2a - Pandafied (jaumebonet Answer) - (pass series, return tuple, new columns exist):
2.31 ms ± 39.3 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

# 3a - Tuples (pass series, return tuple then zip, new columns exist):
1.36 ms ± 58.4 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

# 4a - Tuples (Jesse Answer) - (pass value, return tuple then zip, new columns exist):
694 µs ± 3.9 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

En algunos casos (# 1a y # 4a), aplicar la función a un DataFrame en el que las columnas de salida ya existen es más rápido que crearlas desde la función.

Aquí está el código para ejecutar las pruebas:

# Paste and run the following in ipython console. It will not work if you run it from a .py file.
print('\nAccepted Answer (pass series, return series, new columns dont exist):')
df_test = create_new_df_test()
%timeit result = df_test.apply(sizes_pass_series_return_series, axis=1)
print('Accepted Answer (pass series, return series, new columns exist):')
df_test = create_new_df_test()
df_test = pd.concat([df_test, pd.DataFrame(columns=['size_kb', 'size_mb', 'size_gb'])])
%timeit result = df_test.apply(sizes_pass_series_return_series, axis=1)

print('\nPandafied (pass series, return tuple, new columns dont exist):')
df_test = create_new_df_test()
%timeit df_test[['size_kb', 'size_mb', 'size_gb']] = df_test.apply(sizes_pass_series_return_tuple, axis=1, result_type="expand")
print('Pandafied (pass series, return tuple, new columns exist):')
df_test = create_new_df_test()
df_test = pd.concat([df_test, pd.DataFrame(columns=['size_kb', 'size_mb', 'size_gb'])])
%timeit df_test[['size_kb', 'size_mb', 'size_gb']] = df_test.apply(sizes_pass_series_return_tuple, axis=1, result_type="expand")

print('\nTuples (pass series, return tuple then zip, new columns dont exist):')
df_test = create_new_df_test()
%timeit df_test['size_kb'],  df_test['size_mb'], df_test['size_gb'] = zip(*df_test.apply(sizes_pass_series_return_tuple, axis=1))
print('Tuples (pass series, return tuple then zip, new columns exist):')
df_test = create_new_df_test()
df_test = pd.concat([df_test, pd.DataFrame(columns=['size_kb', 'size_mb', 'size_gb'])])
%timeit df_test['size_kb'],  df_test['size_mb'], df_test['size_gb'] = zip(*df_test.apply(sizes_pass_series_return_tuple, axis=1))

print('\nTuples (pass value, return tuple then zip, new columns dont exist):')
df_test = create_new_df_test()
%timeit df_test['size_kb'],  df_test['size_mb'], df_test['size_gb'] = zip(*df_test['size'].apply(sizes_pass_value_return_tuple))
print('Tuples (pass value, return tuple then zip, new columns exist):')
df_test = create_new_df_test()
df_test = pd.concat([df_test, pd.DataFrame(columns=['size_kb', 'size_mb', 'size_gb'])])
%timeit df_test['size_kb'],  df_test['size_mb'], df_test['size_gb'] = zip(*df_test['size'].apply(sizes_pass_value_return_tuple))
Rocky K
fuente
¡Gracias por analizar las características de rendimiento también!
PaulMest
3

Creo que la versión 1.1 rompe el comportamiento sugerido en la respuesta principal aquí.

import pandas as pd
def test_func(row):
    row['c'] = str(row['a']) + str(row['b'])
    row['d'] = row['a'] + 1
    return row

df = pd.DataFrame({'a': [1, 2, 3], 'b': ['i', 'j', 'k']})
df.apply(test_func, axis=1)

El código anterior se ejecutó en pandas 1.1.0 devuelve:

   a  b   c  d
0  1  i  1i  2
1  1  i  1i  2
2  1  i  1i  2

Mientras que en pandas 1.0.5 regresó:

   a   b    c  d
0  1   i   1i  2
1  2   j   2j  3
2  3   k   3k  4

Lo que creo que es lo que esperarías.

No estoy seguro de cómo las notas de la versión explican este comportamiento, sin embargo, como se explica aquí, evitar la mutación de las filas originales al copiarlas resucita el comportamiento anterior. es decir:

def test_func(row):
    row = row.copy()   #  <---- Avoid mutating the original reference
    row['c'] = str(row['a']) + str(row['b'])
    row['d'] = row['a'] + 1
    return row
mugir
fuente
Creo que su muestra de código puede haber tenido un error de copiar / pegar. ¿Podrías comprobarlo y ver si eso es lo que pretendías enviar?
PaulMest
1
Gracias @PaulMest tenías razón. Corregí los dos errores tipográficos y agregué un nuevo enlace / referencia donde se responde la pregunta.
MOO
1
¡Bienvenido a Stack Overflow! @moo
PaulMest
1

Generalmente, para devolver múltiples valores, esto es lo que hago

def gimmeMultiple(group):
    x1 = 1
    x2 = 2
    return array([[1, 2]])
def gimmeMultipleDf(group):
    x1 = 1
    x2 = 2
    return pd.DataFrame(array([[1,2]]), columns=['x1', 'x2'])
df['size'].astype(int).apply(gimmeMultiple)
df['size'].astype(int).apply(gimmeMultipleDf)

Devolver un marco de datos definitivamente tiene sus ventajas, pero a veces no es obligatorio. Puedes mirar lo que apply()devuelve y jugar un poco con las funciones;)

FooBar
fuente
Gracias por esta muestra. Sin embargo, esto no genera un solo marco de datos para todos los resultados. Cuando trato de volver a agregarlo al marco de datos original, obtengo "ValueError: la matriz no se puede transmitir a la forma correcta".
PaulMest
¿Puede proporcionar código para producir una pequeña muestra de datos?
FooBar
Cosa segura. Acabo de actualizar el código en mi publicación original para incluir datos de muestra y salida.
PaulMest
0

Proporciona un nuevo marco de datos con dos columnas del original.

import pandas as pd
df = ...
df_with_two_columns = df.apply(lambda row:pd.Series([row['column_1'], row['column_2']], index=['column_1', 'column_2']),axis = 1)
Waldeyr Mendes da Silva
fuente