Retorna várias colunas de pandas apply ()

100

Eu tenho um pandas trama de dados, df_test. Ele contém uma coluna 'tamanho' que representa o tamanho em bytes. Calculei KB, MB e GB usando o seguinte 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]

Executei isso em mais de 120.000 linhas e leva cerca de 2,97 segundos por coluna * 3 = ~ 9 segundos de acordo com% timeit.

Existe alguma maneira de tornar isso mais rápido? Por exemplo, posso, em vez de retornar uma coluna por vez do apply e executá-lo 3 vezes, posso retornar todas as três colunas em uma passagem para inserir de volta no dataframe original?

Todas as outras perguntas que encontrei desejam assumir vários valores e retornar um único valor . Quero pegar um único valor e retornar várias colunas .

PaulMest
fonte

Respostas:

114

Essa é uma questão antiga, mas para completar, você pode retornar uma Série da função aplicada que contém os novos dados, evitando a necessidade de iterar três vezes. Passar axis=1para a função apply aplica a função sizesa cada linha do dataframe, retornando uma série para adicionar a um novo dataframe. Esta série, s, contém os novos valores, bem como os dados originais.

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
fonte
11
Estou surpreso que tenha passado quase 2 anos sem a resposta certa. Eu estava procurando por outra coisa e tropecei nisso. Espero que não seja tarde demais para ser útil!
Nelz11
10
O que há rows_listnesta resposta?
David Stansby
É apenas uma lista de Séries para construir o Dataframe.
Nelz11
1
Se a série pd.Series precisar de um índice, você precisará fornecê-lo pd.Series(data, index=...). Caso contrário, você obterá erros criptográficos ao tentar atribuir o resultado de volta ao dataframe pai.
smci
94

Use aplicar e zip 3 vezes mais rápido do que o modo de série.

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

Os resultados do teste são:

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
fonte
Estou surpreso que não tenha recebido mais votos positivos. Obrigado por compartilhar a variante adicional e os dados de tempo.
bom senso
Você poderia explicar como você retornou a tupla? Parece ser a opção mais rápida
Camilo
Consulte meu código de amostra, essa é a forma de tupla.
Jesse
parece o mais rápido e fácil também. surpreso por não conseguir encontrar sozinho.
Shahir Ansari
53

Algumas das respostas atuais funcionam bem, mas quero oferecer outra opção, talvez mais "pandificada". Isso funciona para mim com o atual pandas 0.23 (não tenho certeza se funcionará nas versões 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")

Observe que o truque está no result_typeparâmetro de apply, que expandirá seu resultado em um DataFrameque pode ser atribuído diretamente a colunas novas / antigas.

Jaumebonet
fonte
1
Isso mesmo ... desculpe ... depois de algumas verificações, ele funciona com 0,22 em alguns casos, mas eu estava em um ambiente virtual e na verdade rodando o 0,23 quando tentei ...: /
jaumebonet
4
Esta é a resposta ideal. Obrigado
AdR de
16

Apenas outra forma legível. Este código adicionará três novas colunas e seus valores, retornando séries sem usar parâmetros na função de aplicação.

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)

Um exemplo geral 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
fonte
9

Respostas muito legais! Obrigado Jesse e jaumebonet! Apenas algumas observações em relação a:

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

Embora expandir seja um pouco mais elegante ( pandificado ), o zip é pelo menos ** 2x mais rápido . Neste exemplo simples abaixo, fiquei 4x mais 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
fonte
8

O desempenho entre as principais respostas é significativamente variado, e Jesse e famaral42 já discutiram isso, mas vale a pena compartilhar uma comparação justa entre as principais respostas e elaborar um detalhe sutil, mas importante da resposta de Jesse: o argumento passado para o função, também afeta o desempenho .

(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

Aqui estão os 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 como retornar tuplas é o método mais rápido, mas o que é passado em como um argumento, também afeta o desempenho. A diferença no código é sutil, mas a melhoria de desempenho é significativa.

O teste nº 4 (aprovação em um único valor) é duas vezes mais rápido que o teste nº 3 (aprovação em série), embora a operação realizada seja aparentemente idêntica.

Mas tem mais ...

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

Em alguns casos (# 1a e # 4a), aplicar a função a um DataFrame no qual as colunas de saída já existem é mais rápido do que criá-las a partir da função.

Aqui está o código para executar os testes:

# 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
fonte
Obrigado por detalhar as características de desempenho também!
PaulMest
2

Acredito que a versão 1.1 quebra o comportamento sugerido na resposta principal aqui.

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)

O código acima executado no pandas 1.1.0 retorna:

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

Enquanto no pandas 1.0.5 ele retornou:

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

O que eu acho que você esperaria.

Não tenho certeza de como as notas de lançamento explicam esse comportamento, no entanto, conforme explicado aqui, evitar a mutação das linhas originais copiando-as ressuscita o comportamento antigo. ie:

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
muu
fonte
Acho que seu exemplo de código pode ter um erro de copiar / colar. Você poderia verificar e ver se era isso que pretendia enviar?
PaulMest
1
Obrigado @PaulMest você estava certo. Corrigi os dois erros de digitação e adicionei um novo link / referência onde a pergunta é respondida.
moo
1
Bem-vindo ao Stack Overflow! @moo
PaulMest
1

Geralmente, para retornar vários valores, isso é o que eu faço

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)

Retornar um dataframe definitivamente tem suas vantagens, mas às vezes não é obrigatório. Você pode ver o que apply()retorna e brincar um pouco com as funções;)

FooBar
fonte
Obrigado por esta amostra. No entanto, isso não produz um único dataframe para todos os resultados. Quando tento adicioná-lo de volta ao dataframe original, recebo "ValueError: o array não pode ser transmitido para a forma correta".
PaulMest de
Você pode fornecer código para produzir uma pequena amostra de dados?
FooBar de
Coisa certa. Acabei de atualizar o código em minha postagem original para incluir dados de amostra e saída.
PaulMest
0

Ele fornece um novo dataframe com duas colunas do 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
fonte