
Compartir:
Antiguo desarrollador .NET Advocate @Vonage, ingeniero de software poliglota full-stack, AI/ML
"Oye Facebook, ¿qué tipo de perro es ese?" Añadir ML a Messenger
Tiempo de lectura: 9 minutos
Las redes neuronales convolucionales (CNN) proporcionan un mecanismo potente y escalable para realizar la clasificación de imágenes. Pueden ser relativamente difíciles de construir, entrenar y ajustar desde cero, que es lo que hace que herramientas como TensorFlow y los modelos de inicio sean tan indispensables para mejorar nuestros flujos de trabajo de ML.
Dicho esto, para nosotros, los usuarios de .NET, ejecutar scripts python desde un intérprete de comandos integrado en la aplicación no es la solución ideal, por lo que el lanzamiento de la biblioteca ML.NET TensorFlow es tan emocionante.
¿Y si te dijera que con sólo un par de cientos de líneas de código C# y un poco de configuración puedes crear una aplicación ASP.NET core que albergará una potente CNN con la que puedes interactuar de forma tan sencilla como enviar un mensaje con imagen a una página de Facebook?
Con una formación tan sencilla como:
Training Image
Y una solicitud de clasificación tan simple como:
Classification Request
Bueno, eso es precisamente lo que vamos a hacer - usando ML.NET, vamos a construir un potente clasificador y luego usando Messages API de Nexmo y Messenger vamos a crear un vector potente y fácil de usar para el entrenamiento y la clasificación.
Objetivos de aprendizaje
En este tutorial, vamos a:
Crear una Red Neuronal ML.NET TensorFlow
Entrenar esa red neuronal para que reconozca distintos tipos de perros
Crear un vector de mensajería para pedir a la red neuronal que clasifique perros que nunca ha visto antes.
Cree un vector de aprendizaje para que la red neuronal aprenda nuevos tipos de perros de forma dinámica.
Requisitos previos
Visual Studio 2019 versión 16.3 o superior
Una página de Facebook vinculada a tu Account Nexmo Consulte aquí la configuración
Opcional: Ngrok para el despliegue de prueba
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.
Configuración del proyecto
Lo primero es lo primero - abramos Visual Studio, creemos una nueva aplicación ASP.NET Core 3.0 API y llamémosla MessagesTensorFlow. Ahora vamos a añadir los siguientes paquetes NuGet a la solución:
Castillo hinchable
jose-jwt
Microsoft.ML
Microsoft.ML.ImageAnalytics
Microsoft.ML.TensorFlow
Newtonsoft.Json
Vamos a empezar nuestra red neuronal con el modelo Inception V1 y luego sembrarla con imágenes / etiquetas del disco. Crear una carpeta en el directorio MessagesTensorFlow llamado assets.
En activos descargar y descomprimir el Modelo Inception V1
Además, bajo assets, crea una carpeta llamada train y predict. En cada uno de esos directorios, añade un archivo tags.tsv. Su estructura de directorios debe ser algo como esto ahora:
Directory structure
Ahora vaya a cada archivo y en la sección de propiedades avanzadas establezca la opción Copiar al directorio de salida en Copiar si es más reciente
Crear al alumno
Ahora vamos a crear la clase que realmente va a contener nuestra red neuronal. Crear un archivo llamado TFEngine.cs.
Importaciones
Añade las siguientes importaciones al principio del archivo:
using Microsoft.ML;
using Microsoft.ML.Data;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net; Configuración de la clase
A continuación, dentro de la clase TFEngine vamos a añadir algunas rutas para que podamos acceder a todo lo que todos los archivos que se ingiere en nuestro modelo. Así como algunos ajustes para la gestión de los datos de inicio.
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;También vamos a configurar esta clase como un singleton y permitir sólo un acceso a ella a la vez. También vamos a añadir un webClient para descargar las URLs de las imágenes.
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();
}También vamos a crear algunos campos para mantener nuestra tubería que se utilizará para crear nuestro modelo, el modelo que se utilizará para realizar la predicción, y el MLContext.
private IEstimator<ITransformer> _pipeline;
private ITransformer _model;
private MLContext _mlContext;A continuación, añada una clase ImageData que contendrá los datos de la imagen a medida que pasan por el modelo
public class ImageData
{
[LoadColumn(0)]
public string ImagePath;
[LoadColumn(1)]
public string Label;
}A continuación, cree una estructura para albergar los datos de predicción a medida que salen del modelo:
public class ImagePrediction : ImageData
{
public float[] Score;
public string PredictedLabelValue;
}La puntuación será una matriz que contendrá las probabilidades que la red neuronal asigna a cada etiqueta posible, y el ValorEtiquetaPredicho será, por supuesto, la predicción de la red (el elemento con la puntuación más alta).
Formación de modelos
Ahora es el momento de entrenar nuestro modelo.
Añadir un método llamado 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";
}
}Este es realmente el corazón de lo que va a hacer que nuestro predictor funcione. La sección '_pipeline =' es una cadena de comandos que:
Cargar las imágenes del disco
Redimensionar las imágenes para su ingestión
Extraer y vectorizar los píxeles de las imágenes
Cargar el modelo TensorFlow de inicio (esencialmente nuestra red neuronal prefabricada).
Crear un modelo de entrenamiento y ejecutar los datos de entrenamiento a través de él para crear un modelo de predicción para su uso.
Clasificación de una sola imagen
Con nuestro modelo entrenado ahora podemos crear un método que tomará un nombre de archivo y devolverá una cadena que contiene una predicción y la confianza de la red en la predicción. Esta función toma un imageUrl, guarda el archivo en el disco, clasifica la imagen y devuelve una cadena que contiene los clasificadores conjetura con su confianza.
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";
}
} Añadir datos de entrenamiento
La operación final que vamos a pedir al motor de flujo tensorial es esencialmente la inversa de la predicción, le pediremos que acepte una URL de imagen y una etiqueta y que se actualice para reconocer mejor las imágenes de esa etiqueta. El AddTrainingImage guarda la imagen proporcionada en el disco, añade información sobre las imágenes al archivo tags.tsv, y regenera el 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";
}
} Uso de la Messages API para impulsar la clasificación y la formación
Mensajes Objetos
A continuación vamos a añadir algunos POCOs para mantener nuestros datos de mensajería, ya que entra y sale de la API de Messages - estos objetos son bastante verboso y no hacen nada particularmente interesante aparte de permitir la serialización / deserialización de JSON por lo que, en aras de la brevedad no dude en utilizar simplemente las siguientes estructuras:
Interacción con la API
La creación de estas estructuras nos libera para gestionar los datos que estamos recibiendo y enviando a la Messages API. Sin embargo, necesitamos un paso más para poder utilizar realmente la API: tendremos que generar un JWT para autenticar nuestra aplicación con la Messages API. Para ello, vamos a crear los siguientes archivos.
TokenGenerator.cs
MessageSender.cs
Generar JWT
TokenGenerator va a tener un método estático GenerateToken que aceptará una lista de Claims y la privateKey de tu aplicación
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);
}
}
}
}Esto generará un JWT para su uso con la Messages API.
Generar una lista de reclamaciones para JWT
En el MessageSender.cs tendremos un método para generar los claims para el JWT a partir de su 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;
} Leer la configuración de la aplicación y crear JWT
Entonces tendremos otro método para leer los elementos relevantes de su configuración, que el controlador nos entregará a través de Inyección de Dependencia, recuperar la lista de reclamaciones, y construir el 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;
}Esto requerirá, por supuesto, un par de elementos en su archivo appsettings.json Añada el siguiente objeto a su archivo appsettings.json y rellénelo con los valores apropiados:
"Authentication": {
"appId": "app_id",
"privateKey": "path_to_key_file"
} Enviar un mensaje
Ahora vamos a unir todo esto con nuestro método SendMessage, que tomará nuestro mensaje, toId, fromId, y config. Este método generará un JWT y enviará una solicitud a la Messages API para enviar un mensaje con la información de nuestro clasificador a nuestro usuario 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 clasificación
Vamos a querer que el manejo de los webhooks entrantes sea asíncrono y responda inmediatamente, por lo que vamos a crear un archivo ClassificationHandler.cs para manejar realmente las operaciones de clasificación / respuesta. Este archivo contendrá un par de pequeñas estructuras que nos permitirán desempaquetar, clasificar o entrenar, y responder a los mensajes entrantes.
En ClassificationHandler.cs añada el siguiente 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; }
} Manejar mensajes entrantes Webhooks
Desde la perspectiva de nuestro código lo último que vamos a necesitar hacer es crear un par de controladores para manejar los mensajes entrantes y el estado de la Messages API.
En la carpeta Controllers, añada 2 "API controller - Empty" llamados InboundController y StatusController.
Controlador de estado
El controlador de Estado va a proporcionar el estado de los mensajes de nuestra aplicación a medida que fluyen a través de la API, para realizar un seguimiento de lo que está pasando, vamos a añadir un método post al controlador de Estado para escribir el contenido del estado a la consola de depuración:
[HttpPost]
public HttpStatusCode Post([FromBody]StatusMessage message)
{
Debug.WriteLine(JsonConvert.SerializeObject(message));
return HttpStatusCode.NoContent;
} Controlador de entrada
El controlador de entrada va a gestionar los mensajes de entrada de nuestro webhook.
Configuración de la clase
Primero vamos a configurarlo creando un diccionario para las etiquetas de entrenamiento pendientes, un objeto Configuration para que el controlador acceda a la configuración, y mediante Dependency Injecting la Configuration en el constructor Inbound Controller:
public static Dictionary<string, string> _pendingTrainLabels = new Dictionary<string, string>();
public IConfiguration Configuration { get; set; }
public InboundController(IConfiguration configuration)
{
Configuration = configuration;
} Gestión de mensajes entrantes
A continuación, escribiremos el manejador de InboundMessage. Este manejador será una petición POST. Comprobará si hay texto en el mensaje. Si es así, guardará el resto del mensaje como una etiqueta de entrenamiento, y la próxima vez que el usuario envíe un mensaje con una imagen, el clasificador será entrenado con esa imagen y etiqueta.
En cualquier otro mensaje de imagen, simplemente clasificará la imagen y enviará el resultado de la clasificación al remitente del mensaje.
En ambos casos se inicia un WorkItem en el ThreadPool, pasando uno de esos prácticos objetos de petición ClassificationHandler que generamos antes - esto desbloquea el controlador para enviar un estado de vuelta a la api de mensajes (en este caso un 204 para informarle de que ha recibido el mensaje)
[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;
}
} Sembrar con pocos datos.
Puedes añadir las imágenes y etiquetas que quieras para empezar. Para simplificar, voy a empezar con una sola imagen: la de mi perro (llamado Zero).
Training data
Voy a poner esa imagen en el directorio assets/train.
Ahora bien, como Zero es un whippet, voy a añadir, en el archivo tags.tsv de la carpeta assets/train, el nombre de archivo 'zero.jpg' seguido de un tabulador, seguido de la etiqueta 'whippet' seguida de una nueva línea
zero.jpg whippet Pruebas
Con esto hecho, todo lo que queda por hacer es encenderlo, exponerlo a Internet, y probarlo. Yo uso ngrok y IIS express para probarlo.
Configuración de IIS Express
Primero ve a la pestaña de depuración de las propiedades del proyecto y busca la Url de la aplicación - específicamente qué puerto va a estar usando - yo desmarco la casilla Habilitar SSL para pruebas.
Debug
A continuación, inicie el sitio de Visual Studio utilizando IIS Express - verá el puerto en la barra de direcciones del navegador que aparece - en mi ejemplo he limpiado todo el controlador meteorológico cosas que viene fuera de la caja, así que me sale un 404 cuando me disparo - que está bien ya que esto es realmente sólo actúa como un servicio web para escuchar y responder a webhooks. No hay ninguna solicitud get para propagar una página de nuevo a su navegador web.
Uso de Ngrok para exponer el puerto a Internet
Para que la Messages API reenvíe los mensajes necesitaremos exponer el sitio a Internet - para las pruebas, utilizaremos ngrok para exponer nuestro puerto IIS express. Abre tu línea de comandos y utiliza este comando, sustitúyelo por tu número de puerto.
ngrok http --host-header="localhost:" http://localhost:
Este comando produce una salida como ésta:
Command Output
Configuración de los Webhooks
Usando el enlace http que acabamos de obtener de ngrok puedes crear la url a la que llamará el webhook - puedes ver en la Ruta de los controladores que acabamos de hacer el aspecto que tendrá la ruta:
Route
Va a funcionar para ser http://dc0feb1d.ngrok.io/api/Status para los mensajes de estado y http://dc0feb1d.ngrok.io/api/Inbound para los mensajes de entrada
NOTA: La primera parte de la url (dc0feb1d) cambiará cada vez que reinicie ngrok en el nivel gratuito.
Usaremos esas URLs de callback para registrar nuestros webhooks con Nexmo.
Vaya a ${CUSTOMER_DASHBOARD_URL} e inicie sesión en su cuenta de Nexmo
Vaya a mensajes y envío -> Sus aplicaciones y seleccione el botón de edición de su aplicación
En la pantalla de edición, cambie los campos URL de estado y URL de entrada a los valores indicados anteriormente y haga clic en el botón azul de guardar situado en la esquina inferior derecha.
Y ya está. Ahora tienes un clasificador / aprendiz que puede alimentar imágenes a través de Messenger.
Enlaces útiles
Documentación de ML.NET Tensor Flow para más información sobre ML.NET Tensor Flow - de hecho, la función GenerateModel de TFEngine deriva de este tutorial.
