feat(model): KAP YTM PD floor integration, expanded 226-var search, ADF fix (AIC->BIC), Model#2 with 6-test diagnostics

- Replace hardcoded DEFAULT_PD_FLOORS with build_complete_pd_floor_table() (KAP bond YTM)
- Fix ADF test: autolag='AIC' -> 'BIC' for small sample (N=26) robustness
- Expand variable search: 40 -> 226 vars (log/diff/return/lag2), 1.9M combos
- Select Model #2: HOUSING_PRICE + CREDIT_SPREAD_LAG1 + CURRENT_ACCOUNT_R
- Add 6-test diagnostics table to AR1 sheet (ADF/LB/DW/BP/ARCH/Shapiro)
- Add Korean variable names for transformed variables
- Generate report v7 with full diagnostics
This commit is contained in:
Variet Agent
2026-03-12 00:06:23 +09:00
parent 87725b7c19
commit d1ddf06e5d
19 changed files with 1736 additions and 167 deletions

View File

@@ -1,17 +1,16 @@
"""
거시경제 변수 ↔ Zt 연계 통계모형
거시경제 변수 ↔ Zt 연계 AR(1) + Macro 모형
Zt(신용사이클 인덱스)를 거시경제변수로 설명하는 회귀모형을 구축하고,
미래 거시 시나리오에 따른 Zt 전망을 생성합니다.
Zt(신용사이클 인덱스)를 거시경제변수와 자기회귀 항으로 설명하는 모형.
모형:
Z_t = β₀ + β₁·GDP_growth + β₂·Unemployment + β₃·Base_Rate
+ β₄·CD_Rate + β₅·CPI_growth + β₆·Leading_Index + ε_t
AR(1) + Macro 모형:
Z(t) = c + phi*Z(t-1) + beta1*X1(t) + beta2*X2(t) + beta3*X3(t) + eps(t)
방법론 참고:
- IMF (2021). "IFRS 9 and CECL Compatible Estimation for Top-Down Solvency Stress Testing"
- ECB (2019). "Scenario Design for IFRS 9 Expected Credit Loss Estimation"
- Fed (2022). "Dodd-Frank Act Stress Test Methodology"
- Moody's Analytics: Z-score macro regression scenario forecast
- Zanders Group: Vasicek Z macro regression PiT transition matrix
- EBA/ECB: Forward-looking macro overlay on Z-index
- IFRS 9 B5.5.42-44
"""
import numpy as np
@@ -45,7 +44,15 @@ class MacroZtModel:
self.model = None
self.result = None
self.selected_vars = None
self.scaler_params = {} # 정규화 파라미터
self.scaler_params = {}
# AR(1) attributes
self.ar1_phi = None
self.ar1_const = None
self.ar1_beta = None
self.ar1_sigma_eps = None
self.ar1_macro_stats = {}
self.is_ar1 = False
self._ar1_var_names = None
def fit(
self,
@@ -253,16 +260,23 @@ class MacroZtModel:
try:
X = self.result.model.exog
vif_values = {}
var_names = ["const"] + self.selected_vars
for i in range(X.shape[1]):
if self.is_ar1 and self._ar1_var_names:
var_names = self._ar1_var_names
else:
var_names = ["const"] + self.selected_vars
for i in range(min(X.shape[1], len(var_names))):
vif_values[var_names[i]] = variance_inflation_factor(X, i)
diag["vif"] = vif_values
except Exception:
diag["vif"] = {}
# 계수 요약
if self.is_ar1 and self._ar1_var_names:
coef_names = self._ar1_var_names
else:
coef_names = ["const"] + self.selected_vars
coef_df = pd.DataFrame({
"변수": ["const"] + self.selected_vars,
"변수": coef_names,
"계수": self.result.params,
"표준오차": self.result.bse,
"t값": self.result.tvalues,
@@ -283,36 +297,201 @@ class MacroZtModel:
if self.result is None:
return np.array([])
return self.result.resid
# ================================================================
# AR(1) + Macro 모형
# ================================================================
def fit_ar1(
self,
zt_series: pd.Series,
macro_data: pd.DataFrame,
forced_vars: Optional[List[str]] = None
) -> "MacroZtModel":
"""
AR(1) + Macro 모형 적합
Z(t) = c + phi*Z(t-1) + beta1*X1(t) + beta2*X2(t) + beta3*X3(t) + eps(t)
Z(t-1)을 설명변수에 포함하여 OLS로 추정합니다.
Parameters
----------
zt_series : pd.Series
index=연도, values=Zt 추정값
macro_data : pd.DataFrame
index=연도, columns=거시변수
forced_vars : List[str], optional
강제 지정 거시변수
"""
self.is_ar1 = True
# 인덱스 정렬 및 교집합
common_years = sorted(set(zt_series.index) & set(macro_data.index))
if len(common_years) < 5:
raise ValueError(f"공통 데이터 포인트가 부족합니다: {len(common_years)}")
# AR(1) 구성: Z(t)와 Z(t-1) 쌍
zt_full = zt_series.loc[common_years].sort_index()
years = list(zt_full.index)
# t-1이 필요하므로 첫 해 제외
y_years = years[1:]
y = zt_full.loc[y_years].values.astype(float)
z_lag = zt_full.loc[years[:-1]].values.astype(float)
# 거시변수
X_macro = macro_data.loc[y_years].copy()
X_macro = X_macro.ffill().bfill().dropna(axis=1)
# 변수 선택
if forced_vars:
available = [v for v in forced_vars if v in X_macro.columns]
if len(available) != len(forced_vars):
missing = set(forced_vars) - set(available)
logger.warning(f"AR(1) 강제 지정 변수 중 누락: {missing}")
self.selected_vars = available
else:
self.selected_vars = list(X_macro.columns)
# 거시변수 표본 통계 저장 (시나리오 충격용)
for col in self.selected_vars:
col_data = macro_data[col].dropna()
self.ar1_macro_stats[col] = {
"mean": float(col_data.mean()),
"std": float(col_data.std()),
"last": float(col_data.iloc[-1]) if len(col_data) > 0 else 0.0
}
# 거시변수 표준화 (mean=0, std=1)
# → β가 "1σ 충격의 Z 영향"으로 직접 해석됨
# → 절편 c가 장기 평균 Z 수준을 결정
X_std = X_macro[self.selected_vars].copy()
for col in self.selected_vars:
mean = self.ar1_macro_stats[col]["mean"]
std = self.ar1_macro_stats[col]["std"]
if std > 0:
X_std[col] = (X_std[col] - mean) / std
else:
X_std[col] = 0.0
# 설계행렬: [const, Z(t-1), X1_std(t), X2_std(t), X3_std(t)]
X_design = np.column_stack([
z_lag,
X_std.values
])
X_with_const = sm.add_constant(X_design)
# OLS 적합
self.model = sm.OLS(y, X_with_const)
self.result = self.model.fit()
# AR(1) 파라미터 추출
# params: [const, phi, beta1, beta2, ...]
params = self.result.params
self.ar1_const = params[0]
self.ar1_phi = params[1]
self.ar1_beta = {}
for i, col in enumerate(self.selected_vars):
self.ar1_beta[col] = params[2 + i]
self.ar1_sigma_eps = float(np.std(self.result.resid))
# 변수명 저장 (진단용)
self._ar1_var_names = ["const", "Z_lag1"] + self.selected_vars
# 정상성 체크
if abs(self.ar1_phi) > 0 and abs(self.ar1_phi) < 1:
half_life = np.log(2) / abs(np.log(abs(self.ar1_phi)))
else:
half_life = np.inf
logger.info(f"AR(1)+Macro 적합 완료:")
logger.info(f" phi = {self.ar1_phi:.4f} (반감기 = {half_life:.1f}년)")
logger.info(f" c = {self.ar1_const:.4f}")
for col, beta in self.ar1_beta.items():
logger.info(f" beta({col}) = {beta:+.6f}")
logger.info(f" R2 = {self.result.rsquared:.4f}, "
f"Adj.R2 = {self.result.rsquared_adj:.4f}")
if abs(self.ar1_phi) >= 1.0:
logger.warning(f" phi={self.ar1_phi:.4f} >= 1.0 -> non-stationary!")
return self
def forecast_z_path(
self,
z_last: float,
macro_shocks: Dict[str, float],
horizon: int = 50
) -> np.ndarray:
"""
AR(1) 모형으로 미래 Z 경로 생성
t=1: Z = c + phi*Z(t0) + sum(beta_i * shock_i * sigma_i)
t>=2: Z = c + phi*Z(t-1) (거시 충격 없음, AR 감쇠만)
Parameters
----------
z_last : float
마지막 관측 Z(t0)
macro_shocks : Dict[str, float]
변수별 충격 (sigma 배수)
예: {"USDKRW": +1.5, "RETAIL_SALES": -1.5}
horizon : int
예측 기간 (년)
Returns
-------
np.ndarray : [Z(t0+1), ..., Z(t0+horizon)]
"""
if not self.is_ar1:
raise ValueError("AR(1) 모형이 적합되지 않았습니다.")
z_path = np.zeros(horizon)
z_prev = z_last
for t in range(horizon):
z_next = self.ar1_const + self.ar1_phi * z_prev
# t=0 (첫 해)만 거시 충격 적용
# β는 표준화된 변수 기준이므로 shock_sigma가 곧 β의 배수
if t == 0:
for var, shock_sigma in macro_shocks.items():
if var in self.ar1_beta:
beta = self.ar1_beta[var]
z_next += beta * shock_sigma
z_path[t] = z_next
z_prev = z_next
return z_path
def build_macro_zt_model(
zt_dict: Dict[int, float],
macro_df: pd.DataFrame,
method: str = "stepwise_aic",
method: str = "ar1_macro",
forced_vars: Optional[List[str]] = None
) -> MacroZtModel:
"""
편의 함수: Zt 딕셔너리 + 거시 DataFrame 회귀모형 구축
편의 함수: Zt + 거시 DataFrame -> 회귀모형 구축
Parameters
----------
zt_dict : Dict[int, float]
{연도: Zt값}
macro_df : pd.DataFrame
index=연도, columns=거시변수
method : str
변수 선택 방법
forced_vars : List[str], optional
강제 지정 변수 (지정 시 method 무시)
Returns
-------
MacroZtModel : 적합된 모형
zt_dict : {연도: Zt값}
macro_df : index=연도, columns=거시변수
method : "ar1_macro" (기본) | "stepwise_aic" | "all"
forced_vars : 강제 지정 변수
"""
zt_series = pd.Series(zt_dict, name="Zt")
zt_series.index.name = "YEAR"
model = MacroZtModel()
model.fit(zt_series, macro_df, method=method, forced_vars=forced_vars)
if method == "ar1_macro":
model.fit_ar1(zt_series, macro_df, forced_vars=forced_vars)
else:
model.fit(zt_series, macro_df, method=method, forced_vars=forced_vars)
return model