MIF_E31232465/page_modules/preprocessing_page.py

1202 lines
49 KiB
Python

"""
preprocessing_page.py
=====================
Halaman Bersihkan Data — NLP Pipeline 5 Tahap.
PIPELINE 5 TAHAP:
1. Case Folding — ubah semua huruf jadi lowercase
2. Cleaning — hapus URL, mention, hashtag, angka, emoji, tanda baca
3. Normalisasi — singkatan/slang → kata baku (DARI FILE normalisasi)
4. Stopword Removal — hapus kata umum (DARI FILE stopword); JAGA kata sentimen
5. Stemming — bentuk dasar kata via Sastrawi ECS
PERBAIKAN DARI VERSI SEBELUMNYA (sync dengan sentiment_service.py):
- Domain override 'mending''lebih baik' DIHAPUS.
Alasan: mengubah kata kritis/negatif menjadi sinyal positif di lexicon.
"mending X daripada Y" = kritik; setelah diubah jadi "lebih baik X..."
lexicon menangkap 'baik' sebagai POSITIF → hasil sentimen salah.
- Domain override 'mendingan''lebih baik' DIHAPUS (alasan sama).
- 'mending', 'mendingan', 'daripada', 'ketimbang', 'ngapain', 'percuma',
'begini' DILINDUNGI dari stopword removal agar pola kontekstual
(POLA_KOMPARATIF, POLA_KRITIK_TERSIRAT) di sentiment_service.py tetap
dapat mendeteksinya saat input lexicon preprocessing.
- KATA_SENTIMEN_PENTING diperluas: tambahkan 'mending', 'malah'.
CATATAN PENTING:
Pipeline ini HARUS IDENTIK dengan sentiment_service.py agar token yang
dihasilkan di sini konsisten dengan token saat training model.
"""
import streamlit as st
import pandas as pd
import os
import re
import string
from collections import Counter
import plotly.graph_objects as go
from datetime import datetime, timedelta
from database import engine, get_tweet_count, get_latest_crawl_time
from page_modules.table_utils import render_standard_table
from timezone_utils import (
parse_dt_with_tz,
parse_dt_with_source_tz,
get_timezone_label,
get_timezone_name,
)
# ═══════════════════════════════════════════════════════════
# TIMEZONE HELPERS
# ═══════════════════════════════════════════════════════════
def parse_dt(series):
return parse_dt_with_tz(
series,
st.session_state.get("user_timezone", "WIB (UTC+7)")
)
def parse_crawled_dt(series):
return parse_dt_with_source_tz(
series,
st.session_state.get("user_timezone", "WIB (UTC+7)"),
os.getenv("APP_TIMEZONE", "Asia/Jakarta")
)
def format_dt(value):
if value is None or pd.isna(value):
return "Belum ada"
try:
tz_label = get_timezone_label(
st.session_state.get("user_timezone", "WIB (UTC+7)")
)
return f"{value.strftime('%d/%m/%Y %H:%M')} {tz_label}"
except Exception:
return "Belum ada"
def user_today():
timezone_choice = st.session_state.get("user_timezone", "WIB (UTC+7)")
return pd.Timestamp.now(tz=get_timezone_name(timezone_choice)).date()
def _sync_dynamic_period():
mode = st.session_state.get("analysis_mode")
today = user_today()
configs = {
"realtime": (today - timedelta(days=6), today, "Tweet Terkini — 7 Hari Terakhir"),
"30days": (today - timedelta(days=29), today, "30 Hari Terakhir"),
"captured": (today, today, "Tweet Hari Ini"),
}
if mode not in configs:
return
start_day, end_day, mode_display = configs[mode]
dt_start = datetime.combine(start_day, datetime.min.time())
dt_end = datetime.combine(end_day, datetime.max.time().replace(microsecond=0))
st.session_state.filter_start_date = dt_start
st.session_state.filter_end_date = dt_end
st.session_state.filter_label = f"{dt_start.strftime('%d/%m/%Y')} s/d {dt_end.strftime('%d/%m/%Y')}"
st.session_state.mode_display = mode_display
st.session_state.filter_date_column = "created_at"
# ═══════════════════════════════════════════════════════════
# PREPROCESSING PIPELINE — 5 TAHAP
#
# ┌─────────────────────────────────────────────────────┐
# │ PENTING: Pipeline ini HARUS identik dengan │
# │ sentiment_service.py agar token konsisten! │
# │ │
# │ URUTAN WAJIB: │
# │ 1. Case Folding → lowercase dulu │
# │ 2. Cleaning → hapus noise setelah lowercase │
# │ 3. Normalisasi → slang→baku setelah bersih │
# │ 4. Stopword → buang kata umum, jaga sentimen │
# │ 5. Stemming → bentuk dasar via Sastrawi ECS │
# └─────────────────────────────────────────────────────┘
# ═══════════════════════════════════════════════════════════
# ───────────────────────────────────────────────────────────
# KATA SENTIMEN PENTING
# Kata-kata ini WAJIB DIJAGA dan tidak boleh dihapus saat
# stopword removal, meskipun ada di file stopword.
#
# PERBAIKAN: Tambahkan 'mending' dan 'malah' agar tidak
# hilang di stopword removal dan bisa dideteksi oleh
# pola kontekstual di sentiment_service.py.
# ───────────────────────────────────────────────────────────
KATA_SENTIMEN_PENTING = {
# ── Negasi (pembalik makna kalimat) ──────────────────
"tidak", "bukan", "jangan", "kurang", "belum", "tanpa",
# ── Intensitas (penguat/pelemah sentimen) ─────────────
"sangat", "banget", "sekali", "paling", "amat",
"luar", "biasa",
# ── Positif umum ──────────────────────────────────────
"keren", "bagus", "mantap", "setuju", "dukung", "mendukung",
"andal", "handal", "gercep", "bangga", "senang", "suka",
"baik", "benar", "tepat", "oke", "puas",
"sejahtera", "berkembang", "maju", "inovatif",
"tegas", "sigap", "tanggap", "adil", "bijak", "bermanfaat",
"untung", "berhasil", "sukses", "solusi", "manfaat",
"berguna", "membantu", "bantu", "pro", "lanjut",
# ── Positif domain e-commerce / ongkir ────────────────
"gratis", "murah", "hemat", "terjangkau", "cepat",
"aman", "mudah", "praktis", "terpercaya",
# ── Negatif umum ──────────────────────────────────────
"kecewa", "buruk", "jelek", "parah", "gagal", "hancur",
"rusak", "bohong", "tipu", "korupsi",
# ── Negatif domain e-commerce / ongkir ────────────────
"mahal", "lambat", "lelet", "ribet", "susah", "repot",
"rugi", "boros",
# ── Emosi ─────────────────────────────────────────────
"marah", "sedih", "khawatir", "kecewa",
# ── DITAMBAHKAN: Penanda pola kontekstual ─────────────
# Kata-kata ini perlu tetap ada agar pola komparatif dan
# pola kritik tersirat bisa terdeteksi di sentiment_service.
"mending", # "mending X daripada Y" = kritik implisit
"mendingan", # variasi mending
"malah", # "malah rugi / malah tambah mahal" = negatif
"percuma", # "percuma aja kebijakan ini" = sia-sia/negatif
}
# ───────────────────────────────────────────────────────────
# KATA POLA PENTING
# Kata struktural yang diperlukan agar pola kontekstual
# di sentiment_service.py bisa bekerja dengan benar.
# Kata-kata ini HARUS dilindungi dari stopword removal.
# ───────────────────────────────────────────────────────────
KATA_POLA_PENTING = {
"mending", # penanda pola komparatif negatif
"mendingan", # variasi mending
"daripada", # komponen "mending X daripada Y"
"ketimbang", # variasi daripada
"ngapain", # penanda kritik tersirat
"percuma", # penanda sia-sia
"begini", # "kebijakan begini" = kritik tersirat
}
# ───────────────────────────────────────────────────────────
# LOAD NORMALIZATION DARI FILE
# PERBAIKAN: Hapus override 'mending' → 'lebih baik'
# ───────────────────────────────────────────────────────────
def _load_normalization() -> dict:
"""
Muat kamus normalisasi dari file eksternal.
PERUBAHAN DARI VERSI SEBELUMNYA:
- 'mending' TIDAK lagi dioverride ke 'lebih baik'
- 'mendingan' TIDAK lagi dioverride ke 'lebih baik'
KENAPA?
'mending' dalam tweet biasanya digunakan sebagai kritik:
"mending ngurusin judol daripada ngurusin ongkir"
Jika diubah ke "lebih baik", lexicon scoring mendeteksi 'baik'
sebagai sinyal positif → hasil sentimen SALAH (Positif, harusnya Negatif).
Biarkan 'mending' apa adanya agar POLA_KOMPARATIF_NEGATIF
di sentiment_service.py bisa mendeteksinya.
"""
norm_file = "indonesian-normalisasi-slangword-complete.txt"
norm_dict: dict = {}
try:
with open(norm_file, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split(",", 1)
if len(parts) != 2:
continue
slang = parts[0].strip().strip("'\"").lower()
normal = parts[1].strip().lower()
if slang and normal:
norm_dict[slang] = normal
except FileNotFoundError:
st.warning(
"⚠️ File normalisasi tidak ditemukan: "
f"'{norm_file}'. Hanya entri domain yang aktif."
)
# ── Override khusus domain ───────────────────────────
DOMAIN_OVERRIDES: dict = {
# Nama platform — pertahankan apa adanya
"shopee": "shopee",
"tokopedia": "tokopedia",
"lazada": "lazada",
"tiktok": "tiktok",
"bukalapak": "bukalapak",
"blibli": "blibli",
# Logistik — pertahankan apa adanya
"sicepat": "sicepat",
"jne": "jne",
"jnt": "jnt",
"anteraja": "anteraja",
"ninja": "ninja",
# Ongkir & belanja
"freeongkir": "gratis ongkos kirim",
"gratisongkir": "gratis ongkos kirim",
"ongkir": "ongkos kirim",
"ongkr": "ongkos kirim",
"bykrm": "biaya kirim",
"biayakirim": "biaya pengiriman",
# Kebijakan & lembaga
"komdigi": "komdigi",
"kemendag": "kementerian perdagangan",
"kominfo": "kementerian komunikasi",
# E-commerce umum
"ecommerce": "e commerce",
"marketplace": "marketplace",
"seller": "penjual",
"buyer": "pembeli",
"online": "online",
# Negasi informal
"gk": "tidak", "ga": "tidak", "gak": "tidak",
"nggak": "tidak", "ngga": "tidak", "tdk": "tidak",
"tak": "tidak", "enggak": "tidak", "engga": "tidak",
"kagak": "tidak", "kaga": "tidak", "ndak": "tidak",
"ngak": "tidak",
# Intensitas
"bgt": "banget", "bngt": "banget", "bget": "banget", "bgtt": "banget",
# Positif informal (hanya yang benar-benar positif)
"mantep": "mantap", "mntap": "mantap",
"kece": "keren",
"ancur": "hancur", "parahh": "parah",
# ── SENGAJA TIDAK DIOVERRIDE (vs versi lama): ────────
# "mending" → TIDAK diubah ke "lebih baik"
# "mendingan" → TIDAK diubah ke "lebih baik"
# Alasan: lihat docstring di atas.
#
# "malah" → TIDAK dioverride ke "bahkan"
# Alasan: nuansa kritis 'malah' perlu dipertahankan.
#
# "sip" → TIDAK dioverride ke "baik"
# Alasan: "baik" terlalu kontekstual untuk lexicon positif.
}
norm_dict.update(DOMAIN_OVERRIDES)
return norm_dict
# ───────────────────────────────────────────────────────────
# LOAD STOPWORDS DARI FILE
# PERBAIKAN: Lindungi kata pola penting dari stopword removal
# ───────────────────────────────────────────────────────────
def _load_stopwords() -> set:
"""
Muat daftar stopword dari file eksternal.
PERUBAHAN DARI VERSI SEBELUMNYA:
Selain melindungi KATA_SENTIMEN_PENTING, kini juga melindungi
KATA_POLA_PENTING agar pola kontekstual di sentiment_service.py
bisa bekerja dengan benar:
- 'mending' → penanda pola komparatif negatif
- 'daripada' → komponen "mending X daripada Y"
- 'ketimbang' → variasi daripada
- 'ngapain' → penanda kritik tersirat
- 'percuma' → penanda sia-sia/negatif
- 'begini'"kebijakan begini" = kritik tersirat
"""
stopword_file = "indonesian-stopwords-complete.txt"
base: set = set()
try:
with open(stopword_file, "r", encoding="utf-8") as f:
for line in f:
word = line.strip().lower()
if word:
base.add(word)
except FileNotFoundError:
st.warning(
"⚠️ File stopword tidak ditemukan: "
f"'{stopword_file}'. Menggunakan daftar minimal."
)
base = {
"yang", "dan", "di", "ke", "dari", "ini", "itu",
"dengan", "untuk", "pada", "adalah", "oleh", "ada",
"ya", "akan", "atau", "juga", "sama", "karena",
"jika", "sudah", "telah", "jadi", "bisa",
}
# ── Langkah 1: Lindungi kata sentimen ────────────────
for kata in KATA_SENTIMEN_PENTING:
base.discard(kata)
# ── Langkah 2: Lindungi kata pola kontekstual ─────────
# Kata-kata ini diperlukan agar pola analisis sentimen
# bisa bekerja setelah stopword removal.
for kata in KATA_POLA_PENTING:
base.discard(kata)
# ── Langkah 3: Tambah noise Twitter/sosmed ────────────
TWITTER_NOISE: set = {
"rt", "amp",
"https", "http", "co", "pic",
"wkwk", "wkwkwk", "wkwkwkwk",
"haha", "hahaha", "hehe", "hihi", "huhu", "xixi",
"nih", "sih", "dong", "deh", "loh", "lah", "tuh",
"kak", "gan", "bro", "sob", "min",
}
base.update(TWITTER_NOISE)
return base
# ───────────────────────────────────────────────────────────
# LOAD STEMMER
# ───────────────────────────────────────────────────────────
def _load_stemmer():
"""Muat stemmer Sastrawi. Return None jika tidak tersedia."""
try:
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
return StemmerFactory().create_stemmer()
except Exception:
return None
# ═══════════════════════════════════════════════════════════
# FUNGSI 5 TAHAP PREPROCESSING
# ═══════════════════════════════════════════════════════════
def step1_case_folding(text: str) -> str:
"""
TAHAP 1 — CASE FOLDING
Input : teks asli (campuran huruf besar/kecil)
Output: semua huruf jadi lowercase
"""
return str(text).lower()
def step2_cleaning(text: str) -> str:
"""
TAHAP 2 — CLEANING
Input : teks lowercase
Output: teks bersih dari semua elemen noise
"""
text = re.sub(r"http\S+|www\S+|https\S+", "", text)
text = re.sub(r"@\w+", "", text)
text = re.sub(r"#\w+", "", text)
text = re.sub(r"\d+", "", text)
text = re.sub(
r"["
r"\U00010000-\U0010ffff"
r"\U0001F600-\U0001F64F"
r"\U0001F300-\U0001F5FF"
r"\U0001F680-\U0001F6FF"
r"\U0001F1E0-\U0001F1FF"
r"\u2600-\u26FF"
r"\u2700-\u27BF"
r"]+",
"", text, flags=re.UNICODE
)
text = text.translate(str.maketrans("", "", string.punctuation))
text = re.sub(r"[^a-zA-Z\s]", "", text)
text = re.sub(r"\s+", " ", text).strip()
return text
def step3_normalization(text: str, norm_dict: dict) -> str:
"""
TAHAP 3 — NORMALISASI
Input : teks bersih + norm_dict dari file
Output: teks dengan slang/singkatan sudah diganti kata baku
PERUBAHAN: 'mending' tidak lagi dinormalisasi ke 'lebih baik'.
Lihat komentar di _load_normalization() untuk penjelasan.
"""
tokens = text.split()
normalized = [norm_dict.get(token, token) for token in tokens]
return " ".join(normalized)
def step4_stopword_removal(tokens: list, stopwords: set) -> list:
"""
TAHAP 4 — STOPWORD REMOVAL
Input : list token + stopwords dari file
Output: list token bersih
PERUBAHAN: Kata pola penting (mending, daripada, dll.) dilindungi
dari pembuangan melalui KATA_POLA_PENTING di _load_stopwords().
"""
result = []
for token in tokens:
if token in KATA_SENTIMEN_PENTING:
result.append(token)
continue
if token in KATA_POLA_PENTING:
result.append(token)
continue
if token in stopwords:
continue
if len(token) <= 2:
continue
result.append(token)
return result
def step5_stemming(tokens: list, stemmer) -> list:
"""
TAHAP 5 — STEMMING
Input : list token setelah stopword removal
Output: list token dalam bentuk kata dasar
Algoritma: Enhanced Confix Stripping (ECS) via Sastrawi
"""
if stemmer is None:
return tokens
return [stemmer.stem(token) for token in tokens]
# ───────────────────────────────────────────────────────────
# FUNGSI UTAMA — JALANKAN SEMUA 5 TAHAP
# ───────────────────────────────────────────────────────────
def full_preprocessing(
text: str,
stopwords: set,
stemmer,
norm_dict: dict,
) -> dict:
"""
Jalankan 5 tahap preprocessing secara berurutan.
Return dict berisi hasil setiap tahap.
"""
s1_fold = step1_case_folding(text)
s2_clean = step2_cleaning(s1_fold)
s3_norm = step3_normalization(s2_clean, norm_dict)
s4_tokens = s3_norm.split()
s4_filtered = step4_stopword_removal(s4_tokens, stopwords)
s5_stemmed = step5_stemming(s4_filtered, stemmer)
return {
"setelah_casefolding": s1_fold,
"setelah_cleaning": s2_clean,
"setelah_normalisasi": s3_norm,
"setelah_stopword": " ".join(s4_filtered),
"clean_text": " ".join(s5_stemmed),
"_tokens_clean": s5_stemmed,
}
# ═══════════════════════════════════════════════════════════
# UI HELPERS
# ═══════════════════════════════════════════════════════════
def _section_header(title, subtitle=""):
sub_html = (
f'<div style="font-size:0.75rem;color:#64748b;margin-top:4px;line-height:1.5;">{subtitle}</div>'
if subtitle else ""
)
st.markdown(f"""
<div style="background:#ffffff;border:1.5px solid #e2e8f0;border-radius:14px;
padding:0.9rem 1.25rem;margin-bottom:1rem;
box-shadow:0 2px 6px rgba(15,23,42,0.05);">
<div style="font-size:0.9rem;font-weight:700;color:#0f172a;letter-spacing:0.01em;">{title}</div>
{sub_html}
</div>
""", unsafe_allow_html=True)
def _gap(size="md"):
heights = {"xs": "0.6rem", "sm": "1rem", "md": "1.45rem", "lg": "2rem"}
st.markdown(f'<div style="height:{heights.get(size,"1.45rem")};"></div>', unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# STYLES
# ═══════════════════════════════════════════════════════════
def _render_preprocessing_styles():
st.markdown("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
section[data-testid="stMain"] * {
font-family: 'Plus Jakarta Sans', sans-serif !important;
}
.block-container { padding-top: 1rem !important; }
[data-testid="stMainBlockContainer"] { padding-top: 1rem !important; }
header[data-testid="stHeader"] { height: 0 !important; min-height: 0 !important; }
.pipeline-step {
transition: transform 0.2s cubic-bezier(.34,1.56,.64,1), box-shadow 0.2s ease;
}
.pipeline-step:hover {
transform: translateY(-4px) scale(1.01);
box-shadow: 0 12px 28px rgba(15,23,42,0.12) !important;
}
.stButton > button {
border-radius: 12px !important;
font-weight: 700 !important;
font-family: 'Plus Jakarta Sans', sans-serif !important;
transition: all 0.18s ease !important;
}
.stButton > button:hover {
transform: translateY(-1px);
box-shadow: 0 6px 18px rgba(59,108,247,0.18) !important;
}
@keyframes fadeDown {
from { opacity:0; transform:translateY(-10px); }
to { opacity:1; transform:translateY(0); }
}
.pp-header { animation: fadeDown 0.45s ease both; }
@keyframes fadeRight {
from { opacity:0; transform:translateX(-12px); }
to { opacity:1; transform:translateX(0); }
}
.pipe-1 { animation: fadeRight 0.35s 0.05s ease both; }
.pipe-2 { animation: fadeRight 0.35s 0.12s ease both; }
.pipe-3 { animation: fadeRight 0.35s 0.19s ease both; }
.pipe-4 { animation: fadeRight 0.35s 0.26s ease both; }
.pipe-5 { animation: fadeRight 0.35s 0.33s ease both; }
.example-box { transition: all 0.18s ease; }
.example-box:hover {
border-color: #93c5fd !important;
box-shadow: 0 4px 14px rgba(59,108,247,0.10) !important;
}
.fix-badge {
display: inline-block;
background: #fef9c3;
color: #854d0e;
border: 1px solid #fde68a;
border-radius: 6px;
padding: 2px 8px;
font-size: 0.65rem;
font-weight: 700;
margin-left: 6px;
vertical-align: middle;
}
</style>
""", unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# PAGE HEADER
# ═══════════════════════════════════════════════════════════
def _render_page_header():
st.markdown("""
<div class="pp-header" style="
background: linear-gradient(135deg,#ffffff 0%,#f0fdf4 50%,#ecfdf5 100%);
border: 1px solid #d1fae5;
border-radius: 20px;
padding: 1.5rem 1.75rem;
margin-bottom: 1.5rem;
box-shadow: 0 4px 20px rgba(16,163,74,0.08);
display: flex; align-items: center; gap: 1.1rem;
">
<div style="
width:52px;height:52px;
background:linear-gradient(135deg,#16a34a,#059669);
border-radius:14px;
display:flex;align-items:center;justify-content:center;
font-size:1.5rem;
box-shadow:0 6px 16px rgba(16,163,74,0.35);
flex-shrink:0;
">🧹</div>
<div>
<h2 style="font-size:1.25rem;font-weight:800;color:#0f172a;
margin:0 0 4px;letter-spacing:-0.01em;line-height:1.2;">
Bersihkan Data</h2>
<p style="font-size:0.8rem;color:#64748b;margin:0;line-height:1.5;">
Preprocessing teks 5 tahap otomatis:
<strong style="color:#059669;">Case Folding → Cleaning → Normalisasi → Stopword Removal → Stemming</strong>
</p>
</div>
<div style="
margin-left:auto;
background:linear-gradient(135deg,#f0fdf4,#dcfce7);
border:1px solid #86efac;
border-radius:10px;
padding:0.45rem 0.9rem;
font-size:0.72rem;font-weight:700;color:#16a34a;
white-space:nowrap;letter-spacing:0.04em;text-transform:uppercase;
">✨ NLP Pipeline</div>
</div>
""", unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# PIPELINE STEPS CARDS
# ═══════════════════════════════════════════════════════════
def _render_pipeline_steps(stemmer_ok: bool, norm_count: int, sw_count: int):
steps = [
{
"num": "01", "anim": "pipe-1",
"icon": "🔡", "color": "#0284c7", "dark": "#0c4a6e",
"bg": "linear-gradient(135deg,#eff6ff,#dbeafe)", "border": "#bfdbfe",
"title": "Case Folding",
"desc": "Menyeragamkan semua huruf menjadi lowercase sebelum proses lainnya.",
"items": [
'"Gratis""gratis"',
'"ONGKIR""ongkir"',
'"KEREN""keren"',
"Seluruh karakter → huruf kecil",
"Dilakukan PERTAMA agar regex & dict konsisten",
"Fondasi seluruh tahap berikutnya",
],
},
{
"num": "02", "anim": "pipe-2",
"icon": "🧽", "color": "#3b6cf7", "dark": "#1e3a8a",
"bg": "linear-gradient(135deg,#eef2ff,#e0e7ff)", "border": "#c7d2fe",
"title": "Cleaning",
"desc": "Menghapus semua elemen noise yang tidak bermakna dari teks.",
"items": [
"Hapus URL (http, https, www)",
"Hapus mention (@username)",
"Hapus hashtag (#topik)",
"Hapus angka & digit",
"Hapus emoji & simbol unicode",
"Hapus tanda baca & karakter non-latin",
],
},
{
"num": "03", "anim": "pipe-3",
"icon": "🔄", "color": "#16a34a", "dark": "#14532d",
"bg": "linear-gradient(135deg,#f0fdf4,#dcfce7)", "border": "#86efac",
"title": f'Normalisasi <span class="fix-badge">✦ {norm_count:,} entri</span>',
"desc": "Mengubah singkatan/slang ke kata baku. 'mending' TIDAK diubah ke 'lebih baik' (perbaikan konteks sentimen).",
"items": [
"gk/ga/gak/kagak/ngga → tidak",
"bgt/bngt/bget → banget",
"ongkir → ongkos kirim",
"mantep → mantap",
"⚠️ mending → mending (dijaga, bukan 'lebih baik')",
f"Total: {norm_count:,} pasang slang→baku dimuat",
],
},
{
"num": "04", "anim": "pipe-4",
"icon": "🚫", "color": "#ea580c", "dark": "#7c2d12",
"bg": "linear-gradient(135deg,#fff7ed,#ffedd5)", "border": "#fed7aa",
"title": f'Stopword Removal <span class="fix-badge">✦ {sw_count:,} kata</span>',
"desc": "Membuang kata umum; kata sentimen & kata pola kontekstual DIJAGA.",
"items": [
f"{sw_count:,} stopword dimuat dari file",
"JAGA negasi: tidak, bukan, jangan, belum",
"JAGA positif: keren, bagus, mantap, gratis",
"JAGA negatif: kecewa, buruk, gagal, mahal",
"JAGA pola: mending, daripada, percuma, begini",
"Hapus token ≤ 2 karakter (noise)",
],
},
{
"num": "05", "anim": "pipe-5",
"icon": "🌱", "color": "#ca8a04", "dark": "#713f12",
"bg": "linear-gradient(135deg,#fefce8,#fef9c3)", "border": "#fde68a",
"title": "Stemming",
"desc": "Mengubah kata ke bentuk dasar via ECS Sastrawi.",
"items": [
"berlari → lari",
"makanan → makan",
"pembatasan → batas",
"pengiriman → kirim",
f"Status: {'✅ Sastrawi aktif' if stemmer_ok else '⚠️ Sastrawi tidak tersedia'}",
"Algoritma: Enhanced Confix Stripping",
],
},
]
row1 = st.columns(3, gap="medium")
for col, step in zip(row1, steps[:3]):
_render_step_card(col, step)
_gap("sm")
_, col4, col5, _ = st.columns([0.5, 1, 1, 0.5], gap="medium")
_render_step_card(col4, steps[3])
_render_step_card(col5, steps[4])
def _render_step_card(col, step):
items_html = "".join(
f'<div style="display:flex;align-items:flex-start;gap:0.4rem;margin-bottom:0.28rem;">'
f'<span style="color:{step["color"]};font-size:0.62rem;margin-top:3px;flex-shrink:0;">▶</span>'
f'<span style="font-size:0.71rem;color:{step["dark"]};opacity:0.88;line-height:1.5;">{item}</span>'
f'</div>'
for item in step["items"]
)
with col:
st.markdown(f"""
<div class="pipeline-step {step['anim']}" style="
background:{step['bg']};
border:1.5px solid {step['border']};
border-radius:16px;
padding:1.1rem 1rem 1rem;
box-shadow:0 2px 8px {step['color']}14;
position:relative;overflow:hidden;
min-height: 230px;
">
<div style="position:absolute;top:-12px;right:-12px;
width:58px;height:58px;background:{step['color']}10;
border-radius:50%;"></div>
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.65rem;">
<div style="width:36px;height:36px;background:{step['color']};border-radius:10px;
display:flex;align-items:center;justify-content:center;
font-size:1rem;box-shadow:0 4px 10px {step['color']}44;flex-shrink:0;">
{step['icon']}</div>
<div>
<div style="font-size:0.58rem;font-weight:700;color:{step['color']};
letter-spacing:0.08em;text-transform:uppercase;">Tahap {step['num']}</div>
<div style="font-size:0.88rem;font-weight:800;color:{step['dark']};line-height:1.2;">
{step['title']}</div>
</div>
</div>
<div style="font-size:0.69rem;color:{step['dark']};opacity:0.7;
line-height:1.5;margin-bottom:0.6rem;font-style:italic;">
{step['desc']}</div>
<div style="border-top:1px solid {step['border']};padding-top:0.55rem;">
{items_html}
</div>
</div>
""", unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# FLOW ARROW
# ═══════════════════════════════════════════════════════════
def _render_flow_arrow():
nodes = [
("📄 Teks Asli", "#94a3b8", "#f8fafc", "#e2e8f0"),
("① Case Folding", "#0284c7", "#eff6ff", "#bfdbfe"),
("② Cleaning", "#3b6cf7", "#eef2ff", "#c7d2fe"),
("③ Normalisasi", "#16a34a", "#f0fdf4", "#86efac"),
("④ Stopword", "#ea580c", "#fff7ed", "#fed7aa"),
("⑤ Stemming", "#ca8a04", "#fefce8", "#fde68a"),
("✅ Teks Bersih", "#0f172a", "#0f172a", "#334155"),
]
parts = ""
for i, (label, color, bg, border) in enumerate(nodes):
text_c = "#f8fafc" if label == "✅ Teks Bersih" else color
parts += (
f'<div style="background:{bg};border:1.5px solid {border};border-radius:8px;'
f'padding:0.28rem 0.6rem;font-size:0.68rem;font-weight:700;color:{text_c};'
f'white-space:nowrap;">{label}</div>'
)
if i < len(nodes) - 1:
next_color = nodes[i + 1][1]
parts += (
f'<div style="display:flex;align-items:center;">'
f'<div style="width:22px;height:2px;'
f'background:linear-gradient(90deg,{color},{next_color});"></div>'
f'<div style="width:0;height:0;border-top:5px solid transparent;'
f'border-bottom:5px solid transparent;'
f'border-left:7px solid {next_color};margin-left:-1px;"></div>'
f'</div>'
)
st.markdown(f"""
<div style="display:flex;align-items:center;flex-wrap:nowrap;
overflow-x:auto;gap:0;padding:0.5rem 0 1rem;margin-bottom:0.5rem;">
{parts}
</div>
""", unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# STAT PILLS
# ═══════════════════════════════════════════════════════════
def _render_stat_pills(total_raw, total_clean, removed):
c1, c2, c3 = st.columns(3, gap="medium")
cards = [
(c1, "📥", "linear-gradient(135deg,#eef2ff,#e0e7ff)", "#3b6cf7", "#1e3a8a", "#c7d2fe",
"Tweet Diproses", f"{total_raw:,}", "Total tweet periode ini"),
(c2, "", "linear-gradient(135deg,#f0fdf4,#dcfce7)", "#16a34a", "#14532d", "#86efac",
"Tweet Siap Analisis", f"{total_clean:,}", "Lulus semua 5 tahap"),
(c3,
"🗑️" if removed > 0 else "",
("linear-gradient(135deg,#fff7ed,#ffedd5)" if removed > 0 else "linear-gradient(135deg,#f0fdf4,#dcfce7)"),
("#ea580c" if removed > 0 else "#16a34a"),
("#7c2d12" if removed > 0 else "#14532d"),
("#fed7aa" if removed > 0 else "#86efac"),
"Tweet Dibuang", f"{removed:,}",
("Teks kosong setelah preprocessing" if removed > 0 else "Semua tweet lolos")),
]
for col, icon, bg, color, dark, border, label, val, sub in cards:
with col:
fs = "1.1rem" if len(str(val)) > 8 else "1.55rem"
st.markdown(f"""
<div style="background:{bg};border:1.5px solid {border};
border-radius:14px;padding:1.1rem 0.9rem;text-align:center;
box-shadow:0 2px 8px {color}14;margin-bottom:0.5rem;">
<div style="width:38px;height:38px;background:{color};border-radius:10px;
display:flex;align-items:center;justify-content:center;
font-size:1rem;margin:0 auto 0.55rem;box-shadow:0 4px 10px {color}44;">{icon}</div>
<div style="font-size:0.62rem;font-weight:800;color:{color};text-transform:uppercase;
letter-spacing:0.06em;margin-bottom:0.25rem;">{label}</div>
<div style="font-size:{fs};font-weight:800;color:{dark};line-height:1.15;
margin-bottom:0.2rem;">{val}</div>
<div style="font-size:0.66rem;color:{color};font-weight:600;opacity:0.85;">{sub}</div>
</div>
""", unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# LIVE EXAMPLE
# ═══════════════════════════════════════════════════════════
def _render_live_example(df_c):
if df_c.empty:
return
sample = df_c.sample(1).iloc[0]
_section_header(
"🔍 Contoh Hasil Preprocessing per Tahap",
"Contoh tweet acak dari dataset — refresh halaman untuk contoh berbeda"
)
steps_ex = [
("📄 Teks Asli", "text_asli", "#0f172a", "#f8fafc", "#e2e8f0"),
("① Setelah Case Folding", "setelah_casefolding", "#0c4a6e", "#eff6ff", "#bfdbfe"),
("② Setelah Cleaning", "setelah_cleaning", "#1e3a8a", "#eef2ff", "#c7d2fe"),
("③ Setelah Normalisasi", "setelah_normalisasi", "#14532d", "#f0fdf4", "#86efac"),
("④ Setelah Stopword", "setelah_stopword", "#7c2d12", "#fff7ed", "#fed7aa"),
("⑤ Hasil Akhir (Stem)", "clean_text", "#713f12", "#fefce8", "#fde68a"),
]
for label, col_key, text_color, bg, border in steps_ex:
raw = sample.get(col_key, "-")
text_display = str(raw) if raw and str(raw).strip() else ""
word_count = len(text_display.split()) if text_display != "" else 0
char_count = len(text_display) if text_display != "" else 0
char_info = f"{word_count} kata · {char_count} karakter"
content_html = (
f'<div style="font-size:0.82rem;color:{text_color};'
f'line-height:1.65;word-break:break-word;">{text_display}</div>'
)
st.markdown(
f'<div class="example-box" style="background:{bg};border:1.5px solid {border};'
f'border-radius:12px;padding:0.85rem 1.1rem;margin-bottom:0.55rem;">'
f'<div style="display:flex;justify-content:space-between;align-items:center;'
f'margin-bottom:0.4rem;">'
f'<span style="font-size:0.68rem;font-weight:800;color:{text_color};'
f'text-transform:uppercase;letter-spacing:0.07em;">{label}</span>'
f'<span style="font-size:0.63rem;color:#94a3b8;font-weight:600;">{char_info}</span>'
f'</div>'
f'{content_html}'
f'</div>',
unsafe_allow_html=True
)
# ═══════════════════════════════════════════════════════════
# TOP WORDS CHART
# ═══════════════════════════════════════════════════════════
def _render_top_words_chart(df_c):
_section_header(
"📊 Kata-Kata Paling Sering Muncul",
f"Dari {len(df_c):,} tweet yang sudah bersih (hasil akhir tahap 5) — Top 20 kata"
)
if "_tokens_clean" in df_c.columns:
all_words = [
w for tokens in df_c["_tokens_clean"]
for w in (tokens if isinstance(tokens, list) else [])
]
else:
all_words = " ".join(df_c["clean_text"].fillna("")).split()
filtered_words = [w for w in all_words if len(w) > 2]
word_freq = Counter(filtered_words).most_common(20)
if not word_freq:
st.info("⚠️ Belum cukup kata untuk ditampilkan.")
return
words = [w[0] for w in word_freq]
counts = [w[1] for w in word_freq]
max_c = max(counts) if counts else 1
bar_colors = [f"rgba(59,108,247,{0.35 + 0.65*(c/max_c):.2f})" for c in counts[::-1]]
fig = go.Figure(data=[
go.Bar(
y=words[::-1], x=counts[::-1], orientation="h",
marker=dict(color=bar_colors, line=dict(width=0), cornerradius=6),
text=[str(c) for c in counts[::-1]],
textposition="outside",
textfont=dict(size=10, color="#475569"),
hovertemplate="<b>%{y}</b><br>Muncul %{x} kali<extra></extra>",
)
])
fig.update_layout(
height=540,
margin=dict(l=0, r=60, t=8, b=8),
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
xaxis=dict(showgrid=True, gridcolor="rgba(203,213,225,0.8)",
tickfont=dict(size=10, color="#94a3b8"),
zeroline=False, showline=False, fixedrange=True),
yaxis=dict(showgrid=False, tickfont=dict(size=11, color="#334155"), fixedrange=True),
showlegend=False,
)
st.markdown("""
<div style="background:#ffffff;border:1.5px solid #e2e8f0;border-radius:14px;
padding:1rem 1.2rem 0.5rem;box-shadow:0 2px 6px rgba(15,23,42,0.05);">
""", unsafe_allow_html=True)
st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False})
st.markdown("</div>", unsafe_allow_html=True)
# ═══════════════════════════════════════════════════════════
# MAIN SHOW
# ═══════════════════════════════════════════════════════════
def show():
_render_preprocessing_styles()
_render_page_header()
if "analysis_mode" not in st.session_state:
st.warning("⚠️ Silakan pilih mode tampilan di halaman Ambil Data Twitter terlebih dahulu.")
return
_sync_dynamic_period()
start_date = st.session_state.get("filter_start_date")
end_date = st.session_state.get("filter_end_date")
filter_label = st.session_state.get("filter_label", "-")
mode_display = st.session_state.get("mode_display", "-")
if start_date is None or end_date is None:
st.warning("⚠️ Silakan buka halaman Ambil Data Twitter terlebih dahulu.")
return
mode_meta = {
"realtime": ("#16a34a", "📡"),
"30days": ("#3b6cf7", "📅"),
"captured": ("#0284c7", "📆"),
"custom": ("#d97706", "🔍"),
}
mode_color, mode_icon = mode_meta.get(
st.session_state.analysis_mode, ("#3b6cf7", "📊")
)
st.markdown(
f'<div style="background:#fff;border-left:4px solid {mode_color};'
f'border-top:1.5px solid #e2e8f0;border-right:1.5px solid #e2e8f0;'
f'border-bottom:1.5px solid #e2e8f0;border-radius:0 12px 12px 0;'
f'padding:0.875rem 1.25rem;margin-bottom:1.5rem;'
f'box-shadow:0 2px 6px rgba(15,23,42,0.05);'
f'display:flex;align-items:center;gap:0.75rem;">'
f'<span style="font-size:1.375rem;">{mode_icon}</span>'
f'<div>'
f'<div style="font-size:0.875rem;font-weight:700;color:#0f172a;">{mode_display}</div>'
f'<div style="font-size:0.78rem;color:#475569;margin-top:2px;">'
f'Periode: <strong style="color:{mode_color};">{filter_label}</strong></div>'
f'</div></div>',
unsafe_allow_html=True
)
stemmer = _load_stemmer()
stopwords = _load_stopwords()
norm_dict = _load_normalization()
_section_header(
"🔬 Alur NLP Pipeline — 5 Tahap Preprocessing",
"Setiap tweet diproses berurutan melalui 5 tahap sebelum siap dianalisis sentimennya"
)
_render_pipeline_steps(
stemmer_ok=stemmer is not None,
norm_count=len(norm_dict),
sw_count=len(stopwords),
)
_gap("sm")
_render_flow_arrow()
_gap("md")
try:
df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine)
if df_all.empty:
st.warning("⚠️ Belum ada data. Kembali ke halaman Ambil Data Twitter.")
return
df_all["created_at"] = parse_dt(df_all["created_at"])
if "crawled_at" in df_all.columns:
df_all["crawled_at"] = parse_crawled_dt(df_all["crawled_at"])
except Exception as e:
st.error(f"❌ Gagal membaca database: {e}")
return
s_dt = pd.Timestamp(start_date)
e_dt = pd.Timestamp(end_date)
df = df_all[
(df_all["created_at"] >= s_dt) & (df_all["created_at"] <= e_dt)
].copy()
if df.empty:
st.warning(f"⚠️ Tidak ada tweet dengan tanggal asli dalam periode {filter_label}.")
return
total_tweets_in_db = get_tweet_count()
latest_crawl_marker = get_latest_crawl_time() or "no-crawl"
data_marker = (total_tweets_in_db, latest_crawl_marker)
cache_key = (
f"pp5_{st.session_state.analysis_mode}_"
f"{start_date}_{end_date}_{total_tweets_in_db}_{latest_crawl_marker}"
)
for old_key in list(st.session_state.keys()):
if old_key.startswith("pp5_") and old_key != cache_key:
del st.session_state[old_key]
force_refresh = data_marker != st.session_state.get("_pp_last_data_marker")
if cache_key not in st.session_state or force_refresh:
with st.spinner("🧹 Menjalankan 5 tahap preprocessing…"):
results = []
for _, row in df.iterrows():
r = full_preprocessing(
text = row["text"],
stopwords = stopwords,
stemmer = stemmer,
norm_dict = norm_dict,
)
r["tweet_id"] = row.get("tweet_id", "")
r["text_asli"] = row["text"]
r["created_at"] = row["created_at"]
r["crawled_at"] = row.get("crawled_at")
results.append(r)
df_c = pd.DataFrame(results)
df_c = df_c[df_c["clean_text"].str.strip().str.len() > 0].copy()
df_c = df_c.reset_index(drop=True)
st.session_state[cache_key] = df_c
st.session_state[cache_key + "_sw_ok"] = stemmer is not None
st.session_state["_pp_last_data_marker"] = data_marker
df_c = st.session_state[cache_key]
stemmer_ok = st.session_state.get(cache_key + "_sw_ok", False)
removed = len(df) - len(df_c)
_section_header(
"📌 Ringkasan Hasil Preprocessing",
f"Berdasarkan tanggal asli tweet · {filter_label}"
)
_gap("xs")
_render_stat_pills(len(df), len(df_c), removed)
_gap("lg")
_render_live_example(df_c)
_gap("lg")
_section_header(
"📋 Tabel Perbandingan Teks per Tahap",
f"{len(df_c):,} tweet · {filter_label} — scroll horizontal untuk lihat semua kolom"
)
if "crawled_at" not in df_c.columns:
df_c = df_c.copy()
df_c["crawled_at"] = pd.NaT
disp = df_c[[
"tweet_id", "created_at", "crawled_at",
"text_asli",
"setelah_casefolding",
"setelah_cleaning",
"setelah_normalisasi",
"setelah_stopword",
"clean_text",
]].copy()
disp.columns = [
"ID Tweet", "Tanggal Tweet", "Masuk Database",
"Teks Asli",
"① Case Folding",
"② Cleaning",
"③ Normalisasi",
"④ Stopword",
"⑤ Hasil Akhir",
]
disp["Tanggal Tweet"] = disp["Tanggal Tweet"].apply(format_dt)
disp["Masuk Database"] = disp["Masuk Database"].apply(format_dt)
render_standard_table(
disp,
height=360,
min_width=1980,
nowrap=["ID Tweet", "Tanggal Tweet", "Masuk Database"],
wide_columns=[
"Teks Asli", "① Case Folding", "② Cleaning",
"③ Normalisasi", "④ Stopword", "⑤ Hasil Akhir",
],
column_widths={
"ID Tweet": "155px",
"Tanggal Tweet": "165px",
"Masuk Database": "165px",
"Teks Asli": "280px",
"① Case Folding": "240px",
"② Cleaning": "240px",
"③ Normalisasi": "240px",
"④ Stopword": "230px",
"⑤ Hasil Akhir": "230px",
},
)
_gap("lg")
_render_top_words_chart(df_c)
_gap("lg")
st.session_state["preprocessed_df"] = df_c
st.markdown("""
<div style="background:#ffffff;border:1.5px solid #e2e8f0;border-radius:14px;
padding:1.1rem 1.2rem;box-shadow:0 2px 6px rgba(15,23,42,0.05);
margin-bottom:1rem;">
""", unsafe_allow_html=True)
d1, d2 = st.columns(2, gap="medium")
with d1:
st.download_button(
"📥 Unduh Hasil Preprocessing Lengkap (semua kolom)",
df_c.drop(columns=["_tokens_clean"], errors="ignore"
).to_csv(index=False).encode("utf-8"),
f"preprocessing_lengkap_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
"text/csv",
use_container_width=True,
)
with d2:
out2 = df_c[["tweet_id", "text_asli", "clean_text"]].copy()
out2.columns = ["tweet_id", "tweet", "clean_text"]
st.download_button(
"📥 Unduh Teks Bersih Saja (siap analisis sentimen)",
out2.to_csv(index=False).encode("utf-8"),
f"teks_bersih_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
"text/csv",
use_container_width=True,
)
st.markdown("</div>", unsafe_allow_html=True)
_gap("sm")
if st.button(
"📈 Lanjut ke Analisis Sentimen →",
type="primary",
use_container_width=True,
):
st.session_state.current_page = "sentiment"
st.rerun()
_gap("sm")