245 lines
9.4 KiB
Python
245 lines
9.4 KiB
Python
"""
|
|
views/perbandingan_konfigurasi.py
|
|
==================================
|
|
KHUSUS ADMIN. Halaman ini TIDAK PERNAH dikirim ke role 'pemilik' -- lihat
|
|
app.py, daftar `pages` untuk role tsb tidak menyertakan Page ini sama
|
|
sekali (bukan cuma disembunyikan di sidebar).
|
|
|
|
Isi (3 tab):
|
|
1. "Evaluasi Metrik" -- tabel perbandingan MAE/RMSE/MAPE untuk 4 skenario
|
|
x N produk (skenario optimal tiap produk ditandai hijau), + tabel
|
|
parameter hasil grid search terbaik.
|
|
2. "Status Model" -- ringkasan skenario optimal, ketersediaan scaler,
|
|
dan kapan terakhir tiap produk diperbarui.
|
|
3. "Perbarui Model" -- panel upload file all_results.pkl baru: validasi
|
|
struktur -> preview dampak merge -> konfirmasi -> backup otomatis lalu
|
|
tulis & reload (lihat utils/model_manager.py untuk detail prosesnya).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import streamlit as st
|
|
|
|
from utils import model_manager, ui_components
|
|
from utils.data_loader import (
|
|
DEFAULT_DATA_PATH,
|
|
get_hyperparameter_table,
|
|
get_last_updated_info,
|
|
get_metric_comparison_table,
|
|
)
|
|
|
|
METRIC_OPTIONS = ["MAPE", "MAE", "RMSE"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tab 1: Evaluasi Metrik
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _highlight_optimal_row(row):
|
|
"""Beri background hijau pada kolom skenario yang jadi optimal_scenario baris ini."""
|
|
optimal = row.get("optimal_scenario")
|
|
return [
|
|
"background-color:#dcfce7;font-weight:600;" if col == optimal else ""
|
|
for col in row.index
|
|
]
|
|
|
|
|
|
def _render_metric_table(all_results: dict) -> None:
|
|
ui_components.section_header(
|
|
"📊", "Perbandingan Metrik Antar Skenario",
|
|
"**Kinerja 4 skenario pemodelan** (model standar, model dengan transformasi logaritmik, model dengan penambahan regresor eksternal, dan model dengan kombinasi transformasi logaritmik dan regresor eksternal) **untuk tiap produk.**",
|
|
)
|
|
|
|
metric = st.radio("Metrik", METRIC_OPTIONS, horizontal=True, key="metric_choice")
|
|
table = get_metric_comparison_table(all_results, metric=metric)
|
|
|
|
if table.empty:
|
|
st.info("Belum ada data metrik untuk ditampilkan.")
|
|
return
|
|
|
|
# 'optimal_scenario' berisi teks (nama skenario), jadi HARUS dikecualikan
|
|
# dari .format(precision=...) -- kalau ikut, akan error karena bukan angka.
|
|
numeric_cols = [c for c in table.columns if c != "optimal_scenario"]
|
|
|
|
styled = (
|
|
table.style
|
|
.apply(_highlight_optimal_row, axis=1)
|
|
.format(subset=numeric_cols, precision=2)
|
|
)
|
|
st.dataframe(styled, width="stretch")
|
|
st.caption("🟩 Sel hijau menunjukkan skenario optimal.")
|
|
|
|
|
|
def _render_hyperparameter_table(all_results: dict) -> None:
|
|
ui_components.section_header(
|
|
"🧪", "Hyperparameter Skenario Optimal",
|
|
"Hasil grid search terbaik -- hanya untuk skenario yang sedang dipakai tiap produk.",
|
|
)
|
|
table = get_hyperparameter_table(all_results)
|
|
st.dataframe(table, width="stretch", hide_index=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tab 2: Status Model
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _render_status_table(all_results: dict) -> None:
|
|
ui_components.section_header(
|
|
"🕘", "Status Model per Produk",
|
|
"Skenario optimal, ketersediaan scaler, dan kapan terakhir diperbarui lewat panel upload.",
|
|
)
|
|
table = get_last_updated_info(all_results)
|
|
st.dataframe(table, width="stretch", hide_index=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tab 3: Perbarui Model (upload & merge)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@st.dialog("✅ Model Berhasil Diperbarui")
|
|
def _show_success_dialog(message: str) -> None:
|
|
"""
|
|
Popup konfirmasi setelah update model berhasil. Muncul di tengah layar,
|
|
bisa ditutup dengan klik "Oke" ATAU klik di luar area popup (perilaku
|
|
bawaan st.dialog) -- keduanya sama-sama menutup dialog & mengosongkan
|
|
kembali panel upload seperti baru pertama kali dibuka.
|
|
"""
|
|
st.write(message)
|
|
if st.button("Oke", type="primary", width="stretch", key="btn_dialog_ok"):
|
|
st.rerun()
|
|
|
|
|
|
def _render_upload_panel(all_results: dict) -> None:
|
|
ui_components.section_header(
|
|
"📤", "Perbarui Model (Upload .pkl)",
|
|
"Sistem hanya akan memperbarui produk yang berubah. Data produk lainnya tetap aman.",
|
|
)
|
|
|
|
# >>> FIX (baca): kalau ada pesan sukses yang "dititipkan" dari proses
|
|
# SEBELUM rerun (lihat >>> FIX (tulis) di bagian tombol "Terapkan" di
|
|
# bawah), tampilkan sebagai POPUP (bukan banner biasa). Dihapus dari
|
|
# session_state setelah dibaca, supaya tidak muncul lagi tiap buka tab.
|
|
pending_success = st.session_state.pop("_merge_success_msg", None)
|
|
if pending_success:
|
|
_show_success_dialog(pending_success)
|
|
|
|
# >>> FIX: key file_uploader disisipi angka versi yang berubah tiap
|
|
# kali update berhasil -- ini "trik" resmi Streamlit untuk mengosongkan
|
|
# file_uploader secara total (termasuk nama file yang tampil), bukan
|
|
# cuma pesannya saja yang hilang.
|
|
uploader_version = st.session_state.get("_uploader_version", 0)
|
|
uploaded = st.file_uploader(
|
|
"Pilih file all_results.pkl", type=["pkl"], key=f"uploader_pkl_{uploader_version}",
|
|
)
|
|
|
|
if uploaded is None:
|
|
# File dibatalkan/dihapus dari uploader -> buang state pending
|
|
# supaya tombol "Terapkan" tidak bisa dipakai dengan data basi.
|
|
st.session_state.pop("_pending_merge_data", None)
|
|
return
|
|
|
|
raw_bytes = uploaded.getvalue()
|
|
new_data, errors = model_manager.validate_new_data(raw_bytes)
|
|
|
|
if errors:
|
|
st.error(f"❌ File ditolak, ditemukan {len(errors)} masalah:")
|
|
for e in errors:
|
|
st.write(f"- {e}")
|
|
st.session_state.pop("_pending_merge_data", None)
|
|
return
|
|
|
|
st.success("✅ File siap diterapkan! Berikut rincian perubahannya:")
|
|
diff = model_manager.preview_merge(all_results, new_data)
|
|
|
|
c1, c2, c3 = st.columns(3)
|
|
c1.metric("Produk baru", len(diff["produk_baru"]))
|
|
c2.metric("Produk diperbarui", len(diff["produk_diperbarui"]))
|
|
c3.metric("Produk tidak berubah", len(diff["produk_tidak_berubah"]))
|
|
|
|
if diff["produk_baru"]:
|
|
st.caption("🆕 Baru: " + ", ".join(p.title() for p in diff["produk_baru"]))
|
|
if diff["produk_diperbarui"]:
|
|
st.caption("♻️ Diperbarui: " + ", ".join(p.title() for p in diff["produk_diperbarui"]))
|
|
|
|
# Simpan data yang sudah divalidasi ke session_state supaya tombol
|
|
# konfirmasi di bawah tidak perlu parse ulang bytes yang sama.
|
|
st.session_state["_pending_merge_data"] = new_data
|
|
|
|
st.warning("🛡️ Jangan khawatir, data yang sedang digunakan akan disimpan otomatis (ke folder `backups/`) sebagai cadangan sebelum diperbarui.")
|
|
if st.button("✅ Terapkan pembaruan", type="primary", width="stretch", key="btn_apply_merge"):
|
|
pending = st.session_state.get("_pending_merge_data")
|
|
if pending is None:
|
|
st.error("Tidak ada data valid. Upload ulang filenya!")
|
|
return
|
|
|
|
try:
|
|
merged, backup_path = model_manager.apply_merge(
|
|
all_results, pending, path=DEFAULT_DATA_PATH,
|
|
)
|
|
except Exception as e: # noqa: BLE001
|
|
st.error(f"Gagal menerapkan pembaruan: {e}")
|
|
return
|
|
|
|
st.session_state["all_results"] = merged
|
|
st.session_state.pop("_pending_merge_data", None)
|
|
|
|
# >>> FIX: naikkan versi supaya file_uploader di render berikutnya
|
|
# jadi widget BARU yang kosong (bukan cuma pesannya yang hilang).
|
|
st.session_state["_uploader_version"] = uploader_version + 1
|
|
|
|
# >>> FIX (tulis): pesan sukses DITITIPKAN ke session_state dulu
|
|
# (bukan langsung st.success()/dialog di sini), karena st.rerun()
|
|
# di baris terakhir akan memotong render sebelum sempat tampil.
|
|
# Nanti dibaca & ditampilkan sebagai popup di atas (>>> FIX (baca)).
|
|
if backup_path:
|
|
st.session_state["_merge_success_msg"] = (
|
|
f"Model berhasil diperbarui. File lama disimpan di `{backup_path}`."
|
|
)
|
|
else:
|
|
st.session_state["_merge_success_msg"] = (
|
|
"Model berhasil diperbarui (belum ada file lama untuk di-backup)."
|
|
)
|
|
st.rerun()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Entry point
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def render() -> None:
|
|
st.markdown(
|
|
"""
|
|
<style>
|
|
[data-testid="stElementToolbar"] button svg {
|
|
transform: scale(1.3) !important;
|
|
}
|
|
[data-testid="stElementToolbar"] button {
|
|
padding: 6px 10px !important;
|
|
}
|
|
[data-testid="stElementToolbar"] {
|
|
padding: 4px 6px !important;
|
|
gap: 4px !important;
|
|
}
|
|
</style>
|
|
""",
|
|
unsafe_allow_html=True,
|
|
)
|
|
st.title("Perbandingan Konfigurasi")
|
|
|
|
all_results = st.session_state.get("all_results", {})
|
|
if not all_results:
|
|
st.error("Data model belum dimuat.")
|
|
return
|
|
|
|
tab1, tab2, tab3 = st.tabs(["📊 Evaluasi Metrik", "🕘 Status Model", "📤 Perbarui Model"])
|
|
|
|
with tab1:
|
|
_render_metric_table(all_results)
|
|
st.divider()
|
|
_render_hyperparameter_table(all_results)
|
|
|
|
with tab2:
|
|
_render_status_table(all_results)
|
|
|
|
with tab3:
|
|
_render_upload_panel(all_results) |