Batch Inference Pipeline
Build a scheduled batch inference data pipeline that periodically runs trained ML model predictions across newly arrived market data on a configurable schedule, efficiently storing batch predictions to a feature store or database for downstream trading strategy consumption and signal generation.
MLOps: Run Batch Inference Pipeline
This notebook provides a comprehensive guide and implementation for building and running a batch inference pipeline in an MLOps context. Batch inference is a crucial component for applying trained machine learning models to large datasets efficiently and at scale.
Key Concepts
| Concept | Description |
|---|---|
| Batch Inference | The process of making predictions on a large dataset in one go, typically at scheduled intervals or when new data becomes available. It's suitable for scenarios where real-time predictions are not required. |
| MLOps | A set of practices that aims to deploy and maintain ML systems in production reliably and efficiently. It combines Machine Learning, DevOps, and Data Engineering. |
| Data Preprocessing | Steps taken to transform raw input data into a format suitable for model inference, including cleaning, normalization, and feature engineering. |
| Model Loading | The process of retrieving a trained machine learning model from persistent storage (e.g., cloud storage, model registry) into memory for use. |
| Prediction Logic | The core part of the inference pipeline where the loaded model processes preprocessed data to generate predictions. |
| Post-processing | Steps applied to model predictions to make them more useful or actionable, such as converting raw scores into probabilities, labels, or business metrics. |
| Monitoring | Tracking the performance and behavior of the inference pipeline and the deployed model (e.g., data drift, model drift, prediction quality). |
| Error Handling | Robust mechanisms to gracefully handle failures, such as corrupted data, unavailable models, or infrastructure issues, often involving retry strategies and alerting. |
| Scalability | Designing the pipeline to handle increasing data volumes and computational demands efficiently, often leveraging distributed computing frameworks. |
| Orchestration | Managing and scheduling the various steps of the batch inference pipeline, ensuring correct execution order and dependency management (e.g., using Airflow, Kubeflow). |
| Version Control | Managing different versions of models, code, and data to ensure reproducibility and traceability of predictions. |
| Logging | Recording detailed information about the inference process, including input data characteristics, model predictions, errors, and performance metrics, for debugging and auditing. |
Dependency Installation
This section installs all necessary Python libraries required for the batch inference pipeline. We use pip to install packages such as pandas for data manipulation, numpy for numerical operations, scikit-learn for machine learning utilities, tensorflow or pytorch for deep learning models (if applicable), and google-cloud-storage for cloud interactions.
# Install necessary libraries
!pip install pandas numpy scikit-learn tensorflow google-cloud-storage google-cloud-aiplatform matplotlib seaborn --quiet
!pip install loguru --quiet # For structured logging
!pip install tenacity --quiet # For retry mechanisms
Library Imports
This section imports all required libraries for the batch inference pipeline. Standard Python libraries are imported first, followed by third-party libraries. This ensures a clean and organized import structure.
# Standard library imports
import os
import sys
import time
import logging
import random
from collections import deque
from typing import Dict, Any, List, Tuple
# Third-party library imports
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
from sklearn.metrics import accuracy_score, classification_report
from sklearn.preprocessing import StandardScaler
import tensorflow as tf # Example for a deep learning framework
from google.cloud import storage
from google.cloud import aiplatform
from loguru import logger
from tenacity import retry, wait_exponential, stop_after_attempt, Retrying, wait_fixed, retry_if_exception_type
# Visualization libraries
import matplotlib.pyplot as plt
import seaborn as sns
# Configure logging with loguru
logger.remove()
logger.add(sys.stderr, level="INFO", format="<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>")
# Suppress TensorFlow warnings (optional, adjust as needed)
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
# Configure AI Platform logging (optional)
# aiplatform.init(project='YOUR_GCP_PROJECT_ID', location='YOUR_GCP_REGION')
logger.info("All necessary libraries imported and logging configured.")
[32m2026-06-10 07:47:20.391[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m36[0m - [1mAll necessary libraries imported and logging configured.[0m
Core Functions
This section defines the core functions that constitute the batch inference pipeline. Each function is presented in its own dedicated code block, accompanied by a detailed markdown header explaining its purpose, algorithm, parameters, and return values. All functions include comprehensive docstrings, type hints, and logger statements for important operations, ensuring clarity, maintainability, and traceability.
Function Name: create_inference_state
This function initializes the state dictionary for the batch inference pipeline. It sets up essential parameters like the project ID, storage bucket, model path, and other configurations required for the inference process. The algorithm involves defining default values and merging them with any provided custom configurations.
Parameters:
project_id(str): Your Google Cloud Project ID.bucket_name(str): The name of the GCS bucket where model artifacts and data are stored.model_name(str): The name of the model to be used for inference.model_version(str): The version of the model to load.target_variable(str): The name of the target variable (label) in the dataset.feature_columns(List[str]): A list of column names used as features for the model.batch_size(int): The number of records to process in each batch during inference.
Returns:
Dict[str, Any]: An initialized state dictionary containing all necessary configuration for the inference pipeline.
def create_inference_state(
project_id: str,
bucket_name: str,
model_name: str,
model_version: str,
target_variable: str,
feature_columns: List[str],
batch_size: int = 1000
) -> Dict[str, Any]:
"""
Initializes the state dictionary for the batch inference pipeline.
This function sets up fundamental configurations such as GCP project details,
GCS bucket and model information, target variable, feature columns, and
batch processing size. It ensures all necessary parameters are available
for subsequent steps in the pipeline.
Parameters
----------
project_id : str
The Google Cloud Project ID.
bucket_name : str
The name of the GCS bucket for model artifacts and data.
model_name : str
The name of the model to be used for inference.
model_version : str
The version of the model to load.
target_variable : str
The name of the target variable (label) in the dataset.
feature_columns : List[str]
A list of column names used as features for the model.
batch_size : int, optional
The number of records to process in each batch during inference,
defaults to 1000.
Returns
-------
Dict[str, Any]
An initialized state dictionary containing all necessary configuration
for the inference pipeline.
Examples
--------
>>> state = create_inference_state(
... project_id="my-gcp-project",
... bucket_name="my-ml-bucket",
... model_name="fraud-detection-model",
... model_version="v1.0.0",
... target_variable="is_fraud",
... feature_columns=["transaction_amount", "user_age"],
... batch_size=500
... )
>>> assert state['project_id'] == "my-gcp-project"
>>> assert state['batch_size'] == 500
"""
logger.info("Initializing inference state.")
state = {
"project_id": project_id,
"bucket_name": bucket_name,
"model_name": model_name,
"model_version": model_version,
"target_variable": target_variable,
"feature_columns": feature_columns,
"batch_size": batch_size,
"model_path": f"gs://{bucket_name}/models/{model_name}/{model_version}/",
"raw_data_path": f"gs://{bucket_name}/data/raw/",
"processed_data_path": f"gs://{bucket_name}/data/processed/",
"inference_results_path": f"gs://{bucket_name}/inference_results/",
"inference_start_time": None,
"inference_end_time": None,
"total_records_processed": 0,
"predictions": [],
"metrics": {},
"errors": []
}
logger.info(f"Inference state initialized for model '{model_name}' version '{model_version}'.")
logger.debug(f"Initial state: {state}")
return state
Function Name: load_data_for_inference
This function is responsible for loading raw data from a specified Google Cloud Storage (GCS) path into a pandas DataFrame. It utilizes the google-cloud-storage client to interact with GCS and reads CSV files. To ensure robustness, the function incorporates a retry mechanism with exponential backoff for transient errors during file access or network issues.
Algorithm:
- Construct the full GCS URI for the raw data based on the
statedictionary. - Use
google.cloud.storage.Clientto interact with the GCS bucket. - Implement a retry strategy (e.g., using
tenacity) for potentialIOErroror network-related exceptions. - Read the data into a pandas DataFrame. This might involve listing blobs in a prefix if data is sharded or reading a single large file.
- Log the success or failure of the data loading operation.
Parameters:
state(Dict[str, Any]): The current state dictionary containingraw_data_path,bucket_name, andproject_id.file_pattern(str, optional): A pattern to match specific files in theraw_data_path(e.g.,'*.csv'), defaults to"*.csv".
Returns:
Tuple[Dict[str, Any], pd.DataFrame]: An updated state dictionary and the loaded raw data as a pandas DataFrame. Returns an empty DataFrame if no data is found or an error occurs after retries.
def load_data_for_inference(
state: Dict[str, Any],
file_pattern: str = "*.csv"
) -> Tuple[Dict[str, Any], pd.DataFrame]:
"""
Loads raw data for inference from Google Cloud Storage (GCS).
This function reads data from the specified `raw_data_path` in the state
dictionary, supporting retry logic for resilient data retrieval.
Parameters
----------
state : Dict[str, Any]
The current state dictionary, must contain 'raw_data_path',
'bucket_name', and 'project_id'.
file_pattern : str, optional
Pattern to filter files within the raw_data_path, e.g., '*.csv',
defaults to "*.csv".
Returns
-------
Tuple[Dict[str, Any], pd.DataFrame]
An updated state dictionary and the loaded raw data as a pandas DataFrame.
Returns an empty DataFrame if no data is found or an error occurs after retries.
Examples
--------
>>> current_state = {
... "project_id": "test-project",
... "bucket_name": "test-bucket",
... "raw_data_path": "gs://test-bucket/data/raw/inference_data/"
... }
>>> # Assume 'test-bucket/data/raw/inference_data/batch_1.csv' exists in GCS
>>> # with some data.
>>> # state, df = load_data_for_inference(current_state, file_pattern="batch_*.csv")
>>> # if not df.empty: print(f"Loaded {len(df)} records.")
"""
raw_data_path = state.get("raw_data_path")
bucket_name = state.get("bucket_name")
project_id = state.get("project_id")
if not all([raw_data_path, bucket_name, project_id]):
logger.error("Missing raw_data_path, bucket_name, or project_id in state for data loading.")
state["errors"].append("Missing state parameters for data loading.")
return state, pd.DataFrame()
logger.info(f"Attempting to load data from GCS path: {raw_data_path} with pattern: {file_pattern}")
data_df = pd.DataFrame()
@retry(
wait=wait_exponential(multiplier=1, min=4, max=60),
stop=stop_after_attempt(5),
retry=retry_if_exception_type((IOError, ConnectionError, Exception)),
reraise=True
)
def _load_from_gcs():
logger.debug(f"Retrying data load for {raw_data_path}...")
client = storage.Client(project=project_id)
# Extract bucket name from path if not explicitly provided, or use state's
path_parts = raw_data_path.replace(f"gs://{bucket_name}/", "").split('/')
prefix = '/'.join(path_parts) if path_parts[-1] else '/'.join(path_parts[:-1])
if prefix and not prefix.endswith('/'):
prefix += '/'
blobs = list(client.bucket(bucket_name).list_blobs(prefix=prefix))
csv_files = [blob for blob in blobs if blob.name.endswith('.csv') and glob_match(blob.name.split('/')[-1], file_pattern)]
if not csv_files:
logger.warning(f"No CSV files found matching '{file_pattern}' in '{raw_data_path}'.")
return pd.DataFrame()
all_dfs = []
for blob in csv_files:
blob_path = f"gs://{bucket_name}/{blob.name}"
logger.info(f"Reading file: {blob_path}")
# Add a small random jitter to prevent thundering herd problem on GCS
time.sleep(random.uniform(0.1, 0.5))
try:
df = pd.read_csv(blob_path)
all_dfs.append(df)
except Exception as e:
logger.warning(f"Could not read {blob_path}: {e}")
# If one file fails, try to continue with others or raise if critical.
# For now, we'll log and continue.
if not all_dfs:
logger.error(f"Failed to load any data from {raw_data_path} after filtering.")
return pd.DataFrame()
return pd.concat(all_dfs, ignore_index=True)
def glob_match(filename: str, pattern: str) -> bool:
# Simple glob-like matching for file patterns
# More robust solution would use fnmatch.fnmatch
pattern_parts = pattern.replace('.', '\\.').replace('*', '.*').split('/')
filename_parts = filename.split('/')
if len(pattern_parts) != len(filename_parts):
return False
for p, f in zip(pattern_parts, filename_parts):
if not re.fullmatch(p, f):
return False
return True
import re # Imported locally for glob_match
try:
data_df = _load_from_gcs()
logger.info(f"Successfully loaded {len(data_df)} records for inference.")
except Exception as e:
logger.error(f"Failed to load data after multiple retries from {raw_data_path}: {e}")
state["errors"].append(f"Data loading failed: {e}")
data_df = pd.DataFrame() # Ensure data_df is empty on failure
state["total_records_processed"] = len(data_df)
return state, data_dfFunction Name: preprocess_data_for_inference
This function performs necessary data preprocessing steps on the raw input DataFrame to prepare it for model inference. This typically includes handling missing values, encoding categorical features, and scaling numerical features. The function ensures that the data conforms to the expectations of the loaded model. Preprocessing artifacts (like scalers) are stored in the state dictionary for consistency and potential reuse.
Algorithm:
- Identify numerical and categorical features based on the
feature_columnsspecified in thestate. - Handle missing values: For numerical columns, fill with the median; for categorical, fill with the most frequent value.
- Apply
StandardScalerto numerical features. The scaler is fitted on the current batch data (or a pre-fitted one from the state) and then transformed. - Perform one-hot encoding for categorical features.
- Ensure the final DataFrame only contains the required
feature_columnsin the correct order. - Update the
statedictionary with any fitted preprocessing objects (e.g.,StandardScaler). - Log the preprocessing steps and outcomes.
Parameters:
state(Dict[str, Any]): The current state dictionary, containingfeature_columnsand potentially pre-fitted scalers.raw_df(pd.DataFrame): The raw input data as a pandas DataFrame.
Returns:
Tuple[Dict[str, Any], pd.DataFrame]: An updated state dictionary and the preprocessed data as a pandas DataFrame.
def preprocess_data_for_inference(
state: Dict[str, Any],
raw_df: pd.DataFrame
) -> Tuple[Dict[str, Any], pd.DataFrame]:
"""
Preprocesses raw data for model inference.
Performs data cleaning, feature engineering, and scaling to prepare
the input data for the machine learning model. This includes handling
missing values, scaling numerical features, and potentially encoding
categorical features.
Parameters
----------
state : Dict[str, Any]
The current state dictionary, must contain 'feature_columns'.
Can also contain 'scaler' for pre-fitted scalers.
raw_df : pd.DataFrame
The raw input data to be preprocessed.
Returns
-------
Tuple[Dict[str, Any], pd.DataFrame]
An updated state dictionary and the preprocessed data as a pandas DataFrame.
Returns an empty DataFrame if preprocessing fails.
Examples
--------
>>> current_state = {
... "feature_columns": ["numerical_feature", "categorical_feature"],
... "target_variable": "label"
... }
>>> data = pd.DataFrame({
... "numerical_feature": [10, 20, np.nan, 40, 50],
... "categorical_feature": ["A", "B", "A", "C", "B"],
... "another_feature": [1,2,3,4,5]
... })
>>> # state, processed_df = preprocess_data_for_inference(current_state, data)
>>> # if not processed_df.empty: print(processed_df.head())
"""
logger.info("Starting data preprocessing for inference.")
if raw_df.empty:
logger.warning("Raw DataFrame is empty, skipping preprocessing.")
return state, pd.DataFrame()
feature_cols = state.get("feature_columns", [])
if not feature_cols:
logger.error("Feature columns not defined in state. Cannot preprocess.")
state["errors"].append("Feature columns missing for preprocessing.")
return state, pd.DataFrame()
processed_df = raw_df.copy()
try:
# Identify numerical and categorical features that are in the raw_df
available_feature_cols = [col for col in feature_cols if col in processed_df.columns]
numerical_cols = processed_df[available_feature_cols].select_dtypes(include=np.number).columns.tolist()
categorical_cols = processed_df[available_feature_cols].select_dtypes(include='object').columns.tolist()
logger.debug(f"Numerical columns identified: {numerical_cols}")
logger.debug(f"Categorical columns identified: {categorical_cols}")
# Handle missing values with a small random jitter to avoid perfect colinearity if values are constant
for col in numerical_cols:
if processed_df[col].isnull().any():
median_val = processed_df[col].median()
logger.info(f"Filling missing numerical values in '{col}' with median: {median_val}")
processed_df[col] = processed_df[col].fillna(median_val + random.uniform(-0.01, 0.01))
for col in categorical_cols:
if processed_df[col].isnull().any():
mode_val = processed_df[col].mode()[0] # mode() can return multiple if tied
logger.info(f"Filling missing categorical values in '{col}' with mode: {mode_val}")
processed_df[col] = processed_df[col].fillna(mode_val)
# Scaling numerical features
if numerical_cols:
logger.info(f"Scaling numerical features: {numerical_cols}")
scaler = state.get("scaler")
if scaler is None:
logger.warning("No pre-fitted scaler found in state. Fitting new StandardScaler. \n In a production scenario, a pre-fitted scaler should be loaded.")
scaler = StandardScaler()
processed_df[numerical_cols] = scaler.fit_transform(processed_df[numerical_cols])
state["scaler"] = scaler # Store for potential future use or consistency
else:
logger.info("Using pre-fitted scaler from state to transform numerical features.")
processed_df[numerical_cols] = scaler.transform(processed_df[numerical_cols])
# One-hot encode categorical features (if any)
if categorical_cols:
logger.info(f"One-hot encoding categorical features: {categorical_cols}")
# For simplicity, we'll use pandas get_dummies. In a real MLOps setting,
# a consistent encoder (e.g., from sklearn or a stored mapping) should be used.
processed_df = pd.get_dummies(processed_df, columns=categorical_cols, drop_first=True, dtype=int)
# Update feature_columns in state to reflect the actual columns in processed_df
# This is crucial for subsequent steps like run_batch_inference
state["feature_columns"] = processed_df.columns.tolist()
logger.info(f"Data preprocessing complete. Processed {len(processed_df)} records.")
logger.debug(f"Processed DataFrame head:\n{processed_df.head()}")
except Exception as e:
logger.error(f"Error during data preprocessing: {e}", exc_info=True)
state["errors"].append(f"Preprocessing failed: {e}")
return state, pd.DataFrame()
return state, processed_dfFunction Name: load_model_for_inference
This function is responsible for loading a trained machine learning model from a specified GCS path. It supports various model formats, including TensorFlow SavedModel and Scikit-learn models (via joblib). The function incorporates a retry mechanism with exponential backoff to handle transient network issues or temporary unavailability of model artifacts. It updates the state dictionary with the loaded model object.
Algorithm:
- Construct the full GCS URI for the model based on
model_pathin thestatedictionary. - Determine the model type (e.g., TensorFlow, Scikit-learn) based on expected file extensions or metadata.
- Implement a retry strategy for model loading operations.
- Load the model using the appropriate library (e.g.,
tf.keras.models.load_modelfor TensorFlow,joblib.loadfor Scikit-learn). - Store the loaded model in the
statedictionary. - Log the success or failure of the model loading.
Parameters:
state(Dict[str, Any]): The current state dictionary, which must containmodel_pathandmodel_name.
Returns:
Tuple[Dict[str, Any], Any]: An updated state dictionary and the loaded model object. ReturnsNoneas the model object if loading fails after retries.
import joblib # For loading scikit-learn models
def load_model_for_inference(
state: Dict[str, Any]
) -> Tuple[Dict[str, Any], Any]: # Any for the model type, as it can be tf.Model, sklearn model etc.
"""
Loads a trained machine learning model for inference from GCS.
This function retrieves the model based on the 'model_path' specified in
the state dictionary. It supports different model formats and includes
retry logic for resilient model loading.
Parameters
----------
state : Dict[str, Any]
The current state dictionary, must contain 'model_path' and 'model_name'.
Returns
-------
Tuple[Dict[str, Any], Any]
An updated state dictionary and the loaded model object. Returns `None`
as the model object if loading fails after retries.
Examples
--------
>>> current_state = {
... "project_id": "my-gcp-project",
... "bucket_name": "my-ml-bucket",
... "model_name": "my-tf-model",
... "model_version": "1",
... "model_path": "gs://my-ml-bucket/models/my-tf-model/1/"
... }
>>> # Example: model_object = tf.keras.models.Sequential([...])
>>> # model_object.save('my-tf-model/1/')
>>> # state, model = load_model_for_inference(current_state)
>>> # if model: print("Model loaded successfully!")
"""
model_path = state.get("model_path")
model_name = state.get("model_name")
if not all([model_path, model_name]):
logger.error("Missing model_path or model_name in state for model loading.")
state["errors"].append("Missing state parameters for model loading.")
return state, None
logger.info(f"Attempting to load model '{model_name}' from: {model_path}")
loaded_model = None
@retry(
wait=wait_exponential(multiplier=1, min=4, max=60),
stop=stop_after_attempt(5),
retry=retry_if_exception_type((IOError, ConnectionError, Exception)),
reraise=True
)
def _load_model_with_retry():
logger.debug(f"Retrying model load for {model_name} from {model_path}...")
# Add a small random jitter to prevent thundering herd problem
time.sleep(random.uniform(0.1, 0.5))
try:
# Heuristic to determine model type - can be made more robust with metadata
if "tensorflow" in model_path or "tf" in model_name.lower():
# Assuming TensorFlow SavedModel format
logger.info(f"Loading TensorFlow model from {model_path}")
model = tf.keras.models.load_model(model_path)
elif "sklearn" in model_name.lower() or model_path.endswith('.joblib'):
# Assuming Scikit-learn model saved with joblib
logger.info(f"Loading Scikit-learn model from {model_path}")
# For joblib, typically load a single file, not a directory
# This part might need adjustment based on how the sklearn model is saved
# For simplicity, assuming model_path points directly to the .joblib file
client = storage.Client(project=state.get("project_id"))
bucket = client.get_bucket(state.get("bucket_name"))
blob_name = model_path.replace(f"gs://{state.get('bucket_name')}/", "")
blob = bucket.blob(blob_name)
# Download to a temporary file, then load
temp_model_file = f"/tmp/{model_name}.joblib"
blob.download_to_filename(temp_model_file)
model = joblib.load(temp_model_file)
os.remove(temp_model_file)
else:
logger.warning(f"Unknown model type for '{model_name}'. Attempting generic load.")
# Fallback for other model types if applicable
# For example, for PyTorch, you might need to define a model architecture first
raise ValueError("Unsupported model type or format.")
return model
except Exception as e:
logger.error(f"Error loading model '{model_name}' from {model_path}: {e}")
raise # Re-raise for tenacity to catch
try:
loaded_model = _load_model_with_retry()
state["model"] = loaded_model
logger.info(f"Model '{model_name}' loaded successfully.")
except Exception as e:
logger.error(f"Failed to load model '{model_name}' after multiple retries: {e}")
state["errors"].append(f"Model loading failed: {e}")
state["model"] = None # Ensure model is None on failure
return state, loaded_model
Function Name: run_batch_inference
This function executes the batch inference process using the loaded model on the preprocessed data. It iterates through the input data in batches, makes predictions, and collects the results. This function is designed to handle potentially large datasets by processing them incrementally, preventing memory exhaustion. It also tracks the total number of records processed and the time taken for inference.
Algorithm:
- Retrieve the loaded model, batch size, and feature columns from the
statedictionary. - Iterate over the
processed_dfin batches. - For each batch, extract the relevant features.
- Make predictions using the loaded model (handling both TensorFlow and Scikit-learn prediction methods).
- Store the predictions, associating them with the original data's index or identifier.
- Update the
statewith the total number of processed records and collected predictions. - Log the progress and any encountered issues.
Parameters:
state(Dict[str, Any]): The current state dictionary, which must contain the loadedmodel,batch_size, andfeature_columns.processed_df(pd.DataFrame): The preprocessed data on which to run inference.
Returns:
Tuple[Dict[str, Any], pd.Series]: An updated state dictionary and a pandas Series containing the generated predictions. Returns an empty Series if inference fails.
def run_batch_inference(
state: Dict[str, Any],
processed_df: pd.DataFrame
) -> Tuple[Dict[str, Any], pd.Series]:
"""
Executes batch inference using the loaded model on preprocessed data.
Processes the input DataFrame in batches, makes predictions, and stores
the results in the state dictionary. Supports TensorFlow and Scikit-learn models.
Parameters
----------
state : Dict[str, Any]
The current state dictionary, must contain the loaded 'model',
'batch_size', and 'feature_columns'.
processed_df : pd.DataFrame
The preprocessed data to run inference on.
Returns
-------
Tuple[Dict[str, Any], pd.Series]
An updated state dictionary and a pandas Series containing the generated
predictions. Returns an empty Series if inference fails.
Examples
--------
>>> # Assuming 'state' contains a loaded model and 'processed_df' has features
>>> # state, predictions = run_batch_inference(state, processed_df)
>>> # if not predictions.empty: print(predictions.head())
"""
logger.info("Starting batch inference.")
model = state.get("model")
batch_size = state.get("batch_size")
feature_cols = state.get("feature_columns")
if model is None:
logger.error("No model found in state. Cannot run inference.")
state["errors"].append("Model missing for inference.")
return state, pd.Series()
if processed_df.empty:
logger.warning("Processed DataFrame is empty, skipping inference.")
return state, pd.Series()
if not feature_cols:
logger.error("Feature columns not defined in state. Cannot run inference.")
state["errors"].append("Feature columns missing for inference.")
return state, pd.Series()
predictions_list = []
state["inference_start_time"] = time.time()
num_batches = int(np.ceil(len(processed_df) / batch_size))
logger.info(f"Running inference on {len(processed_df)} records in {num_batches} batches of size {batch_size}.")
try:
for i in range(num_batches):
start_idx = i * batch_size
end_idx = min((i + 1) * batch_size, len(processed_df))
batch_data = processed_df.iloc[start_idx:end_idx]
# Ensure batch_data has only the feature columns and in correct order
# This step is crucial if get_dummies added new columns or changed order
# A robust solution would involve storing the exact feature order from training
# For now, we'll assume processed_df is already aligned from the preprocess step
input_features = batch_data[feature_cols]
# Add random jitter to simulate variable processing times, for backoff scenario
time.sleep(random.uniform(0.01, 0.05))
batch_predictions = None
# Heuristic to determine model type based on its method attributes
if hasattr(model, 'predict') and callable(getattr(model, 'predict')):
if hasattr(model, 'call') or isinstance(model, tf.keras.Model): # More specific for TF Keras
logger.debug(f"Making TensorFlow Keras predictions for batch {i+1}/{num_batches}")
# Keras predict expects numpy array or tf.Tensor
batch_predictions = model.predict(input_features.values).flatten()
elif hasattr(model, 'predict_proba') and callable(getattr(model, 'predict_proba')):
logger.debug(f"Making Scikit-learn predict_proba predictions for batch {i+1}/{num_batches}")
# Scikit-learn models usually have predict_proba for classification
batch_predictions = model.predict_proba(input_features.values)[:, 1] # Probability of the positive class
else:
logger.debug(f"Making Scikit-learn predict predictions for batch {i+1}/{num_batches}")
batch_predictions = model.predict(input_features.values)
else:
logger.error("Loaded model does not have a 'predict' method. Cannot perform inference.")
raise AttributeError("Model lacks predict method.")
if batch_predictions is not None:
predictions_list.extend(batch_predictions)
logger.info(f"Processed batch {i+1}/{num_batches}. Records: {end_idx - start_idx}")
state["inference_end_time"] = time.time()
total_inference_time = state["inference_end_time"] - state["inference_start_time"]
logger.info(f"Batch inference completed in {total_inference_time:.2f} seconds.")
state["total_records_processed"] = len(processed_df)
state["predictions"] = predictions_list
return state, pd.Series(predictions_list, index=processed_df.index)
except Exception as e:
logger.error(f"Error during batch inference: {e}", exc_info=True)
state["errors"].append(f"Inference failed: {e}")
state["inference_end_time"] = time.time() # End time even on failure
return state, pd.Series()
Function Name: postprocess_predictions
This function takes the raw model predictions and performs any necessary post-processing steps to make them more interpretable, useful, or actionable. This might include converting probabilities to class labels, applying business rules, or enriching predictions with original data features. The function also updates the state dictionary with the final, post-processed results.
Algorithm:
- Retrieve the raw predictions and, if necessary, the original data indices from the
statedictionary or the inputprocessed_df. - If predictions are probabilities, apply a threshold to convert them into binary class labels.
- Optionally, join the predictions back to the original
processed_dfto provide context. - Log the post-processing steps and the structure of the final results.
- Update the
statedictionary with the post-processed predictions.
Parameters:
state(Dict[str, Any]): The current state dictionary, containingpredictionsand potentiallytarget_variable.original_indices(pd.Index): The original DataFrame indices to align predictions.prediction_threshold(float, optional): The threshold to convert probabilities to binary labels (e.g., 0.5 for binary classification), defaults to 0.5.
Returns:
Tuple[Dict[str, Any], pd.DataFrame]: An updated state dictionary and a pandas DataFrame containing the post-processed predictions, potentially including original identifiers.
def postprocess_predictions(
state: Dict[str, Any],
original_indices: pd.Index,
prediction_threshold: float = 0.5
) -> Tuple[Dict[str, Any], pd.DataFrame]:
"""
Post-processes raw model predictions to make them actionable.
This typically involves converting probabilities to class labels based on a
threshold, and combining predictions with original data identifiers.
Parameters
----------
state : Dict[str, Any]
The current state dictionary, must contain 'predictions' (list of raw predictions)
and 'target_variable' (for naming the prediction column).
original_indices : pd.Index
The index from the original (or processed) DataFrame to align predictions.
prediction_threshold : float, optional
The threshold to convert continuous predictions (probabilities) into
binary class labels, defaults to 0.5.
Returns
-------
Tuple[Dict[str, Any], pd.DataFrame]
An updated state dictionary and a pandas DataFrame containing the
post-processed predictions, with 'prediction' and 'confidence' columns.
Returns an empty DataFrame if post-processing fails or no predictions.
Examples
--------
>>> current_state = {
... "predictions": [0.1, 0.9, 0.3, 0.7, 0.05],
... "target_variable": "is_fraud"
... }
>>> original_idx = pd.Index([101, 102, 103, 104, 105])
>>> # state, final_preds_df = postprocess_predictions(current_state, original_idx)
>>> # if not final_preds_df.empty: print(final_preds_df.head())
"""
logger.info("Starting prediction post-processing.")
raw_predictions = state.get("predictions")
target_variable = state.get("target_variable", "prediction")
if not raw_predictions:
logger.warning("No raw predictions found in state, skipping post-processing.")
return state, pd.DataFrame()
try:
# Convert list of predictions to a Series for easier handling
predictions_series = pd.Series(raw_predictions, index=original_indices)
# Initialize post-processed DataFrame with index
postprocessed_df = pd.DataFrame(index=original_indices)
# Assume raw predictions are probabilities if between 0 and 1
if all(0.0 <= p <= 1.0 for p in raw_predictions):
postprocessed_df[f"{target_variable}_confidence"] = predictions_series
postprocessed_df[f"{target_variable}_label"] = (predictions_series >= prediction_threshold).astype(int)
logger.info(f"Converted probabilities to labels using threshold {prediction_threshold}.")
else:
# If not probabilities, treat as direct predictions (e.g., regression output or discrete classes)
postprocessed_df[f"{target_variable}"] = predictions_series
logger.info("Treated raw predictions as direct labels/values.")
state["postprocessed_predictions"] = postprocessed_df # Store the DataFrame itself
logger.info(f"Prediction post-processing complete. Generated {len(postprocessed_df)} post-processed records.")
logger.debug(f"Post-processed predictions head:\n{postprocessed_df.head()}")
except Exception as e:
logger.error(f"Error during prediction post-processing: {e}", exc_info=True)
state["errors"].append(f"Post-processing failed: {e}")
return state, pd.DataFrame()
return state, postprocessed_df
Function Name: store_inference_results
This function is responsible for storing the final post-processed inference results to a specified location in Google Cloud Storage (GCS). It ensures that the results are saved in a durable and accessible format (e.g., Parquet or CSV), facilitating further analysis or downstream consumption. The function includes error handling and retry mechanisms for resilient data storage.
Algorithm:
- Construct the GCS output path based on the
inference_results_pathin thestatedictionary, including a timestamp or unique identifier to prevent overwrites. - Convert the
postprocessed_dfinto a suitable format (e.g., Parquet for efficiency, CSV for readability). - Use
pandas.DataFrame.to_csvorpandas.DataFrame.to_parquetto write the data directly to GCS. - Implement a retry strategy for file writing operations to handle transient storage issues.
- Log the outcome of the storage operation.
Parameters:
state(Dict[str, Any]): The current state dictionary, containinginference_results_pathandbucket_name.postprocessed_df(pd.DataFrame): The DataFrame containing the final post-processed predictions.
Returns:
Dict[str, Any]: An updated state dictionary, including the path where results were stored or any errors encountered.
def store_inference_results(
state: Dict[str, Any],
postprocessed_df: pd.DataFrame
) -> Dict[str, Any]:
"""
Stores the post-processed inference results to Google Cloud Storage.
Saves the DataFrame of predictions to a specified GCS location, typically
in Parquet or CSV format, with retry logic for robust storage.
Parameters
----------
state : Dict[str, Any]
The current state dictionary, must contain 'inference_results_path'
and 'bucket_name'.
postprocessed_df : pd.DataFrame
The DataFrame containing the final post-processed predictions.
Returns
-------
Dict[str, Any]
An updated state dictionary, including the path where results were
stored or any errors encountered.
Examples
--------
>>> current_state = {
... "bucket_name": "my-ml-bucket",
... "inference_results_path": "gs://my-ml-bucket/inference_results/"
... }
>>> final_predictions = pd.DataFrame({
... "id": [1,2,3], "prediction_label": [0,1,0], "prediction_confidence": [0.1, 0.9, 0.3]
... })
>>> # updated_state = store_inference_results(current_state, final_predictions)
>>> # if 'stored_results_path' in updated_state: print("Results saved.")
"""
inference_results_path = state.get("inference_results_path")
bucket_name = state.get("bucket_name")
if not all([inference_results_path, bucket_name]):
logger.error("Missing inference_results_path or bucket_name in state for storing results.")
state["errors"].append("Missing state parameters for storing results.")
return state
if postprocessed_df.empty:
logger.warning("Post-processed DataFrame is empty, skipping result storage.")
return state
# Generate a unique filename for the results
timestamp = time.strftime("%Y%m%d-%H%M%S")
output_filename = f"inference_results_{timestamp}.parquet"
full_output_path = os.path.join(inference_results_path, output_filename)
logger.info(f"Attempting to store inference results to GCS: {full_output_path}")
@retry(
wait=wait_exponential(multiplier=1, min=4, max=60),
stop=stop_after_attempt(5),
retry=retry_if_exception_type((IOError, ConnectionError, Exception)),
reraise=True
)
def _save_to_gcs():
logger.debug(f"Retrying saving results to {full_output_path}...")
time.sleep(random.uniform(0.1, 0.5))
try:
# Using pandas to_parquet for efficiency and schema preservation
postprocessed_df.to_parquet(full_output_path, index=False)
logger.info(f"Successfully saved inference results to {full_output_path}")
except Exception as e:
logger.error(f"Error saving results to GCS: {e}")
raise # Re-raise for tenacity to catch
try:
_save_to_gcs()
state["stored_results_path"] = full_output_path
except Exception as e:
logger.error(f"Failed to store inference results after multiple retries: {e}")
state["errors"].append(f"Storing results failed: {e}")
return state
Demonstration/Visualization
This section demonstrates the end-to-end batch inference pipeline using the functions defined above. We will simulate realistic data, load a dummy model, preprocess the data, run inference, post-process predictions, and visualize the results. This provides a tangible example of how each component interacts and confirms the pipeline's functionality.
1. Simulate Data and Initialize State
We'll begin by simulating a raw dataset for inference and initializing the pipeline's state dictionary with placeholder values. This setup allows us to run the entire pipeline without needing actual cloud resources or pre-trained models for this demonstration.
# --- Demonstration Setup ---
logger.info("--- Starting Demonstration ---")
# 1. Simulate raw data for inference
# Let's create a dummy dataset for demonstration purposes
np.random.seed(42)
num_samples = 1000
dummy_data = pd.DataFrame({
'feature_1': np.random.rand(num_samples) * 100,
'feature_2': np.random.normal(loc=50, scale=10, size=num_samples),
'feature_3': np.random.randint(0, 5, num_samples).astype(str), # Categorical feature
'feature_4': np.random.rand(num_samples) * 10,
'id': range(num_samples)
})
# Introduce some missing values for demonstration of preprocessing
dummy_data.loc[np.random.choice(num_samples, 50, replace=False), 'feature_1'] = np.nan
dummy_data.loc[np.random.choice(num_samples, 30, replace=False), 'feature_3'] = np.nan
logger.info(f"Simulated raw data with {len(dummy_data)} samples and some missing values.")
# 2. Define placeholder state variables
PROJECT_ID = "gcp-project-demo"
BUCKET_NAME = "mlops-inference-bucket"
MODEL_NAME = "classification-model"
MODEL_VERSION = "v1.0.0"
TARGET_VARIABLE = "risk_score"
FEATURE_COLUMNS = ['feature_1', 'feature_2', 'feature_3', 'feature_4']
BATCH_SIZE = 100
# 3. Initialize inference state
initial_state = create_inference_state(
project_id=PROJECT_ID,
bucket_name=BUCKET_NAME,
model_name=MODEL_NAME,
model_version=MODEL_VERSION,
target_variable=TARGET_VARIABLE,
feature_columns=FEATURE_COLUMNS,
batch_size=BATCH_SIZE
)
logger.info("Inference state initialized for demonstration.")
print("Initial State Snippet:")
print(pd.Series({k: v for k, v in initial_state.items() if not isinstance(v, list) and not isinstance(v, dict)}).to_frame(name="Value"))
# Store the original dataframe to simulate loading it later
original_data_for_demo = dummy_data.copy()
[32m2026-06-10 07:47:20.521[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m3[0m - [1m--- Starting Demonstration ---[0m [32m2026-06-10 07:47:20.527[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m22[0m - [1mSimulated raw data with 1000 samples and some missing values.[0m [32m2026-06-10 07:47:20.528[0m | [1mINFO [0m | [36m__main__[0m:[36mcreate_inference_state[0m:[36m56[0m - [1mInitializing inference state.[0m [32m2026-06-10 07:47:20.529[0m | [1mINFO [0m | [36m__main__[0m:[36mcreate_inference_state[0m:[36m76[0m - [1mInference state initialized for model 'classification-model' version 'v1.0.0'.[0m [32m2026-06-10 07:47:20.530[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m44[0m - [1mInference state initialized for demonstration.[0m
Initial State Snippet:
Value
project_id gcp-project-demo
bucket_name mlops-inference-bucket
model_name classification-model
model_version v1.0.0
target_variable risk_score
batch_size 100
model_path gs://mlops-inference-bucket/models/classificat...
raw_data_path gs://mlops-inference-bucket/data/raw/
processed_data_path gs://mlops-inference-bucket/data/processed/
inference_results_path gs://mlops-inference-bucket/inference_results/
inference_start_time None
inference_end_time None
total_records_processed 0
2. Load and Preprocess Data
In a real-world scenario, load_data_for_inference would fetch data from GCS. For this demonstration, we will directly use our simulated original_data_for_demo to represent the loaded raw data. Then, we will apply the preprocess_data_for_inference function.
# Simulate loading data (using our dummy data)
state, loaded_data_df = initial_state, original_data_for_demo
logger.info(f"Simulated loading {len(loaded_data_df)} records.")
print("\nLoaded Data Head (before preprocessing):")
print(loaded_data_df.head())
print("\nMissing values before preprocessing:")
print(loaded_data_df.isnull().sum())
# Preprocess the loaded data
state, processed_data_df = preprocess_data_for_inference(state, loaded_data_df)
if not processed_data_df.empty:
logger.info("Data preprocessing completed.")
print("\nProcessed Data Head:")
print(processed_data_df.head())
print("\nProcessed Data Info:")
print(processed_data_df.info())
else:
logger.error("Data preprocessing failed or resulted in an empty DataFrame.")
[32m2026-06-10 07:47:20.542[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m3[0m - [1mSimulated loading 1000 records.[0m [32m2026-06-10 07:47:20.549[0m | [1mINFO [0m | [36m__main__[0m:[36mpreprocess_data_for_inference[0m:[36m41[0m - [1mStarting data preprocessing for inference.[0m [32m2026-06-10 07:47:20.552[0m | [1mINFO [0m | [36m__main__[0m:[36mpreprocess_data_for_inference[0m:[36m69[0m - [1mFilling missing numerical values in 'feature_1' with median: 49.53341131587344[0m [32m2026-06-10 07:47:20.555[0m | [1mINFO [0m | [36m__main__[0m:[36mpreprocess_data_for_inference[0m:[36m75[0m - [1mFilling missing categorical values in 'feature_3' with mode: 0[0m [32m2026-06-10 07:47:20.557[0m | [1mINFO [0m | [36m__main__[0m:[36mpreprocess_data_for_inference[0m:[36m80[0m - [1mScaling numerical features: ['feature_1', 'feature_2', 'feature_4'][0m [32m2026-06-10 07:47:20.559[0m | [33m[1mWARNING [0m | [36m__main__[0m:[36mpreprocess_data_for_inference[0m:[36m83[0m - [33m[1mNo pre-fitted scaler found in state. Fitting new StandardScaler. In a production scenario, a pre-fitted scaler should be loaded.[0m [32m2026-06-10 07:47:20.568[0m | [1mINFO [0m | [36m__main__[0m:[36mpreprocess_data_for_inference[0m:[36m93[0m - [1mOne-hot encoding categorical features: ['feature_3'][0m [32m2026-06-10 07:47:20.572[0m | [1mINFO [0m | [36m__main__[0m:[36mpreprocess_data_for_inference[0m:[36m102[0m - [1mData preprocessing complete. Processed 1000 records.[0m [32m2026-06-10 07:47:20.577[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m13[0m - [1mData preprocessing completed.[0m
Loaded Data Head (before preprocessing): feature_1 feature_2 feature_3 feature_4 id 0 37.454012 51.777010 0 0.920670 0 1 95.071431 36.646556 0 5.990446 1 2 73.199394 53.801979 NaN 6.236488 2 3 59.865848 56.105857 4 6.485048 3 4 15.601864 55.597904 0 2.674020 4 Missing values before preprocessing: feature_1 50 feature_2 0 feature_3 30 feature_4 0 id 0 dtype: int64 Processed Data Head: feature_1 feature_2 feature_4 id feature_3_1 feature_3_2 feature_3_3 \ 0 -0.404776 0.079727 -1.398482 0 0 0 0 1 1.609507 -1.451016 0.364974 1 0 0 0 2 0.844869 0.284592 0.450556 2 0 0 0 3 0.378733 0.517675 0.537015 3 0 0 0 4 -1.168718 0.466286 -0.788602 4 0 0 0 feature_3_4 0 0 1 0 2 0 3 1 4 0 Processed Data Info: <class 'pandas.core.frame.DataFrame'> RangeIndex: 1000 entries, 0 to 999 Data columns (total 8 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 feature_1 1000 non-null float64 1 feature_2 1000 non-null float64 2 feature_4 1000 non-null float64 3 id 1000 non-null int64 4 feature_3_1 1000 non-null int64 5 feature_3_2 1000 non-null int64 6 feature_3_3 1000 non-null int64 7 feature_3_4 1000 non-null int64 dtypes: float64(3), int64(5) memory usage: 62.6 KB None
3. Load Dummy Model and Run Inference
Since we don't have a real model trained and stored in GCS for this demonstration, we will create a simple dummy Scikit-learn classification model and then call run_batch_inference using this dummy model. This allows us to test the inference execution flow.
from sklearn.linear_model import LogisticRegression
# Simulate a dummy model (e.g., a simple Logistic Regression)
logger.info("Simulating a dummy model for demonstration.")
# Create some dummy target values for fitting the dummy model
# In a real scenario, the model would already be trained.
# Here, we're just creating a placeholder for the `model` object.
dummy_X = processed_data_df.values # Features for dummy model
dummy_y = np.random.randint(0, 2, size=len(processed_data_df)) # Binary target
dummy_model = LogisticRegression(random_state=42)
dummy_model.fit(dummy_X, dummy_y)
# Add the dummy model to the state for demonstration purposes
# In a real scenario, load_model_for_inference would fetch it from GCS.
state['model'] = dummy_model
logger.info("Dummy model created and added to state.")
# Run batch inference
state, predictions_series = run_batch_inference(state, processed_data_df)
if not predictions_series.empty:
logger.info(f"Inference completed. Generated {len(predictions_series)} predictions.")
print("\nPredictions Snippet (raw output):")
print(predictions_series.head())
else:
logger.error("Batch inference failed or resulted in no predictions.")
[32m2026-06-10 07:47:20.596[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m4[0m - [1mSimulating a dummy model for demonstration.[0m
/usr/local/lib/python3.12/dist-packages/sklearn/linear_model/_logistic.py:465: ConvergenceWarning: lbfgs failed to converge (status=1):
STOP: TOTAL NO. OF ITERATIONS REACHED LIMIT.
Increase the number of iterations (max_iter) or scale the data as shown in:
https://scikit-learn.org/stable/modules/preprocessing.html
Please also refer to the documentation for alternative solver options:
https://scikit-learn.org/stable/modules/linear_model.html#logistic-regression
n_iter_i = _check_optimize_result(
[32m2026-06-10 07:47:20.625[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m19[0m - [1mDummy model created and added to state.[0m
[32m2026-06-10 07:47:20.626[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m31[0m - [1mStarting batch inference.[0m
[32m2026-06-10 07:47:20.627[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m55[0m - [1mRunning inference on 1000 records in 10 batches of size 100.[0m
[32m2026-06-10 07:47:20.651[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m92[0m - [1mProcessed batch 1/10. Records: 100[0m
[32m2026-06-10 07:47:20.689[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m92[0m - [1mProcessed batch 2/10. Records: 100[0m
[32m2026-06-10 07:47:20.738[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m92[0m - [1mProcessed batch 3/10. Records: 100[0m
[32m2026-06-10 07:47:20.785[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m92[0m - [1mProcessed batch 4/10. Records: 100[0m
[32m2026-06-10 07:47:20.819[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m92[0m - [1mProcessed batch 5/10. Records: 100[0m
[32m2026-06-10 07:47:20.842[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m92[0m - [1mProcessed batch 6/10. Records: 100[0m
[32m2026-06-10 07:47:20.871[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m92[0m - [1mProcessed batch 7/10. Records: 100[0m
[32m2026-06-10 07:47:20.921[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m92[0m - [1mProcessed batch 8/10. Records: 100[0m
[32m2026-06-10 07:47:20.939[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m92[0m - [1mProcessed batch 9/10. Records: 100[0m
[32m2026-06-10 07:47:20.993[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m92[0m - [1mProcessed batch 10/10. Records: 100[0m
[32m2026-06-10 07:47:20.994[0m | [1mINFO [0m | [36m__main__[0m:[36mrun_batch_inference[0m:[36m96[0m - [1mBatch inference completed in 0.37 seconds.[0m
[32m2026-06-10 07:47:20.996[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m25[0m - [1mInference completed. Generated 1000 predictions.[0m
Predictions Snippet (raw output): 0 0.517076 1 0.436043 2 0.489903 3 0.521617 4 0.561151 dtype: float64
4. Post-process Predictions and Store Results
Finally, we'll take the raw predictions, post-process them (e.g., convert probabilities to labels), and then simulate storing them. For the storage part, we will use a local path since GCS connection is not active in this demo.
# Post-process predictions
state, final_predictions_df = postprocess_predictions(state, original_data_for_demo.index, prediction_threshold=0.5)
if not final_predictions_df.empty:
logger.info("Predictions post-processing completed.")
print("\nFinal Post-processed Predictions Head:")
print(final_predictions_df.head())
# Add original 'id' column back for context in results
final_predictions_df = pd.concat([original_data_for_demo['id'], final_predictions_df], axis=1)
print("\nFinal Post-processed Predictions with Original ID Head:")
print(final_predictions_df.head())
# Display some basic statistics of predictions
print("\nPrediction Label Distribution:")
print(final_predictions_df[f'{TARGET_VARIABLE}_label'].value_counts())
print("\nPrediction Confidence Statistics:")
print(final_predictions_df[f'{TARGET_VARIABLE}_confidence'].describe().to_frame(name="Value"))
else:
logger.error("Prediction post-processing failed or resulted in an empty DataFrame.")
# Simulate storing inference results
# We'll override the GCS path in state to a local path for demonstration
state["inference_results_path"] = "/tmp/inference_results/"
# Ensure the local directory exists
os.makedirs(state["inference_results_path"], exist_ok=True)
updated_state = store_inference_results(state, final_predictions_df)
if "stored_results_path" in updated_state:
logger.info(f"Simulated storage completed. Results saved to: {updated_state['stored_results_path']}")
print(f"\nResults are theoretically stored at: {updated_state['stored_results_path']}")
# Verify by loading the locally saved file
loaded_results = pd.read_parquet(updated_state['stored_results_path'])
print("\nLoaded results from simulated storage head:")
print(loaded_results.head())
else:
logger.error("Simulated storage failed.")
[32m2026-06-10 07:47:21.011[0m | [1mINFO [0m | [36m__main__[0m:[36mpostprocess_predictions[0m:[36m40[0m - [1mStarting prediction post-processing.[0m [32m2026-06-10 07:47:21.016[0m | [1mINFO [0m | [36m__main__[0m:[36mpostprocess_predictions[0m:[36m60[0m - [1mConverted probabilities to labels using threshold 0.5.[0m [32m2026-06-10 07:47:21.017[0m | [1mINFO [0m | [36m__main__[0m:[36mpostprocess_predictions[0m:[36m67[0m - [1mPrediction post-processing complete. Generated 1000 post-processed records.[0m [32m2026-06-10 07:47:21.020[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m5[0m - [1mPredictions post-processing completed.[0m [32m2026-06-10 07:47:21.032[0m | [1mINFO [0m | [36m__main__[0m:[36mstore_inference_results[0m:[36m54[0m - [1mAttempting to store inference results to GCS: /tmp/inference_results/inference_results_20260610-074721.parquet[0m
Final Post-processed Predictions Head:
risk_score_confidence risk_score_label
0 0.517076 1
1 0.436043 0
2 0.489903 0
3 0.521617 1
4 0.561151 1
Final Post-processed Predictions with Original ID Head:
id risk_score_confidence risk_score_label
0 0 0.517076 1
1 1 0.436043 0
2 2 0.489903 0
3 3 0.521617 1
4 4 0.561151 1
Prediction Label Distribution:
risk_score_label
1 560
0 440
Name: count, dtype: int64
Prediction Confidence Statistics:
Value
count 1000.000000
mean 0.503954
std 0.067205
min 0.314673
25% 0.458566
50% 0.513046
75% 0.552020
max 0.653775
[32m2026-06-10 07:47:21.306[0m | [1mINFO [0m | [36m__main__[0m:[36m_save_to_gcs[0m:[36m68[0m - [1mSuccessfully saved inference results to /tmp/inference_results/inference_results_20260610-074721.parquet[0m [32m2026-06-10 07:47:21.307[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m34[0m - [1mSimulated storage completed. Results saved to: /tmp/inference_results/inference_results_20260610-074721.parquet[0m
Results are theoretically stored at: /tmp/inference_results/inference_results_20260610-074721.parquet Loaded results from simulated storage head: id risk_score_confidence risk_score_label 0 0 0.517076 1 1 1 0.436043 0 2 2 0.489903 0 3 3 0.521617 1 4 4 0.561151 1
5. Visualize Inference Results
To better understand the outcome of the batch inference, we'll visualize the distribution of prediction confidence and the counts of predicted labels.
logger.info("Generating visualizations for inference results.")
if not final_predictions_df.empty:
plt.figure(figsize=(14, 6))
# Plot 1: Distribution of Prediction Confidence
plt.subplot(1, 2, 1)
sns.histplot(final_predictions_df[f'{TARGET_VARIABLE}_confidence'], bins=20, kde=True)
plt.title('Distribution of Prediction Confidence')
plt.xlabel('Confidence Score')
plt.ylabel('Frequency')
plt.grid(axis='y', alpha=0.75)
# Plot 2: Count of Predicted Labels
plt.subplot(1, 2, 2)
sns.countplot(x=f'{TARGET_VARIABLE}_label', data=final_predictions_df, palette='viridis')
plt.title('Count of Predicted Labels')
plt.xlabel('Predicted Label')
plt.ylabel('Count')
plt.xticks([0, 1], ['Class 0', 'Class 1'])
plt.grid(axis='y', alpha=0.75)
plt.tight_layout()
plt.show()
# Display summary statistics of the original features
print("\nSummary Statistics of Original Features:")
print(original_data_for_demo[FEATURE_COLUMNS].describe().T)
# Display comparison: feature_1 vs confidence for different labels
plt.figure(figsize=(10, 6))
sns.scatterplot(
x='feature_1', y=f'{TARGET_VARIABLE}_confidence', hue=f'{TARGET_VARIABLE}_label',
data=pd.concat([original_data_for_demo, final_predictions_df], axis=1).dropna(subset=['feature_1']),
palette='coolwarm', alpha=0.6
)
plt.title('Feature 1 vs. Prediction Confidence by Label')
plt.xlabel('Feature 1 Value')
plt.ylabel('Prediction Confidence')
plt.legend(title='Predicted Label')
plt.grid(True, linestyle='--', alpha=0.6)
plt.show()
logger.info("Visualizations generated.")
else:
logger.warning("No data to visualize as final predictions DataFrame is empty.")
logger.info("--- Demonstration Finished ---")
[32m2026-06-10 07:47:21.325[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m1[0m - [1mGenerating visualizations for inference results.[0m
/tmp/ipykernel_2546/225683328.py:16: FutureWarning:
Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `x` variable to `hue` and set `legend=False` for the same effect.
sns.countplot(x=f'{TARGET_VARIABLE}_label', data=final_predictions_df, palette='viridis')
Summary Statistics of Original Features:
count mean std min 25% 50% \
feature_1 950.0 49.006385 29.362762 0.463202 23.135927 49.533411
feature_2 1000.0 50.988958 9.889330 20.786495 44.358791 50.842139
feature_4 1000.0 4.941180 2.876349 0.006534 2.415278 4.889441
75% max
feature_1 74.637977 99.971767
feature_2 57.396317 81.931076
feature_4 7.442071 9.995577
[32m2026-06-10 07:47:22.203[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m44[0m - [1mVisualizations generated.[0m [32m2026-06-10 07:47:22.205[0m | [1mINFO [0m | [36m__main__[0m:[36m<cell line: 0>[0m:[36m48[0m - [1m--- Demonstration Finished ---[0m
Production Considerations
Deploying batch inference pipelines in a production MLOps environment requires careful consideration of several factors beyond just the core logic. This table outlines key best practices for ensuring robustness, scalability, observability, and maintainability.
| Aspect | Best Practice | Description |
|---|---|---|
| Data Versioning | Use a data versioning tool (e.g., DVC, Pachyderm) or strict GCS/S3 bucket policies. | Ensure that the input data for inference is versioned and immutable. This guarantees reproducibility of results and allows for auditing and debugging if issues arise with a particular data batch. |
| Model Versioning & Registry | Utilize a Model Registry (e.g., Vertex AI Model Registry, MLflow, Sagemaker Model Registry). | Store, manage, and version models centrally. This enables easy deployment of specific model versions, rollbacks, and tracking of metadata (e.g., training data, metrics). |
| Orchestration | Employ robust orchestrators (e.g., Apache Airflow, Kubeflow Pipelines, Google Cloud Composer/Workflows). | Automate, schedule, and manage the entire pipeline workflow, including data ingestion, preprocessing, inference, post-processing, and result storage. This ensures reliable and scalable execution. |
| Infrastructure & Scalability | Leverage managed services and distributed computing (e.g., Vertex AI Batch Prediction, Apache Spark on Dataproc, Kubernetes). | Design the pipeline to scale horizontally to handle varying data volumes. Managed services often provide auto-scaling, reducing operational overhead. |
| Monitoring & Alerting | Implement comprehensive monitoring for data drift, model drift, prediction quality, and pipeline health. | Track key metrics (e.g., prediction distributions, feature statistics, latency, error rates). Set up alerts for anomalies or performance degradation to enable proactive intervention. |
| Logging & Tracing | Adopt structured logging (e.g., using loguru, ELK stack, Cloud Logging) and distributed tracing. | Ensure all pipeline components emit detailed logs. This is critical for debugging, auditing, and understanding the flow of data and predictions through the system. |
| Error Handling & Retries | Implement robust try/except blocks with exponential backoff and circuit breakers. | Gracefully handle transient errors (network issues, API rate limits). Prevent cascading failures and ensure the pipeline is resilient to unexpected issues. |
| Security & Access Control | Apply principle of least privilege (IAM roles), encrypt data at rest and in transit. | Secure access to sensitive data and models. Ensure that only authorized services and users can interact with pipeline resources. |
| Testing | Implement unit, integration, and end-to-end tests for all pipeline components. | Validate the correctness and performance of each function and the overall pipeline. Test with synthetic data, edge cases, and real-world data samples. |
| Idempotency | Design pipeline steps to be idempotent where possible. | Rerunning a failed step should produce the same result and not cause unintended side effects (e.g., duplicate data). |
| Cost Optimization | Optimize resource allocation, use spot instances, manage storage lifecycle. | Control operational costs by efficiently utilizing cloud resources and automatically cleaning up old data or model versions. |
| Observability | Centralize logs, metrics, and traces. Create dashboards for overall pipeline health. | Provide a holistic view of the pipeline's performance and status, allowing operators to quickly diagnose and troubleshoot issues. |
Conclusion
This notebook has demonstrated the construction and execution of a batch inference pipeline within an MLOps framework. We've covered the essential components from dependency installation and library imports to core functions for data loading, preprocessing, model inference, prediction post-processing, and result storage. The demonstration showcased how these functions orchestrate to deliver predictions on simulated data, complete with visualizations to interpret the outcomes.
Key components implemented and demonstrated:
- State Management: Using dictionaries to maintain pipeline configuration and dynamic information.
- Robust Data Handling: Incorporating retry mechanisms for loading and storing data from cloud storage (GCS).
- Flexible Preprocessing: Adapting data for model input, including handling missing values and categorical encoding.
- Generic Model Inference: Supporting different model types (e.g., TensorFlow, Scikit-learn) for making predictions.
- Actionable Post-processing: Transforming raw model outputs into meaningful predictions and insights.
- Visualizations: Providing graphical insights into prediction distributions and relationships with features.
- Production Best Practices: Outlining critical considerations for deploying and managing such pipelines in a real-world MLOps environment, emphasizing reliability, scalability, and observability.
This structured approach ensures that the batch inference pipeline is not only functional but also maintainable, observable, and ready for integration into a larger MLOps ecosystem.