Download app.py from Zainajabroh/IDSS: direct link, hf CLI and curl.
- Browser
- Download file 25 kB
-
https://huggingface.co/Zainajabroh/IDSS/resolve/main/app.py
- Command line
-
hf download hf://Zainajabroh/IDSS/app.py
-
curl -L -o app.py https://huggingface.co/Zainajabroh/IDSS/resolve/main/app.py
25 kB
| """ | |
| IDSS β AI Use Case Prioritization untuk UMKM Kuliner | |
| Streamlit app interaktif: manager bisa mengubah stakeholder, bobot, dan matriks | |
| keputusan, lalu melihat hasil SAW, TOPSIS, dan Weighted Product secara real-time. | |
| Jalankan dengan: streamlit run app.py | |
| """ | |
| import numpy as np | |
| import pandas as pd | |
| import plotly.express as px | |
| import plotly.graph_objects as go | |
| import streamlit as st | |
| # ====================================================================================== | |
| # KONFIGURASI HALAMAN | |
| # ====================================================================================== | |
| st.set_page_config( | |
| page_title="IDSS - AI Use Case Prioritization", | |
| page_icon="π", | |
| layout="wide", | |
| ) | |
| HIGHLIGHT_COLOR = "#f97316" # oranye β dipakai KHUSUS untuk alternatif terbaik keseluruhan | |
| METHOD_COLORS = {"SAW": "#3b82f6", "TOPSIS": "#22c55e", "WP": "#a855f7"} | |
| # ====================================================================================== | |
| # DATA DEFAULT (studi kasus UMKM Kuliner DIY β sama seperti di notebook) | |
| # ====================================================================================== | |
| DEFAULT_CRITERIA = pd.DataFrame( | |
| [ | |
| {"kode": "C1", "nama": "Business impact", "arah": "Benefit", "batas_min": None, "batas_maks": None}, | |
| {"kode": "C2", "nama": "Data readiness", "arah": "Benefit", "batas_min": None, "batas_maks": None}, | |
| {"kode": "C3", "nama": "Ease of adoption", "arah": "Benefit", "batas_min": None, "batas_maks": None}, | |
| {"kode": "C4", "nama": "Implementation cost (juta IDR)", "arah": "Cost", "batas_min": None, "batas_maks": 20.0}, | |
| {"kode": "C5", "nama": "Implementation time (minggu)", "arah": "Cost", "batas_min": None, "batas_maks": 12.0}, | |
| {"kode": "C6", "nama": "Implementation risk", "arah": "Cost", "batas_min": None, "batas_maks": None}, | |
| ] | |
| ) | |
| DEFAULT_ALTERNATIVES = pd.DataFrame( | |
| [ | |
| {"kode": "A1", "nama": "WhatsApp customer assistant", "C1": 4.0, "C2": 4.4, "C3": 4.8, "C4": 8, "C5": 5, "C6": 2.8}, | |
| {"kode": "A2", "nama": "Demand & inventory forecasting", "C1": 4.8, "C2": 2.8, "C3": 3.1, "C4": 18, "C5": 12, "C6": 3.1}, | |
| {"kode": "A3", "nama": "Automated financial recording", "C1": 4.4, "C2": 4.1, "C3": 4.0, "C4": 10, "C5": 6, "C6": 2.2}, | |
| {"kode": "A4", "nama": "Marketing content generation", "C1": 3.8, "C2": 4.3, "C3": 4.5, "C4": 5, "C5": 3, "C6": 3.2}, | |
| {"kode": "A5", "nama": "AI customer review analysis", "C1": 3.9, "C2": 4.8, "C3": 4.2, "C4": 7, "C5": 4, "C6": 2.6}, | |
| ] | |
| ) | |
| DEFAULT_STAKEHOLDERS = pd.DataFrame( | |
| [ | |
| {"jabatan": "Coordinator", "C1": 30, "C2": 20, "C3": 15, "C4": 10, "C5": 10, "C6": 15}, | |
| {"jabatan": "MSME Owner 1", "C1": 25, "C2": 15, "C3": 20, "C4": 20, "C5": 10, "C6": 10}, | |
| {"jabatan": "MSME Owner 2", "C1": 25, "C2": 20, "C3": 20, "C4": 15, "C5": 10, "C6": 10}, | |
| {"jabatan": "Business mentor", "C1": 30, "C2": 15, "C3": 15, "C4": 15, "C5": 10, "C6": 15}, | |
| {"jabatan": "Technical team", "C1": 20, "C2": 25, "C3": 10, "C4": 15, "C5": 10, "C6": 20}, | |
| ] | |
| ) | |
| # ====================================================================================== | |
| # SESSION STATE | |
| # ====================================================================================== | |
| if "criteria" not in st.session_state: | |
| st.session_state.criteria = DEFAULT_CRITERIA.copy() | |
| if "alternatives" not in st.session_state: | |
| st.session_state.alternatives = DEFAULT_ALTERNATIVES.copy() | |
| if "stakeholders" not in st.session_state: | |
| st.session_state.stakeholders = DEFAULT_STAKEHOLDERS.copy() | |
| def sync_criteria_columns(): | |
| """Samakan kolom kriteria di tabel alternatif & stakeholder dengan daftar kriteria terbaru.""" | |
| codes = [c for c in st.session_state.criteria["kode"].dropna().tolist() if str(c).strip() != ""] | |
| alt = st.session_state.alternatives.copy() | |
| for c in codes: | |
| if c not in alt.columns: | |
| alt[c] = 0.0 | |
| keep = ["kode", "nama"] + codes | |
| alt = alt[[c for c in keep if c in alt.columns]] | |
| st.session_state.alternatives = alt | |
| sh = st.session_state.stakeholders.copy() | |
| for c in codes: | |
| if c not in sh.columns: | |
| sh[c] = 0 | |
| keep2 = ["jabatan"] + codes | |
| sh = sh[[c for c in keep2 if c in sh.columns]] | |
| st.session_state.stakeholders = sh | |
| def reset_to_default(): | |
| st.session_state.criteria = DEFAULT_CRITERIA.copy() | |
| st.session_state.alternatives = DEFAULT_ALTERNATIVES.copy() | |
| st.session_state.stakeholders = DEFAULT_STAKEHOLDERS.copy() | |
| # ====================================================================================== | |
| # FUNGSI INTI: FEASIBILITY GATE, SAW, TOPSIS, WP | |
| # ====================================================================================== | |
| def apply_feasibility(alt_df: pd.DataFrame, criteria_df: pd.DataFrame) -> pd.Series: | |
| """True/False per alternatif berdasarkan batas_min / batas_maks tiap kriteria (opsional).""" | |
| mask = pd.Series(True, index=alt_df.index) | |
| for _, row in criteria_df.iterrows(): | |
| code = row["kode"] | |
| if code not in alt_df.columns: | |
| continue | |
| if pd.notna(row.get("batas_min")): | |
| mask &= alt_df[code] >= row["batas_min"] | |
| if pd.notna(row.get("batas_maks")): | |
| mask &= alt_df[code] <= row["batas_maks"] | |
| return mask | |
| def compute_weights(stakeholder_df: pd.DataFrame, codes: list) -> tuple[pd.Series, pd.Series]: | |
| """ | |
| Normalisasi TIAP baris stakeholder ke total 100 dulu (biar tetap adil walau ada yang | |
| salah input), baru dirata-rata antar stakeholder, lalu dinormalisasi jadi bobot total = 1. | |
| Mengembalikan (bobot_final, total_poin_asli_per_stakeholder). | |
| """ | |
| pts = stakeholder_df[codes].astype(float) | |
| row_sums = pts.sum(axis=1) | |
| row_sums_safe = row_sums.replace(0, np.nan) | |
| normalized_rows = pts.div(row_sums_safe, axis=0) * 100 | |
| mean_points = normalized_rows.mean(axis=0) | |
| weights = mean_points / mean_points.sum() | |
| return weights, row_sums | |
| def normalize_saw(matrix: pd.DataFrame, direction: dict) -> pd.DataFrame: | |
| norm = pd.DataFrame(index=matrix.index, columns=matrix.columns, dtype=float) | |
| for col in matrix.columns: | |
| if direction[col] == "Benefit": | |
| norm[col] = matrix[col] / matrix[col].max() | |
| else: | |
| norm[col] = matrix[col].min() / matrix[col] | |
| return norm | |
| def compute_saw(matrix: pd.DataFrame, weights: pd.Series, direction: dict): | |
| norm = normalize_saw(matrix, direction) | |
| weighted = norm * weights[matrix.columns] | |
| scores = weighted.sum(axis=1) | |
| return scores, weighted | |
| def compute_topsis(matrix: pd.DataFrame, weights: pd.Series, direction: dict): | |
| denom = np.sqrt((matrix ** 2).sum(axis=0)) | |
| r = matrix / denom | |
| v = r * weights[matrix.columns] | |
| a_pos, a_neg = {}, {} | |
| for col in matrix.columns: | |
| if direction[col] == "Benefit": | |
| a_pos[col], a_neg[col] = v[col].max(), v[col].min() | |
| else: | |
| a_pos[col], a_neg[col] = v[col].min(), v[col].max() | |
| a_pos, a_neg = pd.Series(a_pos), pd.Series(a_neg) | |
| s_pos = np.sqrt(((v - a_pos) ** 2).sum(axis=1)) | |
| s_neg = np.sqrt(((v - a_neg) ** 2).sum(axis=1)) | |
| closeness = s_neg / (s_pos + s_neg) | |
| return closeness, v, a_pos, a_neg, s_pos, s_neg | |
| def compute_wp(matrix: pd.DataFrame, weights: pd.Series, direction: dict): | |
| if (matrix <= 0).any().any(): | |
| return None, None, "Ada nilai \u2264 0 di matriks β WP tidak bisa dihitung (pemangkatan negatif terhadap 0/negatif tidak terdefinisi)." | |
| signed_w = pd.Series( | |
| {c: weights[c] if direction[c] == "Benefit" else -weights[c] for c in matrix.columns} | |
| ) | |
| S = (matrix ** signed_w).prod(axis=1) | |
| V = S / S.sum() | |
| return V, S, None | |
| def ranked_bar(series: pd.Series, names: dict, winner: str, x_title: str, title: str) -> go.Figure: | |
| s = series.sort_values() | |
| colors = [HIGHLIGHT_COLOR if a == winner else "#93c5fd" for a in s.index] | |
| labels = [f"{a} β {names.get(a, '')}" for a in s.index] | |
| fig = go.Figure( | |
| go.Bar( | |
| x=s.values, y=labels, orientation="h", marker_color=colors, | |
| text=[f"{v:.4f}" for v in s.values], textposition="outside", | |
| ) | |
| ) | |
| fig.update_layout(title=title, xaxis_title=x_title, height=90 + 60 * len(s), margin=dict(l=10, r=10, t=60, b=10)) | |
| return fig | |
| # ====================================================================================== | |
| # SIDEBAR | |
| # ====================================================================================== | |
| with st.sidebar: | |
| st.header("βοΈ Kontrol") | |
| st.caption( | |
| "Nilai di semua tabel bisa diedit langsung dengan klik sel. Baris kriteria & alternatif " | |
| "sudah baku (tetap); hanya tabel stakeholder yang barisnya bisa ditambah/dihapus." | |
| ) | |
| if st.button("π Reset ke data contoh (UMKM Kuliner)", use_container_width=True): | |
| reset_to_default() | |
| st.rerun() | |
| st.divider() | |
| st.caption( | |
| "Dibuat berdasarkan studi kasus *AI Use Case Prioritization for Culinary MSMEs* " | |
| "(UGM). Mendukung 3 metode MCDM: **SAW**, **TOPSIS**, **Weighted Product**." | |
| ) | |
| st.title("π Intelligent Decision Support System (IDSS)") | |
| st.caption("AI Use Case Prioritization β bandingkan SAW, TOPSIS, dan Weighted Product secara interaktif") | |
| tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs( | |
| ["π Kriteria & Alternatif", "βοΈ Bobot Stakeholder", "π SAW", "π TOPSIS", "βοΈ Weighted Product", "π Dashboard Perbandingan"] | |
| ) | |
| # ====================================================================================== | |
| # TAB 1 β KRITERIA & ALTERNATIF | |
| # ====================================================================================== | |
| with tab1: | |
| st.subheader("1. Daftar Kriteria") | |
| st.caption( | |
| "Kode, nama, dan arah kriteria sudah baku (tidak bisa diubah). Yang bisa diedit hanya " | |
| "`batas_min` / `batas_maks` β opsional, dipakai sebagai *feasibility gate* (alternatif yang " | |
| "melanggar batas akan otomatis dicoret dari perhitungan). Kosongkan kalau tidak ada batas." | |
| ) | |
| edited_criteria = st.data_editor( | |
| st.session_state.criteria, | |
| num_rows="fixed", | |
| use_container_width=True, | |
| key="criteria_editor", | |
| column_config={ | |
| "kode": st.column_config.TextColumn("Kode", disabled=True), | |
| "nama": st.column_config.TextColumn("Nama Kriteria", disabled=True), | |
| "arah": st.column_config.TextColumn("Arah", disabled=True), | |
| "batas_min": st.column_config.NumberColumn("Batas Min (opsional)", format="%.2f"), | |
| "batas_maks": st.column_config.NumberColumn("Batas Maks (opsional)", format="%.2f"), | |
| }, | |
| ) | |
| if not edited_criteria.equals(st.session_state.criteria): | |
| st.session_state.criteria = edited_criteria | |
| sync_criteria_columns() | |
| st.rerun() | |
| criteria_df = st.session_state.criteria.dropna(subset=["kode"]).copy() | |
| criteria_df = criteria_df[criteria_df["kode"].astype(str).str.strip() != ""] | |
| if criteria_df["kode"].duplicated().any(): | |
| st.error("Ada kode kriteria yang duplikat β perbaiki dulu sebelum lanjut.") | |
| st.stop() | |
| if criteria_df.empty: | |
| st.error("Minimal harus ada 1 kriteria.") | |
| st.stop() | |
| criteria_codes = criteria_df["kode"].tolist() | |
| criteria_names = dict(zip(criteria_df["kode"], criteria_df["nama"])) | |
| criteria_direction = dict(zip(criteria_df["kode"], criteria_df["arah"])) | |
| st.subheader("2. Matriks Keputusan (nilai mentah tiap alternatif)") | |
| st.caption("Kode dan nama use case sudah baku (tidak bisa diubah). Yang bisa diedit hanya nilai C1-C6.") | |
| edited_alt = st.data_editor( | |
| st.session_state.alternatives, | |
| num_rows="fixed", | |
| use_container_width=True, | |
| key="alt_editor", | |
| column_config={ | |
| "kode": st.column_config.TextColumn("Kode", disabled=True), | |
| "nama": st.column_config.TextColumn("Nama Use Case", disabled=True), | |
| **{c: st.column_config.NumberColumn(f"{c}: {criteria_names.get(c, '')}", format="%.2f") for c in criteria_codes}, | |
| }, | |
| ) | |
| st.session_state.alternatives = edited_alt | |
| alt_df = st.session_state.alternatives.dropna(subset=["kode"]).copy() | |
| alt_df = alt_df[alt_df["kode"].astype(str).str.strip() != ""] | |
| if alt_df["kode"].duplicated().any(): | |
| st.error("Ada kode alternatif yang duplikat β perbaiki dulu sebelum lanjut.") | |
| st.stop() | |
| if len(alt_df) < 2: | |
| st.error("Minimal harus ada 2 alternatif supaya bisa dibandingkan.") | |
| st.stop() | |
| alt_names = dict(zip(alt_df["kode"], alt_df["nama"])) | |
| matrix_full = alt_df.set_index("kode")[criteria_codes].astype(float) | |
| st.subheader("3. Feasibility Gate") | |
| feasible_mask = apply_feasibility(matrix_full, criteria_df) | |
| gate_view = matrix_full.copy() | |
| gate_view.insert(0, "nama", [alt_names[a] for a in gate_view.index]) | |
| gate_view["Status"] = np.where(feasible_mask, "β Lolos", "β Tidak lolos") | |
| st.dataframe(gate_view, use_container_width=True) | |
| st.caption(f"{feasible_mask.sum()}/{len(matrix_full)} alternatif lolos gate dan dipakai di perhitungan SAW/TOPSIS/WP.") | |
| matrix = matrix_full.loc[feasible_mask] | |
| if len(matrix) < 2: | |
| st.error("Kurang dari 2 alternatif yang lolos feasibility gate β longgarkan batas_min/batas_maks dulu.") | |
| st.stop() | |
| # ====================================================================================== | |
| # TAB 2 β BOBOT STAKEHOLDER | |
| # ====================================================================================== | |
| with tab2: | |
| st.subheader("1. Alokasi 100 Poin per Stakeholder") | |
| st.caption( | |
| "Tambah/hapus baris untuk menambah/mengurangi stakeholder. Masukkan **Jabatan** dan alokasikan " | |
| "poin ke tiap kriteria β total per baris **harus 100**." | |
| ) | |
| edited_stakeholders = st.data_editor( | |
| st.session_state.stakeholders, | |
| num_rows="dynamic", | |
| use_container_width=True, | |
| key="stakeholder_editor", | |
| column_config={ | |
| "jabatan": st.column_config.TextColumn("Jabatan", required=True), | |
| **{c: st.column_config.NumberColumn(f"{c}: {criteria_names.get(c, '')}", min_value=0, max_value=100, step=1) for c in criteria_codes}, | |
| }, | |
| ) | |
| st.session_state.stakeholders = edited_stakeholders | |
| sh_df = st.session_state.stakeholders.dropna(subset=["jabatan"]).copy() | |
| sh_df = sh_df[sh_df["jabatan"].astype(str).str.strip() != ""] | |
| if sh_df.empty: | |
| st.error("Minimal harus ada 1 stakeholder.") | |
| st.stop() | |
| weights, row_sums = compute_weights(sh_df, criteria_codes) | |
| st.subheader("2. Validasi Total Poin") | |
| validasi = pd.DataFrame({"Jabatan": sh_df["jabatan"].values, "Total Poin": row_sums.values}) | |
| validasi["Status"] = np.where(np.isclose(validasi["Total Poin"], 100), "β 100", "β οΈ Bukan 100 (akan dinormalisasi otomatis)") | |
| st.dataframe(validasi, use_container_width=True, hide_index=True) | |
| bermasalah = validasi[~np.isclose(validasi["Total Poin"], 100)] | |
| if not bermasalah.empty: | |
| daftar = ", ".join(f"{r.Jabatan} ({r['Total Poin']:.0f})" for r in bermasalah.itertuples()) | |
| st.warning( | |
| f"Stakeholder berikut totalnya **bukan 100**: {daftar}. " | |
| "Poinnya tetap dipakai tapi sudah dinormalisasi otomatis secara proporsional supaya " | |
| "perhitungan tidak berhenti β sebaiknya tetap diperbaiki di tabel atas untuk akurasi." | |
| ) | |
| else: | |
| st.success("Semua stakeholder totalnya sudah tepat 100. β ") | |
| st.subheader("3. Bobot Final per Kriteria") | |
| weights_view = pd.DataFrame( | |
| {"Kriteria": [f"{c}: {criteria_names[c]}" for c in criteria_codes], "Bobot": [weights[c] for c in criteria_codes]} | |
| ).sort_values("Bobot", ascending=True) | |
| fig_w = go.Figure( | |
| go.Bar(x=weights_view["Bobot"], y=weights_view["Kriteria"], orientation="h", marker_color="#3b82f6", | |
| text=[f"{v:.1%}" for v in weights_view["Bobot"]], textposition="outside") | |
| ) | |
| fig_w.update_layout(title="Bobot Akhir per Kriteria", xaxis_title="Bobot (proporsi dari 1.0)", | |
| height=90 + 45 * len(weights_view), margin=dict(l=10, r=10, t=60, b=10)) | |
| st.plotly_chart(fig_w, use_container_width=True) | |
| # ====================================================================================== | |
| # PERHITUNGAN SENTRAL (dipakai tab 3-6) | |
| # ====================================================================================== | |
| saw_scores, saw_weighted = compute_saw(matrix, weights, criteria_direction) | |
| topsis_scores, topsis_v, topsis_apos, topsis_aneg, topsis_spos, topsis_sneg = compute_topsis(matrix, weights, criteria_direction) | |
| wp_scores, wp_S, wp_error = compute_wp(matrix, weights, criteria_direction) | |
| saw_winner = saw_scores.idxmax() | |
| topsis_winner = topsis_scores.idxmax() | |
| wp_winner = wp_scores.idxmax() if wp_scores is not None else None | |
| # ====================================================================================== | |
| # TAB 3 β SAW | |
| # ====================================================================================== | |
| with tab3: | |
| st.subheader("Hasil SAW (Simple Additive Weighting)") | |
| st.latex(r"V_i = \sum_j w_j \cdot r_{ij}") | |
| saw_table = pd.DataFrame({"nama": [alt_names[a] for a in saw_scores.index], "Skor SAW": saw_scores}) | |
| saw_table = saw_table.sort_values("Skor SAW", ascending=False) | |
| st.dataframe(saw_table, use_container_width=True) | |
| st.success(f"π Pemenang SAW: **{saw_winner} β {alt_names[saw_winner]}** (skor {saw_scores[saw_winner]:.4f})") | |
| st.plotly_chart(ranked_bar(saw_scores, alt_names, saw_winner, "Skor SAW", "Ranking SAW"), use_container_width=True) | |
| st.markdown("**Dekomposisi skor per kriteria**") | |
| decomp = saw_weighted.copy() | |
| decomp.index = [f"{a}: {alt_names[a]}" for a in decomp.index] | |
| decomp_long = decomp.reset_index().melt(id_vars="index", var_name="Kriteria", value_name="Kontribusi") | |
| fig_decomp = px.bar(decomp_long, x="Kontribusi", y="index", color="Kriteria", orientation="h", | |
| title="Kontribusi Tiap Kriteria terhadap Skor SAW") | |
| fig_decomp.update_layout(yaxis_title="", height=90 + 45 * len(decomp)) | |
| st.plotly_chart(fig_decomp, use_container_width=True) | |
| # ====================================================================================== | |
| # TAB 4 β TOPSIS | |
| # ====================================================================================== | |
| with tab4: | |
| st.subheader("Hasil TOPSIS") | |
| st.latex(r"C_i^{*} = \frac{S_i'}{S_i^{*} + S_i'}") | |
| topsis_table = pd.DataFrame( | |
| { | |
| "nama": [alt_names[a] for a in topsis_scores.index], | |
| "Skor TOPSIS (C*)": topsis_scores, | |
| "Jarak ke ideal positif (S*)": topsis_spos, | |
| "Jarak ke ideal negatif (S')": topsis_sneg, | |
| } | |
| ).sort_values("Skor TOPSIS (C*)", ascending=False) | |
| st.dataframe(topsis_table, use_container_width=True) | |
| st.success(f"π Pemenang TOPSIS: **{topsis_winner} β {alt_names[topsis_winner]}** (skor {topsis_scores[topsis_winner]:.4f})") | |
| st.plotly_chart(ranked_bar(topsis_scores, alt_names, topsis_winner, "Skor TOPSIS (C*)", "Ranking TOPSIS"), use_container_width=True) | |
| with st.expander("Lihat solusi ideal positif (A*) & negatif (A')"): | |
| st.dataframe(pd.DataFrame({"A* (ideal positif)": topsis_apos, "A' (ideal negatif)": topsis_aneg}), use_container_width=True) | |
| # ====================================================================================== | |
| # TAB 5 β WEIGHTED PRODUCT | |
| # ====================================================================================== | |
| with tab5: | |
| st.subheader("Hasil Weighted Product (WP)") | |
| st.latex(r"S_i = \prod_{j=1}^{n} x_{ij}^{\,w_j} \qquad V_i = \frac{S_i}{\sum_i S_i}") | |
| if wp_error: | |
| st.error(wp_error) | |
| else: | |
| wp_table = pd.DataFrame({"nama": [alt_names[a] for a in wp_scores.index], "Skor WP (V)": wp_scores, "S_i": wp_S}) | |
| wp_table = wp_table.sort_values("Skor WP (V)", ascending=False) | |
| st.dataframe(wp_table, use_container_width=True) | |
| st.success(f"π Pemenang WP: **{wp_winner} β {alt_names[wp_winner]}** (skor {wp_scores[wp_winner]:.4f})") | |
| st.plotly_chart(ranked_bar(wp_scores, alt_names, wp_winner, "Skor WP (V)", "Ranking Weighted Product"), use_container_width=True) | |
| # ====================================================================================== | |
| # TAB 6 β DASHBOARD PERBANDINGAN | |
| # ====================================================================================== | |
| with tab6: | |
| st.subheader("Dashboard Perbandingan SAW vs TOPSIS vs WP") | |
| st.caption( | |
| "Skor ketiga metode sama-sama berada di rentang 0-1, tapi maknanya beda " | |
| "(agregat bobotΓnilai vs kedekatan ke solusi ideal) β yang paling valid dibandingkan " | |
| "lintas metode adalah **urutan rangking-nya**, bukan besaran angka mentahnya." | |
| ) | |
| comp = pd.DataFrame({"SAW": saw_scores, "TOPSIS": topsis_scores}) | |
| if wp_scores is not None: | |
| comp["WP"] = wp_scores | |
| comp.insert(0, "nama", [alt_names[a] for a in comp.index]) | |
| method_cols = [c for c in ["SAW", "TOPSIS", "WP"] if c in comp.columns] | |
| rank_table = comp[method_cols].rank(ascending=False).astype(int) | |
| rank_table.columns = [f"Rank_{c}" for c in rank_table.columns] | |
| comp_full = pd.concat([comp, rank_table], axis=1) | |
| avg_rank = rank_table.mean(axis=1).sort_values() | |
| overall_best = avg_rank.index[0] | |
| winners = {"SAW": saw_winner, "TOPSIS": topsis_winner} | |
| if wp_winner is not None: | |
| winners["WP"] = wp_winner | |
| n_won = sum(1 for w in winners.values() if w == overall_best) | |
| st.dataframe(comp_full.sort_values("SAW", ascending=False), use_container_width=True) | |
| c1, c2 = st.columns([2, 1]) | |
| with c1: | |
| st.info( | |
| f"**Pemenang per metode:** " + " | ".join(f"{m}: {w} ({alt_names[w]})" for m, w in winners.items()) | |
| ) | |
| with c2: | |
| st.success( | |
| f"π **Terbaik keseluruhan:** {overall_best} β {alt_names[overall_best]}\n\n" | |
| f"Menang di {n_won}/{len(method_cols)} metode langsung, rata-rata rank = {avg_rank[overall_best]:.2f}" | |
| ) | |
| # --- Grouped bar chart interaktif: skor per metode, highlight alternatif terbaik --- | |
| fig = go.Figure() | |
| for m in method_cols: | |
| colors = [HIGHLIGHT_COLOR if a == overall_best else METHOD_COLORS[m] for a in comp.index] | |
| fig.add_trace( | |
| go.Bar( | |
| name=m, x=[f"{a}<br>{alt_names[a]}" for a in comp.index], y=comp[m], | |
| marker_color=colors, text=[f"{v:.3f}" for v in comp[m]], textposition="outside", | |
| ) | |
| ) | |
| fig.update_layout( | |
| barmode="group", title="Skor SAW vs TOPSIS vs WP per Alternatif (oranye = terbaik keseluruhan)", | |
| yaxis_title="Skor (0-1)", height=500, legend_title="Metode", | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| # --- Rata-rata rank --- | |
| fig_rank = go.Figure( | |
| go.Bar( | |
| x=avg_rank.sort_values(ascending=False).values, | |
| y=[f"{a}: {alt_names[a]}" for a in avg_rank.sort_values(ascending=False).index], | |
| orientation="h", | |
| marker_color=[HIGHLIGHT_COLOR if a == overall_best else "#94a3b8" for a in avg_rank.sort_values(ascending=False).index], | |
| text=[f"{v:.2f}" for v in avg_rank.sort_values(ascending=False).values], | |
| textposition="outside", | |
| ) | |
| ) | |
| fig_rank.update_layout(title="Rata-rata Rank Lintas 3 Metode (1 = terbaik)", xaxis_title="Rata-rata rank", | |
| height=90 + 45 * len(avg_rank), xaxis_autorange="reversed") | |
| st.plotly_chart(fig_rank, use_container_width=True) | |
| # --- Radar chart: profil kriteria ternormalisasi tiap alternatif --- | |
| st.markdown("**Profil kriteria (nilai ternormalisasi SAW) β bonus untuk lihat kekuatan/kelemahan tiap alternatif**") | |
| norm_saw = normalize_saw(matrix, criteria_direction) | |
| fig_radar = go.Figure() | |
| for a in norm_saw.index: | |
| fig_radar.add_trace( | |
| go.Scatterpolar( | |
| r=norm_saw.loc[a, criteria_codes].tolist() + [norm_saw.loc[a, criteria_codes[0]]], | |
| theta=criteria_codes + [criteria_codes[0]], | |
| fill="toself", | |
| name=f"{a}: {alt_names[a]}", | |
| line_color=HIGHLIGHT_COLOR if a == overall_best else None, | |
| opacity=1.0 if a == overall_best else 0.55, | |
| ) | |
| ) | |
| fig_radar.update_layout(polar=dict(radialaxis=dict(visible=True, range=[0, 1])), height=500, | |
| title="Radar Perbandingan Kriteria Antar Alternatif") | |
| st.plotly_chart(fig_radar, use_container_width=True) | |
| st.download_button( | |
| "β¬οΈ Unduh hasil perbandingan (CSV)", | |
| comp_full.to_csv(index=True).encode("utf-8"), | |
| file_name="perbandingan_saw_topsis_wp.csv", | |
| mime="text/csv", | |
| ) | |