Pular para o conteúdo

    Capítulo 16, Intermediário

    Dados ausentes, em profundidade

    Preencher tudo com a mediana global é o primeiro reflexo, e quase sempre há uma resposta melhor. Aqui entram o preenchimento por grupo, a interpolação e os tipos que guardam o ausente sem converter nada.

    O conjunto maior

    Daqui até o fim do curso, o conjunto padrão é o funcionarios_100.csv: 104 linhas, mais sujas que as 15 do nível Básico (ids repetidos, mais ausentes, datas em dois formatos). Uma função reúne a limpeza dos capítulos 10 e 11, e o último cadastro de cada id vence, porque os repetidos do arquivo são atualizações:

    intermediario/cap16_ausentes_avancado.pylinhas 10 a 29
    import numpy as np
    import pandas as pd
    
    pd.set_option("display.width", 170)
    pd.set_option("display.max_columns", 20)
    
    CANONICO = {"engenharia": "Engenharia", "marketing": "Marketing", "finanças": "Finanças",
                "ciência de dados": "Ciência de Dados", "rh": "RH"}
    
    
    def carregar_100() -> pd.DataFrame:
        df = pd.read_csv("dados/funcionarios_100.csv")
        df["salario"] = pd.to_numeric(df["salario"].str.replace("R$ ", "", regex=False))
        df["departamento"] = df["departamento"].str.strip().str.lower().map(CANONICO)
        return df.drop_duplicates("id_funcionario", keep="last").reset_index(drop=True)
    
    
    df = carregar_100()
    print(df.shape)
    print(df.isna().sum()[lambda s: s > 0].to_dict())
    
    Saída
    (100, 10)
    {'departamento': 3, 'cidade': 5, 'idade': 4, 'salario': 7}
    

    Preencher por grupo

    A mediana global trata um engenheiro e uma pessoa do RH como iguais. Mas o salário depende do departamento, e a mediana do próprio grupo é uma estimativa muito melhor. O transform devolve, para cada linha, a mediana do departamento dela, e o fillna aceita essa Series inteira:

    intermediario/cap16_ausentes_avancado.pylinhas 34 a 40
    ausentes = df["salario"].isna()
    global_ = df["salario"].fillna(df["salario"].median())
    por_depto = df.groupby("departamento")["salario"].transform("median")
    do_grupo = df["salario"].fillna(por_depto)
    
    print(int(ausentes.sum()), round(float(global_[ausentes].mean())), round(float(do_grupo[ausentes].mean())))
    print(int(do_grupo.isna().sum()))
    
    Saída
    7 9110 8911
    0
    

    As estimativas diferem: quem estava sem salário recebeu, em média, valores diferentes pelos dois métodos (a mediana global puxa todo mundo para o mesmo número). Neste conjunto, nenhuma linha sobrou ausente (o 0), mas isso é sorte do conjunto. Basta alguém sem salário e sem departamento para a conta falhar, porque ele não pertence a nenhum grupo (o groupby descarta a chave ausente, como vimos no capítulo 13):

    intermediario/cap16_ausentes_avancado.pylinhas 42 a 45
    pequeno = pd.DataFrame({"depto": ["A", "A", "B", None], "salario": [10.0, np.nan, 20.0, np.nan]})
    por_grupo = pequeno.groupby("depto")["salario"].transform("median")
    print(pequeno["salario"].fillna(por_grupo).tolist())
    print(pequeno["salario"].fillna(por_grupo).fillna(pequeno["salario"].median()).tolist())
    
    Saída
    [10.0, 10.0, 20.0, nan]
    [10.0, 10.0, 20.0, 15.0]
    

    O primeiro resultado preencheu o A (com 10), mas deixou o último ausente. O segundo passo, com a mediana global como reserva, fecha o buraco. Por isso o padrão completo tem sempre dois passos: o grupo e, depois, a reserva.

    Interpolar

    Em dados com ordem (medições, preços ao longo dos dias), o interpolate estima o valor que falta traçando uma reta entre os vizinhos, o que é mais fiel do que repetir o valor anterior:

    intermediario/cap16_ausentes_avancado.pylinhas 50 a 53
    medicoes = pd.Series([10.0, np.nan, np.nan, 16.0, np.nan, 20.0])
    print(medicoes.interpolate().tolist())
    print(medicoes.ffill().tolist())
    print(medicoes.interpolate(limit=1).tolist())
    
    Saída
    [10.0, 12.0, 14.0, 16.0, 18.0, 20.0]
    [10.0, 10.0, 10.0, 16.0, 16.0, 20.0]
    [10.0, 12.0, nan, 16.0, 18.0, 20.0]
    

    O limit=1 impede que a estimativa atravesse um buraco longo: preencher dois dias seguidos sem dado é aceitável, e preencher duas semanas é inventar.

    Descartar com critério

    O dropna(thresh=n) mantém as linhas com pelo menos n valores preenchidos, o que é mais fino que "qualquer ausente" e que "todos ausentes":

    intermediario/cap16_ausentes_avancado.pylinhas 58 a 60
    print(len(df), len(df.dropna()), len(df.dropna(thresh=9)), len(df.dropna(thresh=10)))
    completas = df.notna().all(axis=1).mean()
    print(round(float(completas), 2))
    
    Saída
    100 81 100 81
    0.81
    

    Tipos que guardam o ausente

    Como vimos no capítulo 3, o NaN obriga uma coluna de inteiros a virar decimal. Os tipos anuláveis (Int64, Float64, boolean, string) têm o seu próprio marcador, o pd.NA, e mantêm o tipo:

    intermediario/cap16_ausentes_avancado.pylinhas 65 a 67
    idade = df["idade"].astype("Int64")
    print(idade.dtype, idade.isna().sum(), idade.head(3).tolist())
    print(df[["idade", "salario"]].convert_dtypes().dtypes.to_dict())
    
    Saída
    Int64 4 [32, 37, 36]
    {'idade': Int64Dtype(), 'salario': Int64Dtype()}
    

    O convert_dtypes() escolhe o tipo anulável mais adequado para cada coluna. O pd.NA tem uma lógica própria, mais rigorosa que a do NaN:

    intermediario/cap16_ausentes_avancado.pylinhas 69 a 73
    print(pd.NA + 1, pd.NA == 1, True | pd.NA, False & pd.NA)
    try:
        bool(pd.NA)
    except TypeError as erro:
        print("TypeError:", erro)
    
    Saída
    <NA> <NA> True False
    TypeError: boolean value of NA is ambiguous
    

    Uma conta com pd.NA é NA. Uma comparação com ele é NA. Mas True | NA é verdadeiro, porque o resultado não depende do desconhecido, e False & NA é falso pelo mesmo motivo. E usar um NA como condição de um if é um erro explícito, em vez de decidir sozinho: o Pandas se recusa a adivinhar.

    SituaçãoO que eu faço
    Número cujo ausente atrapalha a médiaMediana do grupo, depois a global como reserva
    Série com ordem (tempo)interpolate com limit, ou ffill
    A ausência pode ser informaçãoColuna indicadora *_informada antes de preencher
    Inteiros com ausentesInt64 (anulável), e não float64
    Linhas com poucos dadosdropna(thresh=...)

    Exercício 1

    Preencher por grupo, com reserva

    Escreva preencher_por_grupo(tabela, coluna, grupo), que preencha os ausentes de coluna com a mediana do grupo e, quando o grupo também faltar, com a mediana global. Confira que não sobra nenhum ausente e que a tabela original não mudou.