Pular para o conteúdo

    Capítulo 10, Básico

    Duplicatas, tipos e nomes

    Os problemas deste capítulo não dão erro: o Pandas lê tudo sem reclamar, e as contas rodam. Limpar é descobrir o que está silenciosamente errado e corrigir com o mínimo de perda.

    Duplicatas

    Há duas perguntas diferentes: existem linhas inteiramente repetidas, e existem identificadores repetidos? O duplicated() compara todas as colunas por padrão, e o subset restringe a comparação:

    basico/cap10_limpeza_tipos_texto.pylinhas 10 a 19
    import numpy as np
    import pandas as pd
    
    pd.set_option("display.width", 170)
    pd.set_option("display.max_columns", 20)
    df = pd.read_csv("dados/funcionarios_15.csv")
    
    print(int(df.duplicated().sum()), int(df.duplicated(subset=["id_funcionario"]).sum()))
    colunas = ["id_funcionario", "nome", "departamento", "experiencia", "data_admissao"]
    print(df.loc[df.duplicated("id_funcionario", keep=False), colunas])
    
    Saída
    0 1
        id_funcionario       nome departamento  experiencia data_admissao
    0              101  Ana Souza   Engenharia            1    2021-01-10
    14             101  Ana Souza           RH            7    2023-06-24
    

    Nenhuma linha é inteiramente repetida (0), mas um identificador aparece duas vezes (1). A mesma funcionária foi cadastrada duas vezes, e com campos atualizados na segunda: o departamento mudou, e a experiência também. Por isso a comparação de linhas inteiras não achou nada.

    O drop_duplicates precisa saber qual manter, e a escolha importa:

    basico/cap10_limpeza_tipos_texto.pylinhas 21 a 25
    primeiro = df.drop_duplicates(subset=["id_funcionario"], keep="first")
    ultimo = df.drop_duplicates(subset=["id_funcionario"], keep="last")
    print(len(primeiro), len(ultimo))
    print(primeiro.loc[primeiro["id_funcionario"] == 101, "departamento"].item())
    print(ultimo.loc[ultimo["id_funcionario"] == 101, "departamento"].item())
    
    Saída
    14 14
    Engenharia
    RH
    

    O keep="first" fica com o primeiro cadastro (Engenharia), e o keep="last" fica com o mais recente (RH). Se o segundo registro é uma atualização, o certo é guardar o mais novo, e não o primeiro que apareceu. O curso original mantém o primeiro, e eu sigo isso nos próximos capítulos para os números baterem com o curso, mas em dados reais eu ordeno por data antes de remover, e fico com o último.

    Tipos

    A coluna salario é str, porque duas linhas têm R$ 4650 e R$ 5550. Há dois caminhos para convertê-la em número. O to_numeric(errors="coerce") converte o que consegue e transforma o resto em NaN, e isso destrói os dois salários escritos com R$:

    basico/cap10_limpeza_tipos_texto.pylinhas 30 a 35
    direto = pd.to_numeric(df["salario"], errors="coerce")
    print(int(df["salario"].isna().sum()), int(direto.isna().sum()))
    
    sem_simbolo = df["salario"].str.replace("R$ ", "", regex=False)
    certo = pd.to_numeric(sem_simbolo)
    print(int(certo.isna().sum()), certo.dtype)
    
    Saída
    2 4
    2 float64
    

    Os ausentes de verdade eram 2. Com o coerce direto, viraram 4: perdemos 2 salários que existiam, só que escritos de um jeito diferente. Primeiro tirar o símbolo e depois converter (sem coerce) preserva tudo. E sem o coerce, um valor que realmente não é número para a execução com uma mensagem clara:

    basico/cap10_limpeza_tipos_texto.pylinhas 37 a 40
    try:
        pd.to_numeric(pd.Series(["10", "abc"]))
    except ValueError as erro:
        print("ValueError:", erro)
    
    Saída
    ValueError: Unable to parse string "abc" at position 1
    

    O errors="coerce" é útil, mas é uma decisão de descartar dados. Eu o uso depois de conferir quantos valores ele vai transformar em NaN, e não como primeira tentativa.

    O `astype(int)` e o ausente

    Converter uma coluna com ausentes para inteiro falha, porque o NaN não é inteiro. O tipo anulável Int64 resolve:

    basico/cap10_limpeza_tipos_texto.pylinhas 45 a 49
    try:
        df["idade"].astype(int)
    except ValueError as erro:
        print(type(erro).__name__)
    print(df["idade"].astype("Int64").tolist()[4:8])
    
    Saída
    IntCastingNaNError
    [28, 29, <NA>, 31]
    

    Datas

    Uma data guardada como texto não serve para nada: não ordena por tempo, não subtrai, não extrai o ano. O to_datetime a transforma em um tipo de data de verdade, e o acessor .dt dá as peças:

    basico/cap10_limpeza_tipos_texto.pylinhas 54 a 56
    datas = pd.to_datetime(df["data_admissao"])
    print(datas.dtype, datas.dt.year.tolist()[:5])
    print(datas.min().date(), datas.max().date())
    
    Saída
    datetime64[us] [2021, 2022, 2023, 2024, 2021]
    2021-01-10 2024-08-17
    

    Nomes de colunas

    O rename devolve uma tabela nova e deixa a original intacta (não precisa de inplace). Para limpar todos os nomes de uma vez, encadeie os métodos de texto. Em português, tirar os acentos evita muita dor de cabeça com nomes de colunas, e o normalize faz isso:

    basico/cap10_limpeza_tipos_texto.pylinhas 61 a 74
    renomeada = df.rename(columns={"nome": "nome_completo"})
    print(renomeada.columns.tolist()[:3], df.columns.tolist()[:3])
    
    sujas = pd.Series([" Nome Completo ", "Salário (R$)", "Data de Admissão"])
    limpas = (
        sujas.str.strip()
        .str.lower()
        .str.normalize("NFKD")
        .str.encode("ascii", errors="ignore")
        .str.decode("ascii")
        .str.replace(r"[^a-z0-9]+", "_", regex=True)
        .str.strip("_")
    )
    print(limpas.tolist())
    
    Saída
    ['id_funcionario', 'nome_completo', 'departamento'] ['id_funcionario', 'nome', 'departamento']
    ['nome_completo', 'salario_r', 'data_de_admissao']
    

    O normalize("NFKD") separa cada letra acentuada em letra mais acento, e o encode("ascii", errors="ignore") descarta tudo o que não é ASCII (os acentos soltos), de modo que Salário vira Salario. Eu escolhi esse caminho de propósito, em vez de uma expressão regular que apague os acentos: com o pyarrow instalado, o tipo str do Pandas 3 usa o motor de expressões regulares do Arrow, que aceita \p{M} mas rejeita \u0300, e o módulo re do Python faz o contrário. O mesmo padrão pode funcionar em uma máquina e falhar em outra. O capítulo 17 volta a isso.

    Texto inconsistente

    O departamento tem engenharia e MARKETING ao lado de Engenharia e Marketing. A solução comum é strip().str.title(), mas o title() estraga siglas e preposições:

    basico/cap10_limpeza_tipos_texto.pylinha 79
    print(df["departamento"].str.strip().str.title().dropna().unique().tolist())
    
    Saída
    ['Engenharia', 'Marketing', 'Ciência De Dados', 'Rh', 'Finanças']
    

    Repare em Rh (a sigla perdeu a forma) e em Ciência De Dados (o De ganhou maiúscula). Em colunas de categorias conhecidas, o caminho mais seguro é um dicionário com os nomes corretos, aplicado com o map. E ele traz um bônus: o que não está no dicionário vira NaN, o que permite detectar valores inesperados:

    basico/cap10_limpeza_tipos_texto.pylinhas 81 a 91
    CANONICO = {
        "engenharia": "Engenharia",
        "marketing": "Marketing",
        "finanças": "Finanças",
        "ciência de dados": "Ciência de Dados",
        "rh": "RH",
    }
    limpo = df["departamento"].str.strip().str.lower().map(CANONICO)
    inesperados = df.loc[limpo.isna() & df["departamento"].notna(), "departamento"]
    print(limpo.value_counts(dropna=False).to_dict())
    print(len(inesperados))
    
    Saída
    {'Marketing': 4, 'Engenharia': 3, 'RH': 3, 'Ciência de Dados': 2, 'Finanças': 2, nan: 1}
    0
    

    Agora são 5 departamentos (mais 1 ausente), e nenhum valor inesperado: o que não estava no dicionário seria mostrado em inesperados. Se amanhã chegar Tecnologia em um arquivo novo, ele vira NaN e você é avisado, em vez de ganhar um sexto departamento em silêncio.

    Exercício 1

    Limpar o salário

    Escreva limpar_salario(serie), que receba uma Series de texto com valores como "R$ 4650", "4800" e ausentes, e devolva uma Series de números (float64), sem perder nenhum valor que exista.