Credit Risk Fundamentals › Practical Implementation: Portfolio Credit Analysis
Quantitative FinanceAll chapters Credit Risk Fundamentals › Practical Implementation: Portfolio Credit AnalysisOne section · Sections in this chapter11 / 15 ↓
Reading tools Definitions & inline links
Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.
Article links
Make inline references clickable
Let's build a more complete credit risk analysis tool that incorporates the concepts we've covered. This implementation brings together PD, LGD, and EAD estimation with portfolio-level risk aggregation, showing how the theoretical framework translates into practical applications:
from scipy import stats
class CreditPortfolio:
"""
A class for analyzing credit risk in a loan portfolio.
"""
def __init__(self, loans_df):
"""
Initialize with a DataFrame containing:
- 'name': Borrower identifier
- 'ead': Exposure at default
- 'pd': Probability of default
- 'lgd': Loss given default
"""
self.loans = loans_df.copy()
self._calculate_metrics()
def _calculate_metrics(self):
"""Calculate EL and UL for each loan."""
self.loans["el"] = (
self.loans["pd"] * self.loans["lgd"] * self.loans["ead"]
)
# Assume 25% LGD volatility for UL calculation
lgd_vol = 0.25
self.loans["ul"] = self.loans.apply(
lambda row: (
row["ead"]
* np.sqrt(
row["pd"] * (1 - row["pd"]) * row["lgd"] ** 2
+ row["pd"] * lgd_vol**2
)
),
axis=1,
)
def total_expected_loss(self):
"""Return total portfolio expected loss."""
return self.loans["el"].sum()
def total_unexpected_loss(self, correlation=0.20):
"""Return total portfolio unexpected loss with given default correlation."""
uls = self.loans["ul"].values
n = len(uls)
variance = (uls**2).sum()
for i in range(n):
for j in range(i + 1, n):
variance += 2 * correlation * uls[i] * uls[j]
return np.sqrt(variance)
def credit_var(self, confidence=0.99, correlation=0.20):
"""
Estimate Credit VaR at given confidence level.
Uses normal approximation: VaR ≈ EL + z × UL
"""
z = stats.norm.ppf(confidence)
el = self.total_expected_loss()
ul = self.total_unexpected_loss(correlation)
return el + z * ul
def concentration_analysis(self):
"""Analyze portfolio concentration by exposure."""
total_ead = self.loans["ead"].sum()
self.loans["ead_share"] = self.loans["ead"] / total_ead
# Herfindahl-Hirschman Index
hhi = (self.loans["ead_share"] ** 2).sum()
effective_n = 1 / hhi # Effective number of equally-sized loans
return {
"hhi": hhi,
"effective_n": effective_n,
"largest_exposure_share": self.loans["ead_share"].max(),
}
def summary_report(self):
"""Generate comprehensive portfolio summary."""
return {
"total_ead": self.loans["ead"].sum(),
"total_el": self.total_expected_loss(),
"total_ul_20pct_corr": self.total_unexpected_loss(0.20),
"credit_var_99": self.credit_var(0.99, 0.20),
"el_rate_bps": self.total_expected_loss()
/ self.loans["ead"].sum()
* 10000,
"concentration": self.concentration_analysis(),
}# Create a larger, more realistic portfolio
np.random.seed(42)
n_loans = 50
loan_data = {
"name": [f"Borrower_{i:02d}" for i in range(n_loans)],
"ead": np.random.lognormal(
mean=15, sigma=1.2, size=n_loans
), # Log-normal exposures
"pd": np.random.choice(
[0.0006, 0.0018, 0.0115, 0.0465],
size=n_loans,
p=[0.20, 0.40, 0.30, 0.10],
), # Rating-based PDs
"lgd": np.random.uniform(0.35, 0.55, size=n_loans), # LGD variation
}
loan_portfolio = pd.DataFrame(loan_data)
credit_model = CreditPortfolio(loan_portfolio)
report = credit_model.summary_report()Portfolio Credit Risk Summary Report ======================================================= Exposure Profile: Number of Loans: 50 Total EAD: $230.6M Largest Exposure Share: 13.1% Effective # of Loans: 18.4 Credit Risk Metrics: Expected Loss: $0.95M EL Rate: 41.4 bps Unexpected Loss (ρ=20%): $4.73M 99% Credit VaR: $11.95M
The summary report provides a snapshot of the portfolio's risk profile. While the Expected Loss represents the average cost of credit (approximately 23 basis points), the Unexpected Loss and Credit VaR figures reveal the potential tail risk. The 99% Credit VaR indicates that in a 1-in-100 year adverse scenario, losses could exceed the expected level by a significant margin, driven by the correlation between borrowers. This VaR figure informs capital requirements: you need sufficient capital to absorb losses up to this level while remaining solvent.
# Monte Carlo simulation of portfolio losses
def simulate_portfolio_losses(portfolio_model, n_sims=10000, correlation=0.20):
"""Simulate portfolio losses using a simple one-factor model."""
np.random.seed(123)
loans = portfolio_model.loans
n_loans = len(loans)
# Factor model: each borrower's default depends on:
# - Common factor (systematic risk)
# - Idiosyncratic factor
losses = np.zeros(n_sims)
for sim in range(n_sims):
# Common factor
z_common = np.random.normal()
for _, loan in loans.iterrows():
# Idiosyncratic factor
z_idio = np.random.normal()
# Combined latent variable
z = (
np.sqrt(correlation) * z_common
+ np.sqrt(1 - correlation) * z_idio
)
# Default if latent variable exceeds threshold
threshold = stats.norm.ppf(1 - loan["pd"])
if z > threshold:
# Simulate LGD with some randomness
realized_lgd = np.clip(
loan["lgd"] + 0.15 * np.random.randn(), 0.1, 0.95
)
losses[sim] += loan["ead"] * realized_lgd
return losses
# Run simulation
simulated_losses = simulate_portfolio_losses(credit_model)
# Sensitivity analysis: VaR vs correlation
correlations = np.arange(0.05, 0.50, 0.05)
var_99_values = [
credit_model.credit_var(0.99, corr) / 1e6 for corr in correlations
]

The simulation results demonstrate the heavy-tailed nature of credit losses. While expected loss provides a central estimate, the 99th percentile loss can be many times higher, especially when default correlation is significant. In this sensitivity analysis, doubling the correlation assumption from 10% to 20% increases VaR from about $10M to $12M, or roughly 20%. VaR continues to rise as correlation increases, although each additional five-percentage-point step has a smaller marginal effect. Because capital estimates remain materially sensitive to correlation, correlation assumptions and their model risk require close monitoring.

