""" sentiment_service.py — Hybrid Classifier (versi perbaikan v2) ================================================================ PERBAIKAN UTAMA vs versi sebelumnya: MASALAH 1 — NORMALISASI MERUSAK KONTEKS SENTIMEN 'mending' → 'lebih baik' (salah: di domain override) Akibat: "mending ngurusin judol daripada ngurusin ongkir" → "lebih baik ngurusin judol daripada..." → lexicon tangkap 'baik' = POSITIF (SEHARUSNYA NEGATIF) SOLUSI: Override 'mending' → 'mending' (biarkan apa adanya); hapus 'mendingan' → 'lebih baik' dari domain override. MASALAH 2 — LEXICON TERLALU LUAS (KATA KONTEKSTUAL) Kata-kata berikut dihapus dari LEXICON_POSITIF karena terlalu kontekstual — maknanya bergantung penuh pada kalimat sekitar: "baik" → "lebih baik X daripada Y" bukan pujian "benar" → "itu benar" bisa netral "penting" → "lebih penting" bukan pujian "wajar" → "wajar aja" bisa netral "perlu" → "perlu X" bukan pujian "tepat" → bisa kontekstual "jelas" → bisa kontekstual "fair" → bisa kontekstual "manfaat" → bisa kontekstual SOLUSI: Hapus dari LEXICON_POSITIF, pindahkan ke list terpisah yang tidak ikut skor lexicon otomatis. MASALAH 3 — POLA KOMPARATIF TIDAK DIKENALI "mending X daripada Y", "lebih baik X daripada Y" = kritik implisit SOLUSI: Tambahkan deteksi POLA_KOMPARATIF_NEGATIF yang memberi skor negatif ketika pola ini ditemukan di teks. MASALAH 4 — POLA KRITIK TERSIRAT TIDAK DIKENALI "tidak penting", "gak usah", "ngapain", "begini aja" = kritik implisit SOLUSI: Tambahkan POLA_KRITIK_TERSIRAT yang memberi skor negatif ketika pola ini ditemukan. MASALAH 5 — "pembatasan gratis" DIHITUNG POSITIF "pembatasan gratis ongkir" → lexicon tangkap 'gratis' = POSITIF padahal konteksnya adalah keluhan/informasi negatif. SOLUSI: Jika 'gratis' didahului 'pembatasan' dalam window 3 kata, tidak dihitung sebagai sinyal positif. MASALAH 6 — TWEET INFORMATIF DIKLASIFIKASI POSITIF/NEGATIF "saya melihat berita mengenai pembatasan gratis ongkir" → NETRAL SOLUSI: Deteksi KATA_INFORMATIF; jika tweet hanya berisi konteks informatif tanpa kata sentimen eksplisit → dorong ke Netral. """ import re import string import joblib # ═══════════════════════════════════════════════════════════ # KATA SENTIMEN PENTING (dijaga dari stopword removal) # ═══════════════════════════════════════════════════════════ KATA_SENTIMEN_PENTING = { # Negasi "tidak", "bukan", "jangan", "kurang", "belum", "tanpa", # Intensitas "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", "mending", "malah", "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", } # ═══════════════════════════════════════════════════════════ # LEXICON SENTIMEN (DIPERBAIKI — kata kontekstual dihapus) # # PRINSIP PEMILIHAN KATA LEXICON: # Kata masuk lexicon HANYA jika bisa berdiri sendiri sebagai # sinyal sentimen tanpa bergantung konteks kalimat di sekitarnya. # # DIHAPUS dari versi lama karena terlalu kontekstual: # "baik", "benar", "penting", "tepat", "jelas", "transparan", # "amanah", "wajar", "fair", "perlu", "manfaat", "kompetitif" # ═══════════════════════════════════════════════════════════ LEXICON_POSITIF = { # Apresiasi murni "bagus", "keren", "mantap", "mantep", "hebat", "oke", "sip", "top", "jos", "goks", "kece", "mantul", "terbaik", "cakep", "cucok", "gaskeun", "kuy", # Dukungan / persetujuan "setuju", "dukung", "mendukung", "pro", "lanjut", "sepakat", # Emosi positif "bangga", "senang", "suka", "puas", "gembira", "bahagia", "syukur", "alhamdulillah", # Keberhasilan "berhasil", "sukses", "berjaya", # Kualitas SDM/institusi "andal", "handal", "gercep", "sigap", "tanggap", "tegas", "adil", "bijak", "bermanfaat", "berguna", "membantu", "inovatif", "maju", "berkembang", "sejahtera", "terpercaya", # Ekonomi / ongkir positif "untung", "gratis", "murah", "hemat", "terjangkau", "efisien", "mudah", "praktis", "cepat", "aman", "andalan", # Solusi "solusi", "menguntungkan", "memuaskan", "membanggakan", "mengagumkan", # ─── DIHAPUS (terlalu kontekstual): ──────────────── # "baik" → "lebih baik X daripada Y" bukan pujian langsung # "benar" → kontekstual ("itu benar" bisa netral) # "penting" → "lebih penting" bukan pujian # "wajar" → netral kontekstual # "perlu" → netral kontekstual # "tepat" → kontekstual # "jelas" → kontekstual # "fair" → kontekstual # "manfaat" → kontekstual # "kompetitif", "saing" → kontekstual } LEXICON_NEGATIF = { # Penilaian buruk "buruk", "jelek", "parah", "rusak", "hancur", "ancur", "gagal", "ambruk", "terpuruk", "bangkrut", "ngaco", "ngasal", "gaje", "receh", "salah", "keliru", "gegabah", "sembarangan", "ngawur", # Ketidakjujuran "bohong", "tipu", "curang", "manipulasi", "korupsi", "kebohongan", "penipuan", "hoax", "kibul", "monopoli", "licik", # Emosi negatif "kecewa", "marah", "sedih", "khawatir", "takut", "benci", "jijik", "muak", "kesal", "frustrasi", "geram", "dongkol", # Ekonomi / ongkir negatif "mahal", "rugi", "merugikan", "rugikan", "overprice", "lambat", "lemot", "lelet", "boros", "memberatkan", "menyulitkan", # Kesulitan / masalah "ribet", "susah", "sulit", "bermasalah", "repot", "ngeributin", # Sifat mengecewakan "mengecewakan", "menyebalkan", "menyusahkan", # Kritik / penolakan "mengeluh", "protes", "menolak", "tolak", "keberatan", "diskriminasi", "zalim", } KATA_NEGASI = { "tidak", "bukan", "jangan", "belum", "tanpa", "kurang", "anti", "non", "tak", "ga", "gak", "nggak", "ngga", "enggak", "engga", } # ═══════════════════════════════════════════════════════════ # POLA KONTEKSTUAL (BARU — deteksi kritik implisit) # # Pola-pola ini mendeteksi sentimen dari struktur kalimat, # bukan hanya dari kata individual. Sangat penting untuk # tweet berbahasa informal Indonesia. # ═══════════════════════════════════════════════════════════ # Pola komparatif yang menandakan kritik/saran negatif tersirat # Format: (regex_pattern, skor_negatif_tambahan) POLA_KOMPARATIF_NEGATIF = [ # "mending X daripada Y" — membandingkan, menyiratkan Y tidak layak (r"\bmending\b.{1,80}\bdaripada\b", 2), # "lebih baik X daripada Y" — sama dengan di atas (r"\blebih baik\b.{1,80}\bdaripada\b", 1), # "daripada X, mending Y" — variasi urutan (r"\bdaripada\b.{1,50}\bmending\b", 1), # "ketimbang urus ongkir, mending urus judol" (r"\bketimbang\b.{1,60}\b(kebijakan|urus|ngurusin)\b", 1), ] # Pola kritik tersirat — frasa yang menyiratkan ketidaksetujuan # meskipun tidak ada kata negatif eksplisit POLA_KRITIK_TERSIRAT = [ # "tidak penting / gak penting" — dismissif (r"\b(tidak|tak|gak|ga|ngga|nggak)\b.{0,15}\bpenting\b", 1), # "ngapain / buat apa / untuk apa" + konteks kebijakan (r"\b(ngapain|buat apa|untuk apa|ngapain)\b.{1,50}\b(kebijakan|ongkir|aturan|regulasi)\b", 2), # "gak usah / tidak usah / gak perlu" (r"\b(gak|ga|tidak|tak|ngga)\b\s*(usah|perlu)\b", 1), # "percuma / sia-sia / buang-buang" (r"\b(percuma|sia-sia|buang-buang)\b", 2), # "mending urusin yang lain / yang lebih penting" (r"\bmending\b.{1,30}\b(urusin|urus)\b", 1), # "kebijakan begini / aturan begini" — menyiratkan tidak setuju (r"\b(kebijakan|aturan|regulasi)\b.{0,20}\bbegini\b", 1), # "apa gunanya / apa manfaatnya" — retorikal negatif (r"\bapa\b.{0,10}\b(guna|manfaat|untung)\b.{0,10}(nya|sih|ini)\b", 1), ] # Kata informatif — menunjukkan tweet berisi pelaporan/informasi netral KATA_INFORMATIF = { "berita", "melihat", "membaca", "mendengar", "mengetahui", "laporan", "informasi", "kabar", "melaporkan", "dikabarkan", "menurut", "dilaporkan", "diberitakan", "dikutip", "mengutip", } # ═══════════════════════════════════════════════════════════ # LOAD NORMALISASI DARI FILE # PERUBAHAN: Hapus override 'mending' → 'lebih baik' karena # mengubah kata kritis negatif menjadi sinyal positif. # ═══════════════════════════════════════════════════════════ def _load_normalization() -> dict: """ Muat kamus normalisasi dari file eksternal. PERBAIKAN di versi ini: - 'mending' dan 'mendingan' TIDAK lagi dioverride ke 'lebih baik' karena mengakibatkan kata negatif/kritis menjadi sinyal positif di lexicon scoring. Kata ini dibiarkan apa adanya agar POLA_KOMPARATIF dan POLA_KRITIK dapat mendeteksinya. - Override 'malah' dihapus — file normalisasi umum mengubah 'malah' → 'bahkan' yang bisa mengubah konteks sentimen. """ 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: pass # ── Override khusus domain ─────────────────────────────────────────────── # Entri ini menimpa file generik. Perhatikan komentar DIHAPUS di bawah. DOMAIN_OVERRIDES: dict = { # Nama platform — pertahankan apa adanya "shopee": "shopee", "tokopedia": "tokopedia", "lazada": "lazada", "tiktok": "tiktok", "bukalapak": "bukalapak", "blibli": "blibli", # Logistik "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 — pastikan ditangkap "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" # Alasan: "mending X daripada Y" adalah ekspresi kritik. # Mengubah ke "lebih baik" membuat lexicon menangkap 'baik' = POSITIF, # padahal kalimatnya bermakna negatif/kritik. Biarkan "mending" apa # adanya agar POLA_KOMPARATIF_NEGATIF bisa mendeteksinya. # # "mendingan" → TIDAK diubah ke "lebih baik" (alasan sama) # # "malah" → TIDAK dioverride. File normalisasi mengubah 'mlah'→'malah' # yang sudah benar; 'malah' sendiri tidak perlu diubah ke 'bahkan' # karena mengubah nuansa kritis. # # "sip" → TIDAK dioverride ke "baik". "sip" cukup dikenal dan # berdiri sendiri sebagai apresiasi. "baik" sudah dihapus dari # lexicon karena terlalu kontekstual. } norm_dict.update(DOMAIN_OVERRIDES) return norm_dict # ═══════════════════════════════════════════════════════════ # LOAD STOPWORDS DARI FILE # ═══════════════════════════════════════════════════════════ def _load_stopwords() -> set: """ Muat daftar stopword dari file eksternal. PERBAIKAN di versi ini: - Pertahankan 'mending' dari stopword removal. Kata ini penting sebagai penanda POLA_KOMPARATIF_NEGATIF dan POLA_KRITIK_TERSIRAT. - Pertahankan 'daripada' dari stopword removal. Kata ini adalah komponen kunci pola "mending X daripada Y". """ 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: 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", } # ── Lindungi kata sentimen penting ────────────────────────────────────── for kata in KATA_SENTIMEN_PENTING: base.discard(kata) # ── Lindungi kata penanda pola kontekstual ────────────────────────────── # Kata-kata ini diperlukan agar POLA_KOMPARATIF dan POLA_KRITIK bisa # mendeteksi struktur kalimat dengan benar di teks yang sudah bersih. KATA_POLA_PENTING: set = { "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 } for kata in KATA_POLA_PENTING: base.discard(kata) # ── Tambah noise Twitter/sosmed ───────────────────────────────────────── base.update({ "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", }) return base # ═══════════════════════════════════════════════════════════ # LOAD STEMMER # ═══════════════════════════════════════════════════════════ def _load_stemmer(): try: from Sastrawi.Stemmer.StemmerFactory import StemmerFactory return StemmerFactory().create_stemmer() except Exception: return None # ── Inisialisasi global ────────────────────────────────────────────────────── _STOPWORDS = _load_stopwords() _STEMMER = _load_stemmer() _NORM_DICT = _load_normalization() # ═══════════════════════════════════════════════════════════ # MODEL LOADING (lazy, singleton) # ═══════════════════════════════════════════════════════════ _model = None _tfidf = None def _load_model(): global _model, _tfidf if _model is None: try: _model = joblib.load("model_naive_bayes.pkl") except Exception as e: print(f"[WARNING] Gagal load model_naive_bayes.pkl: {e}") if _tfidf is None: try: _tfidf = joblib.load("tfidf_vectorizer.pkl") except Exception as e: print(f"[WARNING] Gagal load tfidf_vectorizer.pkl: {e}") return _model, _tfidf # ═══════════════════════════════════════════════════════════ # 5-TAHAP PREPROCESSING PIPELINE # IDENTIK dengan preprocessing_page.py # ═══════════════════════════════════════════════════════════ def _step1_case_folding(text: str) -> str: return str(text).lower() def _step2_cleaning(text: str) -> str: 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: tokens = text.split() return " ".join(norm_dict.get(token, token) for token in tokens) def _step4_stopword_removal(tokens: list, stopwords: set) -> list: result = [] for token in tokens: if token in KATA_SENTIMEN_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: if stemmer is None: return tokens return [stemmer.stem(token) for token in tokens] def preprocess_for_model(text: str) -> str: """Full 5-tahap preprocessing → string teks bersih siap TF-IDF.""" s1 = _step1_case_folding(text) s2 = _step2_cleaning(s1) s3 = _step3_normalization(s2, _NORM_DICT) s4 = _step4_stopword_removal(s3.split(), _STOPWORDS) s5 = _step5_stemming(s4, _STEMMER) return " ".join(s5) def preprocess_untuk_lexicon(text: str) -> str: """ Preprocessing untuk lexicon matching. PENTING: TIDAK distem dan TIDAK dihapus stopword-nya agar pola kontekstual (mending, daripada, begini, dll.) tetap ada dan bisa dideteksi oleh POLA_KOMPARATIF dan POLA_KRITIK. """ s1 = _step1_case_folding(text) s2 = _step2_cleaning(s1) s3 = _step3_normalization(s2, _NORM_DICT) return s3 # Kembalikan setelah normalisasi saja — stopword & stem TIDAK dilakukan # ═══════════════════════════════════════════════════════════ # LEXICON SCORER (DIPERBAIKI — pola kontekstual + pembatasan) # ═══════════════════════════════════════════════════════════ def _hitung_skor_lexicon(teks_lexicon: str) -> dict: """ Hitung skor sentimen via lexicon + pola kontekstual + negation handling. PERUBAHAN UTAMA vs versi lama: ───────────────────────────────────────────────────────────────────── 1. POLA_KOMPARATIF_NEGATIF: "mending X daripada Y" → tambah skor negatif 2. POLA_KRITIK_TERSIRAT: "tidak penting", "gak usah", dll. → skor negatif 3. POLA 'pembatasan gratis': 'gratis' tidak dihitung positif jika didahului 'pembatasan' dalam window 3 kata 4. DETEKSI TWEET INFORMATIF: jika teks hanya berisi konteks informasi tanpa kata sentimen eksplisit, tandai sebagai 'informatif' 5. Window negasi tetap 3 kata (dari versi sebelumnya) ───────────────────────────────────────────────────────────────────── Return: {"positif": int, "negatif": int, "net": int, "informatif": bool} """ tokens = teks_lexicon.split() skor_pos = 0 skor_neg = 0 # ── Tahap 1: Deteksi pola komparatif ────────────────────────────────── for pola, bobot in POLA_KOMPARATIF_NEGATIF: if re.search(pola, teks_lexicon): skor_neg += bobot # ── Tahap 2: Deteksi pola kritik tersirat ───────────────────────────── for pola, bobot in POLA_KRITIK_TERSIRAT: if re.search(pola, teks_lexicon): skor_neg += bobot # ── Tahap 3: Lexicon per token ──────────────────────────────────────── for i, token in enumerate(tokens): # Negation window: 3 kata sebelumnya ada_negasi = any( tokens[i - j] in KATA_NEGASI for j in range(1, 4) if i - j >= 0 ) # Konteks pembatasan: 'gratis' setelah 'pembatasan' tidak = positif ada_pembatasan = any( tokens[i - j] == "pembatasan" for j in range(1, 4) if i - j >= 0 ) if token in LEXICON_POSITIF: if ada_negasi: skor_neg += 1 elif ada_pembatasan and token == "gratis": # "pembatasan gratis ongkir" = konteks negatif/netral, # bukan pujian terhadap 'gratis'. Lewati. pass else: skor_pos += 1 elif token in LEXICON_NEGATIF: if ada_negasi: skor_pos += 1 else: skor_neg += 1 # ── Tahap 4: Deteksi tweet informatif ──────────────────────────────── # Tweet informatif: mengandung kata pelaporan, TIDAK ada kata sentimen # eksplisit, dan panjang teks tidak terlalu pendek. token_set = set(tokens) ada_info_kata = bool(token_set & KATA_INFORMATIF) ada_sentimen_eksplisit = bool( (token_set & LEXICON_POSITIF) | (token_set & LEXICON_NEGATIF) ) # Informatif jika: ada kata informatif + tidak ada sentimen eksplisit # + skor negatif dari pola kecil (≤1, artinya cuma 'pembatasan') is_informatif = ( ada_info_kata and not ada_sentimen_eksplisit and skor_neg <= 1 ) return { "positif": skor_pos, "negatif": skor_neg, "net": skor_pos - skor_neg, "informatif": is_informatif, } # ═══════════════════════════════════════════════════════════ # PREDIKSI MODEL NB # ═══════════════════════════════════════════════════════════ _LABEL_MAP = { "positif": "Positif", "Positif": "Positif", "positive": "Positif", "pos": "Positif", "negatif": "Negatif", "Negatif": "Negatif", "negative": "Negatif", "neg": "Negatif", "netral": "Netral", "Netral": "Netral", "neutral": "Netral", "net": "Netral", } def _prediksi_model(teks_model: str): """ Prediksi dari model NB 3-kelas. Return: (label_norm, confidence, proba_dict) atau None jika gagal. """ model, tfidf = _load_model() if model is None or tfidf is None or not teks_model.strip(): return None try: vec = tfidf.transform([teks_model]) pred_raw = model.predict(vec)[0] proba = model.predict_proba(vec)[0] classes = list(model.classes_) label_norm = _LABEL_MAP.get(str(pred_raw), "Netral") pred_idx = classes.index(pred_raw) if pred_raw in classes else 0 confidence = float(proba[pred_idx]) proba_dict = { _LABEL_MAP.get(str(cls), str(cls)): float(p) for cls, p in zip(classes, proba) } return label_norm, confidence, proba_dict except Exception as e: print(f"[WARNING] Prediksi model gagal: {e}") return None # ═══════════════════════════════════════════════════════════ # HYBRID CLASSIFIER (DIPERBAIKI) # ═══════════════════════════════════════════════════════════ def _klasifikasi_hybrid( teks_model: str, skor: dict, teks_lower: str, ) -> tuple: """ Klasifikasi hybrid: sinyal lexicon + pola kontekstual + model NB. ALUR KEPUTUSAN: ───────────────────────────────────────────────────────────────────── Layer 0 — Override Informatif: Jika skor['informatif'] = True DAN net mendekati 0 → Netral langsung. Mencegah tweet berita/informatif terklasifikasi positif/negatif karena kata domain (mis. 'gratis' dalam "berita mengenai gratis ongkir"). Layer 1a — Override Positif KUAT (net >= 2): Sinyal lexicon + pola sangat kuat → Positif langsung. Layer 1b — Override Negatif KUAT (net <= -2): Sinyal lexicon + pola sangat kuat → Negatif langsung. CATATAN: Pola komparatif "mending X daripada Y" menambah net -2/-3 sehingga tweet kritik implisit langsung terdeteksi di sini. Layer 2a — Override Positif LEMAH (net == 1): Konsultasi model. Jika model tidak sangat yakin Negatif → Positif. Layer 2b — Override Negatif LEMAH (net == -1): Konsultasi model. Jika model tidak sangat yakin Positif → Negatif. Layer 3 — Fallback Model NB: Anti-bias: - Model Negatif + lexicon bersih (net >= 0) + conf < 0.65 → Netral - Model Positif + lexicon negatif (net <= -1) + conf < 0.65 → Netral Layer 4 — Ultimate Fallback (model tidak tersedia): Gunakan sinyal lexicon saja. ───────────────────────────────────────────────────────────────────── Return: (label: str, confidence: float) """ net = skor["net"] is_info = skor.get("informatif", False) # ── Layer 0: Override tweet informatif ─────────────────────────────── # Tweet yang hanya berisi pelaporan/informasi tanpa sentimen eksplisit # dan net mendekati 0 → langsung Netral agar tidak salah klasifikasi. if is_info and -1 <= net <= 1: return ("Netral", 0.52) # ── Layer 1a: Positif KUAT ──────────────────────────────────────────── if net >= 2: conf = min(0.65 + (net * 0.05), 0.92) return ("Positif", round(conf, 3)) # ── Layer 1b: Negatif KUAT ──────────────────────────────────────────── # Pola komparatif ("mending X daripada Y") memberi net -2 hingga -4, # sehingga tweet kritik implisit langsung tertangkap di layer ini. if net <= -2: conf = min(0.65 + (abs(net) * 0.05), 0.92) return ("Negatif", round(conf, 3)) # ── Layer 2a: Positif LEMAH ─────────────────────────────────────────── if net == 1: model_result = _prediksi_model(teks_model) if model_result is not None: _, _, proba_dict = model_result neg_prob = proba_dict.get("Negatif", 0.0) if neg_prob < 0.70: conf = round(0.55 + (0.70 - neg_prob) * 0.30, 3) return ("Positif", min(conf, 0.82)) else: return ("Positif", 0.55) return ("Positif", 0.58) # ── Layer 2b: Negatif LEMAH ─────────────────────────────────────────── if net == -1: model_result = _prediksi_model(teks_model) if model_result is not None: _, _, proba_dict = model_result pos_prob = proba_dict.get("Positif", 0.0) if pos_prob < 0.70: conf = round(0.55 + (0.70 - pos_prob) * 0.30, 3) return ("Negatif", min(conf, 0.82)) else: return ("Negatif", 0.55) return ("Negatif", 0.58) # ── Layer 3: Fallback Model NB ──────────────────────────────────────── model_result = _prediksi_model(teks_model) if model_result is not None: label_norm, confidence, proba_dict = model_result # Anti-bias 1: Model Negatif tapi lexicon bersih → Netral if label_norm == "Negatif" and net >= 0 and confidence < 0.65: corrected_conf = round(0.50 + max(0, confidence - 0.50) * 0.20, 3) return ("Netral", corrected_conf) # Anti-bias 2: Model Positif tapi lexicon negatif → Netral if label_norm == "Positif" and net <= -1 and confidence < 0.65: corrected_conf = round(0.50 + max(0, confidence - 0.50) * 0.20, 3) return ("Netral", corrected_conf) return (label_norm, round(confidence, 3)) # ── Layer 4: Ultimate Fallback ──────────────────────────────────────── if net > 0: return ("Positif", 0.55) elif net < 0: return ("Negatif", 0.55) else: return ("Netral", 0.50) # ═══════════════════════════════════════════════════════════ # PUBLIC API # ═══════════════════════════════════════════════════════════ def analisis_sentimen_single(text: str) -> tuple: """ Analisis sentimen satu teks. Return: (label, confidence) label: 'Positif' | 'Netral' | 'Negatif' """ teks_model = preprocess_for_model(text) teks_lexicon = preprocess_untuk_lexicon(text) teks_lower = str(text).lower() skor = _hitung_skor_lexicon(teks_lexicon) return _klasifikasi_hybrid(teks_model, skor, teks_lower) def analisis_sentimen_batch(texts: list) -> list: """ Analisis sentimen batch. Return: list of (label, confidence) """ return [analisis_sentimen_single(text) for text in texts] # ═══════════════════════════════════════════════════════════ # BACKWARD COMPATIBILITY # ═══════════════════════════════════════════════════════════ def bersihkan_teks(text: str) -> str: """[LEGACY] Gunakan preprocess_for_model().""" return preprocess_for_model(text) def prediksi_sentimen(list_text: list): """ [LEGACY] Prediksi batch. Return: (list clean_texts, list labels) """ clean_texts = [preprocess_for_model(t) for t in list_text] labels = [analisis_sentimen_single(t)[0] for t in list_text] return clean_texts, labels