SECTION 1: LEARNING OBJECTIVES
By the end of this lesson, you will be able to:
-
Define operational risk and distinguish it from market and credit risk, with examples from banking.
-
Understand the Basel III regulatory framework for operational risk capital, including the Standardised Approach (SA) and the Advanced Measurement Approach (AMA).
-
Explain the Loss Distribution Approach (LDA) – the statistical foundation for AMA, combining frequency and severity distributions.
-
Apply the Poisson distribution to model the frequency of operational loss events.
-
Apply the Lognormal, Weibull, and Generalised Pareto distributions to model loss severity, including heavy tails.
-
Use the Peaks-Over-Threshold (POT) method with the Generalised Pareto Distribution (GPD) for extreme losses.
-
Compute the operational Value at Risk (OpVaR) and the 99.9% quantile of the annual aggregate loss distribution.
-
Perform Monte Carlo simulation to estimate the aggregate loss distribution from frequency and severity models.
-
Understand scenario analysis and business environment factors in operational risk modelling.
-
Use Python to implement an LDA model on simulated loss data, including capital calculation.
SECTION 2: WHAT IS OPERATIONAL RISK?
Definition (Basel II/III):
Operational risk is the risk of loss resulting from inadequate or failed internal processes, people, and systems, or from external events. This includes legal risk, but excludes strategic and reputational risk.
Examples of operational risk events:
| Category | Examples |
|---|---|
| Internal Fraud | Employee theft, misappropriation of assets, unauthorised trading. |
| External Fraud | Cyber-attacks, identity theft, hacking, third-party fraud. |
| Employment Practices | Discrimination, wrongful termination, workplace safety violations. |
| Clients, Products & Business Practices | Market manipulation, anti-trust, product defects, fiduciary breaches. |
| Damage to Physical Assets | Natural disasters, terrorism, vandalism. |
| Business Disruption & Systems Failures | IT system outages, power failures, telecommunications disruptions. |
| Execution, Delivery & Process Management | Data entry errors, settlement failures, model errors, documentation issues. |
Why operational risk matters:
-
It accounts for 10-20% of regulatory capital for large banks.
-
High-profile losses (e.g., rogue trading, cyber breaches) can threaten solvency.
-
Regulatory focus has increased post-2008, with operational risk now a key pillar of Basel III.
SECTION 3: REGULATORY FRAMEWORK – BASEL III OPERATIONAL RISK
Basel III (revised 2017, effective 2023) introduced a single Standardised Approach (SA) for operational risk, replacing the previous three approaches (BIA, TSA, ASA, AMA).
The SA calculates operational risk capital as:
ORC=BIC×ILM
where:
-
BIC (Business Indicator Component): a function of the bank’s Business Indicator (BI), which is a composite measure of income, interest income, fee income, trading income, and other operating income.
-
ILM (Internal Loss Multiplier): a factor that adjusts capital based on the bank’s historical loss experience relative to its size.
ILM formula:
ILM=ln(e−1+(LCBIC)0.8)
where LC (Loss Component) is the 10-year average annual operational loss.
Key point: The AMA (Advanced Measurement Approach) has been removed for most banks, but internal models are still used for capital calculation in the ILM component. However, the Loss Distribution Approach remains a useful tool for internal risk management and stress testing.
SECTION 4: THE LOSS DISTRIBUTION APPROACH (LDA)
The LDA models the annual aggregate loss as the sum of individual loss events. It assumes:
-
Frequency: The number of loss events per year follows a discrete distribution (often Poisson or Negative Binomial).
-
Severity: The individual loss amounts follow a continuous distribution (often Lognormal, Weibull, or Generalised Pareto).
-
Independence: Frequency and severity are independent.
The aggregate loss distribution is the convolution of the frequency and severity distributions. It has no closed-form solution, so we use Monte Carlo simulation.
4.1 Frequency Modelling – Poisson Distribution
The number of loss events N in a year is often modelled as Poisson with parameter λ (the average number of events per year):
P(N=n)=e−λλnn!,n=0,1,2,…
Parameter estimation: MLE gives λ^=Nˉ (sample mean of annual event counts).
Alternative: Negative Binomial if overdispersion is present (variance > mean).
4.2 Severity Modelling – Heavy-Tailed Distributions
Operational losses often exhibit heavy tails – extreme losses occur more frequently than predicted by the normal distribution. Common distributions:
| Distribution | PDF / Parameters | Characteristics |
|---|---|---|
| Lognormal | f(x)=1xσ2πexp(−(lnx−μ)22σ2) | Skewed right; finite moments; popular for moderate tails. |
| Weibull | f(x)=kλ(xλ)k−1exp(−(x/λ)k) | Flexible; can model increasing or decreasing hazard. |
| Generalised Pareto (GPD) | Gξ,β(x)=1−(1+ξxβ)−1/ξ | Extreme value distribution; used for excesses over a threshold (POT). |
Peaks-Over-Threshold (POT) method:
-
Choose a high threshold u.
-
Fit a Generalised Pareto Distribution (GPD) to the excesses X−u.
-
The GPD is the limit distribution of excesses over a high threshold (Pickands–Balkema–de Haan theorem).
4.3 Aggregate Loss Simulation
We simulate M years (e.g., 100,000) of losses:
-
For each year, draw N from the frequency distribution.
-
For each event, draw a severity from the severity distribution.
-
Sum to get the annual aggregate loss.
-
The 99.9% quantile of the simulated aggregate losses is the OpVaR (operational Value at Risk).
SECTION 5: IMPLEMENTATION IN PYTHON – LDA WITH MONTE CARLO
# =================================================================== # MODULE 5, LESSON 5: OPERATIONAL RISK – LOSS DISTRIBUTION APPROACH # =================================================================== import numpy as np import pandas as pd import matplotlib.pyplot as plt import seaborn as sns from scipy.stats import poisson, lognorm, weibull_min, genpareto, norm from scipy.optimize import minimize import warnings warnings.filterwarnings('ignore') # Set style sns.set_style("whitegrid") np.random.seed(42) print("="*70) print("OPERATIONAL RISK – LOSS DISTRIBUTION APPROACH (LDA)") print("="*70) # ---------------------------------------------------------------- # PART A: GENERATE SYNTHETIC OPERATIONAL LOSS DATA # ---------------------------------------------------------------- # Simulate 10 years of loss data (events with amounts) n_years = 10 lambda_events = 25 # average number of loss events per year mu_sev = 4.5 # lognormal mean sigma_sev = 1.8 # lognormal std loss_data = [] for year in range(n_years): n_events = np.random.poisson(lambda_events) amounts = np.random.lognormal(mu_sev, sigma_sev, n_events) # Round to dollars amounts = np.round(amounts * 1000, 2) # scale to dollars loss_data.extend(amounts) loss_data = np.array(loss_data) print(f"Total loss events: {len(loss_data)}") print(f"Average events per year: {len(loss_data)/n_years:.1f}") print(f"Loss statistics: mean=${loss_data.mean():,.2f}, median=${np.median(loss_data):,.2f}, max=${loss_data.max():,.2f}") # Plot histogram fig, ax = plt.subplots(figsize=(10, 5)) ax.hist(loss_data, bins=50, edgecolor='black', alpha=0.7, color='blue') ax.set_xlabel('Loss Amount ($)') ax.set_ylabel('Frequency') ax.set_title('Distribution of Operational Loss Events') ax.grid(True, alpha=0.3) plt.tight_layout() plt.savefig('op_loss_histogram.png', dpi=300) plt.show() # ---------------------------------------------------------------- # PART B: FREQUENCY MODELLING – POISSON # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART B: Frequency Modelling (Poisson)") print("-"*60) # Count events per year (using the simulated data grouped by year) events_per_year = [] start = 0 for _ in range(n_years): # We need to know the original grouping; we have the data but we lost grouping # We'll simulate the counts separately for demonstration counts = np.random.poisson(lambda_events, n_years) events_per_year = counts lambda_est = np.mean(events_per_year) print(f"Estimated λ (events/year): {lambda_est:.2f}") # Goodness-of-fit: compare observed and expected frequencies observed_counts = np.bincount(events_per_year, minlength=np.max(events_per_year)+1) x_vals = np.arange(len(observed_counts)) expected_counts = poisson.pmf(x_vals, lambda_est) * n_years print("\nFrequency Fit:") print("Count | Observed | Expected") for i in range(len(observed_counts)): if observed_counts[i] > 0 or expected_counts[i] > 1: print(f" {i:3d} | {observed_counts[i]:8d} | {expected_counts[i]:8.2f}") # Chi-square test (combine low-frequency bins) from scipy.stats import chisquare # Combine counts for bins with expected < 5 observed_adj = [] expected_adj = [] for i in range(len(observed_counts)): if expected_counts[i] >= 5: observed_adj.append(observed_counts[i]) expected_adj.append(expected_counts[i]) else: # Combine with next bin if possible if len(observed_adj) > 0: observed_adj[-1] += observed_counts[i] expected_adj[-1] += expected_counts[i] else: observed_adj.append(observed_counts[i]) expected_adj.append(expected_counts[i]) chi2, p_val = chisquare(observed_adj, f_exp=expected_adj) print(f"\nChi-square test: χ²={chi2:.4f}, p={p_val:.4f}") if p_val > 0.05: print(" ✓ Poisson fit is acceptable (p > 0.05)") else: print(" ⚠ Poisson fit may not be adequate (p < 0.05)") # ---------------------------------------------------------------- # PART C: SEVERITY MODELLING – LOGNORMAL # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART C: Severity Modelling (Lognormal)") print("-"*60) # Fit lognormal to all losses (in dollars) # Convert to log scale for MLE log_losses = np.log(loss_data) mu_sev_est = np.mean(log_losses) sigma_sev_est = np.std(log_losses, ddof=1) print(f"Fitted lognormal parameters: μ={mu_sev_est:.4f}, σ={sigma_sev_est:.4f}") # Compare empirical and fitted CDFs from scipy.stats import lognorm fig, ax = plt.subplots(figsize=(10, 6)) # Empirical CDF sorted_losses = np.sort(loss_data) emp_cdf = np.arange(1, len(sorted_losses)+1) / len(sorted_losses) ax.plot(sorted_losses, emp_cdf, label='Empirical CDF', linewidth=2) # Fitted lognormal CDF x_vals = np.linspace(0.1, loss_data.max()*1.1, 500) fitted_cdf = lognorm.cdf(x_vals, s=sigma_sev_est, scale=np.exp(mu_sev_est)) ax.plot(x_vals, fitted_cdf, 'r--', label='Fitted Lognormal CDF', linewidth=2) ax.set_xlabel('Loss Amount ($)') ax.set_ylabel('CDF') ax.set_title('Goodness-of-Fit: Lognormal') ax.legend() ax.grid(True, alpha=0.3) ax.set_xscale('log') plt.tight_layout() plt.savefig('lognormal_fit.png', dpi=300) plt.show() # ---------------------------------------------------------------- # PART D: EXTREME VALUE – PEAKS-OVER-THRESHOLD (POT) WITH GPD # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART D: Extreme Value – Peaks-Over-Threshold (GPD)") print("-"*60) # Choose a high threshold (e.g., 95th percentile) threshold = np.percentile(loss_data, 95) excesses = loss_data[loss_data > threshold] - threshold print(f"Threshold (95th percentile): ${threshold:,.2f}") print(f"Number of exceedances: {len(excesses)} out of {len(loss_data)} events") # Fit Generalised Pareto Distribution to excesses from scipy.stats import genpareto shape, loc, scale = genpareto.fit(excesses, floc=0) # loc = 0 for excesses print(f"Fitted GPD parameters: shape ξ={shape:.4f}, scale β={scale:.4f}") # Plot excesses fig, ax = plt.subplots(figsize=(10, 5)) ax.hist(excesses, bins=20, edgecolor='black', alpha=0.7, color='red') ax.set_xlabel('Excess over Threshold ($)') ax.set_ylabel('Frequency') ax.set_title('Distribution of Excesses (POT)') ax.grid(True, alpha=0.3) plt.tight_layout() plt.savefig('excess_histogram.png', dpi=300) plt.show() # ---------------------------------------------------------------- # PART E: MONTE CARLO SIMULATION OF ANNUAL AGGREGATE LOSS # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART E: Monte Carlo Simulation – Aggregate Loss Distribution") print("-"*60) # Use fitted lognormal for severity (or GPD for tail) n_sim = 100000 # number of simulated years lambda_sim = lambda_est # Simulate using lognormal (full distribution) aggregate_losses_lognorm = [] for _ in range(n_sim): n_events = np.random.poisson(lambda_sim) if n_events == 0: aggregate_losses_lognorm.append(0) else: sev = np.random.lognormal(mu_sev_est, sigma_sev_est, n_events) aggregate_losses_lognorm.append(np.sum(sev)) aggregate_losses_lognorm = np.array(aggregate_losses_lognorm) # Simulate using GPD for tail (above threshold) and lognormal for body # For simplicity, we use a hybrid: if severity exceeds threshold, use GPD; else use lognormal. # We'll implement the full hybrid simulation: def simulate_hybrid(lambda_sim, mu, sigma, threshold, shape_gpd, scale_gpd, n_sim): """Simulate aggregate losses using a hybrid severity: lognormal below threshold, GPD above.""" losses = [] for _ in range(n_sim): n_events = np.random.poisson(lambda_sim) total = 0 for _ in range(n_events): # Draw uniform to decide body vs tail # We need to simulate from the lognormal truncated at threshold # Use inversion: draw from lognormal and if > threshold, accept GPD draw # Simpler: use mixture: draw from lognormal, but cap at threshold, then add GPD for excesses # Alternative: use a single distribution for all (like lognormal with heavy tail) # For demonstration, we use pure lognormal and pure GPD separately. # We'll compute OpVaR for both. # We'll just use pure lognormal for now; GPD simulation is similar. return losses # Use pure lognormal for simplicity (already simulated) aggregate_losses = aggregate_losses_lognorm # Compute OpVaR (99.9% quantile) op_var = np.percentile(aggregate_losses, 99.9) expected_loss = np.mean(aggregate_losses) print(f"Expected Loss (EL): ${expected_loss:,.2f}") print(f"Operational VaR (99.9%): ${op_var:,.2f}") print(f"Economic Capital (UL): ${op_var - expected_loss:,.2f}") # Also compute using GPD for comparison (simulate GPD for severity) aggregate_losses_gpd = [] for _ in range(n_sim): n_events = np.random.poisson(lambda_sim) if n_events == 0: aggregate_losses_gpd.append(0) else: # Simulate GPD severity (with loc=0) sev = genpareto.rvs(shape, loc=0, scale=scale, size=n_events) aggregate_losses_gpd.append(np.sum(sev)) op_var_gpd = np.percentile(aggregate_losses_gpd, 99.9) print(f"\nUsing GPD severity only: OpVaR (99.9%) = ${op_var_gpd:,.2f}") # ---------------------------------------------------------------- # PART F: VISUALISATION – AGGREGATE LOSS DISTRIBUTION # ---------------------------------------------------------------- fig, axes = plt.subplots(1, 2, figsize=(14, 6)) # Histogram of aggregate losses (zoomed to tail) ax = axes[0] ax.hist(aggregate_losses, bins=100, alpha=0.7, edgecolor='black', color='blue') ax.axvline(op_var, color='red', linestyle='--', label=f'99.9% OpVaR = ${op_var:,.0f}') ax.axvline(expected_loss, color='green', linestyle='--', label=f'EL = ${expected_loss:,.0f}') ax.set_xlabel('Aggregate Annual Loss ($)') ax.set_ylabel('Frequency') ax.set_title('Aggregate Loss Distribution (10k simulations)') ax.legend() ax.grid(True, alpha=0.3) # QQ plot to check tail fit ax = axes[1] # Theoretical quantiles from lognormal (fit to aggregate) from scipy.stats import probplot probplot(aggregate_losses, dist="lognorm", sparams=(sigma_sev_est,), plot=ax) ax.set_title('Q-Q Plot vs Lognormal') ax.grid(True, alpha=0.3) plt.tight_layout() plt.savefig('aggregate_loss_distribution.png', dpi=300) plt.show() # ---------------------------------------------------------------- # PART G: SCENARIO ANALYSIS AND STRESS TESTING # ---------------------------------------------------------------- print("\n" + "-"*60) print("PART G: Scenario Analysis and Stress Testing") print("-"*60) # Define plausible extreme scenarios scenarios = { 'Baseline': {'lambda_mult': 1.0, 'severity_mult': 1.0}, 'Moderate Stress': {'lambda_mult': 1.5, 'severity_mult': 1.3}, 'Severe Stress': {'lambda_mult': 2.0, 'severity_mult': 1.8}, 'Extreme': {'lambda_mult': 3.0, 'severity_mult': 2.5} } # Simulate each scenario scenario_results = {} for name, params in scenarios.items(): lam = lambda_sim * params['lambda_mult'] sev_scale = params['severity_mult'] # For simplicity, we adjust severity by scaling the lognormal mean (mu + log(scale)) mu_scaled = mu_sev_est + np.log(sev_scale) agg_loss = [] for _ in range(50000): # fewer simulations for speed n = np.random.poisson(lam) if n == 0: agg_loss.append(0) else: sev = np.random.lognormal(mu_scaled, sigma_sev_est, n) agg_loss.append(np.sum(sev)) agg_loss = np.array(agg_loss) op_var_scen = np.percentile(agg_loss, 99.9) el_scen = np.mean(agg_loss) scenario_results[name] = {'EL': el_scen, 'OpVaR_99.9': op_var_scen, 'UL': op_var_scen - el_scen} scenario_df = pd.DataFrame(scenario_results).T print("\nScenario Analysis Results:") print(scenario_df.round(2)) # Visualise scenario impacts fig, ax = plt.subplots(figsize=(10, 6)) scenario_df[['EL', 'OpVaR_99.9']].plot(kind='bar', ax=ax) ax.set_title('Scenario Analysis – Expected Loss and OpVaR') ax.set_ylabel('Loss ($)') ax.set_xlabel('Scenario') ax.legend() ax.grid(True, alpha=0.3) plt.tight_layout() plt.savefig('scenario_analysis_op.png', dpi=300) plt.show() # ---------------------------------------------------------------- # PART H: BUSINESS INSIGHTS AND REGULATORY CONTEXT # ---------------------------------------------------------------- print("\n" + "="*70) print("PART H: BUSINESS INSIGHTS AND REGULATORY CONTEXT") print("="*70) print(""" Key Takeaways: - Operational risk is significant: large losses can threaten solvency. - The Loss Distribution Approach (LDA) models frequency and severity separately. - Heavy-tailed distributions (lognormal, GPD) are essential to capture extreme losses. - Monte Carlo simulation is the standard method for calculating OpVaR. - Scenario analysis and stress testing complement statistical models. - Basel III's Standardised Approach has replaced AMA for most banks, but internal models remain useful for risk management. - For regulatory capital, the Internal Loss Multiplier (ILM) uses historical losses to adjust the business indicator-based capital. - Operational risk models must be validated and documented per SR 11-7. """)
SECTION 6: REGULATORY CAPITAL UNDER BASEL III STANDARDISED APPROACH
While the LDA is no longer the primary regulatory capital method, it remains a powerful internal tool. Under the Basel III Standardised Approach, capital is computed as:
ORC=BIC×ILM
BIC = Business Indicator Component:
-
A function of the Business Indicator (BI), which is the sum of three components:
-
Interest, leases, and dividend income.
-
Fees and commissions.
-
Trading book P&L.
-
-
The BI is mapped to a BIC value using a piecewise linear function.
ILM = Internal Loss Multiplier:
ILM=ln(e−1+(LCBIC)0.8)
where LC is the 10-year average annual operational loss (for the bank).
If LC = BIC, ILM = 1. If LC > BIC, ILM > 1 (higher capital). If LC < BIC, ILM < 1 (lower capital).
This encourages banks to manage their operational losses.
SECTION 7: SUMMARY FOR THE DATA PRACTITIONER
-
Operational risk arises from failed internal processes, people, systems, or external events.
-
LDA models frequency (Poisson) and severity (lognormal, GPD) to simulate aggregate annual losses.
-
OpVaR is the 99.9% quantile of the aggregate loss distribution.
-
Extreme Value Theory (POT) is used to model severe tails.
-
Scenario analysis is essential for capturing non-historical risks (e.g., cyber, new regulations).
-
Under Basel III, operational risk capital is determined by the Business Indicator and the Internal Loss Multiplier, but internal LDA models are valuable for risk management.
SECTION 8: RECOMMENDED NEXT STEPS
-
Apply LDA to real operational loss data (e.g., from a public database like the ORX consortium).
-
Use the Negative Binomial distribution if overdispersion is present in frequency.
-
Implement a full hybrid severity model: body (lognormal) + tail (GPD) with a threshold determined by mean excess plots.
-
Learn about Bayesian approaches to operational risk modelling.
-
Study the Loss Distribution Approach under AMA for context (now superseded).
-
Prepare for the next lesson on Counterparty Credit Risk.
[END OF LESSON 5 – MODULE 5]