revisi prepo dikit

This commit is contained in:
adistya13 2026-06-01 06:19:50 +07:00
parent 68319b9d23
commit dc707605b3
10 changed files with 484 additions and 316 deletions

View File

@ -1 +1 @@
{"activated": true, "updated_at": "2026-05-29T05:53:53.439233+00:00"}
{"activated": true, "updated_at": "2026-05-31T01:21:56.067731+00:00"}

View File

@ -10,13 +10,17 @@ PIPELINE 5 TAHAP:
4. Stopword Removal hapus kata umum (DARI FILE stopword); JAGA kata sentimen
5. Stemming bentuk dasar kata via Sastrawi ECS
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).
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
@ -119,18 +123,16 @@ def _sync_dynamic_period():
# 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.
# 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 bagus" ≠ "bagus" → "tidak" wajib ada
"tidak", "bukan", "jangan", "kurang", "belum", "tanpa",
# ── Intensitas (penguat/pelemah sentimen) ─────────────
# "sangat bagus" lebih positif dari "bagus" saja
"sangat", "banget", "sekali", "paling", "amat",
"luar", "biasa", # ← "luar biasa" = dua token, keduanya dijaga
"luar", "biasa",
# ── Positif umum ──────────────────────────────────────
"keren", "bagus", "mantap", "setuju", "dukung", "mendukung",
"andal", "handal", "gercep", "bangga", "senang", "suka",
@ -150,30 +152,52 @@ KATA_SENTIMEN_PENTING = {
"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
# File: indonesian-normalisasi-slangword-complete.txt
# Format per baris: slang,kata_baku
# Contoh: gk,tidak | ongkir,ongkos kirim | free,gratis
# PERBAIKAN: Hapus override 'mending' → 'lebih baik'
# ───────────────────────────────────────────────────────────
def _load_normalization() -> dict:
"""
Muat kamus normalisasi dari file eksternal.
KENAPA DARI FILE?
File berisi 1.700+ pasang slangbaku 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.
PERUBAHAN DARI VERSI SEBELUMNYA:
- 'mending' TIDAK lagi dioverride ke 'lebih baik'
- 'mendingan' TIDAK lagi dioverride ke 'lebih baik'
Setelah file dimuat, override dengan entri khusus domain
(nama platform, singkatan kebijakan) yang mungkin belum ada
di file generik.
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 = {}
@ -184,28 +208,20 @@ def _load_normalization() -> dict:
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",
@ -237,6 +253,28 @@ def _load_normalization() -> dict:
"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)
@ -245,23 +283,22 @@ def _load_normalization() -> dict:
# ───────────────────────────────────────────────────────────
# LOAD STOPWORDS DARI FILE
# File: indonesian-stopwords-complete.txt
# Format: satu kata per baris
# PERBAIKAN: Lindungi kata pola penting dari stopword removal
# ───────────────────────────────────────────────────────────
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.
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()
@ -273,7 +310,6 @@ def _load_stopwords() -> set:
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."
@ -286,24 +322,21 @@ def _load_stopwords() -> set:
}
# ── 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)
# ── Langkah 2: Tambah noise Twitter/sosmed ────────────
# Ini bukan stopword bahasa Indonesia biasa, tapi noise
# yang sangat sering muncul di tweet dan tidak bermakna.
# ── 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 = {
# 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",
}
@ -326,8 +359,6 @@ def _load_stemmer():
# ═══════════════════════════════════════════════════════════
# FUNGSI 5 TAHAP PREPROCESSING
# Setiap fungsi bertanggung jawab SATU tahap saja.
# Input & output setiap tahap dijelaskan di docstring.
# ═══════════════════════════════════════════════════════════
def step1_case_folding(text: str) -> str:
@ -335,11 +366,6 @@ 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()
@ -349,43 +375,25 @@ 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"["
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"\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
)
# 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
@ -393,22 +401,11 @@ def step2_cleaning(text: str) -> str:
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
Input : teks bersih + norm_dict 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.
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]
@ -418,36 +415,24 @@ def step3_normalization(text: str, norm_dict: dict) -> str:
def step4_stopword_removal(tokens: list, stopwords: set) -> list:
"""
TAHAP 4 STOPWORD REMOVAL
Input : list token (hasil split dari teks ternormalisasi)
stopwords : set kata yang harus dibuang (dari file)
Input : list token + stopwords 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.
PERUBAHAN: Kata pola penting (mending, daripada, dll.) dilindungi
dari pembuangan melalui KATA_POLA_PENTING di _load_stopwords().
"""
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 KATA_POLA_PENTING:
result.append(token)
continue
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
@ -456,18 +441,8 @@ 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
@ -484,37 +459,14 @@ def full_preprocessing(
norm_dict: dict,
) -> dict:
"""
Jalankan 5 tahap preprocessing secara berurutan dan kembalikan
hasil setiap tahap sebagai dict (untuk ditampilkan di tabel).
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)
Jalankan 5 tahap preprocessing secara berurutan.
Return dict berisi hasil setiap tahap.
"""
# 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 {
@ -523,7 +475,7 @@ def full_preprocessing(
"setelah_normalisasi": s3_norm,
"setelah_stopword": " ".join(s4_filtered),
"clean_text": " ".join(s5_stemmed),
"_tokens_clean": s5_stemmed, # list, untuk Counter frekuensi kata
"_tokens_clean": s5_stemmed,
}
@ -711,13 +663,13 @@ def _render_pipeline_steps(stemmer_ok: bool, norm_count: int, sw_count: int):
"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 kata tidak baku, singkatan, dan slang menjadi kata baku (dari file).",
"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 → sangat",
"bgt/bngt/bget → banget",
"ongkir → ongkos kirim",
"mantep → mantap",
"free → gratis",
"⚠️ mending → mending (dijaga, bukan 'lebih baik')",
f"Total: {norm_count:,} pasang slang→baku dimuat",
],
},
@ -726,13 +678,13 @@ def _render_pipeline_steps(stemmer_ok: bool, norm_count: int, sw_count: int):
"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 dari file; kata sentimen DIJAGA.",
"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, mending, malah",
"JAGA intensitas: sangat, banget, sekali",
"JAGA negatif: kecewa, buruk, gagal, mahal",
"JAGA pola: mending, daripada, percuma, begini",
"Hapus token ≤ 2 karakter (noise)",
],
},
@ -753,14 +705,12 @@ def _render_pipeline_steps(stemmer_ok: bool, norm_count: int, sw_count: int):
},
]
# Baris 1: 3 kartu pertama
row1 = st.columns(3, gap="medium")
for col, step in zip(row1, steps[:3]):
_render_step_card(col, step)
_gap("sm")
# Baris 2: 2 kartu terakhir (tengah agar simetris)
_, col4, col5, _ = st.columns([0.5, 1, 1, 0.5], gap="medium")
_render_step_card(col4, steps[3])
_render_step_card(col5, steps[4])
@ -964,7 +914,6 @@ def _render_top_words_chart(df_c):
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)
@ -1055,14 +1004,10 @@ def show():
unsafe_allow_html=True
)
# ── 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"
@ -1077,7 +1022,6 @@ def show():
_render_flow_arrow()
_gap("md")
# ── Load data dari database ────────────────────────────────
try:
df_all = pd.read_sql("SELECT * FROM tweets ORDER BY created_at DESC", engine)
if df_all.empty:
@ -1100,9 +1044,6 @@ def show():
st.warning(f"⚠️ Tidak ada tweet dengan tanggal asli dalam periode {filter_label}.")
return
# ── 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)
@ -1112,7 +1053,6 @@ 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]
@ -1123,7 +1063,6 @@ def show():
with st.spinner("🧹 Menjalankan 5 tahap preprocessing…"):
results = []
for _, row in df.iterrows():
# Jalankan 5 tahap — norm_dict diteruskan sebagai parameter
r = full_preprocessing(
text = row["text"],
stopwords = stopwords,
@ -1137,9 +1076,7 @@ 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
@ -1149,7 +1086,6 @@ def show():
df_c = st.session_state[cache_key]
stemmer_ok = st.session_state.get(cache_key + "_sw_ok", False)
# ── Statistik ──────────────────────────────────────────────
removed = len(df) - len(df_c)
_section_header(
@ -1163,7 +1099,6 @@ def show():
_render_live_example(df_c)
_gap("lg")
# ── Tabel ──────────────────────────────────────────────────
_section_header(
"📋 Tabel Perbandingan Teks per Tahap",
f"{len(df_c):,} tweet · {filter_label} — scroll horizontal untuk lihat semua kolom"
@ -1173,7 +1108,6 @@ def show():
df_c = df_c.copy()
df_c["crawled_at"] = pd.NaT
# Kolom ditampilkan sesuai urutan pipeline
disp = df_c[[
"tweet_id", "created_at", "crawled_at",
"text_asli",
@ -1223,10 +1157,8 @@ def show():
_render_top_words_chart(df_c)
_gap("lg")
# ── Simpan ke session state untuk halaman sentimen ─────────
st.session_state["preprocessed_df"] = df_c
# ── Download ───────────────────────────────────────────────
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);

View File

@ -1,15 +1,51 @@
"""
sentiment_service.py Hybrid Classifier (versi perbaikan)
=============================================================
Perbaikan utama vs versi sebelumnya:
1. LEXICON DIPERLUAS kata positif & negatif yang tidak ada di vocab TF-IDF
(bagus, mantap, setuju, puas, hemat, berhasil, dll.) kini tetap bisa
terdeteksi melalui lexicon scoring.
2. THRESHOLD DISESUAIKAN Layer 1 diperlonggar (net >= 2 Positif),
Layer anti-bias diperketat agar prediksi lebih proporsional.
3. NEGATION WINDOW DIPERLUAS window 3 kata (sebelumnya 2) agar
"tidak terlalu bagus" tetap terdeteksi negasinya.
4. PREPROCESSING IDENTIK dengan preprocessing_page.py (5 tahap).
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
@ -49,60 +85,78 @@ KATA_SENTIMEN_PENTING = {
# ═══════════════════════════════════════════════════════════
# LEXICON SENTIMEN
# Diperluas agar kata yang tidak ada di vocab TF-IDF tetap
# bisa berkontribusi melalui jalur lexicon scoring.
# 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 = {
# Umum & informal
"bagus", "baik", "keren", "mantap", "mantep", "hebat",
# 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",
"berhasil", "sukses", "berjaya", "prestasi", "pencapaian",
"syukur", "alhamdulillah",
# Keberhasilan
"berhasil", "sukses", "berjaya",
# Kualitas SDM/institusi
"andal", "handal", "gercep", "sigap", "tanggap", "tegas",
"adil", "bijak", "bermanfaat", "berguna", "membantu",
"inovatif", "maju", "berkembang", "sejahtera",
"benar", "tepat", "jelas", "transparan", "amanah", "terpercaya",
"inovatif", "maju", "berkembang", "sejahtera", "terpercaya",
# Ekonomi / ongkir positif
"untung", "gratis", "murah", "hemat", "terjangkau",
"terbaik", "luar biasa",
"cakep", "cucok", "gaskeun", "kuy",
"solusi", "manfaat", "menguntungkan",
"memuaskan", "membanggakan", "mengagumkan",
"sehat", "fair", "wajar",
"syukur", "alhamdulillah",
"saing", "kompetitif",
# Domain ongkir/ecommerce positif
"terjangkau", "hemat", "efisien", "mudah", "praktis",
"cepat", "aman", "terpercaya", "andalan",
# Dukungan kebijakan
"dukung", "setuju", "bagus", "tepat", "bijak",
"perlu", "penting", "benar", "wajar", "adil",
"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 = {
# Umum
# Penilaian buruk
"buruk", "jelek", "parah", "rusak", "hancur", "ancur",
"gagal", "ambruk", "terpuruk", "bangkrut",
"bohong", "tipu", "curang", "manipulasi", "korupsi",
"kebohongan", "penipuan",
"kecewa", "mending", "malah", "marah", "sedih",
"khawatir", "takut", "benci", "jijik", "muak",
"kesal", "frustrasi", "geram", "dongkol",
"mahal", "rugi", "merugikan", "rugikan",
"lambat", "lemot", "lelet", "ribet", "susah",
"sulit", "bermasalah",
"ngaco", "ngasal", "gaje", "receh",
"tolol", "bodoh", "idiot", "goblok",
"mengecewakan", "menyebalkan", "menyusahkan",
"salah", "keliru", "gegabah", "sembarangan", "ngawur",
# Ketidakjujuran
"bohong", "tipu", "curang", "manipulasi", "korupsi",
"kebohongan", "penipuan", "hoax", "kibul",
"monopoli", "licik",
# Domain ongkir negatif
"boros", "memberatkan", "menyulitkan",
"repot", "ribet", "ngeributin", "ribut",
# Kritik kebijakan
"salah", "keliru", "gegabah", "sembarangan",
"tidak jelas", "ngawur", "asal",
# 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 = {
@ -112,11 +166,72 @@ KATA_NEGASI = {
}
# ═══════════════════════════════════════════════════════════
# 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:
@ -135,34 +250,67 @@ def _load_normalization() -> dict:
except FileNotFoundError:
pass
DOMAIN_OVERRIDES = {
"shopee": "shopee", "tokopedia": "tokopedia", "lazada": "lazada",
"tiktok": "tiktok", "bukalapak": "bukalapak", "blibli": "blibli",
"sicepat": "sicepat", "jne": "jne", "jnt": "jnt",
"anteraja": "anteraja", "ninja": "ninja",
# ── 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",
"komdigi": "komdigi", "kemendag": "kementerian perdagangan",
"ongkir": "ongkos kirim",
"ongkr": "ongkos kirim",
"bykrm": "biaya kirim",
"biayakirim": "biaya pengiriman",
# Kebijakan & lembaga
"komdigi": "komdigi",
"kemendag": "kementerian perdagangan",
"kominfo": "kementerian komunikasi",
"ecommerce": "e commerce", "marketplace": "marketplace",
"seller": "penjual", "buyer": "pembeli", "online": "online",
# Negasi tambahan
# 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
"bgt": "banget", "bngt": "banget", "bget": "banget", "bgtt": "banget",
# Positif informal — hanya yang benar-benar positif
"mantep": "mantap", "mntap": "mantap",
"bener": "benar", "beneran": "benar",
"kece": "keren", "sip": "baik",
# Negatif informal
"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
@ -173,6 +321,15 @@ def _load_normalization() -> dict:
# ═══════════════════════════════════════════════════════════
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:
@ -189,11 +346,26 @@ def _load_stopwords() -> set:
"jika", "sudah", "telah", "jadi", "bisa",
}
# Lindungi kata sentimen penting
# ── Lindungi kata sentimen penting ──────────────────────────────────────
for kata in KATA_SENTIMEN_PENTING:
base.discard(kata)
# Tambah noise Twitter
# ── 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",
@ -216,7 +388,7 @@ def _load_stemmer():
return None
# ── Inisialisasi global (dimuat sekali saat modul diimport)
# ── Inisialisasi global ─────────────────────────────────────────────────────
_STOPWORDS = _load_stopwords()
_STEMMER = _load_stemmer()
_NORM_DICT = _load_normalization()
@ -247,6 +419,7 @@ def _load_model():
# ═══════════════════════════════════════════════════════════
# 5-TAHAP PREPROCESSING PIPELINE
# IDENTIK dengan preprocessing_page.py
# ═══════════════════════════════════════════════════════════
def _step1_case_folding(text: str) -> str:
@ -312,54 +485,104 @@ def preprocess_for_model(text: str) -> str:
def preprocess_untuk_lexicon(text: str) -> str:
"""Preprocessing ringan untuk lexicon matching (tanpa stemming)."""
"""
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
return s3 # Kembalikan setelah normalisasi saja — stopword & stem TIDAK dilakukan
# ═══════════════════════════════════════════════════════════
# LEXICON SCORER (dengan negation window diperluas ke 3)
# LEXICON SCORER (DIPERBAIKI — pola kontekstual + pembatasan)
# ═══════════════════════════════════════════════════════════
def _hitung_skor_lexicon(teks_lexicon: str) -> dict:
"""
Hitung skor sentimen via lexicon + negation handling.
Hitung skor sentimen via lexicon + pola kontekstual + negation handling.
Perubahan vs versi lama:
- Window negasi diperluas menjadi 3 kata (sebelumnya 2)
- Lexicon positif/negatif lebih luas (kata yang tidak ada di vocab TF-IDF)
Return: {"positif": int, "negatif": int, "net": int}
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):
# Cek negasi dalam window 3 kata sebelumnya
# 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,
}
@ -406,7 +629,7 @@ def _prediksi_model(teks_model: str):
# ═══════════════════════════════════════════════════════════
# HYBRID CLASSIFIER (diperkuat)
# HYBRID CLASSIFIER (DIPERBAIKI)
# ═══════════════════════════════════════════════════════════
def _klasifikasi_hybrid(
@ -415,66 +638,75 @@ def _klasifikasi_hybrid(
teks_lower: str,
) -> tuple:
"""
Klasifikasi hybrid: sinyal lexicon + model NB 3-kelas.
Klasifikasi hybrid: sinyal lexicon + pola kontekstual + model NB.
PERUBAHAN vs versi lama:
Layer 1 Override Positif KUAT (net >= 2):
Confidence lebih tinggi karena lexicon lebih kuat.
Range: 0.650.92
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):
Simetris dengan positif.
Range: 0.650.90
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 2 Override Positif LEMAH (net == 1):
Diperlonggar: sekarang berlaku jika model tidak sangat
yakin negatif (threshold 0.70, sebelumnya 0.65).
Layer 2a Override Positif LEMAH (net == 1):
Konsultasi model. Jika model tidak sangat yakin Negatif Positif.
Layer 2b Override Negatif LEMAH (net == -1):
Simetris baru. Sebelumnya tidak ada.
Konsultasi model. Jika model tidak sangat yakin Positif Negatif.
Layer 3 Fallback Model NB:
Anti-bias diperketat:
- Model Negatif + lexicon bersih (net >= 0) + conf < 0.65
turunkan ke Netral (threshold naik dari 0.70 ke 0.65)
- Model Positif + lexicon negatif kuat (net <= -1) + conf < 0.65
turunkan ke Netral
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:
Lexicon saja jika model tidak tersedia.
Layer 4 Ultimate Fallback (model tidak tersedia):
Gunakan sinyal lexicon saja.
Return: (label: str, confidence: float)
"""
net = skor["net"]
pos = skor["positif"]
neg = skor["negatif"]
is_info = skor.get("informatif", False)
# ── Layer 1a: Positif KUAT ───────────────────────────────────────────────
# ── 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 ───────────────────────────────────────────────
# ── 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.90)
conf = min(0.65 + (abs(net) * 0.05), 0.92)
return ("Negatif", round(conf, 3))
# ── Layer 2a: Positif LEMAH ──────────────────────────────────────────────
if net == 1 and pos >= 1:
# ── 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: # diperlonggar dari 0.65
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 (baru) ───────────────────────────────────────
if net == -1 and neg >= 1:
# ── Layer 2b: Negatif LEMAH ───────────────────────────────────────────
if net == -1:
model_result = _prediksi_model(teks_model)
if model_result is not None:
_, _, proba_dict = model_result
@ -486,7 +718,7 @@ def _klasifikasi_hybrid(
return ("Negatif", 0.55)
return ("Negatif", 0.58)
# ── Layer 3: Fallback Model NB 3-kelas ───────────────────────────────────
# ── Layer 3: Fallback Model NB ────────────────────────────────────────
model_result = _prediksi_model(teks_model)
if model_result is not None:
label_norm, confidence, proba_dict = model_result
@ -503,7 +735,7 @@ def _klasifikasi_hybrid(
return (label_norm, round(confidence, 3))
# ── Layer 4: Ultimate Fallback ────────────────────────────────────────────
# ── Layer 4: Ultimate Fallback ────────────────────────────────────────
if net > 0:
return ("Positif", 0.55)
elif net < 0:

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@ -1,8 +1,10 @@
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
"2058829602865451445","Mon May 25 08:36:43 +0000 2026","0","komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir","2058829602865451445","","","in","","0","0","0","https://x.com/undefined/status/2058829602865451445","2046458899843170304",
"2058950111976386607","Mon May 25 16:35:34 +0000 2026","0","komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju","2058950111976386607","","","in","","0","0","0","https://x.com/undefined/status/2058950111976386607","2046458899843170304",
"2058950559856832819","Mon May 25 16:37:21 +0000 2026","0","setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini","2058950559856832819","","","in","","0","0","0","https://x.com/undefined/status/2058950559856832819","2046458899843170304",
"2060234400777064942","Fri May 29 05:38:53 +0000 2026","0","tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi","2060234400777064942","","","in","","0","0","0","https://x.com/undefined/status/2060234400777064942","1814143201718247424",
"2058816188659192179","Mon May 25 07:43:24 +0000 2026","0","komdigi gajelas bikin kebijakan ongkir!","2058816188659192179","","","in","","0","0","0","https://x.com/undefined/status/2058816188659192179","1814143201718247424",
"2058829602865451445","Mon May 25 08:36:43 +0000 2026","0","komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir","2058829602865451445","","","in","","0","0","0","https://x.com/undefined/status/2058829602865451445","2046458899843170304",
"2060580175084204231","Sat May 30 04:32:52 +0000 2026","0","komdigi gratis ongkir sangat banyak yang tidak setuju","2060580175084204231","","","in","","0","0","0","https://x.com/undefined/status/2060580175084204231","1814143201718247424",
"2059619299309011403","Wed May 27 12:54:41 +0000 2026","0","komdigi ngeluarin kebijakan ongkir kaya gini ngapain dah","2059619299309011403","","","in","","0","0","0","https://x.com/undefined/status/2059619299309011403","1814143201718247424",
"2058950111976386607","Mon May 25 16:35:34 +0000 2026","0","komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju","2058950111976386607","","","in","","0","0","0","https://x.com/undefined/status/2058950111976386607","2046458899843170304",
"2060641816647790635","Sat May 30 08:37:48 +0000 2026","0","komdigi gk penting deh ngeluarin kebijakan ongkir begini mending tu urusin yang lebih penting!!","2060641816647790635","","","in","","0","0","0","https://x.com/undefined/status/2060641816647790635","1814143201718247424",
"2058816188659192179","Mon May 25 07:43:24 +0000 2026","0","komdigi gajelas bikin kebijakan ongkir!","2058816188659192179","","","in","","0","0","0","https://x.com/undefined/status/2058816188659192179","1814143201718247424",
"2058816239112446387","Mon May 25 07:43:37 +0000 2026","0","komdigi gk penting buat kebijakan ongkir kaya gini","2058816239112446387","","","in","","0","0","0","https://x.com/undefined/status/2058816239112446387","1814143201718247424",
"2060234400777064942","Fri May 29 05:38:53 +0000 2026","0","tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi","2060234400777064942","","","in","","0","0","0","https://x.com/undefined/status/2060234400777064942","1814143201718247424",

1 conversation_id_str created_at favorite_count full_text id_str image_url in_reply_to_screen_name lang location quote_count reply_count retweet_count tweet_url user_id_str username
2058829602865451445 Mon May 25 08:36:43 +0000 2026 0 komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir 2058829602865451445 in 0 0 0 https://x.com/undefined/status/2058829602865451445 2046458899843170304
2058950111976386607 Mon May 25 16:35:34 +0000 2026 0 komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju 2058950111976386607 in 0 0 0 https://x.com/undefined/status/2058950111976386607 2046458899843170304
2 2058950559856832819 Mon May 25 16:37:21 +0000 2026 0 setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini 2058950559856832819 in 0 0 0 https://x.com/undefined/status/2058950559856832819 2046458899843170304
3 2060234400777064942 2058829602865451445 Fri May 29 05:38:53 +0000 2026 Mon May 25 08:36:43 +0000 2026 0 tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir 2060234400777064942 2058829602865451445 in 0 0 0 https://x.com/undefined/status/2060234400777064942 https://x.com/undefined/status/2058829602865451445 1814143201718247424 2046458899843170304
4 2058816188659192179 2060580175084204231 Mon May 25 07:43:24 +0000 2026 Sat May 30 04:32:52 +0000 2026 0 komdigi gajelas bikin kebijakan ongkir! komdigi gratis ongkir sangat banyak yang tidak setuju 2058816188659192179 2060580175084204231 in 0 0 0 https://x.com/undefined/status/2058816188659192179 https://x.com/undefined/status/2060580175084204231 1814143201718247424
5 2059619299309011403 Wed May 27 12:54:41 +0000 2026 0 komdigi ngeluarin kebijakan ongkir kaya gini ngapain dah 2059619299309011403 in 0 0 0 https://x.com/undefined/status/2059619299309011403 1814143201718247424
6 2058950111976386607 Mon May 25 16:35:34 +0000 2026 0 komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju 2058950111976386607 in 0 0 0 https://x.com/undefined/status/2058950111976386607 2046458899843170304
7 2060641816647790635 Sat May 30 08:37:48 +0000 2026 0 komdigi gk penting deh ngeluarin kebijakan ongkir begini mending tu urusin yang lebih penting!! 2060641816647790635 in 0 0 0 https://x.com/undefined/status/2060641816647790635 1814143201718247424
8 2058816188659192179 Mon May 25 07:43:24 +0000 2026 0 komdigi gajelas bikin kebijakan ongkir! 2058816188659192179 in 0 0 0 https://x.com/undefined/status/2058816188659192179 1814143201718247424
9 2058816239112446387 Mon May 25 07:43:37 +0000 2026 0 komdigi gk penting buat kebijakan ongkir kaya gini 2058816239112446387 in 0 0 0 https://x.com/undefined/status/2058816239112446387 1814143201718247424
10 2060234400777064942 Fri May 29 05:38:53 +0000 2026 0 tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi 2060234400777064942 in 0 0 0 https://x.com/undefined/status/2060234400777064942 1814143201718247424

View File

@ -1,8 +1,10 @@
"conversation_id_str","created_at","favorite_count","full_text","id_str","image_url","in_reply_to_screen_name","lang","location","quote_count","reply_count","retweet_count","tweet_url","user_id_str","username"
"2058950559856832819","Mon May 25 16:37:21 +0000 2026","0","setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini","2058950559856832819","","","in","","0","0","0","https://x.com/undefined/status/2058950559856832819","2046458899843170304",
"2058829602865451445","Mon May 25 08:36:43 +0000 2026","0","komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir","2058829602865451445","","","in","","0","0","0","https://x.com/undefined/status/2058829602865451445","2046458899843170304",
"2058950111976386607","Mon May 25 16:35:34 +0000 2026","0","komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju","2058950111976386607","","","in","","0","0","0","https://x.com/undefined/status/2058950111976386607","2046458899843170304",
"2058950559856832819","Mon May 25 16:37:21 +0000 2026","0","setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini","2058950559856832819","","","in","","0","0","0","https://x.com/undefined/status/2058950559856832819","2046458899843170304",
"2060234400777064942","Fri May 29 05:38:53 +0000 2026","0","tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi","2060234400777064942","","","in","","0","0","0","https://x.com/undefined/status/2060234400777064942","1814143201718247424",
"2058816188659192179","Mon May 25 07:43:24 +0000 2026","0","komdigi gajelas bikin kebijakan ongkir!","2058816188659192179","","","in","","0","0","0","https://x.com/undefined/status/2058816188659192179","1814143201718247424",
"2059619299309011403","Wed May 27 12:54:41 +0000 2026","0","komdigi ngeluarin kebijakan ongkir kaya gini ngapain dah","2059619299309011403","","","in","","0","0","0","https://x.com/undefined/status/2059619299309011403","1814143201718247424",
"2060641816647790635","Sat May 30 08:37:48 +0000 2026","0","komdigi gk penting deh ngeluarin kebijakan ongkir begini mending tu urusin yang lebih penting!!","2060641816647790635","","","in","","0","0","0","https://x.com/undefined/status/2060641816647790635","1814143201718247424",
"2060580175084204231","Sat May 30 04:32:52 +0000 2026","0","komdigi gratis ongkir sangat banyak yang tidak setuju","2060580175084204231","","","in","","0","0","0","https://x.com/undefined/status/2060580175084204231","1814143201718247424",
"2058816188659192179","Mon May 25 07:43:24 +0000 2026","0","komdigi gajelas bikin kebijakan ongkir!","2058816188659192179","","","in","","0","0","0","https://x.com/undefined/status/2058816188659192179","1814143201718247424",
"2058816239112446387","Mon May 25 07:43:37 +0000 2026","0","komdigi gk penting buat kebijakan ongkir kaya gini","2058816239112446387","","","in","","0","0","0","https://x.com/undefined/status/2058816239112446387","1814143201718247424",
"2060234400777064942","Fri May 29 05:38:53 +0000 2026","0","tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi","2060234400777064942","","","in","","0","0","0","https://x.com/undefined/status/2060234400777064942","1814143201718247424",

1 conversation_id_str created_at favorite_count full_text id_str image_url in_reply_to_screen_name lang location quote_count reply_count retweet_count tweet_url user_id_str username
2 2058950559856832819 Mon May 25 16:37:21 +0000 2026 0 setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini 2058950559856832819 in 0 0 0 https://x.com/undefined/status/2058950559856832819 2046458899843170304
3 2058829602865451445 Mon May 25 08:36:43 +0000 2026 0 komdigi mending ngurusin aja tuh judol daripada ngurusin pembatasan gratis ongkir 2058829602865451445 in 0 0 0 https://x.com/undefined/status/2058829602865451445 2046458899843170304
4 2058950111976386607 Mon May 25 16:35:34 +0000 2026 0 komdigi bagus sih ngeluarin kebijakan gratis ongkir kaya ini setuju 2058950111976386607 in 0 0 0 https://x.com/undefined/status/2058950111976386607 2046458899843170304
2058950559856832819 Mon May 25 16:37:21 +0000 2026 0 setuju bgt komdigi ngeluarin kebijakan gratis ongkir gini 2058950559856832819 in 0 0 0 https://x.com/undefined/status/2058950559856832819 2046458899843170304
2060234400777064942 Fri May 29 05:38:53 +0000 2026 0 tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi 2060234400777064942 in 0 0 0 https://x.com/undefined/status/2060234400777064942 1814143201718247424
2058816188659192179 Mon May 25 07:43:24 +0000 2026 0 komdigi gajelas bikin kebijakan ongkir! 2058816188659192179 in 0 0 0 https://x.com/undefined/status/2058816188659192179 1814143201718247424
5 2059619299309011403 Wed May 27 12:54:41 +0000 2026 0 komdigi ngeluarin kebijakan ongkir kaya gini ngapain dah 2059619299309011403 in 0 0 0 https://x.com/undefined/status/2059619299309011403 1814143201718247424
6 2060641816647790635 Sat May 30 08:37:48 +0000 2026 0 komdigi gk penting deh ngeluarin kebijakan ongkir begini mending tu urusin yang lebih penting!! 2060641816647790635 in 0 0 0 https://x.com/undefined/status/2060641816647790635 1814143201718247424
7 2060580175084204231 Sat May 30 04:32:52 +0000 2026 0 komdigi gratis ongkir sangat banyak yang tidak setuju 2060580175084204231 in 0 0 0 https://x.com/undefined/status/2060580175084204231 1814143201718247424
8 2058816188659192179 Mon May 25 07:43:24 +0000 2026 0 komdigi gajelas bikin kebijakan ongkir! 2058816188659192179 in 0 0 0 https://x.com/undefined/status/2058816188659192179 1814143201718247424
9 2058816239112446387 Mon May 25 07:43:37 +0000 2026 0 komdigi gk penting buat kebijakan ongkir kaya gini 2058816239112446387 in 0 0 0 https://x.com/undefined/status/2058816239112446387 1814143201718247424
10 2060234400777064942 Fri May 29 05:38:53 +0000 2026 0 tadi pagi saya melihat berita mengenai pembatasan gratis ongkir oleh komdigi 2060234400777064942 in 0 0 0 https://x.com/undefined/status/2060234400777064942 1814143201718247424