""" LIME-style local surrogate explanation. Unlike shap_exact.py (which uses global knowledge of the model -- its coefficients), this implements the actual LIME idea from scratch: perturb the applicant's features randomly in a neighborhood around their real values, get the real model's prediction for each perturbed point, then fit a simple local linear regression to those (perturbation, prediction) pairs. The regression's coefficients are the "LIME weights" -- an explanation that never looked at the model's internals, only its input/output behavior. Running both this and the exact SHAP values and checking they agree (see app.py's "cross-check" panel) is a legitimate audit technique: two independently-derived explanations of the same decision converging is much stronger evidence than either alone -- and if they diverge, that's a signal worth investigating before trusting the explanation. """ from __future__ import annotations import random import numpy as np from sklearn.linear_model import LinearRegression from model import TrainedModel, FEATURES, FEATURE_LABELS, predict_proba def lime_explain(trained: TrainedModel, applicant: dict, n_samples: int = 400, seed: int = 0) -> dict: rng = np.random.default_rng(seed) x0 = np.array([applicant[f] for f in FEATURES], dtype=float) # feature-wise perturbation scale: 15% of the training population's std, # so the local neighborhood is meaningful relative to each feature's range stds = np.array([max(trained.feature_means[f] * 0.15, 0.5) for f in FEATURES]) samples = x0 + rng.normal(0, 1, size=(n_samples, len(FEATURES))) * stds samples = np.clip(samples, 0, None) preds = [] for row in samples: applicant_perturbed = dict(zip(FEATURES, row)) preds.append(predict_proba(trained, applicant_perturbed)) preds = np.array(preds) # weight samples by proximity to x0 (closer perturbations matter more -- # the defining idea of LIME's "local" fidelity) dists = np.linalg.norm((samples - x0) / stds, axis=1) weights = np.exp(-(dists ** 2) / 2) local_model = LinearRegression() local_model.fit(samples, preds, sample_weight=weights) lime_weights = dict(zip(FEATURES, local_model.coef_)) local_r2 = local_model.score(samples, preds, sample_weight=weights) return { "weights": lime_weights, "local_fidelity_r2": float(local_r2), "n_samples": n_samples, }