Spaces:
Sleeping
Sleeping
| # app.py β DOMINANT Graph Anomaly Detection | Sem Labels | |
| import streamlit as st | |
| import numpy as np | |
| import torch | |
| import os | |
| from datetime import datetime | |
| st.set_page_config( | |
| page_title="DOMINANT β Anomaly Detection", | |
| page_icon="π¬", | |
| layout="wide", | |
| initial_sidebar_state="expanded" | |
| ) | |
| st.markdown(""" | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@300;400;600;700&display=swap'); | |
| html, body, [class*="css"] { | |
| font-family: 'IBM Plex Sans', sans-serif; | |
| background: #020408; color: #cdd9e5; | |
| } | |
| h1,h2,h3 { font-weight: 700; letter-spacing: -0.5px; } | |
| code, pre { font-family: 'IBM Plex Mono', monospace !important; } | |
| .card { | |
| background: #0d1117; border: 1px solid #30363d; | |
| border-radius: 10px; padding: 18px; | |
| } | |
| .metric-val { font-size: 2rem; font-weight: 700; font-family: 'IBM Plex Mono'; } | |
| .metric-lbl { font-size: .68rem; color: #8b949e; text-transform: uppercase; letter-spacing: 2px; margin-top: 2px; } | |
| .anomaly-row { | |
| display: flex; align-items: center; gap: 10px; | |
| padding: 8px 12px; border-radius: 8px; margin: 3px 0; | |
| font-family: 'IBM Plex Mono', monospace; font-size: .82rem; | |
| } | |
| .anomaly-high { background: #1a0505; border-left: 3px solid #f85149; } | |
| .anomaly-med { background: #1a0e00; border-left: 3px solid #d29922; } | |
| .anomaly-low { background: #051a0a; border-left: 3px solid #3fb950; } | |
| .score-bar-bg { background: #21262d; border-radius: 3px; height: 5px; } | |
| .stProgress > div > div { background: linear-gradient(90deg,#f85149,#ff7b72) !important; } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # ββ SESSION STATE βββββββββββββββββββββββββββββββββββββββββββββ | |
| for k, v in { | |
| 'trainer': None, 'treinado': False, 'data': None, | |
| 'edge_weight': None, 'metricas': None, | |
| 'neo4j': None, 'neo4j_ok': False, | |
| }.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 | |
| def carregar_libs(): | |
| try: | |
| from dominant_data import gerar_grafo_anomaly, get_adj_normalizada | |
| from dominant_model import TrainerDOMINANT | |
| return gerar_grafo_anomaly, get_adj_normalizada, TrainerDOMINANT | |
| except Exception as e: | |
| return str(e), None, None | |
| # ββ CHARTS ββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def loss_auc_svg(historico): | |
| loss = historico['loss'] | |
| auc = historico['auc'] | |
| ep = len(loss) | |
| if ep == 0: return '' | |
| def pts(vals, H=110): | |
| mn,mx = min(vals),max(vals); r = mx-mn or 1 | |
| return ' '.join(f'{i*460/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:10px"> | |
| <div style="font-size:11px;color:#8b949e;margin-bottom:6px"> | |
| <span style="color:#f85149">β Loss</span> | |
| <span style="color:#3fb950;margin-left:12px">β AUC (sem labels)</span> | |
| </div> | |
| <svg viewBox="0 0 470 120" style="width:100%"> | |
| <polyline points="{pts(loss)}" fill="none" stroke="#f85149" stroke-width="2"/> | |
| <polyline points="{pts(auc)}" fill="none" stroke="#3fb950" stroke-width="2"/> | |
| <line x1="0" y1="110" x2="460" y2="110" stroke="#21262d"/> | |
| </svg></div>""" | |
| def roc_svg(y_true, scores): | |
| from sklearn.metrics import roc_curve, auc as sk_auc | |
| fpr,tpr,_ = roc_curve(y_true, scores) | |
| ra = sk_auc(fpr, tpr) | |
| pts = ' '.join(f'{f*440:.1f},{170-t*170:.1f}' for f,t in zip(fpr,tpr)) | |
| return f"""<div class="card"> | |
| <div style="font-size:11px;color:#8b949e;margin-bottom:4px"> | |
| ROC-AUC <b style="color:#f85149;font-family:'IBM Plex Mono'">{ra:.4f}</b> | |
| <span style="color:#8b949e;font-size:.75rem;margin-left:8px">(sem nenhum label no treino)</span> | |
| </div> | |
| <svg viewBox="0 0 460 185" style="width:100%"> | |
| <line x1="0" y1="0" x2="440" y2="170" stroke="#21262d" stroke-dasharray="4"/> | |
| <polyline points="{pts}" fill="none" stroke="#f85149" stroke-width="2.5"/> | |
| <line x1="0" y1="170" x2="440" y2="170" stroke="#30363d"/> | |
| <line x1="0" y1="0" x2="0" y2="170" stroke="#30363d"/> | |
| <text x="220" y="183" text-anchor="middle" fill="#4b5563" font-size="10">FPR</text> | |
| <text x="-85" y="85" transform="rotate(-90)" fill="#4b5563" font-size="10">TPR</text> | |
| </svg></div>""" | |
| def score_dist_svg(scores, y_true, thresh): | |
| """DistribuiΓ§Γ£o dos scores: normal vs anomalia.""" | |
| scores_norm = scores[y_true == 0] | |
| scores_anom = scores[y_true == 1] | |
| def hist_pts(vals, bins=30, H=120, W=440, color='#3fb950'): | |
| if len(vals) == 0: return '' | |
| counts, edges = np.histogram(vals, bins=bins, range=(0,1)) | |
| max_c = max(counts.max(), 1) | |
| bars = '' | |
| bw = W / bins | |
| for i, c in enumerate(counts): | |
| x = i * bw | |
| h = c / max_c * H | |
| bars += f'<rect x="{x:.1f}" y="{H-h:.1f}" width="{bw*.9:.1f}" height="{h:.1f}" fill="{color}" opacity=".75" rx="1"/>' | |
| return bars | |
| t_x = thresh * 440 | |
| return f"""<div class="card"> | |
| <div style="font-size:11px;color:#8b949e;margin-bottom:4px"> | |
| DISTRIBUIΓΓO DO ANOMALY SCORE | |
| <span style="color:#3fb950;margin-left:8px">β Normal</span> | |
| <span style="color:#f85149;margin-left:8px">β Anomalia (real)</span> | |
| </div> | |
| <svg viewBox="0 0 460 140" style="width:100%"> | |
| {hist_pts(scores_norm, color='#3fb950')} | |
| {hist_pts(scores_anom, color='#f85149')} | |
| <line x1="{t_x:.1f}" y1="0" x2="{t_x:.1f}" y2="120" | |
| stroke="#d29922" stroke-width="1.5" stroke-dasharray="4"/> | |
| <text x="{t_x+3:.1f}" y="15" fill="#d29922" font-size="9">threshold</text> | |
| <line x1="0" y1="120" x2="440" y2="120" stroke="#21262d"/> | |
| </svg></div>""" | |
| def scatter_erros_svg(err_attr, err_struct, y_true, n_show=300): | |
| """Scatter erro atributo vs erro estrutura.""" | |
| idx = np.random.choice(len(err_attr), min(n_show, len(err_attr)), replace=False) | |
| ea = err_attr[idx]; es = err_struct[idx]; yt = y_true[idx] | |
| mn_a,mx_a = ea.min(),ea.max(); mn_s,mx_s = es.min(),es.max() | |
| def sc(v,mn,mx,W): return (v-mn)/(mx-mn+1e-8)*W | |
| circles = '' | |
| for a,s,y in zip(ea,es,yt): | |
| px = sc(a,mn_a,mx_a,400); py = 200-sc(s,mn_s,mx_s,200) | |
| col = '#f85149' if y==1 else '#3fb95066' | |
| r = 5 if y==1 else 3 | |
| circles += f'<circle cx="{px:.1f}" cy="{py:.1f}" r="{r}" fill="{col}"/>' | |
| return f"""<div class="card"> | |
| <div style="font-size:11px;color:#8b949e;margin-bottom:4px"> | |
| ERRO ATRIBUTO vs ERRO ESTRUTURA | |
| <span style="color:#3fb950;margin-left:8px">β Normal</span> | |
| <span style="color:#f85149;margin-left:8px">β Anomalia real</span> | |
| </div> | |
| <svg viewBox="0 0 430 220" style="width:100%;background:#070d14;border-radius:6px"> | |
| {circles} | |
| <text x="200" y="215" text-anchor="middle" fill="#4b5563" font-size="10">Erro Atributo β</text> | |
| <text x="-110" y="10" transform="rotate(-90)" fill="#4b5563" font-size="10">Erro Estrutura β</text> | |
| </svg></div>""" | |
| def tsne_svg(embeddings, y_true): | |
| try: | |
| from sklearn.manifold import TSNE | |
| tsne = TSNE(n_components=2, random_state=42, | |
| perplexity=min(30, len(embeddings)//3)) | |
| coords = tsne.fit_transform(embeddings) | |
| 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) in enumerate(zip(cx,cy)): | |
| px = sc(x,mn_x,mx_x,440); py = sc(y,mn_y,mx_y,260) | |
| col = '#f85149' if y_true[i]==1 else '#3fb95055' | |
| r = 6 if y_true[i]==1 else 3 | |
| circles += f'<circle cx="{px:.1f}" cy="{py:.1f}" r="{r}" fill="{col}" opacity=".85"/>' | |
| return f"""<div class="card"> | |
| <div style="font-size:11px;color:#8b949e;margin-bottom:4px"> | |
| EMBEDDINGS t-SNE β separaΓ§Γ£o aprendida SEM LABELS | |
| <span style="color:#3fb950;margin-left:8px">β Normal</span> | |
| <span style="color:#f85149;margin-left:8px">β Anomalia</span> | |
| </div> | |
| <svg viewBox="0 0 460 270" style="width:100%;background:#070d14;border-radius:6px"> | |
| {circles} | |
| </svg></div>""" | |
| except Exception as e: | |
| return f'<p style="color:#4b5563">t-SNE indisponΓvel: {e}</p>' | |
| def top_anomalias_html(top_list, feat_names, data_x): | |
| html = '' | |
| for item in top_list: | |
| idx = item['idx'] | |
| score = item['score'] | |
| real = item['label_real'] | |
| cls = 'anomaly-high' if score > 0.7 else ('anomaly-med' if score > 0.4 else 'anomaly-low') | |
| badge = '<span style="color:#f85149;font-size:.75rem">β REAL</span>' if real else '' | |
| # Top features anΓ΄malas deste nΓ³ | |
| feats_no = data_x[idx].numpy() | |
| top_feat_idx = np.argsort(np.abs(feats_no - feats_no.mean()))[::-1][:3] | |
| feat_str = ' Β· '.join(feat_names[fi] for fi in top_feat_idx) | |
| bar_w = int(score * 100) | |
| html += f"""<div class="anomaly-row {cls}"> | |
| <span style="min-width:50px;color:#8b949e">N{idx:04d}</span> | |
| <span style="min-width:130px"> | |
| <div class="score-bar-bg" style="width:130px"> | |
| <div style="width:{bar_w}%;height:5px;border-radius:3px; | |
| background:{'#f85149' if score>.7 else ('#d29922' if score>.4 else '#3fb950')}"></div> | |
| </div> | |
| <span style="font-size:.75rem;color:#8b949e">{score:.4f}</span> | |
| </span> | |
| <span style="color:#8b949e;font-size:.75rem;flex:1">{feat_str}</span> | |
| {badge} | |
| </div>""" | |
| return html | |
| # ββ SIDEBAR βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def sidebar(): | |
| st.sidebar.markdown('## π¬ DOMINANT Config') | |
| n_nos = st.sidebar.slider('NΓ³s no grafo', 100, 1000, 500, 50) | |
| n_arestas= st.sidebar.slider('Arestas', 500, 8000, 2500, 500) | |
| n_feats = st.sidebar.select_slider('Features por nΓ³', [8,16,32], 16) | |
| taxa_an = st.sidebar.slider('Taxa anomalia %', 2, 15, 5) | |
| st.sidebar.markdown('---') | |
| st.sidebar.markdown('### Modelo') | |
| hidden = st.sidebar.select_slider('Hidden dim', [32,64,128], 64) | |
| embed = st.sidebar.select_slider('Embed dim', [16,32,64], 32) | |
| alpha = st.sidebar.slider('Ξ± (struct vs attr)', 0.0, 1.0, 0.5, 0.05) | |
| lr = st.sidebar.select_slider('LR', [0.001,0.003,0.005,0.01], 0.005) | |
| epocas = st.sidebar.slider('Γpocas', 20, 200, 100, 10) | |
| dropout = st.sidebar.slider('Dropout', 0.1, 0.5, 0.3, 0.05) | |
| st.sidebar.markdown('---') | |
| st.sidebar.markdown(f'**Ξ± = {alpha:.2f}**') | |
| st.sidebar.caption(f'Ξ±β1: mais peso na estrutura\nΞ±β0: mais peso nos atributos') | |
| if st.session_state.neo4j_ok: | |
| st.sidebar.success('ποΈ Neo4j Conectado') | |
| else: | |
| st.sidebar.warning('β οΈ Neo4j Offline') | |
| return dict(n_nos=n_nos, n_arestas=n_arestas, n_feats=n_feats, | |
| taxa_an=taxa_an/100, hidden=hidden, embed=embed, | |
| alpha=alpha, lr=lr, epocas=epocas, dropout=dropout) | |
| # ββ 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,#f85149,#d29922,#3fb950); | |
| -webkit-background-clip:text;-webkit-text-fill-color:transparent"> | |
| DOMINANT | |
| </h1> | |
| <p style="color:#8b949e;margin:2px 0 0 2px;font-size:.9rem"> | |
| Deep Anomaly Detection on Attributed Networks Β· Ding et al., IJCAI 2019 Β· | |
| <b style="color:#f85149">Zero labels no treino</b> | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| tabs = st.tabs(['π Teoria', 'ποΈ Grafo', 'π§ Treinar', | |
| 'π Performance', 'π Top Anomalias', 'ποΈ Neo4j']) | |
| # ββ TAB 0: TEORIA βββββββββββββββββββββββββββββββββββββββββ | |
| with tabs[0]: | |
| c1, c2 = st.columns(2) | |
| with c1: | |
| st.markdown('### Por que sem labels?') | |
| st.markdown(""" | |
| Em produΓ§Γ£o, fraude real **nΓ£o tem label imediato**. | |
| O banco sΓ³ descobre que foi fraude dias ou semanas depois, | |
| quando o cliente contesta. O DOMINANT resolve isso: | |
| > *"Aprendo o que Γ© normal. Quem desvia Γ© anΓ΄malo."* | |
| **IntuiΓ§Γ£o:** | |
| - Treina um autoencoder no grafo inteiro | |
| - NΓ³s normais sΓ£o **reconstruΓdos bem** | |
| - Anomalias tΓͺm **alto erro de reconstruΓ§Γ£o** | |
| - Anomaly score = erro de reconstruΓ§Γ£o | |
| """) | |
| st.markdown('### Dois tipos de anomalia detectados') | |
| for tipo, desc, cor in [ | |
| ('Atributo', 'Features fora da distribuiΓ§Γ£o normal (saldo extremo, chargeback alto)', '#f85149'), | |
| ('Estrutural', 'PadrΓ£o de conexΓ΅es anΓ΄malo (hub suspeito, isolado)', '#d29922'), | |
| ('Combinada', 'Ambos β o caso mais tΓpico de fraude real', '#a78bfa'), | |
| ]: | |
| st.markdown( | |
| f'<div class="card" style="margin:6px 0;border-left:3px solid {cor}">' | |
| f'<b style="color:{cor}">{tipo}</b><br>' | |
| f'<span style="font-size:.85rem;color:#8b949e">{desc}</span></div>', | |
| unsafe_allow_html=True) | |
| with c2: | |
| st.markdown('### Arquitetura DOMINANT') | |
| st.markdown(""" | |
| ``` | |
| Grafo G = (A, X) | |
| A: matriz de adjacΓͺncia | |
| X: features dos nΓ³s | |
| β | |
| GCN Encoder | |
| gc1: X β H (hidden) | |
| gc2: H β Z (embedding) | |
| β | |
| ββββββ΄βββββ | |
| βΌ βΌ | |
| Attr Dec Struct Dec | |
| GCN β XΜ GCN β Z' | |
| Z' @ Z'α΅ β Γ | |
| β β | |
| L_attr L_struct | |
| βX - XΜβΒ² BCE(A, Γ) | |
| ββββββ¬βββββ | |
| β | |
| Loss = Ξ±Β·L_s + (1-Ξ±)Β·L_a | |
| Anomaly Score(v) = | |
| Ξ±Β·err_struct(v) + (1-Ξ±)Β·err_attr(v) | |
| ``` | |
| """) | |
| st.markdown(f'**Ξ± = {cfg["alpha"]:.2f}** controla o balanΓ§o estrutura/atributo') | |
| # ββ TAB 1: GRAFO ββββββββββββββββββββββββββββββββββββββββββ | |
| with tabs[1]: | |
| res = carregar_libs() | |
| if isinstance(res[0], str): | |
| st.error(f'Erro de importaΓ§Γ£o: {res[0]}') | |
| st.stop() | |
| gerar_grafo, get_adj_norm, TrainerDOMINANT = res | |
| c1, c2 = st.columns([1, 2]) | |
| with c1: | |
| st.markdown(f""" | |
| **ConfiguraΓ§Γ£o atual:** | |
| - `{cfg["n_nos"]}` nΓ³s (contas bancΓ‘rias) | |
| - `{cfg["n_arestas"]}` arestas (transaΓ§Γ΅es) | |
| - `{cfg["n_feats"]}` features por nΓ³ | |
| - `{cfg["taxa_an"]:.0%}` anomalias embutidas | |
| - Labels **ocultos** do modelo | |
| """) | |
| if st.button('π Gerar Grafo', type='primary', use_container_width=True): | |
| with st.spinner('Gerando...'): | |
| data, feat_names = gerar_grafo( | |
| n_nos=cfg['n_nos'], | |
| n_arestas=cfg['n_arestas'], | |
| n_features=cfg['n_feats'], | |
| taxa_anomalia=cfg['taxa_an'], | |
| ) | |
| ei, ew, _ = get_adj_norm(data.edge_index, data.x.shape[0]) | |
| data.edge_index = ei | |
| st.session_state.data = data | |
| st.session_state.edge_weight = ew | |
| st.session_state.feat_names = feat_names | |
| st.session_state.treinado = False | |
| st.session_state.trainer = None | |
| st.session_state.metricas = None | |
| st.success('β Grafo gerado!') | |
| with c2: | |
| if st.session_state.data is not None: | |
| data = st.session_state.data | |
| n_anom = int(data.y.sum()) | |
| m1,m2,m3,m4 = st.columns(4) | |
| for col, v, l in [ | |
| (m1, data.x.shape[0], 'NΓ³s'), | |
| (m2, data.edge_index.shape[1], 'Arestas'), | |
| (m3, n_anom, 'π¨ Anomalias'), | |
| (m4, f'{n_anom/data.x.shape[0]:.1%}', 'Taxa'), | |
| ]: | |
| col.markdown( | |
| f'<div class="card" style="text-align:center">' | |
| f'<div class="metric-val" style="color:#f85149">{v}</div>' | |
| f'<div class="metric-lbl">{l}</div></div>', | |
| unsafe_allow_html=True) | |
| st.markdown('<br>', unsafe_allow_html=True) | |
| # VisualizaΓ§Γ£o do grafo (amostra) | |
| try: | |
| from pyvis.network import Network | |
| n_show = min(80, data.x.shape[0]) | |
| idx_show = np.random.choice(data.x.shape[0], n_show, replace=False) | |
| idx_map = {int(v): i for i, v in enumerate(idx_show)} | |
| net = Network(height='400px', width='100%', | |
| bgcolor='#0d1117', font_color='#cdd9e5', directed=False) | |
| net.set_options('{"physics":{"stabilization":{"iterations":80}},' | |
| '"edges":{"color":{"inherit":false},"width":0.8}}') | |
| for i in idx_show: | |
| is_anom = bool(data.y[i]) | |
| net.add_node(int(i), | |
| color='#f85149' if is_anom else '#3fb950', | |
| size=12 if is_anom else 7, | |
| title=f'NΓ³ {i} | {"β Anomalia" if is_anom else "Normal"}') | |
| ei = data.edge_index | |
| shown = 0 | |
| for j in range(ei.shape[1]): | |
| s,d = int(ei[0,j]), int(ei[1,j]) | |
| if s in idx_map and d in idx_map and shown < 200: | |
| net.add_edge(s, d, color='#30363d') | |
| shown += 1 | |
| st.components.v1.html(net.generate_html(), height=420) | |
| st.caption('π΄ Anomalia (label real) Β· π’ Normal Β· apenas amostra') | |
| except ImportError: | |
| st.info('pyvis nΓ£o instalado β adicione ao requirements.txt para ver o grafo interativo.') | |
| else: | |
| st.info('Clique em **Gerar Grafo**.') | |
| # ββ TAB 2: TREINAR ββββββββββββββββββββββββββββββββββββββββ | |
| with tabs[2]: | |
| _, _, TrainerDOMINANT = carregar_libs() | |
| if st.session_state.data is None: | |
| st.warning('β¬ οΈ Gere o grafo primeiro.') | |
| else: | |
| c1, c2 = st.columns([1, 2]) | |
| with c1: | |
| st.markdown(f""" | |
| **ParΓ’metros do modelo:** | |
| - Hidden: `{cfg["hidden"]}` Β· Embed: `{cfg["embed"]}` | |
| - Ξ±: `{cfg["alpha"]}` Β· LR: `{cfg["lr"]}` | |
| - Dropout: `{cfg["dropout"]}` Β· Γpocas: `{cfg["epocas"]}` | |
| **β οΈ Importante:** O modelo nΓ£o vΓͺ nenhum label durante o treino. | |
| A AUC Γ© calculada apenas para avaliaΓ§Γ£o do paper. | |
| """) | |
| if st.button('π Treinar DOMINANT', type='primary', use_container_width=True): | |
| st.session_state.trainer = TrainerDOMINANT( | |
| st.session_state.data, | |
| st.session_state.edge_weight, | |
| hidden_dim=cfg['hidden'], | |
| embed_dim=cfg['embed'], | |
| alpha=cfg['alpha'], | |
| lr=cfg['lr'], | |
| dropout=cfg['dropout'], | |
| ) | |
| prog = st.progress(0) | |
| status = st.empty() | |
| logs = [] | |
| log_box = c2.empty() | |
| def cb(ep, total, loss, auc): | |
| prog.progress(ep/total) | |
| status.markdown( | |
| f'**Γpoca {ep}/{total}** Β· Loss `{loss:.4f}` Β· AUC `{auc:.3f}`') | |
| if ep % 10 == 0 or ep == total: | |
| logs.append(f'[{ep:>3}] loss={loss:.4f} auc={auc:.3f}') | |
| log_box.code('\n'.join(logs[-15:])) | |
| with st.spinner('Treinando sem labels...'): | |
| st.session_state.trainer.treinar(cfg['epocas'], cb) | |
| st.session_state.treinado = True | |
| st.session_state.metricas = \ | |
| st.session_state.trainer.metricas_completas() | |
| st.success( | |
| f'β Melhor AUC: {st.session_state.trainer.melhor_auc:.4f}') | |
| with c2: | |
| if st.session_state.treinado: | |
| st.components.v1.html( | |
| loss_auc_svg(st.session_state.trainer.historico), height=200) | |
| # ββ TAB 3: PERFORMANCE ββββββββββββββββββββββββββββββββββββ | |
| with tabs[3]: | |
| if not st.session_state.treinado or st.session_state.metricas is None: | |
| st.warning('β¬ οΈ Treine o modelo primeiro.') | |
| else: | |
| m = st.session_state.metricas | |
| cols = st.columns(5) | |
| for col, (nome, val, cor) in zip(cols, [ | |
| ('ROC-AUC', f"{m['auc']:.4f}", '#f85149'), | |
| ('Avg Prec', f"{m['ap']:.4f}", '#d29922'), | |
| ('F1', f"{m['f1']:.4f}", '#3fb950'), | |
| ('Precision', f"{m['precision']:.4f}", '#58a6ff'), | |
| ('Recall', f"{m['recall']:.4f}", '#a78bfa'), | |
| ]): | |
| col.markdown( | |
| f'<div class="card" style="text-align:center">' | |
| f'<div class="metric-val" style="color:{cor}">{val}</div>' | |
| f'<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.components.v1.html( | |
| roc_svg(m['y_true'], m['scores']), height=230) | |
| st.components.v1.html( | |
| score_dist_svg(m['scores'], m['y_true'], m['thresh']), height=200) | |
| with c2: | |
| st.components.v1.html( | |
| scatter_erros_svg(m['err_attr'], m['err_struct'], m['y_true']), | |
| height=260) | |
| st.components.v1.html( | |
| tsne_svg(m['embeddings'], m['y_true']), height=310) | |
| # ββ TAB 4: TOP ANOMALIAS ββββββββββββββββββββββββββββββββββ | |
| with tabs[4]: | |
| if not st.session_state.treinado: | |
| st.warning('β¬ οΈ Treine o modelo primeiro.') | |
| else: | |
| st.markdown('### NΓ³s mais anΓ΄malos detectados') | |
| st.markdown('Rankeados por anomaly score β sem nenhum label no treino.') | |
| n_top = st.slider('Top N', 10, 50, 20) | |
| top = st.session_state.trainer.get_top_anomalias(n_top) | |
| feat_names = st.session_state.get('feat_names', [f'f{i}' for i in range(16)]) | |
| data_x = st.session_state.data.x | |
| n_detectados = sum(1 for t in top if t['label_real'] == 1) | |
| n_real_total = int(st.session_state.data.y.sum()) | |
| c1,c2,c3 = st.columns(3) | |
| c1.metric('Top anomalias analisadas', n_top) | |
| c2.metric('Anomalias reais detectadas', n_detectados) | |
| c3.metric(f'Precision@{n_top}', f'{n_detectados/n_top:.1%}') | |
| st.markdown('<br>', unsafe_allow_html=True) | |
| st.markdown( | |
| f'<div style="font-family:\'IBM Plex Mono\',monospace">' | |
| f'{top_anomalias_html(top, feat_names, data_x)}</div>', | |
| unsafe_allow_html=True) | |
| # Detalhe de um nΓ³ especΓfico | |
| with st.expander('π Inspecionar nΓ³ especΓfico'): | |
| idx_insp = st.number_input('Γndice do nΓ³', 0, | |
| int(data_x.shape[0])-1, int(top[0]['idx'])) | |
| m = st.session_state.metricas | |
| score_no = float(m['scores'][idx_insp]) | |
| label_no = int(st.session_state.data.y[idx_insp]) | |
| feats_no = data_x[idx_insp].numpy() | |
| st.markdown(f""" | |
| **NΓ³ {idx_insp}** Β· Score: `{score_no:.4f}` Β· | |
| Err Atributo: `{m['err_attr'][idx_insp]:.4f}` Β· | |
| Err Estrutura: `{m['err_struct'][idx_insp]:.4f}` Β· | |
| Label real: `{'β Anomalia' if label_no else 'β Normal'}` | |
| """) | |
| # Features do nΓ³ como barras | |
| bars_html = '<div style="font-family:\'IBM Plex Mono\',monospace;font-size:.78rem">' | |
| for fi, fn in enumerate(feat_names): | |
| v = float(feats_no[fi]) | |
| pct = min(abs(v)*100, 100) | |
| cor = '#f85149' if v > 0.7 else ('#d29922' if v > 0.4 else '#3fb950') | |
| bars_html += ( | |
| f'<div style="display:flex;align-items:center;gap:8px;margin:2px 0">' | |
| f'<span style="color:#8b949e;width:200px;font-size:.75rem">{fn}</span>' | |
| f'<div style="flex:1;background:#21262d;border-radius:3px;height:8px">' | |
| f'<div style="width:{pct:.0f}%;height:8px;background:{cor};border-radius:3px"></div></div>' | |
| f'<span style="color:{cor};width:45px;text-align:right">{v:.3f}</span>' | |
| f'</div>') | |
| bars_html += '</div>' | |
| st.markdown(bars_html, unsafe_allow_html=True) | |
| # ββ 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 anomalias no Neo4j'): | |
| driver, db = st.session_state.neo4j | |
| top = st.session_state.trainer.get_top_anomalias(50) | |
| m = st.session_state.metricas | |
| try: | |
| with driver.session(database=db) as s: | |
| s.run(""" | |
| MERGE (r:DOMINANTRun {ts: $ts}) | |
| SET r.auc=$auc, r.ap=$ap, r.alpha=$alpha, | |
| r.n_nos=$n, r.n_anomalias=$na | |
| """, ts=datetime.now().isoformat(), | |
| auc=float(m['auc']), ap=float(m['ap']), | |
| alpha=cfg['alpha'], | |
| n=int(st.session_state.data.x.shape[0]), | |
| na=int(st.session_state.data.y.sum())) | |
| for item in top[:20]: | |
| s.run(""" | |
| MERGE (n:AnomaliaNode {idx: $idx}) | |
| SET n.score=$score, n.label=$label | |
| """, idx=item['idx'], score=item['score'], | |
| label=item['label_real']) | |
| st.success(f'β Run + {min(20,len(top))} anomalias salvas!') | |
| except Exception as e: | |
| st.error(str(e)) | |
| if __name__ == '__main__': | |
| main() |