Merge branch 'main' of https://github.com/mhilya/sista_mif_ta
This commit is contained in:
commit
b5d5a9b63a
File diff suppressed because one or more lines are too long
|
|
@ -2,19 +2,17 @@
|
||||||
"""
|
"""
|
||||||
TRACER STUDY - RE-TRAINING WORKER FINAL (SUBPROCESS)
|
TRACER STUDY - RE-TRAINING WORKER FINAL (SUBPROCESS)
|
||||||
Dipanggil oleh FastAPI (main3.py) sebagai background subprocess.
|
Dipanggil oleh FastAPI (main3.py) sebagai background subprocess.
|
||||||
|
|
||||||
Alur:
|
Alur:
|
||||||
1. Backup pkl lama → pkl.bak
|
1. Backup pkl lama → pkl.bak
|
||||||
2. Merge corpus + manual_override (TANPA drop_duplicates)
|
2. Merge corpus + manual_override (TANPA drop_duplicates)
|
||||||
Override diberi sample_weight lebih tinggi, bukan menghapus data historis.
|
|
||||||
3. Dynamic train/test split dari data gabungan (selalu fresh)
|
3. Dynamic train/test split dari data gabungan (selalu fresh)
|
||||||
4. Evaluasi MODEL LAMA pada test set yang sama (fair comparison)
|
4. Evaluasi MODEL LAMA pada test set yang sama (fair comparison)
|
||||||
5. K-Fold + train model final (candidate) dengan sample_weight
|
5. K-Fold + train model final (candidate) dengan sample_weight
|
||||||
6. Evaluasi candidate pada test set yang sama → delta terhadap old model
|
6. Evaluasi candidate pada test set yang sama → delta terhadap old model
|
||||||
7. Promote jika lebih baik; rollback jika tidak
|
7. Promote jika lebih baik; rollback jika tidak
|
||||||
8. Setiap write status dilindungi FileLock (atomic)
|
8. Setiap write status dilindungi FileLock (atomic)
|
||||||
|
9. [v5] Semua event log disimpan ke file JSONL terpisah untuk audit BAB 4
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -39,11 +37,13 @@ from Sastrawi.Stemmer.StemmerFactory import StemmerFactory
|
||||||
warnings.filterwarnings("ignore")
|
warnings.filterwarnings("ignore")
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# KONFIGURASI
|
# KONFIGURASI PATH
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
BASE_DIR = Path(__file__).parent.parent
|
BASE_DIR = Path(__file__).parent.parent
|
||||||
ML_DIR = BASE_DIR / "ml_assets"
|
ML_DIR = BASE_DIR / "ml_assets"
|
||||||
DATA_DIR = BASE_DIR.parent / "data" / "processed"
|
DATA_DIR = BASE_DIR.parent / "data" / "processed"
|
||||||
|
LOG_DIR = ML_DIR / "logs" # [v5] Direktori log terpisah
|
||||||
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
PIPELINE_PATH = ML_DIR / "ml_pipeline_internal.pkl"
|
PIPELINE_PATH = ML_DIR / "ml_pipeline_internal.pkl"
|
||||||
PIPELINE_BAK_PATH = ML_DIR / "ml_pipeline_internal.pkl.bak"
|
PIPELINE_BAK_PATH = ML_DIR / "ml_pipeline_internal.pkl.bak"
|
||||||
|
|
@ -54,46 +54,17 @@ LOCK_PATH = ML_DIR / "retrain_status.lock"
|
||||||
CORPUS_PATH = DATA_DIR / "training_corpus.csv"
|
CORPUS_PATH = DATA_DIR / "training_corpus.csv"
|
||||||
TEST_PATH = DATA_DIR / "test_set.csv"
|
TEST_PATH = DATA_DIR / "test_set.csv"
|
||||||
|
|
||||||
|
# [v5] Nama file log berbasis timestamp — satu file per sesi retraining
|
||||||
|
SESSION_TS = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
LOG_FILE_PATH = LOG_DIR / f"retrain_log_{SESSION_TS}.jsonl"
|
||||||
|
|
||||||
TARGET_CLASSES = ["Programmer", "Data Analyst", "Wirausaha Informatika", "Non-IT"]
|
TARGET_CLASSES = ["Programmer", "Data Analyst", "Wirausaha Informatika", "Non-IT"]
|
||||||
MIN_IMPROVEMENT_THRESHOLD = 0.01 # Model baru harus lebih baik minimal +1% weighted F1
|
MIN_IMPROVEMENT_THRESHOLD = 0.01
|
||||||
MAX_REGRESSION_ALLOWED = 0.02 # Toleransi degradasi maksimal 2% sebelum rollback keras
|
MAX_REGRESSION_ALLOWED = 0.02
|
||||||
|
|
||||||
# ── KONFIGURASI BOBOT OVERRIDE ──────────────────────────────────────────────
|
OVERRIDE_INFLUENCE_TARGET = 0.30
|
||||||
#
|
OVERRIDE_MIN_WEIGHT = 2.0
|
||||||
# Masalah yang diselesaikan:
|
|
||||||
# Pada skala besar (N_corpus >> N_override), bobot statis kehilangan daya
|
|
||||||
# akibat dilusi. Formula proporsi murni w = t*N / (M*(1-t)) menyelesaikan
|
|
||||||
# dilusi, tapi menghasilkan w=42.85 pada skenario 10k/100 — yang berisiko
|
|
||||||
# overfitting ekstrem pada noise override.
|
|
||||||
#
|
|
||||||
# Solusi: Logarithmic damping — tanpa tembok statis.
|
|
||||||
# w_raw = formula proporsi (jaminan 30% jika tidak di-damp)
|
|
||||||
# w = MIN + ln(1 + max(0, w_raw - MIN))
|
|
||||||
#
|
|
||||||
# Fase linear (w_raw rendah): w ≈ w_raw → proporsi terpenuhi
|
|
||||||
# Fase log (w_raw tinggi): w tumbuh tapi melambat → damp alami
|
|
||||||
#
|
|
||||||
# Implikasi jujur:
|
|
||||||
# Target 30% TIDAK dipertahankan di skala ekstrem. Ini trade-off yang
|
|
||||||
# disengaja: degradasi gradual lebih aman daripada overfitting ke 10
|
|
||||||
# baris override berbobot 42x. Tanpa tembok statis, redaman terjadi
|
|
||||||
# secara natural mengikuti kurva logaritmik, bukan menabrak batas arbitrer.
|
|
||||||
#
|
|
||||||
# Perilaku nyata (MIN=2.0, TARGET=0.30):
|
|
||||||
# w_raw │ w_log │ influence aktual
|
|
||||||
# ──────────────────────────────────
|
|
||||||
# 2.00 │ 2.000 │ formula <= MIN, pakai floor
|
|
||||||
# 4.29 │ 3.178 │ (1k corpus, 100 override) → ~24%
|
|
||||||
# 8.57 │ 3.999 │ (100 korpus, 5 override) → ~17%
|
|
||||||
# 42.86 │ 5.723 │ (10k corpus, 100 override)→ ~5.4%
|
|
||||||
# 428.60 │ 8.063 │ (10k corpus, 10 override) → ~0.8%
|
|
||||||
#
|
|
||||||
# Jika angka influence aktual dianggap terlalu kecil → naikkan TARGET.
|
|
||||||
# Jika model terlalu sensitif ke override → naikkan MIN agar floor lebih tinggi.
|
|
||||||
OVERRIDE_INFLUENCE_TARGET = 0.30 # Titik acuan proporsi (valid di skala normal)
|
|
||||||
OVERRIDE_MIN_WEIGHT = 2.0 # Lantai: override selalu minimal 2× korpus
|
|
||||||
|
|
||||||
# [FIX v4] STOPWORDS lengkap — versi retrain_worker2 hanya punya 10 kata (bug terpotong)
|
|
||||||
STOPWORDS = {
|
STOPWORDS = {
|
||||||
"yang", "di", "ke", "dari", "dan", "atau", "dengan", "untuk", "pada", "dalam",
|
"yang", "di", "ke", "dari", "dan", "atau", "dengan", "untuk", "pada", "dalam",
|
||||||
"adalah", "ini", "itu", "tidak", "juga", "sudah", "akan", "bisa", "ada", "oleh",
|
"adalah", "ini", "itu", "tidak", "juga", "sudah", "akan", "bisa", "ada", "oleh",
|
||||||
|
|
@ -109,49 +80,73 @@ STOPWORDS = {
|
||||||
|
|
||||||
stemmer = StemmerFactory().create_stemmer()
|
stemmer = StemmerFactory().create_stemmer()
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# [v5] CUSTOM JSON FORMATTER — setiap log entry jadi JSON valid
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
"""Format log record sebagai JSON satu baris (JSONL)."""
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
log_entry = {
|
||||||
|
"timestamp": datetime.fromtimestamp(record.created).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
}
|
||||||
|
# [v5] Jika ada extra data (stage, metrics, dll), gabungkan ke root
|
||||||
|
if hasattr(record, "stage"):
|
||||||
|
log_entry["stage"] = record.stage
|
||||||
|
if hasattr(record, "event_type"):
|
||||||
|
log_entry["event_type"] = record.event_type
|
||||||
|
if hasattr(record, "metadata") and isinstance(record.metadata, dict):
|
||||||
|
log_entry["metadata"] = record.metadata
|
||||||
|
return json.dumps(log_entry, ensure_ascii=False)
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# [v5] DUAL LOGGER SETUP — console + file JSONL
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
logger = logging.getLogger("retrain_worker")
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
logger.propagate = False # mencegah duplikasi ke root logger
|
||||||
|
|
||||||
|
# Handler 1: Console (untuk dev/debugging)
|
||||||
|
console_handler = logging.StreamHandler(sys.stdout)
|
||||||
|
console_handler.setFormatter(logging.Formatter(
|
||||||
|
"%(asctime)s | %(levelname)s | %(message)s"
|
||||||
|
))
|
||||||
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
|
# Handler 2: File JSONL (untuk audit BAB 4 & debugging production)
|
||||||
|
file_handler = logging.FileHandler(LOG_FILE_PATH, mode="w", encoding="utf-8")
|
||||||
|
file_handler.setFormatter(JsonFormatter())
|
||||||
|
logger.addHandler(file_handler)
|
||||||
|
|
||||||
|
logger.info(f"[init] Log file tersimpan di: {LOG_FILE_PATH}")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# [v5] STRUCTURED LOG HELPER — untuk event penting dengan metadata
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
def log_event(event_type: str, message: str, stage: str = "info", metadata: dict = None):
|
||||||
|
"""
|
||||||
|
Tulis event terstruktur ke log file.
|
||||||
|
Berbeda dengan logger.info biasa, fungsi ini menyertakan metadata
|
||||||
|
yang bisa diparse programmatically (misal: old_f1, new_f1, delta).
|
||||||
|
"""
|
||||||
|
extra = {"event_type": event_type, "stage": stage}
|
||||||
|
if metadata:
|
||||||
|
extra["metadata"] = metadata
|
||||||
|
logger.info(message, extra=extra)
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# DYNAMIC WEIGHT CALCULATOR
|
# DYNAMIC WEIGHT CALCULATOR
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
def compute_override_weight(n_corpus: int, n_override: int) -> float:
|
def compute_override_weight(n_corpus: int, n_override: int) -> float:
|
||||||
"""
|
|
||||||
Hitung bobot override dengan logarithmic damping.
|
|
||||||
|
|
||||||
TIDAK ada OVERRIDE_MAX_WEIGHT — tembok statis menghancurkan jaminan
|
|
||||||
pengaruh tepat saat skala besar membutuhkannya. Sebagai gantinya,
|
|
||||||
fungsi ln(1+x) menyediakan redaman alami:
|
|
||||||
|
|
||||||
w_raw = (t * n_corpus) / (n_override * (1 - t)) # proporsi murni
|
|
||||||
w = MIN + ln(1 + max(0, w_raw - MIN)) # log damping
|
|
||||||
|
|
||||||
Jaminan matematis:
|
|
||||||
- w selalu >= OVERRIDE_MIN_WEIGHT (floor tetap ada)
|
|
||||||
- w tidak pernah meledak ke infinity karena ln tumbuh O(log n)
|
|
||||||
- Tidak ada tembok statis yang membuat influence kolaps tiba-tiba
|
|
||||||
|
|
||||||
Catatan interaksi:
|
|
||||||
LogisticRegression dipanggil dengan class_weight='balanced' DAN
|
|
||||||
sample_weight. Keduanya dikalikan oleh sklearn secara internal.
|
|
||||||
Artinya sampel override dari kelas minoritas mendapat boost ganda.
|
|
||||||
Pantau per-class F1 di metrics JSON untuk mendeteksi efek ini.
|
|
||||||
"""
|
|
||||||
if n_override == 0:
|
if n_override == 0:
|
||||||
return 1.0
|
return 1.0
|
||||||
t = OVERRIDE_INFLUENCE_TARGET
|
t = OVERRIDE_INFLUENCE_TARGET
|
||||||
w_raw = (t * n_corpus) / (n_override * (1.0 - t))
|
w_raw = (t * n_corpus) / (n_override * (1.0 - t))
|
||||||
# ln damping: linear di zona rendah, melambat secara alami di skala besar
|
|
||||||
w_log = OVERRIDE_MIN_WEIGHT + math.log1p(max(0.0, w_raw - OVERRIDE_MIN_WEIGHT))
|
w_log = OVERRIDE_MIN_WEIGHT + math.log1p(max(0.0, w_raw - OVERRIDE_MIN_WEIGHT))
|
||||||
return round(w_log, 4)
|
return round(w_log, 4)
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# LOGGING
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(asctime)s | %(levelname)s | %(message)s",
|
|
||||||
handlers=[logging.StreamHandler(sys.stdout)]
|
|
||||||
)
|
|
||||||
logger = logging.getLogger("retrain_worker")
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# STATUS WRITER — atomic dengan FileLock (v4)
|
# STATUS WRITER — atomic dengan FileLock (v4)
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
@ -161,13 +156,13 @@ def write_status(stage: str, message: str, extra: dict = None):
|
||||||
payload = {"stage": stage, "message": message, "timestamp": datetime.now().isoformat()}
|
payload = {"stage": stage, "message": message, "timestamp": datetime.now().isoformat()}
|
||||||
if extra: payload.update(extra)
|
if extra: payload.update(extra)
|
||||||
STATUS_PATH.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
STATUS_PATH.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
logger.info(f"[{stage}] {message}")
|
# [v5] Tulis juga ke log file dengan stage tracking
|
||||||
|
logger.info(f"[{stage}] {message}", extra={"stage": stage})
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
# PREPROCESSING — dua fungsi terpisah (v4)
|
# PREPROCESSING
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
def preprocess_stemmed(text: str) -> str:
|
def preprocess_stemmed(text: str) -> str:
|
||||||
"""Untuk data korpus yang SUDAH di-stem oleh prepare_corpus — tidak re-stem."""
|
|
||||||
if pd.isna(text) or not isinstance(text, str): return ""
|
if pd.isna(text) or not isinstance(text, str): return ""
|
||||||
text = text.lower().strip()
|
text = text.lower().strip()
|
||||||
if text in {"nan", "none", "null", "-", "0", "", "tidak diisi"}: return ""
|
if text in {"nan", "none", "null", "-", "0", "", "tidak diisi"}: return ""
|
||||||
|
|
@ -178,7 +173,6 @@ def preprocess_stemmed(text: str) -> str:
|
||||||
return " ".join(words)
|
return " ".join(words)
|
||||||
|
|
||||||
def preprocess_raw(text: str) -> str:
|
def preprocess_raw(text: str) -> str:
|
||||||
"""Untuk data manual_override yang BELUM di-stem — jalankan Sastrawi."""
|
|
||||||
if pd.isna(text) or not isinstance(text, str): return ""
|
if pd.isna(text) or not isinstance(text, str): return ""
|
||||||
text = text.lower().strip()
|
text = text.lower().strip()
|
||||||
if text in {"nan", "none", "null", "-", "0", "", "tidak diisi"}: return ""
|
if text in {"nan", "none", "null", "-", "0", "", "tidak diisi"}: return ""
|
||||||
|
|
@ -210,18 +204,7 @@ def evaluate_model(model, X_test: pd.Series, y_test: pd.Series) -> dict:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
# BASELINE F1 — re-evaluasi model lama pada test set yang sama
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
|
||||||
def get_baseline_f1_on_testset(X_test: pd.Series, y_test: pd.Series) -> float:
|
def get_baseline_f1_on_testset(X_test: pd.Series, y_test: pd.Series) -> float:
|
||||||
"""
|
|
||||||
Evaluasi model lama (backup) pada X_test/y_test yang SAMA dengan yang
|
|
||||||
akan digunakan mengevaluasi candidate. Ini satu-satunya cara yang adil
|
|
||||||
(apples-to-apples) karena distribusi test set berubah setiap retrain.
|
|
||||||
|
|
||||||
Membandingkan F1 baru dengan angka JSON lama (dihitung di distribusi berbeda)
|
|
||||||
adalah perbandingan apel vs jeruk — tidak valid untuk keputusan promote/rollback.
|
|
||||||
"""
|
|
||||||
if not PIPELINE_BAK_PATH.exists():
|
if not PIPELINE_BAK_PATH.exists():
|
||||||
logger.warning("Backup .bak tidak ditemukan — baseline F1 diasumsikan 0.0")
|
logger.warning("Backup .bak tidak ditemukan — baseline F1 diasumsikan 0.0")
|
||||||
return 0.0
|
return 0.0
|
||||||
|
|
@ -244,6 +227,15 @@ def do_rollback(reason: str, old_f1: float, new_f1: float):
|
||||||
logger.info("Rollback berhasil: pkl lama dipulihkan dari .bak")
|
logger.info("Rollback berhasil: pkl lama dipulihkan dari .bak")
|
||||||
else:
|
else:
|
||||||
logger.warning("File .bak tidak ditemukan — pkl aktif dibiarkan.")
|
logger.warning("File .bak tidak ditemukan — pkl aktif dibiarkan.")
|
||||||
|
|
||||||
|
# [v5] Log event rollback dengan metadata lengkap
|
||||||
|
log_event(
|
||||||
|
event_type="rollback",
|
||||||
|
message=f"Model lama dipertahankan. {reason}",
|
||||||
|
stage="rolled_back",
|
||||||
|
metadata={"reason": reason, "old_f1": old_f1, "new_f1": new_f1}
|
||||||
|
)
|
||||||
|
|
||||||
write_status(
|
write_status(
|
||||||
stage="rolled_back",
|
stage="rolled_back",
|
||||||
message=f"Model lama dipertahankan. {reason}",
|
message=f"Model lama dipertahankan. {reason}",
|
||||||
|
|
@ -254,6 +246,13 @@ def do_rollback(reason: str, old_f1: float, new_f1: float):
|
||||||
# MAIN
|
# MAIN
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
def main(extra_csv_path: str = None):
|
def main(extra_csv_path: str = None):
|
||||||
|
# [v5] Log awal sesi — penting untuk audit trail
|
||||||
|
log_event(
|
||||||
|
event_type="session_start",
|
||||||
|
message=f"Retraining session dimulai. Log file: {LOG_FILE_PATH.name}",
|
||||||
|
stage="started",
|
||||||
|
metadata={"extra_csv_provided": extra_csv_path is not None}
|
||||||
|
)
|
||||||
write_status("started", "Worker dimulai")
|
write_status("started", "Worker dimulai")
|
||||||
|
|
||||||
# ── STEP 1: BACKUP ──────────────────────────────────────
|
# ── STEP 1: BACKUP ──────────────────────────────────────
|
||||||
|
|
@ -264,8 +263,6 @@ def main(extra_csv_path: str = None):
|
||||||
shutil.copy2(PIPELINE_PATH, PIPELINE_BAK_PATH)
|
shutil.copy2(PIPELINE_PATH, PIPELINE_BAK_PATH)
|
||||||
logger.info(f"Backup tersimpan: {PIPELINE_BAK_PATH}")
|
logger.info(f"Backup tersimpan: {PIPELINE_BAK_PATH}")
|
||||||
|
|
||||||
# baseline_f1 akan dihitung setelah split dinamis terbentuk
|
|
||||||
# (evaluasi model lama pada test set yang sama dengan candidate)
|
|
||||||
baseline_f1 = 0.0
|
baseline_f1 = 0.0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -277,7 +274,7 @@ def main(extra_csv_path: str = None):
|
||||||
|
|
||||||
df_corpus = pd.read_csv(CORPUS_PATH, sep=";", dtype=str).dropna(subset=["job_text_raw", "label"])
|
df_corpus = pd.read_csv(CORPUS_PATH, sep=";", dtype=str).dropna(subset=["job_text_raw", "label"])
|
||||||
df_corpus["features"] = df_corpus["job_text_raw"].apply(preprocess_stemmed)
|
df_corpus["features"] = df_corpus["job_text_raw"].apply(preprocess_stemmed)
|
||||||
df_corpus["_weight"] = 1.0 # Bobot normal untuk data historis
|
df_corpus["_weight"] = 1.0
|
||||||
logger.info(f"Corpus asli: {len(df_corpus)} baris")
|
logger.info(f"Corpus asli: {len(df_corpus)} baris")
|
||||||
|
|
||||||
df_extra = pd.DataFrame()
|
df_extra = pd.DataFrame()
|
||||||
|
|
@ -288,9 +285,6 @@ def main(extra_csv_path: str = None):
|
||||||
override_w = compute_override_weight(len(df_corpus), len(df_extra))
|
override_w = compute_override_weight(len(df_corpus), len(df_extra))
|
||||||
df_extra["_weight"] = override_w
|
df_extra["_weight"] = override_w
|
||||||
|
|
||||||
# Pre-compute total bobot sekali — dipakai di logging & metrics JSON.
|
|
||||||
# .sum() lebih benar daripada len() * .iloc[0] karena tidak mengasumsikan
|
|
||||||
# homogenitas bobot di seluruh baris (future-proof jika bobot per-baris ditambahkan).
|
|
||||||
total_corpus_weight = df_corpus["_weight"].sum()
|
total_corpus_weight = df_corpus["_weight"].sum()
|
||||||
total_override_weight = df_extra["_weight"].sum() if len(df_extra) > 0 else 0.0
|
total_override_weight = df_extra["_weight"].sum() if len(df_extra) > 0 else 0.0
|
||||||
actual_influence_pct = (
|
actual_influence_pct = (
|
||||||
|
|
@ -308,9 +302,7 @@ def main(extra_csv_path: str = None):
|
||||||
else:
|
else:
|
||||||
logger.info("Tidak ada data tambahan — menggunakan corpus asli saja")
|
logger.info("Tidak ada data tambahan — menggunakan corpus asli saja")
|
||||||
|
|
||||||
# Concat tanpa deduplication — semua frekuensi historis dipertahankan
|
|
||||||
df_all = pd.concat([df_corpus, df_extra], ignore_index=True) if len(df_extra) > 0 else df_corpus.copy()
|
df_all = pd.concat([df_corpus, df_extra], ignore_index=True) if len(df_extra) > 0 else df_corpus.copy()
|
||||||
|
|
||||||
mask = df_all["features"].str.len() > 0
|
mask = df_all["features"].str.len() > 0
|
||||||
df_all = df_all[mask]
|
df_all = df_all[mask]
|
||||||
logger.info(f"Total setelah merge (tanpa deduplicate): {len(df_all)} baris")
|
logger.info(f"Total setelah merge (tanpa deduplicate): {len(df_all)} baris")
|
||||||
|
|
@ -322,19 +314,27 @@ def main(extra_csv_path: str = None):
|
||||||
y_all = df_all["label"]
|
y_all = df_all["label"]
|
||||||
w_all = df_all["_weight"]
|
w_all = df_all["_weight"]
|
||||||
|
|
||||||
# ── STEP 3: DYNAMIC SPLIT — selalu dari data gabungan terkini ────
|
# [v5] Log event data merge dengan metadata
|
||||||
# Test set statis (test_set.csv) tidak digunakan karena:
|
log_event(
|
||||||
# (a) tidak mencerminkan pola baru dari data override
|
event_type="data_merged",
|
||||||
# (b) baseline_f1 dari JSON dihitung pada distribusi berbeda →
|
message=f"Data training siap: {len(df_all)} baris",
|
||||||
# perbandingan apel vs jeruk, tidak valid untuk keputusan promote.
|
stage="loading_data",
|
||||||
# Solusi: split dinamis, lalu evaluasi model LAMA pada test set YANG SAMA.
|
metadata={
|
||||||
|
"corpus_rows": len(df_corpus),
|
||||||
|
"override_rows": len(df_extra),
|
||||||
|
"override_weight": override_w if len(df_extra) > 0 else 1.0,
|
||||||
|
"influence_pct": round(actual_influence_pct, 2),
|
||||||
|
"total_merged": len(df_all),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── STEP 3: DYNAMIC SPLIT ────────────────────────────
|
||||||
X_train, X_test, y_train, y_test, w_train, _ = train_test_split(
|
X_train, X_test, y_train, y_test, w_train, _ = train_test_split(
|
||||||
X_all, y_all, w_all, test_size=0.3, random_state=42, stratify=y_all
|
X_all, y_all, w_all, test_size=0.3, random_state=42, stratify=y_all
|
||||||
)
|
)
|
||||||
logger.info(f"Dynamic split: {len(X_train)} train | {len(X_test)} test")
|
logger.info(f"Dynamic split: {len(X_train)} train | {len(X_test)} test")
|
||||||
|
|
||||||
# ── STEP 4: BASELINE — evaluasi model LAMA pada test set yang sama ──
|
# ── STEP 4: BASELINE ─────────────────────────────────
|
||||||
# Ini satu-satunya cara perbandingan yang jujur (apples-to-apples).
|
|
||||||
write_status("evaluating", "Mengevaluasi model lama pada test set baru...")
|
write_status("evaluating", "Mengevaluasi model lama pada test set baru...")
|
||||||
baseline_f1 = get_baseline_f1_on_testset(X_test, y_test)
|
baseline_f1 = get_baseline_f1_on_testset(X_test, y_test)
|
||||||
|
|
||||||
|
|
@ -342,12 +342,12 @@ def main(extra_csv_path: str = None):
|
||||||
write_status("training", f"K-Fold validation & training... ({len(X_train)} sampel)")
|
write_status("training", f"K-Fold validation & training... ({len(X_train)} sampel)")
|
||||||
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
|
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
|
||||||
fold_metrics = []
|
fold_metrics = []
|
||||||
|
fold_accuracies = [] # [v5] untuk log ringkas
|
||||||
for i, (tr_idx, te_idx) in enumerate(skf.split(X_train, y_train)):
|
for i, (tr_idx, te_idx) in enumerate(skf.split(X_train, y_train)):
|
||||||
m = Pipeline([
|
m = Pipeline([
|
||||||
('tfidf', TfidfVectorizer(max_features=3000, ngram_range=(1, 2), sublinear_tf=True, min_df=1)),
|
('tfidf', TfidfVectorizer(max_features=3000, ngram_range=(1, 2), sublinear_tf=True, min_df=1)),
|
||||||
('clf', LogisticRegression(max_iter=1000, class_weight='balanced', solver='lbfgs'))
|
('clf', LogisticRegression(max_iter=1000, class_weight='balanced', solver='lbfgs'))
|
||||||
])
|
])
|
||||||
# sample_weight diteruskan ke step 'clf' via Pipeline naming convention
|
|
||||||
m.fit(
|
m.fit(
|
||||||
X_train.iloc[tr_idx], y_train.iloc[tr_idx],
|
X_train.iloc[tr_idx], y_train.iloc[tr_idx],
|
||||||
clf__sample_weight=w_train.iloc[tr_idx].values
|
clf__sample_weight=w_train.iloc[tr_idx].values
|
||||||
|
|
@ -355,8 +355,21 @@ def main(extra_csv_path: str = None):
|
||||||
y_pred = m.predict(X_train.iloc[te_idx])
|
y_pred = m.predict(X_train.iloc[te_idx])
|
||||||
rep = classification_report(y_train.iloc[te_idx], y_pred, output_dict=True, zero_division=0)
|
rep = classification_report(y_train.iloc[te_idx], y_pred, output_dict=True, zero_division=0)
|
||||||
fold_metrics.append(rep)
|
fold_metrics.append(rep)
|
||||||
|
fold_accuracies.append(round(rep['accuracy'], 4))
|
||||||
write_status("training", f"K-Fold selesai: fold {i+1}/5 | acc={rep['accuracy']:.4f}")
|
write_status("training", f"K-Fold selesai: fold {i+1}/5 | acc={rep['accuracy']:.4f}")
|
||||||
|
|
||||||
|
# [v5] Log ringkas hasil K-Fold
|
||||||
|
log_event(
|
||||||
|
event_type="kfold_completed",
|
||||||
|
message=f"K-Fold 5 lipatan selesai. Akurasi per fold: {fold_accuracies}",
|
||||||
|
stage="training",
|
||||||
|
metadata={
|
||||||
|
"fold_accuracies": fold_accuracies,
|
||||||
|
"mean_accuracy": round(float(np.mean(fold_accuracies)), 4),
|
||||||
|
"std_accuracy": round(float(np.std(fold_accuracies)), 4),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# ── STEP 6: TRAINING MODEL FINAL ─────────────────────
|
# ── STEP 6: TRAINING MODEL FINAL ─────────────────────
|
||||||
write_status("training", "Training model final pada seluruh data training...")
|
write_status("training", "Training model final pada seluruh data training...")
|
||||||
candidate_model = Pipeline([
|
candidate_model = Pipeline([
|
||||||
|
|
@ -392,11 +405,29 @@ def main(extra_csv_path: str = None):
|
||||||
f"minimum dibutuhkan: +{MIN_IMPROVEMENT_THRESHOLD*100:.1f}%)"
|
f"minimum dibutuhkan: +{MIN_IMPROVEMENT_THRESHOLD*100:.1f}%)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# [v5] Log event evaluasi dengan metadata lengkap — ini yang akan dikutip di BAB 4
|
||||||
|
log_event(
|
||||||
|
event_type="evaluation_completed",
|
||||||
|
message=f"Evaluasi selesai. Delta F1: {delta:+.4f} ({'promote' if should_promote else 'rollback'})",
|
||||||
|
stage="evaluating",
|
||||||
|
metadata={
|
||||||
|
"baseline_f1": baseline_f1,
|
||||||
|
"candidate_f1": new_f1,
|
||||||
|
"delta": round(delta, 4),
|
||||||
|
"delta_pct": round(delta * 100, 2),
|
||||||
|
"threshold_min": MIN_IMPROVEMENT_THRESHOLD,
|
||||||
|
"decision": "promote" if should_promote else "rollback",
|
||||||
|
"reason": reason,
|
||||||
|
"new_accuracy": new_metrics["accuracy"],
|
||||||
|
"per_class_f1": {cls: v["f1"] for cls, v in new_metrics["per_class"].items()},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
if not should_promote:
|
if not should_promote:
|
||||||
do_rollback(reason, baseline_f1, new_f1)
|
do_rollback(reason, baseline_f1, new_f1)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ── STEP 7: PROMOTE ──────────────────────────────────
|
# ── STEP 8: PROMOTE ──────────────────────────────────
|
||||||
write_status("promoting", "Model baru lebih baik — mempromote model baru...")
|
write_status("promoting", "Model baru lebih baik — mempromote model baru...")
|
||||||
shutil.move(str(CANDIDATE_PATH), str(PIPELINE_PATH))
|
shutil.move(str(CANDIDATE_PATH), str(PIPELINE_PATH))
|
||||||
logger.info(f"Model baru dipromote: {PIPELINE_PATH}")
|
logger.info(f"Model baru dipromote: {PIPELINE_PATH}")
|
||||||
|
|
@ -420,7 +451,6 @@ def main(extra_csv_path: str = None):
|
||||||
y_test, candidate_model.predict(X_test),
|
y_test, candidate_model.predict(X_test),
|
||||||
output_dict=True, zero_division=0
|
output_dict=True, zero_division=0
|
||||||
),
|
),
|
||||||
# Perbandingan fair: kedua model dievaluasi pada test set yang SAMA
|
|
||||||
"comparison": {
|
"comparison": {
|
||||||
"note": "Both models evaluated on identical dynamic test set",
|
"note": "Both models evaluated on identical dynamic test set",
|
||||||
"old_weighted_f1": baseline_f1,
|
"old_weighted_f1": baseline_f1,
|
||||||
|
|
@ -430,6 +460,20 @@ def main(extra_csv_path: str = None):
|
||||||
}
|
}
|
||||||
METRICS_PATH.write_text(json.dumps(new_metrics_full, indent=2, ensure_ascii=False), encoding="utf-8")
|
METRICS_PATH.write_text(json.dumps(new_metrics_full, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||||
|
|
||||||
|
# [v5] Log event promote final
|
||||||
|
log_event(
|
||||||
|
event_type="model_promoted",
|
||||||
|
message=f"Model baru berhasil dipromote. {reason}",
|
||||||
|
stage="promoted",
|
||||||
|
metadata={
|
||||||
|
"old_f1": baseline_f1,
|
||||||
|
"new_f1": new_f1,
|
||||||
|
"delta": round(delta, 4),
|
||||||
|
"log_file": LOG_FILE_PATH.name,
|
||||||
|
"metrics_file": METRICS_PATH.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
write_status(
|
write_status(
|
||||||
stage="promoted",
|
stage="promoted",
|
||||||
message=f"Model baru berhasil dipromote. {reason}",
|
message=f"Model baru berhasil dipromote. {reason}",
|
||||||
|
|
@ -447,12 +491,20 @@ def main(extra_csv_path: str = None):
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"ERROR saat training: {type(e).__name__}: {e}", exc_info=True)
|
logger.error(f"ERROR saat training: {type(e).__name__}: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# [v5] Log event error dengan traceback info
|
||||||
|
log_event(
|
||||||
|
event_type="training_error",
|
||||||
|
message=f"Training gagal: {type(e).__name__}: {str(e)[:200]}",
|
||||||
|
stage="failed",
|
||||||
|
metadata={"error_type": type(e).__name__, "error_message": str(e)[:500]}
|
||||||
|
)
|
||||||
|
|
||||||
do_rollback(
|
do_rollback(
|
||||||
reason=f"Training gagal karena error: {type(e).__name__}: {str(e)[:200]}",
|
reason=f"Training gagal karena error: {type(e).__name__}: {str(e)[:200]}",
|
||||||
old_f1=baseline_f1,
|
old_f1=baseline_f1,
|
||||||
new_f1=0.0
|
new_f1=0.0
|
||||||
)
|
)
|
||||||
# Override stage ke 'failed' agar UI tahu ini bukan rollback biasa
|
|
||||||
lock = FileLock(LOCK_PATH, timeout=10)
|
lock = FileLock(LOCK_PATH, timeout=10)
|
||||||
with lock:
|
with lock:
|
||||||
status = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
|
status = json.loads(STATUS_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import joblib
|
||||||
|
pipeline = joblib.load('ml_assets/ml_pipeline_internal.pkl')
|
||||||
|
vec = pipeline.named_steps['tfidf']
|
||||||
|
clf = pipeline.named_steps['clf']
|
||||||
|
|
||||||
|
# Ambil 5 fitur dengan bobot tertinggi untuk kelas Programmer
|
||||||
|
feats = vec.get_feature_names_out()
|
||||||
|
prog_idx = list(clf.classes_).index('Programmer')
|
||||||
|
top5 = sorted(zip(feats, clf.coef_[prog_idx]), key=lambda x: -x[1])[:10]
|
||||||
|
for w, c in top5:
|
||||||
|
print(f"{w}: {c:.4f}")
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"status": "error", "message": "Processing failed: 'charmap' codec can't encode character '\\U0001f602' in position 885944: character maps to <undefined>", "processed_rows": 0, "total_rows": 0, "source_type": "unknown"}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"status": "error", "message": "Processing failed: 'charmap' codec can't encode character '\\U0001f602' in position 885944: character maps to <undefined>", "processed_rows": 0, "total_rows": 0, "source_type": "unknown"}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,29 @@
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.267450", "level": "INFO", "logger": "retrain_worker", "message": "[init] Log file tersimpan di: D:\\msaif\\Project\\Laravel\\sista_mif_ta\\fastapi\\ml_assets\\logs\\retrain_log_20260605_190842.jsonl"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.267450", "level": "INFO", "logger": "retrain_worker", "message": "Retraining session dimulai. Log file: retrain_log_20260605_190842.jsonl", "stage": "started", "event_type": "session_start", "metadata": {"extra_csv_provided": true}}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.268452", "level": "INFO", "logger": "retrain_worker", "message": "[started] Worker dimulai", "stage": "started"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.269454", "level": "INFO", "logger": "retrain_worker", "message": "[backup] Membuat backup model lama...", "stage": "backup"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.271473", "level": "INFO", "logger": "retrain_worker", "message": "Backup tersimpan: D:\\msaif\\Project\\Laravel\\sista_mif_ta\\fastapi\\ml_assets\\ml_pipeline_internal.pkl.bak"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.271473", "level": "INFO", "logger": "retrain_worker", "message": "[loading_data] Memuat dan menggabungkan data training...", "stage": "loading_data"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.275472", "level": "INFO", "logger": "retrain_worker", "message": "Corpus asli: 250 baris"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.445530", "level": "INFO", "logger": "retrain_worker", "message": "Data manual_override: 1 baris | weight=6.6648x (log-damped) | influence aktual=2.6% (target nominal 30%)"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.465047", "level": "INFO", "logger": "retrain_worker", "message": "Total setelah merge (tanpa deduplicate): 251 baris"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.465047", "level": "INFO", "logger": "retrain_worker", "message": "Data training siap: 251 baris", "stage": "loading_data", "event_type": "data_merged", "metadata": {"corpus_rows": 250, "override_rows": 1, "override_weight": 6.6648, "influence_pct": 2.6, "total_merged": 251}}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.479066", "level": "INFO", "logger": "retrain_worker", "message": "Dynamic split: 175 train | 76 test"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.480074", "level": "INFO", "logger": "retrain_worker", "message": "[evaluating] Mengevaluasi model lama pada test set baru...", "stage": "evaluating"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.492120", "level": "INFO", "logger": "retrain_worker", "message": "Baseline F1 (old model, same test set): 0.9059"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.494119", "level": "INFO", "logger": "retrain_worker", "message": "[training] K-Fold validation & training... (175 sampel)", "stage": "training"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.534414", "level": "INFO", "logger": "retrain_worker", "message": "[training] K-Fold selesai: fold 1/5 | acc=0.5143", "stage": "training"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.542922", "level": "INFO", "logger": "retrain_worker", "message": "[training] K-Fold selesai: fold 2/5 | acc=0.6857", "stage": "training"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.552262", "level": "INFO", "logger": "retrain_worker", "message": "[training] K-Fold selesai: fold 3/5 | acc=0.5429", "stage": "training"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.561273", "level": "INFO", "logger": "retrain_worker", "message": "[training] K-Fold selesai: fold 4/5 | acc=0.5714", "stage": "training"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.569782", "level": "INFO", "logger": "retrain_worker", "message": "[training] K-Fold selesai: fold 5/5 | acc=0.7143", "stage": "training"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.569782", "level": "INFO", "logger": "retrain_worker", "message": "K-Fold 5 lipatan selesai. Akurasi per fold: [0.5143, 0.6857, 0.5429, 0.5714, 0.7143]", "stage": "training", "event_type": "kfold_completed", "metadata": {"fold_accuracies": [0.5143, 0.6857, 0.5429, 0.5714, 0.7143], "mean_accuracy": 0.6057, "std_accuracy": 0.0796}}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.570788", "level": "INFO", "logger": "retrain_worker", "message": "[training] Training model final pada seluruh data training...", "stage": "training"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.579235", "level": "INFO", "logger": "retrain_worker", "message": "Candidate model tersimpan: D:\\msaif\\Project\\Laravel\\sista_mif_ta\\fastapi\\ml_assets\\ml_pipeline_candidate.pkl"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.589948", "level": "INFO", "logger": "retrain_worker", "message": "[evaluating] Mengevaluasi model baru vs model lama (test set sama)...", "stage": "evaluating"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.593983", "level": "INFO", "logger": "retrain_worker", "message": "Baseline F1 (old model, same test) : 0.9059"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.593983", "level": "INFO", "logger": "retrain_worker", "message": "Candidate F1 (new model, same test): 0.7011 (delta: -0.2048)"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.593983", "level": "INFO", "logger": "retrain_worker", "message": "Evaluasi selesai. Delta F1: -0.2048 (rollback)", "stage": "evaluating", "event_type": "evaluation_completed", "metadata": {"baseline_f1": 0.9059, "candidate_f1": 0.7011, "delta": -0.2048, "delta_pct": -20.48, "threshold_min": 0.01, "decision": "rollback", "reason": "Model baru lebih buruk secara signifikan (-20.48% weighted F1)", "new_accuracy": 0.6711, "per_class_f1": {"Programmer": 0.8, "Data Analyst": 0.19, "Wirausaha Informatika": 0.474, "Non-IT": 0.897}}}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.593983", "level": "INFO", "logger": "retrain_worker", "message": "Rollback berhasil: pkl lama dipulihkan dari .bak"}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.593983", "level": "INFO", "logger": "retrain_worker", "message": "Model lama dipertahankan. Model baru lebih buruk secara signifikan (-20.48% weighted F1)", "stage": "rolled_back", "event_type": "rollback", "metadata": {"reason": "Model baru lebih buruk secara signifikan (-20.48% weighted F1)", "old_f1": 0.9059, "new_f1": 0.7011}}
|
||||||
|
{"timestamp": "2026-06-05T19:08:42.594976", "level": "INFO", "logger": "retrain_worker", "message": "[rolled_back] Model lama dipertahankan. Model baru lebih buruk secara signifikan (-20.48% weighted F1)", "stage": "rolled_back"}
|
||||||
Loading…
Reference in New Issue