Ir para o conteúdo

Train/Val/Test Split

Divisão Treino / Validação / Teste

A divisão não é um passo de preparação. É o desenho experimental do projeto inteiro: ela decide a que pergunta o seu número final responde e o quanto você tem direito de acreditar nele.

Duas coisas dão errado e são opostas. Ou a divisão deixa informação atravessar — caso em que o número fica alto demais e vazamento é o capítulo sobre isso. Ou a divisão é honesta mas pequena demais, ou usada vezes demais, caso em que o número fica ruidoso demais para sustentar a decisão que você está tomando com ele. Esta página é sobretudo sobre a segunda, porque quase ninguém a mede.

Os três conjuntos e o que cada um custa

Conjunto Para que serve Com que frequência se pode olhar
Treino ajustar os pesos continuamente
Validação toda escolha que você faz — arquitetura, taxa de aprendizado, quando parar, quais features quantas vezes quiser, sabendo que ele está sendo consumido
Teste um número em que escolha nenhuma se baseou uma vez

Validação não é "um segundo conjunto de teste". É o conjunto que você tem permissão de queimar. Cada decisão tomada olhando para ele transfere um pouco dele para dentro do modelo, que é por que a pontuação dele sobe ao longo de um projeto e por que o conjunto de teste precisa ficar inteiramente fora desse processo.


1. Dividir o quê, exatamente

O train_test_split divide linhas. Se linhas são a unidade certa é uma pergunta sobre os seus dados e errar isso é o bug de divisão mais comum no mundo real.

Estratégia Respeita Use quando
train_test_split(...) nada as linhas são genuinamente independentes — mais raro do que se pensa
stratify=y a proporção das classes classificação, sempre; essencial quando uma classe é rara
GroupShuffleSplit, GroupKFold o grupo um paciente, usuário, dispositivo ou documento produz várias linhas
TimeSeriesSplit, uma data de corte a flecha do tempo as linhas são ordenadas e você prevê para a frente
from sklearn.model_selection import train_test_split, GroupShuffleSplit, TimeSeriesSplit

# classificação: estratifique, sempre
X_tr, X_te, y_tr, y_te = train_test_split(X, y, test_size=.2, stratify=y, random_state=42)

# medidas repetidas: a unidade é o paciente, não a consulta
train, test = next(GroupShuffleSplit(n_splits=1, test_size=.2, random_state=42)
                   .split(X, y, groups=patient_id))

# dados ordenados: o conjunto de teste é o futuro
corte = '2026-01-01'
train, test = df[df.index < corte], df[df.index >= corte]

A pergunta a fazer antes de escolher não é "como divido isto?" mas "para o que o modelo deve generalizar?". Um paciente novo, um usuário novo, o mês que vem, um hospital novo. Seja qual for essa unidade, é ela que a divisão precisa manter inteira — porque o conjunto de teste é um ensaio da implantação e, na implantação, essa unidade é sempre nova.


2. Quanto você de fato sabe?

Uma acurácia de teste é uma proporção estimada a partir de um número finito de linhas, então vem com um intervalo de confiança como qualquer outra estimativa. Quase ninguém o calcula e ele costuma ser muito mais largo que as três casas decimais que as pessoas citam.

O intervalo mostrado é o intervalo de Wilson, que é o correto para uma proporção — o \(p \pm 1{,}96\sqrt{p(1-p)/n}\) dos manuais sai da escala perto de 0 e de 1, que é exatamente onde mora um classificador decente.

Duas consequências que vale internalizar:

  • Um conjunto de teste de 200 linhas não distingue 0,90 de 0,94. O intervalo em \(n = 200\), \(p = 0{,}90\) vai de 0,851 a 0,934. Dois modelos dentro dessa janela não são melhor e pior; são indistinguíveis.
  • Precisão custa linhas de forma quadrática. A largura cai como \(1/\sqrt{n}\), então dividir a janela ao meio significa quadruplicar o conjunto de teste. É por isso que a divisão percentual muda com o tamanho do conjunto: com 10 milhões de linhas, 1% são 100 000 linhas de teste e mais 9% quase não compram precisão enquanto custam dados reais de treino.
Tamanho do conjunto Uma divisão razoável Por quê
< 10 000 validação cruzada, mais um teste separado uma divisão única é ruidosa demais para agir — veja o laboratório
10 000 – 1 M 80 / 10 / 10 1 % ainda são milhares de linhas
> 1 M 98 / 1 / 1 a precisão do teste já está no piso prático
> 100 M 99,8 / 0,1 / 0,1 100 000 linhas dão um intervalo apertado; o resto rende mais treinando

Dimensione o teste a partir da decisão, não de um percentual

Decida que diferença você precisa detectar — dois pontos? meio ponto? — e então escolha \(n\) de modo que o intervalo seja mais estreito que isso. O 70/15/15 que todo mundo copia não é regra, é hábito.


3. Validação cruzada

Quando uma divisão única é ruidosa demais, use cada linha para validar, uma vez cada.

5 dobras:  [VAL][TRN][TRN][TRN][TRN]     cada linha é validada exatamente uma vez
           [TRN][VAL][TRN][TRN][TRN]     e treinada quatro vezes
           [TRN][TRN][VAL][TRN][TRN]
           [TRN][TRN][TRN][VAL][TRN]     reporte média ± dp entre as dobras
           [TRN][TRN][TRN][TRN][VAL]
from sklearn.model_selection import cross_val_score, StratifiedKFold

cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
scores = cross_val_score(pipeline, X, y, cv=cv, scoring='f1_macro')
print(f"{scores.mean():.3f} ± {scores.std():.3f}")

Duas coisas que as pessoas erram aqui:

  1. Valide cruzadamente o Pipeline, não o modelo. Se o scaler, o imputer ou o seletor de features é ajustado fora, as cinco dobras ficam contaminadas de uma vez. O capítulo de vazamento mede quanto isso custa.
  2. Uma pontuação de validação cruzada ainda é uma pontuação de validação. Se você comparou vinte configurações com ela, a melhor das vinte é otimista — veja a seção 4. Guarde um conjunto de teste que a validação cruzada nunca tocou, ou use CV aninhada: um laço interno para escolher, um externo para medir.
from sklearn.model_selection import GridSearchCV, cross_val_score

search = GridSearchCV(pipeline, grid, cv=StratifiedKFold(5))     # interno: escolhe
honesto = cross_val_score(search, X, y, cv=StratifiedKFold(5))   # externo: mede

4. A regra de ouro e quanto custa quebrá-la

A regra

Olhe a pontuação de teste, mude qualquer coisa e o conjunto de teste deixou de ser conjunto de teste. Não como questão de etiqueta — como questão de aritmética. Você selecionou um modelo usando aquelas linhas, então a pontuação naquelas linhas agora é uma pontuação dentro da amostra.

A razão pela qual as pessoas quebram a regra é que o custo parece abstrato. Não é e o passo 2 do laboratório põe um número nisso: escolher o melhor de quarenta modelos genuinamente equivalentes num conjunto de 200 linhas infla a acurácia reportada em 1,4 ponto de ruído puro.

Se você precisa iterar — e precisa — a estrutura que permite fazer isso honestamente é:

  • um conjunto de validação ou um laço interno de CV, que você pode consumir à vontade;
  • um conjunto de teste aberto uma vez, no fim, para produzir o número que você publica;
  • e, se você o abrir e não gostar, o movimento honesto é reportar assim mesmo e dizer quantas configurações foram tentadas.

Laboratório: quanto vale uma estimativa de divisão?

Passo 1 — o mesmo modelo, 120 sementes diferentes

Nada muda entre as execuções, exceto quais linhas caem no conjunto de teste.

estimator mean sd min max spread
single_80_20 0.798 0.032 0.717 0.875 0.158
mean_of_5_folds 0.799 0.006 0.788 0.813 0.025
binomial_se(n=120) — 0.037 — — —
"""The same model, the same data, 120 different values of random_state.

Nothing changes between the runs except which rows happen to land in the test
set. The spread of the resulting accuracies is the precision of the number you
would have reported from a single split — and it is far wider than the two
decimal places everybody quotes.

The last row is not measured but predicted: a test accuracy is a proportion
estimated from n_test rows, so its standard error is sqrt(p(1-p)/n_test).
Compare it to the standard deviation measured on the row above.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.model_selection import StratifiedKFold, cross_val_score, train_test_split
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

N, DIM, REPEATS = 600, 8, 120

rng = np.random.default_rng(1)
direction = rng.normal(size=DIM)
X = rng.normal(size=(N, DIM))
y = ((X @ direction + 1.1 * rng.normal(size=N)) > 0).astype(int)

model = lambda: make_pipeline(
    StandardScaler(), MLPClassifier(hidden_layer_sizes=(16,), max_iter=2000, random_state=0)
)

single = []
for seed in range(REPEATS):                 # only the seed changes, nothing else
    X_train, X_test, y_train, y_test = train_test_split(
        X, y, test_size=0.2, random_state=seed, stratify=y
    )
    single.append(model().fit(X_train, y_train).score(X_test, y_test))
single = np.array(single)

folds = []
for seed in range(REPEATS // 5):            # the same budget of fits, spent differently
    cv = StratifiedKFold(5, shuffle=True, random_state=seed)
    folds.append(cross_val_score(model(), X, y, cv=cv).mean())
folds = np.array(folds)

print("| `estimator` | `mean` | `sd` | `min` | `max` | `spread` |")
print("|---|---:|---:|---:|---:|---:|")
for name, v in (("single_80_20", single), ("mean_of_5_folds", folds)):
    print(f"| `{name}` | {v.mean():.3f} | **{v.std():.3f}** | {v.min():.3f} | {v.max():.3f} "
          f"| **{v.max() - v.min():.3f}** |")

n_test = int(0.2 * N)
p = single.mean()
print(f"| `binomial_se(n={n_test})` | — | **{np.sqrt(p * (1 - p) / n_test):.3f}** | — | — | — |")

O mesmo modelo, os mesmos dados, o mesmo código. A acurácia de uma única divisão 80/20 vai de 0,717 a 0,875 — uma amplitude de 15,8 pontos, determinada inteiramente pelo random_state. Se você tivesse rodado uma vez e reportado, o número teria caído em qualquer ponto dessa janela e você não teria como saber em qual.

As linhas do meio explicam a solução e a causa. Fazer a média de cinco dobras em vez de tomar uma divisão corta o desvio padrão de 0,032 para 0,006 — com o mesmo número total de ajustes, porque cada dobra também treina em 80%. E a última linha é a teoria: uma proporção medida em 120 linhas tem erro padrão \(\sqrt{p(1-p)/n} = 0{,}037\), que é o 0,032 medido. Nada de estranho aconteceu; é só quanto vale uma estimativa de 120 linhas.

Experimente

Aumente N de 600 para 6000 e rode de novo. A amplitude da divisão única encolhe por cerca de \(\sqrt{10} \approx 3{,}2\), exatamente como \(1/\sqrt{n}\) prevê — e a diferença entre uma divisão e cinco dobras se estreita junto, que é por que validação cruzada importa mais em dados pequenos.

Passo 2 — quanto custa escolher o vencedor

Quarenta candidatos, todos da mesma arquitetura, diferindo só na semente aleatória. O desvio padrão das acurácias verdadeiras deles é 0,0012 — são, para qualquer precisão útil, o mesmo modelo. Então toda diferença entre eles num conjunto de seleção de 200 linhas é ruído.

candidates_tried reported actually_worth optimism
1 0.8998 0.8993 +0.0005
2 0.9043 0.8992 +0.0051
5 0.9056 0.8987 +0.0069
10 0.9085 0.8984 +0.0101
20 0.9114 0.8984 +0.0129
40 0.9124 0.8984 +0.0140
sd_of_true_accuracy_across_candidates
0.0012
"""Choosing the best of k on a held-out set, and what the winner is worth.

Forty candidates, all the same architecture, differing only in the seed — so
they are genuinely equivalent, and the standard deviation of their true
accuracies is about a thousandth. Any difference between them on a 200-row
selection set is therefore noise, and nothing else.

Picking the highest of k noisy scores and reporting it is picking the maximum
of k draws from noise. The `optimism` column is how much of the reported number
was never there: the difference between what the winner scored on the set that
chose it and what it is actually worth, averaged over 400 independent draws of
that set so that no single lucky subset can be blamed.

Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""

import numpy as np
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler

N, DIM, TRAIN = 12000, 8, 2000
CANDIDATES, SELECTION_ROWS, REPEATS = 40, 200, 400

rng = np.random.default_rng(2)
direction = rng.normal(size=DIM)
X = rng.normal(size=(N, DIM))
y = ((X @ direction + 1.1 * rng.normal(size=N)) > 0).astype(int)

fitted = [
    make_pipeline(StandardScaler(),
                  MLPClassifier(hidden_layer_sizes=(16,), max_iter=1500, random_state=seed))
    .fit(X[:TRAIN], y[:TRAIN])
    for seed in range(CANDIDATES)
]

# every candidate scored row by row on 10 000 untouched rows: the truth to compare against
correct = np.array([m.predict(X[TRAIN:]) == y[TRAIN:] for m in fitted])
truth = correct.mean(axis=1)

draw = np.random.default_rng(0)
print(f"| `candidates_tried` | `reported` | `actually_worth` | `optimism` |")
print("|---:|---:|---:|---:|")
for k in (1, 2, 5, 10, 20, 40):
    reported, actual = [], []
    for _ in range(REPEATS):
        rows = draw.choice(correct.shape[1], SELECTION_ROWS, replace=False)
        scores = correct[:k][:, rows].mean(axis=1)
        winner = int(np.argmax(scores))
        reported.append(scores[winner])
        actual.append(truth[:k][winner])
    gap = np.mean(reported) - np.mean(actual)
    print(f"| {k} | {np.mean(reported):.4f} | {np.mean(actual):.4f} | **{gap:+.4f}** |")

print()
print(f"| `sd_of_true_accuracy_across_candidates` |")
print("|---:|")
print(f"| {truth.std():.4f} |")

Leia a coluna optimism de cima a baixo. Avaliar um modelo dá +0,0005 — uma estimativa não enviesada, que é o que um conjunto separado deveria fornecer. Tentar dez e reportar o melhor dá +0,0101. Tentar quarenta dá +0,0140.

Nada disso é melhoria. Os quarenta modelos são equivalentes por construção, então o ganho reportado é o máximo de quarenta sorteios ruidosos e o máximo do ruído fica acima da média do ruído. O viés cresce com quantas coisas você tentou e é invisível no número reportado — que é precisamente por que "quantas configurações você tentou?" é uma pergunta justa a fazer a qualquer resultado, inclusive aos seus.

O que o laboratório ensina

Passo 1: uma divisão única não mede o seu modelo, mede o seu modelo e um sorteio de loteria — e a loteria vale 15 pontos aqui. Passo 2: qualquer conjunto no qual você escolhe deixa de ser um conjunto no qual você pode medir e o estrago escala com o número de escolhas. Os dois juntos são o argumento inteiro a favor de validar cruzadamente as suas decisões e de manter um conjunto de teste fora de todas elas.