Projeto “end-to-end” de Machine Learning utilizando LightGBM, SHAP, Category Encoders e Scikit-Optimize.

EDERSON ANDRE DE SOUZA
Data Hackers
Published in
12 min readMar 30, 2020

--

No meu aprendizado de Ciência de Dados, muitas vezes vi outras pessoas pedindo para outros demonstrarem um projeto de Machine Learning do início ao fim.

Normalmente o que encontramos são posts de técnicas esparsas ou parte da construção do modelo, sem, no entando, mostrar o todo.

Assim, com o objetivo de trazer aos estudantes e aspirantes a Cientistas de Dados a visão completa de um trabalho de classificação com Machine Learning, resolvi escrever uma série compostas por três artigos, o qual mostrará passo-a-passo, do início ao fim, como construir um modelo utilizando várias das melhores ferramentas disponíveis no momento.

Os artigos serão organizados da seguinte maneira:

  • Parte I — Da preparação dos dados até a baseline;
  • Parte II — Treino e tuning do modelo LightGBM com Scikit-Optimize;
  • Parte III — Feature Importance com SHAP e análise dos resultados.

Nesta série compartilharei um dos trabalhos mais completos que já fiz no Codementor.io.

O objetivo do modelo é a detecção de malware / bots utilizando os dados de tráfego da rede.

Os dados utilizados para análise foram obtidos no repositório do The Malware Capture Facility Project disponível neste link.

Trata-se do “Cenário 01” do banco de dados CTU-13, arquivo Netflow capture20110810.pcap.netflow.labeled.

Não entrarei em detalhes de como os dados foram obtidos, mas no link acima há toda explicação de como o experimento foi feito.

Para a reprodução deste trabalho é necessária a instalação, além dos pacotes padrões contidos no Anaconda, alguns adicionais. Para tanto, utilize:

pip install -r requeriments.txt
O repositório com todo o trabalho você encontra aqui.

Com todas as armas prontas, vamos dominar este dragão!

Parte I — Da preparação dos dados até a baseline.

Importação das bibliotecas básicas e carregamento dos dados

import timeimport numpy as npimport pandas as pdimport matplotlib.pyplot as pltimport seaborn as snssns.set()%matplotlib inline

Tendo importado os pacotes básicos, iniciaremos o carregamento dos dados:

scenarioOneData = pd.read_csv(“set1.csv”)

Feito isso, o primeiro passo a ser tomado após a leitura dos dados é a separação dos dados entre treino e teste.

”Idealmente, o modelo deve ser avaliado em amostras de dados que não foram usadas para construir ou ajustá-lo, assim fornecem um senso imparcial de sua eficácia. Quando temos uma grande quantidade de dados disponível, uma certa quantia destes pode ser separada para avaliar o modelo final. Conjunto de dados de “treino” é o termo geral para as amostras usadas para criar e treinar o modelo, enquanto o conjunto de dados de “teste” ou “validação” é usado para qualificar o desempenho”.

Fonte: Max Kuhn and Kjell Johnson, p. 67, Applied Predictive Modeling, 2013 (tradução livre)

from sklearn.model_selection import train_test_splittrain_data, test_data = train_test_split(scenarioOneData, test_size = 0.2, random_state=0)

Para a realização da divisão entre treino e teste utilizei a função train_test_split() do Sklearn. Neste trabalho eu atribuí o tamanho dos dados de teste em 20% devido a grande quantidade disponível (quase 3 milhões de observações).

Notem que nesta separação não ainda não defini os alvos (y_train, y_test). Isso porque, como verão, será necessário o tratamento dos dados e para isso criarei uma única função de preprocessamento.

Exploração dos dados

Com os dados carregados e separados é importantíssimo conhecê-los.

Alguns dos benefícios que se obtém explorando os dados são:

- Você ganhará dicas valiosas para a limpeza dos dados;

- Você terá ideias para a engenharia de variáveis (feature engineering);

- Facilitará a comunicação dos resultados o que poderá causar grande impacto.

Existem inúmeras técnicas e procedimentos que podem ser implementados nesta fase. Contudo, você precisa apenas de algumas delas para conhecer os dados suficientemente afim trabalhar com eles.

Posso citar:

- Estatísticas básicas, tais como: média, mediana, contagem de observações, valores mínimos e máximos, etc;

- Exibir uma amostra dos dados através de uma tabela do Pandas;

- Criar gráficos demonstrando as distribuições das variáveis numéricas e categóricas;

- Estudo de Correlação.

Algumas perguntas a serem respondidas nesta fase:

- Quantas observações eu tenho?

- Quantas variáveis?

- Quais são os tipos de dados das minhas variáveis? Numéricas? Categóricas?

- Eu já tenho uma variável alvo?

- As colunas fazem sentido?

- Os valores fazem sentido?

- Os valores estão na escala correta?

- Existem dados faltantes?

Vamos verificar a quantidade de dados e variáveis:

train_data.shape — Out: (2259708,15)

Temos, portanto, 2.259.708 observações e 15 colunas no conjunto de treino.

Vamos visualizar as 10 primeiras observações da tabela de dados:

Logo no primeiro contato com os dados podemos observar que se tratam de dados brutos, com variáveis nos mais diversos formatos, havendo, portanto, a necessidade deixá-las em um formato que o modelo possa entender.

Assim, as variáveis categóricas que estão em formato de texto deverão ser transformadas em númericas, são elas: Proto, Dir e State.

Quanto ao alvo (Label), também deverá passar pelo processo de tratamento, teremos que mapear as classes positiva e negativa e transformá-las em formato binário (0 e 1).

Mas antes disso, o ideal é conhecer o que cada variável representa para poder realizar alguma engenharia (fazer combinações entre elas é um exemplo).

Nesse momento, o trabalho conjunto entre o Cientista de Dados e o profissional da área é muito importante. Digo isso porque é ele quem poderá confirmar se as variáveis do modelo (existentes e as objeto de engenharia) fazem algum sentido na produção.

Um exemplo disso é a variável SrcAddr(endereço IP da máquina de origem). Como estes dados foram gerados em um experimento, a utilização dessa variável falhará miseravelmente. Ela trará resultados excepcionais, pois a origem dos ataques são de um único IP: o 147.32.84.165. Contudo, em produção, não iria funcionar porque os ataques normalmente vem de máquinas diferentes com IPs diferentes.

No período em que fiquei trabalhando neste projeto, pude interpretar as variáveis como:

- StartTime — Data e hora do início do tráfego;

- Dur — Duração total do tráfego de dados;

- Proto — Protocolo utilizado;

- SrcAddr — Endereço IP da máquina origem;

- Sport — Porta de conexão da máquina origem;

- DstAddr — Endereço IP da máquina destino;

- State— Estado da conexão;

- Dport — Porta de conexão da máquina destino;

- TotPkts — Total de pacotes de dados enviados;

- TotBytes — Total dos dados trafegádos;

- ScrBytes — Total de dados da origem.

Quanto às variáveis sTos, dTos, mesmo após uma pequena pesquisa, não consegui encontrar uma definição objetiva para trazer neste artigo. E, como veremos ao final deste projeto, estas duas variáveis não são representativas, então podemos deixar de investigá-las mais aprofundadamente.

Peço perdão se cometi algum equívoco, meu conhecimento sobre arquitetura de rede e suas terminologias é bem limitado.

Tendo uma ideia do que cada variável representa, podemos descartar então: StartTime, ScrAddr, Sport, DstAddr, Dport.

Percebam que todas estas variáveis descartadas estão relacionadas ao fato dos dados terem sido coletados em um teste e, portanto, são enviesados. Assim, a data e horário, os IPs e as portas das máquinas não ajudarão a generalizar o modelo, e, sim, levarão ao overfitting.

Seguindo com a análise exploratória, vamos verificar a existência de valores faltantes (missing values):

train_data.isnull().sum()

Analisando os resultados notamos a ausência de valores em Sport, Dport, State, sTos e dTos.

É interessante sempre procurar entender o porquê da existência de missing values. Mais uma vez é importante o trabalho em conjunto com o profissional da área. Desta forma, tendo o conhecimento do processo todo poderemos saber se a ausência do valor é uma falha na imputação/coleta dos dados ou se alguma situação em especial faz com que o valor não seja gerado.

Sabendo da existência de missing values nos nossos dados, o que devemos fazer é tratá-los de alguma forma.

Existem diversos métodos para esse tratamento, desde o preechimento com a média, moda ou mediana dos valores da variável, descarte total da variável caso a quantidade de valores faltantes seja significativa e até métodos mais complexos como uso de algorítmos e modelos de Machine Learning para prever quais seriam os valores faltantes.

O que optei em utilizar aqui foi preencher os valores faltantes com valores diferentes dos encontrados na variável. Deste modo o modelo entende que tais valores não pertencem a nenhuma outra classe existente. Usualmente utiliza-se o valor -1.

Notem que algumas das variáveis com valores faltantes estão no rols das que serão descartas, portanto, podemos ignorá-las.

Preprocessamento e Feature Engineering

O primeiro passo que faremos no preprocessamento dos dados será mapear o alvo, ou seja, transformá-lo em formato binário.

Ao todo existem 110 categorias diferentes dentro do alvo.

train_data[‘Label’].value_counts()

No entanto, existe um padrão nas observações positivas. Todas elas possuem o termo Botnet.

Assim, todas as categorias que contiverem a expressão Botnet serão convertidas para a label 1 e o restante delas para a label 0.

Para realizar esta tarefa criei a seguinte função, a qual será inserida na função de preprocessamento:

def mapLabel(inputString):    if “Botnet” in inputString:        return 1    else:       return 0

A função de preprocessamento que será criada englobará o descarte das variáveis, o preenchimento dos valores faltantes, o mapeamento do alvo e a criação de novas variáveis (Feature Engineering).

Para auxiliar no melhoramento do modelo, o Cientista de Dados pode ou, até mesmo, deve abusar da sua criatividade criando novas variáveis. A este processo chamamos de feature engineering.

Em uma definição mais adequada dizemos que:

Feature engineering é o processo de formulação das features (variáveis) mais adequadas de acordo com os dados, o modelo e a tarefa”.

Fonte: Alice Zheng and Amanda Casari, p. 3, Feature Engineering for Machine Learning Principles and Techniques for Data Scientists, 2018. (tradução livre)

Neste trabalho resolvi criar algumas variáveis bem simples utilizando os dados existentes.

Foram elas:

- Média de bytes por pacote: TotBytes / TotPkts

- Média de bytes por pacote da fonte: SrcBytes / TotPkts

- Média de pacotes por unidade de tempo: TotPkts / Dur

- Média de bytes por unidade de tempo: TotBytes / Dur

- Média de bytes da fonte por unidade de tempo: SrcBytes / Dur

Percebam que aqui a criação destas variáveis se deu antes mesmo de testar o modelo pela primeira vez. Contudo, quando trabalhei neste projeto o modelo foi treinado com as variáveis originais e depois inseri as novas.

É recomendado não pular este passo, uma vez ele serve de comparação de desempenho. Como o modelo se comportou apenas com o conjunto original e como foi após a adição das variáveis? O modelo melhorou?

Lembre-se que um modelo com menos variáveis requer menos poder computacional para o treino e ainda tende a reduzir o overfitting.

Notem que ainda resta um passo para que os dados estejam prontos para serem treinados. Este passo é tratar as variáveis categóricas.

Para isso usaremos a função OrdinalEncoder da biblioteca Category Encoders.

from category_encoders.ordinal import OrdinalEncoder

E por que utilizar Ordinal Encoder em vez de One Hot Encoder?

Pra quem não sabe a diferença entre eles, o One Hot Encoder transforma cada categoria em uma variável binária (dummy), indicando se a observação pertence ou não àquela categoria. O Ordinal Encoder, por outro lado, transforma as categorias em números ordinais sequenciais (1,2,3,…,n) dentro de uma mesma variável.

Cada método possui prós e contras dependendo da situação e do modelo.

O primeiro, em caso de um número muito grande de categorias, como é o caso em manipulações de textos, termina-se com uma grande quantidade de variáveis no modelo, o que, como já mencionei, demanda grande poder computacional, mais precisamente, de memória disponível.

O segundo, por sua vez, pode não ser interpretado adequadamente em certos modelos.

Um exemplo disso são os modelos lineares.

Como este Encoder gera números ordinais sequenciais, e os modelos lineares entendem que valores mais altos tem peso maior que valores mais baixos, as categorias ficam todas desbalanceadas. Como o intuito é apenas diferenciar uma categoria da outra e não dar importância diferente a elas, o uso nesse caso não é recomendado.

Modelos de árvore como o LightGBM (que será utilizado aqui) e a Random Forest, no entanto, lidam muito bem com este tipo de Encoder. Desta maneira, não há a necessidade de criar inúmeras variáveis dummies para o modelo interpretar corretamente.

Uma das coisas legais desse pacote Category Encoders é poder passar uma lista de variáveis a serem tranformadas dentro da função:

OrdinalEncoder(cols=[‘Proto’, ‘Dir’, ‘State’])

Fica a recomendação para estudarem a documentação do Category Encoders. Lá vocês poderão encontrar cerca de 15 tipos encoders, não só os mais comuns apresentados aqui. link

Finalizados todos os passos do preprocessamento, a função principal ficou assim:

def preprocessing(data, train_data=True):# Drop featuresdata = data.drop([‘StartTime’, ‘SrcAddr’, ‘Sport’, ‘DstAddr’,   ‘Dport’], axis=1)
# Fill all NaN with -1.
data[‘dTos’] = data[‘dTos’].fillna(-1)data[‘sTos’] = data[‘sTos’].fillna(-1)data[‘State’] = data[‘State’].fillna(‘None’)
# Create some features
data[‘TotBytes_over_TotPkts’] = data[‘TotBytes’] / data[‘TotPkts’]data[‘SrcBytes_over_TotPkts’] = data[‘SrcBytes’] / data[‘TotPkts’]data[‘TotPkts_over_Dur’] = data[‘TotPkts’] / data[‘Dur’]data[‘TotBytes_over_Dur’] = data[‘TotBytes’] / data[‘Dur’]data[‘SrcBytes_over_Dur’] = data[‘SrcBytes’] / data[‘Dur’]
# Map label
def mapLabel(inputString):
if “Botnet” in inputString:return 1else:return 0data[“Label”] = data[“Label”].apply(mapLabel)
# Store the labels
y = data[‘Label’]
# Drop the label
X = data.drop([‘Label’], axis=1)encoder = OrdinalEncoder(cols=[‘Proto’, ‘Dir’, ‘State’])if train_data: X = encoder.fit_transform(X)else: X = encoder.tranform(X)
return X, y

Após passar os dados pela função preprocessamento eles estão prontos para ir ao modelo. Vejamos:

Mas antes disso precisamos regressar mais uma vez à análise exploratória.

Agora com os dados tratados e mapeados poderemos responder a uma pergunta muito importante:

Como é a distribuição das classes positiva e negativa (malware e não-malware)?

Na classe positiva temos apenas 1.4491% das observações e na negativa 98.5509%.

Notem que estamos diante de um cenário muito comum em Machine Learning, que é classes desbalanceadas.

Nesse caso, a classe minoritária é mais difícil de prever, porque há poucos ou muito menos exemplos dela. Isso significa que é mais desafiador para um modelo aprender as características dos exemplos minoritários e diferenciá-los da classe majoritária.

Consequentemente, este cenário pode ser um problema caso não tratado de maneira correta.

O que precisa ser feito é informar ao modelo que ele deverá atribuir maior importância à classe minoritária e isso será feito quando o modelo for treinado.

Quando não está disponível nos parâmetros do modelo a opção de informar o peso das classes, faz necessário o emprego de técnicas como Oversampling, Undersampling e ou até mesmo a criação de observações sintéticas, mas este tópico foge do propósito deste artigo.

Baseline

O primeiro passo para avaliação do modelo e, consequentemente, de todo o trabalho é o estabelecimento de uma baseline, ou seja, de um valor base de avaliação.

Isso é de extrema importância, pois servirá como ponto de referência para sabermos se estamos progredindo e se os resultados obtidos são bons o suficiente.

Para ilustrar o que seria uma baseline em um problema de previsão de demanda, por exemplo, poderia ser a média simples das vendas dos períodos anteriores.

Se o modelo treinado não for capaz de produzir erros menores que os produzidos pela baseline, então ele não foi capaz de aprender e não deverá ser posto em produção.

Para modelos de classificação como este, as baselines mais comuns são:

- Gerar previsões usando a classe dominante, ou seja, prever que todas as observação serão negativas/não-malware. (Zero Rule Algorithm)

- Gerar previsões aleatórias uniformes com as duas classes. (Random Prediction Algorithm)

- Gerar previsões aleatórias estratificadas com as duas classes.

- Utilizar modelos lineares ou até mesmo o modelo que se pretende treinar right of the box, ou seja, sem nenhum ajuste.

Entendo que a escolha baseline deve levar em conta a métrica mínima aceitável para o problema, a solução atual (se existente) e lógica.

A métrica mínima aceitável, na minha opinião, anda junto com a solução atual. Se não existe, por exemplo, uma solução implementada para detectar o malware, a métrica mínima seria não detectar nenhum, assim a baseline poderia ser a primeira opção.

Adicionando um pouco de lógica e estatística no exemplo anterior, sabemos que 1,4491% do tráfego era de malwares, assim, poderíamos utilizar a terceira opção (aleatótia estratificada). Este método geraria previsões aleatórias com 1,4491% de probabilidade de ser malware.

No caso de existir alguma ferramenta de detecção, a baseline poderia ser as métricas alcançadas por este sistema. Nesse caso a proposta do modelo de Machine Learning seria o melhoramento ou a subtituição do sistema atual.

Para este trabalho vamos utilizar o método aleatório estratificado.

Todos os três primeiros métodos e alguns outros podem ser gerados através do DummyClassifier do Scikit Learn.

Estabelecida nossa baseline, as métricas obtidas foram as seguintes:

- AUC: 0.499

- Average Precision: 0.014

- Precision: 0.012

- Recall: 0.011

Abordarei um pouco sobre cada uma dessas métricas na Parte II desta série.

Após isso, nós chegamos à primeira parada da jornada pelo processo completo de resolução de problema com Machine Learning.

O que fizemos até aqui:

  1. Instalamos as bibliotecas adicionais necessárias (LightGBM, Category Encoders, Scikit-Optmize e SHAP).
  2. Realizamos o carregamento e a exploração básica necessária dos dados.
  3. Verificamos a necessidade de tratamento dos dados e fizemos:
  • O preenchimentos dos valores faltantes (missing values);
  • Descarte de variáveis com dados enviesados;
  • Tratamento das variáveis categóricas com Category Encoder;
  • Mapeamento do alvo.

4. Criamos algumas variáveis e abordamos Feature Engineering.

5. Definimos a baseline do projeto.

O que está por vir:

  1. Criação do primeiro modelo.
  2. Aperfeiçoando o modelo com Scikit-Optmize.
  3. Treinamento do modelo final.

Espero que a leitura tenha sido proveitosa, até o próximo capítulo.

--

--