Skip to main content

TST, HongKong

Serving your SciKit Learn Model as a Prediction API

Github Repository

Preparing the ML Model

Data Preprocessing

Advertisement Dataset: Use the advertising dataset given in ISLR and analyse the relationship between advertisement channels and sales.

import joblib
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_absolute_error, mean_squared_error
from sklearn.model_selection import train_test_split
SEED = 42
adv_df = pd.read_csv('dataset/advertising.csv')
adv_df.head(5)
TVRadioNewspaperSales
0230.137.869.222.1
144.539.345.110.4
217.245.969.39.3
3151.541.358.518.5
4180.810.858.412.9
adv_df.describe()
TVRadioNewspaperSales
count200.000000200.000000200.000000200.000000
mean147.04250023.26400030.55400014.022500
std85.85423614.84680921.7786215.217457
min0.7000000.0000000.3000001.600000
25%74.3750009.97500012.75000010.375000
50%149.75000022.90000025.75000012.900000
75%218.82500036.52500045.10000017.400000
max296.40000049.600000114.00000027.000000
# features / labels split
X = adv_df.drop('Sales', axis=1)
y = adv_df['Sales']
# train, validation and test split
X_train, X_temp, y_train, y_temp = train_test_split(
X, y, test_size=0.3, random_state=SEED
)

X_val, X_test, y_val, y_test = train_test_split(
X_temp, y_temp, test_size=0.5, random_state=SEED
)

print(len(X), len(X_train), len(X_val), len(X_test))
# 200 140 30 30

Model Training

model = RandomForestRegressor(
n_estimators=3,
random_state=SEED
)
model.fit(X_train, y_train)

Model Validation

# validate model performance and tune hyper parameter
val_preds = model.predict(X_val)
print(mean_absolute_error(y_val, val_preds))
print(mean_squared_error(y_val, val_preds)**0.5)
# 0.7566666666666663 MAE should be small compared to MEAN 14.022500
# 0.9330753611738063 RMSE should be small compared to STD 5.217457
adv_df.describe()['Sales']
count200.000000
mean14.022500
std5.217457
min1.600000
25%10.375000
50%12.900000
75%17.400000
max27.000000
Name: Sales, dtype: float64
# try to improve the model by adding estimators
model2 = RandomForestRegressor(
n_estimators=30,
random_state=SEED
)
model2.fit(X_train, y_train)
val_preds2 = model2.predict(X_val)

print(mean_absolute_error(y_val, val_preds2))
print(mean_squared_error(y_val, val_preds2)**0.5)
# 0.483111111111111 MAE should be small compared to MEAN 14.022500
# 0.6177971619660723 RMSE should be small compared to STD 5.217457

Model Testing

# retest the optimized model on unseen data
test_preds = model2.predict(X_test)
print(mean_absolute_error(y_test, test_preds))
print(mean_squared_error(y_test, test_preds)**0.5)
# 0.5649999999999998 MAE should be small compared to MEAN 14.022500
# 0.6758333675845999 RMSE should be small compared to STD 5.217457

Model Saving

production_model = RandomForestRegressor(
n_estimators=30,
random_state= SEED
)

# fit production model to entire dataset
production_model.fit(X, y)
# save model for deployment
joblib.dump(production_model, 'models/production_model.pkl')
list(X.columns)
# ['TV', 'Radio', 'Newspaper']
joblib.dump(list(X.columns), 'models/production_model_column_names.pkl')

Model Loading

column_names = joblib.load('models/production_model_column_names.pkl')
column_names
# ['TV', 'Radio', 'Newspaper']
loaded_model = joblib.load('models/production_model.pkl')
print(loaded_model.predict([X.iloc[42]]))
print(loaded_model.predict([[180.8, 10.8, 58.4]]))
# [20.68666667] TRUE 20.7
#[13.28] TRUE 12.9

Preparing the Model API

Install Flask:

pip install flask

And prepare the prediction API route:

Prediction_API.py

from flask import Flask, request, jsonify
import joblib
import pandas as pd

model = joblib.load('models/production_model.pkl')
col_names = joblib.load('models/production_model_column_names.pkl')

app = Flask(__name__)

# wait for json post request and return prediction
@app.route('/api', methods=['POST'])
def predict():
req_data = request.json
df = pd.DataFrame(req_data).reindex(columns=col_names)

prediction = list(model.predict(df))

return jsonify({'prediction': str(prediction)})

# load the model and start web api
if __name__ == 'main':
app.run()

Start the application by running:

export FLASK_ENV=development
export FLASK_APP=Prediction_API
flask run

Serving your SciKit Learn Model as a Prediction API

We can now use Postman to POST 3 datapoints from our dataset to the API endpoint:

[
{"TV":180.8, "Radio":10.8, "Newspaper":58.4},
{"TV":44.5, "Radio":39.3, "Newspaper":45.1},
{"TV":17.2, "Radio":45.9, "Newspaper":69.3}
]

As they are from our labelled dataset we know that the prediction response should be around 12.9, 10.4 and 9.3 for those 3 datapoints:

{
"prediction": "[13.279999999999996, 10.389999999999997, 8.99666666666667]"
}

Serving your SciKit Learn Model as a Prediction API

Containerizing the Application

Preparations

Prepare the necessary files:

.
├── Dockerfile
├── app.py
├── models
│   ├── production_model_column_names.pkl
│   └── production_model.pkl
└── requirements.txt

Take the latest pickle files from your model training and copy the Flask app into app.py:

from flask import Flask, request, jsonify
import joblib
import pandas as pd

model = joblib.load('models/production_model.pkl')
col_names = joblib.load('models/production_model_column_names.pkl')

app = Flask(__name__)

# wait for json post request and return prediction
@app.route('/api', methods=['POST'])
def predict():
req_data = request.json
df = pd.DataFrame(req_data).reindex(columns=col_names)

prediction = list(model.predict(df))

return jsonify({'prediction': str(prediction)})

# load the model and start web api
if __name__ == 'main':
app.run()

Define all the Python dependencies that need to be installed inside the container for the app to work in requirements.txt:

blinker==1.6.2
click==8.1.3
joblib==1.2.0
Flask==2.3.2
itsdangerous==2.1.2
Jinja2==3.1.2
MarkupSafe==2.1.2
numpy==1.25.0
pandas==2.0.2
scikit-learn==1.2.2
Werkzeug==2.3.6

And the Dockerfile to build the container with:

# base image to use
FROM python:3.10-slim-bookworm
# dir name inside the container used for your app
WORKDIR /opt/python_app
# copy all files into the work dir
COPY . .
# install python dependencies
RUN pip install --upgrade pip
RUN pip install -r requirements.txt
# execute the app when container starts
EXPOSE 5000/tcp
CMD [ "python", "-m" , "flask", "--app", "app", "run", "--host=0.0.0.0"]

Building the Docker Image

You can simply run docker build --tag flask-app . to build the container using Docker. But I started getting deprecation warnings on the build command and that it is going to be replaced by buildx and the installation guide for this still feels a bit flaky...

But I wanted to use Podman Desktop for a while. So let's give that a try then :)

Install the package from Flathub:

flatpak install flathub io.podman_desktop.PodmanDesktop

And run the application using:

flatpak run io.podman_desktop.PodmanDesktop

After clicking on Create Container and selecting from Dockerfile:

Serving your SciKit Learn Model as a Prediction API

I ran into my first issue - which, I am sure, is actually a Flatpack issue. You have to point the builder to your Dockerfile. But since the file is then copied into a temp directory that the Flatpack app is allowed to access you then also have to point to the Build Context Directory to the containing folder. This will copy everything inside this folder into a different temp folder. Otherwise the build will fail because it cannot find the requirements.txt file:

Serving your SciKit Learn Model as a Prediction API

Click on Build and wait for the process to finish:

Serving your SciKit Learn Model as a Prediction API

Running the Docker Container

To run the container in Docker we just need to make sure that the API port is exposed. In the code above By default Flask uses port 5000 and the Dockerfile above exposed this port when building the Docker image. So all I need to do is to run:

docker run -P flask-api

This exposes the REST API to a random outer port 32773 - which is what you want in a cloud-native environment:

Serving your SciKit Learn Model as a Prediction API

To get more control you can override the default port by - e.g. setting the outer port to 8080:

docker run -d --rm -p 8080:5000 flask-api

Serving your SciKit Learn Model as a Prediction API

Or going back to Podman Desktop:

Serving your SciKit Learn Model as a Prediction API

Serving your SciKit Learn Model as a Prediction API

With those settings the API is now exposed on it's native port 5000 and can be tested using Postman:

Serving your SciKit Learn Model as a Prediction API

React.js Frontend

Using Vite to scaffold a basic React frontend:

npm create vite@latest
cd into/directory
npm install
npm run dev

Add a component that fetches the prediction from your backend API and import it to your page:

import React, { useState } from 'react';

const apiUrl = 'http://localhost:5000/api'
const method = 'POST'
const mode = 'cors'
const headers = {
'content-type': 'application/json'
}

const jsonData = [ {"TV":17.2, "Radio":45.9, "Newspaper":69.3} ]


const makeRequest = async (data) => {

const res = await fetch(apiUrl, {
method: method,
mode: mode,
cache: "no-cache",
credentials: "same-origin",
headers: headers,
body: JSON.stringify(data)
});
const response = await res.json();
return response;
};

export default function App() {
const [answer, setAnswer] = useState();

const submit = async () => {
const res = await makeRequest(jsonData);
setAnswer(parseFloat(res.prediction.replace("[", "").replace("]", "")));
};

return (
<>
<button onClick={submit}>Send</button>
<div><p>Sales: {JSON.stringify(answer)}</p></div>
</>
);
}

The frontend takes the hard coded values [{"TV":17.2, "Radio":45.9, "Newspaper":69.3}] and requests a prediction on them when the button is pressed.

Now we are only missing an input field that allows the user to send a custom request:

import React, { useId, useState } from 'react';

// prepare request
const apiUrl = 'http://localhost:5000/api'
const method = 'POST'
const mode = 'cors'
const cache = "no-cache"
const credentials = "same-origin"
const headers = {
'content-type': 'application/json'
}

// default request data
const jsonData = [{"TV":17.2,"Radio":45.9,"Newspaper":69.3}]
const stringData = JSON.stringify(jsonData)

// make request when submit button is pressed
const makeRequest = async (data) => {
const res = await fetch(apiUrl, {
method: method,
mode: mode,
cache: cache,
credentials: credentials,
headers: headers,
body: JSON.stringify(data)
});
const response = await res.json();
return response;
};

export default function App(props) {
// get input field value
const id = useId();
const [input, setInput] = useState(props?.value ?? '');
// store api response
const [answer, setAnswer] = useState();
// handle submit button press
const submit = async () => {
const res = await makeRequest(JSON.parse(input) || jsonData);
setAnswer(parseFloat(res.prediction.replace("[", "").replace("]", "")));
console.log(JSON.parse(input))
};

return (
<>
<h1>Sales Predictions</h1>
<form>
<fieldset>
<label>
<h3>Expenditures for Adverts</h3>
<p>Please specify the advertisement budget for <strong>TV</strong>, <strong>Radio</strong> and <strong>Newspaper</strong> adverts:</p>
<label htmlFor={id}><strong>Input JSON Data:</strong> </label>
<input
id={id}
value={input}
placeholder=' [{"TV":17.2,"Radio":45.9,"Newspaper":69.3}]'
className="form-control"
type="json"
size="32"
onInput={e => setInput(e.target.value)}
/><br/>
<em>Example: <code>{stringData}</code></em>
</label>
</fieldset>
</form>
<div>
<button type='submit' onClick={submit}>Send</button>
<h5>Revenue Prediction:</h5>
<p>{JSON.stringify(answer)}</p>
</div>
</>
);
}

Serving your SciKit Learn Model as a Prediction API

Adding the Frontend

Starting the backend separately from the frontend is a bit inconvenient. Instead we can pre-render the frontend and add it into the backend container. Run a Vite.js build to generate the static frontend with the following command:

npm run build

The static files will be generated in ./dist. Copy them into the index.html file into a folder named templates in your Flask app root and rename to sales.html. Static JS, CSS and image files need to go into the static folder:

.
├── app.py
├── Dockerfile
├── models
│   ├── production_model_column_names.pkl
│   └── production_model.pkl
├── requirements.txt
├── static
│   ├── assets
│   │   ├── index-bf239c1f.js
│   │   └── index-d526a0c5.css
│   └── favicon.ico
└── templates
└── sales.html

Unfortunately, we need to edit the links for those static files in sales.html by adding the /static/ prefix:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sales Prediction API</title>
<script type="module" crossorigin src="/static/assets/index-bf239c1f.js"></script>
<link rel="stylesheet" href="/static/assets/index-d526a0c5.css">
</head>
<body>
<div id="root"></div>

</body>
</html>

Now add the static route to app.py that will serve this page for us on /sales/:

from flask import Flask, request, jsonify, render_template
import joblib
import pandas as pd

model = joblib.load('models/production_model.pkl')
col_names = joblib.load('models/production_model_column_names.pkl')

app = Flask(__name__, static_folder='static')

# serve html frontend
@app.route('/sales/')
@app.route('/sales/<name>')
def sales(name=None):
return render_template('sales.html', name=name)


# wait for json post request and return prediction
@app.route('/api', methods=['POST'])
def predict():
req_data = request.json
df = pd.DataFrame(req_data).reindex(columns=col_names)

prediction = list(model.predict(df))

return jsonify({'prediction': str(prediction)})

# load the model and start web api
if __name__ == 'main':
app.run()

Rebuild the Docker image and restart the container. It should now serve the frontend on /sales/ connecting to the backend on /api/:

  • http://localhost:5000/sales/
  • http://localhost:5000/api/