Como otimizar consumo de memória ao usar Pandas —parte 1

O tipo object, espécie de string em Pandas, prejudica a performance durante uma análise e, às vezes, pode ser evitado

24 out 2019


Quem usa Pandas numa máquina de 8 GB de RAM conhece bem a frustração de perder uma análise porque o computador trava. E não estamos falando em dezenas de gigabytes de dados: basta o dataset ter uns 800 MB para as chances de travamento serem reais.

Isso porque, apesar de o tamanho do arquivo não ser grande, Pandas demanda memória. E aqui cabe explicar um ponto fundamental: o tamanho do dataset não equivale à exigência de RAM. Há quem diga ser necessário o dobro de RAM em relação do tamanho do arquivo.

Entretanto, Wes McKinney, criador de Pandas, num post em seu blog, ressalta o que chama de “regra de ouro” quando se fala de memória:

My rule of thumb for Pandas is that you should have 5 to 10 times as much RAM as the size of your dataset.

Repare que ele não crava um valor, mas sugere um intervalo: memória de cinco a dez vezes o tamanho o dataset.

Para observar isso de maneira mais aprofundada, vamos trabalhar com um exemplo: os dados da cota parlamentar dos deputados federais dos últimos dez anos. O dataset usado aqui é a agregação dos arquivos .csv divulgados pela Câmara dos Deputados, disponíveis para download no portal. Vamos trabalhar esse dataset numa máquina de 8 GB de RAM.

In [1]:
# Checagem de memória na máquina
!free -m
              total        used        free      shared  buff/cache   available
Mem:           7832        2856        2359         196        2616        4497
Swap:          2047           0        2047
In [2]:
import os
import csv

csv_file = 'data.csv'

# Checagem do tamanho com conversão de bytes em megabytes
size = os.path.getsize(csv_file) / 1024**2

# Checagem de linhas e colunas
with open(csv_file) as file:
    reader = csv.reader(file, delimiter=',')
    ncols = len(next(reader))
    nrows = 0
    for _ in reader:
        nrows += 1

# Obtenção do resultado
print(f'Arquivo: {csv_file}\n\
Tamanho: {round(size, 2)} MB\n\
Linhas: {nrows}\n\
Colunas: {ncols}')
Arquivo: data.csv
Tamanho: 754.91 MB
Linhas: 3484985
Colunas: 30

O arquivo é constituído de 3.484.985 linhas e 30 colunas, e tem pouco mais de 750 MB. Mas são necessários quase 3 GB de RAM —cerca de quatro vezes seu tamanho— para ser trabalhado em Pandas —em grande parte, devido às colunas com valores do tipo object: cada uma delas consome, em média, 203.63 MB de memória.

In [1]:
import pandas as pd

# Leitura convencional do arquivo
df = pd.read_csv('data.csv')

# Checagem de informações com uso de memória geral
df.info(memory_usage='deep')
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 3484985 entries, 0 to 3484984
Data columns (total 30 columns):
txNomeParlamentar            object
cpf                          float64
ideCadastro                  float64
nuCarteiraParlamentar        float64
nuLegislatura                int64
sgUF                         object
sgPartido                    object
codLegislatura               int64
numSubCota                   int64
txtDescricao                 object
numEspecificacaoSubCota      int64
txtDescricaoEspecificacao    object
txtFornecedor                object
txtCNPJCPF                   object
txtNumero                    object
indTipoDocumento             int64
datEmissao                   object
vlrDocumento                 float64
vlrGlosa                     float64
vlrLiquido                   float64
numMes                       int64
numAno                       int64
numParcela                   int64
txtPassageiro                object
txtTrecho                    object
numLote                      int64
numRessarcimento             float64
vlrRestituicao               float64
nuDeputadoId                 int64
ideDocumento                 int64
dtypes: float64(8), int64(11), object(11)
memory usage: 2.9 GB
In [2]:
# Checagem de memória consumida por coluna
df.memory_usage(deep=True, index=False)
Out[2]:
txNomeParlamentar            269236856
cpf                           27879880
ideCadastro                   27879880
nuCarteiraParlamentar         27879880
nuLegislatura                 27879880
sgUF                         205474768
sgPartido                    209496629
codLegislatura                27879880
numSubCota                    27879880
txtDescricao                 362762917
numEspecificacaoSubCota       27879880
txtDescricaoEspecificacao    158995761
txtFornecedor                308344599
txtCNPJCPF                   261348406
txtNumero                    234588538
indTipoDocumento              27879880
datEmissao                   256587872
vlrDocumento                  27879880
vlrGlosa                      27879880
vlrLiquido                    27879880
numMes                        27879880
numAno                        27879880
numParcela                    27879880
txtPassageiro                153056118
txtTrecho                    142387243
numLote                       27879880
numRessarcimento              27879880
vlrRestituicao                27879880
nuDeputadoId                  27879880
ideDocumento                  27879880
dtype: int64
In [3]:
# Checagem da média de memória consumida por tipo
for i in list(set(df.dtypes)):
    selected_type = df.select_dtypes(include=[i])
    mean_usage = selected_type.memory_usage(deep=True).mean()
    converted_mean_usage = mean_usage / 1024 ** 2
    print(f'Média de memória consumida por colunas do tipo {i}: {round(converted_mean_usage, 2)} MB')
Média de memória consumida por colunas do tipo float64: 23.63 MB
Média de memória consumida por colunas do tipo object: 203.63 MB
Média de memória consumida por colunas do tipo int64: 24.37 MB

Ou seja, dos cerca de 3 GB de RAM necessários para trabalhar este dataset, mais de 2 GB de RAM são apenas para as 11 colunas contendo valores do tipo object.

Primeiro gargalo: object

O tipo object é uma representação dos valores em string de Python. E string de Python é um problema quando o assunto é memória.

Numa sequência de valores object, cada elemento tem um ponteiro que leva a um buffer contíguo de outros ponteiros, estes últimos levando às posições na memória. Isso significa que object:

  1. exige duplo trabalho de busca de valores,
  2. procura na memória valores distribuídos de maneira fragmentada.

É diferente, por exemplo, de uma array de NumPy que, assim como uma array de C, tem um ponteiro direto para um buffer contíguo de valores.

Para melhor compreensão, imagine que seu sistema é um motoboy de pizzaria.

A pizzaria pode receber pedidos para entregas em casas vizinhas. Ou seja, o motoboy pode pegar as duas pizzas e fazer apenas uma viagem. Isso é uma array de NumPy.

Ou a pizzaria pode receber pedidos para entregas em casas de lados opostos da cidade. O motoboy pega a primeira pizza, faz uma viagem até a zona sul, retorna à pizzaria para pegar a segunda pizza e segue para a zona norte. Isso é object de Pandas.

Nesse exemplo, a gasolina da moto é a memória do seu computador. Mas existe uma maneira de economizar “combustível”: converter object para category.

O tipo category, como definido na documentação de Pandas, tem uma quantidade limitada e fixa de valores possíveis. Por exemplo: sexo biológico (M e F), tipo sanguíneo (A+, B-, AB+, O- etc.), tamanho de roupa (P, M, G, GG etc.)…

Ao converter object para category, nos bastidores você está convertendo string em algo numérico —ou seja, facilitando a busca do valor na memória.

Vamos ver no nosso dataset quais colunas com object preenchem esses requisitos e podem ser convertidas em category.

In [4]:
# Checagem de colunas do tipo 'object'
for i in df.select_dtypes(include='object'):
    print(f'Coluna: {df[i].name}\tValores únicos: {df[i].nunique()}\n{df[i].unique()}\n')
Coluna: txNomeParlamentar  Valores únicos: 1441
['JAIRO ATAÍDE' 'BISPO GÊ TENUTA' 'JORGINHO MALULY' ... 'JOSEILDO RAMOS'
 'RICARDO PERICAR' 'FABIANO TOLENTINO']

Coluna: sgUF    Valores únicos: 27
['MG' 'SP' 'RJ' 'BA' 'PE' 'RR' 'MS' 'PR' 'SC' 'RS' 'MT' 'ES' 'DF' 'CE'
 'TO' 'GO' 'AP' 'PA' 'AM' 'RO' 'AC' 'AL' 'MA' 'RN' 'PB' 'SE' 'PI' nan]

Coluna: sgPartido   Valores únicos: 41
['DEM' 'PRB' 'PT' 'PTB' 'PSB' 'PMDB' 'PP**' 'PCdoB' 'PSDB' 'PR' 'PPS'
 'PDT' 'PSOL' 'PSC' 'PHS' 'PV' 'PTC' 'PMN' 'PTdoB' nan 'MDB' 'SD'
 'REPUBLICANOS' 'CIDADANIA' 'PROS' 'PSD' 'PP' 'PODE' 'PATRI' 'S.PART.'
 'AVANTE' 'PL' 'PRP' 'PATRIOTA' 'SOLIDARIEDADE' 'PEN' 'PSDC' 'PSL' 'REDE'
 'PPL' 'PRTB' 'NOVO']

Coluna: txtDescricao    Valores únicos: 19
['MANUTENÇÃO DE ESCRITÓRIO DE APOIO À ATIVIDADE PARLAMENTAR'
 'COMBUSTÍVEIS E LUBRIFICANTES.'
 'CONSULTORIAS, PESQUISAS E TRABALHOS TÉCNICOS.'
 'DIVULGAÇÃO DA ATIVIDADE PARLAMENTAR.' 'TELEFONIA' 'SERVIÇOS POSTAIS'
 'ASSINATURA DE PUBLICAÇÕES' 'FORNECIMENTO DE ALIMENTAÇÃO DO PARLAMENTAR'
 'HOSPEDAGEM ,EXCETO DO PARLAMENTAR NO DISTRITO FEDERAL.'
 'LOCAÇÃO DE VEÍCULOS AUTOMOTORES OU FRETAMENTO DE EMBARCAÇÕES'
 'Emissão Bilhete Aéreo' 'PASSAGENS AÉREAS'
 'SERVIÇO DE SEGURANÇA PRESTADO POR EMPRESA ESPECIALIZADA.'
 'SERVIÇO DE TÁXI, PEDÁGIO E ESTACIONAMENTO'
 'LOCAÇÃO OU FRETAMENTO DE VEÍCULOS AUTOMOTORES'
 'LOCAÇÃO OU FRETAMENTO DE AERONAVES'
 'LOCAÇÃO OU FRETAMENTO DE EMBARCAÇÕES'
 'PASSAGENS TERRESTRES, MARÍTIMAS OU FLUVIAIS'
 'PARTICIPAÇÃO EM CURSO, PALESTRA OU EVENTO SIMILAR']

Coluna: txtDescricaoEspecificacao   Valores únicos: 4
[nan 'Veículos Automotores' 'Sem especificações' 'Embarcações' 'Aeronaves']

Coluna: txtFornecedor   Valores únicos: 191212
['CEMIG' 'COOPERBH-TAXI' 'COPASA' ... 'AUTO SERVICO BARREIRA S LTDA'
 'Posto Planetário LTDA' 'Liberio joao de santana & cia ltda']

Coluna: txtCNPJCPF  Valores únicos: 92209
['069.811.800/0011-6' '042.763.570/0015-8' '172.811.060/0010-3' ...
 '295.372.890/0016-4' '286.268.100/0017-7' '653.567.270/0015-1']

Coluna: txtNumero   Valores únicos: 2036653
['003068310' '003110535' '003153516' ... '763733' 'Bilhete: IK7DPX'
 'Bilhete: WF2HSM']

Coluna: datEmissao  Valores únicos: 310769
['2010-04-16T00:00:00' '2010-05-17T00:00:00' '2010-01-15T00:00:00' ...
 '2019-07-24T11:49:38' '2019-08-09T12:38:47' '2019-10-08T16:55:05']

Coluna: txtPassageiro   Valores únicos: 34380
[nan 'VIEIRA/JAIRO' 'LIMA/JANIO' ... 'ALUA CARMO DE MOURA'
 'RICARDO PERICAR' 'FABIANO TOLENTINO']

Coluna: txtTrecho   Valores únicos: 18981
[nan 'BSBG3CNF' 'CNFG3BSB' ... 'BSB/MAO/FOR/REC' 'VCP/BSB/SSA'
 'BSB/GRU/VIX/CGH/BSB']

A coluna sgUF tem somente 27 valores únicos, além de outros nulos; sgPartido, 41 valores únicos, mais nulos; txtDescricao, 19; txtDescricaoEspecificacao, quatro e outros nulos. Essas colunas todas podem ser convertidas, pois contêm valores fixos e limitados.

In [5]:
def mem_usage(pandas_object):
    """Calcula o uso de memória do dataframe ou da série"""
    if isinstance(pandas_object, pd.DataFrame):
        usage = pandas_object.memory_usage(deep=True).sum()
    else:
        usage = pandas_object.memory_usage(deep=True)
    converted_usage = usage / 1024 ** 2
    return f'{round(converted_usage, 2)} MB'
In [6]:
# Criação de dataframe vazio
new_df = pd.DataFrame()

# Conversão das colunas
for col in df.columns:
    if col in ['sgUF', 'sgPartido', 'txtDescricao', 'txtDescricaoEspecificacao']:
        new_df.loc[:,col] = df[col].astype('category')
    else:
        new_df.loc[:,col] = df[col]

# Resultado
print(f'Memória consumida antes: {mem_usage(df)}\n\
Memória consumida depois: {mem_usage(new_df)}')
Memória consumida antes: 3053.99 MB
Memória consumida depois: 2082.63 MB
In [7]:
#Criação de dataframe com resultados
previous = pd.DataFrame(df.memory_usage(deep=True), columns=['Antes'])
now = pd.DataFrame(new_df.memory_usage(deep=True), columns=['Depois'])
result = pd.concat([previous, now], axis=1)
result['Variação (%)'] = ((result['Depois'] / result['Antes']) - 1) * 100

# Resultado das colunas convertidas
result[result['Variação (%)'] != 0]
Out[7]:
Antes Depois Variação (%)
sgUF 205474768 3487858 -98.302537
sgPartido 209496629 3488772 -98.334688
txtDescricao 458518562 3488536 -99.239172
txtDescricaoEspecificacao 158995761 3485503 -97.807801

A memória consumida por essas colunas teve redução superior a 95%, e a memória total caiu de 3 GB para 2 GB. A mudança mais significativa foi em txtDescricao, superior a 99%.

O que aconteceu nos bastidores: a conversão de object para category fez com que o valor passasse a ser interpretado como integer —e, agora sabemos, números consomem menos memória.

Inclusive é possível ver o integer de cada valor categórico:

In [8]:
# Checagem dos valores de 'txtDescricao' convertidos para integer
dict(zip(new_df['txtDescricao'], new_df['txtDescricao'].cat.codes))
Out[8]:
{'MANUTENÇÃO DE ESCRITÓRIO DE APOIO À ATIVIDADE PARLAMENTAR': 11,
 'COMBUSTÍVEIS E LUBRIFICANTES.': 1,
 'CONSULTORIAS, PESQUISAS E TRABALHOS TÉCNICOS.': 2,
 'DIVULGAÇÃO DA ATIVIDADE PARLAMENTAR.': 3,
 'TELEFONIA': 18,
 'SERVIÇOS POSTAIS': 17,
 'ASSINATURA DE PUBLICAÇÕES': 0,
 'FORNECIMENTO DE ALIMENTAÇÃO DO PARLAMENTAR': 5,
 'HOSPEDAGEM ,EXCETO DO PARLAMENTAR NO DISTRITO FEDERAL.': 6,
 'LOCAÇÃO DE VEÍCULOS AUTOMOTORES OU FRETAMENTO DE EMBARCAÇÕES': 7,
 'Emissão Bilhete Aéreo': 4,
 'PASSAGENS AÉREAS': 13,
 'SERVIÇO DE SEGURANÇA PRESTADO POR EMPRESA ESPECIALIZADA.': 15,
 'SERVIÇO DE TÁXI, PEDÁGIO E ESTACIONAMENTO': 16,
 'LOCAÇÃO OU FRETAMENTO DE VEÍCULOS AUTOMOTORES': 10,
 'LOCAÇÃO OU FRETAMENTO DE AERONAVES': 8,
 'LOCAÇÃO OU FRETAMENTO DE EMBARCAÇÕES': 9,
 'PASSAGENS TERRESTRES, MARÍTIMAS OU FLUVIAIS': 14,
 'PARTICIPAÇÃO EM CURSO, PALESTRA OU EVENTO SIMILAR': 12}

Cada valor recebeu um integer de referência, de 0 a 18. E sempre que o valor for repetido, o que se repetirá será um integer, não um object.

Mas atenção: converter object para category pode levar a coluna a consumir mais memória, caso haja muitos valores únicos —ou seja, sem repetição de valor. É o que diz a documentação:

Note: If the number of categories approaches the length of the data, the Categorical will use nearly the same or more memory than an equivalent object dtype representation.

No dataset que estamos vendo, isso ocorre com txtNumero, por exemplo:

In [9]:
# Exemplo de conversão ruim em 'txtNumero'
as_object = new_df['txtNumero']
as_category = new_df['txtNumero'].astype('category')

# Resultado
print(f'Coluna como object: {as_object.memory_usage(deep=True)}\n\
Coluna como category: {as_category.memory_usage(deep=True)}\n\
Diferença: {round(((as_category.memory_usage(deep=True)-as_object.memory_usage(deep=True)) / 1024 ** 2), 2)} MB')
Coluna como object: 234588666
Coluna como category: 240993720
Diferença: 6.11 MB

Cabe, pois, certa parcimônia: não vale a pena converter tudo que é object em category. E lembrar sempre: category vale para valores fixos (ou seja, permanentes, que aparecem de maneira recorrente) e limitados (sem tanta variação).

Em outros artigos, continuaremos a investigar como otimizar memória com Pandas. Trataremos dos diversos tipos de integer.