Serverless Machine Learning with ML.NET and Azure Functions

Introduction

In a previous blog post, I explored how to build and deploy machine learning models built with the ML.NET framework using an ASP.NET Core Web API, Docker and Azure Container Instances. While this is certainly a good way to deploy such models especially those that are critical and require high availability and/or consist of long-running processes, it's not the case when those requirements are not needed. In such cases serverless computing makes more sense from a cost and resource utilization standpoint. Therefore, in this blog post I will go over how to train a classification model with ML.NET and deploy it using Azure Functions. Source code for this post can be found at the following link.

Prerequisites

Prior to starting, make sure you have all of the necessary software to build this project. Although this project was built on a system running Ubuntu 16.04 it should work cross-platform.

Set Up Azure Environment

Before writing any code we want to configure our Azure environment. To do so we'll be using the Azure CLI. Although in these examples I am providing the resource group name, storage account name and function application name feel free to use one of your choosing. Naming is not as important for resource group or storage account but definitely is the case for the application.

Fist we want to log into our account using the following command

az login

This will guide you through a series of prompts that will eventually result in you being logged in. To make sure you are logged in you can use the account command.

az account list

The following output should appear if successfull.

[
  {
    "cloudName": "AzureCloud",
    "id": "<YOUR-ID>",
    "isDefault": true,
    "name": "Pay-As-You-Go",
    "state": "Enabled",
    "tenantId": "<YOUR-TENANT-ID>",
    "user": {
      "name": "<YOUR-USERNAME>",
      "type": "user"
    }
  }
]

Next, we want to create a resource group to contain all of our Azure resources for this application.

az group create --name azfnmlnetdemo --location eastus

Once our resource group is created, it's time to start adding resources for it. First we'll add a storage account which will contain our trained model.

az storage account create --name azfnmlnetdemostorage --location eastus --resource-group azfnmlnetdemo --sku Standard_LRS

Then we'll create an Serverless Function Application and link it to our storage account. We'll want to create a unique name for it. An easy way to do so is to add the date to the end of the name of our application (i.e. myappname20180816).

az functionapp create --name azfnmlnetdemo20180821 --storage-account azfnmlnetdemostorage --consumption-plan-location eastus --resource-group azfnmlnetdemo

The final step in the environment setup is to set the runtime of our Serverless Function Application in the Application Settings to beta which supports .NET Core.

az functionapp config appsettings set --name azfnmlnetdemo20180821 --resource-group azfnmlnetdemo --settings FUNCTIONS_EXTENSION_VERSION=beta

Now we're ready to build our machine learning model and upload it to our storage account

Building The Model

Once our environment is set up we can start building our solution. The first step is to create a directory and initialize our solution inside of it.

Set Up The Solution

mkdir azfnmlnetdemo
cd azfnmlnetdemo
dotnet new sln

Create The Model Project

Then we want to create a console project for our model and add it to our solution.

dotnet new console -o model
dotnet sln add model/model.csproj

Add Dependencies

Since we’ll be using the ML.NET framework, we need to add it to our model project.

cd model
dotnet add package Microsoft.ML
dotnet restore

Download The Data

Before we start training the model, we need to download the data we’ll be using to train. We do so by creating a directory called data and downloading the data file onto there.

mkdir data
curl -o data/iris.txt https://archive.ics.uci.edu/ml/machine-learning-databases/iris/iris.data

If we take a look at the data file, it should look something like this:

5.1,3.5,1.4,0.2,Iris-setosa
4.9,3.0,1.4,0.2,Iris-setosa
4.7,3.2,1.3,0.2,Iris-setosa
4.6,3.1,1.5,0.2,Iris-setosa
5.0,3.6,1.4,0.2,Iris-setosa
5.4,3.9,1.7,0.4,Iris-setosa
4.6,3.4,1.4,0.3,Iris-setosa
5.0,3.4,1.5,0.2,Iris-setosa
4.4,2.9,1.4,0.2,Iris-setosa
4.9,3.1,1.5,0.1,Iris-setosa

Train The Model

Now that we have all our dependencies set up, it’s time to train our model. I leveraged the demo that is used on the ML.NET Getting-Started website.

Define Data Structures

In the root directory of our model project, let’s create two classes called IrisData and IrisPrediction which will define our features and predicted attribute respectively. Both of them will use Microsoft.ML.Runtime.Api to add the property attributes.

Here is what our IrisData class looks like:

using Microsoft.ML.Runtime.Api;

namespace model
{
    public class IrisData
    {
        [Column("0")]
        public float SepalLength;

        [Column("1")]
        public float SepalWidth;

        [Column("2")]
        public float PetalLength;

        [Column("3")]
        public float PetalWidth;

        [Column("4")]
        [ColumnName("Label")]
        public string Label;
    }
}

Similarly, here is the IrisPrediction class:

using Microsoft.ML.Runtime.Api;

namespace model
{
    public class IrisPrediction
    {
        [ColumnName("PredictedLabel")]
        public string PredictedLabels;
    }
}

Build the Training Pipeline

The way the ML.NET computations process data is via a sequential pipeline of steps that are performed eventually leading up to the training of the model. Therefore, we can create a class called Model to perform all of these tasks for us.

using Microsoft.ML.Data;
using Microsoft.ML;
using Microsoft.ML.Runtime.Api;
using Microsoft.ML.Trainers;
using Microsoft.ML.Transforms;
using Microsoft.ML.Models;
using System;
using System.Threading.Tasks;

namespace model
{
    class Model
    {

        public static async Task<PredictionModel<IrisData,IrisPrediction>> Train(LearningPipeline pipeline, string dataPath, string modelPath)
        {
            // Load Data
            pipeline.Add(new TextLoader(dataPath).CreateFrom<IrisData>(separator:','));

            // Transform Data
            // Assign numeric values to text in the "Label" column, because
            // only numbers can be processed during model training
            pipeline.Add(new Dictionarizer("Label"));

            // Vectorize Features
            pipeline.Add(new ColumnConcatenator("Features", "SepalLength", "SepalWidth", "PetalLength", "PetalWidth"));

            // Add Learner
            pipeline.Add(new StochasticDualCoordinateAscentClassifier());

            // Convert Label back to text
            pipeline.Add(new PredictedLabelColumnOriginalValueConverter() {PredictedLabelColumn = "PredictedLabel"});

            // Train Model
            var model = pipeline.Train<IrisData,IrisPrediction>();

            // Persist Model
            await model.WriteAsync(modelPath);

            return model;
        }
    }
}

In addition to building our pipeline and training our machine learning model, the Model class also serialized and persisted the model for future use in a file called model.zip.

Test The Model

Now that we have our data structures and model training pipeline set up, it’s time to test everything to make sure it’s working. We’ll put our logic inside of our Program.cs file.

using System;
using Microsoft.ML;

namespace model
{
    class Program
    {
        static void Main(string[] args)
        {

            string dataPath = "model/data/iris.txt";

            string modelPath = "model/model.zip";

            var model = Model.Train(new LearningPipeline(),dataPath,modelPath).Result;

            // Test data for prediction
            var prediction = model.Predict(new IrisData()
            {
                SepalLength = 3.3f,
                SepalWidth = 1.6f,
                PetalLength = 0.2f,
                PetalWidth = 5.1f
            });

            Console.WriteLine($"Predicted flower type is: {prediction.PredictedLabels}");
        }
    }
}

All set to run. We can do so by entering the following command from our solution directory:

dotnet run -p model/model.csproj

Once the application has been run, the following output should display on the console.

Automatically adding a MinMax normalization transform, use 'norm=Warn' or
'norm=No' to turn this behavior off.Using 2 threads to train.
Automatically choosing a check frequency of 2.Auto-tuning parameters: maxIterations = 9998.
Auto-tuning parameters: L2 = 2.667734E-05.
Auto-tuning parameters: L1Threshold (L1/L2) = 0.Using best model from iteration 882.
Not training a calibrator because it is not needed.
Predicted flower type is: Iris-virginica

Additionally, you’ll notice that a file called model.zip was created in the root directory of our model project. This persisted model can now be used outside of our application to make predictions, but first we need to upload it to our Azure Storage account.

Upload The Model

Now that we have a trained model and it has been persisted to the model.zip file, it's time to upload it to Azure Storage so that it is available to our Azure Functions application.

To get started with that, first we need the access keys for our storage account. You can get those by using the following command.

az storage account keys list --account-name azfnmlnetdemostorage --resource-group azfnmlnetdemo

The result of that command should return your primary and secondary keys. You can use either one for the following steps.

Although we can upload directly to the account, it's best to create a container to upload our model to. To keep it simple, I'll call the container models.

az storage container create --name models --account-key <YOUR-ACCOUNT-KEY> --account-name azfnmlnetdemostorage --fail-on-exist

Once our container's created, we can upload our model.zip file to it.

az storage blob upload --container-name models --account-name azfnmlnetdemostorage --file model/model.zip --name model.zip

To verify that the file has been uploaded, you can list the files inside the models storage container.

az storage blob list --container-name models --account-name azfnmlnetdemostorage --output table

That command should produce output similar to that below:

Name       Blob Type    Blob Tier    Length    Content Type     Last Modified              Snapshot
---------  -----------  -----------  --------  ---------------  -------------------------  ----------
model.zip  BlockBlob                 4373      application/zip  2018-08-21T19:26:09+00:00

That's all there is to the upload process. It's now time to build our Azure Functions Application

Build The Azure Functions Application

Initialize Azure Function Project

In our solution directory, we want to create a new directory for our Azure Function project

mkdir serverlessfunctionapp
dotnet sln add serverlessfunctionapp/serverlessfunctionapp.csproj

Then, we can scaffold an Azure Functions project inside our newly created serverlessfunctionapp project directory using Azure Functions Core Tools

cd serverlessfunctionapp
func init

At this point you will be prompted to select the runtime for your application. For this application select dotnet.

This will generate a few files in the serverlessfunctionapp directory. Keep in mind though that this does not create the function.

Add Dependencies

Before we create any functions, we need to add the dependencies for our Azure Functions application. Since we'll be using Microsoft.ML in our Azure Function application, we'll need to add it as a dependency. From the serverlessfunctionapp enter the following command:

dotnet add package Microsoft.ML
dotnet restore

Create Serverless Function

Once we've added the dependencies it's time to create a new function. To do so we'll use the Azure Functions Core Tools new command. Although not required, it's good practice to separate functions and related files into their own directory.

mkdir Predict
cd Predict
func new

At this time you will be prompted to select a template. For our classification model, we'll be using an HttpTrigger which is exactly what it sounds like. An HTTP request is what calls or invokes our function. With that being said, select the HttpTrigger option.

You will then be prompted to enter a name for your function. You can use any name but to make things easy, name it the same as the directory the function is in. Once that process is complete, there should be a file called Predict.cs inside our serverlessfunctionapp/Predict directory. This is where we'll write the logic for our application.

Define Data Structures

We'll also be making use of the IrisData and IrisPrediction classes inside our Predict function. Therefore, we need to create classes for them inside our Predict directory. The content will be the same as when we trained our model with the exception of the namespace which will now be serverlessfunctionapp.Predict. The content of those files should look like the code below:

//IrisData.cs
using Microsoft.ML.Runtime.Api;

namespace serverlessfunctionapp.Predict
{
    public class IrisData
    {
        [Column("0")]
        public float SepalLength;

        [Column("1")]
        public float SepalWidth;

        [Column("2")]
        public float PetalLength;

        [Column("3")]
        public float PetalWidth;

        [Column("4")]
        [ColumnName("Label")]
        public string Label;
    }
}

//IrisPrediction.cs
using Microsoft.ML.Runtime.Api;

namespace serverlessfunctionapp.Predict
{
    public class IrisPrediction
    {
        [ColumnName("PredictedLabel")]
        public string PredictedLabels;
    }
}

Write Function Logic

With our dependencies and data structures set up, it's time to write our function logic to make predictions. The first thing we want to do is replace the Run method inside the Predict class with the following code.

public static IActionResult Run(
    [HttpTrigger(AuthorizationLevel.Function, "get", "post", Route = null)]HttpRequest req,
    [Blob("models/model.zip", FileAccess.Read, Connection = "AzureWebJobsStorage")] Stream serializedModel,
    TraceWriter log)
{
    // Workaround for Azure Functions Host
    if (typeof(Microsoft.ML.Runtime.Data.LoadTransform) == null ||
        typeof(Microsoft.ML.Runtime.Learners.LinearClassificationTrainer) == null ||
        typeof(Microsoft.ML.Runtime.Internal.CpuMath.SseUtils) == null ||
        typeof(Microsoft.ML.Runtime.FastTree.FastTree) == null)
    {
        log.Error("Error loading ML.NET");
        return new StatusCodeResult(500);
    }

    //Read incoming request body
    string requestBody = new StreamReader(req.Body).ReadToEnd();

    log.Info(requestBody);

    //Bind request body to IrisData object
    IrisData data = JsonConvert.DeserializeObject<IrisData>(requestBody);

    //Load prediction model
    var model = PredictionModel.ReadAsync<IrisData, IrisPrediction>(serializedModel).Result;

    //Make prediction
    IrisPrediction prediction = model.Predict(data);

    //Return prediction
    return (IActionResult)new OkObjectResult(prediction.PredictedLabels);
}

There are a few notable change worth looking at. One of them is the workaround at the beginning of the function.

if (typeof(Microsoft.ML.Runtime.Data.LoadTransform) == null ||
    typeof(Microsoft.ML.Runtime.Learners.LinearClassificationTrainer) == null ||
    typeof(Microsoft.ML.Runtime.Internal.CpuMath.SseUtils) == null ||
    typeof(Microsoft.ML.Runtime.FastTree.FastTree) == null)
{
    log.Error("Error loading ML.NET");
    return new StatusCodeResult(500);
}

There are some issues with Azure Functions and ML.NET Assemblies which are being worked on by both teams at Microsoft (see Github Issue). In the meantime, it's safe to just include that code in there.

The other addition to note is the method signature. As you can see, I have added an additional parameter called serializedModel which is decorated by the Blob attribute.

[Blob("models/model.zip", FileAccess.Read, Connection = "AzureWebJobsStorage")] Stream serializedModel

What this code is doing is telling the function to import the blob model.zip as a Stream and bind it to serializedModel. Using additional arguments, I tell my function to only have Read access to the model.zip blob inside the models container which can be accessed with the AzureWebJobsStorage connection string. Right now that last part might seem confusing, but this is something we configured when we set up the Azure environment and linked azfnmlnetdemostorage account with our azfnmlnetdemo20180821 serverless function app using the --storage-account option. Although the production environment is configured, if we try to test our application locally we won't be able to access our storage account because we have not configured the connection string locally. We can do so by looking in the local.settings.json file inside our serverlessfunctionapp directory. The contents should look like the following.

{
  "IsEncrypted": false,
  "Values": {
    "AzureWebJobsStorage": "",
    "AzureWebJobsDashboard": "",
    "FUNCTIONS_WORKER_RUNTIME": "dotnet"
  }
}

Our function running locally will look in this file, try to find AzureWebJobsStorage and use the connection string value in the Predict function. To get the connection string for our azfnmlnetdemostorage account, enter the following command.

az storage account show-connection-string --name azfnmlnetdemostorage

The output of that command should look like the following:

{
  "connectionString": "<YOUR-CONNECTION-STRING>"
}

At this point, you just need to copy the value of connectionString to your local.settings.json file and replace the current empty string for AzureWebJobsStorage. It's important to note that it's okay to paste the connection string in here since the local.settings.json file is not committed to version control. (See .gitignore inside serverlessfunctionapp directory). Now the application is ready to be tested locally.

Testing The Function Locally

To test the application, first build your project by entering the following command from the serverlessfunctionapp directory.

dotnet build

Then, navigate to the build directory ./bin/Debug/netstandard2.0 and enter the following command:

func host start

Finally, using a tool like Postman or Insomnia make an HTTP POST request to the http://localhost:7071/api/Predict endpoint with the following body:

{
  "SepalLength": 3.3,
  "SepalWidth": 1.6,
  "PetalLength": 0.2,
  "PetalWidth": 5.1
}

If everything is set up correctly, you should receive the following output

Iris-virginica

Once satisfied with testing, press Ctrl + C to stop the application.

Deploy To Azure

Push Build

Great! Now on to the final step, deploying our application to production. Since we already configured everything it should only require a few commands to do so.

First, make sure you are logged in. Using Azure Functions Core Tools log in with the following command:

func azure login

Like with the Azure CLI, you will follow a series of prompts to log into your account.

Once you have successfully logged in, it's time to publish our application to Azure. From the serverlessfunctionapp directory enter the following command.

func azure functionapp publish azfnmlnetdemo20180821

When our deployment is complete, we can check whether our function was published successfully by using the following command.

func azure functionapp list-functions azfnmlnetdemo20180821

The output should look similar to that below.

Functions in azfnmlnetdemo20180821:
    Predict - [httpTrigger]

Configure Platform

For the last part of the deployment step, we'll need to head over to the Azure Portal. To do so, visit https://portal.azure.com and log in.

Once logged in, type the name of your application into the search bar at the top of the page and select your Azure Function application of type App Service

Then, from the accordion element on the left, select the top-most item with your appplication name on it. Then, select the Platform features tab and open the Application settings option.

When the Application settings page loads, change the Platform setting to 64-bit. The reason for this is ML.NET has to be built and run on a 64-bit environment due to some of its native dependencies.

That's all there is to it.

Test The Deployed Function

Now it's time to test our deployed function. We can do so from the portal by going back to the accordion and selecting the function name below the Functions parent element and clicking on the Test button on the far right. Doing so will show a form that will allow us to test our application. Make sure the HTTP method option is set to POST. In the text area for the Request body paste the following content:

{
  "SepalLength": 3.3,
  "SepalWidth": 1.6,
  "PetalLength": 0.2,
  "PetalWidth": 5.1
}

Once the form is filled in, click Run at the top of the page and if successful Iris-virginica should show up in the Output area.

To test the function outside the portal, you can click on the Get function URL link next to the Run button and make an HTTP POST request using that link.

Conclusion

In this writeup, we trained a classification model that predicts a class of flower using Microsoft's ML.NET framework. Then, we exposed this model for inference via an Azure Functions serverless application. In doing so, we can more efficiently manage our cost as well as our resource utilization. Happy coding!

Resources

Create a function app for serverless code execution
Using the Azure CLI 2.0 with Azure Storage
Work with Azure Functions Core Tools


Send me a message or webmention