Esta é, possivelmente, a aula mais importante do curso. Avaliar um modelo é responder à pergunta: "qual desempenho eu espero em dados que o modelo nunca viu?" — e essa pergunta é traiçoeira. A maioria dos desastres de ML nasce aqui: em uma validação que superestimou o desempenho por vazar informação ou por usar uma estratégia inadequada à estrutura dos dados.

Dominar as estratégias de estimação de desempenho: hold-out, validação cruzada (k-fold, estratificada, agrupada), bootstrap, e validação orientada ao tempo. Entender variância das estimativas e como comparar modelos com testes estatísticos.

O problema central: estimar o erro de generalização

O erro que nos interessa é o erro de generalização — o erro esperado em novos dados. Não temos acesso a ele; só podemos estimá-lo. Toda estratégia de avaliação é uma forma de simular "dados novos" a partir dos dados que temos.

Duas propriedades definem a qualidade de uma estimativa:

  • Viés: ela tende a superestimar ou subestimar sistematicamente o desempenho real?
  • Variância: ela oscila muito dependendo de como os dados foram divididos?

O ideal é baixo viés e baixa variância. Na prática, escolhemos estratégias que equilibram os dois conforme o tamanho e a estrutura dos dados.

Hold-out simples

from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import roc_auc_score
from sklearn.datasets import make_classification

X, y = make_classification(n_samples=2000, n_features=20,
                           weights=[0.7, 0.3], random_state=42)

X_tr, X_te, y_tr, y_te = train_test_split(
    X, y, test_size=0.25, stratify=y, random_state=42
)

clf = RandomForestClassifier(n_estimators=300, random_state=42)
clf.fit(X_tr, y_tr)
auc = roc_auc_score(y_te, clf.predict_proba(X_te)[:, 1])
print(f"AUC (hold-out): {auc:.3f}")

Prós: simples e barato. Contras: alta variância com poucos dados (o resultado depende muito de qual 25% caiu no teste) e desperdiça dados (o teste não é usado no treino). Use quando há muitos dados. Sempre stratify=y em classificação.

Validação cruzada k-fold

Divide os dados em k partes; treina em k−1 e valida na parte restante, rodando k vezes. Cada amostra é usada para validação exatamente uma vez.

from sklearn.model_selection import cross_val_score, StratifiedKFold
import numpy as np

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(clf, X, y, cv=cv, scoring="roc_auc")

print(f"AUC por fold: {np.round(scores, 3)}")
print(f"AUC médio:    {scores.mean():.3f} ± {scores.std():.3f}")
  • k-fold estratificado (StratifiedKFold) mantém a proporção de classes em cada fold — essencial em classificação, sobretudo desbalanceada.
  • Escolha de k: 5 ou 10 são padrões. k maior → menor viés, maior custo e maior correlação entre folds.
  • Repeated k-fold: repete o processo com embaralhamentos diferentes para reduzir a variância da estimativa.
from sklearn.model_selection import RepeatedStratifiedKFold
rcv = RepeatedStratifiedKFold(n_splits=5, n_repeats=3, random_state=42)
scores = cross_val_score(clf, X, y, cv=rcv, scoring="roc_auc")
print(f"AUC (5x3): {scores.mean():.3f} ± {scores.std():.3f}")

Leave-One-Out e o trade-off viés-variância da CV

LOO-CV (k = n) tem viés mínimo (quase todos os dados no treino) mas variância alta e custo enorme. Na prática, k = 5 ou 10 costuma dar o melhor equilíbrio — um resultado empírico bem estabelecido na literatura.

Validação com grupos (GroupKFold)

Quando amostras não são independentes — várias medições do mesmo paciente, transações do mesmo cliente, frames do mesmo vídeo — misturar o mesmo grupo entre treino e validação vaza informação. Use GroupKFold.

from sklearn.model_selection import GroupKFold

# groups[i] identifica a que paciente/cliente pertence a amostra i
groups = np.random.randint(0, 200, size=len(y))  # exemplo

gkf = GroupKFold(n_splits=5)
scores = cross_val_score(clf, X, y, groups=groups, cv=gkf, scoring="roc_auc")
print(f"AUC (group): {scores.mean():.3f} ± {scores.std():.3f}")

Regra: a unidade de validação deve ser a unidade sobre a qual você generaliza. Se você vai prever para pacientes novos, valide deixando pacientes inteiros de fora.

Bootstrap para intervalos de confiança

O bootstrap reamostra com reposição para estimar a distribuição de uma métrica — dando intervalos de confiança, não só um ponto.

import numpy as np
from sklearn.metrics import roc_auc_score

def bootstrap_auc(y_true, y_score, n_boot=2000, seed=42):
    rng = np.random.default_rng(seed)
    n = len(y_true)
    aucs = []
    for _ in range(n_boot):
        idx = rng.integers(0, n, n)          # amostra com reposição
        if len(np.unique(y_true[idx])) < 2:  # precisa das duas classes
            continue
        aucs.append(roc_auc_score(y_true[idx], y_score[idx]))
    aucs = np.array(aucs)
    return aucs.mean(), np.percentile(aucs, [2.5, 97.5])

y_score = clf.fit(X_tr, y_tr).predict_proba(X_te)[:, 1]
media, (lo, hi) = bootstrap_auc(y_te, y_score)
print(f"AUC = {media:.3f}  IC95% = [{lo:.3f}, {hi:.3f}]")

Reportar um intervalo de confiança em vez de um número seco é sinal de maturidade metodológica: comunica a incerteza da estimativa.

Validação orientada ao tempo

Em séries temporais e dados com ordem cronológica, você nunca pode usar o futuro para prever o passado. K-fold comum embaralha o tempo e vaza informação futura, inflando o desempenho de forma irreal.

from sklearn.model_selection import TimeSeriesSplit

tscv = TimeSeriesSplit(n_splits=5)
# Treino sempre no passado, validação no futuro imediato
for i, (tr_idx, va_idx) in enumerate(tscv.split(X)):
    print(f"Fold {i}: treino[0:{tr_idx[-1]+1}]  ->  val[{va_idx[0]}:{va_idx[-1]+1}]")

Padrões importantes:

  • Expanding window: o treino cresce a cada fold (usa todo o passado).
  • Sliding window: janela de treino de tamanho fixo que desliza (bom quando há drift).
  • Gap/embargo: deixe um intervalo entre treino e validação para evitar vazamento por autocorrelação ou por features que "olham para trás".
     |--- treino ---|  gap  |-- val --|
tempo ─────────────────────────────────►

Comparando modelos: não confie só na média

Modelo A teve AUC médio 0.86 e B teve 0.85. A é melhor? Talvez não — a diferença pode estar dentro do ruído. Compare de forma pareada, fold a fold.

from sklearn.model_selection import cross_val_score
from scipy.stats import wilcoxon
from lightgbm import LGBMClassifier

cv = StratifiedKFold(n_splits=10, shuffle=True, random_state=42)
scores_a = cross_val_score(RandomForestClassifier(random_state=42), X, y, cv=cv, scoring="roc_auc")
scores_b = cross_val_score(LGBMClassifier(random_state=42, verbose=-1), X, y, cv=cv, scoring="roc_auc")

stat, p = wilcoxon(scores_a, scores_b)  # teste pareado não-paramétrico
print(f"A: {scores_a.mean():.3f} | B: {scores_b.mean():.3f} | p = {p:.3f}")
print("Diferença significativa" if p < 0.05 else "Sem evidência de diferença")

Cuidados: os folds compartilham dados, o que viola a independência assumida por muitos testes (o teste t pareado ingênuo tende a ter taxa de falso-positivo inflada). Alternativas mais robustas incluem o teste t corrigido de Nadeau-Bengio (para repeated CV) e, para comparar muitos modelos em muitos datasets, o teste de Friedman seguido do pós-teste de Nemenyi, visualizado com diagramas de diferença crítica.

Benchmarking sistemático em R (mlr3)

library(mlr3verse)

task <- tsk("sonar")
learners <- list(
  lrn("classif.ranger",  predict_type = "prob"),
  lrn("classif.xgboost", predict_type = "prob"),
  lrn("classif.log_reg", predict_type = "prob")
)
resampling <- rsmp("repeated_cv", repeats = 5, folds = 5)

design <- benchmark_grid(task, learners, resampling)
bmr <- benchmark(design)

bmr$aggregate(msr("classif.auc"))
# autoplot(bmr) para o boxplot comparativo

O mlr3 foi feito para exatamente isso: rodar muitos learners sob o mesmo resampling e comparar de forma justa.

Avaliar é estimar o erro de generalização, e toda estratégia troca viés por variância. O hold-out é barato e ruidoso — serve quando há muitos dados, e sempre estratificado. O k-fold estratificado, com 5 ou 10 folds e possivelmente repetido, é o padrão robusto em classificação. Quando as amostras não são independentes entra o GroupKFold, seguindo a regra de que a unidade de validação precisa ser a unidade de generalização. O bootstrap dá intervalos de confiança para as métricas. Dados temporais pedem TimeSeriesSplit com gap ou embargo: embaralhar o tempo é vazar o futuro para dentro do treino. E, na hora de comparar modelos, compare de forma pareada e com teste estatístico apropriado — Wilcoxon, t corrigido, Friedman com Nemenyi.

Exercícios

  1. Você tem 500 exames de imagem provenientes de 80 pacientes (vários exames por paciente) e quer prever uma doença em pacientes novos. Qual estratégia de validação usar e por quê?

    ✓ Resposta:

    GroupKFold (ou StratifiedGroupKFold) com o paciente como grupo. Como há múltiplos exames por paciente, exames do mesmo paciente são correlacionados; se um paciente aparecesse ao mesmo tempo no treino e na validação, o modelo poderia "reconhecer" o paciente em vez de generalizar para pacientes novos, vazando informação e inflando o desempenho. Deixando pacientes inteiros de fora, a validação reflete o cenário real (pacientes nunca vistos). Como o alvo é uma doença (possivelmente rara), a versão estratificada por grupo é preferível.

  2. Por que aplicar k-fold comum (com embaralhamento) em uma série temporal produz estimativas otimistas e inúteis?

    ✓ Resposta:

    O embaralhamento coloca observações do futuro no conjunto de treino e observações do passado na validação. O modelo aprende padrões usando informação que, na operação real, ainda não existiria no momento da previsão (vazamento temporal). Além disso, séries costumam ter autocorrelação: pontos vizinhos no tempo são parecidos, então validar em um ponto cercado por vizinhos de treino é quase "decorar". O desempenho estimado fica muito acima do que se obteria prevendo o futuro de verdade. O correto é TimeSeriesSplit (treino no passado, validação no futuro), idealmente com um gap/embargo entre eles.

  3. Qual a vantagem de reportar um intervalo de confiança via bootstrap em vez de apenas a média da métrica?

    ✓ Resposta:

    O intervalo comunica a incerteza da estimativa. Um AUC de 0.85 com IC95% [0.83, 0.87] é muito mais confiável do que um AUC de 0.85 com IC95% [0.70, 0.95]. O bootstrap aproxima a distribuição amostral da métrica reamostrando com reposição, permitindo quantificar quão estável é o resultado e comparar modelos considerando sobreposição de intervalos — em vez de tomar decisões com base em diferenças que podem ser puro ruído.

  4. Dois modelos têm AUC médio de 0.860 e 0.858 em 10-fold CV. Que passos você toma antes de declarar o primeiro como vencedor?

    ✓ Resposta:

    (1) Comparar fold a fold (pareado), não só as médias. (2) Aplicar um teste estatístico apropriado — Wilcoxon pareado ou, para repeated CV, o t corrigido de Nadeau-Bengio (o t pareado ingênuo infla falso-positivos porque os folds compartilham dados). (3) Verificar a variância entre folds: uma diferença de 0.002 provavelmente está dentro do ruído. (4) Considerar critérios além da métrica: custo computacional, interpretabilidade, robustez a drift, calibração. Conclusão provável: não há evidência de diferença real; escolha pelo modelo mais simples/barato.

  5. O que é um "embargo" (gap) na validação temporal e quando ele é necessário?

    ✓ Resposta:

    É um intervalo de tempo deliberadamente descartado entre o fim do treino e o início da validação. Ele é necessário quando há autocorrelação ou quando features são construídas com janelas que "olham para trás" (médias móveis, defasagens) ou o alvo depende de um horizonte futuro. Sem o gap, informação próxima da fronteira treino/validação vaza (por exemplo, uma média móvel calculada perto do corte usa pontos que estão do outro lado). O embargo garante que treino e validação estejam suficientemente separados no tempo para simular o uso real.

Fontes e leituras recomendadas