ML .NET: IA para ajudar o Detetive Pikachu com Descobertas sobre Pokémon

Olá pessoa!

Que tal ajudarmos o detetive Pikachu em uma investigação usando IA para descobertas sobre Pokémon?

Este post faz parte de uma série sobre o ML.NET! Para visualizar a série inteira clique aqui

Nessa publicação vamos usar inteligência artificial para ajudar o detetive Pikachu a investigar uma base sobre pokémon de diferentes regiões.

A base de dados que usaremos com as características dos pokémon foi encontrada no Github do usuário armgilles. Essa base contém os atributos base: HP, Attack, Sp. Attack, Defense, Sp. Defense e Speed. Além disso, contém outras informações como: nome, número, tipos, região e identificação de lendários.

Essa base contém as mega evoluções e todos os pokémon até a região de Kalos (dos jogos Pokémon X & Y), você pode verificar o formato dos dados na imagem abaixo:

Pokemon.csv

Agora vamos criar nosso projeto F# para importar esses dados usando Type Providers!

Crie um projeto do tipo Console em F# normalmente e como quase sempre fazemos por aqui, instale o pacote FSharp.Data:

PM> Install-Package FSharp.Data

Se você nunca utilizou o CsvProvider sugiro que dê uma olhadinha nesse post aqui) e depois volte pra cá!

Assim como fizemos no post anterior de ML.NET, vamos fazer o download do arquivo CSV e incorporá-lo no projeto, conforme imagem a seguir:

Pokemon.csv no projeto

Lembre-se também de alterar a propriedade Copy to Output Directory para Copy if newer.

Feito isso, vamos carregar nosso CSV:

[<Literal>]
let url = "Dataset/pokemon.csv"
type PokemonCSV = CsvProvider<url>

[<EntryPoint>]
let main argv =
    let allPokemon = PokemonCSV.Load url

Simples assim!

Agora vamos criar o nosso próprio tipo para Pokémon, obtendo apenas as propriedades que forem necessárias para nossa exploração.

Para isso, vamos criar um novo arquivo chamado Types e incluir o nosso tipo lá:

module Types

type Pokemon = {
    Number : single
    Name : string
    Type1: string
    Type2 : string
    Total: single
    HP : single
    Attack : single
    Defense : single
    SpAttack : single
    SpDefense : single
    Speed : single
}   

Note que estamos alterando o tipo de todas as propriedades básicas para single, isso porque nos cálculos que vamos usar poderemos precisar de casas decimais.

Agora vamos voltar para a função main e transformar os dados carregados do CSV para valores do nosso domínio. Para isso, basta utilizarmos um map, conforme código:

let listOfPokemon = allPokemon.Rows
                    |> Seq.map (fun pokemon -> {
                        Number = (single) pokemon.Number
                        Name = pokemon.Name 
                        Type1 = pokemon.``Type 1``
                        Type2 = pokemon.``Type 2``
                        HP = (single) pokemon.HP
                        Attack = (single) pokemon.Attack
                        SpAttack = (single) pokemon.``Sp. Atk``
                        Defense = (single) pokemon.Defense
                        SpDefense = (single) pokemon.``Sp. Def``
                        Speed = (single) pokemon.Speed
                        Total = (single) pokemon.Total
                    })

Agora como estamos utilizando o ML.NET, precisamos criar nosso contexto de machine learning, sem ele não conseguimos nem carregar os nossos dados para treinamento:

let mlContext = new MLContext();
let data = listOfPokemon
            |> mlContext.Data.LoadFromEnumerable

Neste ponto já preparamos nossos dados, agora é hora de entender o que vamos fazer e o que estamos tentando descobrir nessa base!

Clusterização

Diferente da classificação de comentários feito anteriormente, não temos nenhuma classificação previamente feita em nossa base de dados. Isso implica que não temos a resposta para treinar o modelo.

O que fazer então?

Vamos utilizar o que chamamos de aprendizado não supervisionado. Isso significa que executaremos um algoritmo que precisará encontrar a relação entre os dados por si só. Para fazer isso precisamos informar quantos grupos (clusters) queremos classificar a base e quais são os parâmetros para localizar a similaridade entre os grupos.

De forma bastante simplista, o algoritmo irá separar os dados na quantidade de clusters que informarmos. Para fazer isso será gerado um centróide com uma média de todas as características determinadas.

Depois disso, a distância de cada registro para cada centróide é calculada. No fim desse cálculo, o registro fará parte do cluster que possui o centróide mais próximo.

Para fazer essa clusterização, usaremos o algoritmo K-Means.

K-Means no ML.NET

Como já dito, precisamos escolher as informações para calcular os centróides dos clusters. Para nosso problema, vamos usar os tipos e atributos base.

Só temos um pequeno problema, os tipos são do tipo string e o restante dos valores são single. Precisamos transformar os tipos em um valor single também, dessa forma todas as informações (features) utilizadas serão do mesmo tipo.

Vamos começar criando uma nova propriedade no tipo Pokemon:

type Pokemon = {
    //...
    Type1: string
    Type2 : string
    ConvertedType1:single
    ConvertedType2:single
    //...
}

Agora vamos criar um método para converter o tipo de string para single. Basta fazermos um pattern matching para isso:

let typeToSingle pkmnType = 
    match pkmnType with
    |"Bug" -> 0
    |"Dark"-> 1
    |"Dragon"-> 2
    |"Electric"-> 3
    |"Fairy"-> 4
    |"Fighting"-> 5
    |"Fire"-> 6
    |"Flying"-> 7
    |"Ghost"-> 8
    |"Grass"-> 9
    |"Ground"-> 10
    |"Ice"-> 11
    |"Normal"-> 12
    |"Poison"-> 13
    |"Psychic"-> 14
    |"Rock"-> 15
    |"Stell"-> 16
    |"Water"-> 17
    | _ -> -1

    |> (single)

Pronto! Agora já podemos preencher esse valor no momento que carregamos o CSV:

//...
let listOfPokemon = allPokemon.Rows
                    |> Seq.map (fun pokemon -> {
                        //...
                        Type1 = pokemon.``Type 1``
                        ConvertedType1 = (typeToSingle pokemon.``Type 1``)
                        Type2 = pokemon.``Type 2``
                        ConvertedType2 = (typeToSingle pokemon.``Type 2``)
                        //...
                    })

Feito!

Agora já podemos voltar ao processo de treinamento do algoritmo e informar todos as propriedades que serão utilizadas para fazer a clusterização.

Faremos isso criando um EstimatorChain que deve criar uma coluna chamada “Features” com todas as propriedades, veja:

let pipeline = EstimatorChain().Append(
                           mlContext.Transforms.Concatenate( "Features", 
                               "ConvertedType1","ConvertedType2",
                               "HP","Attack","SpAttack", 
                               "Defense", "SpDefense", "Speed","Total" ))  

Essa é a hora de criarmos o algoritmo que treinará o modelo, para isso, precisamos indicar o número de clusters e o campo com as features que acabamos de criar:

let options = Trainers.KMeansTrainer.Options()
options.NumberOfClusters <- 3
options.FeatureColumnName <- "Features"
    
let trainer = mlContext.Clustering.Trainers.KMeans options

Neste ponto, para criar o modelo basta unirmos a pipeline criada anteriormente com o nosso algoritmo de treino:

let pipelineTraining = pipeline.Append trainer
let model = pipelineTraining.Fit data

Agora já podemos utilizar nosso modelo? -Quase.

Precisamos criar um PredictionEngine. Ele pode ser gerado através de nosso modelo, no entanto, para criarmos um motor de predição precisamos informar o tipo usado como entrada e o tipo de saída.

Vamos voltar no nosso arquivo Types.fs e criar o tipo para a saída do algoritmo, ele deve conter o cluster e a distância para todos os centróides:

open Microsoft.ML.Data

//...

[<CLIMutable>]
type ClusterPrediction = {
    [<ColumnName("PredictedLabel")>] 
    PredictedClusterId : uint32

    [<ColumnName("Score")>] 
    Distances : single array
}

Veja que estamos utilizando as anotações do ML.NET para indicar os valores resultantes.

Voltando para a função main, já podemos criar nosso motor de predição:

let predictiveModel = 
    mlContext.Model.CreatePredictionEngine<Pokemon, ClusterPrediction>(model)

Agora que temos nosso motor de predição, vamos percorrer nossa base e verificar o resultado para cada registro:

let clusterizedList = 
    listOfPokemon
    |> Seq.map(fun pokemon -> pokemon,(predictiveModel.Predict pokemon))

Note que criamos uma sequence agrupando o pokémon e seu resultado em uma tupla. Isso facilitará nosso trabalho tendo uma coleção unificando com os dois valores.

Vamos simplesmente exibir o resultado no console, mas vamos fazer isso em um formato de CSV:

printfn "Number; Name; Total; Type1; Type2; Cluster"

clusterizedList
|> Seq.iter(fun (pkmn, result) -> printfn "%i;%s;%i;%s;%s;%i" 
                                            ((int)pkmn.Number) 
                                            pkmn.Name 
                                            ((int)pkmn.Total)
                                            pkmn.Type1
                                            pkmn.Type2
                                            result.PredictedClusterId

Simples, não?

Ao executar já podemos conferir o resultado, veja:

Pokemon no Console

Podemos exportar o resultado do console para um arquivo CSV, dessa forma fica mais fácil de visualizarmos os dados. Faremos isso da forma mais simples e sem precisar alterar nosso código, vamos utilizar o prompt de comandos do Windows.

Primeiro navegue até o diretório do projeto e execute o projeto redirecionando o console para o arquivo:

DIRETORIO DO PROJETO> dotnet run PokeClustering >pkmn.csv

Feito isso temos um arquivo CSV pronto para visualizarmos. Podemos abrir no Excel, realizar filtros e fazer qualquer tipo de operação:

Pokemon no CSV

Ao realizar filtros por cluster fica bastante claro o tipo a classificação, veja os primeiros registros do Cluster 1:

Pokemon no CSV - Cluster 1

Não precisamos de muito tempo para verificar que todos os pokémon do cluster 1 não são necessariamente poderosos. Talvez os mais poderosos que tenha aparecido nesses registros sejam mesmo o Pidgeotto e o grande Pikachu.

Note que eu digo talvez, mesmo com o status base deles sendo claramente maior. Isso porque nossos dados são uma versão resumida dos monstrinhos, não estamos levando em consideração a lista de movimentos nem o nível necessário para evolução, por exemplo.

A média do total dos atributos do cluster 1 ficou em: 303.90, sendo a menor média de todos os clusters. Vamos chamar este cluster de “Pokémon mais fracos”.

Vamos primeiro para o terceiro cluster, afinal ele é o cluster intermediário.

Pokemon no CSV - Cluster 2

Note que podemos ver as evoluções dos pokémon iniciais e até uma mega evolução (Mega Beedrill). A média deste cluster ficou em: 472.97.

Por fim, temos o cluster dos pokémon mais poderosos! Esse cluster possui uma média de 622.57. Ele é formado praticamente por lendários, pseudo-lendários (como o Dragonite) e Mega evoluções:

Pokemon no CSV - Cluster 3

Note que existem alguns outliers, como o Arcanine, por exemplo. Ele possui uma média relativamente menor do que resto, mas acaba estando mais próximo deste cluster do que do anterior.

Já conseguimos visualizar como o agrupamento funcionou nesta planilha, mas que tal gerarmos um gráfico completo?

Visualização dos dados

Não vamos conseguir gerar um gráfico usando todas as features utilizadas para a clusterização, isso porque o gráfico teria dimensões demais para ser visualizado. Mas podemos ter um resultado muito semelhante em um gráfico de 3 dimensões: onde X e Y representam o tipo do pokémon e Z representa o Total dos atributos.

Para fazer esse gráfico vamos instalar o pacote nuget: Xplot.Plotly.

PM> Install-Package Xplot.Plotly

Agora precisamos criar os dados para nosso gráfico, podemos fazer isso através de uma list comprehension, onde iremos iterar por todos os cluster, gerando a série de dados:

let chartData = [
    for cluster in 1..options.NumberOfClusters do
        let pkmn = clusterizedList 
                    |> Seq.filter( fun (pkmn, result) -> result.PredictedClusterId = (uint32) cluster)

        yield //...
]

Ok, estamos percorrendo os clusters e filtrando os pokémon de cada um, mas o que precisamos retornar no yield?

Precisamos de um valor do tipo Scatter3d, nele podemos definir quais valores serão usados em quais eixos, qual valor deve ser utilizado para o label e até o tamanho do ponto no gráfico.

let chartData = [
    for cluster in 1..options.NumberOfClusters do
        let pkmn = clusterizedList 
                    |> Seq.filter( fun (pkmn, result) -> result.PredictedClusterId = (uint32) cluster)

        yield Scatter3d(
                x = (pkmn |> Seq.map( fun (pkmn, result) -> pkmn.ConvertedType1)),
                y = (pkmn |> Seq.map( fun (pkmn, result) -> pkmn.ConvertedType2)),
                z = (pkmn |> Seq.map( fun (pkmn, result) -> pkmn.Total)),
                text = (pkmn |> Seq.map( fun (pkmn, result) -> pkmn.Name)),
                mode = "markers",
                marker =
                    Marker(
                        size = 12.,
                        opacity = 0.8
                    )
        )
]

Pronto, agora é só configurarmos algumas coisinhas e realizarmos a plotagem:

let chartOptions = Options ( title = "Pokémon Cluster")
    
let chart = 
    chartData
    |> Chart.Plot
    |> Chart.WithOptions chartOptions
    |> Chart.WithHeight 600
    |> Chart.WithWidth 800
    |> Chart.WithLabels ["Pokémon mais fracos"; "Pokémon muito poderosos";"Pokémon"]

Note que nos labels estou nomeando os clusters para facilitar a visualização. Agora com o mesmo snippet que sempre utilizamos podemos fazer o gráfico ser gerado e visualizado no Chrome:

let html = chart.GetHtml()
File.AppendAllLines ("metrics.html",[html])

Process.Start (@"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", 
               "file:\\" + Directory.GetCurrentDirectory() + "\\metrics.html")
               |> ignore

Você pode conferir o resultado do gráfico gerado (apenas para os pokémon da primeira geração) abaixo:

Atenção

Como você está em um dispositivo móvel a visualização do gráfico pode ser comprometida, acesse este link para ver o gráfico em tela cheia.

Optei por colocar apenas os pokémon da primeira região para facilitar a visualização, caso esteja interessado, você pode visualizar o gráfico com todos eles aqui.

Com isso utilizamos K-means com ML.NET para realizar a clusterização e análise de uma base Pokémon!

Atenção

Você pode verificar o código fonte completo aqui e os arquivos resultantes (modelo e CSV) aqui.

Bom, o post de hoje termina por aqui aqui.

Espero que tenham gostado, qualquer dúvida, correção ou sugestão, deixem nos comentários!

Até mais.

Sempre vale lembrar que as informações e textos aqui no blog representam minha opinião pessoal, o que pode não ser igual à sua ou de qualquer outra pessoa, incluindo a empresa para qual eu trabalho. Portanto as publicações inseridas aqui estão relacionadas somente a mim.

Assine a Newsletter