Pular para conteúdo

ROC, PR e Dados Desbalanceados

A aula de métricas avaliou 20 pacientes encaminhados a um especialista com limiar 0,5 e obteve uma matriz de confusão: TP = 7, FN = 3, FP = 2, TN = 8. Mas o modelo não produziu rótulos: produziu uma probabilidade para cada paciente. O 0,5 foi escolha nossa, não do modelo. Esta aula examina os mesmos 20 pacientes em todos os limiares e depois passa ao cenário em que isso mais importa, o de dados desbalanceados.

Todo número abaixo vem de código desta página; toda figura, de um script curto ao lado dela.

Como esta aula está organizada

A aula tem duas abordagens. Cada uma tem slides, texto e um lab para fazer em aula no Colab; o quiz do fim cobre a fundamental. Comece pela fundamental: a avançada parte dela.

Abordagem fundamental Abordagem avançada
pergunta Como avaliar um modelo em todos os limiares e escolher um? O que fazer quando a classe de interesse é 2% das linhas?
conteúdo curva ROC, AUC, curva precisão–recall, AP, ROC vs PR com classe rara, escolha do limiar o que o desbalanceamento quebra, pesos de classe, reamostragem, SMOTE, duas armadilhas, ordem de ataque
lab em aula mamografias: as curvas, passos 1 a 5 remédios, limiar e teste, passos 6 a 11
ao final, você sabe desenhar e ler uma ROC à mão; ler AUC como probabilidade de ordenação; dizer quando usar PR em vez de ROC; escolher um limiar por um critério declarado diagnosticar se o problema é o modelo ou o limiar; medir pesos e reamostragem sem se enganar; evitar vazamento e probabilidades distorcidas

Os dois labs usam o mesmo notebook no Colab: a parte fundamental vai do passo 1 ao 5; a avançada, do 6 ao 11. Tudo roda no navegador; não é preciso baixar nada.

Abordagem fundamental

Uma pergunta guia esta parte: um classificador que produz escores é uma família de classificadores, um por limiar — como avaliar a família inteira e depois escolher um membro? Duas curvas respondem e cada uma se resume a um número.

Depois de um limiar e da sua matriz de confusão, a parte segue quatro etapas, desenhadas abaixo: 1. a curva ROC, um ponto por limiar; 2. um número, a AUC, que avalia a ordenação como um todo; 3. a curva precisão–recall e o que muda quando a classe positiva é rara; 4. escolher o limiar que vai para produção. Cada etapa leva à sua seção e, nos slides, uma faixa embaixo mostra em que etapa você está.

  1. Curva ROCcada limiar é um ponto: doentes pegos contra saudáveis alarmados
  2. Um número: AUCa fração de pares (doente, saudável) na ordem certa
  3. Curva precisão–recallprecisão média; por que ela mostra uma classe rara que a ROC esconde
  4. Escolher o limiarquatro critérios, decididos na validação; uma fórmula quando os escores são calibrados

Slides da abordagem fundamental

Clique nos slides e use as setas ← → para navegar; F abre em tela cheia e Esc mostra todos os slides. Abrir os slides numa aba nova

1. Um limiar, uma matriz de confusão

Um classificador que produz escores é, na verdade, uma família de classificadores, um por limiar. Escolha um limiar \(t\) e sinalize todo mundo com escore \(\geq t\): você obtém uma matriz de confusão, da qual duas taxas resumem os erros:

\[ \begin{aligned} \text{TPR} = \text{recall} &= \frac{TP}{TP + FN} &&\text{fração dos doentes que pegamos,}\\[6pt] \text{FPR} &= \frac{FP}{FP + TN} &&\text{fração dos saudáveis que alarmamos.} \end{aligned} \]

Nesta aula usamos o termo recall, como no scikit-learn e nas saídas do código; é a revocação (sensibilidade) da aula de métricas. Com \(t = 0{,}5\), os 20 pacientes dão TPR = 7/10 = 0,7 e FPR = 2/10 = 0,2: um ponto, \((0{,}2;\ 0{,}7)\). Mude o limiar e o ponto se move. A curva ROC (receiver operating characteristic, nome herdado dos operadores de radar da Segunda Guerra Mundial) é o caminho desse ponto quando o limiar vai de "não sinalizar ninguém" a "sinalizar todo mundo".

2. A curva ROC, à mão

Etapa 1. Eis os 20 pacientes ordenados pelo escore. Com o limiar no escore de um paciente, esse paciente e todos acima dele são sinalizados. Assim, cada linha da tabela é um limiar; as contagens acumuladas de TP e FP dão o ponto:

paciente escore doente TP FP TPR FPR precisão
— acima de 0,95 0 0 0,0 0,0 —
P01 0,95 1 1 0 0,1 0,0 1,000
P02 0,90 1 2 0 0,2 0,0 1,000
P03 0,85 1 3 0 0,3 0,0 1,000
P04 0,80 1 4 0 0,4 0,0 1,000
P05 0,75 0 4 1 0,4 0,1 0,800
P06 0,70 1 5 1 0,5 0,1 0,833
P07 0,65 1 6 1 0,6 0,1 0,857
P08 0,60 0 6 2 0,6 0,2 0,750
P09 0,55 1 7 2 0,7 0,2 0,778
P10 0,45 1 8 2 0,8 0,2 0,800
P11 0,40 0 8 3 0,8 0,3 0,727
P12 0,35 1 9 3 0,9 0,3 0,750
P13 0,30 0 9 4 0,9 0,4 0,692
P14 0,25 0 9 5 0,9 0,5 0,643
P15 0,20 1 10 5 1,0 0,5 0,667
P16 0,15 0 10 6 1,0 0,6 0,625
P17 0,10 0 10 7 1,0 0,7 0,588
P18 0,08 0 10 8 1,0 0,8 0,556
P19 0,05 0 10 9 1,0 0,9 0,526
P20 0,02 0 10 10 1,0 1,0 0,500

A regra para desenhá-la é mecânica. Baixar o limiar além de um paciente doente acrescenta um TP: o ponto anda para cima 1/10 (são 10 doentes). Baixá-lo além de um paciente saudável acrescenta um FP: o ponto anda para a direita 1/10 (são 10 saudáveis). O limiar 0,5 da aula de métricas fica entre P09 e P10, na linha de P09: TPR 0,7, FPR 0,2.

Escada ROC dos 20 pacientes: quatro degraus para cima (P01–P04), depois degraus alternados; o limiar 0,5 é o ponto FPR 0,2, TPR 0,7; a área sombreada sob a escada é 0,86

(script da figura)

Três pontos de referência:

  • (0, 0): o limiar está acima de todos os escores, ninguém é sinalizado: nenhum alarme falso e nenhum acerto;
  • (1, 1): o limiar está abaixo de todos os escores, todo mundo é sinalizado;
  • a diagonal TPR = FPR: um modelo que dá escores aleatórios sinaliza doentes e saudáveis na mesma taxa, qualquer que seja o limiar. Um bom modelo sobe cedo: encontra os doentes primeiro. Um perfeito sobe direto até (0, 1) e depois vai para a direita.

O scikit-learn desenha a mesma curva:

import numpy as np
from sklearn.metrics import roc_curve

score = np.array([0.95, 0.90, 0.85, 0.80, 0.75, 0.70, 0.65, 0.60, 0.55, 0.45,
                  0.40, 0.35, 0.30, 0.25, 0.20, 0.15, 0.10, 0.08, 0.05, 0.02])
sick = np.array([1, 1, 1, 1, 0, 1, 1, 0, 1, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0])

fpr, tpr, thresholds = roc_curve(sick, score)
for t, f, r in zip(thresholds, fpr, tpr):
    print(f"threshold {t:4.2f}   FPR {f:.1f}   TPR {r:.1f}")
_, _, all_thresholds = roc_curve(sick, score, drop_intermediate=False)
print(len(thresholds), "points kept;", len(all_thresholds), "with drop_intermediate=False")
threshold  inf   FPR 0.0   TPR 0.0
threshold 0.95   FPR 0.0   TPR 0.1
threshold 0.80   FPR 0.0   TPR 0.4
threshold 0.75   FPR 0.1   TPR 0.4
threshold 0.65   FPR 0.1   TPR 0.6
threshold 0.60   FPR 0.2   TPR 0.6
threshold 0.45   FPR 0.2   TPR 0.8
threshold 0.40   FPR 0.3   TPR 0.8
threshold 0.35   FPR 0.3   TPR 0.9
threshold 0.25   FPR 0.5   TPR 0.9
threshold 0.20   FPR 0.5   TPR 1.0
threshold 0.02   FPR 1.0   TPR 1.0
12 points kept; 21 with drop_intermediate=False

O primeiro limiar é inf: o canto "não sinalizar ninguém". A lista é mais curta que a tabela porque roc_curve descarta, por padrão (drop_intermediate=True), os pontos no meio de um trecho reto: P02 e P03 estão no segmento de P01 a P04; removê-los não muda a curva. Passe drop_intermediate=False para ter um ponto por escore distinto, mais o canto.

O simulador abaixo faz o mesmo com 600 escores simulados (500 negativos, 100 positivos). Deslize o limiar e veja a matriz de confusão andar ao longo da curva ROC:

3. AUC: um número para a curva inteira

Etapa 2. A área sob a curva ROC (AUC) resume a curva num único número. Para a escada, ela pode ser calculada com retângulos: cada passo para a direita (um paciente saudável) acrescenta um retângulo de largura 1/10 e altura igual à TPR naquele momento. Os pacientes saudáveis aparecem nas alturas 0,4 (P05), 0,6 (P08), 0,8 (P11), 0,9 (P13), 0,9 (P14) e 1,0 (P16 a P20, cinco vezes):

\[ \text{AUC} = \tfrac{1}{10}\,(0{,}4 + 0{,}6 + 0{,}8 + 0{,}9 + 0{,}9 + 5 \times 1{,}0) = \tfrac{8{,}6}{10} = 0{,}86 . \]

O mesmo número, contado em pares

Agora esqueça a curva. Forme todos os pares (doente, saudável): 10 × 10 = 100 pares. Em quantos o doente recebe o escore maior? Para P05 (0,75), 4 doentes têm escore maior (P01–P04). Para P08, 6. Para P11, 8. Para P13 e P14, 9 cada. Para P16–P20, todos os 10. No total, \(4 + 6 + 8 + 9 + 9 + 5 \times 10 = 86\) dos 100 pares estão na ordem certa: 0,86 de novo.

Não é coincidência. A altura de cada retângulo, vezes 10, é o número de doentes ordenados acima daquele paciente saudável. Logo

\[ \text{AUC} = P\big(\text{escore de um doente ao acaso} > \text{escore de um saudável ao acaso}\big), \]

com empates contando meio. Esta é a estatística \(U\) de Mann–Whitney dividida pelo número de pares (Hanley & McNeil, 1982); é também a forma mais útil de ler uma AUC: se eu sortear um doente e um saudável, com que frequência o modelo os coloca na ordem certa?

from sklearn.metrics import roc_auc_score

# 1) área: cada paciente saudável soma um retângulo de largura 1/10 e altura = TPR atual
tpr_now, area = 0.0, 0.0
for s in sick:
    if s == 1:
        tpr_now += 1 / 10
    else:
        area += (1 / 10) * tpr_now
# 2) pares: em quantos pares (doente, saudável) o doente tem o escore maior?
pos, neg = score[sick == 1], score[sick == 0]
concordant = (pos[:, None] > neg[None, :]).sum()
print(f"area by rectangles: {area:.2f}")
print(f"concordant pairs:   {concordant} of {len(pos) * len(neg)} = {concordant / (len(pos) * len(neg)):.2f}")
print(f"roc_auc_score:      {roc_auc_score(sick, score):.2f}")
area by rectangles: 0.86
concordant pairs:   86 of 100 = 0.86
roc_auc_score:      0.86

Lendo a escala: AUC = 0,5 é uma ordenação aleatória (a diagonal), AUC = 1 coloca todo doente acima de todo saudável e AUC < 0,5 quer dizer que a ordenação está invertida, errando mais do que acertando. Uma AUC de 0,14 não é um modelo ruim, é um bom modelo com os rótulos trocados: inverta os escores e ele tem 0,86.

O que a AUC mede: a separação entre as classes

A leitura em pares diz o que a AUC mede: o quanto as distribuições de escore dos doentes e dos saudáveis se sobrepõem. Na figura, os saudáveis recebem escores de uma normal \(N(0, 1)\) e os doentes de uma \(N(d, 1)\). Com as classes quase sobrepostas (\(d = 0{,}5\)), sortear um doente e um saudável e perguntar quem tem o escore maior é pouco melhor que jogar uma moeda: AUC 0,64. À medida que as distribuições se afastam, a curva sobe para o canto superior esquerdo e a AUC vai a 0,85 e a 0,98.

Em cima, histogramas dos escores de saudáveis N(0, 1) e doentes N(d, 1) para d = 0,5, 1,5 e 3; embaixo, a curva ROC de cada caso, com AUC 0,641, 0,850 e 0,984, ao lado do valor teórico Φ(d/√2)

(script da figura)

Neste modelo existe até uma fórmula. A diferença entre o escore de um doente e o de um saudável é uma normal \(N(d, 2)\), então \(\text{AUC} = P(\text{diferença} > 0) = \Phi(d/\sqrt{2})\), em que \(\Phi\) é a função de distribuição acumulada da normal padrão:

from scipy.stats import norm

rng = np.random.default_rng(0)
for d in [0.5, 1.5, 3.0]:
    neg, pos = rng.normal(0, 1, 2000), rng.normal(d, 1, 2000)    # saudáveis e doentes
    y, s = np.r_[np.zeros(2000), np.ones(2000)], np.r_[neg, pos]
    print(f"d = {d}: AUC {roc_auc_score(y, s):.3f} · Phi(d / sqrt 2) {norm.cdf(d / np.sqrt(2)):.3f}")
d = 0.5: AUC 0.641 · Phi(d / sqrt 2) 0.638
d = 1.5: AUC 0.850 · Phi(d / sqrt 2) 0.856
d = 3.0: AUC 0.984 · Phi(d / sqrt 2) 0.983

A amostra de 2.000 pacientes por classe fica a menos de 0,01 da fórmula. A lição vale para qualquer modelo: nenhum limiar separa duas distribuições sobrepostas. Mover o limiar só escolhe em que ponto da mesma curva operar; para subir a curva é preciso um modelo, ou atributos, que afaste as distribuições.

Só a ordem importa

Como a AUC conta pares na ordem certa, ela depende apenas da ordenação dos escores, nunca de seus valores. Qualquer transformação que preserve a ordem preserva a AUC:

for name, s in [('score', score), ('score ** 2', score ** 2), ('log(score)', np.log(score)),
                ('100 * score - 7', 100 * score - 7), ('1 - score', 1 - score)]:
    print(f"{name:16s} AUC = {roc_auc_score(sick, s):.2f}")
score            AUC = 0.86
score ** 2       AUC = 0.86
log(score)       AUC = 0.86
100 * score - 7  AUC = 0.86
1 - score        AUC = 0.14

Ao quadrado, com logaritmo ou reescaladas, as "probabilidades" deixam de ser probabilidades (100 × escore − 7 vai até 88), mas a AUC fica em 0,86. Inverter a ordem (1 - score) dá 1 − 0,86 = 0,14. Isso tem duas consequências que vale guardar:

  • a AUC não diz nada sobre calibração. Um modelo cujas "probabilidades" estão todas entre 0,40 e 0,41 pode ter AUC 0,99, desde que a ordem esteja certa;
  • a AUC não diz nada sobre um limiar em particular. Ela faz uma média sobre todos eles, inclusive limiares que você nunca usaria. Dois modelos com a mesma AUC podem ser muito diferentes no único ponto de operação que importa para você.

Escores, não rótulos

roc_auc_score precisa dos escores: predict_proba(X)[:, 1] ou decision_function(X). Passar a saída de predict é um erro comum que não dispara nenhuma exceção:

from sklearn.metrics import balanced_accuracy_score

labels = (score >= 0.5).astype(int)              # rótulos previstos, não escores
print(f"AUC from labels:   {roc_auc_score(sick, labels):.2f}")   # errado: rótulos
print(f"balanced accuracy: {balanced_accuracy_score(sick, labels):.2f}")
print(f"AUC from scores:   {roc_auc_score(sick, score):.2f}")    # certo: escores
AUC from labels:   0.75
balanced accuracy: 0.75
AUC from scores:   0.86

Com apenas dois valores distintos, a "curva" tem um único ponto, (0,2; 0,7), ligado aos cantos por retas. Sua área é \((\text{TPR} + \text{TNR})/2 = (0{,}7 + 0{,}8)/2 = 0{,}75\): a acurácia balanceada em 0,5, não a AUC do modelo, que com os escores dá 0,86. Uma versão mais branda do mesmo efeito aparece com o k-NN: com \(k = 5\), predict_proba só pode assumir os valores 0; 0,2; 0,4; 0,6; 0,8 e 1, então sua curva ROC tem no máximo 7 pontos, uma escada grosseira. O lab avançado mostra uma.

4. A curva precisão–recall

Etapa 3. A curva ROC olha as duas classes separadamente: TPR dentro dos doentes, FPR dentro dos saudáveis. A pergunta que um médico faz quando um paciente é sinalizado é outra: dos pacientes que eu sinalizo, quantos estão mesmo doentes? Essa é a precisão, \(TP / (TP + FP)\), a última coluna da tabela. Plotar a precisão contra o recall (= TPR) para cada limiar dá a curva precisão–recall (PR).

A curva PR dos 20 pacientes é um dente de serra: a precisão sobe depois de cada doente e cai depois de cada saudável; a linha de base aleatória é a prevalência 0,5; a precisão média sombreada é 0,8685

(script da figura)

Ela não se parece com a escada ROC. Cada doente move o ponto para a direita (recall + 0,1) e um pouco para cima, porque um alarme correto aumenta a precisão. Cada saudável deixa o recall igual e empurra a precisão para baixo. O resultado é um dente de serra. O ponto final é sempre recall 1 com precisão = 10/20 = 0,5, a fração de doentes entre os 20. Essa fração, a prevalência, é também a curva de um modelo aleatório: se o grupo sinalizado é uma amostra aleatória, sua fração de doentes é a da população, qualquer que seja o limiar. No gráfico PR, a linha "sem habilidade" é, portanto, uma horizontal na altura da prevalência, não uma diagonal.

Precisão média

A curva PR é resumida pela precisão média (AP, average precision):

\[ \text{AP} = \sum_k \big(R_k - R_{k-1}\big)\, P_k , \]

uma soma, sobre os limiares, do ganho de recall vezes a precisão alcançada ali. O recall só cresce nos doentes, de 1/10 a cada vez, então a AP é simplesmente a média da precisão na posição de cada doente. Pela tabela, essas precisões são 1, 1, 1, 1, 5/6, 6/7, 7/9, 8/10, 9/12 e 10/15:

\[ \text{AP} = \tfrac{1}{10}\left(4 + 0{,}833 + 0{,}857 + 0{,}778 + 0{,}800 + 0{,}750 + 0{,}667\right) = 0{,}8685 . \]
from sklearn.metrics import auc, average_precision_score, precision_recall_curve

flagged = np.arange(1, 21)                      # limiar no paciente k sinaliza k pacientes
precision_k = np.cumsum(sick) / flagged
print("precision at each sick patient:", precision_k[sick == 1].round(3))
print(f"AP by hand:              {precision_k[sick == 1].mean():.4f}")
print(f"average_precision_score: {average_precision_score(sick, score):.4f}")
prec, rec, _ = precision_recall_curve(sick, score)
print(f"trapezoidal area:        {auc(rec, prec):.4f}")
precision at each sick patient: [1.    1.    1.    1.    0.833 0.857 0.778 0.8   0.75  0.667]
AP by hand:              0.8685
average_precision_score: 0.8685
trapezoidal area:        0.8608

A última linha é uma armadilha a evitar. auc(recall, precision) liga os pontos PR por retas e calcula a área dos trapézios, aqui 0,8608. Mas, entre dois pontos de operação, a precisão não varia linearmente com o recall; a reta descreve classificadores que não existem e o trapézio pode ser otimista (Davis & Goadrich, 2006). A AP soma retângulos, sem interpolar. Os dois números são próximos aqui e podem ficar bem distantes em curvas reais; reporte a AP, calculada com average_precision_score.

5. ROC vs PR quando a classe positiva é rara

As duas curvas reagem de forma diferente a uma coisa: a prevalência. Para isolá-la, mantenha o modelo fixo: pacientes saudáveis recebem escores de uma normal \(N(0, 1)\), doentes de uma \(N(2, 1)\). Depois mude apenas quantos doentes existem, de 50% até 1%:

rng = np.random.default_rng(0)
neg = rng.normal(0, 1, 100_000)                   # saudáveis: escore ~ N(0, 1)
for prev in [0.5, 0.1, 0.01]:
    n_pos = round(len(neg) * prev / (1 - prev))
    pos = rng.normal(2, 1, n_pos)                 # doentes: escore ~ N(2, 1), sempre o mesmo escorador
    s = np.r_[pos, neg]
    y = np.r_[np.ones(n_pos), np.zeros(len(neg))]
    tp, fp = (pos >= 1.16).sum(), (neg >= 1.16).sum()      # um limiar fixo
    print(f"prevalence {prev:4.0%}  AUC {roc_auc_score(y, s):.3f}  AP {average_precision_score(y, s):.3f}"
          f"   at 1.16: TPR {tp / n_pos:.2f}  FPR {fp / len(neg):.2f}  precision {tp / (tp + fp):.2f}")
prevalence  50%  AUC 0.921  AP 0.922   at 1.16: TPR 0.80  FPR 0.12  precision 0.87
prevalence  10%  AUC 0.922  AP 0.666   at 1.16: TPR 0.80  FPR 0.12  precision 0.42
prevalence   1%  AUC 0.914  AP 0.252   at 1.16: TPR 0.79  FPR 0.12  precision 0.06

Mesmo modelo com prevalência de 50%, 10% e 1%: as três curvas ROC coincidem (AUC 0,921; 0,922; 0,914) enquanto as curvas PR afundam (AP 0,922; 0,666; 0,252); no mesmo limiar, a precisão cai de 0,87 para 0,42 e para 0,06

(script da figura)

As curvas ROC são a mesma curva (a pequena oscilação da curva de 1% é ruído de amostragem, com apenas 1.010 doentes). As curvas PR desabam: a AP vai de 0,922 para 0,666 e para 0,252. No mesmo limiar, pegando os mesmos cerca de 80% dos doentes com a mesma taxa de 12% de alarmes falsos, a precisão cai de 0,87 para 0,42 e para 0,06. Com prevalência de 1%, 94 de cada 100 pacientes sinalizados são saudáveis.

A razão está nos denominadores. A FPR divide pelo número de saudáveis, então é uma propriedade do modelo só nos saudáveis, cega para quantos doentes existem. A precisão divide pelo número de sinalizados, que mistura as duas classes na proporção de seus tamanhos. Para ver isso, escreva a precisão em termos da prevalência \(\pi\), a fração de doentes na população. Em \(N\) pessoas há \(\pi N\) doentes e \((1-\pi) N\) saudáveis. O modelo sinaliza a fração TPR dos doentes, \(TP = \text{TPR} \cdot \pi N\), e a fração FPR dos saudáveis, \(FP = \text{FPR} \cdot (1-\pi) N\). Na precisão, \(TP / (TP + FP)\), o \(N\) cancela:

\[ \text{precisão} = \frac{\pi \cdot \text{TPR}}{\pi \cdot \text{TPR} + (1-\pi) \cdot \text{FPR}}. \]

TPR e FPR são do modelo; \(\pi\) é da população. Com TPR 0,80 e FPR 0,12: em \(\pi = 0{,}5\), a fórmula dá \(0{,}40 / (0{,}40 + 0{,}06) = 0{,}87\); em \(\pi = 0{,}01\), \(0{,}008 / (0{,}008 + 0{,}119) = 0{,}06\). Uma taxa de alarmes falsos de 12% parece pequena, mas 12% de 100.000 pessoas saudáveis são 12.000 alarmes falsos contra cerca de 800 verdadeiros.

Nenhuma das curvas está errada; elas respondem a perguntas diferentes. Uma regra prática (Saito & Rehmsmeier, 2015):

  • ROC / AUC quando as duas classes importam, quando a prevalência em produção é desconhecida ou vai mudar ou quando você compara ordenadores em conjuntos com prevalências diferentes. Ela é livre de prevalência por construção;
  • PR / AP quando a classe positiva é rara e o que você paga são os casos sinalizados (alertas a revisar, pacientes a chamar de volta, transações a bloquear). Ela mostra o custo dos alarmes falsos que a curva ROC esconde. Leia a AP sempre contra sua linha de base: uma AP de 0,25 é fraca com prevalência de 50% e 25 vezes melhor que o acaso com 1%.

6. Escolhendo o limiar de operação

Etapa 4. As curvas descrevem todos os limiares; um modelo em produção usa um. Escolhê-lo é uma decisão, então segue a regra usada desde a aula de k-NN para escolher o k: ela é tomada num conjunto de validação, nunca no de teste. (A validação cruzada vai tornar essa escolha mais robusta mais adiante; aqui usamos uma única divisão hold-out.) Trate os 20 pacientes como nosso conjunto de validação e avalie cada limiar segundo quatro critérios comuns:

import pandas as pd

rows = []
for t in score:                                  # o escore de cada paciente é um limiar candidato
    flag = score >= t
    TP = int((flag & (sick == 1)).sum())
    FP = int((flag & (sick == 0)).sum())
    FN = 10 - TP
    rows.append(dict(t=t, TP=TP, FP=FP, precision=TP / (TP + FP), recall=TP / 10,
                     F1=2 * TP / (2 * TP + FP + FN), J=(TP - FP) / 10, cost=10 * FN + FP))
table = pd.DataFrame(rows)
print(table.round(3).to_string(index=False))
   t  TP  FP  precision  recall    F1   J  cost
0.95   1   0      1.000     0.1 0.182 0.1    90
0.90   2   0      1.000     0.2 0.333 0.2    80
0.85   3   0      1.000     0.3 0.462 0.3    70
0.80   4   0      1.000     0.4 0.571 0.4    60
0.75   4   1      0.800     0.4 0.533 0.3    61
0.70   5   1      0.833     0.5 0.625 0.4    51
0.65   6   1      0.857     0.6 0.706 0.5    41
0.60   6   2      0.750     0.6 0.667 0.4    42
0.55   7   2      0.778     0.7 0.737 0.5    32
0.45   8   2      0.800     0.8 0.800 0.6    22
0.40   8   3      0.727     0.8 0.762 0.5    23
0.35   9   3      0.750     0.9 0.818 0.6    13
0.30   9   4      0.692     0.9 0.783 0.5    14
0.25   9   5      0.643     0.9 0.750 0.4    15
0.20  10   5      0.667     1.0 0.800 0.5     5
0.15  10   6      0.625     1.0 0.769 0.4     6
0.10  10   7      0.588     1.0 0.741 0.3     7
0.08  10   8      0.556     1.0 0.714 0.2     8
0.05  10   9      0.526     1.0 0.690 0.1     9
0.02  10  10      0.500     1.0 0.667 0.0    10
print("max F1:              t =", table.loc[table.F1.idxmax(), 't'])
print("max Youden J:        t =", table.loc[table.J == table.J.max(), 't'].tolist())
ok = table[table.recall >= 0.9]
print("recall >= 0.9, best precision: t =", ok.loc[ok.precision.idxmax(), 't'])
print("min cost (FN = 10 FP): t =", table.loc[table.cost.idxmin(), 't'], "  cost", table.cost.min())
max F1:              t = 0.35
max Youden J:        t = [0.45, 0.35]
recall >= 0.9, best precision: t = 0.35
min cost (FN = 10 FP): t = 0.2   cost 5

Quatro critérios, nem todos com a mesma resposta:

  • F1 máximo escolhe \(t = 0{,}35\): 9 de 10 doentes encontrados, 3 alarmes falsos, F1 = 0,818. O F1 pesa precisão e recall igualmente, o que raramente é o que a aplicação quer, mas é um padrão razoável quando ninguém sabe dizer outra coisa.
  • O J de Youden \(= \text{TPR} - \text{FPR}\) (Youden, 1950) é a distância vertical do ponto ROC até a diagonal. Aqui ele empata em 0,6 entre \(t = 0{,}45\) (TPR 0,8, FPR 0,2) e \(t = 0{,}35\) (TPR 0,9, FPR 0,3). Ele ignora a prevalência, como a curva ROC em que vive.
  • "Recall ≥ 0,9, depois a melhor precisão" é como os requisitos costumam chegar ("temos de pegar pelo menos 90% dos casos"): entre os limiares que chegam a recall 0,9, \(t = 0{,}35\) tem a melhor precisão, 0,75.
  • Custo esperado mínimo. Suponha que deixar passar uma doença custe 10 vezes mais que um encaminhamento desnecessário: custo = 10·FN + 1·FP. O mínimo, custo 5, está em \(t = 0{,}20\): os 10 doentes encontrados, 5 saudáveis encaminhados. O padrão 0,5 custa 32 (3 perdas × 10 + 2 alarmes falsos).

À esquerda: recall, precisão, F1 e J de Youden em função do limiar; o F1 tem pico de 0,818 em t = 0,35. À direita: o custo 10·FN + FP cai de 100 até o mínimo de 5 para t acima de 0,15 e até 0,20, contra 32 em t = 0,5

(script da figura)

Para o critério de custo existe também uma fórmula. Se os escores fossem probabilidades calibradas, sinalizar um paciente com probabilidade \(p\) custaria \((1-p)\,c_{FP}\) em média; não sinalizar custaria \(p\,c_{FN}\). Sinalize quando o segundo for maior:

\[ p > t^* = \frac{c_{FP}}{c_{FP} + c_{FN}} = \frac{1}{1 + 10} \approx 0{,}09 \]

(Elkan, 2001). Nos nossos 20 pacientes, \(t = 0{,}10\) custa 7 (7 alarmes falsos, nenhuma perda), perto do mínimo empírico mas não igual: nossos escores não são perfeitamente calibrados e 20 pacientes é uma amostra pequena. A fórmula diz onde procurar; o conjunto de validação diz onde parar. Note também que 0,5 é o limiar certo apenas quando os dois erros custam o mesmo e os escores são calibrados. Fora disso, nada no 0,5 é especial.

O scikit-learn (≥ 1.5) automatiza isso com TunedThresholdClassifierCV, que procura o limiar por validação cruzada segundo o critério que você passar em scoring (por padrão, a acurácia balanceada, isto é, o J de Youden). Você poderá usá-lo depois da aula de validação cruzada; o procedimento é o mesmo que fizemos à mão nesta seção.

Ideia-chave

A curva é o modelo; o ponto sobre ela é a sua decisão. A AUC e a AP avaliam a ordenação, que o treino produz; o limiar escolhe o ponto de operação, que você produz — e que pode ser mudado depois, sem retreinar nada.

7. Resumo: as duas curvas e o ponto de operação

curva ROC curva precisão–recall
eixos FPR × TPR recall × precisão
denominadores cada classe separadamente a coluna dos sinalizados, que mistura as duas
linha "sem habilidade" a diagonal uma horizontal na prevalência
número que a resume AUC = P(doente ordenado acima de saudável) AP = média da precisão em cada positivo
muda com a prevalência? não sim
use quando as duas classes importam; a prevalência vai mudar a classe positiva é rara e você paga pelos casos sinalizados
  1. Avalie a ordenação com AUC e AP, sempre passando escores, nunca rótulos.
  2. Leia a AP contra a prevalência: 0,25 é fraco com 50% de positivos e 25 vezes melhor que o acaso com 1%.
  3. Escolha o limiar na validação, por um critério declarado: uma meta de recall, uma razão de custos, o melhor F1 se nada mais for conhecido.
  4. Nada no 0,5 é especial fora do caso em que os dois erros custam o mesmo e os escores são calibrados.

Lab fundamental — as curvas de uma triagem

O conjunto mammography (OpenML 310, também um dos benchmarks do artigo do SMOTE) tem 11.183 regiões de mamografias descritas por 6 atributos numéricos calculados a partir da imagem, das quais 260 (2,3%) são microcalcificações, pequenos depósitos de cálcio que podem ser um sinal precoce de câncer de mama. Daqui em diante, elas são a classe positiva, chamada de maligna para abreviar; o resto é benigna. A tarefa: sinalizá-las para um radiologista olhar. Deixar passar uma é muito pior do que um alarme falso, então a meta é declarada de saída: recall de pelo menos 0,80 nos casos malignos, com a melhor precisão possível.

Como fazer este lab em aula (cerca de 35 min, em duplas)

  1. Abra o notebook do lab no Colab. Não é preciso baixar nem instalar nada: o Colab roda no navegador. Para guardar as suas respostas, salve uma cópia no seu Drive (Arquivo → Salvar uma cópia no Drive) antes de editar. Rode as células em ordem, numa mesma sessão.
  2. Este lab corresponde aos passos 1 a 5 do notebook. O lab avançado continua no mesmo notebook, a partir do passo 6.
  3. Cada passo começa com um Preveja: responda com a dupla antes de rodar a célula. Errar a previsão é o que torna o resultado memorável.
  4. Depois de rodar, compare com a saída e a leitura desta página. Toda saída aqui é exatamente o que o código imprime.
  5. As Simulações pedem que vocês escrevam um pequeno experimento em Python: mudar uma entrada e observar o que acontece com a saída. Nada de conta à mão. A solução fica recolhida logo abaixo: tente antes de abrir.
  6. Ritmo sugerido: passos 1 a 3 em 10 min; passo 4 e a simulação em 12 min; passo 5 e a simulação em 13 min.

Passo 1 — carregar e olhar a prevalência

Preveja. Microcalcificações são achados raros numa mamografia. Antes de rodar: você espera que elas sejam mais ou menos de 10% das regiões? Com essa prevalência, quanto de acurácia um modelo que nunca sinaliza nada conseguiria?

import numpy as np
import pandas as pd
from sklearn.datasets import fetch_openml

mammo = fetch_openml(data_id=310, as_frame=True)
X = mammo.data                                   # 6 atributos numéricos por região
y = (mammo.target == '1').astype(int)            # o alvo é '-1' (benigno) ou '1' (maligno)
print(X.shape)
print(y.value_counts())
print(f"prevalence: {y.mean():.2%}")
(11183, 6)
class
0    10923
1      260
Name: count, dtype: int64
prevalence: 2.32%

O alvo vem como as strings '-1' e '1'; nós o mapeamos para 0/1 de modo que 1 seja a classe que queremos pegar. 260 das 11.183 regiões são malignas: 2,32%. Toda métrica que calcularmos tem de ser lida contra esse número.

Passo 2 — divisão estratificada treino / validação / teste

Preveja. Por que estratificar importa mais aqui do que num conjunto equilibrado? Se a validação tiver cerca de 50 casos malignos, quanto de recall vale um único caso?

from sklearn.model_selection import train_test_split

X_tmp, X_test, y_tmp, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=7)
X_train, X_val, y_train, y_val = train_test_split(X_tmp, y_tmp, test_size=0.25,
                                                  stratify=y_tmp, random_state=7)
for name, part in [('train', y_train), ('validation', y_val), ('test', y_test)]:
    print(f"{name:10s} {len(part):5d} rows  {part.sum():3d} malignant  ({part.mean():.2%})")
train       6709 rows  156 malignant  (2.33%)
validation  2237 rows   52 malignant  (2.32%)
test        2237 rows   52 malignant  (2.32%)

60/20/20, estratificada. O treino ajusta os modelos e a validação escolhe entre eles e fixa o limiar; o conjunto de teste é usado uma vez, no passo 10. Repare como cada parte tem poucos positivos: 52 casos malignos na validação significam que um caso vale quase 2 pontos de recall.

Passo 3 — um baseline que nunca sinaliza nada

Preveja. Qual vai ser a acurácia de responder "benigno" para todas as regiões? E o recall?

from sklearn.dummy import DummyClassifier
from sklearn.metrics import accuracy_score, recall_score

dummy = DummyClassifier(strategy='most_frequent').fit(X_train, y_train)
pred = dummy.predict(X_val)
print(f"accuracy {accuracy_score(y_val, pred):.4f}   recall {recall_score(y_val, pred):.3f}")
accuracy 0.9768   recall 0.000

Responder "benigno" para tudo dá 97,7% de acurácia e não encontra nenhum câncer. É com essa marca que a acurácia precisa ser comparada; é por isso que não vamos mais olhar para ela.

Passo 4 — uma regressão logística no limiar padrão

Preveja. A acurácia vai superar a do baseline por muito ou por pouco? E o recall em 0,5: você espera perto de 0,9 ou bem abaixo disso, dado que só 2,3% das regiões são malignas?

from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, precision_score, roc_auc_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

lr = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_train, y_train)
p_val = lr.predict_proba(X_val)[:, 1]
pred = (p_val >= 0.5).astype(int)
print(f"accuracy {accuracy_score(y_val, pred):.4f}   flagged {pred.sum()} of {len(pred)}")
print(f"recall {recall_score(y_val, pred):.3f}   precision {precision_score(y_val, pred):.3f}")
print(f"ROC AUC {roc_auc_score(y_val, p_val):.3f}   AP {average_precision_score(y_val, p_val):.3f}"
      f"   (AP of a random scorer = {y_val.mean():.3f})")
accuracy 0.9839   flagged 30 of 2237
recall 0.442   precision 0.767
ROC AUC 0.946   AP 0.653   (AP of a random scorer = 0.023)

Como ler:

  • acurácia de 98,4%, mal acima dos 97,7% do dummy;
  • em 0,5 o modelo sinaliza só 30 regiões: 23 delas são malignas (precisão 0,767), mas são 23 de 52, recall 0,442. Mais da metade dos cânceres passa, longe da meta de 0,80;
  • a ordenação é boa: ROC AUC 0,946 e AP 0,653, contra uma AP de 0,023 de um modelo aleatório (cerca de 28 vezes melhor). O modelo sabe bastante; 0,5 é só o corte errado para este problema.

Simulação: a AUC só enxerga a ordem. Pegue os escores p_val e transforme-os de maneiras que preservam a ordem (elevar ao cubo, virar chances \(p/(1-p)\)) e de uma que a inverte (\(1-p\)). Para cada versão, meça a AUC, a AP e quantas regiões ficam acima de 0,5. O que muda e o que não muda?

Solução
for name, s in [('p', p_val), ('p ** 3', p_val ** 3),
                ('p / (1 - p)', p_val / (1 - p_val)), ('1 - p', 1 - p_val)]:
    flagged = (s >= 0.5).sum()
    print(f"{name:12s} AUC {roc_auc_score(y_val, s):.3f}   AP {average_precision_score(y_val, s):.3f}"
          f"   sinalizadas em 0,5: {flagged:4d}   recall {recall_score(y_val, s >= 0.5):.3f}")
p            AUC 0.946   AP 0.653   sinalizadas em 0,5:   30   recall 0.442
p ** 3       AUC 0.946   AP 0.653   sinalizadas em 0,5:   14   recall 0.231
p / (1 - p)  AUC 0.946   AP 0.653   sinalizadas em 0,5:   38   recall 0.558
1 - p        AUC 0.054   AP 0.012   sinalizadas em 0,5: 2207   recall 0.558

As três primeiras linhas têm exatamente a mesma AUC e a mesma AP, porque nenhuma transformação crescente muda quem vem antes de quem. O que muda, e muito, é o ponto de operação em 0,5: de 14 a 38 regiões sinalizadas, com o recall indo de 0,231 a 0,558. Ou seja, a AUC avalia o que o treino produziu (a ordenação) e é cega para o corte; o 0,5, por sua vez, só significa alguma coisa se os escores forem probabilidades de verdade. A última linha inverte a ordem: a AUC vira \(1 - 0{,}946 = 0{,}054\), um "modelo péssimo" que na verdade é o mesmo modelo com o sinal trocado.

Passo 5 — as curvas ROC e PR

Preveja. Para chegar a recall 0,80 neste conjunto, você acha que a precisão fica mais perto de 0,8 ou de 0,3? Lembre que só 2,3% das regiões são malignas.

import matplotlib.pyplot as plt
from sklearn.metrics import PrecisionRecallDisplay, RocCurveDisplay, precision_recall_curve, roc_curve

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
RocCurveDisplay.from_predictions(y_val, p_val, ax=ax1, plot_chance_level=True)
PrecisionRecallDisplay.from_predictions(y_val, p_val, ax=ax2, plot_chance_level=True)
plt.show()

fpr, tpr, _ = roc_curve(y_val, p_val)
for max_fpr in [0.01, 0.05, 0.10]:
    print(f"FPR <= {max_fpr:.2f}: best recall {tpr[fpr <= max_fpr].max():.3f}")
prec, rec, _ = precision_recall_curve(y_val, p_val)
for min_rec in [0.5, 0.8, 0.9]:
    print(f"recall >= {min_rec:.1f}: best precision {prec[rec >= min_rec].max():.3f}")
FPR <= 0.01: best recall 0.654
FPR <= 0.05: best recall 0.808
FPR <= 0.10: best recall 0.904
recall >= 0.5: best precision 0.784
recall >= 0.8: best precision 0.294
recall >= 0.9: best precision 0.232

Os dois gráficos mostram o mesmo modelo. A curva ROC sobe rápido: com taxa de falsos positivos de 5% ela já chega a recall 0,808. Parece barato, mas 5% das 2.185 regiões benignas são até 109 alarmes falsos, contra 42 cânceres encontrados. A curva PR mostra esse preço diretamente: para chegar a recall 0,8 a melhor precisão é 0,294, ou seja, cerca de 2 de cada 3 regiões sinalizadas são benignas; para chegar a 0,9 ela cai para 0,232. Esse é o compromisso com que os radiologistas vão conviver e nenhuma das curvas pode mudá-lo. Só a escolha do ponto sobre a curva é nossa.

Simulação: o preço de cada ponto de recall. Para metas de recall de 0,50 a 0,95, encontre na validação o limiar que atinge a meta com a melhor precisão e conte quantas regiões ele sinaliza. Traduza a precisão em algo que um radiologista entenda: quantas regiões benignas ele revisa para cada câncer encontrado?

Solução
prec, rec, thr = precision_recall_curve(y_val, p_val)
print("meta   limiar  sinalizadas  precisão  benignas revistas por câncer encontrado")
for meta in [0.5, 0.7, 0.8, 0.9, 0.95]:
    ok = rec[:-1] >= meta
    i = int(np.argmax(np.where(ok, prec[:-1], -1)))
    flag = p_val >= thr[i]
    tp = int((flag & (y_val == 1)).sum()); fp = int((flag & (y_val == 0)).sum())
    print(f"{meta:4.2f}   {thr[i]:6.4f}  {flag.sum():11d}  {prec[i]:8.3f}  {fp / tp:34.1f}")
meta   limiar  sinalizadas  precisão  benignas revistas por câncer encontrado
0.50   0.3651           37     0.784                                 0.3
0.70   0.1085           82     0.451                                 1.2
0.80   0.0632          143     0.294                                 2.4
0.90   0.0425          203     0.232                                 3.3
0.95   0.0050          835     0.060                                15.7

A última coluna é a curva PR escrita como carga de trabalho. Com meta de recall 0,50, o radiologista revisa uma região benigna a cada três cânceres; subindo para 0,80, passa a revisar quase duas e meia por câncer; em 0,95, quase 16. O custo de cada ponto extra de recall cresce rápido e é por isso que a meta de 0,80 foi declarada antes de ver os dados em vez de depois. Repare também que o limiar correspondente desce de 0,37 para 0,005: nada nesses números se parece com 0,5.

Para fechar a parte fundamental. Em uma frase cada: (1) por que a curva ROC não muda quando a doença fica mais rara e a curva PR muda? (2) O que a AUC mede que a acurácia em 0,5 não mede? (3) Quem decide o ponto de operação, o treino ou você?

Sua vez (fundamental)

Para casa:

  1. A escada à mão. Pegue os 10 primeiros pacientes do exemplo das 20 linhas e desenhe a curva ROC deles no papel, um degrau por paciente. Confira com roc_curve. Em que ponto está o limiar 0,5?
  2. AUC sem calibração. Comprima os escores da validação do passo 4 para a faixa de 0,40 a 0,41 com 0.40 + 0.01 * p_val. O que acontece com a AUC e o que acontece com a acurácia em 0,5? Que métrica você usaria para descobrir que as probabilidades estão erradas?
  3. AP contra a linha de base. Calcule a AP de um escorador aleatório (use rng.random(len(y_val))) e compare com a AP do modelo e com a prevalência. Quantas vezes melhor que o acaso o modelo é?

Abordagem avançada

A parte fundamental tratou de avaliar e cortar uma ordenação. Esta parte vai ao cenário em que isso mais importa: a classe de interesse é 2% das linhas. A pergunta passa a ser o que de fato muda quando aplicamos os remédios comuns — pesos de classe, reamostragem, SMOTE — e o que eles não mudam. Os blocos de código continuam os das seções 1 a 6 (o código dos labs roda à parte, no Colab): os imports e os 20 pacientes (score, sick) já estão na sessão.

Esta parte termina com uma ordem de ataque e, antes dela, segue quatro etapas, desenhadas abaixo: 1. o que dá errado quando a classe é rara; 2. três remédios: pesos de classe, limiar deslocado, reamostragem; 3. lado a lado, na mesma divisão; 4. duas armadilhas que fazem um remédio parecer melhor do que é. Cada etapa leva à sua seção e, nos slides, uma faixa embaixo mostra em que etapa você está.

  1. O que dá errado2% de fraude: acurácia alta, recall baixo; o problema é o modelo ou o limiar?
  2. Três remédiospesos de classe, limiar deslocado, reamostragem e SMOTE
  3. Lado a ladocinco abordagens na mesma divisão, uma curva só
  4. Duas armadilhasreamostrar antes da divisão; probabilidades distorcidas

Slides da abordagem avançada

Clique nos slides e use as setas ← → para navegar; F abre em tela cheia e Esc mostra todos os slides. Abrir os slides numa aba nova

8. Dados desbalanceados: o que dá errado

Etapa 1. Agora o lado do treino. Tome uma triagem de fraude simulada: 20.000 transações de cartão, cerca de 2% fraudulentas, 5 atributos numéricos que são um pouco maiores, em média, nas fraudes. A divisão é 60/20/20 com stratify=y, então cada parte mantém os 2%:

from sklearn.model_selection import train_test_split

rng = np.random.default_rng(0)
n = 20_000
y = (rng.random(n) < 0.02).astype(int)                               # cerca de 2% de fraude
X = rng.normal(size=(n, 5)) + y[:, None] * np.array([1.2, 1.0, 0.8, 0.8, 0.6])

X_tmp, X_test, y_tmp, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=0)
X_train, X_val, y_train, y_val = train_test_split(X_tmp, y_tmp, test_size=0.25,
                                                  stratify=y_tmp, random_state=0)
for name, part in [('train', y_train), ('validation', y_val), ('test', y_test)]:
    print(f"{name:10s} {len(part):6,d} rows  {part.sum():4d} frauds  ({part.mean():.2%})")
train      12,000 rows   233 frauds  (1.94%)
validation  4,000 rows    77 frauds  (1.93%)
test        4,000 rows    78 frauds  (1.95%)

Sem stratify, o número de fraudes em cada parte mudaria de divisão para divisão; com tão poucos positivos, só isso já move o recall em vários pontos. Estratificar é o primeiro remédio e é de graça. Uma regressão logística, treinada como de costume:

from sklearn.dummy import DummyClassifier
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, recall_score
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

dummy = DummyClassifier(strategy='most_frequent').fit(X_train, y_train)
plain = make_pipeline(StandardScaler(), LogisticRegression()).fit(X_train, y_train)
p_plain = plain.predict_proba(X_test)[:, 1]
pred = plain.predict(X_test)                     # limiar 0.5
print(f"dummy accuracy:   {accuracy_score(y_test, dummy.predict(X_test)):.4f}")
print(f"model accuracy:   {accuracy_score(y_test, pred):.4f}")
print(f"flagged:          {pred.sum()} of {len(pred)}  (true frauds: {y_test.sum()})")
print(f"recall:           {recall_score(y_test, pred):.3f}   precision: {precision_score(y_test, pred):.3f}")
print(f"ROC AUC:          {roc_auc_score(y_test, p_plain):.3f}   AP: {average_precision_score(y_test, p_plain):.3f}")
print(f"mean probability: {p_plain.mean():.4f}   median: {np.median(p_plain):.4f}")
dummy accuracy:   0.9805
model accuracy:   0.9838
flagged:          23 of 4000  (true frauds: 78)
recall:           0.231   precision: 0.783
ROC AUC:          0.930   AP: 0.459
mean probability: 0.0206   median: 0.0030

(Nesta seção o conjunto de teste é só exibido, para comparar os remédios nas mesmas linhas; toda escolha, como um limiar, é feita no conjunto de validação.)

A acurácia, 98,38%, parece excelente e não diz quase nada: o dummy que nunca sinaliza nada já tem 98,05%. O modelo sinaliza 23 transações e pega 18 das 78 fraudes, um recall de 0,23. Mas a ordenação é boa: ROC AUC 0,930. Sorteie uma fraude e uma transação legítima: em 93% das vezes, a fraude recebe o escore maior.

Então o modelo não é cego; suas probabilidades é que são pequenas. A transação mediana recebe 0,003; 60 das 78 fraudes ficam abaixo de 0,5. Isso é honesto: com uma taxa base de 2%, mesmo uma transação suspeita costuma ser mais provavelmente legítima do que não, então poucas passam de 0,5. O problema é o limiar e a métrica, não necessariamente o modelo. Esse diagnóstico importa porque cada remédio abaixo age numa parte diferente: alguns mudam o ponto de operação, outros a ordenação; é fácil confundi-los.

9. Remédios, medidos lado a lado

Etapa 2. Três famílias de remédio, medidas na mesma divisão: mudar o peso de cada classe na perda, mudar o limiar e mudar as linhas de treino.

Cada família age num ponto diferente do caminho entre as linhas de treino e a decisão. A figura situa as três e lista também as variantes mais comuns do imblearn que esta aula não mede:

O caminho linhas de treino → ajuste da perda → escores → limiar e decisão, com três cartões: nível dos dados (subamostragem, sobreamostragem, SMOTE e variantes), nível do algoritmo (class_weight, sample_weight, ensembles balanceados) e nível da decisão (limiar escolhido na validação); os dois primeiros mudam o modelo e suas probabilidades, o terceiro mantém os dois

(script da figura)

Pesos de classe

A maioria dos classificadores do scikit-learn aceita class_weight. Com 'balanced', cada exemplo da classe \(c\) entra na perda com peso

\[ w_c = \frac{n}{k \cdot n_c}, \]

em que \(n\) é o número de linhas, \(k = 2\) o número de classes e \(n_c\) as linhas da classe \(c\). No nosso conjunto de treino, \(n = 12.000\), com 11.767 legítimas e 233 fraudes: \(w_0 = 12000 / (2 \times 11767) = 0{,}510\) e \(w_1 = 12000 / (2 \times 233) = 25{,}751\). Cada fraude agora pesa tanto quanto 50 transações legítimas: as duas classes contribuem igualmente para a perda.

from sklearn.utils.class_weight import compute_class_weight

n_c = np.bincount(y_train)                       # [legítima, fraude]
print("by hand:", (len(y_train) / (2 * n_c)).round(3))
print("sklearn:", compute_class_weight('balanced', classes=np.array([0, 1]), y=y_train).round(3))
by hand: [ 0.51  25.751]
sklearn: [ 0.51  25.751]
weighted = make_pipeline(StandardScaler(), LogisticRegression(class_weight='balanced'))
weighted.fit(X_train, y_train)
p_w = weighted.predict_proba(X_test)[:, 1]
for name, p in [('no weights', p_plain), ('balanced', p_w)]:
    flag = p >= 0.5
    print(f"{name:10s}  AUC {roc_auc_score(y_test, p):.3f}  AP {average_precision_score(y_test, p):.3f}  "
          f"flagged {flag.sum():3d}  recall {recall_score(y_test, flag):.3f}  "
          f"precision {precision_score(y_test, flag):.3f}")
no weights  AUC 0.930  AP 0.459  flagged  23  recall 0.231  precision 0.783
balanced    AUC 0.929  AP 0.460  flagged 673  recall 0.821  precision 0.095

As métricas de ordenação quase não se movem: AUC 0,930 → 0,929, AP 0,459 → 0,460. O que se move é o ponto de operação em 0,5: 23 → 673 transações sinalizadas, recall 0,231 → 0,821, precisão 0,783 → 0,095. Os pesos não tornaram o modelo melhor em separar fraudes do resto; fizeram-no dizer "fraude" com muito mais frequência.

Histogramas da probabilidade prevista de fraude no conjunto de teste. Sem pesos, as transações legítimas se acumulam perto de 0 e só 18 das 78 fraudes passam de 0,5; com pesos balanceados, todos os escores se deslocam para a direita (64 fraudes e 609 transações legítimas passam de 0,5) e a AUC não muda (0,930 vs 0,929)

(script da figura)

Para a regressão logística podemos ver exatamente como. Compare os parâmetros ajustados:

lr_plain, lr_w = plain[-1], weighted[-1]
print("no weights: coef", lr_plain.coef_[0].round(2), " intercept", lr_plain.intercept_[0].round(2))
print("balanced:   coef", lr_w.coef_[0].round(2), " intercept", lr_w.intercept_[0].round(2))
print(f"intercept shift {lr_w.intercept_[0] - lr_plain.intercept_[0]:.2f}"
      f"   log(n0 / n1) = {np.log(n_c[0] / n_c[1]):.2f}")
no weights: coef [1.07 1.   0.85 0.8  0.59]  intercept -5.77
balanced:   coef [1.09 1.04 0.78 0.84 0.68]  intercept -1.9
intercept shift 3.86   log(n0 / n1) = 3.92

As inclinações ficam a menos de 0,1 umas das outras; o intercepto sobe 3,86, quase exatamente \(\log(n_0/n_1) = \log(11767/233) = 3{,}92\). Pesar os positivos por \(n_0/n_1\) multiplica as chances (odds) por esse fator, o que em log-odds é somar uma constante. Para uma regressão logística, class_weight='balanced' é aproximadamente um intercepto deslocado, que é a mesma coisa que um limiar deslocado.

Movendo o limiar

Se os pesos basicamente movem o limiar, podemos mover o limiar diretamente e manter o modelo sem pesos. Somar \(\log(n_0/n_1)\) às log-odds e cortar em 0,5 é o mesmo que cortar a probabilidade original em \(n_1/(n_0 + n_1)\), a prevalência do treino:

t_prev = y_train.mean()                          # limiar = prevalência do treino
moved = p_plain >= t_prev
print(f"no weights, t = {t_prev:.4f}: flagged {moved.sum()}  recall {recall_score(y_test, moved):.3f}  "
      f"precision {precision_score(y_test, moved):.3f}")
print(f"balanced,   t = 0.5   : flagged {(p_w >= 0.5).sum()}  recall {recall_score(y_test, p_w >= 0.5):.3f}  "
      f"precision {precision_score(y_test, p_w >= 0.5):.3f}")
print("transactions classified differently:", (moved != (p_w >= 0.5)).sum(), "of", len(y_test))
no weights, t = 0.0194: flagged 685  recall 0.833  precision 0.095
balanced,   t = 0.5   : flagged 673  recall 0.821  precision 0.095
transactions classified differently: 42 of 4000

Mesmo ponto de operação, sem retreinar: as duas regras discordam em 42 de 4.000 transações. O limiar também não precisa ser a prevalência. Qualquer critério da seção 6 (melhor F1, uma meta de recall, um custo) pode ser aplicado aos escores de validação do modelo sem pesos. Muitas vezes este é o remédio mais barato e o único que pode ser mudado depois da implantação sem mexer no modelo.

Reamostragem: mudar os dados de treino

A terceira família muda as linhas em que o modelo é treinado, para que as classes pareçam balanceadas. A biblioteca imbalanced-learn (pip install imbalanced-learn, importada como imblearn) oferece os métodos padrão com interface do scikit-learn:

  • Subamostragem aleatória (RandomUnderSampler) descarta linhas da maioria ao acaso até as classes ficarem iguais. Rápida, mas joga informação fora: aqui, 11.534 das 11.767 transações legítimas;
  • Sobreamostragem aleatória (RandomOverSampler) duplica linhas da minoria ao acaso. Nada é jogado fora, mas cada fraude se repete cerca de 50 vezes e um modelo flexível pode decorar as cópias;
  • SMOTE (Synthetic Minority Over-sampling Technique, Chawla et al., 2002) cria pontos novos da minoria. Para um ponto da minoria \(x_i\), ele escolhe um de seus \(k\) vizinhos mais próximos da minoria, \(x_{nn}\) (como no k-NN, padrão \(k = 5\)), sorteia um \(\lambda \in [0, 1]\) e acrescenta
\[ x_{\text{new}} = x_i + \lambda\,(x_{nn} - x_i), \]

um ponto no segmento entre eles.

SMOTE num conjunto de brinquedo em 2-D: 12 pontos originais da minoria e 18 sintéticos, cada um sobre um segmento tracejado que liga um ponto da minoria a um de seus 5 vizinhos mais próximos da minoria

(script da figura)

A figura mostra o que o SMOTE faz e sua limitação. Os pontos sintéticos preenchem a região entre os pontos da minoria em vez de se empilharem sobre eles, o que é mais suave que duplicar. Mas o SMOTE só olha vizinhos da minoria: ele traça sem problema um segmento atravessando uma região cheia de pontos da maioria (o ponto da minoria mais abaixo e à esquerda na figura está dentro da nuvem azul; um ponto sintético foi criado no caminho até ele). Variantes como Borderline-SMOTE e SMOTE + ligações de Tomek limpam esses pontos; nenhuma muda a ideia básica.

ferramenta do imblearn o que faz
TomekLinks remove o ponto da maioria de cada par de vizinhos mais próximos mútuos de classes diferentes: limpa a fronteira
EditedNearestNeighbours remove pontos da maioria que não concordam com seus 3 vizinhos mais próximos (por padrão, basta um discordar)
BorderlineSMOTE aplica o SMOTE só aos pontos da minoria perto da fronteira, cercados em maior parte pela maioria
ADASYN gera mais pontos sintéticos em torno dos pontos da minoria com mais vizinhos da maioria, os mais difíceis
SMOTEENN, SMOTETomek SMOTE seguido de uma limpeza por ENN ou por ligações de Tomek
BalancedRandomForestClassifier uma random forest em que cada árvore treina numa amostra balanceada por subamostragem
EasyEnsembleClassifier vários AdaBoost, cada um numa subamostra balanceada diferente da maioria
RUSBoostClassifier AdaBoost com subamostragem aleatória antes de cada rodada

As limpezas (Tomek, ENN) removem poucas linhas e mexem pouco no desbalanceamento; os ensembles balanceados combinam a subamostragem com a média de muitos modelos, o que recupera parte da informação que cada subamostra joga fora. Todas cabem num pipeline do imblearn e devem ser comparadas como as outras: na validação, com a mesma métrica.

A reamostragem só pode tocar os dados de treino. Os conjuntos de validação e teste precisam manter a prevalência real, porque é ela que o modelo vai encontrar. O imblearn.pipeline.Pipeline (ou seu make_pipeline) garante isso: um amostrador dentro dele roda durante o fit e é pulado no predict. Um pipeline comum do sklearn não aceita um amostrador.

from imblearn.over_sampling import SMOTE, RandomOverSampler
from imblearn.pipeline import make_pipeline as make_imb_pipeline
from imblearn.under_sampling import RandomUnderSampler

samplers = {'undersampling': RandomUnderSampler(random_state=0),
            'oversampling': RandomOverSampler(random_state=0),
            'SMOTE': SMOTE(random_state=0)}
for name, sampler in samplers.items():
    X_r, y_r = sampler.fit_resample(X_train, y_train)
    print(f"{name:14s} the model trains on {len(y_r):6,d} rows, {y_r.sum():6,d} of them fraud")
undersampling  the model trains on    466 rows,    233 of them fraud
oversampling   the model trains on 23,534 rows, 11,767 of them fraud
SMOTE          the model trains on 23,534 rows, 11,767 of them fraud

A comparação

Etapa 3. As cinco abordagens na mesma divisão. Para cada uma: as métricas de ordenação, o ponto de operação em 0,5 e o ponto de operação no limiar que maximiza o F1 no conjunto de validação, medido depois no conjunto de teste:

from sklearn.metrics import f1_score

models = {'no weights': make_pipeline(StandardScaler(), LogisticRegression()),
          'balanced weights': make_pipeline(StandardScaler(), LogisticRegression(class_weight='balanced'))}
for name, sampler in samplers.items():
    models[name] = make_imb_pipeline(StandardScaler(), sampler, LogisticRegression())

rows = []
for name, model in models.items():
    model.fit(X_train, y_train)                  # a reamostragem, se houver, acontece aqui, só no X_train
    prec, rec, thr = precision_recall_curve(y_val, model.predict_proba(X_val)[:, 1])
    t_best = thr[np.argmax(2 * prec[:-1] * rec[:-1] / (prec[:-1] + rec[:-1] + 1e-12))]   # na validação
    p = model.predict_proba(X_test)[:, 1]
    row = {'model': name, 'AUC': roc_auc_score(y_test, p), 'AP': average_precision_score(y_test, p)}
    for tag, t in [('0.5', 0.5), ('tuned', t_best)]:
        f = p >= t
        row |= {f'P@{tag}': precision_score(y_test, f), f'R@{tag}': recall_score(y_test, f),
                f'F1@{tag}': f1_score(y_test, f)}
    row['t tuned'] = t_best
    rows.append(row)
print(pd.DataFrame(rows).set_index('model').round(3).to_string())
                    AUC     AP  P@0.5  R@0.5  F1@0.5  P@tuned  R@tuned  F1@tuned  t tuned
model                                                                                    
no weights        0.930  0.459  0.783  0.231   0.356    0.478    0.423     0.449    0.217
balanced weights  0.929  0.460  0.095  0.821   0.170    0.400    0.487     0.439    0.910
undersampling     0.931  0.464  0.092  0.833   0.166    0.486    0.449     0.467    0.942
oversampling      0.929  0.461  0.094  0.821   0.169    0.396    0.487     0.437    0.910
SMOTE             0.929  0.453  0.098  0.821   0.176    0.462    0.462     0.462    0.939

Leia por grupos de colunas:

  • AUC e AP (a ordenação) são iguais para as cinco, dentro do ruído: 0,929–0,931 e 0,453–0,464. Nenhum remédio ensinou ao modelo algo novo sobre fraude. Isso é típico de um modelo bem especificado com exemplos suficientes da minoria; a reamostragem pode ajudar um pouco ou atrapalhar um pouco, mas não é daí que vêm as grandes diferenças;
  • em 0,5 a tabela se divide em duas: o modelo sem pesos fica com precisão alta e recall baixo (0,783 / 0,231), os quatro modelos rebalanceados com precisão baixa e recall alto (cerca de 0,09–0,10 / 0,82–0,83). São o mesmo tipo de ordenação cortado em lugares muito diferentes;
  • no limiar ajustado na validação, as diferenças encolhem: F1 entre 0,44 e 0,47 para todos. Quando cada modelo pode ter seu próprio limiar, o que sobra é a ordenação, que era a mesma. Veja os limiares: 0,22 para o modelo sem pesos, 0,91–0,94 para os rebalanceados. Mesmo ponto de operação, números diferentes no mostrador.

A figura mostra as cinco curvas precisão–recall no conjunto de teste. Elas quase coincidem: é a mesma ordenação. Os círculos marcam onde cada modelo corta em 0,5 e as estrelas, onde corta no limiar ajustado na validação. Em 0,5, o modelo sem pesos fica numa ponta da curva e os quatro rebalanceados na outra; com o limiar ajustado, os cinco se reencontram no mesmo trecho:

As curvas precisão–recall dos cinco remédios no conjunto de teste quase coincidem; em 0,5, o modelo sem pesos está em precisão 0,78 e recall 0,23, e os quatro rebalanceados em precisão perto de 0,1 e recall perto de 0,82; nos limiares de F1 ajustados na validação (0,22 para o modelo sem pesos, 0,91 a 0,94 para os outros), os cinco ficam juntos perto de recall 0,45 e precisão 0,45

(script da figura)

A lição: reamostragem e pesos basicamente movem o ponto de operação; mover o limiar faz o mesmo, de graça. Vale tentá-los, especialmente com modelos em que não é fácil trocar o limiar ou quando há pouquíssimos positivos, mas eles não são mágica e trazem duas armadilhas.

10. Duas armadilhas

Reamostrar antes da divisão

Etapa 4. Reamostrar o conjunto inteiro e depois dividir é o erro clássico, uma forma de vazamento de dados. A diferença entre as duas ordens está no que o "teste" passa a conter:

Em cima, a ordem errada: todas as linhas com 2% de fraude são sobreamostradas (cada fraude repetida cerca de 50 vezes) e só depois divididas, então o "teste" tem 50% de fraudes e cópias das fraudes do treino; um 5-NN marca recall 1,00 e precisão 0,96. Embaixo, a ordem certa: dividir primeiro, sobreamostrar só as linhas de treino dentro do pipeline e prever na validação e no teste intocados, com 2% de fraude; o mesmo 5-NN marca recall 0,42 e precisão 0,20

(script da figura)

Medido com dois amostradores e dois modelos, contra a versão honesta (dividir primeiro, reamostrar só a parte de treino, dentro do pipeline):

from sklearn.neighbors import KNeighborsClassifier

X_dev, y_dev = np.r_[X_train, X_val], np.r_[y_train, y_val]      # tudo menos o conjunto de teste
for sampler in [RandomOverSampler(random_state=0), SMOTE(random_state=0)]:
    for clf in [KNeighborsClassifier(n_neighbors=5), LogisticRegression()]:
        # ERRADO: reamostrar o conjunto inteiro e depois dividir
        X_r, y_r = sampler.fit_resample(X, y)
        Xa, Xb, ya, yb = train_test_split(X_r, y_r, test_size=0.2, stratify=y_r, random_state=0)
        wrong = clf.fit(Xa, ya).predict(Xb)
        # CERTO: dividir primeiro, reamostrar só no ajuste
        right = make_imb_pipeline(sampler, clf).fit(X_dev, y_dev).predict(X_test)
        print(f"{type(sampler).__name__:17s} {type(clf).__name__:20s} "
              f"wrong: R {recall_score(yb, wrong):.2f} P {precision_score(yb, wrong):.2f}   "
              f"right: R {recall_score(y_test, right):.2f} P {precision_score(y_test, right):.2f}")
print(f"fraud share in the 'wrong' test set: {yb.mean():.0%}")
RandomOverSampler KNeighborsClassifier wrong: R 1.00 P 0.96   right: R 0.42 P 0.20
RandomOverSampler LogisticRegression   wrong: R 0.82 P 0.84   right: R 0.82 P 0.09
SMOTE             KNeighborsClassifier wrong: R 1.00 P 0.92   right: R 0.56 P 0.12
SMOTE             LogisticRegression   wrong: R 0.84 P 0.84   right: R 0.82 P 0.10
fraud share in the 'wrong' test set: 50%

Duas coisas dão errado ao mesmo tempo:

  • o conjunto de teste está contaminado. Depois da sobreamostragem, cada fraude existe cerca de 50 vezes, então quase toda fraude da parte de "teste" tem cópias idênticas na parte de "treino". O k-NN as encontra e copia seu rótulo: recall 1,00 e precisão 0,96, contra 0,42 e 0,20 na versão honesta. O SMOTE mal melhora, já que cada ponto sintético de teste está num segmento entre pontos de treino;
  • o conjunto de teste não tem mais a prevalência real. Ele tem 50% de fraudes em vez de 2%. Mesmo a regressão logística, que não consegue decorar cópias e mantém seu recall de 0,82, salta de precisão 0,09 para 0,84, um número que não diz nada sobre a produção, onde 98% das transações são legítimas.

Probabilidades distorcidas

Pesos ou reamostragem dizem ao modelo que as fraudes são 50% do mundo. Ele acredita:

p_smote = models['SMOTE'].predict_proba(X_test)[:, 1]
print(f"true fraud rate (test):       {y_test.mean():.4f}")
print(f"mean probability, no weights: {p_plain.mean():.4f}")
print(f"mean probability, balanced:   {p_w.mean():.4f}")
print(f"mean probability, SMOTE:      {p_smote.mean():.4f}")
odds = p_w / (1 - p_w) * n_c[1] / n_c[0]         # desfaz a reponderação: chances vezes n1/n0
print(f"balanced, corrected:          {(odds / (1 + odds)).mean():.4f}")
true fraud rate (test):       0.0195
mean probability, no weights: 0.0206
mean probability, balanced:   0.2370
mean probability, SMOTE:      0.2227
balanced, corrected:          0.0208

As probabilidades do modelo sem pesos têm média 0,0206, perto da taxa real de fraude, 0,0195. Os modelos com pesos balanceados e com SMOTE têm médias 0,237 e 0,223: mais de dez vezes altas demais. Se você só precisa de uma ordenação ou de uma decisão sim/não num limiar ajustado, isso não importa. Se precisa do número em si (uma perda esperada, um escore de risco mostrado a um médico, a fórmula de custo \(t^* = c_{FP}/(c_{FP}+c_{FN})\) da seção 6), importa. Para a regressão logística a correção é direta: multiplique as chances por \(n_1/n_0\) para desfazer os pesos, o que traz a média de volta a 0,0208 (Elkan, 2001). Para outros modelos, recalibre as probabilidades em dados separados; o CalibratedClassifierCV do scikit-learn faz isso, usando a validação cruzada que vem mais adiante no curso.

11. Uma ordem sensata de ataque

  1. Meça a coisa certa. Estratifique toda divisão. Reporte recall, precisão, curva PR e AP ao lado da prevalência, além da ROC AUC para a ordenação. A acurácia não diz quase nada aqui. Muitos "problemas de desbalanceamento" são problemas de métrica.
  2. Escolha o limiar em dados de validação, com o critério que a aplicação implica (uma meta de recall, uma razão de custos, o melhor F1 se nada mais for conhecido).
  3. Pesos de classe estão a um argumento de distância e não mudam os dados. Eles basicamente deslocam o ponto de operação; combinados com o passo 2, raramente mudam muito numa regressão logística, mas podem ajudar modelos cujo treino é dominado pela maioria (árvores, SVMs).
  4. Reamostragem só dentro de um pipeline do imblearn, só nos dados de treino e comparada com os passos 2–3 no mesmo conjunto de validação. Espere diferenças pequenas.
  5. Mais exemplos da minoria é o único remédio que melhora a própria ordenação. Nenhuma reamostragem inventa informação que os dados não têm.

Lab avançado — remédios, limiar e o único teste

Os passos 6 a 11 continuam a mesma triagem de mamografias, agora com a meta declarada no começo do lab fundamental: recall de pelo menos 0,80 nos casos malignos, com a melhor precisão possível.

Como fazer este lab em aula (cerca de 35 min, em duplas)

  1. Use o mesmo notebook do Colab do lab fundamental e vá até o passo 6. Nada para baixar.
  2. Os passos 6 a 11 usam os dados e o modelo dos passos 1 a 4. Se a sessão do Colab for nova (ou tiver expirado), clique na célula do passo 6 e use Ambiente de execução → Executar anteriores (Ctrl+F8) antes de continuar.
  3. O passo 7 usa o imbalanced-learn. Ele já vem no Colab; se faltar, rode !pip install -q imbalanced-learn numa célula antes.
  4. A dinâmica é a mesma: Preveja antes de rodar, compare com a página e tente as Simulações antes de abrir a solução.
  5. Ritmo sugerido: passos 6 e 7 com a simulação em 15 min; passo 8 e a simulação em 10 min; passos 9 a 11 em 10 min.

Passo 6 — pesos de classe

Preveja. Com class_weight='balanced', o que você espera que mude mais: a ROC AUC (a ordenação) ou o número de regiões sinalizadas em 0,5?

lr_w = make_pipeline(StandardScaler(), LogisticRegression(class_weight='balanced')).fit(X_train, y_train)
p_val_w = lr_w.predict_proba(X_val)[:, 1]
pred = (p_val_w >= 0.5).astype(int)
print(f"flagged {pred.sum()}   recall {recall_score(y_val, pred):.3f}   precision {precision_score(y_val, pred):.3f}")
print(f"ROC AUC {roc_auc_score(y_val, p_val_w):.3f}   AP {average_precision_score(y_val, p_val_w):.3f}")
n0, n1 = np.bincount(y_train)
print(f"intercept shift {lr_w[-1].intercept_[0] - lr[-1].intercept_[0]:.2f}   log(n0/n1) = {np.log(n0 / n1):.2f}")
flagged 298   recall 0.904   precision 0.158
ROC AUC 0.954   AP 0.641
intercept shift 4.04   log(n0/n1) = 3.74

Como na aula, os pesos basicamente movem o ponto de operação: em 0,5 o modelo com pesos sinaliza 298 regiões em vez de 30, o recall salta de 0,442 para 0,904 e a precisão cai de 0,767 para 0,158. O intercepto sobe 4,04, perto de \(\log(n_0/n_1) = 3{,}74\). Em dados reais, a coincidência é mais frouxa do que na triagem de fraude simulada; a ordenação também se move um pouco: a ROC AUC sobe (0,946 → 0,954) enquanto a AP desce (0,653 → 0,641). As duas métricas pesam partes diferentes da curva; aqui, as mudanças estão dentro do ruído de 52 positivos.

Simulação: o limiar que imita os pesos. Se os pesos basicamente deslocam o ponto de operação, dá para chegar perto sem retreinar: corte os escores do modelo simples na prevalência do treino em vez de em 0,5. Compare esse ponto com o do modelo com pesos em 0,5 e conte em quantas regiões as duas regras discordam.

Solução
t_prev = y_train.mean()
a = p_val >= t_prev                               # modelo simples, limiar = prevalência do treino
b = p_val_w >= 0.5                                # modelo com pesos, limiar padrão
for name, f in [(f'simples, t = {t_prev:.4f}', a), ('balanced, t = 0.5   ', b)]:
    print(f"{name}: sinalizadas {f.sum():3d}  recall {recall_score(y_val, f):.3f}"
          f"  precisão {precision_score(y_val, f):.3f}")
print(f"regiões classificadas de forma diferente: {(a != b).sum()} de {len(y_val)}")
simples, t = 0.0233: sinalizadas 366  recall 0.923  precisão 0.131
balanced, t = 0.5   : sinalizadas 298  recall 0.904  precisão 0.158
regiões classificadas de forma diferente: 110 de 2237

Os dois pontos de operação são parecidos — recall alto, precisão por volta de 0,14 — e nenhum modelo precisou ser retreinado para chegar ao primeiro. Mas a correspondência é mais frouxa do que na triagem de fraude simulada da seção 9, em que as duas regras discordavam em 42 de 4.000 linhas: aqui são 110 de 2.237. O motivo é que pesar a classe não desloca exatamente o intercepto em dados reais; o passo 6 já tinha mostrado isso (4,04 contra \(\log(n_0/n_1) = 3{,}74\)). A lição prática continua: antes de retreinar com pesos, teste o que um limiar diferente faria, porque ele é de graça e reversível.

Passo 7 — reamostragem dentro de um pipeline do imblearn

Preveja. A subamostragem vai treinar em quantas linhas, mais ou menos? E o SMOTE? Qual das duas joga informação fora?

from imblearn.over_sampling import SMOTE
from imblearn.pipeline import make_pipeline as make_imb_pipeline
from imblearn.under_sampling import RandomUnderSampler

models = {
    'logistic': lr,
    'logistic, balanced': lr_w,
    'undersampling': make_imb_pipeline(StandardScaler(), RandomUnderSampler(random_state=0),
                                       LogisticRegression()),
    'SMOTE': make_imb_pipeline(StandardScaler(), SMOTE(random_state=0), LogisticRegression()),
}
for name in ['undersampling', 'SMOTE']:
    models[name].fit(X_train, y_train)            # o amostrador roda aqui, só no X_train
    X_r, y_r = models[name][1].fit_resample(X_train, y_train)
    print(f"{name:14s} trains on {len(y_r):5d} rows, {y_r.sum():4d} malignant")
undersampling  trains on   312 rows,  156 malignant
SMOTE          trains on 13106 rows, 6553 malignant

Os dois amostradores vão em direções opostas. A subamostragem treina a regressão logística em 312 linhas (as 156 malignas mais 156 benignas sorteadas), descartando 6.397 regiões benignas. O SMOTE mantém todas as 6.553 regiões benignas e acrescenta 6.397 malignas sintéticas. Os dois vivem dentro de um pipeline do imblearn, então as linhas de validação e teste nunca são reamostradas: o predict_proba pula o amostrador. (A chamada a fit_resample aqui só conta as linhas; os pipelines já foram ajustados na linha anterior.)

Passo 8 — comparar no conjunto de validação

Preveja. Entre os quatro modelos, qual você espera que tenha a melhor ordenação (ROC AUC e AP)? A diferença vai ser grande?

from sklearn.metrics import f1_score

rows = []
for name, model in models.items():
    p = model.predict_proba(X_val)[:, 1]
    f = p >= 0.5
    rows.append({'model': name, 'ROC AUC': roc_auc_score(y_val, p), 'AP': average_precision_score(y_val, p),
                 'precision@0.5': precision_score(y_val, f), 'recall@0.5': recall_score(y_val, f),
                 'F1@0.5': f1_score(y_val, f)})
comparison = pd.DataFrame(rows).set_index('model')
print(comparison.round(3).to_string())
                    ROC AUC     AP  precision@0.5  recall@0.5  F1@0.5
model                                                                
logistic              0.946  0.653          0.767       0.442   0.561
logistic, balanced    0.954  0.641          0.158       0.904   0.269
undersampling         0.941  0.622          0.146       0.904   0.251
SMOTE                 0.955  0.647          0.164       0.885   0.276

O mesmo padrão da triagem de fraude simulada:

  • ordenação: ROC AUC entre 0,941 e 0,955, AP entre 0,622 e 0,653. Com 52 positivos na validação, uma região maligna ordenada algumas posições acima ou abaixo move esses números mais ou menos nessa medida. Nenhum remédio é claramente melhor em ordenar; a subamostragem, que jogou fora 97% dos dados benignos, é ligeiramente a pior nas duas;
  • em 0,5: o modelo simples está em precisão 0,767 / recall 0,442, os três rebalanceados em 0,146–0,164 / 0,885–0,904. Nenhum dos dois é o ponto de operação que queremos (recall ≥ 0,80 com a melhor precisão); ambos são só onde o 0,5 calha de cortar.

Como a escolha do limiar é nossa, a comparação justa é a da ordenação, que diz: fique com o modelo mais simples.

Simulação: comparar no limiar justo. A tabela acima corta os quatro modelos em 0,5, o que é injusto: cada um põe seus escores numa escala diferente. Refaça a comparação dando a cada modelo o seu próprio limiar, o que atinge recall ≥ 0,80 na validação com a melhor precisão. As diferenças aumentam ou diminuem?

Solução
print("modelo                limiar  recall  precisão  sinalizadas")
for name, model in models.items():
    p = model.predict_proba(X_val)[:, 1]
    prec, rec, thr = precision_recall_curve(y_val, p)
    ok = rec[:-1] >= 0.80
    i = int(np.argmax(np.where(ok, prec[:-1], -1)))
    print(f"{name:20s}{thr[i]:8.4f}{rec[i]:8.3f}{prec[i]:10.3f}{(p >= thr[i]).sum():13d}")
modelo                limiar  recall  precisão  sinalizadas
logistic              0.0632   0.808     0.294          143
logistic, balanced    0.6026   0.808     0.211          199
undersampling         0.6433   0.808     0.206          204
SMOTE                 0.6379   0.808     0.255          165

Com todos no mesmo recall, o que sobra para comparar é a precisão — e aí a regressão logística simples fica na frente (0,294 contra 0,206 a 0,255), ou seja, 143 regiões sinalizadas em vez de até 204 para encontrar os mesmos 42 cânceres. Repare nos limiares: 0,06 para o modelo simples e 0,60 a 0,64 para os rebalanceados. O mesmo ponto de operação aparece com números completamente diferentes no mostrador, o que é exatamente por que comparar modelos num limiar fixo de 0,5 não quer dizer nada. Nenhum remédio ganhou da ordenação original; o mais simples venceu.

Passo 9 — escolher o modelo e o limiar na validação

Preveja. Dado o que o passo 8 mostrou, qual modelo deve ser escolhido? E o limiar que atinge recall 0,80 vai ficar perto de 0,5 ou bem abaixo?

best_name = comparison['AP'].idxmax()
best = models[best_name]
p_val_best = best.predict_proba(X_val)[:, 1]
prec, rec, thr = precision_recall_curve(y_val, p_val_best)
ok = rec[:-1] >= 0.80                            # limiares que atingem a meta de recall
i = np.argmax(np.where(ok, prec[:-1], -1))       # entre eles, a melhor precisão
threshold = thr[i]
print(f"model: {best_name}   threshold {threshold:.4f}")
print(f"validation: recall {rec[i]:.3f}   precision {prec[i]:.3f}   flagged {(p_val_best >= threshold).sum()}")
model: logistic   threshold 0.0632
validation: recall 0.808   precision 0.294   flagged 143

O modelo com a melhor AP na validação é a regressão logística simples (0,653, à frente dos 0,647 do SMOTE por um fio). Entre os seus limiares que chegam a recall 0,80 na validação, o de melhor precisão é 0,0632: o modelo sinaliza toda região com probabilidade estimada de malignidade acima de 6,3%. Na validação isso significa 143 regiões sinalizadas, 42 dos 52 cânceres encontrados (0,808), precisão 0,294. Um limiar de 0,06 pode parecer estranho, mas é a aritmética de uma taxa base de 2,3% e de uma perda que custa muito mais do que uma segunda olhada.

Passo 10 — o único teste

Preveja. O recall no teste vai ficar acima ou abaixo dos 0,808 da validação? Com 52 casos malignos, quanto vale um caso?

from sklearn.metrics import confusion_matrix

p_test = best.predict_proba(X_test)[:, 1]
pred_test = (p_test >= threshold).astype(int)
print(confusion_matrix(y_test, pred_test, labels=[1, 0]))
print(f"recall {recall_score(y_test, pred_test):.3f}   precision {precision_score(y_test, pred_test):.3f}"
      f"   flagged {pred_test.sum()} of {len(pred_test)}")
print(f"ROC AUC {roc_auc_score(y_test, p_test):.3f}   AP {average_precision_score(y_test, p_test):.3f}")
[[  43    9]
 [  97 2088]]
recall 0.827   precision 0.307   flagged 140 of 2237
ROC AUC 0.913   AP 0.644

Uma vez, no fim, o modelo e o limiar escolhidos encontram o conjunto de teste. 43 das 52 regiões malignas são encontradas (recall 0,827) e 9 passam; 97 regiões benignas são sinalizadas, então o radiologista revisa 140 de 2.237 regiões (6,3%) e cerca de 1 em cada 3 delas é um câncer (precisão 0,307). As estimativas da validação (recall 0,808, precisão 0,294) se sustentaram. Dois cuidados. Com 52 positivos, um câncer vale 1,9 ponto de recall, então "0,827" quer dizer "43, mais ou menos alguns"; a ROC AUC, 0,913 aqui contra 0,946 na validação, se move tanto quanto isso entre duas divisões deste tamanho. E se 0,827 decepcionar você, voltar agora para mudar o limiar transformaria o conjunto de teste num conjunto de validação.

Passo 11 — a escada grosseira de um k-NN

Preveja. Com \(k = 5\), quantas probabilidades distintas o k-NN pode produzir? Quantos pontos a curva ROC dele vai ter e isso é um problema para a nossa meta de recall 0,80?

from sklearn.neighbors import KNeighborsClassifier

knn = make_pipeline(StandardScaler(), KNeighborsClassifier(n_neighbors=5)).fit(X_train, y_train)
p_knn = knn.predict_proba(X_val)[:, 1]
print("distinct probabilities:", np.unique(p_knn))
fpr_k, tpr_k, thr_k = roc_curve(y_val, p_knn)
print(f"ROC points: {len(thr_k)}   ROC AUC {roc_auc_score(y_val, p_knn):.3f}   AP {average_precision_score(y_val, p_knn):.3f}")
for t, f, r in zip(thr_k, fpr_k, tpr_k):
    print(f"threshold {t:.1f}   FPR {f:.3f}   recall {r:.3f}")

fig, ax = plt.subplots(figsize=(5, 4))
RocCurveDisplay.from_predictions(y_val, p_val, ax=ax, name='logistic')
RocCurveDisplay.from_predictions(y_val, p_knn, ax=ax, name='5-NN')
plt.show()
distinct probabilities: [0.  0.2 0.4 0.6 0.8 1. ]
ROC points: 7   ROC AUC 0.935   AP 0.670
threshold inf   FPR 0.000   recall 0.000
threshold 1.0   FPR 0.001   recall 0.308
threshold 0.8   FPR 0.002   recall 0.481
threshold 0.6   FPR 0.004   recall 0.558
threshold 0.4   FPR 0.007   recall 0.750
threshold 0.2   FPR 0.038   recall 0.885
threshold 0.0   FPR 1.000   recall 1.000

Com \(k = 5\), a probabilidade de malignidade é a fração de regiões malignas entre os 5 vizinhos mais próximos, então só pode ser 0; 0,2; 0,4; 0,6; 0,8 ou 1: seis limiares, sete pontos ROC contando o canto. A ordenação não é ruim (ROC AUC 0,935, AP 0,670, perto da regressão logística), mas o cardápio de pontos de operação é curto. Para a nossa meta, recall ≥ 0,80, o k-NN precisa saltar de 0,750 (limiar 0,4) para 0,885 (limiar 0,2, FPR 0,038): não há nada no meio. O último segmento da curva ROC dele, de FPR 0,038 até 1, é uma reta: a AUC conta a área sob ela, mas o único jeito de operar sobre ela é sinalizar ao acaso as regiões com probabilidade 0. Um \(k\) maior dá mais valores distintos e uma curva mais fina.

Para fechar a aula. Em uma frase cada: (1) o que os pesos de classe mudaram e o que não mudaram? (2) Por que reamostrar antes de dividir inflaciona o resultado de duas maneiras? (3) Por que a regressão logística simples venceu a comparação?

Sua vez (avançado)

Para casa, com mais tempo:

  1. Um custo em vez de uma meta. Suponha que deixar passar um câncer custe 20 vezes uma nova convocação desnecessária. No conjunto de validação, calcule o custo \(20 \cdot FN + FP\) para cada limiar do modelo escolhido e encontre o mínimo. Compare com a fórmula \(t^* = 1/(1+20)\). Por que eles diferem?
  2. Reamostrar antes de dividir. Aplique SMOTE a todo o X, y, depois divida 80/20 e ajuste um 5-NN. Compare seu recall e sua precisão com o passo 10. Quais duas coisas deram errado?
  3. Mais vizinhos. Repita o passo 11 com \(k = 15\) e \(k = 51\). Quantas probabilidades distintas, quantos pontos ROC e o que acontece com a AUC? Quais pontos de operação perto de recall 0,80 passam a existir?
  4. A sorte da divisão. Troque random_state=7 no passo 2 por 0, 1, 2, 3 e 4 e rode de novo os passos 2–10. Quanto se movem o limiar escolhido e o recall de teste? O que isso diz sobre reportar "recall 0,827" com três casas decimais?

Referências

  • Hanley, J. A.; McNeil, B. J. "The Meaning and Use of the Area under a Receiver Operating Characteristic (ROC) Curve." Radiology 143, no. 1 (1982): 29–36 — a AUC como a probabilidade de ordenar um positivo ao acaso acima de um negativo ao acaso. DOI
  • Fawcett, T. "An Introduction to ROC Analysis." Pattern Recognition Letters 27, no. 8 (2006): 861–874. DOI
  • Davis, J.; Goadrich, M. "The Relationship Between Precision-Recall and ROC Curves." Proceedings of the 23rd International Conference on Machine Learning (ICML) (2006): 233–240 — por que pontos PR não podem ser ligados por retas. DOI
  • Saito, T.; Rehmsmeier, M. "The Precision-Recall Plot Is More Informative than the ROC Plot When Evaluating Binary Classifiers on Imbalanced Datasets." PLoS ONE 10, no. 3 (2015): e0118432. DOI
  • Youden, W. J. "Index for Rating Diagnostic Tests." Cancer 3, no. 1 (1950): 32–35. DOI
  • Elkan, C. "The Foundations of Cost-Sensitive Learning." Proceedings of the 17th International Joint Conference on Artificial Intelligence (IJCAI) (2001): 973–978 — o limiar ótimo para custos dados e como a reponderação muda as probabilidades. PDF
  • Chawla, N. V.; Bowyer, K. W.; Hall, L. O.; Kegelmeyer, W. P. "SMOTE: Synthetic Minority Over-sampling Technique." Journal of Artificial Intelligence Research 16 (2002): 321–357. DOI
  • He, H.; Garcia, E. A. "Learning from Imbalanced Data." IEEE Transactions on Knowledge and Data Engineering 21, no. 9 (2009): 1263–1284 — um panorama de métodos de amostragem, sensíveis a custo e de avaliação. DOI
  • Lemaître, G.; Nogueira, F.; Aridas, C. K. "Imbalanced-learn: A Python Toolbox to Tackle the Curse of Imbalanced Datasets in Machine Learning." JMLR 18, no. 17 (2017): 1–5. PDF
  • imbalanced-learn — reamostragem (SMOTE, sub/sobreamostragem) dentro de pipelines.

Quiz

O quiz cobre a abordagem fundamental.