https://a.storyblok.com/f/270183/125424/a9502f3f65/what-kind-of-dog-is-that.png

“Ei, Facebook, que raça de cachorro é essa?” Adicionando aprendizado de máquina ao Messenger

Publicado em May 13, 2021

Tempo de leitura: 9 minutos

As redes neurais convolucionais (CNNs) oferecem um mecanismo poderoso e escalável para realizar a classificação de imagens. Elas podem ser relativamente difíceis de construir, treinar e ajustar do zero, e é isso que torna ferramentas como o TensorFlow e os modelos Inception tão indispensáveis para aprimorar nossos fluxos de trabalho de aprendizado de máquina.

Dito isso, para nós, profissionais do .NET, executar scripts em Python a partir de um shell dentro do aplicativo não é exatamente a solução ideal, e é isso que torna o lançamento da biblioteca ML.NET para TensorFlow tão empolgante.

E se eu lhe dissesse que, com apenas algumas centenas de linhas de código em C# e um pouco de configuração, você poderia criar um aplicativo ASP.NET Core que abrigaria uma poderosa rede neural (CNN) com a qual você poderia interagir de forma tão simples quanto enviar uma foto para uma página do Facebook?

Com um treinamento tão simples quanto:

Training ImageTraining Image

E uma solicitação de classificação tão simples quanto:

Classification RequestClassification Request

Bem, é exatamente isso que vamos fazer — usando o ML.NET, vamos criar um classificador avançado e, em seguida, com a Messages API e o Messenger da Nexmo, vamos criar uma ferramenta poderosa e fácil de usar para treinamento e classificação.

Objetivos de aprendizagem

Neste tutorial, vamos:

  • Criar uma rede neural do TensorFlow no ML.NET

  • Treine essa rede neural para reconhecer diferentes tipos de cães

  • Crie um vetor de mensagens para solicitar que a rede neural classifique cães que ela nunca viu antes

  • Crie um vetor de aprendizado para permitir que a rede neural aprenda novos tipos de cães de forma dinâmica.

Pré-requisitos

Configuração do projeto

Primeiro, vamos abrir o Visual Studio, criar um novo aplicativo API ASP.NET Core 3.0 e chamá-lo de MessagesTensorFlow. Agora, vamos adicionar os seguintes pacotes NuGet à solução:

  • Castelo Inflável

  • jose-jwt

  • Microsoft.ML

  • Microsoft.ML.ImageAnalytics

  • Microsoft.ML.TensorFlow

  • Newtonsoft.Json

Vamos começar nossa rede neural com o modelo Inception V1 e, em seguida, alimentá-la com imagens e rótulos armazenados no disco. Crie uma pasta no diretório MessagesTensorFlow chamada assets.

Na pasta “assets”, baixe e descompacte o Modelo Inception V1

Além disso, na pasta “assets”, crie uma pasta chamada “train” e outra chamada “predict”. Em cada uma dessas pastas, adicione um arquivo chamado “tags.tsv”. Sua estrutura de pastas deve ficar mais ou menos assim agora:

Directory structureDirectory structure

Agora, acesse cada arquivo e, na seção de propriedades avançadas, defina a opção “Copiar para o diretório de saída” como “Copiar se for mais recente”

Criando o aluno

Vamos agora criar a classe que irá, de fato, abrigar nossa rede neural. Crie um arquivo chamado TFEngine.cs.

Importações

Adicione as seguintes importações no início do arquivo:

using Microsoft.ML;
using Microsoft.ML.Data;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;

Organização da aula

Então, dentro da classe TFEngine, vamos adicionar alguns caminhos para que possamos acessar todos os arquivos que vamos incorporar ao nosso modelo. Além disso, vamos definir algumas configurações para gerenciar os dados iniciais.

static readonly string _assetsPath = Path.Combine(Environment.CurrentDirectory, "assets");
static readonly string _imagesFolder = Path.Combine(_assetsPath, "train");
static readonly string _savePath = Path.Combine(_assetsPath, "predict");
static readonly string _trainTagsTsv = Path.Combine(_imagesFolder, "tags.tsv");
static readonly string _inceptionTensorFlowModel = Path.Combine(_assetsPath, "inception5h", "tensorflow_inception_graph.pb");

const int ImageHeight = 224;
const int ImageWidth = 224;
const float Mean = 117;
const bool ChannelsLast = true;

Vamos também definir essa classe como um singleton e permitir apenas um acesso a ela por vez. Além disso, vamos adicionar um webClient para baixar as URLs das imagens.

static readonly object _lock = new object();

private static WebClient _client = new WebClient();
private static TFEngine _instance;
public static TFEngine Instance
{
    get
    {
        lock (_lock)
        {
            if (_instance == null)
            {
                _instance = new TFEngine();
            }
            return _instance;
        }

    }
}

private TFEngine()
{
    _mlContext = new MLContext();
    GenerateModel();
}

Também vamos criar alguns campos para armazenar nosso pipeline, que será usado para criar nosso modelo — o modelo que será utilizado para realizar a previsão — e o MLContext.

private IEstimator<ITransformer> _pipeline;
private ITransformer _model;
private MLContext _mlContext;

Em seguida, adicione uma classe chamada ImageData, que armazenará os dados da imagem à medida que ela passa pelo modelo

public class ImageData
{
    [LoadColumn(0)]
    public string ImagePath;

    [LoadColumn(1)]
    public string Label;
}

Em seguida, crie uma estrutura para armazenar os dados de previsão à medida que eles são gerados pelo modelo:

public class ImagePrediction : ImageData
{
    public float[] Score;

    public string PredictedLabelValue;
}

A pontuação será um array contendo as probabilidades que a rede neural atribui a cada rótulo possível, e o `PredictedLabelValue` será, obviamente, a previsão da rede (o item com a pontuação mais alta)

Treinamento de modelos

Agora é hora de treinar nosso modelo!

Adicione um método chamado GenerateModel

public string ClassifySingleImage(string imageUrl)
{
    try
    {
        var filename = Path.Combine(_savePath, $"{Guid.NewGuid()}.jpg");
        _client.DownloadFile(imageUrl, filename);
        var imageData = new ImageData()
        {
            ImagePath = filename
        };

        var predictor = _mlContext.Model.CreatePredictionEngine<ImageData, ImagePrediction>(_model);
        var prediction = predictor.Predict(imageData);
        var response = $"I'm about {prediction.Score.Max() * 100}% sure that the image you sent me is a {prediction.PredictedLabelValue}";
        Console.WriteLine($"Image: {Path.GetFileName(imageData.ImagePath)} predicted as: {prediction.PredictedLabelValue} with score: {prediction.Score.Max() * 100} ");
        return response;
    }
    catch (Exception)
    {
        return "Something went wrong when trying to classify image";
    }
}

Essa é, na verdade, a essência do que fará nosso preditor funcionar. A seção “_pipeline =” é uma sequência de comandos que irá:

  • Carregar as imagens do disco

  • Redimensione as imagens para importação

  • Extrair e vetorizar os pixels das imagens

  • Carregar o modelo inicial do TensorFlow (que é, basicamente, nossa rede neural pré-criada)

  • Crie um modelo de treinamento e processe os dados de treinamento nele para criar um modelo de previsão que possamos utilizar

Classificação de uma única imagem

Com nosso modelo treinado, agora podemos criar um método que receba o nome de um arquivo e retorne uma string contendo uma previsão e o nível de confiança da rede nessa previsão. Essa função recebe um imageUrl, salva o arquivo no disco, classifica a imagem e retorna uma string contendo a previsão do classificador com seu nível de confiança.

public string ClassifySingleImage(string imageUrl)
{
    try
    {
        var filename = Path.Combine(_savePath, $"{Guid.NewGuid()}.jpg");
        _client.DownloadFile(imageUrl, filename);
        var imageData = new ImageData()
        {
            ImagePath = filename
        };

        var predictor = _mlContext.Model.CreatePredictionEngine<ImageData, ImagePrediction>(_model);
        var prediction = predictor.Predict(imageData);
        var response = $"I'm about {prediction.Score.Max() * 100}% sure that the image you sent me is a {prediction.PredictedLabelValue}";
        Console.WriteLine($"Image: {Path.GetFileName(imageData.ImagePath)} predicted as: {prediction.PredictedLabelValue} with score: {prediction.Score.Max() * 100} ");
        return response;
    }
    catch (Exception)
    {
        return "Something went wrong when trying to classify image";
    }
}

Adicionando dados de treinamento

A operação final que vamos solicitar ao TensorFlow Engine é, essencialmente, o inverso da previsão: vamos pedir que ele aceite uma URL de imagem e um rótulo, e que se atualize para reconhecer melhor as imagens com esse rótulo. O método `AddTrainingImage` salva a imagem fornecida no disco, acrescenta informações sobre essa imagem ao arquivo `tags.tsv` e regenera o modelo.

public string AddTrainingImage(string imageUrl, string label)
{
    try
    {
        var id = Guid.NewGuid();
        var fileName = Path.Combine(_imagesFolder, $"{id}.jpg");
        _client.DownloadFile(imageUrl, fileName);
        File.AppendAllText(_trainTagsTsv, $"{id}.jpg\t{label}" + Environment.NewLine);
        IDataView trainingData = _mlContext.Data.LoadFromTextFile<ImageData>(path: _trainTagsTsv, hasHeader: false);
        _model = _pipeline.Fit(trainingData);
        return $"I have trained myself to recognize the image you sent me as a {label}. Your teaching is appreciated";
    }
    catch (Exception)
    {
        return "something went wrong when trying to train on image";
    }
}

Utilizando a Messages API para impulsionar a classificação e o treinamento

Objetos de mensagens

A seguir, vamos adicionar alguns POCOs para armazenar nossos dados de mensagens à medida que eles chegam e são enviados para a Messages API — esses objetos são bastante detalhados e não fazem nada de particularmente interessante, além de permitir a serialização/desserialização de JSON; portanto, para sermos mais concisos, fique à vontade para simplesmente usar as seguintes estruturas:

Interagindo com a API

A criação dessas estruturas nos permite gerenciar os dados que recebemos e enviamos para a Messages API. No entanto, precisamos de mais uma etapa para podermos realmente usar a API — precisaremos gerar um JWT para autenticar nosso aplicativo na Messages API. Para isso, vamos criar os seguintes arquivos.

  • TokenGenerator.cs

  • MessageSender.cs

Gerar JWT

O `TokenGenerator` terá um método estático, `GenerateToken`, que aceitará uma lista de Claims e a chave privada do seu aplicativo

using Org.BouncyCastle.Crypto.Parameters;
using Org.BouncyCastle.OpenSsl;
using Org.BouncyCastle.Security;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Claims;
using System.Security.Cryptography;

namespace MessagesTensorFlow
{
    public class TokenGenerator
    {
        public static string GenerateToken(List<Claim> claims, string privateKey)
        {
            RSAParameters rsaParams;
            using (var tr = new StringReader(privateKey))
            {
                var pemReader = new PemReader(tr);
                var kp = pemReader.ReadObject();
                var privateRsaParams = kp as RsaPrivateCrtKeyParameters;
                rsaParams = DotNetUtilities.ToRSAParameters(privateRsaParams);
            }
            using (RSACryptoServiceProvider rsa = new RSACryptoServiceProvider())
            {
                rsa.ImportParameters(rsaParams);
                Dictionary<string, object> payload = claims.ToDictionary(k => k.Type, v => (object)v.Value);
                return Jose.JWT.Encode(payload, rsa, Jose.JwsAlgorithm.RS256);
            }
        }
    }
}

Isso irá gerar um JWT para você usar com a Messages API.

Gerar uma lista de reivindicações para JWT

No arquivo MessageSender.cs, teremos um método para gerar as reivindicações para o JWT a partir do seu appId:

private static List<Claim> GetClaimsList(string appId)
{
    const int SECONDS_EXPIRY = 3600;
    var t = DateTime.UtcNow - new DateTime(1970, 1, 1);
    var iat = new Claim("iat", ((Int32)t.TotalSeconds).ToString(), ClaimValueTypes.Integer32); // Unix Timestamp for right now
    var application_id = new Claim("application_id", appId); // Current app ID
    var exp = new Claim("exp", ((Int32)(t.TotalSeconds + SECONDS_EXPIRY)).ToString(), ClaimValueTypes.Integer32); // Unix timestamp for when the token expires
    var jti = new Claim("jti", Guid.NewGuid().ToString()); // Unique Token ID
    var claims = new List<Claim>() { iat, application_id, exp, jti };

    return claims;
}

Leia as configurações do aplicativo e crie um JWT

Em seguida, teremos outro método para ler os itens relevantes da sua configuração — que o controlador nos fornecerá por meio da injeção de dependências —, recuperar a lista de claims e gerar o JWT

private static string BuildJwt(IConfiguration config)
{
    var appId = config["Authentication:appId"];
    var priavteKeyPath = config["Authentication:privateKey"];
    string privateKey = "";
    using (var reader = File.OpenText(priavteKeyPath)) // file containing RSA PKCS1 private key
        privateKey = reader.ReadToEnd();

    var jwt = TokenGenerator.GenerateToken(GetClaimsList(appId), privateKey);
    return jwt;
}

É claro que isso exigirá algumas configurações no seu arquivo appsettings.json. Adicione o objeto a seguir ao seu arquivo appsettings.json e preencha-o com os valores adequados:

"Authentication": {
    "appId": "app_id",
    "privateKey": "path_to_key_file"
  }

Enviar uma mensagem

Agora vamos integrar tudo isso com nosso método `SendMessage`, que receberá nossa mensagem, `toId`, `fromId` e `config`. Esse método irá gerar um JWT e enviar uma solicitação à Messages API para que seja enviada uma mensagem contendo o feedback do nosso classificador ao nosso usuário final.

public static void SendMessage(string message, string fromId, string toId, IConfiguration config)
{
    const string MESSAGING_URL = @"https://api.nexmo.com/v0.1/messages";
    try
    {
        var jwt = BuildJwt(config);

        var requestObject = new MessageRequest()
        {
            to = new MessageRequest.To()
            {
                id = toId,
                type = "messenger"
            },
            from = new MessageRequest.From()
            {
                id = fromId,
                type = "messenger"
            },
            message = new MessageRequest.Message()
            {
                content = new MessageRequest.Message.Content()
                {
                    type = "text",
                    text = message
                },
                messenger = new MessageRequest.Message.Messenger()
                {
                    category = "RESPONSE"
                }
            }
        };
        var requestPayload = JsonConvert.SerializeObject(requestObject, new JsonSerializerSettings() { NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling = DefaultValueHandling.Ignore });
        var httpWebRequest = (HttpWebRequest)WebRequest.Create(MESSAGING_URL);
        httpWebRequest.ContentType = "application/json";
        httpWebRequest.Accept = "application/json";
        httpWebRequest.Method = "POST";
        httpWebRequest.PreAuthenticate = true;
        httpWebRequest.Headers.Add("Authorization", "Bearer " + jwt);
        using (var streamWriter = new StreamWriter(httpWebRequest.GetRequestStream()))
        {
            streamWriter.Write(requestPayload);
        }
        using (var httpResponse = (HttpWebResponse)httpWebRequest.GetResponse())
        {
            using (var streamReader = new StreamReader(httpResponse.GetResponseStream()))
            {
                var result = streamReader.ReadToEnd();
                Console.WriteLine(result);
                Console.WriteLine("Message Sent");
            }
        }
    }
    catch (Exception e)
    {
        Debug.WriteLine(e.ToString());
    }
}

Manipulador de classificação

Queremos que o tratamento dos webhooks recebidos seja assíncrono e responda imediatamente; por isso, vamos criar um arquivo chamado ClassificationHandler.cs para lidar efetivamente com as operações de classificação e resposta. Esse arquivo conterá algumas pequenas estruturas que nos permitirão descarregar, classificar ou treinar e responder às mensagens recebidas.

No arquivo ClassificationHandler.cs, adicione o seguinte código:

public static void ClassifyAndRespond(object state)
{
    var request = state as ClassifyRequest;
    var response = TFEngine.Instance.ClassifySingleImage(request.imageUrl);
    MessageSender.SendMessage(response, request.toId, request.fromid, request.Configuration);
}

public static void AddTrainingData(object state)
{
    var request = state as TrainRequest;
    var response = TFEngine.Instance.AddTrainingImage(request.imageUrl, request.Label);
    MessageSender.SendMessage(response, request.toId, request.fromid, request.Configuration);
}
public class TrainRequest : Request
{
    public string Label { get; set; }
}
public class ClassifyRequest : Request{}
public abstract class Request
{
    public string imageUrl { get; set; }
    public string toId { get; set; }
    public string fromid { get; set; }

    public IConfiguration Configuration { get; set; }
}

Tratar webhooks de mensagens recebidas

Do ponto de vista do nosso código, a última coisa que precisaremos fazer é criar alguns controladores para lidar com as mensagens recebidas e o status da Messages API.

Na pasta Controllers, adicione dois “controladores API - Vazio” chamados InboundController e StatusController.

Controlador de status

O controlador Status irá fornecer o status das mensagens do nosso aplicativo à medida que elas passam pela API; para acompanhar o que está acontecendo, vamos adicionar um método POST ao controlador Status para exibir o conteúdo do status no console de depuração:

[HttpPost]
public HttpStatusCode Post([FromBody]StatusMessage message)
{
    Debug.WriteLine(JsonConvert.SerializeObject(message));
    return HttpStatusCode.NoContent;
}

Controlador de entrada

O Controlador de Entrada ficará encarregado de gerenciar as mensagens recebidas do nosso webhook.

Organização da aula

Vamos, primeiro, preparar tudo criando um dicionário para os rótulos de treinamento pendentes, um objeto `Configuration` para que o controlador acesse a configuração e, por meio da injeção de dependências, inserindo o objeto `Configuration` no construtor do controlador `Inbound`:

public static Dictionary<string, string> _pendingTrainLabels = new Dictionary<string, string>();
public IConfiguration Configuration { get; set; }
public InboundController(IConfiguration configuration)
{
    Configuration = configuration;
}

Tratamento de mensagens recebidas

A seguir, vamos escrever o manipulador InboundMessage propriamente dito. Esse manipulador será uma solicitação POST. Ele verificará se há algum texto na mensagem. Se houver, ele verificará se a primeira palavra da mensagem é “train”. Se for o caso, ele salvará o restante da mensagem como um rótulo de treinamento e, na próxima vez que esse usuário enviar uma mensagem com uma imagem, o classificador será treinado com essa imagem e esse rótulo.

Em qualquer outra mensagem com imagem, ele simplesmente classificará a imagem e enviará o resultado da classificação de volta ao remetente da mensagem.

Em ambos os casos, ele inicia um WorkItem no ThreadPool, passando um desses práticos objetos de solicitação do ClassificationHandler que geramos anteriormente — isso libera o controlador para enviar um status de volta à Messages API (neste caso, um 204 para informar que a mensagem foi recebida)

[HttpPost]
public HttpStatusCode Post([FromBody]InboundMessage message)
{
    const string TRAIN = "train";
    try
    {
        Debug.WriteLine(JsonConvert.SerializeObject(message));
        if (!string.IsNullOrEmpty(message.message.content.text))
        {
            var split = message.message.content.text.Split(new[] { ' ' }, 2);
            if (split.Length > 1)
            {
                if (split[0].ToLower() == TRAIN)
                {
                    var label = split[1];
                    var requestor = message.from.id;
                    if (!_pendingTrainLabels.ContainsKey(requestor))
                    {
                        _pendingTrainLabels.Add(requestor, label);
                    }
                    else
                    {
                        _pendingTrainLabels[requestor] = label;
                    }
                }
            }
        }
        if (_pendingTrainLabels.ContainsKey(message.from.id) && message.message.content?.image?.url != null)
        {
            ThreadPool.QueueUserWorkItem(ClassificationHandler.AddTrainingData, new ClassificationHandler.TrainRequest()
            {
                toId = message.to.id,
                fromid = message.from.id,
                imageUrl = message.message.content.image.url,
                Label = _pendingTrainLabels[message.from.id],
                Configuration = Configuration
            });
            _pendingTrainLabels.Remove(message.from.id);
        }
        else
        {
            ThreadPool.QueueUserWorkItem(ClassificationHandler.ClassifyAndRespond,
            new ClassificationHandler.ClassifyRequest()
            {
                toId = message.to.id,
                fromid = message.from.id,
                imageUrl = message.message.content.image.url,
                Configuration = Configuration
            });
        }

        return HttpStatusCode.NoContent;
    }
    catch (Exception ex)
    {
        return HttpStatusCode.NoContent;
    }
}

Inicialização com poucos dados.

Você pode adicionar as imagens e tags que quiser para começar. Para simplificar, vou começar com apenas uma imagem — uma foto do meu cachorro (que, apropriadamente, se chama Zero).

Training dataTraining data

Vou colocar essa imagem no diretório assets/train.

Como o Zero é um whippet, vou, no arquivo tags.tsv, na pasta assets/train, adicionar o nome do arquivo “zero.jpg”, seguido de uma tabulação, seguido do rótulo “whippet” e, em seguida, uma nova linha

zero.jpg    whippet

Testes

Feito isso, só falta ligar o sistema, conectá-lo à internet e testá-lo. Eu uso o ngrok e o IIS Express para testá-lo.

Configuração do IIS Express

Primeiro, acesse a guia “Depuração” nas propriedades do projeto e procure o campo “URL do aplicativo” — especificamente, qual porta ele vai usar —; eu desmarco a caixa de seleção “Ativar SSL” para fins de teste.

DebugDebug

Em seguida, execute o site a partir do Visual Studio usando o IIS Express — você verá a porta na barra de endereços do navegador que é exibido — no meu exemplo, removi todo o conteúdo do controlador meteorológico que vem por padrão, então recebo um erro 404 ao iniciá-lo — o que está tudo bem, já que ele está, na verdade, atuando apenas como um serviço web para ouvir e responder a webhooks. Não há nenhuma solicitação GET para enviar uma página de volta ao seu navegador.

Usando o Ngrok para expor a porta à Internet

Para que a Messages API encaminhe as mensagens, precisaremos expor o site à internet — para fins de teste, usaremos o ngrok para expor nossa porta do IIS Express. Abra a linha de comando e use este comando, substituindo por seu número de porta.

ngrok http --host-header="localhost:" http://localhost:

Este comando gera uma saída como esta:

Command OutputCommand Output

Configurando os webhooks

Usando o link http que acabamos de obter do ngrok, você pode criar a URL para a qual o webhook fará a chamada de retorno — você pode ver na seção “Rota” dos controladores que acabamos de criar como será essa rota:

RouteRoute

Isso vai resultar em http://dc0feb1d.ngrok.io/api/Status para mensagens de status e http://dc0feb1d.ngrok.io/api/Inbound para mensagens recebidas

NOTA: A primeira parte da URL (dc0feb1d) mudará sempre que você reiniciar o ngrok no plano gratuito.

Usaremos essas URLs de retorno de chamada para registrar nossos webhooks no Nexmo.

Acesse ${CUSTOMER_DASHBOARD_URL} e faça login na sua conta da Nexmo

Acesse “Mensagens e despacho” → “Seus pedidos” e selecione o botão “Editar” correspondente ao seu pedido

Na tela de edição, altere os campos “URL de status” e “URL de entrada” para os valores indicados acima e clique no botão azul “Salvar”, no canto inferior direito.

E é isso. Agora você tem um classificador/modelo de aprendizado ao qual pode enviar imagens pelo Messenger.

Compartilhar:

https://a.storyblok.com/f/270183/384x384/73d57fd8eb/stevelorello.png
Steve LorelloEx-funcionários da Vonage

Steve é um ex-membro da equipe da Vonage. Ele atuou como Developer Advocate .NET na Vonage, engenheiro de software full-stack poliglota, especializado em IA/ML