diff --git a/indonesian-stopwords-complete.txt b/indonesian-stopwords-complete.txt index d671116..f99e212 100644 --- a/indonesian-stopwords-complete.txt +++ b/indonesian-stopwords-complete.txt @@ -1116,7 +1116,7 @@ yang selagi kebijakan pemerintah -komdigi + kementerian kemendag aturan diff --git a/model_naive_bayes.pkl b/model_naive_bayes.pkl index a05c6b2..065a77a 100644 Binary files a/model_naive_bayes.pkl and b/model_naive_bayes.pkl differ diff --git a/model_naive_bayes_old.pkl b/model_naive_bayes_old.pkl new file mode 100644 index 0000000..a05c6b2 Binary files /dev/null and b/model_naive_bayes_old.pkl differ diff --git a/nrt_activation.json b/nrt_activation.json index 498a33d..54df601 100644 --- a/nrt_activation.json +++ b/nrt_activation.json @@ -1 +1 @@ -{"activated": false, "updated_at": "2026-05-22T16:01:11.643950+00:00"} \ No newline at end of file +{"activated": true, "updated_at": "2026-05-29T05:53:53.439233+00:00"} \ No newline at end of file diff --git a/page_modules/preprocessing_page.py b/page_modules/preprocessing_page.py index 017f36d..033356c 100644 --- a/page_modules/preprocessing_page.py +++ b/page_modules/preprocessing_page.py @@ -3,17 +3,24 @@ preprocessing_page.py ===================== Halaman Bersihkan Data — NLP Pipeline 5 Tahap. -PIPELINE 5 TAHAP (selaras dengan sentiment_service.py): +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 - 4. Stopword Removal — hapus kata umum; JAGA kata sentimen penting + 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 -Catatan: Tokenizing tidak ditampilkan sebagai tahap tersendiri karena: - • Stopword removal & stemming sudah melakukan split() secara internal - • TF-IDF melakukan tokenisasi sendiri saat inferensi - → Tokenizing adalah proses teknis, bukan tahap utama pipeline +PERUBAHAN DARI VERSI SEBELUMNYA: + - Normalisasi kini dimuat dari 'indonesian-normalisasi-slangword-complete.txt' + (1.700+ entri), menggantikan dict hardcoded yang hanya ~60 entri. + - Stopword kini murni dari 'indonesian-stopwords-complete.txt', ditambah + noise Twitter yang spesifik — tidak ada penghapusan manual acak. + - KATA_SENTIMEN_PENTING diperluas dengan kata domain e-commerce/ongkir. + - Semua fungsi preprocessing menerima parameter eksplisit (tidak pakai global). + +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 @@ -33,18 +40,15 @@ from timezone_utils import ( 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, @@ -52,7 +56,6 @@ def parse_crawled_dt(series): os.getenv("APP_TIMEZONE", "Asia/Jakarta") ) - def format_dt(value): if value is None or pd.isna(value): return "Belum ada" @@ -96,63 +99,224 @@ def _sync_dynamic_period(): # ═══════════════════════════════════════════════════════════ # PREPROCESSING PIPELINE — 5 TAHAP -# PENTING: Pipeline ini harus IDENTIK dengan sentiment_service.py -# agar token yang dihasilkan konsisten dengan training model. # -# URUTAN: -# 1. Case Folding → lowercase dulu sebelum cleaning -# 2. Cleaning → hapus noise setelah lowercase -# 3. Normalisasi → singkatan/slang → kata baku -# 4. Stopword Removal → buang kata umum, jaga kata sentimen -# 5. Stemming → bentuk dasar via Sastrawi ECS +# ┌─────────────────────────────────────────────────────┐ +# │ 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. +# +# Kenapa perlu? Karena file stopword mengandung kata seperti +# "tidak", "belum", "sangat" yang justru krusial untuk +# menentukan sentimen positif/negatif suatu kalimat. +# ─────────────────────────────────────────────────────────── KATA_SENTIMEN_PENTING = { - # Negasi + # ── Negasi (pembalik makna kalimat) ────────────────── + # "tidak bagus" ≠ "bagus" → "tidak" wajib ada "tidak", "bukan", "jangan", "kurang", "belum", "tanpa", - # Positif + # ── Intensitas (penguat/pelemah sentimen) ───────────── + # "sangat bagus" lebih positif dari "bagus" saja + "sangat", "banget", "sekali", "paling", "amat", + "luar", "biasa", # ← "luar biasa" = dua token, keduanya dijaga + # ── Positif umum ────────────────────────────────────── "keren", "bagus", "mantap", "setuju", "dukung", "mendukung", "andal", "handal", "gercep", "bangga", "senang", "suka", - "baik", "benar", "tepat", "oke", + "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", - "sangat", "banget", "sekali", "paling", "amat", "luar", "biasa", - # Negatif + # ── 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", - # Emosi - "marah", "sedih", "khawatir", + # ── Negatif domain e-commerce / ongkir ──────────────── + "mahal", "lambat", "lelet", "ribet", "susah", "repot", + "rugi", "boros", + # ── Emosi ───────────────────────────────────────────── + "marah", "sedih", "khawatir", "kecewa", } -def _load_stopwords(): +# ─────────────────────────────────────────────────────────── +# LOAD NORMALIZATION DARI FILE +# File: indonesian-normalisasi-slangword-complete.txt +# Format per baris: slang,kata_baku +# Contoh: gk,tidak | ongkir,ongkos kirim | free,gratis +# ─────────────────────────────────────────────────────────── +def _load_normalization() -> dict: + """ + Muat kamus normalisasi dari file eksternal. + + KENAPA DARI FILE? + File berisi 1.700+ pasang slang→baku yang jauh lebih lengkap + dibanding dict hardcoded. Dengan ini, kata seperti: + gk/ga/gak/kagak/ngga → semua jadi "tidak" + bgt/bngt/bget → semua jadi "sangat" + ongkir/ongkr → jadi "ongkos kirim" + ...dan ribuan kasus lainnya tertangani otomatis. + + Setelah file dimuat, override dengan entri khusus domain + (nama platform, singkatan kebijakan) yang mungkin belum ada + di file generik. + """ + 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 + # Split hanya pada koma pertama — nilai bisa mengandung koma + # Contoh: "on the way, sedang di jalan,dijalan" → split jadi 2 bagian + parts = line.split(",", 1) + if len(parts) != 2: + continue + # Bersihkan tanda kutip liar di awal/akhir (ada di beberapa baris file) + slang = parts[0].strip().strip("'\"").lower() + normal = parts[1].strip().lower() + if slang and normal: + norm_dict[slang] = normal + except FileNotFoundError: + # Jika file tidak ditemukan, lanjut dengan dict kosong. + # Entri domain di bawah tetap akan ditambahkan. + st.warning( + "⚠️ File normalisasi tidak ditemukan: " + f"'{norm_file}'. Hanya entri domain yang aktif." + ) + + # ── Override khusus domain ─────────────────────────── + # Entri ini menimpa file generik karena domain spesifik + # membutuhkan perlakuan khusus (nama platform tidak diubah, + # singkatan kebijakan punya padanan resmi, dll.) + 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", + } + norm_dict.update(DOMAIN_OVERRIDES) + + return norm_dict + + +# ─────────────────────────────────────────────────────────── +# LOAD STOPWORDS DARI FILE +# File: indonesian-stopwords-complete.txt +# Format: satu kata per baris +# ─────────────────────────────────────────────────────────── +def _load_stopwords() -> set: + """ + Muat daftar stopword dari file eksternal. + + PROSES SETELAH MUAT FILE: + 1. Hapus KATA_SENTIMEN_PENTING dari daftar + → Agar "tidak", "belum", "sangat", dll. tidak ikut dibuang + 2. Tambahkan noise Twitter/sosmed yang memang harus dibuang + → "rt", "amp", sisa URL, suara tawa, partikel informal + + KENAPA DARI FILE? + File berisi 700+ stopword Indonesia yang lebih lengkap dan + terstandar dibanding daftar manual. Kita tidak perlu menambah/ + mengurangi secara manual kecuali untuk dua kategori di atas. + """ stopword_file = "indonesian-stopwords-complete.txt" - base = set() + base: set = set() + try: with open(stopword_file, "r", encoding="utf-8") as f: - base = set(f.read().splitlines()) + for line in f: + word = line.strip().lower() + if word: + base.add(word) except FileNotFoundError: + # Fallback minimal — cukup untuk tetap jalan + 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", + "jika", "sudah", "telah", "jadi", "bisa", } + + # ── Langkah 1: Lindungi kata sentimen ──────────────── + # Beberapa kata sentimen penting ADA di file stopword + # (misal: "tidak", "belum", "sangat", "paling", "kurang"). + # Kita HAPUS dari stopword agar tidak ikut dibuang. for kata in KATA_SENTIMEN_PENTING: base.discard(kata) - base.update({ - "rt", "amp", "https", "http", "co", "t", - "wkwk", "wkwkwk", "haha", "hehe", "xixi", - "yg", "dgn", "utk", "dr", "krn", "tp", "jd", "sdh", - "aja", "doang", "nih", "sih", "dong", "deh", - "loh", "lah", "tuh", "kak", "gan", - }) + + # ── Langkah 2: Tambah noise Twitter/sosmed ──────────── + # Ini bukan stopword bahasa Indonesia biasa, tapi noise + # yang sangat sering muncul di tweet dan tidak bermakna. + TWITTER_NOISE: set = { + # Artefak Twitter + "rt", "amp", + # Sisa URL setelah cleaning (kadang lolos) + "https", "http", "co", "pic", + # Suara tawa (tidak bermakna untuk sentimen) + "wkwk", "wkwkwk", "wkwkwkwk", + "haha", "hahaha", "hehe", "hihi", "huhu", "xixi", + # Partikel informal yang tidak bermakna + "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() @@ -160,131 +324,198 @@ def _load_stemmer(): return None -NORMALISASI = { - # Negasi - "gk": "tidak", "ga": "tidak", "gak": "tidak", - "nggak": "tidak", "ngga": "tidak", "tdk": "tidak", - "tak": "tidak", "enggak": "tidak", "engga": "tidak", - "kagak": "tidak", "kaga": "tidak", "ndak": "tidak", - "gkk": "tidak", "ngak": "tidak", - # Kata ganti - "yg": "yang", "dgn": "dengan", "utk": "untuk", - "org": "orang", "krn": "karena", "dr": "dari", - "sm": "sama", "pd": "pada", "dlm": "dalam", - "bwt": "buat", "trm": "terima", - # Verba - "tp": "tapi", "tpi": "tapi", "jd": "jadi", - "sdh": "sudah", "blm": "belum", "emg": "memang", - "emang": "memang", "gimana": "bagaimana", - "gitu": "begitu", "gini": "begini", - "udah": "sudah", "udh": "sudah", - "mau": "mau", - # Intensitas - "bgt": "banget", "bngt": "banget", "bget": "banget", "bgtt": "banget", - # Positif informal - "bener": "benar", "beneran": "benar", - "mantep": "mantap", "mntap": "mantap", - "kece": "keren", "kece bgt": "keren banget", - "cucok": "cocok", "cucuk": "cocok", - "cakep": "bagus", "oke bgt": "oke banget", - "sip": "baik", "siipp": "baik", - "top": "terbaik", "topbgt": "terbaik banget", - "jos": "bagus", "josss": "bagus", - "goks": "luar biasa", - "setujuu": "setuju", "stuju": "setuju", - "dukung": "dukung", - "proud": "bangga", - "mantul": "mantap betul", - # Negatif informal - "ancur": "hancur", "ancrr": "hancur", - "parahh": "parah", "parahhh": "parah", - "gagall": "gagal", - "ngaco": "tidak benar", - "ngasal": "tidak benar", - "receh": "tidak penting", - "gaje": "tidak jelas", - "asal": "sembarangan", - # Domain - "ongkir": "ongkos kirim", - "freeongkir": "gratis ongkos kirim", - "gratisongkir": "gratis ongkos kirim", - "free": "gratis", - "ecommerce": "e commerce", - "komdigi": "komdigi", - "subsidi": "subsidi", - "marketplace": "marketplace", - "seller": "penjual", - "buyer": "pembeli", - "online": "online", - "shopee": "shopee", - "tokopedia": "tokopedia", - "lazada": "lazada", - "tiktok": "tiktok", -} +# ═══════════════════════════════════════════════════════════ +# FUNGSI 5 TAHAP PREPROCESSING +# Setiap fungsi bertanggung jawab SATU tahap saja. +# Input & output setiap tahap dijelaskan di docstring. +# ═══════════════════════════════════════════════════════════ - -# ── Tahap 1: Case Folding ─────────────────────────────────────────────────── def step1_case_folding(text: str) -> str: + """ + TAHAP 1 — CASE FOLDING + Input : teks asli (campuran huruf besar/kecil) + Output: semua huruf jadi lowercase + + Kenapa pertama? + Agar tahap berikutnya (cleaning, normalisasi) bekerja + secara konsisten — regex dan dict lookup case-sensitive. + Contoh: "Gratis" → "gratis", "ONGKIR" → "ongkir" + """ return str(text).lower() -# ── Tahap 2: Cleaning ─────────────────────────────────────────────────────── def step2_cleaning(text: str) -> str: + """ + TAHAP 2 — CLEANING + Input : teks lowercase + Output: teks bersih dari semua elemen noise + + Urutan pembersihan PENTING: + 1. URL dulu (sebelum @ dan # agar tidak salah potong) + 2. Mention (@username) + 3. Hashtag (#topik) + 4. Angka + 5. Emoji & simbol unicode + 6. Tanda baca + 7. Karakter non-latin (huruf Arab, Cina, dll.) + 8. Spasi berlebih + """ + # 1. Hapus URL (http, https, www) text = re.sub(r"http\S+|www\S+|https\S+", "", text) + # 2. Hapus mention Twitter (@username) text = re.sub(r"@\w+", "", text) + # 3. Hapus hashtag (#topik) text = re.sub(r"#\w+", "", text) + # 4. Hapus angka dan digit text = re.sub(r"\d+", "", text) + # 5. Hapus emoji & simbol unicode (berbagai range) text = re.sub( - r"[\U00010000-\U0010ffff" - r"\U0001F600-\U0001F64F" - r"\U0001F300-\U0001F5FF" - r"\U0001F680-\U0001F6FF" - r"\U0001F1E0-\U0001F1FF" - r"\u2600-\u26FF\u2700-\u27BF" - r"]+", "", text, flags=re.UNICODE + r"[" + r"\U00010000-\U0010ffff" # Suplemen karakter unicode + r"\U0001F600-\U0001F64F" # Emotikon wajah + r"\U0001F300-\U0001F5FF" # Simbol & piktogram + r"\U0001F680-\U0001F6FF" # Transport & peta + r"\U0001F1E0-\U0001F1FF" # Bendera negara + r"\u2600-\u26FF" # Simbol campuran + r"\u2700-\u27BF" # Dingbats + r"]+", + "", text, flags=re.UNICODE ) + # 6. Hapus tanda baca (.,!?;: dll.) text = text.translate(str.maketrans("", "", string.punctuation)) + # 7. Hapus karakter non-latin (hanya sisakan huruf a-z dan spasi) text = re.sub(r"[^a-zA-Z\s]", "", text) + # 8. Normalisasi spasi berlebih → satu spasi, lalu strip text = re.sub(r"\s+", " ", text).strip() return text -# ── Tahap 3: Normalisasi ──────────────────────────────────────────────────── -def step3_normalization(text: str) -> str: - return " ".join(NORMALISASI.get(word, word) for word in text.split()) +def step3_normalization(text: str, norm_dict: dict) -> str: + """ + TAHAP 3 — NORMALISASI + Input : teks bersih (sudah case fold + cleaning) + norm_dict : kamus {slang: kata_baku} dari file + Output: teks dengan slang/singkatan sudah diganti kata baku + + Cara kerja: token per token (word by word). + Setiap token dicari di norm_dict. + Jika ada → ganti. Jika tidak ada → biarkan. + + Contoh: + "gk bs ongkir" → "tidak bisa ongkos kirim" + "mantep bgt" → "mantap sangat" + + Kenapa setelah Cleaning? + Karena slang di file ditulis dalam bentuk sudah lowercase + dan sudah tanpa tanda baca. Jika normalisasi dilakukan + sebelum cleaning, banyak entri tidak cocok. + """ + tokens = text.split() + normalized = [norm_dict.get(token, token) for token in tokens] + return " ".join(normalized) -# ── Tahap 4: Stopword Removal ─────────────────────────────────────────────── def step4_stopword_removal(tokens: list, stopwords: set) -> list: - return [ - w for w in tokens - if (w not in stopwords or w in KATA_SENTIMEN_PENTING) and len(w) > 2 - ] + """ + TAHAP 4 — STOPWORD REMOVAL + Input : list token (hasil split dari teks ternormalisasi) + stopwords : set kata yang harus dibuang (dari file) + Output: list token bersih + + ATURAN PENYARINGAN (prioritas urutan): + 1. JAGA token yang ada di KATA_SENTIMEN_PENTING + → meskipun juga ada di stopwords, tetap disimpan + 2. BUANG token yang ada di stopwords + 3. BUANG token dengan panjang ≤ 2 karakter + → menghilangkan sisa noise seperti "rt", "yg", "di" + → PENGECUALIAN: token di KATA_SENTIMEN_PENTING tetap disimpan + walau ≤ 2 karakter (contoh: "ok" jika masuk sentimen) + + Kenapa setelah Normalisasi? + Agar "gak" yang sudah dinormalisasi jadi "tidak" tidak ikut + dibuang — "tidak" dilindungi di KATA_SENTIMEN_PENTING. + """ + result = [] + for token in tokens: + # Prioritas 1: selalu simpan jika kata sentimen penting + if token in KATA_SENTIMEN_PENTING: + result.append(token) + continue + # Prioritas 2: buang jika stopword + if token in stopwords: + continue + # Prioritas 3: buang jika terlalu pendek (noise) + if len(token) <= 2: + continue + # Lolos semua filter → simpan + result.append(token) + return result -# ── Tahap 5: Stemming ─────────────────────────────────────────────────────── def step5_stemming(tokens: list, stemmer) -> list: + """ + TAHAP 5 — STEMMING + Input : list token setelah stopword removal + stemmer : objek Sastrawi (atau None) + Output: list token dalam bentuk kata dasar + + Algoritma: Enhanced Confix Stripping (ECS) via Sastrawi + Contoh: + "pengiriman" → "kirim" + "pembatasan" → "batas" + "berlari" → "lari" + "makanan" → "makan" + + Jika stemmer None (Sastrawi tidak terinstal), token dikembalikan + apa adanya tanpa error. + """ if stemmer is None: return tokens - return [stemmer.stem(w) for w in tokens] + return [stemmer.stem(token) for token in tokens] -def full_preprocessing(text: str, stopwords: set, stemmer): +# ─────────────────────────────────────────────────────────── +# FUNGSI UTAMA — JALANKAN SEMUA 5 TAHAP +# ─────────────────────────────────────────────────────────── +def full_preprocessing( + text: str, + stopwords: set, + stemmer, + norm_dict: dict, +) -> dict: """ - Jalankan 5 tahap preprocessing dan kembalikan dict hasil setiap tahap. + Jalankan 5 tahap preprocessing secara berurutan dan kembalikan + hasil setiap tahap sebagai dict (untuk ditampilkan di tabel). - URUTAN TAHAP: - 1. Case Folding → lowercase - 2. Cleaning → hapus noise - 3. Normalisasi → normalisasi kata - 4. Stopword Removal → buang stopword (split() dilakukan internal) - 5. Stemming → bentuk dasar + Parameter: + text : teks tweet asli + stopwords : set stopword (dari _load_stopwords) + stemmer : objek Sastrawi (dari _load_stemmer) + norm_dict : kamus normalisasi (dari _load_normalization) + + Return dict berisi: + setelah_casefolding : hasil Tahap 1 + setelah_cleaning : hasil Tahap 2 + setelah_normalisasi : hasil Tahap 3 + setelah_stopword : hasil Tahap 4 (joined string) + clean_text : hasil akhir Tahap 5 (joined string) + _tokens_clean : hasil Tahap 5 sebagai list (untuk analisis) """ - s1_fold = step1_case_folding(text) - s2_clean = step2_cleaning(s1_fold) - s3_norm = step3_normalization(s2_clean) - s4_filtered = step4_stopword_removal(s3_norm.split(), stopwords) - s5_stemmed = step5_stemming(s4_filtered, stemmer) + # Tahap 1 — Case Folding + s1_fold = step1_case_folding(text) + + # Tahap 2 — Cleaning + s2_clean = step2_cleaning(s1_fold) + + # Tahap 3 — Normalisasi (perlu norm_dict) + s3_norm = step3_normalization(s2_clean, norm_dict) + + # Tahap 4 — Stopword Removal (split → filter → simpan sebagai list) + s4_tokens = s3_norm.split() + s4_filtered = step4_stopword_removal(s4_tokens, stopwords) + + # Tahap 5 — Stemming + s5_stemmed = step5_stemming(s4_filtered, stemmer) return { "setelah_casefolding": s1_fold, @@ -292,7 +523,7 @@ def full_preprocessing(text: str, stopwords: set, stemmer): "setelah_normalisasi": s3_norm, "setelah_stopword": " ".join(s4_filtered), "clean_text": " ".join(s5_stemmed), - "_tokens_clean": s5_stemmed, + "_tokens_clean": s5_stemmed, # list, untuk Counter frekuensi kata } @@ -443,7 +674,7 @@ def _render_page_header(): # PIPELINE STEPS CARDS # ═══════════════════════════════════════════════════════════ -def _render_pipeline_steps(stemmer_ok): +def _render_pipeline_steps(stemmer_ok: bool, norm_count: int, sw_count: int): steps = [ { "num": "01", "anim": "pipe-1", @@ -456,8 +687,8 @@ def _render_pipeline_steps(stemmer_ok): '"ONGKIR" → "ongkir"', '"KEREN" → "keren"', "Seluruh karakter → huruf kecil", - "Dilakukan pertama agar cleaning konsisten", - "Basis untuk normalisasi & stopword", + "Dilakukan PERTAMA agar regex & dict konsisten", + "Fondasi seluruh tahap berikutnya", ], }, { @@ -479,30 +710,30 @@ def _render_pipeline_steps(stemmer_ok): "num": "03", "anim": "pipe-3", "icon": "🔄", "color": "#16a34a", "dark": "#14532d", "bg": "linear-gradient(135deg,#f0fdf4,#dcfce7)", "border": "#86efac", - "title": 'Normalisasi ✦ Diperluas', - "desc": "Mengubah kata tidak baku, singkatan, dan slang menjadi kata baku.", + "title": f'Normalisasi ✦ {norm_count:,} entri', + "desc": "Mengubah kata tidak baku, singkatan, dan slang menjadi kata baku (dari file).", "items": [ - "gk/ga/gak/kagak → tidak", + "gk/ga/gak/kagak/ngga → tidak", + "bgt/bngt/bget → sangat", "ongkir → ongkos kirim", "mantep → mantap", - "kece → keren", - "jos/josss → bagus", "free → gratis", + 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": 'Stopword Removal ✦ Diperbaiki', - "desc": "Membuang kata umum; kata sentimen penting DIJAGA.", + "title": f'Stopword Removal ✦ {sw_count:,} kata', + "desc": "Membuang kata umum dari file; kata sentimen DIJAGA.", "items": [ - "Hapus kata umum (dan, di, ke...)", - "Hapus token < 3 karakter", - "JAGA negasi: tidak, bukan, jangan", - "JAGA positif: keren, bagus, mantap", - "JAGA evaluatif: setuju, dukung, bijak", - "JAGA intensitas: banget, sangat, sekali", + 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, mending, malah", + "JAGA intensitas: sangat, banget, sekali", + "Hapus token ≤ 2 karakter (noise)", ], }, { @@ -678,7 +909,6 @@ def _render_live_example(df_c): "Contoh tweet acak dari dataset — refresh halaman untuk contoh berbeda" ) - # Urutan tampilan sesuai pipeline: CF → Clean → Norm → Stop → Stem steps_ex = [ ("📄 Teks Asli", "text_asli", "#0f172a", "#f8fafc", "#e2e8f0"), ("① Setelah Case Folding", "setelah_casefolding", "#0c4a6e", "#eff6ff", "#bfdbfe"), @@ -727,10 +957,14 @@ def _render_top_words_chart(df_c): ) 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 [])] + 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() + # Filter minimum 3 karakter (konsisten dengan step4) filtered_words = [w for w in all_words if len(w) > 2] word_freq = Counter(filtered_words).most_common(20) @@ -821,19 +1055,29 @@ def show(): unsafe_allow_html=True ) - # ── Pipeline Overview ───────────────────────────────────── + # ── Muat resource preprocessing (sekali per session) ────── + # Semua tiga resource dimuat di sini, bukan di dalam loop, + # agar tidak memuat ulang setiap tweet. + stemmer = _load_stemmer() + stopwords = _load_stopwords() + norm_dict = _load_normalization() + + # ── Pipeline Overview ────────────────────────────────────── _section_header( "🔬 Alur NLP Pipeline — 5 Tahap Preprocessing", "Setiap tweet diproses berurutan melalui 5 tahap sebelum siap dianalisis sentimennya" ) - stemmer_tmp = _load_stemmer() - _render_pipeline_steps(stemmer_tmp is not None) + _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") - # ── Load data ───────────────────────────────────────────── + # ── Load data dari database ──────────────────────────────── try: df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine) if df_all.empty: @@ -848,13 +1092,17 @@ def show(): 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() + 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 - # ── Cache preprocessing ─────────────────────────────────── + # ── Cache preprocessing ──────────────────────────────────── + # Cache key: kombinasi mode + periode + jumlah tweet di DB + # Jika ada tweet baru → cache otomatis invalid → proses ulang 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) @@ -864,6 +1112,7 @@ def show(): f"{start_date}_{end_date}_{total_tweets_in_db}_{latest_crawl_marker}" ) + # Bersihkan cache lama untuk mode/periode yang sudah tidak aktif 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] @@ -871,13 +1120,16 @@ def show(): force_refresh = data_marker != st.session_state.get("_pp_last_data_marker") if cache_key not in st.session_state or force_refresh: - stemmer = _load_stemmer() - stopwords = _load_stopwords() - with st.spinner("🧹 Menjalankan 5 tahap preprocessing…"): results = [] for _, row in df.iterrows(): - r = full_preprocessing(row["text"], stopwords, stemmer) + # Jalankan 5 tahap — norm_dict diteruskan sebagai parameter + 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"] @@ -885,16 +1137,19 @@ def show(): results.append(r) df_c = pd.DataFrame(results) + # Buang baris yang clean_text-nya kosong setelah semua 5 tahap df_c = df_c[df_c["clean_text"].str.strip().str.len() > 0].copy() + # Reset index agar rapi + df_c = df_c.reset_index(drop=True) - st.session_state[cache_key] = df_c - st.session_state[cache_key + "_stemmer_ok"] = stemmer is not None - st.session_state["_pp_last_data_marker"] = data_marker + 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 + "_stemmer_ok", False) + stemmer_ok = st.session_state.get(cache_key + "_sw_ok", False) - # ── Statistik ───────────────────────────────────────────── + # ── Statistik ────────────────────────────────────────────── removed = len(df) - len(df_c) _section_header( @@ -908,7 +1163,7 @@ def show(): _render_live_example(df_c) _gap("lg") - # ── Tabel ───────────────────────────────────────────────── + # ── Tabel ────────────────────────────────────────────────── _section_header( "📋 Tabel Perbandingan Teks per Tahap", f"{len(df_c):,} tweet · {filter_label} — scroll horizontal untuk lihat semua kolom" @@ -918,7 +1173,7 @@ def show(): df_c = df_c.copy() df_c["crawled_at"] = pd.NaT - # Kolom ditampilkan sesuai urutan pipeline: CF → Clean → Norm → Stop → Stem + # Kolom ditampilkan sesuai urutan pipeline disp = df_c[[ "tweet_id", "created_at", "crawled_at", "text_asli", @@ -968,10 +1223,10 @@ def show(): _render_top_words_chart(df_c) _gap("lg") - # ── Simpan ke session state ─────────────────────────────── + # ── Simpan ke session state untuk halaman sentimen ───────── st.session_state["preprocessed_df"] = df_c - # ── Download ────────────────────────────────────────────── + # ── Download ─────────────────────────────────────────────── st.markdown("""
list[tuple[str, float]]: - """ - Prediksi sentimen menggunakan Hybrid Classifier dari sentiment_service. - - Pipeline per tweet: - 1. preprocess_for_model() → teks untuk TF-IDF + NB - 2. preprocess_untuk_lexicon() → teks untuk pengecekan lexicon - 3. _hitung_skor_lexicon() → hitung sinyal positif/negatif kuat - 4. _klasifikasi_hybrid() → putuskan label + confidence - - Return: list of (label, confidence) - """ +def predict_batch_hybrid(texts): results = [] for text in texts: teks_model = preprocess_for_model(text) teks_lexicon = preprocess_untuk_lexicon(text) teks_lower = str(text).lower() - - skor = _hitung_skor_lexicon(teks_lexicon) - label, conf = _klasifikasi_hybrid(teks_model, skor, teks_lower) + skor = _hitung_skor_lexicon(teks_lexicon) + label, conf = _klasifikasi_hybrid(teks_model, skor, teks_lower) results.append((label, conf)) return results -def preprocess_single(text: str) -> str: - """Preprocess satu teks untuk disimpan ke kolom clean_text.""" +def preprocess_single(text): return preprocess_for_model(text) # ───────────────────────────────────────────────────────────── -# Shared UI helpers +# UI helpers # ───────────────────────────────────────────────────────────── def _section_header(title, subtitle=""): @@ -173,6 +138,20 @@ def _section_gap(size="md"): st.markdown(f'
', unsafe_allow_html=True) +def _card_open(extra_style=""): + """Buka div card pengganti st.container(border=True).""" + st.markdown( + f'
', + unsafe_allow_html=True, + ) + + +def _card_close(): + st.markdown('
', unsafe_allow_html=True) + + def _render_sentiment_styles(): st.markdown("""