← Voltar pro projeto

Avaliação Final, Curva ROC e Ajuste de Hiperparâmetro

No capítulo 2 defini o critério antes de ver qualquer número (F1 primeiro, AUC como desempate) e a Random Forest venceu a comparação entre quatro famílias de modelo. Esse capítulo final treina ela de verdade, em todo o conjunto de treino oficial, mede no teste oficial (as 175.341 sessões que o modelo nunca viu), e não para na acurácia: abro o corte de decisão, a curva ROC, quais variáveis mais pesaram, onde exatamente o modelo erra, e se dá pra melhorar afinando hiperparâmetro. O notebook completo, executado, com as três partes do projeto, está linkado na abertura do capítulo 1.

Avaliação final: Random Forest, no teste oficial de verdade

Treinei o Random Forest (200 árvores, com class_weight="balanced", um jeito de avisar o modelo que errar um ataque, a classe rara, custa mais caro do que errar uma sessão normal, sem precisar duplicar ou descartar nenhuma linha do jeito que o reamostramento tentava fazer lá na fraude de cartão) em todo o conjunto de treino oficial e medi no conjunto de teste oficial, os 175.341 registros que o modelo nunca viu:

modelo_final = Pipeline([
    ("prep", preprocessador),
    ("clf", RandomForestClassifier(n_estimators=200, random_state=42, class_weight="balanced", n_jobs=-1)),
])
modelo_final.fit(X_train, y_train)
y_pred = modelo_final.predict(X_test)

print(classification_report(y_test, y_pred, digits=4))

O mesmo Pipeline de sempre, agora com o preprocessador inteiro (os 39 StandardScaler + 3 OneHotEncoder) grudado na frente do Random Forest final, um único .fit() treinando as duas etapas juntas. classification_report é quem calcula precisão, revocação e F1 por classe de uma vez, a mesma tabela que aparece logo abaixo.

ClassePrecisãoRevocaçãoF1Sessões no teste
Normal0,77980,97590,866956.000
Ataque0,98720,87070,9253119.341

Precisão aqui é: das sessões que o modelo marcou como ataque, quantas eram ataque de verdade. Revocação é o oposto: dos ataques reais, quantos o modelo pegou. F1 resume as duas numa nota só. Acurácia geral 90,43%, mas o número que eu realmente confio é a revocação da classe ataque: 87,07%, ou seja, o modelo pega 103.912 dos 119.341 ataques reais do teste, e deixa passar 15.429. Em segurança, esse tipo de falso negativo (ataque classificado como normal) é o erro caro, é literalmente o intruso que passa despercebido. Do lado oposto, 1.349 sessões normais viraram alarme falso, incômodo pra quem investiga, mas bem menos grave que deixar passar um ataque de verdade. A AUC no teste oficial fica em 0,9830, quase o teto de 1,0, uma medida de que o modelo sabe rankear risco de sessão bem mesmo antes de qualquer limiar de decisão entrar em jogo, a mesma métrica de ranking que já vi na fraude de cartão.

Ajustando o corte: precisão contra revocação

Toda essa avaliação usou o corte padrão do scikit-learn: o .predict() decide "ataque" sempre que o score de risco (a fração de árvores que votou ataque, o mesmo número que dá pra pegar com .predict_proba()) passa de 0,5. Mas esse 0,5 é só um ponto arbitrário numa reta inteira de opções, a mesma ideia de limiar que já vi lá na aula de classificação com regressão, só que agora dá pra girar de verdade.

Pra isso, escrevi um segundo notebook (datasets/unsw-nb15/02_dados_extras_scatter_e_threshold.ipynb), só porque o notebook original nunca salvou o y_score contínuo, apenas o y_pred já limiarizado em 0,5. As primeiras células desse notebook reconstroem, célula por célula, exatamente o mesmo pipeline dos capítulos 2 e 3:

df_train = pd.read_csv("/content/data/UNSW_NB15_training-set.csv")
df_test = pd.read_csv("/content/data/UNSW_NB15_testing-set.csv")

DESCARTAR = ["id", "attack_cat", "label"]
X_train = df_train.drop(columns=[c for c in DESCARTAR if c in df_train.columns])
y_train = df_train["label"]
X_test = df_test.drop(columns=[c for c in DESCARTAR if c in df_test.columns])
y_test = df_test["label"]

cat_cols = X_train.select_dtypes(include=["object"]).columns.tolist()
num_cols = X_train.select_dtypes(include=["number"]).columns.tolist()

transformadores = [
    ("num", StandardScaler(), num_cols),
    ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), cat_cols),
]
preprocessador = ColumnTransformer(transformers=transformadores)

modelo_final = Pipeline([
    ("prep", preprocessador),
    ("clf", RandomForestClassifier(n_estimators=200, random_state=42, class_weight="balanced", n_jobs=-1)),
])
modelo_final.fit(X_train, y_train)

y_pred = modelo_final.predict(X_test)
y_score = modelo_final.predict_proba(X_test)[:, 1]

Célula por célula, é o mesmo código do capítulo 2 (a partição oficial, o ColumnTransformer) e do começo deste capítulo (o Pipeline com o Random Forest de 200 árvores), sem nenhuma linha nova, só reconstruído aqui porque esse notebook roda isolado, num runtime do Colab que não carrega o estado dos outros dois. A única diferença é a última linha: além de y_pred, guardo também y_score, a probabilidade contínua, pra poder testar cortes diferentes de 0,5 no bloco seguinte. Com o pipeline retreinado, o bloco novo varre 19 cortes diferentes:

thresholds = np.round(np.arange(0.05, 1.0, 0.05), 2)
threshold_sweep = []
for t in thresholds:
    y_pred_t = (y_score >= t).astype(int)
    threshold_sweep.append({
        "threshold": float(t),
        "precision": round(float(precision_score(y_test, y_pred_t, zero_division=0)), 4),
        "recall": round(float(recall_score(y_test, y_pred_t, zero_division=0)), 4),
        "f1": round(float(f1_score(y_test, y_pred_t, zero_division=0)), 4),
    })

np.arange(0.05, 1.0, 0.05) gera os 19 cortes (0,05, 0,10, ..., 0,95), e o for testa cada um: y_score >= t transforma o score contínuo em rótulo binário nesse corte específico (a mesma comparação que .predict() faz internamente, só que fixando t em vez de usar sempre 0,5), e as três métricas são recalculadas do zero a cada volta do laço. zero_division=0 evita erro caso algum corte extremo zere uma das classes inteiramente. O resultado, 19 linhas de precisão/revocação/F1, é o que alimenta o gráfico interativo abaixo:

Carregando dados reais...

Arrasta o slider e repara na troca. Baixar o corte pra 0,05 (marcar "ataque" com qualquer suspeita, por menor que seja) empurra a revocação pra 0,9781, quase pegando todo mundo, mas derruba a precisão pra 0,9234, mais alarme falso pro time de segurança investigar à toa. Subir o corte pra 0,95 (só marcar "ataque" quando o modelo tem certeza quase absoluta) inverte o jogo: precisão de 0,999, praticamente nenhum alarme falso, mas a revocação despenca pra 0,6951, deixando passar quase um terço dos ataques reais. Não existe um corte certo, isolado da pergunta de negócio. Se o custo de investigar um alarme falso é baixo, vale puxar o corte pra baixo e aceitar mais alarme falso em troca de pegar mais ataque de verdade. Se cada investigação é cara, o corte sobe.

No corte que usei o post inteiro (0,5), a combinação de class_weight="balanced" com esse corte padrão já entrega revocação de 0,8715 sem eu precisar mexer em nada na mão (os números batem, com uma variação de milésimo, com a tabela lá em cima, porque reconstruí o pipeline do zero nesse notebook separado). É por isso que não fui atrás de ajustar esse corte na avaliação final: o desbalanceamento de classe já tava sendo compensado no próprio treino, não só na hora de decidir.

A curva ROC: o mesmo corte, visto de outro ângulo

A matriz de confusão e a tabela de precisão/revocação lá em cima avaliam o modelo num único corte de decisão (o padrão, 0,5). Mas o slider que acabei de mostrar já entrega o resto do quadro: a Random Forest não devolve só um rótulo, ela estima uma probabilidade P(ataque | x) pra cada sessão, e o corte é uma decisão operacional de segurança, não uma verdade matemática fixa.

A curva ROC (Receiver Operating Characteristic) transforma essa troca em desenho: varrendo todos os cortes possíveis, ela traça a taxa de verdadeiro positivo (a mesma revocação de sempre) contra a taxa de falso positivo (a fração de sessões normais que viram alarme falso). A AUC (Area Under the Curve, a área debaixo dessa curva) condensa o gráfico inteiro num único número, com uma leitura probabilística direta: é a chance de o modelo dar um score mais alto a um ataque sorteado ao acaso do que a uma sessão normal sorteada ao acaso. AUC = 1,0 é o classificador perfeito, AUC = 0,5 é o mesmo que jogar moeda (a diagonal pontilhada do gráfico). No notebook original, esse cálculo é só duas linhas:

y_score = modelo_final.predict_proba(X_test)[:, 1]
auc = roc_auc_score(y_test, y_score)

predict_proba devolve duas colunas, a probabilidade de cada classe, [:, 1] pega só a coluna da classe 1 (ataque). roc_auc_score recebe o rótulo real e esse score contínuo, sem nenhum corte fixo envolvido, e calcula a AUC direto. O notebook original parava aí e só desenhava o gráfico com RocCurveDisplay.from_predictions(...) do próprio scikit-learn, uma imagem estática, sem nunca salvar os pontos da curva em lugar nenhum.

Pra ter um gráfico real e interativo aqui no blog, escrevi um terceiro notebook (datasets/unsw-nb15/03_curva_roc.ipynb), com a mesma reconstrução de sempre no começo (a essa altura, terceira vez que esse bloco aparece nesse projeto, então só o repito, sem re-explicar):

df_train = pd.read_csv("/content/data/UNSW_NB15_training-set.csv")
df_test = pd.read_csv("/content/data/UNSW_NB15_testing-set.csv")

DESCARTAR = ["id", "attack_cat", "label"]
X_train = df_train.drop(columns=[c for c in DESCARTAR if c in df_train.columns])
y_train = df_train["label"]
X_test = df_test.drop(columns=[c for c in DESCARTAR if c in df_test.columns])
y_test = df_test["label"]

cat_cols = X_train.select_dtypes(include=["object"]).columns.tolist()
num_cols = X_train.select_dtypes(include=["number"]).columns.tolist()

transformadores = [
    ("num", StandardScaler(), num_cols),
    ("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), cat_cols),
]
preprocessador = ColumnTransformer(transformers=transformadores)

modelo_final = Pipeline([
    ("prep", preprocessador),
    ("clf", RandomForestClassifier(n_estimators=200, random_state=42, class_weight="balanced", n_jobs=-1)),
])
modelo_final.fit(X_train, y_train)

y_score = modelo_final.predict_proba(X_test)[:, 1]

E, em cima desse y_score, extrai a curva ponto a ponto:

fpr, tpr, thresholds = roc_curve(y_test, y_score)
auc = roc_auc_score(y_test, y_score)
print(f"AUC (conferencia, deve bater com 0.9830 ja publicado): {auc:.4f}")
print(f"Curva bruta tem {len(fpr)} pontos, vamos reduzir pra 50 pra plotar no blog")

fpr_grid = np.linspace(0, 1, 50)
tpr_grid = np.interp(fpr_grid, fpr, tpr)

roc_curve_points = [
    {"fpr": round(float(f), 4), "tpr": round(float(t), 4)}
    for f, t in zip(fpr_grid, tpr_grid)
]

roc_curve(y_test, y_score) varre internamente cada score único presente em y_score como um corte candidato, e devolve três arrays do mesmo tamanho: fpr, tpr e os thresholds que geraram cada par. Nesse dataset a curva bruta saiu com 282 pontos, granularidade demais pra um gráfico de blog e um JSON maior que o necessário. np.linspace(0, 1, 50) cria 50 posições igualmente espaçadas no eixo de falso positivo, e np.interp(fpr_grid, fpr, tpr) estima a taxa de verdadeiro positivo real em cada uma dessas 50 posições, interpolando entre os pontos reais mais próximos, sem inventar nenhum valor: é a mesma curva, só amostrada num grid mais leve de desenhar. O print de conferência confirma AUC = 0,9830, batendo exatamente com o número calculado no notebook original.

Carregando dados reais...

A curva gruda no canto superior esquerdo, bem longe da diagonal pontilhada do acaso: com um corte de falso positivo de só 2%, a revocação já salta pra 86%, e a curva segue quase colada no teto dali em diante. Isso mostra que o modelo separa bem as duas classes independente de qualquer corte específico, é essa folga que sobra pra eu poder puxar o corte pra baixo, como fiz na seção anterior, sem que a precisão desabe junto.

Quem decide: as 15 variáveis que mais pesam

Random Forest permite espiar quais variáveis mais reduziram a impureza das árvores ao longo do treino, a mesma régua de Gini que já vi lá nas árvores de decisão, só que agora somada em 200 árvores em vez de uma só:

ohe = modelo_final.named_steps["prep"].named_transformers_["cat"]
nomes_cat = ohe.get_feature_names_out(cat_cols)
nomes_features = np.concatenate([num_cols, nomes_cat])

importancias = modelo_final.named_steps["clf"].feature_importances_
imp = pd.Series(importancias, index=nomes_features).sort_values(ascending=False)

Esse bloco existe pra resolver um problema chato: feature_importances_ devolve só um array cru de números, sem nome nenhum grudado, e depois do ColumnTransformer as três colunas categóricas originais (proto, service, state) nem existem mais, viraram dezenas de colunas binárias novas, tipo proto_tcp, proto_udp, service_dns. Pra reconstruir "qual número da lista corresponde a qual variável", preciso ir buscar esses nomes de volta de dentro do próprio pipeline já treinado: modelo_final.named_steps["prep"] pega a etapa do pré-processador, .named_transformers_["cat"] pega especificamente o OneHotEncoder que rodou nas categóricas, e .get_feature_names_out(cat_cols) pergunta pra ele "quais foram os nomes das colunas novas que você criou?". np.concatenate([num_cols, nomes_cat]) gruda essa lista de volta com as 39 numéricas na frente (que mantiveram o nome original, StandardScaler não cria coluna nova nenhuma), respeitando a mesma ordem que o ColumnTransformer usa por dentro. Só depois disso pd.Series(importancias, index=nomes_features) consegue emparelhar cada número de importância com o nome certo.

A campeã, ct_dst_src_ltm, conta quantas das últimas 100 conexões compartilham a mesma dupla origem-destino, um jeito direto de flagrar comportamento de scanner ou de flood: tráfego normal raramente bate repetidamente no mesmo par origem-destino centenas de vezes seguidas, ataque automatizado sim. Repara que sttl (o TTL que já apareceu lá na correlação) aparece de novo, na segunda posição, confirmando que aquele 0,91 de correlação com o rótulo não era coincidência estatística vazia, é sinal real. E boa parte do resto da lista (ct_dst_sport_ltm, ct_srv_dst, ct_state_ttl, ct_srv_src) é da mesma família "quantas conexões recentes se parecem com essa", o tipo de variável desenhada especificamente pra capturar rajada de conexão repetida, a assinatura mais comum de ataque automatizado contra tráfego humano.

Onde o modelo erra: 400 sessões reais nas 3 variáveis mais importantes

As duas primeiras posições do ranking de importância, ct_dst_src_ltm e sttl, mais a terceira, sbytes, dão pra visualizar juntas num espaço só. É o mesmo notebook 02 que gerou o sweep de threshold, com o mesmo pipeline retreinado, que também extrai essa amostra:

FEATURES_3D = ["sttl", "ct_dst_src_ltm", "sbytes"]
N_SAMPLE = 400

amostra_idx = X_test.sample(n=N_SAMPLE, random_state=42).index
y_pred_series = pd.Series(y_pred, index=X_test.index)

sample_points = []
for i in amostra_idx:
    sample_points.append({
        "x": float(X_test.loc[i, FEATURES_3D[0]]),
        "y": float(X_test.loc[i, FEATURES_3D[1]]),
        "z": float(X_test.loc[i, FEATURES_3D[2]]),
        "label_real": int(y_test.loc[i]),
        "label_previsto": int(y_pred_series.loc[i]),
    })

X_test.sample(n=400, random_state=42) sorteia 400 índices do conjunto de teste oficial, a semente fixa é só pra eu conseguir reproduzir a mesma amostra depois. y_pred_series reempacota o array cru y_pred (que só tem posição, sem índice) numa Series com o mesmo índice de X_test, pra poder buscar a previsão de uma sessão específica por .loc[i] dentro do laço. Pra cada uma das 400 sessões sorteadas, o laço guarda as três features de interesse (sttl, ct_dst_src_ltm, sbytes) mais o rótulo real e o rótulo previsto, nada inventado, é exatamente o X_test/y_test oficiais, só que reduzido a uma amostra visualizável. Plotei nesses 3 eixos:

Carregando dados reais...

Nessa amostra de 400: 247 acertos em sessão de ataque, 112 acertos em sessão normal, 36 falsos negativos e só 5 falsos positivos, uma proporção parecida com a do teste inteiro. Um detalhe que salta aos olhos ao girar a nuvem: sttl não é contínuo de verdade, ele se agrupa nuns poucos valores redondos (31, 62, 254...), porque TTL é literalmente um contador de saltos de roteador que cada sistema operacional inicializa num valor padrão (64, 128 ou 255) e vai decrementando um a um a cada salto. Nas 283 sessões de ataque dessa amostra, sttl=254 aparece em 238 delas (84%), mas também aparece em 37 das 117 sessões normais (32%), o valor mais comum dos dois lados, longe de ser um separador limpo sozinho. Já sttl=31 é quase exclusivo de tráfego normal: 74 das 117 sessões normais, zero sessão de ataque. É exatamente por isso que uma variável sozinha não decide nada aqui. Dá pra ver os 36 pontos vermelhos (falso negativo) espalhados pelo meio da nuvem, sem formar uma ilha isolada, e é aí que entram as outras 39 variáveis que o gráfico não mostra, trabalhando junto pra fechar os 87% de revocação que o modelo final entrega.

Tentando melhorar com GridSearch, e batendo no próprio teto

Testei GridSearchCV variando número de árvores (200 ou 400), profundidade máxima (sem limite ou até 30) e tamanho mínimo de folha (1 ou 2), 8 combinações, validação cruzada de 3 dobras, numa amostra de 30 mil sessões pra manter o custo tratável:

param_grid = {
    "clf__n_estimators": [200, 400],
    "clf__max_depth": [None, 30],
    "clf__min_samples_leaf": [1, 2],
}

grid = GridSearchCV(pipe_rf, param_grid, scoring="f1", cv=cv, n_jobs=-1, verbose=1)
grid.fit(Xg, yg)

O prefixo clf__ na frente de cada chave não é enfeite: pipe_rf é um Pipeline com uma etapa chamada "clf" (o Random Forest), e o __ é o jeito do scikit-learn dizer "o parâmetro n_estimators pertence à etapa clf, não a qualquer outra do pipeline", a mesma sintaxe model__ que já apareceu no post de pipeline e GridSearch. Sem esse prefixo, GridSearchCV não saberia em qual das etapas mexer.

Saída: melhor combinação encontrada, n_estimators=200, max_depth=None, min_samples_leaf=1, com F1 de validação 0,9716. Exatamente os parâmetros padrão do Random Forest que eu já tinha treinado antes.

Pra não confiar cegamente nesse F1 de validação (calculado numa amostra de 30 mil, lembra), re-treinei a configuração vencedora no conjunto de treino oficial inteiro e avaliei no teste oficial, do mesmo jeito rigoroso da avaliação final:

melhores = {k.replace("clf__", ""): v for k, v in grid.best_params_.items()}
modelo_tunado = Pipeline([("prep", preprocessador),
                          ("clf", RandomForestClassifier(random_state=42, class_weight="balanced",
                                                         n_jobs=-1, **melhores))])
modelo_tunado.fit(X_train, y_train)
y_pred_t = modelo_tunado.predict(X_test)
y_score_t = modelo_tunado.predict_proba(X_test)[:, 1]

print(classification_report(y_test, y_pred_t, digits=4))
print(f"AUC-ROC (ajustada): {roc_auc_score(y_test, y_score_t):.4f}")

grid.best_params_ guarda as chaves com o prefixo clf__ (o mesmo prefixo explicado acima), então a primeira linha tira esse prefixo pra poder passar os valores direto pro construtor do RandomForestClassifier via **melhores. O resto é o mesmo Pipeline de sempre, só que agora com os hiperparâmetros vencedores no lugar dos valores padrão, treinado no conjunto de treino oficial inteiro (não mais na amostra de 30 mil da busca) e avaliado no teste oficial.

Saída: precisão, revocação e F1 por classe idênticos à Random Forest original, casa decimal por casa decimal, e AUC-ROC (ajustada): 0.9830.

O resultado bate, casa decimal por casa decimal, com a Random Forest original: mesma precisão, revocação e F1 por classe, mesma AUC de 0,9830. Não é falha de busca, é a busca confirmando algo que vale registrar sem meias palavras: pra esse dataset, com esse conjunto de candidatos testados, o palpite padrão do scikit-learn (100 ou 200 árvores, sem limitar profundidade) já estava no teto que essa família de hiperparâmetro alcança. Nem toda otimização encontra algo melhor pra otimizar, o mesmo tipo de resultado "chato" que já vi no post de ensembles, quando a regressão logística crua bateu ensemble nenhum se afinar mudasse o resultado.

Fechando o projeto (e a playlist inteira)

O que eu já sabia, de algum post anteriorO que esse trabalho final juntou
Dado desbalanceado engana acuráciaAqui, com 12,6% de ataque, revocação da classe minoritária importa mais que o número geral
Correlação alta sinaliza redundânciaCorrelação entre feature e rótulo (sttl × Label, 0,91) pode sinalizar sinal real, não só redundância entre duas variáveis
Random Forest lida bem com padrão não-linearAqui ele bateu Regressão Logística, Árvore sozinha e Naive Bayes nos dois eixos (F1 e AUC)
GridSearch encontra o melhor ponto do espaço de buscaÀs vezes o melhor ponto já é o padrão, e vale a pena descobrir isso medindo, não assumindo

Esse projeto fecha um ciclo pra mim: comecei essa playlist ajustando uma reta com equação normal, passei por árvore, cluster, redução de dimensão, texto virando vetor, e terminei aplicando tudo isso sozinho, sem aula pra seguir, num problema de segurança de rede de verdade, com resultado real (AUC 0,98 no teste oficial) que eu consigo defender linha por linha, do CSV bruto até a árvore de decisão final. Valeu, professor Boldt, o resto eu tirei de letra.