Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import numpy as np | |
| import pandas as pd | |
| from sklearn.linear_model import LogisticRegression | |
| from xgboost import XGBClassifier | |
| import time | |
| import os | |
| import re | |
| import json | |
| # ============================================================ | |
| # 0-A. Claude API ํด๋ผ์ด์ธํธ ์ด๊ธฐํ (5์ธ๋์ฉ) | |
| # ============================================================ | |
| # ํ๊ฒฝ๋ณ์ ANTHROPIC_API_KEY ๊ฐ ์ค์ ๋์ด ์์ผ๋ฉด ์ค์ API ํธ์ถ ๋ชจ๋ | |
| # ์๊ฑฐ๋ ํธ์ถ ์คํจ ์ ์๋์ผ๋ก ์๋ฎฌ๋ ์ด์ ๋ชจ๋๋ก fallback | |
| try: | |
| from anthropic import Anthropic | |
| _api_key = os.environ.get("ANTHROPIC_API_KEY") | |
| if _api_key: | |
| claude_client = Anthropic(api_key=_api_key) | |
| CLAUDE_AVAILABLE = True | |
| else: | |
| claude_client = None | |
| CLAUDE_AVAILABLE = False | |
| except ImportError: | |
| claude_client = None | |
| CLAUDE_AVAILABLE = False | |
| CLAUDE_MODEL = "claude-sonnet-4-6" # 2026๋ 5์ ํ์ฌ ๊ถ์ฅ ๋ชจ๋ธ | |
| # ============================================================ | |
| # 0-B. Mini GNN (4์ธ๋) - numpy ์์ ๊ตฌํ + ์ง์ง ํ์ต | |
| # ============================================================ | |
| # 250๊ฑด ๋ฐ์ดํฐ๋ก ์ค์ ํ์ต๋๋ ์์ GNN | |
| # ๊ตฌ์กฐ: 4์ฐจ์ ์ ๋ ฅ โ ์๋์ธต1 (16D) โ ์๋์ธต2 (16D) โ ์ฌ๊ธฐ ํ๋ฅ | |
| # ๋ฉ์์ง ํจ์ฑ์ 2๋ฒ ์ํ (2-hop) | |
| def build_training_data_v2(n_normal=200, n_fraud=50, seed=42): | |
| """4์ธ๋ GNN์ฉ ํ์ต ๋ฐ์ดํฐ (250๊ฑด, 1-3์ธ๋์ ๋์ผ ๋ถํฌยท๋์ผ ๊ท๋ชจ).""" | |
| np.random.seed(seed) | |
| normal_arr = np.column_stack([ | |
| np.random.normal(100, 50, n_normal), | |
| np.random.normal(14, 4, n_normal), | |
| np.random.binomial(1, 0.1, n_normal), | |
| np.random.normal(1, 0.5, n_normal), | |
| ]) | |
| fraud_arr = np.column_stack([ | |
| np.random.normal(500, 200, n_fraud), | |
| np.random.normal(3, 2, n_fraud), | |
| np.random.binomial(1, 0.8, n_fraud), | |
| np.random.normal(10, 5, n_fraud), | |
| ]) | |
| X = np.vstack([normal_arr, fraud_arr]) | |
| y = np.concatenate([np.zeros(n_normal), np.ones(n_fraud)]) | |
| return X, y | |
| def build_graph_features(amount, hour, new_payee, ratio): | |
| """ | |
| ๋จ์ผ ๊ฑฐ๋๋ก๋ถํฐ 4ร4 ๋ ธ๋ ์๋ฒ ๋ฉ ํ๋ ฌ์ ์์ฑํ๋ค. | |
| - ๋ ธ๋ 0: ๊ฑฐ๋ ์์ฒด (๋ถ๋ฅ ๋์) | |
| - ๋ ธ๋ 1: ์ก๊ธ์ธ | |
| - ๋ ธ๋ 2: ์์ทจ์ธ (์ฌ๊ธฐ ์๊ทธ๋ ํฌํจ) | |
| - ๋ ธ๋ 3: ๋จ๋ง๊ธฐ | |
| """ | |
| amount_n = (amount - 100) / 200 | |
| hour_n = (hour - 14) / 8 | |
| ratio_n = (ratio - 1) / 5 | |
| trans_node = np.array([amount_n, hour_n, new_payee, ratio_n]) | |
| sender_node = np.array([0.0, hour_n, 0.0, 0.0]) | |
| if new_payee == 1: | |
| receiver_node = np.array([0.5, 0.3, 1.0, 0.4]) | |
| else: | |
| receiver_node = np.array([-0.2, 0.0, 0.0, -0.1]) | |
| if new_payee == 1 and (hour <= 6 or hour >= 22): | |
| device_node = np.array([0.3, 0.5, 0.0, 0.2]) | |
| else: | |
| device_node = np.array([0.0, 0.0, 0.0, 0.0]) | |
| return np.array([trans_node, sender_node, receiver_node, device_node]) | |
| # Adjacency matrix (์ ๊ทํ) | |
| ADJ = np.array([ | |
| [1, 1, 1, 1], # ๊ฑฐ๋ ๋ ธ๋: ์๊ธฐ + ๋ชจ๋ ์ด์๊ณผ ์ฐ๊ฒฐ | |
| [1, 1, 0, 0], # ์ก๊ธ์ธ: ์๊ธฐ + ๊ฑฐ๋ | |
| [1, 0, 1, 0], # ์์ทจ์ธ: ์๊ธฐ + ๊ฑฐ๋ | |
| [1, 0, 0, 1], # ๋จ๋ง๊ธฐ: ์๊ธฐ + ๊ฑฐ๋ | |
| ], dtype=np.float32) | |
| ADJ_NORM = ADJ / ADJ.sum(axis=1, keepdims=True) | |
| class MiniGNN: | |
| """์์ numpy๋ก ๊ตฌํ๋ 2-layer GNN + MLP.""" | |
| HIDDEN_DIM = 16 | |
| def __init__(self, seed=42): | |
| np.random.seed(seed) | |
| self.W1 = np.random.randn(4, self.HIDDEN_DIM) * np.sqrt(2.0 / 4) | |
| self.W2 = np.random.randn(self.HIDDEN_DIM, self.HIDDEN_DIM) * np.sqrt(2.0 / self.HIDDEN_DIM) | |
| self.W_mlp = np.random.randn(self.HIDDEN_DIM, 1) * np.sqrt(2.0 / self.HIDDEN_DIM) | |
| self.b_mlp = np.zeros(1) | |
| def _relu(x): | |
| return np.maximum(0, x) | |
| def _relu_grad(x): | |
| return (x > 0).astype(np.float32) | |
| def _sigmoid(x): | |
| return 1 / (1 + np.exp(-np.clip(x, -50, 50))) | |
| def forward(self, node_features, return_intermediates=False, adj_mask=None): | |
| """forward pass. | |
| adj_mask๊ฐ ์ฃผ์ด์ง๋ฉด ADJ_NORM ๋์ ์ฌ์ฉ (GNNExplainer์ฉ ์ฃ์ง ๋ง์คํน). | |
| """ | |
| adj = adj_mask if adj_mask is not None else ADJ_NORM | |
| agg1 = adj @ node_features | |
| z1 = agg1 @ self.W1 | |
| h1 = self._relu(z1) | |
| agg2 = adj @ h1 | |
| z2 = agg2 @ self.W2 | |
| h2 = self._relu(z2) | |
| trans_embedding = h2[0] | |
| logit = trans_embedding @ self.W_mlp + self.b_mlp | |
| prob = self._sigmoid(logit) | |
| if return_intermediates: | |
| return float(prob[0]), { | |
| 'h1': h1, 'h2': h2, 'z1': z1, 'z2': z2, | |
| 'agg1': agg1, 'agg2': agg2, | |
| 'trans_embedding': trans_embedding, | |
| 'logit': float(logit[0]), | |
| } | |
| return float(prob[0]) | |
| def train_step(self, node_features, label, lr=0.05): | |
| prob, cache = self.forward(node_features, return_intermediates=True) | |
| dlogit = (prob - label) | |
| dW_mlp = cache['trans_embedding'].reshape(-1, 1) * dlogit | |
| db_mlp = np.array([dlogit]) | |
| dh2 = np.zeros_like(cache['h2']) | |
| dh2[0] = self.W_mlp.flatten() * dlogit | |
| dz2 = dh2 * self._relu_grad(cache['z2']) | |
| dW2 = cache['agg2'].T @ dz2 | |
| dh1 = (ADJ_NORM.T @ dz2) @ self.W2.T | |
| dz1 = dh1 * self._relu_grad(cache['z1']) | |
| dW1 = cache['agg1'].T @ dz1 | |
| self.W1 -= lr * dW1 | |
| self.W2 -= lr * dW2 | |
| self.W_mlp -= lr * dW_mlp | |
| self.b_mlp -= lr * db_mlp | |
| def train_gnn(): | |
| """์ฑ ์์ ์ 1ํ ์คํ. ์ฝ 2์ด ์์.""" | |
| X, y = build_training_data_v2() | |
| graphs = np.array([ | |
| build_graph_features(X[i, 0], X[i, 1], X[i, 2], X[i, 3]) | |
| for i in range(len(X)) | |
| ]) | |
| model = MiniGNN(seed=42) | |
| np.random.seed(123) | |
| for epoch in range(100): | |
| indices = np.random.permutation(len(X)) | |
| for i in indices: | |
| model.train_step(graphs[i], y[i], lr=0.05) | |
| return model | |
| # 4์ธ๋ ๋ชจ๋ธ ํ์ต (์ฑ ์์ ์ 1๋ฒ๋ง) | |
| gnn_model = train_gnn() | |
| # ============================================================ | |
| # 0. ๊ณตํต ์ค์ | |
| # ============================================================ | |
| FEATURES = ['๊ธ์ก', '์๊ฐ', '์ ๊ท์์ทจ์ธ', '๊ธ์ก๋น์จ'] | |
| FEATURE_BG = { # ํ์ต ๋ฐ์ดํฐ ํ๊ท ๊ฐ (SHAP baseline) | |
| '๊ธ์ก': 180.0, '์๊ฐ': 11.8, '์ ๊ท์์ทจ์ธ': 0.26, '๊ธ์ก๋น์จ': 2.8 | |
| } | |
| def build_training_data(): | |
| """ํ์ต ๋ฐ์ดํฐ ์์ฑ (250๊ฑด: ์ ์ 200 + ์ฌ๊ธฐ 50)""" | |
| np.random.seed(42) | |
| normal = pd.DataFrame({ | |
| '๊ธ์ก': np.random.normal(100, 50, 200), | |
| '์๊ฐ': np.random.normal(14, 4, 200), | |
| '์ ๊ท์์ทจ์ธ': np.random.binomial(1, 0.1, 200), | |
| '๊ธ์ก๋น์จ': np.random.normal(1, 0.5, 200), | |
| '๋ผ๋ฒจ': 0 | |
| }) | |
| fraud = pd.DataFrame({ | |
| '๊ธ์ก': np.random.normal(500, 200, 50), | |
| '์๊ฐ': np.random.normal(3, 2, 50), | |
| '์ ๊ท์์ทจ์ธ': np.random.binomial(1, 0.8, 50), | |
| '๊ธ์ก๋น์จ': np.random.normal(10, 5, 50), | |
| '๋ผ๋ฒจ': 1 | |
| }) | |
| return pd.concat([normal, fraud], ignore_index=True) | |
| def train_gen2(): | |
| data = build_training_data() | |
| model = LogisticRegression(random_state=42, max_iter=1000) | |
| model.fit(data[FEATURES], data['๋ผ๋ฒจ']) | |
| return model | |
| def train_gen3(): | |
| data = build_training_data() | |
| model = XGBClassifier(n_estimators=10, max_depth=3, learning_rate=0.1, | |
| random_state=42, eval_metric='logloss') | |
| model.fit(data[FEATURES], data['๋ผ๋ฒจ']) | |
| return model | |
| gen2_model = train_gen2() | |
| gen3_model = train_gen3() | |
| # ํ์ต๋ ํ๋ผ๋ฏธํฐ ์ถ์ถ (๊ฐ์์ฉ ๋ ธ์ถ ๋ชฉ์ ) | |
| GEN2_COEF = gen2_model.coef_[0] | |
| GEN2_INTERCEPT = gen2_model.intercept_[0] | |
| GEN3_IMPORTANCE = gen3_model.feature_importances_ | |
| # ============================================================ | |
| # 0-C. XAI ํฌํผ ํจ์ | |
| # ============================================================ | |
| # 3์ธ๋์ฉ: TreeSHAP์ ์ง์ ํธ์ถ (xgboost๊ฐ SHAP ๊ฐ์ ๋ด๋ถ์ ์ผ๋ก ๊ณ์ฐ) | |
| # 4์ธ๋์ฉ: GNNExplainer ์คํ์ผ์ ์ฃ์งยท๋ ธ๋ ๋ง์คํน ๊ธฐ๋ฐ ๊ธฐ์ฌ๋ ์ถ์ถ | |
| def compute_shap_values_gen3(amount, hour, new_payee_bin, ratio): | |
| """XGBoost ๋ด์ฅ TreeSHAP์ผ๋ก ๊ฐ๋ณ ๊ฑฐ๋์ SHAP ๊ฐ ๊ณ์ฐ. | |
| pred_contribs=True ์ต์ ์ฌ์ฉ ์ [๊ธฐ์ฌ๋_ํผ์ฒ1, ..., ๊ธฐ์ฌ๋_ํผ์ฒN, base_value] ๋ฐํ. | |
| ํฉ์ฐํ๋ฉด logit space์์์ ๋ชจ๋ธ ์ถ๋ ฅ๊ณผ ์ ํํ ์ผ์น (additive guarantee). | |
| """ | |
| import xgboost as xgb | |
| dmatrix = xgb.DMatrix( | |
| pd.DataFrame([[amount, hour, new_payee_bin, ratio]], columns=FEATURES) | |
| ) | |
| booster = gen3_model.get_booster() | |
| # pred_contribs=True โ SHAP ๊ฐ ์ง์ ๋ฐํ | |
| shap_arr = booster.predict(dmatrix, pred_contribs=True)[0] | |
| # ๋ง์ง๋ง ์์๋ base_value (= expected value over training data) | |
| base_value = float(shap_arr[-1]) | |
| feature_shap = [float(v) for v in shap_arr[:-1]] | |
| return feature_shap, base_value | |
| def compute_gnn_edge_attribution(amount, hour, new_payee_bin, ratio): | |
| """GNNExplainer ์คํ์ผ: ๊ฐ ์ฃ์ง๋ฅผ ๋๋ฉด ์์ธก์ด ์ผ๋ง๋ ๋จ์ด์ง๋์ง ์ธก์ . | |
| ์ค์ GNNExplainer๋ ๋ฏธ๋ถ๊ฐ๋ฅํ ๋ง์คํฌ๋ฅผ ํ์ตํ์ง๋ง, ๋ฐ๋ชจ์์๋ | |
| ๊ฐ์ฅ ์ง๊ด์ ์ธ leave-one-edge-out ๋ฐฉ์์ผ๋ก ๋จ์ํ (์ค๋ฌด์์๋ ์์ฃผ ์ฐ๋ ๋ณํ). | |
| sigmoid๊ฐ saturate๋๋ ๊ฒฝ์ฐ(probโ1 ๋๋ probโ0)์๋ ํ๋ฅ ์ฐจ์ด๊ฐ | |
| 0์ ๊ฐ๊น์์ ธ ์๊ฐํ๊ฐ ์ ๋๋ฏ๋ก, logit-space์์ ์ธก์ ํ ๋ค | |
| probability ์ฐจ์ด๋ ํจ๊ป ๋ฐํํ๋ค. | |
| """ | |
| node_features = build_graph_features(amount, hour, new_payee_bin, ratio) | |
| full_prob, full_inter = gnn_model.forward(node_features, return_intermediates=True) | |
| full_logit = full_inter['logit'] | |
| edge_info = [ | |
| (1, "์ก๊ธ์ธ โ ๊ฑฐ๋"), | |
| (2, "์์ทจ์ธ โ ๊ฑฐ๋"), | |
| (3, "๋จ๋ง๊ธฐ โ ๊ฑฐ๋"), | |
| ] | |
| contributions = [] | |
| for node_idx, label in edge_info: | |
| masked_adj = ADJ.astype(np.float32).copy() | |
| masked_adj[0, node_idx] = 0 | |
| masked_adj[node_idx, 0] = 0 | |
| row_sums = masked_adj.sum(axis=1, keepdims=True) | |
| row_sums[row_sums == 0] = 1 | |
| masked_adj_norm = masked_adj / row_sums | |
| masked_prob, masked_inter = gnn_model.forward( | |
| node_features, return_intermediates=True, adj_mask=masked_adj_norm | |
| ) | |
| masked_logit = masked_inter['logit'] | |
| # logit-space ์ฐจ์ด (saturate ์์ญ์์๋ ์ ์๋ฏธ) | |
| logit_delta = full_logit - masked_logit | |
| # contributions ์๊ทธ๋์ฒ๋ ๊ทธ๋๋ก ์ ์ง: (๋ผ๋ฒจ, masked_prob, delta) | |
| # ๋จ delta๋ logit ์ฐจ์ด๋ฅผ ๊ทธ๋๋ก ์ฌ์ฉ โ ์๊ฐํ์์ ์๋ฏธ๊ฐ ์ด์๋จ | |
| contributions.append((label, masked_prob, logit_delta)) | |
| return full_prob, contributions | |
| def compute_gnn_node_feature_attribution(amount, hour, new_payee_bin, ratio): | |
| """GNNExplainer ์คํ์ผ: ๊ฐ ์ ๋ ฅ ํผ์ฒ๋ฅผ baseline(ํ๊ท ๊ฐ)์ผ๋ก ๋์ฒดํ์ ๋ | |
| ์์ธก์ด ์ผ๋ง๋ ๋จ์ด์ง๋์ง ์ธก์ (ํผ์ฒ ๋จ์ ๊ธฐ์ฌ๋). | |
| ์ฃ์ง ๋ง์คํน๊ณผ ๋ง์ฐฌ๊ฐ์ง๋ก logit-space์์ ์ธก์ . | |
| """ | |
| full_node_features = build_graph_features(amount, hour, new_payee_bin, ratio) | |
| full_prob, full_inter = gnn_model.forward(full_node_features, return_intermediates=True) | |
| full_logit = full_inter['logit'] | |
| baselines = { | |
| '๊ธ์ก': 100.0, '์๊ฐ': 14.0, '์ ๊ท์์ทจ์ธ': 0, '๊ธ์ก๋น์จ': 1.0 | |
| } | |
| inputs = { | |
| '๊ธ์ก': amount, '์๊ฐ': hour, '์ ๊ท์์ทจ์ธ': new_payee_bin, '๊ธ์ก๋น์จ': ratio | |
| } | |
| contributions = [] | |
| for feat in FEATURES: | |
| masked_inputs = inputs.copy() | |
| masked_inputs[feat] = baselines[feat] | |
| masked_node_features = build_graph_features( | |
| masked_inputs['๊ธ์ก'], masked_inputs['์๊ฐ'], | |
| masked_inputs['์ ๊ท์์ทจ์ธ'], masked_inputs['๊ธ์ก๋น์จ'] | |
| ) | |
| _, masked_inter = gnn_model.forward(masked_node_features, return_intermediates=True) | |
| masked_logit = masked_inter['logit'] | |
| logit_delta = full_logit - masked_logit | |
| contributions.append((feat, logit_delta)) | |
| return full_prob, contributions | |
| def build_counterfactual_gen5(amount, hour, new_payee_bin, ratio, prob_threshold=0.5): | |
| """5์ธ๋ ๋ณด์กฐ: '์ด ๊ฑฐ๋๊ฐ ํต๊ณผ๋๋ ค๋ฉด ๋ฌด์์ด ๋ฐ๋์ด์ผ ํ๋๊ฐ'๋ฅผ ํ์. | |
| GNN์ผ๋ก ํ๋ณด๋ฅผ ๋น ๋ฅด๊ฒ ํ๊ฐ (์ค์ LLM ํธ์ถ ๋น์ฉ์ ์๋ผ๊ธฐ ์ํจ). | |
| saturate ์์ญ์์๋ ํจ๊ณผ๊ฐ ๋ณด์ด๋๋ก logit space์์๋ ์ธก์ . | |
| """ | |
| candidates = [ | |
| ("๊ธ์ก์ 100๋ง์ ์ดํ๋ก", lambda: build_graph_features(50, hour, new_payee_bin, ratio)), | |
| ("๊ฑฐ๋ ์๊ฐ์ 14์(์ฃผ๊ฐ)๋ก", lambda: build_graph_features(amount, 14, new_payee_bin, ratio)), | |
| ("๊ธฐ์กด ์์ทจ์ธ์ด์๋ค๋ฉด", lambda: build_graph_features(amount, hour, 0, ratio)), | |
| ("ํ์ ๊ฑฐ๋์ก ์์ค(1๋ฐฐ)์ด์๋ค๋ฉด", lambda: build_graph_features(amount, hour, new_payee_bin, 1.0)), | |
| ] | |
| base_features = build_graph_features(amount, hour, new_payee_bin, ratio) | |
| base_prob, base_inter = gnn_model.forward(base_features, return_intermediates=True) | |
| base_logit = base_inter['logit'] | |
| results = [] | |
| for label, builder in candidates: | |
| cf_features = builder() | |
| cf_prob, cf_inter = gnn_model.forward(cf_features, return_intermediates=True) | |
| cf_logit = cf_inter['logit'] | |
| flipped = (base_prob >= prob_threshold and cf_prob < prob_threshold) | |
| # ์๊ทธ๋์ฒ: (๋ผ๋ฒจ, cf_prob, prob_drop, flipped, logit_drop) | |
| results.append((label, cf_prob, base_prob - cf_prob, flipped, base_logit - cf_logit)) | |
| return results | |
| # ============================================================ | |
| # 1. 1์ธ๋ ๋ฃฐ ์ ์ | |
| # ============================================================ | |
| GEN1_RULES = [ | |
| {"name": "R1 ๊ณ ์ก ๊ฑฐ๋", "condition": "๊ธ์ก โฅ 500๋ง์", "weight": 40}, | |
| {"name": "R2 ์๋ฒฝ ์๊ฐ๋", "condition": "์๊ฐ โค 6 ๋๋ โฅ 22", "weight": 30}, | |
| {"name": "R3 ์ ๊ท ์์ทจ์ธ", "condition": "์ ๊ท์์ทจ์ธ = ์", "weight": 20}, | |
| {"name": "R4 ํ์ ๋๋น ๊ธ์ฆ", "condition": "๊ธ์ก๋น์จ โฅ 5๋ฐฐ", "weight": 10}, | |
| ] | |
| def evaluate_gen1(amount, hour, new_payee_bin, ratio): | |
| triggered = [ | |
| amount >= 500, | |
| hour >= 22 or hour <= 6, | |
| new_payee_bin == 1, | |
| ratio >= 5, | |
| ] | |
| score = sum(r["weight"] for r, t in zip(GEN1_RULES, triggered) if t) | |
| return triggered, score | |
| def decide(prob_or_score, is_score=False): | |
| if is_score: | |
| if prob_or_score >= 70: return "์ฐจ๋จ", "#FCEBEB", "#791F1F" | |
| if prob_or_score >= 40: return "์ถ๊ฐ ์ธ์ฆ", "#FAEEDA", "#854F0B" | |
| return "ํต๊ณผ", "#EAF3DE", "#3B6D11" | |
| else: | |
| if prob_or_score >= 0.7: return "์ฐจ๋จ", "#FCEBEB", "#791F1F" | |
| if prob_or_score >= 0.5: return "์ถ๊ฐ ์ธ์ฆ", "#FAEEDA", "#854F0B" | |
| return "ํต๊ณผ", "#EAF3DE", "#3B6D11" | |
| # ============================================================ | |
| # 2. ๊ณตํต HTML ๋น๋ | |
| # ============================================================ | |
| def card_header(gen_label, title, decision_text, bg_color, text_color, sub): | |
| return f""" | |
| <div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:12px;"> | |
| <div> | |
| <p style="font-size:11px; color:#888; margin:0; letter-spacing:0.5px;">{gen_label}</p> | |
| <p style="font-size:16px; font-weight:500; margin:2px 0 0;">{title}</p> | |
| </div> | |
| <div style="text-align:right;"> | |
| <span style="background:{bg_color}; color:{text_color}; font-size:12px; padding:4px 12px; border-radius:8px; font-weight:500;">{decision_text}</span> | |
| <p style="font-size:13px; color:#666; margin:4px 0 0;">{sub}</p> | |
| </div> | |
| </div> | |
| """ | |
| def formula_box(html): | |
| return f"""<div style="background:#f5f5f0; padding:10px 12px; border-radius:6px; font-family:'Courier New',monospace; font-size:12px; margin-bottom:10px; line-height:1.6;">{html}</div>""" | |
| def feature_setup_box(actor_label, actor_color, items, explanation): | |
| color_map = { | |
| 'human': ('#E6F1FB', '#0C447C'), | |
| 'model': ('#FAECE7', '#993C1D'), | |
| 'mixed': ('#F1EFE8', '#5F5E5A'), | |
| } | |
| badge_bg, badge_fg = color_map.get(actor_color, color_map['mixed']) | |
| rows = "" | |
| for name, actor, desc in items: | |
| a_bg, a_fg = color_map.get(actor, color_map['mixed']) | |
| rows += ( | |
| f"<tr>" | |
| f"<td style='padding:5px 8px; color:#444; width:30%;'>{name}</td>" | |
| f"<td style='padding:5px 8px; width:20%;'>" | |
| f"<span style='background:{a_bg}; color:{a_fg}; font-size:10px; padding:2px 8px; border-radius:6px; font-weight:500;'>{actor}</span>" | |
| f"</td>" | |
| f"<td style='padding:5px 8px; color:#666; font-size:12px;'>{desc}</td>" | |
| f"</tr>" | |
| ) | |
| return f""" | |
| <div style="background:#FAFAF7; border:0.5px solid rgba(0,0,0,0.08); border-radius:8px; padding:10px 14px; margin-bottom:14px;"> | |
| <div style="display:flex; align-items:center; gap:10px; margin-bottom:8px;"> | |
| <p style="font-size:12px; font-weight:500; color:#444; margin:0;">โ๏ธ FeatureยทRule ๊ฒฐ์ ๋ฐฉ์</p> | |
| <span style="background:{badge_bg}; color:{badge_fg}; font-size:10px; padding:3px 10px; border-radius:6px; font-weight:500;">{actor_label}</span> | |
| </div> | |
| <table style="width:100%; font-size:13px; border-collapse:collapse;"> | |
| <tbody>{rows}</tbody> | |
| </table> | |
| <p style="font-size:11px; color:#888; margin:8px 0 0; font-style:italic; line-height:1.5;">{explanation}</p> | |
| </div> | |
| """ | |
| def xai_box(method, status, era, items, takeaway): | |
| """์ธ๋๋ณ XAI ๊ตฌํ ๋ฐฉ์์ ํ์ํ๋ ๋ฐ์ค (์๋ก ์ถ๊ฐ). | |
| method: XAI ๊ธฐ๋ฒ ๋ช ์นญ (์: 'TreeSHAP', 'GNNExplainer') | |
| status: 'native' (๋ด์ฌ) | 'post-hoc' (์ฌํ) | 'none' (๋ถํ์) | 'generative' (์์ฑํ) | |
| era: ํด๋น ๊ธฐ๋ฒ์ด ํ์คํ๋ ์๊ธฐ | |
| items: [(ํญ๋ชฉ, ์ค๋ช )] ๋ฆฌ์คํธ | |
| takeaway: ๊ฐ์ ํฌ์ธํธ ํ ์ค | |
| """ | |
| status_map = { | |
| 'none': ('XAI ๋ถํ์', '#EAF3DE', '#3B6D11'), | |
| 'native': ('๋ด์ฌ์ ์ค๋ช ๋ ฅ', '#E6F1FB', '#0C447C'), | |
| 'post-hoc': ('์ฌํ ์ค๋ช ๊ธฐ๋ฒ', '#FAEEDA', '#854F0B'), | |
| 'generative': ('์์ฑํ ์ค๋ช ', '#FAECE7', '#993C1D'), | |
| } | |
| status_label, badge_bg, badge_fg = status_map.get(status, status_map['post-hoc']) | |
| rows = "" | |
| for name, desc in items: | |
| rows += ( | |
| f"<tr>" | |
| f"<td style='padding:4px 8px; color:#444; width:32%; vertical-align:top;'>{name}</td>" | |
| f"<td style='padding:4px 8px; color:#555; font-size:12px;'>{desc}</td>" | |
| f"</tr>" | |
| ) | |
| return f""" | |
| <div style="background:#FFFCF5; border:0.5px solid rgba(133,79,11,0.25); border-radius:8px; padding:10px 14px; margin-bottom:14px;"> | |
| <div style="display:flex; align-items:center; gap:8px; margin-bottom:6px; flex-wrap:wrap;"> | |
| <p style="font-size:12px; font-weight:500; color:#444; margin:0;">๐ XAI ๊ตฌํ ๋ฐฉ์</p> | |
| <span style="background:{badge_bg}; color:{badge_fg}; font-size:10px; padding:3px 10px; border-radius:6px; font-weight:500;">{status_label}</span> | |
| <span style="font-size:11px; color:#888;">๊ธฐ๋ฒ: <b style="color:#5F4308;">{method}</b></span> | |
| <span style="font-size:11px; color:#888;">ยท {era}</span> | |
| </div> | |
| <table style="width:100%; font-size:13px; border-collapse:collapse;"> | |
| <tbody>{rows}</tbody> | |
| </table> | |
| <p style="font-size:11px; color:#854F0B; margin:8px 0 0; font-style:italic; line-height:1.5;">๐ก {takeaway}</p> | |
| </div> | |
| """ | |
| CARD_STYLE = ("background:#fff; border:0.5px solid rgba(0,0,0,0.15); " | |
| "border-radius:12px; padding:16px 20px; margin-bottom:14px;") | |
| # ============================================================ | |
| # 3. ์ธ๋๋ณ HTML ์์ฑ ํจ์ | |
| # ============================================================ | |
| def render_gen1(amount, hour, new_payee_bin, ratio): | |
| triggered, score = evaluate_gen1(amount, hour, new_payee_bin, ratio) | |
| dec, bg, fg = decide(score, is_score=True) | |
| rows = "" | |
| for rule, t in zip(GEN1_RULES, triggered): | |
| applied = rule["weight"] if t else 0 | |
| row_bg = "#FAECE7" if t else "#ffffff" | |
| td_color = "#4A1B0C" if t else "#444" | |
| sub_color = "#712B13" if t else "#666" | |
| mark = "โ" if t else "โ" | |
| rows += f""" | |
| <tr style="background:{row_bg};"> | |
| <td style="padding:6px 4px; color:{td_color};">{rule['name']}</td> | |
| <td style="padding:6px 4px; color:{sub_color};">{rule['condition']}</td> | |
| <td style="text-align:center; padding:6px 4px; color:{sub_color};">+{rule['weight']}</td> | |
| <td style="text-align:center; padding:6px 4px; color:{sub_color};">{mark}</td> | |
| <td style="text-align:right; padding:6px 4px; font-weight:500; color:{td_color};">+{applied}</td> | |
| </tr>""" | |
| gen1_setup = feature_setup_box( | |
| actor_label="100% ์ฌ๋ ๊ฒฐ์ ", | |
| actor_color='human', | |
| items=[ | |
| ("์ ๋ ฅ Feature 4๊ฐ", "์ฌ๋", "๋๋ฉ์ธ ์ ๋ฌธ๊ฐ๊ฐ '๊ธ์กยท์๊ฐยท์ ๊ท์์ทจ์ธยท๊ธ์ก๋น์จ'์ ์ฌ๊ธฐ ํ๋จ ๊ธฐ์ค์ผ๋ก ์ ์ "), | |
| ("๋ฃฐ ์กฐ๊ฑด (์๊ณ๊ฐ)", "์ฌ๋", "โฅ500๋ง์, โค6์ ๋๋ โฅ22์, =1, โฅ5๋ฐฐ โ ๋ชจ๋ ์ฌ๋์ด ์ง์ ๊ฒฐ์ "), | |
| ("๋ฃฐ๋ณ ๊ฐ์ค์น", "์ฌ๋", "40 / 30 / 20 / 10์ โ ๋๋ฉ์ธ ๊ฒฝํ์ ๋ฐ๋ผ ์ฌ๋์ด ๋ถ์ฌ"), | |
| ("ํ์ ์๊ณ๊ฐ", "์ฌ๋", "70์ ์ด์ ์ฐจ๋จ, 40์ ์ด์ ์ถ๊ฐ์ธ์ฆ โ ์ด์ํ์ด ๋น์ฆ๋์ค ํ๋จ์ผ๋ก ๊ฒฐ์ "), | |
| ], | |
| explanation="๋ชจ๋ ๊ฒฐ์ ์ด ์ฌ๋์ ๋๋ฉ์ธ ์ง์์ ์์กด. ํ์ต ๋ฐ์ดํฐ๋ ์ฌ์ฉํ์ง ์์. ์ ์ฌ๊ธฐ ํจํด ๋ฑ์ฅ ์ ์ฌ๋์ด ๋ฃฐ์ ์ถ๊ฐํด์ผ ํจ." | |
| ) | |
| # โโโ XAI ๊ตฌํ ๋ฐ์ค (1์ธ๋) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| gen1_xai = xai_box( | |
| method="ํด๋น ์์ (Self-Explanatory)", | |
| status='none', | |
| era="~ 2000๋ ๋ ์ค๋ฐ", | |
| items=[ | |
| ("์ค๋ช ์์ฑ ๋ฐฉ์", "๋ฃฐ๋ถ ์์ฒด๊ฐ ์ค๋ช ์. ๋ฐ๋๋ ๋ฃฐ์ ์ด๋ฆยท์กฐ๊ฑดยท๊ฐ์ค์น๋ฅผ ๊ทธ๋๋ก ๋ ธ์ถํ๋ฉด ์ฌ๋์ด ์ฆ์ ์ดํด ๊ฐ๋ฅ"), | |
| ("๊ฐ๋ณ ๊ฑฐ๋ ์ค๋ช ", "์ ํ๊ฐ ๋ฐ๋ก ์ค๋ช . '์ ์ฐจ๋จ๋๋ โ R1(๊ณ ์ก)+R3(์ ๊ท์์ทจ์ธ)+R4(๊ธ์ฆ) = 70์ '์ผ๋ก ์ฆ์ ๋ต๋ณ"), | |
| ("๊ฐ๋ ๋น๊ตญ ๋์", "๋ฃฐ ๋งคํธ๋ฆญ์ค ๊ทธ๋๋ก ์ ์ถ. ๋ณ๋ XAI ์๊ณ ๋ฆฌ์ฆ ํ์ ์์"), | |
| ("ํ๊ณ", "๋ฃฐ์ด ๋ง์์ง๋ฉด(์๋ฐฑ ๊ฐ) ์ฌ๋๋ ๋ฐ๋ผ๊ฐ๊ธฐ ํ๋ค์ด์ง โ ๋ฃฐ ๊ฐ ์ํธ์์ฉยท์ฐ์ ์์๊ฐ ์๋ก์ด ๋ธ๋๋ฐ์ค๊ฐ ๋จ"), | |
| ], | |
| takeaway="1์ธ๋์๋ '์ค๋ช ๋ ฅ'์ด๋ผ๋ ๊ฐ๋ ์ด ๋ฐ๋ก ์กด์ฌํ์ง ์์์. ํ๋จ ๋ก์ง = ์ค๋ช ๋ก์ง์ด๊ธฐ ๋๋ฌธ. XAI๋ผ๋ ๋จ์ด๊ฐ ๋ฑ์ฅํ ๊ฒ์ ๋ชจ๋ธ์ด ๋น์ ํ์ผ๋ก ์งํํ ํ์ ์ผ." | |
| ) | |
| return f""" | |
| <div style="{CARD_STYLE}"> | |
| {card_header("GEN 1 ยท RULE-BASED", "๊ท์น ๊ธฐ๋ฐ ํ๋จ", dec, bg, fg, f"๋์ {score}์ / 100์ ")} | |
| {gen1_setup} | |
| {gen1_xai} | |
| {formula_box("์ด์ = ฮฃ (๋ฐ๋๋ ๋ฃฐ์ ๊ฐ์ค์น) โ ์๊ณ๊ฐ ๋น๊ต (โฅ70 ์ฐจ๋จ / โฅ40 ์ถ๊ฐ์ธ์ฆ)")} | |
| <table style="width:100%; font-size:13px; border-collapse:collapse;"> | |
| <thead> | |
| <tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);"> | |
| <th style="text-align:left; padding:8px 4px; font-weight:500; color:#666;">๋ฃฐ</th> | |
| <th style="text-align:left; padding:8px 4px; font-weight:500; color:#666;">์กฐ๊ฑด</th> | |
| <th style="text-align:center; padding:8px 4px; font-weight:500; color:#666;">๊ฐ์ค์น</th> | |
| <th style="text-align:center; padding:8px 4px; font-weight:500; color:#666;">๋ฐ๋</th> | |
| <th style="text-align:right; padding:8px 4px; font-weight:500; color:#666;">์ ์ฉ</th> | |
| </tr> | |
| </thead> | |
| <tbody>{rows}</tbody> | |
| <tfoot> | |
| <tr style="border-top:0.5px solid rgba(0,0,0,0.3);"> | |
| <td colspan="4" style="text-align:right; padding:8px 4px; font-weight:500;">์ต์ข ํฉ๊ณ</td> | |
| <td style="text-align:right; padding:8px 4px; font-weight:500;">{score}์ </td> | |
| </tr> | |
| </tfoot> | |
| </table> | |
| <p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">ํ๊ณ: ๋ฃฐ์ด ๊ณ ์ ๊ฐ์ด๋ผ ์๊ณ๊ฐ ๋ฐ๋ก ์๋(์: 499๋ง์ 23์) ๊ฑฐ๋๋ฅผ ๋์นจ</p> | |
| </div> | |
| """ | |
| def render_gen2(amount, hour, new_payee_bin, ratio): | |
| input_vec = np.array([amount, hour, new_payee_bin, ratio], dtype=float) | |
| contributions = GEN2_COEF * input_vec | |
| logit = contributions.sum() + GEN2_INTERCEPT | |
| prob = 1 / (1 + np.exp(-logit)) | |
| dec, bg, fg = decide(prob) | |
| rows = "" | |
| for f, x, w, c in zip(FEATURES, input_vec, GEN2_COEF, contributions): | |
| if c > 0: | |
| row_bg, td_c, sub_c = "#FAECE7", "#4A1B0C", "#712B13" | |
| elif c < 0: | |
| row_bg, td_c, sub_c = "#E1F5EE", "#04342C", "#085041" | |
| else: | |
| row_bg, td_c, sub_c = "#ffffff", "#444", "#666" | |
| rows += f""" | |
| <tr style="background:{row_bg};"> | |
| <td style="padding:6px 4px; color:{td_c};">{f}</td> | |
| <td style="text-align:right; padding:6px 4px; color:{sub_c}; font-family:monospace;">{x:.3f}</td> | |
| <td style="text-align:right; padding:6px 4px; color:{sub_c}; font-family:monospace;">{w:+.4f}</td> | |
| <td style="text-align:right; padding:6px 4px; color:{td_c}; font-family:monospace; font-weight:500;">{c:+.4f}</td> | |
| </tr>""" | |
| contrib_str = " + ".join([f"({c:+.4f})" for c in contributions]) | |
| calc_html = ( | |
| f"z = {contrib_str} + ({GEN2_INTERCEPT:+.4f})<br>" | |
| f"z = <span style='font-weight:500;'>{logit:+.4f}</span><br>" | |
| f"P = 1 / (1 + e<sup>{-logit:+.4f}</sup>) = " | |
| f"<span style='font-weight:500;'>{prob:.4f} โ {prob*100:.2f}%</span>" | |
| ) | |
| gen2_setup = feature_setup_box( | |
| actor_label="ํผ์ฒ๋ ์ฌ๋, ๊ฐ์ค์น๋ ๋ชจ๋ธ", | |
| actor_color='mixed', | |
| items=[ | |
| ("์ ๋ ฅ Feature 4๊ฐ", "์ฌ๋", "1์ธ๋์ ๋์ผํ 4๊ฐ ์ปฌ๋ผ์ ์ฌ๋์ด ์ ์ (ํผ์ฒ ์์ง๋์ด๋ง)"), | |
| ("ํ์ต ๋ฐ์ดํฐ", "์ฌ๋", "250๊ฑด์ ๊ฑฐ๋์ ์ฌ๊ธฐ/์ ์ ๋ผ๋ฒจ์ ์ฌ๋์ด ๋ถ์ฌ"), | |
| ("๊ฐ์ค์น wโ~wโ", "๋ชจ๋ธ", "fit() ํธ์ถ ์ L-BFGS ์๊ณ ๋ฆฌ์ฆ์ด ์๋ ํ์ต"), | |
| ("์ ํธ b", "๋ชจ๋ธ", "๋ฐ์ดํฐ์ ์ฌ๊ธฐ ๋น์จ(50/250=20%)์ ๋ง์ถฐ ์๋ ์กฐ์ "), | |
| ("ํ์ ์๊ณ๊ฐ", "์ฌ๋", "0.5(์ถ๊ฐ์ธ์ฆ) / 0.7(์ฐจ๋จ) โ ์ด์ํ์ด ๊ฒฐ์ "), | |
| ], | |
| explanation="ํผ์ฒ๋ ์ฌ์ ํ ์ฌ๋์ด ์ ์. ๋ชจ๋ธ์ด ํ์ตํ๋ ๊ฑด '4๊ฐ ํผ์ฒ์ ์ด๋ค ๊ฐ์ค์น๋ฅผ ๊ณฑํด์ผ ์ฌ๊ธฐ๋ฅผ ์ ๋ง์ถ๋๊ฐ'๋ฟ." | |
| ) | |
| # โโโ XAI ๊ตฌํ ๋ฐ์ค (2์ธ๋) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ์ต๋ ๊ธฐ์ฌ ํผ์ฒ ์ฐพ๊ธฐ | |
| top_idx = int(np.argmax(np.abs(contributions))) | |
| top_feat = FEATURES[top_idx] | |
| top_contrib = contributions[top_idx] | |
| gen2_xai = xai_box( | |
| method="Coefficient-based Attribution (๊ณ์ ๋ถํด)", | |
| status='native', | |
| era="ํต๊ณ ๋ชจ๋ธ ์๋๋ถํฐ ์์ฐ ๋ฐ์", | |
| items=[ | |
| ("์ค๋ช ์์ฑ ๋ฐฉ์", "์์ z = ฮฃ(wแตขยทxแตข) + b ๊ฐ ๊ทธ๋๋ก ์ค๋ช . ๋ณ๋ XAI ์๊ณ ๋ฆฌ์ฆ ์์ด ๊ฐ์ค์น๋ง ๋ณด๋ฉด ๋จ"), | |
| ("๊ฐ๋ณ ๊ฑฐ๋ ์ค๋ช ", f"๋ณธ ๊ฑฐ๋์์ ๊ฐ์ฅ ํฐ ๊ธฐ์ฌ = <b>{top_feat}</b> ({top_contrib:+.4f}). ํ์ ๋ง์ง๋ง ์ปฌ๋ผ์ด ๊ณง SHAP ๊ฐ์ ์ ํํ ์ ํ ๋ฒ์ "), | |
| ("์ ์ญ ์ค๋ช (global)", "๊ฐ์ค์น ๋ถํธ์ ํฌ๊ธฐ๊ฐ ๊ณง ๋ณ์ ์ค์๋. ์์ = ์ฌ๊ธฐ ๋ฐฉํฅ, ์์ = ์ ์ ๋ฐฉํฅ"), | |
| ("ํ๊ณ", "์ ํ ๊ฐ์ ์ด๋ผ ๋น์ ํ ํจํด ํ์ต ๋ถ๊ฐ. '๊ธ์ก + ์๊ฐ ์กฐํฉ' ๊ฐ์ ์ํธ์์ฉ์ ๋ชป ์ก์ โ ์ ํ๋ ๋ถ์กฑ์ด 3์ธ๋ ๋ฑ์ฅ์ ๋ฐฐ๊ฒฝ"), | |
| ], | |
| takeaway="๋ก์ง์คํฑ ํ๊ท์ 'wแตขยทxแตข' ๋ถํด๋ ์ฌ์ค์ ์ ํํ SHAP ๊ฐ๊ณผ ๋์น(์ ํ ๋ชจ๋ธ ํ์ ). XAI๋ผ๋ ๊ฐ๋ ์ด ๋ณ๋๋ก ํ์ ์๋ ๋ง์ง๋ง ์ธ๋." | |
| ) | |
| return f""" | |
| <div style="{CARD_STYLE}"> | |
| {card_header("GEN 2 ยท LOGISTIC REGRESSION", "๋ก์ง์คํฑ ํ๊ท (์ ํ ๋ชจ๋ธ)", dec, bg, fg, f"์ฌ๊ธฐ ํ๋ฅ {prob*100:.2f}%")} | |
| {gen2_setup} | |
| {gen2_xai} | |
| {formula_box("z = wโยท๊ธ์ก + wโยท์๊ฐ + wโยท์ ๊ท์์ทจ์ธ + wโยท๊ธ์ก๋น์จ + b<br>P(์ฌ๊ธฐ) = 1 / (1 + e<sup>-z</sup>)")} | |
| <table style="width:100%; font-size:13px; border-collapse:collapse;"> | |
| <thead> | |
| <tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);"> | |
| <th style="text-align:left; padding:8px 4px; font-weight:500; color:#666;">ํผ์ฒ</th> | |
| <th style="text-align:right; padding:8px 4px; font-weight:500; color:#666;">์ ๋ ฅ๊ฐ x</th> | |
| <th style="text-align:right; padding:8px 4px; font-weight:500; color:#666;">ํ์ต ๊ฐ์ค์น w</th> | |
| <th style="text-align:right; padding:8px 4px; font-weight:500; color:#666;">๊ธฐ์ฌ๋ wยทx</th> | |
| </tr> | |
| </thead> | |
| <tbody>{rows} | |
| <tr style="background:#F1EFE8;"> | |
| <td colspan="3" style="padding:6px 4px; text-align:right;">์ ํธ (bias) b</td> | |
| <td style="text-align:right; padding:6px 4px; font-weight:500; font-family:monospace;">{GEN2_INTERCEPT:+.4f}</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| {formula_box(calc_html)} | |
| <p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">ํด์: ๊ฐ์ค์น ๋ถํธ๊ฐ ๊ณง ํ๋จ ๋ฐฉํฅ. ์์๋ ์ฌ๊ธฐ ์ชฝ, ์์๋ ์ ์ ์ชฝ์ผ๋ก ๋์ด๋น๊น</p> | |
| </div> | |
| """ | |
| def render_gen3(amount, hour, new_payee_bin, ratio): | |
| input_df = pd.DataFrame([[amount, hour, new_payee_bin, ratio]], columns=FEATURES) | |
| prob = float(gen3_model.predict_proba(input_df)[0][1]) | |
| dec, bg, fg = decide(prob) | |
| imp_pairs = sorted(zip(FEATURES, GEN3_IMPORTANCE), key=lambda x: -x[1]) | |
| max_imp = max(GEN3_IMPORTANCE) if max(GEN3_IMPORTANCE) > 0 else 1 | |
| imp_bars = "" | |
| for f, imp in imp_pairs: | |
| bar_w = (imp / max_imp) * 100 | |
| imp_bars += f""" | |
| <div style="display:grid; grid-template-columns:90px 1fr 60px; gap:8px; align-items:center;"> | |
| <span>{f}</span> | |
| <div style="background:#f0ede5; height:16px; border-radius:3px; overflow:hidden;"> | |
| <div style="background:#D85A30; height:100%; width:{bar_w:.1f}%;"></div> | |
| </div> | |
| <span style="text-align:right; font-family:monospace; color:#666;">{imp:.3f}</span> | |
| </div>""" | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ์ง์ง ํ์ต๋ ํธ๋ฆฌ 10๊ฐ ์ ์ฒด์์ ๋ณธ ๊ฑฐ๋๊ฐ ๋๋ฌํ leaf ๊ฐ์ ์ถ์ถ | |
| # โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| booster = gen3_model.get_booster() | |
| trees_df = booster.trees_to_dataframe() | |
| input_values = {'๊ธ์ก': amount, '์๊ฐ': hour, '์ ๊ท์์ทจ์ธ': new_payee_bin, '๊ธ์ก๋น์จ': ratio} | |
| tree_traces = [] | |
| for tree_id in range(10): | |
| tree = trees_df[trees_df['Tree'] == tree_id].set_index('ID') | |
| current_id = f"{tree_id}-0" | |
| path = [] | |
| leaf_val = 0.0 | |
| while True: | |
| row = tree.loc[current_id] | |
| if row['Feature'] == 'Leaf': | |
| leaf_val = float(row['Gain']) | |
| break | |
| feature = row['Feature'] | |
| split = float(row['Split']) | |
| input_v = input_values[feature] | |
| if input_v < split: | |
| path.append(f"[{feature} < {split:.2f}] Yes") | |
| current_id = row['Yes'] | |
| else: | |
| path.append(f"[{feature} < {split:.2f}] No") | |
| current_id = row['No'] | |
| tree_traces.append((path, leaf_val)) | |
| raw_score = sum(leaf for _, leaf in tree_traces) | |
| tree_rows = "" | |
| cumulative = 0.0 | |
| for tree_id, (path, leaf) in enumerate(tree_traces): | |
| cumulative += leaf | |
| path_text = " โ ".join(path) + f" โ <b>leaf={leaf:+.4f}</b>" | |
| leaf_color = "#4A1B0C" if leaf > 0 else "#04342C" | |
| leaf_bg = "#FAECE7" if leaf > 0 else "#E1F5EE" | |
| tree_rows += ( | |
| f"<tr style='background:{leaf_bg};'>" | |
| f"<td style='padding:5px 6px; font-family:monospace;'>#{tree_id}</td>" | |
| f"<td style='padding:5px 6px; font-size:11px; color:#555;'>{path_text}</td>" | |
| f"<td style='text-align:right; padding:5px 6px; font-family:monospace; color:{leaf_color}; font-weight:500;'>{leaf:+.4f}</td>" | |
| f"<td style='text-align:right; padding:5px 6px; font-family:monospace; color:#666;'>{cumulative:+.4f}</td>" | |
| f"</tr>" | |
| ) | |
| sigmoid_result = 1 / (1 + np.exp(-raw_score)) | |
| tree_table = f""" | |
| <table style="width:100%; font-size:12px; border-collapse:collapse;"> | |
| <thead> | |
| <tr style="background:#f5f5f0; border-bottom:0.5px solid rgba(0,0,0,0.15);"> | |
| <th style="text-align:left; padding:6px; font-weight:500; color:#666; width:8%;">ํธ๋ฆฌ</th> | |
| <th style="text-align:left; padding:6px; font-weight:500; color:#666; width:60%;">๋ณธ ๊ฑฐ๋์ ๋ถ๊ธฐ ๊ฒฝ๋ก โ ๋๋ฌํ leaf</th> | |
| <th style="text-align:right; padding:6px; font-weight:500; color:#666; width:14%;">leaf ๊ฐ</th> | |
| <th style="text-align:right; padding:6px; font-weight:500; color:#666; width:18%;">๋์ raw score</th> | |
| </tr> | |
| </thead> | |
| <tbody>{tree_rows} | |
| <tr style="background:#F1EFE8; font-weight:500; border-top:1px solid rgba(0,0,0,0.3);"> | |
| <td colspan="3" style="text-align:right; padding:6px;">์ต์ข raw score (10๊ฐ ํธ๋ฆฌ ํฉ์ฐ)</td> | |
| <td style="text-align:right; padding:6px; font-family:monospace;">{raw_score:+.4f}</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| """ | |
| final_calc = formula_box( | |
| f"P(์ฌ๊ธฐ) = sigmoid({raw_score:+.4f}) " | |
| f"= 1 / (1 + e<sup>{-raw_score:+.4f}</sup>) " | |
| f"= <b>{sigmoid_result:.4f}</b> ({sigmoid_result*100:.2f}%)" | |
| ) | |
| # โโโ TreeSHAP ๊ณ์ฐ ๋ฐ ์๊ฐํ โโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| shap_values, shap_base = compute_shap_values_gen3(amount, hour, new_payee_bin, ratio) | |
| # SHAP ๊ฒ์ฆ: base + ฮฃ(shap) โ raw_score (logit space) | |
| shap_sum = shap_base + sum(shap_values) | |
| # SHAP waterfall: ์ ๋๊ฐ ํฐ ์์ผ๋ก ์ ๋ ฌ | |
| shap_pairs = sorted(zip(FEATURES, shap_values), key=lambda x: -abs(x[1])) | |
| max_abs_shap = max(abs(v) for v in shap_values) if any(shap_values) else 1.0 | |
| shap_rows = "" | |
| for feat, sv in shap_pairs: | |
| direction = "์ฌ๊ธฐ โ" if sv > 0 else "์ ์ โ" | |
| color = "#A32D2D" if sv > 0 else "#3B6D11" | |
| bar_bg = "#FAECE7" if sv > 0 else "#E1F5EE" | |
| bar_w = (abs(sv) / max_abs_shap) * 100 if max_abs_shap > 0 else 0 | |
| bar_align = "flex-start" if sv > 0 else "flex-end" | |
| # ์ข์ฐ๋ก ๋ถ๋ฆฌ๋ ๋ง๋ (์์๋ ์ค๋ฅธ์ชฝ, ์์๋ ์ผ์ชฝ) | |
| if sv > 0: | |
| bar_html = f""" | |
| <div style="display:flex; height:14px;"> | |
| <div style="width:50%; background:transparent;"></div> | |
| <div style="width:50%; background:#f0ede5; border-radius:0 3px 3px 0; overflow:hidden;"> | |
| <div style="background:#A32D2D; height:100%; width:{bar_w:.1f}%;"></div> | |
| </div> | |
| </div> | |
| """ | |
| else: | |
| bar_html = f""" | |
| <div style="display:flex; height:14px;"> | |
| <div style="width:50%; background:#f0ede5; border-radius:3px 0 0 3px; overflow:hidden; display:flex; justify-content:flex-end;"> | |
| <div style="background:#3B6D11; height:100%; width:{bar_w:.1f}%;"></div> | |
| </div> | |
| <div style="width:50%; background:transparent;"></div> | |
| </div> | |
| """ | |
| shap_rows += f""" | |
| <tr style="background:{bar_bg};"> | |
| <td style="padding:6px 8px; color:#444; width:18%;">{feat}</td> | |
| <td style="padding:6px 8px; font-family:monospace; color:{color}; text-align:right; width:14%;">{sv:+.4f}</td> | |
| <td style="padding:6px 8px; width:54%;">{bar_html}</td> | |
| <td style="padding:6px 8px; font-size:11px; color:{color}; width:14%;">{direction}</td> | |
| </tr>""" | |
| shap_table = f""" | |
| <table style="width:100%; font-size:13px; border-collapse:collapse; margin-bottom:8px;"> | |
| <thead> | |
| <tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);"> | |
| <th style="text-align:left; padding:6px 8px; font-weight:500; color:#666;">ํผ์ฒ</th> | |
| <th style="text-align:right; padding:6px 8px; font-weight:500; color:#666;">SHAP ๊ฐ</th> | |
| <th style="text-align:center; padding:6px 8px; font-weight:500; color:#666;">โ ์ ์ ๋ฐฉํฅ | ์ฌ๊ธฐ ๋ฐฉํฅ โ</th> | |
| <th style="text-align:left; padding:6px 8px; font-weight:500; color:#666;">๊ธฐ์ฌ</th> | |
| </tr> | |
| </thead> | |
| <tbody>{shap_rows} | |
| <tr style="background:#F1EFE8; border-top:1px solid rgba(0,0,0,0.3);"> | |
| <td style="padding:6px 8px;">base value (ํ๊ท ๊ฑฐ๋)</td> | |
| <td style="padding:6px 8px; font-family:monospace; text-align:right;">{shap_base:+.4f}</td> | |
| <td colspan="2" style="padding:6px 8px; font-size:11px; color:#666;">ํ์ต ๋ฐ์ดํฐ ์ ์ฒด์ ํ๊ท logit</td> | |
| </tr> | |
| <tr style="background:#FAEEDA; font-weight:500;"> | |
| <td style="padding:6px 8px;">ํฉ๊ณ = base + ฮฃ(SHAP)</td> | |
| <td style="padding:6px 8px; font-family:monospace; text-align:right;">{shap_sum:+.4f}</td> | |
| <td colspan="2" style="padding:6px 8px; font-size:11px; color:#666;">โ raw score {raw_score:+.4f} (๊ฐ์ฐ์ฑ ๋ณด์ฅ)</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| """ | |
| gen3_setup = feature_setup_box( | |
| actor_label="ํผ์ฒ๋ ์ฌ๋, ํธ๋ฆฌ ๊ตฌ์กฐ๋ ๋ชจ๋ธ", | |
| actor_color='mixed', | |
| items=[ | |
| ("์ ๋ ฅ Feature 4๊ฐ", "์ฌ๋", "1ยท2์ธ๋์ ์์ ํ ๋์ผํ 4๊ฐ ์ปฌ๋ผ"), | |
| ("ํ์ต ๋ฐ์ดํฐ", "์ฌ๋", "1ยท2์ธ๋์ ๋์ผํ 250๊ฑด"), | |
| ("ํธ๋ฆฌ ๋ถ๊ธฐ ์๊ณ๊ฐ", "๋ชจ๋ธ", "Gain ์ต๋ํ๋ก ์๋ ๊ฒฐ์ (์: ์๊ฐ < 8.69, ๊ธ์ก๋น์จ < 1.77)"), | |
| ("๊ฐ leaf ๊ฐ", "๋ชจ๋ธ", "๊ฐ leaf์ ๋๋ฌํ ์ํ๋ค์ ์์ฐจ๋ก ์๋ ๊ณ์ฐ"), | |
| ("ํธ๋ฆฌ ๊ฐ์ / ๊น์ด", "์ฌ๋", "n_estimators=10, max_depth=3 (ํ์ดํผํ๋ผ๋ฏธํฐ)"), | |
| ("ํ์ ์๊ณ๊ฐ", "์ฌ๋", "0.5(์ถ๊ฐ์ธ์ฆ) / 0.7(์ฐจ๋จ)"), | |
| ], | |
| explanation="2์ธ๋๋ณด๋ค ํ์ต๋๋ ๋ถ๋ถ์ด ํจ์ฌ ๋ง์์ง. ๋ถ๊ธฐ ์๊ณ๊ฐ๊ณผ leaf ๊ฐ ๋ชจ๋ ๋ฐ์ดํฐ์์ ์๋ ๋ฐ๊ฒฌ." | |
| ) | |
| # โโโ XAI ๊ตฌํ ๋ฐ์ค (3์ธ๋) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| top_shap_feat, top_shap_val = shap_pairs[0] | |
| gen3_xai = xai_box( | |
| method="TreeSHAP (Lundberg 2017)", | |
| status='post-hoc', | |
| era="2017๋ ๋ฐํ โ 2018-2020๋ ๊ธ์ต๊ถ ํ์คํ", | |
| items=[ | |
| ("์ค๋ช ์์ฑ ๋ฐฉ์", "๋ชจ๋ธ ํ์ต ํ ๋ณ๋ ์๊ณ ๋ฆฌ์ฆ์ผ๋ก SHAP ๊ฐ ๊ณ์ฐ. XGBoost๋ TreeSHAP์ด๋ผ๋ ํธ๋ฆฌ ๊ตฌ์กฐ ํนํ ์๊ณ ๋ฆฌ์ฆ์ด ๋ด์ฅ๋์ด ์์ด ์ค์๊ฐ ์ถ๋ก ์๋ ์ฌ์ฉ ๊ฐ๋ฅ (๊ฐ๋ณ ๊ฑฐ๋๋น ~1ms)"), | |
| ("๊ฐ๋ณ ๊ฑฐ๋ ์ค๋ช ", f"๋ณธ ๊ฑฐ๋์ SHAP ๋ถํด: ๊ฐ์ฅ ํฐ ๊ธฐ์ฌ๋ <b>{top_shap_feat}</b> ({top_shap_val:+.4f}). ๋ถํธ๊ฐ ์์๋ฉด ์ฌ๊ธฐ ๋ฐฉํฅ, ์์๋ฉด ์ ์ ๋ฐฉํฅ์ผ๋ก ๋์ด๋น๊น"), | |
| ("์ํ์ ๋ณด์ฅ", f"๊ฐ์ฐ์ฑ(Additive): base({shap_base:+.4f}) + ฮฃ(SHAP) = {shap_sum:+.4f} โ raw score {raw_score:+.4f}. ์ฆ 'ํ๊ท ๊ฑฐ๋ ๋๋น ์ด ๊ฑฐ๋๊ฐ ์ ๋ ์์ฌ์ค๋ฌ์ด๊ฐ'๋ฅผ ์ ํํ ๋ถํด"), | |
| ("๊ธ์ต๊ถ ํ์ฉ", "๊ฐ๋ ๋น๊ตญ ๋ณด๊ณ ์(์ค๋ช ๊ฐ๋ฅ์ฑ ์๋ฃ), ๊ณ ๊ฐ ๊ฑฐ์ ์ฌ์ ํต๋ณด(Adverse Action Notice), ๋ชจ๋ธ ๋๋ฒ๊น ยท๊ฒ์ฆ"), | |
| ("ํ๊ณ", "์ด๋๊น์ง๋ '๊ทผ์ฌ๋ ๊ธฐ์ฌ๋'. ๋ชจ๋ธ์ด ์ค์ ๋ก ๊ทธ๋ ๊ฒ ์ฌ๊ณ ํ๋ค๋ ๋ณด์ฅ์ ์๋ โ 4์ธ๋ GNN์์๋ ๋ ํฐ ํ๊ณ๊ฐ ๋จ"), | |
| ], | |
| takeaway="3์ธ๋๋ถํฐ 'XAI'๊ฐ ๋ณธ๊ฒฉ์ ์ผ๋ก ๋ณ๋ ๋ชจ๋๋ก ๋ฑ์ฅ. ๋ชจ๋ธ = ํ๋จ๊ธฐ, SHAP = ์ค๋ช ๊ธฐ๋ก ์ญํ ์ด ๋ถ๋ฆฌ๋จ. ์ด๊ฒ ํ์ฌ ๊ธ์ต๊ถ FDS์ ํ์ค ์ํคํ ์ฒ." | |
| ) | |
| return f""" | |
| <div style="{CARD_STYLE}"> | |
| {card_header("GEN 3 ยท XGBOOST (TREE ENSEMBLE)", "XGBoost (ํธ๋ฆฌ 10๊ฐ ์์๋ธ)", dec, bg, fg, f"์ฌ๊ธฐ ํ๋ฅ {prob*100:.2f}%")} | |
| {gen3_setup} | |
| {gen3_xai} | |
| {formula_box("F(x) = ฮฃ<sub>k=1..K</sub> f<sub>k</sub>(x), f<sub>k</sub> โ ํธ๋ฆฌ ๊ณต๊ฐ<br>P(์ฌ๊ธฐ) = sigmoid(F(x)) [K=10, max_depth=3, lr=0.1]")} | |
| <p style="font-size:13px; color:#666; margin:12px 0 6px;">ํผ์ฒ ์ค์๋ (Gain ๊ธฐ๋ฐ, ์ ์ญ ์ค๋ช )</p> | |
| <div style="display:flex; flex-direction:column; gap:6px; font-size:13px;">{imp_bars}</div> | |
| <p style="font-size:12px; color:#888; margin:6px 0 0; font-style:italic;">โ ๏ธ ์ ์ค์๋๋ ๋ชจ๋ธ ์ ์ฒด ํ๊ท ์ด๋ผ ๊ฐ๋ณ ๊ฑฐ๋ ์ค๋ช ์๋ ๋ถ์ ํฉ โ ๊ทธ๋์ SHAP์ด ํ์</p> | |
| <p style="font-size:13px; color:#666; margin:16px 0 6px;">๐ฏ TreeSHAP โ ๋ณธ ๊ฑฐ๋์ ๋ํ ๊ฐ๋ณ ๊ธฐ์ฌ๋ ๋ถํด (Local Explanation)</p> | |
| <p style="font-size:12px; color:#888; margin:0 0 8px; font-style:italic;">'์ด ๊ฑฐ๋๊ฐ ํ๊ท ๋ณด๋ค ์ ๋ ์์ฌ์ค๋ฌ์ด๊ฐ'๋ฅผ ํผ์ฒ๋ณ๋ก ์ ๋ ๋ถํด. ํฉ์ฐํ๋ฉด ๋ชจ๋ธ์ raw score์ ์ผ์น (๊ฐ์ฐ์ฑ ๋ณด์ฅ).</p> | |
| {shap_table} | |
| <p style="font-size:13px; color:#666; margin:14px 0 6px;">๐ณ ํ์ต๋ ํธ๋ฆฌ 10๊ฐ์ leaf ๊ฐ ๋์ (๋ถ์คํ ๋ณธ์ง)</p> | |
| <p style="font-size:12px; color:#888; margin:0 0 8px; font-style:italic;">๊ฐ ํธ๋ฆฌ๊ฐ ์ด์ ํธ๋ฆฌ์ ์์ฐจ๋ฅผ ๋ณด์ ํ๋ฉฐ leaf ๊ฐ์ ๋ํด๊ฐ โ ๋์ ๋ raw score๋ฅผ sigmoid๋ก ๋ณํ</p> | |
| {tree_table} | |
| <p style="font-size:13px; color:#666; margin:14px 0 6px;">๐งฎ ์ต์ข ํ๋ฅ ๊ณ์ฐ</p> | |
| {final_calc} | |
| <p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">๊ฐ์ : ๋น์ ํ ํจํดยทํผ์ฒ ์ํธ์์ฉ ์๋ ํ์ต + SHAP์ผ๋ก ๊ฐ๋ณ ์ค๋ช ํ๋ณด. ํ๊ณ: ํ์ต ๋ฐ์ดํฐ ๋ถํฌ ๋ฐ์ ์ผ์ด์ค(์: ์ ์ธ ์๊ธ)๋ ์ฌ์ ํ ๋ชป ์ก์</p> | |
| </div> | |
| """ | |
| def render_gen4(amount, hour, new_payee_bin, ratio, prob3): | |
| """4์ธ๋ GNN - ์ง์ง ํ์ต๋ mini GNN์ forward pass ๊ฒฐ๊ณผ + GNNExplainer ์คํ์ผ XAI""" | |
| node_features = build_graph_features(amount, hour, new_payee_bin, ratio) | |
| prob, intermediates = gnn_model.forward(node_features, return_intermediates=True) | |
| prob = float(prob) | |
| dec, bg, fg = decide(prob) | |
| h1 = intermediates['h1'] | |
| h2 = intermediates['h2'] | |
| trans_emb = intermediates['trans_embedding'] | |
| logit = intermediates['logit'] | |
| # ๊ทธ๋ํ ์๊ฐํ ์์ | |
| edge2_color = "#D85A30" if new_payee_bin == 1 else "#888780" | |
| edge2_dash = 'stroke-dasharray="" ' if new_payee_bin == 1 else 'stroke-dasharray="3,3" ' | |
| risk_fill = "#F7C1C1" if new_payee_bin == 1 else "#D3D1C7" | |
| risk_stroke = "#A32D2D" if new_payee_bin == 1 else "#5F5E5A" | |
| risk_text = "์ฌ๊ธฐ๊ณ์ข" if new_payee_bin == 1 else "์ผ๋ฐ" | |
| risk_color = "#501313" if new_payee_bin == 1 else "#444441" | |
| payee_type = "์ ๊ท" if new_payee_bin == 1 else "๊ธฐ์กด" | |
| svg = f""" | |
| <svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%; height:auto; max-height:200px;"> | |
| <defs> | |
| <marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto"> | |
| <path d="M 0 0 L 10 5 L 0 10 z" fill="#888780"/> | |
| </marker> | |
| </defs> | |
| <line x1="300" y1="100" x2="130" y2="50" stroke="#888780" stroke-width="1" marker-end="url(#arr)"/> | |
| <line x1="300" y1="100" x2="130" y2="150" stroke="#888780" stroke-width="1" marker-end="url(#arr)"/> | |
| <line x1="300" y1="100" x2="470" y2="100" stroke="{edge2_color}" stroke-width="2" marker-end="url(#arr)"/> | |
| <line x1="470" y1="100" x2="560" y2="50" stroke="{edge2_color}" stroke-width="1.5" {edge2_dash}marker-end="url(#arr)"/> | |
| <line x1="470" y1="100" x2="560" y2="150" stroke="#888780" stroke-width="1" stroke-dasharray="3,3" marker-end="url(#arr)"/> | |
| <circle cx="130" cy="50" r="26" fill="#B5D4F4" stroke="#185FA5" stroke-width="1"/> | |
| <text x="130" y="54" text-anchor="middle" font-size="11" font-weight="500" fill="#0C447C">์ก๊ธ์ธ</text> | |
| <text x="130" y="22" text-anchor="middle" font-size="10" fill="#185FA5">์ ์์ด๋ ฅ 95%</text> | |
| <circle cx="130" cy="150" r="22" fill="#D3D1C7" stroke="#5F5E5A" stroke-width="1"/> | |
| <text x="130" y="154" text-anchor="middle" font-size="11" fill="#444441">๋จ๋ง๊ธฐ</text> | |
| <text x="130" y="183" text-anchor="middle" font-size="10" fill="#5F5E5A">์ ๊ท IP</text> | |
| <rect x="260" y="78" width="80" height="44" rx="6" fill="#F0997B" stroke="#993C1D" stroke-width="1.5"/> | |
| <text x="300" y="96" text-anchor="middle" font-size="11" font-weight="500" fill="#4A1B0C">๋ณธ ๊ฑฐ๋</text> | |
| <text x="300" y="112" text-anchor="middle" font-size="10" fill="#712B13">{amount:.0f}๋ง / {int(hour):02d}์</text> | |
| <circle cx="470" cy="100" r="26" fill="#F0997B" stroke="#993C1D" stroke-width="1.5"/> | |
| <text x="470" y="100" text-anchor="middle" font-size="11" font-weight="500" fill="#4A1B0C">์์ทจ์ธ</text> | |
| <text x="470" y="138" text-anchor="middle" font-size="10" fill="#712B13">{payee_type} 1-hop</text> | |
| <circle cx="560" cy="50" r="18" fill="{risk_fill}" stroke="{risk_stroke}" stroke-width="1"/> | |
| <text x="560" y="54" text-anchor="middle" font-size="10" fill="{risk_color}">{risk_text}</text> | |
| <circle cx="560" cy="150" r="18" fill="#D3D1C7" stroke="#5F5E5A" stroke-width="1"/> | |
| <text x="560" y="154" text-anchor="middle" font-size="10" fill="#444441">์ผ๋ฐ</text> | |
| <text x="300" y="20" text-anchor="middle" font-size="11" fill="#5F5E5A">1-hop ์ด์</text> | |
| <text x="560" y="20" text-anchor="middle" font-size="11" fill="#5F5E5A">2-hop ์ด์</text> | |
| </svg> | |
| """ | |
| # 16์ฐจ์ ์๋ฒ ๋ฉ์ 4ร4 ๊ทธ๋ฆฌ๋๋ก ์๊ฐํ + ์์ ๊ฐ์กฐ | |
| h1_trans = h1[0] | |
| h2_trans = h2[0] | |
| def render_node_grid(values, prefix, base_x, base_y): | |
| cells = "" | |
| max_v = max(abs(values.max()), abs(values.min())) if len(values) > 0 else 1.0 | |
| for i, v in enumerate(values): | |
| r, c = i // 4, i % 4 | |
| x = base_x + c * 16 | |
| y = base_y + r * 16 | |
| intensity = abs(v) / max_v if max_v > 0 else 0 | |
| if v > 0: | |
| fill = f"rgb({int(240 - 100*intensity)}, {int(160 - 70*intensity)}, {int(150 - 70*intensity)})" | |
| stroke = "#993C1D" | |
| text_color = "#4A1B0C" if intensity > 0.4 else "#5F4308" | |
| else: | |
| fill = f"rgb({int(220 - 60*intensity)}, {int(220 - 30*intensity)}, {int(210 - 50*intensity)})" | |
| stroke = "#888" | |
| text_color = "#555" | |
| cells += ( | |
| f'<rect x="{x}" y="{y}" width="14" height="14" rx="2" ' | |
| f'fill="{fill}" stroke="{stroke}" stroke-width="0.5"/>' | |
| f'<text x="{x+7}" y="{y+10}" text-anchor="middle" ' | |
| f'font-size="6" fill="{text_color}">{v:.1f}</text>' | |
| ) | |
| return cells | |
| grid_h1 = render_node_grid(h1_trans, "h", 195, 55) | |
| grid_h2 = render_node_grid(h2_trans, "h'", 395, 55) | |
| feature_expansion_svg = f""" | |
| <svg viewBox="0 0 720 270" xmlns="http://www.w3.org/2000/svg" style="width:100%; height:auto;"> | |
| <defs> | |
| <marker id="arr-flow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="4" markerHeight="4" orient="auto"> | |
| <path d="M 0 0 L 10 5 L 0 10 z" fill="#bbb"/> | |
| </marker> | |
| </defs> | |
| <text x="80" y="20" text-anchor="middle" font-size="11" font-weight="500" fill="#0C447C">์ ๋ ฅ์ธต</text> | |
| <text x="80" y="34" text-anchor="middle" font-size="9" fill="#185FA5">์ฌ๋์ด ์ ์</text> | |
| <text x="240" y="20" text-anchor="middle" font-size="11" font-weight="500" fill="#5F5E5A">์๋์ธต 1 (1-hop)</text> | |
| <text x="240" y="34" text-anchor="middle" font-size="9" fill="#888">๋ชจ๋ธ์ด ์์ฑ</text> | |
| <text x="440" y="20" text-anchor="middle" font-size="11" font-weight="500" fill="#5F5E5A">์๋์ธต 2 (2-hop)</text> | |
| <text x="440" y="34" text-anchor="middle" font-size="9" fill="#888">๋ชจ๋ธ์ด ์์ฑ</text> | |
| <text x="640" y="20" text-anchor="middle" font-size="11" font-weight="500" fill="#4A1B0C">์ถ๋ ฅ</text> | |
| <text x="640" y="34" text-anchor="middle" font-size="9" fill="#712B13">์ฌ๋์ด ์ ์</text> | |
| <rect x="20" y="55" width="120" height="22" rx="4" fill="#B5D4F4" stroke="#185FA5" stroke-width="1"/> | |
| <text x="80" y="70" text-anchor="middle" font-size="11" fill="#0C447C">๊ธ์ก = {amount:.0f}</text> | |
| <rect x="20" y="85" width="120" height="22" rx="4" fill="#B5D4F4" stroke="#185FA5" stroke-width="1"/> | |
| <text x="80" y="100" text-anchor="middle" font-size="11" fill="#0C447C">์๊ฐ = {int(hour)}</text> | |
| <rect x="20" y="115" width="120" height="22" rx="4" fill="#B5D4F4" stroke="#185FA5" stroke-width="1"/> | |
| <text x="80" y="130" text-anchor="middle" font-size="11" fill="#0C447C">์ ๊ท์์ทจ์ธ = {new_payee_bin}</text> | |
| <rect x="20" y="145" width="120" height="22" rx="4" fill="#B5D4F4" stroke="#185FA5" stroke-width="1"/> | |
| <text x="80" y="160" text-anchor="middle" font-size="11" fill="#0C447C">๊ธ์ก๋น์จ = {ratio:.1f}</text> | |
| <text x="80" y="185" text-anchor="middle" font-size="10" font-weight="500" fill="#0C447C">4 features</text> | |
| <text x="80" y="198" text-anchor="middle" font-size="9" fill="#555">์๋ฏธ: ๋ช ํ</text> | |
| {grid_h1} | |
| <text x="240" y="143" text-anchor="middle" font-size="10" font-weight="500" fill="#5F5E5A">16 features</text> | |
| <text x="240" y="156" text-anchor="middle" font-size="9" fill="#888">์๋ฏธ: ๋ชจ๋ฆ</text> | |
| {grid_h2} | |
| <text x="440" y="143" text-anchor="middle" font-size="10" font-weight="500" fill="#5F5E5A">16 features</text> | |
| <text x="440" y="156" text-anchor="middle" font-size="9" fill="#888">์๋ฏธ: ๋ชจ๋ฆ</text> | |
| <rect x="590" y="100" width="100" height="40" rx="6" fill="#F0997B" stroke="#993C1D" stroke-width="1.5"/> | |
| <text x="640" y="118" text-anchor="middle" font-size="11" font-weight="500" fill="#4A1B0C">์ฌ๊ธฐ ํ๋ฅ </text> | |
| <text x="640" y="132" text-anchor="middle" font-size="11" font-weight="500" fill="#4A1B0C">{prob:.4f}</text> | |
| <line x1="142" y1="66" x2="193" y2="62" stroke="#bbb" stroke-width="0.5"/> | |
| <line x1="142" y1="66" x2="193" y2="80" stroke="#bbb" stroke-width="0.5"/> | |
| <line x1="142" y1="96" x2="193" y2="96" stroke="#bbb" stroke-width="0.5"/> | |
| <line x1="142" y1="96" x2="193" y2="115" stroke="#bbb" stroke-width="0.5"/> | |
| <line x1="142" y1="126" x2="193" y2="80" stroke="#bbb" stroke-width="0.5"/> | |
| <line x1="142" y1="126" x2="193" y2="115" stroke="#bbb" stroke-width="0.5"/> | |
| <line x1="142" y1="156" x2="193" y2="96" stroke="#bbb" stroke-width="0.5"/> | |
| <line x1="142" y1="156" x2="193" y2="130" stroke="#bbb" stroke-width="0.5"/> | |
| <text x="167" y="180" text-anchor="middle" font-size="10" font-weight="500" fill="#5F4308">Wโ</text> | |
| <text x="167" y="193" text-anchor="middle" font-size="8" fill="#888">4ร16=64</text> | |
| <line x1="298" y1="80" x2="393" y2="62" stroke="#bbb" stroke-width="0.4"/> | |
| <line x1="298" y1="80" x2="393" y2="96" stroke="#bbb" stroke-width="0.4"/> | |
| <line x1="298" y1="115" x2="393" y2="80" stroke="#bbb" stroke-width="0.4"/> | |
| <line x1="298" y1="115" x2="393" y2="115" stroke="#bbb" stroke-width="0.4"/> | |
| <text x="345" y="180" text-anchor="middle" font-size="10" font-weight="500" fill="#5F4308">Wโ</text> | |
| <text x="345" y="193" text-anchor="middle" font-size="8" fill="#888">16ร16=256</text> | |
| <line x1="498" y1="66" x2="589" y2="115" stroke="#bbb" stroke-width="0.4"/> | |
| <line x1="498" y1="96" x2="589" y2="120" stroke="#bbb" stroke-width="0.4"/> | |
| <line x1="498" y1="115" x2="589" y2="120" stroke="#bbb" stroke-width="0.4"/> | |
| <line x1="498" y1="130" x2="589" y2="125" stroke="#bbb" stroke-width="0.4"/> | |
| <text x="543" y="180" text-anchor="middle" font-size="10" font-weight="500" fill="#5F4308">Wโ (MLP)</text> | |
| <text x="543" y="193" text-anchor="middle" font-size="8" fill="#888">16โ1</text> | |
| <rect x="20" y="215" width="680" height="48" rx="6" fill="#FAFAF7" stroke="rgba(0,0,0,0.1)" stroke-width="0.5"/> | |
| <text x="40" y="232" font-size="10" font-weight="500" fill="#444">์ด ํ์ต ํ๋ผ๋ฏธํฐ: 337๊ฐ (Wโ:64 + Wโ:256 + W_mlp:16 + bias:1)</text> | |
| <text x="40" y="247" font-size="10" fill="#666">ํ์ต ๋ฐ์ดํฐ 250๊ฑด ยท 100 epoch ยท ํ์ต ์๊ฐ ์ฝ 2์ด (์ฑ ์์ ์ 1๋ฒ)</text> | |
| <text x="40" y="259" font-size="10" font-style="italic" fill="#888">vs 2์ธ๋ ๋ก์ง์คํฑ ํ๊ท 5๊ฐ ํ๋ผ๋ฏธํฐ โ ์ฝ 67๋ฐฐ ์ฆ๊ฐ, ํํ๋ ฅโ ํด์๊ฐ๋ฅ์ฑโ</text> | |
| </svg> | |
| """ | |
| # Layer๋ณ ํ์ฑ๋ | |
| layer1_active = (h1_trans > 0).sum() | |
| layer2_active = (h2_trans > 0).sum() | |
| layer1_mean = float(h1_trans[h1_trans > 0].mean()) if layer1_active > 0 else 0.0 | |
| layer2_mean = float(h2_trans[h2_trans > 0].mean()) if layer2_active > 0 else 0.0 | |
| # โโโ GNNExplainer ์คํ์ผ XAI: ์ฃ์ง ๋ง์คํน (logit space) โ | |
| # NOTE: delta๋ logit ์ฐจ์ด. sigmoid๊ฐ saturate(probโ0 ๋๋ 1)๋์ด๋ | |
| # logit space์์๋ ๋ณํ๊ฐ ๊ทธ๋๋ก ๋ณด์กด๋๋ฏ๋ก ์๊ฐํ์ ์ ํฉ. | |
| _, edge_contribs = compute_gnn_edge_attribution(amount, hour, new_payee_bin, ratio) | |
| max_edge_delta = max(abs(d) for _, _, d in edge_contribs) if edge_contribs else 1.0 | |
| edge_rows = "" | |
| for label, masked_p, delta in edge_contribs: | |
| # logit space ๊ธฐ์ค ์๊ณ๊ฐ (0.1 ์ด์์ด๋ฉด ์ ์๋ฏธ) | |
| if abs(delta) < 0.1: | |
| interp = "๊ฑฐ์ ์ํฅ ์์" | |
| color, row_bg = "#888", "#ffffff" | |
| elif delta > 0: | |
| interp = "์ด ์ฃ์ง๋ฅผ ๋๋ฉด ์ํ๋ โ โ ์ด ์ฃ์ง๊ฐ ์ํ์ ๋ง๋๋ ํต์ฌ" | |
| color, row_bg = "#A32D2D", "#FAECE7" | |
| else: | |
| interp = "์ด ์ฃ์ง๋ฅผ ๋๋ฉด ์ํ๋ โ โ ์ด ์ฃ์ง๊ฐ ์์ ์ ํธ์์" | |
| color, row_bg = "#3B6D11", "#E1F5EE" | |
| bar_w = (abs(delta) / max_edge_delta) * 100 if max_edge_delta > 0 else 0 | |
| edge_rows += f""" | |
| <tr style="background:{row_bg};"> | |
| <td style="padding:6px 8px; color:#444; width:24%;">{label}</td> | |
| <td style="padding:6px 8px; font-family:monospace; text-align:right; color:#666; width:14%;">{masked_p:.4f}</td> | |
| <td style="padding:6px 8px; font-family:monospace; text-align:right; color:{color}; font-weight:500; width:12%;">{delta:+.4f}</td> | |
| <td style="padding:6px 8px; width:30%;"> | |
| <div style="background:#f0ede5; height:12px; border-radius:3px; overflow:hidden;"> | |
| <div style="background:{color}; height:100%; width:{bar_w:.1f}%;"></div> | |
| </div> | |
| </td> | |
| <td style="padding:6px 8px; font-size:11px; color:{color}; width:20%;">{interp}</td> | |
| </tr>""" | |
| edge_table = f""" | |
| <table style="width:100%; font-size:13px; border-collapse:collapse; margin-bottom:8px;"> | |
| <thead> | |
| <tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);"> | |
| <th style="text-align:left; padding:6px 8px; font-weight:500; color:#666;">์ ๊ฑฐ ๋์ ์ฃ์ง</th> | |
| <th style="text-align:right; padding:6px 8px; font-weight:500; color:#666;">์ ๊ฑฐ ํ P</th> | |
| <th style="text-align:right; padding:6px 8px; font-weight:500; color:#666;">ฮ logit</th> | |
| <th style="text-align:center; padding:6px 8px; font-weight:500; color:#666;">ํฌ๊ธฐ</th> | |
| <th style="text-align:left; padding:6px 8px; font-weight:500; color:#666;">ํด์</th> | |
| </tr> | |
| </thead> | |
| <tbody>{edge_rows} | |
| <tr style="background:#F1EFE8;"> | |
| <td style="padding:6px 8px;">๊ธฐ์ค์ (์ ์ฒด ๊ทธ๋ํ)</td> | |
| <td style="padding:6px 8px; font-family:monospace; text-align:right; font-weight:500;">{prob:.4f}</td> | |
| <td colspan="3" style="padding:6px 8px; font-size:11px; color:#666;">์ฃ์ง๋ฅผ ๋ชจ๋ ์ด๋ฆฐ ์๋ณธ ์์ธก. โป P๋ sigmoid ํ ๊ฐ์ด๋ผ saturate ๊ฐ๋ฅ โ ๊ธฐ์ฌ๋๋ logit space์์ ์ธก์ </td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| """ | |
| # โโโ GNNExplainer ์คํ์ผ XAI: ๋ ธ๋ ํผ์ฒ ๋ง์คํน โโโโโโโโโ | |
| _, node_feat_contribs = compute_gnn_node_feature_attribution(amount, hour, new_payee_bin, ratio) | |
| max_feat_delta = max(abs(d) for _, d in node_feat_contribs) if node_feat_contribs else 1.0 | |
| feat_rows = "" | |
| for feat, delta in sorted(node_feat_contribs, key=lambda x: -abs(x[1])): | |
| color = "#A32D2D" if delta > 0 else ("#3B6D11" if delta < 0 else "#888") | |
| row_bg = "#FAECE7" if delta > 0 else ("#E1F5EE" if delta < 0 else "#ffffff") | |
| bar_w = (abs(delta) / max_feat_delta) * 100 if max_feat_delta > 0 else 0 | |
| feat_rows += f""" | |
| <tr style="background:{row_bg};"> | |
| <td style="padding:5px 8px; color:#444; width:25%;">{feat}</td> | |
| <td style="padding:5px 8px; font-family:monospace; text-align:right; color:{color}; width:18%;">{delta:+.4f}</td> | |
| <td style="padding:5px 8px; width:57%;"> | |
| <div style="background:#f0ede5; height:12px; border-radius:3px; overflow:hidden;"> | |
| <div style="background:{color}; height:100%; width:{bar_w:.1f}%;"></div> | |
| </div> | |
| </td> | |
| </tr>""" | |
| feat_table = f""" | |
| <table style="width:100%; font-size:13px; border-collapse:collapse;"> | |
| <thead> | |
| <tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);"> | |
| <th style="text-align:left; padding:5px 8px; font-weight:500; color:#666;">ํผ์ฒ (baseline์ผ๋ก ๋์ฒด ์)</th> | |
| <th style="text-align:right; padding:5px 8px; font-weight:500; color:#666;">ฮ logit</th> | |
| <th style="text-align:center; padding:5px 8px; font-weight:500; color:#666;">ํฌ๊ธฐ</th> | |
| </tr> | |
| </thead> | |
| <tbody>{feat_rows}</tbody> | |
| </table> | |
| """ | |
| # ๊ฐ์ฅ ๊ฐํ๊ฒ ํ์ฑํ๋ ์ฐจ์ top 3 | |
| top3_indices = np.argsort(h2_trans)[-3:][::-1] | |
| top3_rows = "" | |
| for i, idx in enumerate(top3_indices): | |
| val = h2_trans[idx] | |
| if val < 0.01: | |
| interpretation = "(๊ฑฐ์ ํ์ฑํ ์ ๋จ)" | |
| color = "#888" | |
| bg_row = "#ffffff" | |
| else: | |
| interpretation_pool = [ | |
| "์์ทจ์ธ ์ํ๋ ์ ํธ ์ถ์ (๋ชจ๋ธ๋ง ์๋ ์ถ์ ํจํด)", | |
| "์ก๊ธ์ธ ํ์ ํ๋ ์ดํ๋ ์ถ์ ", | |
| "๊ฑฐ๋ ์๊ฐ๋ + ๊ธ์ก ์กฐํฉ ์ ํธ", | |
| "์ฌ๋์ด ํด์ ๋ถ๊ฐ (๋ชจ๋ธ ๋ด๋ถ ํํ)", | |
| "๊ทธ๋ํ 2-hop ์ํ ํด๋ฌ์คํฐ ์ ํธ ์ถ์ ", | |
| ] | |
| interpretation = interpretation_pool[i % len(interpretation_pool)] | |
| color = "#4A1B0C" if val > 0.5 else "#5F4308" | |
| bg_row = "#FAECE7" if val > 0.5 else "#FAEEDA" | |
| top3_rows += ( | |
| f"<tr style='background:{bg_row};'>" | |
| f"<td style='padding:5px 4px; font-family:monospace; color:{color};'>h'<sub>{idx}</sub> (์๋์ธต 2)</td>" | |
| f"<td style='text-align:right; padding:5px 4px; font-family:monospace; color:{color};'>{val:+.3f}</td>" | |
| f"<td style='padding:5px 4px; color:{color}; font-style:italic;'>{interpretation}</td>" | |
| f"</tr>" | |
| ) | |
| interpret_table = f""" | |
| <table style="width:100%; font-size:12px; border-collapse:collapse;"> | |
| <thead> | |
| <tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);"> | |
| <th style="text-align:left; padding:5px 4px; font-weight:500; color:#666; width:25%;">์ฐจ์</th> | |
| <th style="text-align:right; padding:5px 4px; font-weight:500; color:#666; width:15%;">ํ์ฑ๊ฐ</th> | |
| <th style="text-align:left; padding:5px 4px; font-weight:500; color:#666;">์ฌ๋์ ์ถ์ (๋ชจ๋ธ์ ์๋ ค์ฃผ์ง ์์)</th> | |
| </tr> | |
| </thead> | |
| <tbody>{top3_rows} | |
| <tr><td style='padding:5px 4px; color:#888;' colspan='3'>๋๋จธ์ง 13๊ฐ ์ฐจ์: ๋๋ถ๋ถ ํด์ ๋ถ๊ฐ</td></tr> | |
| </tbody> | |
| </table> | |
| """ | |
| gen4_setup = feature_setup_box( | |
| actor_label="๊ตฌ์กฐ๋ ์ฌ๋, ์๋ฒ ๋ฉ์ ๋ชจ๋ธ ์์ฑ", | |
| actor_color='model', | |
| items=[ | |
| ("๊ทธ๋ํ ๊ตฌ์กฐ ์ ์", "์ฌ๋", "๋ ธ๋ ์ข ๋ฅ(๊ฑฐ๋ยท์ก๊ธ์ธยท์์ทจ์ธยท๋จ๋ง๊ธฐ)์ ์ฃ์ง ๊ด๊ณ๋ ์ฌ๋์ด ์ค๊ณ"), | |
| ("ํ์ต ๋ฐ์ดํฐ", "์ฌ๋", "1-3์ธ๋์ ๋์ผํ 250๊ฑด ๊ฑฐ๋์ ๋ผ๋ฒจ ๋ถ์ฌ"), | |
| ("๋ ธ๋ ์ด๊ธฐ ์๋ฒ ๋ฉ", "์ฌ๋", "๊ฐ ๋ ธ๋์ 4์ฐจ์ ์ด๊ธฐ๊ฐ์ ์ฌ๋์ด ์ธ์ฝ๋ฉ ๊ท์น ์์ฑ"), | |
| ("์๋์ธต 16์ฐจ์ Feature", "๋ชจ๋ธ", "์ฌ๋์ด ์ ์ ์ ํจ. ๋ชจ๋ธ์ด ํ์ต์ผ๋ก 16๊ฐ ์ต๋ช ์ฐจ์์ ์๋ ์์ฑ"), | |
| ("๊ฐ์ค์น Wโ, Wโ, W_mlp", "๋ชจ๋ธ", "337๊ฐ ํ๋ผ๋ฏธํฐ๋ฅผ backpropagation์ผ๋ก ์๋ ํ์ต"), | |
| ("ํ์ ์๊ณ๊ฐ", "์ฌ๋", "0.5(์ถ๊ฐ์ธ์ฆ) / 0.7(์ฐจ๋จ)"), | |
| ], | |
| explanation="๊ฒฐ์ ์ ์ฐจ์ด: 1-3์ธ๋๋ ์ฌ๋์ด ์ ํ 4๊ฐ ํผ์ฒ๋ง ๋ดค์ง๋ง, 4์ธ๋๋ ๋ชจ๋ธ์ด 16+16=32๊ฐ์ ์ ์ต๋ช ํผ์ฒ๋ฅผ ์ค์ค๋ก ๋ง๋ค์ด๋." | |
| ) | |
| # โโโ XAI ๊ตฌํ ๋ฐ์ค (4์ธ๋) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # ๊ฐ์ฅ ์ํฅ๋ ฅ ํฐ ์ฃ์งยทํผ์ฒ ์ฐพ๊ธฐ | |
| top_edge_label, _, top_edge_delta = max(edge_contribs, key=lambda x: abs(x[2])) | |
| top_feat_name, top_feat_delta = max(node_feat_contribs, key=lambda x: abs(x[1])) | |
| gen4_xai = xai_box( | |
| method="GNNExplainer (Ying 2019) โ ์ฃ์งยท๋ ธ๋ ๋ง์คํน", | |
| status='post-hoc', | |
| era="2019๋ ๋ฐํ โ 2021-2023๋ ๊ทธ๋ํ FDS ๋์ ", | |
| items=[ | |
| ("์ SHAP๋ง์ผ๋ก ๋ถ์กฑํ๊ฐ", "GNN์ ์ ๋ ฅ์ด '๊ฑฐ๋ 1๊ฑด์ ํผ์ฒ'๊ฐ ์๋๋ผ '๊ฑฐ๋๋ฅผ ๋๋ฌ์ผ ๊ทธ๋ํ(๋ ธ๋+์ฃ์ง) ์ ์ฒด'. SHAP์ ํผ์ฒ ๊ธฐ์ฌ๋๋ง ๋ถํดํ ๋ฟ, '์ด๋ ์ด์๊ณผ์ ๊ด๊ณ๊ฐ ์ํ์ ๋ง๋ค์๋๊ฐ'๋ ๋ตํ์ง ๋ชปํจ"), | |
| ("์ฃ์ง ๋ง์คํน (Edge Attribution)", f"๊ฐ ์ฃ์ง๋ฅผ ์ฐจ๋ก๋ก ๋๊ณ ์์ธก ๋ณํ ์ธก์ (logit space). ๋ณธ ๊ฑฐ๋์์ ๊ฐ์ฅ ํฐ ์ํฅ ์ฃ์ง = <b>{top_edge_label}</b> (ฮ logit = {top_edge_delta:+.4f}). ์ด๊ฒ ๊ทธ๋ํ ๊ตฌ์กฐ ์ค๋ช ์ ํต์ฌ"), | |
| ("๋ ธ๋ ํผ์ฒ ๋ง์คํน", f"๊ฐ ์ ๋ ฅ ํผ์ฒ๋ฅผ ํ๊ท ๊ฐ์ผ๋ก ๋์ฒดํ๊ณ ์์ธก ๋ณํ ์ธก์ . ๋ณธ ๊ฑฐ๋์์ ๊ฐ์ฅ ํฐ ์ํฅ ํผ์ฒ = <b>{top_feat_name}</b> (ฮ logit = {top_feat_delta:+.4f})"), | |
| ("logit space ์ธก์ ์ด์ ", "์ฌ๊ธฐ ์ผ์ด์ค์์ sigmoid๊ฐ saturate(Pโ1.0)๋๋ฉด ํ๋ฅ ์ฐจ์ด๊ฐ 0์ ์๋ ดํด ์๊ฐํ ๋ถ๊ฐ. ๊ทธ๋์ sigmoid ์ง์ ์ logit์์ ์ฐจ์ด๋ฅผ ์ธก์ (์ค๋ฌด GNNExplainer ๊ตฌํ๋ ๋์ผ)"), | |
| ("์ค๋ฌด ์ด์ ๊ฐ์น", "์: '์ด ๊ฑฐ๋๊ฐ ์ฐจ๋จ๋ ์ด์ : ์์ทจ์ธ์ด ์ฌ๊ธฐ๊ณ์ข์ 2-hop ๊ฑฐ๋ฆฌ์ ์๊ธฐ ๋๋ฌธ' ๊ฐ์ ๊ทธ๋ํ ๊ธฐ๋ฐ ์ค๋ช ์ด ๊ฐ๋ฅ โ ์ฝ์ผํฐยท์ฌ์ฌํ์ด ๊ณ ๊ฐ ์๋์ ํ์ฉ"), | |
| ("ํ๊ณ", "์ฌ์ ํ 16์ฐจ์ ์๋ ์๋ฒ ๋ฉ ์์ฒด์ ์๋ฏธ๋ ํด์ ๋ถ๊ฐ. ์ 'ํ์ฑํ ์์ 3์ฐจ์'์ ์ ํ ํด์์ ๋ชจ๋ ์ฌ๋์ ์ฌํ ์ถ์ธก์ผ ๋ฟ"), | |
| ], | |
| takeaway="4์ธ๋ XAI์ ํต์ฌ์ 'ํผ์ฒ ๊ธฐ์ฌ๋ โ ๊ทธ๋ํ ๊ตฌ์กฐ ๊ธฐ์ฌ๋'๋ก ์ค๋ช ๋จ์๊ฐ ํ์ฅ๋ ๊ฒ. ๋ณด์ด์คํผ์ฑ ํด๋ฌ์คํฐ ํ์ง ๊ฐ์ ์์ ์์ ๊ฒฐ์ ์ ์ผ๋ก ์ ์ฉํจ." | |
| ) | |
| return prob, f""" | |
| <div style="{CARD_STYLE}"> | |
| {card_header("GEN 4 ยท GNN (GRAPH NEURAL NETWORK)", "๊ทธ๋ํ ์ ๊ฒฝ๋ง (numpy ํ์ต)", dec, bg, fg, f"์ฌ๊ธฐ ํ๋ฅ {prob*100:.2f}%")} | |
| {gen4_setup} | |
| {gen4_xai} | |
| {formula_box("h<sub>v</sub><sup>(l+1)</sup> = ReLU(W<sup>(l)</sup> ยท AGG({{h<sub>u</sub><sup>(l)</sup> : u โ N(v)}}))<br>P(์ฌ๊ธฐ) = sigmoid(W<sub>mlp</sub> ยท h<sub>๊ฑฐ๋</sub><sup>(2)</sup>) [2-hop ๋ฉ์์ง ํจ์ฑ, 250๊ฑด ํ์ต๋จ]")} | |
| <p style="font-size:13px; color:#666; margin:12px 0 6px;">2-hop ์ด์ ๊ทธ๋ํ</p> | |
| {svg} | |
| <p style="font-size:13px; color:#666; margin:16px 0 6px;">๐ฏ GNNExplainer โ ์ฃ์ง ๊ธฐ์ฌ๋ (Edge Attribution)</p> | |
| <p style="font-size:12px; color:#888; margin:0 0 8px; font-style:italic;">"์ด ๊ฑฐ๋๊ฐ ์ํํ ์ด์ ๊ฐ ์ด๋ค ์ด์๊ณผ์ ๊ด๊ณ ๋๋ฌธ์ธ๊ฐ?" โ ์ฃ์ง๋ฅผ ํ๋์ฉ ๋๋ฉด์ ์์ธก์ด ์ผ๋ง๋ ๋จ์ด์ง๋์ง ์ธก์ (leave-one-edge-out)</p> | |
| {edge_table} | |
| <p style="font-size:13px; color:#666; margin:16px 0 6px;">๐ฏ GNNExplainer โ ๋ ธ๋ ํผ์ฒ ๊ธฐ์ฌ๋ (Node Feature Attribution)</p> | |
| <p style="font-size:12px; color:#888; margin:0 0 8px; font-style:italic;">"์ด ๊ฑฐ๋์ ์ด๋ค ์์ฑ์ด ๊ฐ์ฅ ์ํ์ ๋ง๋ค์๋?" โ ๊ฐ ํผ์ฒ๋ฅผ ํ๊ท ๊ฐ(baseline)์ผ๋ก ๋์ฒดํ๊ณ ์์ธก ๋ณํ ์ธก์ </p> | |
| {feat_table} | |
| <p style="font-size:13px; color:#666; margin:14px 0 6px;">๐ Feature๊ฐ ์ด๋ป๊ฒ ํ์ฅ๋๋๊ฐ (์ค์ ํ์ต๋ ๊ฐ์ค์น๋ก forward pass)</p> | |
| <p style="font-size:12px; color:#888; margin:0 0 10px; font-style:italic;">์ฌ๋์ด ์ ํ 4๊ฐ โ ๋ชจ๋ธ์ด ๋ง๋ 16๊ฐ โ ๋ ๋ค๋ฅธ 16๊ฐ โ ์ฌ๊ธฐ ํ๋ฅ </p> | |
| {feature_expansion_svg} | |
| <p style="font-size:13px; color:#666; margin:14px 0 6px;">๐ Layer๋ณ ํ์ฑํ ํต๊ณ</p> | |
| <table style="width:100%; font-size:13px; border-collapse:collapse; margin-bottom:10px;"> | |
| <thead> | |
| <tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);"> | |
| <th style="text-align:left; padding:6px 4px; font-weight:500; color:#666;">๊ณ์ธต</th> | |
| <th style="text-align:left; padding:6px 4px; font-weight:500; color:#666;">์ง๊ณ ๋ด์ฉ</th> | |
| <th style="text-align:right; padding:6px 4px; font-weight:500; color:#666;">ํ์ฑ ์ฐจ์</th> | |
| <th style="text-align:right; padding:6px 4px; font-weight:500; color:#666;">ํ๊ท ํ์ฑ๊ฐ</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr> | |
| <td style="padding:6px 4px;">Layer 1 (1-hop)</td> | |
| <td style="padding:6px 4px; color:#666;">์ก๊ธ์ธ+์์ทจ์ธ+๋จ๋ง๊ธฐ ์๋ฒ ๋ฉ ์ง๊ณ</td> | |
| <td style="text-align:right; padding:6px 4px; font-family:monospace;">{layer1_active}/16</td> | |
| <td style="text-align:right; padding:6px 4px; font-family:monospace;">{layer1_mean:.3f}</td> | |
| </tr> | |
| <tr> | |
| <td style="padding:6px 4px;">Layer 2 (2-hop)</td> | |
| <td style="padding:6px 4px; color:#666;">Layer 1 ๊ฒฐ๊ณผ๋ฅผ ๋ค์ ํ hop ์ ํ</td> | |
| <td style="text-align:right; padding:6px 4px; font-family:monospace;">{layer2_active}/16</td> | |
| <td style="text-align:right; padding:6px 4px; font-family:monospace;">{layer2_mean:.3f}</td> | |
| </tr> | |
| <tr style="background:#FAEEDA;"> | |
| <td style="padding:6px 4px; color:#4A1B0C;">MLP (๋ถ๋ฅ๊ธฐ)</td> | |
| <td style="padding:6px 4px; color:#712B13;">๊ฑฐ๋ ๋ ธ๋ ์๋ฒ ๋ฉ โ logit โ sigmoid</td> | |
| <td style="text-align:right; padding:6px 4px; font-family:monospace; color:#4A1B0C;">logit={logit:+.2f}</td> | |
| <td style="text-align:right; padding:6px 4px; font-family:monospace; color:#4A1B0C;">P={prob:.3f}</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| <p style="font-size:13px; color:#666; margin:14px 0 6px;">๐ฌ ๊ฐ์ฅ ๊ฐํ๊ฒ ํ์ฑํ๋ ์ฐจ์ (์ฌํ ์ถ์ โ ๋ชจ๋ธ ๋ด๋ถ๋ ์ ์ ์์)</p> | |
| {interpret_table} | |
| <p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">๊ฐ์ : ๊ทธ๋ํ ๊ตฌ์กฐ(๊ด๊ณ๋ง)๋ฅผ ํ์ต + GNNExplainer๋ก '์ด๋ ์ด์ยท์ด๋ ํผ์ฒ๊ฐ ๊ฒฐ์ ์ ๊ธฐ์ฌํ๋์ง' ์ถ์ถ. ํ๊ณ: 16์ฐจ์ ์๋ฒ ๋ฉ ์์ฒด์ ์๋ฏธ๋ ์ฌ์ ํ ๋ธ๋๋ฐ์ค</p> | |
| </div> | |
| """ | |
| # ============================================================ | |
| # 5์ธ๋ โ Claude API ์ค์ ํธ์ถ | |
| # ============================================================ | |
| def build_claude_prompt(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, | |
| gen3_shap=None, gen4_edges=None, gen4_feats=None): | |
| """Claude์๊ฒ ๋ณด๋ผ ์์คํ ํ๋กฌํํธ์ ์ฌ์ฉ์ ๋ฉ์์ง ๊ตฌ์ฑ. | |
| 3ยท4์ธ๋์ XAI ๊ฒฐ๊ณผ(SHAP, GNNExplainer)๋ฅผ ์ปจํ ์คํธ๋ก ํจ๊ป ์ฃผ์ ํ์ฌ | |
| LLM์ด ๋จ์ํ ์์ฒด ์ถ๋ก ์ด ์๋๋ผ ํ์ ๋ชจ๋ธ์ ์ค๋ช ๊น์ง ์ฐธ์กฐํ๋๋ก ํจ. | |
| ์ด๊ฒ ์ค๋ฌด์์ ๊ถ์ฅ๋๋ 'Grounded Reasoning' ํจํด. | |
| """ | |
| system_prompt = ( | |
| "๋น์ ์ ํ๊ตญ ์ํ์ FDS(์ด์๊ธ์ต๊ฑฐ๋ํ์ง์์คํ ) ๋ถ์ ์ ๋ฌธ๊ฐ์ ๋๋ค. " | |
| "์ฃผ์ด์ง ๊ฑฐ๋ ์ ๋ณด์ 1-4์ธ๋ ๋ชจ๋ธ์ ์ฌ์ ํ๋จยทXAI ๋ถ์ ๊ฒฐ๊ณผ๋ฅผ ๋ฐํ์ผ๋ก ์ฌ๊ธฐ ์ฌ๋ถ๋ฅผ ์ข ํฉ ํ์ ํ์ธ์. " | |
| "ํนํ ๋ถ๋์ฐ ์๊ธ ์ก๊ธ, ์ฌ์ ์ ๋๊ธ ๊ฒฐ์ ๊ฐ์ ์ ์ ๊ฑฐ๋ ํจํด๊ณผ ๋ณด์ด์คํผ์ฑยท๋ํฌํต์ฅ ํจํด์ ๊ตฌ๋ถํด์ผ ํฉ๋๋ค.\n\n" | |
| "๋ถ์ ๊ฒฐ๊ณผ๋ ๋ฐ๋์ submit_fds_judgment ๋๊ตฌ๋ฅผ ์ฌ์ฉํด์ ์ ์ถํ์ธ์. " | |
| "reasoning_steps๋ 3~5๊ฐ๋ก, ๊ฐ ๋จ๊ณ๋ง๋ค ์ด๋ค ์ ํธ๋ฅผ ์ด๋ป๊ฒ ํด์ํ๋์ง ํ ์ค๋ก ์ฐ๊ณ " | |
| "attention ๊ฐ์ค์น(0.0~1.0)๋ฅผ ๋ถ์ฌํ์ธ์. attention์ ํฉ์ 1.0 ๊ทผ์ฒ๊ฐ ๋๋๋ก ๋ถ๋ฐฐํ์ธ์. " | |
| "judgment ํ๋์๋ ํ๋จ ์ฌ์ ์ ๊ถ๊ณ ์กฐ์น๋ฅผ ํจ๊ป ์์ฐ์ด๋ก ์์ฑํ์ธ์. " | |
| "counterfactual ํ๋์๋ '์ด ๊ฑฐ๋๊ฐ ํต๊ณผ๋๋ ค๋ฉด ๋ฌด์์ด ๋ฌ๋ผ์ ธ์ผ ํ๋๊ฐ'๋ฅผ 1~2๋ฌธ์ฅ์ผ๋ก ์ ์ผ์ธ์." | |
| ) | |
| payee_str = "์ ๊ท" if new_payee_bin == 1 else "๊ธฐ์กด" | |
| time_period = "์๋ฒฝ" if (hour <= 6 or hour >= 22) else ("์ฃผ๊ฐ" if 9 <= hour <= 18 else "์ ๋ ") | |
| # 3ยท4์ธ๋ XAI ๊ฒฐ๊ณผ๋ฅผ ํ ์คํธ๋ก ์ ๋ฆฌ | |
| xai_context = "" | |
| if gen3_shap is not None: | |
| shap_lines = ", ".join([f"{f}={v:+.3f}" for f, v in gen3_shap]) | |
| xai_context += f"\n[3์ธ๋ XGBoost SHAP ๋ถํด] {shap_lines}\n" | |
| if gen4_edges is not None: | |
| edge_lines = ", ".join([f"{lbl}={d:+.3f}" for lbl, _, d in gen4_edges]) | |
| xai_context += f"[4์ธ๋ GNN ์ฃ์ง ๊ธฐ์ฌ๋] {edge_lines}\n" | |
| if gen4_feats is not None: | |
| feat_lines = ", ".join([f"{f}={d:+.3f}" for f, d in gen4_feats]) | |
| xai_context += f"[4์ธ๋ GNN ๋ ธ๋ํผ์ฒ ๊ธฐ์ฌ๋] {feat_lines}\n" | |
| user_message = ( | |
| f"[๊ฑฐ๋ ์ ๋ณด]\n" | |
| f"- ๊ธ์ก: {amount:.0f}๋ง์\n" | |
| f"- ๊ฑฐ๋ ์๊ฐ: {int(hour):02d}์ ({time_period})\n" | |
| f"- ์์ทจ์ธ: {payee_str} ์์ทจ์ธ\n" | |
| f"- ๊ณผ๊ฑฐ ๋๋น ๋ฐฐ์จ: {ratio:.1f}๋ฐฐ (์ก๊ธ์ธ์ ํ๊ท ๊ฑฐ๋์ก ๋๋น)\n\n" | |
| f"[1-4์ธ๋ ๋ชจ๋ธ ์ฌ์ ํ๋จ]\n" | |
| f"- ํ๊ท ์ฌ๊ธฐ ํ๋ฅ : {prior_avg*100:.1f}%\n" | |
| f"- ์ข ํฉ ํ์ : {prior_dec}\n" | |
| f"{xai_context}\n" | |
| f"์ ๊ฑฐ๋์ ๋ํด 1-4์ธ๋ XAI ๊ฒฐ๊ณผ๋ฅผ ์ฐธ์กฐํ์ฌ FDS ์ ๋ฌธ๊ฐ ๊ด์ ์์ ์ข ํฉ ํ์ ํด์ฃผ์ธ์. " | |
| f"ํนํ XAI ๊ฒฐ๊ณผ ์ค ๊ฐ์ฅ ์ค์ํ ์ ํธ๊ฐ ๋ฌด์์ด์๋์ง reasoning์ ๋ฐ์ํ์ธ์." | |
| ) | |
| return system_prompt, user_message | |
| # Tool Use ์คํค๋ง ์ ์ โ counterfactual ํ๋ ์ถ๊ฐ | |
| FDS_JUDGMENT_TOOL = { | |
| "name": "submit_fds_judgment", | |
| "description": "FDS ๋ถ์ ๊ฒฐ๊ณผ๋ฅผ ๊ตฌ์กฐํ๋ ํ์์ผ๋ก ์ ์ถํฉ๋๋ค.", | |
| "input_schema": { | |
| "type": "object", | |
| "properties": { | |
| "risk_score": { | |
| "type": "number", | |
| "description": "์ฌ๊ธฐ ์์ฌ๋ (0.0~1.0)", | |
| "minimum": 0.0, | |
| "maximum": 1.0 | |
| }, | |
| "decision": { | |
| "type": "string", | |
| "description": "์ต์ข ํ์ ", | |
| "enum": ["์ฐจ๋จ", "์ถ๊ฐ ์ธ์ฆ", "ํต๊ณผ"] | |
| }, | |
| "reasoning_steps": { | |
| "type": "array", | |
| "description": "์ถ๋ก ๋จ๊ณ 3~5๊ฐ", | |
| "minItems": 3, | |
| "maxItems": 5, | |
| "items": { | |
| "type": "object", | |
| "properties": { | |
| "step": {"type": "string", "description": "์ถ๋ก ๋ด์ฉ (ํ ์ค)"}, | |
| "attention": {"type": "number", "minimum": 0.0, "maximum": 1.0}, | |
| "evidence_source": { | |
| "type": "string", | |
| "description": "์ด ์ถ๋ก ๋จ๊ณ์ ๊ทผ๊ฑฐ ์ถ์ฒ (์: '3์ธ๋ SHAP', '4์ธ๋ GNNExplainer', '์๊ฐ ์ ๋ณด', '๋๋ฉ์ธ ์ง์')", | |
| } | |
| }, | |
| "required": ["step", "attention"] | |
| } | |
| }, | |
| "judgment": { | |
| "type": "string", | |
| "description": "์ต์ข ์์ฐ์ด ํ๋จ (์ ๊ทธ๋ ๊ฒ ํ๋จํ๋์ง + ๊ถ๊ณ ์กฐ์น๋ฅผ ํ๊ตญ์ด๋ก)" | |
| }, | |
| "counterfactual": { | |
| "type": "string", | |
| "description": "์ด ๊ฑฐ๋๊ฐ ํต๊ณผ๋๋ ค๋ฉด ๋ฌด์์ด ๋ฌ๋ผ์ ธ์ผ ํ๋๊ฐ (๋ฐ์ฌ์ค ์ค๋ช )" | |
| } | |
| }, | |
| "required": ["risk_score", "decision", "reasoning_steps", "judgment"] | |
| } | |
| } | |
| def _try_parse_json_with_repair(text): | |
| try: | |
| return json.loads(text) | |
| except json.JSONDecodeError: | |
| pass | |
| repaired = re.sub(r',(\s*[}\]])', r'\1', text) | |
| try: | |
| return json.loads(repaired) | |
| except json.JSONDecodeError: | |
| pass | |
| repaired2 = re.sub(r'[\x00-\x1f]', lambda m: f'\\u{ord(m.group()):04x}', repaired) | |
| return json.loads(repaired2) | |
| def call_claude_api(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, | |
| gen3_shap=None, gen4_edges=None, gen4_feats=None): | |
| if not CLAUDE_AVAILABLE: | |
| return None, "API ํค ๋ฏธ์ค์ (ANTHROPIC_API_KEY ํ๊ฒฝ๋ณ์ ์์)" | |
| system_prompt, user_message = build_claude_prompt( | |
| amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, | |
| gen3_shap=gen3_shap, gen4_edges=gen4_edges, gen4_feats=gen4_feats | |
| ) | |
| try: | |
| t0 = time.time() | |
| response = claude_client.messages.create( | |
| model=CLAUDE_MODEL, | |
| max_tokens=1024, | |
| temperature=0.2, | |
| system=system_prompt, | |
| tools=[FDS_JUDGMENT_TOOL], | |
| tool_choice={"type": "tool", "name": "submit_fds_judgment"}, | |
| messages=[{"role": "user", "content": user_message}] | |
| ) | |
| latency = time.time() - t0 | |
| parsed = None | |
| raw_text = "" | |
| for block in response.content: | |
| if block.type == "tool_use" and block.name == "submit_fds_judgment": | |
| parsed = block.input | |
| raw_text = json.dumps(parsed, ensure_ascii=False, indent=2) | |
| break | |
| elif block.type == "text": | |
| raw_text += block.text | |
| if parsed is None: | |
| json_match = re.search(r'\{.*\}', raw_text, re.DOTALL) | |
| if not json_match: | |
| return None, f"์๋ต์ JSON ์์. raw_text ์๋ถ๋ถ: {raw_text[:200]}" | |
| try: | |
| parsed = _try_parse_json_with_repair(json_match.group(0)) | |
| except json.JSONDecodeError as e: | |
| return None, f"JSONDecodeError: {str(e)[:150]} | raw ์๋ถ๋ถ: {raw_text[:200]}" | |
| meta = { | |
| "input_tokens": response.usage.input_tokens, | |
| "output_tokens": response.usage.output_tokens, | |
| "latency": latency, | |
| "model": CLAUDE_MODEL, | |
| "raw_text": raw_text, | |
| } | |
| return parsed, meta | |
| except Exception as e: | |
| return None, f"API ํธ์ถ ์คํจ: {type(e).__name__}: {str(e)[:200]}" | |
| def render_gen5_card(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, | |
| prob, decision_text, reasoning_steps, judgment_text, | |
| counterfactual_text, cf_results, meta_html, source_label): | |
| """5์ธ๋ ์นด๋ HTML ๋ ๋๋ง""" | |
| dec, bg, fg = decide(prob) | |
| context_html = ( | |
| f"[SYS] ๋น์ ์ ํ๊ตญ ์ํ์ FDS ๋ถ์ ์ ๋ฌธ๊ฐ์ ๋๋ค. ๊ฑฐ๋ ์ ๋ณดยท1-4์ธ๋ ์ฌ์ ํ๋จยทXAI ๊ฒฐ๊ณผ๋ฅผ ํ ๋๋ก ์ข ํฉ ํ์ ํ์ธ์.<br>" | |
| f"[INPUT] amount={amount:.0f}๋ง, hour={int(hour):02d}, " | |
| f"new_payee={'true' if new_payee_bin==1 else 'false'}, ratio={ratio:.1f}ร<br>" | |
| f"[PRIOR] 1-4์ธ๋ ํ๊ท : {prior_avg*100:.1f}% / ์ข ํฉ: {prior_dec}<br>" | |
| f"[XAI-IN] 3์ธ๋ SHAP + 4์ธ๋ GNNExplainer ๊ฒฐ๊ณผ ํจ๊ป ์ฃผ์ (Grounded Reasoning)<br>" | |
| f"[TASK] JSON ํ์์ผ๋ก risk_score, decision, reasoning_steps(+evidence_source), judgment, counterfactual ์ถ๋ ฅ" | |
| ) | |
| cot_rows = "" | |
| for i, step in enumerate(reasoning_steps, 1): | |
| if isinstance(step, dict): | |
| step_text = step.get("step", "") | |
| attn = step.get("attention", 0.0) | |
| evidence = step.get("evidence_source", "-") | |
| else: | |
| step_text = str(step) | |
| attn = 0.0 | |
| evidence = "-" | |
| try: | |
| attn = float(attn) | |
| except (ValueError, TypeError): | |
| attn = 0.0 | |
| # evidence_source์ ๋ฐ๋ผ ๋ฐฐ์ง ์์ ๋ค๋ฅด๊ฒ | |
| if "SHAP" in evidence or "3์ธ๋" in evidence: | |
| ev_bg, ev_fg = "#FAEEDA", "#854F0B" | |
| elif "GNN" in evidence or "4์ธ๋" in evidence: | |
| ev_bg, ev_fg = "#E6F1FB", "#0C447C" | |
| elif "๋๋ฉ์ธ" in evidence or "์ง์" in evidence: | |
| ev_bg, ev_fg = "#FAECE7", "#993C1D" | |
| else: | |
| ev_bg, ev_fg = "#F1EFE8", "#5F5E5A" | |
| cot_rows += ( | |
| f"<tr><td style='padding:6px 4px;'>{i}</td>" | |
| f"<td style='padding:6px 4px; color:#666;'>{step_text}</td>" | |
| f"<td style='padding:6px 4px;'><span style='background:{ev_bg}; color:{ev_fg}; font-size:10px; padding:2px 7px; border-radius:5px;'>{evidence}</span></td>" | |
| f"<td style='text-align:right; padding:6px 4px; font-family:monospace; color:#666;'>{attn:.2f}</td></tr>" | |
| ) | |
| judg_color = "#3B6D11" if prob < 0.5 else "#633806" | |
| judgment_html = ( | |
| f"<b style='color:{judg_color};'>{decision_text} ๊ถ๊ณ (์์ฌ๋ {prob*100:.0f}%)</b><br><br>" | |
| f"{judgment_text}" | |
| ) | |
| # โโโ Counterfactual ์๊ฐํ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| cf_rows = "" | |
| for item in cf_results: | |
| # ์ ์๊ทธ๋์ฒ: (label, cf_prob, prob_drop, flipped, logit_drop) | |
| if len(item) == 5: | |
| label, cf_prob, prob_drop, flipped, logit_drop = item | |
| else: # ์ด์ ์๊ทธ๋์ฒ ํธํ | |
| label, cf_prob, prob_drop, flipped = item | |
| logit_drop = 0.0 | |
| flip_badge = ("<span style='background:#EAF3DE; color:#3B6D11; font-size:10px; padding:2px 7px; border-radius:5px; margin-left:6px;'>ํ์ ๋ค์งํ โ</span>" | |
| if flipped else "") | |
| # logit drop์ด ์๋ฏธ์๋ ์ ํธ (saturate๋์ด๋ ์ด์๋จ์) | |
| logit_color = "#3B6D11" if logit_drop > 0.5 else "#888" | |
| prob_color = "#3B6D11" if prob_drop > 0.05 else "#888" | |
| cf_rows += ( | |
| f"<tr>" | |
| f"<td style='padding:5px 8px; color:#444;'>{label}{flip_badge}</td>" | |
| f"<td style='text-align:right; padding:5px 8px; font-family:monospace; color:#666;'>{cf_prob:.4f}</td>" | |
| f"<td style='text-align:right; padding:5px 8px; font-family:monospace; color:{prob_color}; font-weight:500;'>โ{prob_drop:.4f}</td>" | |
| f"<td style='text-align:right; padding:5px 8px; font-family:monospace; color:{logit_color}; font-weight:500;'>โ{logit_drop:.4f}</td>" | |
| f"</tr>" | |
| ) | |
| cf_table = f""" | |
| <table style="width:100%; font-size:13px; border-collapse:collapse;"> | |
| <thead> | |
| <tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);"> | |
| <th style="text-align:left; padding:5px 8px; font-weight:500; color:#666;">๋ฐ์ฌ์ค ๊ฐ์ </th> | |
| <th style="text-align:right; padding:5px 8px; font-weight:500; color:#666;">๋ณ๊ฒฝ ํ P</th> | |
| <th style="text-align:right; padding:5px 8px; font-weight:500; color:#666;">ฮP</th> | |
| <th style="text-align:right; padding:5px 8px; font-weight:500; color:#666;">ฮ logit</th> | |
| </tr> | |
| </thead> | |
| <tbody>{cf_rows}</tbody> | |
| </table> | |
| <p style="font-size:11px; color:#888; margin:6px 0 0; font-style:italic;">โป saturate ์์ญ(Pโ1.0)์์๋ ฮP๊ฐ ์์๋ ฮ logit์ด ํฌ๋ฉด ์ค์ ๋ก๋ ๊ฐํ๊ฒ ์ ์ ์ชฝ์ผ๋ก ๋์ด๋น๊ธฐ๋ ๋ณ๊ฒฝ์</p> | |
| """ | |
| cf_natural = "" | |
| if counterfactual_text: | |
| cf_natural = ( | |
| f"<div style='background:#E6F1FB; padding:10px 14px; border-radius:6px; " | |
| f"font-size:13px; line-height:1.7; color:#0C447C; margin-bottom:8px;'>" | |
| f"๐ฌ <b>LLM์ด ์์ฑํ ๋ฐ์ฌ์ค ์ค๋ช :</b><br>{counterfactual_text}</div>" | |
| ) | |
| gen5_setup = feature_setup_box( | |
| actor_label="ํ๋กฌํํธ๋ง ์ฌ๋, ์ถ๋ก ์ ์ ์ ์ผ๋ก ๋ชจ๋ธ", | |
| actor_color='model', | |
| items=[ | |
| ("์์คํ ํ๋กฌํํธ", "์ฌ๋", "'๋น์ ์ FDS ๋ถ์๊ฐ์ ๋๋ค' ๋ฑ์ ์ญํ ๋ถ์ฌ๋ง ์ฌ๋์ด ์์ฑ"), | |
| ("ํ์ต ๋ฐ์ดํฐ", "๋ชจ๋ธ", "Anthropic์ด ์ธํฐ๋ท ๊ท๋ชจ ๋ฐ์ดํฐ๋ก ์ฌ์ ํ์ต (์์กฐ ํ ํฐ)"), | |
| ("๋๋ฉ์ธ ์ง์", "๋ชจ๋ธ", "๋ณด์ด์คํผ์ฑ ํจํด, ๋ถ๋์ฐ ๊ฑฐ๋ ์ ํ ๋ฑ์ ์ฌ์ ํ์ต์ผ๋ก ๋ณด์ "), | |
| ("์ถ๋ก ๋จ๊ณ (CoT)", "๋ชจ๋ธ", "๊ฐ ๋จ๊ณ์์ ๋ฌด์์ ์ฃผ๋ชฉํ ์ง ๋ชจ๋ธ์ด ์ค์ค๋ก ๊ฒฐ์ "), | |
| ("์ต์ข ํ๋จ ๋ฌธ์ฅ", "๋ชจ๋ธ", "์์ฐ์ด๋ก ์๋ ์์ฑ"), | |
| ("ํ์ ์๊ณ๊ฐ", "์ฌ๋", "0.5(์ถ๊ฐ์ธ์ฆ) / 0.7(์ฐจ๋จ)"), | |
| ], | |
| explanation="๋ชจ๋ธ์ด ์ฌ์ ํ์ต๋ ๋๋ฉ์ธ ์ง์์ผ๋ก '์ ์ฌ๊ธฐ์ธ์ง/์๋์ง'๋ฅผ ์์ฐ์ด๋ก ์ถ๋ก . 4์ธ๋๊น์ง์ 250๊ฑด ํ์ต๊ณผ๋ ์ฐจ์์ด ๋ค๋ฅธ ๊ท๋ชจ์ ์ฌ์ ํ์ต์ด ๊น๋ ค ์์." | |
| ) | |
| # โโโ XAI ๊ตฌํ ๋ฐ์ค (5์ธ๋) โ ํต์ฌ ๋ณํ โโโโโโโโโโโโโโโโโโ | |
| gen5_xai = xai_box( | |
| method="CoT + Tool Use + Grounded Reasoning + Counterfactual", | |
| status='generative', | |
| era="2023๋ ChatGPT ์ดํ ~ ํ์ฌ", | |
| items=[ | |
| ("ํจ๋ฌ๋ค์ ์ ํ", "3ยท4์ธ๋: ๋ชจ๋ธ์ด ํ๋จ ํ โ ๋ณ๋ ์๊ณ ๋ฆฌ์ฆ์ด ์ค๋ช ์ถ์ถ (Post-hoc). 5์ธ๋: ์ถ๋ก ๊ณผ์ ์์ฒด๊ฐ ์์ฐ์ด๋ก ์์ฑ๋์ด ๊ทธ๊ฒ ๊ณง ์ค๋ช (Generative)"), | |
| ("Chain-of-Thought (CoT)", "๊ฐ ์ถ๋ก ๋จ๊ณ๋ฅผ ๋ชจ๋ธ์ด ์ง์ ์์ฐ์ด๋ก ์ถ๋ ฅ. ์ '์ถ๋ก ์ฒด์ธ' ํ๊ฐ ๊ทธ๊ฒ. ์ด๋ ์ ํธ์ ์ผ๋ง๋ ์ฃผ๋ชฉํ๋์ง(attention)๊น์ง ํจ๊ป ์ฐ์ถ"), | |
| ("Tool Use๋ก ๊ตฌ์กฐ ๊ฐ์ ", "submit_fds_judgment ๋๊ตฌ์ JSON ์คํค๋ง๋ก ์ถ๋ ฅ ํ์์ ๊ฐ์ โ ์์ ํ ์คํธ ํ์ฑ ์ค๋ฅ ์์ฒ ์ฐจ๋จ, DB ์ ์ฌยท๊ฐ์ฌ ์ถ์ ๊ฐ๋ฅ"), | |
| ("Grounded Reasoning", "3์ธ๋ SHAP๊ณผ 4์ธ๋ GNNExplainer ๊ฒฐ๊ณผ๋ฅผ ํ๋กฌํํธ์ ํจ๊ป ์ฃผ์ โ LLM์ด ๋จ์ ์์ฒด ์ถ๋ก ์ด ์๋๋ผ ํ์ ๋ชจ๋ธ์ ์ค๋ช ๊น์ง ์ฐธ์กฐ. evidence_source ํ๋๋ก ์ถ์ฒ ์ถ์ "), | |
| ("Counterfactual Explanation", "'์ด ๊ฑฐ๋๊ฐ ํต๊ณผ๋๋ ค๋ฉด ๋ฌด์์ด ๋ฌ๋ผ์ ธ์ผ ํ๋'๋ฅผ ์์ฐ์ด๋ก ์์ฑ + GNN์ผ๋ก ๋น ๋ฅด๊ฒ ๊ฒ์ฆ (์๋ ํ). ๊ณ ๊ฐ ์๋ยท์ด์ ์ ๊ธฐ ์ฒ๋ฆฌ์ ์ง์ ํ์ฉ ๊ฐ๋ฅ"), | |
| ("Faithfulness ํ๊ณ", "LLM์ด ์ถ๋ ฅํ reasoning์ด ์ค์ ๋ด๋ถ ๊ณ์ฐ์ ์ ํํ ๋ฐ์ํ๋ค๋ ๋ณด์ฅ์ ํ๊ณ ๋ฏธํด๊ฒฐ ๋ฌธ์ โ ๊ทธ๋์ 1-4์ธ๋ ์ ์์ ๊ต์ฐจ ๊ฒ์ฆํ๋ ํ์ด๋ธ๋ฆฌ๋๊ฐ ๊ถ์ฅ๋จ"), | |
| ], | |
| takeaway="5์ธ๋ XAI์ ๋ณธ์ง: '์ค๋ช ์ด ์ฌํ ์ถ์ถ๋๋ ๋ฌด์'์์ 'ํ๋จ์ ์ฐ์ถ๋ฌผ ๊ทธ ์์ฒด'๋ก ๋ณํ. SHAPยทGNNExplainer๋ ์ฌ๋ผ์ง์ง ์๊ณ LLM์ ์ ๋ ฅ ์ปจํ ์คํธ๋ก ํก์๋์ด ํจ๊ป ์๋." | |
| ) | |
| return f""" | |
| <div style="{CARD_STYLE}"> | |
| {card_header("GEN 5 ยท FOUNDATION MODEL (LLM)", f"์ด๊ฑฐ๋ ์ถ๋ก ๋ชจ๋ธ ({source_label})", dec, bg, fg, f"์์ฌ๋ {prob*100:.0f}%")} | |
| {gen5_setup} | |
| {gen5_xai} | |
| <p style="font-size:13px; color:#666; margin:4px 0 6px;">์ปจํ ์คํธ ํ ํฐํ (1-4์ธ๋ XAI ๊ฒฐ๊ณผ ํจ๊ป ์ฃผ์ )</p> | |
| <div style="background:#f5f5f0; padding:10px 12px; border-radius:6px; font-family:monospace; font-size:11px; line-height:1.7; margin-bottom:12px;">{context_html}</div> | |
| <p style="font-size:13px; color:#666; margin:4px 0 6px;">๐ง ์ถ๋ก ์ฒด์ธ (Chain-of-Thought) + ๊ทผ๊ฑฐ ์ถ์ฒ (evidence_source)</p> | |
| <table style="width:100%; font-size:13px; border-collapse:collapse; margin-bottom:12px;"> | |
| <thead> | |
| <tr style="border-bottom:0.5px solid rgba(0,0,0,0.15);"> | |
| <th style="text-align:left; padding:6px 4px; font-weight:500; color:#666; width:6%;">#</th> | |
| <th style="text-align:left; padding:6px 4px; font-weight:500; color:#666; width:54%;">์ถ๋ก ๋ด์ฉ</th> | |
| <th style="text-align:left; padding:6px 4px; font-weight:500; color:#666; width:25%;">๊ทผ๊ฑฐ ์ถ์ฒ</th> | |
| <th style="text-align:right; padding:6px 4px; font-weight:500; color:#666; width:15%;">Attention</th> | |
| </tr> | |
| </thead> | |
| <tbody>{cot_rows}</tbody> | |
| </table> | |
| <p style="font-size:13px; color:#666; margin:4px 0 6px;">๐ ์์ฑ๋ ์์ฐ์ด ํ๋จ</p> | |
| <div style="background:#FAEEDA; padding:12px 14px; border-radius:6px; font-size:13px; line-height:1.7; color:#412402; margin-bottom:12px;">{judgment_html}</div> | |
| <p style="font-size:13px; color:#666; margin:4px 0 6px;">๐ Counterfactual Explanation (๋ฐ์ฌ์ค ์ค๋ช )</p> | |
| <p style="font-size:12px; color:#888; margin:0 0 8px; font-style:italic;">"๋ฌด์์ด ๋ฌ๋ผ์ก๋ค๋ฉด ์ด ๊ฑฐ๋๊ฐ ํต๊ณผ๋์์๊น?" โ 4์ธ๋ GNN์ผ๋ก ๋น ๋ฅด๊ฒ ๊ฒ์ฆํ ๊ฒฐ๊ณผ</p> | |
| {cf_natural} | |
| {cf_table} | |
| <details style="margin-top:10px;"> | |
| <summary style="font-size:12px; color:#666; cursor:pointer;">์์ฑ ํ๋ผ๋ฏธํฐ ๋ณด๊ธฐ</summary> | |
| <div style="background:#f5f5f0; padding:8px 12px; border-radius:6px; font-family:monospace; font-size:11px; margin-top:6px; line-height:1.6;">{meta_html}</div> | |
| </details> | |
| <p style="font-size:12px; color:#888; margin:10px 0 0; font-style:italic;">๊ฐ์ : 1-4์ธ๋ XAI ๊ฒฐ๊ณผ๋ฅผ ์ปจํ ์คํธ๋ก ๋ฐ์ ์์ฐ์ด ์ถ๋ก + ๊ถ๊ณ ์กฐ์น + ๋ฐ์ฌ์ค ์ค๋ช ๊น์ง ํ ๋ฒ์ ์์ฑ</p> | |
| </div> | |
| """ | |
| def render_gen5_simulation(amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, | |
| gen3_shap, gen4_edges, gen4_feats, cf_results): | |
| """API ํธ์ถ ์คํจ/๋ฏธ์ค์ ์ fallback์ฉ ์๋ฎฌ๋ ์ด์ """ | |
| is_high_risk = (new_payee_bin == 1 and (hour <= 6 or hour >= 22) and amount >= 500) | |
| prob = 0.95 if is_high_risk else min(prior_avg + 0.02, 0.98) | |
| payee_str = "์ ๊ท ์์ทจ์ธ" if new_payee_bin == 1 else "๊ธฐ์กด ์์ทจ์ธ" | |
| time_str = "์๋ฒฝ" if (hour <= 6 or hour >= 22) else "์ผ๋ฐ" | |
| # SHAP/GNN XAI ๊ฒฐ๊ณผ์์ ๊ฐ์ฅ ํฐ ์ ํธ ์ถ์ถํ์ฌ reasoning์ ๋ฐ์ | |
| top_shap = max(gen3_shap, key=lambda x: abs(x[1])) if gen3_shap else ("๊ธ์ก", 0) | |
| top_edge = max(gen4_edges, key=lambda x: abs(x[2])) if gen4_edges else ("์ด์", 0, 0) | |
| reasoning_steps = [ | |
| { | |
| "step": f"4์ธ๋ GNNExplainer: '{top_edge[0]}' ์ฃ์ง ๊ธฐ์ฌ๋ {top_edge[2]:+.3f} โ ๊ทธ๋ํ ๊ตฌ์กฐ์ ํต์ฌ ์ ํธ", | |
| "attention": 0.32, | |
| "evidence_source": "4์ธ๋ GNNExplainer" | |
| }, | |
| { | |
| "step": f"3์ธ๋ SHAP: '{top_shap[0]}' ๊ธฐ์ฌ๋ {top_shap[1]:+.3f} โ ํ๊ท ๊ฑฐ๋ ๋๋น ์ํ ๋ฐฉํฅ", | |
| "attention": 0.28, | |
| "evidence_source": "3์ธ๋ SHAP" | |
| }, | |
| { | |
| "step": f"์๊ฐ {int(hour):02d}์ + {payee_str} โ ๋ณด์ด์คํผ์ฑ ์ ํ ํจํด ๋งค์นญ", | |
| "attention": 0.24, | |
| "evidence_source": "๋๋ฉ์ธ ์ง์" | |
| }, | |
| { | |
| "step": f"ํ์ {ratio:.1f}๋ฐฐ ๊ธ์ก โ ์ก๊ธ์ธ ํ์ ํ๋ ์ดํ๋ ์ธก์ ", | |
| "attention": 0.16, | |
| "evidence_source": "๊ฑฐ๋ ์ปจํ ์คํธ" | |
| }, | |
| ] | |
| if is_high_risk: | |
| decision_text = "์ฐจ๋จ" | |
| judgment_text = ( | |
| f"{int(hour):02d}์ {time_str} ์๊ฐ๋์ ํ์๋ณด๋ค {ratio:.1f}๋ฐฐ ๊ธ์ฆํ {amount:.0f}๋ง์์ด " | |
| f"<u>{payee_str}</u>์๊ฒ ์ด์ฒด๋๋ ๊ฒ์ ์ ํ์ ์ธ ๋ณด์ด์คํผ์ฑ ํจํด์ ๋๋ค. " | |
| f"3์ธ๋ SHAP์์๋ '{top_shap[0]}'์ด ๊ฐ์ฅ ๊ฐํ ์ํ ์ ํธ๋ก ๋ํ๋ฌ๊ณ , " | |
| f"4์ธ๋ GNNExplainer ๊ฒฐ๊ณผ '{top_edge[0]}'์ด ๊ทธ๋ํ ๊ตฌ์กฐ ์ฐจ์์์ ํต์ฌ ๊ธฐ์ฌ๋ฅผ ํ์ต๋๋ค.<br><br>" | |
| f"<b>๊ถ๊ณ ์กฐ์น:</b> โ ์ฆ์ ๊ฑฐ๋ ๋ณด๋ฅ, โก ๋ฑ๋ก๋ ์ ํ๋ฒํธ๋ก ๋ณธ์ธ ์ง์ ํ์ธ, โข ํ์ธ ์ ์๊ธ ๋๊ฒฐ 24์๊ฐ ์ ์ง" | |
| ) | |
| counterfactual_text = "์์ทจ์ธ์ด ์ก๊ธ์ธ์ ๊ธฐ์กด ๊ฑฐ๋ ์ด๋ ฅ์ด ์๋ ๊ณ์ข์๊ฑฐ๋, ๊ฑฐ๋ ์๊ฐ์ด ์ฃผ๊ฐ(9-18์)์ด์๋ค๋ฉด ์ํ๋๊ฐ ํฐ ํญ์ผ๋ก ๊ฐ์ํ์ ๊ฒ์ ๋๋ค." | |
| elif prob >= 0.5: | |
| decision_text = "์ถ๊ฐ ์ธ์ฆ" | |
| judgment_text = ( | |
| f"{int(hour):02d}์ ๊ฑฐ๋์์ ์ผ๋ถ ์ด์ ์ ํธ({ratio:.1f}๋ฐฐ ๊ธ์ก, {payee_str})๊ฐ ๊ฐ์ง๋์์ผ๋ " | |
| f"๊ฒฐ์ ์ ์ํ ํจํด์ ์๋๋๋ค. ์ฐจ๋จ๋ณด๋ค๋ ์ถ๊ฐ ์ธ์ฆ์ผ๋ก ๋ณธ์ธ ์์ฌ๋ฅผ ํ์ธํ๋ ๊ฒ์ด ์ ์ ํฉ๋๋ค.<br><br>" | |
| f"<b>๊ถ๊ณ ์กฐ์น:</b> โ ARS ๋๋ OTP ์ถ๊ฐ ์ธ์ฆ, โก ์ก๊ธ ์๋ ์ฌํ์ธ ๋ฉ์์ง ๋ฐ์ก" | |
| ) | |
| counterfactual_text = "์ ๊ท ์์ทจ์ธ ํ๋๊ทธ๊ฐ ์์๊ฑฐ๋ ๊ธ์ก์ด ํ์ ์์ค์ด์๋ค๋ฉด ํต๊ณผ ๊ฐ๋ฅํ์ ๊ฒ์ ๋๋ค." | |
| else: | |
| decision_text = "ํต๊ณผ" | |
| judgment_text = ( | |
| f"{int(hour):02d}์ ๊ฑฐ๋์ ํจํด์ด ์ก๊ธ์ธ์ ํ์ ํ๋ ๋ฒ์ ๋ด์ ์์ผ๋ฉฐ, " | |
| f"1-4์ธ๋ ๋ชจ๋ธ ๋ชจ๋ ์ํ ์ ํธ๋ฅผ ๊ฐํ๊ฒ ๋ณด๋ด์ง ์์์ต๋๋ค. ์ ์ ๊ฑฐ๋๋ก ํ๋จ๋ฉ๋๋ค.<br><br>" | |
| f"<b>๊ถ๊ณ ์กฐ์น:</b> ๋ณ๋ ์กฐ์น ์์ด ๊ฑฐ๋ ์งํ" | |
| ) | |
| counterfactual_text = "(ํต๊ณผ ๊ฑฐ๋์ด๋ฏ๋ก ๋ฐ์ฌ์ค ๋ถ์์ ์ ์ฉ ๋์ ์๋)" | |
| meta_html = ( | |
| "mode: <b>SIMULATION</b> (API ๋ฏธ์ฐ๊ฒฐ)<br>" | |
| "์ด ๊ฒฐ๊ณผ๋ if-else ํ๋์ฝ๋ฉ์ผ๋ก ์์ฑ๋ ์๋ฎฌ๋ ์ด์ ์ ๋๋ค.<br>" | |
| "๋จ, reasoning_steps์๋ ์ค์ 3์ธ๋ SHAP๊ณผ 4์ธ๋ GNNExplainer ๊ฒฐ๊ณผ๊ฐ ๋ฐ์๋จ.<br>" | |
| "์ค์ Claude ํธ์ถ์ ํ์ฑํํ๋ ค๋ฉด ANTHROPIC_API_KEY ํ๊ฒฝ๋ณ์๋ฅผ ์ค์ ํ์ธ์." | |
| ) | |
| return render_gen5_card( | |
| amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, | |
| prob, decision_text, reasoning_steps, judgment_text, | |
| counterfactual_text, cf_results, | |
| meta_html, source_label="์๋ฎฌ๋ ์ด์ ๋ชจ๋" | |
| ) | |
| def render_gen5(amount, hour, new_payee_bin, ratio, prior_avg, use_claude_api=True): | |
| """5์ธ๋ ์ง์ ์ . | |
| 1-4์ธ๋ XAI ๊ฒฐ๊ณผ(SHAP, GNNExplainer)๋ฅผ ๋ชจ๋ ์์งํ์ฌ LLM์ ํจ๊ป ์ ๋ฌ. | |
| """ | |
| prior_dec, _, _ = decide(prior_avg) | |
| # 3ยท4์ธ๋ XAI ๊ฒฐ๊ณผ ์์ง (Grounded Reasoning์ฉ) | |
| gen3_shap_vals, _ = compute_shap_values_gen3(amount, hour, new_payee_bin, ratio) | |
| gen3_shap = list(zip(FEATURES, gen3_shap_vals)) | |
| _, gen4_edges = compute_gnn_edge_attribution(amount, hour, new_payee_bin, ratio) | |
| _, gen4_feats = compute_gnn_node_feature_attribution(amount, hour, new_payee_bin, ratio) | |
| # Counterfactual ํ๋ณด ํ๊ฐ (GNN์ผ๋ก ๋น ๋ฅด๊ฒ) | |
| cf_results = build_counterfactual_gen5(amount, hour, new_payee_bin, ratio) | |
| if not use_claude_api or not CLAUDE_AVAILABLE: | |
| return render_gen5_simulation( | |
| amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, | |
| gen3_shap, gen4_edges, gen4_feats, cf_results | |
| ) | |
| parsed, meta_or_err = call_claude_api( | |
| amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, | |
| gen3_shap=gen3_shap, gen4_edges=gen4_edges, gen4_feats=gen4_feats | |
| ) | |
| if parsed is None: | |
| fallback_html = render_gen5_simulation( | |
| amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, | |
| gen3_shap, gen4_edges, gen4_feats, cf_results | |
| ) | |
| warning = ( | |
| f'<div style="background:#FCEBEB; border-left:3px solid #A32D2D; padding:10px 14px; ' | |
| f'border-radius:6px; margin-bottom:10px; font-size:13px; color:#4A1B0C;">' | |
| f'โ ๏ธ Claude API ํธ์ถ ์คํจ. ์๋ฎฌ๋ ์ด์ ๋ชจ๋๋ก ๋์ฒดํฉ๋๋ค.<br>' | |
| f'<span style="font-family:monospace; font-size:11px; color:#712B13;">์ฌ์ : {meta_or_err}</span>' | |
| f'</div>' | |
| ) | |
| return warning + fallback_html | |
| try: | |
| prob = float(parsed.get("risk_score", 0.5)) | |
| prob = max(0.0, min(1.0, prob)) | |
| decision_text = parsed.get("decision", "์ถ๊ฐ ์ธ์ฆ") | |
| reasoning_steps = parsed.get("reasoning_steps", []) | |
| judgment_text = parsed.get("judgment", "(ํ๋จ ๋ด์ฉ ๋๋ฝ)") | |
| counterfactual_text = parsed.get("counterfactual", "") | |
| meta = meta_or_err | |
| meta_html = ( | |
| f"model: <b>{meta['model']}</b> / temperature: 0.2 / max_tokens: 1024<br>" | |
| f"input_tokens: {meta['input_tokens']} / output_tokens: {meta['output_tokens']} / " | |
| f"latency: {meta['latency']:.2f}s<br>" | |
| f"mode: <b style='color:#3B6D11;'>LIVE API CALL โ</b> ยท Grounded with 3-4์ธ๋ XAI" | |
| ) | |
| return render_gen5_card( | |
| amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, | |
| prob, decision_text, reasoning_steps, judgment_text, | |
| counterfactual_text, cf_results, | |
| meta_html, source_label=f"์ค์ {CLAUDE_MODEL}" | |
| ) | |
| except (KeyError, ValueError, TypeError) as e: | |
| fallback_html = render_gen5_simulation( | |
| amount, hour, new_payee_bin, ratio, prior_avg, prior_dec, | |
| gen3_shap, gen4_edges, gen4_feats, cf_results | |
| ) | |
| warning = ( | |
| f'<div style="background:#FCEBEB; border-left:3px solid #A32D2D; padding:10px 14px; ' | |
| f'border-radius:6px; margin-bottom:10px; font-size:13px; color:#4A1B0C;">' | |
| f'โ ๏ธ Claude ์๋ต ํ์ฑ ์คํจ. ์๋ฎฌ๋ ์ด์ ๋ชจ๋๋ก ๋์ฒดํฉ๋๋ค.<br>' | |
| f'<span style="font-family:monospace; font-size:11px; color:#712B13;">' | |
| f'{type(e).__name__}: {str(e)[:150]}</span></div>' | |
| ) | |
| return warning + fallback_html | |
| # ============================================================ | |
| # 4. ๋ฉ์ธ ๋ถ์ ํจ์ | |
| # ============================================================ | |
| def render_xai_evolution_summary(): | |
| """๋ชจ๋ ์นด๋ ์์ ํ์๋๋ XAI ์งํ ์์ฝ ๋ฐ์ค (๊ฐ์์ฉ)""" | |
| return """ | |
| <div style="background:linear-gradient(to right, #FFFCF5, #FAFAF7); border:0.5px solid rgba(133,79,11,0.3); border-radius:12px; padding:14px 18px; margin-bottom:16px;"> | |
| <p style="font-size:13px; font-weight:500; color:#5F4308; margin:0 0 10px;">๐ ์ธ๋๋ณ XAI ๊ตฌํ ๋ฐฉ์ โ ํ๋์ ๋ณด๊ธฐ</p> | |
| <table style="width:100%; font-size:12px; border-collapse:collapse;"> | |
| <thead> | |
| <tr style="border-bottom:0.5px solid rgba(133,79,11,0.2);"> | |
| <th style="text-align:left; padding:4px 6px; font-weight:500; color:#854F0B; width:10%;">์ธ๋</th> | |
| <th style="text-align:left; padding:4px 6px; font-weight:500; color:#854F0B; width:22%;">XAI ๊ธฐ๋ฒ</th> | |
| <th style="text-align:left; padding:4px 6px; font-weight:500; color:#854F0B; width:18%;">์ค๋ช ๋จ์</th> | |
| <th style="text-align:left; padding:4px 6px; font-weight:500; color:#854F0B;">ํ ์ค ํต์ฌ</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr><td style="padding:4px 6px; color:#0C447C; font-weight:500;">1์ธ๋</td> | |
| <td style="padding:4px 6px; color:#444;">๋ถํ์ (Self-Explanatory)</td> | |
| <td style="padding:4px 6px; color:#666;">๋ฐ๋๋ ๋ฃฐ</td> | |
| <td style="padding:4px 6px; color:#555;">๋ฃฐ๋ถ ์์ฒด๊ฐ ์ค๋ช . ๋ณ๋ ์๊ณ ๋ฆฌ์ฆ ํ์ ์์</td></tr> | |
| <tr><td style="padding:4px 6px; color:#0C447C; font-weight:500;">2์ธ๋</td> | |
| <td style="padding:4px 6px; color:#444;">๊ณ์ ๋ถํด (Coefficient)</td> | |
| <td style="padding:4px 6px; color:#666;">ํผ์ฒ๋ณ wแตขยทxแตข</td> | |
| <td style="padding:4px 6px; color:#555;">์ ํ ๋ชจ๋ธ = ์ ํํ SHAP. ๋ด์ฌ์ ์ค๋ช ๋ ฅ ์ ์ง</td></tr> | |
| <tr style="background:#FAEEDA;"><td style="padding:4px 6px; color:#854F0B; font-weight:500;">3์ธ๋ โก</td> | |
| <td style="padding:4px 6px; color:#4A1B0C;"><b>TreeSHAP</b> (2017~)</td> | |
| <td style="padding:4px 6px; color:#712B13;">ํผ์ฒ ๊ธฐ์ฌ๋</td> | |
| <td style="padding:4px 6px; color:#5F4308;"><b>XAI๊ฐ ๋ณธ๊ฒฉ ๋ฑ์ฅํ ์ธ๋.</b> ๋ชจ๋ธยท์ค๋ช ๊ธฐ ๋ถ๋ฆฌ. ํ์ฌ ๊ธ์ต๊ถ ํ์ค</td></tr> | |
| <tr style="background:#FAECE7;"><td style="padding:4px 6px; color:#993C1D; font-weight:500;">4์ธ๋</td> | |
| <td style="padding:4px 6px; color:#4A1B0C;"><b>GNNExplainer</b> (2019~)</td> | |
| <td style="padding:4px 6px; color:#712B13;">์ฃ์ง + ๋ ธ๋ ํผ์ฒ</td> | |
| <td style="padding:4px 6px; color:#5F4308;">์ค๋ช ๋จ์๊ฐ '๊ทธ๋ํ ๊ตฌ์กฐ'๋ก ํ์ฅ. ๊ด๊ณ๋ง ๊ธฐ๋ฐ ์ค๋ช </td></tr> | |
| <tr style="background:#E6F1FB;"><td style="padding:4px 6px; color:#0C447C; font-weight:500;">5์ธ๋ ๐</td> | |
| <td style="padding:4px 6px; color:#04342C;"><b>CoT + Tool Use + Counterfactual</b></td> | |
| <td style="padding:4px 6px; color:#085041;">์์ฐ์ด ์ถ๋ก ์ฒด์ธ</td> | |
| <td style="padding:4px 6px; color:#0C447C;"><b>ํจ๋ฌ๋ค์ ์ ํ:</b> ์ค๋ช ์ด ์ฌํ ์ถ์ถ โ ํ๋จ์ ์ฐ์ถ๋ฌผ ๊ทธ ์์ฒด. 3ยท4์ธ๋ XAI๋ฅผ ์ปจํ ์คํธ๋ก ํก์</td></tr> | |
| </tbody> | |
| </table> | |
| <p style="font-size:11px; color:#888; margin:8px 0 0; font-style:italic; line-height:1.5;"> | |
| ๐ก ๊ฐ์ ํฌ์ธํธ: XAI๊ฐ '๋ฌธ์ '๊ฐ ๋ ๊ฒ์ 3์ธ๋(XGBoost)๋ถํฐ. 1-2์ธ๋๋ ๋ด์ฌ์ ์ค๋ช ๋ ฅ์ด, 5์ธ๋๋ ์์ฑํ ์ค๋ช ์ด ์์ด ๋ณ๋ ์๊ณ ๋ฆฌ์ฆ์ด ๋ ์ค์. 3-4์ธ๋์์ SHAP/GNNExplainer๊ฐ ํต์ฌ. | |
| </p> | |
| </div> | |
| """ | |
| def analyze_transaction(amount, hour, new_payee, ratio, use_claude_api): | |
| start_time = time.time() | |
| new_payee_bin = 1 if new_payee == "์" else 0 | |
| # 1์ธ๋ | |
| g1 = render_gen1(amount, hour, new_payee_bin, ratio) | |
| _, score1 = evaluate_gen1(amount, hour, new_payee_bin, ratio) | |
| prob1 = min(score1 / 100, 0.99) | |
| # 2์ธ๋ | |
| g2 = render_gen2(amount, hour, new_payee_bin, ratio) | |
| input_vec = np.array([amount, hour, new_payee_bin, ratio], dtype=float) | |
| logit2 = (GEN2_COEF * input_vec).sum() + GEN2_INTERCEPT | |
| prob2 = float(1 / (1 + np.exp(-logit2))) | |
| # 3์ธ๋ | |
| g3 = render_gen3(amount, hour, new_payee_bin, ratio) | |
| input_df = pd.DataFrame([[amount, hour, new_payee_bin, ratio]], columns=FEATURES) | |
| prob3 = float(gen3_model.predict_proba(input_df)[0][1]) | |
| # 4์ธ๋ | |
| prob4, g4 = render_gen4(amount, hour, new_payee_bin, ratio, prob3) | |
| # 5์ธ๋ (1-4์ธ๋ ํ๊ท ์ prior๋ก) - ํ ๊ธ์ ๋ฐ๋ผ API/์๋ฎฌ๋ ์ด์ ๋ถ๊ธฐ | |
| prior_avg = (prob1 + prob2 + prob3 + prob4) / 4 | |
| g5 = render_gen5(amount, hour, new_payee_bin, ratio, prior_avg, | |
| use_claude_api=use_claude_api) | |
| elapsed = time.time() - start_time | |
| # ๋ชจ๋ ๋ฐฐ์ง | |
| if use_claude_api and CLAUDE_AVAILABLE: | |
| mode_badge = ('<span style="background:#E6F1FB; color:#0C447C; padding:3px 10px; ' | |
| 'border-radius:8px; font-size:11px; font-weight:500;">' | |
| 'โก 5์ธ๋ LIVE API ๋ชจ๋</span>') | |
| elif use_claude_api and not CLAUDE_AVAILABLE: | |
| mode_badge = ('<span style="background:#FAEEDA; color:#854F0B; padding:3px 10px; ' | |
| 'border-radius:8px; font-size:11px; font-weight:500;">' | |
| 'โ ๏ธ API ํค ๋ฏธ์ค์ โ ์๋ฎฌ๋ ์ด์ ์๋ ์ ํ</span>') | |
| else: | |
| mode_badge = ('<span style="background:#F1EFE8; color:#5F5E5A; padding:3px 10px; ' | |
| 'border-radius:8px; font-size:11px; font-weight:500;">' | |
| '๐งช 5์ธ๋ ์๋ฎฌ๋ ์ด์ ๋ชจ๋ (ํ ๊ธ OFF)</span>') | |
| summary = f""" | |
| <div style="background:#f5f5f0; border-radius:12px; padding:16px 20px; margin-bottom:14px;"> | |
| <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:8px;"> | |
| <p style="font-size:13px; color:#666; margin:0;">๋ถ์ ๋์ ๊ฑฐ๋</p> | |
| {mode_badge} | |
| </div> | |
| <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(120px, 1fr)); gap:12px;"> | |
| <div><p style="font-size:12px; color:#888; margin:0;">๊ธ์ก</p><p style="font-size:18px; font-weight:500; margin:2px 0 0;">{amount:.0f}๋ง์</p></div> | |
| <div><p style="font-size:12px; color:#888; margin:0;">๊ฑฐ๋ ์๊ฐ</p><p style="font-size:18px; font-weight:500; margin:2px 0 0;">{int(hour):02d}์</p></div> | |
| <div><p style="font-size:12px; color:#888; margin:0;">์ ๊ท ์์ทจ์ธ</p><p style="font-size:18px; font-weight:500; margin:2px 0 0;">{new_payee}</p></div> | |
| <div><p style="font-size:12px; color:#888; margin:0;">๊ณผ๊ฑฐ ๋๋น ๋ฐฐ์จ</p><p style="font-size:18px; font-weight:500; margin:2px 0 0;">{ratio:.1f}ร</p></div> | |
| </div> | |
| <p style="font-size:12px; color:#888; margin:10px 0 0;">๋ถ์ ์์์๊ฐ: {elapsed:.3f}์ด</p> | |
| </div> | |
| """ | |
| return summary + render_xai_evolution_summary() + g1 + g2 + g3 + g4 + g5 | |
| # ============================================================ | |
| # 5. Gradio UI | |
| # ============================================================ | |
| with gr.Blocks(theme=gr.themes.Default(), title="FDS 1-5์ธ๋ ๋น๊ต ๋ฐ๋ชจ") as demo: | |
| _api_badge = ( | |
| f'<span style="background:#EAF3DE; color:#3B6D11; padding:3px 10px; border-radius:8px; font-size:12px; font-weight:500;">' | |
| f'โ Claude API ์ฐ๊ฒฐ๋จ ({CLAUDE_MODEL})</span>' | |
| if CLAUDE_AVAILABLE else | |
| '<span style="background:#FAEEDA; color:#854F0B; padding:3px 10px; border-radius:8px; font-size:12px; font-weight:500;">' | |
| 'โ API ๋ฏธ์ฐ๊ฒฐ (5์ธ๋ ์๋ฎฌ๋ ์ด์ ๋ชจ๋)</span>' | |
| ) | |
| gr.HTML(f""" | |
| <div style="text-align:center; padding:10px 0;"> | |
| <h1 style="margin:0;">๐ก๏ธ FDS 1-5์ธ๋ ๋น๊ต ๋ฐ๋ชจ</h1> | |
| <p style="color:#666; margin:6px 0 0;">์ค๋ฌด ์ด์ ๋ด๋น์ ์์ฐ์ฉ ยท ํ๋จ ์์ยท๊ฐ์ค์นยท๊ทผ๊ฑฐ + ์ธ๋๋ณ XAI ๊ตฌํ ์ ์ฒด ๋ ธ์ถ ๋ชจ๋</p> | |
| <div style="margin-top:8px;">{_api_badge}</div> | |
| </div> | |
| """) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| amount_in = gr.Number(label="๊ธ์ก (๋ง์)", value=700) | |
| hour_in = gr.Slider(label="๊ฑฐ๋ ์๊ฐ (0-23์)", minimum=0, maximum=23, value=3, step=1) | |
| payee_in = gr.Radio(label="์ ๊ท ์์ทจ์ธ", choices=["์๋์ค", "์"], value="์") | |
| ratio_in = gr.Number(label="๊ณผ๊ฑฐ ๋๋น ๋ฐฐ์จ", value=14.0) | |
| use_api_in = gr.Checkbox( | |
| label="๐ค 5์ธ๋์ ์ค์ Claude API ํธ์ถ", | |
| value=CLAUDE_AVAILABLE, | |
| interactive=CLAUDE_AVAILABLE, | |
| info=( | |
| "์ฒดํฌ: claude-sonnet-4-6 ์ค์ ํธ์ถ (์ง์ฐ 1~3์ด, ํธ์ถ๋น ์ฝ 10์)" | |
| if CLAUDE_AVAILABLE else | |
| "ANTHROPIC_API_KEY๊ฐ ์ค์ ๋์ง ์์ ์๋ฎฌ๋ ์ด์ ๋ง ๊ฐ๋ฅํฉ๋๋ค" | |
| ) | |
| ) | |
| submit_btn = gr.Button("๐ ๋ถ์ ์คํ", variant="primary") | |
| gr.Examples( | |
| examples=[ | |
| [700, 3, "์", 14.0], | |
| [800, 23, "์", 8.0], | |
| [45, 2, "์๋์ค", 1.2], | |
| [1500, 14, "์๋์ค", 2.0], | |
| [499, 23, "์", 4.5], | |
| ], | |
| inputs=[amount_in, hour_in, payee_in, ratio_in], | |
| label="์์ฐ ์์ (๋ง์ง๋ง์ 1์ธ๋ ๋ฃฐ์ ํํผํ๋ ์ผ์ด์ค)" | |
| ) | |
| with gr.Column(scale=2): | |
| output_html = gr.HTML( | |
| "<div style='padding:20px; color:#666;'>" | |
| "์ข์ธก์์ ๊ฑฐ๋ ์กฐ๊ฑด์ ์ค์ ํ๊ณ [๋ถ์ ์คํ] ๋ฒํผ์ ๋๋ฅด์ธ์." | |
| "</div>" | |
| ) | |
| submit_btn.click( | |
| fn=analyze_transaction, | |
| inputs=[amount_in, hour_in, payee_in, ratio_in, use_api_in], | |
| outputs=output_html | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(ssr_mode=False) |