ML.NET: Inteligência Artificial (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.

Nesta publicação, usaremos IA 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 megaevoluções e todos os Pokémon até a região de Kalos (dos jogos Pokémon X & Y). Verifique o formato dos dados na imagem a seguir:

Agora, criaremos 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 neste post aqui e depois volte para cá!

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

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, criaremos nosso próprio tipo para Pokémon, obtendo apenas as propriedades que forem necessárias para nossa exploração.

Para isso, criaremos 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 usaremos, poderemos precisar de casas decimais.

Agora, voltaremos para a função main e transformaremos 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. 1
                        Type2 = pokemon. 2
                        HP = (single) pokemon.HP
                        Attack = (single) pokemon.Attack
                        SpAttack = (single) pokemon.. Atk
                        Defense = (single) pokemon.Defense
                        SpDefense = (single) pokemon.. Def
                        Speed = (single) pokemon.Speed
                        Total = (single) pokemon.Total
                    })

Agora, como estamos utilizando o ML.NET, precisamos criar nosso contexto de machine learning, pois sem ele não é possível carregar os nossos dados para treinamento:

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

Nesse ponto, já preparamos nossos dados. Agora, é hora de entender o que faremos e o que estamos tentando descobrir nesta base!

Clusterização

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

O que fazer, então?

Utilizaremos 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 em 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 separará os dados na quantidade de clusters que informarmos. Para fazer isso, será gerado um centroide com uma média de todas as características determinadas.

Depois disso, a distância de cada registro para cada centroide é calculada. No fim desse cálculo, o registro fará parte do cluster que tiver o centroide 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 centroides dos clusters. Para o nosso problema, usaremos 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.

Começaremos criando uma nova propriedade no tipo Pokémon:

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

Agora, criaremos 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. 1
                        ConvertedType1 = (typeToSingle pokemon. 1)
                        Type2 = pokemon. 2
                        ConvertedType2 = (typeToSingle pokemon. 2)
                        //...
                    })

Feito!

Agora já podemos voltar ao processo de treinamento do algoritmo e informar todas 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 por meio 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.

Voltaremos ao nosso arquivo Types.fs e criaremos o tipo para a saída do algoritmo. Ele deve conter o cluster e a distância para todos os centroides:

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, percorreremos nossa base e verificaremos 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á o nosso trabalho, tendo uma coleção unificando com os dois valores.

Vamos simplesmente exibir o resultado no console, mas faremos 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:

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, utilizando 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 abri-lo no Excel, realizar filtros e fazer qualquer tipo de operação:

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

Não precisamos de muito tempo para verificar que todos os Pokémons do cluster 1 não são, necessariamente, poderosos. Talvez os mais poderosos que tenham 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, e 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. Chamaremos esse cluster de “Pokémons mais fracos”.

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

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

Por fim, temos o cluster dos Pokémons mais poderosos! Esse cluster tem uma média de 622.57. Ele é formado praticamente por lendários, pseudolendários (como o Dragonite) e megaevoluções:

Note que existem alguns outliers, como o Arcanine, por exemplo. Ele tem uma média relativamente menor do que os demais, mas acaba estando mais próximo desse 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 três dimensões: X e Y representam o tipo do Pokémon e Z representa o total dos atributos.

Para fazer esse gráfico, instalaremos o pacote nuget: Xplot.Plotly.

PM> Install-Package Xplot.Plotly

Agora, precisamos criar os dados para nosso gráfico. Podemos fazer isso por meio de uma list comprehension, em que iremos iterar por todos os clusters, 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émons de cada um, mas o que precisamos retornar no yield?

Precisamos de um valor do tipo Scatter3d, pois 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émons da primeira geração) a seguir:

Optei por colocar apenas os Pokémons 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.

O post de hoje termina por aqui.

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

Até mais!


Autor:

Mestre em Computação Aplicada com foco em inteligência artificial. Microsoft MVP e programador apaixonado. Entusiasta de programação funcional, do uso de tecnologia como empoderamento das pessoas e autor dos livros: 1. Programação funcional em .NET: Explore um novo universo; 2. Guia Prático para Iniciantes em Python 3. Microsoft Kinect: Crie aplicações interativas 4. Criando aplicações para o seu Windows Phone (Silverlight & Runtime Edition