""" Belkin & Suchower (1998) 신용사이클 인덱스 Zt 추정 모듈 핵심 방법론: X_i = √ρ · Z + √(1-ρ) · Y_i 여기서: X_i: 차입자 i의 신용도 변화 (표준정규) Z: 체계적 요인 (credit cycle index, 표준정규) Y_i: 개별적 요인 (표준정규, Z와 독립) ρ: 자산상관계수 TTC 전이행렬의 누적확률 임계값을 Φ⁻¹로 변환한 후, 관측 연도별 전이행렬과 모델 전이행렬 사이의 WLS를 최소화하여 Zt 추정. 참고문헌: - Belkin, B., Suchower, S., & Forest, L.R. (1998). "A One-Parameter Representation of Credit Risk and Transition Matrices" - Basel Committee on Banking Supervision (2005). "An Explanatory Note on the Basel II IRB Risk Weight Functions" """ import numpy as np from scipy.stats import norm from scipy.optimize import minimize_scalar, minimize from typing import Dict, Tuple, Optional import logging logger = logging.getLogger(__name__) def compute_thresholds(ttc_matrix: np.ndarray) -> np.ndarray: """ TTC 전이행렬에서 등급 경계 임계값(thresholds) 산출 각 시작등급 i에 대해, 누적 전이확률의 역정규분포로 임계값 산출: d_{i,j} = Φ⁻¹(Σ_{k≤j} p̄_{i,k}) Parameters ---------- ttc_matrix : np.ndarray N×N TTC 전이행렬 (행 합 = 1) Returns ------- np.ndarray N×N 임계값 행렬 (마지막 열은 항상 +∞) """ n = ttc_matrix.shape[0] thresholds = np.full((n, n), np.inf) for i in range(n): cum_prob = 0.0 for j in range(n - 1): cum_prob += ttc_matrix[i, j] # 누적확률을 [1e-10, 1-1e-10] 범위로 클리핑 (Φ⁻¹ 발산 방지) cum_prob_clipped = np.clip(cum_prob, 1e-10, 1.0 - 1e-10) thresholds[i, j] = norm.ppf(cum_prob_clipped) return thresholds def model_transition_prob( thresholds: np.ndarray, z: float, rho: float, i: int, j: int ) -> float: """ Z 조건부 전이확률 계산 p_{ij}(Z) = Φ((d_{i,j} - √ρ·Z) / √(1-ρ)) - Φ((d_{i,j-1} - √ρ·Z) / √(1-ρ)) Parameters ---------- thresholds : np.ndarray - 임계값 행렬 z : float - 신용사이클 인덱스 rho : float - 자산상관계수 i : int - 시작 등급 인덱스 j : int - 목표 등급 인덱스 Returns ------- float : 조건부 전이확률 """ sqrt_rho = np.sqrt(rho) sqrt_1_rho = np.sqrt(1.0 - rho) # 상한 임계값 # 논문: P(j|Z) = Φ((d_upper + √ρ·Z)/√(1-ρ)) - Φ((d_lower + √ρ·Z)/√(1-ρ)) # Z>0 (호황) → 누적확률 증가 → 상위등급 확률↑, 부도확률↓ d_upper = thresholds[i, j] upper = norm.cdf((d_upper + sqrt_rho * z) / sqrt_1_rho) # 하한 임계값 (j=0이면 -∞) if j == 0: lower = 0.0 else: d_lower = thresholds[i, j - 1] lower = norm.cdf((d_lower + sqrt_rho * z) / sqrt_1_rho) return max(upper - lower, 0.0) def model_transition_matrix( thresholds: np.ndarray, z: float, rho: float ) -> np.ndarray: """ Z 조건부 전체 전이행렬 산출 """ n = thresholds.shape[0] tm = np.zeros((n, n)) for i in range(n - 1): # D행은 흡수상태 for j in range(n): tm[i, j] = model_transition_prob(thresholds, z, rho, i, j) # 행 합 정규화 (수치오차 보정) row_sum = tm[i].sum() if row_sum > 0: tm[i] /= row_sum # D행: 흡수상태 tm[-1, -1] = 1.0 return tm def zt_objective( z: float, observed_tm: np.ndarray, thresholds: np.ndarray, rho: float, weights: Optional[np.ndarray] = None ) -> float: """ Zt 추정을 위한 WLS 목적함수 minimize_Z Σ_{i,j} w_{ij} * (p_{ij}^{obs} - p_{ij}^{model}(Z))² Parameters ---------- z : float - 신용사이클 인덱스 후보값 observed_tm : np.ndarray - 관측된 전이행렬 thresholds : np.ndarray - TTC 임계값 rho : float - 자산상관계수 weights : np.ndarray - 가중치 행렬 (기본: 부도열에 높은 가중치) """ n = observed_tm.shape[0] if weights is None: # 가중치: 부도열(D)에 10배 가중, 대각에 5배, 나머지 1배 weights = np.ones((n, n)) weights[:, -1] = 10.0 # 부도 전이확률에 높은 가중 for i in range(n): weights[i, i] = 5.0 # 잔류 확률에도 가중 wss = 0.0 for i in range(n - 1): # D행 제외 for j in range(n): p_obs = observed_tm[i, j] p_model = model_transition_prob(thresholds, z, rho, i, j) wss += weights[i, j] * (p_obs - p_model) ** 2 return wss def estimate_zt( observed_tm: np.ndarray, thresholds: np.ndarray, rho: float, z_bounds: Tuple[float, float] = (-4.0, 4.0) ) -> float: """ 단일 연도의 Zt 추정 scipy.optimize.minimize_scalar로 WLS 목적함수 최소화 Parameters ---------- observed_tm : np.ndarray - 해당 연도 관측 전이행렬 thresholds : np.ndarray - TTC 임계값 rho : float - 자산상관계수 z_bounds : tuple - Z 탐색 범위 Returns ------- float : 추정된 Zt 값 """ result = minimize_scalar( zt_objective, bounds=z_bounds, method="bounded", args=(observed_tm, thresholds, rho) ) return result.x def estimate_zt_series( transition_matrices: Dict[int, np.ndarray], ttc_matrix: np.ndarray, rho: float = 0.20 ) -> Dict[int, float]: """ 전체 기간에 대한 Zt 시계열 추정 Parameters ---------- transition_matrices : Dict[int, np.ndarray] 연도별 관측 전이행렬 ttc_matrix : np.ndarray TTC 전이행렬 rho : float 자산상관계수 Returns ------- Dict[int, float] {연도: Zt값} 딕셔너리 """ logger.info("TTC 전이행렬에서 임계값 산출 중...") thresholds = compute_thresholds(ttc_matrix) zt_series = {} years = sorted(transition_matrices.keys()) logger.info(f"Zt 시계열 추정 중 ({years[0]}-{years[-1]}, rho={rho})...") for year in years: observed_tm = transition_matrices[year] z_hat = estimate_zt(observed_tm, thresholds, rho) zt_series[year] = z_hat logger.debug(f" {year}: Zt = {z_hat:+.4f}") logger.info(f"Zt 추정 완료. 범위: [{min(zt_series.values()):.3f}, {max(zt_series.values()):.3f}]") return zt_series def estimate_rho_and_zt( transition_matrices: Dict[int, np.ndarray], ttc_matrix: np.ndarray, rho_bounds: Tuple[float, float] = (0.05, 0.50) ) -> Tuple[float, Dict[int, float]]: """ 자산상관계수 ρ와 Zt 시계열 동시 추정 (NLS) 총 목적함수 = Σ_t Σ_{i,j} w_{ij} * (p_{ij,t}^{obs} - p_{ij,t}^{model}(Z_t(ρ), ρ))² 외부 루프: ρ 탐색 내부 루프: 각 연도별 Zt 추정 (ρ 고정) Returns ------- Tuple[float, Dict[int, float]] (최적 ρ, Zt 시계열) """ years = sorted(transition_matrices.keys()) def total_objective(rho): thresholds = compute_thresholds(ttc_matrix) total_wss = 0.0 for year in years: observed_tm = transition_matrices[year] z_hat = estimate_zt(observed_tm, thresholds, rho) total_wss += zt_objective(z_hat, observed_tm, thresholds, rho) return total_wss logger.info(f"ρ 동시 추정 중 (범위: {rho_bounds})...") result = minimize_scalar(total_objective, bounds=rho_bounds, method="bounded") optimal_rho = result.x logger.info(f"최적 ρ = {optimal_rho:.4f}") # 최적 ρ로 Zt 재추정 zt_series = estimate_zt_series(transition_matrices, ttc_matrix, optimal_rho) return optimal_rho, zt_series