Build a Credit Risk Scorecard

Train a loan default model with SHAP explainability and credit scoring.

Introduction

30 Second Summary

Every time someone applies for a loan, a bank has to answer one question: will this person pay us back? Behind the scenes, a machine learning model makes that decision in milliseconds, using data about the applicant's income, employment history, and credit behavior.

In this project, you will build the exact model that answers that question. You'll train a credit risk scorecard on 32,000+ real loan applications, evaluate it with banking-industry metrics, and generate explainability reports showing why each decision was made.

What You'll Build

Run a single notebook cell and watch it score a loan applicant in real time: a default probability, a SHAP waterfall chart explaining the key risk factors, and a final risk grade of Approve, Review, or Reject.

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

  • A notebook that loads 32,000+ real loan applications and visualizes risk patterns through distribution charts and a correlation heatmap.
  • A trained logistic regression model that predicts loan default with 85%+ AUC-ROC accuracy, evaluated with the same metrics banks use in production.
  • SHAP explainability charts that show exactly which factors drove each individual prediction. This is what regulators require banks to produce for every rejected applicant.
  • Secret Mission: Convert your model's raw probabilities into a traditional credit score on the 300-850 scale, with risk tiers that output Approve, Review, or Reject.

Are there any prerequisites?

You only need basic Python syntax (variables, loops, functions). No banking knowledge or machine learning experience is required.

Before We Start

Before you write a single line of code, take a moment to lock in what you're about to build and why it matters.

Credit risk modeling is the single most common data science task in banking. Every time a bank decides whether to approve or reject a loan application, a model like the one you're about to build is making that call.

Set Up Your Environment

Before you can work with loan data, you need a dedicated Python environment with the right libraries installed. This keeps your credit risk project's dependencies isolated from anything else on your machine.

In this step, you'll create a fresh conda environment with Python 3.12, install the data science libraries you need, and launch a Jupyter Notebook where you'll build the entire scorecard.

In this step, get ready to:
  • Create a conda environment with Python 3.12 and activate it.
  • Install the required libraries (pandas, scikit-learn, matplotlib, seaborn, shap, jupyter).
  • Launch Jupyter Notebook and create credit_risk_scorecard.ipynb.
Create a conda environment with Python 3.12

The SHAP library (which you'll use later for model explainability) requires Python 3.12 or higher. Creating a dedicated conda environment guarantees you have the correct version without affecting anything else on your system.

  • Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Create a new conda environment called credit_risk with Python 3.12 by running this command:
conda create -n credit_risk python=3.12

What does this command do?

  • conda create tells conda to build a brand new isolated environment.
  • -n credit_risk names it credit_risk so you can activate it later by name.
  • python=3.12 pins the environment to Python 3.12 specifically.

Conda will show a list of packages it plans to install and ask you to confirm.

  • When you see Proceed ([y]/n)?, type y and press Enter.
  • Once it finishes, activate the environment by running:
conda activate credit_risk

Your terminal prompt now shows (credit_risk) at the beginning, confirming you're inside the new environment.

Don't see (credit_risk) in your prompt?

  • Make sure you typed conda activate credit_risk exactly (no spaces in the name).
  • If you see a message about initialising your shell, run conda init, close and reopen your terminal, then try activating again.

Why isn't my conda environment activating?

Install the required libraries

With your environment active, you can install the data science libraries this project needs.

  • Install all the libraries by running this command:
pip install pandas scikit-learn matplotlib seaborn shap jupyter

What are these libraries?

  • pandas loads and manipulates tabular data (your loan applications).
  • scikit-learn provides machine learning algorithms including logistic regression.
  • matplotlib and seaborn create charts and visualisations.
  • shap generates model explainability reports (required by banking regulators).
  • jupyter runs the interactive notebook interface where you'll write all your code.

The install takes a minute or two. You'll see packages downloading and a final confirmation when it completes.

Before you run the next command, what do you expect to see if all packages installed correctly?

  • Verify everything installed by running:
python -c "import pandas; import sklearn; import shap; print('All libraries ready!')"

You should see All libraries ready! printed in your terminal.

Seeing a ModuleNotFoundError?

  • Confirm your prompt still shows (credit_risk). If it shows (base) or nothing, run conda activate credit_risk again before retrying the pip install.
  • If one specific library failed, try installing it individually (e.g. pip install shap) and check the error output for details.

Help me fix a ModuleNotFoundError when importing my credit risk libraries.

Launch Jupyter Notebook and create your notebook

Jupyter Notebook gives you an interactive environment where you can run code in small cells and see results immediately. This is how data scientists build models in practice.

  • Move to your Desktop and create a project folder by running these commands:
cd ~/Desktop
mkdir credit-risk-scorecard
cd credit-risk-scorecard
  • Confirm you're in the new folder by running:
pwd

You should see a path ending in credit-risk-scorecard.

  • Launch Jupyter Notebook by running:
jupyter notebook

What happens when you run this?

Jupyter starts a local server and opens a new browser tab showing your project folder. The terminal will show log messages and remain occupied. Leave it running.

Your browser should now show the Jupyter file browser with an empty folder.

  • In the Jupyter browser tab, click the New button in the top right corner.
  • Select Python 3 (ipykernel) from the dropdown.
  • A new notebook opens with the title Untitled. Click on the word Untitled at the top of the page.
  • In the rename dialog, type credit_risk_scorecard and click Rename.

Your notebook is now saved as credit_risk_scorecard.ipynb and ready to use.

Browser didn't open automatically?

  • Look in your terminal output for a URL that starts with http://localhost:8888. Copy and paste it into your browser manually.
  • If you see a token in the URL, include the full URL with the token when pasting.

Help me open Jupyter Notebook in my browser.

Your environment is ready and your notebook is waiting. Next up, you'll load 32,000+ real loan applications and start exploring what makes someone a credit risk.

Load and Explore the Loan Data

In Step 1, you set up your environment with all the libraries you need. Now it is time to meet the data.

Before building any model, you need to understand what you are working with. What does a risky borrower look like in the data? Which columns capture income, debt, and repayment history? Spotting patterns at this stage tells you where the risk signals live.

In this step, get ready to:
  • Download a real loan dataset from Kaggle and load it into a pandas DataFrame.
  • Visualize default rates, income distributions, and feature correlations.
  • Split the data into training and test sets for model building.
Download and load the loan dataset

You will use a real-world credit risk dataset hosted on Kaggle. It contains over 32,000 loan applications with features like income, loan amount, interest rate, and whether the borrower defaulted.

  • Open your browser and go to the Credit Risk Dataset page on Kaggle:
  • https://www.kaggle.com/datasets/laotse/credit-risk-dataset
  • Sign in with your Kaggle account (or create a free one if you do not have one yet).
  • Click the Download button near the top of the page to download the ZIP file.
  • Extract the ZIP file. You will find a file called credit_risk_dataset.csv inside.
  • Move credit_risk_dataset.csv into the same folder where your Jupyter Notebook is running (the folder you launched jupyter notebook from).

Find your notebook folder

Check the URL bar in Jupyter. The file browser shows the folder Jupyter is serving from. Place credit_risk_dataset.csv there so your notebook can find it.

Now you will write your first notebook cells. Each code block below is a separate Jupyter cell.

  • In your credit_risk_scorecard.ipynb notebook, create a new cell and paste the following imports:
# Cell 1: Imports
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.metrics import (
    roc_auc_score,
    classification_report,
    roc_curve,
)
from sklearn.impute import SimpleImputer
import shap

print("All libraries imported successfully!")

What does this code do?

  • pandas and numpy handle data loading and numeric operations.
  • matplotlib and seaborn create charts and visualizations.
  • The sklearn imports bring in tools for splitting data, training models, scaling features, and evaluating performance.
  • shap provides model explainability so you can show WHY the model made a decision.
  • Run the cell by pressing Shift+Enter.

You should see All libraries imported successfully! printed below the cell.

Seeing a ModuleNotFoundError?

This means a library is not installed in your active conda environment. Make sure your credit_risk environment is active (you should see it in your terminal prompt). Re-run the pip install command from Step 1.

help me fix a ModuleNotFoundError in my Jupyter notebook

  • Create a new cell below and paste this code to load the dataset:
# Cell 2: Load the data
df = pd.read_csv("credit_risk_dataset.csv")
print(f"Dataset shape: {df.shape}")
print(f"\nColumn names:\n{df.columns.tolist()}")
print(f"\nFirst 5 rows:")
df.head()

What does this code do?

  • pd.read_csv() reads the CSV file into a DataFrame, which is like a spreadsheet in Python.
  • df.shape tells you how many rows (loan applications) and columns (features) you have.
  • df.head() displays the first 5 rows so you can see what actual applicant data looks like.
  • Run the cell with Shift+Enter.

You should see output showing a shape of roughly (32581, 12) and a table of the first 5 rows.

Seeing FileNotFoundError?

This means Python cannot find credit_risk_dataset.csv in the current folder. Double check that the CSV file is in the same directory as your notebook. You can verify by running !ls in a cell (macOS/Linux) or !dir (Windows) to list the files.

help me fix a FileNotFoundError for my CSV in Jupyter

What do these columns mean in banking?

  • person_income = the applicant's annual income.
  • loan_percent_income = the debt-to-income ratio. A value of 0.30 means 30% of income goes to loan payments.
  • cb_person_cred_hist_length = credit history length in years. Longer history means the bank has more data on you.
  • loan_status = the target variable. 1 means the borrower defaulted (failed to repay). 0 means they repaid.

These are the features a bank uses to decide whether to approve your loan. Your model will learn which combinations predict default.

Explore the data with statistics and charts

Now that the data is loaded, you need to understand its shape. How balanced is the target variable? Are there missing values? Which features correlate with default?

  • Create a new cell and paste this code to generate summary statistics:
# Cell 3: Understand the data
print("=== Dataset Info ===")
print(f"Total records: {len(df)}")
print(f"\nTarget variable (loan_status) distribution:")
print(df["loan_status"].value_counts(normalize=True).round(3))
print(f"\nMissing values:")
print(df.isnull().sum()[df.isnull().sum() > 0])
print(f"\nBasic statistics:")
df.describe()

What does this code do?

  • value_counts(normalize=True) shows what percentage of borrowers defaulted vs repaid. You should see roughly 78% repaid (0) and 22% defaulted (1).
  • isnull().sum() reveals which columns have missing data. Missing values will need handling before training.
  • describe() shows the mean, min, max, and quartiles for every numeric column.
  • Run the cell with Shift+Enter.

You should see the target distribution showing approximately 0.782 for class 0 (repaid) and 0.218 for class 1 (default). You will also see missing values in a couple of columns.

  • Create a new cell and paste this code to visualize key distributions:
# Cell 4: Visualize the data
fig, axes = plt.subplots(2, 2, figsize=(12, 10))

# Target distribution
df["loan_status"].value_counts().plot(
    kind="bar", ax=axes[0, 0], color=["green", "red"]
)
axes[0, 0].set_title("Loan Status Distribution")
axes[0, 0].set_xticklabels(["Repaid (0)", "Default (1)"], rotation=0)

# Income distribution
df["person_income"].clip(upper=200000).hist(bins=50, ax=axes[0, 1])
axes[0, 1].set_title("Annual Income Distribution")
axes[0, 1].set_xlabel("Income ($)")

# Debt-to-income ratio
df["loan_percent_income"].hist(bins=50, ax=axes[1, 0])
axes[1, 0].set_title("Debt-to-Income Ratio (loan_percent_income)")
axes[1, 0].set_xlabel("Loan as % of Income")

# Default rate by loan grade
default_by_grade = df.groupby("loan_grade")["loan_status"].mean()
default_by_grade.plot(kind="bar", ax=axes[1, 1], color="coral")
axes[1, 1].set_title("Default Rate by Loan Grade")
axes[1, 1].set_ylabel("Default Rate")
axes[1, 1].set_xticklabels(axes[1, 1].get_xticklabels(), rotation=0)

plt.tight_layout()
plt.show()

What do these charts tell you?

  • Top-left: the class imbalance. Far more people repay than default. Your model needs to catch the smaller default group.
  • Top-right: most applicants earn under $100,000. A few extreme outliers exist above $200,000.
  • Bottom-left: debt-to-income ratio clusters between 0 and 0.3. Higher values signal more risk.
  • Bottom-right: default rate climbs sharply from grade A to grade G. This confirms loan grade is a strong risk signal.
  • Run the cell with Shift+Enter. You should see a 2x2 grid of charts appear below the cell.
  • Create one more cell and paste this code for the correlation heatmap:
# Cell 5: Correlation heatmap
numeric_cols = df.select_dtypes(include=[np.number]).columns.tolist()
plt.figure(figsize=(10, 8))
sns.heatmap(df[numeric_cols].corr(), annot=True, cmap="RdBu_r", center=0, fmt=".2f")
plt.title("Feature Correlation Heatmap")
plt.tight_layout()
plt.show()

What does this heatmap show?

Each cell shows how strongly two features move together. Values close to +1 or -1 indicate strong correlation. Look for features that correlate with loan_status (the target). You should see loan_percent_income and loan_int_rate showing moderate positive correlation with default.

  • Run the cell with Shift+Enter. A color-coded heatmap appears showing correlations between all numeric features.
Split into training and test sets

In real banking, you build a model on historical loan data, then test it on future unseen applicants. The train/test split simulates this exact workflow.

  • Create a new cell and paste this code to split the data:
# Cell 6: Train/test split
X = df.drop("loan_status", axis=1)
y = df["loan_status"]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42, stratify=y
)
print(f"Training set: {X_train.shape[0]} records")
print(f"Test set: {X_test.shape[0]} records")
print(f"Default rate (train): {y_train.mean():.3f}")
print(f"Default rate (test): {y_test.mean():.3f}")

What does this code do?

  • X holds all the features (everything except the target). y holds just the target column (loan_status).
  • test_size=0.2 reserves 20% of the data for testing. The model never sees these during training.
  • stratify=y ensures both train and test sets have the same default rate (~22%). Without this, random chance could give you a test set with a very different balance.
  • random_state=42 makes the split reproducible. Everyone running this notebook gets the same split.

Before you run this cell, what do you expect the default rate to be in both training and test sets? Think about what stratify=y guarantees.

  • Run the cell with Shift+Enter.

You should see approximately 26,065 training records and 6,516 test records. Both default rates should be around 0.218, confirming the stratified split preserved the class balance.

Default rates don't match?

If your train and test default rates differ by more than 0.001, you may have accidentally removed the stratify=y argument. Double check that your train_test_split call matches the code above exactly.

help me fix my train/test split so both sets have the same default rate

✔️ Awesome, I've got everything!

Great. Double check that you have 6 cells in your notebook (Imports, Load, Understand, Visualize, Heatmap, Split) and that each one ran without errors.

ⓧ I'd like to double check the full code

Scroll through your notebook and confirm you have these 6 cells in order:

  • Cell 1: Imports (prints "All libraries imported successfully!").
  • Cell 2: Load the data (prints dataset shape and first 5 rows).
  • Cell 3: Understand the data (prints target distribution, missing values, and statistics).
  • Cell 4: Visualize the data (shows a 2x2 grid of bar charts and histograms).
  • Cell 5: Correlation heatmap (shows a color-coded correlation matrix).
  • Cell 6: Train/test split (prints training and test set sizes and default rates).

If any cell is missing or shows an error, re-paste the code from the blocks above and run it again.

You now have a clear picture of the data. Roughly 22% of applicants default, income clusters below $100k, and loan grade strongly predicts risk. Next up, you will train your first model on this data and see how it performs.

Train Your First Model (The Naive Attempt)

You have 32,000+ loan records loaded, explored, and split into training and test sets. Now comes the moment of truth: can you build a model that predicts which applicants will default?

In this step, you'll take the most direct approach possible. Grab the numeric columns, fill in missing values with a quick fix, and train a logistic regression model. No fancy feature engineering, no careful data cleaning. Just raw data in, predictions out.

In this step, get ready to:
  • Train a logistic regression model on numeric features only.
  • Evaluate the model with AUC-ROC and a classification report.
Select numeric features and train a baseline model

Your dataset has both numeric columns (income, age, loan amount) and categorical columns (home ownership, loan intent, loan grade). Logistic regression needs numeric input, so the quickest path is to ignore the categorical columns entirely and just use the numbers.

  • Create a new cell in your notebook (below Cell 6) and add this code to select numeric features, fill missing values, and train the model:
# Use only numeric features, fill missing values with median
numeric_features_naive = [
    "person_age",
    "person_income",
    "person_emp_length",
    "loan_amnt",
    "loan_int_rate",
    "loan_percent_income",
    "cb_person_cred_hist_length",
]

X_train_naive = X_train[numeric_features_naive].copy()
X_test_naive = X_test[numeric_features_naive].copy()

# Fill missing values with median
X_train_naive = X_train_naive.fillna(X_train_naive.median())
X_test_naive = X_test_naive.fillna(X_train_naive.median())

# Train a basic logistic regression
lr_naive = LogisticRegression(max_iter=1000, random_state=42)
lr_naive.fit(X_train_naive, y_train)

What does this code do?

You're deliberately cutting corners here to see what happens.

  • numeric_features_naive picks only 7 of your 12 columns. All categorical information (home ownership, loan intent, loan grade) is thrown away.
  • .fillna(median()) replaces missing values with the median of that column. Quick fix, but it doesn't address outliers or data quality.
  • LogisticRegression(max_iter=1000) trains a binary classifier that outputs a probability of default for each applicant.
Evaluate the model on unseen applicants

AUC-ROC measures how well your model separates defaulters from non-defaulters. A score of 0.5 means the model is guessing randomly. A score of 1.0 means perfect separation. Banks typically require AUC above 0.80 for a production scorecard.

  • Add this evaluation code directly below the training code in the same cell:
# Evaluate
y_pred_proba_naive = lr_naive.predict_proba(X_test_naive)[:, 1]
auc_naive = roc_auc_score(y_test, y_pred_proba_naive)
gini_naive = 2 * auc_naive - 1

print("=== NAIVE MODEL RESULTS ===")
print(f"AUC-ROC: {auc_naive:.4f}")
print(f"Gini Coefficient: {gini_naive:.4f}")
print(f"\nClassification Report:")
y_pred_naive = lr_naive.predict(X_test_naive)
print(classification_report(y_test, y_pred_naive, target_names=["Repaid", "Default"]))
print("\nVerdict: This model is mediocre. Banks need AUC > 0.80.")
print("Problem: We ignored categorical features and didn't engineer any risk signals.")

What does this code do?

  • predict_proba(X_test_naive)[:, 1] gets the model's predicted probability of default for each test applicant.
  • roc_auc_score compares those probabilities against the actual outcomes to produce the AUC-ROC score.
  • The Gini coefficient is simply 2 * AUC - 1. It's the metric banks report most often when describing model power.
  • classification_report breaks down precision, recall, and F1-score for each class (Repaid vs Default). Recall on the Default class tells you what percentage of actual defaulters the model catches.

Before you run this cell, think: you trained on 7 raw numeric features with no cleaning. Do you think this model will hit the 0.80 AUC threshold banks require?

  • Run Cell 7 by pressing Shift+Enter.

You'll see an AUC-ROC score around 0.60-0.70. That's barely better than a coin flip. Look at the classification report and find the recall value for the Default class. It catches only about 50-60% of people who will actually default.

In banking terms, this means you're approving loans to people who won't pay you back. A bank would never ship this model.

Why is this result expected?

The model performed poorly because you deliberately ignored critical information. Loan grade (A through G) is one of the strongest default predictors, but it's categorical so you skipped it. Home ownership status and loan intent also carry risk signals that the model never saw.

You also fed in raw values without removing outliers (ages over 100, impossibly long employment lengths). The model learned from noisy data with incomplete features. This is exactly the gap you'll fix in the next step.

Seeing an error instead of results?

  • If you see NameError: name 'X_train' is not defined, go back and run Cell 6 (the train/test split cell) first.
  • If you see NameError: name 'LogisticRegression' is not defined, go back and run Cell 1 (the imports cell) first.
  • If you see a KeyError on a column name, double-check that the feature names in your list match the dataset columns exactly (they are case-sensitive).

Still stuck? Help me debug my naive model cell in Jupyter Notebook.

✔️ Awesome, I've got everything!

Great. Make sure your Cell 7 is saved and shows the AUC-ROC and classification report output below it.

ⓧ I'd like to double check the full code

Confirm your Cell 7 contains both parts in this order:

  • The feature selection, imputation, and training section (starting with numeric_features_naive = [...] and ending with lr_naive.fit(X_train_naive, y_train)).
  • The evaluation section (starting with y_pred_proba_naive = lr_naive.predict_proba(...) and ending with the two print() statements about the verdict).

Scroll up and confirm both chunks are present in order. Run the cell again with Shift+Enter to verify you see the AUC-ROC score and classification report.

Your first model is trained, and the results speak for themselves. An AUC below 0.80 would get rejected in any banking model review. Next up, you'll fix what went wrong by engineering real banking features and properly cleaning the data.

Engineer Banking Features and Clean Properly

Your naive model from the last step caught only around 60-70% of defaults. That is not good enough for a bank.

The problem is clear: you fed raw numbers into the model and completely ignored the categorical columns (like loan grade and home ownership). Banks never do this. They engineer features that combine signals, remove unrealistic outliers, and properly encode all the available data.

In this step, get ready to:
  • Clean the data by removing unrealistic outliers.
  • Engineer new risk features that banks actually use.
  • Build a preprocessing pipeline that handles all feature types.
Clean the data and remove outliers

Real-world data is messy. Your dataset contains people listed as 120 years old or earning millions per year. These outliers distort the model's understanding of normal risk.

  • Create a new cell in your notebook (Cell 8) and paste the following code to clean the data and re-split it:
# Cell 8: Data cleaning - remove outliers
print("=== DATA CLEANING ===")
print(f"Records before cleaning: {len(df)}")

df_clean = df.copy()

# Remove unrealistic values
df_clean = df_clean[df_clean["person_age"] <= 100]
df_clean = df_clean[df_clean["person_emp_length"] <= 60]
df_clean = df_clean[df_clean["person_income"] <= 500000]

print(f"Records after cleaning: {len(df_clean)}")
print(f"Removed {len(df) - len(df_clean)} outlier records")

# Re-split with clean data
X_clean = df_clean.drop("loan_status", axis=1)
y_clean = df_clean["loan_status"]

X_train_c, X_test_c, y_train_c, y_test_c = train_test_split(
    X_clean, y_clean, test_size=0.2, random_state=42, stratify=y_clean
)

What does this code do?

  • Filters out rows where person_age exceeds 100, person_emp_length exceeds 60 years, or person_income exceeds $500,000. These are data entry errors or extreme outliers.
  • Re-splits the cleaned data into training (80%) and test (20%) sets so you are now working only with realistic applicants.
  • The stratify=y_clean parameter ensures both splits have the same proportion of defaults.
  • Run the cell.

You should see the record count drop slightly as the outliers are removed. The output prints both the before and after counts.

Seeing a NameError?

Make sure you have already run all previous cells (Cells 1-7) so that df and train_test_split are defined.

Help me debug this error

Engineer banking risk features

Banks do not just hand raw columns to a model. They combine columns to create new signals that better capture risk. For example, knowing someone earns $50,000 is useful, but knowing their loan is 80% of their income is far more predictive of default.

  • Create a new cell (Cell 9) and paste the following feature engineering code:
# Cell 9: Feature engineering
def engineer_features(df_input):
    """Engineer banking risk features."""
    df_feat = df_input.copy()

    # Income-to-loan ratio: how many times income covers the loan
    df_feat["income_to_loan_ratio"] = df_feat["person_income"] / (
        df_feat["loan_amnt"] + 1
    )

    # Grade-rate interaction: combines loan grade risk with interest rate
    grade_map = {"A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7}
    df_feat["grade_numeric"] = df_feat["loan_grade"].map(grade_map)
    df_feat["grade_rate_interaction"] = (
        df_feat["grade_numeric"] * df_feat["loan_int_rate"]
    )

    return df_feat


X_train_feat = engineer_features(X_train_c)
X_test_feat = engineer_features(X_test_c)

print(f"Features before engineering: {X_train_c.shape[1]}")
print(f"Features after engineering: {X_train_feat.shape[1]}")
print(f"\nNew features added: income_to_loan_ratio, grade_numeric, grade_rate_interaction")

What do these new features mean?

  • income_to_loan_ratio tells you how many times the applicant's income covers their loan amount. A ratio of 2 means they earn double what they borrowed. Higher is safer.
  • grade_numeric converts the letter grade (A through G) into a number so the model can use it mathematically.
  • grade_rate_interaction multiplies the grade number by the interest rate. A high-risk grade combined with a high interest rate is a strong signal of potential default.
  • Run the cell.

You should see the feature count increase from the original column count. The print output confirms your three new features were added.

Seeing fewer features than expected?

Make sure Cell 8 ran successfully first. The X_train_c and X_test_c variables are created in the cleaning cell. If they do not exist, this cell will fail.

Help me debug this issue

Build the preprocessing pipeline

You now have numeric features and categorical features that need different treatment. Numeric features need scaling. Categorical features need one-hot encoding. A ColumnTransformer lets you apply different transformations to different columns in one clean step.

  • Create a new cell (Cell 10) and paste the following code to define your feature lists:
# Cell 10: Build the preprocessing pipeline
# Define feature types
numeric_features = [
    "person_age",
    "person_income",
    "person_emp_length",
    "loan_amnt",
    "loan_int_rate",
    "loan_percent_income",
    "cb_person_cred_hist_length",
    "income_to_loan_ratio",
    "grade_numeric",
    "grade_rate_interaction",
]

categorical_features = [
    "person_home_ownership",
    "loan_intent",
    "loan_grade",
    "cb_person_default_on_file",
]

What does this code do?

  • numeric_features lists all 10 numeric columns (including your 3 new engineered ones).
  • categorical_features lists the 4 text-based columns that the naive model completely ignored.
  • Now add the following code directly below in the same cell to build the transformers, assemble the pipeline, and run it:
# Build preprocessing pipeline
numeric_transformer = Pipeline(
    steps=[
        ("imputer", SimpleImputer(strategy="median")),
        ("scaler", StandardScaler()),
    ]
)

categorical_transformer = Pipeline(
    steps=[
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("onehot", OneHotEncoder(drop="first", sparse_output=False, handle_unknown="ignore")),
    ]
)

preprocessor = ColumnTransformer(
    transformers=[
        ("num", numeric_transformer, numeric_features),
        ("cat", categorical_transformer, categorical_features),
    ]
)

# Fit and transform
X_train_processed = preprocessor.fit_transform(X_train_feat)
X_test_processed = preprocessor.transform(X_test_feat)

print(f"Processed feature matrix shape: {X_train_processed.shape}")
print(f"(Original had {X_train_c.shape[1]} columns, now we have {X_train_processed.shape[1]} after encoding)")

What does this code do?

  • numeric_transformer fills missing numbers with the median, then scales everything to the same range using StandardScaler.
  • categorical_transformer fills missing categories with the most common value, then converts each category into binary columns using OneHotEncoder.
  • preprocessor combines both transformers into one object that knows which columns get which treatment.
  • fit_transform learns the scaling parameters from the training data and transforms it in one step. transform applies the same learned parameters to the test data to prevent data leakage.

Before you run this cell, think about it: do you expect more or fewer columns than the original dataset had?

  • Run the cell.

You should see the processed feature matrix has significantly more columns than the raw data. The one-hot encoding expanded each categorical column into multiple binary columns.

Seeing a KeyError or ValueError?

Check that Cell 9 ran without errors. The X_train_feat variable must contain the engineered features (like income_to_loan_ratio) before the pipeline can reference them.

Also confirm the feature names in numeric_features and categorical_features match your DataFrame columns exactly. Typos in column names cause KeyErrors.

Help me fix my preprocessing pipeline

✔️ Awesome, I've got everything!

Great. Double check you have saved your notebook with all three new cells (8, 9, and 10) visible.

ⓧ I'd like to double check the full code

Scroll through your notebook and confirm these cells are present in order:

  • Cell 8: Data cleaning. Removes outliers and re-splits into X_train_c / X_test_c.
  • Cell 9: Feature engineering. Defines engineer_features() and creates X_train_feat / X_test_feat.
  • Cell 10: Preprocessing pipeline. Defines numeric_features, categorical_features, builds the preprocessor, and produces X_train_processed / X_test_processed.

Your data is now clean, enriched with banking-grade features, and fully preprocessed. In the next step, you will retrain the model on this improved data and see the dramatic jump in accuracy. You will also add SHAP explainability so the model can justify every decision it makes.

Retrain, Evaluate, and Explain with SHAP

In the last step, you cleaned the data, encoded categorical features, and engineered banking risk signals like income_to_loan_ratio. Your processed feature matrix is ready. Now it's time to find out whether all that work actually paid off.

You'll retrain the logistic regression on your improved features and measure the jump in accuracy. You'll also add SHAP explainability, which banking regulators require so that rejected applicants can understand WHY they were declined.

In this step, get ready to:
  • Retrain the model and compare AUC-ROC to the naive baseline.
  • Generate a global SHAP plot showing which features drive risk predictions.
  • Explain one individual applicant's prediction with a SHAP waterfall chart.
Retrain and evaluate the improved model

Your naive model from Step 3 scored an AUC-ROC around 0.60-0.70. Banks need AUC above 0.80 before they'll put a model into production. Before you run the retrained model, think about this: do you expect the cleaned data and engineered features to clear that 0.80 bar?

  • In your notebook, create a new cell below Cell 10 and paste the following code to train the improved model:
# Cell 11: Train the improved model
lr_improved = LogisticRegression(max_iter=1000, random_state=42, C=1.0)
lr_improved.fit(X_train_processed, y_train_c)

# Evaluate
y_pred_proba_improved = lr_improved.predict_proba(X_test_processed)[:, 1]
auc_improved = roc_auc_score(y_test_c, y_pred_proba_improved)
gini_improved = 2 * auc_improved - 1

print("=== IMPROVED MODEL RESULTS ===")
print(f"AUC-ROC: {auc_improved:.4f}")
print(f"Gini Coefficient: {gini_improved:.4f}")
print(f"\n--- Comparison ---")
print(f"Naive model AUC:    {auc_naive:.4f}")
print(f"Improved model AUC: {auc_improved:.4f}")
print(f"Improvement: +{(auc_improved - auc_naive):.4f}")
print(f"\nClassification Report:")
y_pred_improved = lr_improved.predict(X_test_processed)
print(
    classification_report(
        y_test_c, y_pred_improved, target_names=["Repaid", "Default"]
    )
)

What does this code do?

  • LogisticRegression(max_iter=1000, random_state=42, C=1.0) creates a logistic regression model with regularization strength C=1.0, which balances between fitting the training data and generalizing to new applicants.
  • predict_proba gives you the probability of default for each applicant, not just a yes/no prediction. Banks use these probabilities for risk scoring.
  • The Gini coefficient (2 * AUC - 1) is how banks report model discriminatory power. A Gini above 0.40 is considered production-ready.
  • The comparison block prints both the naive and improved AUC side by side so you can see exactly how much your feature engineering helped.
  • Run Cell 11 by pressing Shift+Enter.

You should see the AUC-ROC jump to 0.85 or higher. Compare that to the naive model's score in the printout. The classification report also shows improved recall for the Default class, meaning the model catches more risky applicants.

Seeing a NameError for auc_naive?

This means Cell 7 (the naive model) wasn't run in this session. Go back and run Cells 1 through 10 in order, then run Cell 11 again.

Help me debug my NameError in the credit risk notebook.

The AUC number is great, but a visual makes the improvement undeniable. An ROC curve plots the tradeoff between catching defaults (True Positive Rate) and incorrectly flagging good applicants (False Positive Rate).

  • Create a new cell below and paste this code to plot the ROC curve:
# Cell 12: Plot ROC curve
fpr, tpr, thresholds = roc_curve(y_test_c, y_pred_proba_improved)

plt.figure(figsize=(8, 6))
plt.plot(fpr, tpr, color="blue", lw=2, label=f"Improved Model (AUC = {auc_improved:.3f})")
plt.plot([0, 1], [0, 1], color="gray", lw=1, linestyle="--", label="Random (AUC = 0.5)")
plt.xlabel("False Positive Rate")
plt.ylabel("True Positive Rate")
plt.title("ROC Curve - Credit Risk Model")
plt.legend(loc="lower right")
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()

print(f"\nGini Coefficient: {gini_improved:.4f}")
print("(Banks typically want Gini > 0.40 for a production scorecard)")

What does the ROC curve show?

The blue curve is your model. The closer it hugs the top-left corner, the better it is at distinguishing defaulters from good borrowers.

The dashed gray line represents random guessing (AUC = 0.5). The gap between your curve and that line IS the value your model provides. The Gini coefficient quantifies that gap as a single number banks report to regulators.

  • Run Cell 12 by pressing Shift+Enter.

You should see a smooth blue curve hugging the top-left corner, well above the dashed diagonal line. The legend confirms your AUC and the print statement confirms your Gini is above 0.40.

Plot not showing?

Make sure you have %matplotlib inline set in your notebook, or try adding plt.show() at the end of the cell. In Jupyter, plots should display inline by default.

Help me fix my matplotlib plot not displaying in Jupyter.

Generate global SHAP explanations

Your model performs well, but accuracy alone isn't enough for banking. Regulators require that you can explain WHY the model makes each decision. SHAP (SHapley Additive exPlanations) uses game theory to calculate exactly how much each feature pushed a prediction toward default or repaid.

Why do banks need model explainability?

If a bank rejects your loan application, many jurisdictions require them to tell you WHY. A model that says "rejected, probability 0.73" isn't enough. SHAP lets you say "you were declined because your debt-to-income ratio is too high and your credit history is too short."

This is also why banks prefer logistic regression over black-box models. SHAP's LinearExplainer gives exact (not approximate) Shapley values for linear models, making the explanations mathematically precise.

  • Create a new cell below Cell 12 and paste this code to generate the global SHAP summary:
# Cell 13: SHAP Explainability - Global
# Get feature names after preprocessing
feature_names_num = numeric_features
feature_names_cat = preprocessor.named_transformers_["cat"]["onehot"].get_feature_names_out(categorical_features).tolist()
all_feature_names = feature_names_num + feature_names_cat

# Create SHAP explainer for linear model
explainer = shap.LinearExplainer(lr_improved, X_train_processed, feature_names=all_feature_names)
shap_values = explainer.shap_values(X_test_processed)

# Global feature importance - beeswarm plot
print("=== SHAP GLOBAL FEATURE IMPORTANCE ===")
print("This shows which features matter MOST across all applicants.\n")
shap.summary_plot(shap_values, X_test_processed, feature_names=all_feature_names, show=True)

What does this code do?

  • get_feature_names_out reconstructs the column names after one-hot encoding. Without this, you'd see "feature_0" instead of meaningful names.
  • shap.LinearExplainer is the exact SHAP explainer for linear models. It computes mathematically precise Shapley values rather than approximations.
  • shap.summary_plot produces a beeswarm chart where each dot is one applicant. The x-axis shows how much that feature pushed the prediction. Color indicates the feature's actual value (red = high, blue = low).
  • Run Cell 13 by pressing Shift+Enter.

You should see a colorful beeswarm plot with features ranked by importance. Look for loan_percent_income (debt-to-income ratio) near the top. This tells you that across all applicants, debt-to-income is the strongest risk signal.

Getting a slow computation warning?

SHAP can take 10-30 seconds to compute on 6,000+ test records. This is normal. If it runs longer than 2 minutes, restart your kernel and re-run all cells from the top.

Help me troubleshoot slow SHAP computation in my credit risk notebook.

Explain an individual prediction with SHAP

The global plot shows trends across all applicants. But when a bank declines someone, they need to explain that specific person's decision. A SHAP waterfall plot does exactly this. It shows which features pushed that one applicant toward default and which pushed toward repaid.

  • Create a new cell below Cell 13 and paste this code to explain applicant #0:
# Cell 14: SHAP Explainability - Individual prediction
# Explain one specific applicant
applicant_idx = 0

print(f"=== EXPLAINING APPLICANT #{applicant_idx} ===")
print(f"Predicted default probability: {y_pred_proba_improved[applicant_idx]:.3f}")
print(f"Actual outcome: {'DEFAULT' if y_test_c.iloc[applicant_idx] == 1 else 'REPAID'}")
print()

# Waterfall plot for individual explanation
shap_explanation = shap.Explanation(
    values=shap_values[applicant_idx],
    base_values=explainer.expected_value,
    data=X_test_processed[applicant_idx],
    feature_names=all_feature_names,
)
shap.plots.waterfall(shap_explanation, show=True)

print("\nThis waterfall shows exactly WHY this applicant received their prediction.")
print("Red bars push toward DEFAULT, blue bars push toward REPAID.")
print("Banks are REQUIRED to provide this kind of explanation to rejected applicants.")

How do you read the waterfall plot?

  • The bottom of the chart shows the base value (average prediction across all applicants).
  • Each bar shows one feature's contribution. Red bars push the prediction toward default. Blue bars push toward repaid.
  • The final value at the top is the model's actual prediction for this applicant. The bars explain exactly how the model got from the average to this specific prediction.
  • Run Cell 14 by pressing Shift+Enter.

You should see the applicant's predicted probability printed, followed by a waterfall chart. Red bars show which features pushed this person toward default. Blue bars show which features pushed them toward repaid. This is the kind of explanation a bank would provide to a rejected applicant.

Waterfall plot looks empty or has no bars?

Make sure all_feature_names is defined (from Cell 13). If you restarted your kernel, re-run Cells 1 through 13 first, then run Cell 14.

Help me debug my SHAP waterfall plot showing no bars.

✔️ Awesome, I've got everything!

Great. Make sure you've saved your notebook with all four new cells (11 through 14) executed.

ⓧ I'd like to double check the full code

Scroll through your notebook and confirm these cells are present in order, each showing output:

  • Cell 11: Train the improved model. Output shows AUC-ROC, Gini, comparison with naive model, and classification report.
  • Cell 12: Plot ROC curve. Output shows a blue curve chart with the AUC legend, plus Gini printed below.
  • Cell 13: SHAP Global. Output shows the beeswarm summary plot with ranked features.
  • Cell 14: SHAP Individual. Output shows applicant #0's probability, a waterfall plot, and the explanation text.

Secret mission

Build a Credit Score (300-850 Scale)

Professional credit scorecards don't output raw probabilities. They convert the model's output into a score on a recognizable scale (like FICO 300-850) with clear decision boundaries. Can you build the final piece that makes your project production-ready?

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:

  • Conda environment credit_risk with Python 3.12 and all installed libraries.
  • Project files: credit_risk_scorecard.ipynb and credit_risk_dataset.csv.
  • Kaggle account (free, used to download the dataset).

Keep everything running

No action needed. Choose this if you're still actively building or want to revisit the notebook later.

  • Your conda environment stays on your machine and takes minimal disk space.
  • Your Jupyter Notebook and dataset remain in your working directory.
  • Your free Kaggle account stays active with no charges.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and environment stay intact for when you return.

  • In the terminal where Jupyter is running, press Ctrl+C to stop the Jupyter Notebook server.
  • Deactivate your conda environment by running:
conda deactivate

When you want to resume, reactivate and relaunch:

conda activate credit_risk
jupyter notebook

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your work.

  • Stop the Jupyter Notebook server by pressing Ctrl+C in the terminal where it is running.
  • Deactivate and remove the conda environment by running these commands:
conda deactivate
conda env remove -n credit_risk
  • Delete the project files. On macOS/Linux, run:
rm credit_risk_scorecard.ipynb credit_risk_dataset.csv
  • On Windows, run:
Remove-Item credit_risk_scorecard.ipynb, credit_risk_dataset.csv
  • Confirm the environment is gone by running:
conda env list

You should no longer see credit_risk in the list of environments.

Nice Work!

Nice Work!

You just built a production-grade credit risk scorecard from scratch. You took 32,000+ loan applications, trained a machine learning model to predict defaults, explained its decisions with SHAP, and classified applicants into risk tiers.

You've learned how to:

  • Train a logistic regression model on real loan data and improve it from mediocre (AUC ~0.65) to banking-grade (AUC 0.85+) through feature engineering and proper data cleaning.
  • Evaluate model performance with banking-industry metrics: AUC-ROC and Gini coefficient.
  • Generate SHAP explainability reports showing exactly which features drove each lending decision, meeting the regulatory requirement for model transparency.
  • Converted raw default probabilities into a traditional credit score (300-850 scale) with risk grades and APPROVE/REVIEW/REJECT decisions (Secret Mission).

Ready to quiz yourself?