TIFNGK_E41222120/virtualEnvironment/app.py

1208 lines
53 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Dashboard Analisis Intoleransi Sound Horeg di Media X
Berfokus pada analisis mendalam kasus, bukan klasifikasi real-time.
Models: SVM, KNN, Ensemble (Soft Voting SVM+KNN, bobot 2:1)
Fitur: TF-IDF
"""
import streamlit as st
import pandas as pd
import numpy as np
import joblib
import plotly.express as px
import plotly.graph_objects as go
import os
from sklearn.model_selection import train_test_split
from sklearn.metrics import (
accuracy_score, f1_score, precision_recall_fscore_support,
confusion_matrix, classification_report
)
# ============================================================
# SETUP PATH
# ============================================================
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
MODELS_DIR = os.path.join(SCRIPT_DIR, "models")
OUTPUT_DIR = os.path.join(SCRIPT_DIR, "output")
# ============================================================
# KONFIGURASI HALAMAN
# ============================================================
st.set_page_config(
page_title="Analisis Wacana Sound Horeg di Media X",
page_icon="🔊",
layout="wide",
initial_sidebar_state="expanded",
)
# ============================================================
# KONSTANTA
# ============================================================
LABEL_MAP = {0: "Netral", 1: "Rasional Negatif", 2: "Cacimaki/Intoleransi"}
LABELS_ORDER = ["Netral", "Rasional Negatif", "Cacimaki/Intoleransi"]
COLOR_MAP = {
"Netral": "#6B7280", # abu-abu
"Rasional Negatif": "#F59E0B", # oranye
"Cacimaki/Intoleransi": "#DC2626", # merah
}
MODEL_COLORS = {
"SVM": "#2563EB",
"KNN": "#10B981",
"Ensemble": "#8B5CF6",
}
# ============================================================
# LOADER (CACHED)
# ============================================================
@st.cache_resource
def load_models():
svm = joblib.load(os.path.join(MODELS_DIR, "model_svm.pkl"))
knn = joblib.load(os.path.join(MODELS_DIR, "model_knn.pkl"))
ens = joblib.load(os.path.join(MODELS_DIR, "model_ensemble.pkl"))
vec = joblib.load(os.path.join(MODELS_DIR, "vectorizer_tfidf.pkl"))
return svm, knn, ens, vec
@st.cache_data
def load_dataset():
df = pd.read_csv(os.path.join(OUTPUT_DIR, "dataset.csv"), sep=";")
df = df.dropna(subset=["clean_text", "label"]).reset_index(drop=True)
df["label"] = df["label"].astype(int)
df["label_name"] = df["label"].map(LABEL_MAP)
return df
@st.cache_data
def load_kesalahan_svm():
return pd.read_csv(os.path.join(OUTPUT_DIR, "kesalahan_svm.csv"), sep=";")
@st.cache_data
def compute_predictions():
"""Reproduksi split skripsi: seed=42, test_size=0.2, stratify=label — identik dengan preprocessing1.py."""
df = load_dataset()
svm, knn, ens, vec = load_models()
X_tr, X_te, y_tr, y_te = train_test_split(
df["clean_text"], df["label"],
test_size=0.2, random_state=42, stratify=df["label"]
)
Xv = vec.transform(X_te)
pred_svm = svm.predict(Xv)
pred_knn = knn.predict(Xv)
pred_ens = ens.predict(Xv)
out = pd.DataFrame({
"clean_text": X_te.values,
"label_asli": y_te.values,
"pred_svm": pred_svm,
"pred_knn": pred_knn,
"pred_ens": pred_ens,
})
out["label_asli_name"] = out["label_asli"].map(LABEL_MAP)
out["pred_svm_name"] = out["pred_svm"].map(LABEL_MAP)
out["pred_knn_name"] = out["pred_knn"].map(LABEL_MAP)
out["pred_ens_name"] = out["pred_ens"].map(LABEL_MAP)
return out, X_tr, y_tr
@st.cache_data
def compute_feature_importance():
"""Turunkan importance per kelas dari coef_ SVM OvO.
Sesuai Tabel 4.20 di skripsi."""
svm, _, _, vec = load_models()
feat = vec.get_feature_names_out()
C = svm.coef_.toarray() if hasattr(svm.coef_, "toarray") else np.asarray(svm.coef_)
# SVC OvO: pasangan (0,1), (0,2), (1,2)
# Kelas 0 (Netral): sisi negatif di (0,1) dan (0,2)
imp0 = (-C[0] + -C[1]) / 2
# Kelas 1 (Rasional Negatif): sisi positif (0,1), sisi negatif (1,2)
imp1 = (C[0] + -C[2]) / 2
# Kelas 2 (Cacimaki/Intoleransi): sisi positif (0,2) dan (1,2)
imp2 = (C[1] + C[2]) / 2
return {
"Netral": pd.DataFrame({"kata": feat, "skor": imp0}).sort_values("skor", ascending=False).reset_index(drop=True),
"Rasional Negatif": pd.DataFrame({"kata": feat, "skor": imp1}).sort_values("skor", ascending=False).reset_index(drop=True),
"Cacimaki/Intoleransi": pd.DataFrame({"kata": feat, "skor": imp2}).sort_values("skor", ascending=False).reset_index(drop=True),
}
# ============================================================
# UTILITAS VISUALISASI
# ============================================================
def plot_confusion_matrix(y_true, y_pred, title=""):
cm = confusion_matrix(y_true, y_pred, labels=[0, 1, 2])
z_text = [[str(v) for v in row] for row in cm]
fig = go.Figure(data=go.Heatmap(
z=cm,
x=LABELS_ORDER,
y=LABELS_ORDER,
text=z_text,
texttemplate="%{text}",
textfont={"size": 18, "color": "white"},
colorscale="Blues",
showscale=True,
hovertemplate="Asli: %{y}<br>Prediksi: %{x}<br>Jumlah: %{z}<extra></extra>",
))
fig.update_layout(
title=title,
xaxis_title="Prediksi",
yaxis_title="Label Asli",
yaxis=dict(autorange="reversed"),
height=420,
margin=dict(l=20, r=20, t=60, b=20),
)
return fig
def get_metrics_table(y_true, preds_dict):
rows = []
for name, pred in preds_dict.items():
acc = accuracy_score(y_true, pred)
f1m = f1_score(y_true, pred, average="macro")
f1w = f1_score(y_true, pred, average="weighted")
rows.append({
"Model": name,
"Akurasi": f"{acc*100:.2f}%",
"Macro F1": f"{f1m:.2f}",
"Weighted F1": f"{f1w:.2f}",
})
return pd.DataFrame(rows)
def get_per_class_metrics(y_true, pred, model_name):
p, r, f, s = precision_recall_fscore_support(y_true, pred, labels=[0, 1, 2])
return pd.DataFrame({
"Model": [model_name]*3,
"Kelas": LABELS_ORDER,
"Precision": p.round(2),
"Recall": r.round(2),
"F1-score": f.round(2),
"Support": s,
})
# ============================================================
# SIDEBAR NAVIGASI
# ============================================================
st.sidebar.title("Sound Horeg Dashboard")
st.sidebar.caption("Analisis Intoleransi pada Media X")
st.sidebar.markdown("---")
PAGES = [
"Overview Dataset",
"Komparasi 3 Model",
"Detail Model: SVM",
"Detail Model: KNN",
"Detail Model: Ensemble",
"Analisis Kasus Sound Horeg",
"Eksplorasi Tweet Interaktif",
]
page = st.sidebar.radio("Navigasi", PAGES, label_visibility="collapsed")
st.sidebar.markdown("---")
st.sidebar.caption(
"Dashboard ini berfokus pada **analisis mendalam wacana** "
"fenomena *sound horeg* di media X. Akurasi klasifikasi bukan "
"tujuan utama — model digunakan sebagai instrumen analitis."
)
# ============================================================
# LOAD DATA SEKALI
# ============================================================
df_all = load_dataset()
preds_df, X_train_series, y_train_series = compute_predictions()
y_true = preds_df["label_asli"].values
preds_dict = {
"SVM": preds_df["pred_svm"].values,
"KNN": preds_df["pred_knn"].values,
"Ensemble": preds_df["pred_ens"].values,
}
# ============================================================
# HALAMAN 1: OVERVIEW DATASET
# ============================================================
if page == "Overview Dataset":
st.title("Overview Dataset")
st.caption("Distribusi data, statistik dasar, dan karakteristik korpus *tweet* tentang *sound horeg*.")
total = len(df_all)
n_train = len(X_train_series)
n_test = len(preds_df)
c1, c2, c3, c4 = st.columns(4)
c1.metric("Total Tweet", f"{total:,}")
c2.metric("Data Latih", f"{n_train:,}")
c3.metric("Data Uji", f"{n_test:,}")
c4.metric("Jumlah Kelas", "3")
st.markdown("### Distribusi Label")
col_a, col_b = st.columns([1, 1])
with col_a:
dist = df_all["label_name"].value_counts().reindex(LABELS_ORDER).reset_index()
dist.columns = ["Label", "Jumlah"]
dist["Persentase"] = (dist["Jumlah"] / dist["Jumlah"].sum() * 100).round(2)
fig = px.bar(
dist, x="Label", y="Jumlah", color="Label",
color_discrete_map=COLOR_MAP, text="Jumlah",
)
fig.update_traces(textposition="outside")
fig.update_layout(showlegend=False, height=400)
st.plotly_chart(fig, use_container_width=True)
with col_b:
fig2 = px.pie(
dist, names="Label", values="Jumlah", color="Label",
color_discrete_map=COLOR_MAP, hole=0.4,
)
fig2.update_layout(height=400)
st.plotly_chart(fig2, use_container_width=True)
st.dataframe(dist, use_container_width=True, hide_index=True)
st.markdown("### Distribusi Pada Data Uji (n=279)")
test_dist = preds_df["label_asli_name"].value_counts().reindex(LABELS_ORDER).reset_index()
test_dist.columns = ["Label", "Jumlah"]
test_dist["Persentase"] = (test_dist["Jumlah"] / test_dist["Jumlah"].sum() * 100).round(2)
st.dataframe(test_dist, use_container_width=True, hide_index=True)
st.caption(
"Distribusi yang relatif seimbang membuat akurasi cukup representatif "
"sebagai indikator performa (sesuai sub-bab 4.9.1 skripsi)."
)
st.markdown("### Statistik Panjang Tweet")
df_all["length"] = df_all["clean_text"].astype(str).str.split().str.len()
stat_panjang = df_all.groupby("label_name")["length"].agg(["mean", "median", "min", "max"]).round(1)
stat_panjang = stat_panjang.reindex(LABELS_ORDER).reset_index().rename(columns={
"label_name": "Label", "mean": "Rata-rata", "median": "Median", "min": "Min", "max": "Max"
})
st.dataframe(stat_panjang, use_container_width=True, hide_index=True)
fig_len = px.box(
df_all, x="label_name", y="length", color="label_name",
category_orders={"label_name": LABELS_ORDER},
color_discrete_map=COLOR_MAP, points=False,
labels={"length": "Jumlah kata per tweet", "label_name": "Label"}
)
fig_len.update_layout(showlegend=False, height=380)
st.plotly_chart(fig_len, use_container_width=True)
with st.expander("📄 Contoh Data Mentah"):
st.dataframe(
df_all[["raw_text", "clean_text", "label_name"]].head(15),
use_container_width=True, hide_index=True
)
# ============================================================
# HALAMAN 2: KOMPARASI 3 MODEL
# ============================================================
elif page == "Komparasi 3 Model":
st.title("Komparasi 3 Model")
st.caption("Perbandingan performa SVM, KNN, dan Ensemble (Soft Voting SVM+KNN dengan bobot 2:1).")
# Metrik utama
st.markdown("### Tabel 4.16 — Ringkasan Performa")
metrics_df = get_metrics_table(y_true, preds_dict)
st.dataframe(metrics_df, use_container_width=True, hide_index=True)
# Bar chart akurasi
col_a, col_b = st.columns([1, 1])
with col_a:
acc_df = pd.DataFrame({
"Model": list(preds_dict.keys()),
"Akurasi (%)": [accuracy_score(y_true, p)*100 for p in preds_dict.values()],
})
fig = px.bar(
acc_df, x="Model", y="Akurasi (%)", color="Model",
color_discrete_map=MODEL_COLORS, text="Akurasi (%)",
)
fig.update_traces(texttemplate="%{text:.2f}%", textposition="outside")
fig.update_layout(showlegend=False, height=400, title="Akurasi Per Model")
st.plotly_chart(fig, use_container_width=True)
with col_b:
f1_rows = []
for n, p in preds_dict.items():
f1_rows.append({"Model": n, "Tipe F1": "Macro F1", "Nilai": f1_score(y_true, p, average="macro")})
f1_rows.append({"Model": n, "Tipe F1": "Weighted F1", "Nilai": f1_score(y_true, p, average="weighted")})
f1_df = pd.DataFrame(f1_rows)
fig = px.bar(
f1_df, x="Model", y="Nilai", color="Tipe F1", barmode="group", text="Nilai",
)
fig.update_traces(texttemplate="%{text:.2f}", textposition="outside")
fig.update_layout(height=400, title="Macro F1 vs Weighted F1")
st.plotly_chart(fig, use_container_width=True)
# Per kelas
st.markdown("### Tabel 4.17 — Performa Per Kelas")
per_class_all = pd.concat([
get_per_class_metrics(y_true, preds_dict["SVM"], "SVM"),
get_per_class_metrics(y_true, preds_dict["KNN"], "KNN"),
get_per_class_metrics(y_true, preds_dict["Ensemble"], "Ensemble"),
], ignore_index=True)
st.dataframe(per_class_all, use_container_width=True, hide_index=True)
metric_choice = st.selectbox(
"Pilih metrik untuk dibandingkan visual",
["Precision", "Recall", "F1-score"],
index=1
)
fig = px.bar(
per_class_all, x="Kelas", y=metric_choice, color="Model",
barmode="group", color_discrete_map=MODEL_COLORS, text=metric_choice,
category_orders={"Kelas": LABELS_ORDER}
)
fig.update_traces(texttemplate="%{text:.2f}", textposition="outside")
fig.update_layout(height=440)
st.plotly_chart(fig, use_container_width=True)
# 3 confusion matrix berdampingan
st.markdown("### Confusion Matrix Ketiga Model")
cols = st.columns(3)
for col, (name, pred) in zip(cols, preds_dict.items()):
with col:
st.plotly_chart(plot_confusion_matrix(y_true, pred, title=name), use_container_width=True)
# Insight box
st.markdown("### 💡 Insight Komparatif")
_ins_acc_svm = accuracy_score(y_true, preds_dict["SVM"]) * 100
_ins_acc_knn = accuracy_score(y_true, preds_dict["KNN"]) * 100
_ins_acc_ens = accuracy_score(y_true, preds_dict["Ensemble"]) * 100
_ins_selisih_svm_knn = _ins_acc_svm - _ins_acc_knn
_, _ins_r_svm, _, _ = precision_recall_fscore_support(y_true, preds_dict["SVM"], labels=[0, 1, 2])
_, _ins_r_knn, _, _ = precision_recall_fscore_support(y_true, preds_dict["KNN"], labels=[0, 1, 2])
_, _ins_r_ens, _, _ = precision_recall_fscore_support(y_true, preds_dict["Ensemble"], labels=[0, 1, 2])
_ins_worst_svm = LABELS_ORDER[int(np.argmin(_ins_r_svm))]
_ins_worst_knn = LABELS_ORDER[int(np.argmin(_ins_r_knn))]
_ins_worst_ens = LABELS_ORDER[int(np.argmin(_ins_r_ens))]
_ins_recall_worst_svm = float(_ins_r_svm[int(np.argmin(_ins_r_svm))])
_ins_recall_worst_knn = float(_ins_r_knn[int(np.argmin(_ins_r_knn))])
_ins_recall_worst_ens = float(_ins_r_ens[int(np.argmin(_ins_r_ens))])
if _ins_acc_ens < _ins_acc_svm:
_ins_judul = "Mengapa Ensemble justru lebih rendah dari SVM tunggal?"
_ins_keterangan = (
f"Temuan ini menegaskan bahwa pendekatan Ensemble **tidak selalu menghasilkan "
f"peningkatan performa**, terutama ketika model komponen memiliki disparitas "
f"akurasi yang besar (SVM {_ins_acc_svm:.2f}% vs KNN {_ins_acc_knn:.2f}% — "
f"selisih {_ins_selisih_svm_knn:.2f}%)."
)
else:
_ins_judul = "Ensemble vs SVM: Keunggulan Tipis dengan KNN sebagai Penentu"
_ins_keterangan = (
f"Ensemble ({_ins_acc_ens:.2f}%) sedikit melampaui SVM ({_ins_acc_svm:.2f}%) "
f"dengan selisih {abs(_ins_acc_ens - _ins_acc_svm):.2f}%. Namun disparitas "
f"antara SVM dan KNN tetap besar (selisih {_ins_selisih_svm_knn:.2f}%), "
f"sehingga KNN masih membebani performa Ensemble secara keseluruhan."
)
st.info(
f"""
**{_ins_judul}**
Ensemble menggabungkan SVM dan KNN melalui *soft voting* dengan bobot 2:1
(lebih berat ke SVM). Walaupun bobot SVM sudah dua kali lipat KNN, probabilitas
KNN yang kurang akurat tetap berkontribusi pada keputusan akhir Ensemble.
{_ins_keterangan}
Kelas yang paling sulit dideteksi per model: SVM → **{_ins_worst_svm}** (recall {_ins_recall_worst_svm:.2f}),
KNN → **{_ins_worst_knn}** (recall {_ins_recall_worst_knn:.2f}),
Ensemble → **{_ins_worst_ens}** (recall {_ins_recall_worst_ens:.2f}).
"""
)
# ============================================================
# HALAMAN 3: DETAIL SVM
# ============================================================
elif page == "Detail Model: SVM":
st.title("Detail Model: SVM")
st.caption("Support Vector Machine — kernel linear, C=1. Model dengan performa terbaik pada penelitian ini.")
acc = accuracy_score(y_true, preds_dict["SVM"])
f1m = f1_score(y_true, preds_dict["SVM"], average="macro")
f1w = f1_score(y_true, preds_dict["SVM"], average="weighted")
c1, c2, c3, c4 = st.columns(4)
c1.metric("Akurasi", f"{acc*100:.2f}%")
c2.metric("Macro F1", f"{f1m:.2f}")
c3.metric("Weighted F1", f"{f1w:.2f}")
c4.metric("Total Salah Klasifikasi",
int((preds_df["label_asli"] != preds_df["pred_svm"]).sum()))
st.markdown("---")
st.markdown("### Confusion Matrix (Gambar 4.6)")
_cm_svm = confusion_matrix(y_true, preds_dict["SVM"], labels=[0, 1, 2])
_svm_d = (_cm_svm[0, 0], _cm_svm[1, 1], _cm_svm[2, 2])
_svm_caci_netral = int(_cm_svm[2, 0])
_svm_rn_caci = int(_cm_svm[1, 2])
_svm_caci_rn = int(_cm_svm[2, 1])
col_a, col_b = st.columns([1, 1])
with col_a:
st.plotly_chart(plot_confusion_matrix(y_true, preds_dict["SVM"], title="SVM"), use_container_width=True)
with col_b:
st.dataframe(get_per_class_metrics(y_true, preds_dict["SVM"], "SVM"),
use_container_width=True, hide_index=True)
st.markdown(f"""
**Bacaan singkat:**
- Diagonal utama ({_svm_d[0]}, {_svm_d[1]}, {_svm_d[2]}) = prediksi benar
- Pola kesalahan terbesar: **{_svm_caci_netral} tweet Cacimaki/Intoleransi diprediksi sebagai Netral**
- Kesalahan antara Rasional Negatif ↔ Cacimaki tergolong kecil ({_svm_rn_caci} & {_svm_caci_rn} kasus) — artinya
ketika SVM mengenali sinyal negatif, ia cukup baik membedakan jenisnya.
""")
# Pola kesalahan
st.markdown("---")
st.markdown("### Pola Kesalahan Klasifikasi (Tabel 4.18)")
wrong = preds_df[preds_df["label_asli"] != preds_df["pred_svm"]].copy()
wrong["pola"] = wrong["label_asli_name"] + "" + wrong["pred_svm_name"]
pola_counts = wrong["pola"].value_counts().reset_index()
pola_counts.columns = ["Pola kesalahan (label asli → prediksi)", "Jumlah"]
pola_counts["Persentase"] = (pola_counts["Jumlah"] / pola_counts["Jumlah"].sum() * 100).round(2)
st.dataframe(pola_counts, use_container_width=True, hide_index=True)
fig = px.bar(
pola_counts, x="Pola kesalahan (label asli → prediksi)", y="Jumlah",
text="Jumlah",
color="Persentase", color_continuous_scale="Reds",
)
fig.update_traces(textposition="outside")
fig.update_layout(height=460, xaxis_tickangle=-25)
st.plotly_chart(fig, use_container_width=True)
# Highlight 53,33%
neg_to_neutral = wrong[
(wrong["label_asli"].isin([1, 2])) & (wrong["pred_svm"] == 0)
].shape[0]
total_wrong = len(wrong)
pct = neg_to_neutral / total_wrong * 100
st.error(
f"**Temuan kunci:** {neg_to_neutral} dari {total_wrong} kesalahan "
f"({pct:.2f}%) adalah tweet bersentimen negatif (Rasional Negatif atau "
f"Cacimaki/Intoleransi) yang **gagal dideteksi dan justru tergolong "
f"ke kelas Netral**. Batas leksikal antara wacana negatif dan netral "
f"pada fenomena *sound horeg* memang tidak selalu tegas."
)
# Daftar lengkap kesalahan SVM dari CSV
st.markdown("---")
st.markdown("### 📋 Daftar Tweet Salah Klasifikasi SVM")
st.caption("Seluruh kasus kesalahan klasifikasi SVM — dapat difilter per pola untuk analisis kualitatif.")
df_kesalahan = load_kesalahan_svm()
pola_unik = ["Semua"] + sorted(df_kesalahan["pola_kesalahan"].unique().tolist())
col_fil1, col_fil2 = st.columns([1, 3])
with col_fil1:
pola_pilih = st.selectbox("Filter pola kesalahan", pola_unik, key="svm_kes_filter")
if pola_pilih != "Semua":
df_kes_view = df_kesalahan[df_kesalahan["pola_kesalahan"] == pola_pilih].reset_index(drop=True)
else:
df_kes_view = df_kesalahan.copy()
st.caption(f"Menampilkan **{len(df_kes_view)}** dari {len(df_kesalahan)} kasus kesalahan SVM.")
st.dataframe(
df_kes_view.rename(columns={
"clean_text": "Tweet (setelah preprocessing)",
"label_asli": "Label Asli",
"label_prediksi": "Prediksi SVM",
"pola_kesalahan": "Pola Kesalahan",
}),
use_container_width=True,
hide_index=True,
height=460,
)
csv_kes = df_kes_view.to_csv(index=False, sep=";").encode("utf-8")
st.download_button(
"💾 Download tabel kesalahan (CSV)",
data=csv_kes,
file_name="kesalahan_svm_filtered.csv",
mime="text/csv",
key="dl_kes_svm",
)
# 4 karakteristik
st.markdown("---")
st.markdown("### Empat Karakteristik Penyebab Kesulitan Klasifikasi")
st.caption("Analisis kualitatif dari sub-bab 4.9.1 skripsi.")
karakteristik = [
("1⃣ Kritik rasional yang disisipi kata kasar",
"Kritik valid tetapi memuat kata seperti *anjing*, *anjir* sebagai ekspresi spontan. "
"Kata-kata utama yang membawa muatan keluhan tidak terasa cukup negatif secara leksikal, "
"sementara kata kasar tersebut lebih sering muncul pada kelas cacimaki — sehingga sinyalnya membingungkan. "
"Model memilih kelas Netral sebagai keputusan *aman*."),
("2⃣ Cacimaki dengan struktur kalimat panjang dan asosiatif",
"Cacimaki disampaikan lewat rangkaian asosiasi (tokoh politik, ijazah, kutukan). "
"Bobot fitur kata cacimaki tersebar di antara kata netral lain, sehingga model menyimpulkan tweet sebagai netral."),
("3⃣ Cacimaki yang dikemas dalam idiom atau metafora",
"Misal *otak kebanyakan denger sound horeg makanya gapunya pikir* (metafora dehumanisasi) atau "
"doa kutukan *moga2 cpt laknat tuhan*. Tanpa makian eksplisit, model gagal mengenali muatan intoleransi."),
("4⃣ Tweet sangat pendek dengan konteks ambigu",
"Kasus ekstrem seperti *punya sound horeg* — bahkan manusia sulit menentukan sentimennya tanpa konteks. "
"Model justru memprediksi Rasional Negatif karena bobot kata *sound horeg* condong ke kelas negatif."),
]
for title, desc in karakteristik:
with st.expander(title, expanded=False):
st.write(desc)
# Kata kunci diskriminatif
st.markdown("---")
st.markdown("### Kata Kunci Diskriminatif (Tabel 4.20)")
st.caption(
"Top-20 kata dengan koefisien tertinggi dari SVM kernel linear, "
"merepresentasikan *penanda leksikal* yang paling kuat membedakan tiap kelas."
)
fi = compute_feature_importance()
n_top = st.slider("Jumlah kata teratas", 5, 30, 20, key="fi_slider")
tabs = st.tabs(LABELS_ORDER)
for tab, label in zip(tabs, LABELS_ORDER):
with tab:
top_df = fi[label].head(n_top).copy()
top_df["rank"] = range(1, len(top_df) + 1)
fig = px.bar(
top_df.sort_values("skor"),
x="skor", y="kata", orientation="h",
color="skor", color_continuous_scale="Viridis",
title=f"Top-{n_top} kata diskriminatif — {label}",
height=max(380, n_top * 22),
)
fig.update_layout(showlegend=False, coloraxis_showscale=False)
st.plotly_chart(fig, use_container_width=True)
st.markdown("### 💡 Interpretasi Kata Kunci")
st.info(
"""
- **Netral**: didominasi kata bermuatan negatif (*anjir*, *bakar*, *bodoh*, *gila*, *mati*).
Kata kasar pada media sosial Indonesia tidak selalu menunjukkan intoleransi — sering kali
sekadar ekspresi spontan. Kata seperti *kaca*, *kuping*, *rumah*, *jawa*, *budaya*
menunjukkan **dimensi fisik** dan **dimensi budaya** hadir di seluruh wacana.
- **Rasional Negatif**: muncul *neng* (hasil *stemming* dari *seneng*) dan *cinta* sebagai
penanda — pola **sarkasme dan ironi** yang khas pada kritik di media X
(*"seneng banget ya warga sini sama sound horeg"*).
- **Cacimaki/Intoleransi**: kata *prabowo* dan *karnaval* mengindikasikan **dimensi politik**
pada wacana intoleransi. Bukan berarti tokoh tersebut intoleran, melainkan menunjukkan
bahwa cacimaki *sound horeg* sering disertai konteks politik tertentu.
"""
)
# ============================================================
# HALAMAN 4: DETAIL KNN
# ============================================================
elif page == "Detail Model: KNN":
st.title("Detail Model: KNN")
st.caption("K-Nearest Neighbors — n_neighbors=13, metric=cosine, weights=uniform.")
acc = accuracy_score(y_true, preds_dict["KNN"])
f1m = f1_score(y_true, preds_dict["KNN"], average="macro")
f1w = f1_score(y_true, preds_dict["KNN"], average="weighted")
c1, c2, c3, c4 = st.columns(4)
c1.metric("Akurasi", f"{acc*100:.2f}%")
c2.metric("Macro F1", f"{f1m:.2f}")
c3.metric("Weighted F1", f"{f1w:.2f}")
c4.metric("Total Salah Klasifikasi",
int((preds_df["label_asli"] != preds_df["pred_knn"]).sum()))
st.markdown("---")
st.markdown("### Confusion Matrix (Gambar 4.7)")
_cm_knn = confusion_matrix(y_true, preds_dict["KNN"], labels=[0, 1, 2])
_knn_d = (_cm_knn[0, 0], _cm_knn[1, 1], _cm_knn[2, 2])
_knn_rn_netral = int(_cm_knn[1, 0])
_knn_caci_netral = int(_cm_knn[2, 0])
_knn_non_netral_netral = _knn_rn_netral + _knn_caci_netral
col_a, col_b = st.columns([1, 1])
with col_a:
st.plotly_chart(plot_confusion_matrix(y_true, preds_dict["KNN"], title="KNN"), use_container_width=True)
with col_b:
st.dataframe(get_per_class_metrics(y_true, preds_dict["KNN"], "KNN"),
use_container_width=True, hide_index=True)
st.markdown(f"""
**Bacaan singkat:**
- Diagonal utama ({_knn_d[0]}, {_knn_d[1]}, {_knn_d[2]}) — semua lebih rendah dari SVM
- KNN memiliki kesalahan lebih besar di hampir semua sel dibanding SVM
- Total **{_knn_non_netral_netral} tweet non-netral diprediksi sebagai netral** ({_knn_rn_netral} Rasional Negatif + {_knn_caci_netral} Cacimaki/Intoleransi)
""")
# Pola kesalahan KNN
st.markdown("---")
st.markdown("### Pola Kesalahan KNN")
wrong = preds_df[preds_df["label_asli"] != preds_df["pred_knn"]].copy()
wrong["pola"] = wrong["label_asli_name"] + "" + wrong["pred_knn_name"]
pola_counts = wrong["pola"].value_counts().reset_index()
pola_counts.columns = ["Pola kesalahan (label asli → prediksi)", "Jumlah"]
pola_counts["Persentase"] = (pola_counts["Jumlah"] / pola_counts["Jumlah"].sum() * 100).round(2)
st.dataframe(pola_counts, use_container_width=True, hide_index=True)
fig = px.bar(
pola_counts, x="Pola kesalahan (label asli → prediksi)", y="Jumlah",
text="Jumlah",
color="Persentase", color_continuous_scale="Greens",
)
fig.update_traces(textposition="outside")
fig.update_layout(height=460, xaxis_tickangle=-25)
st.plotly_chart(fig, use_container_width=True)
st.markdown("---")
st.markdown("### 💡 Mengapa KNN 'Menarik' Prediksi ke Kelas Mayoritas?")
st.warning(
f"""
KNN bekerja dengan mengukur kedekatan jarak antar dokumen menggunakan **cosine similarity**.
Karakteristik kerjanya menyebabkan beberapa kesulitan pada data TF-IDF yang berdimensi tinggi:
1. **Data teks bersifat *sparse*** — banyak fitur bernilai nol, sehingga pengukuran jarak
antar dokumen pendek menjadi kurang stabil. Dua tweet yang berbeda kelas dapat memiliki
*cosine similarity* tinggi hanya karena berbagi beberapa kata umum.
2. **Tidak ada batas keputusan eksplisit** — KNN tidak membangun *hyperplane* seperti SVM;
prediksinya bergantung penuh pada kemiripan permukaan antar tweet.
3. **Tarikan ke kelas mayoritas** — ketika tetangga terdekat berasal dari berbagai kelas,
KNN cenderung memilih kelas paling sering muncul (Netral di sini), terutama pada
tweet pendek yang minim fitur khas.
Inilah mengapa **{_knn_non_netral_netral} tweet non-netral salah diprediksi sebagai netral** —
pola "tarikan ke netral" lebih kuat pada KNN dibanding SVM.
"""
)
# Perbandingan kasus KNN vs SVM
st.markdown("---")
st.markdown("### Kasus Dimana KNN Salah tapi SVM Benar")
knn_wrong_svm_right = preds_df[
(preds_df["pred_knn"] != preds_df["label_asli"]) &
(preds_df["pred_svm"] == preds_df["label_asli"])
][["clean_text", "label_asli_name", "pred_svm_name", "pred_knn_name"]].rename(columns={
"clean_text": "Tweet",
"label_asli_name": "Label Asli",
"pred_svm_name": "Prediksi SVM",
"pred_knn_name": "Prediksi KNN",
})
st.caption(f"Ditemukan {len(knn_wrong_svm_right)} kasus di mana KNN gagal tetapi SVM benar.")
st.dataframe(knn_wrong_svm_right, use_container_width=True, hide_index=True, height=400)
# ============================================================
# HALAMAN 5: DETAIL ENSEMBLE
# ============================================================
elif page == "Detail Model: Ensemble":
st.title("Detail Model: Ensemble")
st.caption("Soft Voting Classifier — kombinasi SVM dan KNN dengan bobot 2:1.")
acc = accuracy_score(y_true, preds_dict["Ensemble"])
f1m = f1_score(y_true, preds_dict["Ensemble"], average="macro")
f1w = f1_score(y_true, preds_dict["Ensemble"], average="weighted")
c1, c2, c3, c4 = st.columns(4)
c1.metric("Akurasi", f"{acc*100:.2f}%")
c2.metric("Macro F1", f"{f1m:.2f}")
c3.metric("Weighted F1", f"{f1w:.2f}")
c4.metric("Total Salah Klasifikasi",
int((preds_df["label_asli"] != preds_df["pred_ens"]).sum()))
st.markdown("---")
st.markdown("### Confusion Matrix (Gambar 4.8)")
_cm_ens = confusion_matrix(y_true, preds_dict["Ensemble"], labels=[0, 1, 2])
_ens_d = (_cm_ens[0, 0], _cm_ens[1, 1], _cm_ens[2, 2])
_ens_rn_netral = int(_cm_ens[1, 0])
_ens_caci_netral = int(_cm_ens[2, 0])
col_a, col_b = st.columns([1, 1])
with col_a:
st.plotly_chart(plot_confusion_matrix(y_true, preds_dict["Ensemble"], title="Ensemble"), use_container_width=True)
with col_b:
st.dataframe(get_per_class_metrics(y_true, preds_dict["Ensemble"], "Ensemble"),
use_container_width=True, hide_index=True)
st.markdown(f"""
**Bacaan singkat:**
- Diagonal utama ({_ens_d[0]}, {_ens_d[1]}, {_ens_d[2]}) — lebih baik dari KNN tetapi tidak lebih baik dari SVM tunggal
- {_ens_rn_netral} kasus Rasional Negatif dan {_ens_caci_netral} kasus Cacimaki/Intoleransi diprediksi sebagai Netral
- Pengaruh prediksi KNN terlihat dari pola kesalahan yang sedikit lebih banyak dibanding SVM
""")
# Pola kesalahan Ensemble
st.markdown("---")
st.markdown("### Pola Kesalahan Ensemble")
wrong = preds_df[preds_df["label_asli"] != preds_df["pred_ens"]].copy()
wrong["pola"] = wrong["label_asli_name"] + "" + wrong["pred_ens_name"]
pola_counts = wrong["pola"].value_counts().reset_index()
pola_counts.columns = ["Pola kesalahan (label asli → prediksi)", "Jumlah"]
pola_counts["Persentase"] = (pola_counts["Jumlah"] / pola_counts["Jumlah"].sum() * 100).round(2)
st.dataframe(pola_counts, use_container_width=True, hide_index=True)
fig = px.bar(
pola_counts, x="Pola kesalahan (label asli → prediksi)", y="Jumlah",
text="Jumlah",
color="Persentase", color_continuous_scale="Purples",
)
fig.update_traces(textposition="outside")
fig.update_layout(height=460, xaxis_tickangle=-25)
st.plotly_chart(fig, use_container_width=True)
# Efek soft voting 2:1
st.markdown("---")
_ens_acc_e = accuracy_score(y_true, preds_dict["Ensemble"]) * 100
_ens_acc_s = accuracy_score(y_true, preds_dict["SVM"]) * 100
_ens_judul_voting = (
"Efek Soft Voting 2:1 — Mengapa Ensemble Lebih Rendah dari SVM?"
if _ens_acc_e < _ens_acc_s
else "Efek Soft Voting 2:1 — Ensemble vs SVM: Analisis Keputusan"
)
st.markdown(f"### {_ens_judul_voting}")
# Hitung agreement
agree_all = ((preds_df["pred_svm"] == preds_df["pred_knn"]) &
(preds_df["pred_svm"] == preds_df["pred_ens"])).sum()
svm_eq_ens = (preds_df["pred_svm"] == preds_df["pred_ens"]).sum()
knn_eq_ens = (preds_df["pred_knn"] == preds_df["pred_ens"]).sum()
svm_knn_disagree = (preds_df["pred_svm"] != preds_df["pred_knn"]).sum()
c1, c2, c3 = st.columns(3)
c1.metric("SVM ≠ KNN", svm_knn_disagree,
help="Jumlah kasus di mana SVM dan KNN tidak sepakat. Di sinilah voting menentukan.")
c2.metric("Ensemble = SVM", svm_eq_ens,
help="Berapa kali Ensemble mengikuti SVM.")
c3.metric("Ensemble = KNN", knn_eq_ens,
help="Berapa kali Ensemble mengikuti KNN.")
# Kasus dimana SVM benar tapi Ensemble salah (karena KNN menarik)
svm_right_ens_wrong = preds_df[
(preds_df["pred_svm"] == preds_df["label_asli"]) &
(preds_df["pred_ens"] != preds_df["label_asli"])
]
st.error(
f"**{len(svm_right_ens_wrong)} kasus** di mana SVM sudah benar, tetapi probabilitas "
f"KNN yang kurang akurat menarik keputusan Ensemble menjadi salah. Inilah konsekuensi "
f"langsung dari disparitas akurasi yang besar antar model komponen — bobot 2:1 belum "
f"cukup untuk meredam pengaruh KNN sepenuhnya."
)
with st.expander(f"📄 Lihat {len(svm_right_ens_wrong)} kasus SVM benar tapi Ensemble salah"):
st.dataframe(
svm_right_ens_wrong[["clean_text", "label_asli_name", "pred_svm_name", "pred_knn_name", "pred_ens_name"]].rename(columns={
"clean_text": "Tweet",
"label_asli_name": "Label Asli",
"pred_svm_name": "SVM",
"pred_knn_name": "KNN",
"pred_ens_name": "Ensemble",
}),
use_container_width=True, hide_index=True, height=400
)
# ============================================================
# HALAMAN 6: ANALISIS KASUS SOUND HOREG (BAB 4.10)
# ============================================================
elif page == "Analisis Kasus Sound Horeg":
st.title("Analisis Kasus Sound Horeg")
st.caption("Telaah mendalam fenomena *sound horeg* melalui tiga sudut pandang yang saling berhubungan (Bab 4.10).")
tab1, tab2, tab3, tab4 = st.tabs([
"📜 Tipologi Wacana (4.10.1)",
"🔊 Dimensi Fisik & Kesehatan (4.10.2)",
"🎭 Dimensi Budaya & Tradisi (4.10.3)",
"🧩 Sintesis (4.10.4)",
])
# ---- TAB 1: Tipologi ----
with tab1:
st.markdown("### Tipologi Wacana Sound Horeg di Media X")
st.write(
"Ujaran negatif terhadap *sound horeg* di media X memiliki **sasaran yang beragam** "
"(fenomena, pelaku, kelompok, suku, individu) dan **bentuk penyampaian yang tidak selalu eksplisit**. "
"Sebagian besar muncul sebagai sindiran halus, pertanyaan, atau candaan bermuatan negatif — "
"sehingga batas antara *rasional negatif* dan *cacimaki/intoleransi* menjadi tipis."
)
st.markdown("#### Tabel 4.21 — Contoh Ujaran Representasi Wacana Negatif")
tipologi_data = [
(1, "@ramaArsy1 HAPUS SOUND HOREG!!! MERESAHKAN GAK GUNAAA CUMA NGERUSAK DAN GANGGU ORANGGGG!!!!!!! btw yg ga terima sini maju lu anj.", "Rasional negatif penolakan eksplisit"),
(2, "TOLOL BEGO ANJING BINATANG HARAM GADA OTAK YANG NYIPTAIN SOUND HOREG BABI.", "Cacimaki/intoleransi serangan terhadap individu"),
(3, "@V3g3L Kebanggaan apa yg didapat dari pemilik sound horeg merusak rumah dan fasilitas umum ?.", "Rasional negatif dampak dan pertanyaan"),
(4, "Sound horeg. Kerasukan kuda lumping. Mulyono. Jawa destruktif.", "Cacimaki/intoleransi serangan individu dan kelompok"),
(5, "Toa adzan disebut terlalu keras tapi sound horeg dimaklumi wkwkwkw agak lain si amang.", "Negatif rasional berupa candaan yang fakta"),
(6, "@ramaArsy1 Norak bener moga2 cpt dilaknat Tuhan yg masang sound horeg.", "Cacimaki/intoleransi ujaran halus/sarkas berbentuk doa"),
(7, "polusi suara anj gw nggk tau apa gunanya sound horeg ini selain bikin kuping budeg.", "Rasional negatif berupa dampak fisik"),
(8, "sudah saatnya mengapresiasi sound horeg semoga semakin menggelegar sampek bisa bikin rubuh rumah para penikmat dan pemilik sound horeg tsb.", "Rasional negatif ujaran halus/sarkas positif"),
(9, "ntar kalo meninggoy kuburannya diisi sound horeg dipepet ke kuping sampe kupingnya pecah.", "Cacimaki/intoleransi berupa candaan kematian"),
(10, "@vanc1Bozz @ramaArsy1 Berarti kalau ada yang lempar molly ke sound horeg netizen tambah seneng.", "Cacimaki/intoleransi berbentuk sarkas candaan ancaman"),
(11, "@ramaArsy1 Semakin zalim ke sesama pecinta sound horeg semakin senang (pridavan sektor timur).", "Cacimaki/intoleransi sarkas membawa nama kelompok"),
(12, "@CutSarina5 Sound horeg ini adalah budaya jatim..jangan sampai diredam bahkan diklaim negara tetangga..harus dijaga dilestarikan..paham ya.", "Cacimaki/intoleransi sarkas membawa nama daerah dan budaya"),
(13, "@yoyouzhii Serame sound horeg. Nt pasti sdh pada dijajah sound horeg yang kesenian pada hilang.", "Rasional negatif berbentuk pernyataan tentang kesenian"),
(14, "@anonjawa Ga semua orang jawa suka sama sound horeg ya. Gausa bawa2 suku.", "Rasional negatif membawa nama kelompok/daerah"),
(15, "@KakMugi ini baru enak diliat wkwkkw banyak2in lah bakar2 sound Horeg biar punah.", "Cacimaki/intoleransi sarkas candaan halus ancaman kelompok"),
(16, "Baguslah terbakar sekalian orang yg mengadakan Sound Horeg ini kan gara2 Jokowi bisnis sound system sepi order jaman SBY banyak panggilan.", "Cacimaki/intoleransi sarkas candaan tentang rasa senang terhadap peristiwa dan tokoh politik"),
(17, "@tekarok007 masyarakat tidak butuh gagasan ga butuh belajar. KITA BUTUH MITOS KITA BUTUH DONGENG KITA BUTUH BANSOS KITA BUTUH SOUND HOREG !!!.", "Rasional negatif sarkas seruan"),
]
tipologi_df = pd.DataFrame(tipologi_data, columns=["No", "Ujaran", "Arah Tipe Wacana"])
# Filter
all_kategori = ["Semua"] + sorted({
"Rasional negatif" if "Rasional" in s else "Cacimaki/intoleransi"
for s in tipologi_df["Arah Tipe Wacana"]
})
pilih = st.selectbox("Filter kategori utama", all_kategori, key="tipologi_filter")
if pilih != "Semua":
view = tipologi_df[tipologi_df["Arah Tipe Wacana"].str.contains(pilih, case=False)]
else:
view = tipologi_df
st.dataframe(view, use_container_width=True, hide_index=True, height=480)
st.info(
"**Keberagaman sasaran dan bentuk penyampaian** inilah salah satu sumber kesulitan "
"model klasifikasi dalam membedakan ujaran *rasional negatif* dan *cacimaki/intoleransi*."
)
# ---- TAB 2: Dimensi Fisik ----
with tab2:
st.markdown("### Dimensi Gangguan Fisik dan Kesehatan")
st.write(
"Sebagian besar ujaran pada Tabel 4.21 secara eksplisit menyinggung dampak fisik yang "
"dirasakan masyarakat sekitar. Dua bentuk perusakan yang muncul:"
)
col_a, col_b = st.columns(2)
with col_a:
st.markdown("#### 🏠 Perusakan Properti")
st.markdown("""
- **Disengaja**: pembongkaran atau pemindahan struktur tertentu untuk memperlancar arak-arakan *sound system*
- **Tidak disengaja**: gelombang suara berintensitas tinggi yang menghasilkan getaran merusak
- Contoh ujaran: *"merusak rumah dan fasilitas umum"* (Tweet #3),
*"bisa bikin rubuh rumah para penikmat"* (Tweet #8)
""")
with col_b:
st.markdown("#### 👂 Gangguan Pendengaran")
st.markdown("""
- **Permanen**: penurunan kemampuan mendengar yang tidak dapat dipulihkan
- **Sementara**: penurunan fungsi pendengaran yang masih dapat pulih
- Contoh ujaran: *"polusi suara"*, *"bikin kuping budeg"* (Tweet #7)
*Sumber: Hamdiyati & Wibawanti (2024)*
""")
st.markdown("---")
st.markdown("#### Kata-kata Penanda Dimensi Fisik di Korpus")
kata_fisik = ["kaca", "kuping", "rumah", "pecah", "berisik", "polusi", "rusak", "speaker", "soundnya"]
fi = compute_feature_importance()
rows = []
for kata in kata_fisik:
for label in LABELS_ORDER:
df_label = fi[label]
row = df_label[df_label["kata"] == kata]
if not row.empty:
rows.append({
"Kata": kata,
"Kelas": label,
"Skor Koefisien": row.iloc[0]["skor"],
"Rank": df_label[df_label["kata"] == kata].index[0] + 1,
})
kata_fisik_df = pd.DataFrame(rows)
if not kata_fisik_df.empty:
fig = px.bar(
kata_fisik_df, x="Kata", y="Skor Koefisien", color="Kelas",
barmode="group", color_discrete_map=COLOR_MAP,
category_orders={"Kelas": LABELS_ORDER},
)
fig.update_layout(height=420)
st.plotly_chart(fig, use_container_width=True)
st.success(
"Ujaran-ujaran negatif yang menyinggung dimensi fisik dan kesehatan **bukan ujaran "
"tanpa dasar**. Ujaran tersebut mencerminkan keluhan berlandaskan empiris — baik "
"kerusakan properti maupun risiko kesehatan."
)
# ---- TAB 3: Dimensi Budaya ----
with tab3:
st.markdown("### Dimensi Budaya dan Tradisi: Pertentangan Nilai")
st.write(
"Fenomena *sound horeg* tidak lepas dari dimensi budaya dan tradisi yang membentuk "
"interaksi sosial. Pertentangan terjadi antara dua kelompok pemaknaan:"
)
col_a, col_b = st.columns(2)
with col_a:
st.markdown("#### 🎉 Pelaku Sound Horeg (Remaja)")
st.markdown("""
- *Sound horeg* dimaknai sebagai **sarana ekspresi diri** dan kebebasan berekspresi
- Bagian dari proses pencarian jati diri pada kalangan remaja
- Dentuman suara dan modifikasi *speaker* dianggap **medium performatif** yang memberi kebanggaan sosial
- Pelampiasan dari rutinitas atau masalah lingkungan sekolah
*Sumber: Auliana dkk. (2025)*
""")
with col_b:
st.markdown("#### 🕌 Masyarakat Umum & Tokoh Keagamaan")
st.markdown("""
- **Masyarakat terdampak**: *sound horeg* merusak ketertiban sosial
- **Tokoh keagamaan (pesantren tradisional, MUI)**: simbol **dekadensi moral** dan hilangnya adab di ruang publik
- Dipandang sebagai pelanggaran norma sosial
*Sumber: Mohammad Fikri dkk. (2025)*
""")
st.markdown("---")
st.markdown("#### Kata-kata Penanda Dimensi Budaya & Politik di Korpus")
kata_budaya = ["jawa", "budaya", "karnaval", "prabowo", "pariwisata", "tradisi", "santri"]
fi = compute_feature_importance()
rows = []
for kata in kata_budaya:
for label in LABELS_ORDER:
df_label = fi[label]
row = df_label[df_label["kata"] == kata]
if not row.empty:
rows.append({
"Kata": kata,
"Kelas": label,
"Skor Koefisien": row.iloc[0]["skor"],
})
kata_budaya_df = pd.DataFrame(rows)
if not kata_budaya_df.empty:
fig = px.bar(
kata_budaya_df, x="Kata", y="Skor Koefisien", color="Kelas",
barmode="group", color_discrete_map=COLOR_MAP,
category_orders={"Kelas": LABELS_ORDER},
)
fig.update_layout(height=420)
st.plotly_chart(fig, use_container_width=True)
st.info(
"Fenomena *sound horeg* memperlihatkan dinamika kompleks antara **ekspresi budaya "
"populer anak muda** dan **struktur kuasa nilai** dari masyarakat dan tokoh "
"keagamaan — sebuah gambaran kebudayaan yang mengekspresikan identitas pribadi "
"dalam lingkungan sosial."
)
# ---- TAB 4: Sintesis ----
with tab4:
st.markdown("### Sintesis: Sound Horeg sebagai Cerminan Ketegangan Sosial Digital")
st.write(
"Hasil klasifikasi ketiga model (SVM, KNN, Ensemble) dan analisis wacana saling "
"memvalidasi satu argumen utama:"
)
_sint_wrong_svm = preds_df[preds_df["label_asli"] != preds_df["pred_svm"]]
_sint_neg_netral = _sint_wrong_svm[
(_sint_wrong_svm["label_asli"].isin([1, 2])) & (_sint_wrong_svm["pred_svm"] == 0)
].shape[0]
_sint_total_wrong = len(_sint_wrong_svm)
_sint_pct = _sint_neg_netral / _sint_total_wrong * 100 if _sint_total_wrong > 0 else 0
st.markdown(
f"""
> **Kegagalan model mendeteksi {_sint_pct:.2f}% ujaran negatif bukan kelemahan teknis semata,
> melainkan cerminan dari kompleksitas wacana sound horeg di media X.**
"""
)
st.markdown("---")
st.markdown("#### Empat Karakteristik Cara Penyampaian Ketidakpuasan")
col_a, col_b = st.columns(2)
with col_a:
st.markdown("""
**1. Kritik rasional disisipi kata kasar**
*"tidak bisa tidur gara2 sound horeg pawai hari santri lgian hari santri malah nyetel dj an si anjing"*
**2. Struktur kalimat panjang dan asosiatif**
*"mending bunuh sekarang gede jadi jawir suka sound horeg ijazah palsu ngaku lulus ugm jadi presiden amit amit jabang bayi setan"*
""")
with col_b:
st.markdown("""
**3. Cacimaki dikemas idiom/metafora**
*"otak kebanyakan denger sound horeg makanya gapunya pikir"*
**4. Doa kutukan**
*"norak bener moga2 cpt laknat tuhan masang sound horeg"*
""")
st.markdown("---")
st.markdown("#### Validasi melalui Kata Kunci Diskriminatif")
st.markdown(
"""
- **Dimensi fisik & budaya** hadir di seluruh kelas wacana (kata *kaca*, *kuping*, *rumah*,
*jawa*, *budaya* sebagai penanda kuat kelas **Netral** — bukti bahwa topik ini muncul di
semua nada penyampaian, hanya gaya berbeda).
- **Strategi sarkasme** divalidasi oleh kemunculan kata *neng* (dari *seneng*) dan *cinta*
sebagai penanda kelas **Rasional Negatif** — pengguna ingin menyalurkan ketidakpuasan
tanpa terbaca sebagai intoleran.
- **Dimensi politik** muncul lewat kata *prabowo* dan *karnaval* pada kelas
**Cacimaki/Intoleransi** — wacana *sound horeg* meluas dari sekadar kebisingan menjadi
kritik terhadap figur dan peristiwa publik.
"""
)
st.markdown("---")
st.success(
"""
**Kesimpulan akhir:**
Wacana *sound horeg* di media X merupakan **titik temu berbagai bentuk ketegangan sosial
yang lebih luas** — fisik, budaya, dan politik. Ketiga model klasifikasi yang berbasis
representasi TF-IDF (fitur leksikal) mengenali ujaran negatif eksplisit dengan baik,
tetapi kesulitan menangkap strategi linguistik halus seperti sarkasme, idiom, dan
doa kutukan. **Performa ketiga model adalah cerminan langsung dari kondisi ruang
sosial digital di Indonesia**, bukan kelemahan teknis semata.
"""
)
# ============================================================
# HALAMAN 7: EKSPLORASI TWEET INTERAKTIF
# ============================================================
elif page == "Eksplorasi Tweet Interaktif":
st.title("Eksplorasi Tweet Interaktif")
st.caption(
"Telusuri data uji (279 tweet) dengan filter agreement/disagreement antar model — "
"ini cara paling produktif menemukan **kasus sulit** yang menarik untuk dianalisis."
)
explore_df = preds_df.copy()
explore_df["sepakat_3"] = (
(explore_df["pred_svm"] == explore_df["pred_knn"]) &
(explore_df["pred_svm"] == explore_df["pred_ens"])
)
explore_df["svm_benar"] = explore_df["pred_svm"] == explore_df["label_asli"]
explore_df["knn_benar"] = explore_df["pred_knn"] == explore_df["label_asli"]
explore_df["ens_benar"] = explore_df["pred_ens"] == explore_df["label_asli"]
explore_df["semua_benar"] = (explore_df["svm_benar"] & explore_df["knn_benar"] & explore_df["ens_benar"])
explore_df["semua_salah"] = (~explore_df["svm_benar"] & ~explore_df["knn_benar"] & ~explore_df["ens_benar"])
st.markdown("### Filter")
col1, col2, col3 = st.columns(3)
with col1:
label_filter = st.multiselect(
"Label Asli", LABELS_ORDER, default=LABELS_ORDER, key="exp_label"
)
with col2:
agreement_filter = st.selectbox(
"Mode Filter Kesepakatan Model",
[
"Semua",
"Ketiganya sepakat",
"Ketiganya tidak sepakat",
"Hanya SVM yang benar (KNN & Ensemble salah)",
"Hanya KNN yang benar (SVM & Ensemble salah)",
"Hanya Ensemble yang benar (SVM & KNN salah)",
"Semua model salah",
"Semua model benar",
],
key="exp_agreement",
)
with col3:
search = st.text_input("Cari kata kunci dalam tweet", "", key="exp_search").strip().lower()
# Apply filters
view = explore_df[explore_df["label_asli_name"].isin(label_filter)]
if agreement_filter == "Ketiganya sepakat":
view = view[view["sepakat_3"]]
elif agreement_filter == "Ketiganya tidak sepakat":
# Semua 3 prediksi berbeda
view = view[
(view["pred_svm"] != view["pred_knn"]) &
(view["pred_svm"] != view["pred_ens"]) &
(view["pred_knn"] != view["pred_ens"])
]
elif agreement_filter == "Hanya SVM yang benar (KNN & Ensemble salah)":
view = view[view["svm_benar"] & ~view["knn_benar"] & ~view["ens_benar"]]
elif agreement_filter == "Hanya KNN yang benar (SVM & Ensemble salah)":
view = view[~view["svm_benar"] & view["knn_benar"] & ~view["ens_benar"]]
elif agreement_filter == "Hanya Ensemble yang benar (SVM & KNN salah)":
view = view[~view["svm_benar"] & ~view["knn_benar"] & view["ens_benar"]]
elif agreement_filter == "Semua model salah":
view = view[view["semua_salah"]]
elif agreement_filter == "Semua model benar":
view = view[view["semua_benar"]]
if search:
view = view[view["clean_text"].str.lower().str.contains(search, na=False)]
st.markdown(f"### Hasil: {len(view)} tweet")
if len(view) == 0:
st.warning("Tidak ada tweet yang cocok dengan filter ini.")
else:
# Summary cards
c1, c2, c3, c4 = st.columns(4)
c1.metric("Total tweet", len(view))
c2.metric("SVM benar", int(view["svm_benar"].sum()),
delta=f"{view['svm_benar'].mean()*100:.1f}%", delta_color="off")
c3.metric("KNN benar", int(view["knn_benar"].sum()),
delta=f"{view['knn_benar'].mean()*100:.1f}%", delta_color="off")
c4.metric("Ensemble benar", int(view["ens_benar"].sum()),
delta=f"{view['ens_benar'].mean()*100:.1f}%", delta_color="off")
# Tampilkan hasil
show_cols = view[[
"clean_text", "label_asli_name",
"pred_svm_name", "pred_knn_name", "pred_ens_name"
]].rename(columns={
"clean_text": "Tweet",
"label_asli_name": "Label Asli",
"pred_svm_name": "SVM",
"pred_knn_name": "KNN",
"pred_ens_name": "Ensemble",
})
st.dataframe(show_cols, use_container_width=True, hide_index=True, height=520)
# Download
csv = show_cols.to_csv(index=False).encode("utf-8")
st.download_button(
"💾 Download hasil filter (CSV)",
data=csv,
file_name="hasil_eksplorasi_tweet.csv",
mime="text/csv",
)
# Distribusi tweet sulit
st.markdown("---")
st.markdown("### Distribusi Tingkat Kesulitan Tweet")
n_benar = explore_df[["svm_benar", "knn_benar", "ens_benar"]].sum(axis=1)
diff_df = pd.DataFrame({
"Kategori": [
"Semua model benar (mudah)",
"2 dari 3 benar",
"Hanya 1 dari 3 benar",
"Semua model salah (sulit)",
],
"Jumlah": [
int((n_benar == 3).sum()),
int((n_benar == 2).sum()),
int((n_benar == 1).sum()),
int((n_benar == 0).sum()),
],
})
fig = px.bar(
diff_df, x="Kategori", y="Jumlah", color="Kategori",
text="Jumlah",
color_discrete_sequence=["#10B981", "#84CC16", "#F59E0B", "#DC2626"],
)
fig.update_traces(textposition="outside")
fig.update_layout(showlegend=False, height=380)
st.plotly_chart(fig, use_container_width=True)
st.caption(
"Tweet di kategori **\"Semua model salah\"** adalah kandidat paling kaya untuk "
"analisis kualitatif kasus sulit — gunakan filter di atas untuk menelusuri."
)