Citation: Islam, S. (2026). Explainable AI for Hydro-Climate Models: A Unified R Workflow Using SHAP, LIME and Partial Dependence. In: Hydro-Climate Analytics: Remote Sensing, AI and Geospatial Modelling (Springer). DOI: 10.XXXX/XXXXXXX
Machine learning models are increasingly used in hydro-climate science to predict streamflow, estimate drought indices, map flood risk, and project evapotranspiration under changing climate scenarios. These models — random forests, gradient boosting machines, deep neural networks — often outperform traditional physically-based models in predictive accuracy (Breiman 2001). However, they operate as black boxes: their internal decision logic is opaque to practitioners, regulators, and policy makers who must trust model outputs for operational water management and climate adaptation decisions.
Explainable Artificial Intelligence (XAI) addresses this gap by providing mathematically rigorous tools to attribute model predictions to individual input features. XAI transforms a black-box model into an interpretable system without sacrificing predictive performance.
xaiHydro provides a unified, hydrology-aware R
workflow for applying three complementary XAI families to any
trained hydro-climate model:
| Method | Scope | Key advantage |
|---|---|---|
| SHAP (Lundberg and Lee 2017) | Global + local | Theoretically grounded; satisfies efficiency, symmetry, and dummy axioms |
| LIME (Ribeiro et al. 2016) | Local | Interpretable surrogate; works with any model type |
| PDP / ALE (Friedman 2001; Apley and Zhu 2020) | Global marginal | Visualises feature response curves across the predictor range |
All three methods require only a trained model and
its training data — no additional field data
collection, no satellite downloads, no pre-processing pipelines. This
makes xaiHydro uniquely accessible for researchers who
already have a fitted model and want to explain it.
This vignette serves as both:
xaiHydro with reproducible code.# CRAN release (recommended)
install.packages("xaiHydro")
# Development version from GitHub
# install.packages("remotes")
remotes::install_github("sadikul-islam/xaiHydro")Load the package:
xaiHydro ships with two built-in data simulators so
every example in this vignette is fully reproducible without external
data.
| precipitation | temperature | evapotranspiration | soil_moisture | snow_water_equiv | ndvi | lag1_precip | lag3_precip | streamflow |
|---|---|---|---|---|---|---|---|---|
| 9.22 | 23.75 | 5.489 | 0.3396 | 7.17 | 0.4962 | 0.00 | 0.00 | 11.112 |
| 0.00 | 16.97 | 4.846 | 0.3073 | 3.83 | 0.5052 | 9.22 | 0.00 | 12.939 |
| 6.27 | 20.70 | 5.562 | 0.2638 | 2.07 | 0.4675 | 0.00 | 5.16 | 10.519 |
| 4.53 | 23.18 | 6.311 | 0.2270 | 11.25 | 0.5279 | 6.27 | 3.60 | 15.716 |
| 0.00 | 22.80 | 5.722 | 0.2828 | 5.94 | 0.4594 | 4.53 | 3.60 | 11.772 |
| 0.00 | 23.16 | 4.893 | 0.3021 | 4.76 | 0.5431 | 0.00 | 1.51 | 4.187 |
str(df_flow)
#> 'data.frame': 365 obs. of 9 variables:
#> $ precipitation : num 9.22 0 6.27 4.53 0 0 0 0 6.37 1.72 ...
#> $ temperature : num 23.8 17 20.7 23.2 22.8 ...
#> $ evapotranspiration: num 5.49 4.85 5.56 6.31 5.72 ...
#> $ soil_moisture : num 0.34 0.307 0.264 0.227 0.283 ...
#> $ snow_water_equiv : num 7.17 3.83 2.07 11.25 5.94 ...
#> $ ndvi : num 0.496 0.505 0.468 0.528 0.459 ...
#> $ lag1_precip : num 0 9.22 0 6.27 4.53 0 0 0 0 6.37 ...
#> $ lag3_precip : num 0 0 5.16 3.6 3.6 1.51 0 0 2.12 2.7 ...
#> $ streamflow : num 11.1 12.9 10.5 15.7 11.8 ...The function generates a synthetic daily hydro-meteorological dataset mimicking realistic seasonal dynamics:
| Column | Description | Unit |
|---|---|---|
precipitation |
Daily rainfall | mm |
temperature |
Mean daily air temperature | °C |
evapotranspiration |
Reference ET (Penman–Monteith style) | mm/day |
soil_moisture |
Volumetric soil moisture | m³/m³ |
snow_water_equiv |
Snow water equivalent | mm |
ndvi |
NDVI vegetation greenness index | dimensionless |
lag1_precip |
Previous-day precipitation | mm |
lag3_precip |
3-day moving mean precipitation | mm |
streamflow |
Response variable — simulated discharge | m³/s |
The response is generated as a non-linear function of predictors with added noise, including a precipitation × NDVI interaction term to capture vegetation-modulated runoff.
| precip_anom | temp_anom | pdsi | ndvi_anom | spi_3 | spi_12 | et_deficit | spei |
|---|---|---|---|---|---|---|---|
| 26.03 | 0.39 | 0.723 | 0.2974 | -1.553 | 0 | 0.086 | -1.027 |
| -53.98 | 0.17 | -0.551 | -0.5852 | -0.680 | 0 | 0.483 | -0.529 |
| 6.96 | 1.79 | -0.486 | 0.0418 | -0.423 | 0 | 0.682 | -0.329 |
| -4.24 | 1.10 | -0.897 | -0.0679 | 0.054 | 0 | 0.372 | -0.355 |
| -33.33 | -0.01 | 0.262 | -0.2826 | -0.502 | 0 | 0.367 | -0.484 |
| -125.80 | -0.03 | -1.664 | -1.3441 | 0.132 | 0 | 0.358 | -0.324 |
This dataset simulates 20 years of monthly hydro-climatic anomalies with SPEI (Standardised Precipitation-Evapotranspiration Index) as the response, driven by precipitation anomalies, temperature anomalies, SPI at 3- and 12-month scales, PDSI, NDVI anomaly, and ET deficit.
We train a Random Forest model on the simulated streamflow dataset.
The same workflow applies to any regression model supported by
predict().
library(randomForest)
# Separate predictors and response
y_flow <- df_flow$streamflow
X_flow <- df_flow[, setdiff(names(df_flow), "streamflow")]
set.seed(42)
rf_model <- randomForest(
x = X_flow,
y = y_flow,
ntree = 300,
mtry = 3,
importance = TRUE
)
print(rf_model)
# Mean of squared residuals: ~X.XX
# % Var explained: ~XX.Xlibrary(xgboost)
y_spei <- df_drought$spei
X_drought <- df_drought[, setdiff(names(df_drought), "spei")]
xgb_mat <- xgboost::xgb.DMatrix(as.matrix(X_drought), label = y_spei)
xgb_model <- xgboost::xgboost(
data = xgb_mat,
nrounds = 150,
max_depth = 4,
eta = 0.1,
objective = "reg:squarederror",
verbose = 0
)hydro_explainer() is the single entry point for all XAI
analyses. It wraps [DALEX::explain()] (Biecek and Burzykowski
2021) and attaches hydrology-specific metadata used for axis
labels, titles, and interpretation messages throughout the package.
exp_flow <- hydro_explainer(
model = rf_model,
data = X_flow,
y = y_flow,
variable = "streamflow",
units = "m3/s",
label = "RandomForest"
)
print(exp_flow)
# == xaiHydro Explainer ==
# Target variable : streamflow [m3/s]
# Model label : RandomForest
# Observations : 365
# Predictors : 8
# Model class : randomForestThe explainer object is compatible with all xaiHydro
functions and can also be passed directly to any DALEX
function for additional analyses.
For models that require a non-standard prediction call (XGBoost,
Keras, PyTorch via torch), supply a
predict_function:
pfun_xgb <- function(model, newdata) {
predict(model, xgboost::xgb.DMatrix(as.matrix(newdata)))
}
exp_drought <- hydro_explainer(
model = xgb_model,
data = X_drought,
y = y_spei,
variable = "drought_index",
units = "SPEI",
label = "XGBoost",
predict_function = pfun_xgb
)SHAP (SHapley Additive exPlanations) values decompose each model prediction into additive contributions from each feature, satisfying three desirable axioms: efficiency (contributions sum to the prediction gap from baseline), symmetry (equal features receive equal credit), and dummy (non-contributing features get zero) (Lundberg and Lee 2017).
shap_vals <- hydro_shap(
explainer = exp_flow,
nsim = 50, # Monte Carlo replicates — increase for stability
seed = 42
)
# Returns a tidy data frame:
# id | feature | shap_value | feature_value | mean_abs_shap
head(shap_vals, 8)Tip: nsim = 50 is adequate for
publication figures. Use nsim = 100–200 for final
manuscript outputs.
The beeswarm summary plot shows:
Features where red points cluster on the right indicate that high values of that predictor increase predicted streamflow — physically consistent with rainfall–runoff theory.
# Explain a high-flow day (e.g., observation 180 after a storm event)
plot_shap_waterfall(shap_vals, obs_id = 180, top_n = 7)The waterfall plot shows exactly how prediction for day 180 was built up from the global baseline (mean predicted streamflow) through additive feature contributions. Red bars push the prediction above baseline; blue bars pull it below.
LIME (Local Interpretable Model-agnostic Explanations) fits a locally weighted linear surrogate model in the neighbourhood of a specific observation (Ribeiro et al. 2016). It provides a simple, interpretable approximation of the black-box model’s local behaviour.
lime_result <- hydro_lime(
explainer = exp_flow,
new_obs = X_flow[180, , drop = FALSE],
n_features = 6, # top-6 features in the local model
kernel_width = 0.75
)
# Local R² indicates surrogate model quality (closer to 1 = better)
plot_lime_hydro(lime_result)The local R² is printed in the subtitle. Values above 0.7 indicate the surrogate captures the model’s local structure well.
Partial Dependence Plots (PDP) show the marginal effect of a single predictor on the response, averaging over all other predictors (Friedman 2001). Accumulated Local Effects (ALE) are preferred when predictors are correlated, as they avoid averaging over unrealistic covariate combinations (Apley and Zhu 2020).
Permutation importance measures how much model error increases when each feature’s values are randomly shuffled, breaking the feature– response relationship (Breiman 2001). Features causing large error increases are the most important.
imp <- hydro_importance(
explainer = exp_flow,
loss_function = "rmse",
B = 20 # repetitions — reduces variance of estimate
)
plot_importance_hydro(imp, top_n = 8)The breakdown profile decomposes a single prediction into ordered
additive contributions from each feature (Biecek and Burzykowski
2021). Unlike SHAP (which averages over all orderings), the
sequential Break Down algorithm shows contributions in a specific order;
the SHAP-type breakdown averages over B random orderings
for a more stable result.
bd <- hydro_breakdown(
explainer = exp_flow,
new_obs = X_flow[180, , drop = FALSE],
type = "shap", # or "break_down"
B = 25
)
plot_breakdown_hydro(bd, max_features = 8)Before trusting XAI explanations, model residuals should be examined. Systematic patterns in residuals (heteroscedasticity, temporal autocorrelation) can bias SHAP and PDP estimates.
The four-panel figure checks:
hydro_xai_report() runs all key analyses in one call and
assembles them into a publication-ready multi-panel figure. This is the
figure used in the book chapter case study.
hydro_xai_report(
explainer = exp_flow,
nsim = 50,
top_n = 6,
new_obs = X_flow[180, , drop = FALSE],
seed = 42,
save_path = "xaiHydro_streamflow_report.png",
width = 16,
height = 11,
dpi = 300
)This section demonstrates the full xaiHydro workflow on
an XGBoost drought model, reproducing the second case study in the book
chapter.
library(xgboost)
# Data
df_d <- sim_drought_data(n = 240, seed = 2026)
y_d <- df_d$spei
X_d <- df_d[, setdiff(names(df_d), "spei")]
# Model
xgb_m <- xgboost::xgboost(
data = xgboost::xgb.DMatrix(as.matrix(X_d), label = y_d),
nrounds = 150, max_depth = 4, eta = 0.1,
objective = "reg:squarederror", verbose = 0
)
# Explainer
pfun <- function(m, nd) predict(m, xgboost::xgb.DMatrix(as.matrix(nd)))
exp_d <- hydro_explainer(xgb_m, X_d, y_d,
variable = "drought_index",
units = "SPEI",
label = "XGBoost",
predict_function = pfun)
# XAI suite
shap_d <- hydro_shap(exp_d, nsim = 50)
imp_d <- hydro_importance(exp_d, B = 20)
pdp_d <- hydro_pdp(exp_d, variable = c("spi_3", "spi_12", "et_deficit"))
plot_shap_summary(shap_d, top_n = 6,
title = "SHAP Summary — SPEI drought model (XGBoost)")
plot_importance_hydro(imp_d)
plot_pdp_hydro(pdp_d, ncol = 3)
hydro_residuals(exp_d)When precipitation and lag1_precip appear
in the top-ranked SHAP features with positive effects (red points on the
right), this confirms the model has correctly learnt that higher
antecedent and current rainfall drives higher streamflow — consistent
with rainfall–runoff theory. Conversely, high
evapotranspiration having negative SHAP (blue points on
left) indicates that high ET demand reduces net water available for
runoff.
Use ALE when: - Predictors are correlated (precipitation–soil moisture, temperature–ET). - The training data does not uniformly cover the predictor space. - You suspect extrapolation artefacts in PDP.
Use PDP when: - Predictors are approximately independent. - You want a simpler figure for a non-technical audience.
An R² < 0.5 for the LIME surrogate means the local linear
approximation is unreliable for that observation. Increase
kernel_width (to use a broader neighbourhood) or reduce
n_features (for a sparser model).
sessionInfo()
#> R version 4.6.0 (2026-04-24 ucrt)
#> Platform: x86_64-w64-mingw32/x64
#> Running under: Windows 11 x64 (build 26200)
#>
#> Matrix products: default
#> LAPACK version 3.12.1
#>
#> locale:
#> [1] LC_COLLATE=C LC_CTYPE=English_India.utf8
#> [3] LC_MONETARY=English_India.utf8 LC_NUMERIC=C
#> [5] LC_TIME=English_India.utf8
#>
#> time zone: Asia/Calcutta
#> tzcode source: internal
#>
#> attached base packages:
#> [1] stats graphics grDevices utils datasets methods base
#>
#> other attached packages:
#> [1] xaiHydro_0.1.0
#>
#> loaded via a namespace (and not attached):
#> [1] digest_0.6.39 R6_2.6.1 fastmap_1.2.0 xfun_0.60
#> [5] cachem_1.1.0 knitr_1.52 htmltools_0.5.9 rmarkdown_2.32
#> [9] lifecycle_1.0.5 cli_3.6.6 sass_0.4.10 jquerylib_0.1.4
#> [13] compiler_4.6.0 rstudioapi_0.19.0 tools_4.6.0 evaluate_1.0.5
#> [17] bslib_0.12.0 yaml_2.3.12 otel_0.2.0 jsonlite_2.0.0
#> [21] rlang_1.3.0