Cross-Validation: Penalty Tuning and Model Selection
Source:vignettes/cross_validation.Rmd
cross_validation.RmdOverview
Ridge penalties stabilise niche estimates when sample sizes are small
or environmental variables are correlated. xnicher supports
three penalty knobs:
| Parameter | Controls | Default |
|---|---|---|
prior_log_sigma_lambda |
Shrinkage on | 0 |
prior_mu_lambda |
Shrinkage on toward center | 0 |
prior_alpha_lambda |
Shrinkage on skewness | 0 |
Setting a penalty too high over-regularises (biased niche); setting it too low under-regularises (high variance). Cross-validation selects the penalty strength that maximises out-of-sample log-likelihood.
1 Fit a baseline model
library(xnicher)
set.seed(42)
fit_base <- optimize_niche(
env_occ = example_env_occ_2d,
env_m = example_env_m_2d,
likelihood = "weighted",
num_starts = 5L,
breadth = 0.45,
prior_mu_lambda = 0,
verbose = FALSE
)
fit_base
#> -- xnicher optimization result --
#> Likelihood : weighted
#> Starts : 6 (converged: 6 )
#> Best loglik: -645.0662
#> Convergence: 1
#> eta : 1
#> Variables : bio1WH, bio12WH2 Run k-fold cross-validation
cv_xnicher() refits the same model on each train fold
(warm-started from the full-data optimum) and scores the held-out fold
using the un-penalised log-likelihood.
cv_base <- cv_xnicher(
fit = fit_base,
env_occ = example_env_occ_2d,
env_m = example_env_m_2d,
type = "kfold",
k = 5L,
seed = 42L,
verbose = FALSE
)
cv_base
#> -- xnicher_cv result --
#> Type : kfold (k = 5)
#> Family : weighted
#> Folds : 5
#> CV loglik: -650.923 (per-occ: -8.9168 )3 Compare penalty strengths
Fit models at different penalty levels and compare their CV log-likelihoods. Higher (less negative) CV log-likelihood indicates better out-of-sample fit.
lambdas <- c(0, 0.5, 1.0, 2.0, 5.0)
cv_results <- data.frame(
lambda = lambdas,
cv_loglik = NA_real_,
cv_loglik_mean = NA_real_
)
for (i in seq_along(lambdas)) {
fit_i <- optimize_niche(
env_occ = example_env_occ_2d,
env_m = example_env_m_2d,
likelihood = "weighted",
num_starts = 5L,
breadth = 0.45,
prior_mu_lambda = lambdas[i],
verbose = FALSE
)
cv_i <- cv_xnicher(
fit = fit_i,
env_occ = example_env_occ_2d,
env_m = example_env_m_2d,
type = "kfold",
k = 5L,
seed = 42L,
verbose = FALSE
)
cv_results$cv_loglik[i] <- cv_i$cv_loglik
cv_results$cv_loglik_mean[i] <- cv_i$cv_loglik_mean
}
cv_results
#> lambda cv_loglik cv_loglik_mean
#> 1 0.0 -650.9230 -8.916753
#> 2 0.5 -656.6578 -8.995313
#> 3 1.0 -658.5260 -9.020904
#> 4 2.0 -660.9557 -9.054188
#> 5 5.0 -665.4376 -9.115583The penalty value that maximises cv_loglik is the best
choice:
4 Cross-validation across likelihood families
CV can also compare different likelihood families. Refit each family with the same data and compare their out-of-sample log-likelihoods:
families <- c("presence_only", "weighted", "ip_weighted")
cv_family <- data.frame(
family = families,
cv_loglik = NA_real_,
cv_loglik_mean = NA_real_,
stringsAsFactors = FALSE
)
for (i in seq_along(families)) {
fam <- families[i]
needs_m <- fam %in% c("weighted", "ip_weighted")
fit_i <- optimize_niche(
env_occ = example_env_occ_2d,
env_m = if (needs_m) example_env_m_2d else NULL,
likelihood = fam,
num_starts = 5L,
breadth = 0.45,
verbose = FALSE
)
cv_i <- cv_xnicher(
fit = fit_i,
env_occ = example_env_occ_2d,
env_m = if (needs_m) example_env_m_2d else NULL,
type = "kfold",
k = 5L,
seed = 42L,
verbose = FALSE
)
cv_family$cv_loglik[i] <- cv_i$cv_loglik
cv_family$cv_loglik_mean[i] <- cv_i$cv_loglik_mean
}
cv_family
#> family cv_loglik cv_loglik_mean
#> 1 presence_only -630.3632 -8.635113
#> 2 weighted -658.5260 -9.020904
#> 3 ip_weighted -640.1458 -8.7691205 Leave-one-out cross-validation
For small datasets, leave-one-out (LOO) CV uses every observation as a test fold exactly once. This is more expensive but has lower variance than k-fold:
cv_loo <- cv_xnicher(
fit = fit_base,
env_occ = example_env_occ_2d,
env_m = example_env_m_2d,
type = "loo",
verbose = FALSE
)
cv_loo
#> -- xnicher_cv result --
#> Type : loo
#> Family : weighted
#> Folds : 73
#> CV loglik: -649.3751 (per-occ: -8.8955 )6 Per-fold diagnostics
The per_fold data frame in the result object shows
convergence status and log-likelihood for each fold, useful for
detecting problematic subsets:
head(cv_base$per_fold)
#> fold n_test loglik_test convergence
#> 1 1 15 -134.8284 1
#> 2 2 14 -133.4668 4
#> 3 3 15 -130.5017 4
#> 4 4 14 -126.9578 4
#> 5 5 15 -125.1682 4Folds with convergence != 1 may need more optimizer
iterations (increase ucminf_control$maxeval).
Caveats
- Spatial autocorrelation: Random k-fold CV assumes exchangeable observations. For spatially autocorrelated data, the CV log-likelihood may be optimistic. Spatial-block CV is planned for a future release.
-
Computational cost: LOO with large
(e.g. 500+) runs one refit per observation. Consider
type = "kfold", k = 10Las a faster proxy. - Different normalisation: Presence-only and weighted families have different likelihood normalising constants, so their CV log-likelihoods are not strictly comparable. Use this comparison as a diagnostic rather than a formal test.
References
Jimenez, L., Soberon, J., Christen, J. A., & Soto, D. (2022). Dealing with critical uncertainty in ecological niche models. Ecological Modelling, 468, 109823. https://doi.org/10.1016/j.ecolmodel.2021.109823