Pular para o conteúdo

    Capítulo 19, Intermediário

    Categorias e faixas

    Uma coluna com poucos valores distintos repetidos milhares de vezes (departamento, canal, estado) é desperdício como texto. O tipo `category` a guarda uma vez, ocupa uma fração da memória e ainda traz uma ordem.

    O tipo `category`

    Em vez de guardar a palavra Engenharia cem mil vezes, o category guarda uma tabela de categorias e, para cada linha, só o número da categoria. A diferença de memória é grande, e os valores são os mesmos:

    intermediario/cap19_categorias.pylinhas 10 a 23
    import numpy as np
    import pandas as pd
    
    pd.set_option("display.width", 170)
    pd.set_option("display.max_columns", 20)
    
    departamentos = ["Engenharia", "Marketing", "Finanças", "Ciência de Dados", "RH"]
    grande = pd.Series(np.random.default_rng(0).choice(departamentos, 100_000))
    como_categoria = grande.astype("category")
    
    print(grande.dtype, como_categoria.dtype)
    print(bool(como_categoria.memory_usage(deep=True) < 0.2 * grande.memory_usage(deep=True)))
    print(como_categoria.cat.categories.tolist())
    print(como_categoria.cat.codes.head(3).tolist())
    
    Saída
    str category
    True
    ['Ciência de Dados', 'Engenharia', 'Finanças', 'Marketing', 'RH']
    [4, 0, 2]
    

    A tabela de categorias está em .cat.categories, e os números por linha em .cat.codes. O ganho é maior quanto menos valores distintos e mais linhas houver. Para uma coluna de identificadores (quase todos diferentes), o category não ajuda.

    Categorias com ordem

    Uma categoria ordenada sabe que Iniciante < Pleno < Sênior, e por isso permite comparações e ordenações corretas. Ordenar texto comum é ordenar em ordem alfabética, e a ordem alfabética raramente é a ordem do negócio (Alto viria antes de Baixo, e Médio ficaria entre os dois):

    intermediario/cap19_categorias.pylinhas 28 a 33
    niveis = pd.CategoricalDtype(["Iniciante", "Pleno", "Sênior"], ordered=True)
    equipe = pd.Series(["Sênior", "Iniciante", "Pleno", "Sênior"]).astype(niveis)
    
    print(equipe.sort_values().tolist())
    print((equipe >= "Pleno").tolist())
    print(equipe.max(), equipe.value_counts(sort=False).to_dict())
    
    Saída
    ['Iniciante', 'Pleno', 'Sênior', 'Sênior']
    [True, False, True, True]
    Sênior {'Iniciante': 1, 'Pleno': 1, 'Sênior': 2}
    

    O value_counts(sort=False) mantém a ordem das categorias, e não a da frequência.

    Faixas: `cut` e `qcut`

    O pd.cut divide um número em faixas de limites que você escolhe. O pd.qcut divide em faixas com o mesmo número de elementos (quartis, decis), e os limites vêm dos dados:

    intermediario/cap19_categorias.pylinhas 38 a 45
    funcionarios = pd.read_csv("dados/funcionarios_100.csv").drop_duplicates("id_funcionario", keep="last")
    salario = pd.to_numeric(funcionarios["salario"].str.replace("R$ ", "", regex=False))
    
    faixas = pd.cut(salario, bins=[0, 5000, 8000, np.inf], labels=["Baixa", "Média", "Alta"])
    quartis = pd.qcut(salario, q=4, labels=["Q1", "Q2", "Q3", "Q4"])
    print(faixas.value_counts().to_dict())
    print(quartis.value_counts().sort_index().to_dict())
    print(int(faixas.isna().sum()), int(salario.isna().sum()))
    
    Saída
    {'Alta': 60, 'Média': 29, 'Baixa': 4}
    {'Q1': 24, 'Q2': 23, 'Q3': 23, 'Q4': 23}
    7 7
    

    As faixas do cut têm tamanhos diferentes (os limites são seus), e as do qcut têm tamanhos quase iguais. Os salários ausentes ficam sem faixa (NaN), e por isso as contagens somam menos que o total.

    Os limites do `cut`

    Por padrão, as faixas são fechadas à direita: (0, 5000] inclui o 5000 e exclui o 0. Com right=False, ficam [0, 5000). Um valor exatamente no limite cai em faixas diferentes conforme essa escolha, e esse é um erro comum:

    intermediario/cap19_categorias.pylinhas 50 a 52
    valor = pd.Series([5000])
    print(pd.cut(valor, [0, 5000, 8000], labels=["A", "B"]).tolist())
    print(pd.cut(valor, [0, 5000, 8000], labels=["A", "B"], right=False).tolist())
    
    Saída
    ['A']
    ['B']
    

    Agrupar por categoria

    No Pandas 3, o groupby por uma coluna category só mostra as categorias que aparecem nos dados (observed=True é o padrão). Para listar também as categorias vazias (útil em relatórios que precisam de todas as linhas, mesmo as zeradas), peça observed=False:

    intermediario/cap19_categorias.pylinhas 57 a 60
    nivel = pd.Series(pd.Categorical(["Pleno", "Sênior", "Pleno"], categories=["Iniciante", "Pleno", "Sênior"]))
    horas = pd.Series([10, 20, 30])
    print(horas.groupby(nivel).sum().to_dict())
    print(horas.groupby(nivel, observed=False).sum().to_dict())
    
    Saída
    {'Pleno': 40, 'Sênior': 20}
    {'Iniciante': 0, 'Pleno': 40, 'Sênior': 20}
    

    O que uma categoria não aceita

    Uma coluna category só aceita os valores que estão na lista de categorias. Atribuir outro é um erro (e isso é uma vantagem: valida os dados):

    intermediario/cap19_categorias.pylinhas 65 a 68
    try:
        nivel[0] = "Estagiário"
    except TypeError as erro:
        print("TypeError:", str(erro)[:56])
    
    Saída
    TypeError: Cannot setitem on a Categorical with a new category (Est
    

    E, ao juntar duas colunas category com categorias diferentes, o resultado perde o tipo:

    intermediario/cap19_categorias.pylinhas 70 a 72
    a = pd.Series(pd.Categorical(["x", "y"]))
    b = pd.Series(pd.Categorical(["z"]))
    print(pd.concat([a, b]).dtype)
    
    Saída
    str
    

    Exercício 1

    Faixas de tempo de casa

    Escreva faixa_de_casa(anos), que receba uma Series com os anos de casa e devolva uma coluna de categorias ordenadas (Recente com menos de 2 anos, Estabelecido de 2 a menos de 6, e Veterano com 6 ou mais). Confira que a soma das contagens é igual ao total.