Trocando de Dataset e Escolhendo um Candidato
No capítulo 1 eu explorei os 2,5 milhões de sessões brutas do UNSW-NB15: desbalanceamento, dados ausentes, um bug de qualidade escondido, redundância entre variáveis, e a decisão de não podar nenhuma feature. Terminei apontando um problema que ainda não resolvi: srcip, dstip, sport e dsport separam bem demais, de um jeito que não generaliza. Esse capítulo começa resolvendo exatamente isso, trocando de dataset, e termina escolhendo, com critério, qual família de modelo treinar.
Por que trocar de dataset: vazamento de dado
O UNSW-NB15 vem em dois formatos. Um é o que usei o capítulo inteiro passado: os quatro CSVs brutos, com todos os 49 campos, incluindo srcip, dstip, sport, dsport e os timestamps Stime/Ltime. O outro é a partição oficial (UNSW_NB15_training-set.csv e UNSW_NB15_testing-set.csv), um subconjunto que os próprios autores do dataset (Moustafa & Slay, 2015) já separaram em treino e teste, sem esses identificadores.
Escolho a partição oficial pra modelar, e a razão central é vazamento de dado (data leakage): um IP ou uma porta identifica uma máquina específica desse laboratório, não um padrão de comportamento de ataque. Se eu deixasse srcip entrar no modelo, ele aprenderia algo como "59.166.0.4 é sempre suspeito" em vez de aprender o que faz um ataque parecer um ataque (a duração da sessão, o volume de bytes, o TTL, e por aí vai). Isso infla o desempenho artificialmente dentro desse laboratório específico e não generaliza pra nenhuma rede fora dele. Os timestamps Stime/Ltime têm o mesmo problema: fluxos próximos no tempo tendem a ser correlacionados entre si (mesma sessão de ataque gerando várias linhas seguidas), e usar o timestamp bruto deixaria essa correlação vazar pro modelo.
Tem um segundo motivo, menos sobre vazamento e mais sobre método científico: a partição oficial é o benchmark padrão da literatura sobre esse dataset. Treinar e avaliar nela deixa meu resultado comparável com outros trabalhos publicados usando o mesmo dataset, o que eu perderia se inventasse meu próprio split aleatório em cima dos arquivos brutos, e correria o risco adicional de vazamento entre treino e teste por causa de fluxos correlacionados (mesmo host, mesma sessão) caindo dos dois lados do split.
A troca tem um custo real: abro mão de 2,5 milhões de linhas por um conjunto bem menor.
df_train = pd.read_csv("/content/data/UNSW_NB15_training-set.csv")
df_test = pd.read_csv("/content/data/UNSW_NB15_testing-set.csv")
Saída: treino com 82.332 linhas, teste com 175.341, cada uma com 45 colunas.
Repara que o teste é maior que o treino aqui, o oposto do que eu esperaria de um split 80/20 comum. Os autores do UNSW-NB15 desenharam de propósito um conjunto de teste generoso, justamente pra dar uma medida de generalização mais confiável. E tem outra inversão escondida: na partição oficial, a classe ataque vira majoritária no teste (mais adiante, no capítulo 3, isso aparece nos números: 119.341 sessões de ataque contra 56.000 normais), o oposto da proporção que vi na EDA dos arquivos brutos. É um lembrete de que EDA e modelagem, aqui, não rodam sobre o mesmo dataframe, e as proporções de um não valem pro outro.
As colunas da partição oficial
As 45 colunas da partição não são as mesmas 49 dos arquivos brutos. Os autores já removeram os identificadores e os timestamps, e organizaram o que sobrou em grupos que fazem sentido de ler juntos:
- Básicas do fluxo:
dur(duração),proto,service,state. - Volume e taxa:
spkts/dpkts(pacotes),sbytes/dbytes(bytes),rate,sload/dload(carga). - TCP e tempo:
sttl/dttl(o TTL que já apareceu no capítulo 1),swin/dwin(janela TCP),tcprtt,synack,ackdat. - Contadores de conexão (
ct_...): quantas conexões recentes compartilham serviço, host ou porta, a família de variável desenhada especificamente pra capturar varredura e repetição. - Alvos:
label(0/1) eattack_cat.
Das 42 colunas de entrada (descartando id e os dois alvos), 39 são numéricas e 3 são categóricas (proto, service, state):
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"]
cat_cols = X_train.select_dtypes(include=["object"]).columns.tolist()
num_cols = X_train.select_dtypes(include=["number"]).columns.tolist()
Saída: categóricas
['proto', 'service', 'state'], 39 numéricas.
select_dtypes separa sozinho as 3 categóricas e as 39 numéricas, sem eu precisar digitar nome de coluna na mão.
Montando o pipeline de pré-processamento
from sklearn.compose import ColumnTransformer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
transformadores = [
("num", StandardScaler(), num_cols),
("cat", OneHotEncoder(handle_unknown="ignore", sparse_output=False), cat_cols),
]
preprocessador = ColumnTransformer(transformers=transformadores)
ColumnTransformer aplica, em paralelo, um transformador diferente pra cada grupo de coluna: StandardScaler nas 39 numéricas (centra em média 0 e desvio 1, essencial pra Regressão Logística, inócuo pra árvore) e OneHotEncoder nas 3 categóricas, o mesmo par que já apareceu no Titanic, agora resolvendo protocolo de rede em vez de porto de embarque. Um detalhe que vale registrar: handle_unknown="ignore". Se o teste trouxer um valor de service que o treino nunca viu, em vez de estourar erro, o OneHotEncoder simplesmente zera aquela linha inteira pra aquela variável, o mesmo espírito do token [UNK] que já vi resolvendo palavra desconhecida.
Essa célula não devolve gráfico nem número, só monta o transformador. E é exatamente aí que mora o ponto mais importante dela: eu não chamo .fit() nesse preprocessador sozinho, ele entra inteiro dentro de um Pipeline, junto com o classificador. Isso importa porque, na validação cruzada que vem a seguir, cada fold treina seu próprio StandardScaler só com o pedaço de treino daquele fold, a média e o desvio nunca veem o pedaço de validação. Se eu tivesse rodado preprocessador.fit_transform(X_train) antes de dividir os folds, a média e o desvio calculados em cima do treino inteiro vazariam informação estatística do fold de validação pro treino, um vazamento sutil (bem menor que o dos IPs, mas real) que o Pipeline evita de graça.
Comparando quatro candidatos, numa amostra rápida
Antes de escolher um modelo, defini o critério antes de ver qualquer número: prioridade pro F1-score (equilíbrio entre não deixar ataque passar e não gerar alarme falso), com AUC como desempate. Como as classes são desbalanceadas, uso class_weight="balanced" onde o modelo aceita, e validação cruzada estratificada (mantém a proporção de classes em cada fold).
Testei quatro famílias bem diferentes entre si, cada uma carregando uma hipótese diferente sobre o dado:
- Regressão Logística: assume fronteira de decisão linear, referência simples e interpretável.
- Árvore de Decisão: captura relação não linear, mas tende a overfitting sozinha.
- Random Forest: agrega muitas árvores, reduzindo variância via bagging.
- Naive Bayes Gaussiano: assume independência entre variáveis dado a classe, rápido, uma linha de base.
modelos = {
"Regressao Logistica": LogisticRegression(max_iter=1000, class_weight="balanced"),
"Arvore de Decisao": DecisionTreeClassifier(random_state=42, class_weight="balanced"),
"Random Forest": RandomForestClassifier(n_estimators=100, random_state=42, class_weight="balanced", n_jobs=-1),
"Naive Bayes": GaussianNB(),
}
score = lambda clf, m: cross_val_score(Pipeline([("prep", preprocessador), ("clf", clf)]), Xs, ys, cv=cv, scoring=m, n_jobs=-1).mean()
linhas = [{"Modelo": n, "F1": score(c, "f1"), "AUC": score(c, "roc_auc")} for n, c in modelos.items()]
Vale abrir esse bloco em câmera lenta, porque ele empacota bastante coisa em duas linhas. modelos é só um dicionário nome → instância de classificador, os quatro candidatos que eu quero comparar. score é uma função de uma linha só (lambda): recebe um classificador clf e o nome de uma métrica m, monta um Pipeline novo grudando o mesmo preprocessador na frente daquele classificador específico (cada candidato precisa do próprio pipeline, não dá pra reaproveitar um pipeline já treinado por outro modelo), roda validação cruzada de 3 dobras nesse pipeline (numa amostra de 20 mil sessões de treino, rodar isso nas 82 mil inteiras a cada candidato custaria tempo à toa nessa fase de triagem), e tira a média das 3 notas. A linha de baixo chama essa função score duas vezes pra cada um dos 4 modelos, uma vez pedindo "f1", outra pedindo "roc_auc". 4 modelos × 2 métricas × 3 dobras: 24 treinos completos, escondidos atrás de duas linhas de código.
Random Forest ganha nas duas métricas (F1 0,97, AUC 1,00 nessa amostra de triagem), a Árvore de Decisão sozinha já vem logo atrás, e o Naive Bayes Gaussiano fica bem pra trás dos outros três (F1 0,54). Faz sentido pelo mesmo motivo que já vi na PCA do câncer de mama: Naive Bayes assume que as variáveis são independentes entre si dado a classe, e o capítulo 1 já mostrou que boa parte das 39 variáveis numéricas são tudo menos independentes, várias contam a mesma história com palavras diferentes. A expectativa teórica também bate: Random Forest combina o baixo viés das árvores com a baixa variância do bagging, exatamente o tipo de robustez que eu esperaria lidar bem com as 39 variáveis, boa parte redundante entre si, que o capítulo 1 mapeou.
Fechando este capítulo
Com o critério definido antes de ver os números (F1 primeiro, AUC como desempate) e o resultado batendo com a expectativa teórica, a Random Forest é a escolhida pro treino final. No capítulo 3 eu treino ela em todo o conjunto de treino oficial, avalio no teste oficial (nunca visto), abro a curva ROC, o corte de decisão, quais variáveis mais pesaram na decisão e se dá pra melhorar afinando hiperparâmetro.