SECTION 1: LEARNING OBJECTIVES
By the end of this lesson, you will be able to:
-
Calculate Expected Loss (EL) using PD, LGD, and EAD for the loan portfolio.
-
Calculate Economic Capital using the Vasicek model.
-
Apply the selected model to predict PD for the entire portfolio.
-
Perform stress testing using macroeconomic scenarios.
-
Quantify the business impact – reduced defaults, cost savings, revenue uplift.
-
Calculate ROI for the digital lending transformation project.
-
Present findings to stakeholders in a clear, actionable format.
SECTION 2: RISK CALCULATION FRAMEWORK
2.1 Key Risk Metrics
| Metric | Formula | Description |
|---|---|---|
| Probability of Default (PD) | Model prediction | Likelihood of default within 1 year. |
| Loss Given Default (LGD) | 1 – Recovery Rate | Proportion of exposure lost on default. |
| Exposure at Default (EAD) | Loan amount + undrawn commitments | Total exposure at default. |
| Expected Loss (EL) | PD × LGD × EAD | Average loss expected. |
| Unexpected Loss (UL) | Vasicek model | Capital required to cover losses at 99.9% confidence. |
2.2 Vasicek Model for Economic Capital
UL99.9%=LGD×EAD×[Φ(Φ−1(PD)+ρΦ−1(0.999)1−ρ)−PD]
where:
-
Φ = standard normal CDF
-
ρ = asset correlation (0.12 for corporate, 0.04 for retail)
2.3 Stress Testing
Scenarios:
-
Baseline: Current economic conditions.
-
Adverse: Moderate recession (GDP -2%, unemployment +3%).
-
Severely Adverse: Severe recession (GDP -5%, unemployment +6%).
-
Regulatory: CCAR/DFAST prescribed scenarios.
SECTION 3: IMPLEMENTATION IN PYTHON – RISK ANALYTICS
# =================================================================== # MODULE 9, LESSON 4: RISK ANALYTICS, STRESS TESTING, AND BUSINESS IMPACT # =================================================================== import numpy as np import pandas as pd import matplotlib.pyplot as plt import seaborn as sns from scipy.stats import norm import joblib import warnings warnings.filterwarnings('ignore') # Set style sns.set_style("whitegrid") np.random.seed(42) print("="*70) print("CAPSTONE PROJECT – RISK ANALYTICS AND STRESS TESTING") print("="*70) # ---------------------------------------------------------------- # PART A: LOAD DATA AND MODEL # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART A: Loading Data and Model") print("-"*60) # Load the test data (using full dataset for risk calculation) loan_apps = pd.read_csv('loan_applications.csv') feature_names = ['applicant_age', 'income', 'credit_score', 'dti', 'loan_amount', 'loan_term', 'employment_years', 'home_owner', 'marital_status', 'education', 'loan_to_income', 'dti_credit_interaction', 'dti_squared', 'loan_amount_log'] # Create features (same as before) df = loan_apps.copy() df['loan_to_income'] = df['loan_amount'] / df['income'] df['dti_credit_interaction'] = df['dti'] * df['credit_score'] / 1000 df['dti_squared'] = df['dti'] ** 2 df['loan_amount_log'] = np.log(df['loan_amount'] + 1) # One-hot encode categorical features X_full = pd.get_dummies(df[feature_names], columns=['marital_status', 'education']) # Load the best model (XGBoost) model = joblib.load('xgboost_model.pkl') scaler = joblib.load('scaler.pkl') # Standardise X_full_scaled = scaler.transform(X_full) # Predict PDs pd_pred = model.predict_proba(X_full_scaled)[:, 1] df['pd'] = pd_pred print(f"PD predictions generated for {len(df)} loans.") print(f"Average PD: {df['pd'].mean():.4f}") print(f"Median PD: {df['pd'].median():.4f}") # ---------------------------------------------------------------- # PART B: EXPECTED LOSS CALCULATION # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART B: Expected Loss Calculation") print("-"*60) # Assign LGD based on home_owner (secured vs unsecured) # Home owners generally have lower LGD (secured by property) df['lgd'] = np.where(df['home_owner'] == 1, np.random.normal(0.35, 0.08, len(df)).clip(0.10, 0.70), np.random.normal(0.65, 0.10, len(df)).clip(0.20, 0.90)) # EAD = loan_amount (for term loans) df['ead'] = df['loan_amount'] # Expected Loss df['el'] = df['pd'] * df['lgd'] * df['ead'] # Portfolio totals total_exposure = df['ead'].sum() total_el = df['el'].sum() avg_pd = df['pd'].mean() avg_lgd = df['lgd'].mean() print(f"Portfolio Summary:") print(f" Total Exposure (EAD): ${total_exposure:,.2f}") print(f" Total Expected Loss (EL): ${total_el:,.2f}") print(f" EL as % of Exposure: {total_el/total_exposure*100:.2f}%") print(f" Average PD: {avg_pd:.4f}") print(f" Average LGD: {avg_lgd:.4f}") # EL by segment el_by_purpose = df.groupby('purpose').agg({ 'el': 'sum', 'ead': 'sum', 'pd': 'mean', 'lgd': 'mean', 'default': 'mean' }).sort_values('el', ascending=False) print("\nEL by Loan Purpose:") print(el_by_purpose.round(4)) # ---------------------------------------------------------------- # PART C: ECONOMIC CAPITAL (VASICEK MODEL) # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART C: Economic Capital (Vasicek Model)") print("-"*60) def vasicek_ul(pd, lgd, ead, rho=0.12, confidence=0.999): """ Calculate unexpected loss using the Vasicek model. """ z = norm.ppf(confidence) pd_adj = norm.cdf((norm.ppf(pd) + np.sqrt(rho) * z) / np.sqrt(1 - rho)) ul = lgd * (pd_adj - pd) * ead return np.maximum(ul, 0) # Calculate UL for each loan df['ul'] = vasicek_ul(df['pd'], df['lgd'], df['ead']) total_ul = df['ul'].sum() print(f"Total Unexpected Loss (99.9%): ${total_ul:,.2f}") print(f"UL as % of Exposure: {total_ul/total_exposure*100:.2f}%") print(f"Capital Buffer Required: ${total_ul:,.2f}") # ---------------------------------------------------------------- # PART D: STRESS TESTING # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART D: Stress Testing") print("-"*60) def apply_macro_stress(df, gdp_shock, unemp_shock): """ Apply macroeconomic shocks to PD calculations. """ df_stressed = df.copy() # Simulate stress impact on PD # GDP growth reduces PD; unemployment increases PD # Scale factor based on the model's sensitivity to macro factors pd_shift = -1.5 * gdp_shock + 3.0 * unemp_shock # Apply shift in log-odds space log_odds = np.log(df_stressed['pd'] / (1 - df_stressed['pd'])) log_odds_shifted = log_odds + pd_shift df_stressed['pd_stressed'] = 1 / (1 + np.exp(-log_odds_shifted)) df_stressed['pd_stressed'] = df_stressed['pd_stressed'].clip(0.001, 0.999) df_stressed['el_stressed'] = df_stressed['pd_stressed'] * df_stressed['lgd'] * df_stressed['ead'] return df_stressed # Define scenarios scenarios = { 'Baseline': {'gdp': 0.0, 'unemp': 0.0, 'description': 'Current economic conditions'}, 'Adverse': {'gdp': -0.02, 'unemp': 0.03, 'description': 'Moderate recession'}, 'Severely Adverse': {'gdp': -0.05, 'unemp': 0.06, 'description': 'Severe recession'}, 'Regulatory (CCAR)': {'gdp': -0.04, 'unemp': 0.04, 'description': 'Regulatory stress scenario'}, } # Run stress tests stress_results = [] for name, params in scenarios.items(): df_stressed = apply_macro_stress(df, params['gdp'], params['unemp']) el_stressed = df_stressed['el_stressed'].sum() pd_avg_stressed = df_stressed['pd_stressed'].mean() stress_results.append({ 'Scenario': name, 'Description': params['description'], 'EL': el_stressed, 'EL Increase %': (el_stressed / total_el - 1) * 100, 'Average PD': pd_avg_stressed }) stress_df = pd.DataFrame(stress_results) print("Stress Test Results:") print(stress_df.round(2).to_string(index=False)) # Visualise stress test results fig, axes = plt.subplots(1, 2, figsize=(14, 5)) # EL by scenario ax = axes[0] scenario_names = stress_df['Scenario'].tolist() el_values = stress_df['EL'].values / 1e6 ax.bar(scenario_names, el_values, color=['green', 'orange', 'red', 'purple'], alpha=0.7) ax.set_xlabel('Scenario') ax.set_ylabel('Expected Loss ($M)') ax.set_title('Expected Loss Under Stress Scenarios') for i, v in enumerate(el_values): ax.text(i, v + 0.1, f'${v:.1f}M', ha='center', va='bottom', fontweight='bold') ax.grid(True, alpha=0.3) # PD distribution under stress ax = axes[1] for scenario in ['Baseline', 'Adverse', 'Severely Adverse']: df_stressed = apply_macro_stress(df, scenarios[scenario]['gdp'], scenarios[scenario]['unemp']) ax.hist(df_stressed['pd_stressed'], bins=30, alpha=0.4, label=scenario, density=True) ax.set_xlabel('Probability of Default') ax.set_ylabel('Density') ax.set_title('PD Distribution Under Stress') ax.legend() ax.grid(True, alpha=0.3) plt.tight_layout() plt.savefig('stress_test_results.png', dpi=300, bbox_inches='tight') plt.show() print("Stress test visualisation saved as 'stress_test_results.png'") # ---------------------------------------------------------------- # PART E: BUSINESS IMPACT AND ROI # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART E: Business Impact and ROI Calculation") print("-"*60) # Assumptions (simulated) current_default_rate = 0.06 # 6% current default rate new_default_rate = df['default'].mean() # Model-based default rate reduction_in_defaults = current_default_rate - new_default_rate # Portfolio size portfolio_size = total_exposure # Benefits reduced_losses = portfolio_size * reduction_in_defaults cost_savings = 2_000_000 # $2M annual operational savings revenue_uplift = 3_000_000 # $3M additional revenue from faster approvals # Investment total_investment = 5_000_000 # $5M project cost # Annual benefits annual_benefits = reduced_losses + cost_savings + revenue_uplift # ROI over 3 years roi_3yr = (annual_benefits * 3 - total_investment) / total_investment * 100 print(f"Business Impact Summary:") print(f" Current Default Rate: {current_default_rate*100:.2f}%") print(f" New Default Rate: {new_default_rate*100:.2f}%") print(f" Reduction in Defaults: {reduction_in_defaults*100:.2f}%") print(f" Portfolio Size: ${portfolio_size:,.2f}") print(f" Reduced Losses (Annual): ${reduced_losses:,.2f}") print(f" Cost Savings (Annual): ${cost_savings:,.2f}") print(f" Revenue Uplift (Annual): ${revenue_uplift:,.2f}") print(f" Total Annual Benefits: ${annual_benefits:,.2f}") print(f" Project Investment: ${total_investment:,.2f}") print(f" 3-Year ROI: {roi_3yr:.1f}%") # ---------------------------------------------------------------- # PART F: EXECUTIVE SUMMARY # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART F: Executive Summary for Stakeholders") print("-"*60) executive_summary = f""" --- EXECUTIVE SUMMARY: DIGITAL LENDING TRANSFORMATION --- 1. PROJECT SUMMARY Atlantic Bank's digital lending transformation project has successfully implemented an AI-powered underwriting platform for personal loans. 2. MODEL PERFORMANCE - Best Model: XGBoost (tuned) - AUC: {auc_best:.3f} - KS Statistic: {ks_best:.3f} - Calibration: Within acceptable limits 3. PORTFOLIO RISK - Total Exposure: ${total_exposure:,.0f} - Average PD: {avg_pd*100:.2f}% - Expected Loss: ${total_el:,.0f} - Economic Capital (99.9%): ${total_ul:,.0f} 4. STRESS TEST RESULTS - Adverse Scenario: EL increases by {stress_df[stress_df['Scenario']=='Adverse']['EL Increase %'].values[0]:.1f}% - Severely Adverse: EL increases by {stress_df[stress_df['Scenario']=='Severely Adverse']['EL Increase %'].values[0]:.1f}% 5. BUSINESS IMPACT - Default Rate Reduction: {reduction_in_defaults*100:.2f}% - Annual Benefits: ${annual_benefits:,.0f} - 3-Year ROI: {roi_3yr:.1f}% 6. RECOMMENDATIONS - Deploy the XGBoost model for internal risk assessment. - Use Logistic Regression for regulatory submissions. - Implement SHAP for model explainability. - Continue monitoring for drift and model performance. 7. NEXT STEPS - Phase 2: Commercial loan expansion - Phase 3: Integration with other lending products """ print(executive_summary) # ---------------------------------------------------------------- # PART G: SAVE RESULTS # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART G: Saving Results") print("-"*60) # Save risk results df[['application_id', 'pd', 'lgd', 'ead', 'el', 'ul', 'default']].to_csv('risk_results.csv', index=False) stress_df.to_csv('stress_results.csv', index=False) print("Risk results saved as 'risk_results.csv'") print("Stress results saved as 'stress_results.csv'") print("Executive summary generated.") print("="*70) print("END OF LESSON 4 – MODULE 9") print("="*70)
SECTION 4: SUMMARY FOR THE DATA PRACTITIONER
-
Expected Loss (EL) = PD × LGD × EAD – calculated for the entire portfolio.
-
Economic Capital = Unexpected Loss (UL) using the Vasicek model at 99.9% confidence.
-
Stress testing shows the portfolio’s resilience under adverse scenarios.
-
Business impact includes reduced defaults, cost savings, and revenue uplift.
-
ROI of 3-year project is positive, justifying the investment.
-
Executive summary communicates key findings to stakeholders.
SECTION 5: RECOMMENDED NEXT STEPS
-
Review the risk analytics and stress test results.
-
Prepare for Lesson 5: Model Deployment and Monitoring.
-
Consider additional scenarios for stress testing.
-
Explore the use of SHAP for model explainability.