import streamlit as st
import joblib
import json
import numpy as np
import pandas as pd
import shap
import matplotlib
import matplotlib.pyplot as plt
matplotlib.use("Agg")
from openai import OpenAI
import os
# Page Config
st.set_page_config(
page_title="IDS - XAI Dashboard",
page_icon="π‘οΈ",
layout="wide",
initial_sidebar_state="expanded",
)
# CSS
st.markdown("""
""", unsafe_allow_html=True)
# Load Assets
@st.cache_resource
def load_all():
model = joblib.load("models/LightGBM.pkl")
le = joblib.load("models/label_encoder.pkl")
try:
explainer = joblib.load("models/shap_explainer.pkl")
except FileNotFoundError:
explainer = shap.TreeExplainer(model)
with open("data/feature_columns.json") as f:
features = json.load(f)
return model, le, explainer, features
model, le, explainer, features = load_all()
# Helpers
def severity(label):
high = {"DoS Hulk","DDoS","DoS GoldenEye","DoS slowloris","DoS Slowhttptest","Heartbleed"}
medium = {"Bot","PortScan","FTP-Patator","SSH-Patator","Web Attack \x96 Brute Force",
"Web Attack \x96 Sql Injection","Web Attack \x96 XSS","Infiltration"}
if label in high: return "HIGH", "#ef4444"
if label in medium: return "MEDIUM", "#f59e0b"
if label == "BENIGN": return "BENIGN", "#22c55e"
return "LOW", "#a78bfa"
def openai_explain(prediction, top_features, feature_values, confidence):
api_key = os.environ.get("OPENAI_API_KEY", "")
if not api_key:
return None, "β οΈ OPENAI_API_KEY belum diset di environment / repo secret."
feat_lines = "\n".join(f" - {f}" for f in top_features)
val_lines = "\n".join(
f" - {k}: {v:.4g}" for k, v in list(feature_values.items())[:15] if v != 0
)
prompt = f"""Kamu adalah sistem penjelasan otomatis untuk Intrusion Detection System berbasis machine learning.
Hasil deteksi model LightGBM pada traffic jaringan:
- Prediksi : {prediction}
- Confidence : {confidence:.2%}
Top 5 fitur SHAP yang paling berpengaruh:
{feat_lines}
Nilai fitur traffic (non-zero):
{val_lines}
Tulis penjelasan 3β4 kalimat dalam bahasa Indonesia yang profesional dan naratif (tanpa bullet points):
1. Mengapa model memutuskan "{prediction}"
2. Apa yang mencurigakan atau normal dari fitur-fitur tersebut β jelaskan maknanya secara konkret \
(misal: "Flow IAT Mean rendah berarti paket dikirim sangat cepat dan berdekatan")
3. Rekomendasi tindakan singkat yang perlu dilakukan jika ini adalah serangan
Maksimal 150 kata."""
try:
resp = OpenAI(api_key=api_key).chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Kamu adalah analis keamanan jaringan senior."},
{"role": "user", "content": prompt}
],
max_tokens=300, temperature=0.4,
)
return resp.choices[0].message.content, None
except Exception as e:
return None, f"OpenAI error: {e}"
def plot_shap_bar(shap_2d, feature_names, pred_class, top_n=10):
vals = shap_2d[:, pred_class]
pairs = sorted(zip(feature_names, vals), key=lambda x: abs(x[1]), reverse=True)[:top_n]
names, values = [p[0] for p in pairs], [p[1] for p in pairs]
colors = ["#ef4444" if v > 0 else "#22c55e" for v in values]
fig, ax = plt.subplots(figsize=(7, 3.8))
fig.patch.set_facecolor("#141414")
ax.set_facecolor("#141414")
ax.barh(names[::-1], values[::-1], color=colors[::-1], height=0.55)
ax.axvline(0, color="#2a2a2a", linewidth=1.2)
ax.set_xlabel("SHAP value", color="#6b7280", fontsize=8)
ax.tick_params(colors="#9ca3af", labelsize=7.5)
for sp in ax.spines.values(): sp.set_edgecolor("#1f1f1f")
ax.grid(axis="x", color="#1f1f1f", linewidth=0.6)
from matplotlib.patches import Patch
ax.legend(
handles=[Patch(color="#ef4444", label="β mengarah ke attack"),
Patch(color="#22c55e", label="β mengarah ke benign")],
fontsize=7, facecolor="#1c1c1c", edgecolor="#2a2a2a",
labelcolor="#9ca3af", loc="lower right"
)
plt.tight_layout(pad=0.5)
return fig
def section_label(text, color="#f59e0b"):
st.markdown(
f'
{text}
',
unsafe_allow_html=True
)
def sub_label(text):
st.markdown(
f'{text}
',
unsafe_allow_html=True
)
# Top SHAP features
TOP_SHAP_FEATURES = [
"Flow IAT Mean",
"Flow IAT Min",
"Bwd Packets/s",
"Fwd Header Length",
"Init_Win_bytes_forward",
]
CONTEXT_FEATURES = [
"Active Max",
"Init_Win_bytes_bckward",
"Destination Port",
"Fwd IAT Std",
"Flow Duration",
"min_seg_size_forward",
]
DISPLAY_FEATURES = TOP_SHAP_FEATURES + CONTEXT_FEATURES
# PRESETS
@st.cache_data
def load_presets():
try:
with open("data/preset_samples.json") as f:
raw = json.load(f)
return {
"Custom": {},
"Normal Traffic": raw.get("BENIGN", {}),
"DoS Hulk": raw.get("DoS Hulk", {}),
"PortScan": raw.get("PortScan", {}),
"Bot": raw.get("Bot", {}),
}
except FileNotFoundError:
return {"Custom": {}}
PRESETS = load_presets()
PRESET_NAMES = list(PRESETS.keys())
def sk(feat):
"""Safe session_state key for a feature."""
return "inp_" + feat.replace(" ", "_").replace("/", "_per_").replace("-", "_").replace(".", "_")
# SIDEBAR
with st.sidebar:
st.markdown("""
π‘οΈ IDS Β· XAI
INTRUSION DETECTION SYSTEM
""", unsafe_allow_html=True)
st.markdown(
'β‘ QUICK PRESET
',
unsafe_allow_html=True
)
preset = st.radio("", PRESET_NAMES, label_visibility="collapsed")
st.markdown("
", unsafe_allow_html=True)
st.markdown("""
MODEL INFO
Algorithm : LightGBM
Dataset : CIC-IDS-2017
Balancing : SMOTE
Classes : 15
Accuracy : 99.8%
Macro F1 : 0.905
Macro Prec : 0.878
Macro Rec : 0.953
XAI : SHAP
LLM : GPT-4o-mini
""", unsafe_allow_html=True)
# Inject preset
if "last_preset" not in st.session_state:
st.session_state["last_preset"] = preset
st.session_state["preset_full"] = PRESETS.get(preset, {})
if st.session_state["last_preset"] != preset:
p = PRESETS.get(preset, {})
for feat in DISPLAY_FEATURES:
st.session_state[sk(feat)] = float(p.get(feat, 0.0))
st.session_state["preset_full"] = p
st.session_state["last_preset"] = preset
# HEADER
st.markdown("""
Intrusion Detection System
EXPLAINABLE AI DASHBOARD Β· CIC-IDS-2017 Β· LIGHTGBM + SHAP + GPT-4o-mini
LightGBM
SHAP
GPT-4o-mini
""", unsafe_allow_html=True)
# BASIC INPUT
section_label("π‘ NETWORK TRAFFIC FEATURES")
sub_label("TOP SHAP FEATURES")
shap_cols = st.columns(len(TOP_SHAP_FEATURES))
display_vals = {}
for i, feat in enumerate(TOP_SHAP_FEATURES):
with shap_cols[i]:
display_vals[feat] = st.number_input(
feat, step=0.01, format="%.2f", key=sk(feat)
)
sub_label("TRAFFIC CONTEXT")
ctx_cols = st.columns(len(CONTEXT_FEATURES))
for i, feat in enumerate(CONTEXT_FEATURES):
with ctx_cols[i]:
display_vals[feat] = st.number_input(
feat, step=0.01, format="%.2f", key=sk(feat)
)
# ADVANCED INPUT
extra_features = [f for f in features if f not in DISPLAY_FEATURES]
st.markdown("
", unsafe_allow_html=True)
with st.expander("βοΈ Advanced Input", expanded=False):
st.markdown(
''
'Pilih fitur tambahan yang ingin kamu atur nilainya secara manual. '
'Jika preset aktif, nilai fitur lain sudah terisi otomatis dari dataset.'
'
',
unsafe_allow_html=True
)
selected_extra = st.multiselect(
"Pilih fitur tambahan:",
options=extra_features,
default=[],
placeholder="Cari nama fitur...",
key="adv_selected"
)
adv_override = {}
if selected_extra:
st.markdown("
", unsafe_allow_html=True)
adv_cols = st.columns(3)
for i, feat in enumerate(selected_extra):
safe_key = (
"adv_"
+ feat.replace(" ", "_").replace("/", "_per_")
.replace("-", "_").replace(".", "_")
)
preset_val = float(st.session_state.get("preset_full", {}).get(feat, 0.0))
with adv_cols[i % 3]:
adv_override[feat] = st.number_input(
feat, value=preset_val, step=0.01, format="%.4f", key=safe_key
)
else:
st.markdown(
''
'Belum ada fitur dipilih
',
unsafe_allow_html=True
)
# BUTTONS
st.markdown("
", unsafe_allow_html=True)
run = st.button("π Analyze Traffic", use_container_width=True)
# BUILD FEATURE VECTOR
def build_row():
preset_full = st.session_state.get("preset_full", {})
full = {f: float(preset_full.get(f, 0.0)) for f in features}
full.update(display_vals)
full.update(adv_override)
row = pd.DataFrame([full])[features]
display = {k: v for k, v in full.items() if v != 0.0}
return row, display
# RENDER RESULT
def render_result(row, display_map, mode_label=""):
pred_enc = model.predict(row)[0]
proba = model.predict_proba(row)[0]
conf = proba[pred_enc]
label = le.inverse_transform([pred_enc])[0]
sev, sev_color = severity(label)
is_attack = label != "BENIGN"
st.markdown(
"
",
unsafe_allow_html=True
)
badge = (
f''
f'{mode_label}'
) if mode_label else ""
section_label(f"π¬ ANALYSIS RESULT {badge}")
col_pred, col_shap = st.columns([1, 2], gap="large")
# Left column: classification card + probabilities
with col_pred:
icon = "π¨" if is_attack else "β
"
bg = "rgba(239,68,68,0.07)" if is_attack else "rgba(34,197,94,0.07)"
bord = "#ef4444" if is_attack else "#22c55e"
st.markdown(f"""
CLASSIFICATION
{icon} {label}
confidence: {conf:.2%}
β‘ {sev}
""", unsafe_allow_html=True)
sub_label("CLASS PROBABILITIES")
prob_df = (
pd.DataFrame({"Class": le.classes_, "Prob": proba})
.sort_values("Prob", ascending=False)
.head(5)
)
fig2, ax2 = plt.subplots(figsize=(4, 2.5))
fig2.patch.set_facecolor("#141414")
ax2.set_facecolor("#141414")
bar_c = ["#f59e0b" if c == label else "#242424" for c in prob_df["Class"]]
ax2.barh(prob_df["Class"][::-1], prob_df["Prob"][::-1], color=bar_c[::-1], height=0.5)
ax2.tick_params(colors="#9ca3af", labelsize=7)
for sp in ax2.spines.values(): sp.set_edgecolor("#1f1f1f")
ax2.grid(axis="x", color="#1f1f1f", linewidth=0.5)
ax2.set_xlim(0, 1)
fig2.tight_layout(pad=0.4)
st.pyplot(fig2, use_container_width=True)
plt.close(fig2)
# Right column: SHAP bar + top features
with col_shap:
sub_label("SHAP FEATURE IMPORTANCE")
with st.spinner("Computing SHAP valuesβ¦"):
sv = explainer.shap_values(row)
if isinstance(sv, np.ndarray) and sv.ndim == 3:
shap_2d = sv[0]
elif isinstance(sv, list):
shap_2d = np.array([s[0] for s in sv]).T
else:
shap_2d = sv
fig_shap = plot_shap_bar(shap_2d, features, pred_enc)
st.pyplot(fig_shap, use_container_width=True)
plt.close(fig_shap)
vals = shap_2d[:, pred_enc]
pairs = sorted(zip(features, vals), key=lambda x: abs(x[1]), reverse=True)[:5]
top_features_text = [
f"{f} ({'increase' if v > 0 else 'decrease'}, impact={v:.3f})"
for f, v in pairs
]
sub_label("TOP CONTRIBUTING FEATURES")
for tf in top_features_text:
dot = "π΄" if "increase" in tf else "π’"
st.markdown(
f'{dot} {tf}
',
unsafe_allow_html=True
)
# LLM explanation
st.markdown("
", unsafe_allow_html=True)
section_label("π€ LLM NATURAL LANGUAGE EXPLANATION")
with st.spinner("Generating explanation via GPT-4o-miniβ¦"):
explanation, err = openai_explain(label, top_features_text, display_map, conf)
if err:
st.warning(err)
else:
st.markdown(f"""
{explanation}
""", unsafe_allow_html=True)
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# TRIGGER
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
if run:
row, dmap = build_row()
is_adv = bool(adv_override)
render_result(row, dmap, mode_label="ADVANCED" if is_adv else "")