Serving a machine learning model via API

About the Plumer package

In oder to serve our API we will make use of the great Plumer package in R

To read more about this package go to:
https://www.rplumber.io/docs/

Setup

Load in some packages.
If you are going to host the api on a suse or redhat linux server make sure you have all the dependencies as well as the packages installed to follow through this example yourself.

library(EightyR)
load_toolbox()
load_pkg(c("plumber","caret"))

In order to serve an api you need to store all the functions that you want to serve inside a R script.

Hello world example

As our “hello world” example we create a script called hello_api.R:

#hello_api.R

#* @get /mean
normalMean <- function(samples=10){
  data <- rnorm(samples)
  mean(data)
}

#* @post /sum
addTwo <- function(a, b){
  as.numeric(a) + as.numeric(b)
}

Notice that we use the special comments #* to denote the type of api request and also the name of the request.

We can spin up this api on a specified local port using the following R code in another script run_hello_api.r:

#!/usr/bin/Rscript

# run_hello_api.r
library(EightyR)
load_toolbox()
load_pkg("plumber")

r <- plumb("hello_api.R")  # Where 'plumber.R' is the location of the file shown above
r$run(host = "0.0.0.0", port=8000)

Remember to give this file execution rights if you want to run it using ./, you can do this using the command chmod

We will run this process in a shell using the following bash code:

./run_hello_api.r 

Now that this process is running we can make some requests:

curl "http://127.0.0.1:8000/mean"

[-0.2278]

Providing a parameter using ?var= syntax:

curl "http://127.0.0.1:8000/mean?samples=10000"

[0.007]

Making a request using "a=&b=" syntax:

curl --data "a=4&b=3" "http://127.0.0.1:8000/sum"

[7]

Using JSON syntax:

curl --data '{"a":4, "b":5}' http://127.0.0.1:8000/sum

[9]

Make sure to close the job when you are done testing the api!

Hosting the api

So now that we know how to use the plumer package let’s get our hands dirty…

Create a basic model

For our practical example we will build a basic caret rergession model and service it via an api so that someone can request a prediction by giving properly named inputs for the model.

Let’s predict how long an animal will sleep given some descriptors of said animal

What the data looks like:

example_data <- 
ggplot2::msleep %>% 
    select(sleep_total,vore,bodywt,brainwt,awake) %>% 
    na.omit()

example_data
## # A tibble: 51 x 5
##    sleep_total vore   bodywt brainwt awake
##          <dbl> <chr>   <dbl>   <dbl> <dbl>
##  1        17   omni    0.48  0.0155    7  
##  2        14.9 omni    0.019 0.00029   9.1
##  3         4   herbi 600     0.423    20  
##  4        10.1 carni  14     0.07     13.9
##  5         3   herbi  14.8   0.0982   21  
##  6         5.3 herbi  33.5   0.115    18.7
##  7         9.4 herbi   0.728 0.0055   14.6
##  8        12.5 herbi   0.42  0.0064   11.5
##  9        10.3 omni    0.06  0.001    13.7
## 10         8.3 omni    1     0.0066   15.7
## # ... with 41 more rows

Construct a naive model

example_model <-  
    caret::train(form = sleep_total~., data = example_data,method = "rf")

# example_model %>% saveRDS("data/example_model.rds")

example_model
## Random Forest 
## 
## 51 samples
##  4 predictor
## 
## No pre-processing
## Resampling: Bootstrapped (25 reps) 
## Summary of sample sizes: 51, 51, 51, 51, 51, 51, ... 
## Resampling results across tuning parameters:
## 
##   mtry  RMSE       Rsquared   MAE      
##   2     1.7612212  0.8756261  1.3182399
##   4     1.0041945  0.9582245  0.7173262
##   6     0.6508145  0.9789417  0.4539108
## 
## RMSE was used to select the optimal model using the smallest value.
## The final value used for the model was mtry = 6.

Simple enough to train on this data and without any tuning we can assume a margin of error less than 1/2 an hour…

Create the api

OK, so we need to first define the plumber.R file that we will serve.

Notice that we can define the template very verbosely so that we can both leverage the GET protocol to feedback some characteristics and to populate the HTML page should a user inspect the api to learn how to use it.

This was based on the awesome post by blogger shirin:
https://shirinsplayground.netlify.com/2018/01/plumber/

# script name:
# plumber.R

# set API title and description to show up in curl "http://127.0.0.1:8000/mean"/__swagger__/
#' @apiTitle Run predictions for the total hours of sleep of an animal
#' @apiDescription This API takes various animal's data on factors related to sleep and predicts how long the animal will sleep on its next cycle.
#' indicates hours of sleep

# load model
# this path would have to be adapted if you would deploy this
# load("/Users/shiringlander/Documents/Github/shirinsplayground/data/model_rf.RData")
readRDS("/usr/etc/learning_api_in_R/data/example_model.rds")

#' Log system time, request method and HTTP user agent of the incoming request
#' @filter logger
function(req){
  cat("System time:", as.character(Sys.time()), "\n",
      "Request method:", req$REQUEST_METHOD, req$PATH_INFO, "\n",
      "HTTP user agent:", req$HTTP_USER_AGENT, "@", req$REMOTE_ADDR, "\n")
  plumber::forward()
}

# core function follows below:
# define parameters with type and description
# name endpoint
# return output as html/text
# specify 200 (okay) return

#' predict Chronic Kidney Disease of test case with Random Forest model
#' @param   vore Character variable. Options are : carni, herbi, insecti, omni.
#' @param   bodywt:numeric Numeric variable. Weight in lb of the animal.
#' @param   brainwt:numeric Numeric variable. Weight in lb of the animal.
#' @param   awake:numeric Numeric variable. Time animal has been awake.
#' @post /predict
#' @html
#' @response 200 Returns the hour(s) prediction from the Random Forest model predicting hours of next sleep
calculate_prediction <- function(vore,bodywt,brainwt,awake) {
  
  # make data frame from parameters
  input_data <<- data.frame(vore,bodywt,brainwt,awake,
                                                        stringsAsFactors = FALSE)
  
  input_data <- 
    input_data %>% 
    mutate(
        vore = vore %>% as.numeric(),
            bodywt = bodywt %>% as.numeric(),
            brainwt = brainwt %>% as.numeric(),
            awake = awake %>% as.numeric()
    )
  
  # and make sure they really are the right format
  if(all(input_data["vore"] %>% class != "character")){
    res$status <- 400
    res$body <- "Parameters have to be in the right format. vore - character"
  }
  
  if( all(input_data[c("bodywt","brainwt","awake")] %>% class != "numeric") ){
    res$status <- 400
    res$body <- "Parameters have to be in the right format. vore - character"
  }
  
  # validation for parameter
  if (any(is.na(input_data))) {
    res$status <- 400
    res$body <- "Parameters have to be numeric or integers - NA's intriduced by coercion"
  }

  # predict and return result
  pred_rf <<- predict(example_model, input_data)
  paste("----------------\nNext sleep cycle predicted to be", as.character(pred_rf), "\n----------------\n")
}

Setup executable script for the server to execute:

Let’s call this Serve_api.R

#!/usr/bin/env R
library(EightyR)
load_toolbox()
load_pkg("plumber")
r <- plumb("plumber.R")
r$run(host = "0.0.0.0", port=8000)

OK, so up to now we have setup all the files we need to serve this api. Now we can ssh into a server and clone this repo over there.

Remember to change the file paths in the script to suite your new machine or cd into the repository before running the script.

For this experiment I quickly initiaited an AWS EC2 instance that we can serve the api with.

Note that with AWS linux you need to make sure all the dependancies are installed.

SSH into the server

In any shell:

ssh -i "path/{My_Private_Key}.pem" ec2-user@ec2{My_IP}.eu-west-1.compute.amazonaws.com

Clone the repo with your code:

git clone origin https://github.com/lurd4862/Testing_apis_R.git

Change the paths referencing your model…

Cd into the repository…

Run the process in a shell:

Rscript Serve_api.R

You can background this process with a noup command so that the api does not stop when you leave this shell.

Now that the api is running on the port we specified in the run script we can access it:

request the following test case using a json input


curl -H "Content-Type: application/json" -X GET -d '{"vore":"carni", "bodywt":85, "brainwt":0.325, "awake":17.8}' http://127.0.0.1:8000/predict

Next sleep cycle predicted to be 10.9981933333333

curl -H “Content-Type: application/json” -X GET -d ‘{“vore”:“carni”, “bodywt”:85, “brainwt”:0.325, “awake”:17.8}’ ec2-34-242-232-117.eu-west-1.compute.amazonaws.com/predict

ec2-34-242-232-117.eu-west-1.compute.amazonaws.com/predict?vore=“carni”?bodywt=85?brainwt=0.325?awake=17.8