Machine Learning en .NET

Python ha llegado a conquistar el mundo, y a veces pareciera que es la única herramienta que podemos usar para hacer machine learning. Sin embargo, esto no podría estar más lejos de la realidad puesto que los desarrolladores de C# podemos usar nuestro lenguaje favorito para hacer este tipo de tareas. En este post les voy a enseñar cómo es que pueden usar el poder de machine learning en .NET.

Vamos a abordar uno de los problemas que es considerado como el "hola mundo" del aprendizaje automático; me refiero a predecir la supervivencia de los pasajeros del Titanic. Obtén los datos en Kaggle.

Tres proyectos

En su forma más básica, un proyecto de machine learning se divide en dos etapas: entrenamiento y producción. Nosotros vamos a reflejar esta separación creando dos proyectos, y por la forma en que ML.NET funciona, tenemos que crear un proyecto auxiliar, es decir tres proyectos en total.

Mi recomendación es que el proyecto de entrenamiento sea una aplicación de consola (Titanic.Train), el proyecto auxiliar (Titanic.Common) sea una biblioteca de clases y el último proyecto sea la aplicación en la que vas a usar tu algoritmo ya entrenado, en mi caso, es simplemente otra aplicación de consola (Titanic.Production):

Podemos usar dotnet para crear los proyectos:

dotnet new classlib -n Titanic.Common
dotnet new console -n Titanic.Train
dotnet new console -n Titanic.Production

Lo siguiente es relacionar los proyectos entre sí:

dotnet add Titanic.Train/Titanic.Train.csproj reference Titanic.Common/Titanic.Common.csproj
dotnet add Titanic.Production/Titanic.Production.csproj reference Titanic.Common/Titanic.Common.csproj

Vamos a crear una solución para agrupar nuestros proyectos:

dotnet new sln -n Titanic
dotnet sln add \
   Titanic.Common/Titanic.Common.csproj \
   Titanic.Train/Titanic.Train.csproj \
   Titanic.Production/Titanic.Production.csproj

Al final, deberemos tener una estructura de los directorios como esta (yo borré las carpetas bin y obj):

.
├── Titanic.Common
│   ├── Class1.cs
│   └── Titanic.Common.csproj
├── Titanic.Production
│   ├── Program.cs
│   └── Titanic.Production.csproj
├── Titanic.Train
│   ├── Program.cs
│   └── Titanic.Train.csproj
└── Titanic.sln

El paquete Microsoft.ML

El framework que nos va a permitir hacer machine learning viene contenido dentro de un paquete de NuGet llamado Microsoft.ML, para agregarlo a nuestros proyectos, de nuevo vamos a usar dotnet:

dotnet add Titanic.Common/Titanic.Common.csproj package Microsoft.ML
dotnet add Titanic.Train/Titanic.Train.csproj package Microsoft.ML
dotnet add Titanic.Production/Titanic.Production.csproj package Microsoft.ML

De ahora en adelante, las clases que vamos a usar estarán dentro de alguno de los namespaces de Microsoft.ML:

El contexto

Una pieza central en ML.NET es conocida como el contexto, este es un objeto a partir del cual vamos a gestionar todo el proceso: desde la lectura de los datos hasta el entrenamiento del algoritmo de nuestra elección.

var mlContext = new MLContext(seed: 42);

El argumento seed es importante si queremos obtener resultados reproducibles si es que estamos experimentando con diversos modelos/transformaciones, cuando estés contento con un determinado modelo o transformaciones, recuerda remover este parámetro.

En entrenamiento

Leyendo los datos

Una de las características de C# es que siempre tenemos que ser muy específicos en los tipos de dato que queremos manejar dentro de nuestros programas, así que para llevar a cabo la lectura debemos crear una clase que represente cada una de nuestras observaciones, en nuestro caso, una observación es un pasajero del Titanic (o una fila dentro de nuestro archivo de datos .csv).

Aquí es importante tener como referencia nuestro archivo CSV, ya que necesitamos tener en cuenta el número de columna en el cual la información está ubicada ya que en nuestra clase modelando cada observación debemos especificar esta información por medio del decorador LoadColumn, por ejemplo, esta es una vista previa del archivo .CSV:

CSV representation

Y así es como se vería la clase Passenger para representar nuestras observaciones:

namespace Titanic.Common
{
    using Microsoft.ML.Data;

    public class Passenger
    {
        [LoadColumn(0)]
        public int Id { get; set; }

        [LoadColumn(1)]
        public int Survived { get; set; }

        [LoadColumn(2)]
        public int TicketClass { get; set; }

        [LoadColumn(3)]
        public string Name { get; set; }

        [LoadColumn(4)]
        public string Sex { get; set; }

        [LoadColumn(5)]
        public string Age { get; set; }

        [LoadColumn(6)]
        public string SiblingsOrSpouses { get; set; }

        [LoadColumn(7)]
        public string ParentsOrChildren { get; set; }

        // ...

Una vez que tenemos esta clase podemos usarla en conjunto con el contexto para leer los datos de nuestro archivo train.csv usando el método LoadFromTextFile, pasandole argumentos adicionales para especificarle que estamos leyendo un archivo CSV con encabezado:

IDataView allData = context.Data.LoadFromTextFile<Passenger>(
    path: "train.csv",
    separatorChar: ',',
    hasHeader: true
);

Train-test split

Debes saber que en para entrenar y evaluar un algoritmo de machine learning es importante contar con al menos dos conjuntos de datos: validación y prueba, no voy a entrar en detalles aquí, pero te recomiendo que le eches un ojo a mi video en YouTube sobre el tema.

Para lograr esta separación en C# es necesario usar nuevamente el contexto, para ser más específico, el método TrainTestSplit, con un argumento que nos permitirá especificarle que queremos que el 20% de los datos sean destinados al conjunto de prueba:

var splits = context.Data.TrainTestSplit(
    data: allData,
    testFraction: 0.2
);

Más adelante veremos cómo usar el objeto splits (que es un objeto de la clase DataOperationsCatalog.TrainTestData).

Transformations

Esta es una sección pesada, puesto que aquí es donde sucede una de las partes más grandes del machine learning que es el pre-procesamiento de los datos. Usualmente tenemos que realizar diversas transformaciones a nuestros datos, y aquí simplemente vamos a hablar de algunas de ellas, como practicante de la ciencia de datos es tu labor intentar diversas transformaciones para ver cuáles son las que resultan en un mejor modelo predictivo.

Es importante entender que los algoritmos de machine learning usualmente funcionan con números flotantes como entrada, entonces hay que transformar cualquier entero, cualquier cadena a una representación numérica.

Otra de las cosas importantes, y esta es específica a ML.NET es que el nombre de las columnas es de vital importancia a la hora de entrenar el modelo. Hasta el momento, nuestros datos tienen nombres como "Id", "Survived", "TicketClass", "Name"... Antes de entrenar nuestro algoritmo debemos tener dos columnas con nombres específicos: "Label" and "Features", no te preocupes, en un momento te cuento como lograr esto.

Obteniendo nuestra "Label"

El objetivo de nuestro algoritmo es encontrar si alguien sobrevivió al accidente del Titanic, hasta el momento tenemos dentro de nuestros datos la columna "Survived", pero que hasta el momento es de tipo entero, 0 para alguien que no sobrevivió, 1 para alguien que sí. Sin embargo, necesitamos transformar esta en una columna de valor bool (y además de todo, vamos a nombrar esta nueva columna con el nombre "Label"), podemos lograr esto con el siguiente código:

var booleanMap = context.Data.LoadFromEnumerable(new[]
{
    new { InputValue = 1, Value = true },
    new { InputValue = 2, Value = false },
});

var transformLabel = context.Transforms.Conversion.MapValue(
    outputColumnName: "Label",
    lookupMap: booleanMap,
    keyColumn: booleanMap.Schema["InputValue"],
    valueColumn: booleanMap.Schema["Value"],
    inputColumnName: nameof(Passenger.Survived)
);

Hay muchas cosas que están sucediendo en el código anterior, vamos a desempacar todo:

Variables categóricas

La columna TicketClass es de tipo int (hice un video sobre los tipos de variable que hay en la ciencia de datos) sin embargo el dejarla como tal no es buena idea porque la clase de los tickets es un ejemplo de una variable categórica, por tanto la mejor forma de representar esta variable es por medio de un one-hot vector:

var transformTicketClassOneHot = context.Transforms.Categorical.OneHotEncoding(
    outputColumnName: "OneHotTicketClass",
    inputColumnName: nameof(Passenger.TicketClass)
);

Tal vez comienzas a ver un patrón en la forma en que especificamos las transformaciones: un nombre de columna de entrada y otro de salida, esto es de vital importancia en el siguiente fragmento de código.

Podríamos transformar otras variables de la misma manera en que transformamos el tipo de clase, por ejemplo Sex y Embarked son otro ejemplo de variables categóricas, pero no siempre es necesario hacer una transformación para cada variable, podemos agrupar varias transformaciones similares de la siguiente manera:

var transformSeveralOneHot = context.Transforms.Categorical.OneHotEncoding(
    new InputOutputColumnPair []
    {
        new InputOutputColumnPair(inputColumnName: "Embarked", outputColumnName: "OneHotEmbarked"),
        new InputOutputColumnPair(inputColumnName: "Sex", outputColumnName: "OneHotSex"),
    }
);

¿Valores faltantes?

En nuestro dataset tenemos una columna llamada Fare, que es la tarifa que cada uno de los pasajeros pagó por su boleto. ¡Pero cuidado! que hay algunas observaciones para las que no tenemos valores. Una de las opciones que tenemos cuando nos enfrentamos a este tipo de situaciones es rellenar los valores faltantes con un valor derivado de los datos que sí tenemos.

En este caso, podemos optar por seleccionar el valor promedio como el valor a usar para rellenar los valores faltantes:

var transformFillMeanFare = context.Transforms.ReplaceMissingValues(
    outputColumnName: "Fare",
    inputColumnName: "Fare",
    replacementMode: ReplacementMode.Mean
);

Reescalando los valores

Ahora que tenemos las tarifas completas (sin valores faltantes) debemos ejecutar otro proceso que es conocido como Normalización, consiste a grandes rasgos en poner los valores de nuestras variables en escalas similares para impedir que el algoritmo reaccione fuertemente a ciertos valores.

Así es como la implementamos en ML.NET:

var transformNormaliseFare = context.Transforms.NormalizeMinMax(
    outputColumnName: "NormalisedFare",
    inputColumnName: "Fare"
);

Combinando todas nuestras transformaciones

Podríamos hacer muchas más transformaciones en nuestras variables, pero para no hacer este artículo aún más largo de lo que ya es, vamos a dejarlo ahí. El siguiente paso es obtener nuestra importantísima columna "Features" a partir de las transformaciones que ya realizamos. Vamos a concatenar todos nuestros nuevos valores con otra transformación de ML.NET:

var transformConcatenateFeatures = context.Transforms.Concatenate(
    outputColumnName: "Features",
    "NormalisedFare", "OneHotTicketClass", "OneHotEmbarked", "OneHotSex"
);

Esta transformación es para unificar todas nuestras variables bajo un nuevo nombre, nuestra preciada columna "Features".

Algoritmo predictor

El siguiente paso en el desarrollo es elegir el modelo de machine learning que vamos a utilizar ML.NET contiene varios algoritmos para diversas tareas, como nuestro problema es un problema de clasificación binaria, vamos a usar un SdcaLogisticRegression, puedes visitar este enlace para encontrar el mejor algoritmo para tu problema:

var trainer = context.BinaryClassification.Trainers.SdcaLogisticRegression(
    labelColumnName: "Label",
    featureColumnName: "Features"
);

Nuevamente, todo parte de la instancia de MLContext que creamos al inicio.

Creando un pipeline

Hasta el momento todas las partes de nuestro programa de machine learning existen aisladas unas de las otras; el siguiente paso es ensamblar todas las partes. Esta es una de las grandes ventajas que a mi parecer ML.NET nos da sobre otros frameworks, ya que nos fuerza a integrar todo lo relacionado con el modelo en una colección de operaciones, o, pipeline por su nombre en inglés.

En C#, para agrupar todas las transformaciones que hemos hecho podemos usar el método Append en ellas, de la siguiente manera:

var pipeline = transformLabel
    .Append(transformTicketClassOneHot)
    .Append(transformSeveralOneHot)
    .Append(transformFillMeanFare)
    .Append(transformNormaliseFare)
    .Append(transformConcatenateFeatures)
    .Append(trainer);

Entrenando todo el pipeline

Una vez ensamblado, podemos iniciar el entrenamiento de nuestro algoritmo. La forma de hacerlo es a través de una simple línea de código llamando al método Fit y usando simplemente los datos de entrenamiento (¿recuerdas la separación de los datos que hicimos anteriormente en la variable split?):

var trainedModel = pipeline.Fit(splits.TrainSet);

Y listo... tenemos nuestro modelo ya entrenado. Sin embargo, no es el fin de la aventura, aún hay mucho por hacer.

Evaluación

Una vez entrenado el modelo toca evaluarlo para poder decir qué tan efectivo es; mientras que cada problema tendrá sus propias formas de ser evaluado, existen un conjunto de métricas genéricas que podemos utilizar para evaluar cualquier problema que involucre una clasificación binaria:

var metrics = context.BinaryClassification.EvaluateNonCalibrated(
    data: trainedModel.Transform(splits.TestSet),
    labelColumnName: "Label"
);

Console.WriteLine($"Exactitud: {metrics.Accuracy:0.##}");
Console.WriteLine($"Precisión: {metrics.PositivePrecision:0.##}");
Console.WriteLine($"Recall:    {metrics.PositiveRecall:0.##}");

Si te das cuenta, volvemos a hacer uso del context y de nuestro trainedModel, además de nuestro TestSet. Para terminar, obtenemos en la consola los resultados de la evaluación de nuestro algoritmo (que bueno, por el momento podrán no ser los mejores)...

Guardando el modelo entrenado

Por último, si estamos contentos con los resultados de nuestro algoritmo podemos guardarlo en un archivo .zip, seguimos usando el context y el modelo recién entrenado:

context.Model.Save(
    model: trainedModel,
    inputSchema: allData.Schema,
    filePath: "model.zip"
);

Para terminar, es hora de ejecutar nuestro programa vamos a utilizar dotnet:

dotnet run --project Titanic.Train/Titanic.Train.csproj

Ante lo cual, vamos a ver las métricas en la pantalla (les dije que podrían no ser las mejores):

Exactitud: 0.69
Precisión: 0.65
Recall:    0.41

Y tendremos nuestro pipeline ya entrenado en el archivo modelo.zip:

.
├── Titanic.Common
│   ├── Passenger.cs
│   └── Titanic.Common.csproj
├── Titanic.Production
│   ├── Program.cs
│   └── Titanic.Production.csproj
├── Titanic.Train
│   ├── Program.cs
│   └── Titanic.Train.csproj
├── Titanic.sln
├── model.zip
└── train.csv

Continua experimentando

Como puedes ver, los resultados no son los mejores, todavía se pueden seguir mejorando y eso ya es tarea tuya, espero hasta el momento la forma de construir un pipeline en C# haya quedado clara. Cualquier duda o pregunta, contactame en Twitter @io_exception.@io_exception](https://twitter.com/io_exception)

En producción

El siguiente paso es implementar nuestro modelo en producción, para lo cual dejaremos en paz nuestro proyecto de entrenamiento y nos moveremos al proyecto productivo.

Leyendo el modelo entrenado

El primer paso es crear un nuevo MLContext, recuerda, en producción no vamos a usar el argumento seed:

var context = new MLContext();

Nuestro siguiente paso leer el modelo que recién entrenamos, de nuevo, usando el contexto que creamos:

var trainedModel = context.Model.Load("model.zip", out var schema);

¿Y nuestras predicciones?

Nuevamente, recordemos que C# es muy estricto con el tipado, así que hay que crear una clase para almacenar las predicciones, es importante que la clase contenga tres propiedades: PredictedLabel, Probability y Score:

namespace Titanic.Common
{
    using Microsoft.ML.Data;

    public class SurvivalPrediction
    {
        public bool PredictedLabel { get; set; }
        public float Probability { get; set; }
        public float Score { get; set; }

        // ...

Por el momento solamente nos preocuparemos por la propiedad PredictedLabel.

Ya tenemos nuestro modelo y la clase en la cual vamos a almacenar las predicciones. El siguiente, y último paso, es crear un objeto de la clase PredictionEngine:

var predictionEngine = context.Model.CreatePredictionEngine<Passenger, SurvivalPrediction>(
    transformer: trainedModel
);

Prediciendo en nuevos valores

Aquí es en donde puedes echar a andar tu imaginación (o simplemente tomar en cuenta los requisitos del negocio) a la hora de predecir los valores. Ya sea que tu modelo de machine learning esté en la nube detrás de una API REST o corriendo en una aplicación de escritorio, nuestro modelo está listo para recibir objetos de la clase Passenger y regresar un objeto de la clase SurvivalPrediction.

En este caso nosotros tenemos una simple aplicación de consola y un pasajero predefinido:

var newPassenger = new Passenger
{
    TicketClass = 1,
    Sex = "male",
    Embarked = "S",
    Fare = 11.5f
};

var prediction = predictionEngine.Predict(newPassenger);

var survivalStatus = prediction.PredictedLabel ? "sobrevivió" : "no sobrevivió";

Console.WriteLine($"El modelo dice que el pasajero... " + survivalStatus);

Ejecutamos nuestro código como:

dotnet run --project Titanic.Production/Titanic.Production.csproj

Y la salida será:

El modelo dice que el pasajero... sobrevivió.

et voilà, hemos entrenado nuestro primer modelo de machine learning en .NET con C#. Recuerda, como en la programación, lo importante es practicar, practicar, practicar. Este código está en mi repositorio ml.net en GitHub para que lo veas completo y en todo su esplendor. Si tienes dudas me puedes mandar un tweet a @io_exception para tratar de descubrirlo juntos.