Files
LifetimePD/output/visualizer.py
Variet Agent 3a9374c61a 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
2026-03-10 21:57:34 +09:00

355 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
시각화 및 리포트 모듈
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}/")