Build an API App backed by FastAPI and Vue.js
API is usually considered as the last step of a Machine Learning project deliverable. But when it comes to the demonstration, it’s not attractive to ask audience to look at the Swagger/Postman API testing screen during your presentation. In a well-structured data science team, you may have the luxury of support from front-end engineers to build a web page for your prototype. But in most scenarios, data scientists are working on their own to complete the minimal variable data product.
There are different alternatives (such as R Shiny, Plotly Dash, Flask, etc), to achieve the purpose. In this post, I’m going to document my approach of developing a web page on top of existing API using FastAPI + Vue.js.
The resulting API App is demonstrated in the screenshot below:
Prerequisite
FastAPI
FastAPI (Document | GitHub) is my personal preference of API framework because of its high performance and comprehensive documentation.
In this post, FastAPI is used to wrap the ML model to a working API and host a static HTML on the same port. By doing this, we just need to launch a single service to make both API and Web-App working.
Vue.js
Vue.js (Document | GitHub) is one of the popular Javascript frameworks. I’m personally more appealing to Vue.js as I feel it’s easier to pick up for quick prototypes, though React.js or Angular.js are more widely used.
Vue.js provides a series of plugins to make life easier:
- Vue2: the core Vue.js library. version 2 is used, as some legacy packages are still incompatible with the ongoing version3 of Vue.
- Vuetify: the material design framework, which provides quick and beautiful UI components for Vue. Vue3 compatible version is still developing, and that’s the main reason that I still keep it on Vue2.
- Vue Router: router for Vue.js, which provides the capability of multi-page web app.
Some other packages are not in Vue suite, but also useful to have:
- axios: the popular Javascript library that works with API.
- vue-echarts: interactive charting library that works with Vue2 & Vue3.
Development Cycle
Having a brief understanding of the FastAPI and Vue.js, we are ready to start our development cycle of API APP, which generally consists of the following steps.
- model development → output ETL pipeline, model object
- API wrapping → output an API, API spec (swagger/openAI)
- App design → output a web app
- [optional] containerization → output a docker image
As this post is primarily focusing on API App development, the process of model building and API wrapping will be only briefly described.
If you are already familiar with model developing and API wrapping, you can skip to the App Design section directly.
Model Development
Data Preparation
My favorite dataset - Titanic, is used in this post for demonstration. The data preparation process can be described as following:
- load csv data using pandas
- split data into training and validation set
- encode the categorical variables so that ML model can understand
Don’t worry about encode_cat_variables
function, if you intend to run this code snippet. You can refer to the completed code in my GitHub repository.
import pandas as pd
import numpy as np
import lightgbm as lgb
from sklearn.model_selection import train_test_split
import pickle
from utils import encode_cat_variables
# define variables
fnames_cat = ['Pclass', 'Sex', 'Embarked']
fnames_num = ['Age', 'Fare', 'SibSp', 'Parch']
fnames = fnames_cat + fnames_num
# load data
data = pd.read_csv('./data/titanic.csv')
# split train / valid
train, valid = train_test_split(data, stratify=data.Survived, train_size=0.7)
# encode categorical variables
train, le = encode_cat_variables(train, fnames_cat)
valid, le = encode_cat_variables(valid, fnames_cat, le)
Model Building
LightGBM model is used in this post. LightGBM has a unique function of keeping categorical features, so we don’t have to do the one-hot encoding. As a result of model building, we saved the trained model object and variable encoders to prepare for API wrapping.
# convert to lightgbm Dataset
dtrain = lgb.Dataset(train[fnames].values, train.Survived)
dvalid = lgb.Dataset(valid[fnames].values, valid.Survived)
params = {
'objective': 'binary',
'eta': 0.1,
'metric': 'auc'
}
model = lgb.train(params,
dtrain,
num_boost_round=100,
valid_sets=[dtrain, dvalid],
valid_names=['train', 'valid'],
feature_name=fnames,
categorical_feature=fnames_cat,
verbose_eval=10)
# save model & encoder
model.save_model(filename = "./saved_model/model.txt")
with open('./saved_model/label_encoder.pkl', 'wb') as f:
pickle.dump(le, f)
API Wrapping
Load saved model
All the saved model and related utility functions need to be restored in API. We can load them in the beginning of API script.
import pickle
import lightgbm as lgb
# load encoder
with open('./saved_model/label_encoder.pkl', 'rb') as f:
le = pickle.load(f)
# load model
model = lgb.Booster(model_file='./saved_model/model.txt')
Define payload data model
Payload (API input) data model need to be defined in a custom python class TitanicFeature
.
The data model is useful to
- constrain the inputs for validation
- convert inputs to desired types if possible
from pydantic import BaseModel, Field
# data model of predictors
class TitanicFeature(BaseModel):
Age: int = Field(..., example=20)
Pclass: int = Field(..., example=1)
Sex: str = Field(..., example='male')
SibSp: int = Field(..., example=1)
Parch: int = Field(..., example=1)
Fare: float = Field(..., example=120)
Embarked: str = Field(..., example='S')
Define API methods
A POST method needs to be defined to allow the users to post their input to the ML model, and the prediction score and SHAP values will be returned.
@app.post("/predict")
async def predict(payload: TitanicFeature):
# convert the payload to pandas DataFrame
input_df = pd.DataFrame([payload.dict()])
# encoded all the categorical variables
input_df_encoded, _ = encode_cat_variables(input_df, list(le.keys()), le)
# output the prediction score
score = model.predict(input_df_encoded)[0]
# output the SHAP values
shap_values = model.predict(input_df_encoded, pred_contrib=True)[0]
# remove the last term - bias
shap_values = shap_values[:-1]
# desc sort SHAP variables by absolute value
shap_values = shap_values[np.argsort(-np.abs(shap_values))]
shap_values = [
{"name": fnames[i], "value": np.round(v, 4)} for i, v in enumerate(shap_values)
]
return {
'score': score,
'shap_values': shap_values
}
Define APP entry HTML
A GET method needs to be defined to get the HTML entry point (app.html
), which is the placeholder for our Vue application.
from fastapi.responses import FileResponse
@app.get("/app")
def read_index():
return FileResponse("./app.html")
Launch and test API
Lastly, we should run the following command to launch the API for testing.
- OpenAPI: go to http://localhost:8005/docs to test out if API methods return the expected results.
- WebAPP: go to http://localhost:8005/app to view the hosted web page. For now, the web page should be blank, as we haven’t put anything inside the
app.html
.
# launch API (from api.py) on port 8005
uvicorn api:app --reload --port 8005
App Design
There are generally two ways to develop the Vue.js Applications.
- In browser development, and use
CDN
version of Javascript packages - In Command Line Interface (CLI) development, and use
npm
version of Javascript packages
As development in browser is much easier to pick up and suitable for smaller and simpler web page, the CDN
approach will be used in this post. However when the scale of web app goes larger, CLI approach is strongly recommended.
Vue has its own CLI tool and the readers can follow it’s official website to learn the basics of Vue CLI
.
Visual Studio Code is very recommended to develop the web application. As the Emmet
is supported in Visual Studio Code, you can just type doc
, after you create the blank app.html
file. Hit Enter
button and the HTML template will be autocompleted.
Include Javascript Libraries
Just like start any R / Python scripts, we need to load the required libraries. Javascript libraries can be loaded in HTML, and usually placed at bottom of the body
tag, so that the page loading won’t be largely affected.
In the following code snippet, we loaded three packages (Vue2
, Vuetify2
, axiso
). In case, you need to add any other libraries, just look for the CDN section
of the package installation guide and copy the script
tag into your HTML.
<body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
</body>
Create Vue Instance
The Vue instance is also defined in <script>
tag.
data
section: defines the data two-way binding to the Vue application, which means the variables indata
can used to render in UI or updated by UI.methods
section: defines the function used in Vue app.
<script>
new Vue({
el: '#app',
vuetify: new Vuetify(),
data: function(){
return {}
},
methods: {},
})
</script>
Create Vuetify-theme App
The power of vuetify
helps us quickly build a beautify theme. All the vuetify
tags are started with v
. We just need copy the following <div>
chunk into the <body>
of HTML, to create the UI of web app.
<v-app>
: define an application withvuetify
theme<v-app-bar>
: create an application bar<v-main>
: create a placeholder for main content
More UI components can be explored on the Vuetify
website.
<div id="app">
<v-app>
<v-app-bar app dense dark color="purple">
<v-app-bar-title>Titanic Demo</v-app-bar-title>
</v-app-bar>
<v-main></v-main>
</v-app>
</div>
The resulting web app should look like the screenshot below.
Create model input form and submit button
After the theme and layout is set, we will create the input panel to allow users to type in their parameters for model prediction. Copy the <v-navigation-drawer>
into the <v-main>
to create a side panel.
<v-navigation-drawer>
: creates a navigation panel, which can be fixed or temporary.class="my-3"
:m
means margin,y
means vertical,3
is the spacing (3 x 4px = 12px). Similarly, you will guess what doesmy-6
mean. More can be found here<v-divider>
: creates a styled horizontal line.<v-text-field>
: creates a text input. For simplicity, I used text inputs for all variables, but select type input should be used for categorical variables, such asSex
orEmbarked
.v-for
: is for-loop syntax in Vue. It will loop over thepayload
object and create a<v-text-field>
input for each predictor.v-model
: defines the two-way binding between Vue and UI, so when the app is launched, the UI will take thename
variable inpayload
for the default value, and whenever the user updates the UI, thename
value inpayload
will be updated as well.:label
: is short-hand ofv-on:label
, which tells Vue thatlabel
will take a variable instead of a string as argument. If we uselabel="p.name"
, then each input field in the UI will have the same label ofp.name
, which is not expected here.<v-btn>
: creates a styled button.@click
is same asonclick
in native Javascript.@click=call_api
meanscall_api()
function will be called if the button is clicked.
<v-navigation-drawer right absolute>
<v-container>
<div class="my-3">Side Panel</div>
<v-divider></v-divider>
<div class="my-6">
<v-text-field
v-for="p in payload"
v-model="p.value"
:label="p.name"
:key="p.name"
dense
outlined>
</v-text-field>
</div>
<div class="my-3">
<v-btn
text
color="purple"
@click="call_api">
predict
</v-btn>
</div>
</v-container>
</v-navigation-drawer>
We defined the payload
in data
section to store and update all the predictors for the model.
data: function () {
return {
payload: [
{ name: "Age", value: 20, type: "int" },
{ name: "Pclass", value: "1", type: "str", items: ["1", "2", "3"] },
{ name: "Sex", value: "male", type: "str", items: ["male", "female"] },
{ name: "SibSp", value: 1, type: "int" },
{ name: "Parch", value: 1, type: "int" },
{ name: "Fare", value: 120, type: "int" },
{ name: "Embarked", value: "S", type: "str", items: ["S", "C", "Q"] },],
score: null
}
},
The resulting web app should look like the screenshot below.
Call API from Vue
When the App received inputs (payload
) from users, we are ready to make the API to get the prediction results. Remember we created a button to call call_api
function ? We will define it here in the methods
section of Vue instance.
this
: refers to Vue instance.this.payload
points to thepayload
object we created and updated by users
methods: {
call_api: function () {
// reformat the payload from [{name: Age, value: 10}, ...] to {Age:10, ...}
payload = this.payload.reduce((acc, cur) => ({ ...acc, [cur.name]: cur.value }), {})
axios.post("/predict", this.payload)
.then(resp => resp.data)
.then(data => {
this.score = data.score
})
.catch(e => console.log(e))
},
},
Display the model output
If the API call was tested okay, we should plan how to display the result on UI. Copy the following <v-container>
chunk inside <v-main>
, but outside <v-navigation-drawer>
to display the payload, model prediction score.
<v-container>
: creates a<div>
but with pre-defined spacings.{{ }}
: is the placeholder to display string in UI.v-if
: conditional display. The<h1>
content will only be shown when score is not null.
<v-container id="main">
<h1>Payload</h1>
{{ payload }}
<h1 v-if="score">Model Score</h1>
{{ score }}
</v-container>
The resulting web app should look like the screenshot below.
Play around with all the buttons in the web page to make sure the behavior is working in the expected way.
Please refer to
index.html
for a minimal Vue.js applicationapp.html
for a complete Vue.js application
Congratulations , you now have a Web App serving at http://localhost:8005/app
together with your working API.
Leave a comment