| """ |
| 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], |
| } |
|
|
| 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) |
|
|
| |
| 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") |
|
|
| |
| 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 |
| from sklearn.model_selection import train_test_split |
| from xgboost import XGBClassifier |
|
|
| 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: |
| 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) |
| |
| 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: |
| logger.warning("SHAP κ° μ°μΆ μ€ν¨, 0 λ²‘ν° μ¬μ©: %s", exc) |
| sv = np.zeros(len(FEATURE_COLS)) |
| base_val = 0.0 |
| else: |
| |
| 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, |
| ) |
|
|