ableman82's picture
Update app.py
62e30d6 verified
Raw
History Blame Contribute Delete
105 kB
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)
@staticmethod
def _relu(x):
return np.maximum(0, x)
@staticmethod
def _relu_grad(x):
return (x > 0).astype(np.float32)
@staticmethod
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} &lt; {split:.2f}] Yes")
current_id = row['Yes']
else:
path.append(f"[{feature} &lt; {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), &nbsp; f<sub>k</sub> โˆˆ ํŠธ๋ฆฌ ๊ณต๊ฐ„<br>P(์‚ฌ๊ธฐ) = sigmoid(F(x))&nbsp;&nbsp;[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>) &nbsp; [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)