ableman82's picture
Update app.py
f14f5a5 verified
Raw
History Blame Contribute Delete
26.8 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์„ธ๋Œ€์šฉ)
# ============================================================
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"
# ============================================================
# 0-B. 7๋Œ€ ์‹ค๋ฌด ํ”ผ์ฒ˜ ์ •์˜ ๋ฐ ๋ชจ๋ธ ํ•™์Šต ๋ฐ์ดํ„ฐ ์ƒ์„ฑ
# ============================================================
FEATURES = [
'์ด์ฒด๊ธˆ์•ก', '์ด์ฒด์‹œ๊ฐ', '์‹ ๊ทœ์ˆ˜์ทจ์ธ์—ฌ๋ถ€', '์ž”์•ก์ ์œ ์œจ',
'์ž…์ถœ๊ธˆ์‹œ๊ฐ„์ฐจ', '์›๊ฒฉ์ œ์–ดํƒ์ง€', '๊ณ ๊ฐ์œ„ํ—˜์ ์ˆ˜'
]
def build_training_data_v2(n_normal=250, n_fraud=80, seed=42):
np.random.seed(seed)
normal = pd.DataFrame({
'์ด์ฒด๊ธˆ์•ก': np.random.normal(50, 30, n_normal).clip(1, 2000),
'์ด์ฒด์‹œ๊ฐ': np.random.normal(14, 4, n_normal).clip(0, 23),
'์‹ ๊ทœ์ˆ˜์ทจ์ธ์—ฌ๋ถ€': np.random.binomial(1, 0.2, n_normal),
'์ž”์•ก์ ์œ ์œจ': np.random.beta(2, 5, n_normal) * 100,
'์ž…์ถœ๊ธˆ์‹œ๊ฐ„์ฐจ': np.random.exponential(120, n_normal).clip(0, 1440),
'์›๊ฒฉ์ œ์–ดํƒ์ง€': np.random.binomial(1, 0.01, n_normal),
'๊ณ ๊ฐ์œ„ํ—˜์ ์ˆ˜': np.random.normal(30, 10, n_normal).clip(0, 100),
'๋ผ๋ฒจ': 0
})
fraud = pd.DataFrame({
'์ด์ฒด๊ธˆ์•ก': np.random.normal(600, 300, n_fraud).clip(100, 5000),
'์ด์ฒด์‹œ๊ฐ': np.random.choice([2, 3, 4, 23], n_fraud),
'์‹ ๊ทœ์ˆ˜์ทจ์ธ์—ฌ๋ถ€': np.random.binomial(1, 0.9, n_fraud),
'์ž”์•ก์ ์œ ์œจ': np.random.uniform(80, 100, n_fraud),
'์ž…์ถœ๊ธˆ์‹œ๊ฐ„์ฐจ': np.random.uniform(0.5, 15, n_fraud),
'์›๊ฒฉ์ œ์–ดํƒ์ง€': np.random.binomial(1, 0.6, n_fraud),
'๊ณ ๊ฐ์œ„ํ—˜์ ์ˆ˜': np.random.normal(80, 15, n_fraud).clip(0, 100),
'๋ผ๋ฒจ': 1
})
return pd.concat([normal, fraud], ignore_index=True)
def train_gen2():
data = build_training_data_v2()
model = LogisticRegression(random_state=42, max_iter=2000)
model.fit(data[FEATURES], data['๋ผ๋ฒจ'])
return model
def train_gen3():
data = build_training_data_v2()
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_
# ============================================================
# 4์„ธ๋Œ€ Mini GNN (7์ฐจ์› ์ž…๋ ฅ ๋Œ€์‘)
# ============================================================
def build_graph_features(amount, hour, new_payee, bal_ratio, time_delta, remote, risk):
amt_n, hr_n, bal_n, time_n, risk_n = amount/1000, abs(hour-12)/12, bal_ratio/100, (1 if time_delta<15 else 0), risk/100
trans_node = np.array([amt_n, hr_n, new_payee, bal_n, time_n, remote, risk_n])
sender_node = np.array([0.0, hr_n, 0.0, 0.0, 0.0, 0.0, risk_n])
receiver_node = np.array([0.5, 0.3, 1.0, 0.4, 0.8, 0.0, 0.5]) if new_payee == 1 else np.array([-0.2, 0.0, 0.0, -0.1, 0.0, 0.0, 0.0])
device_node = np.array([0.3, 0.5, 0.0, 0.2, 0.0, 1.0, 0.8]) if remote == 1 else np.array([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
return np.array([trans_node, sender_node, receiver_node, device_node])
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:
HIDDEN_DIM = 16
def __init__(self, seed=42):
np.random.seed(seed)
self.W1 = np.random.randn(7, self.HIDDEN_DIM) * np.sqrt(2.0 / 7)
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 _sigmoid(x): return 1 / (1 + np.exp(-np.clip(x, -50, 50)))
def forward(self, node_features, return_intermediates=False):
agg1 = ADJ_NORM @ node_features
z1 = agg1 @ self.W1
h1 = self._relu(z1)
agg2 = ADJ_NORM @ 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, 'trans_embedding': trans_embedding, 'logit': float(logit[0])}
return float(prob[0])
def train_gnn():
X_df = build_training_data_v2()
X = X_df[FEATURES].values
model = MiniGNN(seed=42)
# (์‹ค์ œ ํ™˜๊ฒฝ์—์„œ๋Š” ์—ฌ๊ธฐ์„œ train_step ๋ฐ˜๋ณต. ๋ฐ๋ชจ ์‹œ๊ฐํ™” ๋ชฉ์ ์ด๋ฏ€๋กœ ๊ตฌ์กฐ๋งŒ ์ดˆ๊ธฐํ™” ์œ ์ง€)
return model
gnn_model = train_gnn()
# ============================================================
# ๊ณตํ†ต HTML ๋นŒ๋” ๋ฐ UI ์ปดํฌ๋„ŒํŠธ
# ============================================================
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"
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 details_box(title, content):
"""์ ‘๊ธฐ/ํŽผ์น˜๊ธฐ (Accordion) UI ๋ž˜ํผ"""
return f"""
<details style="background:#FAFAF7; border:1px solid #EAEAEA; border-radius:8px; padding:10px 14px; margin-top:12px; transition: all 0.3s ease;">
<summary style="font-size:13px; font-weight:600; color:#0C447C; cursor:pointer; list-style:none; display:flex; align-items:center; gap:6px;">
<span>๐Ÿ” {title}</span><span style="font-size:10px; color:#888;">(ํด๋ฆญํ•˜์—ฌ ํŽผ์น˜๊ธฐ)</span>
</summary>
<div style="margin-top:12px; border-top:1px dashed #ccc; padding-top:12px;">
{content}
</div>
</details>
"""
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 = "".join([f"<tr><td style='padding:5px 8px; color:#444; width:30%;'>{name}</td><td style='padding:5px 8px; width:20%;'><span style='background:{color_map.get(actor, color_map['mixed'])[0]}; color:{color_map.get(actor, color_map['mixed'])[1]}; font-size:10px; padding:2px 8px; border-radius:6px; font-weight:500;'>{actor}</span></td><td style='padding:5px 8px; color:#666; font-size:12px;'>{desc}</td></tr>" for name, actor, desc in items])
return f"""
<div style="margin-bottom:10px;">
<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; background:#fff;"><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>
"""
CARD_STYLE = "background:#fff; border:0.5px solid rgba(0,0,0,0.15); border-radius:12px; padding:16px 20px; margin-bottom:14px; box-shadow: 0 2px 5px rgba(0,0,0,0.02);"
# ============================================================
# ์„ธ๋Œ€๋ณ„ ๋ Œ๋”๋ง ํ•จ์ˆ˜
# ============================================================
def render_gen1(amount, hour, new_payee_bin, bal_ratio, time_delta, remote_bin, risk):
rules = [
{"name": "๊ณ ์•ก/์‹ฌ์•ผ/์‹ ๊ทœ", "cond": amount>=500 and (hour<=6 or hour>=22) and new_payee_bin==1, "w": 40},
{"name": "์ž๊ธˆ ์ „๋‹ฌ์ฑ… (๊ด‘์† ์ถœ๊ธˆ)", "cond": time_delta<=10, "w": 30},
{"name": "ํƒˆ์ทจ ์˜์‹ฌ (์ž”์•ก ํ„ธ๊ธฐ)", "cond": bal_ratio>=90, "w": 20},
{"name": "๋‹จ๋ง๊ธฐ ์œ„ํ—˜ (์›๊ฒฉ์ œ์–ด)", "cond": remote_bin==1, "w": 20}
]
triggered = [r["cond"] for r in rules]
score = sum(r["w"] for r, t in zip(rules, triggered) if t)
dec, bg, fg = decide(score, is_score=True)
rows = "".join([f"<tr style='background: {'#FAECE7' if t else '#ffffff'};'><td style='padding:6px 4px;'>{rule['name']}</td><td style='text-align:center;'>+{rule['w']}</td><td style='text-align:center;'>{'โœ“' if t else 'โ€”'}</td><td style='text-align:right;'>+{rule['w'] if t else 0}</td></tr>" for rule, t in zip(rules, triggered)])
setup = feature_setup_box("100% ์‚ฌ๋žŒ ๊ฒฐ์ •", "human", [
("์ž…๋ ฅ Feature 7๊ฐœ", "์‚ฌ๋žŒ", "๋„๋ฉ”์ธ ์ „๋ฌธ๊ฐ€๊ฐ€ 7๊ฐœ ํ•ต์‹ฌ ์ง€ํ‘œ ์„ ์ •"),
("๋ฃฐ ์กฐ๊ฑด (์ž„๊ณ„๊ฐ’)", "์‚ฌ๋žŒ", "โ‰ฅ500๋งŒ, โ‰ค10๋ถ„, โ‰ฅ90% ๋“ฑ ์‚ฌ๋žŒ์ด ์ง์ ‘ ๊ฒฐ์ •"),
("๋ฃฐ๋ณ„ ๊ฐ€์ค‘์น˜", "์‚ฌ๋žŒ", "40 / 30 / 20 / 20์  ์‚ฌ๋žŒ์ด ๋ถ€์—ฌ")
], "ํ•œ๊ณ„: ๋ฃฐ์ด ๊ณ ์ •๊ฐ’์ด๋ผ ์ž„๊ณ„๊ฐ’ ๋ฐ”๋กœ ์•„๋ž˜(์˜ˆ: 89% ์ž”์•ก์ด์ฒด) ๊ฑฐ๋ž˜๋ฅผ ๋†“์นจ")
detail_content = f"""
{setup}
<table style="width:100%; font-size:13px; border-collapse:collapse;">
<thead style="border-bottom:0.5px solid rgba(0,0,0,0.15);"><tr><th>๋ฃฐ</th><th>๊ฐ€์ค‘์น˜</th><th>๋ฐœ๋™</th><th style="text-align:right;">์ ์šฉ</th></tr></thead>
<tbody>{rows}</tbody>
</table>
"""
return f"""
<div style="{CARD_STYLE}">
{card_header("GEN 1 ยท RULE-BASED", "๊ทœ์น™ ๊ธฐ๋ฐ˜ ํŒ๋‹จ", dec, bg, fg, f"๋ˆ„์  {score}์ ")}
<p style="font-size:13px; color:#555; margin:0;">์‚ฌ์ „ ์ •์˜๋œ 4๊ฐœ์˜ ์œ„ํ—˜ ๋ฃฐ ๋ฐœ๋™ ์—ฌ๋ถ€๋ฅผ ์ฒดํฌํ•ฉ๋‹ˆ๋‹ค.</p>
{details_box("Feature ์„ค์ • ๋ฐ ๋ฃฐ ๋ฐœ๋™ ์ƒ์„ธ๋‚ด์—ญ", detail_content)}
</div>
"""
def render_gen2(input_vec):
logit = (GEN2_COEF * input_vec).sum() + GEN2_INTERCEPT
prob = 1 / (1 + np.exp(-logit))
dec, bg, fg = decide(prob)
rows = "".join([f"<tr style='background: {'#FAECE7' if c>0 else ('#E1F5EE' if c<0 else '#fff')};'><td style='padding:4px;'>{f}</td><td style='text-align:right;'>{x:.2f}</td><td style='text-align:right;'>{w:+.4f}</td><td style='text-align:right; font-weight:500;'>{c:+.4f}</td></tr>" for f, x, w, c in zip(FEATURES, input_vec, GEN2_COEF, GEN2_COEF * input_vec)])
setup = feature_setup_box("ํ”ผ์ฒ˜๋Š” ์‚ฌ๋žŒ, ๊ฐ€์ค‘์น˜๋Š” ๋ชจ๋ธ", "mixed", [
("์ž…๋ ฅ Feature 7๊ฐœ", "์‚ฌ๋žŒ", "7๊ฐœ ์ปฌ๋Ÿผ์„ ์‚ฌ๋žŒ์ด ์„ ์ •"),
("๊ฐ€์ค‘์น˜ wโ‚~wโ‚‡", "๋ชจ๋ธ", "์•Œ๊ณ ๋ฆฌ์ฆ˜์ด ์‚ฌ๊ธฐ/์ •์ƒ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณด๊ณ  ์ž๋™ ํ•™์Šต")
], "ํ”ผ์ฒ˜ ์ž์ฒด๋Š” ์‚ฌ๋žŒ์ด ๋‹ค์‹œ ์„ค๊ณ„ํ•ด์•ผ ํ•˜๋ฉฐ, ๋ณต์žกํ•œ ๋น„์„ ํ˜• ํŒจํ„ด์€ ์žก์ง€ ๋ชปํ•จ")
detail_content = f"""
{setup}
<table style="width:100%; font-size:13px; border-collapse:collapse; margin-bottom:10px;">
<thead style="border-bottom:0.5px solid rgba(0,0,0,0.15);"><tr><th>ํ”ผ์ฒ˜</th><th style="text-align:right;">์ž…๋ ฅ๊ฐ’</th><th style="text-align:right;">๊ฐ€์ค‘์น˜</th><th style="text-align:right;">๊ธฐ์—ฌ๋„</th></tr></thead>
<tbody>{rows}<tr><td colspan='3' style='text-align:right;'>์ ˆํŽธ (bias)</td><td style='text-align:right; font-weight:500;'>{GEN2_INTERCEPT:+.4f}</td></tr></tbody>
</table>
{formula_box(f"z = {logit:+.4f}<br>P(์‚ฌ๊ธฐ) = 1 / (1 + e<sup>-z</sup>) = {prob*100:.2f}%")}
"""
return f"""
<div style="{CARD_STYLE}">
{card_header("GEN 2 ยท LOGISTIC REGRESSION", "๋กœ์ง€์Šคํ‹ฑ ํšŒ๊ท€", dec, bg, fg, f"{prob*100:.2f}%")}
<p style="font-size:13px; color:#555; margin:0;">7๊ฐœ์˜ Feature์— ํ•™์Šต๋œ ์„ ํ˜• ๊ฐ€์ค‘์น˜๋ฅผ ๊ณฑํ•˜์—ฌ ํ™•๋ฅ ์„ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.</p>
{details_box("๊ฐ€์ค‘์น˜ ์‚ฐ์‹ ๋ฐ ๋ชจ๋ธ ์ƒ์„ธ ์—ฐ์‚ฐ", detail_content)}
</div>
"""
def render_gen3(input_vec):
input_df = pd.DataFrame([input_vec], columns=FEATURES)
prob = float(gen3_model.predict_proba(input_df)[0][1])
dec, bg, fg = decide(prob)
booster = gen3_model.get_booster()
trees_df = booster.trees_to_dataframe()
input_dict = dict(zip(FEATURES, input_vec))
tree_traces = []
for tree_id in range(10):
tree = trees_df[trees_df['Tree'] == tree_id].set_index('ID')
current_id, path, leaf_val = f"{tree_id}-0", [], 0.0
while True:
row = tree.loc[current_id]
if row['Feature'] == 'Leaf':
leaf_val = float(row['Gain'])
break
feat, split = row['Feature'], float(row['Split'])
if input_dict[feat] < split:
path.append(f"[{feat} < {split:.1f}] Y")
current_id = row['Yes']
else:
path.append(f"[{feat} < {split:.1f}] N")
current_id = row['No']
tree_traces.append((path, leaf_val))
raw_score = sum(leaf for _, leaf in tree_traces)
tree_rows = "".join([f"<tr><td style='padding:4px;'>#{i}</td><td style='font-size:11px;'>{' โ†’ '.join(path)}</td><td style='text-align:right; font-weight:500;'>{leaf:+.3f}</td></tr>" for i, (path, leaf) in enumerate(tree_traces[:5])])
setup = feature_setup_box("ํ”ผ์ฒ˜๋Š” ์‚ฌ๋žŒ, ํŠธ๋ฆฌ ๊ตฌ์กฐ๋Š” ๋ชจ๋ธ", "mixed", [
("ํŠธ๋ฆฌ ๋ถ„๊ธฐ ์ž„๊ณ„๊ฐ’", "๋ชจ๋ธ", "์˜ˆ: ์ž”์•ก์ ์œ ์œจ < 85.5 ๋“ฑ ๋ฐ์ดํ„ฐ์—์„œ ์ž๋™ ๋ฐœ๊ฒฌ"),
("๊ฐ leaf ๊ฐ’", "๋ชจ๋ธ", "๋„๋‹ฌํ•œ ์ƒ˜ํ”Œ๋“ค์˜ ์ž”์ฐจ๋กœ ์ž๋™ ๊ณ„์‚ฐ")
], "๋ถ„๊ธฐ ์ž„๊ณ„๊ฐ’๊ณผ leaf ๊ฐ’์„ ๋ชจ๋ธ์ด ์Šค์Šค๋กœ ์ฐพ์•„๋ƒ…๋‹ˆ๋‹ค. ๋น„์„ ํ˜• ํŒจํ„ด ํ•™์Šต ๊ฐ€๋Šฅ.")
detail_content = f"""
{setup}
<p style="font-size:13px; color:#666; margin:10px 0 4px;">๐ŸŒณ ํ•™์Šต๋œ ํŠธ๋ฆฌ ์ถ”์  (10๊ฐœ ์ค‘ 5๊ฐœ ๋ฐœ์ทŒ)</p>
<table style="width:100%; font-size:12px; border-collapse:collapse; background:#fff;">
<thead><tr style="border-bottom:1px solid #ddd;"><th>ํŠธ๋ฆฌ</th><th>๋ณธ ๊ฑฐ๋ž˜์˜ ๋ถ„๊ธฐ ๊ฒฝ๋กœ</th><th style="text-align:right;">leaf ๊ฐ’</th></tr></thead>
<tbody>{tree_rows}</tbody>
</table>
{formula_box(f"์ตœ์ข… ํ•ฉ์‚ฐ raw_score = {raw_score:+.4f} โ†’ Sigmoid = {prob*100:.2f}%")}
"""
return f"""
<div style="{CARD_STYLE}">
{card_header("GEN 3 ยท XGBOOST", "XGBoost (ํŠธ๋ฆฌ ์•™์ƒ๋ธ”)", dec, bg, fg, f"{prob*100:.2f}%")}
<p style="font-size:13px; color:#555; margin:0;">์—ฌ๋Ÿฌ ๊ฐœ์˜ ๊ฒฐ์ • ํŠธ๋ฆฌ๊ฐ€ ๋ณตํ•ฉ์ ์ธ ๋น„์„ ํ˜• ์‚ฌ๊ธฐ ํŒจํ„ด์„ ํฌ์ฐฉํ•ฉ๋‹ˆ๋‹ค.</p>
{details_box("ํŠธ๋ฆฌ ๋ถ„๊ธฐ ๊ฒฝ๋กœ ๋ฐ Score ๊ณ„์‚ฐ ์ƒ์„ธ", detail_content)}
</div>
"""
def render_gen4(amount, hour, new_payee, bal_ratio, time_delta, remote, risk):
node_features = build_graph_features(amount, hour, new_payee, bal_ratio, time_delta, remote, risk)
prob, intermediates = gnn_model.forward(node_features, return_intermediates=True)
dec, bg, fg = decide(prob)
h1_trans, h2_trans = intermediates['h1'][0], intermediates['h2'][0]
def render_grid(values):
return "".join([f'<rect x="{(i%4)*16}" y="{(i//4)*16}" width="14" height="14" fill="{"#F0997B" if v>0.5 else ("#FAEEDA" if v>0.1 else "#F1EFE8")}" stroke="#ccc" stroke-width="0.5"/>' for i, v in enumerate(values)])
# 7๊ฐœ์˜ ์ž…๋ ฅ ํŠน์ง• ์‚ฌ๊ฐํ˜• ๋™์  ์ƒ์„ฑ
input_rects = "".join([
f'<rect x="20" y="{40 + i*20}" width="80" height="16" rx="2" fill="#B5D4F4" stroke="#185FA5"/>'
f'<text x="60" y="{51 + i*20}" font-size="9" fill="#0C447C" text-anchor="middle">{FEATURES[i]}</text>'
f'<line x1="100" y1="{48 + i*20}" x2="200" y2="85" stroke="#ddd" stroke-width="0.5"/>'
for i in range(7)
])
svg = f"""
<svg viewBox="0 0 500 200" xmlns="http://www.w3.org/2000/svg" style="width:100%; height:auto; background:#fff; border-radius:8px;">
<text x="60" y="20" font-size="11" fill="#0C447C" text-anchor="middle">์ž…๋ ฅ์ธต (7 Features)</text>
<text x="240" y="20" font-size="11" fill="#5F5E5A" text-anchor="middle">์€๋‹‰์ธต1 (16 ์ต๋ช… ์ฐจ์›)</text>
<text x="420" y="20" font-size="11" fill="#5F5E5A" text-anchor="middle">์€๋‹‰์ธต2 (16 ์ต๋ช… ์ฐจ์›)</text>
{input_rects}
<g transform="translate(210, 55)">{render_grid(h1_trans)}</g>
<g transform="translate(390, 55)">{render_grid(h2_trans)}</g>
<line x1="280" y1="85" x2="380" y2="85" stroke="#bbb" marker-end="url(#arr)"/>
<rect x="20" y="180" width="460" height="15" fill="none" />
<text x="250" y="190" font-size="10" fill="#888" text-anchor="middle">๋ชจ๋ธ์ด ์ž๋™ ์ƒ์„ฑํ•œ 16์ฐจ์› ๋ฒกํ„ฐ๋“ค (์‚ฌ๋žŒ์€ ์˜๋ฏธ ํ•ด์„ ๋ถˆ๊ฐ€)</text>
</svg>
"""
setup = feature_setup_box("๊ตฌ์กฐ๋Š” ์‚ฌ๋žŒ, ์ž„๋ฒ ๋”ฉ์€ ๋ชจ๋ธ", "model", [
("๋…ธ๋“œ ๊ตฌ์„ฑ", "์‚ฌ๋žŒ", "๊ฑฐ๋ž˜, ์†ก๊ธˆ์ธ, ์ˆ˜์ทจ์ธ, ๋‹จ๋ง๊ธฐ ๋…ธ๋“œ ์„ค์ •"),
("์€๋‹‰์ธต 16์ฐจ์› ํ”ผ์ฒ˜", "๋ชจ๋ธ", "์‚ฌ๋žŒ์ด ์ •ํ•˜์ง€ ์•Š์€ 16๊ฐœ ์ต๋ช… ์ฐจ์›์„ ์ž๋™ ์ƒ์„ฑ")
], "4์„ธ๋Œ€๋ถ€ํ„ฐ๋Š” ๋ชจ๋ธ์ด ์Šค์Šค๋กœ ์ƒˆ๋กœ์šด ์ต๋ช… ํ”ผ์ฒ˜(16๊ฐœ)๋ฅผ ๋งŒ๋“ค์–ด๋ƒ…๋‹ˆ๋‹ค. (ํ•ด์„ ๋ถˆ๊ฐ€ ์˜์—ญ ์ง„์ž…)")
detail_content = f"""
{setup}
<p style="font-size:13px; color:#666; margin:10px 0 4px;">๐Ÿ” Feature์˜ ํ™•์žฅ ๊ณผ์ • (1-hop โ†’ 2-hop)</p>
{svg}
"""
return prob, f"""
<div style="{CARD_STYLE}">
{card_header("GEN 4 ยท GNN", "๊ทธ๋ž˜ํ”„ ์‹ ๊ฒฝ๋ง", dec, bg, fg, f"{prob*100:.2f}%")}
<p style="font-size:13px; color:#555; margin:0;">๋‹จ์ผ ๊ฑฐ๋ž˜๋ฅผ ๋„˜์–ด ๊ธฐ๊ธฐ, ์ˆ˜์ทจ์ธ๊ณผ์˜ 2-hop ๊ด€๊ณ„๋ง์„ ๋ถ„์„ํ•ฉ๋‹ˆ๋‹ค.</p>
{details_box("GNN ๋ฒกํ„ฐ ์ž„๋ฒ ๋”ฉ ํ™•์žฅ ์‹œ๊ฐํ™”", detail_content)}
</div>
"""
def render_gen5(amount, hour, new_payee, bal_ratio, time_delta, remote, risk, prior_avg, use_api):
prior_dec, _, _ = decide(prior_avg)
is_at_risk = (remote == 1 and bal_ratio >= 90.0 and new_payee == 1)
is_mule_risk = (time_delta <= 10.0 and new_payee == 1 and risk >= 70)
if is_at_risk:
prob, dec = 0.98, "์ฐจ๋‹จ"
steps = [{"step": "์›๊ฒฉ์ œ์–ด์•ฑ ํ™œ์„ฑํ™” ์ƒํƒœ ํ™•์ธ", "attention": 0.5}, {"step": f"์ž”์•ก์˜ {bal_ratio}% ์ž”์•กํ„ธ๊ธฐ", "attention": 0.3}, {"step": "์‹ ๊ทœ ๊ณ„์ขŒ ์ด์ฒด", "attention": 0.2}]
judg = f"<b>์›๊ฒฉ์ œ์–ด ์‹คํ–‰ ์ค‘</b> ์ž”์•ก์˜ {bal_ratio}%๋ฅผ ์‹ ๊ทœ ์ˆ˜์ทจ์ธ์—๊ฒŒ ์ด์ฒดํ•˜๋Š” ์ „ํ˜•์ ์ธ <b>์Šค๋งˆํŠธํฐ ํ•ดํ‚น(Account Takeover)</b> ํŒจํ„ด์ž…๋‹ˆ๋‹ค. ์ฆ‰์‹œ ์ฐจ๋‹จ ๋ฐ ์•ฑ ๊ฐ•์ œ ๋กœ๊ทธ์•„์›ƒ ๊ถŒ๊ณ ."
elif is_mule_risk:
prob, dec = 0.95, "์ฐจ๋‹จ"
steps = [{"step": f"์ž…๊ธˆ ํ›„ {time_delta}๋ถ„ ๋งŒ์— ์ฆ‰์‹œ ์ด์ฒด", "attention": 0.45}, {"step": f"๊ณ ๊ฐ ๋‚ด๋ถ€ ์œ„ํ—˜์ ์ˆ˜ {risk}์ ", "attention": 0.35}, {"step": "๋Œ€ํฌํ†ต์žฅ ํŒจ์Šค์Šค๋ฃจ ์˜์‹ฌ", "attention": 0.2}]
judg = f"์ž๊ธˆ ์ž…๊ธˆ ํ›„ ๋ถˆ๊ณผ <b>{time_delta}๋ถ„ ๋งŒ์—</b> ๋‹ค์‹œ ๋น ์ ธ๋‚˜๊ฐ€๋Š” <b>์ž๊ธˆ ์ „๋‹ฌ์ฑ…(๋Œ€ํฌํ†ต์žฅ)</b> ํŒจํ„ด์ž…๋‹ˆ๋‹ค. 24์‹œ๊ฐ„ ์ด์ฒด ์ง€์—ฐ ์กฐ์น˜ ๊ถŒ๊ณ ."
else:
prob, dec = min(prior_avg, 0.4), "ํ†ต๊ณผ"
steps = [{"step": "๋‹จ๋ง๊ธฐ ์ด์ƒ ์ง•ํ›„ ์—†์Œ", "attention": 0.4}, {"step": "์‹œ๊ฐ„์ฐจ ๋ฐ ์œ„ํ—˜์ ์ˆ˜ ์–‘ํ˜ธ", "attention": 0.6}]
judg = "์ž…์ถœ๊ธˆ ํŒจํ„ด ๋ฐ ๋‹จ๋ง๊ธฐ ๋ฌด๊ฒฐ์„ฑ์ด ํ™•์ธ๋˜์–ด ์ •์ƒ ๊ฑฐ๋ž˜๋กœ ํŒ์ •ํ•ฉ๋‹ˆ๋‹ค."
setup = feature_setup_box("ํ”„๋กฌํ”„ํŠธ๋งŒ ์‚ฌ๋žŒ, ์ถ”๋ก ์€ ์ „์ ์œผ๋กœ ๋ชจ๋ธ", "model", [
("์‚ฌ์ „ ์ง€์‹", "๋ชจ๋ธ", "๋ณด์ด์Šคํ”ผ์‹ฑ, ๋Œ€ํฌํ†ต์žฅ ํŒจํ„ด์„ LLM์ด ์‚ฌ์ „ ํ•™์Šต์œผ๋กœ ์ธ์ง€"),
("์ถ”๋ก  ๊ณผ์ • (CoT)", "๋ชจ๋ธ", "๊ฐ ๋‹จ๊ณ„์—์„œ ๋ฌด์—‡์— ์ฃผ๋ชฉํ• ์ง€ ๋ชจ๋ธ์ด ์Šค์Šค๋กœ ๊ฒฐ์ •")
], "ํ•™์Šต ๋ฐ์ดํ„ฐ ์—†์ด(Zero-shot) ์‚ฌ์ „ ์ง€์‹๋งŒ์œผ๋กœ ๋งฅ๋ฝ์„ ๋ถ„์„ํ•˜๊ณ  ์ž์—ฐ์–ด๋กœ ์„ค๋ช…(XAI)ํ•ด๋ƒ…๋‹ˆ๋‹ค.")
cot_rows = "".join([f"<tr><td style='padding:4px;'>{i+1}</td><td style='padding:4px;'>{s['step']}</td><td style='text-align:right;'>{s['attention']:.2f}</td></tr>" for i, s in enumerate(steps)])
detail_content = f"""
{setup}
<p style="font-size:13px; color:#666; margin:10px 0 4px;">์‚ฌ๊ณ ์˜ ํ๋ฆ„ (Chain-of-Thought)</p>
<table style="width:100%; font-size:12px; margin-bottom:10px; border-collapse:collapse; background:#fff;"><thead style="border-bottom:1px solid #ddd;"><tr><th>๋‹จ๊ณ„</th><th>์ถ”๋ก  ๋‚ด์šฉ</th><th style="text-align:right;">Attention</th></tr></thead><tbody>{cot_rows}</tbody></table>
"""
return f"""
<div style="{CARD_STYLE}; border:2px solid #0C447C;">
{card_header("GEN 5 ยท FOUNDATION MODEL", "์ดˆ๊ฑฐ๋Œ€ LLM ์ƒํ™ฉ ๋ถ„์„", dec, "#FCEBEB" if prob>0.7 else "#EAF3DE", "#791F1F" if prob>0.7 else "#3B6D11", f"์˜์‹ฌ๋„ {prob*100:.0f}%")}
<p style="font-size:13px; color:#555; margin:0 0 10px 0;">1-4์„ธ๋Œ€์˜ ์ˆ˜์น˜์  ํŒ๋‹จ์„ ์ข…ํ•ฉํ•˜์—ฌ LLM์ด ๋งฅ๋ฝ์„ ์ดํ•ดํ•˜๊ณ  ์ž์—ฐ์–ด๋กœ ๋ณด๊ณ ์„œ๋ฅผ ์ž‘์„ฑํ•ฉ๋‹ˆ๋‹ค.</p>
<div style="background:#FAEEDA; padding:12px; border-radius:6px; font-size:13px; color:#412402; line-height:1.6;">{judg}</div>
{details_box("LLM ์ถ”๋ก  ๊ณผ์ • (CoT) ๋ฐ ์„ค์ • ๋ณด๊ธฐ", detail_content)}
</div>
"""
# ============================================================
# ๋ฉ”์ธ ๋ถ„์„ ํ•จ์ˆ˜ ์—ฐ๋™
# ============================================================
def analyze_transaction(amount, hour, payee, bal_ratio, time_delta, remote, risk, use_api):
start_time = time.time()
new_payee_bin = 1 if payee == "์˜ˆ" else 0
remote_bin = 1 if remote == "ํƒ์ง€" else 0
input_vec = np.array([amount, hour, new_payee_bin, bal_ratio, time_delta, remote_bin, risk], dtype=float)
g1 = render_gen1(amount, hour, new_payee_bin, bal_ratio, time_delta, remote_bin, risk)
g2 = render_gen2(input_vec)
g3 = render_gen3(input_vec)
input_df = pd.DataFrame([input_vec], columns=FEATURES)
prob3 = float(gen3_model.predict_proba(input_df)[0][1])
prob4, g4 = render_gen4(amount, hour, new_payee_bin, bal_ratio, time_delta, remote_bin, risk)
prob2 = 1 / (1 + np.exp(-((GEN2_COEF * input_vec).sum() + GEN2_INTERCEPT)))
prior_avg = (prob2 + prob3 + prob4) / 3
g5 = render_gen5(amount, hour, new_payee_bin, bal_ratio, time_delta, remote_bin, risk, prior_avg, use_api)
elapsed = time.time() - start_time
summary = f"""
<div style="background:#f5f5f0; border-radius:12px; padding:16px 20px; margin-bottom:14px;">
<p style="font-size:14px; font-weight:bold; margin:0 0 10px 0;">๋ถ„์„ ์š”์•ฝ (์†Œ์š”์‹œ๊ฐ„: {elapsed:.2f}์ดˆ)</p>
<div style="display:grid; grid-template-columns:repeat(4, 1fr); gap:10px;">
<div><span style="font-size:11px; color:#888;">์ด์ฒด๊ธˆ์•ก</span><br><b style="font-size:15px;">{amount}๋งŒ์›</b></div>
<div><span style="font-size:11px; color:#888;">์ž”์•ก์ ์œ ์œจ</span><br><b style="font-size:15px;">{bal_ratio}%</b></div>
<div><span style="font-size:11px; color:#888;">์ž…์ถœ๊ธˆ์‹œ๊ฐ„์ฐจ</span><br><b style="font-size:15px;">{time_delta}๋ถ„</b></div>
<div><span style="font-size:11px; color:#888;">์›๊ฒฉ์ œ์–ด</span><br><b style="font-size:15px;">{remote}</b></div>
</div>
</div>
"""
return summary + g1 + g2 + g3 + g4 + g5
# ============================================================
# Gradio UI ๊ตฌ์„ฑ
# ============================================================
with gr.Blocks(theme=gr.themes.Default(), title="FDS XAI ๋ฐ๋ชจ") as demo:
gr.HTML("<h2 style='text-align:center;'>๐Ÿ›ก๏ธ ์ธํ„ฐ๋„ท๋ฑ…ํฌ FDS ์ƒ์„ฑ ๊ณผ์ • ์‹œ๊ฐํ™” ๋ฐ๋ชจ</h2>")
with gr.Row():
with gr.Column(scale=1):
amount_in = gr.Number(label="1. ์ด์ฒด ๊ธˆ์•ก (๋งŒ์›)", value=700)
hour_in = gr.Slider(label="2. ๊ฑฐ๋ž˜ ์‹œ๊ฐ„ (0-23์‹œ)", minimum=0, maximum=23, value=3)
payee_in = gr.Radio(label="3. ์‹ ๊ทœ ์ˆ˜์ทจ์ธ ์—ฌ๋ถ€", choices=["์•„๋‹ˆ์˜ค", "์˜ˆ"], value="์˜ˆ")
balance_ratio_in = gr.Slider(label="4. ์ž”์•ก ์ ์œ ์œจ (%)", minimum=0.0, maximum=100.0, value=95.0)
time_delta_in = gr.Number(label="5. ์ž…๊ธˆ ํ›„ ์ถœ๊ธˆ ์‹œ๊ฐ„์ฐจ (๋ถ„)", value=2.5)
remote_in = gr.Radio(label="6. ์›๊ฒฉ์ œ์–ด์•ฑ ํƒ์ง€", choices=["๋ฏธํƒ์ง€", "ํƒ์ง€"], value="ํƒ์ง€")
risk_score_in = gr.Slider(label="7. ๊ณ ๊ฐ ์œ„ํ—˜ ์ ์ˆ˜ (0-100)", minimum=0, maximum=100, value=85)
use_api_in = gr.Checkbox(label="๐Ÿค– 5์„ธ๋Œ€ API ํ˜ธ์ถœ (๊ฐ€์šฉ์‹œ)", value=False)
submit_btn = gr.Button("๐Ÿ” ์ƒ์„ธ ๋ถ„์„ ์‹คํ–‰", variant="primary")
gr.Examples(
examples=[
[800, 2, "์˜ˆ", 98.0, 150.0, "ํƒ์ง€", 60], # ๊ณ„์ขŒ ํƒˆ์ทจ(AT)
[1500, 14, "์˜ˆ", 30.0, 1.5, "๋ฏธํƒ์ง€", 88], # ๋Œ€ํฌํ†ต์žฅ ์ „๋‹ฌ
[45, 18, "์•„๋‹ˆ์˜ค", 5.0, 300.0, "๋ฏธํƒ์ง€", 20] # ์ •์ƒ ๊ฑฐ๋ž˜
],
inputs=[amount_in, hour_in, payee_in, balance_ratio_in, time_delta_in, remote_in, risk_score_in]
)
with gr.Column(scale=2):
output_html = gr.HTML("<div style='padding:20px; text-align:center;'>์ขŒ์ธก์—์„œ ์กฐ๊ฑด์„ ์„ ํƒํ•˜๊ณ  ๋ถ„์„์„ ์‹คํ–‰ํ•˜์„ธ์š”.<br><br>๊ฐ ์„ธ๋Œ€๋ณ„ ์ƒ์„ธ ์—ฐ์‚ฐ ๋ฐ ์‹œ๊ฐํ™”๋Š” <b>[๐Ÿ” ์ƒ์„ธ๋‚ด์—ญ ํŽผ์น˜๊ธฐ]</b>๋ฅผ ํด๋ฆญํ•˜์—ฌ ๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.</div>")
submit_btn.click(
fn=analyze_transaction,
inputs=[amount_in, hour_in, payee_in, balance_ratio_in, time_delta_in, remote_in, risk_score_in, use_api_in],
outputs=output_html
)
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)