- 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
355 lines
11 KiB
Python
355 lines
11 KiB
Python
"""
|
||
시각화 및 리포트 모듈
|
||
|
||
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}/")
|