1208 lines
53 KiB
Python
1208 lines
53 KiB
Python
"""
|
||
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."
|
||
)
|