Predictive Analytics for Student Retention

A Python-based machine-learning workflow for preparing institutional data, engineering student-level features, addressing class imbalance, predicting retention outcomes, and interpreting model performance.

Python machine-learning analysis for student retention

Project Overview

This project demonstrates how institutional enrollment, demographic, geographic, academic, athletic, and financial-aid data can be transformed into a machine-learning workflow for student-retention analysis. The workflow includes target-variable construction, feature engineering, data integration, class balancing, model training, prediction, evaluation, and model interpretation.

  • Data Cleaning
  • Feature Engineering
  • Geographic Data
  • Financial-Aid Data
  • SMOTE
  • ADASYN
  • Random Forest
  • Classification Metrics
  • Permutation Importance
  • Streamlit Deployment

Analytical Workflow

1

Load and clean institutional data

2

Construct the retention outcome

3

Engineer student-level features

4

Integrate ZIP-code and financial-aid data

5

Prepare training and testing samples

6

Address outcome imbalance

7

Train and evaluate the model

8

Interpret and deploy predictions

Section 01

Loading and Cleaning Institutional Data

The workflow begins by loading the required libraries and institutional dataset. Variable names are standardized, blank strings are converted to missing values, and the sample is restricted to the relevant student-entry statuses and cohort records.

Import libraries and standardize the dataset Python
# Import the libraries used throughout the workflow

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

from datetime import datetime


# Load the institutional enrollment dataset

df = pd.read_csv(
    "institutional_enrollment.csv",
    low_memory=False
)


# Standardize column names

df.columns = (
    df.columns
      .str.strip()
      .str.lower()
      .str.replace(
          " ",
          "_",
          regex=False
      )
)


# Convert blank strings into missing values

df.replace(
    "",
    np.nan,
    inplace=True
)


# Standardize student-entry status

df["student_orig_entry_status"] = (
    df["student_orig_entry_status"]
      .str.strip()
      .str.upper()
)


# Keep selected entry-status groups

eligible_statuses = [
    "FF",
    "AO",
    "CP",
    "GP",
    "ON"
]

df = df[
    df["student_orig_entry_status"]
      .isin(eligible_statuses)
]


# Retain records with a valid cohort semester

df = df[
    df["period_tbl_cohort_semester"]
      .notna()
]
Purpose: Consistent naming, missing-value treatment, and transparent sample restrictions make the remaining feature-engineering and modeling steps easier to reproduce and audit.

Section 02

Constructing the Retention Target

Cohort labels are converted into machine-readable semester values. A retention outcome is then constructed by comparing the student’s initial semester with enrollment information in the relevant subsequent period.

Convert cohort values and construct the outcome Python
from functions import convert_cohort_to_semester


# Convert a cohort label such as "02/FA"
# into a semester identifier such as 200208

df["beginning_semester"] = (
    df["student_cohort"]
      .apply(convert_cohort_to_semester)
)


# Illustrative retention construction

df["retention"] = np.where(
    df["observed_semester"]
        > df["beginning_semester"],
    1,
    0
)


# Remove records without a valid target value

df = df.dropna(
    subset=["retention"]
)


# Review outcome distribution

print(
    df["retention"]
      .value_counts()
)
Important: The exact retention definition should follow the institution’s official reporting rules. The simplified code shown here is intended to illustrate the workflow rather than reproduce restricted institutional logic.

Section 03

Feature Engineering

Raw institutional fields are transformed into features that can be used by a machine-learning model. Examples include degree status, age at entry, athletic participation, football participation, cumulative GPA, credits, and demographic indicators.

Construct demographic and enrollment features Python
# Degree-seeking indicator

df["degree_nondegree"] = (
    df["degree_nondegree"]
      .map({
          "D": 1,
          "N": 0
      })
)


# Convert birth date to a datetime value

df["student_dob"] = pd.to_datetime(
    df["student_dob"],
    errors="coerce"
)


# Calculate approximate age at entry

df["entry_date"] = pd.to_datetime(
    df["entry_date"],
    errors="coerce"
)

df["age_at_entry"] = (
    (
        df["entry_date"]
        - df["student_dob"]
    ).dt.days
    / 365.25
)


# Combine multiple varsity-sport indicators

sport_columns = [
    "varsity_sports_a",
    "varsity_sports_b",
    "varsity_sports_c",
    "varsity_sports_d"
]

df["athlete"] = (
    df[sport_columns]
      .fillna(0)
      .max(axis=1)
      .astype(int)
)


# Selected variables for the retention model

model_columns = [
    "student_key",
    "retention",
    "female",
    "age_at_entry",
    "cum_gpa",
    "total_credit_hours",
    "athlete",
    "football",
    "degree_nondegree"
]

df = df[model_columns].copy()

Section 04

Integrating Geographic and Financial-Aid Data

Student ZIP-code information is cleaned and merged with a geographic reference file. Financial-aid records are also categorized and merged into the student-semester dataset to provide additional predictors of retention.

Clean and merge ZIP-code information Python
import re


# Standardize permanent ZIP codes

df["perm_zip_code"] = (
    df["perm_zip_code"]
      .astype(str)
      .str.strip()
)


# Keep the first five numeric characters

df["ir_zip"] = (
    df["perm_zip_code"]
      .str.extract(
          r"(\d{5})",
          expand=False
      )
)


# Load ZIP-code coordinates and distance measures

zip_reference = pd.read_csv(
    "us_zip_codes.csv",
    dtype={"ZIP": str}
)


# Merge geographic information

df = df.merge(
    zip_reference[
        [
            "ZIP",
            "LAT",
            "LNG",
            "distance"
        ]
    ],
    left_on="ir_zip",
    right_on="ZIP",
    how="left"
)


# Use a fixed value for international locations

international_mask = (
    df["perm_zip_code"]
      .str.contains(
          r"[A-Za-z]",
          na=False
      )
)

df.loc[
    international_mask,
    "distance"
] = 3000

Categorize and merge financial-aid records Python
def categorize_description(description):
    """
    Convert financial-aid descriptions into broad categories.
    """

    description = str(description).strip().lower()

    if "grant" in description:
        return 1

    if "scholarship" in description:
        return 2

    if "loan" in description:
        return 3

    return 0


financial_aid = pd.read_csv(
    "student_financial_aid.csv",
    low_memory=False
)

financial_aid["financial_categories"] = (
    financial_aid["aid_source_name"]
      .apply(categorize_description)
)


# Build student-semester merge keys

df["student_semester_key"] = (
    df["observed_semester"].astype(str)
    + "_"
    + df["student_key"].astype(str)
)

financial_aid["student_semester_key"] = (
    financial_aid["semester"].astype(str)
    + "_"
    + financial_aid["student_key"].astype(str)
)


# Merge financial-aid categories

df = df.merge(
    financial_aid[
        [
            "student_semester_key",
            "financial_categories"
        ]
    ],
    on="student_semester_key",
    how="left"
)

df["financial_categories"] = (
    df["financial_categories"]
      .fillna(0)
)

df = pd.get_dummies(
    df,
    columns=["financial_categories"],
    dtype=int
)
Data-integration principle: Merge keys should be validated before modeling. Duplicate keys, unmatched records, inconsistent semester formats, and missing ZIP codes can otherwise introduce systematic measurement error.

Section 05

Preparing Training and Testing Samples

A recent cohort is separated as a prospective prediction group. Earlier observations are used to train and evaluate the model through a stratified train-test split.

Separate the prediction cohort and split model data Python
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import MinMaxScaler


# Hold out the Fall 2023 cohort for prospective prediction

prediction_cohort = df[
    df["beginning_semester"] == 202308
].copy()


# Use prior cohorts for model development

model_df = df[
    df["beginning_semester"] != 202308
].copy()


# Remove identifiers and variables unavailable at prediction time

X = model_df.drop(
    columns=[
        "retention",
        "student_key",
        "beginning_semester"
    ]
)

y = model_df["retention"]


# Create stratified training and testing samples

X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.20,
    random_state=42,
    stratify=y
)


# Scale features using training data only

scaler = MinMaxScaler()

X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)
Leakage prevention: The scaler is fitted only on the training sample. Any preprocessing step that learns from the data should be estimated after the train-test split.

Section 06

Handling Imbalanced Retention Outcomes

Retention datasets often contain substantially more retained students than non-retained students. Synthetic oversampling methods can help the model learn patterns associated with the less common outcome.

Apply ADASYN or SMOTE to the training sample Python
from imblearn.over_sampling import (
    SMOTE,
    BorderlineSMOTE,
    ADASYN
)


# Review the original outcome distribution

print(
    y_train.value_counts()
)


# Create a balanced training sample with ADASYN

adasyn = ADASYN(
    random_state=42
)

X_train_balanced, y_train_balanced = (
    adasyn.fit_resample(
        X_train_scaled,
        y_train
    )
)


# Confirm the new class distribution

print(
    pd.Series(
        y_train_balanced
    ).value_counts()
)
Important: Oversampling must be applied only to the training data. Applying SMOTE or ADASYN before the train-test split would allow synthetic information derived from the test sample to leak into model training.

Section 07

Random Forest Retention Model

A Random Forest classifier combines many decision trees to capture nonlinear patterns and interactions among academic, demographic, geographic, athletic, and financial predictors.

Nonlinear Relationships

Trees can identify threshold effects and nonlinear associations without requiring a prespecified functional form.

Variable Interactions

The model can learn interactions between academic, financial, geographic, and demographic characteristics.

Ensemble Prediction

Predictions are aggregated across many trees, typically improving stability relative to a single decision tree.


Train the Random Forest classifier Python
from sklearn.ensemble import RandomForestClassifier


# Configure the classifier

random_forest = RandomForestClassifier(
    n_estimators=500,
    max_depth=None,
    min_samples_split=2,
    min_samples_leaf=1,
    class_weight="balanced",
    random_state=42,
    n_jobs=-1
)


# Fit the model to the balanced training sample

random_forest.fit(
    X_train_balanced,
    y_train_balanced
)


# Generate class and probability predictions

train_predictions = random_forest.predict(
    X_train_balanced
)

test_predictions = random_forest.predict(
    X_test_scaled
)

test_probabilities = random_forest.predict_proba(
    X_test_scaled
)[:, 1]

Section 08

Model Evaluation

Accuracy alone can be misleading when retention outcomes are imbalanced. The evaluation therefore includes precision, recall, F1 scores, a confusion matrix, and the area under the receiver operating characteristic curve.

Evaluate out-of-sample classification performance Python
from sklearn.metrics import (
    classification_report,
    confusion_matrix,
    accuracy_score,
    balanced_accuracy_score,
    roc_auc_score
)


# Classification report

print(
    classification_report(
        y_test,
        test_predictions,
        digits=3
    )
)


# Confusion matrix

confusion = confusion_matrix(
    y_test,
    test_predictions
)

print(
    "Confusion matrix:\n",
    confusion
)


# Summary metrics

accuracy = accuracy_score(
    y_test,
    test_predictions
)

balanced_accuracy = balanced_accuracy_score(
    y_test,
    test_predictions
)

roc_auc = roc_auc_score(
    y_test,
    test_probabilities
)

print(
    f"Accuracy: {accuracy:.3f}"
)

print(
    f"Balanced accuracy: {balanced_accuracy:.3f}"
)

print(
    f"ROC-AUC: {roc_auc:.3f}"
)
Evaluation principle: In a retention context, the cost of missing a student who may need support can differ from the cost of incorrectly flagging a retained student. Threshold selection should therefore reflect the intended institutional use of the model.

Section 09

Model Interpretability

Permutation importance measures how much predictive performance declines when the values of one feature are randomly rearranged. It provides an intuitive way to identify variables that contribute meaningfully to out-of-sample prediction.

Calculate permutation importance Python
from sklearn.inspection import permutation_importance


# Estimate importance using the untouched testing sample

importance_result = permutation_importance(
    random_forest,
    X_test_scaled,
    y_test,
    n_repeats=20,
    random_state=42,
    scoring="balanced_accuracy",
    n_jobs=-1
)


# Organize results into a readable table

importance_table = pd.DataFrame({
    "feature": X.columns,
    "importance_mean": importance_result.importances_mean,
    "importance_sd": importance_result.importances_std
})


importance_table = (
    importance_table
      .sort_values(
          "importance_mean",
          ascending=False
      )
      .reset_index(drop=True)
)


# Display the most influential predictors

print(
    importance_table.head(15)
)
Interpretation caution: Predictive importance does not establish causality. A highly important variable may improve prediction without representing a policy lever or a causal determinant of student retention.

Section 10

Prospective Prediction and Deployment

After model evaluation, the trained preprocessing objects and classifier can be applied to an incoming cohort. The model and scaler may also be saved for integration into a Streamlit application or another institutional analytics workflow.

Predict an incoming cohort and save the model Python
from joblib import dump


# Retain student identifiers before transformation

prediction_student_ids = (
    prediction_cohort["student_key"]
      .astype(str)
      .copy()
)


# Match the training feature structure

prediction_features = (
    prediction_cohort
      .reindex(
          columns=X.columns,
          fill_value=0
      )
)


# Apply training-sample scaling

prediction_features_scaled = scaler.transform(
    prediction_features
)


# Generate predicted retention probabilities

predicted_probability = random_forest.predict_proba(
    prediction_features_scaled
)[:, 1]


# Create a restricted internal output table

prediction_output = pd.DataFrame({
    "student_key": prediction_student_ids,
    "predicted_retention_probability":
        predicted_probability
})


# Save model components for deployment

dump(
    random_forest,
    "retention_random_forest.joblib"
)

dump(
    scaler,
    "retention_scaler.joblib"
)

Responsible Use and Data Privacy

The code displayed on this page has been simplified and reformatted for portfolio and educational purposes. Student-level institutional data should remain confidential and should not be distributed through a public website or public code repository. Any operational use of a retention model should include privacy safeguards, fairness evaluation, documentation, human review, and careful consideration of how predictions may affect students. Predictive scores should support—not replace—professional judgment and direct student engagement.