Spaces:
Runtime error
Runtime error
| from __future__ import annotations | |
| import os | |
| import pandas as pd | |
| import streamlit as st | |
| from model import train, predict_proba, FEATURES, FEATURE_LABELS | |
| from explain.shap_exact import exact_shap, baseline_logit | |
| from explain.lime_local import lime_explain | |
| st.set_page_config(page_title="Aperture Audit | Model Explainability Dashboard", page_icon="◎", layout="wide") | |
| # --------------------------------------------------------------------------- | |
| # Theme: violet / near-black, tech-compliance feel | |
| # --------------------------------------------------------------------------- | |
| st.markdown( | |
| """ | |
| <style> | |
| :root { | |
| --ap-violet: #8B5CF6; | |
| --ap-bg: #0C0A14; | |
| --ap-panel: #17131F; | |
| --ap-border: #2A2438; | |
| --ap-grey: #A79FC2; | |
| } | |
| .stApp { background-color: var(--ap-bg); color: #F2EFFA; } | |
| section[data-testid="stSidebar"] { background-color: var(--ap-panel); } | |
| h1, h2, h3 { font-family: -apple-system, 'Helvetica Neue', sans-serif; letter-spacing: -0.01em; } | |
| .ap-hero { font-size: 2.0rem; font-weight: 700; margin-bottom: 0; color: var(--ap-violet); } | |
| .ap-sub { color: var(--ap-grey); font-size: 0.95rem; margin-top: 0.2rem; } | |
| .ap-card { | |
| background: var(--ap-panel); border: 1px solid var(--ap-border); border-radius: 12px; | |
| padding: 14px 16px; margin-bottom: 10px; | |
| } | |
| div[data-testid="stMetricValue"] { color: var(--ap-violet); } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown('<div class="ap-hero">Aperture Audit</div>', unsafe_allow_html=True) | |
| st.markdown( | |
| '<div class="ap-sub">A model-agnostic post-hoc audit dashboard: every credit decision comes with an ' | |
| 'exact SHAP-style attribution, cross-checked against an independently derived LIME explanation.</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.write("") | |
| DATA_PATH = os.path.join(os.path.dirname(__file__), "data", "credit_applications.csv") | |
| if "trained" not in st.session_state: | |
| st.session_state.trained = train(DATA_PATH) | |
| trained = st.session_state.trained | |
| df = pd.read_csv(DATA_PATH) | |
| with st.sidebar: | |
| st.markdown("### ◎ Aperture Audit") | |
| st.caption("Model-agnostic explainability dashboard, EU AI Act-style audit framing.") | |
| st.metric("Model training accuracy", f"{trained.train_accuracy:.1%}") | |
| st.caption(f"Logistic regression over {len(FEATURES)} features, {len(df)} synthetic applications.") | |
| st.divider() | |
| mode = st.radio("Choose an applicant", ["Pick from dataset", "Enter manually"]) | |
| if mode == "Pick from dataset": | |
| idx = st.selectbox("Applicant row", options=df.index, format_func=lambda i: f"Applicant #{i}") | |
| applicant = df.loc[idx, FEATURES].to_dict() | |
| else: | |
| applicant = {} | |
| for f in FEATURES: | |
| default = float(df[f].median()) | |
| applicant[f] = st.slider(FEATURE_LABELS[f], min_value=0.0, max_value=float(df[f].max() * 1.3), value=default) | |
| st.markdown("#### Applicant") | |
| cols = st.columns(len(FEATURES)) | |
| for c, f in zip(cols, FEATURES): | |
| c.metric(FEATURE_LABELS[f], f"{applicant[f]:.1f}") | |
| proba = predict_proba(trained, applicant) | |
| decision = "APPROVE" if proba >= 0.5 else "DENY" | |
| d1, d2 = st.columns([1, 3]) | |
| with d1: | |
| st.markdown( | |
| f'<div class="ap-card"><div style="font-size:0.8rem; color:var(--ap-grey);">MODEL DECISION</div>' | |
| f'<div style="font-size:1.8rem; font-weight:700; color:var(--ap-violet);">{decision}</div>' | |
| f'<div style="color:var(--ap-grey);">approval probability {proba:.1%}</div></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| tab1, tab2, tab3 = st.tabs(["SHAP-style attribution", "LIME cross-check", "Audit narrative"]) | |
| shap_contribs = exact_shap(trained, applicant) | |
| baseline = baseline_logit(trained) | |
| with tab1: | |
| st.markdown( | |
| "Exact, closed-form Shapley attribution (valid because the underlying model is linear). " | |
| "Each bar is this feature's exact contribution to the decision, relative to the average applicant." | |
| ) | |
| chart_df = pd.DataFrame([{"Feature": c.label, "Contribution": c.contribution} for c in shap_contribs]) | |
| st.bar_chart(chart_df.set_index("Feature")) | |
| st.dataframe( | |
| pd.DataFrame([{ | |
| "Feature": c.label, "Applicant value": round(c.value, 2), "SHAP contribution (logit)": round(c.contribution, 3) | |
| } for c in shap_contribs]), | |
| use_container_width=True, hide_index=True, | |
| ) | |
| st.caption(f"Baseline logit (average applicant): {baseline:.3f}") | |
| with tab2: | |
| st.markdown( | |
| "An independently derived explanation: 400 small random perturbations around this applicant are fed " | |
| "back through the real model, and a local linear regression is fit to the (perturbation, prediction) pairs. " | |
| "Its coefficients never look at the model's internals -- only its input/output behavior." | |
| ) | |
| lime_result = lime_explain(trained, applicant) | |
| compare_rows = [] | |
| for c in shap_contribs: | |
| # Compare like with like: LIME's local coefficient times this applicant's | |
| # deviation from baseline, the same formula SHAP uses -- comparing raw | |
| # coefficient signs against SHAP's deviation-adjusted contribution would | |
| # be an apples-to-oranges mismatch (a feature can have a positive | |
| # coefficient but a negative SHAP contribution if the applicant is below | |
| # the population mean on that feature). | |
| lime_w = lime_result["weights"][c.feature] | |
| lime_contribution = lime_w * (c.value - trained.feature_means[c.feature]) | |
| shap_sign = "+" if c.contribution > 0 else "-" | |
| lime_sign = "+" if lime_contribution > 0 else "-" | |
| agree = "✅ agree" if shap_sign == lime_sign else "⚠️ disagree" | |
| compare_rows.append({ | |
| "Feature": c.label, | |
| "SHAP contribution": round(c.contribution, 3), | |
| "LIME-derived contribution": round(lime_contribution, 3), | |
| "Cross-check": agree, | |
| }) | |
| st.dataframe(pd.DataFrame(compare_rows), use_container_width=True, hide_index=True) | |
| st.caption(f"LIME local surrogate fidelity (R²): {lime_result['local_fidelity_r2']:.3f} — " | |
| f"how well the local linear model explains the real model's behavior near this applicant.") | |
| with tab3: | |
| top3 = shap_contribs[:3] | |
| direction_words = {True: "increased", False: "decreased"} | |
| st.markdown(f"##### Why this applicant was **{decision}D**" if decision == "DENY" else f"##### Why this applicant was **{decision}D**") | |
| for c in top3: | |
| word = direction_words[c.contribution > 0] | |
| st.markdown( | |
| f'<div class="ap-card">Their <b>{c.label.lower()}</b> of <b>{c.value:.1f}</b> {word} their approval ' | |
| f'likelihood by <b>{abs(c.contribution):.2f}</b> logit-points relative to a typical applicant.</div>', | |
| unsafe_allow_html=True, | |
| ) | |
| st.caption( | |
| "This is the kind of per-decision, feature-level explanation EU AI Act Article 13 and similar " | |
| "regulatory frameworks require for high-stakes automated decisions: not just a prediction, but a " | |
| "reason, traceable to specific input features, for that specific person." | |
| ) | |