""" SHAP contributions, folded into the five families the console already speaks. The console, the reason sentences and the Hindi PWA are all written against ``liquidity | behavioural | trendVolatility | obligationLoad | externalShock``. A model that explained itself in thirteen feature names would need every one of those surfaces rewritten, and would hand a field officer a vocabulary they have never used. So the SHAP values are aggregated to the families instead, and the feature that contributed most inside each family is named alongside it. The family map mirrors ``RuleBasedScoringStrategy`` exactly. Where the two disagree, the rule engine is right and this file is wrong — an officer must not see the same enterprise explained two different ways depending on which engine happened to score it. """ from __future__ import annotations import numpy as np import pandas as pd import shap LIQUIDITY = "liquidity" BEHAVIOURAL = "behavioural" TREND_VOLATILITY = "trendVolatility" OBLIGATION_LOAD = "obligationLoad" EXTERNAL_SHOCK = "externalShock" FAMILIES = [LIQUIDITY, BEHAVIOURAL, TREND_VOLATILITY, OBLIGATION_LOAD, EXTERNAL_SHOCK] FAMILY_OF: dict[str, str] = { # liquidity — is there cash in the business "cash_flow_change": LIQUIDITY, "inventory_days": LIQUIDITY, "upi_inflow_change": LIQUIDITY, # behavioural — how the borrower has been acting "emi_delay": BEHAVIOURAL, "days_since_last_entry": BEHAVIOURAL, "dpd_at_t": BEHAVIOURAL, # trend / volatility — how unstable the market around them is "price_z_score": TREND_VOLATILITY, # obligation load — how heavy the instalment is against what comes in "emi_to_inflow": OBLIGATION_LOAD, "emi_at_t": OBLIGATION_LOAD, "outstanding_at_t": OBLIGATION_LOAD, # external shock — what the weather and the mandi are doing to them "margin_squeeze": EXTERNAL_SHOCK, "feed_cost_change": EXTERNAL_SHOCK, "output_price_change": EXTERNAL_SHOCK, "rainfall_anomaly": EXTERNAL_SHOCK, "heat_anomaly": EXTERNAL_SHOCK, } class Explainer: """Wraps a TreeExplainer and does the family arithmetic.""" def __init__(self, model, features: list[str]): self.features = features self._shap = shap.TreeExplainer(model) def explain(self, row: pd.DataFrame) -> tuple[dict[str, int], list[dict]]: """ :return: (sub-scores 0–100 per family, drivers sorted by contribution) Only contributions that push the score **up** are reported as drivers. A negative SHAP value means "this is one of the reasons the score is not worse", which is true and useless to an officer deciding whether to visit. """ values = self._shap.shap_values(row[self.features]) if isinstance(values, list): # older binary API returns one array per class values = values[1] contributions = np.asarray(values).reshape(-1) by_family: dict[str, float] = {f: 0.0 for f in FAMILIES} best_feature: dict[str, tuple[str, float]] = {} for name, value in zip(self.features, contributions): base = name.removesuffix("__missing") family = FAMILY_OF.get(base) if family is None: continue by_family[family] += float(value) held = best_feature.get(family) if held is None or abs(value) > abs(held[1]): best_feature[family] = (base, float(value)) positive = {f: max(0.0, v) for f, v in by_family.items()} total = sum(positive.values()) # No family pushed the score up — a genuinely calm enterprise. Spread the # weight evenly rather than emitting five zeros, which the console would # render as an empty explanation for a score that still exists. if total <= 0: sub_scores = {f: 20 for f in FAMILIES} else: sub_scores = {f: int(round(100 * v / total)) for f, v in positive.items()} drivers = [] for family in FAMILIES: feature, raw = best_feature.get(family, (None, 0.0)) drivers.append({ "family": family, "weight": sub_scores[family], "featureCode": feature, "value": float(row[feature].iloc[0]) if feature in row and pd.notna(row[feature].iloc[0]) else None, "onPrior": bool(feature is None or (feature in row and pd.isna(row[feature].iloc[0]))), "shap": round(raw, 5), }) drivers.sort(key=lambda d: d["weight"], reverse=True) return sub_scores, drivers