Model Evaluation & Quality Gates¶
In Continuous Delivery for Machine Learning (CD4ML), we treat code, data, and models as first-class citizens. Just as code is guarded by unit tests, models must be guarded by statistical Quality Gates to prevent regressions before deployment.
1. Deep Dive on Evaluation Metrics¶
We evaluate our model using three complementary regression metrics:
Mean Absolute Error (MAE)¶
MAE measures the average magnitude of prediction errors, without considering their direction:
- Interpretation: Expressed in the target's unit (number of taxi pickups). An MAE of 11.5 means that, on average, our forecasts miss the actual hourly pickup count by approximately 11.5 rides.
- Robustness: Highly robust to statistical outliers. It is useful for representing typical error behaviors.
Root Mean Squared Error (RMSE)¶
RMSE is the square root of the average of squared prediction errors:
- Interpretation: By squaring the errors before averaging, RMSE penalizes larger errors more heavily.
- Business Significance: Highly critical for fleet logistics. A huge forecasting miss (e.g. predicting 10 pickups when 200 occur) is operationally disastrous because it leaves hundreds of passengers stranded. Dispatchers prefer a model with a slightly higher MAE if it maintains a significantly lower RMSE (fewer catastrophic misses).
Coefficient of Determination ()¶
represents the proportion of variance in the target variable that is predictable from the independent features:
$$R^2 = 1 - \frac{\sum_{i=1}^n (y_i - \hat{y}i)^2}{\sum{i=1}^n (y_i - \bar{y})^2}$$
- Interpretation: Scale-free metric ranging from to . An of indicates that our model explains 65% of the demand variance compared to a naive historical baseline model ().
2. Automated Quality Gates¶
To automate deployment safely, our pipeline implements a gating mechanism:
metrics.require_within(config.evaluation)
- Retrieve Candidate Metrics: The pipeline pulls the candidate model version from MLflow and runs predictions on the test set.
- Evaluate Thresholds: The calculated metrics are compared against maximum error thresholds defined in
configs/project.yaml:max_mae: Max MAE permitted.max_rmse: Max RMSE permitted.
- Deployment Block: If the candidate model's MAE or RMSE exceeds the thresholds, the evaluation script raises a validation exception and aborts. This stops downstream deployment stages, keeping the previous production model active.
3. Pipeline Implementation¶
Below is the code executing the model evaluation and gating:
import logging
from importlib import import_module
from pathlib import Path
from typing import Any
import mlflow
import numpy as np
from mlflow.client import MlflowClient
from mlops_shared.evaluation import (
RegressionMetricCalculator,
RegressionMetrics,
)
from nyc_taxi_demand_forecasting.configuration import ProjectConfigLoader
from nyc_taxi_demand_forecasting.features.hourly_demand import (
FEATURE_COLUMNS,
FEATURE_REFS,
load_entity_df,
)
from nyc_taxi_demand_forecasting.models.registry import latest_model_version
from nyc_taxi_demand_forecasting.models.training import DemandDatasetSplitter
def _tag_evaluation_outcome(
client: MlflowClient,
model_name: str,
model_version: str,
outcome: str,
metrics: RegressionMetrics,
) -> None:
for key, value in (
("evaluated", outcome),
("mae", str(round(metrics.mae, 4))),
("rmse", str(round(metrics.rmse, 4))),
):
client.set_model_version_tag(
name=model_name,
version=model_version,
key=key,
value=value,
)
def run(config_path: Path) -> None:
config = ProjectConfigLoader().load(config_path)
# 1. Load entities dataframe and fetch test historical features from Feast
entity_df = load_entity_df(config.features.training_dataset_path)
feature_store_type = import_module("feast").FeatureStore
store = feature_store_type(repo_path=str(config.feast.repo_path))
training_data = store.get_historical_features(
entity_df=entity_df,
features=list(FEATURE_REFS),
).to_df()
# Split dataset (only evaluate on test split)
_, test_frame = DemandDatasetSplitter().split(
training_data, config.training.test_size
)
# 2. Connect to MLflow and get the latest registered model version
mlflow.set_tracking_uri(config.mlflow.tracking_uri)
client = MlflowClient()
model_name = config.mlflow.registered_model_name
latest_version = latest_model_version(client, model_name)
model_uri = f"models:/{model_name}/{latest_version}"
# 3. Load the model and make predictions
model = mlflow.pyfunc.load_model(model_uri)
# pyfunc emits an ndarray, so normalize the output shape instead of
# asserting a Series — same contract the api/monitor sites carry.
raw_predictions: Any = model.predict( # pyright: ignore[reportUnknownMemberType]
test_frame.loc[:, list(FEATURE_COLUMNS)]
)
predictions = np.asarray(raw_predictions).ravel()
# 4. Compute metrics
metrics = RegressionMetricCalculator().calculate(
test_frame[config.training.target_column], predictions
)
logging.getLogger(__name__).info(
"model_evaluation_completed",
extra={
"mae": metrics.mae,
"rmse": metrics.rmse,
"r2": metrics.r2,
"version": latest_version,
},
)
# 5. Gate before tagging — only a pass marks the version deployable via
# the candidate alias; a failure records its truthful outcome so a
# gate-failed version is distinguishable from a never-evaluated one.
try:
metrics.require_within(config.evaluation)
except ValueError:
_tag_evaluation_outcome(
client, model_name, latest_version, "failed", metrics
)
raise
_tag_evaluation_outcome(
client, model_name, latest_version, "true", metrics
)
client.set_registered_model_alias(
name=model_name,
alias="candidate",
version=latest_version,
)