ML Model Monitoring: Data Drift with PSI, Delayed Labels, Alert Rules and Root Cause
Hands-on lab · IDE in your browser

ML Model Monitoring: Data Drift with PSI, Delayed Labels, Alert Rules and Root Cause

Monitor a late-delivery model through twelve weeks of production.

Time
50 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit50 min · 5 stepsSession running
4 / 5 steps passingFind the cause, fix it · step 5 of 5
monitor.py▶ Run✓ Check
def replay(baseline, production):    """alerts_at() for every week in production, in order, as one list."""    return [a for week in sorted(production["week"].unique()) for a in alerts_at(baseline, production, week)]  # ---------- Step 5: find the cause, fix it ---------- def segment_report(rows, column):    """quality() for each value of column in rows: a DataFrame indexed by the value, highest z first."""    def worst_segment(rows, columns=CATEGORICAL):    """(column, value, z) of the single segment with the highest z across columns."""         def retrain(training, production, as_of):    """A LateModel fitted at the end of week as_of on training plus every production row labelled by then,    with an indicator for each region in that data."""  
TerminalOutput

The job

Wren Parcels' late-delivery model went live twelve weeks ago with no monitoring. Its predictions drive a notice on the web shop and the customer team's staffing plan. You replay the quarter with the monitoring it should have had, and find out what went wrong.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    A baseline

    Wren Parcels shows a "may arrive late" notice when its model gives a parcel a probability above 0.35, and the customer team plans staff from the predicted late rate.

    You writebin_edges()
  2. 2

    Drift, week by week

    The population stability index (PSI) compares two sets of shares: the sum over bins of (actual - expected) × ln(actual / expected).

    You writepsi()
  3. 3

    Quality, once the labels arrive

    Drift says the inputs changed.

    You writelabelled()
  4. 4

    Alerts worth reading

    A job that runs every week will repeat itself: region stays major for six weeks, and z stays high for as long as the model is wrong.

    You writealerts_at()
  5. 5

    Find the cause, fix it

    The page came at the end of week 9, about week 7.

    You writesegment_report()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: A baseline

Wren Parcels shows a "may arrive late" notice when its model gives a parcel a probability above 0.35, and the customer team plans staff from the predicted late rate. The model went live in week 1. Twelve weeks of production traffic are in production.csv, and the eight weeks it was trained on are in training.csv. Your job is the monitoring the model shipped without.

Drift only means something against a fixed reference. The baseline stores, for every input column and for the model's own score, what the training data looked like. Numeric columns are cut into ten quantile bins, so each bin holds about 10% of the training rows. Categorical columns get one share per category, plus a last share for values training never contained.

Do this

1. Write bin_edges(), shares(), category_shares() and build_baseline() in monitor.py. model.py gives you the data and the shipped model; try_it.py adds each row's score column before calling your code.

2. Run it. items has only four bins: most parcels hold one item, so several quantiles fall on the same value, and a repeated edge would only add a bin that no value can fall in.

monitor.py, the file you edit112 lines
"""Monitoring for Wren Parcels' late-delivery model. You write the functions marked TODO, one step at a time."""
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score

from model import CATEGORICAL, NUMERIC, LateModel

BINS = 10
EPS = 1e-4          # floor for empty bins, so PSI stays finite
LABEL_DELAY = 2     # outcomes of week w are known at the end of week w + 2
Z_PAGE = 3.0


# ---------- Step 1: a baseline ----------

def bin_edges(values, bins=BINS):
    """The bins - 1 inner quantile cut points of values (duplicates removed), as a list of floats."""
    # TODO (Step 1): np.quantile at np.linspace(0, 1, bins + 1)[1:-1], then np.unique, as floats.
    raise NotImplementedError("Step 1: write bin_edges()")


def shares(values, edges):
    """The fraction of values in each of the len(edges) + 1 bins. A value equal to an edge counts in the bin above."""
    # TODO (Step 1): np.searchsorted(edges, values, side="right") gives each value's bin; count with
    # np.bincount(..., minlength=len(edges) + 1) and divide by the number of values.
    raise NotImplementedError("Step 1: write shares()")


def category_shares(values, categories):
    """The fraction of values equal to each category, then one last share for values not in categories."""
    # TODO (Step 1): count each category, put everything else in one last count, divide by the total.
    raise NotImplementedError("Step 1: write category_shares()")


def build_baseline(training):
    """{column: profile} for NUMERIC + ["score"] (profile {"edges", "shares"}) and CATEGORICAL
    (profile {"categories": sorted values seen in training, "shares"})."""
    # TODO (Step 1): a profile for each numeric column (and "score") and each categorical one.
    raise NotImplementedError("Step 1: write build_baseline()")


# ---------- Step 2: drift, week by week ----------

def psi(expected, actual, eps=EPS):
    """Population stability index: sum over bins of (actual - expected) * ln(actual / expected), shares floored at eps."""
    raise NotImplementedError("psi() arrives in Step 2")


def column_psi(baseline, column, values):
    """PSI of values against the baseline profile of column, numeric or categorical."""
    raise NotImplementedError("column_psi() arrives in Step 2")


def level(value):
    """"stable" below 0.1, "moderate" below 0.25, "major" from 0.25."""
    raise NotImplementedError("level() arrives in Step 2")


def drift_table(baseline, production):
    """A DataFrame with one row per week (index "week", ascending) and one PSI column per baseline column."""
    raise NotImplementedError("drift_table() arrives in Step 2")


# ---------- Step 3: quality, once the labels arrive ----------

def labelled(production, as_of):
    """The production rows whose outcome is known at the end of week as_of."""
    raise NotImplementedError("labelled() arrives in Step 3")


def quality(rows):
    """{"n", "late_rate", "predicted_rate", "z", "auc"} for rows with "late" and "score" columns.
    z = (late parcels - expected late parcels) / sqrt(sum of score * (1 - score))."""
    raise NotImplementedError("quality() arrives in Step 3")


def quality_table(production, as_of):
    """quality() for every week that has labels at the end of week as_of: a DataFrame indexed by "week"."""
    raise NotImplementedError("quality_table() arrives in Step 3")


# ---------- Step 4: alerts someone will read ----------

def alerts_at(baseline, production, as_of):
    """The alerts the weekly job raises at the end of week as_of, knowing only what it knows then.
    ticket: a column's PSI in week as_of is major and it was not major in week as_of - 1.
    page:   z of the newest labelled week is >= Z_PAGE and was below it the labelled week before.
    Each alert is {"as_of", "severity", "signal", "week", "value"}."""
    raise NotImplementedError("alerts_at() arrives in Step 4")


def replay(baseline, production):
    """alerts_at() for every week in production, in order, as one list."""
    raise NotImplementedError("replay() arrives in Step 4")


# ---------- Step 5: find the cause, fix it ----------

def segment_report(rows, column):
    """quality() for each value of column in rows: a DataFrame indexed by the value, highest z first."""
    raise NotImplementedError("segment_report() arrives in Step 5")


def worst_segment(rows, columns=CATEGORICAL):
    """(column, value, z) of the single segment with the highest z across columns."""
    raise NotImplementedError("worst_segment() arrives in Step 5")


def retrain(training, production, as_of):
    """A LateModel fitted at the end of week as_of on training plus every production row labelled by then,
    with an indicator for each region in that data."""
    raise NotImplementedError("retrain() arrives in Step 5")
Provided for you:model.pyproduction.csvtraining.csvtry_it.py

Frequently asked questions

What is the population stability index (PSI)?

PSI compares a feature's current distribution with its training distribution, bin by bin: the sum of (actual - expected) times ln(actual / expected). Below 0.1 is usually read as stable, 0.1 to 0.25 as a moderate shift and above 0.25 as a major one.

Does data drift mean the model got worse?

Not necessarily. Inputs can shift without hurting predictions, for example when a courier is briefly unavailable. Only outcomes show whether quality dropped, which is why drift raises a ticket while a measured quality loss pages someone.

Why can AUC stay high while a model is failing?

AUC only measures ranking. A model can still put late parcels above punctual ones while its probabilities are far too low, and any threshold, notice or plan that uses those probabilities is then wrong. Calibration checks the probabilities themselves.

Monitoring a model in production

A deployed model meets data it was not trained on. Monitoring has to tell harmless shifts from harmful ones, work with outcomes that arrive late, and raise alerts people keep reading. In this lab you profile the training data, compute the population stability index for every feature and for the model's score, track AUC and calibration as labels arrive, turn both into alert rules that fire once per incident, and trace a calibration failure to the segment that caused it before retraining.