2

ML.NET Predictions on the Web in F# with the SAFE Stack

 3 years ago
source link: https://jonwood.co/blog/2020/12/22/mlnet-predictions-on-the-web-in-f-with-the-safe-stack
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
6.png

Updating the Shared File

Now we can update the file in the Shared project. We can put types and methods in this file that we know will be used in more than one other project. For our case, we can use the model input and output schemas.

type SalaryInput = {
    YearsExperience: float32
    Salary: float32
}

[<CLIMutable>]
type SalaryPrediction = {
    [<ColumnName("Score")>]
    PredictedSalary: float32
}

The SalaryInput class has two properties that are both of type float32. The SalaryPrediction class is special where we need to put the CLIMutable attribute on it. That has one property that's also of type float32. This property has the ColumnName attribute on it to map to the output column from the ML.NET model.

There's one other type we can add to our shared file. We can create an interface that has a method to get our predictions that can be called from the client to the server.

type ISalaryPrediction = { getSalaryPrediction: float32 -> Async<string> }

In this type, we create a method signature called getSalaryPrediction which takes in a paramter of type float32 and it returns a type of Async of string. So this method is asynchornous and will return a string result.

Updating the Server

Next, we can update our server file. This file contains the code to run the web server and any other methods that we may need to call from the client.

To run the web app you have the following code:

let webApp =
    Remoting.createApi()
    |> Remoting.withRouteBuilder Route.builder
    |> Remoting.fromValue predictionApi
    |> Remoting.buildHttpHandler

let app =
    application {
        url "http://0.0.0.0:8085"
        use_router webApp
        memory_cache
        use_static "public"
        use_gzip
    }

run app

The app variable creates an application instance and sets some properties of the web app, such as what the URL is, what router to use, and to use GZip compression. You can also add items such as using OAuth, set logging, or enable CORS.

The webApp variable creates the API and builds the routing. Both of these are based on the predictionApi variable which is based off the ISalaryPrediction type we defined in the shared file.

let predictionApi = { getSalaryPrediction =
    fun yearsOfExperience -> async {
        let prediction = prediction.PredictSalary yearsOfExperience
        match prediction with
        | p when p.PredictedSalary > 0.0f -> return p.PredictedSalary.ToString("C")
        | _ -> return "0"
    } }

The API has the one method we defined in the interface - getSalaryPrediction. This is where we implement that interface method. It takes in a variable, yearsOfExperience, and it runs an async method defined by the async keyword. In the brackets is what it should run.

All we are running in there is to use a prediction variable to call the PredictSalary method on it and pass in the years of experience variable to it. With the value from that we do a match expression and if the PredictedSalary property is greater than 0 we return that property formatted as a currency. If it is 0 or below, we just return the string "0".

But where did the prediction variable come from? Just above the API implementation, a new Prediction type is created.

type Prediction () =
    let context = MLContext()

    let (model, _) = context.Model.Load("./MLModel/salary-model.zip")

    let predictionEngine = context.Model.CreatePredictionEngine<SalaryInput, SalaryPrediction>(model)

    member __.PredictSalary yearsOfExperience =
        let predictedSalary = predictionEngine.Predict { YearsExperience = yearsOfExperience; Salary = 0.0f }

        predictedSalary

This creates the instance of the MLContext. It also loads in the model file, and creates a PredictionEngine instance from the model. Remember the SalaryInput and SalaryPrediction types are from the shared project. And notice that, when we load from the model, it returns a tuple. The first value returns the model whereas the second value returns the DataViewSchema. Since we don't need the DataViewSchema in our case, we can ignore it using an underscore (_) for that variable.

This type also creates a member method called PredictSalary. This is where we call the predictionEngine.Predict method and give it an instance of SalaryInput. Because F# is really good at inferring types, we can just give it the YearsExperience property and it knows that it is the SalaryInput type. We do need to supply the Salary property as well, but we can just set that to 0.0. Then, we return the predicted salary from this method. In F# we don't need to specify the return keyword. It automatically returns if it's the last item in the method.

Updating the Client

With the server updated to do what we need, we can now update the client to use the new information. Everything we need to update will be in the Index.fs file.

There are a few Todo items that it's trying to use here from the Shared project. We'll have to update these to use our new types.

First, we have the Model type. This is the state of our client side information. For the Todo application, it has two properties, Todos and Input. The Input property is the current input in the text box and the Todos property are the currently displayed Todos. So to update this we can change the Todos property to be PredictedSalary to indicate the currently predicted salary from the input of the years of experience. This property would need to be of type string.

type Model =
    { Input: string
      PredictedSalary: string }

The next part to update is the Msg type. This represents the different events that can update the state of your application. For todos, that can be adding a new todo or getting all of the todos. For our application we will keep the SetInput message to get the value of our input text box. We will remove the others and add two - PredictSalary and PredictedSalary. The PredictSalary message will initiate the call to the server to get the predicted salary from our model, and the PredictedSalary message will initiate when we got a new salary from the model so we can update our UI.

type Msg =
    | SetInput of string
    | PredictSalary
    | PredictedSalary of string

For the todosApi we simply rename it to predictionApi and change it to use the ISalaryPrediction instead of the ITodosApi.

let predictionApi =
    Remoting.createApi()
    |> Remoting.withRouteBuilder Route.builder
    |> Remoting.buildProxy<ISalaryPrediction>

The init method can be updated to use our updated model. So instead of having an array of Todos we just have a string of PredictedSalary.

let init(): Model * Cmd<Msg> =
    let model =
        { Input = ""
          PredictedSalary = "" }
    model, Cmd.none

Next, we update the update method. This takes in a message and will perform the work depending on what the message is. For the Todos app, if the message comes in as AddTodo it will then call the todosApi.addTodo method to add the todo to the in-memory storage. In our app, we will keep the SetInput message and add two more to match what we added in our Msg type from above. The PredictSalary message will convert the input from a string to a float32 and pass that into the predictionApi.getSalaryPrediction method. The PredictedSalary message will then update our current model with the new salary.

let update (msg: Msg) (model: Model): Model * Cmd<Msg> =
    match msg with
    | SetInput value ->
        { model with Input = value }, Cmd.none
    | PredictSalary ->
        let salary = float32 model.Input
        let cmd = Cmd.OfAsync.perform predictionApi.getSalaryPrediction salary PredictedSalary
        { model with Input = "" }, cmd
    | PredictedSalary newSalary ->
        { model with PredictedSalary = newSalary }, Cmd.none

The last thing to update here is in the containerBox method. This builds up the UI. You may have already noticed that there is no HTML in our solution anywhere. That's because Fable is using React behind the scenes and we are able to write the HTML in F#. We'll keep the majority of the UI so there's only a few items to update. The content is what's currently holding the list of todos in the current app. For our case, however, we want it to show the predicted salary so we'll remove the ordered list and replace it with the below div. This sets a label and, if the model.PredictedSalary is empty it doesn't display anything. But if it isn't empty it does a formatted string containg the predicted salary.

div [ ] [ label [ ] [ if not (System.String.IsNullOrWhiteSpace model.PredictedSalary) then sprintf "Predicted salary: %s" model.PredictedSalary |> str ]]

Next, we just need to update the placeholder in the text box to match what we would like the user to do.

Control.p [ Control.IsExpanded ] [
                Input.text [
                  Input.Value model.Input
                  Input.Placeholder "How many years of experience?"
                  Input.OnChange (fun x -> SetInput x.Value |> dispatch) ]
            ]

And with the button we just need to tell it to dispatch, or fire off a message, to the PredictSalary message.

Button.a [
   Button.Color IsPrimary
   Button.OnClick (fun _ -> dispatch PredictSalary)
]

With all of those updates we can now run the app again to see how it goes.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK