MIF_E31231226/scraper/analisis.py

596 lines
26 KiB
Python

# -*- coding: utf-8 -*-
"""
analisis.py — SENTARA
=====================
Sistem Analisis Sentimen Ulasan Wisatawan
Metode : Naive Bayes (ComplementNB) + TF-IDF
Label : Dari teks ulasan (bukan rating)
"""
import json
import os
import re
import sqlite3
import sys
import warnings
from pathlib import Path
from urllib.parse import quote_plus
import joblib
import numpy as np
import pandas as pd
import pymysql
from sqlalchemy import create_engine
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics import (
accuracy_score,
classification_report,
f1_score,
)
from sklearn.model_selection import (
GridSearchCV,
StratifiedKFold,
cross_val_score,
train_test_split,
)
from sklearn.naive_bayes import ComplementNB
from sklearn.pipeline import Pipeline
from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
from Sastrawi.StopWordRemover.StopWordRemoverFactory import StopWordRemoverFactory
warnings.filterwarnings("ignore")
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
# ═══════════════════════════════════════════════════════════════
# KONSTANTA & PATH
# ═══════════════════════════════════════════════════════════════
SEED = 42
BASE_DIR = Path(__file__).resolve().parents[1]
ARTEFAK = BASE_DIR / "artefak"
ARTEFAK.mkdir(exist_ok=True)
MODEL_PATH = ARTEFAK / "sentimen_naive_bayes.pkl"
METADATA_PATH = ARTEFAK / "model_metadata.json"
# ═══════════════════════════════════════════════════════════════
# LOGGER
# ═══════════════════════════════════════════════════════════════
def log(level, message):
print(f"[{level}] {message}", flush=True)
def fail(message, code=1):
log("ERROR", message)
sys.exit(code)
# ═══════════════════════════════════════════════════════════════
# KAMUS NORMALISASI LENGKAP (dari dataset.ipynb)
# ═══════════════════════════════════════════════════════════════
NORMALISASI = {
# Negasi — JANGAN hapus
r"\b(ga|gak|gk|nggak|ngga|ngak|engga|enggak)\b": "tidak",
r"\b(blm|blom|blum)\b": "belum",
r"\b(bkn|bukn)\b": "bukan",
r"\b(jgn|jangan|jgan)\b": "jangan",
# Kata ganti
r"\b(ak|aq|gw|gue|gua)\b": "aku",
r"\b(km|loe|lu|elo|lo)\b": "kamu",
r"\b(sy|sya)\b": "saya",
# Kata kerja
r"\b(bs|bsa)\b": "bisa",
r"\b(liat|lht)\b": "lihat",
r"\b(mkn|mkan|maem)\b": "makan",
r"\b(pake|pk|pke)\b": "pakai",
r"\b(tau|taw|tw)\b": "tahu",
r"\b(dtg)\b": "datang",
r"\b(lgsg|lgsung)\b": "langsung",
# Kata sifat
r"\b(bgs|bgus|nice|good|top)\b": "bagus",
r"\b(josss|joss|sipp|sip)\b": "mantap",
r"\b(byk|bnyk)\b": "banyak",
r"\b(lbh|lbih)\b": "lebih",
r"\b(bener|bnr)\b": "benar",
# Kata sambung & keterangan
r"\b(aja|sja|ae)\b": "saja",
r"\b(bgt|bangettt|bangett)\b": "banget",
r"\b(br|bru)\b": "baru",
r"\b(cmn|cuma|cuman)\b": "cuma",
r"\b(dgn|dngn|dg)\b": "dengan",
r"\b(dl|dlu)\b": "dulu",
r"\b(dlm|dalem)\b": "dalam",
r"\b(dr|dri)\b": "dari",
r"\b(emg|emang)\b": "memang",
r"\b(gt|gitu|bgitu)\b": "begitu",
r"\b(hbs|hbis)\b": "habis",
r"\b(hrs|hrus)\b": "harus",
r"\b(jd|jdi)\b": "jadi",
r"\b(jg|jga)\b": "juga",
r"\b(kdg|kdang)\b": "kadang",
r"\b(klo|kalo|kl)\b": "kalau",
r"\b(krn|karna)\b": "karena",
r"\b(kyk|kek|kya)\b": "seperti",
r"\b(lg|lgi)\b": "lagi",
r"\b(mgkn|mngkin)\b": "mungkin",
r"\b(msi|msh|msih)\b": "masih",
r"\b(pd|pda)\b": "pada",
r"\b(sdh|udh|udah|uda)\b": "sudah",
r"\b(skrg|skrng)\b": "sekarang",
r"\b(sllu|slalu)\b": "selalu",
r"\b(sm|ama)\b": "sama",
r"\b(smpai|ampe|smpe)\b": "sampai",
r"\b(smua)\b": "semua",
r"\b(srg|sring)\b": "sering",
r"\b(tp|tpi)\b": "tapi",
r"\b(trs|trus)\b": "lalu",
r"\b(ttp|ttep)\b": "tetap",
r"\b(utk|untk)\b": "untuk",
r"\b(yg|yng)\b": "yang",
r"\b(pdhl|pdhal)\b": "padahal",
r"\b(tmpt|tempt)\b": "tempat",
r"\b(tmn|temen)\b": "teman",
r"\b(org|orng)\b": "orang",
# Konteks pariwisata
r"\b(recommended|recomended)\b": "rekomendasi",
r"\b(healing)\b": "rekreasi",
r"\b(htm)\b": "harga tiket masuk",
r"\b(overall)\b": "secara keseluruhan",
r"\b(spot foto)\b": "lokasi foto",
# Hapus tawa & makian
r"\b(wkwk+|haha+|hehe+)\b": "",
r"\b(bjir|anjay|anjir)\b": "",
}
# ═══════════════════════════════════════════════════════════════
# KAMUS SENTIMEN (pseudo-label fallback)
# ═══════════════════════════════════════════════════════════════
POSITIF_WORDS = {
"bagus","indah","cantik","keren","mantap","asri","bersih","nyaman","rapi",
"adem","sejuk","segar","menarik","spektakuler","eksotis","unik","istimewa",
"menakjubkan","memukau","asyik","asik","senang","puas","suka","happy",
"enjoy","bahagia","menyenangkan","seru","recommended","rekomendasi","wajib",
"worth","memuaskan","healing","josss","joss","sip","lengkap","terawat",
"baik","oke","ramah","murah","terjangkau","luas","teduh","view","sunset",
"sunrise","jernih","bening","enak","lezat","amazing","beautiful","great",
"nice","good","perfect","best","lovely","wonderful","fantastic","awesome",
}
NEGATIF_WORDS = {
"kotor","jorok","jelek","buruk","rusak","kumuh","sempit","parah","payah",
"berantakan","mengecewakan","kecewa","nyesel","menyesal","bocor","mati",
"gelap","bau","busuk","pengap","mahal","kemahalan","lambat","antri","macet",
"sesak","penuh","berebut","kasar","jutek","cuek","berbahaya","bahaya",
"licin","curam","sampah","tidak puas","kapok","ogah","zonk","tipu","pungli",
}
NEGASI = {"tidak","bukan","jangan","belum","tanpa","kurang","ga","gak","nggak"}
# ═══════════════════════════════════════════════════════════════
# INISIALISASI NLP
# ═══════════════════════════════════════════════════════════════
log("INFO", "Memuat stemmer dan stopword Sastrawi...")
_stemmer = StemmerFactory().create_stemmer()
_sw_raw = set(StopWordRemoverFactory().get_stop_words())
# Pertahankan kata negasi — kritis untuk sentimen
STOPWORDS = _sw_raw - NEGASI
log("INFO", f"Stopword: {len(_sw_raw)} kata | Negasi dipertahankan: {NEGASI}")
# ═══════════════════════════════════════════════════════════════
# PREPROCESSING
# ═══════════════════════════════════════════════════════════════
def normalisasi_teks(text):
for pattern, replacement in NORMALISASI.items():
text = re.sub(pattern, replacement, text)
return text
def preprocess_text(text):
"""Pipeline preprocessing 6 tahap."""
text = str(text).lower()
text = re.sub(r"https?://\S+|www\.\S+", " ", text) # hapus URL
text = re.sub(r"@\w+|#\w+", " ", text) # hapus mention/hashtag
text = re.sub(r"[^a-z\s]", " ", text) # hapus non-alfabet
text = re.sub(r"\s+", " ", text).strip()
text = normalisasi_teks(text) # normalisasi kamus
tokens = [t for t in text.split() if t not in STOPWORDS and len(t) > 2]
return _stemmer.stem(" ".join(tokens)).strip()
# ═══════════════════════════════════════════════════════════════
# PSEUDO-LABELING (fallback jika tidak ada model tersimpan)
# ═══════════════════════════════════════════════════════════════
def label_by_keyword(clean_text):
tokens = clean_text.split()
pos = neg = 0
i = 0
while i < len(tokens):
sebelum_negasi = (i > 0 and tokens[i-1] in NEGASI)
w = tokens[i]
if w in POSITIF_WORDS:
neg += 1 if sebelum_negasi else 0
pos += 0 if sebelum_negasi else 1
elif w in NEGATIF_WORDS:
pos += 1 if sebelum_negasi else 0
neg += 0 if sebelum_negasi else 1
i += 1
if pos > neg: return "positif"
if neg > pos: return "negatif"
return "netral"
def make_pseudo_label(row):
return label_by_keyword(row["ulasan_bersih"])
# ═══════════════════════════════════════════════════════════════
# LOAD / TRAIN MODEL
# ═══════════════════════════════════════════════════════════════
def load_saved_model():
"""Muat model tersimpan jika ada."""
if MODEL_PATH.exists():
log("INFO", f"Model tersimpan ditemukan → dimuat dari {MODEL_PATH}")
return joblib.load(MODEL_PATH)
return None
def train_and_save_model(df):
"""
Latih ComplementNB dengan:
- 5-Fold Stratified Cross Validation
- GridSearchCV hyperparameter tuning
- Evaluasi Macro F1 + classification report
"""
log("INFO", "=" * 55)
log("INFO", "TRAINING MODEL NAIVE BAYES (ComplementNB)")
log("INFO", "=" * 55)
X = df["ulasan_bersih"].values
y = df["label"].values
label_counts = pd.Series(y).value_counts()
log("INFO", "Distribusi label: " + str(label_counts.to_dict()))
# Minimal 2 kelas dan tiap kelas >= 5
if label_counts.size < 2 or label_counts.min() < 2:
log("WARNING", "Data tidak cukup untuk training — pakai pseudo-label langsung.")
return None, 0, 0
# Split 80/20
can_stratify = label_counts.min() >= 2
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=SEED,
stratify=y if can_stratify else None
)
log("INFO", f"Split — Train: {len(X_train)} | Test: {len(X_test)}")
kfold = StratifiedKFold(n_splits=min(5, label_counts.min()), shuffle=True, random_state=SEED)
# ── 5-Fold Cross Validation ──────────────────────────────
log("INFO", "Menjalankan 5-Fold Cross Validation...")
pipe_cv = Pipeline([
("tfidf", TfidfVectorizer(ngram_range=(1,2), min_df=1, sublinear_tf=True)),
("clf", ComplementNB()),
])
cv_scores = cross_val_score(pipe_cv, X_train, y_train, cv=kfold, scoring="f1_macro", n_jobs=-1)
log("INFO", f"CV Macro F1 per fold : {[round(s,4) for s in cv_scores]}")
log("INFO", f"CV Macro F1 rata-rata: {cv_scores.mean():.4f} ± {cv_scores.std():.4f}")
# ── GridSearchCV Tuning ───────────────────────────────────
log("INFO", "GridSearchCV tuning hiperparameter...")
param_grid = {
"tfidf__ngram_range": [(1,1),(1,2)],
"tfidf__min_df": [1, 2, 3],
"tfidf__sublinear_tf": [True, False],
"clf__alpha": [0.1, 0.5, 1.0, 2.0],
"clf__norm": [True, False],
}
pipe_gs = Pipeline([
("tfidf", TfidfVectorizer()),
("clf", ComplementNB()),
])
gs = GridSearchCV(pipe_gs, param_grid, cv=kfold, scoring="f1_macro", n_jobs=-1, refit=True)
gs.fit(X_train, y_train)
best = gs.best_estimator_
log("INFO", f"Parameter terbaik : {gs.best_params_}")
log("INFO", f"Best CV Macro F1 : {gs.best_score_:.4f}")
# ── Evaluasi Final ────────────────────────────────────────
y_pred = best.predict(X_test)
accuracy = accuracy_score(y_test, y_pred)
macro_f1 = f1_score(y_test, y_pred, average="macro", zero_division=0)
report = classification_report(y_test, y_pred, zero_division=0)
log("INFO", f"Akurasi : {accuracy:.4f} ({accuracy*100:.2f}%)")
log("INFO", f"Macro F1-Score: {macro_f1:.4f}")
log("INFO", f"\nClassification Report:\n{report}")
# ── Simpan model & metadata ───────────────────────────────
joblib.dump(best, MODEL_PATH)
report_dict = classification_report(y_test, y_pred, output_dict=True, zero_division=0)
metadata = {
"model": "ComplementNB (Naive Bayes)",
"best_params": gs.best_params_,
"cv_macro_f1": {"mean": round(cv_scores.mean(),4), "std": round(cv_scores.std(),4)},
"evaluasi_test": {
"accuracy": round(accuracy,4),
"macro_f1": round(macro_f1,4),
},
"distribusi_label": pd.Series(y).value_counts().to_dict(),
}
with open(METADATA_PATH, "w", encoding="utf-8") as f:
json.dump(metadata, f, indent=4, ensure_ascii=False)
log("INFO", f"Model disimpan → {MODEL_PATH}")
return best, accuracy, macro_f1
# ═══════════════════════════════════════════════════════════════
# DATABASE
# ═══════════════════════════════════════════════════════════════
def read_laravel_env():
env = {}
env_file = BASE_DIR / ".env"
if not env_file.exists():
return env
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
value = value.strip().strip('"').strip("'")
env.setdefault(key.strip(), value)
return env
def env_value(env, key, default=""):
value = os.getenv(key, env.get(key, default))
if value in {None, "", "null", "None"}:
return default
return value
def db_config():
env = read_laravel_env()
conn = env_value(env, "DB_CONNECTION", "mysql")
if conn == "sqlite":
db = env_value(env, "DB_DATABASE", str(BASE_DIR / "database" / "database.sqlite"))
db_path = Path(db)
if not db_path.is_absolute():
db_path = BASE_DIR / db
return {"connection": conn, "database": str(db_path)}
if conn not in {"mysql", "mariadb"}:
fail(f"DB_CONNECTION={conn} belum didukung.")
return {
"connection": conn,
"host": env_value(env, "DB_HOST", "127.0.0.1"),
"port": int(env_value(env, "DB_PORT", "3306")),
"database": env_value(env, "DB_DATABASE", "sistem_analisis"),
"user": env_value(env, "DB_USERNAME", "root"),
"password": env_value(env, "DB_PASSWORD", ""),
}
def make_connections(config):
if config["connection"] == "sqlite":
engine = create_engine(f"sqlite:///{config['database']}")
conn = sqlite3.connect(config["database"])
conn.row_factory = sqlite3.Row
return engine, conn
url = (
"mysql+pymysql://"
f"{quote_plus(config['user'])}:{quote_plus(config['password'])}"
f"@{config['host']}:{config['port']}/{config['database']}?charset=utf8mb4"
)
engine = create_engine(url)
conn = pymysql.connect(
host=config["host"], port=config["port"],
user=config["user"], password=config["password"],
database=config["database"], charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
)
return engine, conn
def is_sqlite(conn):
return isinstance(conn, sqlite3.Connection)
def prepare_sql(conn, sql):
if is_sqlite(conn):
return sql.replace("%s", "?").replace("NOW()", "CURRENT_TIMESTAMP")
return sql
def execute(cursor, conn, sql, params=()):
cursor.execute(prepare_sql(conn, sql), params)
def table_columns(cursor, conn, table):
if is_sqlite(conn):
cursor.execute(f"PRAGMA table_info({table})")
return {row["name"] for row in cursor.fetchall()}
cursor.execute(f"SHOW COLUMNS FROM {table}")
return {row["Field"] for row in cursor.fetchall()}
# ═══════════════════════════════════════════════════════════════
# MAIN
# ═══════════════════════════════════════════════════════════════
def main():
import argparse
parser = argparse.ArgumentParser(description="SENTARA — Analisis Sentimen")
parser.add_argument("--periode-id", type=int, default=None)
args = parser.parse_args()
log("INFO", "=" * 55)
log("INFO", "SENTARA — Sistem Analisis Sentimen Wisata Jember")
log("INFO", "=" * 55)
config = db_config()
engine, raw_conn = make_connections(config)
cursor = raw_conn.cursor()
try:
# ── Cari periode ──────────────────────────────────────
if args.periode_id:
execute(cursor, raw_conn,
"SELECT id, nama FROM periode_analisis WHERE id = %s LIMIT 1",
(args.periode_id,))
else:
execute(cursor, raw_conn, """
SELECT p.id, p.nama FROM periode_analisis p
WHERE EXISTS (SELECT 1 FROM ulasan u WHERE u.periode_id = p.id)
ORDER BY p.id DESC LIMIT 1
""")
periode = cursor.fetchone()
if not periode:
fail("Belum ada periode dengan ulasan.")
periode_id = periode["id"]
periode_nama = periode["nama"]
log("INFO", f"Periode: {periode_nama} (id={periode_id})")
# ── Ambil ulasan baru (belum dianalisis) ──────────────
df = pd.read_sql(
prepare_sql(raw_conn,
"SELECT u.id, u.wisata, u.reviewer, u.rating, u.ulasan, u.tanggal, u.periode_id "
"FROM ulasan u WHERE u.periode_id = %s "
"AND NOT EXISTS (SELECT 1 FROM hasil_analisis h WHERE h.ulasan_id = u.id)"
),
engine, params=(periode_id,),
)
if df.empty:
fail(f"Tidak ada ulasan baru untuk periode_id={periode_id}.")
# ── Validasi & bersihkan ──────────────────────────────
df = df.dropna(subset=["ulasan"]).copy()
df["ulasan"] = df["ulasan"].astype(str)
df = df[df["ulasan"].str.strip().ne("")]
df = df[df["ulasan"].str.strip().ne("0")]
df = df[~df["ulasan"].str.contains(r"\[Tanpa teks\]", na=False)]
df = df[df["ulasan"].str.len() > 5]
if df.empty:
fail("Data kosong setelah validasi.")
# ── Preprocessing ─────────────────────────────────────
log("INFO", "Preprocessing teks (6 tahap)...")
df["ulasan_bersih"] = df["ulasan"].apply(preprocess_text)
df = df[df["ulasan_bersih"].str.strip().ne("")].copy()
log("INFO", f"Data valid setelah preprocessing: {len(df)} baris")
# ── Pseudo-label ──────────────────────────────────────
df["label"] = df.apply(make_pseudo_label, axis=1)
label_counts = df["label"].value_counts()
log("INFO", "Distribusi pseudo-label: " + str(label_counts.to_dict()))
# ── Load atau Train model ─────────────────────────────
saved_pipeline = load_saved_model()
if saved_pipeline is not None:
# Pakai model tersimpan (dari training manual sebelumnya)
log("INFO", "Menggunakan model tersimpan untuk prediksi.")
best_pipeline = saved_pipeline
# Baca metadata akurasi
accuracy = macro_f1 = 0.0
if METADATA_PATH.exists():
with open(METADATA_PATH, encoding="utf-8") as f:
meta = json.load(f)
accuracy = meta.get("evaluasi_test", {}).get("accuracy", 0)
macro_f1 = meta.get("evaluasi_test", {}).get("macro_f1", 0)
log("INFO", f"Akurasi model tersimpan : {accuracy:.4f}")
log("INFO", f"Macro F1 model tersimpan: {macro_f1:.4f}")
report_dict = {"weighted avg": {"precision": accuracy, "recall": accuracy, "f1-score": macro_f1}}
else:
# Tidak ada model → train dari pseudo-label
log("WARNING",
"Model belum tersimpan. Training dari pseudo-label.\n"
"Untuk akurasi lebih baik, latih dengan dataset berlabel manual:\n"
"python analisis.py --mode train --dataset data_berlabel.csv")
best_pipeline, accuracy, macro_f1 = train_and_save_model(df)
if best_pipeline is None:
# Fallback: langsung pakai pseudo-label
df["sentimen"] = df["label"]
df["probabilitas"] = 0.60
accuracy = macro_f1 = 0.0
report_dict = {"weighted avg": {"precision": 0, "recall": 0, "f1-score": 0}}
else:
report_dict = {"weighted avg": {"precision": accuracy, "recall": accuracy, "f1-score": macro_f1}}
# ── Prediksi ──────────────────────────────────────────
if best_pipeline is not None:
log("INFO", "Memprediksi sentimen...")
df["sentimen"] = best_pipeline.predict(df["ulasan_bersih"])
prob_matrix = best_pipeline.predict_proba(df["ulasan_bersih"])
df["probabilitas"] = prob_matrix.max(axis=1).clip(max=0.99)
# Post-processing: probabilitas rendah → fallback kamus
mask_low = df["probabilitas"] < 0.45
if mask_low.sum() > 0:
log("INFO", f"{mask_low.sum()} ulasan probabilitas rendah → fallback ke kamus")
df.loc[mask_low, "sentimen"] = df.loc[mask_low, "ulasan_bersih"].apply(label_by_keyword)
df.loc[mask_low, "probabilitas"] = 0.50
dist = df["sentimen"].value_counts().to_dict()
log("INFO", f"Distribusi sentimen hasil: {dist}")
# ── Simpan ke hasil_analisis ──────────────────────────
hasil_columns = table_columns(cursor, raw_conn, "hasil_analisis")
insert_columns = [
"ulasan_id","wisata","ulasan_asli","ulasan_bersih",
"hasil_preprocessing","sentimen","probabilitas",
"periode_id","created_at","updated_at",
]
if "ulasan_terolah" in hasil_columns:
insert_columns.insert(3, "ulasan_terolah")
placeholders = ", ".join(["%s"] * (len(insert_columns) - 2) + ["NOW()", "NOW()"])
insert_sql = f"INSERT INTO hasil_analisis ({', '.join(insert_columns)}) VALUES ({placeholders})"
for _, row in df.fillna("").iterrows():
values = [
int(row["id"]),
str(row["wisata"]),
str(row["ulasan"]),
str(row["ulasan_bersih"]),
str(row["ulasan_bersih"]),
str(row["sentimen"]).lower(),
float(row["probabilitas"]),
periode_id,
]
if "ulasan_terolah" in hasil_columns:
values.insert(3, str(row["ulasan_bersih"]))
execute(cursor, raw_conn, insert_sql, tuple(values))
# ── Simpan ke evaluasi_model ──────────────────────────
weighted = report_dict.get("weighted avg", {})
execute(cursor, raw_conn, """
INSERT INTO evaluasi_model
(`precision`, `recall`, f1_score, accuracy, tp, tn, fp, fn, periode_id, created_at, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW())
""", (
float(weighted.get("precision", 0)),
float(weighted.get("recall", 0)),
float(weighted.get("f1-score", macro_f1)),
float(accuracy),
0, 0, 0, 0, periode_id,
))
raw_conn.commit()
log("OK", f"{len(df)} hasil analisis disimpan untuk periode '{periode_nama}'.")
log("OK", "Analisis selesai.")
except SystemExit:
raw_conn.rollback()
raise
except Exception as exc:
raw_conn.rollback()
fail(f"Analisis gagal: {exc}")
finally:
raw_conn.close()
engine.dispose()
if __name__ == "__main__":
main()