# 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()