""" 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}
Prediksi: %{x}
Jumlah: %{z}", )) 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." )