Lightweight extension of rsample for spatiotemporal cross-validation
stcvlite provides convenient functions to make rset objects for spatiotemporal cross-validation. rset class is a major component of rsample package, which is a part of tidymodels ecosystem, to perform hyperparameter tuning and model evaluation for machine learning tasks.
The package offers several cross-validation schemes by what to split along, for example, leave-one-out or leave-block-out along space or time indices or by combination of both. The principle encapsulates the schemes as acronyms. To note, the terminology is from Clark et al. (2024):
| Family | Scheme | Description |
|---|---|---|
| LOO | "lolo" |
Leave-one-location-out. In space-time data, all time indices at each location are assigned to the same fold. |
| LOO | "loto" |
Leave-one-time-out. In space-time data, all locations at each time index are assigned to the same fold. |
| LOO | "lolto" |
Leave-one-location-time-out. Same as plain LOOCV |
| LBO | "lblo" |
Leave-block-location-out. Proximal locations are chunked. |
| LBO | "lbto" |
Leave-block-time-out. Closer time indices are chunked. |
| LBO | "lblto" |
Leave-block-location-time-out. Both locations and time indices are considered in chunking. |
| Random | "random" |
Random assignment. Same as plain random CV. |
# install.packages("pak")
# pak::pak("sigmafelix/stcvlite")
install.packages("stcvlite", repos = c('https://sigmafelix.r-universe.dev', 'https://cloud.r-project.org'))
library(stcvlite)
# Load the built-in sample dataset (100 locations × 120 time steps)
data(spdat) # a data.table with lon, lat, time, x1, x2, y columns
Spatial blocks group nearby locations together so that an entire block ends up in
the same fold, preventing spatial autocorrelation from leaking between training and
test sets. generate_block_sp_index() attaches a sp_index column to the covariate
table and supports three block-definition strategies.
When cv_fold is supplied without blocks, coordinates are clustered with DBSCAN.
# Create 5 spatial clusters based on (lon, lat)
spdat_block <- generate_block_sp_index(spdat, cv_fold = 5)
spdat_block[, c("lon", "lat", "sp_index")]
#> lon and lat columns alongside integer cluster ids 1–5
blocks = c(step_lon, step_lat))Pass a length-2 numeric vector to cut the bounding box into rectangular cells.
# ~5° × 5° grid cells
spdat_grid <- generate_block_sp_index(spdat, blocks = c(5, 5))
spdat_grid[, c("lon", "lat", "sp_index")]
blocks = sf/SpatVector)Provide an sf or SpatVector polygon layer whose features define the block
boundaries (e.g. states, counties, hydrological units). The block_id argument
names the column that uniquely identifies each feature.
library(sf)
# Example: U.S. state polygons (replace with your own polygon layer)
state_polygons <- sf::read_sf("path/to/states.gpkg")
spdat_poly <- generate_block_sp_index(
spdat,
blocks = state_polygons,
block_id = "STUSPS" # unique state abbreviation column
)
spdat_poly[, c("lon", "lat", "sp_index")]
generate_cv_index() returns an integer (or factor) fold-assignment vector with
one value per row of the input. Pass this vector together with your data to
convert_cv_index_rset() to obtain a tidymodels-compatible rset object.
Each unique location forms one fold; all time steps at that location are held out together.
data(spdat)
cv_lolo <- generate_cv_index(spdat, cv_mode = "lolo")
# cv_lolo is an integer vector of length nrow(spdat)
# unique values == number of unique locations (100)
rset_lolo <- convert_cv_index_rset(cv_lolo, spdat, cv_mode = "lolo")
rset_lolo
Each unique time step is one fold; all locations at that time step are held out.
cv_loto <- generate_cv_index(spdat, cv_mode = "loto")
# unique values == number of unique time steps (120)
rset_loto <- convert_cv_index_rset(cv_loto, spdat, cv_mode = "loto")
Every single row becomes its own fold (standard LOO). Suitable for small datasets.
cv_lolto <- generate_cv_index(spdat, cv_mode = "lolto")
Spatially contiguous groups of locations form each fold. Uses k-means clustering
internally when cv_fold is an integer.
cv_lblo <- generate_cv_index(spdat, cv_mode = "lblo", cv_fold = 5)
rset_lblo <- convert_cv_index_rset(cv_lblo, spdat, cv_mode = "lblo")
rset_lblo
#> # 5-fold spatial block CV
# You can also supply pre-built polygon blocks:
# cv_lblo_poly <- generate_cv_index(
# spdat, cv_mode = "lblo",
# blocks = state_polygons, block_id = "STUSPS"
# )
Consecutive time steps are grouped into cv_fold temporal chunks.
cv_lbto <- generate_cv_index(spdat, cv_mode = "lbto", cv_fold = 6)
rset_lbto <- convert_cv_index_rset(cv_lbto, spdat, cv_mode = "lbto")
Combines spatial and temporal blocking. Each fold is one cell in the
sp_fold * t_fold grid of spatiotemporal blocks.
cv_lblto <- generate_cv_index(
spdat,
cv_mode = "lblto",
sp_fold = 5,
t_fold = 4
)
# up to 5 × 4 = 20 unique spatiotemporal blocks
rset_lblto <- convert_cv_index_rset(cv_lblto, spdat, cv_mode = "lblto")
Plain random k-fold assignment included as a baseline.
cv_random <- generate_cv_index(spdat, cv_mode = "random", cv_fold = 10)
rset_random <- convert_cv_index_rset(cv_random, spdat, cv_mode = "random")
plot_cv_folds() renders a 3-D scatter (lon × lat × time) colored by fold,
useful for quickly checking that the spatial/temporal structure is captured.
if (requireNamespace("plotly", quietly = TRUE)) {
# Spatial block CV — clusters should appear as distinct spatial patches
plot_cv_folds(spdat, cv_lblo)
# Spatiotemporal block CV
plot_cv_folds(spdat, cv_lblto)
}
The rset objects returned by convert_cv_index_rset() are fully compatible with
the tune and workflows packages.
library(rsample)
library(parsnip)
library(workflows)
library(tune)
# Define a simple linear regression model
lm_spec <- linear_reg() |> set_engine("lm")
wflow <- workflow() |>
add_formula(y ~ x1 + x2) |>
add_model(lm_spec)
# Evaluate using spatial block CV
cv_results_lblo <- fit_resamples(wflow, resamples = rset_lblo)
collect_metrics(cv_results_lblo)
# # A tibble: 2 × 6
# .metric .estimator mean n std_err .config
# <chr> <chr> <dbl> <int> <dbl> <chr>
# 1 rmse standard 5.24 5 0.0108 pre0_mod0_post0
# 2 rsq standard 0.0116 5 0.00110 pre0_mod0_post0
# Evaluate using spatiotemporal block CV
cv_results_lbto <- fit_resamples(wflow, resamples = rset_lbto)
collect_metrics(cv_results_lbto)
# # A tibble: 2 × 6
# .metric .estimator mean n std_err .config
# <chr> <chr> <dbl> <int> <dbl> <chr>
# 1 rmse standard 5.53 6 1.28 pre0_mod0_post0
# 2 rsq standard 0.303 6 0.00171 pre0_mod0_post0
# Evaluate using spatiotemporal block CV
cv_results_lblto <- fit_resamples(wflow, resamples = rset_lblto)
collect_metrics(cv_results_lblto)
# # A tibble: 2 × 6
# .metric .estimator mean n std_err .config
# <chr> <chr> <dbl> <int> <dbl> <chr>
# 1 rmse standard 5.02 20 0.540 pre0_mod0_post0
# 2 rsq standard 0.155 20 0.00665 pre0_mod0_post0