Pular para o conteúdo

    Capítulo 29, Avançado

    Memória: os tipos certos

    O tamanho de um `DataFrame` na memória depende muito mais dos **tipos** das colunas do que do número de linhas. Escolher o tipo certo decide se uma tabela cabe na máquina, e é quase sempre uma troca sem custo de qualidade.

    Onde a memória vai

    Cada coluna ocupa linhas × tamanho do tipo. Um inteiro de 64 bits ocupa 8 bytes mesmo quando só guarda valores de 1 a 10. Uma coluna de texto repete a mesma palavra milhares de vezes. Medir com deep=True mostra o custo real, inclusive o conteúdo dos textos:

    avancado/cap29_memoria_tipos.pylinhas 10 a 20
    import numpy as np
    import pandas as pd
    
    pd.set_option("display.width", 170)
    pd.set_option("display.max_columns", 20)
    
    pedidos = pd.read_csv("dados/pedidos.csv", parse_dates=["data_pedido"])
    grande = pd.concat([pedidos] * 250, ignore_index=True)
    grande["id_pedido"] = np.arange(len(grande))
    print(len(grande))
    print((grande.memory_usage(deep=True) / 1024 / 1024).round(2).to_dict())
    
    Saída
    200000
    {'Index': 0.0, 'id_pedido': 1.53, 'id_cliente': 1.53, 'id_produto': 1.53, 'quantidade': 1.53, 'data_pedido': 1.53, 'desconto': 1.53, 'canal': 2.25}
    

    Reduzir com cuidado

    Uma função de redução aplica três ideias: inteiros menores quando os valores cabem, category para textos com poucos valores distintos, e nada nos decimais que guardam dinheiro (reduzir float64 para float32 perde precisão, e em valores monetários isso é um erro):

    avancado/cap29_memoria_tipos.pylinhas 25 a 42
    def reduzir_memoria(tabela: pd.DataFrame) -> pd.DataFrame:
        nova = tabela.copy()
        for coluna in nova.select_dtypes("integer").columns:
            nova[coluna] = pd.to_numeric(nova[coluna], downcast="integer")
        for coluna in nova.select_dtypes(include=["object", "str"]).columns:
            if nova[coluna].nunique() / len(nova) < 0.5:
                nova[coluna] = nova[coluna].astype("category")
        return nova
    
    
    menor = reduzir_memoria(grande)
    antes = grande.memory_usage(deep=True).sum()
    depois = menor.memory_usage(deep=True).sum()
    print(menor.dtypes.astype(str).to_dict())
    print("usa menos da metade da memória:", depois < 0.5 * antes)
    restaurada = menor.astype(grande.dtypes.to_dict())
    pd.testing.assert_frame_equal(grande, restaurada)
    print("os valores são os mesmos")
    
    Saída
    {'id_pedido': 'int32', 'id_cliente': 'int8', 'id_produto': 'int8', 'quantidade': 'int8', 'data_pedido': 'datetime64[us]', 'desconto': 'float64', 'canal': 'category'}
    usa menos da metade da memória: True
    os valores são os mesmos
    

    Para provar que nenhum valor mudou, eu converto a tabela reduzida de volta aos tipos originais e comparo com assert_frame_equal. Não basta passar check_dtype=False: o assert_frame_equal ainda compara o tipo do contêiner, e uma coluna category contra uma de texto falha, mesmo com os mesmos valores. O id_pedido caiu para int32, o id_cliente, o id_produto e a quantidade para int8, e o canal virou category.

    Cuidado com a redução automática

    O downcast escolhe o menor tipo que cabe nos valores de agora. Se amanhã chegar um id_cliente maior que o limite, uma conta com ele pode estourar (no NumPy, o inteiro dá a volta sem avisar, como vimos no capítulo 7 do curso de NumPy). Eu reduzo os tipos no fim da preparação, para guardar e analisar, e não no meio de um fluxo que ainda recebe dados novos.

    Ler já com os tipos certos

    O melhor lugar para economizar é na leitura: com usecols você lê só as colunas que vai usar, e com dtype você dá o tipo certo desde o início, sem passar antes pelo caro:

    avancado/cap29_memoria_tipos.pylinhas 47 a 60
    from pathlib import Path
    
    Path("saida").mkdir(exist_ok=True)
    grande.to_csv("saida/pedidos_grande.csv", index=False)
    
    padrao = pd.read_csv("saida/pedidos_grande.csv", usecols=["id_cliente", "quantidade", "canal"])
    enxuto = pd.read_csv(
        "saida/pedidos_grande.csv",
        usecols=["id_cliente", "quantidade", "canal"],
        dtype={"id_cliente": "int16", "quantidade": "int8", "canal": "category"},
    )
    print(padrao.dtypes.astype(str).to_dict())
    print(enxuto.dtypes.astype(str).to_dict())
    print("a leitura enxuta usa menos de um quinto:", enxuto.memory_usage(deep=True).sum() < padrao.memory_usage(deep=True).sum() / 5)
    
    Saída
    {'id_cliente': 'int64', 'quantidade': 'int64', 'canal': 'str'}
    {'id_cliente': 'int16', 'quantidade': 'int8', 'canal': 'category'}
    a leitura enxuta usa menos de um quinto: True
    

    Tipos do Arrow

    Para uma leitura que já devolve colunas no formato do Arrow (que representa os ausentes de forma nativa e comprime bem), existe o dtype_backend="pyarrow":

    avancado/cap29_memoria_tipos.pylinhas 65 a 66
    arrow = pd.read_csv("saida/pedidos_grande.csv", nrows=1000, dtype_backend="pyarrow")
    print(arrow.dtypes.astype(str).to_dict())
    
    Saída
    {'id_pedido': 'int64[pyarrow]', 'id_cliente': 'int64[pyarrow]', 'id_produto': 'int64[pyarrow]', 'quantidade': 'int64[pyarrow]', 'data_pedido': 'string[pyarrow]', 'desconto': 'double[pyarrow]', 'canal': 'string[pyarrow]'}
    

    Os tipos terminam em [pyarrow]. Eles são uma boa opção para dados com muitos ausentes e textos, mas algumas operações menos comuns ainda são mais rápidas nos tipos clássicos. Meça com os seus dados antes de adotar.

    TipoQuando
    int8, int16, int32Contagens e códigos com faixa conhecida
    float64Dinheiro e qualquer coisa que exija precisão
    categoryPoucos valores distintos, repetidos (departamento, canal, estado)
    Int64, boolean, stringQuando há ausentes e você quer manter o tipo
    [pyarrow]Muitos textos e ausentes (meça antes)

    Exercício 1

    Medir a economia

    Escreva economia(tabela), que devolva a fração de memória economizada pela reduzir_memoria (um número entre 0 e 1), e confira que ela é maior que 0,5 para a tabela grande.