From 960dd44751841463b8cb44660cc0eae3bd549891 Mon Sep 17 00:00:00 2001 From: rhanarmt Date: Mon, 29 Jun 2026 09:18:16 +0700 Subject: [PATCH] Update: Add R2 and MAE metric explanations in UI and update model/testing scripts --- ...ab3_flowchart_sistem_lengkap_detail.drawio | 6 +- docs/bab3_flowchart_sistem_lengkap_detail.svg | 8 + lib/screens/prediction/prediction_page.dart | 191 ++++++++++++++- ...n_Linear_Regression_dan_Random_Forest.docx | Bin 49669 -> 50297 bytes ml_model/app.py | 20 +- ml_model/create_testing_guide_docx.py | 226 ++++++++++-------- ml_model/database_setup.py | 157 +++++++----- ml_model/encoders.pkl | Bin 1094 -> 938 bytes ml_model/model_metadata.pkl | Bin 544 -> 544 bytes ml_model/model_prediksi.pkl | Bin 37261585 -> 37261505 bytes ml_model/model_testing.py | 17 +- ml_model/requirements.txt | 5 + ml_model/restore_backend_dataset_data.py | 6 +- test_backend.py | 95 +++++--- 14 files changed, 520 insertions(+), 211 deletions(-) diff --git a/docs/bab3_flowchart_sistem_lengkap_detail.drawio b/docs/bab3_flowchart_sistem_lengkap_detail.drawio index 5addb4f..be68388 100644 --- a/docs/bab3_flowchart_sistem_lengkap_detail.drawio +++ b/docs/bab3_flowchart_sistem_lengkap_detail.drawio @@ -50,4 +50,8 @@ prediksi, dan bahan kritis" style="shape=cylinder3d;whiteSpace=wrap;html=1;bound dan ringkasan" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#FFFFFF;strokeColor=#8B5E3C;strokeWidth=2;fontFamily=Arial;fontSize=13;fontColor=#2D2520;arcSize=10;" vertex="1" parent="1"> \ No newline at end of file +POST /api/change-password" style="shape=cylinder3d;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;fillColor=#EFF2F5;strokeColor=#4B5563;strokeWidth=2;fontFamily=Arial;fontSize=13;fontColor=#2D2520;" vertex="1" parent="1"> + + + + \ No newline at end of file diff --git a/docs/bab3_flowchart_sistem_lengkap_detail.svg b/docs/bab3_flowchart_sistem_lengkap_detail.svg index 70cfb24..08a76eb 100644 --- a/docs/bab3_flowchart_sistem_lengkap_detail.svg +++ b/docs/bab3_flowchart_sistem_lengkap_detail.svg @@ -212,4 +212,12 @@ Ya Tidak + + + + + + + + diff --git a/lib/screens/prediction/prediction_page.dart b/lib/screens/prediction/prediction_page.dart index 41cb5f8..76c3f58 100644 --- a/lib/screens/prediction/prediction_page.dart +++ b/lib/screens/prediction/prediction_page.dart @@ -682,6 +682,9 @@ class _PredictionScreenState extends State { label: 'Permintaan', value: '${_controller.predictedDemand ?? 0}', + infoTitle: 'Permintaan (Prediksi)', + infoText: + 'Perkiraan jumlah produk yang akan dibeli pelanggan berdasarkan riwayat penjualan toko.', ), ), const SizedBox(width: 10), @@ -690,6 +693,9 @@ class _PredictionScreenState extends State { label: 'Produksi', value: '${_controller.productionQuantity} pcs', + infoTitle: 'Jumlah Produksi', + infoText: + 'Banyaknya produk yang akan Anda buat. Jumlah ini bisa disesuaikan secara manual di bagian atas.', ), ), ], @@ -707,6 +713,15 @@ class _PredictionScreenState extends State { : _controller .predictionR2! .toStringAsFixed(4), + infoTitle: 'Akurasi Model (R²)', + infoText: + 'Mengukur seberapa pintar model membaca pola penjualan Anda.\n\n' + '• Semakin mendekati 1, tebakan model semakin tepat.\n' + '• Nilai rendah/negatif berarti pola penjualan sangat fluktuatif.\n\n' + 'Standar Acuan Prediksi Baik:\n' + '• Sangat Baik: 0.70 s/d 1.00\n' + '• Cukup Baik: 0.50 s/d 0.69\n' + '• Kurang Baik: Di bawah 0.50', ), ), const SizedBox(width: 10), @@ -720,10 +735,63 @@ class _PredictionScreenState extends State { : _controller .predictionMae! .toStringAsFixed(2), + infoTitle: 'Potensi Meleset (MAE)', + infoText: + 'Rata-rata selisih antara hasil prediksi dengan kenyataan penjualan.\n\n' + '• Menunjukkan rata-rata seberapa jauh tebakan model bisa meleset.\n' + '• Semakin kecil nilai MAE, tebakan model semakin akurat.\n\n' + 'Standar Acuan Prediksi Baik:\n' + '• Semakin dekat ke 0 semakin akurat.\n' + '• Baik/Akurat jika MAE ≤ 2.0 unit (atau di bawah 20% dari rata-rata penjualan).', ), ), ], ), + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.65), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: const Color(0xFFA89080).withOpacity(0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.info_outline_rounded, + size: 14, + color: Color(0xFFA89080), + ), + const SizedBox(width: 6), + Text( + 'Rangkuman Prediksi & Acuan Dosen:', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: const Color(0xFFA89080).withOpacity(0.9), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + '• Akurasi Model (R²): ${_controller.predictionR2 == null ? '-' : _controller.predictionR2!.toStringAsFixed(4)} (${_controller.predictionR2 == null ? 'Belum dihitung' : _controller.predictionR2! >= 0.7 ? 'Sangat Baik, target ≥ 0.50' : _controller.predictionR2! >= 0.5 ? 'Cukup Baik, target ≥ 0.50' : 'Kurang Baik, target ≥ 0.50'})\n' + '• Potensi Meleset (MAE): ${_controller.predictionMae == null ? '-' : '±${_controller.predictionMae!.toStringAsFixed(1)} unit'} (${_controller.predictionMae == null ? 'Belum dihitung' : _controller.predictionMae! <= 2.0 ? 'Sangat Akurat, target ≤ 2.0' : 'Kurang Akurat, target ≤ 2.0'})', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF4B5563), + height: 1.4, + ), + ), + ], + ), + ), ], ), ), @@ -1448,10 +1516,17 @@ class _PredictionScreenState extends State { } class _PredictionMetric extends StatelessWidget { - const _PredictionMetric({required this.label, required this.value}); + const _PredictionMetric({ + required this.label, + required this.value, + this.infoTitle, + this.infoText, + }); final String label; final String value; + final String? infoTitle; + final String? infoText; @override Widget build(BuildContext context) { @@ -1465,9 +1540,117 @@ class _PredictionMetric extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - label, - style: const TextStyle(fontSize: 11, color: Color(0xFF6B7280)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: const TextStyle(fontSize: 11, color: Color(0xFF6B7280)), + ), + if (infoText != null) + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + showDialog( + context: context, + builder: (context) { + return Dialog( + backgroundColor: Colors.transparent, + insetPadding: const EdgeInsets.symmetric(horizontal: 24), + child: Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: const Color(0xFFA89080).withOpacity(0.15), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.info_outline_rounded, + color: Color(0xFFA89080), + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + infoTitle ?? label, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: Color(0xFF1F2937), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + Text( + infoText!, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF4B5563), + height: 1.5, + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFA89080), + foregroundColor: Colors.white, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: const Text( + 'Mengerti', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + child: const Padding( + padding: EdgeInsets.only(left: 6, bottom: 4, top: 4), + child: Icon( + Icons.help_outline_rounded, + size: 13, + color: Color(0xFFA89080), + ), + ), + ), + ], ), const SizedBox(height: 4), Text( diff --git a/ml_model/Panduan_Pengujian_Linear_Regression_dan_Random_Forest.docx b/ml_model/Panduan_Pengujian_Linear_Regression_dan_Random_Forest.docx index 0db3baf73fd1988cbcbbfb841569e6991058ec38..22140adb98efe1b8212ab5bf8ebe2da1f7e7fb73 100644 GIT binary patch delta 13629 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 aYFcrz6lI{G|9LR dict: # LOAD MODELS AT STARTUP # ============================================================================ try: - model = joblib.load('model_prediksi.pkl') - encoders = joblib.load('encoders.pkl') - feature_columns = joblib.load('feature_columns.pkl') - metadata = joblib.load('model_metadata.pkl') + _model_dir = os.path.dirname(__file__) + model = joblib.load(os.path.join(_model_dir, 'model_prediksi.pkl')) + encoders = joblib.load(os.path.join(_model_dir, 'encoders.pkl')) + feature_columns = joblib.load(os.path.join(_model_dir, 'feature_columns.pkl')) + metadata = joblib.load(os.path.join(_model_dir, 'model_metadata.pkl')) logger.info("Models loaded successfully") logger.info(f"Model Type: {metadata['model_type']}") - logger.info(f"R² Score: {metadata['r2_score']:.4f}") + logger.info(f"R2 Score: {metadata['r2_score']:.4f}") except Exception as e: logger.error(f"Failed to load models: {e}") raise @@ -2809,7 +2817,7 @@ if __name__ == '__main__': logger.info("Starting Prediksi Stok API") logger.info("=" * 80) logger.info(f"Model: {metadata['model_type']}") - logger.info(f"Accuracy (R²): {metadata['r2_score']:.4f}") + logger.info(f"Accuracy (R2): {metadata['r2_score']:.4f}") logger.info(f"Features: {len(feature_columns)}") logger.info("Endpoints: /health, /metadata, /info, /prediksi, /batch-prediksi, /products, /transactions, /predictions, /recipes") logger.info("Access API at: http://localhost:5000") diff --git a/ml_model/create_testing_guide_docx.py b/ml_model/create_testing_guide_docx.py index f8a5e7e..c792adf 100644 --- a/ml_model/create_testing_guide_docx.py +++ b/ml_model/create_testing_guide_docx.py @@ -5,9 +5,9 @@ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_BREAK, WD_LINE_SPACING from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Inches, Pt, RGBColor +import os - -OUTPUT = "ml_model/Panduan_Pengujian_Linear_Regression_dan_Random_Forest.docx" +script_dir = os.path.dirname(os.path.abspath(__file__)) NAVY = "17365D" BLUE = "2E74B5" @@ -301,8 +301,8 @@ def add_cover(doc): doc, ["Informasi", "Keterangan"], [ - ["Jumlah data", "6.742 transaksi"], - ["Pembagian data", "80% training dan 20% testing"], + ["Jumlah data", "6.742 transaksi (6.741 data bersih)"], + ["Pembagian data", "80% training dan 20% testing secara kronologis"], ["Target", "jumlah_permintaan_bahan"], ["Model yang dibandingkan", "Linear Regression dan Random Forest Regressor"], ["Tujuan dokumen", "Bahan belajar, pembahasan laporan, dan persiapan sidang"], @@ -329,8 +329,9 @@ def build_document(): add_body( doc, "Pengujian model dilakukan untuk mengetahui kemampuan Linear Regression dan Random Forest " - "dalam memprediksi jumlah permintaan bahan kue. Dataset berisi 6.742 transaksi dan dibagi " - "menjadi 5.393 data training serta 1.349 data testing menggunakan rasio 80:20." + "dalam memprediksi jumlah permintaan bahan kue. Dataset berisi 6.742 transaksi, dengan 1 baris " + "memiliki missing value pada produk_encoded sehingga menghasilkan 6.741 data bersih. Data tersebut " + "dibagi secara kronologis menjadi 5.392 data training serta 1.349 data testing menggunakan rasio 80:20." ) add_table( doc, @@ -409,11 +410,11 @@ def build_document(): doc, ["Komponen", "Nilai"], [ - ["Total data", "6.742 transaksi"], - ["Training set", "5.393 transaksi atau 80%"], - ["Testing set", "1.349 transaksi atau 20%"], + ["Total data", "6.742 transaksi (6.741 data bersih)"], + ["Training set", "5.392 transaksi atau 80% (periode 2021-01-01 s/d 2024-12-28)"], + ["Testing set", "1.349 transaksi atau 20% (periode 2024-12-28 s/d 2025-12-31)"], ["Target", "jumlah_permintaan_bahan"], - ["Jumlah fitur model tersimpan", "10 fitur"], + ["Jumlah fitur model tersimpan", "9 fitur"], ], widths=[2.3, 4.0], ) @@ -422,37 +423,40 @@ def build_document(): doc, ["No.", "Fitur", "Makna"], [ - ["1", "produk_encoded", "Kode angka produk"], + ["1", "produk_encoded", "Kode angka produk (diubah menggunakan LabelEncoder)"], ["2", "tahun", "Tahun transaksi"], ["3", "bulan", "Bulan transaksi"], ["4", "hari", "Tanggal dalam bulan"], - ["5", "hari_dalam_minggu", "Kode hari dalam minggu"], + ["5", "hari_dalam_minggu", "Kode hari dalam minggu transaksi"], ["6", "harga_satuan_update", "Harga satuan bahan"], - ["7", "total_harga_update", "Total harga transaksi"], - ["8", "hari_minggu", "Duplikasi informasi hari dalam minggu"], - ["9", "nama_produk_encoded", "Nama produk yang telah diubah menjadi angka"], - ["10", "kategori_produk_encoded", "Kategori produk yang telah diubah menjadi angka"], + ["7", "hari_minggu", "Kode hari minggu (dayofweek)"], + ["8", "nama_produk_encoded", "Nama produk yang telah diubah menggunakan LabelEncoder"], + ["9", "kategori_produk_encoded", "Kategori produk yang telah diubah menggunakan LabelEncoder"], ], widths=[0.6, 2.3, 3.4], ) add_heading(doc, "2.3 Pembagian Training dan Testing", 2) add_code( doc, - "X_train, X_test, y_train, y_test = train_test_split(\n" - " X, y, test_size=0.2, random_state=42\n" - ")" + "# Urutkan data secara kronologis\n" + "df = df.sort_values(by='tanggal_transaksi')\n" + "# Bagi data dengan rasio 80:20 secara manual\n" + "split_index = int(len(X) * 0.8)\n" + "X_train, X_test = X.iloc[:split_index], X.iloc[split_index:]\n" + "y_train, y_test = y.iloc[:split_index], y.iloc[split_index:]" ) add_body( doc, - "Parameter test_size=0.2 berarti 20% data digunakan untuk testing. Parameter random_state=42 " - "membuat pembagian acak selalu sama ketika program dijalankan ulang, sehingga hasil pengujian " - "dapat direproduksi." + "Pembagian data dilakukan secara kronologis berdasarkan urutan tanggal transaksi. Parameter split_index " + "diambil dari 80% panjang dataset bersih (6.741 data), yaitu int(6741 * 0.8) = 5.392 data training, " + "dan sisa 1.349 data digunakan sebagai testing. Metode ini memastikan bahwa model diuji untuk memprediksi " + "permintaan di masa depan, bukan sekadar memprediksi data acak di masa lalu." ) add_callout( doc, - "Catatan untuk Data Berdasarkan Waktu", - "Karena tujuan sistem adalah memprediksi masa depan, pembagian data secara kronologis lebih kuat " - "daripada pembagian acak. Data lama sebaiknya digunakan sebagai training dan data terbaru sebagai testing.", + "Pemberitahuan Metodologi", + "Pengujian model menggunakan pembagian kronologis (chronological split) sangat penting untuk data deret " + "waktu (time series) guna menghindari data leakage temporal, di mana model tidak sengaja mempelajari data masa depan.", fill=LIGHT_GOLD, title_color=GOLD, ) @@ -590,60 +594,60 @@ def build_document(): add_heading(doc, "6. Perhitungan Hasil Testing Proyek", 1) add_body( doc, - "Pada testing set terdapat 1.349 data. Rata-rata target testing adalah sekitar 4,9622 unit, " - "sedangkan total variasi aktual atau SST adalah 5.133,0719." + "Pada testing set terdapat 1.349 data. Rata-rata target testing adalah sekitar 5,4188 unit, " + "sedangkan total variasi aktual atau SST adalah 10.964,3617." ) add_heading(doc, "6.1 Linear Regression", 2) add_table( doc, ["Komponen", "Nilai"], [ - ["Jumlah absolute error atau SAE", "848,4592"], - ["Jumlah squared error atau SSE", "1.122,3551"], + ["Jumlah absolute error atau SAE", "3.357,4719"], + ["Jumlah squared error atau SSE", "11.007,5413"], ["Jumlah data testing", "1.349"], - ["Total variasi aktual atau SST", "5.133,0719"], + ["Total variasi aktual atau SST", "10.964,3617"], ], widths=[3.4, 2.9], ) - add_formula(doc, "MAE = 848,4592 / 1.349 = 0,6290 ≈ 0,63") - add_formula(doc, "RMSE = √(1.122,3551 / 1.349) = 0,9121 ≈ 0,91") - add_formula(doc, "R² = 1 - (1.122,3551 / 5.133,0719) = 0,7813") + add_formula(doc, "MAE = 3.357,4719 / 1.349 = 2,4889 ≈ 2,49") + add_formula(doc, "RMSE = √(11.007,5413 / 1.349) = 2,8565 ≈ 2,86") + add_formula(doc, "R² = 1 - (11.007,5413 / 10.964,3617) = -0,0039") add_body( doc, - "Interpretasinya, Linear Regression menjelaskan sekitar 78,13% variasi target pada testing set " - "dan rata-rata prediksinya meleset sekitar 0,63 unit." + "Interpretasinya, Linear Regression tidak mampu menjelaskan variasi target pada testing set secara optimal " + "(R² bernilai negatif sebesar -0,0039 atau -0,39%) dan rata-rata prediksinya meleset sekitar 2,49 unit." ) add_heading(doc, "6.2 Random Forest", 2) add_table( doc, ["Komponen", "Nilai"], [ - ["Jumlah absolute error atau SAE", "39,86"], - ["Jumlah squared error atau SSE", "18,7142"], + ["Jumlah absolute error atau SAE", "3.494,5478"], + ["Jumlah squared error atau SSE", "12.431,3712"], ["Jumlah data testing", "1.349"], - ["Total variasi aktual atau SST", "5.133,0719"], + ["Total variasi aktual atau SST", "10.964,3617"], ], widths=[3.4, 2.9], ) - add_formula(doc, "MAE = 39,86 / 1.349 = 0,0295 ≈ 0,03") - add_formula(doc, "RMSE = √(18,7142 / 1.349) = 0,1178 ≈ 0,12") - add_formula(doc, "R² = 1 - (18,7142 / 5.133,0719) = 0,9964") + add_formula(doc, "MAE = 3.494,5478 / 1.349 = 2,5905 ≈ 2,59") + add_formula(doc, "RMSE = √(12.431,3712 / 1.349) = 3,0357 ≈ 3,04") + add_formula(doc, "R² = 1 - (12.431,3712 / 10.964,3617) = -0,1338") add_body( doc, - "Interpretasinya, Random Forest menjelaskan sekitar 99,64% variasi target pada testing set " - "dan rata-rata prediksinya meleset sekitar 0,03 unit. Angka ini harus dibaca bersama analisis " - "data leakage pada bagian berikutnya." + "Interpretasinya, Random Forest tidak mampu menjelaskan variasi target pada testing set secara optimal " + "(R² bernilai negatif sebesar -0,1338 atau -13,38%) dan rata-rata prediksinya meleset sekitar 2,59 unit " + "setelah fitur total_harga_update yang menimbulkan data leakage dihapus." ) add_heading(doc, "6.3 Contoh Hasil Prediksi Individual", 2) add_table( doc, ["Aktual", "Prediksi Linear Regression", "Prediksi Random Forest", "Error Absolut RF"], [ - ["4", "3,5472", "4,01", "0,01"], - ["3", "2,5369", "3,00", "0,00"], - ["4", "4,4879", "4,00", "0,00"], - ["2", "0,7336", "2,00", "0,00"], - ["7", "7,2195", "7,00", "0,00"], + ["7", "5,4795", "3,71", "3,29"], + ["5", "5,5121", "4,99", "0,01"], + ["10", "5,5587", "5,04", "4,96"], + ["1", "5,4686", "5,67", "4,67"], + ["2", "5,4883", "5,64", "3,64"], ], widths=[0.9, 2.0, 2.0, 1.4], font_size=9, @@ -654,38 +658,39 @@ def build_document(): doc, ["Aspek", "Linear Regression", "Random Forest"], [ - ["R² testing", "0,7813", "0,9964"], - ["MAE testing", "0,63 unit", "0,03 unit"], - ["RMSE testing", "0,91 unit", "0,12 unit"], - ["Kemampuan pola non-linear", "Terbatas", "Baik"], + ["R² testing", "-0,0039", "-0,1338"], + ["MAE testing", "2,49 unit", "2,59 unit"], + ["RMSE testing", "2,86 unit", "3,04 unit"], + ["Kemampuan pola non-linear", "Terbatas", "Baik (pada data training)"], ["Kemudahan interpretasi", "Sangat mudah", "Lebih kompleks"], - ["Risiko pada hasil proyek", "Terpengaruh leakage", "Sangat terpengaruh leakage"], + ["Ketergantungan data leakage", "Bebas leakage (total harga dihapus)", "Bebas leakage (total harga dihapus)"], ], widths=[2.2, 2.05, 2.05], ) add_heading(doc, "7.1 Perbandingan MAE", 2) - add_formula(doc, "Penurunan MAE = [(0,6290 - 0,0295) / 0,6290] x 100% = 95,3%") + add_formula(doc, "Peningkatan MAE = [(2,5905 - 2,4889) / 2,4889] x 100% = 4,08%") add_body( doc, - "Pernyataan yang tepat adalah Random Forest mengurangi MAE sekitar 95,3% dibandingkan Linear " - "Regression pada test set saat ini. Pernyataan tersebut tidak sama dengan mengatakan model " - "95,3% lebih akurat." + "Pernyataan yang tepat adalah model Random Forest menghasilkan kesalahan rata-rata yang sedikit " + "lebih tinggi (+4,08%) dibandingkan Linear Regression pada test set aktual tanpa leakage." ) add_heading(doc, "7.2 Indikasi Overfitting", 2) add_table( doc, ["Model", "R² Training", "R² Testing", "Selisih"], [ - ["Linear Regression", "0,7902", "0,7813", "0,0089"], - ["Random Forest", "0,9995", "0,9964", "0,0031"], + ["Linear Regression", "0,0013", "-0,0039", "0,0052"], + ["Random Forest", "0,6913", "-0,1338", "0,8251"], ], widths=[2.0, 1.4, 1.4, 1.4], ) add_body( doc, - "Selisih training dan testing terlihat kecil sehingga secara angka awal model tampak stabil. " - "Namun, kestabilan ini tidak membatalkan masalah data leakage karena informasi target tersedia " - "pada kedua kelompok data melalui total harga." + "Selisih training dan testing pada Linear Regression terlihat sangat kecil, namun kedua nilainya mendekati nol, " + "menunjukkan underfitting. Sedangkan pada Random Forest, terdapat selisih R² yang sangat besar (0,8251) " + "antara training (0,6913) dan testing (-0,1338). Hal ini menunjukkan indikasi overfitting yang sangat kuat " + "pada Random Forest setelah total harga dihapus dari fitur. Model berkinerja cukup baik pada data training " + "namun gagal memprediksi data testing baru yang belum pernah dilihat sebelumnya." ) add_heading(doc, "8. Analisis Data Leakage", 1) @@ -742,10 +747,8 @@ def build_document(): doc, ["Skenario", "Model", "R²", "MAE", "RMSE"], [ - ["Random split tanpa total harga", "Linear Regression", "-0,0045", "1,53", "1,96"], - ["Random split tanpa total harga", "Random Forest", "-0,1124", "1,64", "2,06"], - ["Chronological split tanpa total harga", "Linear Regression", "-0,0025", "1,57", "1,99"], - ["Chronological split tanpa total harga", "Random Forest", "-0,0831", "1,65", "2,06"], + ["Chronological split tanpa total harga (backend Railway)", "Linear Regression", "-0,0039", "2,49", "2,86"], + ["Chronological split tanpa total harga (backend Railway)", "Random Forest", "-0,1338", "2,59", "3,04"], ], widths=[2.3, 1.6, 0.8, 0.8, 0.8], font_size=8.8, @@ -790,9 +793,9 @@ def build_document(): add_heading(doc, "10.3 Konsistensi Aplikasi Flutter", 2) add_body( doc, - "Python menggunakan kode hari 0 sampai 6, sedangkan DateTime.weekday pada Flutter menggunakan " - "1 sampai 7. Nilai harus disamakan sebelum dikirim ke model. Selain itu, pemetaan encoded produk " - "dan kategori pada Flutter harus sama persis dengan LabelEncoder saat training." + "Python menggunakan kode hari 0 sampai 6 melalui dt.dayofweek (Senin=0), sedangkan DateTime.weekday pada Flutter menggunakan " + "1 sampai 7 (Senin=1). Nilai harus dipetakan secara konsisten sebelum dikirim ke backend Railway. Selain itu, pemetaan encoded produk " + "dan kategori pada Flutter harus sama persis dengan LabelEncoder saat training di backend Flask." ) add_heading(doc, "11. Contoh Penjelasan untuk Laporan", 1) @@ -800,31 +803,31 @@ def build_document(): add_callout( doc, "Contoh Penulisan", - "Pengujian model dilakukan menggunakan metode hold-out dengan membagi dataset menjadi 80% data " - "training dan 20% data testing. Dari total 6.742 data transaksi, sebanyak 5.393 data digunakan " - "untuk melatih model dan 1.349 data digunakan untuk menguji performa model. Pembagian data " - "menggunakan random_state sebesar 42 agar hasil pembagian dapat direproduksi. Evaluasi dilakukan " - "menggunakan koefisien determinasi R², Mean Absolute Error, dan Root Mean Squared Error.", + "Pengujian model dilakukan menggunakan metode chronological split dengan membagi dataset bersih " + "menjadi 80% data training dan 20% data testing. Dari total 6.742 data transaksi (6.741 data bersih setelah pembersihan 1 missing value), sebanyak 5.392 data digunakan " + "sebagai training set (periode awal 2021-01-01 s/d 2024-12-28) dan 1.349 data digunakan sebagai testing set " + "(periode akhir 2024-12-28 s/d 2025-12-31). Pembagian data diurutkan secara kronologis berdasarkan tanggal transaksi " + "untuk mengevaluasi kemampuan peramalan model secara realistis. Evaluasi dilakukan menggunakan " + "koefisien determinasi R², Mean Absolute Error, dan Root Mean Squared Error.", ) add_heading(doc, "11.2 Paragraf Hasil Perbandingan", 2) add_callout( doc, "Contoh Penulisan", - "Berdasarkan pengujian awal, Linear Regression menghasilkan R² sebesar 0,7813, MAE sebesar 0,63, " - "dan RMSE sebesar 0,91. Random Forest menghasilkan R² sebesar 0,9964, MAE sebesar 0,03, dan RMSE " - "sebesar 0,12. Secara numerik, Random Forest memberikan hasil prediksi yang lebih dekat terhadap " - "nilai aktual pada testing set dibandingkan Linear Regression.", + "Berdasarkan hasil pengujian model tanpa data leakage, Linear Regression menghasilkan R² sebesar -0,0039, " + "MAE sebesar 2,49, dan RMSE sebesar 2,86. Random Forest menghasilkan R² sebesar -0,1338, MAE sebesar 2,59, " + "dan RMSE sebesar 3,04. Secara numerik, model Linear Regression memberikan kesalahan rata-rata (MAE) " + "yang sedikit lebih rendah dibandingkan Random Forest pada data testing.", ) add_heading(doc, "11.3 Paragraf Keterbatasan", 2) add_callout( doc, "Contoh Penulisan", - "Evaluasi lanjutan menunjukkan bahwa nilai Random Forest yang sangat tinggi dipengaruhi oleh " - "penggunaan fitur total_harga_update. Fitur tersebut dihitung dari harga_satuan_update dikalikan " - "jumlah_permintaan_bahan yang menjadi target model. Kondisi ini menimbulkan data leakage karena " - "fitur mengandung informasi langsung mengenai target. Oleh karena itu, hasil R² sebesar 0,9964 " - "harus dipahami sebagai performa pada konfigurasi dataset awal dan belum sepenuhnya menggambarkan " - "kemampuan prediksi permintaan masa depan.", + "Evaluasi model ini dilakukan setelah mengeluarkan fitur total_harga_update untuk menghindari data leakage. " + "Pada pengujian awal sebelum fitur tersebut dikeluarkan, model Random Forest sempat memperoleh R² sebesar 0,9964, " + "MAE sebesar 0,03, dan RMSE sebesar 0,12. Namun, nilai tersebut tidak valid untuk menggambarkan kemampuan " + "prediksi masa depan karena fitur total harga dihitung langsung dari target (jumlah permintaan). Setelah perbaikan " + "metodologi dengan menghapus fitur total harga, diperoleh hasil pengujian aktual yang lebih realistis dan aman dari data leakage.", fill=LIGHT_GOLD, title_color=GOLD, ) @@ -844,10 +847,11 @@ def build_document(): add_heading(doc, "12.1 Bagaimana Proses Testing Dilakukan?", 2) add_body( doc, - "Jawaban: Proses testing dilakukan dengan membagi 6.742 data menjadi 80% data training dan 20% " - "data testing. Model mempelajari pola dari 5.393 data training, kemudian menghasilkan prediksi " - "untuk 1.349 data testing yang tidak digunakan saat pelatihan. Hasil prediksi dibandingkan dengan " - "nilai aktual menggunakan R², MAE, dan RMSE." + "Jawaban: Proses testing dilakukan dengan membagi 6.742 data transaksi (dengan 1 baris dieliminasi karena " + "mengandung missing value sehingga tersisa 6.741 data bersih) menjadi 80% data training (5.392 data, periode " + "awal) dan 20% data testing (1.349 data, periode akhir) secara kronologis berdasarkan urutan tanggal. Model " + "mempelajari pola dari data training untuk kemudian memprediksi data testing. Hasil prediksi " + "dievaluasi menggunakan R², MAE, dan RMSE." ) add_heading(doc, "12.2 Mengapa Menggunakan Tiga Metrik?", 2) add_body( @@ -856,26 +860,28 @@ def build_document(): "menunjukkan rata-rata kesalahan dalam satuan permintaan sehingga mudah dipahami. RMSE memberikan " "penalti lebih besar untuk kesalahan yang besar. Ketiganya memberikan penilaian yang lebih lengkap." ) - add_heading(doc, "12.3 Mengapa Random Forest Lebih Baik?", 2) + add_heading(doc, "12.3 Mengapa Hasil Evaluasi R² Bernilai Negatif?", 2) add_body( doc, - "Jawaban: Pada konfigurasi testing awal, Random Forest lebih baik karena mampu menangkap hubungan " - "non-linear dan menggabungkan prediksi dari 100 Decision Tree. Namun, performa yang sangat tinggi " - "juga disebabkan total harga yang memiliki hubungan langsung dengan target." + "Jawaban: Nilai R² negatif pada data testing (Linear Regression: -0,0039, Random Forest: -0,1338) menunjukkan " + "bahwa model kesulitan untuk memprediksi data masa depan berdasarkan urutan waktu (chronological split) setelah " + "fitur data leakage (total_harga_update) dihapus. Hal ini mengonfirmasi bahwa fitur waktu kalender saja belum cukup " + "kuat untuk menangkap fluktuasi pola permintaan rill, sehingga performanya di bawah tebakan rata-rata." ) - add_heading(doc, "12.4 Apakah R² 0,9964 Berarti Akurasi 99,64%?", 2) + add_heading(doc, "12.4 Bagaimana Hasil Model yang Di-deploy di Railway?", 2) add_body( doc, - "Jawaban: Tidak sepenuhnya. R² sebesar 0,9964 berarti model menjelaskan sekitar 99,64% variasi " - "target pada testing set. R² bukan akurasi klasifikasi. Selain itu, nilai tersebut dipengaruhi " - "data leakage sehingga belum mewakili kemampuan prediksi masa depan secara valid." + "Jawaban: Model backend Flask yang di-deploy di Railway adalah model Random Forest yang dilatih tanpa menggunakan fitur total_harga_update " + "untuk memastikan kebebasan dari data leakage. Model ini diakses oleh aplikasi Flutter sebagai frontend melalui API. Model ini memiliki metrik evaluasi R² sebesar -0,1338, MAE sebesar 2,59, " + "dan RMSE sebesar 3,04 pada data testing. Walaupun secara angka lebih rendah dibandingkan model awal dengan leakage, " + "performa ini jauh lebih valid dan dapat diandalkan secara akademis." ) - add_heading(doc, "12.5 Apakah Model Mengalami Overfitting?", 2) + add_heading(doc, "12.5 Apakah Model Mengalami Overfitting Setelah Leakage Dihapus?", 2) add_body( doc, - "Jawaban: Selisih R² training dan testing kecil, sehingga secara angka awal tidak menunjukkan " - "overfitting yang besar. Akan tetapi, terdapat masalah yang lebih penting, yaitu data leakage. " - "Leakage membuat training dan testing sama-sama mudah diprediksi karena keduanya mengandung informasi target." + "Jawaban: Ya. Setelah fitur total_harga_update dihapus, model Random Forest menunjukkan indikasi overfitting yang sangat kuat " + "di mana R² training bernilai cukup baik (0,6913) tetapi R² testing anjlok menjadi negatif (-0,1338). Ini membuktikan model " + "menghafal pola data training tetapi gagal dalam melakukan generalisasi pada data pengujian baru." ) add_heading(doc, "12.6 Apa Perbaikan yang Harus Dilakukan?", 2) add_body( @@ -916,8 +922,22 @@ def build_document(): title_color=GREEN, ) - doc.save(OUTPUT) - print(OUTPUT) + path1 = os.path.join(script_dir, 'Panduan_Pengujian_Linear_Regression_dan_Random_Forest.docx') + path2 = os.path.join(os.path.dirname(script_dir), 'Panduan_Pengujian_Linear_Regression_dan_Random_Forest.docx') + + try: + doc.save(path1) + print(f"Saved: {path1}") + except Exception as e: + print(f"Error saving to {path1}: {e}") + + try: + doc.save(path2) + print(f"Saved: {path2}") + except Exception as e: + backup_path = os.path.join(os.path.dirname(script_dir), 'Panduan_Pengujian_Linear_Regression_dan_Random_Forest_Project.docx') + doc.save(backup_path) + print(f"Warning: File {path2} is locked. Saved as backup: {backup_path}") if __name__ == "__main__": diff --git a/ml_model/database_setup.py b/ml_model/database_setup.py index 521b914..f0b5769 100644 --- a/ml_model/database_setup.py +++ b/ml_model/database_setup.py @@ -19,14 +19,15 @@ DB_CONFIG = { # Products list PRODUCTS = [ - {'name': 'Tepung Terigu 1kg', 'category': 'Tepung'}, - {'name': 'Telur 1kg', 'category': 'Telur'}, - {'name': 'Gula Pasir 1kg', 'category': 'Gula'}, - {'name': 'Susu Bubuk', 'category': 'Susu'}, - {'name': 'Cokelat Bubuk 250gr', 'category': 'Cokelat'}, - {'name': 'Mentega 500gr', 'category': 'Mentega'}, - {'name': 'Keju Parut 250gr', 'category': 'Keju'}, - {'name': 'Baking Powder', 'category': 'Bahan Tambahan'}, + {'name': 'Tepung Terigu 1kg', 'category': 'Tepung', 'price': 12000, 'current_stock': 4.000, 'min_stock': 10.000}, + {'name': 'Telur 1kg', 'category': 'Telur', 'price': 25000, 'current_stock': 21.200, 'min_stock': 5.000}, + {'name': 'Gula Pasir 1kg', 'category': 'Gula', 'price': 14000, 'current_stock': 18.200, 'min_stock': 8.000}, + {'name': 'Mentega 500gr', 'category': 'Mentega', 'price': 10000, 'current_stock': 10.600, 'min_stock': 5.000}, + {'name': 'Susu Bubuk', 'category': 'Susu', 'price': 20000, 'current_stock': 14.000, 'min_stock': 4.000}, + {'name': 'Ragi', 'category': 'Bahan Tambahan', 'price': 5000, 'current_stock': 10.000, 'min_stock': 0.000}, + {'name': 'Baking Powder', 'category': 'Bahan Tambahan', 'price': 5000, 'current_stock': 9.960, 'min_stock': 2.000}, + {'name': 'Cokelat Bubuk 250gr', 'category': 'Cokelat', 'price': 15000, 'current_stock': 9.000, 'min_stock': 3.000}, + {'name': 'Keju Parut 250gr', 'category': 'Keju', 'price': 20000, 'current_stock': 9.000, 'min_stock': 2.000}, ] def create_database(): @@ -39,12 +40,12 @@ def create_database(): ) cursor = connection.cursor() cursor.execute(f"CREATE DATABASE IF NOT EXISTS {DB_CONFIG['database']}") - logger.info(f"✅ Database '{DB_CONFIG['database']}' created successfully") + logger.info(f"[OK] Database '{DB_CONFIG['database']}' created successfully") cursor.close() connection.close() return True except Error as err: - print(f"❌ Error creating database: {err}") + print(f"[ERROR] Error creating database: {err}") return False def create_tables(): @@ -56,69 +57,88 @@ def create_tables(): # Products table cursor.execute(""" CREATE TABLE IF NOT EXISTS products ( - id INT PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(100) NOT NULL UNIQUE, - category VARCHAR(50) NOT NULL, - price DECIMAL(10, 2) DEFAULT 0, - stock INT DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + category VARCHAR(100) NOT NULL, + product_type VARCHAR(20) DEFAULT 'Bahan', + unit VARCHAR(20) DEFAULT 'kg', + price INT NOT NULL, + current_stock DECIMAL(10,3) NOT NULL DEFAULT 0, + min_stock DECIMAL(10,3) NOT NULL DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) - logger.info("✅ Products table created successfully") + logger.info("[OK] Products table created successfully") # Transactions table cursor.execute(""" CREATE TABLE IF NOT EXISTS transactions ( - id INT PRIMARY KEY AUTO_INCREMENT, - product_name VARCHAR(100) NOT NULL, - category VARCHAR(50) NOT NULL, + id INT AUTO_INCREMENT PRIMARY KEY, + product_name VARCHAR(255) NOT NULL, + category VARCHAR(100) NOT NULL, quantity INT NOT NULL, - unit_price DECIMAL(10, 2) NOT NULL, - total_price DECIMAL(10, 2) NOT NULL, - transaction_date DATETIME NOT NULL, + unit_price INT NOT NULL, + total_price INT NOT NULL, + transaction_date DATE NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (product_name) REFERENCES products(name) - ) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) - logger.info("✅ Transactions table created successfully") + logger.info("[OK] Transactions table created successfully") - # Predictions table (FIXED SCHEMA) + # Stock Usage History Table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS stock_usage_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + recipe_name VARCHAR(255), + production_quantity INT, + product_id INT NOT NULL, + product_name VARCHAR(255) NOT NULL, + quantity_used FLOAT NOT NULL, + unit VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + logger.info("[OK] Stock Usage History table created successfully") + + # Predictions table cursor.execute(""" CREATE TABLE IF NOT EXISTS predictions ( - id INT PRIMARY KEY AUTO_INCREMENT, - product_name VARCHAR(100) NOT NULL, - category VARCHAR(50) NOT NULL, - unit_price DECIMAL(10, 2), - prediction_date DATETIME NOT NULL, - predicted_quantity DECIMAL(10, 2), - raw_value DECIMAL(10, 2), - estimated_total_price DECIMAL(10, 2), + id INT AUTO_INCREMENT PRIMARY KEY, + product_name VARCHAR(255) NOT NULL, + category VARCHAR(100) NOT NULL, + unit_price INT NOT NULL, + prediction_date DATE NOT NULL, + predicted_quantity INT NOT NULL, + raw_value FLOAT, + estimated_total_price INT, estimated_needs TEXT, - accuracy_r2 DECIMAL(5, 4), - error_mae DECIMAL(5, 4), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (product_name) REFERENCES products(name) - ) + accuracy_r2 FLOAT, + error_mae FLOAT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) - logger.info("✅ Predictions table created successfully") + logger.info("[OK] Predictions table created successfully") # Login table cursor.execute(""" CREATE TABLE IF NOT EXISTS login ( - id INT PRIMARY KEY AUTO_INCREMENT, + id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, email VARCHAR(100) NOT NULL UNIQUE, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(255) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) - logger.info("Login table created successfully") + logger.info("[OK] Login table created successfully") # Password reset OTP table cursor.execute(""" CREATE TABLE IF NOT EXISTS password_reset_otps ( - id INT PRIMARY KEY AUTO_INCREMENT, + id INT AUTO_INCREMENT PRIMARY KEY, login_id INT NOT NULL, email VARCHAR(100) NOT NULL, otp_code VARCHAR(10) NOT NULL, @@ -126,18 +146,43 @@ def create_tables(): is_used TINYINT(1) DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (login_id) REFERENCES login(id) ON DELETE CASCADE - ) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 """) - logger.info("Password reset OTP table created successfully") + logger.info("[OK] Password reset OTP table created successfully") + + # Recipes table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS recipes ( + id INT AUTO_INCREMENT PRIMARY KEY, + recipe_name VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + logger.info("[OK] Recipes table created successfully") + + # Recipe Ingredients table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS recipe_ingredients ( + id INT AUTO_INCREMENT PRIMARY KEY, + recipe_id INT NOT NULL, + product_name VARCHAR(255) NOT NULL, + quantity_needed FLOAT NOT NULL, + unit VARCHAR(50) NOT NULL, + FOREIGN KEY (recipe_id) REFERENCES recipes(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """) + logger.info("[OK] Recipe Ingredients table created successfully") connection.commit() cursor.close() connection.close() return True except Error as err: - print(f"❌ Error creating tables: {err}") + print(f"[ERROR] Error creating tables: {err}") return False + def insert_default_products(): """Insert default products""" try: @@ -147,8 +192,8 @@ def insert_default_products(): for product in PRODUCTS: try: cursor.execute( - "INSERT INTO products (name, category) VALUES (%s, %s)", - (product['name'], product['category']) + "INSERT INTO products (name, category, price, current_stock, min_stock) VALUES (%s, %s, %s, %s, %s)", + (product['name'], product['category'], product['price'], product.get('current_stock', 0), product.get('min_stock', 0)) ) except: pass @@ -156,12 +201,12 @@ def insert_default_products(): connection.commit() cursor.execute("SELECT COUNT(*) FROM products") count = cursor.fetchone()[0] - logger.info(f"✅ Inserted {count} default products") + logger.info(f"[OK] Inserted {count} default products") cursor.close() connection.close() return True except Error as err: - print(f"❌ Error inserting products: {err}") + print(f"[ERROR] Error inserting products: {err}") return False def insert_default_login(): @@ -180,12 +225,12 @@ def insert_default_login(): """, ('Ibu Sulastri', 'sulastri.aritanto10@gmail.com', 'admin', 'password')) connection.commit() - logger.info("Default login account ready") + logger.info("[OK] Default login account ready") cursor.close() connection.close() return True except Error as err: - print(f"Error inserting login account: {err}") + print(f"[ERROR] Error inserting login account: {err}") return False def verify_connection(): @@ -195,12 +240,12 @@ def verify_connection(): cursor = connection.cursor() cursor.execute("SELECT COUNT(*) FROM products") count = cursor.fetchone()[0] - logger.info(f"✅ Database connected! Found {count} products") + logger.info(f"[OK] Database connected! Found {count} products") cursor.close() connection.close() return True except Error as err: - print(f"❌ Connection error: {err}") + print(f"[ERROR] Connection error: {err}") return False def main(): diff --git a/ml_model/encoders.pkl b/ml_model/encoders.pkl index e344d91cca4a857181ebbde549516903bec26d8f..59057e7010e34809ef370016c465a3605c39ea69 100644 GIT binary patch delta 427 zcmb`DKTE?v7{(K|HbSlcpdb$3*eSMRaBxcphaeQqDspL$BqmMDB`>9uQxV}7>D%4e z$xq?z;`i~rH2ntN@*WTOKKJ~dZ|k%9bz!y|t25K6j_NG?X6FV~>q&}Ho)x8pQHe`b zjSME!IjUwh=2G%GX`Zpbff?W{-ecD@J!^r{_WO_h^7e>bb?ova&O$cKXJaARl@m%- zCxbi@DKD7|0VM3w>1nln3n^zqUc{31lMvORC!T=>3FYR^{+-B55pw4As-1%eC}3lA ztzGtfk%BfjC5IzlOo4v)g^WX}Tm^A_Y(+%-yM{IXg;rFwZI?$pV?Lh*`a(U`q^Nc@ i9SzMydd)$CCZJ-g*r;~biIHLk_R6XwM&}Wus_Z}Rik8R# literal 1094 zcmdT?OK;Oa5N^_>N!?e{v{1^+5|?nXT0})}h*k&*L#RR|d;nQ%XO(sR++8nKq#~6= zDUtTZ_V>c<#yP-0U=H@|H}Co8s&V_Iwq)d+>dUnZrN}Q>J{GZ(4|tql#4@K~7_bm? z9%U29Pf&mpCD-5S|LZKw$C#;K>gji-bF{+*agMpKWbZr=k|=O8krq?Gh(gGxWOtyS zuNXtqPYe~sU%^}iCSJ*=iBhtajgj_+3bM%wDzI|f*P!Q2*Oj$X(8uKzaI(43z_PSDb7kin3k9; zw?XnZw3AdDx>U>Hh<8SA#L5z_K>dj-5Lx_ttz1z)1qr$9sEBW({&h-ZVc%rjA(LQ z(yf2Tq@7^)g?9ViHpHBu7$~&Q@(b9V{rqUc=(g7b!)YF4Xuv)Zfr#yAM+ac(d7EH) zBNAkvAs#XNFrLsIuLGcq&jo-?l$GzUw*l~b*cYB4`{+Q&-|@Bp`zW|rq z&Z5QjlE(ZVNsCyOwm~|it8a(&$PU>h`{dz_Jo^8M)hwMLf--xA(m*RfY74-MR3Nee wFHI!Sr&UcK-8DWHWk8lTXtIEzCa8uXx@S&vgf%;KK}j3TTF7H!!@W3v0C`+(g8%>k diff --git a/ml_model/model_metadata.pkl b/ml_model/model_metadata.pkl index d3c9962fc8fc0ed16709e4400eb5014b956c13bc..ddb20f71d60f8233ee56d6cacfdbba43eabd353d 100644 GIT binary patch delta 12 TcmZ3$vVdiRFXM!bewP>l9N+|r delta 12 TcmZ3$vVdiRFXP0GewP>l9OVRx diff --git a/ml_model/model_prediksi.pkl b/ml_model/model_prediksi.pkl index 3f66e56d2a550c20bdbdc27fd88cbe82d81beb1e..6c809fab375e9be5bae2224005bbd2ff3e1a1841 100644 GIT binary patch delta 2827 zcmZA1X_(Db0D$pk%f1Z8*!N{@W2YpF3Qd%yl+tz0Of$pGo$0!lsHi02mSrxj6s0VY zQYc%NR*EP^o3txSE3JB8ANtV$dEVbS&pGFL&ZqyGyW~jfw#7$M*NjiQX(bPYtLcHKNAUgql(_YECVvC1p@6YE5mZEw!Ue zYEK=gBXy$A)P=fIH|kF3(fQPaE}))tA@!o(bP-)leW)*8LYLBI)Q|ep0J@yA=n5K0 zgXl`SiU!jV8cM_HYPyDo)3tORjiBr41{z5>Qh-KLkVaEBEF%Zl*AeqX!`=X(275$LMidOiSnqdXkpXQ}i?~qvf=MR?;)H zidNIJw1%Fe=V>jyKrhldT2CA3C3=}Q(k9wWTWBl2LfdFN?Vz3XD!oRp(;M_Ay+yld zH@!{o&>q@L@6tZnPw&zDbbvmf59uHsqL1if`h-5E&*(6HPDkhq`jWn)ujw25mcFB- z^gaDR$LKhnpp*0?{X{?0FZ3(@MyKdB{Z4<-pY#{~P5;oDS^WF6NfISVk|jmX?v`;@ zR?10vsUQ`ll2n!|QdLr=npBq>k|s4JU1~{fsUvlzp468H(oh;nV`(ByrI|FB7Sd8O zq?NRmHqutwNv5=y4$@INNoVOIU8S3Jm-FO&=^+TxZlp7@=qa-M!C0lYNS8kF# z$(I5dBV(mdiX@`OAoOXVqf zT9(OjSs^Rs8CfN(2?3H(ApX`_SmJ~U=TgF*gDJSKnf>e}BQdz1AHrdl4xap zvZ|ymU7Mr{_jn-yj}z zjP|hVC-tJ< z)Q9>~Kk82dXdn%u!8C-1(l9Ef;WUCq(kL2DB{YV{(l{DV6KEn$qRBLcrqXt_J?%g{ z(oVE9O`~0CSK5tsr#)y-+Kcw4eP~~rPNlRT?N4PigASkrshkd?gXs`Dlx9+dX3=4E zI2}Q==}3ywQ52&Js-!qo(a}^*bEt-np}AB`b(ElbYM_9U)JQ2x(>ywsj-w_zo=%|o zw17^eg>({~OsCKyI+YgF5^AQUw2YS13ObEWr!#0Jok^?cELu%x(>Zi5okwfve7b-x zq>Jccx`ZyJ%V;gFqs!?Ex{|J5dL(gwPXZl^owPP&Wk zrhDjKx{vOs2WTTbNSo*(dYB%eN9i$ooSvX3=_z`eo}p*yIoeFm(+l(>y+kk5EA%S8 zMz7Nw^d`MUZ__*UF1<(Z(+9MLKBSN6WBP@EAqzA{}(Wk1=BA@$NAfh46- zQj(T=a;zLDO>(@PAoFE`oG1(BBsp15kwtQnw&0Y$Vxd=R>@hi zTF#bp8zT3IKT%N25^TqRe_HFB+7C)djja--ZNH_Ljt zMQ)W1a+};PcgUS`m)tG)$h~r(+%FHvMtM*+$wTt6JR*Ay3Lv^0Yi7&&qSM zS)P{{;Kgd?{ba z*Yb^gE8ofYvQ>VNALS?cS$>gU