[MLOps Platform Study] From a BentoML API to Data Drift: A Hands-On Lab in WSL
This post documents the hands-on exercises I completed in my own environment, based on Chapters 6–7 of the book, as part of the Machine Learning Platform Engineering Book Study (2026).
I ran a small MLOps lab on Windows + WSL2 and wrote up what I learned.
| 91.11% Test accuracy · 41/45 |
200 / 400 Valid / invalid requests |
0 → 3 Drifted features · 4 total |
There are several steps between training a model and checking its accuracy, and making that model available to another program. You need to save the model, accept prediction requests, preserve the input format used during training, and check how new data changes over time.
While studying Chapters 6–7, I wanted to see these connections for myself. I built a small Iris classifier in a Windows WSL2 environment, exposed it as an HTTP API with BentoML, and generated data drift reports with Evidently.
This post focuses on what I verified in this small experiment and how I interpreted the results, rather than retelling the book. I used the dataset discussed in the book for the lab.
1. Scope and environment
The workflow is straightforward.
Iris data → Model training → Saved model → HTTP prediction API
↓
Reference/current CSVs → Separate process → Drift reports
| Item | Environment used in this run |
|---|---|
| Host | Windows + Ubuntu WSL2 |
| Execution | Docker container running in WSL |
| Container limits | 2 CPUs, 2 GiB of memory |
| Python | 3.12.16 |
| BentoML | 1.4.40 |
| Evidently | 0.7.23 |
| scikit-learn | 1.9.1 |
| pandas | 2.3.3 |
| Data and model | Built-in Iris dataset; standardization + logistic regression |
I did not use a GPU. Deploying a Kubernetes or Kubeflow cluster was outside the scope of this run. I connected the input/output contracts and data handoffs from Chapter 7 to a local file example. I treated the cluster behavior of Kubeflow Notebooks and Pipelines as concepts I studied, separate from what I actually ran.
Keeping the scope this small made it possible to follow the connections between the model file, API, data, and reports all the way through, even in a modest environment.
2. Saving the training results
I split the 150 Iris samples into 105 training samples and 45 test samples. The split preserved the class proportions, with the random seed fixed at 42. I saved preprocessing and classification together in a single scikit-learn Pipeline.
The held-out test accuracy for this run was about 91.11%: 41 correct predictions out of 45. This came from one particular split of a small dataset, so I did not treat it as a measure of real production performance.
Along with the model, I recorded:
- Input feature names and their order
- Class names
- Training and test sample counts, and the split seed
- Test accuracy
- The SHA-256 hash of the saved model file
- The actual Python and package versions
To run only the training step, build the image first (Section 7), then run this command from the lab directory:
docker run --rm \
--mount "type=bind,source=$PWD,target=/workspace" \
mlops-mini:ch6-ch7 python prepare.py
The script prints the training information and saves the same information to artifacts/training.json. The following is the record from this run; I only compacted the arrays onto single lines.
{
"dataset": "scikit-learn built-in Iris",
"features": ["sepal_length", "sepal_width", "petal_length", "petal_width"],
"class_names": ["setosa", "versicolor", "virginica"],
"train_rows": 105,
"test_rows": 45,
"test_accuracy": 0.9111111111111111,
"model_sha256": "9c7836075dbb71e3a79f861028e06985e5132d00ac135103e2d2a640154fe785",
"random_state": 42,
"drift_scenario": "Synthetic offsets on three numeric features",
"control_scenario": "Reference rows shuffled; identical distributions"
}
Here is how I read it. First, check that train_rows and test_rows add up to 150, and that test_accuracy matches the 41/45 result above. The order in features must match the API input order in Section 3. The model_sha256 value appears again in the API response in that section.
I used the hash to check whether the API was using the same model file. A model file hash alone cannot reproduce the code or data, so I also kept the package list and the data generation code.
Training was not just about recording a good score. It was also about being able to trace that score back to the model and environment that produced it.
3. Connecting predictions to HTTP requests with BentoML
The API accepts rows containing four numbers in this order:
sepal_length, sepal_width, petal_length, petal_width
Even when the number of values is correct, changing the column order can completely change what the input means. When the service converts a request to a DataFrame, it therefore applies the same column names and order used in training.
Here is an example request:
{
"rows": [
[5.1, 3.5, 1.4, 0.2],
[6.7, 3.1, 4.7, 1.5],
[6.5, 3.0, 5.2, 2.0]
]
}
You need two terminals to call the API manually. Start the server in the first WSL terminal. The model file must already exist, so first complete either the training step in Section 2 or the full run in Section 7.
docker run --rm -it \
--cpus 2 --memory 2g \
--env DO_NOT_TRACK=1 \
-p 127.0.0.1:3000:3000 \
--mount "type=bind,source=$PWD,target=/workspace" \
mlops-mini:ch6-ch7 \
bentoml serve service:IrisService --host 0.0.0.0 --port 3000
In a second WSL terminal, go to the lab directory and send the request. The file data/request.json contains the example shown above.
curl -sS http://localhost:3000/predict \
-H 'Content-Type: application/json' \
--data-binary @data/request.json
This is the response recorded in this run. I only changed the line breaks for readability.
{
"class_ids": [0, 1, 2],
"class_names": ["setosa", "versicolor", "virginica"],
"model_sha256": "9c7836075dbb71e3a79f861028e06985e5132d00ac135103e2d2a640154fe785"
}
class_ids contains the numeric labels predicted by the model. class_names maps those labels to the class names recorded during training. The model_sha256 value matches training.json from Section 2, identifying the file used to produce the response as the model that was just trained.
I also sent an invalid request containing only two features:
curl -sS -o /dev/null -w '%{http_code}\n' http://localhost:3000/predict \
-H 'Content-Type: application/json' \
-d '{"rows": [[1.0, 2.0]]}'
The status code should be 400. The server log, artifacts/server.log, recorded the two requests as follows. I omitted trailing details such as trace IDs.
[INFO] ... (method=POST,path=/predict,type=application/json,length=76) (status=200,type=application/json,length=162) 22.638ms
[ERROR] ... Exception on /predict [POST]
bentoml.exceptions.InvalidArgument: Each row needs four finite numbers in the documented feature order
[INFO] ... (method=POST,path=/predict,type=application/json,length=22) (status=400,type=application/json,length=80) 6.310ms
The [ERROR] line and traceback do not mean the server crashed. The service deliberately raised InvalidArgument after checking the input shape, and BentoML returned it as HTTP 400. Without this check, a malformed request could fail inside the model, making it harder for the caller to understand the cause.
The three sample inputs returned setosa, versicolor, and virginica, respectively. I verified the following:
| Check | Result |
|---|---|
| Call /predict with valid input | HTTP 200 |
| Compare the API predictions with direct predictions from the saved model | Match |
| Compare the response's model hash with the training artifact | Match |
| Send invalid input containing only two features | HTTP 400 |
These results show that the tested requests passed through the service's input conversion and model invocation correctly. They do not establish load-test results, production latency, or failure recovery behavior.
Once the model became an API, I had more questions to answer than “Does the classifier work?” I also needed to ask: “Does it distinguish invalid requests?”, “Which model produced the response?”, and “Does the result match a direct prediction?”
4. Checking changes in data distributions with Evidently
For the drift experiment, I explicitly separated the reference data from the comparison data.
The reference data consists of the 105 training inputs. The normal control contains exactly the same rows, only shuffled. It is therefore a controlled check of identical distributions, rather than newly collected production data.
I created the shifted data by adding fixed offsets to three features in the control dataset:
| Feature | Synthetic offset |
|---|---|
| sepal_width | +3.0 |
| petal_length | +4.0 |
| petal_width | +2.0 |
I left sepal_length unchanged. These offsets were not chosen to simulate real flowers or a production environment. They are deliberately large changes to synthetic data, intended to make the detection behavior easy to observe.
The feature-level method recorded in this report was the K–S test p-value, with a threshold of 0.05. The results were:
| Comparison data | Reference / current samples | Drifted features | Share |
|---|---|---|---|
| Control: the same rows, shuffled | 105 each | 0 / 4 | 0% |
| Data with offsets added to three features | 105 each | 3 / 4 | 75% |
The unchanged feature was not flagged, while all three modified features were. I saved both HTML reports and JSON metrics so that I could inspect the reports visually or check the results in code.
Reading the reports
The full run in Section 7 generates the reports. To rerun just this step, use the following command. You can open the generated HTML files in a browser from the artifacts folder in Windows File Explorer.
docker run --rm \
--mount "type=bind,source=$PWD,target=/workspace" \
mlops-mini:ch6-ch7 python drift.py
Saved drift_normal.html and drift_normal.json
Saved drift_shifted.html and drift_shifted.json
The screenshots below show the HTML reports saved in this run, opened in a browser.
First, the control dataset:
Control report: 0 of 4 columns show drift
Read the report from top to bottom, focusing on these three items:
- “Dataset Drift is NOT detected. Dataset drift detection threshold is 0.5” is the dataset-level verdict. The share of drifted columns is compared with the threshold of 0.5. Here, 0 out of 4 columns were flagged, so dataset drift was not detected.
- “4 columns, 0 drifted columns, and 0.0 share of drifted columns” gives the total column count, the number of drifted columns, and their share.
- The table below shows the result for each column.
Stat Testidentifies the test used, andDrift Scoreis its p-value. All four columns have a p-value of 1. Shuffling the same rows leaves the two empirical distributions identical, producing the largest possible p-value.
Next, the shifted dataset:
Shifted-data report: 3 of 4 columns show drift
Three columns changed to Detected. Their share, 0.75, exceeds the threshold of 0.5, so the report flags dataset drift. Only sepal_length still has a p-value of 1. The unchanged column behaving as expected is an additional check on the detection results.
A Drift Score of 0 on the screen is a rounded display value. The actual p-values saved in JSON are:
| Feature | p-value | Verdict (threshold: 0.05) |
|---|---|---|
| sepal_length | 1.0 | Not detected |
| sepal_width | About 2.2e-62 | Detected |
| petal_length | About 4.9e-31 | Detected |
| petal_width | About 9.6e-38 | Detected |
A p-value is the probability, under the assumption that the two samples come from the same distribution, of observing a test statistic at least as extreme as the one obtained. A small value is evidence against that assumption. It does not directly measure the probability that the distributions are the same, or the size of the change. The data helps explain why sepal_width has the smallest p-value: the reference values range from 2.0 to 4.4, while the shifted values range from 5.0 to 7.4. The ranges do not overlap.
There is one detail to watch for. The small histograms in the table make the reference and current data look almost identical in shape. Adding a constant preserves a distribution's shape while shifting its location, and the small plots appear to use each dataset's own value range. Do not conclude that “nothing changed” from the thumbnails alone. Expand the row and compare the distributions on the same axis.
The following screenshot shows the petal_length row expanded using the arrow on the left, with the DATA DISTRIBUTION tab selected.
Reference and current petal_length distributions on the same axis
Gray represents the reference data, and red represents the current data. The reference values span 1.1–6.9, while the current values span 5.1–10.9, shifted four units to the right. The chart makes it clear that the shape is unchanged but the location has moved.
The DATA DRIFT tab in the same row shows the same change in a different way:
Reference mean and standard-deviation band versus current petal_length values
The green line is the reference mean, about 3.78. The green band extends one standard deviation on either side of that mean. Most of the current values, shown in red, sit above the band. With production data ordered by time, this view could help identify when values began to move outside the band. In this experiment, however, the rows were shuffled, so their order on the horizontal axis has no temporal meaning.
The crucial distinction is that data drift and degraded prediction performance are not the same finding. This experiment checked changes in input distributions. I did not collect ground-truth labels for the shifted inputs and measure how much service accuracy declined.
A drift alert can lead to an investigation of changes in data collection, units, preprocessing, or the user population. If ground-truth labels are available, performance metrics should be checked alongside the drift results. Decisions about retraining should follow that investigation.
5. What I learned from passing data between processes
Data generation and report generation ran in separate Python processes. The first step writes CSV files, and the next step reads them.
Even this small example needed clear agreements about:
- Where the files are saved
- Which column names and types the next step expects
- Which file contains reference data, and which contains current data
- The conditions under which the data was generated
I used a Docker bind mount to connect the Windows project folder to /workspace inside the container. Results saved there remained accessible from Windows after the container exited.
This verifies a local folder-sharing setup. It does not verify artifact handoffs between components in Kubeflow Pipelines. In a cluster, execution environments are isolated, so passing a local path string from one step does not guarantee that another container can read the same file.
Connecting this lab back to Chapter 7, I found that explicitly defining input/output formats and storage locations matters just as much as the computation inside each function.
6. A problem I actually encountered: package compatibility
On the first run, training and API validation passed, but importing Evidently failed. The installed combination at that point was Evidently 0.7.23 and pandas 3.1.1.
ImportError: cannot import name 'infer_dtype_from_object'
The error occurred when an internal module imported by Evidently tried to import that function from pandas. After pinning pandas to 2.3.3, the full run passed: model training, HTTP validation, and generation of both drift reports.
I then recorded the main tool versions and the full dependency list, and configured the Docker build to use the complete set of package versions that had been validated. I also pinned the Python base image to the digest used in the run.
This experience showed that “the installation command succeeded” and “the connected workflow ran successfully from beginning to end” are separate checks. That is why I keep a version list and a small end-to-end validation run.
7. Running the lab
Using the code discussed in the book as a starting point, I put together the data generation script, API service, report generation script, full-run script, and Dockerfile. Run the following commands in a WSL terminal from that directory. This assumes Docker is already working in WSL.
docker build -t mlops-mini:ch6-ch7 .
docker run --rm \
--cpus 2 --memory 2g \
--env DO_NOT_TRACK=1 \
--mount "type=bind,source=$PWD,target=/workspace" \
mlops-mini:ch6-ch7
The first command builds an image using the pinned package list. On the first build, downloading the base image and packages takes some time. The second command mounts the current directory at /workspace in the container and executes run_lab.py.
The automated run proceeds through training, validation with actual HTTP requests, and report generation. The API process started for validation is stopped afterward. The terminal prints the following lines in order, along with the training information from Section 2 and the request/response JSON from Section 3.
STEP 1: training and writing data artifacts
STEP 2: starting local BentoML API and validating HTTP requests
STEP 3: reading CSV artifacts in another process and generating reports
Saved drift_normal.html and drift_normal.json
Saved drift_shifted.html and drift_shifted.json
LAB COMPLETE: reports and reproducibility evidence saved in artifacts/
The final line means that all checks passed. The script stops with an error if API predictions differ from direct model predictions, if an invalid request returns something other than HTTP 400, or if the drift counts are not 0 for the control and 3 for the shifted data. If execution stops early, the last STEP line identifies the stage that failed. For API issues, start by checking artifacts/server.log.
You can also check completion in the saved result file:
grep -E '"status"|"normal"|"shifted"' artifacts/result.json
"status": "completed",
"normal": 0.0,
"shifted": 3.0
| Artifact | What it records |
|---|---|
| artifacts/training.json | Sample counts, accuracy, input schema, and model hash |
| artifacts/api-check.json | Actual requests and responses, and validation results |
| artifacts/drift_normal.html | Control report |
| artifacts/drift_shifted.html | Shifted-data report |
| artifacts/drift-summary.json | Metrics for both comparisons |
| artifacts/environment.json | The actual execution environment |
| artifacts/result.json | Overall completion status and validation summary |
The lab README includes a separate command for keeping the API running and calling it manually. The server port is mapped to 127.0.0.1 on the host.
These commands refer to the code directory for this lab. Reproducing the run requires the Python scripts, Dockerfile, and pinned package list together. The model and reports are generated during execution.