Skip to contents

R-CMD-check

Lightweight extension of rsample for spatiotemporal cross-validation

What does this package do?

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.

Cross-validation schemes

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.

Installation

# install.packages("pak")
pak::pak("sigmafelix/stcvlite")

Quick start

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 block creation

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.

Strategy 1 — density-based clustering

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

Strategy 2 — regular grid (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")]

Strategy 3 — arbitrary polygon blocks (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")]

Cross-validation schemes

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.

Leave-one-location-out (lolo)

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

Leave-one-time-out (loto)

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")

Leave-one-location-time-out (lolto)

Every single row becomes its own fold (standard LOO). Suitable for small datasets.

cv_lolto <- generate_cv_index(spdat, cv_mode = "lolto")

Leave-block-location-out (lblo)

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"
# )

Leave-block-time-out (lbto)

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")

Leave-block-location-time-out (lblto)

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")

Random (random)

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")

Visualizing folds

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)
}

Integration with tidymodels

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