← Voltar pro projeto

Explorando 2,5 Milhões de Sessões de Rede

Como contei na abertura do projeto, esse é meu trabalho final da matéria de Reconhecimento de Padrões, sem professor mostrando o caminho dessa vez. Pensa numa sessão de rede como uma ligação telefônica entre dois computadores: tem um início e um fim, uma duração, uma quantidade de informação trocada de cada lado (os bytes), e um "idioma" combinado entre os dois (o protocolo, tipo TCP ou UDP). A missão do projeto inteiro: dado esse pacotinho de informação sobre a ligação, decidir se aquilo é tráfego normal ou ataque. Notebook completo, executado, aqui, os três capítulos saem dele.

Esse primeiro capítulo é só a análise exploratória (EDA). Antes de treinar qualquer modelo eu preciso entender o que tenho na mão: quão desbalanceado é o dataset, onde faltam dados e por quê, se alguma coluna tá suja, quais variáveis contam a mesma história (redundância) e como decidir, com critério, quais entram no modelo. O capítulo 2 muda de dataset (explico por quê) e treina os candidatos, o capítulo 3 fecha com avaliação final, curva ROC e ajuste de hiperparâmetro.

O dataset: 2,5 milhões de sessões, quase 9 em cada 10 são tráfego normal

O UNSW-NB15 nasceu de um laboratório de verdade: o Australian Centre for Cyber Security gerou tráfego de rede misturando navegação normal com nove famílias de ataque diferentes rodando de propósito, e capturou tudo em pacote bruto. O primeiro problema prático: os quatro arquivos CSV brutos (UNSW-NB15_1.csv a _4.csv) não trazem nome de coluna nenhum, só número puro. Os nomes moram separados, num quinto arquivo, o "catálogo" de features:

features = pd.read_csv("/content/data/NUSW-NB15_features.csv", encoding="latin-1")
features.columns = features.columns.str.strip()
pd.set_option("display.max_colwidth", None)
pd.set_option("display.max_rows", None)

print(f"Total de features descritas: {len(features)}")
display(features[["No.", "Name", "Type", "Description"]])

Saída: Total de features descritas: 49, seguido de uma tabela com número, nome, tipo declarado e descrição de cada uma das 49 colunas (por exemplo, sttl aparece descrita como "Source to destination time to live value").

encoding="latin-1" não é capricho: o CSV desse catálogo tem caracteres fora do padrão UTF-8 (acentos e símbolos vindos de quem documentou o dataset), e sem especificar o encoding certo o read_csv quebra. As duas linhas de pd.set_option existem só pra essa exibição específica: sem elas, o pandas trunca a coluna Description (que tem frases longas) e corta a tabela em "..." no meio das 49 linhas, exatamente o oposto do que eu quero quando o objetivo é ler a descrição de cada feature. Esse catálogo é o que eu uso a seguir pra nomear as colunas de verdade:

import re

# Filtragem apenas dos arquivos de dados -> UNSW-NB15_1.csv até UNSW-NB15_4.csv
partes = sorted([
    f for f in os.listdir("/content/data")
    if re.match(r"UNSW-NB15_\d+\.csv", f)
])

# Extraindo os nomes das colunas do arquivo de features
colunas = features["Name"].tolist()

# Carregando e concatenando todas as partes em um único DataFrame
df = pd.concat(
    [pd.read_csv(f"/content/data/{p}", header=None, names=colunas, low_memory=False)
     for p in partes],
    ignore_index=True
)

Vale abrir esse bloco, porque tem uma pegadinha escondida: sem o names=colunas ali dentro do read_csv, o pandas numeraria as colunas como 0, 1, 2... e eu perderia a etiqueta de cada uma. Antes disso, re.match(r"UNSW-NB15_\d+\.csv", f) filtra, de todos os arquivos que vieram no download, só os quatro que interessam aqui (UNSW-NB15_1.csv a _4.csv), descartando de propósito o próprio catálogo de features e os arquivos da partição oficial (UNSW_NB15_training-set.csv etc., que uso só a partir do capítulo 2) que também estão na mesma pasta. sorted(...) garante que os quatro pedaços entrem na ordem certa (_1, _2, _3, _4), não na ordem que o sistema de arquivos decide listar. A lista de compreensão entre colchetes lê os quatro arquivos, um de cada vez, já colando esses nomes certos na hora de ler, e pd.concat(..., ignore_index=True) empilha os quatro pedaços um embaixo do outro, resetando a contagem de linha do zero (sem isso, cada pedaço reiniciaria em 0, e eu teria índice repetido quatro vezes).

print(f"shape do dataset: \n {df.shape}\n\n")
df.head(5)

Saída: (2540047, 49), seguido das 5 primeiras linhas reais, uma sessão UDP com sport=1390, dsport=53 (porta de DNS) e dur=0.001055 segundos, entre outras.

Um jeito simples e barato de conferir que a concatenação dos quatro arquivos e o batimento de nomes deram certo: se names=colunas tivesse desalinhado alguma coluna, o df.head() mostraria valor de texto onde eu esperava número, ou vice-versa, e eu pegaria isso na cara antes de qualquer análise mais séria.

print(f"Linhas: {df.shape[0]:,}")
print(f"Colunas: {df.shape[1]}")
print("Tipagem dos dados:")
print(df.dtypes.value_counts())

pd.options.display.float_format = "{:,.2f}".format
display(df.describe().T)

Saída: int64 28 colunas, float64 12, object 9. O df.describe().T (transposto, uma linha por coluna numérica) devolve contagem, média, desvio, mínimo, quartis e máximo de cada uma das 40 colunas numéricas de uma vez.

Esse terceiro grupo, as 9 colunas object, merece desconfiança, porque uma coluna virar object não significa necessariamente "essa coluna é texto de verdade". Às vezes é o pandas dizendo "achei algo aí dentro que não bate com número puro", e mais pra frente nesse capítulo isso vai ser exatamente a pista de um bug.

2.218.764 sessões normais contra 321.283 de ataque, uma razão de quase 7 pra 1. Já vi esse tipo de desbalanceamento antes na detecção de fraude de cartão de crédito, lá era 0,17% de fraude, aqui são 12,6% de ataque, bem menos extremo, mas ainda desbalanceado o suficiente pra acurácia sozinha mentir: só chutando "normal" sempre, um modelo preguiçoso já acerta 87,4% das vezes sem aprender nada.

Que tipo de ataque tem aí

A coluna attack_cat (só preenchida quando a sessão é ataque, por isso 87,35% dela é vazio, exatamente a fração de tráfego normal) diz qual família de ataque é cada sessão maliciosa:

Antes de eu somar essas 9 categorias, o dado bruto tinha 13 rótulos diferentes, porque " Fuzzers" (com espaço na frente) e "Fuzzers" contavam como duas categorias separadas pro pandas, mesma bobagem com "Reconnaissance"/" Reconnaissance", "Backdoor"/"Backdoors" e "Shellcode"/" Shellcode". O mesmo tipo de sujeira de digitação que já vi no dataset do Titanic, só que aqui em vez de coluna faltando é categoria duplicada por espaço a mais, um .strip() resolveria. Generic domina disparado (215 mil sessões, dois terços de todo ataque), e são técnicas que forjam uma colisão de hash pra confundir a criptografia, bem mais fáceis de automatizar em massa do que, por exemplo, um Worms (só 174 sessões, ataque que precisa se replicar sozinho de máquina em máquina, mais raro de acontecer num laboratório controlado).

Redundância: metade das colunas conta a mesma história

Com 40 variáveis numéricas, uma pergunta natural é: quantas delas carregam informação genuinamente diferente? Rodei a correlação de Pearson entre pares de variáveis numéricas numa amostra de 50 mil sessões (calcular isso nas 2,5 milhões inteiras seria caro à toa):

sample = df[colunas_numericas].sample(n=50000, random_state=42)
corr_matrix = sample.corr()

mask_pairs = np.triu(np.ones_like(corr_matrix, dtype=bool), k=1)
corr_pairs = corr_matrix.where(mask_pairs).stack()
fortes = corr_pairs[corr_pairs.abs() >= 0.8].sort_values(key=abs, ascending=False)

Esse bloco final vale desmontar, porque corr_matrix sozinha é uma armadilha de duplicata: é uma tabela 40×40, cada variável cruzada com todas as outras, mas ela é simétrica, o cruzamento de sbytes com sloss e o de sloss com sbytes são o mesmo número aparecendo duas vezes. Contar isso duas vezes infla a lista à toa. np.triu(..., k=1) desenha uma "grade" que marca só o triângulo de cima da tabela como verdadeiro, pulando a diagonal (o k=1, senão eu contaria cada variável correlacionada com ela mesma, sempre 1,0). corr_matrix.where(mask_pairs) apaga (vira vazio) tudo que caiu fora dessa grade, sobrando só metade única da tabela. .stack() acha essa metade e a "desdobra" numa lista comprida de pares, descartando os vazios sozinho. Só falta filtrar quem tem correlação forte (|r| ≥ 0,8) e ordenar do mais forte pro mais fraco, exatamente o que a última linha faz:

Par de variáveisCorrelação
Stime × Ltime1,00
swin × dwin1,00
dloss × Dpkts0,99
dbytes × dloss0,99
sbytes × sloss0,96
ct_srv_src × ct_srv_dst0,96
ct_dst_ltm × ct_src_dport_ltm0,96
tcprtt × synack0,92
tcprtt × ackdat0,92
sttl × ct_state_ttl0,91
sttl × Label0,91
ct_state_ttl × Label0,88

Alguns desses pares são redundância por construção, não coincidência: Stime e Ltime são o instante de início e o instante de fim da mesma sessão, então correlacionar 1,00 é quase automático (a diferença entre os dois é literalmente a dur, outra coluna). Já sttl (TTL, "time to live", o contador que cada roteador no caminho decrementa até o pacote expirar) correlacionar 0,91 direto com o rótulo de ataque é o achado mais interessante da EDA inteira: ferramenta de ataque automatizada costuma gerar pacote com TTL bem diferente do tráfego normal de navegador, uma "sotaque" técnico que sozinho já entrega bastante informação sobre quem tá do outro lado da conexão. Guardo esse nome, sttl, porque ele vai voltar a aparecer nos próximos dois capítulos.

Boxplots por classe: inspeção visual antes da importância do modelo

Antes de qualquer modelo entrar em cena, uma pergunta mais simples já ajuda a antecipar quais variáveis separam bem tráfego normal de ataque: será que a distribuição de uma feature muda visivelmente entre as duas classes?

features_box = ["dur", "sbytes", "dbytes", "Sload", "Dload"]

fig, axes = plt.subplots(1, len(features_box), figsize=(18, 5))

for i, feat in enumerate(features_box):
    sns.boxplot(
        data=df,
        x="Label",
        y=feat,
        ax=axes[i],
        palette=["steelblue", "tomato"],
        showfliers=False,   # tirando outliers extremos para melhor visualização
        hue="Label",
        legend=False
    )
    axes[i].set_title(feat)
    axes[i].set_xticks([0, 1])
    axes[i].set_xticklabels(["Normal", "Ataque"])

plt.suptitle("Distribuição de Features por Classe", y=1.02)
plt.tight_layout()
plt.show()

Cada subgráfico compara a distribuição de uma feature (dur, sbytes, dbytes, Sload, Dload) entre sessão normal e sessão de ataque, lado a lado. A linha central da caixa é a mediana, a caixa cobre do 1º ao 3º quartil (os 50% centrais dos dados), e os "bigodes" mostram a dispersão típica. showfliers=False só tira os outliers mais extremos da imagem, pra ela não ficar achatada, eles continuam existindo nos dados, só não aparecem nesse desenho específico.

A leitura que interessa: quando a caixa de "Normal" e a caixa de "Ataque" ficam em faixas de valores bem diferentes, com pouca sobreposição, aquela variável sozinha já ajuda a separar as classes. Quando as duas caixas se sobrepõem quase por completo, a variável sozinha discrimina pouco. É uma inspeção visual rápida que antecipa o que a importância do Random Forest confirma de forma quantitativa lá no capítulo 3, mas sem esperar o modelo terminar de treinar pra ter uma primeira pista.

Quem são as colunas categóricas, e por que isso já é um alerta

Separando as colunas que o pandas leu como texto (select_dtypes(include=["object"])), aparecem nove: srcip, sport, dstip, dsport, proto, state, service, ct_ftp_cmd e attack_cat. Contando os valores mais frequentes de cada uma, dois grupos bem diferentes aparecem:

categoricas = df.select_dtypes(include=["object"]).columns.tolist()
for col in categoricas:
    print(f"\n{col}:")
    print(df[col].value_counts().head(10))

proto é dominado por tcp (1.495.074 sessões) e udp (990.435), state por FIN (1.478.689), CON (560.588) e INT (490.471), e service tem um - enorme (1.246.397, serviço não identificado pelo sniffer), seguido de dns (781.668) e http (206.273). Essas três têm poucas categorias e sem ordem entre si, boas candidatas a one-hot encoding mais na frente.

Já srcip, sport, dstip e dsport são outra espécie: identificadores de altíssima cardinalidade. srcip sozinho tem só 10 valores únicos nesse laboratório (59.166.0.0 a 59.166.0.9, as máquinas atacantes/geradoras de tráfego), mas sport e dsport têm milhares. O motivo de eu não usar nenhuma dessas quatro no modelo não é falta de sinal, IP de origem provavelmente separa muito bem quem é máquina de ataque nesse laboratório específico, é o oposto: separa bem demais, de um jeito que não generaliza. Um modelo treinado com srcip decoraria que "59.166.0.4 é sempre suspeito" em vez de aprender o comportamento de um ataque, e fora desse laboratório, com IPs completamente diferentes, essa regra decorada não vale nada. Volto nesse ponto com mais calma no capítulo 2, quando explico a troca de dataset.

Dados ausentes: falta estrutural, não falha de coleta

nulos = df.isnull().sum()
nulos_percentual = (nulos / len(df) * 100)
resumo_nulos = pd.DataFrame({"nulos": nulos, "percentual": nulos_percentual}).sort_values("percentual", ascending=False)

Só três colunas, de 49, têm nulo de verdade (as outras 46 ficam com 0,00%). Minha primeira versão desse gráfico era um heatmap de 5.000 linhas amostradas, um jeito clássico de visualizar padrão de nulo, mas com só 3 colunas afetadas de 49 ele virava uma tira estreita sem informação nenhuma, tempo perdido olhando pra uma imagem quase em branco. Troquei pra um gráfico de barra direto do percentual por coluna, que responde exatamente a pergunta que eu queria fazer: "quanto falta em cada coluna?" Foi minha primeira lição prática desse capítulo: o gráfico "certo" na teoria não é sempre o gráfico certo pro seu dado específico.

E os três nulos que existem contam uma história estrutural, não um problema de coleta. attack_cat (87,35%) só é preenchida quando a sessão é ataque, os 87,35% vazios são exatamente as sessões normais, a mesma coisa que já expliquei lá em cima. is_ftp_login (56,29%) e ct_flw_http_mthd (53,08%) só fazem sentido pra sessões FTP e HTTP respectivamente, sessão de outro protocolo não tem o que preencher ali. Não tem imputação cega a fazer aqui, porque a ausência em si já é informação (essa sessão não é FTP, por exemplo), apagar ou preencher essas colunas com qualquer valor destruiria esse sinal.

Um bug sutil: quando "vazio" não conta como nulo

Falei que só três colunas têm nulo. Mas reparei em algo estranho olhando os valores de ct_ftp_cmd (quantidade de comandos FTP na sessão, que deveria ser um número) na lista de categóricas:

ct_ftp_cmd:
     1429879
0     686192
0     380306
1      29605
1      10472

Duas pistas aqui. Primeira: a categoria mais comum, com 1.429.879 sessões, não tem rótulo nenhum, é uma string vazia (""), não um NaN. E 1.429.879 é exatamente o número de nulos de is_ftp_login lá de cima, ou seja, toda sessão sem login FTP também não tem ct_ftp_cmd preenchido, faz sentido, mas o pandas não contou isso como nulo porque tecnicamente não é NaN, é uma string vazia disfarçada de categoria válida. df.isnull().sum() simplesmente não via esse problema. Segunda pista: "0" aparece duas vezes na lista, uma com 686.192 sessões e outra com 380.306, porque uma tem espaço em branco colado ("0 ") e a outra não, o mesmo tipo de sujeira de digitação que já vi em attack_cat. Essas duas coisas combinadas (string vazia em vez de nulo, mais espaço em branco duplicando categoria) são o motivo de ct_ftp_cmd ter virado object lá na tabela de dtypes: uma coluna que deveria ser puramente numérica ficou "contaminada" o suficiente pro pandas desistir de ler como número.

A lição que fica: df.isnull().sum() é um bom primeiro filtro, mas não é a palavra final sobre o que tá faltando. Uma string vazia, um espaço sozinho, um "-" genérico (como o que já vi em service), tudo isso passa batido por um .isnull() ingênuo. Vale sempre cruzar com df.dtypes e desconfiar de toda coluna numérica por natureza que virou object.

Selecionando (ou não) características

Com a EDA numérica e categórica na mão, chega a pergunta central de qualquer projeto com muitas variáveis: quais delas eu realmente uso? A disciplina trata essa escolha como seleção de características, e existem três famílias clássicas:

  • Filtro (filter): critério estatístico, independente de qualquer modelo. A análise de redundância por correlação lá em cima é exatamente isso: duas variáveis com r ≈ 1,00 carregam quase a mesma informação, uma seria candidata a sair.
  • Wrapper: testar subconjuntos de variáveis rodando o próprio modelo de verdade (por exemplo, eliminação recursiva de características). Poderoso, mas caro, porque cada subconjunto testado é um treino completo.
  • Embutida (embedded): o próprio modelo pontua a importância de cada variável enquanto treina. É o que a importância da Random Forest faz, e eu chego lá no capítulo 3.

Antes de decidir qualquer coisa, rodei um histograma em oito features numéricas escolhidas só pra inspecionar a forma das distribuições, não pra ranquear importância:

features_ilustrativas = ["dur", "sbytes", "dbytes", "sttl", "dttl", "sloss", "dloss", "Sload"]

axes = df[features_ilustrativas].hist(figsize=(14, 8), bins=50, color="steelblue",
                                      edgecolor="black", log=True)
for ax in axes.ravel():
    ax.set_xlabel("Valor da feature")
    ax.set_ylabel("Frequencia (escala log)")

plt.suptitle("Distribuicao de Features Numericas (amostra ilustrativa)", y=1.02)
plt.tight_layout()
plt.show()

O comentário que deixei junto dessa célula no notebook original vale reproduzir aqui, porque evita uma leitura errada: essas oito features são ilustrativas, uma amostra pra inspecionar a forma da distribuição, não o resultado de nenhuma seleção por importância (a importância de verdade vem da correlação, já vista, e da importância do Random Forest, lá no capítulo 3). O eixo Y usa escala logarítmica (log=True) de propósito: bytes trafegados e duração de sessão têm distribuição extremamente assimétrica, a maioria das sessões é curta e leve, uma minoria é muito mais pesada, e numa escala linear essa minoria esmagaria o gráfico até a maioria virar uma linha reta no zero.

Com essa inspeção visual, mais os boxplots por classe e a correlação já vistos lá em cima, já vale registrar a decisão que tomei: não vou podar nenhuma feature no pipeline. A Random Forest que uso no capítulo 3 é razoavelmente robusta a variáveis redundantes e a diferença de escala entre elas, então trato essa seleção como análise interpretativa (entender o que pesa na decisão) em vez de uma etapa que efetivamente altera o que entra no modelo. É uma escolha consciente, não preguiça: uma Regressão Logística ou um KNN provavelmente se beneficiariam de cortar a redundância que achei aqui, mas pro modelo que escolho no próximo capítulo o ganho não compensaria a complexidade extra de decidir "qual das duas colunas quase-idênticas eu corto".

Principais insights, antes de modelar

Fechando esse primeiro capítulo, os pontos que carrego comigo pro resto do projeto:

O que a EDA mostrouPor que importa lá na frente
Desbalanceamento de 12,6% de ataqueAcurácia sozinha engana, F1 e AUC vão ser as métricas que realmente confio
attack_cat, is_ftp_login e ct_flw_http_mthd têm nulo estruturalNão vou imputar às cegas, a ausência em si já é sinal
ct_ftp_cmd escondia dado ausente como string vazia.isnull() sozinho não basta, sempre cruzar com dtype
attack_cat e ct_ftp_cmd tinham categoria duplicada por espaço extraCorrigido antes de qualquer contagem valer a pena
Metade das 40 variáveis numéricas é redundante entre si (correlação ≥ 0,8)Random Forest tolera isso, mas registrar a redundância importa pra interpretar depois
srcip/dstip/sport/dsport separam bem demais, de um jeito que não generalizaDescartados no capítulo 2, junto com a explicação completa do porquê

No capítulo 2 eu troco o dataset bruto que usei esse capítulo inteiro pela partição oficial que os próprios autores do UNSW-NB15 curaram e publicaram, explico com detalhe o porquê dessa troca (é sobre vazamento de dado, não capricho), monto o pipeline de pré-processamento e comparo quatro famílias de modelo numa validação cruzada justa antes de escolher qual treinar de verdade.