Spaces:
Sleeping
Sleeping
| # app.py — TGN Fraud Detection | Temporal Graph Network E-commerce | |
| import streamlit as st | |
| import numpy as np | |
| import torch | |
| import os | |
| from datetime import datetime, timedelta | |
| import time | |
| import random | |
| st.set_page_config( | |
| page_title="TGN Fraud — E-commerce", | |
| page_icon="⏱️", | |
| layout="wide", | |
| initial_sidebar_state="expanded" | |
| ) | |
| st.markdown(""" | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;700&family=Fira+Code:wght@400;500&display=swap'); | |
| html, body, [class*="css"] { font-family: 'Space Grotesk', sans-serif; background:#07090f; color:#e2e8f0; } | |
| h1,h2,h3 { font-weight: 700; } | |
| code { font-family: 'Fira Code', monospace !important; } | |
| .card { | |
| background: linear-gradient(145deg, #0d1117, #111827); | |
| border: 1px solid #1f2937; border-radius: 14px; padding: 20px; | |
| } | |
| .metric-val { font-size: 2.2rem; font-weight: 700; } | |
| .metric-lbl { font-size: 0.7rem; color: #6b7280; text-transform: uppercase; letter-spacing: 1.5px; } | |
| .fraud-pill { background:#ff4444;color:#fff;padding:3px 12px;border-radius:20px;font-size:.8rem;font-weight:700; } | |
| .safe-pill { background:#22c55e;color:#fff;padding:3px 12px;border-radius:20px;font-size:.8rem;font-weight:700; } | |
| .warn-pill { background:#f59e0b;color:#fff;padding:3px 12px;border-radius:20px;font-size:.8rem;font-weight:700; } | |
| .event-row { | |
| display:flex; align-items:center; gap:12px; | |
| padding:10px 14px; border-radius:10px; margin:4px 0; | |
| border-left:3px solid #1f2937; font-size:.85rem; | |
| } | |
| .event-fraud { border-left-color:#ef4444; background:#1a0a0a; } | |
| .event-safe { border-left-color:#22c55e; background:#071a0f; } | |
| .mem-bar-bg { background:#1f2937; border-radius:4px; height:6px; margin:3px 0; } | |
| .mem-bar { height:6px; border-radius:4px; background:linear-gradient(90deg,#3b82f6,#a78bfa); } | |
| .stProgress > div > div { background: linear-gradient(90deg,#6366f1,#a78bfa) !important; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ── SESSION STATE ───────────────────────────────────────────── | |
| defaults = { | |
| 'trainer': None, 'treinado': False, 'data': None, | |
| 'df': None, 'usuarios': None, 'comerciantes': None, | |
| 'n_usuarios': 0, 'stream_eventos': [], 'stream_running': False, | |
| 'neo4j': None, 'neo4j_ok': False, | |
| } | |
| for k, v in defaults.items(): | |
| if k not in st.session_state: | |
| st.session_state[k] = v | |
| # ── NEO4J ───────────────────────────────────────────────────── | |
| def get_neo4j_config(): | |
| cfg = {} | |
| try: | |
| s = st.secrets | |
| if 'NEO4J_URI' in s: | |
| cfg = {'uri': s['NEO4J_URI'], 'username': s['NEO4J_USERNAME'], | |
| 'password': s['NEO4J_PASSWORD'], | |
| 'database': s.get('NEO4J_DATABASE', 'neo4j')} | |
| elif 'neo4j' in s: | |
| n = s['neo4j'] | |
| cfg = {'uri': n.get('uri',''), 'username': n.get('username',''), | |
| 'password': n.get('password',''), 'database': n.get('database','neo4j')} | |
| except Exception: | |
| pass | |
| if not cfg.get('uri'): | |
| cfg = {'uri': os.getenv('NEO4J_URI',''), 'username': os.getenv('NEO4J_USERNAME',''), | |
| 'password': os.getenv('NEO4J_PASSWORD',''), 'database': os.getenv('NEO4J_DATABASE','neo4j')} | |
| return cfg | |
| def conectar_neo4j(): | |
| try: | |
| from neo4j import GraphDatabase | |
| cfg = get_neo4j_config() | |
| if not all([cfg['uri'], cfg['username'], cfg['password']]): | |
| return None | |
| driver = GraphDatabase.driver(cfg['uri'], auth=(cfg['username'], cfg['password'])) | |
| with driver.session(database=cfg['database']) as s: | |
| s.run('RETURN 1') | |
| return driver, cfg['database'] | |
| except Exception: | |
| return None | |
| # ── LIBS ────────────────────────────────────────────────────── | |
| def carregar_libs(): | |
| try: | |
| from tgn_data import gerar_eventos_ecommerce, df_para_tensores | |
| from tgn_model import TrainerTGN | |
| return gerar_eventos_ecommerce, df_para_tensores, TrainerTGN | |
| except Exception as e: | |
| return str(e), None, None | |
| # ── CHARTS ──────────────────────────────────────────────────── | |
| def loss_svg(hist): | |
| lt = hist['loss_train']; lv = hist['loss_val']; av = hist['auc_val'] | |
| ep = len(lt) | |
| if ep == 0: return '' | |
| def pts(vals, H=120): | |
| mn,mx = min(vals),max(vals); r = mx-mn or 1 | |
| return ' '.join(f'{i*480/max(ep-1,1):.1f},{H-(v-mn)/r*H:.1f}' for i,v in enumerate(vals)) | |
| return f"""<div class="card" style="margin-top:12px"> | |
| <div style="color:#6b7280;font-size:11px;margin-bottom:6px"> | |
| TREINO — <span style="color:#6366f1">— Loss Train</span> | |
| <span style="color:#f59e0b;margin-left:8px">— Loss Val</span> | |
| <span style="color:#22c55e;margin-left:8px">— AUC Val</span> | |
| </div> | |
| <svg viewBox="0 0 490 130" style="width:100%"> | |
| <polyline points="{pts(lt)}" fill="none" stroke="#6366f1" stroke-width="2"/> | |
| <polyline points="{pts(lv)}" fill="none" stroke="#f59e0b" stroke-width="2"/> | |
| <polyline points="{pts(av)}" fill="none" stroke="#22c55e" stroke-width="2"/> | |
| <line x1="0" y1="120" x2="480" y2="120" stroke="#1f2937"/> | |
| </svg></div>""" | |
| def roc_svg(y_true, probs): | |
| from sklearn.metrics import roc_curve, auc | |
| fpr,tpr,_ = roc_curve(y_true, probs) | |
| ra = auc(fpr,tpr) | |
| pts = ' '.join(f'{f*460:.1f},{180-t*180:.1f}' for f,t in zip(fpr,tpr)) | |
| return f"""<div class="card"> | |
| <div style="color:#6b7280;font-size:11px;margin-bottom:6px"> | |
| ROC — AUC <b style="color:#6366f1">{ra:.3f}</b></div> | |
| <svg viewBox="0 0 480 195" style="width:100%"> | |
| <line x1="0" y1="0" x2="460" y2="180" stroke="#1f2937" stroke-dasharray="4"/> | |
| <polyline points="{pts}" fill="none" stroke="#6366f1" stroke-width="2.5"/> | |
| <line x1="0" y1="180" x2="460" y2="180" stroke="#374151"/> | |
| <line x1="0" y1="0" x2="0" y2="180" stroke="#374151"/> | |
| <text x="230" y="194" text-anchor="middle" fill="#4b5563" font-size="10">FPR</text> | |
| </svg></div>""" | |
| def cm_html(cm): | |
| tn,fp,fn,tp = cm.ravel() | |
| items = [('#22c55e',tn,'TN','Legítimas\ncorretas'), | |
| ('#ef4444',fp,'FP','Falsos\nalarmes'), | |
| ('#f59e0b',fn,'FN','Fraudes\nperdidas'), | |
| ('#6366f1',tp,'TP','Fraudes\ncapturadas')] | |
| html = '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">' | |
| for c,v,a,d in items: | |
| html += f'<div style="background:{c}15;border:1px solid {c}40;border-radius:10px;padding:14px;text-align:center"><div style="font-size:1.6rem;font-weight:700;color:{c}">{v}</div><div style="color:{c};font-size:.85rem;font-weight:600">{a}</div><div style="color:#6b7280;font-size:.7rem;white-space:pre-line">{d}</div></div>' | |
| return html + '</div>' | |
| def memoria_html(mem_snap, n_show=20): | |
| """Visualiza vetores de memória dos usuários como heatmap de barras.""" | |
| usuarios_mem = mem_snap['usuarios'][:n_show] | |
| last_upd = mem_snap['last_update'][:n_show] | |
| html = '<div class="card"><div style="color:#6b7280;font-size:11px;margin-bottom:12px">MEMÓRIA DOS NÓS — cada linha é um usuário, cada barra é uma dimensão da memória</div>' | |
| for i, (mem, lu) in enumerate(zip(usuarios_mem, last_upd)): | |
| # Normaliza para [0,1] | |
| mn, mx = mem.min(), mem.max() | |
| norm = (mem - mn) / (mx - mn + 1e-8) | |
| # Mostra primeiras 20 dimensões como barras coloridas | |
| bars = '' | |
| for v in norm[:20]: | |
| pct = float(v) * 100 | |
| color = f'hsl({int(220 + v*120)},70%,55%)' | |
| bars += f'<div style="flex:1;height:14px;background:{color};opacity:.85;border-radius:2px"></div>' | |
| ativo = lu > 0 | |
| dot = '🟢' if ativo else '⚫' | |
| html += f'<div style="margin:4px 0"><div style="display:flex;align-items:center;gap:6px"><span style="font-size:.72rem;color:#6b7280;width:60px">{dot} U{i:03d}</span><div style="display:flex;flex:1;gap:1px">{bars}</div></div></div>' | |
| html += '</div>' | |
| return html | |
| def timeline_html(eventos): | |
| """Linha do tempo dos últimos eventos do stream.""" | |
| if not eventos: | |
| return '<p style="color:#4b5563">Nenhum evento ainda. Inicie o stream.</p>' | |
| html = '' | |
| for ev in reversed(eventos[-15:]): | |
| cls = 'event-fraud' if ev['fraude'] else 'event-safe' | |
| badge = f'<span class="fraud-pill">FRAUDE {ev["prob"]:.0%}</span>' if ev['fraude'] else f'<span class="safe-pill">{ev["prob"]:.0%}</span>' | |
| html += f'''<div class="event-row {cls}"> | |
| <span style="color:#6b7280;font-size:.75rem;min-width:55px">{ev["hora"]}</span> | |
| <span>👤 U{ev["uid"]:03d}</span> | |
| <span style="color:#6b7280">→</span> | |
| <span>🏪 M{ev["mid"]:02d}</span> | |
| <span style="color:#a78bfa">R$ {ev["valor"]:.0f}</span> | |
| <span style="color:#6b7280">{ev["categoria"]}</span> | |
| {badge} | |
| </div>''' | |
| return html | |
| # ── SIDEBAR ─────────────────────────────────────────────────── | |
| def sidebar(): | |
| st.sidebar.markdown('## ⏱️ TGN Config') | |
| n_usuarios = st.sidebar.slider('Usuários', 100, 500, 300, 50) | |
| n_comerciantes = st.sidebar.slider('Comerciantes', 20, 150, 80, 10) | |
| n_eventos = st.sidebar.slider('Eventos treino', 1000, 5000, 3000, 500) | |
| taxa_fraude = st.sidebar.slider('Taxa fraude %', 3, 20, 8) | |
| st.sidebar.markdown('---') | |
| st.sidebar.markdown('### Hiperparâmetros') | |
| memory_dim = st.sidebar.select_slider('Memory dim', [32, 64, 128], 64) | |
| time_dim = st.sidebar.select_slider('Time dim', [16, 32, 64], 32) | |
| lr = st.sidebar.select_slider('LR', [0.0001, 0.0005, 0.001], 0.0005) | |
| epocas = st.sidebar.slider('Épocas', 10, 60, 25, 5) | |
| st.sidebar.markdown('---') | |
| if st.session_state.neo4j_ok: | |
| st.sidebar.success('🗄️ Neo4j Conectado') | |
| else: | |
| st.sidebar.warning('⚠️ Neo4j Offline') | |
| if st.session_state.treinado and st.session_state.trainer: | |
| st.sidebar.metric('Melhor AUC', f"{st.session_state.trainer.melhor_auc:.3f}") | |
| return dict(n_usuarios=n_usuarios, n_comerciantes=n_comerciantes, | |
| n_eventos=n_eventos, taxa_fraude=taxa_fraude/100, | |
| memory_dim=memory_dim, time_dim=time_dim, lr=lr, epocas=epocas) | |
| # ── MAIN ────────────────────────────────────────────────────── | |
| def main(): | |
| if st.session_state.neo4j is None: | |
| conn = conectar_neo4j() | |
| st.session_state.neo4j = conn | |
| st.session_state.neo4j_ok = conn is not None | |
| cfg = sidebar() | |
| st.markdown(""" | |
| <div style="margin-bottom:28px"> | |
| <h1 style="font-size:2.2rem;margin:0;background:linear-gradient(90deg,#6366f1,#a78bfa,#ec4899); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent"> | |
| TGN Fraud Detection | |
| </h1> | |
| <p style="color:#6b7280;margin:4px 0 0 2px;font-size:.9rem"> | |
| Temporal Graph Network · E-commerce · Memória Evolutiva dos Nós | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| tabs = st.tabs(['📦 Dados', '🧠 Treinar', '📊 Performance', '🧠 Memória dos Nós', '⚡ Stream ao Vivo', '🗄️ Neo4j']) | |
| # ── TAB 0: DADOS ────────────────────────────────────────── | |
| with tabs[0]: | |
| res = carregar_libs() | |
| if isinstance(res[0], str): | |
| st.error(f'Erro: {res[0]}') | |
| st.stop() | |
| gerar_eventos, df_para_tensores, TrainerTGN = res | |
| col1, col2 = st.columns([1, 2]) | |
| with col1: | |
| st.markdown('### Padrões de Fraude') | |
| for icone, nome, desc in [ | |
| ('⚡','Velocity Attack','Muitas tx em pouco tempo'), | |
| ('🃏','Card Testing','Pequena tx antes de grande'), | |
| ('🌙','Night Transaction','Compras de madrugada'), | |
| ('🛒','Categoria Anômala','Merchant incomum para o usuário'), | |
| ]: | |
| st.markdown(f'**{icone} {nome}** — {desc}') | |
| st.markdown('### Arquitetura TGN') | |
| st.markdown(""" | |
| ``` | |
| Evento (src, dst, t, feats) | |
| ↓ | |
| TimeEncoder(Δt) → cos embedding | |
| ↓ | |
| MessageFunction → msg | |
| ↓ | |
| GRU MemoryUpdater → nova memória | |
| ↓ | |
| TemporalEmbedding → node repr | |
| ↓ | |
| MLP Classifier → P(fraude) | |
| ``` | |
| """) | |
| if st.button('🔄 Gerar Dados', type='primary', use_container_width=True): | |
| with st.spinner('Gerando eventos de e-commerce...'): | |
| df, usuarios, comerciantes, n_u = gerar_eventos( | |
| n_usuarios=cfg['n_usuarios'], | |
| n_comerciantes=cfg['n_comerciantes'], | |
| n_eventos=cfg['n_eventos'], | |
| taxa_fraude=cfg['taxa_fraude'], | |
| ) | |
| data = df_para_tensores(df, n_u, cfg['n_comerciantes']) | |
| st.session_state.df = df | |
| st.session_state.data = data | |
| st.session_state.usuarios = usuarios | |
| st.session_state.comerciantes= comerciantes | |
| st.session_state.n_usuarios = n_u | |
| st.session_state.treinado = False | |
| st.session_state.trainer = None | |
| st.success('✅ Dados gerados!') | |
| with col2: | |
| if st.session_state.df is not None: | |
| df = st.session_state.df | |
| data = st.session_state.data | |
| m1,m2,m3,m4 = st.columns(4) | |
| n_fraudes = int(df['label'].sum()) | |
| for col, val, lbl in [ | |
| (m1, len(df), 'Eventos'), | |
| (m2, n_fraudes, '🚨 Fraudes'), | |
| (m3, f"{n_fraudes/len(df):.1%}", 'Taxa fraude'), | |
| (m4, data['n_nos'], 'Nós no grafo'), | |
| ]: | |
| col.markdown(f'<div class="card" style="text-align:center"><div class="metric-val" style="color:#6366f1">{val}</div><div class="metric-lbl">{lbl}</div></div>', unsafe_allow_html=True) | |
| st.markdown('<br>', unsafe_allow_html=True) | |
| # Distribuição de fraudes por categoria | |
| st.markdown('#### Fraudes por Categoria') | |
| fraudes_cat = df[df['label']==1]['categoria'].value_counts() | |
| legit_cat = df[df['label']==0]['categoria'].value_counts() | |
| cats = list(fraudes_cat.index) | |
| W = 440 | |
| max_v = max(fraudes_cat.max(), legit_cat.max()) | |
| bars_svg = f'<svg viewBox="0 0 {W} {len(cats)*36+20}" style="width:100%;background:#0d1117;border-radius:10px;padding:8px">' | |
| for i, cat in enumerate(cats): | |
| y = i * 36 + 10 | |
| fv = fraudes_cat.get(cat, 0) | |
| lv = legit_cat.get(cat, 0) | |
| fw = int(fv / max_v * (W-120)) | |
| lw = int(lv / max_v * (W-120)) | |
| bars_svg += ( | |
| f'<text x="115" y="{y+10}" text-anchor="end" fill="#9ca3af" font-size="11">{cat}</text>' | |
| f'<rect x="120" y="{y}" width="{lw}" height="10" fill="#22c55e" opacity=".7" rx="2"/>' | |
| f'<rect x="120" y="{y+12}" width="{fw}" height="10" fill="#ef4444" opacity=".8" rx="2"/>' | |
| f'<text x="{120+lw+3}" y="{y+10}" fill="#22c55e" font-size="9">{lv}</text>' | |
| f'<text x="{120+fw+3}" y="{y+22}" fill="#ef4444" font-size="9">{fv}</text>' | |
| ) | |
| bars_svg += '</svg>' | |
| st.markdown(bars_svg, unsafe_allow_html=True) | |
| st.caption('🟢 Legítimas 🔴 Fraudes') | |
| else: | |
| st.info('Configure parâmetros e clique **Gerar Dados**.') | |
| # ── TAB 1: TREINAR ──────────────────────────────────────── | |
| with tabs[1]: | |
| _, _, TrainerTGN = carregar_libs() | |
| if st.session_state.data is None: | |
| st.warning('⬅️ Gere os dados primeiro.') | |
| else: | |
| col1, col2 = st.columns([1, 2]) | |
| with col1: | |
| st.markdown('### Memory dim') | |
| st.markdown(f'`{cfg["memory_dim"]}` dimensões por nó') | |
| st.markdown(f'`{st.session_state.data["n_nos"]}` nós no total') | |
| total_mem = st.session_state.data['n_nos'] * cfg['memory_dim'] * 4 | |
| st.markdown(f'Memória total: `{total_mem/1024:.1f} KB`') | |
| st.markdown('### Time Encoding') | |
| st.markdown(f'`{cfg["time_dim"]}` frequências cossenoidais') | |
| st.markdown('`Δt` → comportamento temporal') | |
| if st.button('🚀 Treinar TGN', type='primary', use_container_width=True): | |
| st.session_state.trainer = TrainerTGN( | |
| st.session_state.data, | |
| memory_dim=cfg['memory_dim'], | |
| time_dim=cfg['time_dim'], | |
| lr=cfg['lr'], | |
| ) | |
| prog = st.progress(0) | |
| status = st.empty() | |
| logs = [] | |
| log_box = col2.empty() | |
| def cb(ep, total, loss, auc, f1): | |
| prog.progress(ep/total) | |
| status.markdown(f'**Época {ep}/{total}** · Loss `{loss:.4f}` · AUC `{auc:.3f}` · F1 `{f1:.3f}`') | |
| if ep % 5 == 0 or ep == total: | |
| logs.append(f'[{ep:>3}] loss={loss:.4f} auc={auc:.3f} f1={f1:.3f}') | |
| log_box.code('\n'.join(logs[-12:])) | |
| with st.spinner('Treinando TGN...'): | |
| st.session_state.trainer.treinar(cfg['epocas'], cb) | |
| st.session_state.treinado = True | |
| st.success(f'✅ Melhor AUC: {st.session_state.trainer.melhor_auc:.3f}') | |
| with col2: | |
| if st.session_state.treinado: | |
| hist = st.session_state.trainer.historico | |
| st.components.v1.html(loss_svg(hist), height=200) | |
| # ── TAB 2: PERFORMANCE ──────────────────────────────────── | |
| with tabs[2]: | |
| if not st.session_state.treinado: | |
| st.warning('⬅️ Treine o modelo primeiro.') | |
| else: | |
| m = st.session_state.trainer.metricas_completas() | |
| cols = st.columns(5) | |
| for col, (nome, val) in zip(cols, [ | |
| ('ROC-AUC', f"{m['auc']:.3f}"), | |
| ('Avg Prec', f"{m['ap']:.3f}"), | |
| ('F1', f"{m['f1']:.3f}"), | |
| ('Precision', f"{m['precision']:.3f}"), | |
| ('Recall', f"{m['recall']:.3f}"), | |
| ]): | |
| col.markdown(f'<div class="card" style="text-align:center"><div class="metric-val" style="color:#6366f1">{val}</div><div class="metric-lbl">{nome}</div></div>', unsafe_allow_html=True) | |
| st.markdown('<br>', unsafe_allow_html=True) | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| st.markdown('### Matriz de Confusão') | |
| st.markdown(cm_html(m['cm']), unsafe_allow_html=True) | |
| with c2: | |
| st.components.v1.html(roc_svg(m['y_true'], m['probs']), height=240) | |
| # ── TAB 3: MEMÓRIA DOS NÓS ─────────────────────────────── | |
| with tabs[3]: | |
| if not st.session_state.treinado: | |
| st.warning('⬅️ Treine o modelo primeiro.') | |
| else: | |
| st.markdown('### 🧠 Estado da Memória — o que o TGN "lembra" de cada usuário') | |
| st.markdown(""" | |
| Cada linha é um usuário. Cada barra colorida é uma dimensão do vetor de memória. | |
| **Usuários com padrão de fraude** tendem a ter memória com ativações distintas. | |
| A memória evolui a cada transação via GRU — captura comportamento histórico. | |
| """) | |
| n_show = st.slider('Usuários para mostrar', 10, 50, 25) | |
| mem = st.session_state.trainer.get_memory_snapshot( | |
| st.session_state.n_usuarios) | |
| # Estatísticas da memória | |
| usuarios_mem = mem['usuarios'] | |
| normas = np.linalg.norm(usuarios_mem, axis=1) | |
| top_ativos = np.argsort(normas)[::-1] | |
| c1, c2, c3 = st.columns(3) | |
| c1.metric('Usuários com memória ativa', int((normas > 0.1).sum())) | |
| c2.metric('Norma média da memória', f'{normas.mean():.3f}') | |
| c3.metric('Usuário mais ativo', f'U{top_ativos[0]:03d}') | |
| st.markdown('<br>', unsafe_allow_html=True) | |
| # Ordenar por norma (mais ativos primeiro) | |
| idx_sorted = np.argsort(normas)[::-1][:n_show] | |
| mem_sorted = { | |
| 'usuarios': usuarios_mem[idx_sorted], | |
| 'last_update': mem['last_update'][idx_sorted], | |
| } | |
| st.components.v1.html(memoria_html(mem_sorted, n_show), height=n_show*26+80) | |
| # t-SNE da memória | |
| with st.expander('📊 t-SNE da memória dos usuários'): | |
| try: | |
| from sklearn.manifold import TSNE | |
| tsne = TSNE(n_components=2, random_state=42, | |
| perplexity=min(30, len(usuarios_mem)//2)) | |
| coords = tsne.fit_transform(usuarios_mem) | |
| df_ = st.session_state.df | |
| # Usuários com mais fraudes | |
| fraud_per_user = df_.groupby('src')['label'].mean() | |
| colors = [fraud_per_user.get(i, 0) for i in range(len(usuarios_mem))] | |
| # SVG scatter | |
| cx = coords[:,0]; cy = coords[:,1] | |
| mn_x,mx_x = cx.min(),cx.max(); mn_y,mx_y = cy.min(),cy.max() | |
| def sc(v,mn,mx,W): return (v-mn)/(mx-mn+1e-8)*W | |
| circles = '' | |
| for i,(x,y,c) in enumerate(zip(cx,cy,colors)): | |
| px = sc(x,mn_x,mx_x,480); py = sc(y,mn_y,mx_y,280) | |
| hue = int(120*(1-c)) | |
| circles += f'<circle cx="{px:.1f}" cy="{py:.1f}" r="5" fill="hsl({hue},70%,50%)" opacity=".75" title="U{i}"/>' | |
| svg = f'<svg viewBox="0 0 480 280" style="width:100%;background:#0d1117;border-radius:10px">{circles}<text x="10" y="20" fill="#6b7280" font-size="11">🟢 Baixo risco 🔴 Alto risco histórico</text></svg>' | |
| st.components.v1.html(svg, height=300) | |
| except Exception as e: | |
| st.info(f't-SNE indisponível: {e}') | |
| # ── TAB 4: STREAM AO VIVO ───────────────────────────────── | |
| with tabs[4]: | |
| if not st.session_state.treinado: | |
| st.warning('⬅️ Treine o modelo primeiro.') | |
| else: | |
| st.markdown('### ⚡ Simulação de Stream em Tempo Real') | |
| st.markdown('Gera transações aleatórias e classifica com o TGN treinado, atualizando a memória dos nós a cada evento.') | |
| col1, col2 = st.columns([1, 2]) | |
| with col1: | |
| n_gerar = st.number_input('Eventos a simular', 5, 100, 20) | |
| if st.button('▶️ Simular Stream', type='primary', use_container_width=True): | |
| data = st.session_state.data | |
| usuarios = st.session_state.usuarios | |
| comerciantes = st.session_state.comerciantes | |
| n_u = st.session_state.n_usuarios | |
| trainer = st.session_state.trainer | |
| categorias = ['eletronicos','viagem','alimentacao','roupas','jogos','farmacia'] | |
| novos = [] | |
| prog = st.progress(0) | |
| for i in range(int(n_gerar)): | |
| uid = random.randint(0, n_u-1) | |
| mid = random.randint(0, len(comerciantes)-1) | |
| u = usuarios[uid] | |
| m = comerciantes[mid] | |
| cat = m['categoria'] | |
| valor = max(1.0, np.random.lognormal(5, 1)) | |
| hora = random.randint(0, 23) | |
| ts = float(i) / n_gerar | |
| feats = [ | |
| np.log1p(valor)/12.0, | |
| hora/24.0, | |
| 1.0 if hora < 6 else 0.0, | |
| 1.0 if cat=='eletronicos' else 0.0, | |
| 1.0 if cat=='viagem' else 0.0, | |
| 0.0, | |
| u['score_credito'], | |
| min(u['tempo_cliente_dias']/1000, 1.0), | |
| m['risco_setor'], | |
| ] | |
| prob, mem_src = trainer.inferir_evento(uid, mid+n_u, ts, feats) | |
| novos.append({ | |
| 'uid': uid, 'mid': mid, 'valor': valor, | |
| 'categoria': cat, 'hora': f'{hora:02d}h', | |
| 'prob': prob, 'fraude': prob > 0.5, | |
| 'mem_norm': float(np.linalg.norm(mem_src)), | |
| }) | |
| prog.progress((i+1)/n_gerar) | |
| time.sleep(0.05) | |
| st.session_state.stream_eventos = ( | |
| st.session_state.stream_eventos + novos)[-50:] | |
| st.success(f'✅ {int(n_gerar)} eventos processados · ' | |
| f'{sum(1 for e in novos if e["fraude"])} fraudes detectadas') | |
| with col2: | |
| eventos = st.session_state.stream_eventos | |
| if eventos: | |
| n_fraud = sum(1 for e in eventos if e['fraude']) | |
| m1,m2,m3 = st.columns(3) | |
| m1.metric('Eventos', len(eventos)) | |
| m2.metric('Fraudes', n_fraud) | |
| m3.metric('Taxa', f'{n_fraud/len(eventos):.1%}') | |
| st.markdown('<br>', unsafe_allow_html=True) | |
| st.components.v1.html( | |
| f'<div style="background:#07090f;padding:8px;border-radius:10px">' | |
| f'{timeline_html(eventos)}</div>', | |
| height=420, scrolling=True) | |
| else: | |
| st.info('Clique em **Simular Stream** para ver eventos em tempo real.') | |
| # ── TAB 5: NEO4J ───────────────────────────────────────── | |
| with tabs[5]: | |
| st.header('🗄️ Neo4j') | |
| if not st.session_state.neo4j_ok: | |
| st.warning('Neo4j offline.') | |
| with st.expander('Como configurar'): | |
| st.markdown(""" | |
| **HF Spaces → Settings → Variables and secrets:** | |
| | Chave | Valor | | |
| |---|---| | |
| | `NEO4J_URI` | `neo4j+s://XXXXXXXX.databases.neo4j.io` | | |
| | `NEO4J_USERNAME` | `neo4j` | | |
| | `NEO4J_PASSWORD` | `sua_senha` | | |
| | `NEO4J_DATABASE` | `neo4j` | | |
| """) | |
| else: | |
| st.success('Conectado!') | |
| if st.session_state.treinado and st.button('💾 Salvar métricas no Neo4j'): | |
| driver, db = st.session_state.neo4j | |
| m = st.session_state.trainer.metricas_completas() | |
| try: | |
| with driver.session(database=db) as s: | |
| s.run(""" | |
| MERGE (r:TGNRun {ts: $ts}) | |
| SET r.auc=$auc, r.f1=$f1, r.ap=$ap, | |
| r.n_eventos=$n, r.memory_dim=$mem | |
| """, ts=datetime.now().isoformat(), | |
| auc=float(m['auc']), f1=float(m['f1']), | |
| ap=float(m['ap']), | |
| n=len(st.session_state.df), | |
| mem=cfg['memory_dim']) | |
| st.success('✅ Salvo!') | |
| except Exception as e: | |
| st.error(str(e)) | |
| if __name__ == '__main__': | |
| main() |