Spaces:
Running on Zero
Running on Zero
| import gradio as gr | |
| import joblib | |
| import numpy as np | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| # Load model and scaler | |
| model = joblib.load('rsf_tuned.pkl') | |
| scaler = joblib.load('scaler.pkl') | |
| # --- ORDINAL MAPPINGS --- | |
| wealth_map = { | |
| "poorest": 0, "poorer": 1, "middle": 2, "richer": 3, "richest": 4 | |
| } | |
| mat_edu_map = { | |
| "no education": 0, "primary": 1, "secondary": 2, "higher": 3 | |
| } | |
| # --- FEATURE NAMES (same as training) --- | |
| numeric_cols = [ | |
| 'birth_order', 'mat_age', 'mat_height_cm', 'mat_weight_kg', | |
| 'wealth_ordinal', 'mat_edu_ordinal', 'multiple_birth_binary' | |
| ] | |
| categorical_cols = [ | |
| 'child_sex', 'religion', 'water_improved', 'sanitation_improved', | |
| 'partner_edu', 'region', 'residence' | |
| ] | |
| cat_feature_names = [ | |
| 'child_sex_female', 'child_sex_male', | |
| 'religion_catholic', 'religion_islam', 'religion_Not asked/Missing', | |
| 'religion_other', 'religion_other christian', 'religion_traditionalist', | |
| 'water_improved_Improved', 'water_improved_Not asked/Missing', 'water_improved_Unimproved', | |
| 'sanitation_improved_Improved', 'sanitation_improved_Not asked/Missing', 'sanitation_improved_Unimproved', | |
| 'partner_edu_don\'t know', 'partner_edu_higher', 'partner_edu_no education', | |
| 'partner_edu_Not asked/Missing', 'partner_edu_primary', 'partner_edu_secondary', | |
| 'region_North Central', 'region_North East', 'region_North West', | |
| 'region_South East', 'region_South South', 'region_South West', | |
| 'residence_rural', 'residence_urban' | |
| ] | |
| def create_inputs(): | |
| return [ | |
| gr.Number(label="Birth Order", value=1, minimum=1, maximum=20), | |
| gr.Number(label="Maternal Age (years)", value=25, minimum=12, maximum=49), | |
| gr.Number(label="Maternal Height (cm)", value=160, minimum=100, maximum=200), | |
| gr.Number(label="Maternal Weight (kg)", value=60, minimum=30, maximum=120), | |
| gr.Dropdown( | |
| choices=[("Poorest", "poorest"), ("Poorer", "poorer"), ("Middle", "middle"), | |
| ("Richer", "richer"), ("Richest", "richest")], | |
| label="Wealth Index", | |
| value="middle" | |
| ), | |
| gr.Dropdown( | |
| choices=[("No Education", "no education"), ("Primary", "primary"), | |
| ("Secondary", "secondary"), ("Higher", "higher")], | |
| label="Maternal Education", | |
| value="primary" | |
| ), | |
| gr.Dropdown(choices=[("No", 0), ("Yes", 1)], label="Multiple Birth", value=0), | |
| gr.Dropdown(choices=["male", "female"], label="Child Sex", value="male"), | |
| gr.Dropdown( | |
| choices=["islam", "catholic", "other christian", "traditionalist", "other"], | |
| label="Religion", | |
| value="islam" | |
| ), | |
| gr.Dropdown( | |
| choices=["Improved", "Unimproved"], | |
| label="Water Source", | |
| value="Improved" | |
| ), | |
| gr.Dropdown( | |
| choices=["Improved", "Unimproved"], | |
| label="Sanitation", | |
| value="Improved" | |
| ), | |
| gr.Dropdown( | |
| choices=["no education", "primary", "secondary", "higher"], | |
| label="Partner Education", | |
| value="primary" | |
| ), | |
| gr.Dropdown( | |
| choices=["North West", "North East", "North Central", "South West", "South East", "South South"], | |
| label="Region", | |
| value="North West" | |
| ), | |
| gr.Dropdown(choices=["rural", "urban"], label="Residence", value="rural") | |
| ] | |
| import spaces | |
| def predict_stunting(birth_order, mat_age, mat_height_cm, mat_weight_kg, | |
| wealth_label, mat_edu_label, multiple_birth_binary, | |
| child_sex, religion, water_improved, sanitation_improved, | |
| partner_edu, region, residence): | |
| print("🔍 Starting prediction...") | |
| wealth_ordinal = wealth_map[wealth_label] | |
| mat_edu_ordinal = mat_edu_map[mat_edu_label] | |
| # Build raw input (14 features) | |
| input_data = pd.DataFrame([[ | |
| birth_order, mat_age, mat_height_cm, mat_weight_kg, | |
| wealth_ordinal, mat_edu_ordinal, multiple_birth_binary, | |
| child_sex, religion, water_improved, sanitation_improved, | |
| partner_edu, region, residence | |
| ]], columns=numeric_cols + categorical_cols) | |
| print(f"📊 input_data shape: {input_data.shape}") | |
| # One-hot encoding | |
| one_hot_data = pd.DataFrame(0, index=input_data.index, columns=cat_feature_names) | |
| for col in categorical_cols: | |
| val = input_data[col].iloc[0] | |
| if pd.isna(val) or val in ["Not asked/Missing", "don't know"]: | |
| col_name = f"{col}_Not asked/Missing" | |
| else: | |
| col_name = f"{col}_{val}" | |
| if col_name in one_hot_data.columns: | |
| one_hot_data[col_name] = 1 | |
| # Combine → 35 features | |
| X_sksurv = pd.concat([input_data[numeric_cols], one_hot_data], axis=1) | |
| print(f"📊 X_sksurv shape: {X_sksurv.shape}") # Should be (1, 35) | |
| print(f"📊 Scaler expects: {scaler.n_features_in_} features") | |
| # The scaler was fit with specific feature names/order recorded in | |
| # scaler.feature_names_in_. Our one-hot + numeric concat above does not | |
| # guarantee that same order, and scaler.transform() raises a ValueError | |
| # if the column order doesn't match what was seen during fit. Reorder | |
| # to match before transforming. | |
| if hasattr(scaler, "feature_names_in_"): | |
| X_sksurv = X_sksurv[scaler.feature_names_in_] | |
| # Scale ALL 35 features | |
| X_scaled = pd.DataFrame( | |
| scaler.transform(X_sksurv), | |
| columns=X_sksurv.columns, | |
| index=X_sksurv.index | |
| ) | |
| # Ensure column order matches what the model was trained on, if available | |
| if hasattr(model, "feature_names_in_"): | |
| X_scaled = X_scaled[model.feature_names_in_] | |
| # Predict | |
| risk = model.predict(X_scaled)[0] | |
| print(f"✅ Risk: {risk:.4f}") | |
| # Survival function for this sample | |
| surv_funcs = model.predict_survival_function(X_scaled) | |
| fn = surv_funcs[0] | |
| # Requested time points (months) | |
| times = np.array([6, 12, 18, 24, 36, 48]) | |
| # The step function is only defined within the domain of the training | |
| # follow-up times. Querying outside that range raises a ValueError, so | |
| # clip the requested times into the valid domain before evaluating. | |
| domain_min, domain_max = fn.domain | |
| times_clipped = np.clip(times, domain_min, domain_max) | |
| surv_probs = fn(times_clipped) | |
| stunting_risks = 1 - surv_probs | |
| print(f"✅ Stunting risks: {stunting_risks}") | |
| # Plot (x-axis still shows the originally requested ages) | |
| fig, ax = plt.subplots(figsize=(8, 4)) | |
| ax.plot(times, stunting_risks, marker='o', linestyle='-', color='steelblue', linewidth=2, markersize=8) | |
| ax.axhline(y=0.20, color='red', linestyle='--', label='20% Risk Threshold') | |
| ax.set_xlabel('Age (months)') | |
| ax.set_ylabel('Stunting Risk') | |
| ax.set_title('Stunting Risk by Age') | |
| ax.set_ylim(0, 1) | |
| ax.grid(True, linestyle=':', alpha=0.7) | |
| ax.legend() | |
| ax.set_xticks(times) | |
| # --- Build a detailed, human-readable explanation --- | |
| RISK_THRESHOLD = 0.20 | |
| # Find first age (from requested times) where predicted stunting risk | |
| # crosses the 20% threshold, if it does within the given horizon. | |
| crossing_age = None | |
| for age, r in zip(times, stunting_risks): | |
| if r >= RISK_THRESHOLD: | |
| crossing_age = int(age) | |
| break | |
| if crossing_age is not None: | |
| threshold_text = ( | |
| f"Predicted stunting risk is estimated to reach the **{RISK_THRESHOLD:.0%} threshold " | |
| f"by around {crossing_age} months** of age." | |
| ) | |
| else: | |
| threshold_text = ( | |
| f"Predicted stunting risk **does not reach the {RISK_THRESHOLD:.0%} threshold** " | |
| f"within the 48-month horizon shown." | |
| ) | |
| # Risk at each timepoint, formatted as a simple bullet list | |
| age_risk_lines = "\n".join( | |
| f"- **{int(age)} months:** {r:.1%} estimated risk" | |
| for age, r in zip(times, stunting_risks) | |
| ) | |
| # Highlight the strongest known risk/protective factors present in this input | |
| contributing_factors = [] | |
| if wealth_ordinal <= 1: | |
| contributing_factors.append("lower household wealth quintile") | |
| if mat_edu_ordinal == 0: | |
| contributing_factors.append("mother has no formal education") | |
| if water_improved == "Unimproved": | |
| contributing_factors.append("unimproved water source") | |
| if sanitation_improved == "Unimproved": | |
| contributing_factors.append("unimproved sanitation") | |
| if multiple_birth_binary == 1: | |
| contributing_factors.append("multiple birth (twins/triplets)") | |
| if mat_age < 18 or mat_age > 40: | |
| contributing_factors.append("maternal age outside the 18–40 range") | |
| if birth_order >= 5: | |
| contributing_factors.append("higher birth order (5th child or later)") | |
| if contributing_factors: | |
| factors_text = "Factors in this input associated with **elevated** stunting risk in the literature:\n" + \ | |
| "\n".join(f"- {f}" for f in contributing_factors) | |
| else: | |
| factors_text = "No major elevated-risk factors were flagged among the inputs provided." | |
| summary_md = f""" | |
| ## Stunting Risk Prediction | |
| ### Predicted stunting risk over time | |
| {age_risk_lines} | |
| {threshold_text} | |
| ### Factors noted for this case | |
| {factors_text} | |
| --- | |
| ⚠️ **Reminder:** This is a research prototype with no external validation. Do not use for | |
| clinical or policy decisions. Predicted probabilities and risk scores should be interpreted | |
| as exploratory model output only. | |
| """ | |
| return summary_md, fig | |
| inputs = create_inputs() | |
| demo = gr.Interface( | |
| fn=predict_stunting, | |
| inputs=inputs, | |
| outputs=[gr.Markdown(label="Risk Score"), gr.Plot(label="Stunting Risk Curve")], | |
| title="Stunting Risk Predictor", | |
| description=""" | |
| ⚠️ **DISCLAIMER**: This is a **research prototype** developed using DHS data. | |
| **External validation has not been performed**. This tool is for | |
| **educational and exploratory purposes only** and is **NOT** intended | |
| for clinical or policy decision-making. | |
| Predicts stunting risk at ages 6, 12, 18, 24, 36, and 48 months using a | |
| Random Survival Forest model. | |
| """, | |
| examples=[ | |
| [1, 25, 160, 60, "middle", "primary", 0, "male", "islam", "Improved", "Improved", "primary", "North West", "rural"], | |
| [4, 18, 150, 55, "poorest", "no education", 0, "female", "catholic", "Unimproved", "Unimproved", "no education", "South East", "rural"], | |
| [2, 35, 170, 70, "richest", "higher", 1, "male", "other christian", "Improved", "Improved", "secondary", "South West", "urban"] | |
| ], | |
| #allow_flagging="never" | |
| ) | |
| if __name__ == "__main__": | |
| print("🚀 Launching app...") | |
| demo.launch(server_name="0.0.0.0", server_port=7860) |