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:
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])
passou, com a tolerância padrão
AssertionError: DataFrame.iloc[:, 1] (column name="b") are different
Os outros parâmetros que mais uso:
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")
AssertionError: Attributes of DataFrame.iloc[:, 0] (column name="a") are different
passou sem olhar a ordem das colunas
| Parâmetro | O que relaxa |
|---|---|
check_dtype=False | Aceita int contra float |
check_like=True | Ignora a ordem das linhas e das colunas |
check_exact=True | Aperta: exige igualdade exata nos decimais |
rtol e atol | A 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:
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))
['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:
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])
['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:
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__)
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.