kr4phy's picture
Sync from GitHub
cff6ac7
Raw
History Blame Contribute Delete
11.3 kB
"""
SHAP 기반 XAI (μ„€λͺ… κ°€λŠ₯ν•œ AI) λͺ¨λ“ˆ.
XGBoost λΆ„λ₯˜ λͺ¨λΈμ„ ν›ˆλ ¨ν•˜μ—¬ 학ꡐ μƒνƒœ(정상/주의/μœ„ν—˜/κ³ μœ„ν—˜)λ₯Ό λΆ„λ₯˜ν•˜κ³ ,
SHAP TreeExplainer 둜 각 ν”Όμ²˜μ˜ 기여도λ₯Ό μ‚°μΆœν•©λ‹ˆλ‹€.
ν”Όμ²˜ λͺ©λ‘:
- student_count_trend : 졜근 Nλ…„ 학생 수 μΆ”μ„Έ (μ„ ν˜• 기울기)
- student_count_latest : μ΅œμ‹  학생 수
- teacher_student_ratio : ꡐ사 1인당 학생 수
- temp_teacher_ratio : κΈ°κ°„μ œ ꡐ원 λΉ„μœ¨
- transfer_net_avg : μ „μΆœμž… 순 λ³€ν™” 평균
- facility_age : μ‹œμ„€ 노후도 (ν˜„μž¬ 연도 - 개ꡐ 연도)
- population_risk_index : μ§€μ—­ μ†Œλ©Έ μœ„ν—˜ μ§€μˆ˜ (μ—¬μ„± 20~39μ„Έ / 전체 노인 인ꡬ)
- data_quality_score : 데이터 신뒰도 점수
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import numpy as np
import pandas as pd
logger = logging.getLogger(__name__)
# μƒνƒœ λ ˆμ΄λΈ”
STATUS_LABELS = {0: "정상", 1: "주의", 2: "μœ„ν—˜", 3: "κ³ μœ„ν—˜"}
STATUS_THRESHOLDS = {
"student_count_latest": [60, 30, 10], # > 60 β†’ 정상, 30~60 β†’ 주의, ...
}
FEATURE_COLS = [
"student_count_trend",
"student_count_latest",
"teacher_student_ratio",
"temp_teacher_ratio",
"transfer_net_avg",
"facility_age",
"population_risk_index",
"data_quality_score",
]
FEATURE_KR_NAMES = {
"student_count_trend": "학생 수 μΆ”μ„Έ",
"student_count_latest": "ν˜„μž¬ 학생 수",
"teacher_student_ratio": "ꡐ사 1인당 학생 수",
"temp_teacher_ratio": "κΈ°κ°„μ œ ꡐ원 λΉ„μœ¨",
"transfer_net_avg": "μ „μΆœμž… 순 λ³€ν™”",
"facility_age": "μ‹œμ„€ 노후도",
"population_risk_index": "μ§€μ—­ μ†Œλ©Έ μœ„ν—˜ μ§€μˆ˜",
"data_quality_score": "데이터 신뒰도",
}
@dataclass
class ShapResult:
"""SHAP 뢄석 κ²°κ³Ό μ»¨ν…Œμ΄λ„ˆ."""
schul_code: str
status_label: str
status_code: int
shap_values: dict[str, float]
top_factors: list[dict[str, Any]]
base_value: float = 0.0
model_accuracy: float | None = None
def to_dict(self) -> dict[str, Any]:
return {
"schul_code": self.schul_code,
"status_label": self.status_label,
"status_code": self.status_code,
"shap_values": self.shap_values,
"top_factors": self.top_factors,
"base_value": self.base_value,
"model_accuracy": self.model_accuracy,
}
def _compute_features(school_series: dict[str, Any]) -> dict[str, float]:
"""
단일 학ꡐ 데이터 λ”•μ…”λ„ˆλ¦¬μ—μ„œ SHAP ν”Όμ²˜λ₯Ό κ³„μ‚°ν•©λ‹ˆλ‹€.
Parameters
----------
school_series:
ν‚€λŠ” ν”Όμ²˜ 이름, 값은 ν•΄λ‹Ή μ§€ν‘œ. 연도별 μ‹œκ³„μ—΄μ€
"student_count_history" (list[float]) ν˜•νƒœλ‘œ μ „λ‹¬ν•©λ‹ˆλ‹€.
Returns
-------
dict[str, float]
FEATURE_COLS 에 ν•΄λ‹Ήν•˜λŠ” ν”Όμ²˜ κ°’ λ”•μ…”λ„ˆλ¦¬.
"""
features: dict[str, float] = {}
# 학생 수 νžˆμŠ€ν† λ¦¬μ—μ„œ μΆ”μ„Έ(기울기) μ‚°μΆœ
history = school_series.get("student_count_history", [])
if len(history) >= 2:
x = np.arange(len(history), dtype=float)
slope = float(np.polyfit(x, history, 1)[0])
features["student_count_trend"] = slope
features["student_count_latest"] = float(history[-1])
elif len(history) == 1:
features["student_count_trend"] = 0.0
features["student_count_latest"] = float(history[-1])
else:
features["student_count_trend"] = 0.0
features["student_count_latest"] = float(school_series.get("student_count", 0) or 0)
# ꡐ사 1인당 학생 수
teacher_count = float(school_series.get("teacher_count", 1) or 1)
student_count = features["student_count_latest"]
features["teacher_student_ratio"] = student_count / max(teacher_count, 1)
# κΈ°κ°„μ œ ꡐ원 λΉ„μœ¨
temp_teacher = float(school_series.get("temp_teacher_count", 0) or 0)
features["temp_teacher_ratio"] = temp_teacher / max(teacher_count, 1)
# μ „μΆœμž… 순 λ³€ν™” 평균
features["transfer_net_avg"] = float(school_series.get("transfer_net_avg", 0) or 0)
# μ‹œμ„€ 노후도
established = int(school_series.get("established_year", 0) or 0)
current_year = pd.Timestamp.now().year
features["facility_age"] = float(current_year - established) if established > 0 else float("nan")
# μ§€μ—­ μ†Œλ©Έ μœ„ν—˜ μ§€μˆ˜ (μ—¬μ„± 20~39μ„Έ / (65μ„Έ 이상 인ꡬ * 0.5))
women_20_39 = float(school_series.get("region_women_20_39", 0) or 0)
elderly = float(school_series.get("region_elderly_pop", 1) or 1)
features["population_risk_index"] = women_20_39 / max(elderly * 0.5, 1)
# 데이터 신뒰도
features["data_quality_score"] = float(school_series.get("data_quality_score", 1.0) or 1.0)
return features
def _rule_based_status(features: dict[str, float]) -> int:
"""
κ·œμΉ™ 기반 μƒνƒœ λΆ„λ₯˜ (ν•™μŠ΅ 데이터 λΆ€μ‘± μ‹œ fallback).
Returns
-------
int
0=정상, 1=주의, 2=μœ„ν—˜, 3=κ³ μœ„ν—˜
"""
student = features.get("student_count_latest", 100)
trend = features.get("student_count_trend", 0)
risk = features.get("population_risk_index", 1.0)
score = 0
if student < 10:
score += 3
elif student < 30:
score += 2
elif student < 60:
score += 1
if trend < -10:
score += 2
elif trend < -5:
score += 1
if risk < 0.5:
score += 1
return min(score, 3)
class ShapExplainer:
"""
XGBoost + SHAP TreeExplainer 기반 학ꡐ μƒνƒœ λΆ„λ₯˜ 및 μ„€λͺ… μ—”μ§„.
- ν›ˆλ ¨ 데이터가 μΆ©λΆ„ν•˜λ©΄ XGBoost λ₯Ό ν•™μŠ΅ν•˜κ³  SHAP 값을 μ‚°μΆœν•©λ‹ˆλ‹€.
- ν›ˆλ ¨ 데이터가 λΆ€μ‘±ν•˜λ©΄ κ·œμΉ™ 기반 λΆ„λ₯˜ + KernelExplainer fallback 을 μ‚¬μš©ν•©λ‹ˆλ‹€.
μ‚¬μš© 예::
explainer = ShapExplainer()
explainer.fit(train_df)
result = explainer.explain(
schul_code="7431234",
school_data={
"student_count_history": [120, 105, 98, 87, 75],
"teacher_count": 6,
"temp_teacher_count": 1,
"established_year": 1975,
...
}
)
"""
_MIN_TRAIN_SAMPLES = 20 # λͺ¨λΈ ν•™μŠ΅μ— ν•„μš”ν•œ μ΅œμ†Œ μƒ˜ν”Œ 수
def __init__(self) -> None:
self._model: Any = None
self._explainer: Any = None
self._is_fitted = False
self._model_accuracy: float | None = None
def fit(self, train_df: pd.DataFrame, label_col: str = "status_code") -> "ShapExplainer":
"""
XGBoost λΆ„λ₯˜ λͺ¨λΈμ„ ν›ˆλ ¨ν•©λ‹ˆλ‹€.
Parameters
----------
train_df:
FEATURE_COLS + label_col 을 ν¬ν•¨ν•˜λŠ” DataFrame.
label_col 은 0~3 μ •μˆ˜ λ ˆμ΄λΈ”.
label_col:
λΆ„λ₯˜ λŒ€μƒ λ ˆμ΄λΈ” 컬럼λͺ….
Returns
-------
self
"""
available_features = [c for c in FEATURE_COLS if c in train_df.columns]
if len(train_df) < self._MIN_TRAIN_SAMPLES or not available_features:
logger.warning(
"ν›ˆλ ¨ μƒ˜ν”Œ λΆ€μ‘± (%d개). κ·œμΉ™ 기반 λΆ„λ₯˜λ₯Ό μ‚¬μš©ν•©λ‹ˆλ‹€.", len(train_df)
)
return self
try:
import shap # type: ignore[import]
from sklearn.model_selection import train_test_split # type: ignore[import]
from xgboost import XGBClassifier # type: ignore[import]
X = train_df[available_features].fillna(0)
y = train_df[label_col]
X_train, X_val, y_train, y_val = train_test_split(X, y, test_size=0.2, random_state=42)
model = XGBClassifier(
n_estimators=100,
max_depth=4,
learning_rate=0.1,
use_label_encoder=False,
eval_metric="mlogloss",
random_state=42,
)
model.fit(X_train, y_train)
self._model_accuracy = float(model.score(X_val, y_val))
self._model = model
self._explainer = shap.TreeExplainer(model)
self._is_fitted = True
logger.info(
"XGBoost ν•™μŠ΅ μ™„λ£Œ: samples=%d accuracy=%.3f",
len(train_df),
self._model_accuracy,
)
except Exception as exc: # noqa: BLE001
logger.error("XGBoost / SHAP μ΄ˆκΈ°ν™” μ‹€νŒ¨: %s", exc)
return self
def explain(
self,
schul_code: str,
school_data: dict[str, Any],
) -> ShapResult:
"""
단일 학ꡐ에 λŒ€ν•œ μƒνƒœ λΆ„λ₯˜ 및 SHAP 기여도λ₯Ό μ‚°μΆœν•©λ‹ˆλ‹€.
Parameters
----------
schul_code:
λŒ€μƒ 학ꡐ SD_SCHUL_CODE.
school_data:
_compute_features κ°€ μ΄ν•΄ν•˜λŠ” 학ꡐ μ§€ν‘œ λ”•μ…”λ„ˆλ¦¬.
Returns
-------
ShapResult
"""
features = _compute_features(school_data)
feature_vector = pd.DataFrame([features])[FEATURE_COLS].fillna(0)
if self._is_fitted and self._model is not None and self._explainer is not None:
status_code = int(self._model.predict(feature_vector)[0])
try:
shap_vals = self._explainer.shap_values(feature_vector)
# multi-class β†’ 예츑 클래슀의 SHAP λ°°μ—΄ 선택
if isinstance(shap_vals, list):
sv = shap_vals[status_code][0]
else:
sv = shap_vals[0]
base_val = float(
self._explainer.expected_value[status_code]
if isinstance(self._explainer.expected_value, (list, np.ndarray))
else self._explainer.expected_value
)
except Exception as exc: # noqa: BLE001
logger.warning("SHAP κ°’ μ‚°μΆœ μ‹€νŒ¨, 0 벑터 μ‚¬μš©: %s", exc)
sv = np.zeros(len(FEATURE_COLS))
base_val = 0.0
else:
# fallback: κ·œμΉ™ 기반 λΆ„λ₯˜
status_code = _rule_based_status(features)
sv = np.zeros(len(FEATURE_COLS))
base_val = 0.0
shap_dict = {col: round(float(v), 4) for col, v in zip(FEATURE_COLS, sv)}
# μƒμœ„ κΈ°μ—¬ μš”μΈ (μ ˆλŒ“κ°’ κΈ°μ€€ μ •λ ¬)
sorted_factors = sorted(shap_dict.items(), key=lambda x: abs(x[1]), reverse=True)
top_factors = [
{
"feature": col,
"feature_kr": FEATURE_KR_NAMES.get(col, col),
"shap_value": val,
"feature_value": round(float(features.get(col, 0)), 4),
}
for col, val in sorted_factors[:5]
]
logger.info(
"SHAP 뢄석 μ™„λ£Œ: schul_code=%s status=%s top_factor=%s",
schul_code,
STATUS_LABELS[status_code],
sorted_factors[0][0] if sorted_factors else "N/A",
)
return ShapResult(
schul_code=schul_code,
status_label=STATUS_LABELS[status_code],
status_code=status_code,
shap_values=shap_dict,
top_factors=top_factors,
base_value=base_val,
model_accuracy=self._model_accuracy,
)