The purpose of this chapter is to serve and use the model for usage outside of the experiment context with the help of BentoML, a tool designed for easy packaging, deployment, and serving of Machine Learning models.
By transforming your model into a BentoML model artifact, it is possible to load the model for future usage with all its dependencies. This will allow you to use the model in a production environment, share it with others, and deploy it to a cluster.
In this chapter, you will learn how to:
Install BentoML
Learn about BentoML's model store
Update and run the experiment to use BentoML to save and load the model to and from the model's store
The following diagram illustrates the control flow of the experiment at the end of this chapter:
Prior to running any pip commands, it is crucial to ensure the virtual environment is activated to avoid potential conflicts with system-wide Python packages.
To check its status, simply run pip -V. If the virtual environment is active, the output will show the path to the virtual environment's Python executable. If it is not, you can activate it with source .venv/bin/activate.
To make the most of BentoML's capabilities, you must start by converting your model into the specialized BentoML model artifact format with all its dependencies.
BentoML offers a model store, which is a centralized repository for all your models. This store is stored in a directory on your local machine at ~/bentoml/.
In order to share the model with others, the model must be exported in the current working directory. It will then be uploaded to DVC and shared with others.
importjsonimportsysfrompathlibimportPathfromtypingimportTupleimportnumpyasnpimporttensorflowastfimportyamlimportbentomlfromPIL.ImageimportImagefromutils.seedimportset_seeddefget_model(image_shape:Tuple[int,int,int],conv_size:int,dense_size:int,output_classes:int,)->tf.keras.Model:"""Create a simple CNN model"""model=tf.keras.models.Sequential([tf.keras.layers.Conv2D(conv_size,(3,3),activation="relu",input_shape=image_shape),tf.keras.layers.MaxPooling2D((3,3)),tf.keras.layers.Flatten(),tf.keras.layers.Dense(dense_size,activation="relu"),tf.keras.layers.Dense(output_classes),])returnmodeldefmain()->None:iflen(sys.argv)!=3:print("Arguments error. Usage:\n")print("\tpython3 train.py <prepared-dataset-folder> <model-folder>\n")exit(1)# Load parametersprepare_params=yaml.safe_load(open("params.yaml"))["prepare"]train_params=yaml.safe_load(open("params.yaml"))["train"]prepared_dataset_folder=Path(sys.argv[1])model_folder=Path(sys.argv[2])image_size=prepare_params["image_size"]grayscale=prepare_params["grayscale"]image_shape=(*image_size,1ifgrayscaleelse3)seed=train_params["seed"]lr=train_params["lr"]epochs=train_params["epochs"]conv_size=train_params["conv_size"]dense_size=train_params["dense_size"]output_classes=train_params["output_classes"]# Set seed for reproducibilityset_seed(seed)# Load datads_train=tf.data.Dataset.load(str(prepared_dataset_folder/"train"))ds_test=tf.data.Dataset.load(str(prepared_dataset_folder/"test"))labels=Nonewithopen(prepared_dataset_folder/"labels.json")asf:labels=json.load(f)# Define the modelmodel=get_model(image_shape,conv_size,dense_size,output_classes)model.compile(optimizer=tf.keras.optimizers.Adam(lr),loss=tf.keras.losses.SparseCategoricalCrossentropy(from_logits=True),metrics=[tf.keras.metrics.SparseCategoricalAccuracy()],)model.summary()# Train the modelmodel.fit(ds_train,epochs=epochs,validation_data=ds_test,)# Save the modelmodel_folder.mkdir(parents=True,exist_ok=True)defpreprocess(x:Image):# convert PIL image to tensorx=x.convert('L'ifgrayscaleelse'RGB')x=x.resize(image_size)x=np.array(x)x=x/255.0# add batch dimensionx=np.expand_dims(x,axis=0)returnxdefpostprocess(x:Image):return{"prediction":labels[tf.argmax(x,axis=-1).numpy()[0]],"probabilities":{labels[i]:probfori,probinenumerate(tf.nn.softmax(x).numpy()[0].tolist())},}# Save the model using BentoML to its model store# https://docs.bentoml.com/en/latest/reference/frameworks/keras.html#bentoml.keras.save_modelbentoml.keras.save_model("celestial_bodies_classifier_model",model,include_optimizer=True,custom_objects={"preprocess":preprocess,"postprocess":postprocess,})# Export the model from the model store to the local model folderbentoml.models.export_model("celestial_bodies_classifier_model:latest",f"{model_folder}/celestial_bodies_classifier_model.bentomodel",)# Save the model historynp.save(model_folder/"history.npy",model.history.history)print(f"\nModel saved at {model_folder.absolute()}")if__name__=="__main__":main()
BentoML can save the model with custom objects.
These custom objects can be used to save the model with arbitrary data that can be used afterword when loading back the model. In this case, the following objects are saved with the model:
preprocess is used to preprocess the input data before feeding it to the model.
postprocess is used to postprocess the output of the model.
These functions will be used later to transform the input and output data when using the model through a HTTP REST API.
Check the differences with Git to better understand the changes:
diff --git a/src/train.py b/src/train.pyindex 5c69e2f..b845eb3 100644--- a/src/train.py+++ b/src/train.py@@ -1,3 +1,4 @@+import jsonimport sys
from pathlib import Path
from typing import Tuple
@@ -5,6 +6,8 @@ from typing import Tupleimport numpy as np
import tensorflow as tf
import yaml
+import bentoml+from PIL.Image import Imagefrom utils.seed import set_seed
@@ -61,6 +64,10 @@ def main() -> None: ds_train = tf.data.Dataset.load(str(prepared_dataset_folder / "train"))
ds_test = tf.data.Dataset.load(str(prepared_dataset_folder / "test"))
+ labels = None+ with open(prepared_dataset_folder / "labels.json") as f:+ labels = json.load(f)+ # Define the model
model = get_model(image_shape, conv_size, dense_size, output_classes)
model.compile(
@@ -79,8 +86,44 @@ def main() -> None: # Save the model
model_folder.mkdir(parents=True, exist_ok=True)
- model_path = model_folder / "model.keras"- model.save(model_path)++ def preprocess(x: Image):+ # convert PIL image to tensor+ x = x.convert('L' if grayscale else 'RGB')+ x = x.resize(image_size)+ x = np.array(x)+ x = x / 255.0+ # add batch dimension+ x = np.expand_dims(x, axis=0)+ return x++ def postprocess(x: Image):+ return {+ "prediction": labels[tf.argmax(x, axis=-1).numpy()[0]],+ "probabilities": {+ labels[i]: prob+ for i, prob in enumerate(tf.nn.softmax(x).numpy()[0].tolist())+ },+ }++ # Save the model using BentoML to its model store+ # https://docs.bentoml.com/en/latest/reference/frameworks/keras.html#bentoml.keras.save_model+ bentoml.keras.save_model(+ "celestial_bodies_classifier_model",+ model,+ include_optimizer=True,+ custom_objects={+ "preprocess": preprocess,+ "postprocess": postprocess,+ }+ )++ # Export the model from the model store to the local model folder+ bentoml.models.export_model(+ "celestial_bodies_classifier_model:latest",+ f"{model_folder}/celestial_bodies_classifier_model.bentomodel",+ )+ # Save the model history
np.save(model_folder / "history.npy", model.history.history)
importjsonimportsysfrompathlibimportPathfromtypingimportListimportmatplotlib.pyplotaspltimportnumpyasnpimporttensorflowastfimportbentomldefget_training_plot(model_history:dict)->plt.Figure:"""Plot the training and validation loss"""epochs=range(1,len(model_history["loss"])+1)fig=plt.figure(figsize=(10,4))plt.plot(epochs,model_history["loss"],label="Training loss")plt.plot(epochs,model_history["val_loss"],label="Validation loss")plt.xticks(epochs)plt.title("Training and validation loss")plt.xlabel("Epochs")plt.ylabel("Loss")plt.legend()plt.grid(True)returnfigdefget_pred_preview_plot(model:tf.keras.Model,ds_test:tf.data.Dataset,labels:List[str])->plt.Figure:"""Plot a preview of the predictions"""fig=plt.figure(figsize=(10,5),tight_layout=True)forimages,label_idxsinds_test.take(1):preds=model.predict(images)foriinrange(10):plt.subplot(2,5,i+1)img=(images[i].numpy()*255).astype("uint8")# Convert image to rgb if grayscaleifimg.shape[-1]==1:img=np.squeeze(img,axis=-1)img=np.stack((img,)*3,axis=-1)true_label=labels[label_idxs[i].numpy()]pred_label=labels[np.argmax(preds[i])]# Add red border if the prediction is wrong else add green borderimg=np.pad(img,pad_width=((1,1),(1,1),(0,0)))iftrue_label!=pred_label:img[0,:,0]=255# Top borderimg[-1,:,0]=255# Bottom borderimg[:,0,0]=255# Left borderimg[:,-1,0]=255# Right borderelse:img[0,:,1]=255img[-1,:,1]=255img[:,0,1]=255img[:,-1,1]=255plt.imshow(img)plt.title(f"True: {true_label}\n"f"Pred: {pred_label}")plt.axis("off")returnfigdefget_confusion_matrix_plot(model:tf.keras.Model,ds_test:tf.data.Dataset,labels:List[str])->plt.Figure:"""Plot the confusion matrix"""fig=plt.figure(figsize=(6,6),tight_layout=True)preds=model.predict(ds_test)conf_matrix=tf.math.confusion_matrix(labels=tf.concat([yfor_,yinds_test],axis=0),predictions=tf.argmax(preds,axis=1),num_classes=len(labels),)# Plot the confusion matrixconf_matrix=conf_matrix/tf.reduce_sum(conf_matrix,axis=1)plt.imshow(conf_matrix,cmap="Blues")# Plot cell valuesforiinrange(len(labels)):forjinrange(len(labels)):value=conf_matrix[i,j].numpy()ifvalue==0:color="lightgray"elifvalue>0.5:color="white"else:color="black"plt.text(j,i,f"{value:.2f}",ha="center",va="center",color=color,fontsize=8,)plt.colorbar()plt.xticks(range(len(labels)),labels,rotation=90)plt.yticks(range(len(labels)),labels)plt.xlabel("Predicted label")plt.ylabel("True label")plt.title("Confusion matrix")returnfigdefmain()->None:iflen(sys.argv)!=3:print("Arguments error. Usage:\n")print("\tpython3 evaluate.py <model-folder> <prepared-dataset-folder>\n")exit(1)model_folder=Path(sys.argv[1])prepared_dataset_folder=Path(sys.argv[2])evaluation_folder=Path("evaluation")plots_folder=Path("plots")# Create folders(evaluation_folder/plots_folder).mkdir(parents=True,exist_ok=True)# Load filesds_test=tf.data.Dataset.load(str(prepared_dataset_folder/"test"))labels=Nonewithopen(prepared_dataset_folder/"labels.json")asf:labels=json.load(f)# Import the model to the model store from a local model foldertry:bentoml.models.import_model(f"{model_folder}/celestial_bodies_classifier_model.bentomodel")exceptbentoml.exceptions.BentoMLException:print("Model already exists in the model store - skipping import.")# Load modelmodel=bentoml.keras.load_model("celestial_bodies_classifier_model")model_history=np.load(model_folder/"history.npy",allow_pickle=True).item()# Log metricsval_loss,val_acc=model.evaluate(ds_test)print(f"Validation loss: {val_loss:.2f}")print(f"Validation accuracy: {val_acc*100:.2f}%")withopen(evaluation_folder/"metrics.json","w")asf:json.dump({"val_loss":val_loss,"val_acc":val_acc},f)# Save training history plotfig=get_training_plot(model_history)fig.savefig(evaluation_folder/plots_folder/"training_history.png")# Save predictions preview plotfig=get_pred_preview_plot(model,ds_test,labels)fig.savefig(evaluation_folder/plots_folder/"pred_preview.png")# Save confusion matrix plotfig=get_confusion_matrix_plot(model,ds_test,labels)fig.savefig(evaluation_folder/plots_folder/"confusion_matrix.png")print(f"\nEvaluation metrics and plot files saved at {evaluation_folder.absolute()}")if__name__=="__main__":main()
Check the differences with Git to better understand the changes:
diff --git a/src/evaluate.py b/src/evaluate.pyindex 3bca979..11322bd 100644--- a/src/evaluate.py+++ b/src/evaluate.py@@ -6,6 +6,7 @@ from typing import Listimport matplotlib.pyplot as plt
import numpy as np
import tensorflow as tf
+import bentomldef get_training_plot(model_history: dict) -> plt.Figure:
@@ -128,9 +129,14 @@ def main() -> None: with open(prepared_dataset_folder / "labels.json") as f:
labels = json.load(f)
+ # Import the model to the model store from a local model folder+ try:+ bentoml.models.import_model(f"{model_folder}/celestial_bodies_classifier_model.bentomodel")+ except bentoml.exceptions.BentoMLException:+ print("Model already exists in the model store - skipping import.")+ # Load model
- model_path = model_folder / "model.keras"- model = tf.keras.models.load_model(model_path)+ model = bentoml.keras.load_model("celestial_bodies_classifier_model") model_history = np.load(model_folder / "history.npy", allow_pickle=True).item()
# Log metrics
# Run the experiment. DVC will automatically run all required stagesdvcrepro
The experiment now uses BentoML to save and load the model. The resulting model is saved in the model folder and is automatically tracked by DVC. The model is then uploaded to the remote storage bucket when pushing the changes to DVC as well.
You can check the models stored in the model store with the following command:
On branch main
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
modified: dvc.lock
modified: requirements-freeze.txt
modified: requirements.txt
modified: src/evaluate.py
modified: src/train.py
# Upload the experiment data, model and cache to the remote bucketdvcpush
# Commit the changesgitcommit-m"BentoML can save and load the model"# Push the changesgitpush