feat: Lifetime PD (50yr) - Belkin & Suchower + Vasicek model
- Belkin & Suchower (1998) credit cycle index (Zt) estimation via WLS - Vasicek single-factor conditional PD/TM model - Macro-Zt OLS regression with stepwise variable selection - 3-scenario (boom/neutral/recession) 50yr PD projection - Statistical validation suite (ADF, Ljung-Box, R2, ARCH) - BOK ECOS API integration with fallback data - Visualization module (7 chart types) - Detailed theoretical methodology docs/methodology.md
This commit is contained in:
1
output/__init__.py
Normal file
1
output/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Output: 시각화 및 리포트
|
||||
354
output/visualizer.py
Normal file
354
output/visualizer.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
시각화 및 리포트 모듈
|
||||
|
||||
Lifetime PD 분석 결과를 차트와 테이블로 시각화합니다.
|
||||
|
||||
차트 목록:
|
||||
1. Zt 시계열 (과거 + 예측)
|
||||
2. Zt vs 거시변수 산점도
|
||||
3. 시나리오별 Marginal PD 곡선
|
||||
4. 시나리오별 Cumulative PD 곡선
|
||||
5. 시나리오 가중평균 PD Term Structure
|
||||
6. 전이행렬 히트맵
|
||||
7. 통계 검증 결과 요약
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.ticker as mticker
|
||||
import seaborn as sns
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 한글 폰트 설정
|
||||
plt.rcParams["font.family"] = "Malgun Gothic"
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
plt.rcParams["figure.dpi"] = 150
|
||||
|
||||
# 시나리오 색상
|
||||
SCENARIO_COLORS = {
|
||||
"upside": "#2ecc71", # 초록
|
||||
"base": "#3498db", # 파랑
|
||||
"downside": "#e74c3c", # 빨강
|
||||
}
|
||||
|
||||
SCENARIO_LABELS = {
|
||||
"upside": "호황 (Upside)",
|
||||
"base": "중립 (Base)",
|
||||
"downside": "불황 (Downside)",
|
||||
}
|
||||
|
||||
|
||||
def plot_zt_timeseries(
|
||||
zt_history: Dict[int, float],
|
||||
z_paths: Dict[str, np.ndarray] = None,
|
||||
base_year: int = 2025,
|
||||
save_path: str = None
|
||||
):
|
||||
"""Zt 시계열 차트 (과거 + 시나리오별 예측)"""
|
||||
fig, ax = plt.subplots(figsize=(14, 6))
|
||||
|
||||
# 과거 Zt
|
||||
years = sorted(zt_history.keys())
|
||||
values = [zt_history[y] for y in years]
|
||||
ax.plot(years, values, "ko-", markersize=4, linewidth=1.5, label="과거 Zt (추정)")
|
||||
ax.fill_between(years, values, 0, alpha=0.15, color="gray")
|
||||
|
||||
# 시나리오별 예측
|
||||
if z_paths:
|
||||
for scenario, z_path in z_paths.items():
|
||||
future_years = list(range(base_year + 1, base_year + len(z_path) + 1))
|
||||
color = SCENARIO_COLORS.get(scenario, "gray")
|
||||
label = SCENARIO_LABELS.get(scenario, scenario)
|
||||
ax.plot(future_years, z_path, color=color, linewidth=1.5,
|
||||
linestyle="--", alpha=0.8, label=label)
|
||||
|
||||
ax.axhline(y=0, color="navy", linestyle=":", alpha=0.5, label="TTC (Z=0)")
|
||||
ax.set_xlabel("연도")
|
||||
ax.set_ylabel("Credit Cycle Index (Zt)")
|
||||
ax.set_title("Belkin & Suchower 신용사이클 인덱스 (Zt)")
|
||||
ax.legend(loc="best", fontsize=9)
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# 주요 이벤트 표시
|
||||
events = {2000: "IMF\n여파", 2003: "카드\n사태", 2008: "GFC", 2020: "COVID"}
|
||||
for yr, label in events.items():
|
||||
if yr in zt_history:
|
||||
ax.annotate(label, xy=(yr, zt_history[yr]),
|
||||
xytext=(yr, zt_history[yr] - 0.5),
|
||||
fontsize=7, ha="center", color="red",
|
||||
arrowprops=dict(arrowstyle="->", color="red", lw=0.8))
|
||||
|
||||
plt.tight_layout()
|
||||
if save_path:
|
||||
fig.savefig(save_path, bbox_inches="tight")
|
||||
logger.info(f"차트 저장: {save_path}")
|
||||
plt.close(fig)
|
||||
return fig
|
||||
|
||||
|
||||
def plot_macro_vs_zt(
|
||||
zt_series: pd.Series,
|
||||
macro_data: pd.DataFrame,
|
||||
save_path: str = None
|
||||
):
|
||||
"""거시변수 vs Zt 산점도 (회귀선 포함)"""
|
||||
n_vars = min(len(macro_data.columns), 6)
|
||||
fig, axes = plt.subplots(2, 3, figsize=(16, 10))
|
||||
axes = axes.flatten()
|
||||
|
||||
common = sorted(set(zt_series.index) & set(macro_data.index))
|
||||
|
||||
for idx, col in enumerate(macro_data.columns[:n_vars]):
|
||||
ax = axes[idx]
|
||||
x = macro_data.loc[common, col].values
|
||||
y = zt_series.loc[common].values
|
||||
|
||||
ax.scatter(x, y, color="#3498db", alpha=0.7, s=30)
|
||||
|
||||
# 회귀선
|
||||
if len(x) > 2:
|
||||
z = np.polyfit(x, y, 1)
|
||||
p = np.poly1d(z)
|
||||
x_line = np.linspace(x.min(), x.max(), 50)
|
||||
ax.plot(x_line, p(x_line), "r--", alpha=0.7, linewidth=1.2)
|
||||
|
||||
corr = np.corrcoef(x, y)[0, 1]
|
||||
ax.set_title(f"{col}\n(ρ = {corr:.3f})", fontsize=10)
|
||||
else:
|
||||
ax.set_title(col, fontsize=10)
|
||||
|
||||
ax.set_xlabel(col, fontsize=9)
|
||||
ax.set_ylabel("Zt", fontsize=9)
|
||||
ax.grid(True, alpha=0.3)
|
||||
|
||||
# 빈 서브플롯 숨기기
|
||||
for idx in range(n_vars, len(axes)):
|
||||
axes[idx].set_visible(False)
|
||||
|
||||
fig.suptitle("거시경제변수 vs 신용사이클 인덱스 (Zt)", fontsize=13, y=1.02)
|
||||
plt.tight_layout()
|
||||
if save_path:
|
||||
fig.savefig(save_path, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return fig
|
||||
|
||||
|
||||
def plot_lifetime_pd(
|
||||
results: Dict,
|
||||
pd_type: str = "cumulative",
|
||||
grades_to_show: List[str] = None,
|
||||
base_year: int = 2025,
|
||||
save_path: str = None
|
||||
):
|
||||
"""
|
||||
시나리오별 Lifetime PD 곡선
|
||||
|
||||
Parameters
|
||||
----------
|
||||
pd_type : str - "cumulative" or "marginal"
|
||||
grades_to_show : List[str] - 표시할 등급 (기본: BBB, BB, B)
|
||||
"""
|
||||
from data.transition_matrices import RATING_GRADES
|
||||
non_default = RATING_GRADES[:-1]
|
||||
|
||||
if grades_to_show is None:
|
||||
grades_to_show = ["BBB", "BB", "B"]
|
||||
|
||||
grade_indices = [non_default.index(g) for g in grades_to_show if g in non_default]
|
||||
n_grades = len(grade_indices)
|
||||
|
||||
fig, axes = plt.subplots(1, n_grades, figsize=(6 * n_grades, 5))
|
||||
if n_grades == 1:
|
||||
axes = [axes]
|
||||
|
||||
pd_key = f"{pd_type}_pd"
|
||||
|
||||
for ax_idx, (ax, gi) in enumerate(zip(axes, grade_indices)):
|
||||
grade = non_default[gi]
|
||||
|
||||
for scenario, data in results["by_scenario"].items():
|
||||
pds = data[pd_key][:, gi]
|
||||
horizon = len(pds)
|
||||
years = list(range(base_year + 1, base_year + horizon + 1))
|
||||
|
||||
color = SCENARIO_COLORS.get(scenario, "gray")
|
||||
label = SCENARIO_LABELS.get(scenario, scenario)
|
||||
ax.plot(years, pds * 100, color=color, linewidth=1.5, label=label)
|
||||
|
||||
# 가중평균
|
||||
weighted_key = f"weighted_{pd_type}_pd"
|
||||
if weighted_key in results:
|
||||
w_pds = results[weighted_key][:, gi]
|
||||
years = list(range(base_year + 1, base_year + len(w_pds) + 1))
|
||||
ax.plot(years, w_pds * 100, color="purple", linewidth=2.5,
|
||||
linestyle="-.", label="가중평균", alpha=0.8)
|
||||
|
||||
ax.set_xlabel("연도")
|
||||
ax.set_ylabel(f"{'누적' if pd_type == 'cumulative' else '한계'} PD (%)")
|
||||
ax.set_title(f"{grade} 등급")
|
||||
ax.legend(fontsize=8)
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.yaxis.set_major_formatter(mticker.FuncFormatter(lambda x, p: f"{x:.1f}%"))
|
||||
|
||||
title = f"50년 {'누적' if pd_type == 'cumulative' else '한계'} PD — 시나리오별"
|
||||
fig.suptitle(title, fontsize=13, y=1.02)
|
||||
plt.tight_layout()
|
||||
if save_path:
|
||||
fig.savefig(save_path, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return fig
|
||||
|
||||
|
||||
def plot_pd_term_structure(
|
||||
results: Dict,
|
||||
base_year: int = 2025,
|
||||
save_path: str = None
|
||||
):
|
||||
"""전 등급 가중평균 누적 PD Term Structure"""
|
||||
from data.transition_matrices import RATING_GRADES
|
||||
non_default = RATING_GRADES[:-1]
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 7))
|
||||
|
||||
weighted = results["weighted_cumulative_pd"]
|
||||
horizon = weighted.shape[0]
|
||||
years = list(range(1, horizon + 1))
|
||||
|
||||
colors = plt.cm.RdYlGn_r(np.linspace(0.1, 0.9, len(non_default)))
|
||||
|
||||
for gi, (grade, color) in enumerate(zip(non_default, colors)):
|
||||
ax.plot(years, weighted[:, gi] * 100, color=color, linewidth=1.5, label=grade)
|
||||
|
||||
ax.set_xlabel("기간 (년)")
|
||||
ax.set_ylabel("누적 PD (%)")
|
||||
ax.set_title("가중평균 누적 PD Term Structure (전 등급)", fontsize=13)
|
||||
ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left", fontsize=9)
|
||||
ax.grid(True, alpha=0.3)
|
||||
ax.set_xlim(1, horizon)
|
||||
|
||||
plt.tight_layout()
|
||||
if save_path:
|
||||
fig.savefig(save_path, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return fig
|
||||
|
||||
|
||||
def plot_transition_heatmap(
|
||||
tm: np.ndarray,
|
||||
title: str = "전이행렬",
|
||||
grades: List[str] = None,
|
||||
save_path: str = None
|
||||
):
|
||||
"""전이행렬 히트맵"""
|
||||
from data.transition_matrices import RATING_GRADES
|
||||
if grades is None:
|
||||
grades = RATING_GRADES
|
||||
|
||||
fig, ax = plt.subplots(figsize=(9, 7))
|
||||
|
||||
sns.heatmap(
|
||||
tm * 100,
|
||||
annot=True, fmt=".2f",
|
||||
xticklabels=grades, yticklabels=grades,
|
||||
cmap="YlOrRd", ax=ax,
|
||||
cbar_kws={"label": "전이확률 (%)"},
|
||||
linewidths=0.5
|
||||
)
|
||||
|
||||
ax.set_xlabel("전이 후 등급")
|
||||
ax.set_ylabel("전이 전 등급")
|
||||
ax.set_title(title, fontsize=13)
|
||||
|
||||
plt.tight_layout()
|
||||
if save_path:
|
||||
fig.savefig(save_path, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return fig
|
||||
|
||||
|
||||
def plot_validation_summary(
|
||||
validation_df: pd.DataFrame,
|
||||
save_path: str = None
|
||||
):
|
||||
"""검증 결과 요약 테이블 이미지"""
|
||||
fig, ax = plt.subplots(figsize=(14, max(3, len(validation_df) * 0.6 + 1)))
|
||||
ax.axis("off")
|
||||
ax.set_title("통계적 검증 결과 요약", fontsize=14, pad=20)
|
||||
|
||||
# 테이블 색상
|
||||
colors = []
|
||||
for _, row in validation_df.iterrows():
|
||||
if "Pass" in str(row.get("결과", "")):
|
||||
colors.append(["#d4edda"] * len(validation_df.columns))
|
||||
else:
|
||||
colors.append(["#f8d7da"] * len(validation_df.columns))
|
||||
|
||||
table = ax.table(
|
||||
cellText=validation_df.values,
|
||||
colLabels=validation_df.columns,
|
||||
cellColours=colors if colors else None,
|
||||
loc="center",
|
||||
cellLoc="center"
|
||||
)
|
||||
table.auto_set_font_size(False)
|
||||
table.set_fontsize(8)
|
||||
table.scale(1.0, 1.5)
|
||||
|
||||
plt.tight_layout()
|
||||
if save_path:
|
||||
fig.savefig(save_path, bbox_inches="tight")
|
||||
plt.close(fig)
|
||||
return fig
|
||||
|
||||
|
||||
def generate_all_plots(
|
||||
zt_history: Dict[int, float],
|
||||
z_paths: Dict[str, np.ndarray],
|
||||
zt_series_pd: pd.Series,
|
||||
macro_data: pd.DataFrame,
|
||||
pd_results: Dict,
|
||||
ttc_matrix: np.ndarray,
|
||||
validation_df: pd.DataFrame,
|
||||
output_dir: str = "results",
|
||||
base_year: int = 2025
|
||||
):
|
||||
"""모든 차트 일괄 생성"""
|
||||
out = Path(output_dir)
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger.info(f"차트 생성 중... (저장 경로: {out})")
|
||||
|
||||
# 1. Zt 시계열
|
||||
plot_zt_timeseries(zt_history, z_paths, base_year,
|
||||
str(out / "01_zt_timeseries.png"))
|
||||
|
||||
# 2. 거시변수 vs Zt
|
||||
plot_macro_vs_zt(zt_series_pd, macro_data,
|
||||
str(out / "02_macro_vs_zt.png"))
|
||||
|
||||
# 3. 누적 PD (주요 등급)
|
||||
plot_lifetime_pd(pd_results, "cumulative", ["BBB", "BB", "B"],
|
||||
base_year, str(out / "03_cumulative_pd.png"))
|
||||
|
||||
# 4. 한계 PD (주요 등급)
|
||||
plot_lifetime_pd(pd_results, "marginal", ["BBB", "BB", "B"],
|
||||
base_year, str(out / "04_marginal_pd.png"))
|
||||
|
||||
# 5. 전 등급 Term Structure
|
||||
plot_pd_term_structure(pd_results, base_year,
|
||||
str(out / "05_pd_term_structure.png"))
|
||||
|
||||
# 6. TTC 전이행렬 히트맵
|
||||
plot_transition_heatmap(ttc_matrix, "TTC 전이행렬 (장기 평균)",
|
||||
save_path=str(out / "06_ttc_heatmap.png"))
|
||||
|
||||
# 7. 검증 결과
|
||||
plot_validation_summary(validation_df,
|
||||
str(out / "07_validation_summary.png"))
|
||||
|
||||
logger.info(f"총 7개 차트 생성 완료 → {out}/")
|
||||
Reference in New Issue
Block a user