Janeiro de 2019

Volume 34 – Número 1

[.NET]

Machine Learning por meio da programação probabilística

Por Yordan Zaykov | Janeiro de 2019

Programação que é probabilística? Sério? Isso não faz muito sentido... Ou é o que eu pensava quando comecei a trabalhar nesse domínio. Os pesquisadores que eu acompanhava não tinham a visão tradicional de colocar problemas de aprendizado de máquina (ML) em categorias. Em vez disso, eles apenas expressavam os processos do mundo real que levavam à geração de seus dados em um formato que podia ser lido pelo computador. Isso é o que eles chamam de modelo e sua representação: um programa probabilístico.

O paradigma é bem interessante. Primeiro, você não precisa aprender as centenas de algoritmos de ML disponíveis por aí. Basta aprender como expressar seus problemas em um programa probabilístico. Isso envolve algum conhecimento de estatística, já que você está modelando a incerteza do mundo real. Digamos, por exemplo, que você deseja prever o preço de uma casa e que é, digamos, uma combinação linear de alguns recursos (localização, tamanho e outros). Seu modelo representa que o preço é a soma dos produtos de cada recurso e algum peso, com ruído adicionado. Isso, por acaso, se chama regressor linear. Em resumo:

For each house:
  score = Weights * house.Features;
  house.Price = score + Noise;

Você pode descobrir os pesos durante o treinamento e usá-los diretamente na previsão. Se você fizer uma pequena mudança no modelo para ter a pontuação com ruído no final limitada em relação a zero, o novo rótulo só poderá ser uma de duas classes. Você acabou de modelar um classificador linear binário, talvez sem saber que esse era o nome.

Em segundo lugar, não é necessário tentar adaptar seu problema e seus dados a um dos algoritmos de ML existentes. Isso deveria ser óbvio: você projetou o modelo para seu problema, ou seja, ele se molda aos seus dados. As ferramentas de programação probabilística modernas podem gerar um algoritmo de ML automaticamente a partir do modelo que foi especificado, usando um método de inferência de uso geral. Não é necessário saber muito sobre ele, pois ele já foi implementado para você. Assim, um modelo específico de um aplicativo combinado com um método de inferência de uso geral gera um algoritmo de ML específico para um aplicativo.

Por fim, a abordagem parece resistir ao tempo. A maioria dos algoritmos de ML bem-sucedidos se enquadram neste paradigma: um modelo, representando um conjunto de suposições, mais um método de inferência, que faz os cálculos. A metodologia evoluiu ao longo do tempo. Hoje em dia, as redes neurais profundas estão na moda; o modelo é uma composição de funções lineares com limite, e o método de inferência é chamado de GDE (gradiente descendente estocástico). Modificar a estrutura da rede para ser recorrente ou convolucional, ou seja, alterar o modelo, permite apontar para aplicativos diferentes. Mas o método de inferência pode permanecer o mesmo, GDE, como mostrado na Figura 1. Assim, embora a escolha do modelo tenha evoluído ao longo dos anos, a abordagem geral de projetar um modelo e aplicar um método de inferência permaneceu constante.

Modelos diferentes para aplicativos diferentes, todos usando o mesmo método de inferência
Figura 1 Modelos diferentes para aplicativos diferentes, todos usando o mesmo método de inferência

Bom, a tarefa do desenvolvedor é criar um modelo para seu aplicativo. Vamos aprender a fazer isso.

Olá, Mundo Incerto!

O primeiro programa que todo mundo cria em uma nova linguagem é “Olá, Mundo”. O equivalente em um cenário probabilístico é, claro, “Olá, Mundo Incerto”. Pense no programa probabilístico como um simulador ou uma amostra de dados. Vou começar com alguns parâmetros e usá-los para gerar dados. Por exemplo, vamos usar duas cadeias de caracteres que são completamente desconhecidas. Ou seja, elas podem ser qualquer cadeia de caracteres ou, em termos mais estatísticos, são variáveis aleatórias em cadeia retiradas de uma distribuição uniforme. Vou concatená-las com um espaço no meio e restringir o resultado para ser a cadeia de caracteres “Olá mundo incerto”:

str1 = String.Uniform()
str2 = String.Uniform()
Constrain(str1 + " " + str2 == "Hello, uncertain world")
Print(str1)
Print(str2)

Esse é um modelo probabilístico: apresentei minhas suposições de como os dados foram gerados. Agora, posso executar um método de inferência que fará os cálculos necessários. O espaço entre as duas cadeias de caracteres pode ser os dois espaços (o que fica entre “Olá” e “mundo” ou o que fica entre “mundo” e “incerto”). Assim, não posso ter certeza do valor de nenhuma das duas variáveis. Ou seja, o resultado é uma distribuição que captura a incerteza sobre os possíveis valores. Str1 pode tanto ser “Olá” ou “Olá mundo” e str2 tem 50% de chance de ser “mundo incerto” e 50% de ser “mundo”.

Um exemplo real

Agora, vamos passar para um exemplo mais realista. A programação probabilística pode ser usada para resolver muitos problemas de ML. Por exemplo, minha equipe desenvolveu um sistema de recomendação um tempo atrás e o colocou no Azure Machine Learning. Antes disso, transformamos um classificador de emails em produto no Exchange. Agora, estamos trabalhando para melhorar a compatibilidade de jogadores no Xbox, atualizando o sistema de classificação de habilidades. Também estamos em vias de desenvolver um sistema para o Bing, que extrai conhecimento automaticamente da Internet modelando texto não estruturado. Tudo isso é conseguido por meio do mesmo paradigma: definir o modelo como um conjunto de suposições representadas em um programa probabilístico e usar um método de inferência de uso geral para fazer os cálculos necessários.

O sistema de classificação de habilidades, chamado TrueSkill, demonstra muitas das vantagens da programação probabilística, incluindo a capacidade de interpretar o comportamento do sistema, de incorporar conhecimentos do domínio no modelo e de aprender com novos dados. Por isso, vamos implementar uma versão simplificada do modelo que está em execução na produção, em títulos populares como “Halo” e “Gears of War”.

Problema e dados O problema a ser resolvido é a classificação dos jogadores nos jogos. Isso tem vários usos, como estabelecer a compatibilidade entre jogadores (que leva a jogos justos e agradáveis pela reunião de jogadores ou equipes com habilidades semelhantes). Para simplificar, vamos assumir que existem apenas dois participantes em cada jogo e que o resultado é uma vitória ou uma derrota, sem empates. Assim, os dados de cada jogo serão os identificadores exclusivos dos dois jogadores e uma indicação de quem venceu. Vou usar um pequeno conjunto de dados feito à mão neste artigo, mas a abordagem pode ser escalada para centenas de milhões de jogos no Xbox.

O que quero fazer é conhecer as habilidades de todos os jogadores e poder prever o vencedor de futuras combinações. Uma abordagem ingênua seria simplesmente contar a quantidade de vitórias e derrotas de cada jogador, mas isso não leva em conta a força dos oponentes nesses jogos. Existe uma maneira melhor.

Design do modelo Vamos começar fazendo suposições sobre como os dados foram gerados. Primeiro, cada jogador tem uma habilidade oculta (ou latente) que nunca foi observada diretamente; só é possível ver os efeitos da habilidade. Vou supor que esse número é real, mas também preciso especificar como ele foi gerado. Uma escolha sensata é ele ter sido gerado com distribuição normal (ou gaussiana). Em um exemplo mais complexo, os parâmetros dessa distribuição gaussiana seriam desconhecidos e poderiam ser aprendidos. Para simplificar, vou defini-los diretamente.

O modelo da habilidade pode ser representado graficamente, como mostra o desenho mais à esquerda na Figura 2, que simplesmente indica que a habilidade de variável aleatória foi retirada de uma distribuição normal.

A composição de um jogo entre duas pessoas
Figura 2 A composição de um jogo entre duas pessoas

Outra suposição que eu posso fazer é que, em cada jogo, os jogadores têm um número de desempenho. Esse número se aproxima da habilidade latente, mas pode variar para cima ou para baixo dependendo de o jogador ter jogado melhor ou pior do que o habitual. Em outras palavras: desempenho é uma versão da habilidade com ruído. E o ruído também costuma ser modelado para ser gaussiano, como mostrado no diagrama central da Figura 2. Aqui, o desempenho é uma variável aleatória retirada de um gaussiano, cuja média é a habilidade do jogador e cuja variação é fixa, indicada pela variável de ruído gerada. Em um modelo mais complexo, eu tentaria conhecer a variação de ruído a partir dos dados, mas, para simplificar, vou fixá-la aqui em 1.

O jogador com melhor desempenho vence. Em um jogo entre duas pessoas, posso representar isso graficamente como mostra o diagrama mais à direita na Figura 2. Eu trapaceei um pouco nessa notação fazendo a variável Jogador Booliano 1 Vence “de certa forma” ser gerada. Isso ocorre porque seu valor é observado durante o treinamento, onde os resultados dos jogos são dados, mas não é observado durante a previsão.

Antes de juntar tudo isso, preciso introduzir algumas novas notações. A primeira se chama placa e representa um loop ForEach. É um retângulo que captura partes do modelo que precisam ser repetidas ao longo de determinado intervalo (por exemplo, vários jogadores ou vários jogos). Na segunda, vou usar linhas tracejadas para indicar a escolha, como quando escolho as habilidades dos dois jogadores em cada jogo. O modelo TrueSkill simplificado é exibido na Figura 3.

O modelo TrueSkill simplificado
Figura 3 O modelo TrueSkill simplificado

Aqui, vou usar a placa no conjunto de jogadores. Em seguida, para cada jogo, escolho as habilidades latentes dos dois jogadores, adiciono ruído e comparo os desempenhos. A variável de ruído não fica em uma placa porque assumimos que seu valor não muda de acordo com o jogador ou com o jogo.

Essa visualização do modelo, também chamada de gráfico de fator, é uma representação conveniente neste caso simples. Mas quando os modelos ficam maiores, o diagrama se torna confuso e difícil de manter. É por isso que os desenvolvedores preferem expressá-los em código, como um programa probabilístico.

Infer.NET

A estrutura de programação probabilística do .NET se chama Infer.NET. Desenvolvida pela Microsoft Research, ela virou software livre alguns meses atrás. O Infer.NET oferece uma API de modelagem para especificar o modelo estatístico, um compilador para gerar o algoritmo de ML a partir do modelo definido pelo usuário e um tempo de execução no qual o algoritmo é executado.

O Infer.NET está ficando cada vez mais integrado ao ML.NET e agora está no namespace Microsoft.ML.Probabilistic. É possível instalar o Infer.NET executando:

dotnet add package Microsoft.ML.Probabilistic.Compiler

O compilador puxará o pacote de tempo de execução automaticamente. Observe que o Infer.NET é executado no .NET Standard e, consequentemente, no Windows, no Linux e no macOS.

Vamos usar C# e começar incluindo os namespaces do Infer.NET:

using Microsoft.ML.Probabilistic.Models;
using Microsoft.ML.Probabilistic.Utilities;
using Microsoft.ML.Probabilistic.Distributions;

Em seguida, vou implementar o modelo definido anteriormente e ver como treiná-lo e como fazer previsões.

Implementação do modelo O modelo é escrito como um programa que simula um jogo, em termos de habilidade dos jogadores, desempenho e resultado, mas esse programa não será efetivamente executado. Nos bastidores, ele acumula uma estrutura de dados que representa o modelo. Quando essa estrutura de dados é enviada a um mecanismo de inferência, ela é compilada em código de ML, que é executado para realizar os devidos cálculos. Vamos implementar o modelo em três etapas: definir o esqueleto das placas para jogadores e jogos, definir o conteúdo da placa de jogador e definir o conteúdo da placa do jogo.

As placas são implementadas no Infer.NET usando a classe Range. Suas instâncias são basicamente variáveis de controle em loops ForEach probabilísticos. Preciso definir o tamanho dessas placas, que é a quantidade de jogadores e jogos, respectivamente. Como não sei de antemão quais são elas, esses valores serão variáveis usadas como espaços reservados. O Infer.NET oferece de maneira conveniente a classe Variable só para isso:

var playerCount = Variable.New<int>();
var player = new Range(playerCount);
var gameCount = Variable.New<int>();
var game = new Range(gameCount);

Com as placas definidas, vamos nos concentrar em seu conteúdo. Para a placa de jogadores, precisarei de uma matriz de variáveis aleatórias duplas para as habilidades de cada jogador. No Infer.NET, isso é feito usando Variable.Array<T>. Também vou precisar de uma matriz de variáveis aleatórias gaussianas para a distribuição anterior pelas habilidades dos jogadores. Em seguida, revejo os jogadores e conecto suas habilidades aos antecedentes. Isso é feito usando o método Variable<T>.Random. Observe como Variable.ForEach(Range) oferece um meio de implementar o conteúdo da placa:

var playerSkills = Variable.Array<double>(player);
var playerSkillsPrior = Variable.Array<Gaussian>(player);
using (Variable.ForEach(player))
{
  playerSkills[player] = Variable<double>.Random(playerSkillsPrior[player]);
}

A última peça do modelo é a placa de jogos. Vou começar definindo as matrizes que conterão os dados de treinamento: o primeiro e o segundo jogadores de cada jogo e os resultados dos jogos. Observe como estou criando um modelo especificamente para meus dados, diferentemente de ajustá-los para que se encaixem em algum algoritmo. Com os contêineres de dados prontos, preciso rever os jogos, selecionar as habilidades dos jogadores em cada jogo, adicionar ruído e comparar os desempenhos para gerar o resultado do jogo:

var players1 = Variable.Array<int>(game);
var players2 = Variable.Array<int>(game);
var player1Wins = Variable.Array<bool>(game);
  const double noise = 1.0;
    using (Variable.ForEach(game))
{
  var player1Skill = playerSkills[players1[game]];
  var player1Performance =
    Variable.GaussianFromMeanAndVariance(player1Skill, noise);
  var player2Skill = playerSkills[players2[game]];
  var player2Performance =
    Variable.GaussianFromMeanAndVariance(player2Skill, noise);
      player1Wins[game] = player1Performance > player2Performance;
}

Curiosamente, o mesmo modelo é usado para treinamento e para previsão. A diferença é que os dados observados serão diferentes. Durante o treinamento, você sabe os resultados do jogo, mas na previsão, não. Ou seja, embora o modelo seja o mesmo, os algoritmos gerados são diferentes. Felizmente, o compilador do Infer.NET cuida de tudo isso.

Treinamento Todas as consultas ao modelo (treinamento, previsão e outras) passam por três etapas: definir antecedentes, observar dados e executar inferência. Tanto o treinamento quanto a previsão são chamados de “inferência” porque eles basicamente fazem a mesma coisa: eles usam dados observados para mudar de uma distribuição anterior para uma distribuição posterior. No treinamento, você começa em uma distribuição anterior mais ampla pelas habilidades, indicando que a incerteza sobre as habilidades é alta. Usarei uma distribuição gaussiana para a anterior. Depois de observar os dados, obtenho uma distribuição gaussiana posterior mais restrita pelas habilidades.

Para os antecedentes de habilidades, vou apenas pegar emprestado alguns parâmetros aprendidos em “Halo 5”. Uma boa escolha de média e variação é 6,0 e 9,0, respectivamente. Defini os valores atribuindo-os à propriedade ObservedValue da variável que mantém os antecedentes. No caso de quatro jogadores, o código ficaria assim:

const int PlayerCount = 4;
playerSkillsPrior.ObservedValue =
  Enumerable.Repeat(Gaussian.FromMeanAndVariance(6, 9),
  PlayerCount).ToArray();

Em seguida, temos os dados. Para cada jogo, tenho os dois jogadores e o resultado. Vamos trabalhar com um exemplo fixo. A Figura 4 mostra três jogos envolvendo quatro jogadores, com setas indicando um jogo realizado e apontando para o perdedor desse jogo.

Os resultados dos três jogos
Figura 4 Os resultados dos três jogos

Para simplificar o código, vou assumir que cada jogador recebeu uma ID exclusiva. Neste exemplo, o primeiro jogo é entre Alice e Bob e a seta indica que Alice ganha. No segundo jogo, Bob ganha de Charlie e, por fim, Donna ganha de Charlie. Vejamos isso expresso em código, usando ObservedValue mais uma vez:

playerCount.ObservedValue = PlayerCount;
gameCount.ObservedValue = 3;
players1.ObservedValue = new[] { 0, 1, 2 };
players2.ObservedValue = new[] { 1, 2, 3 };
player1Wins.ObservedValue = new[] { true, true, false };

Por fim, executo inferência criando uma instância de um mecanismo de inferência e chamando Infer nas variáveis que desejo obter. Neste caso, estou interessado somente na distribuição posterior pelas habilidades dos jogadores:

var inferenceEngine = new InferenceEngine();
var inferredSkills = inferenceEngine.Infer<Gaussian[]>(playerSkills);

Na verdade, a instrução Infer aqui faz grande parte do trabalho, pois ela realiza duas compilações (Infer.NET e C#) e uma execução. O que acontece é que o compilador do Infer.NET rastreia a variável playerSkills transmitida para o modelo probabilístico, cria uma árvore de sintaxe a partir do código do modelo e gera um algoritmo de inferência em C#. Em seguida, o compilador de C# é invocado instantaneamente e o algoritmo é executado em relação aos dados observados. Por conta disso, as chamadas a Infer podem parece um pouco lentas, mesmo que você esteja operando com poucos dados aqui. O manual do Infer.NET explica como acelerar esse processo trabalhando com algoritmos pré-compilados. Ou seja, você realiza as etapas de compilação antecipadamente para que somente a parte dos cálculos seja executada na produção.

Para este exemplo, as habilidades inferidas são:

Alice: Gaussian(8.147, 5.685)
Bob: Gaussian(5.722, 4.482)
Charlie: Gaussian(3.067, 4.814)
Donna: Gaussian(7.065, 6.588)

Previsão Tendo inferido as habilidades dos jogadores, agora posso fazer previsões para futuros jogos. Vou seguir as mesmas três etapas do treinamento, mas agora estou interessado em inferir a distribuição posterior em relação à variável player1Wins. Penso da seguinte forma: no treinamento, as informações fluem para cima no gráfico de fatores, dos dados na base até os parâmetros do modelo no topo. Por outro lado, na previsão você tem os parâmetros do modelo (aprendidos no treinamento) e as informações fluem para baixo.

No meu conjunto de dados, Alice e Donna têm, ambas, uma vitória e nenhuma derrota. No entanto, intuitivamente parece que, em um jogo entre as duas, Alice tem maior chance de vencer porque sua vitória é mais relevante, pois foi contra Bob, que é um jogador mais forte que Charlie. Vamos tentar prever o resultado do jogo entre Alice e Donna (veja a Figura 5).

Prevendo o resultado de um jogo
Figura 5 Prevendo o resultado de um jogo

Neste caso, a distribuição anterior pelas habilidades é a distribuição posterior inferida no treinamento. Os dados observados são um jogo entre jogador 0 (Alice) e jogador 3 (Donna), com resultado desconhecido. Para tornar o resultado desconhecido, preciso limpar o valor observado anteriormente para player1Wins, pois é isto que eu quero em uma distribuição posterior:

playerSkillsPrior.ObservedValue = inferredSkills;
gameCount.ObservedValue = 1;
players1.ObservedValue = new[] { 0 };
players2.ObservedValue = new[] { 3 };
player1Wins.ClearObservedValue();
var player0Vs3 = inferenceEngine.Infer<Bernoulli[]>(player1Wins).First();

Convém mencionar que a incerteza é propagada por todo o modelo, até o resultado do jogo. Isso significa que a posterior obtida em relação ao resultado previsto não é simplesmente uma variável booliana, mas um valor indicando a probabilidade de o primeiro jogador vencer o jogo. Essa distribuição se chama Bernoulli.

O valor da variável inferida é Bernoulli(0.6127). Isso significa que Alice tem uma chance acima de 60% de vencer o jogo contra Donna, o que está de acordo com a minha intuição.

Avaliação Este exemplo demonstra como criar um modelo probabilístico bem conhecido, o TrueSkill. Na prática, criar o modelo certo exige várias iterações no design. Modelos diferentes são comparados pela escolha minuciosa de um conjunto de métricas que indicam o desempenho do modelo com determinados dados.

A avaliação é uma questão central em ML, mas muito ampla para abordarmos aqui. Ela também não é algo específico da programação probabilística. Vale a pena mencionar, no entanto, que ter um modelo permite que você calcule uma “métrica” exclusiva, a evidência do modelo. Essa é a probabilidade de os dados de treinamento terem sido produzidos por esse modelo específico. Isso é ótimo para comparar modelos diferentes; não é necessário ter um conjunto de teste!

Benefícios da programação probabilística

A abordagem mostrada neste artigo pode parecer mais difícil do que você tem visto. Por exemplo, o Connect(); edição especial da revista (msdn.com/magazine/mt848634), apresentou a você o ML:NET, que trata mais da transformação dos dados do que do design do modelo. E, em muitos casos, esse é o caminho certo a se seguir: se os dados parecem corresponder a um algoritmo existente e você se sente tranquilo em tratar o modelo como uma caixa preta, comece com uma solução pronta para usar. No entanto, se precisa de um modelo sob medida, a programação probabilística pode ser a melhor escolha para você. 

Existem outras situações em que pode ser melhor usar programação probabilística. Uma das principais vantagens de ter um modelo que você entende é a maior capacidade de explicar o comportamento do sistema. Quando o modelo não é uma caixa preta, você pode examinar seu interior e ver os parâmetros aprendidos. Eles fazem sentido porque você é que projetou o modelo. Por exemplo, no exemplo anterior, você pode ver as habilidades aprendidas dos jogadores. Em uma versão mais avançada do TrueSkill, chamada TrueSkill 2, vários outros aspectos do jogo são modelados, incluindo como o desempenho em um modo de jogo está conectado ao de outro. Entender essa conexão ajuda os designers de jogos a entender como os modos de jogo diferentes são semelhantes. A capacidade de interpretação de um sistema de ML também é essencial para depuração. Quando um modelo de caixa preta não produz os resultados desejados, é possível que você não saiba nem por onde começar a procurar o problema.

Outra vantagem da programação probabilística é a capacidade de incorporar conhecimento de domínio ao modelo. Isso é evidenciado tanto na estrutura do modelo quanto na capacidade de definir antecedentes. Por outro lado, as abordagens tradicionais normalmente olham apenas os dados, sem permitir que o especialista no domínio justifique o comportamento do sistema. Essa capacidade é exigida em alguns domínios, como saúde, em que há um conhecimento do domínio forte e os dados podem ser escassos.

Uma vantagem da abordagem bayesiana, e outra que tem bom suporte no Infer.NET, é a capacidade de aprender com novos dados. Isso se chama inferência online e é especialmente útil em sistemas que interagem com dados do usuário. Por exemplo, o TrueSkill precisa atualizar as habilidades dos jogadores depois de cada partida, e o sistema de extração de conhecimento precisa aprender continuamente com a Internet conforme vai crescendo. Mas isso é tudo muito fácil: basta conectar as distribuições posteriores inferidas como as novas anteriores e o sistema estará pronto para aprender com os novos dados!

A programação probabilística também se aplica naturalmente a problemas com determinadas características de dados, como dados heterogêneos, dados escassos, dados sem rótulo, dados com partes ausentes e dados coletados com vieses conhecidos.

O que vem a seguir?

Existem dois ingredientes para criar um modelo probabilístico com sucesso. O primeiro, obviamente, é aprender a modelar. Neste artigo, apresentei os principais conceitos e técnicas da metodologia, mas se você quiser aprender mais, recomendo um livro disponível gratuitamente online, “Model-Based Machine Learning” (mbmlbook.com). Ele faz uma breve apresentação de ML baseado em modelo na produção e se destina a desenvolvedores (em vez de cientistas).

Depois de aprender a projetar modelos, você precisará aprender a expressá-los em código. O guia do usuário do Infer.NET é um ótimo recurso para isso. Existem também tutoriais e exemplos que abordam vários cenários; todos eles podem ser encontrados em nosso repositório em github.com/dotnet/infer, onde você também pode se juntar a nós e contribuir.


Yordan Zaykov é o líder principal de engenharia de software de pesquisa da equipe de Desenvolvimento de inferência probabilística no Microsoft Research em Cambridge. Seu foco é em aplicativos com base na estrutura de ML do Infer.NET, incluindo trabalhos em classificação de emails, recomendações, classificação e compatibilidade de jogadores, saúde e mineração de conhecimento.

Agradecemos aos seguintes especialistas técnicos da Microsoft pela revisão deste artigo: John Guiver, James McCaffrey, Tom Minka, John Winn