MLOps·Model Lifecycle Management·Advanced

Mlflow Experiment Tracking

Track ML training experiments systematically using the MLflow platform with automatic hyperparameter logging, metric history visualization, model artifact storage and versioning, and experiment comparison dashboards for reproducible, auditable, and collaborative model development workflows.

machine-learningml-engineeringmlops

MLflow Filesystem Backend

Due to recent changes in MLflow, using the local filesystem (./mlruns) for tracking requires an explicit opt-in. The following cell sets the necessary environment variable to allow this backend. Please ensure this cell is executed before any MLflow operations.

[1]
import os
os.environ["MLFLOW_ALLOW_FILE_STORE"] = "true"
print("MLFLOW_ALLOW_FILE_STORE environment variable set to 'true'.")
MLFLOW_ALLOW_FILE_STORE environment variable set to 'true'.

Track ML experiments with MLflow

This notebook demonstrates how to track Machine Learning experiments using MLflow, an open-source platform for managing the end-to-end machine learning lifecycle. MLflow helps in tracking experiments, packaging ML code into reproducible runs, and sharing and deploying models.

Key Concepts of MLflow

ConceptDescription
MLflow TrackingRecords and queries experiments: code, data, config, and results.
MLflow ProjectsPackages ML code in a reusable, reproducible format.
MLflow ModelsManages ML models from various ML libraries and deploys them to diverse serving platforms.
MLflow Model RegistryA centralized model store to collaboratively manage the full lifecycle of an MLflow Model, including model versioning, stage transitions, and annotations.

In this notebook, we will focus primarily on MLflow Tracking to log parameters, metrics, and models for a simple classification task.

Dependency Installation

[2]
# Install MLflow and other necessary libraries
!pip install mlflow scikit-learn pandas matplotlib seaborn --quiet

import logging

# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 49.4/49.4 kB 2.4 MB/s eta 0:00:00
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 50.2/50.2 kB 1.7 MB/s eta 0:00:00
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 43.5/43.5 kB 1.7 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 10.8/10.8 MB 30.1 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 3.4/3.4 MB 37.2 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.7/1.7 MB 24.7 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 147.8/147.8 kB 6.7 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 114.9/114.9 kB 5.2 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 212.0/212.0 kB 7.4 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 94.9/94.9 kB 3.5 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 132.2/132.2 kB 6.0 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 936.9/936.9 kB 20.1 MB/s eta 0:00:00
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 214.9/214.9 kB 9.3 MB/s eta 0:00:00
[?25h

Library Imports

[3]
# Standard library imports
import os
import random
import time
from collections import deque
import logging

# Set environment variable to allow MLflow filesystem backend
os.environ["MLFLOW_ALLOW_FILE_STORE"] = "true"

# Third-party library imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import accuracy_score, precision_score, recall_score, f1_score, roc_auc_score
import mlflow
import mlflow.sklearn

logging.info("All necessary libraries imported and MLFLOW_ALLOW_FILE_STORE set.")

Core Functions

Function Name: create_mlflow_experiment_state

This function initializes a dictionary representing the MLflow experiment configuration. It sets up the experiment name, a unique run name, and an artifact location. This allows for consistent tracking across multiple runs and helps organize logged data.

Parameters: experiment_name (str): The name of the MLflow experiment. run_name_prefix (str): A prefix for the MLflow run name to ensure uniqueness. artifact_location (str, optional): The URI to which artifacts will be logged. Defaults to './mlruns' for local storage.

Returns: (dict): An updated state dictionary containing MLflow configuration.

[4]
def create_mlflow_experiment_state(
    experiment_name: str,
    run_name_prefix: str,
    artifact_location: str = "./mlruns",
    state: dict = None          # FIX (Bug 1): accept existing state so callers data is preserved
) -> dict:
    """
    Initializes (or updates) a state dictionary with MLflow experiment configuration.

    Parameters
    ----------
    experiment_name : str
        The name of the MLflow experiment.
    run_name_prefix : str
        A prefix for the MLflow run name to ensure uniqueness.
    artifact_location : str, optional
        The URI to which artifacts will be logged, defaults to './mlruns'.
    state : dict, optional
        An existing state dictionary to update.  If None a new dict is created.

    Returns
    -------
    dict
        Updated state dictionary containing MLflow configuration.

    Examples
    --------
    >>> state = create_mlflow_experiment_state("MyClassificationExperiment", "run_", "./mlruns_data")
    >>> print(state['mlflow_config']['experiment_name'])
    MyClassificationExperiment
    """
    # FIX (Bug 1): Start from the caller-supplied state (or a fresh dict) so that
    # keys already in state (X, y, X_train …) are NOT discarded.
    if state is None:
        state = {}

    # FIX (Bug 2): Normalise artifact_location to a proper file:// URI so that
    # mlflow.set_tracking_uri() works correctly across all MLflow versions.
    # Plain relative paths like './mlruns' are ambiguous; an explicit URI is not.
    import os as _os
    if not artifact_location.startswith(("file://", "http://", "https://", "sqlite")):
        artifact_location = "file://" + _os.path.abspath(artifact_location)

    state["mlflow_config"] = {
        "experiment_name": experiment_name,
        "run_name": f"{run_name_prefix}{int(time.time())}",
        "artifact_location": artifact_location,
    }
    logging.info(f"MLflow experiment state initialized for experiment '{experiment_name}'.")
    return state

Function Name: create_synthetic_data

This function generates synthetic classification data using sklearn.datasets.make_classification. It allows simulating a dataset with a specified number of samples, features, and informative features, which is useful for demonstrating ML models without needing real-world data.

Parameters: n_samples (int): The number of samples to generate. n_features (int): The total number of features. n_informative (int): The number of informative features. random_state (int, optional): Controls the reproducibility of the dataset generation. Defaults to 42.

Returns: (dict): An updated state dictionary containing the generated 'X' (features) and 'y' (labels).

[5]
def create_synthetic_data(
    state: dict,
    n_samples: int,
    n_features: int,
    n_informative: int,
    random_state: int = 42
) -> dict:
    """
    Generates synthetic classification data.

    Parameters
    ----------
    state : dict
        Current state dictionary.
    n_samples : int
        The number of samples to generate.
    n_features : int
        The total number of features.
    n_informative : int
        The number of informative features (must be <= n_features).
    random_state : int, optional
        Controls the reproducibility of the dataset generation, defaults to 42.

    Returns
    -------
    dict
        Updated state with 'X' (features) and 'y' (labels).

    Examples
    --------
    >>> state = {}
    >>> state = create_synthetic_data(state, 100, 5, 3)
    >>> print(state['X'].shape, state['y'].shape)
    (100, 5) (100,)
    """
    # FIX (Bug 8): Validate that n_informative does not exceed n_features.
    # sklearn raises a cryptic ValueError otherwise; a clear message is better.
    if n_informative > n_features:
        raise ValueError(
            f"n_informative ({n_informative}) must be <= n_features ({n_features})."
        )
    if n_samples <= 0:
        raise ValueError(f"n_samples must be a positive integer, got {n_samples}.")

    logging.info(f"Generating synthetic data with {n_samples} samples, {n_features} features, {n_informative} informative features.")
    X, y = make_classification(
        n_samples=n_samples,
        n_features=n_features,
        n_informative=n_informative,
        random_state=random_state,
    )
    state['X'] = X
    state['y'] = y
    logging.debug("Synthetic data generation complete.")
    return state

Function Name: split_data

This function splits the input features (X) and labels (y) into training and testing sets using sklearn.model_selection.train_test_split. It's a standard preprocessing step before training a machine learning model to ensure unbiased evaluation.

Parameters: state (dict): Current state dictionary containing 'X' and 'y'. test_size (float, optional): The proportion of the dataset to include in the test split. Defaults to 0.2. random_state (int, optional): Controls the shuffling applied to the data before applying the split. Defaults to 42.

Returns: (dict): An updated state dictionary containing 'X_train', 'X_test', 'y_train', and 'y_test'.

[6]
def split_data(
    state: dict,
    test_size: float = 0.2,
    random_state: int = 42
) -> dict:
    """
    Splits the data into training and testing sets.

    Parameters
    ----------
    state : dict
        Current state dictionary containing 'X' and 'y'.
    test_size : float, optional
        The proportion of the dataset to include in the test split, defaults to 0.2.
    random_state : int, optional
        Controls the shuffling applied to the data before splitting, defaults to 42.

    Returns
    -------
    dict
        Updated state with 'X_train', 'X_test', 'y_train', 'y_test'.

    Examples
    --------
    >>> state = {'X': np.array([[1,2],[3,4],[5,6],[7,8]]), 'y': np.array([0,1,0,1])}
    >>> state = split_data(state, test_size=0.5, random_state=1)
    >>> print(state['X_train'].shape, state['X_test'].shape)
    (2, 2) (2, 2)
    """
    logging.info(f"Splitting data into training and test sets with test_size={test_size}.")
    X_train, X_test, y_train, y_test = train_test_split(state['X'], state['y'], test_size=test_size, random_state=random_state)
    state['X_train'] = X_train
    state['X_test'] = X_test
    state['y_train'] = y_train
    state['y_test'] = y_test
    logging.debug("Data splitting complete.")
    return state

Function Name: train_model

This function trains a Logistic Regression model and logs its parameters, metrics, and the model itself to MLflow. It also includes retry logic with exponential backoff for robustness against transient errors during logging. This function is central to tracking experiments as it encapsulates the model training and MLflow logging process.

Parameters: state (dict): Current state dictionary containing 'X_train', 'y_train', and MLflow configuration. model_params (dict): A dictionary of parameters for the Logistic Regression model. max_retries (int, optional): Maximum number of retries for MLflow logging operations. Defaults to 3.

Returns: (dict): An updated state dictionary including the trained 'model'.

[7]
def train_model(
    state: dict,
    model_params: dict,
    max_retries: int = 3
) -> dict:
    """
    Trains a Logistic Regression model and logs to MLflow.

    Parameters
    ----------
    state : dict
        Current state dictionary containing 'X_train', 'y_train', and MLflow config.
    model_params : dict
        A dictionary of parameters for the Logistic Regression model.
    max_retries : int, optional
        Maximum number of retries for MLflow logging operations, defaults to 3.

    Returns
    -------
    dict
        Updated state including the trained 'model'.
    """
    # FIX (Bug 10): use .get() so a missing key raises a clear message instead
    # of an opaque KeyError propagating from deep inside the retry loop.
    X_train = state.get('X_train')
    y_train = state.get('y_train')
    if X_train is None or y_train is None:
        raise ValueError("state must contain 'X_train' and 'y_train'. Run split_data() first.")

    logging.info("Starting model training and MLflow logging.")

    # FIX (Bug 3): Track whether params have already been logged so that a retry
    # does not attempt to re-log them (which raises MlflowException if a value
    # changes, or produces duplicate entries).  Train the model FIRST so that
    # log_params is only reached after a successful fit.
    params_logged = False
    last_exc = None

    for attempt in range(max_retries):
        try:
            model = LogisticRegression(**model_params, random_state=state.get('random_state', 42))
            model.fit(X_train, y_train)
            state['model'] = model

            # Log params and model only after a successful fit.
            if not params_logged:
                mlflow.log_params(model_params)
                params_logged = True
                logging.info(f"Logged model parameters: {model_params}")

            mlflow.sklearn.log_model(sk_model=model, artifact_path="model")
            logging.info("Logged model artifact.")
            break  # success — exit retry loop

        except Exception as e:
            last_exc = e
            # FIX (Bug 7): skip sleep on the last attempt to avoid pointless delay.
            if attempt < max_retries - 1:
                jitter = random.uniform(0.1, 0.5)
                wait_time = (2 ** attempt) + jitter
                logging.warning(
                    f"Attempt {attempt + 1} failed: {e}. Retrying in {wait_time:.2f} seconds..."
                )
                time.sleep(wait_time)
            else:
                logging.warning(f"Attempt {attempt + 1} failed: {e}. No more retries.")
    else:
        logging.error(f"Failed to train and log model after {max_retries} attempts.")
        raise Exception(
            f"Failed to train and log model after {max_retries} attempts."
        ) from last_exc

    logging.debug("Model training and logging complete.")
    return state

Function Name: evaluate_model

This function evaluates the trained model on the test data and logs various performance metrics (accuracy, precision, recall, F1-score, ROC AUC) to MLflow. It ensures that the model's performance is systematically recorded for comparison across different experiments.

Parameters: state (dict): Current state dictionary containing 'model', 'X_test', 'y_test', and MLflow configuration. max_retries (int, optional): Maximum number of retries for MLflow logging operations. Defaults to 3.

Returns: (dict): An updated state dictionary with 'metrics' (a dictionary of calculated metrics).

[8]
def evaluate_model(
    state: dict,
    max_retries: int = 3
) -> dict:
    """
    Evaluates the trained model and logs metrics to MLflow.

    Parameters
    ----------
    state : dict
        Current state dictionary containing 'model', 'X_test', 'y_test', and MLflow config.
    max_retries : int, optional
        Maximum number of retries for MLflow logging operations, defaults to 3.

    Returns
    -------
    dict
        Updated state with 'metrics' (a dictionary of calculated metrics).
    """
    logging.info("Starting model evaluation and MLflow metric logging.")

    # FIX (Bug 10): use .get() for safe access with a clear error message.
    model  = state.get('model')
    X_test = state.get('X_test')
    y_test = state.get('y_test')
    if model is None or X_test is None or y_test is None:
        raise ValueError("state must contain 'model', 'X_test', and 'y_test'.")

    y_pred  = model.predict(X_test)
    y_proba = model.predict_proba(X_test)[:, 1]

    metrics = {
        "accuracy":  accuracy_score(y_test, y_pred),
        "precision": precision_score(y_test, y_pred, zero_division=0),
        "recall":    recall_score(y_test, y_pred, zero_division=0),
        "f1_score":  f1_score(y_test, y_pred, zero_division=0),
        "roc_auc":   roc_auc_score(y_test, y_proba),
    }
    state['metrics'] = metrics

    last_exc = None
    for attempt in range(max_retries):
        try:
            mlflow.log_metrics(metrics)
            logging.info(f"Logged evaluation metrics: {metrics}")
            break
        except Exception as e:
            last_exc = e
            # FIX (Bug 7): no sleep on the last attempt.
            if attempt < max_retries - 1:
                jitter = random.uniform(0.1, 0.5)
                wait_time = (2 ** attempt) + jitter
                logging.warning(
                    f"Attempt {attempt + 1} failed: {e}. Retrying in {wait_time:.2f} seconds..."
                )
                time.sleep(wait_time)
            else:
                logging.warning(f"Attempt {attempt + 1} failed: {e}. No more retries.")
    else:
        logging.error(f"Failed to log metrics after {max_retries} attempts.")
        raise Exception(f"Failed to log metrics after {max_retries} attempts.") from last_exc

    logging.debug("Model evaluation and metric logging complete.")
    return state

Function Name: log_feature_importance_plot

This function creates a bar plot of feature importances (or coefficients for linear models) and logs it as an artifact to MLflow. Visualizing feature importance helps in understanding which features contribute most to the model's predictions, and logging it ensures this insight is preserved with the experiment run.

Parameters: state (dict): Current state dictionary containing 'model' and information about features. feature_names (list[str]): A list of names for the features. plot_filename (str, optional): The filename for the plot artifact. Defaults to 'feature_importance.png'. max_retries (int, optional): Maximum number of retries for MLflow logging operations. Defaults to 3.

Returns: (dict): The unmodified state dictionary.

[9]
def log_feature_importance_plot(
    state: dict,
    feature_names: list,
    plot_filename: str = 'feature_importance.png',
    max_retries: int = 3
) -> dict:
    """
    Creates and logs a feature importance plot as an MLflow artifact.

    Parameters
    ----------
    state : dict
        Current state dictionary containing 'model'.
    feature_names : list[str]
        A list of names for the features.  Must match the number of model coefficients.
    plot_filename : str, optional
        The filename for the plot artifact, defaults to 'feature_importance.png'.
    max_retries : int, optional
        Maximum number of retries for MLflow logging operations, defaults to 3.

    Returns
    -------
    dict
        The unmodified state dictionary.
    """
    logging.info("Generating and logging feature importance plot.")

    model = state.get('model')
    if model is None:
        raise ValueError("state must contain a trained 'model'.")

    coefficients = model.coef_[0]

    # FIX (Bug 11): Validate that feature_names length matches the number of
    # coefficients.  A mismatch would silently truncate the DataFrame in older
    # pandas or raise a confusing ValueError in newer pandas.
    if len(feature_names) != len(coefficients):
        raise ValueError(
            f"feature_names length ({len(feature_names)}) does not match "
            f"model coefficient length ({len(coefficients)})."
        )

    feature_importance_df = pd.DataFrame({
        'Feature':    feature_names,
        'Importance': np.abs(coefficients),
    }).sort_values(by='Importance', ascending=False)

    plt.figure(figsize=(10, 6))
    sns.barplot(x='Importance', y='Feature', data=feature_importance_df)
    plt.title('Feature Importances (Absolute Coefficients)')
    plt.xlabel('Absolute Coefficient Value')
    plt.ylabel('Feature')
    plt.tight_layout()
    plt.savefig(plot_filename)
    plt.close()

    last_exc = None
    try:
        for attempt in range(max_retries):
            try:
                mlflow.log_artifact(plot_filename)
                logging.info(f"Logged plot '{plot_filename}' as artifact.")
                break
            except Exception as e:
                last_exc = e
                # FIX (Bug 7): skip sleep on the last attempt.
                if attempt < max_retries - 1:
                    jitter = random.uniform(0.1, 0.5)
                    wait_time = (2 ** attempt) + jitter
                    logging.warning(
                        f"Attempt {attempt + 1} failed: {e}. Retrying in {wait_time:.2f} seconds..."
                    )
                    time.sleep(wait_time)
                else:
                    logging.warning(f"Attempt {attempt + 1} failed: {e}. No more retries.")
        else:
            logging.error(f"Failed to log plot after {max_retries} attempts.")
            raise Exception(f"Failed to log plot after {max_retries} attempts.") from last_exc
    finally:
        # FIX (Bug 5): Always remove the local temp file after attempting to log
        # it, whether logging succeeded or failed.  This prevents stale PNG files
        # from accumulating in the working directory across runs.
        import os as _os
        if _os.path.exists(plot_filename):
            _os.remove(plot_filename)
            logging.debug(f"Removed local temp file '{plot_filename}'.")

    logging.debug("Feature importance plot logging complete.")
    return state

Function Name: create_mlflow_run

This is a helper function that orchestrates an MLflow run. It sets the MLflow tracking URI, creates or sets an experiment, and then executes a provided callback function within an MLflow run context. This ensures all operations performed by the callback are properly logged to the current MLflow run.

Parameters: state (dict): Current state dictionary containing MLflow configuration. run_callback (callable): A function that takes the current state and feature_names as arguments, encapsulating the ML model training, evaluation, and artifact logging logic. This function will be executed within the MLflow run. feature_names (list[str]): A list of feature names to be passed to the run_callback.

Returns: (dict): The state dictionary after the run_callback has been executed within the MLflow run.

[10]
def create_mlflow_run(
    state: dict,
    run_callback: callable,
    feature_names: list
) -> dict:
    """
    Orchestrates an MLflow run by setting the tracking URI, creating/setting
    an experiment, and executing a callback function within the run context.

    Parameters
    ----------
    state : dict
        Current state dictionary containing MLflow configuration.
    run_callback : callable
        A function that takes the current state and `feature_names` as arguments.
    feature_names : list[str]
        A list of feature names to be passed to the `run_callback`.

    Returns
    -------
    dict
        The state dictionary after the run_callback has been executed.
    """
    mlflow_config = state['mlflow_config']

    mlflow.set_tracking_uri(mlflow_config['artifact_location'])
    logging.info(f"MLflow Tracking URI set to: {mlflow_config['artifact_location']}")

    mlflow.set_experiment(mlflow_config['experiment_name'])
    logging.info(f"MLflow Experiment set to: {mlflow_config['experiment_name']}")

    with mlflow.start_run(run_name=mlflow_config['run_name']) as run:
        # FIX (Bug 4): Capture run_id from the context-manager `run` object, NOT
        # from mlflow.active_run().  After the `with` block ends, active_run()
        # returns None and calling .info.run_id raises AttributeError.
        run_id = run.info.run_id
        logging.info(f"MLflow Run started: {run_id} (Name: {mlflow_config['run_name']})")
        state['mlflow_run_id'] = run_id
        state = run_callback(state, feature_names)
        # Use the already-captured run_id — do NOT call mlflow.active_run() here.
        logging.info(f"MLflow Run finished: {run_id}")

    return state

Demonstration/Visualization

Here we will demonstrate the end-to-end process of creating synthetic data, training a Logistic Regression model, evaluating it, logging parameters, metrics, and a plot to MLflow for two different sets of model parameters. This will showcase how MLflow helps in tracking and comparing different experiment runs.

[11]
state = {}
state['random_state'] = 42

# 1. Initialize MLflow experiment state
# FIX (Bug 1): pass the existing state so subsequent keys (X, y, …) are preserved.
state = create_mlflow_experiment_state(
    experiment_name="LogisticRegression_Classification",
    run_name_prefix="lr_run_",
    artifact_location="./mlruns",
    state=state,
)

# 2. Generate synthetic data
n_samples    = 1000
n_features   = 10
n_informative = 5
state = create_synthetic_data(state, n_samples, n_features, n_informative, state['random_state'])

# Prepare feature names for logging plots
feature_names = [f'feature_{i}' for i in range(n_features)]

# 3. Split the data
state = split_data(state, test_size=0.2, random_state=state['random_state'])

logging.info("Data prepared for ML experiments.")
display(pd.DataFrame(state['X']).head())
display(pd.Series(state['y']).value_counts().to_frame('Label Count'))
0 1 2 3 4 5 6 7 8 9
0 1.125100 1.178124 0.493516 0.790880 -0.614278 1.347020 1.419515 1.357325 0.966041 -1.981139
1 -0.564641 3.638629 -1.522415 -1.541705 1.616697 4.781310 3.190292 -0.890254 1.438826 -3.828748
2 0.516313 2.165426 -0.628486 -0.386923 0.492518 1.442381 1.332905 -1.958175 -0.348803 -1.804124
3 0.537282 0.966618 -0.115420 0.670755 -0.958516 0.871440 0.508186 -1.034471 -1.654176 -1.910503
4 0.278385 1.065828 -1.724917 -2.235667 0.715107 0.731249 -0.674119 0.598330 -0.524283 1.047610
Label Count
1 503
0 497

Experiment Run 1: Default Parameters

[12]
def train_evaluate_log_callback(current_state: dict, feature_names: list[str]) -> dict:
    """
    Callback function to encapsulate the training, evaluation, and logging logic.
    """
    model_params = {"C": 1.0, "solver": "liblinear"}
    current_state = train_model(current_state, model_params)
    current_state = evaluate_model(current_state)
    current_state = log_feature_importance_plot(current_state, feature_names, 'feature_importance_run1.png')
    return current_state

logging.info("Starting Experiment Run 1 with default Logistic Regression parameters.")
state['mlflow_config']['run_name'] = f"lr_run_{int(time.time())}_default_C_1.0"
state = create_mlflow_run(state, train_evaluate_log_callback, feature_names)
logging.info(f"Experiment Run 1 completed. MLflow Run ID: {state['mlflow_run_id']}")
print(f"Run 1 Metrics: {state['metrics']}")
2026/06/12 10:43:16 INFO mlflow.tracking.fluent: Experiment with name 'LogisticRegression_Classification' does not exist. Creating a new experiment.
2026/06/12 10:43:17 WARNING mlflow.models.model: `artifact_path` is deprecated. Please use `name` instead.
2026/06/12 10:43:17 WARNING mlflow.sklearn: Saving scikit-learn models in the pickle or cloudpickle format requires exercising caution because these formats rely on Python's object serialization mechanism, which can execute arbitrary code during deserialization. The recommended safe alternative is the 'skops' format. For more information, see: https://scikit-learn.org/stable/model_persistence.html
Run 1 Metrics: {'accuracy': 0.835, 'precision': 0.8089887640449438, 'recall': 0.8181818181818182, 'f1_score': 0.8135593220338984, 'roc_auc': np.float64(0.9121347402597404)}

Experiment Run 2: Different Regularization Parameter (C)

[13]
def train_evaluate_log_callback_tuned(current_state: dict, feature_names: list[str]) -> dict:
    """
    Callback function for the second run with tuned parameters.
    """
    model_params = {"C": 0.1, "solver": "liblinear"} # Lower C for stronger regularization
    current_state = train_model(current_state, model_params)
    current_state = evaluate_model(current_state)
    current_state = log_feature_importance_plot(current_state, feature_names, 'feature_importance_run2.png')
    return current_state

logging.info("Starting Experiment Run 2 with tuned Logistic Regression parameters (C=0.1).")
state['mlflow_config']['run_name'] = f"lr_run_{int(time.time())}_tuned_C_0.1"
state = create_mlflow_run(state, train_evaluate_log_callback_tuned, feature_names)
logging.info(f"Experiment Run 2 completed. MLflow Run ID: {state['mlflow_run_id']}")
print(f"Run 2 Metrics: {state['metrics']}")
2026/06/12 10:43:32 WARNING mlflow.models.model: `artifact_path` is deprecated. Please use `name` instead.
2026/06/12 10:43:32 WARNING mlflow.sklearn: Saving scikit-learn models in the pickle or cloudpickle format requires exercising caution because these formats rely on Python's object serialization mechanism, which can execute arbitrary code during deserialization. The recommended safe alternative is the 'skops' format. For more information, see: https://scikit-learn.org/stable/model_persistence.html
Run 2 Metrics: {'accuracy': 0.835, 'precision': 0.8160919540229885, 'recall': 0.8068181818181818, 'f1_score': 0.8114285714285714, 'roc_auc': np.float64(0.9110186688311689)}

Viewing MLflow UI and Querying Runs

To view the MLflow UI, you can run mlflow ui in your terminal from the directory where your mlruns folder is located (in this case, the root of your Colab environment). Then, navigate to the provided local URL (usually http://127.0.0.1:5000 or http://localhost:5000).

Here, we'll programmatically query the MLflow runs to compare the results.

[14]
import pandas as pd

def query_mlflow_runs(experiment_name: str, artifact_location: str) -> pd.DataFrame:
    """
    Queries MLflow runs for a given experiment and returns a DataFrame of results.

    Parameters
    ----------
    experiment_name : str
        The name of the MLflow experiment to query.
    artifact_location : str
        The MLflow tracking URI.

    Returns
    -------
    pd.DataFrame
        A DataFrame containing parameters, metrics, and run IDs of the experiment runs.
    """
    # FIX (Bug 9): Warn if an active run exists before switching the tracking URI.
    # Changing the URI mid-run would silently redirect subsequent log_* calls.
    if mlflow.active_run() is not None:
        logging.warning(
            "query_mlflow_runs called while an MLflow run is active. "
            "Changing the tracking URI now may redirect active logging."
        )

    mlflow.set_tracking_uri(artifact_location)
    client = mlflow.tracking.MlflowClient()

    experiment = client.get_experiment_by_name(experiment_name)
    if experiment is None:
        logging.warning(f"Experiment '{experiment_name}' not found.")
        return pd.DataFrame()

    runs_info = []
    for run in client.search_runs(experiment_ids=[experiment.experiment_id]):
        run_data = {
            "run_id":   run.info.run_id,
            "run_name": run.info.run_name,
            **run.data.params,   # logged parameters
            **run.data.metrics,  # logged metrics
        }
        runs_info.append(run_data)

    df_runs = pd.DataFrame(runs_info)
    logging.info(f"Queried {len(df_runs)} runs for experiment '{experiment_name}'.")
    return df_runs


# Query and display the results
experiment_name   = state['mlflow_config']['experiment_name']
artifact_location = state['mlflow_config']['artifact_location']

all_runs_df = query_mlflow_runs(experiment_name, artifact_location)

# FIX (Bug 6): Guard against empty DataFrame or missing 'accuracy' column
# (e.g. if all runs failed before logging metrics) to avoid a KeyError.
if not all_runs_df.empty and 'accuracy' in all_runs_df.columns:
    display(all_runs_df.sort_values(by='accuracy', ascending=False))
else:
    logging.warning("No runs with 'accuracy' metric found. Displaying unsorted results.")
    display(all_runs_df)
run_id run_name C solver roc_auc precision f1_score recall accuracy
0 17b097cac163447d900ee68a7504fcc2 lr_run_1781261011_tuned_C_0.1 0.1 liblinear 0.911019 0.816092 0.811429 0.806818 0.835
1 06fbfc761a8d41758291ebad8ad45298 lr_run_1781260996_default_C_1.0 1.0 liblinear 0.912135 0.808989 0.813559 0.818182 0.835

Production Considerations

When moving ML experiments and models to production, several factors need to be considered for robustness, scalability, and maintainability. MLflow facilitates many of these, but best practices are crucial.

AspectBest PracticeMLflow Support
Experiment TrackingLog all parameters, metrics, and artifacts consistently.Tracking Component provides APIs for logging.
ReproducibilityPackage code, environments, and data sources.Projects Component for packaging, Docker integration.
Model ManagementVersion models, manage stages (Staging, Production).Model Registry Component for versioning and lifecycle.
DeploymentDeploy models to various serving platforms.Models Component provides standard formats for deployment.
ScalabilityUse a centralized MLflow Tracking Server (e.g., on AWS S3, Azure Blob, Google Cloud Storage, PostgreSQL).Supports various backend stores and artifact stores.
MonitoringMonitor model performance in production and detect drift.Can log production metrics back to MLflow.
AutomationIntegrate MLflow into CI/CD pipelines.MLflow CLI and Python APIs are automation-friendly.
SecurityControl access to experiments and models.Integrates with cloud IAM roles and managed services.

Conclusion

This notebook successfully demonstrated how to use MLflow Tracking to manage machine learning experiments. We covered:

  • Initialization of MLflow experiments and runs.
  • Logging model parameters, performance metrics, and artifacts (like feature importance plots).
  • Orchestrating multiple experiment runs to compare different model configurations.
  • Programmatically querying MLflow runs to analyze results.

By leveraging MLflow, data scientists and ML engineers can systematically track their work, improve reproducibility, and streamline the transition of models from experimentation to production.