Predict Appointment No-Shows

Predict appointment no-shows with Python and explain the results in Power BI.

Introduction

30 Second Summary

A missed appointment leaves a clinic with an empty slot and another patient waiting longer for care. The challenge is spotting risk early enough to act.

In this project, you will generate synthetic appointment data with Python, clean it, explore its patterns, and train a no-show model with Scikit-learn. You will export the model results before turning them into an interactive report in Power BI Desktop.

What You'll Build

Your finished Power BI report lets you filter appointment profiles, inspect model performance, and compare predicted risk with actual outcomes on a dedicated monitoring and governance page.

By the end of this project, you'll have:

  • A reproducible synthetic dataset that models realistic appointment timing, reminders, patient history, and no-show outcomes.
  • A clean exploratory analysis that reveals missing values, class balance, and the strongest no-show patterns before modelling.
  • A held-out model evaluation with scored appointments, performance metrics, and feature importance ready for Power BI.
  • Secret Mission: Add a Decomposition tree with two High Value AI splits to uncover the strongest no-show segment.

Are there any prerequisites?

You need a Windows computer with working access to JupyterLab, Python, and Power BI Desktop.

The appointment records are synthetic and contain no patient information. Use the model as a learning exercise, not as a clinical or patient-care decision system.

Before We Start

Before We Start...

Before the hands-on work begins, lock in the prediction problem and the responsible way you will use its results.

Validate Desktop and Service Access

Prepare the Notebook Workspace

This project begins with a clean workspace so every generated file stays together. You will use an existing Python notebook environment and Power BI Desktop.

This step checks whether the tools already available on your computer can support the workflow.

In this step, get ready to:
  • Create the project folder and notebook.
  • Confirm that the Python libraries load.
  • Confirm that Power BI Desktop opens.
Create the notebook workspace
  • Open the JupyterLab workspace you already use.
  • Select the File Browser in the left sidebar.
  • Open the Desktop folder.
  • Select the New Folder button in the File Browser toolbar.
  • Enter no-show-predictor as the folder name.
  • Open the no-show-predictor folder.
  • Create a Python notebook named no_show_model.ipynb.

You should see an empty notebook inside the no-show-predictor folder.

Check the Python workspace
  • Confirm that the notebook can load the libraries and create its output folder by running this cell:
from pathlib import Path

import joblib
import numpy as np
import pandas as pd
import sklearn

# Keep every export beside this notebook
DATA_DIR = Path("data")
DATA_DIR.mkdir(exist_ok=True)

print("Python workspace ready.")
print(f"NumPy: {np.__version__}")
print(f"pandas: {pd.__version__}")
print(f"scikit-learn: {sklearn.__version__}")
print(f"Output folder: {DATA_DIR.resolve()}")

What does this code do?

  • Path("data") identifies the folder that receives every CSV and model artifact.
  • mkdir(exist_ok=True) creates that folder once and safely reuses it on later runs.
  • The version lines prove that NumPy, pandas, and Scikit-learn loaded in the active notebook.
  • Save no_show_model.ipynb.
  • Inspect the cell output. You should see Python workspace ready. followed by three library versions and the data folder path.

Seeing an import error?

A missing-library message means the active notebook is using a different Python environment from the one you prepared. Switch the notebook kernel to your existing data-science environment, then run the cell again.

Ask for help selecting the correct notebook kernel if the imports still fail.

Confirm Power BI access
  • Press the Windows key to open Windows search.
  • Type Power BI Desktop into the search bar.
  • Press Enter to open it.
  • Confirm that you can see a blank report canvas.
  • Close Power BI Desktop so you can return to the notebook.

Power BI Desktop is missing?

This project assumes Power BI Desktop is available. If Windows search cannot find it, continue on a computer or managed workspace that provides Power BI Desktop.

Your notebook and reporting tools are ready. Next, you will generate a synthetic appointment history with patterns the model can learn.

Create the Appointment Report

Generate, Clean, and Explore Synthetic Data

Your notebook workspace is ready. Now you will create the appointment history that powers the rest of the project.

Synthetic data lets you control the patterns the model should discover. You will also introduce a small amount of bad data so cleaning and validation remain part of the workflow.

In this step, get ready to:
  • Generate repeat-patient appointment records.
  • Clean the data and create leakage-aware features.
  • Explore the no-show patterns and export the prepared table.
Generate synthetic appointments

The generator needs repeat patients because prior attendance can help predict a future visit. A hidden patient tendency creates that relationship without becoming a model feature.

  • Create the synthetic patient profiles by adding a new cell to no_show_model.ipynb and running:
# Use a fixed seed so every learner creates the same dataset
rng = np.random.default_rng(20260909)
N_PATIENTS = 1500

patients = pd.DataFrame({
    "patient_id": np.arange(1001, 1001 + N_PATIENTS),
    "gender": rng.choice(["Female", "Male"], N_PATIENTS, p=[0.57, 0.43]),
    "age": np.clip(rng.normal(41, 22, N_PATIENTS), 0, 95).round(),
    "patient_tendency": rng.normal(0, 0.8, N_PATIENTS),
})

# Health indicators become more likely as age increases
patients["hypertension"] = rng.binomial(1, np.clip((patients["age"] - 30) / 100, 0.02, 0.55))
patients["diabetes"] = rng.binomial(1, np.clip((patients["age"] - 35) / 140, 0.01, 0.35))
patients["alcoholism"] = rng.binomial(1, 0.06, N_PATIENTS)
patients["handicap"] = rng.binomial(1, 0.03, N_PATIENTS)

display(patients.head())

What does this code do?

  • default_rng(20260909) makes the generated records reproducible.
  • patient_tendency represents an unobserved difference between patients. It shapes the outcome but never enters the model.
  • The age-based probabilities create plausible relationships for the health indicators.
  • Run the patient-profile cell.
  • Confirm that the preview contains patient_id, age, and the four health indicators.
  • Create the appointment schedule by running this next cell:
N_APPOINTMENTS = 8000

# Give repeat patients appointments across one calendar year
raw = pd.DataFrame({
    "appointment_id": np.arange(500001, 500001 + N_APPOINTMENTS),
    "patient_id": rng.choice(patients["patient_id"], N_APPOINTMENTS),
    "appointment_day": pd.Timestamp("2025-01-01")
    + pd.to_timedelta(rng.integers(0, 365, N_APPOINTMENTS), unit="D"),
})
raw = raw.merge(patients, on="patient_id", how="left")

# Add information available before each appointment
raw["wait_days_source"] = rng.integers(0, 46, N_APPOINTMENTS)
raw["scheduled_day"] = raw["appointment_day"] - pd.to_timedelta(raw["wait_days_source"], unit="D")
raw["neighbourhood"] = rng.choice([f"Area {n}" for n in range(1, 11)], N_APPOINTMENTS)
raw["appointment_type"] = rng.choice(["Routine", "Follow-up", "Consultation"], N_APPOINTMENTS, p=[0.5, 0.3, 0.2])
raw["day_part"] = rng.choice(["Morning", "Afternoon"], N_APPOINTMENTS, p=[0.58, 0.42])
raw["scholarship"] = rng.binomial(1, 0.18, N_APPOINTMENTS)
raw["sms_received"] = rng.binomial(1, 0.72, N_APPOINTMENTS)

display(raw.head())

How is the schedule constructed?

  • Each patient can appear several times across 2025.
  • scheduled_day is calculated from the appointment date and generated lead time.
  • The remaining fields describe information a clinic could know before the visit.
  • Run the schedule cell.
  • Confirm that the preview contains appointment dates, scheduled dates, appointment types, and reminder status.
  • Generate the no-show outcome and add cleaning problems by running:
# Combine known drivers with the hidden patient tendency
log_odds = (
    -2.6
    + 0.045 * raw["wait_days_source"]
    + 0.65 * (1 - raw["sms_received"])
    + 0.35 * (raw["age"] < 25)
    + 0.25 * (raw["appointment_type"] == "Routine")
    + 0.70 * raw["patient_tendency"]
)
no_show_probability = 1 / (1 + np.exp(-log_odds))
raw["no_show"] = rng.binomial(1, no_show_probability)
raw = raw.drop(columns=["patient_tendency", "wait_days_source"])

# Inject missing values, invalid ages, and duplicate records
missing_age = rng.choice(raw.index, 60, replace=False)
raw.loc[missing_age, "age"] = np.nan
available = raw.index.difference(missing_age)
invalid_age = rng.choice(available, 20, replace=False)
raw.loc[invalid_age, "age"] = rng.choice([-5, 140], 20)
missing_area = rng.choice(raw.index, 80, replace=False)
raw.loc[missing_area, "neighbourhood"] = None
raw = pd.concat([raw, raw.sample(25, random_state=42)], ignore_index=True)
raw.to_csv(DATA_DIR / "synthetic_appointments_raw.csv", index=False)

print("Raw rows:", len(raw))
print("Duplicate appointment IDs:", raw["appointment_id"].duplicated().sum())
print("No-show rate:", round(raw["no_show"].mean(), 3))
display(raw.isna().sum().loc[lambda values: values > 0])

Why generate the outcome this way?

  • log_odds makes longer waits and missing reminders increase no-show risk.
  • rng.binomial() converts each probability into an attended or missed outcome.
  • The hidden tendency and generation probability are excluded before modelling, which prevents synthetic target leakage.
  • Run the outcome cell.
  • Confirm that the output reports 8025 raw rows.
  • Confirm that the output reports 25 duplicate appointment IDs.
  • Confirm that the missing-value summary includes age and neighbourhood.

Synthetic generation failed?

Run the notebook from the first cell if a variable is missing. Ask for help debugging the synthetic generator if the traceback remains.

Clean and engineer the features
  • Remove duplicates, repair missing fields, and create calendar features by running:
# Keep one row per appointment before repairing fields
df = raw.drop_duplicates(subset="appointment_id").copy()
valid_ages = df.loc[df["age"].between(0, 115), "age"]
df["age"] = df["age"].fillna(valid_ages.median())
df = df[df["age"].between(0, 115)].copy()
df["age"] = df["age"].round().astype(int)
df["neighbourhood"] = df["neighbourhood"].fillna("Unknown")

# Rebuild features from the cleaned source columns
df["scheduled_day"] = pd.to_datetime(df["scheduled_day"])
df["appointment_day"] = pd.to_datetime(df["appointment_day"])
df["wait_days"] = (df["appointment_day"] - df["scheduled_day"]).dt.days
df = df[df["wait_days"] >= 0].copy()
df = df.sort_values(["appointment_day", "scheduled_day", "appointment_id"])
df["age_band"] = pd.cut(
    df["age"],
    bins=[-1, 17, 34, 49, 64, 115],
    labels=["0-17", "18-34", "35-49", "50-64", "65+"],
)
df["appointment_weekday"] = df["appointment_day"].dt.day_name()

print("Clean rows:", len(df))
print("Remaining missing values:", int(df.isna().sum().sum()))

What does the cleaning cell change?

  • drop_duplicates() removes the repeated appointment IDs.
  • Missing ages receive the median of the valid ages.
  • Impossible ages are removed instead of being allowed to distort the model.
  • wait_days, age_band, and appointment_weekday become model-ready features.
  • Run the cleaning cell.
  • Confirm that the clean row count is lower than the raw row count.
  • Confirm that Remaining missing values displays 0.
  • Create patient-history features using only earlier appointments by running:
# Sort first so cumulative values follow appointment time
df = df.sort_values(["appointment_day", "scheduled_day", "appointment_id"])
patient_groups = df.groupby("patient_id", sort=False)

# Exclude the current outcome from every history feature
df["previous_appointments"] = patient_groups.cumcount()
df["previous_no_shows"] = patient_groups["no_show"].cumsum() - df["no_show"]
df["previous_no_show_rate"] = (
    df["previous_no_shows"]
    .div(df["previous_appointments"].replace(0, np.nan))
    .fillna(0.0)
)

history_check = df[
    ["patient_id", "appointment_day", "no_show", "previous_appointments", "previous_no_shows"]
].head(12)
display(history_check)

How does the history prevent leakage?

  • cumcount() counts earlier appointments within each patient.
  • The cumulative no-show total subtracts the current row's outcome before storing the history.
  • A patient's first appointment receives a prior no-show rate of zero.
  • Run the history cell.
  • Inspect the first appointment for each visible patient. Its previous appointment count should be 0.

History values look wrong?

If the history values move backwards, rerun the cleaning cell before the history cell. Ask for help checking the history calculation if the counts remain inconsistent.

Explore and export the data
  • Inspect missing values, class balance, numeric distributions, and no-show rates by running this cell:
# Compare data quality before and after cleaning
missing_summary = pd.DataFrame({
    "raw_missing": raw.isna().sum(),
    "clean_missing": df.isna().sum(),
}).loc[lambda table: table.sum(axis=1) > 0]

# Measure the target balance and numeric distributions
class_balance = df["no_show"].value_counts().sort_index().to_frame("appointments")
class_balance["share"] = df["no_show"].value_counts(normalize=True).sort_index()
numeric_summary = df[
    ["age", "wait_days", "previous_appointments", "previous_no_show_rate"]
].describe().round(2)

# Compare no-show rates across key appointment categories
category_rates = {
    field: df.groupby(field, observed=True)["no_show"]
    .agg(appointments="size", no_show_rate="mean")
    .sort_values("no_show_rate", ascending=False)
    for field in ["sms_received", "wait_band", "appointment_type", "gender"]
}

display(missing_summary)
display(class_balance.style.format({"share": "{:.1%}"}))
display(numeric_summary)
for field, summary in category_rates.items():
    print(f"No-show rate by {field}")
    display(summary.style.format({"no_show_rate": "{:.1%}"}))

What does this EDA reveal?

  • missing_summary compares the injected missing values with the cleaned table.
  • class_balance shows the appointment count and share for each outcome.
  • numeric_summary describes the spread of age, wait time, and patient-history features.
  • category_rates compares no-show rates across reminders, wait bands, appointment types, and gender.
  • Run the EDA cell.
  • Confirm that the missing-value table shows raw gaps and zero cleaned gaps for age and neighbourhood.
  • Inspect the class-balance table to compare attended and missed appointments.
  • Inspect the numeric summary for age, wait time, and patient-history distributions.
  • Identify the highest no-show group in each category table.

Before you export the table, which wait band do you expect to have the highest no-show rate?

  • Export the prepared dataset by running this final cell:
# Keep identifiers, pre-appointment features, and the observed outcome
prepared_columns = [
    "patient_id", "appointment_id", "gender", "age", "neighbourhood",
    "scholarship", "hypertension", "diabetes", "alcoholism", "handicap",
    "sms_received", "scheduled_day", "appointment_day", "wait_days",
    "appointment_type", "day_part", "previous_appointments",
    "previous_no_shows", "previous_no_show_rate", "age_band",
    "appointment_weekday", "wait_band", "no_show",
]
prepared = df[prepared_columns].copy()
prepared.to_csv(DATA_DIR / "prepared_appointments.csv", index=False)

print("Prepared shape:", prepared.shape)
print("No-show rate:", round(prepared["no_show"].mean(), 3))
print("Saved:", DATA_DIR / "prepared_appointments.csv")

What does the export contain?

  • prepared_columns keeps the report identifiers, model inputs, and observed outcome.
  • to_csv() creates the stable handoff used by the modelling step.
  • The hidden generation fields remain excluded from the export.
  • Run the export cell.
  • Confirm that prepared_appointments.csv appears inside the JupyterLab data folder.
  • Confirm that the printed shape contains fewer than 8025 rows and 23 columns.

Prepared CSV missing?

If the export is missing, confirm that DATA_DIR still points to the notebook's data folder. Ask for help finding the prepared export if the path still looks wrong.

Your synthetic appointment table is clean, explored, and ready for modelling. Next, you will split it by time before training the no-show classifier.

Analyze No-Show Drivers

Train and Export the Model

Your prepared table now separates appointment information from the future outcome. The next goal is to measure which classifier catches the most missed visits without trusting accuracy alone.

Recall measures how many actual no-shows the model catches. Precision measures how many flagged appointments are truly no-shows.

In this step, get ready to:
  • Create chronological training, validation, and test periods.
  • Select a Scikit-learn pipeline and recall-focused threshold using validation results.
  • Export the untouched test evaluation, scored appointments, feature importance, and fitted bundle.
Create the model candidates

A chronological split trains on earlier appointments and tests on later ones. This mirrors a model predicting future visits more closely than a random split.

  • Add a cell below the prepared-data export.
  • Import the modelling tools and create the chronological partitions by running:
import joblib
from sklearn.compose import ColumnTransformer
from sklearn.ensemble import RandomForestClassifier
from sklearn.inspection import permutation_importance
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import average_precision_score, f1_score, precision_recall_curve
from sklearn.metrics import precision_score, recall_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder

# Keep only information available before each appointment.
NUMERIC = ["age", "scholarship", "hypertension", "diabetes", "alcoholism",
           "handicap", "sms_received", "wait_days", "previous_appointments",
           "previous_no_shows", "previous_no_show_rate"]
CATEGORICAL = ["gender", "neighbourhood", "appointment_type", "day_part",
               "age_band", "appointment_weekday"]
FEATURES = NUMERIC + CATEGORICAL

# Train on early dates and reserve complete later periods for validation and testing.
data = pd.read_csv(DATA_DIR / "prepared_appointments.csv", parse_dates=["appointment_day"])
data = data.sort_values(["appointment_day", "appointment_id"])
train_end, valid_end = pd.Timestamp("2025-09-01"), pd.Timestamp("2025-11-01")
train_mask = data["appointment_day"] < train_end
valid_mask = (data["appointment_day"] >= train_end) & (data["appointment_day"] < valid_end)
test_mask = data["appointment_day"] >= valid_end

X_train, y_train = data.loc[train_mask, FEATURES], data.loc[train_mask, "no_show"]
X_valid, y_valid = data.loc[valid_mask, FEATURES], data.loc[valid_mask, "no_show"]
X_test, y_test = data.loc[test_mask, FEATURES], data.loc[test_mask, "no_show"]
print("Train, validation, test:", len(X_train), len(X_valid), len(X_test))

How does the split protect the test period?

  • FEATURES excludes identifiers, timestamps, and the outcome.
  • Appointments before September train the candidates.
  • September and October select the model and threshold.
  • November and December remain untouched until the final evaluation.
  • Run the split cell.
  • Confirm that three non-zero row counts appear.
  • Add another cell below the split output.
  • Build and compare the two pipelines by running:
# Apply the same categorical encoding before either classifier.
def make_pipeline(model):
    processor = ColumnTransformer(
        [("categories", OneHotEncoder(handle_unknown="ignore"), CATEGORICAL)],
        remainder="passthrough",
    )
    return Pipeline([("prepare", processor), ("model", model)])

models = {
    "logistic_regression": make_pipeline(LogisticRegression(
        max_iter=1000, class_weight="balanced", solver="liblinear", random_state=42
    )),
    "random_forest": make_pipeline(RandomForestClassifier(
        n_estimators=300, min_samples_leaf=5,
        class_weight="balanced_subsample", random_state=42, n_jobs=-1
    )),
}

# Compare both candidates at the same decision threshold.
rows = []
for name, model in models.items():
    model.fit(X_train, y_train)
    probability = model.predict_proba(X_valid)[:, 1]
    prediction = (probability >= 0.50).astype(int)
    rows.append({"model": name,
                 "precision": precision_score(y_valid, prediction, zero_division=0),
                 "recall": recall_score(y_valid, prediction),
                 "average_precision": average_precision_score(y_valid, probability)})
comparison = pd.DataFrame(rows)
display(comparison.round(3))

Why compare these models?

  • ColumnTransformer applies one-hot encoding only to categorical fields.
  • class_weight gives the smaller no-show class more influence during training.
  • average_precision evaluates ranking quality across probability thresholds.
  • Run the comparison cell.
  • Wait for both validation rows to appear.
  • Confirm that each row contains precision, recall, and average precision.

The random forest can take two or three minutes while it grows 300 trees. A quiet kernel during that time does not mean the notebook has stopped.

Model comparison failed?

If training fails, confirm that the split cell ran first and that all feature names exist in prepared_appointments.csv. Ask for help diagnosing the comparison if the traceback remains.

Choose and evaluate the final model

The validation period chooses the candidate and operating threshold. The test period then measures that frozen decision on later appointments.

  • Add a cell below the comparison table.
  • Select the stronger ranking model and tune for at least 78% validation recall by running:
# Select the model with the strongest validation ranking quality.
best_name = comparison.sort_values("average_precision").iloc[-1]["model"]
best_model = models[best_name]
validation_probability = best_model.predict_proba(X_valid)[:, 1]
precision, recall, thresholds = precision_recall_curve(
    y_valid, validation_probability
)

# Among thresholds meeting the recall target, keep the most precise option.
eligible = np.flatnonzero(recall[:-1] >= 0.78)
best_index = eligible[np.argmax(precision[:-1][eligible])]
threshold = float(thresholds[best_index])

print("Selected model:", best_name)
print("Threshold:", round(threshold, 3))
print("Validation recall:", round(recall[best_index], 3))
print("Validation precision:", round(precision[best_index], 3))

How is the threshold chosen?

  • The candidate with the highest validation average precision becomes best_model.
  • precision_recall_curve() tests the trade-off across possible thresholds.
  • The chosen threshold meets the recall target while preserving as much precision as possible.
  • Run the threshold cell.
  • Confirm that the output names a model and a threshold between 0 and 1.
  • Add another cell below the threshold output.

Before you evaluate the test period, do you expect its recall and precision to match the validation values exactly?

  • Evaluate the untouched appointments by running:
# Score the untouched final period once with the frozen threshold.
test_probability = best_model.predict_proba(X_test)[:, 1]
test_prediction = (test_probability >= threshold).astype(int)

metrics = pd.DataFrame([{
    "model": best_name,
    "threshold": threshold,
    "precision": precision_score(y_test, test_prediction, zero_division=0),
    "recall": recall_score(y_test, test_prediction),
    "f1": f1_score(y_test, test_prediction),
    "average_precision": average_precision_score(y_test, test_probability),
}])
display(metrics.round(3))

What does the final evaluation prove?

  • test_probability contains the model's no-show risk score for each unseen appointment.
  • test_prediction applies the selected operating threshold.
  • metrics records the final held-out performance for Power BI.
  • Run the evaluation cell.
  • Confirm that one row displays the model, threshold, precision, recall, F1 score, and average precision.

That is the model decision measured. You now have performance from appointments that played no role in training or threshold selection.

Export the Power BI tables
  • Add a cell below the held-out metrics.
  • Create the scored appointment and feature-importance tables by running:
# Attach predictions only to the untouched test appointments.
scored = data.loc[X_test.index].copy()
scored["predicted_probability"] = test_probability
scored["predicted_no_show"] = test_prediction

# Measure how much each original feature affects held-out ranking quality.
importance_result = permutation_importance(
    best_model, X_test, y_test,
    scoring="average_precision", n_repeats=5, random_state=42, n_jobs=-1
)
feature_importance = pd.DataFrame({
    "feature": FEATURES,
    "importance": importance_result.importances_mean,
}).sort_values("importance", ascending=False)

display(scored[["appointment_id", "no_show", "predicted_probability"]].head())
display(feature_importance.head(10))

What will Power BI receive?

  • scored keeps the held-out appointment details beside the probability and predicted class.
  • permutation_importance() measures how much held-out ranking quality drops when one feature is shuffled.
  • The two previews let you inspect the handoff before any file is written.
  • Run the scoring cell.
  • Wait for the two previews to appear.
  • Add a final cell below the previews.
  • Save the model bundle and three report tables by running:
# Export stable tables for Power BI and preserve the fitted Python model.
scored.to_csv(DATA_DIR / "scored_appointments.csv", index=False)
metrics.to_csv(DATA_DIR / "model_metrics.csv", index=False)
feature_importance.to_csv(DATA_DIR / "feature_importance.csv", index=False)
joblib.dump(
    {"model": best_model, "threshold": threshold, "features": FEATURES},
    "model_bundle.joblib",
)

for path in [
    DATA_DIR / "scored_appointments.csv",
    DATA_DIR / "model_metrics.csv",
    DATA_DIR / "feature_importance.csv",
    Path("model_bundle.joblib"),
]:
    print("Saved:", path)

Why export CSV files?

  • The three CSV files form the handoff to Power BI.
  • model_bundle.joblib preserves the fitted pipeline for later Python use. Power BI does not import this model file directly.
  • Keeping the model and report tables separate makes the boundary between modelling and visualization explicit.
  • Run the export cell.
  • Confirm that all four Saved: lines appear.
  • Confirm that the three CSV files appear inside the JupyterLab data folder.
  • Save no_show_model.ipynb.

Model output missing?

If an export is missing, rerun the scoring cell before the export cell. Ask for help checking the model exports if a file still fails to appear.

Your Python workflow now holds the fitted model and three report-ready CSV files. Next, you will load those outputs into Power BI and turn the predictions into interactive visuals.

Publish Static Dashboard Tiles

Visualize the Model in Power BI

Your Jupyter notebook now holds the full modelling workflow and three exported report tables. The next goal is to turn those static outputs into a report you can filter and explain.

Power BI Desktop reads the three CSV exports without loading or rerunning the fitted Scikit-learn model. The model_bundle.joblib file stays in Python, while Power BI receives scores, metrics, and feature importance.

In this step, get ready to:
  • Import the scored appointments, metrics, and feature importance.
  • Build risk and performance visuals.
  • Save and verify the completed report.
Import the notebook outputs
  • Press the Windows key to open Windows search.
  • Type Power BI Desktop into the search bar.
  • Press Enter.

You should see a blank report canvas. Power BI Desktop is ready to receive the exported model outputs.

  • Select Get data on the Home ribbon.
  • Select Text/CSV.
  • Open scored_appointments.csv from the no-show-predictor\data folder on your Desktop.
  • Select Load in the preview window.

The Fields pane should now list a scored_appointments table containing the actual outcome, predicted class, and probability.

  • Repeat the Text/CSV import for model_metrics.csv.
  • Repeat the Text/CSV import for feature_importance.csv.
  • Confirm that all three table names appear in the Fields pane.

CSV not loading?

If a CSV is missing, return to JupyterLab and rerun the final export cell. Ask for help checking the Power BI import if the preview still fails.

Build the risk overview

The first report page should answer two questions quickly. It should show how many held-out appointments were scored and how much risk the model assigned.

  • Select an empty area of the report canvas.
  • Select Card in the Visualizations pane.
  • Drag appointment_id from scored_appointments into the card field well.
  • Set the aggregation to Count.
  • Set the card title to Scored Appointments in the Format visual pane.

You should see a whole-number count for the held-out appointments. This is the population represented by every risk visual on the page.

  • Add another Card visual.
  • Drag predicted_probability into the card field well.
  • Set the aggregation to Average.
  • Set the card title to Average Predicted Risk.
  • Format the value as a percentage.

You should see the mean predicted no-show risk across the held-out appointments.

  • Add a Clustered column chart to the canvas.
  • Drag age_band into the X-axis field well.
  • Drag predicted_probability into the Y-axis field well.
  • Set the Y-axis aggregation to Average.
  • Set the chart title to Average Predicted Risk by Age Band.
  • Add a Slicer visual.
  • Drag gender into the slicer field well.
  • Select each gender value and confirm that both risk visuals update.

That is the interactive handoff working. The notebook produced the scores, and Power BI now lets you inspect them by appointment profile.

Add performance and importance
  • Add a Card visual.
  • Drag recall from model_metrics into the card field well.
  • Set the card title to Test Recall.
  • Format the value as a percentage.
  • Repeat these card steps for precision with the title Test Precision.

The two cards should match the held-out metrics printed in JupyterLab. That confirms Power BI imported the evaluation without recalculating it.

  • Add a Clustered bar chart to the canvas.
  • Drag feature from feature_importance into the Y-axis field well.
  • Drag importance into the X-axis field well.
  • Sort the chart by importance in descending order.
  • Set the chart title to Held-Out Feature Importance.

Before you inspect the finished page, which feature do you expect to appear at the top of the importance chart?

  • Select an empty area of the canvas to clear the current selection.
  • Inspect the four cards, risk chart, slicer, and importance chart.
  • Select File in Power BI Desktop.
  • Select Save As.
  • Save the report as no-show-dashboard.pbix inside the no-show-predictor folder.

You should see the saved report page with model metrics that match JupyterLab and risk visuals that respond to the gender slicer.

Report values look wrong?

  • Confirm each field comes from the intended table.
  • Set predicted_probability, precision, and recall to percentage formatting.
  • Confirm the feature chart is sorted by importance rather than alphabetically.

Ask for help troubleshooting the report visuals if the values still look wrong.

Your model outputs now form an interactive Power BI report. Next, you will add a second page that tracks actual outcomes against predicted risk and documents the model's operating limits.

Build the Model Monitoring and Governance Page

Your prediction report already shows held-out performance and risk profiles. Now you will add model monitoring and governance so the report explains whether predictions stay aligned with outcomes and how people should use them.

A single test result cannot show how performance changes across later dates. This page separates operational checks from the risk overview and places the model's limits beside its metrics.

In this step, get ready to:
  • Create a dedicated monitoring and governance page.
  • Compare actual no-show rates with predicted risk over time.
  • Document the model's purpose, boundaries, and review triggers.
Create the report page

A separate report page gives monitoring checks enough space to remain readable. It also keeps governance notes visible instead of burying them among prediction visuals.

  • Select Report in the left navigation pane.
  • Select Insert on the ribbon.
  • Select New page.
  • Select Blank page.
  • Double-click the new page tab below the canvas.
  • Enter Model Monitoring and Governance as the page name.
  • Press Enter.

You should see a blank page named Model Monitoring and Governance beside the original report page.

Track actual and predicted rates

This synthetic evaluation page is monitoring-ready, but it does not observe a deployed model. The date trend shows whether average predicted risk follows the generated no-show rate during the held-out period.

  • Select an empty area of the new page.
  • Select Line chart in the Visualizations pane.
  • Drag appointment_day from scored_appointments into the X-axis field well.
  • Drag no_show into the Y-axis field well.
  • Set the no_show aggregation to Average.
  • Drag predicted_probability into the Y-axis field well.
  • Set the predicted_probability aggregation to Average.
  • Set the chart title to Actual and Predicted No-Show Rate by Date.

You should see two lines across the held-out appointment dates. One shows the observed no-show rate, while the other shows the model's average predicted risk.

What does the gap mean?

A persistent gap means predicted probabilities and observed outcomes are moving apart. That signal prompts investigation because it can indicate model drift, data changes, or a threshold that needs review.

  • Select an empty area of the page.
  • Select Card in the Visualizations pane.
  • Drag threshold from model_metrics into the Values field well.
  • Drag recall into the Values field well.
  • Drag precision into the Values field well.
  • Set the card title to Evaluation Guardrails.
  • Format the three values as percentages.

You should see the frozen decision threshold beside held-out recall and precision. These values define the benchmark for later model reviews.

Document the governance guardrails
  • Select Home on the ribbon.
  • Select Text box in the Insert section.
  • Type Purpose: Prioritize outreach for appointments that may need reminders.
  • Type Boundary: This model uses synthetic learning data and reports held-out test performance.
  • Type Decision rule: Use risk scores to support human review, never to deny or delay care.
  • Type Review trigger: Reassess the model when actual and predicted no-show rates separate or subgroup performance changes.
  • Resize the text box so every line remains visible.

Why put governance on the page?

Metrics explain how the model performed. Governance explains what decisions the model may support and when people must review it.

Keeping both on the same page makes the operating limits visible whenever someone interprets the monitoring trend.

Before you inspect the finished page, which date do you expect to show the largest gap between actual and predicted no-show rates?

  • Inspect the two lines across the appointment dates.
  • Confirm that the threshold, recall, and precision match the imported model_metrics table.
  • Confirm that all four governance statements are readable.
  • Select File in Power BI Desktop.
  • Select Save.

The report now shows a monitoring trend, evaluation guardrails, and clear limits for model use. The saved no-show-dashboard.pbix file contains both report pages.

Monitoring page looks incomplete?

  • Confirm both rate fields use the Average aggregation.
  • Confirm the line chart uses appointment_day on its X-axis.
  • Confirm the guardrail values come from model_metrics.

Ask for help troubleshooting the monitoring page if the page still looks wrong.

Your report now pairs prediction insight with monitoring and governance. Next, you will publish both pages and test the interactive risk overview in the Power BI service.

Pin a Live Report Page

Publish the Prediction Dashboard

Your report now pairs the risk overview with a monitoring and governance page. The final goal is to publish both pages to the Power BI service and preserve the risk overview's shared interactions in a live dashboard tile.

A live report-page tile carries the full report page into a dashboard. Its visuals continue to cross-filter inside the tile.

In this step, get ready to:
  • Publish the completed two-page report to My workspace.
  • Pin the risk overview as a live dashboard tile.
  • Test the published interactions.
Publish the completed report

Publishing uploads the report and its imported semantic model to the Power BI service. Use a work or school account because consumer email accounts are not supported for service sign-in.

  • Select File in Power BI Desktop.
  • Select Publish.
  • Select Publish to Power BI.
  • Complete sign-in if Power BI Desktop requests it.
  • Choose My workspace as the destination.
  • Select Select.

Publishing can take a minute while Power BI uploads the report and semantic model. You should then see a success message with a link to the published report.

  • Open the published report link.
  • Confirm that the original risk overview page contains its model cards, chart, slicer, and feature importance.
  • Open the Model Monitoring and Governance page.
  • Confirm that its trend, evaluation guardrails, and governance notes are visible.

Publishing blocked?

Confirm Power BI Desktop uses the same work or school account that opens Power BI service. Ask for help troubleshooting publication if My workspace remains unavailable.

Pin the report page live

Why pin the whole page?

Separate visual tiles behave as independent dashboard objects. Pinning the complete page keeps the slicer and charts inside one shared interaction context.

  • Select an empty area of the published report canvas.
  • Select More options (...) in the report toolbar.
  • Select Pin to a dashboard.
  • Choose New dashboard.
  • Enter Appointment No-Show Monitor as the dashboard name.
  • Select Pin live.

You should see a Success message confirming that the page was added to the dashboard.

  • Open Appointment No-Show Monitor from My workspace.
  • Drag the live tile's bottom-right handle until every visual is readable.

Cannot see the page pin option?

  • Return to the published report if you are viewing the dashboard.
  • Select an empty canvas area if a visual has a selection border.
  • Open More options (...) again.

Ask for help finding the live page option if the command is still missing.

Test the published interactions

Before you test the live tile, which visuals do you expect the gender slicer to change?

  • Select one gender value in the slicer inside the live tile.
  • Inspect the scored-appointment card and age-band risk chart.
  • Clear the slicer selection.
  • Select one age-band column inside the live tile.
  • Inspect the other scored-appointment visuals.

You should see the scored-appointment card and risk visuals respond inside the live tile. The model-metric and feature-importance tables remain fixed because they come from separate exported tables.

Live visuals are not responding?

  • Confirm that you are selecting controls inside the large live report-page tile.
  • Enlarge the tile if a slicer or chart is difficult to select.
  • Open the source report and confirm its interactions work before pinning the page again.

Ask for help troubleshooting the live tile if the visuals still do not respond.

You did it. Your Jupyter-trained model now feeds a published Power BI dashboard whose risk visuals respond together.

Secret mission

Find the Highest-Risk Segment

Extend your report with a Decomposition tree that uses two High Value AI splits to uncover the strongest no-show segment. Then publish that page as a second live tile on your private dashboard.

Clean Up Your Resources

Clean Up Your Resources

Your Power BI service artifacts can stay in My workspace under a Fabric (Free) license for personal use. Choose whether to keep them, pause your work, or delete them.

Cost warning

The Fabric (Free) license supports reports and dashboards for your own use without a paid license.

A Microsoft 365 trial is a separate subscription that can renew after its one-month trial. Turn off recurring billing before it ends if you do not plan to continue.

Resources you used:

  • The local no-show-predictor folder containing the notebook, CSV outputs, fitted model bundle, and Power BI report.
  • The published no-show report in My workspace.
  • The imported semantic model based on scored_appointments, model_metrics, and feature_importance.
  • The Appointment No-Show Monitor dashboard containing the live risk page and any optional Secret Mission tile.
  • The Microsoft 365 business trial if you used the account fallback.

Keep everything running

No action needed. Choose this if you want to keep using the dashboard or continue investigating no-show segments.

  • Keep the no-show-predictor folder on your Desktop.
  • Leave the published report in My workspace.
  • Leave the imported semantic model in My workspace.
  • Leave the Appointment No-Show Monitor dashboard in My workspace.
  • Keep the Microsoft 365 trial only if you accept its renewal terms.

Pause - I'll come back to this later

Pause by ending your active sessions while preserving every project artifact. No Power BI compute process needs to remain running.

  • Switch back to JupyterLab.
  • Shut down the active notebook kernel from the Kernel menu.
  • Close JupyterLab.
  • Switch back to Power BI Desktop.
  • Close Power BI Desktop.
  • Sign out of the Power BI service.

If you used the Microsoft 365 trial:

  • Open the Microsoft 365 admin center.
  • Select Subscriptions if you see Simplified view.
  • Select Billing if you see Dashboard view.
  • Select Your products in Dashboard view.
  • Select the trial subscription.
  • Edit recurring billing.
  • Set recurring billing to Off.
  • Select Save.

Your no-show-predictor folder remains on your Desktop. Your My workspace artifacts also remain available.

Delete - I don't want to use this again

Deletion is permanent. Copy the no-show-predictor folder to another location first if you want to preserve the notebook, model, exports, or report.

Delete the dashboard:

  • Return to My workspace in the Power BI service.
  • Find the Appointment No-Show Monitor dashboard.
  • Open More options for the dashboard.
  • Select Delete.
  • Confirm the deletion when prompted.

Delete the published report:

  • Find the updated published no-show dashboard report in My workspace.
  • Open More options for the report.
  • Select Delete.
  • Confirm the deletion when prompted.

Delete the imported semantic model:

  • Find the semantic model created with the published no-show report in My workspace.
  • Open More options for the semantic model.
  • Select Delete.
  • Confirm the deletion when prompted.
  • Refresh My workspace.

You should no longer see the dashboard. The published report and imported semantic model should also be gone.

Remove the local project folder:

  • Close JupyterLab.
  • Close Power BI Desktop.
  • Press Windows+E to open File Explorer.
  • Select Desktop in the left navigation pane.
  • Select the no-show-predictor folder.
  • Press Shift+Delete.
  • Confirm the permanent deletion.

You should no longer see the no-show-predictor folder on your Desktop.

If you used the Microsoft 365 trial:

  • Open the Microsoft 365 admin center.
  • Select Subscriptions if you see Simplified view.
  • Select Billing if you see Dashboard view.
  • Select Your products in Dashboard view.
  • Select the trial subscription.
  • Select Cancel subscription if that option is available.
  • Complete the cancellation prompts shown.
  • Select the recurring billing option if cancellation is unavailable.
  • Set recurring billing to Off.
  • Select Save.

You should see the subscription marked to end. Otherwise, you should see recurring billing set to Off.

Nice Work!

Nice Work!

Excellent work! You've turned reproducible synthetic appointment data into a trained no-show model. Your published Power BI service report combines risk insights, monitoring trends, and governance guardrails.

You've learned how to:

  • Generate a reproducible synthetic appointment dataset in JupyterLab and prepare leakage-aware features for modelling.
  • Compare Scikit-learn pipelines using validation average precision, choose a recall-focused threshold, and evaluate once on an untouched test period.
  • Build and publish a two-page Power BI report that combines appointment risk, held-out performance, monitoring, and visible governance guardrails.
  • Secret Mission: Add a Decomposition tree that uses two High Value AI splits to uncover a higher-risk appointment segment.

Ready to quiz yourself?