Data Leakage
Vazamento de Dados
Um modelo é uma promessa sobre o futuro: dado o que eu vou saber no momento da decisão, eis o que eu prevejo. Vazamento de dados é o momento em que essa promessa silenciosamente deixa de ser verdade — quando algo com que o modelo foi treinado, ou contra o que foi avaliado, não estaria disponível no momento da decisão.
O sintoma é sempre o mesmo e é por isso que o vazamento é tão perigoso: os números sobem. A acurácia de validação escala, a curva de perda fica linda, o time comemora. Nada no log de treinamento diz que há um problema, porque, do ponto de vista do otimizador, não há: entregaram a resposta a ele e ele usou. A falha só aparece depois, em produção, onde a resposta não vem mais na caixa.
A única pergunta
Para cada coluna, cada transformação e cada linha do conjunto de validação, pergunte:
Eu teria isto, exatamente isto, no momento em que precisei fazer a previsão?
"Sim" é a única resposta segura. "Geralmente", "depende" e "bem, tecnicamente" são todas a mesma resposta e essa resposta é não.
Tudo o que vem abaixo é uma maneira diferente de errar essa pergunta.
De onde vem o vazamento
Vazamento não é um bug. São quatro e eles entram em quatro lugares diferentes do fluxo de trabalho — por isso consertar um deles não protege dos outros.
| # | Entra por | O erro | O conserto |
|---|---|---|---|
| 1 | uma coluna | uma feature que é consequência da resposta | auditoria da linha do tempo de cada feature |
| 2 | a divisão | linhas relacionadas caem dos dois lados | dividir por grupo, não por linha |
| 3 | o tempo | uma feature ou uma divisão que lê o futuro | janelas causais, divisão temporal |
| 4 | o pipeline | um passo é ajustado antes da divisão | Pipeline + validação cruzada |
E um quinto, que não é um bug no código mas na pessoa: o conjunto de teste se gasta um pouco a cada vez que alguém olha para ele. Mais sobre isso no final.
1. Vazamento por uma coluna
O tipo mais literal: uma feature que existe porque a resposta existe.
A tarefa: prever se um paciente receberá prescrição do antibiótico X.
| Feature | Vaza? | Por quê |
|---|---|---|
| idade, pressão arterial | não | registradas antes da consulta |
tomou_antibiotico_x | sim | é a prescrição, um passo depois |
data_visita_farmacia | sim | acontece depois da prescrição |
pontuacao_recomendacao_medico | sim | é parte da decisão que se quer prever |
n_notas_no_prontuario | talvez | paciente mais grave tem prontuário mais longo — e o prontuário continua crescendo depois da consulta |
O modelo aprende tomou_antibiotico_x == True → prescrito == True. Está certo quase sempre, é inútil sempre e vai marcar 0,99 em qualquer conjunto de validação que você montar.
O movimento mental útil é desenhar a linha do tempo. Coloque nela o momento da previsão como uma linha vertical e depois posicione cada feature de um lado ou do outro. Feature à direita da linha é vazamento. Feature que atravessa a linha — n_notas_no_prontuario, gasto_total, qualquer coisa que se acumula — é o caso perigoso, porque é em parte legítima e por isso sobrevive à revisão.
A prova cabal
Uma única feature com correlação acima de \(0{,}95\) com o alvo, num problema que ninguém resolveu, não é uma descoberta. Remova-a e veja o que acontece com a pontuação. Se o modelo desaba, você achou seu vazamento; se não desaba, você não perdeu nada.
2. Vazamento pela divisão
Agora as features estão limpas, o código está limpo e o modelo continua trapaceando — porque train_test_split embaralha linhas e linhas não são independentes.
Um paciente, oito consultas. Um usuário, duzentos cliques. Uma música, quarenta janelas de espectrograma. Um documento e a quase-cópia dele, raspada duas vezes. Embaralhe as linhas e o mesmo paciente fica no treino e no teste. O modelo não precisa mais aprender medicina; basta reconhecer o paciente, cuja resposta já lhe foi dada.
É o vazamento mais comum em projetos reais e o mais difícil de enxergar, porque nada no código parece errado.
# ❌ o mesmo paciente pode cair dos dois lados
X_train, X_test = train_test_split(X, test_size=0.2, random_state=42)
# ✅ um paciente pertence a exatamente um lado
from sklearn.model_selection import GroupShuffleSplit, GroupKFold
splitter = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42)
train, test = next(splitter.split(X, y, groups=patient_id))
A pergunta a fazer não é "minhas linhas são independentes?" mas "qual é a unidade para a qual eu de fato quero generalizar?". Se o modelo deve funcionar num paciente novo, então o paciente é a unidade e o paciente é o grupo. Se deve funcionar numa consulta nova de um paciente conhecido, dividir por linha está certo — mas esse é um produto muito diferente e quase nunca é o que foi prometido.
Quase-duplicatas contam também. Duas fotografias tiradas com um segundo de diferença, um registro inserido duas vezes por um ETL quebrado, uma cópia aumentada gerada antes da divisão — cada uma é um grupo de tamanho dois e cada uma entrega a resposta do outro lado da divisão com a mesma eficiência.
3. Vazamento pelo tempo
O tempo aumenta o volume dos dois problemas anteriores.
A divisão. Numa série temporal, uma divisão aleatória treina na quarta e na sexta para prever a quinta. O modelo está interpolando dentro de uma janela que já viu — o que não é a tarefa. A tarefa é sempre extrapolação: tudo o que entra no treino vem estritamente antes de tudo o que entra no teste.
treino │ teste
──●──●──●──●──●──●──●──●──●──●───│──○──○──○──○──○──►
│
nenhuma amostra à direita desta
linha pode tocar o lado esquerdo
# ❌ divisão aleatória numa série temporal
X_train, X_test = train_test_split(X, y, test_size=0.2, random_state=42)
# ✅ a divisão é uma data, não um embaralhamento
from sklearn.model_selection import TimeSeriesSplit
cv = TimeSeriesSplit(n_splits=5) # cada dobra treina só no passado
A feature. Esta sobrevive à divisão correta e é isso que a torna traiçoeira. Qualquer janela centrada no instante atual alcança o futuro:
# ❌ uma janela centrada em t cobre t-1, t e t+1
df['ma3'] = df['r'].rolling(3, center=True).mean()
# ❌ o mesmo vazamento, com outra roupa
df['z'] = (df['r'] - df['r'].mean()) / df['r'].std() # média da série inteira
df['rank'] = df['r'].rank(pct=True) # posto contra a série inteira
df = df.fillna(df.median()) # mediana da série inteira
# ✅ tudo calculado a partir do passado e só do passado
df['ma3'] = df['r'].rolling(3).mean()
df['ma3'] = df['ma3'].shift(1) # e nem hoje, se hoje fecha depois da decisão
Há também um intervalo a respeitar. Se a decisão é às 09:00 e o rótulo se resolve às 17:00, uma feature que fica disponível às 12:00 é vazamento mesmo estando no passado em relação ao rótulo. O relógio que importa é o da decisão, nunca o do rótulo.
4. Vazamento pelo pipeline
O caso de manual: um passo de pré-processamento ajustado em dados que incluem as linhas de teste. StandardScaler aprende uma média e um desvio padrão; SimpleImputer aprende uma mediana; TargetEncoder aprende uma média do rótulo por categoria. Ajuste qualquer um deles antes da divisão e essas estatísticas carregam informação para fora do conjunto de teste e para dentro do modelo.
O painel abaixo mostra, linha a linha, quais linhas cada passo de fato tocou. A distinção que importa não é treino vs teste — é ajustado vs apenas usado.
A terceira aba é a que importa. Fazer à mão está correto exatamente uma vez, para uma única divisão; no instante em que você faz validação cruzada, ou ajusta hiperparâmetros, ou faz bootstrap, a versão manual volta a vazar, porque o scaler é ajustado uma vez só enquanto as dobras mudam cinco vezes. Um Pipeline não é organização — é a única versão da regra que sobrevive a ser validada cruzadamente.
Tudo o que tem um método fit pertence a ele: scalers, imputers, encoders, PCA, seletores de features, reamostradores, discretizadores.
Nem todo vazamento custa o mesmo
Padronizar cedo é o exemplo que todo curso usa e — medido no laboratório abaixo — vale cerca de +0,004 de AUC. Balancear as classes cedo, sobre o que quase ninguém avisa, vale +0,173. O tamanho de um vazamento não tem nada a ver com a frequência com que ele é ensinado.
5. Vazamento pelo analista
O último não tem linha de código a culpar. Qualquer decisão tomada olhando para o conjunto de teste move informação dele para dentro do modelo, através de você.
- Seleção de features no dataset completo. Ordenar 5 000 colunas pela correlação com o alvo, ficar com as 20 melhores e depois fazer validação cruzada. O filtro viu todos os rótulos; as dobras estão contaminadas antes mesmo de existirem.
- Ajuste de hiperparâmetros no conjunto de teste. Testar trinta configurações e reportar a melhor. A melhor de trinta é o máximo de trinta números ruidosos e o máximo do ruído é enviesado para cima.
- Olhar de novo. Um conjunto de teste usado duas vezes já não é bem um conjunto de teste. Usado trinta vezes, é um conjunto de treino com passos extras.
O painel é o primeiro desses, medido. Não há nada nos dados — as features são ruído gaussiano, o rótulo é cara ou coroa e a acurácia verdadeira é exatamente \(0{,}50\). Veja o que o filtro fabrica mesmo assim:
A defesa é estrutural, não moral: não dá para lembrar de não espiar. Coloque toda decisão dentro do laço de reamostragem — a seleção como passo de um Pipeline, o ajuste dentro de um GridSearchCV que por sua vez é validado cruzadamente (CV aninhada) — e mantenha um holdout final que se abre uma vez só, no fim, para produzir o número que você vai publicar.
O checklist
Antes de acreditar numa pontuação
Suspeitosamente bom é um sintoma, não um resultado. Num problema que ninguém resolveu, um modelo que de repente o resolve é vazamento até prova em contrário.
- Toda feature existiria, com este valor, no momento da decisão?
- Alguma feature sozinha correlaciona acima de \(0{,}95\) com o alvo?
- Existe um grupo — paciente, usuário, dispositivo, documento, sessão — com mais de uma linha?
- Há linhas duplicadas ou quase duplicadas e algum par poderia atravessar a divisão?
- Todo
fitestá dentro de umPipelinee é oPipelineque é validado cruzadamente? - Em séries temporais: a divisão é uma data e toda janela olha estritamente para trás?
- As agregações (médias por grupo, target encoding, contagens) são calculadas só nas linhas de treino?
- Quantas vezes o conjunto de teste já foi olhado?
- Se a melhor feature for removida, a pontuação desaba?
Laboratório: quatro vazamentos, medidos
Quatro experimentos curtos. Cada um roda o pipeline errado e o certo sobre os mesmos dados e imprime as duas pontuações, de modo que o vazamento deixa de ser um aviso e vira um número. Tudo aqui é reproduzível só com numpy e scikit-learn — clone os scripts e mexa nas constantes.
Passo 1 — seleção antes da divisão
Os dados são ruído puro: X é gaussiano, y é cara ou coroa e a acurácia verdadeira de qualquer modelo é 0,500. A única diferença entre as duas linhas é onde o filtro de features roda.
n=100, p=5000, k=20, cv=5 | acc |
|---|---|
select_then_split | 0.870 |
split_then_select | 0.500 |
chance_level | 0.500 |
"""Feature selection before the split: accuracy manufactured out of pure noise.
X is gaussian noise and y is a fair coin, so there is nothing to learn and the
true accuracy of any model is 0.50. Picking the k columns most correlated with
y *before* the folds are cut lets the selector read the label of every row,
including the rows that will later be used to score the model.
Printed as a markdown table, in identifiers only, so the same artifact serves
the English and the Portuguese page.
"""
import numpy as np
from sklearn.feature_selection import SelectKBest, f_classif
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.pipeline import Pipeline
N, P, K, FOLDS = 100, 5000, 20, 5
rng = np.random.default_rng(0)
X = rng.normal(size=(N, P)) # noise: no column carries any information
y = rng.integers(0, 2, size=N) # a fair coin
cv = StratifiedKFold(n_splits=FOLDS, shuffle=True, random_state=0)
clf = lambda: LogisticRegression(max_iter=1000)
# WRONG — the selector sees y for all 100 rows, including the held-out ones
X_selected = SelectKBest(f_classif, k=K).fit_transform(X, y)
leaky = cross_val_score(clf(), X_selected, y, cv=cv).mean()
# RIGHT — selection is a pipeline step, redone inside every fold
honest = cross_val_score(
Pipeline([("sel", SelectKBest(f_classif, k=K)), ("clf", clf())]), X, y, cv=cv
).mean()
print(f"| `n={N}, p={P}, k={K}, cv={FOLDS}` | `acc` |")
print("|---|---:|")
print(f"| `select_then_split` | **{leaky:.3f}** |")
print(f"| `split_then_select` | **{honest:.3f}** |")
print(f"| `chance_level` | {0.5:.3f} |")
Ordenar 5 000 colunas de ruído contra 100 rótulos e ficar com as 20 que por acaso se alinham melhor produz 0,870 — trinta e sete pontos de acurácia conjurados de um gerador de números aleatórios. Com \(5\,000\) colunas e \(100\) linhas, alguma coluna vai correlacionar com a moeda por sorte; o trabalho inteiro do filtro é encontrá-la. Mover o mesmo filtro para dentro da dobra devolve os honestos 0,500.
Experimente
Em lab-1-selection.py, baixe P de 5 000 para 50 e rode de novo. O vazamento encolhe — 0,650 em vez de 0,870 — mas não desaparece. É esse o ponto: a inflação escala com quantas colunas você peneirou, que é exatamente o número que ninguém reporta.
Passo 2 — o mesmo paciente dos dois lados
120 pacientes, oito consultas cada. As consultas de um paciente se parecem entre si e só uma coordenada dessa semelhança tem alguma coisa a ver com o diagnóstico.
120 patients x 8 visits = 960 rows, cv=5 | acc |
|---|---|
StratifiedKFold(shuffle=True) | 0.986 |
GroupKFold(groups=patient) | 0.708 |
majority_class | 0.558 |
"""Group leakage: the same patient on both sides of the split.
Every patient contributes eight visits, and the visits of one patient look
almost alike — they share a fingerprint. Only one coordinate of that
fingerprint is related to the label, so an honest model can do better than
chance and no more. A shuffled split puts some visits of each patient in train
and the rest in test; the network then stops classifying and starts
recognizing the patient whose answer it has already been shown.
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 GroupKFold, StratifiedKFold, cross_val_score
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
PATIENTS, VISITS, DIM, FOLDS = 120, 8, 12, 5
rng = np.random.default_rng(1)
fingerprint = rng.normal(size=(PATIENTS, DIM)) # who the patient is
# only coordinate 0 is related to the diagnosis, and only loosely
label = (fingerprint[:, 0] + 0.7 * rng.normal(size=PATIENTS) > 0).astype(int)
patient = np.repeat(np.arange(PATIENTS), VISITS) # the group id
X = fingerprint[patient] + 0.20 * rng.normal(size=(PATIENTS * VISITS, DIM))
y = label[patient]
model = lambda: make_pipeline(
StandardScaler(), MLPClassifier((32,), max_iter=2000, random_state=0)
)
# WRONG — visits are shuffled, so a patient sits in train and in test at once
leaky = cross_val_score(
model(), X, y, cv=StratifiedKFold(FOLDS, shuffle=True, random_state=0)
).mean()
# RIGHT — a patient belongs to exactly one fold
honest = cross_val_score(
model(), X, y, cv=GroupKFold(FOLDS), groups=patient
).mean()
print(f"| `{PATIENTS} patients x {VISITS} visits = {len(y)} rows, cv={FOLDS}` | `acc` |")
print("|---|---:|")
print(f"| `StratifiedKFold(shuffle=True)` | **{leaky:.3f}** |")
print(f"| `GroupKFold(groups=patient)` | **{honest:.3f}** |")
print(f"| `majority_class` | {max(y.mean(), 1 - y.mean()):.3f} |")
O StratifiedKFold reporta 0,986 — uma rede que aparentemente resolveu o diagnóstico. O GroupKFold, na mesma rede, nos mesmos dados e com o mesmo número de dobras, reporta 0,708 contra uma linha de base de classe majoritária de 0,558. O 0,986 nunca foi um diagnóstico: com seis das oito consultas de um paciente no treino, bastava à rede reconhecer o paciente.
Repare no que teria acontecido num projeto real. O modelo de 0,986 vai para produção. Lá todo paciente é novo, então o desempenho verdadeiro é 0,708 e a diferença é descoberta por um clínico, não pelo time.
Passo 3 — uma feature que espia
Uma série de retornos autocorrelacionada, em que a direção de amanhã de fato é em parte previsível — então o modelo honesto é legitimamente melhor que uma moeda. As duas linhas usam TimeSeriesSplit; a divisão está correta nas duas. Só a janela muda.
1191 days, cv=TimeSeriesSplit(5) | acc |
|---|---|
rolling(3, center=True) | 0.937 |
rolling(3) | 0.716 |
majority_class | 0.542 |
"""Temporal leakage: a feature that looks one step into the future.
The series is an autocorrelated return, so tomorrow's direction really is
partly predictable from today's — an honest model beats the coin. The trap is
`rolling(3, center=True)`: a window centred on t spans t-1, t and t+1, so the
column quietly carries the answer. A temporal split does not save you here,
because the leak is inside the feature, not inside the split.
Printed as a markdown table, in identifiers only, so one artifact serves both
the English and the Portuguese page.
"""
import numpy as np
import pandas as pd
from sklearn.model_selection import TimeSeriesSplit, cross_val_score
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
T, LAGS, FOLDS = 1200, 8, 5
rng = np.random.default_rng(3)
r = np.zeros(T)
for t in range(1, T): # AR(1): today's return echoes yesterday's
r[t] = 0.75 * r[t - 1] + 0.5 * rng.normal()
df = pd.DataFrame({"r": r})
for k in range(1, LAGS + 1):
df[f"r_lag{k}"] = df["r"].shift(k)
df["y"] = (df["r"].shift(-1) > 0).astype(int) # does it go up tomorrow?
df["ma3_center"] = df["r"].rolling(3, center=True).mean() # spans t-1, t, t+1 ← leak
df["ma3_causal"] = df["r"].rolling(3).mean() # spans t-2, t-1, t
data = df.dropna()
lags = ["r"] + [f"r_lag{k}" for k in range(1, LAGS + 1)]
model = lambda: make_pipeline(
StandardScaler(), MLPClassifier((64, 32), max_iter=3000, random_state=0)
)
score = lambda cols: cross_val_score(
model(), data[cols], data["y"], cv=TimeSeriesSplit(FOLDS)
).mean()
leaky = score(lags + ["ma3_center"])
honest = score(lags + ["ma3_causal"])
print(f"| `{len(data)} days, cv=TimeSeriesSplit({FOLDS})` | `acc` |")
print("|---|---:|")
print(f"| `rolling(3, center=True)` | **{leaky:.3f}** |")
print(f"| `rolling(3)` | **{honest:.3f}** |")
print(f"| `majority_class` | {max(data.y.mean(), 1 - data.y.mean()):.3f} |")
rolling(3, center=True) cobre \(t-1\), \(t\) e \(t+1\): a coluna contém o amanhã. A rede recupera isso e reporta 0,937 contra os honestos 0,716. Este é o vazamento que sobrevive a toda precaução relativa à divisão, porque nunca foi sobre a divisão — foi um argumento nomeado, numa linha de engenharia de features, escrita meses antes.
Passo 4 — o vazamento leve e o catastrófico
O mesmo erro, aplicado a dois passos de pré-processamento: ajustar em tudo e depois dividir. Um problema desbalanceado, 5% de positivos, pontuado com ROC AUC para que o balanceamento das classes não distorça a comparação.
n=1000, positives=5%, cv=5 | auc | gap |
|---|---|---|
scale_then_split | 0.827 | +0.004 |
split_then_scale | 0.823 | — |
balance_then_split | 0.985 | +0.173 |
split_then_balance | 0.812 | — |
"""Preprocessing before the split: the mild leak and the catastrophic one.
Two operations, the same mistake — fitted on the whole dataset instead of on
the training fold. Standardizing early moves the score by a rounding error.
Balancing the classes early moves it by a sixth of the scale, because random
oversampling literally copies minority rows, and the copies land on both sides
of the split: the model is scored on rows it memorized.
ROC AUC is the metric here precisely because it does not depend on the class
balance, so the balanced dataset and the original 5% one can be compared.
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.metrics import roc_auc_score
from sklearn.model_selection import StratifiedKFold, cross_val_score
from sklearn.neural_network import MLPClassifier
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
N, DIM, POSITIVE, FOLDS = 1000, 10, 0.05, 5
rng = np.random.default_rng(5)
X = rng.normal(size=(N, DIM))
weights = np.zeros(DIM)
weights[0], weights[1] = 1.0, 0.6 # two columns carry real signal
risk = X @ weights + 1.2 * rng.normal(size=N)
y = (risk > np.quantile(risk, 1 - POSITIVE)).astype(int)
cv = StratifiedKFold(FOLDS, shuffle=True, random_state=0)
net = lambda: MLPClassifier((32,), max_iter=2000, random_state=0)
model = lambda: make_pipeline(StandardScaler(), net())
def balance(X, y, seed=0):
"""Random oversampling: copy minority rows until the classes are even."""
rng = np.random.default_rng(seed)
minority, majority = np.flatnonzero(y == 1), np.flatnonzero(y == 0)
copies = rng.choice(minority, size=len(majority) - len(minority), replace=True)
index = np.concatenate([np.arange(len(y)), copies])
rng.shuffle(index)
return X[index], y[index]
# --- standardization -------------------------------------------------------
X_scaled = StandardScaler().fit_transform(X) # WRONG: mean and std of all rows
scale_early = cross_val_score(net(), X_scaled, y, cv=cv, scoring="roc_auc").mean()
scale_late = cross_val_score(model(), X, y, cv=cv, scoring="roc_auc").mean()
# --- class balancing -------------------------------------------------------
X_balanced, y_balanced = balance(X, y) # WRONG: copies cross the split
balance_early = cross_val_score(
model(), X_balanced, y_balanced, cv=cv, scoring="roc_auc"
).mean()
folds = [] # RIGHT: copies stay in the fold
for train, test in cv.split(X, y):
X_train, y_train = balance(X[train], y[train])
fitted = model().fit(X_train, y_train)
folds.append(roc_auc_score(y[test], fitted.predict_proba(X[test])[:, 1]))
balance_late = np.mean(folds)
print(f"| `n={N}, positives={POSITIVE:.0%}, cv={FOLDS}` | `auc` | `gap` |")
print("|---|---:|---:|")
print(f"| `scale_then_split` | {scale_early:.3f} | +{scale_early - scale_late:.3f} |")
print(f"| `split_then_scale` | **{scale_late:.3f}** | — |")
print(f"| `balance_then_split` | {balance_early:.3f} | +{balance_early - balance_late:.3f} |")
print(f"| `split_then_balance` | **{balance_late:.3f}** | — |")
Padronizar cedo custa 0,004 — uma média e um desvio padrão estimados sobre 1 000 linhas quase não se movem quando 200 delas saem. Balancear cedo custa 0,173, porque a sobreamostragem aleatória copia linhas da minoria e as cópias caem dos dois lados da divisão: o modelo é então pontuado em linhas que memorizou literalmente. O SMOTE se comporta do mesmo jeito, ou pior: seus pontos sintéticos são construídos a partir de vizinhos que podem estar na dobra de teste.
O que o laboratório está de fato ensinando
O vazamento sobre o qual todo mundo é avisado é o mais barato dos quatro. Os três caros — seleção, grupos, reamostragem — são invisíveis no código, não produzem erro algum e só são encontrados por quem foi procurar. É para isso que o checklist existe e é por isso que intuição não substitui checklist.
Acontece com gente séria
Vazamento não é erro de iniciante; é erro sistemático e já foi medido.
- KDD Cup 2008 — detecção de câncer de mama em mamografias. O identificador do paciente acabou sendo uma das features mais preditivas: os ids tinham sido atribuídos em blocos por instituição de origem e as instituições tinham taxas de câncer muito diferentes. A análise vencedora documenta o vazamento e o raciocínio que o encontrou.1
- Imagens de COVID-19, 2020–2021 — uma revisão sistemática de modelos construídos para detectar COVID em radiografias e tomografias de tórax concluiu que nenhum dos modelos revisados era apto para uso clínico, com vazamento entre as causas recorrentes: imagens duplicadas do mesmo paciente espalhadas entre treino e teste e imagens de controle vindas de uma população diferente da dos positivos.2
- Na ciência em geral, 2023 — um levantamento de pesquisa baseada em aprendizado de máquina relata vazamento afetando 294 artigos em 17 áreas (da medicina à ciência política) e propõe uma ficha de informações do modelo para tornar o pipeline auditável.3
O fio comum não é incompetência. É que vazamento produz boas notícias e boa notícia não é auditada com a mesma energia que má notícia.
O hábito a construir
Quando um resultado é surpreendentemente bom, a primeira hipótese não é "o modelo é bom". É "o que este modelo sabe que não deveria saber?" — e a segunda é "como eu provaria que ele não sabe?". Trate um salto de acurácia como um incidente a investigar, não como um marco a anunciar.
-
Rosset, S., Perlich, C., Świrszcz, G., Melville, P., Liu, Y. Medical data mining: insights from winning two competitions. Data Mining and Knowledge Discovery, 2010. Ver também Kaufman, S., Rosset, S., Perlich, C. Leakage in Data Mining: Formulation, Detection, and Avoidance, KDD 2011. ↩
-
Roberts, M. et al. Common pitfalls and recommendations for using machine learning to detect and prognosticate for COVID-19 using chest radiographs and CT scans. Nature Machine Intelligence, 2021. ↩
-
Kapoor, S., Narayanan, A. Leakage and the Reproducibility Crisis in Machine-learning-based Science. Patterns, 2023. ↩