Até aqui, o desbalanceamento foi tratado como um problema de contagem: uma classe tem poucos exemplos, então o modelo aprende a ignorá-la. Mas o desbalanceamento quase sempre vem acompanhado de um problema mais fundamental, e é esse que realmente importa: os dois tipos de erro não custam a mesma coisa.
Deixar uma fraude passar custa o valor da transação. Bloquear um cliente legítimo custa o atrito, o atendimento e a chance de perdê-lo. Não diagnosticar uma doença grave custa uma vida; um falso alarme custa um exame a mais. Tratar esses pares como equivalentes — que é exatamente o que a acurácia faz — é o erro conceitual mais caro em aprendizado supervisionado aplicado.
A aprendizagem sensível ao custo troca a pergunta "quantos acertos?" por "qual o prejuízo esperado?".
Formular o problema em termos de matriz de custos, aplicar as três estratégias de incorporação de custo (pesos, limiar ótimo e reamostragem), calcular o limiar que minimiza o prejuízo esperado, e avaliar modelos por custo em vez de por métricas de contagem.
A matriz de custos
A matriz de confusão conta ocorrências. A matriz de custos atribui valor a cada célula:
| Predito negativo | Predito positivo | |
|---|---|---|
| Real negativo | 0 | C_FP |
| Real positivo | C_FN | 0 |
Numa detecção de fraude com ticket médio de R$ 800 e custo de atrito de R$ 20 por bloqueio indevido:
C_FN = 800 # fraude que passou: perdemos o valor
C_FP = 20 # cliente legítimo bloqueado: atrito e atendimento
def custo_total(y_real, y_pred, c_fn=C_FN, c_fp=C_FP):
fn = ((y_real == 1) & (y_pred == 0)).sum()
fp = ((y_real == 0) & (y_pred == 1)).sum()
return fn * c_fn + fp * c_fp
A razão C_FN / C_FP = 40 é a informação central: um falso negativo custa 40 falsos positivos. Ela determina tudo o que vem depois.
Levantar esses números é trabalho de negócio, não de modelagem, e costuma ser a parte mais difícil — mas mesmo uma estimativa grosseira da ordem de grandeza é infinitamente melhor que a suposição implícita de custos iguais, que é o que a acurácia assume.
Três formas de incorporar custo
Pesos na função de perda
A mais direta: dizer ao algoritmo que errar um positivo dói mais.
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
pesos = {0: 1.0, 1: C_FN / C_FP} # {0: 1, 1: 40}
modelo = LogisticRegression(class_weight=pesos, max_iter=2000)
floresta = RandomForestClassifier(class_weight=pesos, n_estimators=400)
O atalho class_weight="balanced" define os pesos pela frequência inversa das classes. É um padrão razoável quando você não conhece os custos — mas note que ele responde a uma pergunta diferente: equilibra a representação, não o prejuízo. Se você tem os custos, use-os.
Em R, com tidymodels, o mesmo se expressa por pesos de caso:
library(tidymodels)
dados <- dados |>
mutate(peso = importance_weights(ifelse(fraude == "sim", 40, 1)))
ajuste <- workflow() |>
add_recipe(receita) |>
add_model(logistic_reg()) |>
add_case_weights(peso) |>
fit(treino)
Limiar ótimo: a estratégia mais subestimada
Quase todo classificador produz uma probabilidade, e o corte em 0,5 é uma convenção — não um resultado. Mover o limiar é a forma mais barata e mais eficaz de incorporar custo, porque não exige retreinar nada.
A teoria dá o ponto exato. Classificar como positivo compensa quando o prejuízo esperado de fazê-lo é menor que o de não fazer:
$$p \cdot 0 + (1-p) \cdot C_{FP} < p \cdot C_{FN} + (1-p) \cdot 0$$
Resolvendo:
$$p^* = \frac{C_{FP}}{C_{FP} + C_{FN}}$$
Com os números do exemplo: 20 / (20 + 800) ≈ 0,024. Ou seja, vale bloquear a transação quando a probabilidade de fraude passa de 2,4% — muito longe dos 50% do padrão.
import numpy as np
limiar_teorico = C_FP / (C_FP + C_FN)
# e o empírico, que respeita a calibração real do modelo
def limiar_otimo(y_real, prob, c_fn=C_FN, c_fp=C_FP):
candidatos = np.unique(prob)
custos = [custo_total(y_real, (prob >= t).astype(int), c_fn, c_fp) for t in candidatos]
return candidatos[int(np.argmin(custos))]
t = limiar_otimo(y_val, modelo.predict_proba(X_val)[:, 1])
A fórmula teórica vale se o modelo estiver calibrado — se, quando ele diz 0,3, a frequência real for mesmo 30%. Muitos modelos não estão (florestas e SVMs notoriamente). Por isso, calcule também o limiar empírico num conjunto de validação e compare: uma divergência grande entre os dois é um sinal de que vale calibrar o modelo antes.
Regra que evita vazamento: o limiar é um hiperparâmetro. Escolha-o em validação, avalie-o no teste. Escolher o limiar no teste e reportar o custo desse mesmo teste é auto-engano.
Reamostragem proporcional ao custo
Replicar exemplos da classe cara, na proporção do custo, aproxima o efeito dos pesos. É a alternativa para algoritmos que não aceitam class_weight. Tem duas desvantagens frente aos pesos: aumenta o tempo de treino e, com duplicação simples, favorece o sobreajuste nos exemplos replicados. Prefira pesos quando disponíveis.
Custos que variam por exemplo
O caso realista é mais incômodo: numa fraude, C_FN é o valor da transação. Deixar passar uma de R$ 50 não é o mesmo que deixar passar uma de R$ 12.000.
Com custo por exemplo, o limiar deixa de ser único e passa a ser calculado por transação:
def decidir(prob, valor, c_fp=20.0):
"""Bloqueia quando o prejuízo esperado de deixar passar supera o do atrito."""
limiar = c_fp / (c_fp + valor)
return prob >= limiar
Uma transação de R$ 12.000 tem limiar de 0,17%; uma de R$ 50, de 28,6%. O mesmo modelo, a mesma probabilidade — decisões opostas, e ambas corretas.
Esse é o ponto em que a separação entre modelo e decisão deixa de ser elegância arquitetural e vira necessidade. O modelo estima probabilidade; a política de decisão combina essa probabilidade com o contexto econômico de cada caso. Misturar as duas coisas num classificador que cospe 0 ou 1 joga fora a informação que torna a decisão boa.
Avaliar por custo
Se a decisão é por custo, a avaliação também precisa ser — e a comparação entre modelos muda de cara:
from sklearn.model_selection import StratifiedKFold
import numpy as np
def custo_medio_cv(modelo, X, y, valores, c_fp=20.0, folds=5):
cv = StratifiedKFold(n_splits=folds, shuffle=True, random_state=42)
custos = []
for treino_idx, teste_idx in cv.split(X, y):
modelo.fit(X[treino_idx], y[treino_idx])
prob = modelo.predict_proba(X[teste_idx])[:, 1]
limiar = c_fp / (c_fp + valores[teste_idx])
pred = (prob >= limiar).astype(int)
fn = ((y[teste_idx] == 1) & (pred == 0))
fp = ((y[teste_idx] == 0) & (pred == 1))
custos.append((valores[teste_idx][fn].sum()) + fp.sum() * c_fp)
return np.mean(custos), np.std(custos)
É comum e saudável que o modelo com melhor AUC não seja o de menor custo. AUC resume o desempenho em todos os limiares; o custo avalia o desempenho no limiar que você vai usar, com os pesos que importam. Quando os dois discordam, o custo ganha.
Vale sempre reportar junto a linha de base trivial: qual o custo de não ter modelo nenhum (aprovar tudo, ou bloquear tudo). Um modelo que reduz o prejuízo em 3% frente a "aprovar tudo" pode não pagar o próprio custo de manutenção — e isso precisa aparecer no relatório.
Erros frequentes
Otimizar F1 quando existe matriz de custos. F1 assume implicitamente que precisão e revocação valem o mesmo. Se você sabe que um erro custa 40 vezes o outro, F1 está respondendo a outra pergunta.
Aplicar class_weight="balanced" achando que resolve o custo. Ele equilibra frequência, não prejuízo. Numa base com 1% de positivos e razão de custo 40:1, a coincidência entre os dois é acidental.
Reportar acurácia depois de todo o trabalho. Um modelo sensível a custo vai ter acurácia pior que o ingênuo — ele troca muitos falsos positivos baratos por poucos falsos negativos caros, de propósito. Reportar acurácia desfaz a narrativa e confunde quem decide.
O desbalanceamento raramente é só de contagem: os erros têm custos diferentes, e a razão C_FN / C_FP é a informação que organiza tudo o mais. Há três estratégias — pesos na perda, limiar ótimo e reamostragem proporcional —, e as duas primeiras são preferíveis; a reamostragem fica para quando o algoritmo não aceita peso. O limiar que minimiza o prejuízo é p* = C_FP / (C_FP + C_FN): com custos de 20 e 800, dá 0,024, não 0,5. Mover o limiar é a intervenção mais barata que existe, porque não exige retreinar nada. A fórmula supõe um modelo calibrado, então calcule também o limiar empírico em validação — divergência grande é sinal de que vale calibrar. Limiar é hiperparâmetro: escolhe-se em validação e avalia-se no teste. Quando o custo varia por exemplo, como o valor de cada transação, ele passa a ser calculado caso a caso, e aí é preciso separar a estimativa de probabilidade da política de decisão. Avalie por custo, não por AUC ou F1, e compare sempre com a linha de base trivial. Por fim, saiba de antemão: o modelo sensível a custo terá acurácia pior, de propósito — reportar acurácia desfaz o próprio argumento.
Exercícios
-
Num diagnóstico,
C_FN = 5000(doença não detectada) eC_FP = 50(exame adicional). Calcule o limiar ótimo e interprete.✓ Resposta:p* = C_FP / (C_FP + C_FN) = 50 / (50 + 5000) = 50 / 5050 ≈ 0,0099, ou seja, cerca de 1%. A interpretação: vale encaminhar para investigação sempre que a probabilidade estimada de doença passar de 1%, porque o prejuízo esperado de investigar (R$ 50 com 99% de chance de ser desnecessário) continua menor que o de não investigar (R$ 5.000 com 1% de chance). Isso gera muitos falsos positivos — e essa é a decisão correta dada a assimetria. O limiar padrão de 0,5 é uma escolha implícita de que os dois erros custam o mesmo, o que aqui é claramente falso. -
Por que
class_weight="balanced"não substitui a matriz de custos?✓ Resposta:Porque ele responde a outra pergunta. O
"balanced"define os pesos pela frequência inversa das classes — ele corrige a sub-representação no treino, um problema de amostragem. A matriz de custos descreve a assimetria econômica dos erros, que é independente da frequência. Uma classe rara pode ter erro barato (um evento incomum e inofensivo) e uma classe comum pode ter erro caro. Os dois só coincidem por acaso. Quando os custos são conhecidos, passe-os explicitamente ({0: 1, 1: C_FN/C_FP}); o"balanced"é o padrão razoável apenas na ausência dessa informação. -
Explique por que o limiar deve ser escolhido em validação e não no conjunto de teste.
✓ Resposta:Porque o limiar é um hiperparâmetro — um valor ajustado a partir dos dados. Escolhê-lo varrendo o conjunto de teste em busca do menor custo e depois reportar esse custo é selecionar o melhor resultado no mesmo conjunto usado para medir, o que produz uma estimativa otimista que não se sustenta em produção. É a mesma forma de vazamento que ocorre ao escolher hiperparâmetros no teste. O procedimento correto é: treinar no treino, escolher o limiar na validação (ou por validação cruzada dentro do treino), e aplicar esse limiar fixo ao teste, reportando o custo obtido ali.
-
Num cenário em que
C_FNé o valor da transação, por que não existe um limiar único?✓ Resposta:Porque o limiar ótimo depende do custo, e o custo varia por exemplo:
limiar = C_FP / (C_FP + valor). ComC_FP = 20, uma transação de R$ 12.000 tem limiar ≈ 0,0017 e uma de R$ 50 tem limiar ≈ 0,286 — quase 170 vezes maior. O mesmo modelo, diante da mesma probabilidade estimada de 5%, bloqueia a primeira e aprova a segunda, e ambas as decisões são corretas. Isso força a separação entre o modelo (que estima probabilidade) e a política de decisão (que combina probabilidade com o contexto econômico do caso). Um classificador que devolve apenas 0 ou 1 descarta exatamente a informação necessária para essa distinção. -
Seu modelo sensível a custo tem acurácia de 91% contra 99,2% do ingênuo. Como apresentar isso à diretoria?
✓ Resposta:Não apresentando acurácia. Numa base com 1% de positivos, o modelo ingênuo que prevê "tudo negativo" atinge 99% de acurácia sem detectar uma única fraude — a métrica é vazia nesse contexto. A apresentação correta é em dinheiro: prejuízo esperado por mês com o modelo atual, com o novo, e com a linha de base trivial de não fazer nada. Complementa-se com o que a diretoria consegue acionar: quantas fraudes a mais são detectadas, quantos clientes legítimos a mais são incomodados, e qual o saldo líquido. A queda de acurácia é uma consequência desejada do desenho — trocar muitos falsos positivos baratos por poucos falsos negativos caros — e deve ser explicada nesses termos, não escondida.
Fontes e leituras recomendadas
- Elkan (2001) — The Foundations of Cost-Sensitive Learning
- scikit-learn —
class_weighte classes desbalanceadas - scikit-learn — ajuste de limiar por custo (
TunedThresholdClassifierCV) - scikit-learn — calibração de probabilidades
- tidymodels — pesos de caso (
case_weights) - imbalanced-learn — técnicas de reamostragem