Estratégias para otimizar o processamento de dados no Pandas

Laís Van Vossen
Senior Sistemas
Published in
6 min readJun 14, 2024
Gerado pelo Gemini com o prompt: “make a cute image of a cartoon design 2D panda running a marathon, make it colorful and for children”

Introdução

Você já se deparou com uma base de dados que não é nem grande demais para que se justifique aplicar técnicas de Big Data, nem pequena demais que possa ser carregada na RAM do seu notebook e processada normalmente?

A situação fica ainda pior se essa base de dados precisa ser processada de forma rápida em um fluxo contínuo para fornecer dados para usuários. Dessa forma, este texto apresenta algumas maneiras de acelerar esse processo, juntamente com técnicas de leitura da base de dados que permitem que ela seja processada diretamente do seu notebook e sem sair do Pandas.

O tamanho da base importa

É importante ter uma noção de quantos GB a sua base de dados possui antes de definir as ferramentas que serão utilizadas para tratá-la. Bases muito extensas podem requerer bibliotecas e uma estrutura mais robusta para processar, o que pode fazer com que as técnicas apresentadas aqui sejam insuficientes. Então, se sua base de dados tiver mais de 50 GB, é recomendável que você teste outras bibliotecas mais voltadas para lidar com Big Data, como o PySpark ou Dask.

No entanto, se o seu projeto já utiliza Pandas para processar os dados, então continue a leitura que vamos te apresentar algumas técnicas para otimizar esse tempo de processamento.

Para os testes que vamos fazer, utilizamos uma base de dados de 12 GB, com cerca de 23 milhões de linhas e 25 colunas.

Técnicas de leitura de bases de dados com Pandas

Embora o Pandas tenha diversas formas de ler uma base de dados, neste guia focaremos em três métodos: a leitura de arquivo CSV, Parquet e CSV com Chuncksize. No entanto, neste link, há uma lista completa de comparações entre o desempenho de todos os métodos de leitura e escrita de dados que o Pandas possui.

Os testes realizados na base “mk_intelligence_23_milhoes” estão apresentados abaixo, com a média e desvio padrão do tempo despendido para a execução de cada função apresentada como um comentário.

import pandas as pd

%timeit pd.read_csv('mk_intelligence_23_milhoes.csv')
# Tempo: 5min 46s ± 18 s per loop (mean ± std. dev. of 7 runs, 1 loop each)
%timeit pd.read_parquet('mk_intelligence_23_milhoes.parquet')
# Tempo: 1min 57s ± 7.43 s per loop (mean ± std. dev. of 7 runs, 1 loop each)

Aqui já observamos uma redução de quase 3x do tempo de leitura com a mudança do método de read_csv para read_parquet, mas podemos fazer melhor. No código abaixo utilizamos a função read_parquet aliada com a biblioteca FastParquet, que acelera a leitura da base, para reduzir mais 4x o tempo de processamento. No link estão informações de como essa biblioteca pode ser instalada.

%timeit pd.read_parquet('mk_intelligence_23_milhoes.parquet',
engine='fastparquet')
# Tempo: 28.1 s ± 5.74 s per loop (mean ± std. dev. of 7 runs, 1 loop each)

Por fim, há um método que permite carregar essa base de forma praticamente instantânea, a leitura com o read_csv aliado com o uso de chuncksize. Esse parâmetro da função permite que o Pandas leia o número de linhas passadas por chunck de dado, no entanto, o retorno do método passa a ser um objeto do tipo TextFileReader ao invés de um DataFrame.

Neste caso, então, a função read_csv retorna um TextFileReader, que é um objetivo iterável, com aproximadamente 230 chuncks (23 milhões de linhas dividas em chuncks de 100.000 linhas), que podem ser lidos e processados individualmente como um DataFrame convencional. Então, auando usamos o parâmetro chunksize, na função read_csv, o pandas lê o arquivo em pedaços menores, de tamanho especificado, permitindo processar cada pedaço individualmente.

%timeit pd.read_csv('mk_intelligence_23_milhoes.csv', chunksize=100000)
# Tempo: 389 µs ± 20.6 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)

A diferença do tempo de processamento para a leitura da base foi comparada no gráfico abaixo. A leitura da base com read_csv aliada com o Chuncksize foi a que obteve o melhor desempenho, no entanto, caso seja necessário carregar e lidar com a base inteira de uma vez, a opção do read_parquet com o fastparquet também obteve um bom desempenho.

Comparação do tempo de processamento para ler a base de dados.

Processamento de dados com Pandas

Outro problema ao lidar com bases de dados extensas é o tempo de processamento para aplicar funções sobre ela. É muito comum precisar criar novas colunas, calcular diferenças entre valores ou relacionar variáveis em uma base para extrair valor de seus dados. Esse tipo de processamento, se feito da forma errada, pode demorar mais tempo que o necessário, podendo levar a aumento de custos e até impedir que os dados sejam pré-processados em tempo real para apresentar ao usuário.

Por isso, exploraremos três técnicas para processar dados, em uma função simples, mas bastante útil: calcular a diferença de tempo entre duas datas. Suponhamos que precisamos saber quantos dias se passaram desde o início de uma operação, guardado na variável "dt_operation_start" até um determinado dia, neste exemplo até dia 01/06/2024, e vamos calcular essa diferença de tempo de três formas: por uma iteração for, pelo método apply, e por vetorização.

No primeiro método, definimos a função que calcula o tempo passado, e aplicamos ela a cada linha do dataset, iterando sobre elas por meio de um for. Com isso, o tempo médio para finalizar essa operação leva 24 minutos e 5 segundos.

def calcula_tempo_passado(row):
data_hoje = datetime.datetime(2024, 6, 1)
tempo = (row['dt_operation_start'] - data_hoje).days
return abs(tempo)

%%timeit
for index, row in df.iterrows():
df.loc[index, 'dias_passados'] = calcula_tempo_passado(row)

# Tempo: 24min 5s ± 14.4 s per loop (mean ± std. dev. of 7 runs, 1 loop each)

O segundo método é através da função apply. Esta função é muito poderosa e permite a aplicação de operações complexas em qualquer um dos eixos da base de dados. A aplicação da fução apply pura, conforme feito no exemplo abaixo, levou em média 4 minutos e 41 segundos para calcular a quantidade de dias passados entre as duas datas. No entanto, há ainda a possibilidade de mudar a engine que será usada para fazer esse processamento, passando como parâmetro da função apply "engine=numba", "engine=nopython", "engine=nogil" ou "engine=parallel", todos esses métodos tem por objetivo otimizar o processamento de dados pela função, conforme descrito na documentação.

def calcula_tempo_passado(row):
data_hoje = datetime.datetime(2024, 5, 29)
tempo = (row['dt_operation_start'] - data_hoje).days
return abs(tempo)

%%timeit
df['dias_passados'] = df.apply(calcula_tempo_passado, axis=1)

# Tempo: 4min 41s ± 14.3 s per loop (mean ± std. dev. of 7 runs, 1 loop each)

Por fim, vamos experimentar a mesma operação de forma vetorizada, realizando a operação de diferença entre os dias na base de dados inteira, e dividindo por "np.timedelta64(1, ‘D’)" para obter o número de dias que se passaram. Desta forma, conseguimos processar a base inteira em uma média de 286 milisegundos.

data_hoje = datetime.datetime(2024, 5, 29)

%%timeit
df['dias_passados'] = (df['dt_operation_start'] - data_hoje).abs() / np.timedelta64(1, 'D')

# Tempo: 268 ms ± 19.7 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

O motivo da vetorização ser tão mais rápida que os demais métodos, executando a mesma operação até 7000 vezes mais rápido do que com a função for, é que ela permite a execução de operações em conjuntos de dados inteiros simultaneamente, em vez de processar elemento por elemento sequencialmente. A vetorização traduz operações simples em código de máquina otimizado, reduzindo o número de instruções da CPU necessárias. Além disso, uma disposição de memória mais eficiente diminui a ocorrência de cache misses, e o código de baixo nível pode aproveitar recursos de hardware, como SIMD (Single Instruction, Multiple Data), para processar múltiplos dados em paralelo. Essas otimizações resultam em tempos de execução significativamente menores em comparação com laços tradicionais (for-loops) no Python [1][2]. A diferença entre os tempos de processamento para cada método pode ser visto no gráfico abaixo.

Comparação do tempo de processamento para calcular a diferença de dias entre duas datas.

Conclusão

Com a escolha certa de funções e ferramentas, é possível otimizar o processamento de dados pelo Pandas. Alterando a forma de leitura de read_csv para read_parquet com fastparquet e o processamento de um for loop para vetorização, o código que antes levaria por volta de 30 minutos para finalizar, agora roda em menos de 30 segundos.

Há ainda diversas outras formas de melhorar esse tempo, processando os dados de forma paralela ou utilizando o poder de uma GPU, por exemplo, mas as formas aqui apresentadas são uma alteração simples e rápida no código do dia-a-dia, e fornecem um grande impacto na redução do tempo.

Referências

[1] https://pythonspeed.com/articles/vectorization-python/

[2] https://medium.com/analytics-vidhya/understanding-vectorization-in-numpy-and-pandas-188b6ebc5398

--

--