Pular para o conteúdo

    Capítulo 33, Avançado

    Testes e validação de dados

    Um programa tem testes. Uma análise de dados também deveria: do código que limpa e do dado que chega. Aqui estão as duas coisas, e a ferramenta que o Pandas oferece para comparar tabelas.

    Comparar tabelas: `pandas.testing`

    Um assert a == b com tabelas não funciona (compara elemento a elemento e devolve uma tabela de booleanos). O assert_frame_equal compara tudo (valores, tipos, índice, nomes das colunas), e diz onde a diferença está. Por padrão, os decimais são comparados com tolerância, e você pode relaxar ou apertar cada coisa:

    avancado/cap33_testes_dados.pylinhas 10 a 22
    import numpy as np
    import pandas as pd
    from pandas.testing import assert_frame_equal
    
    esperado = pd.DataFrame({"a": [1, 2, 3], "b": [0.1, 0.2, 0.3]})
    obtido = pd.DataFrame({"a": [1, 2, 3], "b": [0.1, 0.2, 0.30000001]})
    
    assert_frame_equal(esperado, obtido)
    print("passou, com a tolerância padrão")
    try:
        assert_frame_equal(esperado, obtido, check_exact=True)
    except AssertionError as erro:
        print("AssertionError:", str(erro).splitlines()[0])
    
    Saída
    passou, com a tolerância padrão
    AssertionError: DataFrame.iloc[:, 1] (column name="b") are different
    

    Os outros parâmetros que mais uso:

    avancado/cap33_testes_dados.pylinhas 24 a 35
    inteiros = pd.DataFrame({"a": [1, 2]})
    decimais = pd.DataFrame({"a": [1.0, 2.0]})
    try:
        assert_frame_equal(inteiros, decimais)
    except AssertionError as erro:
        print("AssertionError:", str(erro).splitlines()[0])
    assert_frame_equal(inteiros, decimais, check_dtype=False)
    
    ordem_a = pd.DataFrame({"x": [1, 2], "y": [3, 4]})
    ordem_b = ordem_a[["y", "x"]]
    assert_frame_equal(ordem_a, ordem_b, check_like=True)
    print("passou sem olhar a ordem das colunas")
    
    Saída
    AssertionError: Attributes of DataFrame.iloc[:, 0] (column name="a") are different
    passou sem olhar a ordem das colunas
    
    ParâmetroO que relaxa
    check_dtype=FalseAceita int contra float
    check_like=TrueIgnora a ordem das linhas e das colunas
    check_exact=TrueAperta: exige igualdade exata nos decimais
    rtol e atolA tolerância relativa e a absoluta (como no np.isclose)

    Validar o dado que chega

    Testar o código não protege você do arquivo que chega amanhã com um problema novo. Uma função de validação descreve o que o dado precisa ser, e devolve todos os problemas de uma vez (e não só o primeiro), o que economiza muita ida e volta:

    avancado/cap33_testes_dados.pylinhas 40 a 71
    class ContratoViolado(ValueError):
        pass
    
    
    def validar_pedidos(pedidos: pd.DataFrame, clientes: pd.DataFrame) -> list[str]:
        problemas = []
        if pedidos["id_pedido"].duplicated().any():
            problemas.append("id_pedido repetido")
        for coluna in ("id_pedido", "id_cliente", "id_produto", "quantidade", "data_pedido"):
            if pedidos[coluna].isna().any():
                problemas.append(f"{coluna} com ausentes")
        if (pedidos["quantidade"] <= 0).any():
            problemas.append("quantidade não positiva")
        if not pedidos["canal"].isin({"site", "loja", "app"}).all():
            problemas.append("canal desconhecido")
        if not pedidos["desconto"].dropna().between(0, 1).all():
            problemas.append("desconto fora de 0 a 1")
        orfaos = ~pedidos["id_cliente"].isin(clientes["id_cliente"])
        if orfaos.any():
            problemas.append(f"{int(orfaos.sum())} pedidos de clientes inexistentes")
        return problemas
    
    
    def exigir_contrato(pedidos: pd.DataFrame, clientes: pd.DataFrame) -> None:
        problemas = validar_pedidos(pedidos, clientes)
        if problemas:
            raise ContratoViolado("; ".join(problemas))
    
    
    clientes = pd.read_csv("dados/clientes.csv")
    pedidos = pd.read_csv("dados/pedidos.csv", parse_dates=["data_pedido"])
    print(validar_pedidos(pedidos, clientes))
    
    Saída
    ['12 pedidos de clientes inexistentes']
    

    A validação achou os 12 pedidos de clientes inexistentes que o capítulo 21 descobriu à mão. Com dados estragados de propósito, ela mostra vários problemas de uma vez:

    avancado/cap33_testes_dados.pylinhas 73 a 82
    estragados = pedidos.copy()
    estragados.loc[0, "quantidade"] = -3
    estragados.loc[1, "canal"] = "telefone"
    estragados.loc[2, "desconto"] = 1.5
    estragados.loc[3, "id_pedido"] = estragados.loc[4, "id_pedido"]
    print(validar_pedidos(estragados, clientes))
    try:
        exigir_contrato(estragados, clientes)
    except ContratoViolado as erro:
        print("ContratoViolado:", str(erro)[:60])
    
    Saída
    ['id_pedido repetido', 'quantidade não positiva', 'canal desconhecido', 'desconto fora de 0 a 1', '12 pedidos de clientes inexistentes']
    ContratoViolado: id_pedido repetido; quantidade não positiva; canal desconhec
    

    Testar o código de limpeza

    Uma função de limpeza merece testes como qualquer outra, e três propriedades valem para quase todas: ela não altera a tabela recebida, é idempotente (limpar duas vezes dá o mesmo que limpar uma) e funciona em uma tabela pequena e escrita à mão, onde você sabe a resposta:

    avancado/cap33_testes_dados.pylinhas 87 a 115
    def limpar(tabela: pd.DataFrame) -> pd.DataFrame:
        return (
            tabela.assign(nome=lambda d: d["nome"].str.strip().str.title())
            .drop_duplicates("id")
            .reset_index(drop=True)
        )
    
    
    def test_limpeza_com_resposta_conhecida():
        entrada = pd.DataFrame({"id": [1, 1, 2], "nome": ["  ana souza ", "  ana souza ", "BRUNO LIMA"]})
        esperado = pd.DataFrame({"id": [1, 2], "nome": ["Ana Souza", "Bruno Lima"]})
        assert_frame_equal(limpar(entrada), esperado)
    
    
    def test_limpeza_nao_altera_a_entrada():
        entrada = pd.DataFrame({"id": [1, 2], "nome": [" a ", " b "]})
        copia = entrada.copy()
        limpar(entrada)
        assert_frame_equal(entrada, copia)
    
    
    def test_limpeza_e_idempotente():
        entrada = pd.DataFrame({"id": [1, 2, 2], "nome": [" a ", " b ", " b "]})
        assert_frame_equal(limpar(limpar(entrada)), limpar(entrada))
    
    
    for teste in (test_limpeza_com_resposta_conhecida, test_limpeza_nao_altera_a_entrada, test_limpeza_e_idempotente):
        teste()
        print("passou:", teste.__name__)
    
    Saída
    passou: test_limpeza_com_resposta_conhecida
    passou: test_limpeza_nao_altera_a_entrada
    passou: test_limpeza_e_idempotente
    

    Em um projeto, essas funções test_* ficam em arquivos test_*.py e rodam com uv run pytest: os projetos do final do curso fazem isso. Aqui eu as chamei diretamente, para mostrar o resultado.

    O que eu valido sempre

    Antes de qualquer análise: chaves únicas onde deveriam ser, sem ausentes onde não podem existir, valores dentro de faixas razoáveis (quantidade positiva, desconto entre 0 e 1), categorias conhecidas e integridade referencial (todo pedido tem cliente). Essas cinco perguntas pegam a maior parte dos arquivos estragados.

    Exercício 1

    Uma regra nova

    Acrescente à validação uma regra: data_pedido não pode ser futura (depois de 2025-12-31). Escreva datas_validas(pedidos), que devolva a lista de problemas (vazia se estiver tudo certo), e confira com a tabela original e com uma data futura inserida.