Otimizando o armazenamento da memória de um Dataframe Pandas

Milton Gama Neto
6 min readSep 5, 2019
Urso panda em uma árvore
Photo by Bruce Hong on Unsplash

Pandas é uma biblioteca para manipulação e análise de dados para linguagem de programação Python. É uma das bibliotecas mais utilizadas pelos cientistas de dados (Pandas é realmente incrível!).

Essa biblioteca não foi feita para trabalhar com Big Data. Porém com algumas técnicas que vou descrever aqui, é possível otimizar o armazenamento, carregar arquivos maiores do que são suportados quando utilizado de maneira convencional. Vale ressaltar que não é uma adaptação para processar Big Data, visto que, para o tamanho do arquivos se encaixa nesse termo é necessário que seja da grandeza de centenas de gigabytes ou mais. O que vamos fazer, é possibilitar o processamento de bases com centenas de megabytes ou poucos gigabytes em Pandas, onde muitas vezes não é possível sequer carregar dados dessa magnitude.

As duas principais técnicas descritas nesse artigo são: ler os dados em partes e ajustar os tipos dos atributos. No final, também são incluídas algumas dicas para melhorar o armazenamento, como limpeza de memória e seleção dos dados importantes.

Os códigos apresentados aqui são genéricos, sempre que possível, não se limitando a uma base de dados. O código implementado está no repositório no github (https://github.com/miltongneto/Otimizacao-Dataframe-Pandas), a execução foi feita com uma base de 3GB.

Imagem de congerdesign por Pixabay

Leitura dos dados

O comando padrão da biblioteca Pandas para ler uma arquivo .csv é o read_csv, onde o único parâmetro obrigatório é o caminho do arquivo. Existe uma quantidade enorme de parâmetros que podem ser utilizados de acordo com a necessidade e são bem detalhados na documentação. Passando apenas o caminho do arquivo para uma base relativamente grande, vamos nos deparar com esta mensagem: MemoryError!

Estranho esse erro pelo fato do arquivo ser menor que a memória do computador? Sim! Adiante vamos entender melhor isso, mas no momento, nos preocuparemos em carregar os dados.

Para isso, utilizaremos o parâmetro chunksizes. Com ele, ao invés de lermos a base completa e carregar na memória, será realizada uma leitura em partes/blocos (batches). O parâmetro serve para definir o tamanho de cada bloco. O retorno do read_csv é diferente com este parâmetro, agora temos um gerador de Dataframes, onde cada um corresponde a uma parte dos dados do arquivo original. Armazenaremos as partes em uma lista, no final das iterações, criaremos um único Dataframe.

chunks = []
for chunk in pd.read_csv(filename,low_memory=False,chunksize=1000):
chunks.append(chunk)
data = pd.concat(chunks)

Com essa abordagem, conseguimos carregar os dados. A verificação da memória utilizada para armazenar o Dataframe é realizada com o código abaixo. O resultado desta operação vai mostrar que o espaço alocado é bem maior do que o tamanho original do arquivo.

data.info(memory_usage='deep')

Quando realizamos a leitura do arquivo, é realizada uma inferência de tipos para cada coluna, onde os valores são “supervalorizados”. Isto é, um espaço muito grande é alocado na memória, mas os dados reais não utilizam isso tudo. Por exemplo, os inteiros de 64 bits (int64) muitas vezes são desnecessários comparado a grandeza real dos dados, onde int32, int16 ou até mesmo int8 já resolveriam o problema e alocariam um espaço bem menor para isso. O mesmo acontece para os ponto flutuantes (float).

“Ok, mas eu já consegui carregar os dados, por que devo me preocupar com isso?”

Carregar os dados não significa que conseguirá manipulá-los. Além das operações ficarem mais lentas, é possível que elas não funcionem e apresentem erro de memória novamente.

Imagem de falconp4 por Pixabay

Ajustando os tipos dos atributos

É necessário realizar uma redução do tamanho da base na memória para conseguir manipular os dados.

Caso você não conheça os limites dos atributos e também não quer perder tempo analisando isso, existe uma maneira muito simples de ajustar o tipo dos dados para otimizar o armazenamento e tornar as operações mais eficientes.

ints = data.select_dtypes(include=['int64','int32','int16']).columns
data[ints] = data[ints].apply(pd.to_numeric, downcast='integer')

floats = data.select_dtypes(include=['float']).columns
data[floats] = data[floats].apply(pd.to_numeric, downcast='float')

data.info(memory_usage='deep')

Em resumo, na hora de realizar a inferência dos tipos, quando Pandas se depara com uma String, ele atribui para aquela coluna o tipo Object. Caso esteja manipulando atributos categóricos com dados nominais, é comum que haja uma repetição dos valores. Pois é de se esperar que a quantidade de possibilidades de uma variável seja menor que as instâncias da base de dados.

O tipo Object é o que requer mais memória. Vamos realizar uma transformação deste tipo para Category. Com isso teremos uma redução muito grande dos recursos consumidos.

O tipo Category não lida com as instâncias de forma independente, em vez disso, ele utiliza os valores únicos e faz cada ocorrência deste na base apontar para o mesmo endereço. Com isto, é possível aproveitar melhor a memória.

objects = data.select_dtypes('object').columns
data[objects] = data[objects].apply(lambda x: x.astype('category'))
data.info(memory_usage='deep')

Essa redução acontece pelo modo que as categorias são armazenadas na memória. Através de uma estrutura do tipo chave-valor, onde cada categoria presente na coluna é transformada em um código e o seu valor textual é armazenado. Depois, as instâncias textuais (linhas da coluna) são transformadas em códigos (números inteiros) sem perder informação e usando bem menos espaço, pois campos textuais costumam necessitar de mais memória e a quantidade de repetição pode ser grande.

A otimização apresentada foi aplicada após o carregamento dos dados. Entretanto, as vezes não é possível realizar muitas operações devido ao espaço excedente, como foi mencionado anteriormente. Uma maneira de contornar isto é determinando os tipos das colunas para uma amostra, e depois ler a base completa.

sample = pd.read_csv(filename, low_memory=False, nrows=1000)types = sample.dtypes
types = types.apply(str)
dict_types = types.to_dict()data = pd.read_csv(filename, low_memory=False, dtype=dict_types)

Combinando as técnicas

É possível que não seja possível ler a base mesmo com os tipos ajustados, uma alternativa é combinar as técnicas mencionadas.

sample = pd.read_csv(filename, low_memory=False, nrows=1000)types = sample.dtypes
types = types.apply(str)
dict_types = types.to_dict()chunks = []
for chunk in pd.read_csv(filename, low_memory=False, dtype=dict_types, chunksize=1000):
chunks.append(chunk)
data = pd.concat(chunks)

Outras abordagens úteis para reduzir o consumo desnecessário da memória

Além das técnicas descritas, existem outras abordagens que podem ser úteis para manipular um conjunto de dados de larga escala que apresenta dificuldade para ser carregado na memória. Entre elas, temos:

Filtrar as colunas que serão utilizadas

Um parâmetro bem interessante do método read_csv é o usecols. Como o próprio nome sugere, o objetivo é indicar quais são as colunas desejadas e o dataframe gerado trará apenas estas.

Muitas vezes os dados não são conhecidos até usar o famoso comando read_csv. Entretanto com problema de memória pode não ser possível visualizar as informações presentes no arquivo. Realizar a leitura em duas etapas pode ser uma solução e reduzir significativamente o uso da memória. A primeira é realizada apenas para obter algumas linhas e o cabeçalho, através dos parâmetros nrows e header, respectivamente. Na segunda, será filtrada apenas as colunas desejadas, e o parâmetro nrows pode ser ignorado.

Apagar dataframes que não tem mais utilidade e forçar o Garbage Collector

É comum criar dataframes para diferentes propósitos ao longo da implementação, onde cada um guarda um tipo de informação ou até mesmo dados transformados. Também é comum que esses dados não sejam mais utilizados mais no código. Esses dados não serão excluídos automaticamente enquanto está sendo executado um notebook e pode ficar consumindo memória em momentos indesejados durante a execução do script.

Por isso é importante deletar os dados explicitamente através do comando nativo del do python e depois forçando o gabage collector (gc) com a função collect.

import gcdef data
gc.collect()

Consideração sobre os experimentos realizados

Os experimentos disponibilizados no github podem ser difíceis de replicar, pois as configurações de hardwares da máquina podem interferir no resultado, além de outros fatores, por exemplo, o como gerenciamento de memória do computador e o consumo naquele determinado momento. Entretanto, o código pode ser aplicado em outras bases.

Conclusão

Pandas não foi construído para Big Data, mas explorar essa lib a fundo pode trazer grandes benefícios e versatilidade para os projetos, e evitar a troca de tecnologia impedimentos mais simples, afinal, essa biblioteca é muito boa! A documentação é bem clara e de fácil entendimento, além de ter uma quantidade imensa de funções e parâmetros extremamente úteis. Explorar estes detalhes é um ótimo caminho para resolver diversos desafios. As abordagens descritas não tornam Pandas escalável e propício para Big Data, mas é uma boa alternativa para conseguir ler alguns gigabytes.

--

--

Milton Gama Neto

Cientista de Dados na Raízen e Mestre em Ciência da Computação na Universidade Federal de Pernambuco