Spaces:
Sleeping
Sleeping
krishy commited on
Commit ·
2d723e9
1
Parent(s): fa08517
Refine scoring calibration, Kisan recommendations, and stress-test edge cases
Browse files- app.py +104 -32
- climaiq_engine.py +44 -19
- climaiq_gemma.py +11 -0
- climaiq_model.pkl +0 -0
- climaiq_scaler.pkl +0 -0
app.py
CHANGED
|
@@ -623,8 +623,8 @@ def build_farmer_input(prefix: str, compact: bool = False, label_lang: Optional[
|
|
| 623 |
prev_default = st.radio(
|
| 624 |
text["default_hist"], ["No", "Yes"], horizontal=True, key=f"{prefix}_default"
|
| 625 |
)
|
| 626 |
-
rain_def = st.slider(text["rain_def"], 0, 60,
|
| 627 |
-
spi = st.slider(text["spi"], -3.0, 2.0,
|
| 628 |
drought = st.selectbox(
|
| 629 |
text["drought_years"],
|
| 630 |
DROUGHT_OPTIONS,
|
|
@@ -643,8 +643,8 @@ def build_farmer_input(prefix: str, compact: bool = False, label_lang: Optional[
|
|
| 643 |
)
|
| 644 |
st.markdown("---")
|
| 645 |
st.markdown(f"<div class='section-label'>{html.escape(text['section_climate'])}</div>", unsafe_allow_html=True)
|
| 646 |
-
rain_def = st.slider(text["rain_def"], 0, 60,
|
| 647 |
-
spi = st.slider(text["spi"], -3.0, 2.0,
|
| 648 |
spi_label_idx = 0 if spi <= -1.5 else 1 if spi <= -0.5 else 2
|
| 649 |
st.caption(SPI_LABELS[lang][spi_label_idx])
|
| 650 |
drought = st.selectbox(
|
|
@@ -708,19 +708,64 @@ def build_score_gauge(score: int, risk_band: str):
|
|
| 708 |
return plotly_theme(fig)
|
| 709 |
|
| 710 |
|
| 711 |
-
def extract_action_cards(text: str, language: str) -> List[str]:
|
| 712 |
fallback = (
|
| 713 |
-
["🌱
|
| 714 |
if language == "Hindi"
|
| 715 |
-
else ["🌱
|
| 716 |
)
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 724 |
|
| 725 |
|
| 726 |
def try_farmer_explanation(client, farmer_input, result, language: str) -> str:
|
|
@@ -821,7 +866,7 @@ def tab_kisan(client):
|
|
| 821 |
stored.setdefault("explanation_sources", {})[lang] = get_inference_config()[0]
|
| 822 |
explanation = stored["explanations"][lang]
|
| 823 |
expl_mode = stored.get("explanation_sources", {}).get(lang, get_inference_config()[0])
|
| 824 |
-
cards = extract_action_cards(explanation, lang)
|
| 825 |
|
| 826 |
st.plotly_chart(build_score_gauge(result["credit_score"], result["risk_band"]), use_container_width=True)
|
| 827 |
st.markdown(
|
|
@@ -962,6 +1007,11 @@ def tab_officer(client):
|
|
| 962 |
|
| 963 |
|
| 964 |
def build_portfolio(n_loans: int, avg_loan: float, dominant_crop: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 965 |
crops = list(CROP_WATER_MAP.keys())
|
| 966 |
probs_map = {
|
| 967 |
"Cotton": [0.5 if c == "Cotton" else 0.5 / (len(crops) - 1) for c in crops],
|
|
@@ -972,16 +1022,16 @@ def build_portfolio(n_loans: int, avg_loan: float, dominant_crop: str):
|
|
| 972 |
crop_probs = probs_map[dominant_crop]
|
| 973 |
portfolio = []
|
| 974 |
for _ in range(n_loans):
|
| 975 |
-
crop =
|
| 976 |
portfolio.append(
|
| 977 |
{
|
| 978 |
-
"age": int(
|
| 979 |
-
"land_size_acres": round(
|
| 980 |
-
"annual_income_lakhs": round(
|
| 981 |
-
"loan_amount_lakhs": round(max(0.5,
|
| 982 |
-
"previous_defaults": int(
|
| 983 |
"crop_type": crop,
|
| 984 |
-
"state": str(
|
| 985 |
"rainfall_deficit_pct": 0.0,
|
| 986 |
"spi": 0.0,
|
| 987 |
"consecutive_drought_years": 0,
|
|
@@ -1014,17 +1064,39 @@ def tab_portfolio(client):
|
|
| 1014 |
if run:
|
| 1015 |
with st.spinner("Simulating portfolio across climate scenarios…"):
|
| 1016 |
portfolio = build_portfolio(int(n_loans), float(avg_loan), dominant_crop)
|
| 1017 |
-
|
| 1018 |
-
|
| 1019 |
-
|
| 1020 |
-
|
| 1021 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1022 |
st.success("Stress test complete. Charts are below.")
|
| 1023 |
|
| 1024 |
if not st.session_state.last_stress_result:
|
| 1025 |
st.markdown(narr_html(text["empty_portfolio"]), unsafe_allow_html=True)
|
| 1026 |
return
|
| 1027 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1028 |
stress = st.session_state.last_stress_result["stress"]
|
| 1029 |
df = pd.DataFrame(stress)
|
| 1030 |
order = ["Normal Monsoon", "Moderate Drought", "Severe Drought", "Back-to-Back Drought"]
|
|
@@ -1049,6 +1121,7 @@ def tab_portfolio(client):
|
|
| 1049 |
"Back-to-Back Drought": "#7F0000",
|
| 1050 |
},
|
| 1051 |
)
|
|
|
|
| 1052 |
bar.update_layout(showlegend=False, yaxis_title="", xaxis_title="Estimated loss (₹ lakhs)", height=330)
|
| 1053 |
st.plotly_chart(plotly_theme(bar), use_container_width=True)
|
| 1054 |
|
|
@@ -1098,13 +1171,12 @@ def tab_portfolio(client):
|
|
| 1098 |
|
| 1099 |
normal_loss = float(df[df["scenario"] == "Normal Monsoon"]["total_loss_lakhs"].iloc[0])
|
| 1100 |
worst_loss = float(df["total_loss_lakhs"].max())
|
| 1101 |
-
gap = round(worst_loss - normal_loss, 2)
|
| 1102 |
-
buf = int(gap // 100 * 100)
|
| 1103 |
cap_lines = (
|
| 1104 |
f"<b>Capital buffer indicator</b><br><br>"
|
| 1105 |
-
f"Baseline (normal monsoon) loss estimate: ₹{normal_loss} lakhs<br>"
|
| 1106 |
-
f"
|
| 1107 |
-
f"
|
| 1108 |
)
|
| 1109 |
st.markdown(f"<div class='soft-note narr-body'>{cap_lines}</div>", unsafe_allow_html=True)
|
| 1110 |
|
|
|
|
| 623 |
prev_default = st.radio(
|
| 624 |
text["default_hist"], ["No", "Yes"], horizontal=True, key=f"{prefix}_default"
|
| 625 |
)
|
| 626 |
+
rain_def = st.slider(text["rain_def"], 0, 60, 0, 1, key=f"{prefix}_rain")
|
| 627 |
+
spi = st.slider(text["spi"], -3.0, 2.0, 0.3, 0.1, key=f"{prefix}_spi")
|
| 628 |
drought = st.selectbox(
|
| 629 |
text["drought_years"],
|
| 630 |
DROUGHT_OPTIONS,
|
|
|
|
| 643 |
)
|
| 644 |
st.markdown("---")
|
| 645 |
st.markdown(f"<div class='section-label'>{html.escape(text['section_climate'])}</div>", unsafe_allow_html=True)
|
| 646 |
+
rain_def = st.slider(text["rain_def"], 0, 60, 0, 1, key=f"{prefix}_rain")
|
| 647 |
+
spi = st.slider(text["spi"], -3.0, 2.0, 0.3, 0.1, key=f"{prefix}_spi")
|
| 648 |
spi_label_idx = 0 if spi <= -1.5 else 1 if spi <= -0.5 else 2
|
| 649 |
st.caption(SPI_LABELS[lang][spi_label_idx])
|
| 650 |
drought = st.selectbox(
|
|
|
|
| 708 |
return plotly_theme(fig)
|
| 709 |
|
| 710 |
|
| 711 |
+
def extract_action_cards(text: str, language: str, farmer_input: Dict, result: Dict) -> List[str]:
|
| 712 |
fallback = (
|
| 713 |
+
["🌱 फसल विविधीकरण करें", "💧 सिंचाई दक्षता बढ़ाएँ", "🛡️ फसल बीमा लें"]
|
| 714 |
if language == "Hindi"
|
| 715 |
+
else ["🌱 Diversify crop mix", "💧 Improve irrigation efficiency", "🛡️ Take crop insurance"]
|
| 716 |
)
|
| 717 |
+
|
| 718 |
+
def _uniq(items: List[str]) -> List[str]:
|
| 719 |
+
out: List[str] = []
|
| 720 |
+
for it in items:
|
| 721 |
+
clean = it.strip()
|
| 722 |
+
if clean and clean not in out:
|
| 723 |
+
out.append(clean)
|
| 724 |
+
return out
|
| 725 |
+
|
| 726 |
+
picked_from_text: List[str] = []
|
| 727 |
+
if text:
|
| 728 |
+
lines = [ln.strip(" -•\t") for ln in text.splitlines() if ln.strip()]
|
| 729 |
+
picked_from_text = [
|
| 730 |
+
ln
|
| 731 |
+
for ln in lines
|
| 732 |
+
if any(k in ln.lower() for k in ["insurance", "crop", "irrig", "बीमा", "फसल", "सिंच"])
|
| 733 |
+
][:3]
|
| 734 |
+
|
| 735 |
+
rf = float(farmer_input.get("rainfall_deficit_pct", 0.0))
|
| 736 |
+
spi = float(farmer_input.get("spi", 0.0))
|
| 737 |
+
dyears = int(farmer_input.get("consecutive_drought_years", 0))
|
| 738 |
+
prev = int(farmer_input.get("previous_defaults", 0))
|
| 739 |
+
dti = float(farmer_input.get("loan_amount_lakhs", 0.0)) / max(float(farmer_input.get("annual_income_lakhs", 1.0)), 0.1)
|
| 740 |
+
crop = str(farmer_input.get("crop_type", ""))
|
| 741 |
+
risk_band = str(result.get("risk_band", ""))
|
| 742 |
+
|
| 743 |
+
dynamic: List[str] = []
|
| 744 |
+
if language == "Hindi":
|
| 745 |
+
if rf <= -20 or spi <= -1.2 or dyears >= 1:
|
| 746 |
+
dynamic.append("💧 तुरंत जल-संरक्षण योजना अपनाएँ (ड्रिप/मल्च/लाइन सिंचाई)")
|
| 747 |
+
if crop in {"Rice", "Sugarcane"} and rf <= -15:
|
| 748 |
+
dynamic.append("🌱 कम पानी वाली फसल/मिश्रित खेती पर विचार करें")
|
| 749 |
+
if dti > 0.5:
|
| 750 |
+
dynamic.append("📉 ऋण राशि या किश्त संरचना को आय के अनुसार पुनर्संतुलित करें")
|
| 751 |
+
if prev == 1:
|
| 752 |
+
dynamic.append("🧾 पिछले बकाये के लिए समयबद्ध चुकौती योजना बनाएं")
|
| 753 |
+
if "High" in risk_band or "Medium" in risk_band:
|
| 754 |
+
dynamic.append("🛡️ मौसम-आधारित फसल बीमा और नुकसान कवर सक्रिय करें")
|
| 755 |
+
else:
|
| 756 |
+
if rf <= -20 or spi <= -1.2 or dyears >= 1:
|
| 757 |
+
dynamic.append("💧 Start an immediate water-conservation plan (drip/mulch/scheduling)")
|
| 758 |
+
if crop in {"Rice", "Sugarcane"} and rf <= -15:
|
| 759 |
+
dynamic.append("🌱 Consider a lower water-intensity crop mix for the next cycle")
|
| 760 |
+
if dti > 0.5:
|
| 761 |
+
dynamic.append("📉 Rebalance loan size/instalments to better match farm income")
|
| 762 |
+
if prev == 1:
|
| 763 |
+
dynamic.append("🧾 Create a time-bound plan to clear past overdues")
|
| 764 |
+
if "High" in risk_band or "Medium" in risk_band:
|
| 765 |
+
dynamic.append("🛡️ Activate weather-index crop insurance and loss cover")
|
| 766 |
+
|
| 767 |
+
cards = _uniq(picked_from_text + dynamic + fallback)
|
| 768 |
+
return cards[:3]
|
| 769 |
|
| 770 |
|
| 771 |
def try_farmer_explanation(client, farmer_input, result, language: str) -> str:
|
|
|
|
| 866 |
stored.setdefault("explanation_sources", {})[lang] = get_inference_config()[0]
|
| 867 |
explanation = stored["explanations"][lang]
|
| 868 |
expl_mode = stored.get("explanation_sources", {}).get(lang, get_inference_config()[0])
|
| 869 |
+
cards = extract_action_cards(explanation, lang, stored["input"], result)
|
| 870 |
|
| 871 |
st.plotly_chart(build_score_gauge(result["credit_score"], result["risk_band"]), use_container_width=True)
|
| 872 |
st.markdown(
|
|
|
|
| 1007 |
|
| 1008 |
|
| 1009 |
def build_portfolio(n_loans: int, avg_loan: float, dominant_crop: str):
|
| 1010 |
+
"""
|
| 1011 |
+
Synthetic portfolio draws. Uses an isolated RNG so each Run produces a fresh book even if
|
| 1012 |
+
global np.random was seeded elsewhere (e.g. training helpers).
|
| 1013 |
+
"""
|
| 1014 |
+
rng = np.random.default_rng()
|
| 1015 |
crops = list(CROP_WATER_MAP.keys())
|
| 1016 |
probs_map = {
|
| 1017 |
"Cotton": [0.5 if c == "Cotton" else 0.5 / (len(crops) - 1) for c in crops],
|
|
|
|
| 1022 |
crop_probs = probs_map[dominant_crop]
|
| 1023 |
portfolio = []
|
| 1024 |
for _ in range(n_loans):
|
| 1025 |
+
crop = str(rng.choice(crops, p=crop_probs))
|
| 1026 |
portfolio.append(
|
| 1027 |
{
|
| 1028 |
+
"age": int(rng.integers(28, 62)),
|
| 1029 |
+
"land_size_acres": round(float(rng.uniform(1, 10)), 2),
|
| 1030 |
+
"annual_income_lakhs": round(float(rng.uniform(1.5, 10)), 2),
|
| 1031 |
+
"loan_amount_lakhs": round(max(0.5, float(rng.normal(avg_loan, 0.6))), 2),
|
| 1032 |
+
"previous_defaults": int(rng.choice([0, 1], p=[0.82, 0.18])),
|
| 1033 |
"crop_type": crop,
|
| 1034 |
+
"state": str(rng.choice(["Maharashtra", "Punjab"], p=[0.6, 0.4])),
|
| 1035 |
"rainfall_deficit_pct": 0.0,
|
| 1036 |
"spi": 0.0,
|
| 1037 |
"consecutive_drought_years": 0,
|
|
|
|
| 1064 |
if run:
|
| 1065 |
with st.spinner("Simulating portfolio across climate scenarios…"):
|
| 1066 |
portfolio = build_portfolio(int(n_loans), float(avg_loan), dominant_crop)
|
| 1067 |
+
rr = recovery_rate / 100
|
| 1068 |
+
stress = run_stress_test(
|
| 1069 |
+
st.session_state.model,
|
| 1070 |
+
st.session_state.scaler,
|
| 1071 |
+
portfolio,
|
| 1072 |
+
avg_loan_lakhs=float(avg_loan),
|
| 1073 |
+
recovery_rate=float(rr),
|
| 1074 |
+
)
|
| 1075 |
+
st.session_state.last_stress_result = {
|
| 1076 |
+
"stress": stress,
|
| 1077 |
+
"narratives": {},
|
| 1078 |
+
"narrative_sources": {},
|
| 1079 |
+
"portfolio_meta": {
|
| 1080 |
+
"n_loans": int(n_loans),
|
| 1081 |
+
"avg_loan": float(avg_loan),
|
| 1082 |
+
"recovery_pct": int(recovery_rate),
|
| 1083 |
+
"dominant_crop": dominant_crop,
|
| 1084 |
+
},
|
| 1085 |
+
}
|
| 1086 |
st.success("Stress test complete. Charts are below.")
|
| 1087 |
|
| 1088 |
if not st.session_state.last_stress_result:
|
| 1089 |
st.markdown(narr_html(text["empty_portfolio"]), unsafe_allow_html=True)
|
| 1090 |
return
|
| 1091 |
|
| 1092 |
+
meta = st.session_state.last_stress_result.get("portfolio_meta")
|
| 1093 |
+
if meta:
|
| 1094 |
+
st.caption(
|
| 1095 |
+
f"Charts reflect your last Run: {meta['n_loans']} loans, avg ticket ₹{meta['avg_loan']} lakhs, "
|
| 1096 |
+
f"{meta['recovery_pct']}% recovery, dominant crop mix {meta['dominant_crop']}. "
|
| 1097 |
+
"Change inputs and tap Run stress test again for a new simulated book."
|
| 1098 |
+
)
|
| 1099 |
+
|
| 1100 |
stress = st.session_state.last_stress_result["stress"]
|
| 1101 |
df = pd.DataFrame(stress)
|
| 1102 |
order = ["Normal Monsoon", "Moderate Drought", "Severe Drought", "Back-to-Back Drought"]
|
|
|
|
| 1121 |
"Back-to-Back Drought": "#7F0000",
|
| 1122 |
},
|
| 1123 |
)
|
| 1124 |
+
bar.update_traces(texttemplate="%{x:.2f}", textposition="outside", cliponaxis=False)
|
| 1125 |
bar.update_layout(showlegend=False, yaxis_title="", xaxis_title="Estimated loss (₹ lakhs)", height=330)
|
| 1126 |
st.plotly_chart(plotly_theme(bar), use_container_width=True)
|
| 1127 |
|
|
|
|
| 1171 |
|
| 1172 |
normal_loss = float(df[df["scenario"] == "Normal Monsoon"]["total_loss_lakhs"].iloc[0])
|
| 1173 |
worst_loss = float(df["total_loss_lakhs"].max())
|
| 1174 |
+
gap = round(max(worst_loss - normal_loss, 0), 2)
|
|
|
|
| 1175 |
cap_lines = (
|
| 1176 |
f"<b>Capital buffer indicator</b><br><br>"
|
| 1177 |
+
f"Baseline (normal monsoon) loss estimate: ₹{round(normal_loss, 2)} lakhs<br>"
|
| 1178 |
+
f"Worst scenario loss in this run: ₹{round(worst_loss, 2)} lakhs<br>"
|
| 1179 |
+
f"<b>Incremental climate tail</b> (worst minus baseline): ₹{gap} lakhs — use this gap when discussing extra provisioning vs normal weather."
|
| 1180 |
)
|
| 1181 |
st.markdown(f"<div class='soft-note narr-body'>{cap_lines}</div>", unsafe_allow_html=True)
|
| 1182 |
|
climaiq_engine.py
CHANGED
|
@@ -188,24 +188,38 @@ def engineer_single_input(data: dict) -> pd.DataFrame:
|
|
| 188 |
|
| 189 |
# ─── Credit Score Calculation ──────────────────────────────────────────────────
|
| 190 |
|
| 191 |
-
def compute_credit_score(
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
return int(np.clip(score, 300, 850))
|
| 201 |
|
| 202 |
|
| 203 |
def get_risk_band(score: int) -> str:
|
| 204 |
if score >= 700:
|
| 205 |
return "Very Low Risk"
|
| 206 |
-
elif score >=
|
| 207 |
return "Low Risk"
|
| 208 |
-
elif score >=
|
| 209 |
return "Medium Risk"
|
| 210 |
else:
|
| 211 |
return "High Risk"
|
|
@@ -286,20 +300,31 @@ def predict_single(data: dict, model, scaler) -> dict:
|
|
| 286 |
|
| 287 |
# ─── Stress Test ───────────────────────────────────────────────────────────────
|
| 288 |
|
| 289 |
-
def run_stress_test(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
"""
|
| 291 |
Run 4 climate scenarios across a list of farmer input dicts.
|
| 292 |
-
|
| 293 |
"""
|
| 294 |
scenarios = {
|
| 295 |
-
"Normal Monsoon":
|
| 296 |
-
"Moderate Drought":
|
| 297 |
-
"Severe Drought":
|
| 298 |
"Back-to-Back Drought": {"rainfall_deficit_pct": -25, "spi": -1.5, "consecutive_drought_years": 2},
|
| 299 |
}
|
| 300 |
|
| 301 |
-
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
results = []
|
| 304 |
|
| 305 |
for scenario_name, overrides in scenarios.items():
|
|
@@ -310,13 +335,13 @@ def run_stress_test(model, scaler, base_portfolio: list) -> list:
|
|
| 310 |
probs.append(result["default_probability"] / 100)
|
| 311 |
|
| 312 |
avg_default_pct = np.mean(probs) * 100
|
| 313 |
-
total_loss = np.mean(probs) *
|
| 314 |
|
| 315 |
results.append({
|
| 316 |
"scenario": scenario_name,
|
| 317 |
"avg_default_pct": round(avg_default_pct, 2),
|
| 318 |
"total_loss_lakhs": round(total_loss, 2),
|
| 319 |
-
"portfolio_size":
|
| 320 |
})
|
| 321 |
|
| 322 |
return results
|
|
|
|
| 188 |
|
| 189 |
# ─── Credit Score Calculation ──────────────────────────────────────────────────
|
| 190 |
|
| 191 |
+
def compute_credit_score(
|
| 192 |
+
model,
|
| 193 |
+
scaler,
|
| 194 |
+
feature_row: pd.DataFrame,
|
| 195 |
+
base_score: int = 650,
|
| 196 |
+
pdo: int = 50,
|
| 197 |
+
p_ref: float = 0.20,
|
| 198 |
+
) -> int:
|
| 199 |
+
"""
|
| 200 |
+
Convert model PD to score using a log-odds mapping.
|
| 201 |
|
| 202 |
+
Calibration anchor:
|
| 203 |
+
- score = base_score when PD = p_ref
|
| 204 |
+
- +pdo points for each halving of default odds
|
| 205 |
+
"""
|
| 206 |
+
scaled = scaler.transform(feature_row)
|
| 207 |
+
pd_raw = float(model.predict_proba(scaled)[0][1])
|
| 208 |
+
pd_safe = float(np.clip(pd_raw, 1e-4, 1 - 1e-4))
|
| 209 |
+
|
| 210 |
+
factor = pdo / np.log(2)
|
| 211 |
+
logit = np.log(pd_safe / (1 - pd_safe))
|
| 212 |
+
logit_ref = np.log(p_ref / (1 - p_ref))
|
| 213 |
+
score = base_score - factor * (logit - logit_ref)
|
| 214 |
return int(np.clip(score, 300, 850))
|
| 215 |
|
| 216 |
|
| 217 |
def get_risk_band(score: int) -> str:
|
| 218 |
if score >= 700:
|
| 219 |
return "Very Low Risk"
|
| 220 |
+
elif score >= 640:
|
| 221 |
return "Low Risk"
|
| 222 |
+
elif score >= 560:
|
| 223 |
return "Medium Risk"
|
| 224 |
else:
|
| 225 |
return "High Risk"
|
|
|
|
| 300 |
|
| 301 |
# ─── Stress Test ───────────────────────────────────────────────────────────────
|
| 302 |
|
| 303 |
+
def run_stress_test(
|
| 304 |
+
model,
|
| 305 |
+
scaler,
|
| 306 |
+
base_portfolio: list,
|
| 307 |
+
avg_loan_lakhs: float = 3.0,
|
| 308 |
+
recovery_rate: float = 0.30,
|
| 309 |
+
) -> list:
|
| 310 |
"""
|
| 311 |
Run 4 climate scenarios across a list of farmer input dicts.
|
| 312 |
+
Expected portfolio loss uses the supplied average ticket (₹ lakhs) and recovery_rate (0–1).
|
| 313 |
"""
|
| 314 |
scenarios = {
|
| 315 |
+
"Normal Monsoon": {"rainfall_deficit_pct": 0, "spi": 0, "consecutive_drought_years": 0},
|
| 316 |
+
"Moderate Drought": {"rainfall_deficit_pct": -20, "spi": -1.2, "consecutive_drought_years": 1},
|
| 317 |
+
"Severe Drought": {"rainfall_deficit_pct": -35, "spi": -1.8, "consecutive_drought_years": 1},
|
| 318 |
"Back-to-Back Drought": {"rainfall_deficit_pct": -25, "spi": -1.5, "consecutive_drought_years": 2},
|
| 319 |
}
|
| 320 |
|
| 321 |
+
n = len(base_portfolio)
|
| 322 |
+
rm = float(recovery_rate)
|
| 323 |
+
if rm < 0:
|
| 324 |
+
rm = 0.0
|
| 325 |
+
elif rm > 1:
|
| 326 |
+
rm = 1.0
|
| 327 |
+
|
| 328 |
results = []
|
| 329 |
|
| 330 |
for scenario_name, overrides in scenarios.items():
|
|
|
|
| 335 |
probs.append(result["default_probability"] / 100)
|
| 336 |
|
| 337 |
avg_default_pct = np.mean(probs) * 100
|
| 338 |
+
total_loss = np.mean(probs) * float(avg_loan_lakhs) * (1 - rm) * n
|
| 339 |
|
| 340 |
results.append({
|
| 341 |
"scenario": scenario_name,
|
| 342 |
"avg_default_pct": round(avg_default_pct, 2),
|
| 343 |
"total_loss_lakhs": round(total_loss, 2),
|
| 344 |
+
"portfolio_size": n,
|
| 345 |
})
|
| 346 |
|
| 347 |
return results
|
climaiq_gemma.py
CHANGED
|
@@ -31,6 +31,13 @@ GEMMA_MODEL = GEMMA_MODEL_CLOUD
|
|
| 31 |
|
| 32 |
DEFAULT_OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
| 33 |
DEFAULT_OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma3:4b")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
|
| 36 |
def _ollama_installed_name_matches(installed: str, requested: str) -> bool:
|
|
@@ -153,6 +160,8 @@ def generate_explanation_text(
|
|
| 153 |
|
| 154 |
def _build_farmer_prompt(farmer_data: dict, result: dict, language: str) -> str:
|
| 155 |
drivers = "\n".join([f"- {d['display_name']}" for d in result["top_risk_drivers"]])
|
|
|
|
|
|
|
| 156 |
|
| 157 |
if language == "Hindi":
|
| 158 |
return f"""
|
|
@@ -178,6 +187,7 @@ ClimaIQ परिणाम:
|
|
| 178 |
1. किसान को सरल भाषा में बताएं कि उनका स्कोर क्यों कम/अधिक है
|
| 179 |
2. मौसम और फसल का जोखिम पर क्या असर हुआ, यह समझाएं
|
| 180 |
3. किसान 3 व्यावहारिक कदम क्या उठा सकते हैं (बीमा, वैकल्पिक फसल, सिंचाई आदि)
|
|
|
|
| 181 |
केवल हिंदी में उत्तर दें। जटिल शब्दों से बचें।
|
| 182 |
"""
|
| 183 |
else:
|
|
@@ -204,6 +214,7 @@ In 4-5 simple sentences:
|
|
| 204 |
1. Explain why their score is what it is
|
| 205 |
2. Explain how the weather and crop type has affected their risk
|
| 206 |
3. Give 3 practical steps they can take (insurance, alternate crops, irrigation, etc.)
|
|
|
|
| 207 |
Use simple, non-technical language. Be empathetic and constructive.
|
| 208 |
"""
|
| 209 |
|
|
|
|
| 31 |
|
| 32 |
DEFAULT_OLLAMA_BASE = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434").rstrip("/")
|
| 33 |
DEFAULT_OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "gemma3:4b")
|
| 34 |
+
CROP_WATER_CLASS = {
|
| 35 |
+
"Rice": "very high",
|
| 36 |
+
"Sugarcane": "very high",
|
| 37 |
+
"Cotton": "moderate",
|
| 38 |
+
"Wheat": "low",
|
| 39 |
+
"Millets": "very low",
|
| 40 |
+
}
|
| 41 |
|
| 42 |
|
| 43 |
def _ollama_installed_name_matches(installed: str, requested: str) -> bool:
|
|
|
|
| 160 |
|
| 161 |
def _build_farmer_prompt(farmer_data: dict, result: dict, language: str) -> str:
|
| 162 |
drivers = "\n".join([f"- {d['display_name']}" for d in result["top_risk_drivers"]])
|
| 163 |
+
crop = str(farmer_data["crop_type"])
|
| 164 |
+
water_class = CROP_WATER_CLASS.get(crop, "moderate")
|
| 165 |
|
| 166 |
if language == "Hindi":
|
| 167 |
return f"""
|
|
|
|
| 187 |
1. किसान को सरल भाषा में बताएं कि उनका स्कोर क्यों कम/अधिक है
|
| 188 |
2. मौसम और फसल का जोखिम पर क्या असर हुआ, यह समझाएं
|
| 189 |
3. किसान 3 व्यावहारिक कदम क्या उठा सकते हैं (बीमा, वैकल्पिक फसल, सिंचाई आदि)
|
| 190 |
+
महत्वपूर्ण तथ्य: इस इंजन में {crop} की पानी-आधारित श्रेणी "{water_class}" मानी जाती है। पानी-आवश्यकता के बारे में इससे उल्टा दावा न करें।
|
| 191 |
केवल हिंदी में उत्तर दें। जटिल शब्दों से बचें।
|
| 192 |
"""
|
| 193 |
else:
|
|
|
|
| 214 |
1. Explain why their score is what it is
|
| 215 |
2. Explain how the weather and crop type has affected their risk
|
| 216 |
3. Give 3 practical steps they can take (insurance, alternate crops, irrigation, etc.)
|
| 217 |
+
Important fact guardrail: in this engine, {crop} is classified as "{water_class}" water-intensity. Do not claim the opposite.
|
| 218 |
Use simple, non-technical language. Be empathetic and constructive.
|
| 219 |
"""
|
| 220 |
|
climaiq_model.pkl
CHANGED
|
Binary files a/climaiq_model.pkl and b/climaiq_model.pkl differ
|
|
|
climaiq_scaler.pkl
CHANGED
|
Binary files a/climaiq_scaler.pkl and b/climaiq_scaler.pkl differ
|
|
|