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

Por padrão, Pandas transforma inteiros em int64, um subtipo 'pesado' de integer que você quase nunca precisa usa

29 out 2019


Na primeira parte sobre otimização de memória com Pandas, tratamos de category, tipo que pode substituir object quando os valores são fixos (permanentes, que aparecem de maneira recorrente) e limitados (sem tanta variação). Exemplos são sexo biológico (M e F), tipo sanguíneo (A+, B-, AB+, O- etc.) e tamanho de roupa (P, M, G, GG etc.).

Vimos que category “traduz” o tipo object para o tipo integer, o que consome menos memória. Em nosso dataset, quando convertemos quatro colunas para category, obtivemos mais de 30% de economia de memória.

In [1]:
import pandas as pd

# Leitura do arquivo sem tipagem
raw_df = pd.read_csv('data.csv')

# Resultado
print(raw_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
None
In [2]:
# Leitura do arquivo com tipagem de 'category'
df = pd.read_csv('data.csv',
                 dtype={
                     'sgUF': 'category',
                     'sgPartido': 'category',
                     'txtDescricao': 'category',
                     'txtDescricaoEspecificacao': 'category'
                 })

# Resultado
print(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                         category
sgPartido                    category
codLegislatura               int64
numSubCota                   int64
txtDescricao                 category
numEspecificacaoSubCota      int64
txtDescricaoEspecificacao    category
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: category(4), float64(8), int64(11), object(7)
memory usage: 2.0 GB
None

Bastante efetiva, a conversão de tipos não é a única ação que pode ser tomada para otimizar memória. E aqui entra uma espécie de downgrade de integer.

int64: uma bala de canhão para matar uma mosca

Quando Pandas reconhece uma sequência de números inteiros, ela automaticamente atribui a cada elemento o subtipo int64 —ou seja, integer de 64 bits. Segundo a documentação,

By default integer types are int64 and float types are float64, regardless of platform (32-bit or 64-bit).

Aliás, a partir da linha 421 de parsers.pyx, do código-fonte de Pandas, está descrita a ordem de tipos usada para avaliar um valor durante a leitura inicial do dataset:

[...]
421        dtype_order = ['int64', 'float64', 'bool', 'object']
422        if quoting == QUOTE_NONNUMERIC:
423            # consistent with csv module semantics, cast all to float
424            dtype_order = dtype_order[1:]
425        self.dtype_cast_order = [np.dtype(x) for x in dtype_order]
[...]

Comparando com os demais subtipos de integer, int64 é imenso. Cada valor consome 64 bits na memória —ou 8 bytes. Em contraste, int8, por exemplo, consome 8 bits —1 byte.

De forma simplista, isso significa que o número 1, por exemplo, pode ocupar 1 byte (se for int8) ou 8 bytes (caso seja int64). O mesmo número pode pesar oito vezes seu peso original na memória!

Isso não significa que todo número possa ter essa variação. Os subtipos de integer acomodam intervalos distintos de números. Vamos ver isso com NumPy, que empresta a Pandas muitas das classes que tratam de tipos:

In [1]:
import numpy as np

int_subtypes = ['int8', 'int16', 'int32', 'int64',
                'uint8', 'uint16', 'uint32', 'uint64']

for i in int_subtypes:
    print(np.iinfo(i))
Machine parameters for int8
---------------------------------------------------------------
min = -128
max = 127
---------------------------------------------------------------

Machine parameters for int16
---------------------------------------------------------------
min = -32768
max = 32767
---------------------------------------------------------------

Machine parameters for int32
---------------------------------------------------------------
min = -2147483648
max = 2147483647
---------------------------------------------------------------

Machine parameters for int64
---------------------------------------------------------------
min = -9223372036854775808
max = 9223372036854775807
---------------------------------------------------------------

Machine parameters for uint8
---------------------------------------------------------------
min = 0
max = 255
---------------------------------------------------------------

Machine parameters for uint16
---------------------------------------------------------------
min = 0
max = 65535
---------------------------------------------------------------

Machine parameters for uint32
---------------------------------------------------------------
min = 0
max = 4294967295
---------------------------------------------------------------

Machine parameters for uint64
---------------------------------------------------------------
min = 0
max = 18446744073709551615
---------------------------------------------------------------

Observe que há duas formas de integer:

  1. aquela que comporta números positivos e negativos, além de zero (chamada signed integer, ou int);
  2. aquela que não compreende números negativos (chamada unsigned integer, ou uint).

Note também que, apesar dessa diferença, a capacidade de armazenamento se equivale:

Subtipo Tamanho em bytes Valor mínimo Valor máximo Total de valores Total de valores usando bits
int8 1 -128 127 256 28
uint8 1 0 255 256 28
int16 2 -32768 32767 65536 216
uint16 2 0 65535 65536 216
int32 4 -2147483648 2147483647 4294967296 232
uint32 4 0 4294967295 4294967296 232
int64 8 -9223372036854775808 9223372036854775807 18446744073709551616 264
uint64 8 0 18446744073709551615 18446744073709551616 264

Agora que sabemos sobre a existência de subtipos de integer, cada um com seu intervalo de valores e consumo de memória, a pergunta que resta é: quando, em toda sua vida, você teve de fazer operações com quintilhões?

Pois é. Creio que nunca. (Ou quase nunca, vá lá.)

Temos, portanto, que int64, o tipo padrão de números inteiros usado em Pandas, é agressivo no consumo de memória. No nosso dataset, por exemplo, cada coluna com valores do tipo int64 consome mais de 26 MB.

In [3]:
# Checagem de consumo de memória em cada coluna 'int64'
for col in df.select_dtypes(include='int64'):
    mean_usage = df[col].memory_usage(deep=True)
    converted_mean_usage = mean_usage / 1024 ** 2
    print(f'Coluna: {df[col].name}\nMin, Max: {df[col].min()}, {df[col].max()}\n\
Tamanho: {converted_mean_usage}\n')
Coluna: nuLegislatura
Min, Max: 2007, 2019
Tamanho: 26.58844757080078

Coluna: codLegislatura
Min, Max: 53, 56
Tamanho: 26.58844757080078

Coluna: numSubCota
Min, Max: 1, 999
Tamanho: 26.58844757080078

Coluna: numEspecificacaoSubCota
Min, Max: 0, 4
Tamanho: 26.58844757080078

Coluna: indTipoDocumento
Min, Max: 0, 4
Tamanho: 26.58844757080078

Coluna: numMes
Min, Max: 1, 12
Tamanho: 26.58844757080078

Coluna: numAno
Min, Max: 2010, 2019
Tamanho: 26.58844757080078

Coluna: numParcela
Min, Max: 0, 1
Tamanho: 26.58844757080078

Coluna: numLote
Min, Max: 0, 1643257
Tamanho: 26.58844757080078

Coluna: nuDeputadoId
Min, Max: 12, 3456
Tamanho: 26.58844757080078

Coluna: ideDocumento
Min, Max: 0, 6941092
Tamanho: 26.58844757080078

Se nossa ideia é buscar otimização, seria melhor rever cada coluna de integer em nosso dataset, observar os valores mínimo e máximo, e adequar o subtipo de integer à demanda específica da coluna.

Por exemplo, a coluna nuLegislatura vai de 2007 a 2019, e caberia com folga nos subtipos int16 ou uint16; já codLegislatura, com valores de 53 a 56, poderia ser tipado como int8 ou uint8… Olhando bem, nenhuma coluna precisa ser int64!

Em vez de checar coluna por coluna, é possível fazer isso numa tacada só, com a função pandas.to_numeric e especificando downcast como argumento. Isso fará com que Pandas “reavalie” as colunas com valores numéricos (no nosso caso, integer) e use o int ou uint mais adequado para a faixa entre os valores mínimo e máximo.

In [4]:
# Tipagem em cada coluna 'int' após 'downcast'
for col in df.select_dtypes(include='int64'):
    df[col] = pd.to_numeric(df[col], downcast='integer')
    print(f'Coluna: {df[col].name}\tTipo: {df[col].dtype}\n')
Coluna: nuLegislatura  Tipo: int16

Coluna: codLegislatura  Tipo: int8

Coluna: numSubCota  Tipo: int16

Coluna: numEspecificacaoSubCota Tipo: int8

Coluna: indTipoDocumento  Tipo: int8

Coluna: numMes  Tipo: int8

Coluna: numAno  Tipo: int16

Coluna: numParcela  Tipo: int8

Coluna: numLote Tipo: int32

Coluna: nuDeputadoId  Tipo: int16

Coluna: ideDocumento  Tipo: int32

Vemos que já não temos mais int64 na lista. Isso se reflete no consumo de memória:

In [5]:
for col in df:
    mean_usage = df[col].memory_usage(deep=True)
    converted_mean_usage = mean_usage / 1024 ** 2
    print(f'Coluna: {df[col].name}\nTamanho: {converted_mean_usage}\nTipo: {df[col].dtype}\n')
Coluna: txNomeParlamentar
Tamanho: 256.76439666748047
Tipo: object

Coluna: cpf
Tamanho: 26.58844757080078
Tipo: float64

Coluna: ideCadastro
Tamanho: 26.58844757080078
Tipo: float64

Coluna: nuCarteiraParlamentar
Tamanho: 26.58844757080078
Tipo: float64

Coluna: nuLegislatura
Tamanho: 6.64720344543457
Tipo: int16

Coluna: sgUF
Tamanho: 3.3264026641845703
Tipo: category

Coluna: sgPartido
Tamanho: 3.3272743225097656
Tipo: category

Coluna: codLegislatura
Tamanho: 3.323662757873535
Tipo: int8

Coluna: numSubCota
Tamanho: 6.64720344543457
Tipo: int16

Coluna: txtDescricao
Tamanho: 3.3270492553710938
Tipo: category

Coluna: numEspecificacaoSubCota
Tamanho: 3.323662757873535
Tipo: int8

Coluna: txtDescricaoEspecificacao
Tamanho: 3.3242111206054688
Tipo: category

Coluna: txtFornecedor
Tamanho: 294.0604467391968
Tipo: object

Coluna: txtCNPJCPF
Tamanho: 249.2413845062256
Tipo: object

Coluna: txtNumero
Tamanho: 223.7211856842041
Tipo: object

Coluna: indTipoDocumento
Tamanho: 3.323662757873535
Tipo: int8

Coluna: datEmissao
Tamanho: 244.70138549804688
Tipo: object

Coluna: vlrDocumento
Tamanho: 26.58844757080078
Tipo: float64

Coluna: vlrGlosa
Tamanho: 26.58844757080078
Tipo: float64

Coluna: vlrLiquido
Tamanho: 26.58844757080078
Tipo: float64

Coluna: numMes
Tamanho: 3.323662757873535
Tipo: int8

Coluna: numAno
Tamanho: 6.64720344543457
Tipo: int16

Coluna: numParcela
Tamanho: 3.323662757873535
Tipo: int8

Coluna: txtPassageiro
Tamanho: 145.96581077575684
Tipo: object

Coluna: txtTrecho
Tamanho: 135.7911787033081
Tipo: object

Coluna: numLote
Tamanho: 13.29428482055664
Tipo: int32

Coluna: numRessarcimento
Tamanho: 26.58844757080078
Tipo: float64

Coluna: vlrRestituicao
Tamanho: 26.58844757080078
Tipo: float64

Coluna: nuDeputadoId
Tamanho: 6.64720344543457
Tipo: int16

Coluna: ideDocumento
Tamanho: 13.29428482055664
Tipo: int32

Antes de reduzir o subtipo, cada coluna de integer consumia mais de 26 MB; agora, não passa de 13 MB, mas a maioria fica entre 3 MB e 6 MB.

No geral, 200 MB de memória foram economizados apenas com pandas.to_numeric e downcast.

In [6]:
# Checagem de tipos no estado final
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                int16
sgUF                         category
sgPartido                    category
codLegislatura               int8
numSubCota                   int16
txtDescricao                 category
numEspecificacaoSubCota      int8
txtDescricaoEspecificacao    category
txtFornecedor                object
txtCNPJCPF                   object
txtNumero                    object
indTipoDocumento             int8
datEmissao                   object
vlrDocumento                 float64
vlrGlosa                     float64
vlrLiquido                   float64
numMes                       int8
numAno                       int16
numParcela                   int8
txtPassageiro                object
txtTrecho                    object
numLote                      int32
numRessarcimento             float64
vlrRestituicao               float64
nuDeputadoId                 int16
ideDocumento                 int32
dtypes: category(4), float64(8), int16(4), int32(2), int8(5), object(7)
memory usage: 1.8 GB
In [7]:
# Checagem de memória consumida por coluna em estado final
df.memory_usage(deep=True)
Out[7]:
Index                              128
txNomeParlamentar            269236856
cpf                           27879880
ideCadastro                   27879880
nuCarteiraParlamentar         27879880
nuLegislatura                  6969970
sgUF                           3487858
sgPartido                      3488772
codLegislatura                 3484985
numSubCota                     6969970
txtDescricao                   3488536
numEspecificacaoSubCota        3484985
txtDescricaoEspecificacao      3485560
txtFornecedor                308344599
txtCNPJCPF                   261348406
txtNumero                    234588538
indTipoDocumento               3484985
datEmissao                   256587872
vlrDocumento                  27879880
vlrGlosa                      27879880
vlrLiquido                    27879880
numMes                         3484985
numAno                         6969970
numParcela                     3484985
txtPassageiro                153056118
txtTrecho                    142387243
numLote                       13939940
numRessarcimento              27879880
vlrRestituicao                27879880
nuDeputadoId                   6969970
ideDocumento                  13939940
dtype: int64

Considerando o post anterior, o nosso dataset não precisa mais de 3 GB para ser processado, mas somente 1,8 GB —redução de 40% até aqui.