Dores de cabeça para fazer casting de colunas no Pandas? Não mais.

Um artigo expondo brevemente o problema da auto-alocação de tipos no Pandas e a solução para isso, em forma de biblioteca.

Evandro Vieira
Data Hackers
12 min readNov 27, 2023

--

Não seja um ‘bebel’ das ideias, faça sua dieta de dados!

Um dos desafios frequentes na vida dos surfistas de planilhas — sejam amadores, profissionais ou aspirantes — é o gerenciamento do consumo de memória durante as operações que abrangem todo o ciclo do ETL (Extração, Transformação e Carregamento). Dataframes seguem uma regra proporcional, assim como tudo na vida: quanto maiores eles são, mais memória demandam da máquina. É lógico que existem diversas boas práticas voltadas para dados que, claro, mitigam a frustração de ver um dataframe sendo processado pela IDE durante meia hora sem que possamos fazer algo a respeito. O Pandas mesmo, que é a biblioteca de manipulação de dados mais usada atualmente, possui seu conjunto de boas práticas.

Aqui mesmo você pode ler um artigo, em inglês, sobre algumas delas! Espero que a sua prática no idioma esteja em dia 😬

Uma dessas práticas envolve a alocação eficiente de memória, considerando os tipos de dados presentes em cada coluna. No contexto do Python, assim como em todas as linguagens de programação, há uma variedade de tipos de dados, e isso não é diferente nas bibliotecas que utilizamos para executar tarefas em Python. O Pandas, por exemplo, possui tipos numéricos e não-numéricos, os quais são úteis para distinguir informações categóricas de numéricas — tipos esses que possuem atribuição padrão.

E o que seria essa ‘atribuição padrão’? Notas sobre alocação de memória, bits e Ibuprofeno.

Quando criamos um DataFrame Pandas, ele automaticamente infere os tipos de dados presentes. Simples assim. Para números inteiros, ele atribui o tipo ‘int64’. Para números de ponto flutuante, automaticamente temos ‘float64’ atribuído. Para outros tipos, temos a atribuição do tipo ‘object’, que nada mais é do que uma designação de um tipo genérico, já que o interpretador não conseguiu realizar uma atribuição automática adequada.

Até aí tudo bem. O Pandas fez a atribuição dos tipos e podemos todos trabalhar nos nossos dados sem problema algum. Errr.. not quite. As coisas não são tão simples assim.

Caso não tenha passado por algum curso superior de computação, vou explicar onde está o problema. Caso tenha passado, vamos revisar juntos: O ‘64’ no final do int e float significa que foram alocados 64 bits para todos os dados da coluna, linha por linha; Se não sabe o que é um bit, não tem problema:

O bit (Binary unIT), a grosso modo, é a unidade mais básica de informação que temos disponível na computação atualmente. Literalmente, só 0 e 1. Ou seja, 1 Bit = 2 valores.

2 bits = 4 valores (já que 2²= 4).

3 bits = 2³ valores, e assim por diante, sempre em potências de 2.

E 64 bits? 2⁶⁴ valores. 18.446.744.073.709.551.616 valores únicos, pra ser exato. E isso é alocado para cada linha de cada coluna que tenha o tipo ‘int64’ atribuído. Para as linhas que possuem atribuição float64, temos algo em torno de 2⁵³ valores únicos — há uma explicação, mas o importante é que você saiba o tamanho da alocação.

Isso gera um problemão com relação ao consumo de memória. Se você tem um dataframe com muitas colunas (dezenas delas) tendo um tipo de dado que abrigue essa quantidade colossal de números como a citada acima, o interpretador precisa alocar um espaço titânico para cada um desses números em memória. E não é só para uma linha, é para todas elas. Para todas as colunas com aquele tipo. Se pensarmos na quantidade absurda de memória desperdiçada por má alocação, com certeza você iria precisar de um Ibuprofeno depois.

Fora que não mencionei o problema do uso indiscriminado do tipo ‘object’. Para começar, ele não possui um número fixo de bits sendo alocados para cada coluna — trabalha de forma dinâmica, utilizando algo chamado ponteiro para realizar essa alocação de memória. 🌈MARAVILHOSO, NÃO?🥳 …Pero no mucho. Isso só é ótimo quando o dado é uniforme (não possui ou quase não possui caracteres misturados). A porrada vem quando o ‘object’ recebe todo tipo de informação, como letras maiúsculas com minúsculas, traços, caracteres especiais, etc. Dependendo do caso, uma leitura de caractere por caractere precisa ser feita. A versatilidade da alocação dinâmica vem ao custo do desempenho de memória.

Sentiu vontade em saber o que é um ponteiro e o seu funcionamento? Artigo na mão, camarada. E em PT-BR ainda!

Há jeitos de resolver isso? Sim! O mais óbvio seria converter cada coluna para o tipo mais adequado de dado, na esperança de reduzir a carga de memória utilizada pela RAM da máquina. Porém quase sempre todas as colunas são diferentes entre si, possuem tamanhos diferentes ou são muitas colunas (CENTENAS OU MILHARES) para poder tratar caso a caso.

Sentiu vontade do Ibuprofeno de novo, né? Guarda ele então.

DOR DE CABEÇA PRA CONVERTER TIPOs DE COLUNAS? SEUS PROBLEMAS ACABARAM!

Uma dieta de dados, literalmente

Felizmente, há quem pensou e elaborou uma biblioteca para resolver os nossos problemas: Ian Ozsvald, escritor para a O’Reilly, consultor de ciência de dados e co-criador do PyData London (uma ONG voltada para ciência de dados e análise de dados) mas que, atualmente, está sob a supervisão do Nok Lam Chan, engenheiro de software de Hong Kong atualmente residindo em Londres, Inglaterra.

Não há muitas informações explícitas com relação a quando foi criada — as informações de forks e pushs no GitHub datam de uns 3 anos atrás, tendo a última informação uns dois meses, onde podemos ver que a biblioteca está na versão 0.0.2 (ou seja, é uma biblioteca relativamente nova). Mas veremos o porquê dessa biblioteca ser tão boa.

Ah, o nome dela? Dtype_diet. Prazer!

Getting started!

Antes de tudo, é bom informar: Essa biblioteca só roda do Python 3.7 em diante. Utiliza versão mais antiga? Atualize. Não há suporte para versões mais antigas do nosso Pitão de cada dia. Dito isso, vamos a instalação dela:

pip install dtype_diet

Instalou? Perfeito!

Vou usar um exemplo bem básico de dataframe para poder mostrar o que essa biblioteca faz. Comecemos importando as bibliotecas necessárias (Pandas, Dtype_Diet e Matplotlib) e o dataset em questão — Eu adotarei uma convenção com a biblioteca, a chamando de ‘dtd’

import pandas as pd
import dtype_diet as dtd
O DATAFRAME EM QUESTÃO: 80.000 linhas, 10 colunas e quase 20 segundos pra ler tudo.

Dataframe importado, vamos começar vendo os tipos de dados que as nossas colunas tem:

df.info()
  • São 80 mil linhas servindo 10 colunas.
  • De 10 colunas, temos 7 sendo ‘int64’ e 3 sendo ‘object’, havendo apenas dois tipos no dataset todo.
  • Não há dados nulos nas colunas, o que indica uso máximo de memória pelo dataframe.
  • +6 MB de consumo de memória, que podem escalar a medida que vamos manuseando o notebook e mais dados são gerados.

A partir dessa informação, vou começar os trabalhos com a nossa biblioteca. Começamos chamando uma variável chamada proposta, que servirá para receber o método ‘report_on_dataframe’. Ele possui a seguinte assinatura:

proposta = dtd.report_on_dataframe(df:DataFrame, unit:str='MB', optimize:str='memory')

A assinatura do método contém 3 argumentos, um sendo obrigatório e os outros dois opcionais:

  • df, obrigatório, que recebe um objeto DataFrame;
  • unitsendo tipo ‘str’ — que serve para escolher quais unidades serão usadas para expressar as informações de desempenho obtidas. O default dela é ‘MB’, mas pode ser trocada para ‘GB’;
  • optimizetambém ‘str’ — que serve para apontar o que deverá ser priorizado no relatório para otimização do dataframe. O padrão é ‘memory’.

Passada a explicação, seguimos. Vou apenas passar o argumento obrigatório, já que o default está definido nos outros dois e isso não é importante no momento:

proposta = dtd.report_on_dataframe(df)
dataframe obtido através do método acima

Algumas coisas que podemos observar de cara:

  • Dos dois tipos originais, subimos para 4 tipos propostos que podem ser usados (vou falar mais a frente sobre um deles , o ‘categories’).
  • A queda no consumo de memória é uma coisa assustadora. Nos casos que temos a transição de ‘object’ para ‘categories’, temos uma redução de 98% do uso de memória. 98%, você não leu errado.
  • TODAS as colunas sofrem uma redução drástica de alocação de bits, sendo que em algumas colunas vamos de 64 bits para 8 — uma senhora estratégia de salvamento de memória.

Tendo o relatório em mãos, podemos seguir para a próxima etapa.

Mas espera, eu vou ter que fazer alguma gambiarra envolvendo dicionários, listas, tuplas, métodos de conversão como o ‘astype’ ou coisas assim? Até poderia, mas só se você não fosse muito esperto — e se os desenvolvedores do dtype_diet também não fossem. Pra isso, temos o método ‘otimize_dtypes’. A assinatura do método:

df_otimizado = optimize_dtypes(df, proposta)

Ela é mais simples e contém 2 argumentos, ambos obrigatórios:

  • df, que recebe o dataframe original
  • proposed_df, que recebe como argumento o método ‘report_on_dataframe’

A sintaxe de tudo é bem simples, duas linhas de código e a mágica tá feita:

proposta = dtd.report_on_dataframe(df)
df_otimizado = dtd.optimize_dtypes(df, proposta)

Agora, vamos ver o dataframe otimizado:

E agora?

  • Os tipos das colunas estão mais diversificados.
  • O uso de memória caiu pra pelo menos 1/6 do que era consumido antes.
  • Não houveram bugs envolvendo overflow de dados (vou falar disso mais a frente)

Testes práticos

Após as mudanças serem feitas, vamos fazer alguns testes envolvendo a velocidade de leitura dos dados. Elaborei um código que executa os trechos em um looping for, umas mil vezes, e por fim obtém o tempo médio das execuções de cada código — poderia ser construída uma função, mas para fins de simplicidade manterei o código como está. Posteriormente, gráficos serão gerados para explicar as diferenças de tempo de execução de cada dataframe em alguns cenários.

Importante notar que não estou preocupado com boas práticas visando desempenho irrestrito; estou focando em jogar o pior que eu puder nos dois códigos, dividido em três casos, para entender como eles irão se sair levando em consideração APENAS a otimização de memória.

# Este é o looping for utilizado para realizar o benchmark dos códigos

num_execucoes = 1000
tempos_celula1 = []
tempos_celula2 = []

for _ in range(num_execucoes):
comeco_1 = time.time()
# o código otimizado vai aqui
final_1 = time.time()
execucao_1 = final_1 - comeco_1
tempos_celula1.append(execucao_1)

comeco_2 = time.time()
# o código não otimizado vai aqui
final_2 = time.time()
execucao_2 = final_2 - comeco_2
tempos_celula2.append(execucao_2)


# Aqui é onde passo a gerar o gráfico de linha

labels = ['df com otimização', 'df sem otimização']
tempos_dataframes = [tempos_celula1, tempos_celula2]

plt.figure(figsize=(10, 5))
for i, tempos_dataframe in enumerate(tempos_dataframes, start=1):
media_dataframe = [np.mean(tempos_dataframe[i:i+50]) for i in range(0, num_execucoes, 50)]
plt.plot(range(1, len(media_dataframe) + 1), media_dataframe, marker='o', label=labels[i-1])

plt.xlabel('Intervalo de Execuções (50 execuções)')
plt.ylabel('Tempo de Execução Médio (em segundos)')
plt.title(f'Flutuação nos Tempos de Execução ({num_execucoes} execuções)')
plt.legend()
plt.grid(True)

plt.show()
  • Caso 1: Realizando alguma query usando o método ‘query()’
# Query bem simples, usado de propósito sabendo-se que é um método lento

df.query('ALINHAMENTO != "IRRELEVANTE"
and TURNO == 1
and SITUACAO == 1')
  • Caso 2: Realizando algumas operações de agrupamento e criação de novas colunas com base em colunas já existentes (ufa)
cond = (df['ALINHAMENTO'] != 'IRRELEVANTE') & (df['TURNO'] == 1)

df_espectro = df[cond]
df_espectro = df_espectro.groupby('ALINHAMENTO')['VOTOS']
.sum()
.reset_index()
.sort_values(by='VOTOS', ascending=False)
df_espectro['%'] = ((df_espectro['VOTOS'] / df_espectro['VOTOS'].sum()) * 100)
.round(1)
  • Caso 3: Realizando operações de query com dois tipos de agrupamento — groupby convencional e tabela pivô, depois gerando mais colunas novas a partir de colunas existentes, assim como dataframes em outras variáveis e, por fim, removendo algumas colunas do conjunto de dados principal.
cond = ((df['ALINHAMENTO'] != 'IRRELEVANTE') & (df['TURNO'] == 1))

df_bairro2 = df[cond]
df_bairro2 = df_bairro2.groupby(['BAIRRO', 'ALINHAMENTO'])['VOTOS'] \
.sum() \
.reset_index()
df_bairro2['%_REL'] = (df_bairro2['VOTOS'] / df_bairro2.groupby('BAIRRO')['VOTOS'].transform('sum') * 100).round(1)

df_partidos = df_otimizado[cond]

lista_partidos = df_partidos[['SIGLA', 'ALINHAMENTO']].drop_duplicates()
df_partidos_1 = df_partidos.groupby('SIGLA')['VOTOS'] \
.sum() \
.reset_index() \
.sort_values(by='VOTOS', ascending=False)
df_partidos_1 = df_partidos_1.merge(lista_partidos[['SIGLA', 'ALINHAMENTO']], on='SIGLA', how='left')

topo = df_partidos_1.head(15)
fundo = df_partidos_1.tail(15)

df_partidos_2 = pd.pivot_table(df_partidos,
values='VOTOS',
index='BAIRRO',
columns='ALINHAMENTO',
aggfunc='sum',
fill_value=0).reset_index()

df_auxiliar = df_bairro2.groupby('BAIRRO')['VOTOS'].sum().to_frame().reset_index()
df_partidos_2 = df_partidos_2.merge(df_auxiliar, on='BAIRRO', how='left')

df_partidos_2['%_CEN'] = (df_partidos_2['CENTRO'] / df_partidos_2['VOTOS'] * 100).round(1)
df_partidos_2['%_DIR'] = (df_partidos_2['DIREITA'] / df_partidos_2['VOTOS'] * 100).round(1)
df_partidos_2['%_ESQ'] = (df_partidos_2['ESQUERDA'] / df_partidos_2['VOTOS'] * 100).round(1)

colunas = ['VOTOS','CENTRO', 'DIREITA', 'ESQUERDA']
df_partidos_2.drop(columns=colunas, inplace=True)

O que os gráficos nos mostram?

Podemos observar, através dos três casos e dos seus respectivos gráficos, que o algoritmo com os tipos otimizados se saiu muito melhor do que o algoritmo não-otimizado, surpreendendo no caso 2, onde o algoritmo otimizado obteve 1/3 do tempo de execução do algoritmo não otimizado. Mesmo nos picos em cada caso, o algoritmo otimizado manteve uma constância muito maior em resultados, devido ao uso mais coerente de memória.

É interessante notar que, em nenhum momento, houve uma preocupação em utilizar boas práticas para testar os algoritmos, fazendo com que encarassem situações caóticas de propósito, para que o máximo de memória fosse utilizado nessas situações. E, ainda sim, o algoritmo otimizado se saiu bem em todos os cenários.

Operações de agrupamento, por exemplo, se tornam bem menos custosas em termos de memória, o que nos leva a entender que essa biblioteca veio para agregar e muito no dia-a-dia do profissional ou entusiasta de dados. Porém, nada é perfeito.

Sobre as ‘categories’

Falei ali em cima que o dtd substitui o tipo ‘object’ por ‘category’, mas não expliquei o que são as ‘categories’. Aqui vai:

Categories é um tipo de dado usado para armazenar dados categóricos, que representam valores de categorias, e não números ou letras em si, etc. A diferença fundamental entre ‘object’ e ‘category’ é a forma como os dados são armazenados.

Enquanto ‘object’ armazena cada elemento como um objeto individual, ‘category’ armazena os dados com base em índices categóricos. Acaba sendo uma mão na roda por conta de alguns fatores, como economia de memória, já que os valores são representados por índices inteiros, evitando repetição de alocação de memória, elevando a performance do algoritmo por conta das buscas otimizadas.

As categories se dão muito bem quando trabalhamos com um conjunto fixo e limitado de categorias, e é por isso também que ela possui algumas limitações, que iremos tratar adiante.

Drawbacks da biblioteca

Por não ser uma biblioteca em constante atualização e desenvolvimento, acabou que algumas coisas ainda estão pelo caminho. Traduzindo diretamente da página onde está a documentação dela, temos algumas coisas:

  • Há o risco de overflow quando usamos essa biblioteca em certos cenários. Como a otimização se dá através de casting ‘para baixo’, algumas operações podem ficar comprometidas (como, por exemplo, agrupamentos e somas). O overflow acontece quando realizamos uma operação e o resultado numérico dela excede a capacidade máxima de bits que ela suporta, gerando alguns problemas bem bizarros como output.
  • No caso do float, a otimização sofre problemas envolvendo precisão dos cálculos e possível perda de informação quando o tipo é convertido para ‘float16’. Como a arquitetura dos computadores atuais é feita para ‘float32’ ou ‘float64’, o ‘float16’ é algo um tanto quanto contraintuitivo — pesquisas e análises que demandem essa ‘sensibilidade’ extrema na flutuação dos dados seriam afetadas.
  • Não há uma documentação extensa e o projeto se encontra parado por um bom tempo, contando apenas com atualizações esporádicas. Talvez o projeto não seja algo prioritário para os seus supervisores, o que dá oportunidades boas para quem quiser colaborar; a documentação da biblioteca se encontra aqui, assim como o contato dos colaboradores da biblioteca.

Recomendações importantes

Tendo em vista o que foi dito acima, darei algumas recomendações de uso para a biblioteca:

  • Entenda bem o objetivo da sua análise: Tendo isto em mente, você saberá quando e onde aplicar os métodos da biblioteca em questão, evitando conversões desnecessárias e dor de cabeça para voltar ao ponto onde tudo deu errado.
  • Na dúvida, duplique: Ainda sim, se for usar e acabar se deparando com a necessidade de realizar operações no dataframe, opere com uma cópia. Isso reduz o risco de você fazer conversões indevidas que resultem em um overflow, por exemplo.
  • Dê às Categories o que é das Categories: Não invente moda; elas são usadas apenas para reduzir memória em usos onde as variáveis categóricas do seu dataframe são fixas (não irão mudar do começo ao fim) e são limitadas (não há um número certo onde a coisa começa a bagunçar). O seguro morreu de velho, então não abuse!

Com isso, finalizo por aqui. Essa biblioteca tem um potencial imenso de crescimento, e espero de verdade que mais pessoas possam passar a usar e até contribuir com o projeto.

Achou que eu não falaria das minhas redes sociais? Achou errado! Gostou do meu artigo? Tem críticas, sugestões de melhoria ou tem algo a contribuir com o artigo? Me dá um toque, oras!

Meu LinkedIn está aberto para mensagens 24 horas. Pretendo também inserir os códigos que utilizei no artigo no meu GitHub, para que todos possam fazer os testes nas suas máquinas. De resto, foi um prazer escrever esse artigo e até mais!

--

--