94% de acurácia e completamente errado
Levei duas semanas construindo um classificador e três linhas de código para descobrir que um modelo que responde sempre a mesma coisa empatava com ele. A acurácia não mentiu — ela só não estava respondendo a pergunta que eu achava.
Mandei o número no canal do time numa sexta à tarde: 94% de acurácia. Tinha até emoji de foguete na mensagem, coisa que eu hoje me arrependo mais do que do modelo.
O problema era prever se uma aplicação de defensivo ia gerar rejeição na conferência — quando a carga chega no destino e alguém confere se o que foi aplicado bate com o que o contrato de comercialização aceita. Rejeição custa caro e custa tarde.
Na segunda de manhã, por curiosidade e não por método, rodei um DummyClassifier. Estratégia most_frequent: chuta sempre a classe mais comum, sem olhar para nenhuma variável. Uma linha e meia.
93,8%.
avaliacao/baseline_rejeicao.pyfrom sklearn.dummy import DummyClassifier
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import confusion_matrix, recall_score
# y = 1 quando a aplicação de defensivo gerou rejeição na conferência
# conjunto de teste: 2.400 aplicações, 148 rejeições (6,2%)
modelo = RandomForestClassifier(n_estimators=300, random_state=42)
modelo.fit(X_treino, y_treino)
burro = DummyClassifier(strategy="most_frequent")
burro.fit(X_treino, y_treino)
for nome, clf in [("floresta", modelo), ("burro ", burro)]:
pred = clf.predict(X_teste)
tn, fp, fn, tp = confusion_matrix(y_teste, pred).ravel()
acuracia = (tp + tn) / len(y_teste)
print(f"{nome} | acc={acuracia:.3f} | recall={recall_score(y_teste, pred):.3f} "
f"| tp={tp} fn={fn} fp={fp} tn={tn}")
# floresta | acc=0.940 | recall=0.095 | tp=14 fn=134 fp=9 tn=2243
# burro | acc=0.938 | recall=0.000 | tp=0 fn=148 fp=0 tn=2252
Olha a linha de baixo com carinho. O modelo burro acerta 93,8% das previsões e o número de rejeições que ele pega é zero. Ele não tem a menor ideia do que é uma rejeição — só aprendeu que responder "não" é a aposta que erra menos.
E a minha floresta com 300 árvores, cinquenta e uma features e duas semanas de feature engineering pegou 14 de 148. Recall de 9,5%. A diferença entre inteligência e burrice ali cabia dentro do ruído de amostragem.
Esse é o ponto que quero defender: acurácia em base desbalanceada é o console.log da estatística. Parece informação, tem cara de informação, e o que ela te conta na prática é a proporção da classe majoritária com passos extras.
O modelo burro entra em toda apresentação
Depois desse tombo eu virei chato com uma regra só, e ela é a coisa mais barata deste texto inteiro.
Nenhum resultado de modelo pode ser apresentado sozinho. Ele vem sempre com o resultado do modelo burro na mesma tabela, na mesma métrica, no mesmo conjunto de teste. Classe majoritária para classificação, média ou último valor observado para regressão, ou a regra de negócio de uma linha que o pessoal da operação já usa de cabeça. Se o modelo não bate o burro por uma margem que justifique o custo de manter aquilo em produção, ele não vai para produção.
A segunda parte da regra é a que dói. Não basta ganhar — tem que ganhar o suficiente para pagar o preço de existir: pipeline de features, versionamento, retreino, monitoramento, alguém de plantão quando a predição sai maluca. Se o ganho em cima do "sempre não" é de 1,4 ponto, a resposta honesta é jogar fora e escrever um if.
Vejo gente ficar ofendida com isso, como se fosse desrespeito com o trabalho. É o contrário. É a única coisa que separa um resultado de modelo de um resultado de sorte.
A métrica sai do custo do erro, não do gosto
A conversa sobre "qual métrica usar" quase sempre é conduzida como preferência técnica. F1 é mais completa, AUC é mais robusta, e por aí vai. Está errada de origem. A métrica sai de uma pergunta de negócio: quanto custa cada tipo de erro?
Na rejeição, os dois erros são assimétricos de um jeito quase cômico. Falso negativo é uma carga que sai da fazenda, roda até o destino e é rejeitada — frete de retorno, multa contratual, um dia de caminhão parado. Falso positivo é uma conferência manual que não precisava acontecer: duas horas de alguém revisando papel.
Não são erros comparáveis. Tratar os dois com o mesmo peso, que é exatamente o que o F1 faz, é uma decisão — só que uma decisão tomada por omissão.
| Métrica | O que ela responde | Quando ela engana |
|---|---|---|
| Acurácia | Que fração de todas as previsões bateu | Sempre que uma classe domina. Com 6% de positivo, responder "não" para tudo já dá 94% |
| Precisão | Dos que marquei como positivo, quantos eram mesmo | Sobe sozinha quando o modelo fica covarde e só marca o caso óbvio. Precisão alta com recall no chão é modelo que aprendeu a calar a boca |
| Recall | Dos que eram positivos, quantos eu peguei | Vai a 100% marcando tudo. Sozinho, incentiva o modelo a gritar o dia inteiro |
| F1 | Equilíbrio entre precisão e recall | Assume que os dois erros custam igual. Aqui um custa 37 vezes o outro |
| ROC AUC | O modelo ordena bem os casos por risco? | Fica otimista em base desbalanceada: os verdadeiros negativos são tantos que a curva sai bonita mesmo com recall péssimo |
| PR AUC | Ele ordena bem dentro da classe rara? | Depende da prevalência — não compare o valor entre bases com proporções diferentes de positivo |
| Matriz de confusão | Quantos casos de cada tipo de erro, em unidades | Não engana. Só é incômoda de colocar no slide, que é justamente o motivo de ela sumir |
E tem uma escolha ainda mais invisível que a da métrica: o limiar de 0,5. Todo mundo chama predict() e aceita 0,5 como se fosse lei da natureza. Não é. É o default de uma função. O modelo devolve probabilidade; transformar probabilidade em decisão é onde o custo do erro entra, e o lugar certo de decidir isso é uma curva, não um argumento padrão.
import numpy as np
from sklearn.metrics import confusion_matrix
CUSTO_FN = 2400.00 # carga rejeitada: frete de retorno + reclassificação + multa
CUSTO_FP = 65.00 # duas horas de conferência manual desnecessária
def limiar_de_menor_custo(y_true, y_prob):
grade = np.linspace(0.02, 0.98, 97)
custos = []
for limiar in grade:
pred = (y_prob >= limiar).astype(int)
tn, fp, fn, tp = confusion_matrix(y_true, pred).ravel()
custos.append(fn * CUSTO_FN + fp * CUSTO_FP)
i = int(np.argmin(custos))
return grade[i], custos[i]
y_prob = modelo.predict_proba(X_teste)[:, 1]
# limiar=0.50 -> fn=53 fp=96 -> R$ 133.440 de erro esperado
# limiar=0.18 -> fn=19 fp=310 -> R$ 65.750 de erro esperado
Mesmo modelo, mesmos pesos, mesmo conjunto de teste. Metade do prejuízo, só mexendo num número que ninguém tinha olhado. O custo de 214 conferências manuais a mais é ridículo perto de 34 cargas que deixaram de ser rejeitadas.
Sim, esses dois custos são estimativas grosseiras. Não importa. Uma estimativa grosseira do custo real bate uma precisão falsa em cima de um default arbitrário todo dia da semana.
Leakage: a falha que engana justamente porque melhora o número
Agora a parte em que eu conto a besteira maior.
Meses depois, num modelo de previsão de atraso na conferência, cheguei em 0,97 de ROC AUC. Eu sabia que aquilo era bom demais, e sabia do jeito errado: em vez de investigar, fui apresentar. Foi um colega da operação que estragou a festa com uma pergunta que não era nem técnica. "Esse campo aqui, de quantos itens foram conferidos... isso só é preenchido depois da conferência, né?"
Era. O modelo previa a conferência usando uma coluna que só existe depois da conferência.
O jeito de descobrir isso sem depender da sorte é ridiculamente simples e eu não fazia: ordenar feature_importances_ e olhar o topo. Uma feature com 0,61 de importância isolada não é sinal de que você é bom, é sinal de que ela sabe a resposta. Toda variável que ficar suspeitamente forte merece a pergunta "esse valor já existia no momento em que a predição precisaria ser feita?" — e essa pergunta se responde na tabela de origem, olhando quando a coluna é preenchida, não no notebook.
O leakage clássico, porém, é mais silencioso que isso e mora numa linha que parece inofensiva:
modelagem/preparo.py# ERRADO — o scaler aprendeu média e desvio da base inteira, teste incluído.
# O conjunto de teste vazou para dentro do treino sem ninguém perceber.
X = StandardScaler().fit_transform(aplicacoes[NUMERICAS])
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=0.2, random_state=42)
# CERTO — tudo que aprende parâmetro a partir do dado fica dentro do Pipeline,
# e o Pipeline é reajustado a cada fold. Imputação de mediana também vaza.
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler
NUMERICAS = ["dose_l_ha", "volume_calda", "velocidade_vento",
"area_ha", "dias_desde_ultima_aplicacao"]
CATEGORICAS = ["cultura", "classe_produto", "regiao"]
preparo = ColumnTransformer([
("num", Pipeline([
("imputa", SimpleImputer(strategy="median")),
("escala", StandardScaler()),
]), NUMERICAS),
("cat", OneHotEncoder(handle_unknown="ignore"), CATEGORICAS),
])
pipe = Pipeline([
("preparo", preparo),
("modelo", RandomForestClassifier(n_estimators=300, random_state=42)),
])
A diferença entre os dois blocos, no meu caso, foram 3 pontos de AUC. Três pontos que eu teria reportado como resultado e que a produção cobraria de volta com juros.
Embaralhar dado com tempo é deixar o modelo ver o futuro
O terceiro jeito de vazar dado não está no preprocessing, está no split — e o train_test_split aleatório, que é o primeiro comando que qualquer curso ensina, está errado em boa parte dos problemas reais.
Dado agrícola tem tempo e tem grupo. Tem tempo porque safra 2024/25 não é safra 2025/26: mudou preço de insumo, mudou clima, mudou norma de rastreabilidade. Se eu embaralho tudo e sorteio 20% para teste, o modelo treina com aplicações de março e é avaliado em janeiro do mesmo ano. Ele viu o futuro. Em produção nunca vai ver, e o número que eu apresentei nunca vai se repetir.
Tem grupo porque o mesmo produtor aparece centenas de vezes na base, com o mesmo maquinário e os mesmos vícios de aplicação. Sorteio aleatório coloca esse produtor nos dois lados. O modelo decora e eu chamo isso de generalização.
modelagem/particoes.pyimport pandas as pd
from sklearn.model_selection import GroupShuffleSplit, TimeSeriesSplit
# 1) corte temporal: treina no passado, avalia no futuro. Nunca embaralhar.
corte = pd.Timestamp("2025-07-01") # início da safra que quero prever
treino = aplicacoes[aplicacoes["dt_aplicacao"] < corte]
teste = aplicacoes[aplicacoes["dt_aplicacao"] >= corte]
# 2) validação cruzada respeitando a ordem, para tunar hiperparâmetro
cv_temporal = TimeSeriesSplit(n_splits=5)
# 3) quando o vazamento é por entidade e não por tempo: nenhum produtor
# pode estar no treino e no teste ao mesmo tempo.
particionador = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
i_treino, i_teste = next(particionador.split(
aplicacoes, y, groups=aplicacoes["cd_produtor"]
))
# AUC com split aleatório .............. 0.91
# AUC com split por produtor ........... 0.78 <- este é o número real
Treze pontos de AUC eram memória de produtor. Doeu, e foi o resultado mais útil daquele mês.
O que QA já resolveu e ML ainda trata como opcional
Vim de automação de teste para modelagem e a primeira coisa que me incomodou foi perceber que a comunidade de ML aceita como normal um nível de validação que qualquer QA reprovaria numa revisão de PR.
Começa pelo básico: reprodutibilidade. Notebook que dá resultado diferente a cada execução porque a semente não está fixa em todo lugar que sorteia — no split, no modelo, no shuffle do batch — é a mesma dívida que torna dado de teste uma dívida técnica, com a agravante de que aqui o resultado não determinístico costuma ser apresentado. Se eu não consigo reproduzir o 0,94, o 0,94 não é um resultado, é uma anedota.
Depois vêm três coisas que a disciplina de teste resolveu há vinte anos e que quase nenhum projeto de ML tem:
- Teste de regressão. Um conjunto congelado de casos que o modelo já acerta e não pode voltar a errar. Roda a cada retreino; se um caso que passava parou de passar, o retreino não sobe.
- Teste de invariância. Mudo alguma coisa que não deveria mexer na predição e verifico que não mexeu. Trocar o nome do produtor, mudar a unidade de medida com conversão correta, permutar a ordem das linhas do lote. Se a predição muda, o modelo está usando algo que não devia — e isso pega leakage e viés no mesmo teste.
- Teste de comportamento por fatia. O modelo tem que se comportar de um jeito conhecido em subgrupos específicos, e isso se afirma antes de olhar o resultado, não depois.
E aí está a parte em que a analogia com teste de software quebra, e ela quebra de verdade: em ML, o esperado não é um valor, é uma faixa. Não existe "passou/falhou" limpo, existe distribuição de erro. A asserção precisa de tolerância — assert recall >= 0.62 em vez de igualdade — e aqui vai a coisa mais importante desta seção: quem define esse 0,62 não é a engenharia. É decisão de produto, porque é uma decisão sobre quanto prejuízo é aceitável. Time técnico que escolhe sozinho a tolerância está tomando decisão de negócio escondido dentro de um arquivo de teste.
Média boa, fatia inútil
Métrica agregada é média, e média esconde. O modelo pode ir bem no geral e ser lixo para a safrinha, ou para propriedade pequena, ou para uma região onde o padrão de aplicação é outro. É o mesmo mecanismo que faz cobertura de teste mentir: um número alto no topo com buracos exatamente onde o risco mora.
avaliacao/fatias.pyimport pandas as pd
from sklearn.metrics import precision_score, recall_score
FATIAS = ["tipo_safra", "porte_propriedade", "regiao"]
def avaliar_por_fatia(df, y_true, y_prob, limiar=0.18, minimo_positivos=25):
"""Recall e precisão por subgrupo. Fatia com poucos positivos não
recebe nota — recebe aviso, que é diferente de recebe aprovação."""
pred = (y_prob >= limiar).astype(int)
linhas = []
for coluna in FATIAS:
for valor in df[coluna].unique():
m = (df[coluna] == valor).to_numpy()
positivos = int(y_true[m].sum())
linhas.append({
"fatia": f"{coluna}={valor}",
"n": int(m.sum()),
"positivos": positivos,
"recall": round(recall_score(y_true[m], pred[m]), 3)
if positivos >= minimo_positivos else None,
"precisao": round(precision_score(y_true[m], pred[m], zero_division=0), 3),
})
return pd.DataFrame(linhas).sort_values("recall", na_position="first")
# fatia n positivos recall precisao
# tipo_safra=safrinha 412 61 0.164 0.500
# regiao=oeste_ba 288 39 0.410 0.552
# porte_propriedade=pequena 734 48 0.437 0.488
# tipo_safra=verao 1988 87 0.874 0.601
# AGREGADO 2400 148 0.872 0.571
Recall agregado de 87%. Recall na safrinha: 16,4%. Se eu subir isso e a equipe de conferência confiar no modelo, a safrinha vira ponto cego — e ponto cego é pior que não ter modelo, porque agora existe uma ferramenta dizendo que está tudo bem.
Avaliação fatiada não é análise extra de quem tem tempo sobrando. É critério de deploy. E ela tem que ser desenhada antes de rodar, senão vira caça a subgrupo que dá certo.
Modelo não quebra. Ele envelhece.
Um serviço quebrado grita: stack trace, 500, container em loop. Modelo degradado continua respondendo com a mesma cara de confiança de sempre, só que errado — a mesma falha silenciosa que faz um pipeline de dados mentir, agora com uma camada de probabilidade por cima para parecer mais convincente.
São dois envelhecimentos diferentes e vale saber qual é qual. Drift de dado de entrada é quando a distribuição das features muda: entrou uma cooperativa nova com talhões três vezes maiores, ou a integração passou a mandar dose em mL/ha e não em L/ha. Drift de conceito é mais cruel — a relação entre as features e o alvo mudou. Uma norma de rastreabilidade nova entra em vigor e o que causava rejeição em 2025 não é o que causa em 2026. Os dados de entrada continuam iguais. A resposta certa é que mudou.
O detalhe operacional que quase ninguém trata: o rótulo verdadeiro demora. A conferência do destino leva de duas a cinco semanas para virar registro. Quem só monitora métrica de acerto descobre a degradação com um mês e meio de atraso — um mês e meio de decisão tomada em cima de predição estragada. Por isso o monitoramento tem que olhar o que chega antes de o rótulo existir: distribuição de cada feature contra a janela de referência, taxa de nulo, cardinalidade de categórica, e a distribuição das próprias probabilidades que o modelo emite. Score médio despencando em relação ao histórico é sinal de que aconteceu alguma coisa, e eu prefiro saber hoje.
"O negócio só quer um número pro slide"
Esse é o contra-argumento honesto e eu já o ouvi de gente que tem razão em boa parte do que diz. O diretor não vai ler matriz de confusão. A reunião tem quarenta minutos e sete pautas. Chegar com sete métricas e uma tabela de fatias é jeito garantido de perder a sala.
Concordo com o diagnóstico e discordo da conclusão. A resposta não é entregar menos número, é entregar o número certo — e eu entrego três, que continua sendo pouco o bastante para caber num slide:
- A métrica que traduz dinheiro. Aqui: quantas rejeições o modelo pega e quantas conferências extras isso custa.
- O mesmo número para o baseline burro, do lado, para a pessoa ver o quanto o modelo adiciona.
- A pior fatia, nomeada. "Funciona, menos na safrinha, onde pega 1 em 6."
Isso não é purismo acadêmico e nem é chatice de QA. É o mínimo para que a decisão de investir três meses de time seja tomada em cima de um fato e não de uma ilusão de ótica. Aliás, na prática o item 3 é o que mais faz o negócio te respeitar: chegar dizendo onde a sua própria coisa não funciona compra uma confiança que nenhum número bonito compra.
Quando alguém mostra 94% e mais nada, a pergunta certa não é sobre o modelo. É sobre quanto dá o burro.
Na maior parte das vezes que eu fiz essa pergunta, ninguém tinha rodado.