Predict Loan Default Risk with ML

Build a loan default classifier using multi-table data and Random Forest.

Introduction

30 Second Summary

Every time someone applies for a loan, the lender faces a gamble. Banks use data to predict who will pay back and who will default before they hand over the cash.

In this project, you will build a machine learning model that predicts whether a loan will be repaid on time or default. You'll work with real-world multi-table data, engineer features from historical borrowing patterns, and compare two classification models to find the best predictor.

What You'll Build

A complete machine learning pipeline in Google Colab that merges three data sources, trains two classification models, and evaluates their ability to identify risky borrowers.

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

  • A merged dataset combining demographics, loan performance, and historical borrowing records into a single analysis-ready table.
  • Engineered features from previous loan history that capture each customer's borrowing patterns and repayment behaviour.
  • Two trained classification models (Logistic Regression and Random Forest) with a full performance comparison using precision, recall, and F1-score.
  • Secret Mission: Tune your model's hyperparameters with GridSearchCV and identify the most important features driving default predictions.

Are there any prerequisites?

You should be comfortable with Python (functions, loops, conditionals, and data structures). No prior experience with data science libraries is required.

You'll also need a free Zindi account to download the dataset. You can create one at zindi.africa.

Set Up Your Colab Notebook and Load Data

A machine learning project needs three things to get off the ground: a workspace to write code, the right tools imported, and data loaded and ready to explore.

Google Colab gives you all of this in a free, browser-based Python environment. In this step, you will create a fresh notebook, import the libraries your project needs, and upload the Zindi loan dataset so it is ready for analysis.

In this step, get ready to:
  • Create a new Colab notebook and name it.
  • Import the machine learning libraries your project uses.
  • Upload and load the three CSV dataset files into DataFrames.
Create your notebook and import libraries

Your notebook is where every cell of code will live for the entire project. You will also import all the libraries upfront so they are available in every cell that follows.

  • In your Google Colab tab, click File in the top menu bar.
  • Click New notebook.
  • Click the notebook title at the top left (it will say something like Untitled0.ipynb).
  • Replace it with Loan Default Prediction and press Enter.

Your first code cell is already waiting for you. This is where you will import every library the project needs.

  • Paste the following code into the first cell to import all required libraries:
# Cell 1: Import Libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay
import sklearn
print(f"scikit-learn version: {sklearn.__version__}")

What does this code do?

  • pandas loads and manipulates tabular data (your CSV files become DataFrames).
  • numpy (NumPy) handles numerical operations under the hood.
  • matplotlib (Matplotlib) draws charts and confusion matrix visualizations.
  • The sklearn imports bring in scikit-learn tools for preprocessing, model training, and evaluation.
  • The final print line confirms which version of scikit-learn is installed.
  • Run the cell by pressing Shift+Enter.
  • You should see output like scikit-learn version: 1.9.0 (the exact number may differ slightly).

Seeing an ImportError?

All of these libraries come pre-installed in Google Colab. If you see an import error, double-check that you pasted the full cell without any missing lines or typos in module names.

Help me fix an ImportError in my Colab notebook.

Upload and load the dataset

The three CSV files you downloaded from Zindi are sitting on your local machine. Colab runs in the cloud, so you need to upload them into your notebook's temporary storage before Python can read them.

  • Click the + Code button in the toolbar to add a new cell below your imports.
  • Paste the following code into the new cell to upload and load your data:
# Cell 2: Upload and Load Data
from google.colab import files
uploaded = files.upload()

demographics = pd.read_csv('traindemographics.csv')
perf = pd.read_csv('trainperf.csv')
prev_loans = pd.read_csv('trainprevloans.csv')

print(f"Demographics shape: {demographics.shape}")
print(f"Performance shape: {perf.shape}")
print(f"Previous loans shape: {prev_loans.shape}")

What does this code do?

  • files.upload() opens a file picker in your browser so you can select files from your computer.
  • pd.read_csv() reads each CSV file into a DataFrame, which is a table-like structure you can filter, sort, and transform.
  • The three print statements confirm how many rows and columns each file contains.
  • Run the cell by pressing Shift+Enter.

What should I see?

A file picker dialog appears. Select all three files at once (traindemographics.csv, trainperf.csv, and trainprevloans.csv) and click Open.

After the upload completes, you will see three shape printouts confirming each DataFrame loaded successfully. The demographics table has columns like customerid and birthdate. The performance table contains good_bad_flag (your target variable). The previous loans table holds historical loan records.

Upload seems stuck or files won't load?

Make sure you select the files with the exact names: traindemographics.csv, trainperf.csv, and trainprevloans.csv. If pandas throws a FileNotFoundError, the filename you typed in read_csv() does not match the uploaded file. Check for extra spaces or different capitalization.

Help me fix a file upload or loading error in Colab.

✔️ Awesome, I've got everything!

Great. Double check that your notebook has two cells and both ran without errors.

ⓧ I'd like to double check the full code

Here is the complete code across both cells. Compare it to what you have in your notebook.

# Cell 1: Import Libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay
import sklearn
print(f"scikit-learn version: {sklearn.__version__}")

# Cell 2: Upload and Load Data
from google.colab import files
uploaded = files.upload()

demographics = pd.read_csv('traindemographics.csv')
perf = pd.read_csv('trainperf.csv')
prev_loans = pd.read_csv('trainprevloans.csv')

print(f"Demographics shape: {demographics.shape}")
print(f"Performance shape: {perf.shape}")
print(f"Previous loans shape: {prev_loans.shape}")

Your workspace is ready and the raw data is loaded. Next up, you will explore these three tables, engineer features from the previous loan history, and merge everything into a single dataset your model can learn from.

Explore and Merge Your Data

Your data is loaded, but it lives in three separate tables. A machine learning model needs a single, flat table where each row represents one prediction to make.

In this step, you'll explore the structure of each table, engineer meaningful features from a customer's borrowing history, and merge everything into one clean DataFrame ready for modeling.

In this step, get ready to:
  • Examine the structure and class balance of your three datasets.
  • Engineer aggregated features from previous loan history.
  • Merge all three tables into a single DataFrame for modeling.
Examine each dataset

Before building anything, you need to understand what you're working with. The good_bad_flag column in perf is your target variable. You also need to know how many previous loans each customer has, since that determines how much history you can engineer features from.

  • Create a new code cell in your Colab notebook and add the following exploration code:
# Cell 3: Explore the Data
print("=== Performance Data (Target) ===")
print(perf.info())
print(f"\nTarget distribution:")
print(perf['good_bad_flag'].value_counts(normalize=True))

print("\n=== Demographics ===")
print(demographics.info())

print("\n=== Previous Loans ===")
print(prev_loans.info())
print(f"\nLoans per customer:")
print(prev_loans.groupby('customerid')['systemloanid'].count().describe())

What does this code do?

This cell gives you a snapshot of each table's structure and content.

  • perf.info() shows column names, data types, and missing value counts for the performance table (which contains your target).
  • value_counts(normalize=True) shows what percentage of loans are Good (1) vs Bad (0). This tells you whether your dataset is balanced or imbalanced.
  • The groupby at the end reveals how many previous loans each customer has. This tells you how rich the historical signal is.
  • Run the cell by pressing **Shift+Enter**.
  • You should see three info outputs plus the target distribution. The good_bad_flag column shows the proportion of Good vs Bad loans, and the customerid groupby shows how many previous loans each customer has on average.

What is the key relationship between these tables?

The customerid column links all three tables together. The perf table has one row per loan to predict. The demographics table has one row per customer. The prev_loans table has multiple rows per customer (one for each historical loan).

This means you can't just merge prev_loans directly. You first need to collapse those multiple rows into one summary row per customer.

Seeing a KeyError or empty output?

  • Make sure you ran the Cell 2 upload and load cell first. The DataFrames perf, demographics, and prev_loans must already exist in memory.
  • Check that you uploaded the correct files. The column names are case-sensitive.

Why can't I see any data in my exploration cell?

Engineer features from previous loan history

Previous loan history is the strongest signal for predicting default among repeat borrowers. A customer who has taken 10 loans and always repaid is very different from a first-time borrower.

You'll aggregate the multiple rows in prev_loans into a single summary row per customer, capturing their borrowing patterns.

  • Create a new code cell and add the feature engineering code:
# Cell 4: Engineer Features from Previous Loans
prev_loan_features = prev_loans.groupby('customerid').agg(
    num_prev_loans=('systemloanid', 'count'),
    avg_prev_loanamount=('loanamount', 'mean'),
    avg_prev_termdays=('termdays', 'mean'),
    total_prev_due=('totaldue', 'sum')
).reset_index()

prev_loans['loan_to_due_ratio'] = prev_loans['loanamount'] / prev_loans['totaldue']
ratio_feature = prev_loans.groupby('customerid')['loan_to_due_ratio'].mean().reset_index()
ratio_feature.columns = ['customerid', 'avg_prev_loan_to_due_ratio']

prev_loan_features = prev_loan_features.merge(ratio_feature, on='customerid', how='left')
print(f"Aggregated previous loan features shape: {prev_loan_features.shape}")
prev_loan_features.head()

What does this code do?

This cell turns many rows per customer into one summary row per customer. Each aggregated column captures a different pattern.

  • num_prev_loans: how many times this person has borrowed before. Repeat borrowers behave differently from new ones.
  • avg_prev_loanamount: the average size of their previous loans. Tells you their typical borrowing level.
  • avg_prev_termdays: the average repayment period. Longer terms might indicate different risk profiles.
  • total_prev_due: the total amount owed across all previous loans. Captures overall debt exposure.
  • avg_prev_loan_to_due_ratio: the average ratio of loan amount to total due. This captures the interest burden across their borrowing history.

What is feature engineering?

Feature engineering is the process of creating new input columns from raw data. Your model can't read multiple rows of loan history per customer. By computing summaries (counts, averages, totals), you create features the model can actually use.

  • Run the cell by pressing **Shift+Enter**.
  • You should see the shape of prev_loan_features printed, followed by a preview table showing one row per customer with five aggregated columns.

Seeing a division error or NaN values?

  • If you see a warning about division by zero, that's expected. Some loans may have a totaldue of 0. The resulting NaN values will be handled in the next step.
  • Make sure the column names in your code match exactly. They are case-sensitive.

Help me debug my feature engineering groupby code.

Merge everything into a single DataFrame

Now you have three pieces of information per customer: their current loan details (in perf), their demographic profile, and their aggregated loan history. Time to combine them into one table.

  • Create a new code cell and add the merge code:
# Cell 5: Merge All Tables
df = perf.merge(demographics, on='customerid', how='left')
df = df.merge(prev_loan_features, on='customerid', how='left')
print(f"Merged DataFrame shape: {df.shape}")
print(f"\nMissing values:\n{df.isnull().sum()}")

What does this code do?

This performs two left merges on customerid. A left merge keeps every row from the left table (perf) and attaches matching data from the right table.

Customers with no previous loans won't have a match in prev_loan_features. Their aggregated columns will show NaN (missing). This is expected and will be handled in the next step by filling those NaN values with 0.

  • Run the cell by pressing **Shift+Enter**.
  • You should see the merged DataFrame shape and a missing values summary. The aggregated previous loan columns (num_prev_loans, avg_prev_loanamount, etc.) will show missing values for customers with no prior loans.

Merged shape looks wrong?

  • If your merged DataFrame has more rows than perf, check that you used how='left' and not how='outer'. A left merge preserves the row count of the left table.
  • Make sure you ran Cell 4 (feature engineering) before this cell. The prev_loan_features DataFrame must exist before merging.

Help me understand why my merge produced unexpected results.

✔️ Awesome, I've got everything!

Great. Double check you've saved your notebook (Colab auto-saves, but you can press **Ctrl+S** to be sure).

ⓧ I'd like to double check the full code

Here's the full notebook code up to this point. Compare your cells against this:

# Cell 1: Import Libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay
import sklearn
print(f"scikit-learn version: {sklearn.__version__}")

# Cell 2: Upload and Load Data
from google.colab import files
uploaded = files.upload()

demographics = pd.read_csv('traindemographics.csv')
perf = pd.read_csv('trainperf.csv')
prev_loans = pd.read_csv('trainprevloans.csv')

print(f"Demographics shape: {demographics.shape}")
print(f"Performance shape: {perf.shape}")
print(f"Previous loans shape: {prev_loans.shape}")

# Cell 3: Explore the Data
print("=== Performance Data (Target) ===")
print(perf.info())
print(f"\nTarget distribution:")
print(perf['good_bad_flag'].value_counts(normalize=True))

print("\n=== Demographics ===")
print(demographics.info())

print("\n=== Previous Loans ===")
print(prev_loans.info())
print(f"\nLoans per customer:")
print(prev_loans.groupby('customerid')['systemloanid'].count().describe())

# Cell 4: Engineer Features from Previous Loans
prev_loan_features = prev_loans.groupby('customerid').agg(
    num_prev_loans=('systemloanid', 'count'),
    avg_prev_loanamount=('loanamount', 'mean'),
    avg_prev_termdays=('termdays', 'mean'),
    total_prev_due=('totaldue', 'sum')
).reset_index()

prev_loans['loan_to_due_ratio'] = prev_loans['loanamount'] / prev_loans['totaldue']
ratio_feature = prev_loans.groupby('customerid')['loan_to_due_ratio'].mean().reset_index()
ratio_feature.columns = ['customerid', 'avg_prev_loan_to_due_ratio']

prev_loan_features = prev_loan_features.merge(ratio_feature, on='customerid', how='left')
print(f"Aggregated previous loan features shape: {prev_loan_features.shape}")
prev_loan_features.head()

# Cell 5: Merge All Tables
df = perf.merge(demographics, on='customerid', how='left')
df = df.merge(prev_loan_features, on='customerid', how='left')
print(f"Merged DataFrame shape: {df.shape}")
print(f"\nMissing values:\n{df.isnull().sum()}")

Your data is now in one place. Next up, you'll prepare these features for machine learning by handling dates, filling missing values, and building a preprocessing pipeline.

Prepare Features for Machine Learning

You have merged three separate data tables into a single DataFrame. Now it is time to transform that raw data into clean features a machine learning model can actually learn from.

Machine learning models cannot interpret date strings or handle missing values directly. In this step, you will parse dates into useful numeric features, fill gaps in the data, and build a preprocessing pipeline that scales and encodes everything automatically.

In this step, get ready to:
  • Engineer new features from dates and visualize default patterns.
  • Handle missing values and define numeric vs categorical feature lists.
  • Build a preprocessing pipeline and split data into training and test sets.
Visualize default patterns and engineer date features

Before engineering features from dates, it helps to visualize the patterns already hiding in your data. A quick chart can reveal which groups default more often and whether loan size relates to outcomes.

  • Create a new code cell in your notebook and add this visualization code:
# Cell 6: Visualize Default Patterns
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

emp_default = df.groupby('employment_status_clients')['good_bad_flag'].apply(
    lambda x: (x == 0).mean()
).sort_values(ascending=False)
emp_default.plot(kind='bar', ax=axes[0], color='#e74c3c')
axes[0].set_title('Default Rate by Employment Status')
axes[0].set_ylabel('Default Rate')
axes[0].set_xlabel('Employment Status')
axes[0].tick_params(axis='x', rotation=45)

df[df['good_bad_flag'] == 1]['loanamount'].hist(alpha=0.5, label='Good (Repaid)', bins=30, ax=axes[1], color='#2ecc71')
df[df['good_bad_flag'] == 0]['loanamount'].hist(alpha=0.5, label='Bad (Default)', bins=30, ax=axes[1], color='#e74c3c')
axes[1].set_title('Loan Amount Distribution by Outcome')
axes[1].set_xlabel('Loan Amount')
axes[1].set_ylabel('Count')
axes[1].legend()

plt.tight_layout()
plt.show()

What do these charts show?

The left chart shows the default rate for each employment status. Some groups default more often than others, which tells you employment status is a useful feature.

The right chart overlays loan amounts for good vs bad outcomes. If the distributions look different, loan amount helps distinguish defaulters from reliable borrowers.

  • Run the cell. You should see two side-by-side charts: a bar chart of default rates by employment status and a histogram comparing loan amounts for good vs bad loans.

Don't see any charts?

Make sure you ran the earlier cells first. The df DataFrame and plt import must exist in your session.

If you see a KeyError for employment_status_clients, confirm that your merge in Cell 5 included the demographics table.

Help me debug my visualization

Now that you can see patterns in the data, let's extract useful numeric features from the date columns. A model cannot read a date string like 2017-03-15, but it can learn from numbers like "this loan took 3 days to approve."

  • Create a new cell and add this date feature engineering code:
# Cell 7: Feature Engineering from Dates
df['approveddate'] = pd.to_datetime(df['approveddate'])
df['creationdate'] = pd.to_datetime(df['creationdate'])
df['days_to_approval'] = (df['approveddate'] - df['creationdate']).dt.days

df['birthdate'] = pd.to_datetime(df['birthdate'], errors='coerce')
df['age'] = (df['approveddate'] - df['birthdate']).dt.days / 365.25

What does this code do?

The first three lines parse approveddate and creationdate into datetime objects, then calculate how many days it took for the loan to be approved. A longer approval time might signal higher risk.

The last two lines parse birthdate and calculate the borrower's approximate age in years. The errors='coerce' parameter converts any unparseable dates to NaT (Not a Time) instead of crashing the program.

  • Run the cell. You will not see printed output, but the new days_to_approval and age columns now exist in your DataFrame.

Verify your new columns

To confirm the features were created, run df[['days_to_approval', 'age']].describe() in a quick scratch cell. You should see numeric statistics for both columns.

Handle missing values and define feature types

Customers who have never borrowed before will have NaN in all previous loan columns after the merge. You need to fill these gaps with sensible defaults before the model can use them.

  • Create a new cell and add this cleaning and feature definition code:
# Cell 8: Clean and Prepare Final Features
# Fill missing values for customers with no previous loans
prev_loan_cols = ['num_prev_loans', 'avg_prev_loanamount', 'avg_prev_termdays',
                  'total_prev_due', 'avg_prev_loan_to_due_ratio']
df[prev_loan_cols] = df[prev_loan_cols].fillna(0)

# Fill missing age with median
df['age'] = df['age'].fillna(df['age'].median())

# Fill missing days_to_approval with 0
df['days_to_approval'] = df['days_to_approval'].fillna(0)

# Drop identifier and date columns
drop_cols = ['customerid', 'systemloanid', 'approveddate', 'creationdate',
             'birthdate', 'referredby', 'bank_branch_clients']
df = df.drop(columns=[col for col in drop_cols if col in df.columns])

# Define feature types
numeric_features = ['loanamount', 'totaldue', 'termdays', 'loannumber',
                    'num_prev_loans', 'avg_prev_loanamount', 'avg_prev_termdays',
                    'total_prev_due', 'avg_prev_loan_to_due_ratio',
                    'days_to_approval', 'age']

categorical_features = ['bank_account_type', 'bank_name_clients',
                        'employment_status_clients', 'level_of_education_clients']

# Drop rows where target is missing
df = df.dropna(subset=['good_bad_flag'])

print(f"Final DataFrame shape: {df.shape}")
print(f"\nFeature types:")
print(f"  Numeric: {len(numeric_features)}")
print(f"  Categorical: {len(categorical_features)}")

What does this code do?

First, it fills NaN in all previous loan columns with 0. A customer with no loan history genuinely has zero previous loans, zero average amount, and zero total due.

Missing ages are filled with the median age (the middle value). This is better than 0, which would claim a borrower is a newborn.

Then it drops columns the model should not see: identifiers (customerid), raw date strings, referredby (too many missing values), and bank_branch_clients (too many unique values). Finally, it defines two lists that tell the preprocessing pipeline which columns are numeric and which are categorical.

  • Run the cell. You should see the final DataFrame shape and a count of 11 numeric features and 4 categorical features.

Seeing a KeyError when dropping columns?

The code uses a list comprehension to only drop columns that actually exist. If you still see an error, check that you ran Cell 5 (merge) and Cell 7 (date engineering) first.

If your feature counts look different, confirm you did not accidentally drop a needed column in a previous cell.

Help me debug my data cleaning cell

Build preprocessing pipeline and split data

A ColumnTransformer applies different transformations to different column types in one step. Numeric features get scaled to the same range, while categorical features get converted into binary columns the model can interpret.

  • Create a new cell and add this pipeline and data splitting code:
# Cell 9: Build Pipeline and Split Data
X = df[numeric_features + categorical_features]
y = df['good_bad_flag'].astype(int)

# Fill any remaining NaN in categorical columns
for col in categorical_features:
    X[col] = X[col].fillna('unknown')

preprocessor = ColumnTransformer(
    transformers=[
        ('num', StandardScaler(), numeric_features),
        ('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), categorical_features)
    ]
)

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}")
print(f"Test set: {X_test.shape}")
print(f"\nClass distribution (train):\n{y_train.value_counts(normalize=True)}")

What does this code do?

It separates X (the features) from y (the target label good_bad_flag). Any remaining missing categorical values get filled with the string unknown.

The preprocessor applies StandardScaler to numeric columns (centering values around 0 with unit variance) and OneHotEncoder to categorical columns (turning each category into its own binary column).

Finally, train_test_split reserves 20% of the data for testing. The stratify=y parameter ensures both sets keep the same ratio of good to bad loans as the original data.

  • Run the cell. You should see the training set shape, test set shape, and the class distribution showing the same ratio in both sets.

Getting a ValueError about unknown categories?

The handle_unknown='ignore' parameter in OneHotEncoder prevents this at prediction time. If you see this error during fit, check that your categorical_features list matches the actual column names in your DataFrame.

If the class distribution looks very different between train and test, make sure you included stratify=y in the split.

Help me debug my preprocessing pipeline

Why use stratify for imbalanced data?

If the dataset has far more good loans than bad loans (which it does), a random split might accidentally put very few bad loans in the test set. Stratifying guarantees both splits mirror the original class balance, giving you a fair evaluation later.

✔️ Awesome, I've got everything!

Great. Double check you have saved all cells in your notebook.

ⓧ I'd like to double check the full code

# Cell 1: Import Libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay
import sklearn
print(f"scikit-learn version: {sklearn.__version__}")

# Cell 2: Upload and Load Data
from google.colab import files
uploaded = files.upload()

demographics = pd.read_csv('traindemographics.csv')
perf = pd.read_csv('trainperf.csv')
prev_loans = pd.read_csv('trainprevloans.csv')

print(f"Demographics shape: {demographics.shape}")
print(f"Performance shape: {perf.shape}")
print(f"Previous loans shape: {prev_loans.shape}")

# Cell 3: Explore the Data
print("=== Performance Data (Target) ===")
print(perf.info())
print(f"\nTarget distribution:")
print(perf['good_bad_flag'].value_counts(normalize=True))

print("\n=== Demographics ===")
print(demographics.info())

print("\n=== Previous Loans ===")
print(prev_loans.info())
print(f"\nLoans per customer:")
print(prev_loans.groupby('customerid')['systemloanid'].count().describe())

# Cell 4: Engineer Features from Previous Loans
prev_loan_features = prev_loans.groupby('customerid').agg(
    num_prev_loans=('systemloanid', 'count'),
    avg_prev_loanamount=('loanamount', 'mean'),
    avg_prev_termdays=('termdays', 'mean'),
    total_prev_due=('totaldue', 'sum')
).reset_index()

prev_loans['loan_to_due_ratio'] = prev_loans['loanamount'] / prev_loans['totaldue']
ratio_feature = prev_loans.groupby('customerid')['loan_to_due_ratio'].mean().reset_index()
ratio_feature.columns = ['customerid', 'avg_prev_loan_to_due_ratio']

prev_loan_features = prev_loan_features.merge(ratio_feature, on='customerid', how='left')
print(f"Aggregated previous loan features shape: {prev_loan_features.shape}")
prev_loan_features.head()

# Cell 5: Merge All Tables
df = perf.merge(demographics, on='customerid', how='left')
df = df.merge(prev_loan_features, on='customerid', how='left')
print(f"Merged DataFrame shape: {df.shape}")
print(f"\nMissing values:\n{df.isnull().sum()}")

# Cell 6: Visualize Default Patterns
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

emp_default = df.groupby('employment_status_clients')['good_bad_flag'].apply(
    lambda x: (x == 0).mean()
).sort_values(ascending=False)
emp_default.plot(kind='bar', ax=axes[0], color='#e74c3c')
axes[0].set_title('Default Rate by Employment Status')
axes[0].set_ylabel('Default Rate')
axes[0].set_xlabel('Employment Status')
axes[0].tick_params(axis='x', rotation=45)

df[df['good_bad_flag'] == 1]['loanamount'].hist(alpha=0.5, label='Good (Repaid)', bins=30, ax=axes[1], color='#2ecc71')
df[df['good_bad_flag'] == 0]['loanamount'].hist(alpha=0.5, label='Bad (Default)', bins=30, ax=axes[1], color='#e74c3c')
axes[1].set_title('Loan Amount Distribution by Outcome')
axes[1].set_xlabel('Loan Amount')
axes[1].set_ylabel('Count')
axes[1].legend()

plt.tight_layout()
plt.show()

# Cell 7: Feature Engineering from Dates
df['approveddate'] = pd.to_datetime(df['approveddate'])
df['creationdate'] = pd.to_datetime(df['creationdate'])
df['days_to_approval'] = (df['approveddate'] - df['creationdate']).dt.days

df['birthdate'] = pd.to_datetime(df['birthdate'], errors='coerce')
df['age'] = (df['approveddate'] - df['birthdate']).dt.days / 365.25

# Cell 8: Clean and Prepare Final Features
# Fill missing values for customers with no previous loans
prev_loan_cols = ['num_prev_loans', 'avg_prev_loanamount', 'avg_prev_termdays',
                  'total_prev_due', 'avg_prev_loan_to_due_ratio']
df[prev_loan_cols] = df[prev_loan_cols].fillna(0)

# Fill missing age with median
df['age'] = df['age'].fillna(df['age'].median())

# Fill missing days_to_approval with 0
df['days_to_approval'] = df['days_to_approval'].fillna(0)

# Drop identifier and date columns
drop_cols = ['customerid', 'systemloanid', 'approveddate', 'creationdate',
             'birthdate', 'referredby', 'bank_branch_clients']
df = df.drop(columns=[col for col in drop_cols if col in df.columns])

# Define feature types
numeric_features = ['loanamount', 'totaldue', 'termdays', 'loannumber',
                    'num_prev_loans', 'avg_prev_loanamount', 'avg_prev_termdays',
                    'total_prev_due', 'avg_prev_loan_to_due_ratio',
                    'days_to_approval', 'age']

categorical_features = ['bank_account_type', 'bank_name_clients',
                        'employment_status_clients', 'level_of_education_clients']

# Drop rows where target is missing
df = df.dropna(subset=['good_bad_flag'])

print(f"Final DataFrame shape: {df.shape}")
print(f"\nFeature types:")
print(f"  Numeric: {len(numeric_features)}")
print(f"  Categorical: {len(categorical_features)}")

# Cell 9: Build Pipeline and Split Data
X = df[numeric_features + categorical_features]
y = df['good_bad_flag'].astype(int)

# Fill any remaining NaN in categorical columns
for col in categorical_features:
    X[col] = X[col].fillna('unknown')

preprocessor = ColumnTransformer(
    transformers=[
        ('num', StandardScaler(), numeric_features),
        ('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), categorical_features)
    ]
)

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}")
print(f"Test set: {X_test.shape}")
print(f"\nClass distribution (train):\n{y_train.value_counts(normalize=True)}")

Your features are cleaned, engineered, and split into training and test sets. Next up, you will train two different classification models and compare which one catches more defaulters.

Train and Compare Two Models

Your data is split and your preprocessing pipeline is ready. Now comes the exciting part: training actual machine learning models to predict loan defaults.

You'll train two different algorithms and compare their predictions. The key question is which model does a better job catching borrowers who are likely to default.

In this step, get ready to:
  • Train a Logistic Regression baseline model.
  • Train a Random Forest classifier.
  • Visualize and compare confusion matrices for both models.
Train a Logistic Regression model

Logistic Regression is the standard starting point for binary classification. It's fast, interpretable, and gives you a meaningful baseline to compare against more complex models.

  • Train your first model by creating a new code cell in your notebook and pasting this code:
# Cell 10: Train Logistic Regression
lr_pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', LogisticRegression(max_iter=1000, random_state=42))
])

lr_pipeline.fit(X_train, y_train)
lr_pred = lr_pipeline.predict(X_test)
print("=== Logistic Regression Results ===")
print(classification_report(y_test, lr_pred, target_names=['Bad (Default)', 'Good (Repaid)']))

What does this code do?

  • Pipeline bundles the preprocessor and classifier into one object. Data flows through scaling, encoding, then the model automatically.
  • LogisticRegression(max_iter=1000, random_state=42) trains a linear classifier. max_iter=1000 gives it enough iterations to converge. random_state=42 makes results reproducible.
  • classification_report prints precision, recall, and F1-score for each class, showing how well the model performs on both defaulters and good borrowers.
  • Run the cell.
  • You should see a classification report table with columns for precision, recall, F1-score, and support for both Bad (Default) and Good (Repaid) classes.

What do these metrics mean?

Precision tells you: of all the loans the model flagged as Bad, how many actually defaulted? Recall tells you: of all the loans that actually defaulted, how many did the model catch?

F1-score is the balance between precision and recall. For loan default prediction, recall on the Bad class matters most because missing a defaulter costs the lender real money.

Seeing an error when running the cell?

  • Make sure you ran all the previous cells in order. Colab cells must run sequentially because each depends on variables from earlier cells.
  • Check that X_train exists by typing X_train.shape in a new cell. If you get a NameError, re-run Cell 9.
  • Help me debug my Logistic Regression training error.
Train a Random Forest model

Random Forest builds many decision trees and combines their predictions. It often captures complex patterns that a linear model like Logistic Regression misses.

  • Train your second model by creating a new code cell and pasting this code:
# Cell 11: Train Random Forest
rf_pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', RandomForestClassifier(n_estimators=100, random_state=42))
])

rf_pipeline.fit(X_train, y_train)
rf_pred = rf_pipeline.predict(X_test)
print("=== Random Forest Results ===")
print(classification_report(y_test, rf_pred, target_names=['Bad (Default)', 'Good (Repaid)']))

What does this code do?

  • RandomForestClassifier(n_estimators=100, random_state=42) builds 100 decision trees, each trained on a random subset of the data.
  • The forest combines all 100 trees' votes to make its final prediction. This "wisdom of the crowd" approach reduces overfitting compared to a single tree.
  • The same Pipeline pattern reuses your preprocessor, keeping the workflow consistent across models.
  • Run the cell.
  • You should see a second classification report. Compare the recall for the Bad (Default) class between Logistic Regression and Random Forest.

Which model has better recall for Bad?

Look at the recall value for the Bad (Default) row in each report. A higher recall means the model catches more actual defaulters. This matters because approving a loan that will default is costly for the lender.

The Random Forest typically outperforms Logistic Regression on this dataset because loan default patterns are non-linear. Borrower behavior depends on combinations of features that a straight line cannot capture.

Random Forest taking a long time?

  • Training 100 trees takes longer than Logistic Regression. Give it 30-60 seconds in Colab.
  • If it seems stuck for more than 2 minutes, check that you are using the free Colab runtime (not a disconnected session). Click **Runtime** in the top menu, then **Reconnect** if needed.
  • Help me troubleshoot my slow Random Forest training.
Visualize the confusion matrices

A classification report gives you numbers, but a confusion matrix shows you exactly where each model goes wrong. It reveals how many defaulters slipped through and how many good borrowers got unfairly rejected.

  • Visualize both models side by side by creating a new code cell and pasting this code:
# Cell 12: Confusion Matrices
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

ConfusionMatrixDisplay.from_predictions(
    y_test, lr_pred, display_labels=['Bad', 'Good'], ax=axes[0]
)
axes[0].set_title('Logistic Regression')

ConfusionMatrixDisplay.from_predictions(
    y_test, rf_pred, display_labels=['Bad', 'Good'], ax=axes[1]
)
axes[1].set_title('Random Forest')

plt.tight_layout()
plt.show()

How to read the confusion matrix

Each matrix is a 2x2 grid. The rows represent what actually happened (Bad or Good). The columns represent what the model predicted.

  • Top-left: true negatives. Defaulters correctly identified as Bad.
  • Top-right: false positives. Defaulters the model incorrectly called Good (these are the dangerous misses).
  • Bottom-left: false negatives. Good borrowers the model incorrectly rejected.
  • Bottom-right: true positives. Good borrowers correctly identified.
  • Run the cell.
  • You should see two confusion matrix plots side by side. The left shows Logistic Regression and the right shows Random Forest.

The precision-recall trade-off for loan default

A model with high recall for Bad catches more defaulters, but it may also reject some good borrowers who would have repaid. A model with high precision for Bad rejects fewer good borrowers, but it lets more defaulters slip through.

For credit risk, lenders typically prefer high recall for the Bad class. Missing a defaulter costs real money. Rejecting a good borrower only costs a missed opportunity.

Confusion matrix not displaying?

  • Make sure lr_pred and rf_pred exist. If you see a NameError, re-run Cells 10 and 11 first.
  • Confirm that matplotlib was imported in Cell 1. Colab needs the import matplotlib.pyplot as plt line to render plots.
  • Help me debug my confusion matrix display issue.

✔️ Awesome, I've got everything!

Great. Double check that you have saved your notebook with all cells run through Cell 12.

ⓧ I'd like to double check the full code

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

# Cell 1: Import Libraries
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.linear_model import LogisticRegression
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay
import sklearn
print(f"scikit-learn version: {sklearn.__version__}")

# Cell 2: Upload and Load Data
from google.colab import files
uploaded = files.upload()

demographics = pd.read_csv('traindemographics.csv')
perf = pd.read_csv('trainperf.csv')
prev_loans = pd.read_csv('trainprevloans.csv')

print(f"Demographics shape: {demographics.shape}")
print(f"Performance shape: {perf.shape}")
print(f"Previous loans shape: {prev_loans.shape}")

# Cell 3: Explore the Data
print("=== Performance Data (Target) ===")
print(perf.info())
print(f"\nTarget distribution:")
print(perf['good_bad_flag'].value_counts(normalize=True))

print("\n=== Demographics ===")
print(demographics.info())

print("\n=== Previous Loans ===")
print(prev_loans.info())
print(f"\nLoans per customer:")
print(prev_loans.groupby('customerid')['systemloanid'].count().describe())

# Cell 4: Engineer Features from Previous Loans
prev_loan_features = prev_loans.groupby('customerid').agg(
    num_prev_loans=('systemloanid', 'count'),
    avg_prev_loanamount=('loanamount', 'mean'),
    avg_prev_termdays=('termdays', 'mean'),
    total_prev_due=('totaldue', 'sum')
).reset_index()

prev_loans['loan_to_due_ratio'] = prev_loans['loanamount'] / prev_loans['totaldue']
ratio_feature = prev_loans.groupby('customerid')['loan_to_due_ratio'].mean().reset_index()
ratio_feature.columns = ['customerid', 'avg_prev_loan_to_due_ratio']

prev_loan_features = prev_loan_features.merge(ratio_feature, on='customerid', how='left')
print(f"Aggregated previous loan features shape: {prev_loan_features.shape}")
prev_loan_features.head()

# Cell 5: Merge All Tables
df = perf.merge(demographics, on='customerid', how='left')
df = df.merge(prev_loan_features, on='customerid', how='left')
print(f"Merged DataFrame shape: {df.shape}")
print(f"\nMissing values:\n{df.isnull().sum()}")

# Cell 6: Visualize Default Patterns
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

emp_default = df.groupby('employment_status_clients')['good_bad_flag'].apply(
    lambda x: (x == 0).mean()
).sort_values(ascending=False)
emp_default.plot(kind='bar', ax=axes[0], color='#e74c3c')
axes[0].set_title('Default Rate by Employment Status')
axes[0].set_ylabel('Default Rate')
axes[0].set_xlabel('Employment Status')
axes[0].tick_params(axis='x', rotation=45)

df[df['good_bad_flag'] == 1]['loanamount'].hist(alpha=0.5, label='Good (Repaid)', bins=30, ax=axes[1], color='#2ecc71')
df[df['good_bad_flag'] == 0]['loanamount'].hist(alpha=0.5, label='Bad (Default)', bins=30, ax=axes[1], color='#e74c3c')
axes[1].set_title('Loan Amount Distribution by Outcome')
axes[1].set_xlabel('Loan Amount')
axes[1].set_ylabel('Count')
axes[1].legend()

plt.tight_layout()
plt.show()

# Cell 7: Feature Engineering from Dates
df['approveddate'] = pd.to_datetime(df['approveddate'])
df['creationdate'] = pd.to_datetime(df['creationdate'])
df['days_to_approval'] = (df['approveddate'] - df['creationdate']).dt.days

df['birthdate'] = pd.to_datetime(df['birthdate'], errors='coerce')
df['age'] = (df['approveddate'] - df['birthdate']).dt.days / 365.25

# Cell 8: Clean and Prepare Final Features
prev_loan_cols = ['num_prev_loans', 'avg_prev_loanamount', 'avg_prev_termdays',
                  'total_prev_due', 'avg_prev_loan_to_due_ratio']
df[prev_loan_cols] = df[prev_loan_cols].fillna(0)

df['age'] = df['age'].fillna(df['age'].median())
df['days_to_approval'] = df['days_to_approval'].fillna(0)

drop_cols = ['customerid', 'systemloanid', 'approveddate', 'creationdate',
             'birthdate', 'referredby', 'bank_branch_clients']
df = df.drop(columns=[col for col in drop_cols if col in df.columns])

numeric_features = ['loanamount', 'totaldue', 'termdays', 'loannumber',
                    'num_prev_loans', 'avg_prev_loanamount', 'avg_prev_termdays',
                    'total_prev_due', 'avg_prev_loan_to_due_ratio',
                    'days_to_approval', 'age']

categorical_features = ['bank_account_type', 'bank_name_clients',
                        'employment_status_clients', 'level_of_education_clients']

df = df.dropna(subset=['good_bad_flag'])

print(f"Final DataFrame shape: {df.shape}")
print(f"\nFeature types:")
print(f"  Numeric: {len(numeric_features)}")
print(f"  Categorical: {len(categorical_features)}")

# Cell 9: Build Pipeline and Split Data
X = df[numeric_features + categorical_features]
y = df['good_bad_flag'].astype(int)

for col in categorical_features:
    X[col] = X[col].fillna('unknown')

preprocessor = ColumnTransformer(
    transformers=[
        ('num', StandardScaler(), numeric_features),
        ('cat', OneHotEncoder(handle_unknown='ignore', sparse_output=False), categorical_features)
    ]
)

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}")
print(f"Test set: {X_test.shape}")
print(f"\nClass distribution (train):\n{y_train.value_counts(normalize=True)}")

# Cell 10: Train Logistic Regression
lr_pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', LogisticRegression(max_iter=1000, random_state=42))
])

lr_pipeline.fit(X_train, y_train)
lr_pred = lr_pipeline.predict(X_test)
print("=== Logistic Regression Results ===")
print(classification_report(y_test, lr_pred, target_names=['Bad (Default)', 'Good (Repaid)']))

# Cell 11: Train Random Forest
rf_pipeline = Pipeline([
    ('preprocessor', preprocessor),
    ('classifier', RandomForestClassifier(n_estimators=100, random_state=42))
])

rf_pipeline.fit(X_train, y_train)
rf_pred = rf_pipeline.predict(X_test)
print("=== Random Forest Results ===")
print(classification_report(y_test, rf_pred, target_names=['Bad (Default)', 'Good (Repaid)']))

# Cell 12: Confusion Matrices
fig, axes = plt.subplots(1, 2, figsize=(12, 5))

ConfusionMatrixDisplay.from_predictions(
    y_test, lr_pred, display_labels=['Bad', 'Good'], ax=axes[0]
)
axes[0].set_title('Logistic Regression')

ConfusionMatrixDisplay.from_predictions(
    y_test, rf_pred, display_labels=['Bad', 'Good'], ax=axes[1]
)
axes[1].set_title('Random Forest')

plt.tight_layout()
plt.show()

You now have two trained models and a clear picture of their strengths. Next up, you'll take your best model further in the Secret Mission by tuning its hyperparameters to squeeze out even better performance.

Secret mission

Tune Your Best Model with GridSearchCV

Your Random Forest already beats Logistic Regression. But can you squeeze more performance out of it? In this secret mission, you will systematically search for the best hyperparameters using cross-validation and discover which features drive your model's predictions.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely in Google Colab, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your notebook for future reference or delete everything.

Resources you used:

  • Google Colab notebook ("Loan Default Prediction") stored in your Google Drive.
  • Downloaded dataset files on your local machine (traindemographics.csv, trainperf.csv, trainprevloans.csv).

Keep everything running

No action needed. Choose this if you plan to revisit your model, try new features, or experiment with different algorithms.

  • Your Colab notebook stays in Google Drive and you can reopen it anytime.
  • The Colab runtime disconnects automatically after inactivity, so no compute resources stay running.
  • Your local CSV files remain in your Downloads folder for future use.

Pause - I'll come back to this later

Free up local disk space while keeping your notebook safe in Google Drive.

  • Delete the three CSV files from your local Downloads folder. You can re-download them from Zindi later.
  • Your Colab notebook remains in Google Drive with all your code and outputs saved.

Delete - I don't want to use this again

Remove all project files from both Google Drive and your local machine.

  • Delete the Colab notebook from Google Drive. Open Google Drive, find the notebook named "Loan Default Prediction" in your Colab Notebooks folder, right-click it, and select Move to trash.
  • Delete the downloaded CSV files from your local machine. Navigate to your Downloads folder and delete traindemographics.csv, trainperf.csv, and trainprevloans.csv.

What about my Zindi account?

Your Zindi account is free and does not incur any charges. You can keep it for future data science competitions or simply leave it inactive.

Nice Work!

Nice Work!

You just built a complete machine learning pipeline that predicts loan default risk from multi-source real-world data.

You've learned how to:

  • Merge multiple data tables and engineer features from historical loan records, turning raw multi-row history into meaningful per-customer signals.
  • Build a reproducible ML pipeline using ColumnTransformer and Pipeline that handles numeric scaling and categorical encoding without data leakage.
  • Evaluate models using precision, recall, and F1-score instead of accuracy alone, understanding why these metrics matter for imbalanced credit risk datasets.
  • Secret Mission: Tuned hyperparameters with GridSearchCV and identified the most important features driving default predictions.

Ready to quiz yourself?