https://d226lax1qjow5r.cloudfront.net/blog/blogposts/how-to-add-machine-learning-to-facebook-messenger-dr/What-Kind-of-Dog-Is-That.png

"Hey Facebook, What Type of Dog Is That ? Ajouter ML à Messenger

Publié le May 13, 2021

Temps de lecture : 9 minutes

Les réseaux neuronaux convolutifs (CNN) constituent un mécanisme puissant et évolutif pour la classification des images. Ils peuvent être relativement difficiles à construire, à entraîner et à régler à partir de zéro, ce qui rend des outils comme TensorFlow et les modèles d'inception si indispensables pour améliorer nos flux de travail en ML.

Cela dit, pour nous, les utilisateurs de .NET, l'exécution de scripts Python à partir d'un shell in-app est loin d'être une solution idéale, ce qui rend la sortie de la bibliothèque ML.NET TensorFlow si excitante.

Et si je vous disais qu'avec seulement quelques centaines de lignes de code C# et un peu de configuration, vous pouvez construire une application ASP.NET core qui hébergera un puissant CNN avec lequel vous pourrez interagir aussi simplement qu'en envoyant un message photo à une page Facebook ?

Avec une formation aussi simple que :

Training ImageTraining Image

Et une demande de classification aussi simple que :

Classification RequestClassification Request

C'est précisément ce que nous allons faire - en utilisant ML.NET, nous allons construire un puissant classificateur, puis en utilisant l'API Messages et Messenger de Nexmo, nous allons créer un vecteur puissant et facile à utiliser pour l'entraînement et la classification.

Objectifs d'apprentissage

Dans ce tutoriel, nous allons :

  • Créer un réseau neuronal ML.NET TensorFlow

  • Entraîner ce réseau neuronal à reconnaître différents types de chiens

  • Créer un vecteur de messagerie pour demander au réseau neuronal de classer des chiens qu'il n'a jamais vus auparavant.

  • Créer un vecteur d'apprentissage pour permettre au réseau neuronal d'apprendre de nouveaux types de chiens de manière dynamique.

Conditions préalables

  • Visual Studio 2019 version 16.3 ou supérieure

  • Une page Facebook liée à votre Account Nexmo Voir ici pour l'installation

  • Optionnel : Ngrok pour le déploiement de tests

Vonage API Account

To complete this tutorial, you will need a Vonage API account. If you don’t have one already, you can sign up today and start building with free credit. Once you have an account, you can find your API Key and API Secret at the top of the Vonage API Dashboard.

Configuration du projet

Tout d'abord, ouvrons Visual Studio, créons une nouvelle application ASP.NET Core 3.0 API et appelons-la MessagesTensorFlow. Ajoutons maintenant les paquets NuGet suivants à la solution :

  • Château gonflable

  • jose-jwt

  • Microsoft.ML

  • Microsoft.ML.ImageAnalytics

  • Microsoft.ML.TensorFlow

  • Newtonsoft.Json

Nous allons démarrer notre réseau neuronal avec le modèle Inception V1, puis l'alimenter avec des images/étiquettes du disque. Créez un dossier sous le répertoire MessagesTensorFlow appelé assets.

Dans les actifs, téléchargez et décompressez le fichier Modèle Inception V1

De même, sous assets, créez un dossier appelé train et predict. Sous chacun de ces répertoires, ajoutez un fichier tags.tsv. La structure de vos répertoires devrait ressembler à ceci :

Directory structureDirectory structure

Allez maintenant dans chaque fichier et, dans la section des propriétés avancées, réglez le paramètre Copier dans le répertoire de sortie sur Copier s'il est plus récent.

Créer l'apprenant

Créons maintenant la classe qui va contenir notre réseau neuronal. Créons un fichier appelé TFEngine.cs.

Importations

Ajoutez les importations suivantes au début du fichier :

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

Configuration de la classe

Ensuite, à l'intérieur de la classe TFEngine, ajoutons quelques chemins pour que nous puissions accéder à tous les fichiers que nous ingérerons dans notre modèle. Ainsi que quelques paramètres pour gérer les données de départ.

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;

Configurons également cette classe en tant que singleton et n'autorisons qu'un seul accès à la fois. Nous allons également ajouter un webClient pour télécharger les URL des images.

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();
}

Nous allons également créer quelques champs pour contenir notre pipeline qui sera utilisé pour créer notre modèle, le modèle qui sera utilisé pour effectuer la prédiction, et le MLContext.

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

Ajoutez ensuite une classe ImageData qui contiendra les données de l'image au fur et à mesure qu'elles passent par le modèle

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

    [LoadColumn(1)]
    public string Label;
}

Créez ensuite une structure pour accueillir les données de prédiction au fur et à mesure qu'elles sortent du modèle :

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

    public string PredictedLabelValue;
}

Le score sera un tableau contenant les probabilités que le réseau neuronal attribue à chaque étiquette possible, et la PredictedLabelValue sera, bien sûr, la prédiction du réseau (l'élément ayant le score le plus élevé).

Modèle de formation

Il est maintenant temps d'entraîner notre modèle !

Ajouter une méthode appelée 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";
    }
}

C'est vraiment le cœur de ce qui va faire fonctionner notre prédicteur. La section '_pipeline =' est une chaîne de commandes qui va.. :

  • Charger les images du disque

  • Redimensionner les images pour l'ingestion

  • Extraire et vectoriser les pixels des images

  • Charger le modèle TensorFlow de départ (essentiellement notre réseau neuronal préfabriqué).

  • Créer un modèle d'entraînement et y faire passer les données d'entraînement pour créer un modèle de prédiction que nous pourrons utiliser

Classification d'une seule image

Avec notre modèle formé, nous pouvons maintenant créer une méthode qui prendra un nom de fichier et renverra une chaîne contenant une prédiction et la confiance du réseau dans la prédiction. Cette fonction prend une imageUrl, enregistre le fichier sur le disque, classifie l'image et renvoie une chaîne contenant l'estimation du classificateur avec sa confiance.

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";
    }
}

Ajout de données de formation

La dernière opération que nous allons demander au moteur Tensor Flow est essentiellement l'inverse de la prédiction, nous allons lui demander d'accepter une URL d'image et un label et de se mettre à jour pour mieux reconnaître les images de ce label. AddTrainingImage enregistre l'image fournie sur le disque, ajoute des informations sur cette image au fichier tags.tsv et régénère le modèle.

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";
    }
}

Utiliser l'API Messages pour favoriser la classification et la formation

Messages Objets

Ensuite, nous allons ajouter quelques POCO pour contenir nos données de messagerie lorsqu'elles entrent et sortent de l'API Messages - ces objets sont assez verbeux et ne font rien de particulièrement intéressant à part permettre la sérialisation / désérialisation de JSON donc, par souci de brièveté, n'hésitez pas à utiliser simplement les structures suivantes :

Interagir avec l'API

La création de ces structures nous permet de gérer les données que nous recevons et envoyons à l'API Messages. Cependant, nous avons besoin d'une étape supplémentaire pour nous permettre d'utiliser l'API - nous devons générer un JWT pour authentifier notre application auprès de l'API Messages. À cette fin, créons les fichiers suivants.

  • TokenGenerator.cs

  • MessageSender.cs

Générer un JWT

TokenGenerator aura une méthode statique GenerateToken qui acceptera une liste de Claims et la privateKey de votre application.

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);
            }
        }
    }
}

Cela générera un JWT pour votre utilisation avec l'API Messages.

Générer une liste de réclamations pour JWT

Dans MessageSender.cs, nous aurons une méthode pour générer les revendications pour le JWT à partir de votre 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;
}

Lire les paramètres de l'application et créer un JWT

Ensuite, nous aurons une autre méthode pour lire les éléments pertinents de votre configuration, que le contrôleur nous transmettra par le biais de l'injection de dépendance, pour récupérer la liste des revendications et construire le 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;
}

Ceci nécessitera bien sûr quelques éléments dans votre fichier appsettings.json Ajoutez l'objet suivant à votre fichier appsettings.json et remplissez-le avec les valeurs appropriées :

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

Envoyer un message

Nous allons maintenant lier le tout avec notre méthode SendMessage, qui prendra notre message, toId, fromId et config. Cette méthode va générer un JWT et envoyer une requête à l'API Messages pour envoyer un message contenant le feedback de notre classificateur à notre utilisateur 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());
    }
}

Manipulateur de classification

Nous allons vouloir que le traitement des webhooks entrants soit asynchrone et réponde immédiatement, nous allons donc créer un fichier ClassificationHandler.cs pour gérer les opérations de classification et de réponse. Ce fichier contiendra quelques petites structures qui nous permettront de décompresser, de classer ou de former, et de répondre aux messages entrants.

Dans ClassificationHandler.cs, ajoutez le code suivant :

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; }
}

Traitement des messages entrants Webhooks

Du point de vue de notre code, la dernière chose à faire est de créer quelques contrôleurs pour gérer les messages entrants et le statut de l'API Messages.

Dans le dossier Controllers, ajoutez 2 "API controller - Empty" appelés InboundController et StatusController.

Contrôleur d'état

Le contrôleur Status va fournir un statut aux messages de notre application au fur et à mesure qu'ils transitent par l'API. Pour garder une trace de ce qui se passe, ajoutons une méthode post au contrôleur Status pour écrire le contenu du statut dans la console de débogage :

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

Contrôleur des flux entrants

Le contrôleur de réception va gérer les messages entrants provenant de notre webhook.

Configuration de la classe

Commençons par créer un dictionnaire pour les étiquettes de formation en attente, un objet Configuration pour que le contrôleur puisse accéder à la configuration, et en injectant la configuration dans le constructeur du contrôleur entrant :

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

Traitement des messages entrants

Ensuite, nous allons écrire le gestionnaire de messages entrants. Ce gestionnaire sera une requête POST. Il vérifiera si le message contient du texte, et si le premier mot du message est "train". Si c'est le cas, il enregistrera le reste du message comme étiquette d'entraînement, et la prochaine fois que l'utilisateur enverra un message avec une image, le classificateur sera entraîné avec cette image et cette étiquette.

Pour tout autre message image, il se contentera de classer l'image et de renvoyer le résultat de la classification à l'expéditeur du message.

Dans les deux cas, il démarre un WorkItem dans le ThreadPool, en transmettant l'un de ces objets de requête ClassificationHandler que nous avons générés plus tôt - cela débloque le contrôleur pour qu'il renvoie un statut à l'API de messages (dans ce cas, un 204 pour l'informer qu'il a reçu le message).

[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;
    }
}

L'ensemencement avec un peu de données.

Vous pouvez ajouter toutes les images et tous les tags que vous souhaitez pour commencer. Par souci de simplicité, je vais commencer par une seule image, celle de mon chien (bien nommé Zero).

Training dataTraining data

Je vais placer cette image dans le répertoire assets/train.

Maintenant, puisque Zero est un whippet, je vais, dans le fichier tags.tsv du dossier assets/train, ajouter le nom de fichier "zero.jpg" suivi d'une tabulation, suivi de l'étiquette "whippet" suivi d'une nouvelle ligne.

zero.jpg    whippet

Essais

Ceci fait, il ne reste plus qu'à l'allumer, à l'exposer à l'internet et à le tester. J'utilise ngrok et IIS express pour le tester.

IIS Express Config

Tout d'abord, allez dans l'onglet debug des propriétés du projet et cherchez l'App Url - spécifiquement le port qu'il va utiliser - je décoche la case Enable SSL pour les tests.

DebugDebug

Lancez ensuite le site à partir de Visual Studio en utilisant IIS Express - vous verrez le port dans la barre d'adresse du navigateur qui s'affiche - dans mon exemple, j'ai supprimé tous les éléments du contrôleur météorologique qui sortent de la boîte, de sorte que j'obtiens un 404 lorsque je le lance - ce qui est correct, car il ne s'agit en fait que d'un service web qui écoute les webhooks et y répond. Il n'y a pas de requête get pour propager une page vers votre navigateur web.

Utilisation de Ngrok pour exposer le port à l'Internet

Pour que l'API Messages transmette les messages, nous devons exposer le site à l'internet - à des fins de test, nous utiliserons ngrok pour exposer notre port express IIS. Ouvrez votre ligne de commande et utilisez cette commande, remplacez-la par votre numéro de port.

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

Cette commande produit un résultat comme celui-ci :

Command OutputCommand Output

Configuration des Webhooks

En utilisant le lien http que nous venons d'obtenir de ngrok, vous pouvez créer l'url que le webhook rappellera - vous pouvez voir dans la route des contrôleurs que nous venons de créer à quoi la route ressemblera :

RouteRoute

Il s'agira de http://dc0feb1d.ngrok.io/api/Status pour les messages d'état et http://dc0feb1d.ngrok.io/api/Inbound pour les messages entrants

NOTE : La première partie de l'url (dc0feb1d) changera à chaque fois que vous redémarrerez ngrok sur le niveau gratuit.

Nous utiliserons ces URLs de rappel pour enregistrer nos webhooks avec Nexmo.

Allez sur ${CUSTOMER_DASHBOARD_URL} et connectez-vous à votre Account Nexmo.

Allez dans Messages et envois -> Vos applications et sélectionnez le bouton de modification de votre application.

Sur l'écran de modification, modifiez les champs Status URL et Inbound URL avec les valeurs notées ci-dessus et cliquez sur le bouton bleu de sauvegarde dans le coin inférieur droit.

Et c'est tout. Vous disposez désormais d'un classificateur/apprenant que vous pouvez alimenter en images par le biais de Messenger.

Liens utiles

Partager:

https://a.storyblok.com/f/270183/384x384/73d57fd8eb/stevelorello.png
Steve LorelloAnciens de Vonage

Ancien développeur .NET Advocate @Vonage, ingénieur logiciel polyglotte full-stack, AI/ML