sista_mif_ta/data/train_model.py

399 lines
19 KiB
Python

# import pandas as pd
# import numpy as np
# import re
# import joblib
# import json
# import warnings
# from pathlib import Path
# from sklearn.feature_extraction.text import TfidfVectorizer
# from sklearn.linear_model import LogisticRegression
# from sklearn.model_selection import StratifiedKFold
# from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay
# from sklearn.pipeline import Pipeline
# import matplotlib
# matplotlib.use('Agg')
# import matplotlib.pyplot as plt
# warnings.filterwarnings('ignore')
# BASE_DIR = Path(__file__).parent
# TRAIN_FILE = BASE_DIR / "processed" / "training_corpus.csv"
# TEST_FILE = BASE_DIR / "processed" / "test_set.csv"
# ML_DIR = BASE_DIR.parent / "fastapi" / "ml_assets"
# ML_DIR.mkdir(parents=True, exist_ok=True)
# TARGET_CLASSES = ["Programmer", "Data Analyst", "Wirausaha Informatika", "Non-IT"]
# CONFIDENCE_THRESHOLD = 0.50
# # [v4] Tidak import Sastrawi — corpus sudah di-stem oleh prepare_corpus.py
# # Stemming dua kali pada data yang sama dapat mendegradasi teks.
# STOPWORDS = {
# "yang", "di", "ke", "dari", "dan", "atau", "dengan", "untuk", "pada", "dalam",
# "adalah", "ini", "itu", "tidak", "juga", "sudah", "akan", "bisa", "ada", "oleh",
# "karena", "secara", "serta", "sebagai", "bagi", "telah", "maka", "namun", "sehingga",
# "jika", "agar", "ketika", "saat", "sebelum", "sesudah", "hingga", "sampai", "antara",
# "sekitar", "hanya", "saja", "belum", "masih", "lagi", "pun", "justru", "walaupun",
# "meskipun", "bahkan", "cukup", "sangat", "paling", "lebih", "kurang", "lain",
# "macam", "cara", "hal", "tentang", "mengenai", "terhadap", "kepada", "menuju",
# "kecuali", "selain", "tanpa", "demi", "guna", "khususnya", "umumnya", "kebanyakan",
# "sebagian", "beberapa", "semua", "setiap", "tiap", "satu", "dua", "tiga", "empat",
# "lima", "enam", "tujuh", "delapan", "sembilan", "sepuluh", "ratus", "ribu", "juta"
# }
# def preprocess_text(text):
# """Pembersihan dasar — tanpa stemming ulang karena korpus sudah di-stem."""
# if pd.isna(text) or not isinstance(text, str): return ""
# text = text.lower().strip()
# if text in {"nan", "none", "null", "-", "0", "", "tidak diisi"}: return ""
# text = text.replace('-', ' ').replace('/', ' ').replace('_', ' ').replace(',', ' ')
# text = re.sub(r'[^\w\s]', '', text)
# text = re.sub(r'\d+', '', text)
# words = [w for w in text.split() if w not in STOPWORDS and len(w) > 2]
# return " ".join(words)
# def main():
# if not TRAIN_FILE.exists():
# print("File training_corpus.csv belum ada. Jalankan prepare_corpus.py terlebih dahulu."); return
# df_train = pd.read_csv(TRAIN_FILE, sep=";", dtype=str).dropna(subset=["job_text_raw", "label"])
# df_test = pd.read_csv(TEST_FILE, sep=";", dtype=str).dropna(subset=["job_text_raw", "label"]) if TEST_FILE.exists() else None
# print("Sedang memproses teks (cleaning dasar tanpa stemming redundan)...")
# X_train = df_train["job_text_raw"].apply(preprocess_text)
# y_train = df_train["label"]
# mask = X_train.str.len() > 0
# X_train, y_train = X_train[mask], y_train[mask]
# X_test, y_test = pd.Series(dtype=str), pd.Series(dtype=str)
# if df_test is not None:
# X_test = df_test["job_text_raw"].apply(preprocess_text)
# y_test = df_test["label"]
# mask_t = X_test.str.len() > 0
# X_test, y_test = X_test[mask_t], y_test[mask_t]
# print(f"Jumlah data latih: {len(X_train)} baris | Data uji: {len(X_test)} baris\n")
# # [KEEP v3] K-Fold cross validation — estimasi variance performa model
# print("Melakukan pengujian K-Fold (5 putaran) pada data latih...")
# skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
# fold_metrics = []
# for i, (tr_idx, te_idx) in enumerate(skf.split(X_train, y_train)):
# model = Pipeline([
# ('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'))
# ])
# model.fit(X_train.iloc[tr_idx], y_train.iloc[tr_idx])
# y_pred = model.predict(X_train.iloc[te_idx])
# rep = classification_report(y_train.iloc[te_idx], y_pred, output_dict=True, zero_division=0)
# fold_metrics.append(rep)
# print(f" Akurasi putaran ke-{i+1}: {rep['accuracy']:.4f}")
# avg_acc = np.mean([f['accuracy'] for f in fold_metrics])
# print(f"\n RATA-RATA PENGUJIAN K-FOLD:")
# print(f" Akurasi Keseluruhan : {avg_acc:.4f} ± {np.std([f['accuracy'] for f in fold_metrics]):.4f}")
# for cls in TARGET_CLASSES:
# p = np.mean([f.get(cls, {}).get('precision', 0) for f in fold_metrics])
# r = np.mean([f.get(cls, {}).get('recall', 0) for f in fold_metrics])
# f1 = np.mean([f.get(cls, {}).get('f1-score', 0) for f in fold_metrics])
# print(f" {cls:25} | P: {p:.3f} | R: {r:.3f} | F1: {f1:.3f}")
# print("\n Membuat model final dari seluruh data latih yang tersedia...")
# final_model = Pipeline([
# ('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'))
# ])
# final_model.fit(X_train, y_train)
# metrics_test = None
# if len(X_test) > 0:
# y_pred = final_model.predict(X_test)
# rep_test = classification_report(y_test, y_pred, output_dict=True, zero_division=0)
# metrics_test = rep_test
# print("\n HASIL PENGUJIAN PADA DATA TEST:")
# print(classification_report(y_test, y_pred, zero_division=0))
# for cls in TARGET_CLASSES:
# sup = rep_test[cls]['support'] if cls in rep_test else 0
# if sup < 5: print(f"Catatan: Kelas '{cls}' cuma punya {sup} data uji — skor F1-nya mungkin kurang akurat.")
# # [KEEP v3] Confusion matrix
# cm = confusion_matrix(y_test, y_pred, labels=TARGET_CLASSES)
# disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=TARGET_CLASSES)
# disp.plot(cmap='Blues', values_format='d', xticks_rotation=45, colorbar=False)
# plt.title('Confusion Matrix - Hold-Out Test'); plt.tight_layout()
# plt.savefig(ML_DIR / 'confusion_matrix_test.png', dpi=300); plt.close()
# print("Grafik confusion matrix berhasil disimpan ke confusion_matrix_test.png")
# # [BUGFIX] Feature importance per kelas
# # clf.coef_ diurutkan mengikuti clf.classes_ (alfabetikal sklearn),
# # BUKAN urutan TARGET_CLASSES yang hardcoded. Pakai clf.classes_ sebagai
# # iterator agar index i selalu selaras dengan baris koefisien yang benar.
# vec, clf = final_model.named_steps['tfidf'], final_model.named_steps['clf']
# feats, coeffs = vec.get_feature_names_out(), clf.coef_
# print("\n5 KATA PALING BERPENGARUH UNTUK TIAP KELAS:")
# for i, cls in enumerate(clf.classes_): # <-- clf.classes_, bukan TARGET_CLASSES
# top = [(feats[j], coeffs[i][j]) for j in coeffs[i].argsort()[-5:][::-1] if coeffs[i][j] > 0]
# print(f" [{cls}] " + ", ".join([f"{w}({c:.2f})" for w, c in top]))
# # [KEEP v3] Confidence distribution
# proba = final_model.predict_proba(X_test)
# max_p = np.max(proba, axis=1)
# plt.hist(max_p, bins=15, edgecolor='black', alpha=0.7, color='teal')
# plt.axvline(x=CONFIDENCE_THRESHOLD, color='red', linestyle='--', linewidth=2, label=f'Threshold {CONFIDENCE_THRESHOLD}')
# plt.xlabel('Confidence Score'); plt.ylabel('Count'); plt.title('Distribusi Confidence Score')
# plt.legend(); plt.grid(axis='y', alpha=0.3); plt.tight_layout()
# plt.savefig(ML_DIR / 'confidence_distribution.png', dpi=300); plt.close()
# below = np.sum(max_p < CONFIDENCE_THRESHOLD)
# print(f"\nPengecekan Keyakinan Model: {below} dari {len(max_p)} prediksi ({below/len(max_p)*100:.1f}%) di bawah threshold {CONFIDENCE_THRESHOLD}.")
# # [KEEP v3] Daftar prediksi yang meleset
# mis = np.where(y_test.values != y_pred)[0]
# if len(mis) > 0:
# print("\n DAFTAR PREDIKSI YANG MELESET:")
# for idx in mis[:min(6, len(mis))]:
# txt = df_test.iloc[idx]['job_text_raw'][:80]
# print(f" • Seharusnya: {y_test.iloc[idx]:20} | Ditebak: {y_pred[idx]:20} | Teks: '{txt}...'")
# else:
# print("\n Semua prediksi pada test set benar.")
# model_path = ML_DIR / "ml_pipeline_internal.pkl"
# joblib.dump(final_model, model_path)
# metrics = {
# "methodology": "Internal MIF only",
# "k_fold": {
# "accuracy_mean": float(avg_acc),
# "accuracy_std": float(np.std([f['accuracy'] for f in fold_metrics])),
# "folds": fold_metrics
# },
# "threshold_config": CONFIDENCE_THRESHOLD
# }
# if metrics_test: metrics["hold_out_test"] = metrics_test
# with open(ML_DIR / "metrics_internal_only.json", 'w') as f: json.dump(metrics, f, indent=2)
# print(f"\n Model berhasil disimpan di: {model_path}")
# print(f"Laporan metrik disimpan di: {ML_DIR / 'metrics_internal_only.json'}")
# if __name__ == "__main__":
# main()
import pandas as pd
import numpy as np
import re
import joblib
import json
import warnings
from pathlib import Path
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import StratifiedKFold
from sklearn.metrics import classification_report, confusion_matrix, ConfusionMatrixDisplay
from sklearn.pipeline import Pipeline
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
# PERBAIKAN 1: Hapus warnings.filterwarnings('ignore') secara global.
# Biarkan warning muncul. Jika ada ConvergenceWarning, Anda akan tahu bahwa max_iter perlu dinaikkan lagi.
BASE_DIR = Path(__file__).parent
TRAIN_FILE = BASE_DIR / "processed" / "training_corpus.csv"
TEST_FILE = BASE_DIR / "processed" / "test_set.csv"
ML_DIR = BASE_DIR.parent / "fastapi" / "ml_assets"
ML_DIR.mkdir(parents=True, exist_ok=True)
TARGET_CLASSES = ["Programmer", "Data Analyst", "Wirausaha Informatika", "Non-IT"]
CONFIDENCE_THRESHOLD = 0.50
STOPWORDS = {
"yang", "di", "ke", "dari", "dan", "atau", "dengan", "untuk", "pada", "dalam",
"adalah", "ini", "itu", "tidak", "juga", "sudah", "akan", "bisa", "ada", "oleh",
"karena", "secara", "serta", "sebagai", "bagi", "telah", "maka", "namun", "sehingga",
"jika", "agar", "ketika", "saat", "sebelum", "sesudah", "hingga", "sampai", "antara",
"sekitar", "hanya", "saja", "belum", "masih", "lagi", "pun", "justru", "walaupun",
"meskipun", "bahkan", "cukup", "sangat", "paling", "lebih", "kurang", "lain",
"macam", "cara", "hal", "tentang", "mengenai", "terhadap", "kepada", "menuju",
"kecuali", "selain", "tanpa", "demi", "guna", "khususnya", "umumnya", "kebanyakan",
"sebagian", "beberapa", "semua", "setiap", "tiap", "satu", "dua", "tiga", "empat",
"lima", "enam", "tujuh", "delapan", "sembilan", "sepuluh", "ratus", "ribu", "juta"
}
def preprocess_text(text):
"""Pembersihan dasar — tanpa stemming ulang karena korpus sudah di-stem."""
if pd.isna(text) or not isinstance(text, str): return ""
text = text.lower().strip()
if text in {"nan", "none", "null", "-", "0", "", "tidak diisi"}: return ""
text = text.replace('-', ' ').replace('/', ' ').replace('_', ' ').replace(',', ' ')
text = re.sub(r'[^\w\s]', '', text)
# PERBAIKAN 2: Penambahan komentar eksplisit.
# Anda harus sadar bahwa menghapus angka menghilangkan sinyal seperti "Python 3" atau "5 tahun".
# Jika ini keputusan sadar, biarkan. Jika tidak, hapus baris di bawah ini.
text = re.sub(r'\d+', '', text)
words = [w for w in text.split() if w not in STOPWORDS and len(w) > 2]
return " ".join(words)
def get_pipeline():
"""Fungsi helper untuk memastikan konsistensi pipeline antara K-Fold dan Final Model."""
return Pipeline([
('tfidf', TfidfVectorizer(
max_features=3000,
ngram_range=(1, 2),
sublinear_tf=True,
min_df=2, # PERBAIKAN 3: Diubah dari 1 ke 2 untuk mencegah overfitting pada noise/typo.
norm='l2' # PERBAIKAN 4: Eksplisit menyatakan normalisasi L2 (default, tapi ditulis agar transparan).
)),
('clf', LogisticRegression(
max_iter=2000, # PERBAIKAN 5: Dinaikkan dari 1000 untuk mencegah ConvergenceWarning pada data sparse.
class_weight='balanced',
solver='lbfgs',
random_state=42 # PERBAIKAN 6: Ditambahkan untuk reproducibility (hasil yang konsisten).
))
])
def main():
if not TRAIN_FILE.exists():
print("File training_corpus.csv belum ada. Jalankan prepare_corpus.py terlebih dahulu.")
return
df_train = pd.read_csv(TRAIN_FILE, sep=";", dtype=str).dropna(subset=["job_text_raw", "label"])
df_test = pd.read_csv(TEST_FILE, sep=";", dtype=str).dropna(subset=["job_text_raw", "label"]) if TEST_FILE.exists() else None
print("Sedang memproses teks (cleaning dasar tanpa stemming redundan)...")
X_train = df_train["job_text_raw"].apply(preprocess_text)
y_train = df_train["label"]
mask = X_train.str.len() > 0
X_train, y_train = X_train[mask], y_train[mask]
X_test, y_test = pd.Series(dtype=str), pd.Series(dtype=str)
if df_test is not None:
X_test = df_test["job_text_raw"].apply(preprocess_text)
y_test = df_test["label"]
mask_t = X_test.str.len() > 0
X_test, y_test = X_test[mask_t], y_test[mask_t]
print(f"Jumlah data latih: {len(X_train)} baris | Data uji: {len(X_test)} baris\n")
# K-Fold cross validation
print("Melakukan pengujian K-Fold (5 putaran) pada data latih...")
skf = StratifiedKFold(n_splits=5, shuffle=True, random_state=42)
fold_metrics = []
for i, (tr_idx, te_idx) in enumerate(skf.split(X_train, y_train)):
# PERBAIKAN 7: Menggunakan fungsi get_pipeline() untuk menghindari duplikasi kode dan inkonsistensi
model = get_pipeline()
model.fit(X_train.iloc[tr_idx], y_train.iloc[tr_idx])
y_pred = model.predict(X_train.iloc[te_idx])
rep = classification_report(y_train.iloc[te_idx], y_pred, output_dict=True, zero_division=0)
fold_metrics.append(rep)
print(f" Akurasi putaran ke-{i+1}: {rep['accuracy']:.4f}")
avg_acc = np.mean([f['accuracy'] for f in fold_metrics])
print(f"\n RATA-RATA PENGUJIAN K-FOLD:")
print(f" Akurasi Keseluruhan : {avg_acc:.4f} ± {np.std([f['accuracy'] for f in fold_metrics]):.4f}")
for cls in TARGET_CLASSES:
p = np.mean([f.get(cls, {}).get('precision', 0) for f in fold_metrics])
r = np.mean([f.get(cls, {}).get('recall', 0) for f in fold_metrics])
f1 = np.mean([f.get(cls, {}).get('f1-score', 0) for f in fold_metrics])
print(f" {cls:25} | P: {p:.3f} | R: {r:.3f} | F1: {f1:.3f}")
print("\n Membuat model final dari seluruh data latih yang tersedia...")
# PERBAIKAN 8: Menggunakan fungsi get_pipeline()
final_model = get_pipeline()
final_model.fit(X_train, y_train)
metrics_test = None
if len(X_test) > 0:
y_pred = final_model.predict(X_test)
rep_test = classification_report(y_test, y_pred, output_dict=True, zero_division=0)
metrics_test = rep_test
print("\n HASIL PENGUJIAN PADA DATA TEST:")
print(classification_report(y_test, y_pred, zero_division=0))
for cls in TARGET_CLASSES:
sup = rep_test[cls]['support'] if cls in rep_test else 0
if sup < 5:
print(f"Catatan: Kelas '{cls}' cuma punya {sup} data uji — skor F1-nya mungkin kurang akurat.")
# Confusion matrix
cm = confusion_matrix(y_test, y_pred, labels=TARGET_CLASSES)
disp = ConfusionMatrixDisplay(confusion_matrix=cm, display_labels=TARGET_CLASSES)
disp.plot(cmap='Blues', values_format='d', xticks_rotation=45, colorbar=False)
plt.title('Confusion Matrix - Hold-Out Test')
plt.tight_layout()
plt.savefig(ML_DIR / 'confusion_matrix_test.png', dpi=300)
plt.close()
print("Grafik confusion matrix berhasil disimpan ke confusion_matrix_test.png")
# PERBAIKAN 9: Feature importance yang menampilkan koefisien POSITIF dan NEGATIF
vec = final_model.named_steps['tfidf']
clf = final_model.named_steps['clf']
feats = vec.get_feature_names_out()
coeffs = clf.coef_
print("\n3 KATA PALING BERPENGARUH (POSITIF & NEGATIF) UNTUK TIAP KELAS:")
for i, cls in enumerate(clf.classes_):
# Ambil 3 indeks dengan nilai tertinggi (positif) dan 3 terendah (negatif)
pos_idx = coeffs[i].argsort()[-3:][::-1]
neg_idx = coeffs[i].argsort()[:3]
top_pos = [(feats[j], coeffs[i][j]) for j in pos_idx if coeffs[i][j] > 0]
top_neg = [(feats[j], coeffs[i][j]) for j in neg_idx if coeffs[i][j] < 0]
pos_str = ", ".join([f"{w}({c:.2f})" for w, c in top_pos]) if top_pos else "Tidak ada"
neg_str = ", ".join([f"{w}({c:.2f})" for w, c in top_neg]) if top_neg else "Tidak ada"
print(f" [{cls}]")
print(f" Mendorong (+) : {pos_str}")
print(f" Menghambat (-): {neg_str}")
# Confidence distribution
proba = final_model.predict_proba(X_test)
max_p = np.max(proba, axis=1)
plt.hist(max_p, bins=15, edgecolor='black', alpha=0.7, color='teal')
plt.axvline(x=CONFIDENCE_THRESHOLD, color='red', linestyle='--', linewidth=2, label=f'Threshold {CONFIDENCE_THRESHOLD}')
plt.xlabel('Confidence Score')
plt.ylabel('Count')
plt.title('Distribusi Confidence Score')
plt.legend()
plt.grid(axis='y', alpha=0.3)
plt.tight_layout()
plt.savefig(ML_DIR / 'confidence_distribution.png', dpi=300)
plt.close()
below = np.sum(max_p < CONFIDENCE_THRESHOLD)
print(f"\nPengecekan Keyakinan Model: {below} dari {len(max_p)} prediksi ({below/len(max_p)*100:.1f}%) di bawah threshold {CONFIDENCE_THRESHOLD}.")
# Daftar prediksi yang meleset
mis = np.where(y_test.values != y_pred)[0]
if len(mis) > 0:
print("\n DAFTAR PREDIKSI YANG MELESET:")
for idx in mis[:min(6, len(mis))]:
txt = df_test.iloc[idx]['job_text_raw'][:80]
print(f" • Seharusnya: {y_test.iloc[idx]:20} | Ditebak: {y_pred[idx]:20} | Teks: '{txt}...'")
else:
print("\n Semua prediksi pada test set benar.")
# Simpan model dan metrik
model_path = ML_DIR / "ml_pipeline_internal.pkl"
joblib.dump(final_model, model_path)
metrics = {
"methodology": "Internal MIF only",
"k_fold": {
"accuracy_mean": float(avg_acc),
"accuracy_std": float(np.std([f['accuracy'] for f in fold_metrics])),
"folds": fold_metrics
},
"threshold_config": CONFIDENCE_THRESHOLD
}
if metrics_test:
metrics["hold_out_test"] = metrics_test
with open(ML_DIR / "metrics_internal_only.json", 'w', encoding='utf-8') as f:
json.dump(metrics, f, indent=2, ensure_ascii=False)
print(f"\n Model berhasil disimpan di: {model_path}")
print(f"Laporan metrik disimpan di: {ML_DIR / 'metrics_internal_only.json'}")
if __name__ == "__main__":
main()