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