""" app.py — Finexcore AI Lending Intelligence Platform Loan default prediction powered by LightGBM + SHAP """ import json import warnings import numpy as np import pandas as pd import shap import joblib import matplotlib matplotlib.use("Agg") import matplotlib.pyplot as plt import plotly.graph_objects as go import plotly.express as px import streamlit as st from pathlib import Path from scipy.special import expit warnings.filterwarnings("ignore") # ── Paths ───────────────────────────────────────────────────────────────────── MODELS_DIR = Path("models") PROCESSED_DIR = Path("data/processed") # ── Finexcore Brand Colors ──────────────────────────────────────────────────── C_NAVY = "#1B2B4B" # Finexcore primary dark navy C_BLUE = "#0057C8" # Finexcore accent blue C_LBLUE = "#E8F0FB" # Light blue background C_WHITE = "#FFFFFF" C_GREEN = "#1A7F5A" # Approve / Low risk C_AMBER = "#D97706" # Medium risk C_RED = "#C0392B" # Decline / High risk C_GRAY = "#6B7280" # Muted text C_BORDER = "#CBD5E1" # ══════════════════════════════════════════════════════════════════════════════ # PAGE CONFIG # ══════════════════════════════════════════════════════════════════════════════ st.set_page_config( page_title = "Finexcore | AI Lending Intelligence", page_icon = "🏦", layout = "wide", initial_sidebar_state = "expanded", ) # ── CSS — Finexcore brand ───────────────────────────────────────────────────── st.markdown(f""" """, unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # CACHED LOADERS # ══════════════════════════════════════════════════════════════════════════════ @st.cache_resource(show_spinner="Initialising AI engine...") def load_model(): return joblib.load(MODELS_DIR / "lgbm_best.pkl") @st.cache_resource(show_spinner="Loading explainer...") def load_explainer(): return shap.TreeExplainer(load_model()) @st.cache_data(show_spinner=False) def load_meta(): with open(MODELS_DIR / "feature_cols.json") as f: feature_cols = json.load(f) with open(MODELS_DIR / "threshold.json") as f: threshold = json.load(f)["threshold"] with open(MODELS_DIR / "metrics.json") as f: metrics = json.load(f) with open(MODELS_DIR / "shap_feature_desc.json") as f: feat_desc = json.load(f) shap_top = pd.read_csv(MODELS_DIR / "shap_top20.csv") return feature_cols, threshold, metrics, feat_desc, shap_top @st.cache_data(show_spinner=False) def load_pipeline_meta(): with open(PROCESSED_DIR / "pipeline_meta.json") as f: return json.load(f) # ══════════════════════════════════════════════════════════════════════════════ # HELPERS # ══════════════════════════════════════════════════════════════════════════════ def kpi(val, label, sub=""): return f"""
{val}
{label}
{"
"+sub+"
" if sub else ""}
""" def risk_tier(prob, threshold): if prob >= threshold: return "HIGH", C_RED, "🔴", "DECLINE" elif prob >= threshold * 0.6: return "MEDIUM", C_AMBER, "🟡", "REVIEW" else: return "LOW", C_GREEN, "🟢", "APPROVE" def predict_single(input_dict, feature_cols, pipeline_meta): model = load_model() explainer = load_explainer() row = {feat: input_dict.get(feat, np.nan) for feat in feature_cols} df = pd.DataFrame([row]) for col, med in pipeline_meta["medians"].items(): if col in df.columns and df[col].isna().any(): df[col] = df[col].fillna(med) for col, (lo, hi) in pipeline_meta["clip_bounds"].items(): if col in df.columns: df[col] = df[col].clip(lower=lo, upper=hi) df = df.fillna(0) X = df[feature_cols].values.astype(np.float32) prob = float(model.predict_proba(X)[0, 1]) sv = load_explainer().shap_values(X) sv = sv[1][0] if isinstance(sv, list) else sv[0] shap_df = pd.DataFrame({ "feature": feature_cols, "shap": sv, "value": X[0] }) shap_df["abs_shap"] = shap_df["shap"].abs() return prob, shap_df.sort_values("abs_shap", ascending=False).reset_index(drop=True) def gauge_chart(prob): colour = C_RED if prob >= 0.65 else (C_AMBER if prob >= 0.39 else C_GREEN) fig = go.Figure(go.Indicator( mode = "gauge+number+delta", value = round(prob * 100, 1), number= {"suffix": "%", "font": {"size": 42, "color": C_NAVY, "family": "Inter"}}, delta = {"reference": 50, "valueformat": ".1f", "increasing": {"color": C_RED}, "decreasing": {"color": C_GREEN}}, gauge = { "axis": {"range": [0, 100], "tickwidth": 1, "tickcolor": C_GRAY, "tickfont": {"size": 10}}, "bar": {"color": colour, "thickness": 0.28}, "bgcolor": "white", "borderwidth": 0, "steps": [ {"range": [0, 39], "color": "#D1FAE5"}, {"range": [39, 65], "color": "#FEF3C7"}, {"range": [65, 100],"color": "#FEE2E2"}, ], "threshold": { "line": {"color": C_NAVY, "width": 3}, "thickness": 0.75, "value": 65, }, }, title = {"text": "Default Probability", "font": {"size": 13, "color": C_GRAY, "family": "Inter"}}, )) fig.update_layout( height=280, margin=dict(t=40, b=10, l=30, r=30), paper_bgcolor="white", font_family="Inter", ) return fig def waterfall_chart(shap_df, prob, top_n=10): top = shap_df.head(top_n).sort_values("shap") colors = [C_RED if s > 0 else C_GREEN for s in top["shap"]] labels = [f"{r['feature']} [{r['value']:.3g}]" for _, r in top.iterrows()] fig = go.Figure(go.Bar( x = top["shap"].tolist(), y = labels, orientation = "h", marker_color= colors, text = [f"{v:+.4f}" for v in top["shap"]], textposition= "outside", textfont = {"size": 10, "family": "Inter"}, )) fig.update_layout( title = dict(text=f"Why this score? — Top {top_n} driving factors", font=dict(size=13, color=C_NAVY, family="Inter")), xaxis_title = "SHAP Impact on Default Probability", yaxis = dict(tickfont=dict(size=9, family="Inter")), height = 420, margin = dict(t=50, b=40, l=10, r=80), paper_bgcolor = "white", plot_bgcolor = "#FAFBFF", xaxis = dict(zeroline=True, zerolinecolor=C_NAVY, zerolinewidth=1.5, gridcolor="#E5EAF3"), showlegend = False, font_family = "Inter", ) return fig # ══════════════════════════════════════════════════════════════════════════════ # SIDEBAR # ══════════════════════════════════════════════════════════════════════════════ def render_sidebar(metrics, threshold): with st.sidebar: # Logo st.markdown("""
""", unsafe_allow_html=True) st.markdown("""
AI Lending Intelligence
Loan default scoring with explainable AI — built for Finexcore's lending and collections suite.
""", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) # Live model stats st.markdown("""
AI Engine Stats
""", unsafe_allow_html=True) col1, col2 = st.columns(2) col1.metric("ROC-AUC", f"{metrics['oof_roc_auc']:.4f}") col2.metric("CV Mean", f"{metrics['cv_mean_auc']:.4f}") col1.metric("Avg Prec", f"{metrics['avg_precision']:.4f}") col2.metric("Threshold",f"{threshold:.2f}") st.markdown("
", unsafe_allow_html=True) st.markdown("""
Navigate
""", unsafe_allow_html=True) page = st.radio("", [ "🔍 Applicant Scoring", "📋 Portfolio Batch", "📊 AI Engine", ], label_visibility="collapsed") st.markdown("
", unsafe_allow_html=True) st.markdown("""
Finexcore AI Lending Intelligence
Powered by LightGBM + SHAP
""", unsafe_allow_html=True) return page # ══════════════════════════════════════════════════════════════════════════════ # PAGE 1 — APPLICANT SCORING # ══════════════════════════════════════════════════════════════════════════════ def page_single(feature_cols, threshold, feat_desc, pipeline_meta): st.markdown("""

🔍 Applicant Risk Scoring

Enter applicant details to receive an instant AI-generated default probability with full explainability

""", unsafe_allow_html=True) left, right = st.columns([1, 1.1], gap="large") # ── LEFT — input form ──────────────────────────────────────────────── with left: tab1, tab2, tab3 = st.tabs(["👤 Personal", "💰 Financial", "📜 Credit History"]) with tab1: st.markdown('
Personal Details
', unsafe_allow_html=True) age = st.slider("Age (years)", 20, 70, 35) gender = st.selectbox("Gender", ["Male", "Female"]) children = st.number_input("Number of children", 0, 10, 0) fam_members = st.number_input("Family members", 1, 15, 2) c1, c2 = st.columns(2) own_car = c1.selectbox("Owns car", ["No", "Yes"]) own_realty = c2.selectbox("Owns realty", ["No", "Yes"]) with tab2: st.markdown('
Financial Profile
', unsafe_allow_html=True) income = st.number_input("Annual income (₹)", 10_000, 10_000_000, 200_000, step=10_000, format="%d") credit = st.number_input("Loan amount (₹)", 10_000, 10_000_000, 500_000, step=10_000, format="%d") annuity = st.number_input("Monthly annuity (₹)", 1_000, 500_000, 25_000, step=1_000, format="%d") goods_price = st.number_input("Goods price (₹)", 0, 10_000_000, 450_000, step=10_000, format="%d") c1, c2 = st.columns(2) employed_yrs = c1.slider("Years employed", 0, 40, 5) is_unemployed = c2.checkbox("Currently unemployed", value=False) with tab3: st.markdown('
Credit Bureau & History
', unsafe_allow_html=True) st.caption("External credit scores — scale 0.0 (poor) to 1.0 (excellent)") c1, c2, c3 = st.columns(3) ext1 = c1.slider("Score 1", 0.0, 1.0, 0.50, 0.01) ext2 = c2.slider("Score 2", 0.0, 1.0, 0.50, 0.01) ext3 = c3.slider("Score 3", 0.0, 1.0, 0.50, 0.01) st.divider() ins_late_ratio = st.slider( "Fraction of installments paid late", 0.0, 1.0, 0.05, 0.01) ins_days_late = st.slider( "Maximum days late on any installment", 0, 365, 5) c1, c2 = st.columns(2) prev_approved = c1.slider("Previous approval rate", 0.0, 1.0, 0.7, 0.01) prev_refused = c2.number_input("Previous refusals", 0, 20, 0) st.markdown("
", unsafe_allow_html=True) run_btn = st.button("⚡ Run Risk Assessment", type="primary", use_container_width=True) # ── RIGHT — results ────────────────────────────────────────────────── with right: if not run_btn: st.markdown(f"""
🏦
Ready to Score
Fill in the applicant details on the left
and click Run Risk Assessment
""", unsafe_allow_html=True) return # Build input dict input_dict = { "AGE_YEARS": float(age), "DAYS_BIRTH": float(age * 365), "CODE_GENDER": 1.0 if gender == "Female" else 0.0, "CNT_CHILDREN": float(children), "CNT_FAM_MEMBERS": float(fam_members), "FLAG_OWN_CAR": 1.0 if own_car == "Yes" else 0.0, "FLAG_OWN_REALTY": 1.0 if own_realty == "Yes" else 0.0, "AMT_INCOME_TOTAL": float(income), "AMT_CREDIT": float(credit), "AMT_ANNUITY": float(annuity), "AMT_GOODS_PRICE": float(goods_price), "EMPLOYED_YEARS": 0.0 if is_unemployed else float(employed_yrs), "DAYS_EMPLOYED": 0.0 if is_unemployed else float(employed_yrs * 365), "IS_UNEMPLOYED": 1.0 if is_unemployed else 0.0, "CREDIT_INCOME_RATIO": credit / max(income, 1), "ANNUITY_INCOME_RATIO": annuity / max(income, 1), "CREDIT_TERM": annuity / max(credit, 1), "GOODS_CREDIT_RATIO": goods_price / max(credit, 1), "INCOME_PER_PERSON": income / max(fam_members, 1), "EMPLOYED_TO_AGE_RATIO": 0.0 if is_unemployed else employed_yrs / max(age, 1), "EXT_SOURCE_1": ext1, "EXT_SOURCE_2": ext2, "EXT_SOURCE_3": ext3, "EXT_SOURCE_MEAN": (ext1 + ext2 + ext3) / 3, "EXT_SOURCE_STD": float(np.std([ext1, ext2, ext3])), "EXT_SOURCE_PRODUCT": ext1 * ext2 * ext3, "EXT_SOURCE_MIN": min(ext1, ext2, ext3), "INS_PAID_LATE_RATIO": ins_late_ratio, "INS_DAYS_LATE_MAX": float(ins_days_late), "INS_DAYS_LATE_MEAN": float(ins_days_late * 0.3), "PREV_APPROVED_RATIO": prev_approved, "PREV_REFUSED_COUNT": float(prev_refused), } with st.spinner("Scoring applicant..."): prob, shap_df = predict_single(input_dict, feature_cols, pipeline_meta) tier, colour, emoji, decision = risk_tier(prob, threshold) score_equiv = max(300, min(850, int(850 - prob * 600))) # Gauge st.plotly_chart(gauge_chart(prob), use_container_width=True, config={"displayModeBar": False}) # Risk result banner dec_color = C_GREEN if decision == "APPROVE" else ( C_AMBER if decision == "REVIEW" else C_RED) st.markdown(f"""
{emoji} {tier} RISK {prob:.1%} default probability
{decision}
Threshold: {threshold:.0%} Equiv. Credit Score: {score_equiv} Margin: {abs(prob - threshold):.1%}
""", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) # SHAP waterfall st.plotly_chart(waterfall_chart(shap_df, prob), use_container_width=True, config={"displayModeBar": False}) # Factor table st.markdown('
Factor Detail
', unsafe_allow_html=True) top10 = shap_df.head(10).copy() top10["Effect"] = top10["shap"].apply( lambda x: "↑ Increases risk" if x > 0 else "↓ Reduces risk") top10["Description"] = top10["feature"].map( lambda f: feat_desc.get(f, f.replace("_", " ").title())) top10["SHAP"] = top10["shap"].round(5) top10["Value"] = top10["value"].round(4) rows_html = "" for _, r in top10.iterrows(): clr = C_RED if r["SHAP"] > 0 else C_GREEN rows_html += f""" {r['feature']} {r['Description']} {r['Value']} {r['SHAP']:+.5f} {r['Effect']} """ st.markdown(f""" {rows_html}
FeatureWhat it means ValueSHAPEffect
""", unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # PAGE 2 — BATCH PORTFOLIO # ══════════════════════════════════════════════════════════════════════════════ def page_batch(feature_cols, threshold, pipeline_meta): st.markdown("""

📋 Portfolio Batch Scoring

Score an entire loan portfolio instantly — get risk tiers and approve/decline decisions for every applicant

""", unsafe_allow_html=True) st.markdown(f"""
How to use: Upload a CSV file matching the processed feature schema, or click the button below to demo with 500 real applicants from the Home Credit test set.
""", unsafe_allow_html=True) c1, c2 = st.columns([2, 1]) with c1: uploaded = st.file_uploader("Upload applicant CSV", type=["csv"], label_visibility="collapsed") with c2: demo_btn = st.button("📂 Load Demo Portfolio (500 applicants)", use_container_width=True) df_batch = None if uploaded: df_batch = pd.read_csv(uploaded) st.success(f"✅ Loaded {len(df_batch):,} applicants from uploaded file.") elif demo_btn: df_batch = pd.read_csv(PROCESSED_DIR / "test_processed.csv", nrows=500) st.success("✅ Loaded 500 applicants from demo portfolio.") if df_batch is None: return model = load_model() pmeta = load_pipeline_meta() obj_cols = df_batch.select_dtypes(include=["object","category"]).columns.tolist() df_feat = pd.DataFrame(index=df_batch.index) for feat in feature_cols: if feat in df_batch.columns and feat not in obj_cols: df_feat[feat] = df_batch[feat] else: df_feat[feat] = pmeta["medians"].get(feat, 0) for col, med in pmeta["medians"].items(): if col in df_feat.columns: df_feat[col] = df_feat[col].fillna(med) for col, (lo, hi) in pmeta["clip_bounds"].items(): if col in df_feat.columns: df_feat[col] = df_feat[col].clip(lower=lo, upper=hi) df_feat = df_feat.fillna(0) with st.spinner(f"Scoring {len(df_feat):,} applicants..."): probs = model.predict_proba( df_feat[feature_cols].values.astype(np.float32))[:, 1] if "SK_ID_CURR" not in df_batch.columns: df_batch.insert(0, "SK_ID_CURR", range(1, len(df_batch) + 1)) results = df_batch[["SK_ID_CURR"]].copy() results["DEFAULT_PROBABILITY"] = np.round(probs, 4) results["RISK_TIER"] = pd.cut( probs, bins=[0, threshold * 0.6, threshold, 1.0], labels=["LOW", "MEDIUM", "HIGH"], ).astype(str) results["DECISION"] = np.where(probs >= threshold, "DECLINE", "APPROVE") approve_n = (results["DECISION"] == "APPROVE").sum() decline_n = (results["DECISION"] == "DECLINE").sum() high_n = (results["RISK_TIER"] == "HIGH").sum() med_n = (results["RISK_TIER"] == "MEDIUM").sum() low_n = (results["RISK_TIER"] == "LOW").sum() # KPI row st.markdown("
", unsafe_allow_html=True) st.markdown('
Portfolio Summary
', unsafe_allow_html=True) c1, c2, c3, c4, c5 = st.columns(5) c1.markdown(kpi(f"{len(results):,}", "Total Applications"), unsafe_allow_html=True) c2.markdown(kpi(f"{approve_n:,}", "Approved", f"{approve_n/len(results):.1%}"), unsafe_allow_html=True) c3.markdown(kpi(f"{decline_n:,}", "Declined", f"{decline_n/len(results):.1%}"), unsafe_allow_html=True) c4.markdown(kpi(f"{probs.mean():.3f}", "Mean Probability"), unsafe_allow_html=True) c5.markdown(kpi(f"{high_n:,}", "High Risk"), unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) # Charts row ch1, ch2 = st.columns(2) with ch1: fig = go.Figure(go.Pie( labels = ["Approve", "Decline"], values = [approve_n, decline_n], marker_colors = [C_GREEN, C_RED], hole = 0.55, textinfo = "percent+label", textfont = {"size": 13, "family": "Inter"}, )) fig.update_layout( title=dict(text="Decision Split", font=dict(size=13, color=C_NAVY, family="Inter")), height=280, margin=dict(t=50, b=10, l=10, r=10), paper_bgcolor="white", showlegend=False, font_family="Inter", ) st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) with ch2: fig = go.Figure() for tier, clr, cnt in [("Low", C_GREEN, low_n), ("Medium", C_AMBER, med_n), ("High", C_RED, high_n)]: fig.add_trace(go.Bar( name=tier, x=[cnt], y=["Portfolio"], orientation="h", marker_color=clr, text=f"{tier}
{cnt:,}", textposition="inside", textfont={"size": 11, "family": "Inter", "color": "white"}, )) fig.update_layout( title=dict(text="Risk Tier Distribution", font=dict(size=13, color=C_NAVY, family="Inter")), barmode="stack", height=280, margin=dict(t=50, b=10, l=10, r=10), paper_bgcolor="white", xaxis=dict(showticklabels=False, showgrid=False), yaxis=dict(showticklabels=False), showlegend=True, legend=dict(orientation="h", y=-0.05), font_family="Inter", ) st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) # Results table st.markdown('
Scored Applicants
', unsafe_allow_html=True) tier_colors = {"HIGH": "#FEE2E2", "MEDIUM": "#FEF3C7", "LOW": "#D1FAE5"} dec_colors = {"DECLINE": C_RED, "APPROVE": C_GREEN} rows_html = "" for _, r in results.head(200).iterrows(): bg = tier_colors.get(str(r["RISK_TIER"]), C_WHITE) dc = dec_colors.get(str(r["DECISION"]), C_GRAY) bar_w = int(r["DEFAULT_PROBABILITY"] * 120) rows_html += f""" {r['SK_ID_CURR']}
{r['DEFAULT_PROBABILITY']:.4f}
{r['RISK_TIER']} {r['DECISION']} """ st.markdown(f""" {rows_html}
Applicant ID Default Probability Risk Tier Decision
""", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) csv_out = results[["SK_ID_CURR","DEFAULT_PROBABILITY", "RISK_TIER","DECISION"]].to_csv(index=False) st.download_button( "⬇️ Download Scored Portfolio CSV", data=csv_out, file_name="finexcore_scored_portfolio.csv", mime="text/csv", use_container_width=True, ) # ══════════════════════════════════════════════════════════════════════════════ # PAGE 3 — AI ENGINE # ══════════════════════════════════════════════════════════════════════════════ def page_overview(metrics, shap_top): st.markdown("""

📊 AI Engine

Model performance, validation methodology, and feature intelligence powering the platform

""", unsafe_allow_html=True) # Top KPIs st.markdown('
Performance Metrics
', unsafe_allow_html=True) c1, c2, c3, c4, c5 = st.columns(5) c1.markdown(kpi(metrics["oof_roc_auc"], "OOF ROC-AUC", "Out-of-fold"), unsafe_allow_html=True) c2.markdown(kpi(metrics["cv_mean_auc"], "CV Mean AUC", "5-fold mean"), unsafe_allow_html=True) c3.markdown(kpi(f"±{metrics['cv_std_auc']}", "CV Std", "Stability"), unsafe_allow_html=True) c4.markdown(kpi(metrics["avg_precision"], "Avg Precision", "PR-AUC"), unsafe_allow_html=True) c5.markdown(kpi(metrics["best_threshold"], "Threshold", "F1-tuned"), unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) # Fold AUC + Confusion Matrix left, right = st.columns(2, gap="large") with left: st.markdown('
Cross-Validation Fold AUCs
', unsafe_allow_html=True) fold_aucs = metrics["fold_aucs"] mean_auc = np.mean(fold_aucs) fig = go.Figure() fig.add_trace(go.Bar( x=[f"Fold {i+1}" for i in range(len(fold_aucs))], y=fold_aucs, marker_color=[C_BLUE] * len(fold_aucs), text=[f"{a:.5f}" for a in fold_aucs], textposition="outside", textfont={"size": 10, "family": "Inter"}, )) fig.add_hline(y=mean_auc, line_dash="dash", line_color=C_RED, annotation_text=f"Mean = {mean_auc:.5f}", annotation_font={"color": C_RED, "size": 11}) fig.update_layout( height=300, paper_bgcolor="white", plot_bgcolor="#FAFBFF", yaxis=dict(range=[min(fold_aucs)-0.006, max(fold_aucs)+0.008], gridcolor="#E5EAF3"), xaxis=dict(showgrid=False), margin=dict(t=20, b=30, l=40, r=20), font_family="Inter", showlegend=False, ) st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) with right: st.markdown('
Confusion Matrix (OOF)
', unsafe_allow_html=True) cm = metrics["confusion_matrix"] vals = [[cm["TN"], cm["FP"]], [cm["FN"], cm["TP"]]] fig = go.Figure(go.Heatmap( z=vals, x=["Predicted: No Default", "Predicted: Default"], y=["Actual: No Default", "Actual: Default"], colorscale=[[0, "#E8F0FB"], [1, C_NAVY]], showscale=False, text=[[f"{v:,}" for v in row] for row in vals], texttemplate="%{text}", textfont={"size": 16, "family": "Inter"}, )) fig.update_layout( height=300, margin=dict(t=20, b=60, l=130, r=20), paper_bgcolor="white", font_family="Inter", xaxis=dict(tickfont={"size": 10}), yaxis=dict(tickfont={"size": 10}), ) st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) st.markdown("
", unsafe_allow_html=True) # SHAP importance + images left2, right2 = st.columns(2, gap="large") with left2: st.markdown('
Top 15 SHAP Features
', unsafe_allow_html=True) top15 = shap_top.head(15) fig = go.Figure(go.Bar( x=top15["mean_abs_shap"][::-1].tolist(), y=top15["feature"][::-1].tolist(), orientation="h", marker_color=C_BLUE, text=[f"{v:.4f}" for v in top15["mean_abs_shap"][::-1]], textposition="outside", textfont={"size": 9, "family": "Inter"}, )) fig.update_layout( height=430, paper_bgcolor="white", plot_bgcolor="#FAFBFF", xaxis=dict(showgrid=True, gridcolor="#E5EAF3", title="Mean |SHAP Value|", titlefont={"size": 11}), yaxis=dict(tickfont={"size": 9}), margin=dict(t=10, b=40, l=10, r=80), font_family="Inter", showlegend=False, ) st.plotly_chart(fig, use_container_width=True, config={"displayModeBar": False}) with right2: st.markdown('
SHAP Beeswarm
', unsafe_allow_html=True) st.image(str(MODELS_DIR / "shap_summary.png"), width=520) st.markdown("
", unsafe_allow_html=True) st.markdown('
Credit Score Dependence — External Bureau
', unsafe_allow_html=True) st.image(str(MODELS_DIR / "shap_dependence_ext_source.png"), width=700) # Technical specs table st.markdown("
", unsafe_allow_html=True) st.markdown('
Technical Specification
', unsafe_allow_html=True) spec = [ ("Algorithm", "LightGBM — Gradient Boosted Decision Trees"), ("Validation", "5-Fold Stratified K-Fold Cross Validation"), ("Training Data", "307,511 real loan applicants (Home Credit Group)"), ("Features", "290 engineered features across 8 data sources"), ("Class Imbalance", "scale_pos_weight = 11.4 (11.4 non-defaults per default)"), ("Early Stopping", "100 rounds on validation AUC, max 5,000 trees"), ("Explainability", "SHAP TreeExplainer — exact Shapley values per prediction"), ("Threshold Tuning", "F1-maximised on out-of-fold predictions"), ("OOF ROC-AUC", f"{metrics['oof_roc_auc']} — top-tier for this dataset"), ] rows_html = "" for i, (k, v) in enumerate(spec): bg = C_LBLUE if i % 2 == 0 else C_WHITE rows_html += f""" {k} {v} """ st.markdown(f""" {rows_html}
ComponentDetails
""", unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # MAIN # ══════════════════════════════════════════════════════════════════════════════ def main(): feature_cols, threshold, metrics, feat_desc, shap_top = load_meta() pipeline_meta = load_pipeline_meta() page = render_sidebar(metrics, threshold) if page == "🔍 Applicant Scoring": page_single(feature_cols, threshold, feat_desc, pipeline_meta) elif page == "📋 Portfolio Batch": page_batch(feature_cols, threshold, pipeline_meta) elif page == "📊 AI Engine": page_overview(metrics, shap_top) if __name__ == "__main__": main()