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( """ """, unsafe_allow_html=True, ) st.markdown('
Aperture Audit
', unsafe_allow_html=True) st.markdown( '
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.
', 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'
MODEL DECISION
' f'
{decision}
' f'
approval probability {proba:.1%}
', 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'
Their {c.label.lower()} of {c.value:.1f} {word} their approval ' f'likelihood by {abs(c.contribution):.2f} logit-points relative to a typical applicant.
', 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." )