Otimização de Arquivos Grandes com Pandas

Eu estava lendo algumas notícias no TabNews e achei bem interessante esse post sobre otimização de arquivos com Python: Como otimizei um Arquivo de 1.6 GB para 9 MB usando Python.

Eu entendi a ideia, mas eu queria tentar simular a ideia aplicada pelo autor e verificar a solução proposta por ele.

Todo o código de teste está disponível nesse Notebook.

Construção dos Dataset (CSV)

Para construir o dataset primeiro separei as variáveis comentadas pelo autor. De acordo com ele um trecho dele:

"Após a criação da coluna com as horas, procedi com a agregação da quantidade de passagens por data, hora, tipo de veículo e sentido:"

Desse trecho eu sei que o que ele tinha antes eram dados temporais (coluna com data e hora), tipo de veículo, sentido e quantidade de passagens. Nesse caso, basta eu criar um exemplo tenha todas essas variáveis.

Antes de tudo eu criei algumas variáveis que definem os dados, como tipos de veículo (vehicle_types), direções (directions), arquivos de entrada e saída e quandidade de dados (n).

big_csv_file_name = 'big_out.csv'
small_csv_file_name = 'small_out.csv'
vehicle_types = ['Carro', 'Moto', 'Caminhão', 'Ônibus']
directions = ['Norte', 'Sul', 'Leste', 'Oeste']
n = 22000000

Os códigos abaixo são apenas para construção da base. Nesse caso foi considerado um ano entre o começo e o fim e valores aleatórios de quantidade, entre 1 e 30.

from datetime import datetime, timedelta
import pandas as pd
import numpy as np

start_date = datetime(2023, 1, 1)
end_date = datetime(2023, 12, 31)
date_range = pd.date_range(start_date, end_date, freq='T')

dates = np.random.choice(date_range, size=n)
vehicles = np.random.choice(vehicle_types, size=n)
directions_data = np.random.choice(directions, size=n)
quantity = np.random.randint(1, 30, size=n)

df = pd.DataFrame({
  'datetime': dates,
  'vehicle_type': vehicles,
  'direction': directions_data,
  'quantity': quantity
})

Ao fim da criação do dataframe é necessário transformar em csv para simular o arquivo de input de dados.

df.to_csv(big_csv_file_name, index=False)

Nessa parte eu só vejo o tamanho do arquivo de entrada antes da agragação.

import os

file_size_big = os.path.getsize(big_csv_file_name)

print(f'O arquivo {big_csv_file_name} tem um tamanho de {file_size_big / (1024 * 1024):.2f} MB.')

Aplicação do Agrupamento

Uma vez tenho criado o arquivo de entrada, agora é necessário ler o arquivo de entrada.

import pandas as pd

csv_df = pd.read_csv(big_csv_file_name)

Analisando as informações do dataframe eu notei que da coluna datetime estava como object.

csv_df.info()

<class 'pandas.core.frame.DataFrame'>
RangeIndex: 22000000 entries, 0 to 21999999
Data columns (total 4 columns):
 #   Column        Dtype 
---  ------        ----- 
 0   datetime      object
 1   vehicle_type  object
 2   direction     object
 3   quantity      int64 
dtypes: int64(1), object(3)
memory usage: 671.4+ MB

Isso não é bom de trabalhar, então transformei ela em datetime. Alterei os outros tipos também.

csv_df['datetime'] = pd.to_datetime(csv_df['datetime'])
csv_df['vehicle_type'] = csv_df['vehicle_type'].astype('category')
csv_df['direction'] = csv_df['direction'].astype('category')

Tendo transformado a coluna datetime em datetime, então fica fácil ter a data e a hora.

csv_df['hour'] = csv_df['datetime'].dt.hour
csv_df['date'] = csv_df['datetime'].dt.date

Depois é só agregar. Eu tentei agragar de acordo com o que o autor colocou.

aggregated_df = csv_df.groupby(['date', 'hour', 'vehicle_type', 'direction', 'quantity']).size().reset_index(name='count')

Depois de agragado eu transformei para csv para ter o arquivo de saída reduzido.

aggregated_df.to_csv(small_csv_file_name, index=False)

Aqui eu só faço a mesma coisa que fiz no arquivo de entrada, vendo o tamanho dele.

import os

file_size_small = os.path.getsize(small_csv_file_name)

print(f'O arquivo {big_csv_file_name} tem um tamanho de {file_size_small / (1024 * 1024):.2f} MB.')

Comparando os resultados, dá para notar que houve uma redução significativa. Para 22.000.000 de dados, eu tive uma redução de 84% do arquivo original. Acredito que a media que a base aumenta de tamanho esse resultado deve diminuir, mas já ajuda.

percentage = (file_size_small / file_size_big) * 100
print(f'O arquivo {small_csv_file_name} é {percentage:.2f}% do tamanho do arquivo {big_csv_file_name}.')

Conclusão

Deu para ver que a recomendação do agrupamento foi muito útil e reduziu para um valor bem melhor. Legal que essa estratégia parece ser muito interessante de aplicar para séries temporais, uma vez que eles, geralmente, são dados com muitos ruídos. Outro ponto é que talvez os meus valores não tenha abaixado mais o tamanho do arquivo final devido ao meu range de quantidade de 1 até 30. Isso piora o agrupamento, mas para valores menores de quantidades isso diminui ainda mais o arquivo final.

Existem algumas alternativas para otimizar a quantidade de espaço utilizado para o armazenamento de dados (sem perder a estrutura original). Uma delas é trabalhar com outros formatos de arquivo. O formato CSV que você usa nesse post é popular por ser simples de entender e fácil de um humano visualizar usando um bloco de notas, por exemplo, então é muito utilizado em tutoriais e aulas introdutórias. Porém, para quantidades maiores de dados, e em ambientes profissionais - onde os dados serão armazenados para serem lidos com frequência depois - é mais comum utilizarmos arquivos que utilizam armazenamento do tipo column-wise ao invés de row-wise.

Por exemplo, o formato .parquet è um formato moderno muito otimizado, column-wise, e utilizado com frequência em ambientes de big data (mas pode ser utilizado em ambientes com bases pequenas ou médias também). Ao salvar um arquivo desse tipo, é aplicado um algoritmo de compressão sem perdas nos dados, muito eficiente (é muito maneiro, sugiro procurar depois como funciona). Sugiro testar fazer um benchmark da quantidade de memória utilizada com os dados originais (sem o agrupamento).

No pandas, pra trabalhar com parquets ao invés de csv, é só mudar a chamada da função to_csv() para to_parquet(), então é tão fácil quanto.

Compare a velocidade de leitura deles também! Usando read_parquet() ao invés de read_csv(). como é column-wise, a leitura do arquivo via código é impressionantemente rápida.

A única desvantagem desse tipo de arquivo para casos de análise/ciência de dados é que, como ele é um binário, você não consegue abri-lo com o Bloco de Notas para dar uma olhada em seu conteúdo. Mas como você já está usando o Pandas, é só dar um .head() que essa desvantagem é completamente mitigada. Além disso, abrir um arquivo de 1.6GB no bloco de notas demora anos, né. Melhor usar o head() mesmo.

Nossa! Depois eu vou testar um caso usando parquet, mas eu vou criar um ambiente separado para ver como fica a leitura e a escrita desses tipos de arquivo.
Acreito que a utilização do CSV no arquivo de saida é para fazer a entrega para o cliente, confirme comentado no post inicial! Ja o csv do arquivo de entrada é que foi extraido do DB como CSV. Mas concordo totalmente se a ideia é salvar em parquet quando for reutilizar

Muito bom o teste, parabens! No meu artigo acabei esquecendo de colocar os blocos de código, mas a sua estrutura ficou bem parecida com a minha e curiosamente também utilizei dados de tráfego.

Tentei deixar o mais parecido possível com o seu caso para poder simular. Admito que levei um tempo para construir os dados. Eu simulei com um conjunto menor de dados e já obtive resultados muito bons.

Um exemplo que ficou bem didático, para casos de agregação e contagem realmente não precisa todas linhas. Porém quando a linha em si é relevante inidividualmente,realmente pensar em formato parquet.