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."
)