Credit Risk Fundamentals › Practical Implementation: Portfolio Credit Analysis

Michael BrenndoerferDecember 21, 20257 min read
Quantitative FinanceAll chapters
Part 10 of 15Risk Management and Financial Risk Controls
Chapter 54Credit Risk Fundamentals › Practical Implementation: Portfolio Credit Analysis
Reading section 11 of 15 · Practical Implementation: Portfolio Credit Analysis · 7 minSection 11 of 15 · 7 min

Book contents

Section 11 of 15 · 7 min read

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:

In[32]:
Code
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(),
        }
In[33]:
Code
# 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()
Out[34]:
Console
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.

In[35]:
Code
# 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
]
Out[36]:
Visualization
Histogram of simulated portfolio losses on a logarithmic density scale. Most simulations cluster near zero, while sparse losses extend beyond $40M; vertical reference lines mark expected loss near $1.0M and 99% VaR near $13.6M.
Portfolio credit loss distribution from 10,000 Monte Carlo simulations. A logarithmic density axis makes the long right tail visible; the 99th-percentile loss lies far beyond expected loss because correlated defaults create rare but severe outcomes.
Out[37]:
Visualization
Marked line with shaded area showing 99% Credit VaR increasing from about $8.8M at 5% default correlation to $15.8M at 45%; the curve rises throughout but gradually flattens.
Sensitivity of Credit VaR to default correlation assumptions. In this portfolio model, 99% VaR rises steadily from about $8.8M at 5% correlation to $15.8M at 45%, with smaller marginal increases at higher 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.

Out[38]:
Visualization
Bar chart showing 50 borrowers sorted by exposure size from largest to smallest, illustrating the log-normal distribution of loan sizes in the simulated portfolio.
Exposure distribution across the portfolio, sorted by borrower size. A small number of large exposures account for a significant portion of total credit risk, illustrating high concentration and the potential impact of individual borrower defaults on overall stability.
Out[39]:
Visualization
Lorenz curve beginning at zero and remaining below the perfect-equality diagonal as borrowers are accumulated from smallest to largest exposure; the shaded gap widens through the middle of the distribution before closing at 100%.
Lorenz curve of portfolio exposure, computed by accumulating borrowers from smallest to largest exposure. Its position below the equality diagonal and the shaded gap quantify concentration in a small number of large loans.