Credit Risk Model Stability Analytics

Build a PD model with GARCH volatility and Basel III Expected Loss analytics.

Introduction

30 Second Summary

Banks approve millions of loans every year, but the models they use to predict who will default can silently degrade over time as economic conditions shift. When a model goes stale, bad loans slip through and losses spike.

In this project, you will build an end-to-end Credit Risk Intelligence System that trains a default prediction model, validates its stability across time periods, layers a volatility forecast on top, and delivers the results through a professional executive dashboard.

What You'll Build

A production-grade credit risk analytics pipeline combining Probability of Default modeling, temporal stability validation, GARCH volatility forecasting, and Basel III-aligned Expected Loss computation.

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

  • A temporally-validated PD model built with Weight of Evidence feature engineering and out-of-time stability testing using Population Stability Index.
  • A GARCH(1,1) volatility layer that quantifies conditional portfolio risk and produces 6-month forward-looking forecasts with confidence bands.
  • A professional executive dashboard presenting loan default rates, model stability, portfolio Expected Loss, and risk-adjusted KPIs to C-level stakeholders.
  • Secret Mission: Build a macro-augmented Early Warning System that integrates macroeconomic indicators to detect portfolio stress 2-3 months before defaults spike.

Are there any prerequisites?

You should have intermediate Python proficiency, a basic understanding of classification models, and familiarity with pandas DataFrames. No prior credit risk domain knowledge is required.

Before We Start

Credit risk models in production don't just need to be accurate. They need to stay accurate over time as borrower populations shift and economic conditions change.

In this step, you'll set up your Python environment with all the libraries required to build an end-to-end credit risk analytics pipeline. This includes machine learning, volatility modeling, explainability, and visualization packages.

In this step, get ready to:
  • Create a virtual environment for the project.
  • Install all required Python packages.
  • Verify Jupyter Notebook is ready to use.
Create your project folder and virtual environment

A virtual environment is an isolated Python installation that keeps this project's packages separate from your system Python. This prevents version conflicts between projects.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir credit-risk-analytics
cd credit-risk-analytics
  • Create a virtual environment by running:
python3 -m venv venv

What does this command do?

python3 -m venv venv tells Python to run the built-in venv module and create a new virtual environment in a folder called venv.

Everything you install from here on stays inside this folder, isolated from the rest of your system.

  • Activate the virtual environment:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate
  • Confirm activation by checking that your terminal prompt now starts with (venv).

Don't see (venv) in your prompt?

  • On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell, then try the activate command again.
  • On macOS, make sure you typed source before the path. Without it, the shell won't run the activation script.
  • Still stuck? Help me activate my Python virtual environment
Install project dependencies

This project uses twelve Python packages spanning machine learning, financial volatility modeling, model explainability, and data visualization. You'll list them in a requirements.txt file so the entire environment is reproducible.

  • Create a new file called requirements.txt in your credit-risk-analytics folder by running:

macOS

touch requirements.txt

Windows

New-Item requirements.txt
  • Open requirements.txt in a text editor and paste the following contents:
pandas
numpy
scikit-learn
xgboost
shap
arch
plotly
category_encoders
scipy
matplotlib
seaborn
jupyterlab

What are these packages for?

  • pandas and numpy handle data manipulation and numerical computation.
  • scikit-learn and xgboost power the Probability of Default classification model.
  • arch implements the GARCH(1,1) volatility forecasting model for time-varying portfolio risk.
  • shap generates model interpretability explanations so you can identify top risk drivers.
  • plotly creates interactive charts for your executive dashboard.
  • category_encoders provides Weight of Evidence encoding for credit risk feature engineering.
  • jupyterlab gives you an interactive notebook environment to build the full pipeline.
  • Save requirements.txt.
  • Install all packages by running:
pip install -r requirements.txt

This will download and install all twelve packages along with their dependencies. It may take 2-3 minutes depending on your internet speed.

Seeing installation errors?

  • Make sure (venv) is still showing in your terminal prompt. If not, re-activate the virtual environment before running pip install again.
  • If you see a permissions error, do not use sudo. Instead, confirm you're inside the virtual environment.
  • If a specific package fails to build, try upgrading pip first with pip install --upgrade pip and then run the install command again.
  • Help me fix package installation errors in my virtual environment
Verify your environment is ready
  • Confirm all key packages import correctly by running:
python3 -c "import pandas; import xgboost; import arch; import shap; import plotly; print('All packages imported successfully')"
  • You should see All packages imported successfully printed in your terminal with no errors.
  • Verify Jupyter is available by running:
jupyter lab --version
  • You should see a version number printed. This confirms JupyterLab is installed and ready.

Seeing ModuleNotFoundError or command not found?

  • Double check that (venv) appears in your prompt. Packages only exist inside the virtual environment where you installed them.
  • If jupyter is not found, re-run pip install -r requirements.txt and check the output for any failed installations.
  • Help me fix import errors in my Python credit risk project

✔️ Awesome, I've got everything!

Great. Double check you've saved requirements.txt and that all imports passed.

ⓧ I'd like to double check the full code

Here is the complete requirements.txt file:

pandas
numpy
scikit-learn
xgboost
shap
arch
plotly
category_encoders
scipy
matplotlib
seaborn
jupyterlab

Your environment is ready. Next up, you'll download the Home Credit dataset and begin engineering features that institutional credit risk teams actually use in production.

Set Up Your Environment & Acquire Data

Your virtual environment is ready and all packages are installed. Now you need data to work with.

Credit risk models are only as good as the data they train on. In this step, you will download a real-world loan dataset from Kaggle, load it into a Jupyter Notebook, and confirm it has the structure needed for temporal stability analysis.

In this step, get ready to:
  • Download the Home Credit - Credit Risk Model Stability dataset from Kaggle.
  • Load the dataset into a pandas DataFrame and confirm its shape.
  • Verify the target distribution and temporal fields required for stability analysis.
Download the Home Credit dataset from Kaggle

The Home Credit - Credit Risk Model Stability competition ran on Kaggle in 2024 with over 27,000 entrants. Its focus was not just prediction accuracy, but model stability over time.

The dataset contains 522K+ loan applications with temporal metadata that lets you test whether a model degrades as borrower populations shift.

Why this dataset over LendingClub?

LendingClub is the most overused credit risk dataset on Kaggle. Hundreds of identical portfolio projects exist. The Home Credit CRMS dataset offers a differentiated angle: model stability over time, not just accuracy.

This maps directly to what Model Risk Management teams care about at financial institutions. Using it signals you understand production model governance.

  • Go to the competition page at kaggle.com/competitions/home-credit-credit-risk-model-stability.
  • Sign in to your Kaggle account (create one for free if you do not have one).
  • Click Late Submission or Join Competition and accept the competition rules. You must accept rules before downloading any data.
  • Click the Data tab on the competition page.
  • Download train_base.csv from the data files list.

Use the Kaggle CLI instead

If you have the Kaggle CLI configured with your API credentials, you can download directly from your terminal. Create a data/ folder first, then run the download command.

mkdir data
kaggle competitions download home-credit-credit-risk-model-stability -p data/

This downloads a zip file. Unzip it and ensure train_base.csv is inside your data/ folder.

  • Create a data/ folder in your project directory if you have not already.
  • Move train_base.csv into the data/ folder.
  • Confirm the file exists by running:
ls data/

You should see train_base.csv in the output.

Don't see the file?

Make sure you accepted the competition rules on Kaggle before downloading. The dataset is only available to participants who have joined the competition.

If using the CLI, confirm your Kaggle API credentials are configured at ~/.kaggle/kaggle.json.

help me troubleshoot downloading the Home Credit dataset from Kaggle

Create the notebook and verify data integrity

With the dataset in place, you need to load it and verify its structure. The dataset should have 522K+ rows, a binary default target column, and temporal fields that enable out-of-time model validation.

  • Launch JupyterLab from your terminal by running:
jupyter lab

JupyterLab opens in your browser automatically.

  • Create a new Python 3 notebook by clicking Python 3 (ipykernel) under the Notebook section on the Launcher page.
  • Rename the notebook to credit_risk_notebook.ipynb by right-clicking the tab and selecting Rename Notebook.
  • In the first cell, add your imports by pasting the following code:
# Cell 1: Imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

What do these imports do?

  • pandas loads and manipulates the dataset as a DataFrame.
  • numpy provides numerical computation functions you will use for WoE calculations and array operations.
  • matplotlib and seaborn create the visualizations for EDA and stability analysis.
  • Run Cell 1 by pressing Shift+Enter.
  • In a new cell (Cell 2), load the dataset and run integrity checks by pasting this code:
# Cell 2: Load dataset
df = pd.read_csv('data/train_base.csv')
print(df.shape)
print(df['target'].value_counts(normalize=True))
print(df[['date_decision', 'MONTH', 'WEEK_NUM']].head())
print(df.dtypes)
print(df.isnull().sum().sort_values(ascending=False).head(20))

What does this code do?

  • df.shape confirms you have 522K+ rows and 48 columns.
  • df['target'].value_counts(normalize=True) shows the class imbalance between defaults and non-defaults. Expect a heavily imbalanced distribution.
  • The temporal fields check confirms date_decision, MONTH, and WEEK_NUM exist. These columns are required for out-of-time validation later.
  • df.dtypes reveals which columns are numeric vs object, guiding your feature engineering decisions.
  • The null summary highlights which features have missing data that will need imputation in the next step.
  • Run Cell 2 by pressing Shift+Enter.
  • Check that the shape output shows approximately (522596, 48).
  • Confirm the target column shows two values (0 and 1) with the majority being 0 (non-default).
  • Verify that date_decision, MONTH, and WEEK_NUM appear in the temporal fields output with valid values.

Seeing a FileNotFoundError?

Make sure your notebook is running from the same directory that contains the data/ folder. If you launched Jupyter from a different location, the relative path will not resolve.

Double-check that the file inside data/ is named exactly train_base.csv with no extra folder nesting from unzipping.

help me fix the FileNotFoundError when loading my dataset

  • Save your notebook by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Make sure your notebook is saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete notebook code up to this point:

# Cell 1: Imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns

# Cell 2: Load dataset
df = pd.read_csv('data/train_base.csv')
print(df.shape)
print(df['target'].value_counts(normalize=True))
print(df[['date_decision', 'MONTH', 'WEEK_NUM']].head())
print(df.dtypes)
print(df.isnull().sum().sort_values(ascending=False).head(20))

Your data is loaded and verified. Next up, you will engineer powerful features using Weight of Evidence transformations that turn raw predictors into monotonic risk-ordered variables.

Engineer Features with WoE/IV

Your raw dataset is loaded, but raw features are rarely model-ready in credit risk. Banks don't feed raw debt amounts or days-past-due counts directly into PD models. They transform them into risk-ordered scores that regulators can audit and interpret.

In this step, you'll engineer features using Weight of Evidence (WoE), the industry-standard transformation for credit risk modeling. You'll also split the data by time (not randomly) to simulate how banks validate models against future unseen populations, and filter features using Information Value (IV) to keep only genuinely predictive signals.

In this step, get ready to:
  • Create out-of-time temporal splits that mirror bank validation practices.
  • Impute missing values using training-set statistics to prevent data leakage.
  • Compute WoE encoding and filter features by Information Value.
Create out-of-time temporal splits

Random train/test splits are a red flag in credit risk. Real borrower populations shift over time, so banks validate models against future cohorts they've never seen. This is called an out-of-time (OOT) split.

Why out-of-time splits instead of random splits?

A random 80/20 split leaks future information into training. Borrowers from 2024 could end up in the training set while 2023 borrowers appear in test. This inflates accuracy but hides model degradation.

OOT splits sort by date and cut forward. Your model only ever predicts on borrowers it hasn't seen from a time period it hasn't trained on. This is exactly how Model Risk Management teams at banks validate production models.

  • In your Jupyter Notebook, find Cell 1 (your imports cell) and add these two new imports at the end:
from category_encoders import WOEEncoder
from sklearn.model_selection import train_test_split
  • Run the updated Cell 1 to load the new imports.
  • Also clean up Cell 2 by removing the exploratory lines at the bottom. Find and delete these two lines:
print(df.dtypes)
print(df.isnull().sum().sort_values(ascending=False).head(20))
  • Those lines served their purpose in Step 1. Your Cell 2 should now end with the temporal fields preview.
  • Re-run Cell 2 so your DataFrame df is loaded fresh.
  • Create a new Cell 3 below Cell 2 and add the temporal split logic by pasting this code:
# Cell 3: Temporal split
df = df.sort_values('date_decision').reset_index(drop=True)
n = len(df)
train_end = int(n * 0.6)
val_end = int(n * 0.8)
df_train = df.iloc[:train_end].copy()
df_val = df.iloc[train_end:val_end].copy()
df_test = df.iloc[val_end:].copy()
print(f'Train: {len(df_train)}, Val: {len(df_val)}, Test: {len(df_test)}')

What does this code do?

  • sort_values('date_decision') orders the entire DataFrame chronologically so earlier loan applications come first.
  • The 60/20/20 split creates three time windows: Train (earliest applications), Validation (middle), Test (most recent). The model trains on the past and predicts on the future.
  • .copy() prevents pandas SettingWithCopyWarning when you modify these DataFrames later.
  • Run Cell 3.
  • You should see output showing approximately 313K training rows, 104K validation rows, and 104K test rows (exact numbers depend on your dataset version).

Seeing a KeyError on date_decision?

  • Make sure you re-ran Cell 2 before running Cell 3. The DataFrame df must be loaded in memory.
  • Verify the column name is exactly date_decision (case-sensitive). Run df.columns.tolist() to check.

Help me debug a KeyError when sorting by date_decision in my credit risk notebook.

Impute missing values with training-set statistics

The Home Credit dataset contains masked credit bureau indicators with partial coverage. You need to fill these gaps before WoE encoding. The key rule: imputation statistics must come from the training set only, then be applied to validation and test.

  • Create a new Cell 4 and paste the imputation logic:
# Cell 4: Feature selection and imputation
feature_cols = [c for c in df.columns if c not in ['target', 'date_decision', 'MONTH', 'WEEK_NUM', 'case_id']]
for col in feature_cols:
    if df_train[col].dtype in ['float64', 'int64']:
        median_val = df_train[col].median()
        df_train[col].fillna(median_val, inplace=True)
        df_val[col].fillna(median_val, inplace=True)
        df_test[col].fillna(median_val, inplace=True)
    else:
        mode_val = df_train[col].mode()[0]
        df_train[col].fillna(mode_val, inplace=True)
        df_val[col].fillna(mode_val, inplace=True)
        df_test[col].fillna(mode_val, inplace=True)

What does this code do?

  • feature_cols selects all columns except the target, temporal metadata, and the case ID. These are the features the model will learn from.
  • Numeric columns get filled with the training set's median. Categorical columns get filled with the training set's mode (most frequent value).
  • The same training-set statistics are applied to validation and test. This prevents data leakage. If you calculated medians from the full dataset, future information would bleed into your training features.
  • Run Cell 4.
  • Verify imputation worked by running this in a new temporary cell:
print(df_train[feature_cols].isnull().sum().sum())
  • You should see 0, confirming no missing values remain in the training features.

Still seeing NaN values after imputation?

  • Ensure Cell 3 ran successfully first. Cell 4 depends on df_train existing.
  • If you see a SettingWithCopyWarning, confirm you used .copy() when creating the split DataFrames in Cell 3.

Help me fix NaN values remaining after my imputation step in the credit risk notebook.

Compute WoE encoding and filter by Information Value

Weight of Evidence transforms each feature into a log-odds ratio that measures how strongly each bin separates defaults from non-defaults. This produces monotonic, interpretable risk scores that regulators accept.

What is Weight of Evidence?

WoE replaces raw feature values with a score: positive WoE means the bin has more non-defaults than expected, negative means more defaults. This makes every feature speak the same "language of risk" regardless of its original scale.

Information Value (IV) summarizes a feature's total predictive power. The thresholds: IV below 0.02 means the feature is useless. IV above 0.5 means it's suspiciously overfit (likely a data leak). You'll keep only features in the sweet spot.

  • Create a new Cell 5 and paste the WoE encoding logic:
# Cell 5: WoE encoding and IV calculation
woe_encoder = WOEEncoder(cols=feature_cols)
X_train = woe_encoder.fit_transform(df_train[feature_cols], df_train['target'])
X_val = woe_encoder.transform(df_val[feature_cols])
X_test = woe_encoder.transform(df_test[feature_cols])
y_train = df_train['target']
y_val = df_val['target']
y_test = df_test['target']

What does this code do?

  • WOEEncoder(cols=feature_cols) initializes the encoder to transform all feature columns using Weight of Evidence.
  • fit_transform calculates the WoE mapping from training data and applies it. The target y is required because WoE is a supervised transformation.
  • transform applies the same learned WoE mapping to validation and test. These sets never influence how bins are calculated.
  • The target vectors (y_train, y_val, y_test) are extracted separately for model training later.
  • Run Cell 5. This may take 30-60 seconds depending on your machine.
  • Verify the output by checking the shape: run X_train.shape in a temporary cell. You should see your training rows with all feature columns now containing WoE values.

WOEEncoder throwing a ValueError?

  • Ensure Cell 4 ran without errors. WOEEncoder cannot handle NaN values in the input.
  • If you see a warning about columns with too few unique values, that's normal. The encoder handles low-cardinality columns gracefully.

Help me fix a ValueError from WOEEncoder in my credit risk pipeline.

Now you'll calculate Information Value to filter out useless and suspiciously overfit features.

  • Create a new Cell 6 and paste the IV calculation and filtering logic:
# Cell 6: IV calculation and feature filtering
def compute_iv(X, y):
    iv_dict = {}
    for col in X.columns:
        crosstab = pd.crosstab(pd.qcut(X[col], q=10, duplicates='drop'), y, normalize='columns')
        if 0 not in crosstab.columns or 1 not in crosstab.columns:
            continue
        woe = np.log((crosstab[1] + 1e-9) / (crosstab[0] + 1e-9))
        iv = ((crosstab[1] - crosstab[0]) * woe).sum()
        iv_dict[col] = iv
    return iv_dict

iv_dict = compute_iv(X_train, y_train)
selected_features = [f for f, iv in iv_dict.items() if 0.02 < iv < 0.5]
X_train = X_train[selected_features]
X_val = X_val[selected_features]
X_test = X_test[selected_features]
print(f'Selected {len(selected_features)} features after IV filtering')

What does this code do?

  • compute_iv() bins each WoE-encoded feature into deciles, then calculates the IV formula: the sum of (distribution difference) times (log odds ratio) across all bins.
  • The 1e-9 prevents division-by-zero errors when a bin has zero observations in one class.
  • Features with IV below 0.02 are dropped (not predictive). Features with IV above 0.5 are also dropped (likely data leakage or perfect separation). Only genuinely predictive features survive.
  • Run Cell 6.
  • You should see a message like Selected N features after IV filtering where N is the number of features that passed the IV thresholds. The exact number depends on your dataset version.

Seeing 0 selected features?

  • Run sorted(iv_dict.values()) to inspect the IV distribution. If all values are outside the 0.02-0.5 range, your WoE encoding in Cell 5 may have an issue.
  • Confirm that X_train from Cell 5 contains numeric WoE-transformed values, not the original raw feature values.

Help me debug why IV filtering selected zero features in my credit risk model.

What do the PSI thresholds mean for IV?

IV below 0.02 means the feature has almost no ability to distinguish defaults from non-defaults. Including it adds noise without signal. IV above 0.5 is a red flag for overfitting. It often means the feature contains information that wouldn't be available at decision time in production.

✔️ Awesome, I've got everything!

Great. Double check you've saved your notebook. Your WoE-transformed feature matrices and target vectors are ready for PD model training.

ⓧ I'd like to double check the full code

Here is the complete notebook code through the end of this step. Compare your cells against this:

# Cell 1: Imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from category_encoders import WOEEncoder
from sklearn.model_selection import train_test_split

# Cell 2: Load dataset
df = pd.read_csv('data/train_base.csv')
print(df.shape)
print(df['target'].value_counts(normalize=True))
print(df[['date_decision', 'MONTH', 'WEEK_NUM']].head())

# Cell 3: Temporal split
df = df.sort_values('date_decision').reset_index(drop=True)
n = len(df)
train_end = int(n * 0.6)
val_end = int(n * 0.8)
df_train = df.iloc[:train_end].copy()
df_val = df.iloc[train_end:val_end].copy()
df_test = df.iloc[val_end:].copy()
print(f'Train: {len(df_train)}, Val: {len(df_val)}, Test: {len(df_test)}')

# Cell 4: Feature selection and imputation
feature_cols = [c for c in df.columns if c not in ['target', 'date_decision', 'MONTH', 'WEEK_NUM', 'case_id']]
for col in feature_cols:
    if df_train[col].dtype in ['float64', 'int64']:
        median_val = df_train[col].median()
        df_train[col].fillna(median_val, inplace=True)
        df_val[col].fillna(median_val, inplace=True)
        df_test[col].fillna(median_val, inplace=True)
    else:
        mode_val = df_train[col].mode()[0]
        df_train[col].fillna(mode_val, inplace=True)
        df_val[col].fillna(mode_val, inplace=True)
        df_test[col].fillna(mode_val, inplace=True)

# Cell 5: WoE encoding and IV calculation
woe_encoder = WOEEncoder(cols=feature_cols)
X_train = woe_encoder.fit_transform(df_train[feature_cols], df_train['target'])
X_val = woe_encoder.transform(df_val[feature_cols])
X_test = woe_encoder.transform(df_test[feature_cols])
y_train = df_train['target']
y_val = df_val['target']
y_test = df_test['target']

# Cell 6: IV calculation and feature filtering
def compute_iv(X, y):
    iv_dict = {}
    for col in X.columns:
        crosstab = pd.crosstab(pd.qcut(X[col], q=10, duplicates='drop'), y, normalize='columns')
        if 0 not in crosstab.columns or 1 not in crosstab.columns:
            continue
        woe = np.log((crosstab[1] + 1e-9) / (crosstab[0] + 1e-9))
        iv = ((crosstab[1] - crosstab[0]) * woe).sum()
        iv_dict[col] = iv
    return iv_dict

iv_dict = compute_iv(X_train, y_train)
selected_features = [f for f, iv in iv_dict.items() if 0.02 < iv < 0.5]
X_train = X_train[selected_features]
X_val = X_val[selected_features]
X_test = X_test[selected_features]
print(f'Selected {len(selected_features)} features after IV filtering')

Your feature engineering pipeline is locked in. You have WoE-transformed, IV-filtered feature matrices split by time. Next up, you'll train an XGBoost PD model on these features and validate its stability across monthly cohorts using the Population Stability Index.

Train the PD Model & Validate Stability

You now have a clean, WoE-transformed feature set ready for modeling. The next challenge is building a Probability of Default model that not only predicts well today, but remains stable as borrower populations shift over time.

In this step you will train an XGBoost classifier, evaluate it with industry-standard discrimination metrics, then stress-test its stability using Population Stability Index (PSI) across monthly cohorts. Finally, you will use SHAP to identify the top risk drivers in the model.

In this step, get ready to:
  • Train an XGBoost PD model with class-imbalance handling and early stopping.
  • Validate model stability with PSI across monthly vintages.
  • Generate SHAP explanations and identify the top 5 risk drivers.
Train the XGBoost PD model

A PD model must handle severe class imbalance. Most loans do not default, so a naive model would predict "no default" every time and still achieve high accuracy. XGBoost's scale_pos_weight parameter corrects this by up-weighting the minority (default) class during training.

  • In your notebook, scroll to the top of Cell 1 (imports) and add the following new imports below the existing ones:
from sklearn.metrics import roc_auc_score
from scipy import stats
import xgboost as xgb
import shap
import plotly.graph_objects as go

What do these imports do?

  • roc_auc_score computes the Area Under the ROC Curve for your model's predictions.
  • stats from scipy provides the KS (Kolmogorov-Smirnov) two-sample test.
  • xgb is the XGBoost gradient boosting library.
  • shap provides model-agnostic explainability via SHAP values.
  • plotly.graph_objects powers the interactive PSI visualization.
  • Run the updated import cell to load the new libraries.
  • Create a new cell below your existing work and train the XGBoost PD model by pasting this code:
# Cell 4: Train XGBoost PD model
scale_pos_weight = (y_train == 0).sum() / (y_train == 1).sum()
xgb_model = xgb.XGBClassifier(
    max_depth=4,
    learning_rate=0.05,
    n_estimators=300,
    scale_pos_weight=scale_pos_weight,
    use_label_encoder=False,
    eval_metric='auc',
    random_state=42
)
xgb_model.fit(X_train, y_train, eval_set=[(X_val, y_val)], early_stopping_rounds=20, verbose=50)

What does this code do?

  • scale_pos_weight is computed as the ratio of non-defaults to defaults. This tells XGBoost to penalize misclassifying a default more heavily.
  • max_depth=4 limits tree depth to prevent overfitting on a large dataset.
  • early_stopping_rounds=20 halts training if the validation AUC does not improve for 20 consecutive rounds.
  • eval_set=[(X_val, y_val)] uses your temporal validation split (the next 20% by date) to monitor performance during training.
  • Run the cell. You should see training progress printed every 50 rounds, with the validation AUC increasing and then plateauing.

Training seems to stop early?

This is expected behavior. Early stopping means the model found its optimal number of trees before reaching 300. Check xgb_model.best_iteration to see how many rounds were actually used.

If you see an error about use_label_encoder, ensure your XGBoost version is recent. You can remove that parameter if you are on XGBoost 2.0+.

Help me debug my XGBoost training error

  • In a new cell, evaluate the model's discrimination power by pasting this code:
# Cell 5: Model evaluation
y_pred_val = xgb_model.predict_proba(X_val)[:, 1]
y_pred_test = xgb_model.predict_proba(X_test)[:, 1]
auc_val = roc_auc_score(y_val, y_pred_val)
gini_val = 2 * auc_val - 1
print(f'Validation AUC: {auc_val:.4f}, Gini: {gini_val:.4f}')

def ks_statistic(y_true, y_pred):
    pos = y_pred[y_true == 1]
    neg = y_pred[y_true == 0]
    return stats.ks_2samp(pos, neg).statistic

ks_val = ks_statistic(y_val.values, y_pred_val)
print(f'KS Statistic (Val): {ks_val:.4f}')

What are these metrics?

ROC-AUC measures overall ranking ability. A score above 0.7 indicates a useful model.

The Gini coefficient (2*AUC - 1) is the standard discrimination metric used by credit risk teams. It ranges from 0 (random) to 1 (perfect separation).

The KS statistic measures the maximum distance between the cumulative distributions of predicted probabilities for defaulters vs non-defaulters. Higher values mean better separation.

  • Run the cell. You should see your Validation AUC, Gini, and KS statistic printed.

AUC is below 0.6?

A very low AUC suggests features may not have transformed correctly. Go back and verify that your selected_features list is non-empty and that X_train contains the filtered WoE-encoded columns.

Help me diagnose my low AUC score

Compute Population Stability Index (PSI)

A model that scores well on one time window might degrade silently as borrower demographics shift. PSI measures how much the distribution of predicted probabilities has drifted from the training baseline. Model Risk Management teams use PSI to decide whether a model needs recalibration.

  • Create a new cell and compute PSI for each monthly cohort by pasting this code:
# Cell 6: PSI computation
def compute_psi(expected, actual, buckets=10):
    breakpoints = np.percentile(expected, np.linspace(0, 100, buckets + 1))
    breakpoints[0] = -np.inf
    breakpoints[-1] = np.inf
    expected_counts = np.histogram(expected, bins=breakpoints)[0] / len(expected)
    actual_counts = np.histogram(actual, bins=breakpoints)[0] / len(actual)
    psi = np.sum((actual_counts - expected_counts) * np.log((actual_counts + 1e-9) / (expected_counts + 1e-9)))
    return psi

train_preds = xgb_model.predict_proba(X_train)[:, 1]
df_train_preds = df_train[['MONTH']].copy()
df_train_preds['pred'] = train_preds
df_val_preds = df_val[['MONTH']].copy()
df_val_preds['pred'] = y_pred_val
df_test_preds = df_test[['MONTH']].copy()
df_test_preds['pred'] = y_pred_test
all_preds = pd.concat([df_train_preds, df_val_preds, df_test_preds])

psi_results = []
for month in sorted(all_preds['MONTH'].unique()):
    month_preds = all_preds[all_preds['MONTH'] == month]['pred'].values
    if len(month_preds) > 50:
        psi_val = compute_psi(train_preds, month_preds)
        psi_results.append({'MONTH': month, 'PSI': psi_val})
psi_df = pd.DataFrame(psi_results)
print(psi_df)

How does PSI work?

PSI bins the training predictions into deciles, then compares how each monthly cohort's predictions distribute across those same bins. If the distributions are similar, PSI stays near zero.

The industry-standard thresholds are: PSI < 0.1 means the model is stable. PSI between 0.1 and 0.25 requires monitoring. PSI > 0.25 signals significant population drift requiring model recalibration.

  • Run the cell. You should see a table of monthly PSI values printed.
  • Create a new cell and visualize PSI over time by pasting this code:
# Cell 7: PSI visualization
fig_psi = go.Figure()
fig_psi.add_trace(go.Scatter(x=psi_df['MONTH'], y=psi_df['PSI'], mode='lines+markers', name='PSI'))
fig_psi.add_hline(y=0.1, line_dash='dash', line_color='green', annotation_text='Stable (<0.1)')
fig_psi.add_hline(y=0.25, line_dash='dash', line_color='red', annotation_text='Degraded (>0.25)')
fig_psi.update_layout(title='Population Stability Index Over Time', xaxis_title='Month', yaxis_title='PSI')
fig_psi.show()

What should I see?

You should see an interactive Plotly chart with PSI values plotted across months. A green dashed line at 0.1 marks the stability threshold, and a red dashed line at 0.25 marks the degradation threshold. Most early months should fall in the green (stable) zone, with potential drift in later test months.

  • Run the cell. Your interactive PSI chart should appear below the cell.

Chart not showing?

Plotly charts require JavaScript rendering. If you are using JupyterLab, the chart should render inline. If you see a blank output, try running fig_psi.write_html('psi_chart.html') and opening the file in your browser instead.

Help me fix my Plotly chart display

Generate SHAP explanations

Regulators and risk committees need to understand why a model flags certain applicants as high-risk. SHAP (SHapley Additive exPlanations) provides mathematically grounded feature importance by computing each feature's marginal contribution to every prediction.

  • Create a new cell and generate SHAP explanations by pasting this code:
# Cell 8: SHAP explanations
explainer = shap.TreeExplainer(xgb_model)
shap_values = explainer.shap_values(X_val)
shap.summary_plot(shap_values, X_val, plot_type='dot', max_display=20)
top5_features = pd.Series(np.abs(shap_values).mean(axis=0), index=X_val.columns).nlargest(5)
print('Top 5 Risk Drivers:')
print(top5_features)

What does this code do?

TreeExplainer uses the Tree SHAP algorithm to compute exact SHAP values for every prediction in the validation set. This is fast because it exploits the tree structure directly.

The summary plot (beeswarm style) shows each feature as a row. Each dot is one applicant, positioned by how much that feature pushed the prediction toward default (right) or away from it (left). Color indicates the feature value (red = high, blue = low).

The top 5 risk drivers are identified by averaging the absolute SHAP values across all validation samples. These are the features that matter most to the model's decisions.

  • Run the cell. You should see a colorful beeswarm plot and a printed list of the top 5 risk-driving features with their mean absolute SHAP values.

SHAP computation is very slow?

TreeExplainer on a large validation set (100K+ rows) can take a few minutes. If it is taking too long, you can pass a subset: explainer.shap_values(X_val.iloc[:5000]) to compute SHAP on 5000 samples instead.

Help me speed up SHAP computation

✔️ Awesome, I've got everything!

Great. Double check that you have saved your notebook with all cells run successfully.

ⓧ I'd like to double check the full code

Here is the complete notebook state at the end of this step:

# Cell 1: Imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from category_encoders import WOEEncoder
from sklearn.model_selection import train_test_split
from sklearn.metrics import roc_auc_score
from scipy import stats
import xgboost as xgb
import shap
import plotly.graph_objects as go

# Cell 2: Load dataset
df = pd.read_csv('data/train_base.csv')
df = df.sort_values('date_decision').reset_index(drop=True)
n = len(df)
train_end = int(n * 0.6)
val_end = int(n * 0.8)
df_train = df.iloc[:train_end].copy()
df_val = df.iloc[train_end:val_end].copy()
df_test = df.iloc[val_end:].copy()

# Cell 3: Feature engineering (WoE/IV) - as in Step 2
# [WoE encoding and IV filtering code from Step 2]

# Cell 4: Train XGBoost PD model
scale_pos_weight = (y_train == 0).sum() / (y_train == 1).sum()
xgb_model = xgb.XGBClassifier(
    max_depth=4,
    learning_rate=0.05,
    n_estimators=300,
    scale_pos_weight=scale_pos_weight,
    use_label_encoder=False,
    eval_metric='auc',
    random_state=42
)
xgb_model.fit(X_train, y_train, eval_set=[(X_val, y_val)], early_stopping_rounds=20, verbose=50)

# Cell 5: Model evaluation
y_pred_val = xgb_model.predict_proba(X_val)[:, 1]
y_pred_test = xgb_model.predict_proba(X_test)[:, 1]
auc_val = roc_auc_score(y_val, y_pred_val)
gini_val = 2 * auc_val - 1
print(f'Validation AUC: {auc_val:.4f}, Gini: {gini_val:.4f}')

def ks_statistic(y_true, y_pred):
    pos = y_pred[y_true == 1]
    neg = y_pred[y_true == 0]
    return stats.ks_2samp(pos, neg).statistic

ks_val = ks_statistic(y_val.values, y_pred_val)
print(f'KS Statistic (Val): {ks_val:.4f}')

# Cell 6: PSI computation
def compute_psi(expected, actual, buckets=10):
    breakpoints = np.percentile(expected, np.linspace(0, 100, buckets + 1))
    breakpoints[0] = -np.inf
    breakpoints[-1] = np.inf
    expected_counts = np.histogram(expected, bins=breakpoints)[0] / len(expected)
    actual_counts = np.histogram(actual, bins=breakpoints)[0] / len(actual)
    psi = np.sum((actual_counts - expected_counts) * np.log((actual_counts + 1e-9) / (expected_counts + 1e-9)))
    return psi

train_preds = xgb_model.predict_proba(X_train)[:, 1]
df_train_preds = df_train[['MONTH']].copy()
df_train_preds['pred'] = train_preds
df_val_preds = df_val[['MONTH']].copy()
df_val_preds['pred'] = y_pred_val
df_test_preds = df_test[['MONTH']].copy()
df_test_preds['pred'] = y_pred_test
all_preds = pd.concat([df_train_preds, df_val_preds, df_test_preds])

psi_results = []
for month in sorted(all_preds['MONTH'].unique()):
    month_preds = all_preds[all_preds['MONTH'] == month]['pred'].values
    if len(month_preds) > 50:
        psi_val = compute_psi(train_preds, month_preds)
        psi_results.append({'MONTH': month, 'PSI': psi_val})
psi_df = pd.DataFrame(psi_results)
print(psi_df)

# Cell 7: PSI visualization
fig_psi = go.Figure()
fig_psi.add_trace(go.Scatter(x=psi_df['MONTH'], y=psi_df['PSI'], mode='lines+markers', name='PSI'))
fig_psi.add_hline(y=0.1, line_dash='dash', line_color='green', annotation_text='Stable (<0.1)')
fig_psi.add_hline(y=0.25, line_dash='dash', line_color='red', annotation_text='Degraded (>0.25)')
fig_psi.update_layout(title='Population Stability Index Over Time', xaxis_title='Month', yaxis_title='PSI')
fig_psi.show()

# Cell 8: SHAP explanations
explainer = shap.TreeExplainer(xgb_model)
shap_values = explainer.shap_values(X_val)
shap.summary_plot(shap_values, X_val, plot_type='dot', max_display=20)
top5_features = pd.Series(np.abs(shap_values).mean(axis=0), index=X_val.columns).nlargest(5)
print('Top 5 Risk Drivers:')
print(top5_features)

You now have a trained, validated, and explainable PD model. Next up, you will layer a GARCH(1,1) volatility model on top of your monthly default rates and compute Basel III Expected Loss for the full portfolio.

Build the GARCH Volatility Layer & Compute Expected Loss

Your PD model is trained, stability-tested with PSI, and the top risk drivers are identified through SHAP. Now it's time to answer the question every risk executive asks: how much money could we lose, and how volatile is that estimate?

In this step, you'll layer a GARCH(1,1) conditional volatility model on top of your portfolio's default rate series. Then you'll compute Basel III-aligned Expected Loss (EL = PD x LGD x EAD) segmented by risk grade.

In this step, get ready to:
  • Construct a monthly portfolio default rate time series and compute log-returns.
  • Fit a GARCH(1,1) volatility model and produce a 6-month conditional forecast with confidence bands.
  • Compute Basel III Expected Loss segmented by risk grade (A through E).
Construct the portfolio default rate time series

GARCH models require a stationary time series as input. You'll aggregate your predicted PD scores by month to create a default rate series, then take log-returns to make it stationary.

  • Add the arch library import to your imports cell (Cell 1) by adding this line at the bottom:
from arch import arch_model

What is this import?

The arch library provides the arch_model function, which is the standard Python implementation for fitting GARCH family models. You installed it in Step 1 as part of your dependencies.

  • Create a new cell (Cell 9) and construct the monthly default rate series by running this code:
# Cell 9: Construct monthly portfolio default rate series
monthly_default_rate = all_preds.groupby('MONTH')['pred'].mean()
monthly_default_rate = monthly_default_rate.sort_index()
print(monthly_default_rate)

What does this code do?

This groups all predicted PD scores by their MONTH field and takes the mean. The result is a time series where each point represents the average predicted probability of default for that month's cohort.

Sorting by index ensures the months are in chronological order, which is essential for time series modeling.

  • Run the cell. You should see a Series printed with month numbers as the index and average PD values (small decimals) as values.
  • Create Cell 10 and compute log-returns to make the series stationary:
# Cell 10: Compute log-returns for GARCH
log_returns = np.log(monthly_default_rate / monthly_default_rate.shift(1)).dropna() * 100

Why log-returns?

GARCH models assume the input series is stationary (no trend). Raw default rates may trend up or down over time. Taking the log of the ratio between consecutive months converts the series into percentage changes, which are typically stationary.

Multiplying by 100 scales the values into percentage points, which makes the GARCH output easier to interpret.

  • Run Cell 10. No output is expected since there's no print statement, but log_returns is now ready for the GARCH model.

Getting a division error or all NaN values?

If monthly_default_rate contains zeros, the log function produces -inf values. Check that your all_preds DataFrame has predicted probabilities (not binary 0/1 predictions) in the pred column.

help me debug NaN values in my log-returns calculation for GARCH

Fit a GARCH(1,1) model and produce a volatility forecast

GARCH (Generalized Autoregressive Conditional Heteroskedasticity) captures a key pattern in financial data: periods of high volatility cluster together. For credit risk, this means periods of high defaults tend to be followed by more high defaults.

  • Create Cell 11 and fit the GARCH(1,1) model on your log-returns:
# Cell 11: Fit GARCH(1,1) model
garch_model = arch_model(log_returns, vol='Garch', p=1, q=1, dist='normal')
garch_result = garch_model.fit(disp='off')
print(garch_result.summary())

What do these parameters mean?

vol='Garch' selects the GARCH volatility process. p=1 means one lag of the squared residual (ARCH term). q=1 means one lag of the conditional variance (GARCH term).

dist='normal' assumes normally distributed errors. disp='off' suppresses the optimizer's iteration output so you only see the final summary.

  • Run Cell 11. You should see a model summary table with parameter estimates for omega, alpha[1], and beta[1], plus log-likelihood and information criteria (AIC/BIC).

Seeing a convergence warning?

If the model fails to converge, your log_returns series may be too short (fewer than 12 data points). Confirm that all_preds spans at least 12 distinct months.

help me fix GARCH convergence issues with my default rate time series

  • Create Cell 12 to produce the 6-month conditional volatility forecast:
# Cell 12: 6-month volatility forecast
garch_forecast = garch_result.forecast(horizon=6)
forecast_variance = garch_forecast.variance.iloc[-1]
forecast_vol = np.sqrt(forecast_variance)
print('6-Month Conditional Volatility Forecast:')
print(forecast_vol)

What does this forecast represent?

The GARCH forecast produces conditional variance for each of the next 6 months. Taking the square root converts variance into volatility (standard deviation units), which is more interpretable.

Higher forecast volatility means the model expects more uncertainty in default rates over the coming months. This is exactly the forward-looking risk signal that portfolio managers need.

  • Run Cell 12. You should see 6 volatility values printed, one per forecast month.
  • Create Cell 13 to visualize the volatility forecast with confidence bands:
# Cell 13: Visualize GARCH volatility
fig_garch = go.Figure()
fig_garch.add_trace(go.Scatter(x=list(range(1, 7)), y=forecast_vol.values, mode='lines+markers', name='Forecast Volatility'))
fig_garch.add_trace(go.Scatter(x=list(range(1, 7)), y=forecast_vol.values * 1.645, mode='lines', name='95% Upper Band', line=dict(dash='dash')))
fig_garch.add_trace(go.Scatter(x=list(range(1, 7)), y=forecast_vol.values * 0.355, mode='lines', name='95% Lower Band', line=dict(dash='dash')))
fig_garch.update_layout(title='GARCH(1,1) 6-Month Conditional Volatility Forecast', xaxis_title='Months Ahead', yaxis_title='Conditional Volatility (%)')
fig_garch.show()

What are the confidence bands?

The upper band (1.645x) represents the 95th percentile of a one-sided normal distribution. This gives executives a "worst-case" volatility scenario they can use for stress testing.

The lower band (0.355x) shows the optimistic scenario. Together, these bands create the uncertainty envelope around the central forecast.

  • Run Cell 13. You should see an interactive Plotly line chart with three traces: the central volatility forecast and two dashed confidence bands.

Chart not rendering in Jupyter?

If the Plotly chart doesn't appear, make sure you're running Jupyter Notebook (not JupyterLab without the plotly extension). Try adding fig_garch.write_html('garch_forecast.html') and opening the HTML file in your browser.

help me render Plotly charts in my Jupyter environment

Compute Basel III Expected Loss by risk grade

Expected Loss is the formula regulators use to determine how much capital a bank must hold against potential credit losses. The formula is EL = PD x LGD x EAD, where LGD (Loss Given Default) is 45% under the Basel IRB Foundation approach for unsecured exposures.

  • Create Cell 14 to compute Expected Loss for the full portfolio:
# Cell 14: Compute Basel III Expected Loss
LGD = 0.45
df_all = pd.concat([df_train, df_val, df_test]).reset_index(drop=True)
all_X = pd.concat([X_train, X_val, X_test]).reset_index(drop=True)
df_all['PD'] = xgb_model.predict_proba(all_X)[:, 1]
df_all['EAD'] = df_all['amtcredit_sum'] if 'amtcredit_sum' in df_all.columns else df_all.filter(like='amt').iloc[:, 0]
df_all['EL'] = df_all['PD'] * LGD * df_all['EAD']

What does each component represent?

PD (Probability of Default) comes from your XGBoost model's predicted probabilities. LGD (Loss Given Default) is set at 45%, the Basel IRB Foundation standard for unsecured senior claims.

EAD (Exposure at Default) uses the loan amount column from the dataset as a proxy for how much is at risk. The code checks for amtcredit_sum first and falls back to the first amount-like column if it's not available.

  • Run Cell 14. No printed output is expected, but the df_all DataFrame now has PD, EAD, and EL columns.
  • Create Cell 15 to segment Expected Loss by risk grade and compute portfolio totals:
# Cell 15: Risk grade segmentation
pd_quantiles = pd.qcut(df_all['PD'], q=5, labels=['A', 'B', 'C', 'D', 'E'])
df_all['risk_grade'] = pd.Categorical(pd_quantiles)
el_by_grade = df_all.groupby('risk_grade').agg(
    total_EAD=('EAD', 'sum'),
    total_EL=('EL', 'sum'),
    avg_PD=('PD', 'mean'),
    count=('EL', 'count')
).reset_index()
el_by_grade['EL_rate'] = el_by_grade['total_EL'] / el_by_grade['total_EAD']
print(el_by_grade)

total_EL = df_all['EL'].sum()
total_EAD = df_all['EAD'].sum()
portfolio_EL_rate = total_EL / total_EAD
print(f'Total Portfolio EL: ${total_EL:,.0f}')
print(f'Portfolio EL Rate: {portfolio_EL_rate:.4%}')

el_summary = {'total_EAD': total_EAD, 'total_EL': total_EL, 'portfolio_EL_rate': portfolio_EL_rate}

What does the risk grade segmentation show?

pd.qcut splits all loans into 5 equal-sized buckets based on their predicted PD. Grade A is the safest 20% of borrowers. Grade E is the riskiest 20%.

The EL_rate column shows the Expected Loss as a percentage of exposure for each grade. This is what a Chief Risk Officer uses to decide where to tighten underwriting standards.

The el_summary dictionary stores the portfolio-level metrics you'll need for the executive dashboard in the next step.

  • Run Cell 15. You should see a table with 5 rows (grades A through E) showing total EAD, total EL, average PD, count, and EL rate for each grade.
  • Below the table, you should see the portfolio-level Total EL as a dollar figure and the Portfolio EL Rate as a percentage.

Seeing an error about the amount column?

If the EAD assignment fails, the dataset may not have a column named amtcredit_sum or any column containing amt. Run df_all.columns.tolist() to find the correct loan amount column name and update the code accordingly.

help me identify the correct EAD column in my Home Credit dataset

✔️ Awesome, I've got everything!

Great. Double check you've saved your notebook before moving on to the dashboard step.

ⓧ I'd like to double check the full code

Here is the complete code added in this step (Cells 9 through 15), plus the new import:

# Added to Cell 1: Imports
from arch import arch_model

# Cell 9: Construct monthly portfolio default rate series
monthly_default_rate = all_preds.groupby('MONTH')['pred'].mean()
monthly_default_rate = monthly_default_rate.sort_index()
print(monthly_default_rate)

# Cell 10: Compute log-returns for GARCH
log_returns = np.log(monthly_default_rate / monthly_default_rate.shift(1)).dropna() * 100

# Cell 11: Fit GARCH(1,1) model
garch_model = arch_model(log_returns, vol='Garch', p=1, q=1, dist='normal')
garch_result = garch_model.fit(disp='off')
print(garch_result.summary())

# Cell 12: 6-month volatility forecast
garch_forecast = garch_result.forecast(horizon=6)
forecast_variance = garch_forecast.variance.iloc[-1]
forecast_vol = np.sqrt(forecast_variance)
print('6-Month Conditional Volatility Forecast:')
print(forecast_vol)

# Cell 13: Visualize GARCH volatility
fig_garch = go.Figure()
fig_garch.add_trace(go.Scatter(x=list(range(1, 7)), y=forecast_vol.values, mode='lines+markers', name='Forecast Volatility'))
fig_garch.add_trace(go.Scatter(x=list(range(1, 7)), y=forecast_vol.values * 1.645, mode='lines', name='95% Upper Band', line=dict(dash='dash')))
fig_garch.add_trace(go.Scatter(x=list(range(1, 7)), y=forecast_vol.values * 0.355, mode='lines', name='95% Lower Band', line=dict(dash='dash')))
fig_garch.update_layout(title='GARCH(1,1) 6-Month Conditional Volatility Forecast', xaxis_title='Months Ahead', yaxis_title='Conditional Volatility (%)')
fig_garch.show()

# Cell 14: Compute Basel III Expected Loss
LGD = 0.45
df_all = pd.concat([df_train, df_val, df_test]).reset_index(drop=True)
all_X = pd.concat([X_train, X_val, X_test]).reset_index(drop=True)
df_all['PD'] = xgb_model.predict_proba(all_X)[:, 1]
df_all['EAD'] = df_all['amtcredit_sum'] if 'amtcredit_sum' in df_all.columns else df_all.filter(like='amt').iloc[:, 0]
df_all['EL'] = df_all['PD'] * LGD * df_all['EAD']

# Cell 15: Risk grade segmentation
pd_quantiles = pd.qcut(df_all['PD'], q=5, labels=['A', 'B', 'C', 'D', 'E'])
df_all['risk_grade'] = pd.Categorical(pd_quantiles)
el_by_grade = df_all.groupby('risk_grade').agg(
    total_EAD=('EAD', 'sum'),
    total_EL=('EL', 'sum'),
    avg_PD=('PD', 'mean'),
    count=('EL', 'count')
).reset_index()
el_by_grade['EL_rate'] = el_by_grade['total_EL'] / el_by_grade['total_EAD']
print(el_by_grade)

total_EL = df_all['EL'].sum()
total_EAD = df_all['EAD'].sum()
portfolio_EL_rate = total_EL / total_EAD
print(f'Total Portfolio EL: ${total_EL:,.0f}')
print(f'Portfolio EL Rate: {portfolio_EL_rate:.4%}')

el_summary = {'total_EAD': total_EAD, 'total_EL': total_EL, 'portfolio_EL_rate': portfolio_EL_rate}

You now have a complete risk quantification layer: a forward-looking volatility forecast and dollar-denominated Expected Loss segmented by grade. Next up, you'll bring all of this together into a polished executive dashboard that tells the full story to C-suite stakeholders.

Design & Deliver the Executive Dashboard

Your analytical engine is complete. You have a validated PD model, temporal stability scores, a volatility forecast, and Expected Loss segmented by risk grade.

Now you need to translate all of this into a format that a Chief Risk Officer can absorb in under 5 seconds. In this step, you will build a 4-panel executive dashboard using Plotly, export it as an interactive HTML file, and write a one-page Model Risk Summary documenting your findings.

In this step, get ready to:
  • Build a 4-panel dashboard with KPI cards, PSI stability chart, EL waterfall, and GARCH forecast.
  • Apply professional institutional-finance styling with a deep navy background and gold accents.
  • Export the dashboard as an interactive HTML and write a Model Risk Summary document.
Design the dashboard layout

Executive dashboards in credit risk follow a specific information hierarchy. KPIs go top-left for immediate scanning. Temporal charts go top-right to answer "is the model drifting?" The bottom row presents deeper analysis.

  • Create a new cell (Cell 16) in your notebook and add the dashboard imports, color palette, and subplot frame by pasting this code:
# Cell 16: Executive Dashboard - 4-panel layout
from plotly.subplots import make_subplots
import plotly.graph_objects as go

NAVY = '#1B2838'
GOLD = '#F5C518'
WHITE = '#FFFFFF'
GREEN = '#00C851'
AMBER = '#FF8800'
RED = '#FF4444'

fig = make_subplots(
    rows=2, cols=2,
    subplot_titles=(
        'Portfolio KPIs',
        'Model Stability (PSI Over Time)',
        'Expected Loss by Risk Grade',
        'GARCH Volatility Forecast'
    ),
    specs=[[{'type': 'table'}, {'type': 'scatter'}],
           [{'type': 'bar'}, {'type': 'scatter'}]]
)

What does this code do?

The color palette mirrors institutional finance reports (Bloomberg Terminal, Goldman Sachs research). Deep navy with gold accents signals professionalism.

The make_subplots call creates a 2x2 grid where each cell has a specified type. The 'table' type in position (1,1) hosts the KPI card. The remaining cells use 'scatter' and 'bar' for the chart panels.

  • Below the subplot frame, add the KPI Table panel:
# KPI Table
fig.add_trace(go.Table(
    header=dict(values=['KPI', 'Value'], fill_color=NAVY, font=dict(color=GOLD, size=13)),
    cells=dict(
        values=[
            ['Total Portfolio EAD', 'Portfolio EL Rate', 'Model Gini', 'Max PSI Score'],
            [f"${el_summary['total_EAD']:,.0f}", f"{el_summary['portfolio_EL_rate']:.2%}", f"{gini_val:.4f}", f"{psi_df['PSI'].max():.4f}"]
        ],
        fill_color=NAVY, font=dict(color=WHITE, size=12)
    )
), row=1, col=1)

What does the KPI table show?

This panel gives stakeholders the four numbers they care about most. Total Portfolio EAD is the dollar exposure. Portfolio EL Rate is the percentage of that exposure expected to default. Model Gini measures discriminatory power. Max PSI Score tells them whether the model is still trustworthy.

  • Now add the PSI stability chart in the top-right panel:
# PSI Chart
fig.add_trace(go.Scatter(x=psi_df['MONTH'], y=psi_df['PSI'], mode='lines+markers', name='PSI', line=dict(color=GOLD)), row=1, col=2)
fig.add_hline(y=0.1, line_dash='dash', line_color=GREEN, row=1, col=2)
fig.add_hline(y=0.25, line_dash='dash', line_color=RED, row=1, col=2)

What are the threshold lines?

The green dashed line at 0.1 marks the "stable" boundary. The red dashed line at 0.25 marks the "significant shift" boundary. Any monthly PSI value above the red line signals the model needs recalibration. This traffic-light system lets executives instantly see model health.

  • Add the bottom-left Expected Loss bar chart and bottom-right GARCH forecast panel:
# EL Waterfall by Grade
fig.add_trace(go.Bar(
    x=el_by_grade['risk_grade'].astype(str),
    y=el_by_grade['total_EL'],
    marker_color=GOLD,
    name='Expected Loss'
), row=2, col=1)

# GARCH Forecast
forecast_x = list(range(1, 7))
fig.add_trace(go.Scatter(x=forecast_x, y=forecast_vol.values, mode='lines+markers', name='Volatility', line=dict(color=GOLD)), row=2, col=2)
fig.add_trace(go.Scatter(x=forecast_x, y=forecast_vol.values * 1.645, mode='lines', name='95% Upper', line=dict(dash='dash', color=RED)), row=2, col=2)
fig.add_trace(go.Scatter(x=forecast_x, y=forecast_vol.values * 0.355, mode='lines', name='95% Lower', line=dict(dash='dash', color=GREEN)), row=2, col=2)

What do these panels show?

The EL bar chart shows how Expected Loss concentrates across risk grades (A through E). Grade E borrowers contribute the most loss despite being a smaller population. This tells portfolio managers exactly where to tighten underwriting.

The GARCH panel shows the 6-month conditional volatility forecast with 95% confidence bands. Widening bands signal increasing uncertainty about future default-rate behaviour.

Getting a ValueError about trace types?

Make sure your specs list matches the trace types exactly. A go.Table trace must go in a cell with {'type': 'table'} and go.Bar must go in a cell with {'type': 'bar'}.

help me fix a ValueError in my Plotly make_subplots specs configuration

Apply professional styling and export the dashboard

The dashboard panels are built. Now you need to apply the institutional-finance color scheme globally and export the result as an interactive HTML file that stakeholders can open in any browser.

  • Add the layout styling and export commands at the bottom of Cell 16:
fig.update_layout(
    paper_bgcolor=NAVY,
    plot_bgcolor=NAVY,
    font=dict(color=WHITE),
    title=dict(text='Credit Risk Intelligence Dashboard', font=dict(color=GOLD, size=20)),
    height=800
)
fig.write_html('executive_dashboard.html')
fig.show()
print('Dashboard exported to executive_dashboard.html')

What does this styling achieve?

Setting paper_bgcolor and plot_bgcolor to deep navy removes all white space and creates a unified canvas. The gold title and white body text ensure maximum contrast and scannability. Every element passes the 5-second test: a C-level executive can grasp the key message immediately.

  • Run Cell 16.
  • You should see a dark navy 4-panel dashboard rendered inline. The title "Credit Risk Intelligence Dashboard" appears in gold at the top. Each panel is populated with your model's actual data.
  • Confirm that executive_dashboard.html now exists in your project folder by checking the printed output message.

Dashboard appears but charts are blank?

Blank panels usually mean the referenced DataFrames (psi_df, el_by_grade, forecast_vol) are empty or the column names don't match. Re-run Cells 1-15 in order to ensure all variables are in memory.

help me debug why my Plotly dashboard panels are rendering blank despite having data in my notebook

✔️ Awesome, I've got everything!

Great. Make sure you have saved your notebook before moving on.

ⓧ I'd like to double check the full code

Here is the complete Cell 16 for comparison:

# Cell 16: Executive Dashboard - 4-panel layout
from plotly.subplots import make_subplots
import plotly.graph_objects as go

NAVY = '#1B2838'
GOLD = '#F5C518'
WHITE = '#FFFFFF'
GREEN = '#00C851'
AMBER = '#FF8800'
RED = '#FF4444'

fig = make_subplots(
    rows=2, cols=2,
    subplot_titles=(
        'Portfolio KPIs',
        'Model Stability (PSI Over Time)',
        'Expected Loss by Risk Grade',
        'GARCH Volatility Forecast'
    ),
    specs=[[{'type': 'table'}, {'type': 'scatter'}],
           [{'type': 'bar'}, {'type': 'scatter'}]]
)

# KPI Table
fig.add_trace(go.Table(
    header=dict(values=['KPI', 'Value'], fill_color=NAVY, font=dict(color=GOLD, size=13)),
    cells=dict(
        values=[
            ['Total Portfolio EAD', 'Portfolio EL Rate', 'Model Gini', 'Max PSI Score'],
            [f"${el_summary['total_EAD']:,.0f}", f"{el_summary['portfolio_EL_rate']:.2%}", f"{gini_val:.4f}", f"{psi_df['PSI'].max():.4f}"]
        ],
        fill_color=NAVY, font=dict(color=WHITE, size=12)
    )
), row=1, col=1)

# PSI Chart
fig.add_trace(go.Scatter(x=psi_df['MONTH'], y=psi_df['PSI'], mode='lines+markers', name='PSI', line=dict(color=GOLD)), row=1, col=2)
fig.add_hline(y=0.1, line_dash='dash', line_color=GREEN, row=1, col=2)
fig.add_hline(y=0.25, line_dash='dash', line_color=RED, row=1, col=2)

# EL Waterfall by Grade
fig.add_trace(go.Bar(
    x=el_by_grade['risk_grade'].astype(str),
    y=el_by_grade['total_EL'],
    marker_color=GOLD,
    name='Expected Loss'
), row=2, col=1)

# GARCH Forecast
forecast_x = list(range(1, 7))
fig.add_trace(go.Scatter(x=forecast_x, y=forecast_vol.values, mode='lines+markers', name='Volatility', line=dict(color=GOLD)), row=2, col=2)
fig.add_trace(go.Scatter(x=forecast_x, y=forecast_vol.values * 1.645, mode='lines', name='95% Upper', line=dict(dash='dash', color=RED)), row=2, col=2)
fig.add_trace(go.Scatter(x=forecast_x, y=forecast_vol.values * 0.355, mode='lines', name='95% Lower', line=dict(dash='dash', color=GREEN)), row=2, col=2)

fig.update_layout(
    paper_bgcolor=NAVY,
    plot_bgcolor=NAVY,
    font=dict(color=WHITE),
    title=dict(text='Credit Risk Intelligence Dashboard', font=dict(color=GOLD, size=20)),
    height=800
)
fig.write_html('executive_dashboard.html')
fig.show()
print('Dashboard exported to executive_dashboard.html')
Write the Model Risk Summary

The dashboard handles visual communication. You also need a written artifact that documents the model's performance, stability, and recommended governance cadence. Model Risk Management teams require this for audit trails.

  • Create a new file called model_risk_summary.md in your project folder.
  • Copy the following template into it, replacing the bracketed placeholders with the actual values printed in your notebook output from previous cells:
# Model Risk Summary

## Model Performance
- ROC-AUC: [auc_val]
- Gini Coefficient: [gini_val]
- KS Statistic: [ks_val]

## Stability Scores
- Max PSI: [max PSI value]
- PSI Status: Stable / Monitor / Degraded

## Top 5 Risk Drivers (SHAP)
1. [Feature 1]
2. [Feature 2]
3. [Feature 3]
4. [Feature 4]
5. [Feature 5]

## Expected Loss Summary
- Total Portfolio EAD: $[value]
- Total Portfolio EL: $[value]
- Portfolio EL Rate: [value]%

## Recommended Monitoring Frequency
- PSI: Monthly
- Model recalibration trigger: PSI > 0.25
- GARCH volatility review: Quarterly

How do I fill in the placeholders?

Each bracketed value corresponds to a variable already printed in your notebook. Copy auc_val and gini_val from your Step 3 output. The PSI max is the value shown in your KPI table. The top 5 risk drivers come from the SHAP beeswarm plot you generated earlier.

For PSI Status, use the thresholds: if your Max PSI is below 0.1, write "Stable". Between 0.1 and 0.25, write "Monitor". Above 0.25, write "Degraded".

  • Save model_risk_summary.md.
  • Verify you now have two new files in your project folder: executive_dashboard.html and model_risk_summary.md.

Why write a separate Model Risk Summary?

Dashboards show the current state. The Model Risk Summary is a governance document that lives alongside the model in production. It tells the next analyst (or auditor) what the model's baseline performance is, what stability thresholds trigger a review, and who the top risk drivers are. Banks require this documentation for regulatory compliance.

Not sure which values to use?

Re-run your notebook cells in order. Cell 8 prints auc_val, gini_val, and ks_val. Cell 15 prints total_EL and portfolio_EL_rate. Your SHAP output from Step 3 lists the top 5 features by importance.

help me locate the metric values I need for my Model Risk Summary document

Secret mission

Build a Macro-Augmented Early Warning System

Your PD model and GARCH layer quantify current risk. But what if you could predict portfolio stress before it shows up in default rates? In this Secret Mission, you'll integrate macroeconomic indicators from FRED (unemployment rate, federal funds rate, consumer sentiment) to build an Early Warning System that flags portfolio stress 2-3 months before defaults spike.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • Python virtual environment (venv/) with all installed packages (pandas, scikit-learn, XGBoost, arch, SHAP, Plotly, hmmlearn, etc.).
  • Downloaded Kaggle dataset stored in the data/ folder (Home Credit Credit Risk Model Stability dataset and macro indicators).
  • Generated project files: credit_risk_notebook.ipynb, executive_dashboard.html, ews_panel.html, and model_risk_summary.md.

Keep everything running

No action needed. Choose this if you're still actively building or plan to extend the project further.

  • All files remain on your local machine at no cost.
  • Your virtual environment stays ready. Reactivate it anytime to continue working.
  • The Kaggle dataset and macro indicators stay in your data/ folder for future analysis.

Pause - I'll come back to this later

There are no running services to pause. Everything in this project is stored as local files.

  • If you have a Jupyter Notebook server running, stop it by pressing Ctrl+C in the terminal where it's running.
  • Your files, virtual environment, and dataset will remain intact whenever you come back.

Delete - I don't want to use this again

Remove all project resources and start fresh. This frees up disk space used by the dataset and virtual environment.

  • If you have a Jupyter Notebook server running, stop it by pressing Ctrl+C in the terminal.

On macOS/Linux, navigate to your project's parent directory and remove the project folder by running:

cd ~/Desktop
rm -rf credit-risk-analytics

On Windows, open PowerShell, navigate to the Desktop, and remove the project folder by running:

cd ~\Desktop
Remove-Item -Recurse -Force credit-risk-analytics
  • This removes the virtual environment, all installed packages, the Kaggle dataset, generated dashboards, the notebook, and the model risk summary.
  • Confirm deletion by running ls (macOS/Linux) or dir (Windows). You should no longer see the credit-risk-analytics folder listed.

Nice Work!

Nice Work!

Congratulations. You have built an institutional-grade credit risk analytics system that demonstrates the exact skills Model Risk Management teams screen for at major financial institutions.

From temporal model validation to regulatory-aligned risk quantification to C-level storytelling through data, this project covers the full stack of production credit risk analytics.

You've learned how to:

  • Build a temporally-validated PD model with Weight of Evidence feature engineering and Population Stability Index monitoring across rolling vintage windows.
  • Implement GARCH(1,1) volatility modeling and compute Basel III-aligned Expected Loss (EL = PD x LGD x EAD) segmented by risk grade.
  • Design a professional executive dashboard with SHAP explainability reports and temporal stability charts for C-level stakeholders.
  • Build a macro-augmented Early Warning System with Granger causality testing and Hidden Markov Model regime detection that predicts portfolio stress 2-3 months ahead.

Ready to quiz yourself?