| Title: | Spatial Tessellation, Modeling, and Cross-Validation Toolkit |
|---|---|
| Description: | Constructs analysis regions from the distribution of the data itself, as an alternative to aggregating onto administrative boundaries that were drawn for unrelated purposes. Seeds and builds Voronoi, Delaunay, hexagonal and square tessellations with reproducible identifiers, selects a cell count from the spatial structure of the observations, assigns features to cells, and aggregates to cell level with optional design-effect corrections so that standard errors account for within-cell autocorrelation. Also manages coordinate reference systems. Fits geographically weighted regression (via 'GWmodel'; Lu et al. (2014) <doi:10.1080/10095020.2014.917453>), Bayesian spatial Gaussian process regression (via 'brms', using the Hilbert space approximation of Riutort-Mayol et al. (2023) <doi:10.1007/s11222-022-10167-2>) and random forests (via 'ranger', with the permutation importance of Strobl et al. (2007) <doi:10.1186/1471-2105-8-25>), each behind one S3 class with consistent predict, fitted, residuals and plot methods. Provides spatial cross-validation with random, block, buffered, leave-location-out and nearest-neighbour distance-matched folds (Mila et al. (2022) <doi:10.1111/2041-210X.13851>), forward variable selection, model comparison, prediction onto a regular surface, and the area of applicability of Meyer and Pebesma (2021) <doi:10.1111/2041-210X.13650> to flag where a fitted model extrapolates beyond its training data. |
| Authors: | Justin Chase [aut, cre, cph] |
| Maintainer: | Justin Chase <[email protected]> |
| License: | MIT + file LICENSE |
| Version: | 2.0.0.9000 |
| Built: | 2026-10-07 22:35:31 UTC |
| Source: | https://github.com/elkronos/gis_modeling_toolkit |
prep_model_data() and
assign_features_to_polygons() return a layer with an
attribute recording what happened to its rows ("dropped" and
"ties" respectively). Those records describe the rows the layer was
built with, and [ on an sf object copies attributes through
unchanged, which would leave a subset reporting its parent's numbers for a
different set of rows. Subsetting therefore returns a plain layer with the
record removed, and so do the dplyr verbs that select or reorder
rows (filter(), slice(), arrange(),
distinct()); read the record from the layer the function returned,
before subsetting it. Binding such layers (rbind(),
dplyr::bind_rows()) likewise returns a plain sf layer.
## S3 method for class 'spatialkit_rows' x[...]## S3 method for class 'spatialkit_rows' x[...]
x |
A layer returned by |
... |
Passed to the underlying |
Each record carries n_rows, the number of rows it was made for.
sf::st_drop_geometry() keeps the rows, and with them the record: it
returns a data frame of class c("spatialkit_rows", "data.frame").
Binding such data frames with
rbind() or dplyr::bind_rows() keeps the first one's record
and class, so the record then describes only the first input's rows: its
n_rows no longer equals nrow() of the result. The
package's own readers ignore a record whose n_rows does not match;
when reading attr(x, "dropped") or attr(x, "ties") yourself
from a layer that has been through such steps, check it the same way.
The subset, without the row records and without this class. A
subset that is not a data frame (a single column taken with
drop = TRUE) is returned unchanged.
prep_model_data() and
assign_features_to_polygons(), the two functions that
attach the records this method removes.
library(sf) dat <- st_as_sf( data.frame(x = 1:5, y = 5:1, resp = c(1, 2, NA, 4, 5), pred = c(1, 2, 3, 4, Inf)), coords = c("x", "y"), crs = 32632 ) clean <- prep_model_data(dat, "resp", "pred") attr(clean, "dropped")$n # 2 attr(clean[1:2, ], "dropped") # NULL -- the record does not followlibrary(sf) dat <- st_as_sf( data.frame(x = 1:5, y = 5:1, resp = c(1, 2, NA, 4, 5), pred = c(1, 2, 3, 4, Inf)), coords = c("x", "y"), crs = 32632 ) clean <- prep_model_data(dat, "resp", "pred") attr(clean, "dropped")$n # 2 attr(clean[1:2, ], "dropped") # NULL -- the record does not follow
Computes the dissimilarity index (DI) of Meyer & Pebesma (2021) for a set of prediction locations and flags those that fall inside the model's area of applicability (AOA), the region of predictor space where the model's cross-validated performance estimate can be expected to hold.
area_of_applicability( newdata, model = NULL, train_sf = NULL, predictor_vars = NULL, weights = NULL, folds = NULL, threshold = NULL, normalizer_max_n = 5000L, seed = 123L, chunk_size = NULL, use_fnn = requireNamespace("FNN", quietly = TRUE) )area_of_applicability( newdata, model = NULL, train_sf = NULL, predictor_vars = NULL, weights = NULL, folds = NULL, threshold = NULL, normalizer_max_n = 5000L, seed = 123L, chunk_size = NULL, use_fnn = requireNamespace("FNN", quietly = TRUE) )
newdata |
Prediction locations: an |
model |
A fitted |
train_sf |
Training data, if not taken from |
predictor_vars |
Predictor names, if not taken from |
weights |
Optional named numeric vector of predictor importances. Any
positive scale works. When |
folds |
Cross-validation folds: a |
threshold |
Optional numeric override for the DI threshold. |
normalizer_max_n |
Subsample the training data to this many points when computing the mean pairwise distance, which is quadratic. Default 5000. |
seed |
Seed for that subsample. Default 123. |
chunk_size |
Query rows per distance block on the dense path. Default
|
use_fnn |
Use FNN for nearest-neighbour search when available. Exposed so the dense fallback can be tested. |
An object of class aoa: a list with
aoa: newdata with a numeric DI column
and a logical AOA column added. This is the object the
computation ran on, which for a coordinate-using model is
newdata after pointizing, CRS reconciliation and the addition
of the "..x" and "..y" columns. A row whose predictors
are not all finite gets NA in both columns, and a row outside
the training range of a predictor in dropped_vars gets
DI = Inf and AOA = FALSE (see Limitations).
threshold: the DI cut-off used.
train_DI: the training points' own DI values.
normalizer: the mean pairwise training distance.
weights: the weight vector actually applied, named by
predictor_vars.
predictor_vars: the predictors used, including
"..x"/"..y" when the model uses coordinates and
excluding dropped_vars.
dropped_vars: predictors dropped for negligible
variance.
scaling: a list with center and scale,
each named by predictor_vars: the training means and standard
deviations the index is computed in, so a location's DI can be
traced to the predictor that put it outside.
n_outliers: the number of training DI values above the
Q3 + 1.5 * IQR fence, which the default threshold rule sets
aside (the "outlier-removed" in its name); computed whether or not
threshold was supplied.
n_train, n_new, n_inside,
n_outside, n_na: row counts. n_train counts the
training rows that survived the finite-value filter; n_new is
every row of newdata, so
n_new = n_inside + n_outside + n_na.
params records the call: folds_supplied,
n_folds, folds_method, threshold_supplied,
normalizer_max_n, normalizer_n_used,
normalizer_subsampled, weights_supplied and
seed. folds_method is the method of a
make_folds() result ("block_kfold",
"random_kfold", ...), "labels" for a vector of fold
labels, "splits" for a bare list of train/test splits, and
NA when no folds were supplied. It is printed with the
object, because the threshold's meaning depends on it: the
cross-validated DI that sets it comes from the same kind of hold-out
as the CV error it should be quoted beside, so an AOA built on
random_kfold folds is logged as a caution and does not
belong next to a blocked cv_*() result.
A fitted model will return a number for any location you hand it, including locations whose predictor values look nothing like anything it was trained on. Those predictions are extrapolations dressed as interpolations, and a cross-validation score says nothing about them, because the held-out folds were drawn from the same predictor distribution as the training data. The AOA marks where the score applies.
Predictors are centred and scaled using the training data's own means and
standard deviations, then optionally weighted by variable importance. For a
prediction point , the DI is the distance to its nearest training
point in that space, divided by the mean pairwise distance among training
points. The same quantity is computed for the training data itself, using
each point's nearest neighbour among the training rows of the fold
that holds it out. That means everything outside its own fold for random
and block folds, and the smaller training set that buffered and NNDM folds
actually leave (see the next section). The threshold is then the largest
training DI that is not an upper outlier, i.e. not above the fence
Q3 + 1.5 * IQR of the training DI, with the quartiles of
stats::quantile()'s default type 7. Prediction points at or below
that threshold are inside the AOA.
That is the paper's "outlier-removed maximum". CAST, the reference
implementation, computes the same fence with the same quartiles but uses the
fence itself as the threshold, capped at the largest training DI. The two
agree whenever no training DI lies above the fence (n_outliers is 0);
otherwise CAST's threshold is the larger, and so is its AOA. (Earlier
CAST releases used grDevices::boxplot.stats(), which gives the
rule used here but with Tukey's hinges as the quartiles, so they can also
differ when the number of training points is even.) To apply the current
CAST rule to the same training DI, pass
threshold = min(quantile(res$train_DI, 0.75) + 1.5 * IQR(res$train_DI),
max(res$train_DI)) for an earlier result res.
The DI is invariant to the overall scale of weights: the numerator
and the normaliser carry the same factor. Importance values can be passed
as-is.
Each training point's reference is its nearest other training row, so
an exact duplicate in predictor space (repeat visits to a site with static
covariates, or covariates read off a raster coarser than the sampling) has a
training DI of 0. Once about three quarters of the rows have a twin among
their reference rows the threshold is 0, and only exact copies of a
training row count as inside. That is logged as a caution; folds that keep
the duplicates together (make_folds(method = "leave_location_out",
group_var = ...)), or removing them, give the threshold its meaning back.
With folds = NULL the training reference is each point's nearest
neighbour anywhere in the training data, which for clustered data is very
close, giving a small threshold and a conservative AOA. Passing the folds
you actually validated with makes the reference distances larger and the AOA
correspondingly wider. That is not a loophole. The AOA is defined relative
to a performance estimate, and a spatially blocked estimate is a claim about
predicting further away. Pass the same make_folds() result you passed
to cv_spatial. Buffered and NNDM folds use the training set
they actually left available, not merely "everything outside the fold".
Predictors must be numeric; categorical variables are refused and never
silently dummy-coded. Predictors whose variance is negligible relative
to their own magnitude (the test is
sd < sqrt(.Machine$double.eps) * max(abs(x)), so the same variable in
metres and in gigametres is treated identically) are dropped from the
distance. A prediction point taking a value there outside the training
range (with the same relative tolerance) is extrapolation along a direction
the training data never varied in: its scaled distance along it is
infinite, so it gets DI = Inf, is outside the AOA, and a warning
gives the count. A point missing that value is judged on the other
predictors. Without weights every predictor counts equally, which
overstates dissimilarity along directions the model barely uses.
When model was fitted with include_coords = TRUE the model
splits on location, so the dissimilarity index has to measure location too:
the coordinates are added to both sides as the predictors "..x" and
"..y" and are then centred, scaled and weighted like any other
column. Without this a prediction point far outside the training extent but
with ordinary covariate values reads as inside the area of
applicability. That is exactly the extrapolation this index exists to catch.
This path needs geometry on both sides, so train_sf and
newdata must both be sf objects; a data.frame is refused
rather than quietly measured without location. Non-POINT
newdata (grid polygons, say) is reduced to representative points
first, as coerce_to_points would. If exactly one side carries
a CRS the other is brought into it: reprojected when its coordinates look
like longitude/latitude, stamped otherwise, with a warning either way. This
is done because degrees fed into a metre-space index silently understate the
distances.
Meyer, H. and Pebesma, E. (2021). Predicting into unknown space? Estimating the area of applicability of spatial prediction models. Methods in Ecology and Evolution 12(9), 1620–1633. doi:10.1111/2041-210X.13650
predict_surface to build the grid,
make_folds for the fold scheme.
Other cross-validation:
cv_bayes(),
cv_block_size_sweep(),
cv_gwr(),
cv_rf(),
cv_spatial(),
estimate_sac_range(),
fold_separation(),
gwr_model_selection(),
make_folds(),
sac_nugget(),
select_features_forward()
Other prediction:
predict_surface()
library(sf) set.seed(1) n <- 120 train <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n)), coords = c("x", "y"), crs = 32632 ) train$z <- 2 * train$a - train$b + rnorm(n, 0, 0.3) # Prediction points, some of them well outside the training predictor range newpts <- st_as_sf( data.frame(x = 5e5 + runif(50, 0, 1000), y = 5e6 + runif(50, 0, 1000), a = c(rnorm(40), rnorm(10, 8)), b = rnorm(50)), coords = c("x", "y"), crs = 32632 ) res <- area_of_applicability(newpts, train_sf = train, predictor_vars = c("a", "b")) res table(res$aoa$AOA)library(sf) set.seed(1) n <- 120 train <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n)), coords = c("x", "y"), crs = 32632 ) train$z <- 2 * train$a - train$b + rnorm(n, 0, 0.3) # Prediction points, some of them well outside the training predictor range newpts <- st_as_sf( data.frame(x = 5e5 + runif(50, 0, 1000), y = 5e6 + runif(50, 0, 1000), a = c(rnorm(40), rnorm(10, 8)), b = rnorm(50)), coords = c("x", "y"), crs = 32632 ) res <- area_of_applicability(newpts, train_sf = train, predictor_vars = c("a", "b")) res table(res$aoa$AOA)
Joins an sf layer of input features to a polygon layer via spatial join.
assign_features_to_polygons( features_sf, polygons_sf, polygon_id_col = "poly_id", keep_unassigned = FALSE, predicate = sf::st_intersects, largest = TRUE, tie_break = c("smallest_area", "first") )assign_features_to_polygons( features_sf, polygons_sf, polygon_id_col = "poly_id", keep_unassigned = FALSE, predicate = sf::st_intersects, largest = TRUE, tie_break = c("smallest_area", "first") )
features_sf |
An sf object containing features to assign. |
polygons_sf |
An sf or sfc polygonal layer. |
polygon_id_col |
Name of the polygon identifier column. Default "poly_id". |
keep_unassigned |
Logical; retain features that fall inside no
polygon, carrying |
predicate |
Binary spatial predicate function. Default sf::st_intersects.
Not used when |
largest |
Logical; when |
tie_break |
Strategy for resolving features that match multiple
polygons (with |
This is the second step of the package's pipeline: it labels every
observation with the cell it falls in, which is what
summarize_by_cell() then aggregates over. Prefer it to sf::st_join()
when the join has to be unambiguous. It resolves features matching
several polygons by an explicit tie_break rule instead of silently
duplicating rows, so the assigned layer keeps one row per input feature
and cell-level counts mean what they say.
The join runs in the CRS of polygons_sf whenever that CRS is projected,
so cell edges are the straight lines the cells were drawn with and overlap
areas are planar. A copy of features_sf is transformed for it, and the
features come back with the coordinates they arrived with. Otherwise (the
polygons are in lon/lat, or carry no CRS) the join runs in the CRS of
features_sf.
An sf object with polygon_id_col attached, one row per input
feature (fewer if keep_unassigned = FALSE dropped unmatched ones), in
the CRS features_sf arrived in. A column of features_sf already
called polygon_id_col is dropped before the spatial join (with a
warning), so re-assigning an already-assigned layer replaces the old IDs
and does not fail. Every other column is kept, including one named like
the polygons' own ID column when that is read from a fallback such as
"id" (a site id joined to cells keyed by id). If no feature
falls inside any polygon
the result is empty (or all-NA with keep_unassigned = TRUE) and a
warning is raised, since the usual cause is two layers in different
places (a CRS that could only be stamped, not reprojected). The
attribute "ties" records how many features matched more than one
polygon (with largest, overlapped several by exactly the same area)
and had the tie_break rule decide for them: a list with n,
which (their row positions in features_sf), rule and n_rows
(the number of rows returned, which the record was made for). A large
n means the polygon layer overlaps, and per-cell counts built from the
result depend on the rule. The record describes the rows this call
returned and does not survive subsetting: joined[i, ], like
dplyr::filter(), slice() or arrange() of it, is a plain layer
with no "ties" attribute, so nothing reports the parent's count
for a different set of rows. sf::st_drop_geometry() keeps the record,
since the rows are the same; see [.spatialkit_rows for
what binding such data frames does.
build_tessellation() to build the polygon layer;
summarize_by_cell() for the aggregation step that consumes the result.
Other aggregation:
determine_optimal_levels(),
kriging_adequacy(),
resolution_profile(),
select_resolution(),
summarize_by_cell(),
summary.resolution_profile()
library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(20, 0, 100), y = 5e6 + runif(20, 0, 100), val = rnorm(20)), coords = c("x", "y"), crs = 32632 ) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(5e5, 5e6), c(5e5 + 100, 5e6), c(5e5 + 100, 5e6 + 100), c(5e5, 5e6 + 100), c(5e5, 5e6) ))), crs = 32632)) grid <- create_grid_polygons(bnd, target_cells = 9, type = "square") assigned <- assign_features_to_polygons(pts, grid) table(assigned$poly_id)library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(20, 0, 100), y = 5e6 + runif(20, 0, 100), val = rnorm(20)), coords = c("x", "y"), crs = 32632 ) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(5e5, 5e6), c(5e5 + 100, 5e6), c(5e5 + 100, 5e6 + 100), c(5e5, 5e6 + 100), c(5e5, 5e6) ))), crs = 32632)) grid <- create_grid_polygons(bnd, target_cells = 9, type = "square") assigned <- assign_features_to_polygons(pts, grid) table(assigned$poly_id)
The single entry point for turning a point pattern into analysis regions, and
the first step of the package's pipeline. It wraps the four tessellation
methods behind one interface that handles CRS projection, clipping and stable
cell identifiers consistently, and returns the cell layer together with the
point-to-cell index that assign_features_to_polygons() and
summarize_by_cell() consume. Prefer it to the individual
constructors whenever you might want to compare methods: the return shape
does not change with method, so swapping "voronoi" for
"hex" costs one argument.
build_tessellation( points_sf, boundary = NULL, method = c("voronoi", "triangles", "hex", "square"), approx_n_cells = NULL, cellsize = NULL, expand = 0, clip = TRUE, keep_duplicates = FALSE, crs = NULL, quiet = FALSE )build_tessellation( points_sf, boundary = NULL, method = c("voronoi", "triangles", "hex", "square"), approx_n_cells = NULL, cellsize = NULL, expand = 0, clip = TRUE, keep_duplicates = FALSE, crs = NULL, quiet = FALSE )
points_sf |
An sf object with POINT/MULTIPOINT geometry. |
boundary |
Polygonal sf/sfc study area. Required for
|
method |
One of "voronoi", "triangles", "hex", "square". |
approx_n_cells |
Approximate number of cells. Read by
|
cellsize |
Numeric cell size, in the units of the working CRS. Read by
|
expand |
Buffer distance, in the working CRS's units, by which the
Voronoi boundary is grown before the diagram is built. Applied by
|
clip |
Logical; clip to boundary. |
keep_duplicates |
Logical. Has no effect on the cells or the index: coincident points are merged before a Voronoi diagram or a Delaunay triangulation is built either way, and every one of them is indexed to the cell they share. |
crs |
Optional target CRS: anything |
quiet |
Logical; suppress this function's progress |
Which method to reach for. "voronoi" gives one cell per point, so
resolution follows sampling density. That is the choice when the
observations themselves define the regions. "hex" and
"square" give equal-area cells on a fixed grid, so cell size is a
decision you make, and the data does not make it for you; hexagons avoid the
axis-aligned artefacts of squares and have uniform neighbour distances.
"triangles" returns the Delaunay triangulation, useful for
interpolation and adjacency work; it is not meant as an aggregation unit.
determine_optimal_levels() will suggest a cell count from the
spatial structure of the data.
"voronoi" and "triangles" are built on the points' vertices:
a MULTIPOINT feature with several vertices gets one cell (or triangle
corner) per vertex, and its index entry is the smallest
cell_id among the cells it touches. See
create_voronoi_polygons().
A list with components:
cellsAn sf polygon layer, one row per cell. It always carries
a cell_id column; the "hex" and "square" methods additionally
carry poly_id, which holds the same values.
indexInteger vector of cell_id values, one per row of
points_sf, and NA for a point that falls inside no cell, that is,
one outside the study area. Only a point within a thousandth of the
median cell width of a cell is snapped to it. That covers points
sitting exactly on a shared edge, and leaves points outside the study
area as NA. A summary built from index therefore counts only the
points the tessellation actually covers.
boundaryThe boundary used (possibly derived and/or
reprojected, and for "voronoi" grown by expand).
methodThe method actually used.
paramsThe parameters the tessellation was built with, plus
snapped, the record of that nearest-cell repair: a list with n,
which (row positions in points_sf) and distance (how far
outside every cell each sat, in CRS units). For "hex" and
"square" also cells_occupied and cells_empty, the number of
cells that hold at least one point and that hold none.
Other tessellation:
create_grid_polygons(),
create_grid_polygons_cached(),
create_voronoi_polygons(),
ensure_stable_poly_id(),
get_voronoi_seeds(),
plot_tessellation_map(),
voronoi_seeds_kmeans(),
voronoi_seeds_random()
library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(20, 0, 100), y = 5e6 + runif(20, 0, 100)), coords = c("x", "y"), crs = 32632 ) tess <- build_tessellation(pts, method = "voronoi", quiet = TRUE) tess$cellslibrary(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(20, 0, 100), y = 5e6 + runif(20, 0, 100)), coords = c("x", "y"), crs = 32632 ) tess <- build_tessellation(pts, method = "voronoi", quiet = TRUE) tess$cells
Removes the lazily-cached fitted() result so that the next call
recomputes from the posterior. This is only necessary if the underlying
brmsfit engine has been manually mutated after fitting. A change to
data_sf invalidates the entry on its own, because the cached value
carries a digest of the data it was computed from (see
fitted.bayesian_fit). Normal usage never requires it.
clear_fitted_cache(object)clear_fitted_cache(object)
object |
A |
The cache environment is shared by every copy of a fit, so clearing it through one copy clears it for all of them. That is harmless: the others recompute.
object, invisibly (called for side effect).
Other package options and caches:
clear_grid_cache(),
create_grid_polygons_cached(),
spatialkit_quiet()
# Only a bayesian_fit carries the cache; on any other fit this is a no-op. if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(60, 0, 1000), y = 5e6 + runif(60, 0, 1000), a = rnorm(60)), coords = c("x", "y"), crs = 32632 ) pts$z <- 2 * pts$a + rnorm(60, 0, 0.3) fit <- fit_rf_model(pts, "z", "a", num_trees = 50, seed = 1) clear_fitted_cache(fit) }# Only a bayesian_fit carries the cache; on any other fit this is a no-op. if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(60, 0, 1000), y = 5e6 + runif(60, 0, 1000), a = rnorm(60)), coords = c("x", "y"), crs = 32632 ) pts$z <- 2 * pts$a + rnorm(60, 0, 0.3) fit <- fit_rf_model(pts, "z", "a", num_trees = 50, seed = 1) clear_fitted_cache(fit) }
Removes all memoized grid results from the internal cache environment.
clear_grid_cache(cache_env = .gmt_cache)clear_grid_cache(cache_env = .gmt_cache)
cache_env |
Environment to clear. Default .gmt_cache. |
Invisibly, the number of entries removed.
Other package options and caches:
clear_fitted_cache(),
create_grid_polygons_cached(),
spatialkit_quiet()
library(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) g1 <- create_grid_polygons_cached(bnd, target_cells = 9) g2 <- create_grid_polygons_cached(bnd, target_cells = 9) # cache hit clear_grid_cache() # returns the number of entries removed, invisibly print(clear_grid_cache()) # 0: already emptylibrary(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) g1 <- create_grid_polygons_cached(bnd, target_cells = 9) g2 <- create_grid_polygons_cached(bnd, target_cells = 9) # cache hit clear_grid_cache() # returns the number of entries removed, invisibly print(clear_grid_cache()) # 0: already empty
Resolves a single polygon to tessellate within. With a boundary it is
that boundary (optionally buffered by expand); without one it is the
axis-aligned bounding box of points_sf (the rectangle in the working
CRS), again optionally buffered, or a small buffer around the points when
they all share one x or one y, or nearly so (the short side of their
bounding box below a millionth of the long side). Reach for it to build the
boundary that
method = "hex" and "square" require, to check that a study-area
polygon actually contains the observations before tessellating, or to
pass the same envelope to create_voronoi_polygons() and
create_grid_polygons() so that two tessellations of one dataset cover
identical ground.
clip_target_for(points_sf, boundary = NULL, expand = 0, quiet = FALSE)clip_target_for(points_sf, boundary = NULL, expand = 0, quiet = FALSE)
points_sf |
An sf object with POINT/MULTIPOINT geometry. |
boundary |
Optional polygonal sf object. One with no CRS, given with
points that have one, is interpreted in the points' own CRS, with a
warning. With points in a projected CRS it is read as |
expand |
Numeric expansion distance or fraction (0–1 = fraction of
extent). Absolute values are expressed in the units of the CRS the clip
target is built in. Because |
quiet |
Logical; suppress this function's progress |
It is not the target build_tessellation() derives on its own:
method = "voronoi" without a boundary clips to the convex hull of the
points buffered by 2 percent of its diagonal, and there expand is always
a distance. A bounding box over a non-rectangular point cloud includes
corners with no data, so a hex or square grid laid over it has cells that
hold no points; pass the study-area polygon when there is one.
An sf polygon layer representing the clip target. For lon/lat input
the layer is returned in the automatically selected local projected CRS,
not the input CRS; a message reports this unless quiet = TRUE.
Other spatial data preparation:
coerce_to_points(),
ensure_projected(),
harmonize_crs(),
prep_model_data()
library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(30, 0, 100), y = 5e6 + runif(30, 0, 100)), coords = c("x", "y"), crs = 32632 ) # No boundary: the bounding box, expanded by 10% of the extent box <- clip_target_for(pts, expand = 0.1, quiet = TRUE) st_area(box)library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(30, 0, 100), y = 5e6 + runif(30, 0, 100)), coords = c("x", "y"), crs = 32632 ) # No boundary: the bounding box, expanded by 10% of the extent box <- clip_target_for(pts, expand = 0.1, quiet = TRUE) st_area(box)
Returns the posterior summary of the global (non-spatial) regression terms:
estimate, error and credible interval per predictor, as
brms::fixef() reports them. Reach for it to read the average effect
of a predictor with its uncertainty attached (the Bayesian counterpart to
a coefficient table), remembering that the Gaussian-process term has
already absorbed the spatially structured part of the signal, so these are
effects net of location.
## S3 method for class 'bayesian_fit' coef(object, ...)## S3 method for class 'bayesian_fit' coef(object, ...)
object |
A |
... |
Ignored. |
A matrix of fixed-effect posterior summaries, as returned by
brms::fixef(), on the fitted scale (per standard deviation of each
predictor under standardize_predictors = TRUE; see above). Never
NULL: a missing 'brms' or a failing
fixef() call errors, following the coef() contract described
in new_spatial_fit.
The summaries are on the scale the model was fitted on. A fit made with
standardize_predictors = TRUE was fitted on centred and scaled
numeric predictors, so each slope is the change in the linear predictor per
standard deviation of its predictor and the intercept is its value
at the predictor means, not the raw-unit numbers stats::lm()
reports on the same formula. Nothing on the returned matrix says so;
print() on the fit does, and the centre and scale of each predictor
are in object$info$predictor_scaling. To put a slope back in raw
units divide its Estimate, Est.Error and interval bounds by
that predictor's scale. The intercept's Estimate follows by
linearity (subtract each raw-unit slope times its predictor's
center), but its Est.Error and interval depend on the
posterior covariance of the coefficients: transform the draws from
brms::as_draws_df(object$engine) for those, or refit without
standardising.
Other methods on a fitted model:
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
Returns the whole surface of coefficients, one row per observation and one
column per term, against the single global vector coef() returns
for an lm. That table is the point of fitting a GWR at all:
inspect the spread of a predictor's column to see where, and by how much,
its relationship with the response changes across the study area, and join
it back to object$data_sf to map it. Use
plot.spatial_fit() for a quick look at that map.
## S3 method for class 'gwr_fit' coef(object, ...)## S3 method for class 'gwr_fit' coef(object, ...)
object |
A |
... |
Ignored. |
A data.frame of local coefficient estimates: one row per
observation, one column per model term.
Never NULL: when the engine carries no SDF component this
errors, following the coef() contract described in
new_spatial_fit.
Only the model terms: the intercept and one column per predictor.
GWmodel's SDF data slot carries a good deal more alongside them
(standard errors, t-values, the observed response, the fitted values, the
residuals, Local_R2): 15 columns for a two-predictor fit, of which 3
are coefficients. Returning the whole slot would have made
coef(fit)$Local_R2 and coef(fit)$a_SE read like coefficients
and ncol(coef(fit)) a meaningless number. Reach for
object$engine$SDF when you want the rest; it is the unmodified
GWmodel object.
If the model terms cannot be located in the SDF (a GWmodel that
names its coefficient columns differently), the whole slot is returned with
a warning saying so. The call neither errors nor returns a silently short
table.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
Consistent with coef.gwr_fit() and coef.bayesian_fit(), which
also error, instead of returning NULL, when they cannot supply
coefficients. See the coef() contract in
new_spatial_fit.
## S3 method for class 'rf_fit' coef(object, ...)## S3 method for class 'rf_fit' coef(object, ...)
object |
An |
... |
Ignored. |
Never returns; always signals an error.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
Converts the geometry column of an sf object to POINTs using one of several strategies.
coerce_to_points( x, mode = c("auto", "centroid", "point_on_surface", "surface", "line_midpoint", "bbox_center"), tmp_project = TRUE )coerce_to_points( x, mode = c("auto", "centroid", "point_on_surface", "surface", "line_midpoint", "bbox_center"), tmp_project = TRUE )
x |
An sf object. |
mode |
One of "auto", "centroid", "point_on_surface", "surface", "line_midpoint", "bbox_center". |
tmp_project |
Logical; temporarily project for line-based midpoints.
When |
The result has one row per row of x, in the same order. An EMPTY
geometry of any type, lines included, becomes an EMPTY POINT in its own
row; prep_model_data() and make_folds() then drop such rows, as they
drop any other empty geometry. Empty lines are never handed to
sf::st_line_sample(): it yields no midpoint for them, which would
misalign the result, and with sf 1.0.x an empty MULTILINESTRING (or an
empty part of one) crashed the R session. An empty part inside a
non-empty feature is ignored, so the feature gets the point its other
parts give; GEOS's interior point, used by "point_on_surface" and by
the temporary projection's choice of CRS, segfaulted on an empty line
part too.
An sf object with geometry coerced to POINTs, row for row with
x; an empty input geometry gives an empty POINT.
Other spatial data preparation:
clip_target_for(),
ensure_projected(),
harmonize_crs(),
prep_model_data()
library(sf) poly <- st_sf( id = 1, geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(2, 0), c(2, 2), c(0, 2), c(0, 0) ))), crs = 32632) ) coerce_to_points(poly, "auto") # interior representative pointlibrary(sf) poly <- st_sf( id = 1, geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(2, 0), c(2, 2), c(0, 2), c(0, 0) ))), crs = 32632) ) coerce_to_points(poly, "auto") # interior representative point
Takes a named list of already-fit spatial_fit objects and produces
a tidy comparison table including in-sample (for a forest, out-of-bag)
metrics and model-specific information criteria (AICc, LOOIC).
compare_models(fits, newdata = NULL, ...)compare_models(fits, newdata = NULL, ...)
fits |
A named list of |
newdata |
Optional sf for out-of-sample evaluation. |
... |
Extra arguments passed to predict(). |
A data.frame comparing all models. Its metric_basis
column says what each row's metrics were computed on (see
evaluate_insample); a table that mixes
"out-of-bag" and "in-sample" rows does not rank the
models, and says so in the log. AICc (GWR) and LOOIC
(Bayesian) are sums over the rows a model was fitted to, so each column
is set to NA, with a warning, when the fits carrying it were
fitted to different rows (a predictor with missing values drops rows,
for example) or to different responses (a transformed response on the
same rows), and the warning says which. convergence_ok is
TRUE or FALSE for a Bayesian fit whose convergence was
checked (see fit_bayesian_spatial_model) and NA
otherwise; a fit that did not converge is ranked like the others, so it
also raises a warning. Alongside the metrics it
carries
resid_morans_I, resid_morans_z, resid_morans_p and
resid_morans_null, the last of which names the null
residual_morans_i scored each model against, since that
choice is per-fit and governs how much the p-value is worth. A
significant p-value is noted in the log (not raised as an R warning):
positive autocorrelation as structure the model may have missed,
negative (resid_morans_z < 0) as the alternating residuals of a
model that tracks its data closely. Read the caveats in
?residual_morans_i before treating silence as evidence of no
residual structure.
With newdata = NULL the metrics come from fitted(object).
That is in-sample for a gwr_fit or a bayesian_fit,
but out-of-bag for an rf_fit, whose fitted() method
returns out-of-bag predictions (see fit_rf_model). The
data.frame model_metrics() returns carries no label distinguishing
the two, so check object$info$fitted_are_oob before comparing
numbers across backends; evaluate_insample() and
compare_models() record it per model in a
metric_basis column. compare_models_cv scores every
backend the same way.
is with the total sum of squares taken about
the mean of the response the model was fitted to. In sample that
is the ordinary . With newdata it is out-of-sample
, the convention every cv_*() function uses: the model is
measured against the prediction it had to beat, the training mean, not
against the new rows' own mean, which it could not have known. It is
below 0 when the model predicts the new rows worse than the training mean
does, and it is NA when the response does not vary about that
baseline by more than rounding error (100 machine epsilons of its
magnitude, whatever its units).
MAPE divides by the observed value and SMAPE by
, so neither is defined where its denominator is zero.
Neither returns Inf or NaN. Both are averaged over the rows
whose denominator is non-zero, and are NA when no row qualifies.
Non-zero is judged at the scale of the data: a denominator no larger
than 100 machine epsilons times the largest one counts as zero, so the
rule does not depend on the units of the response.
The n_MAPE and n_SMAPE columns record how many rows that was;
the n column counts finite observation/prediction pairs. Read a
percentage error next to its count: when n_MAPE < n, MAPE is
an average over a subset of the data, whatever its value.
This bites on any response taking exact zeros: counts, rainfall,
abundance, claim amounts. On a zero-inflated response with 62 zeros out of
120, MAPE is an average over the 58 non-zero rows, which
n_MAPE = 58 now says. SMAPE fails differently and more
subtly: it drops the rows where observation and prediction are both near
zero (which on a well-fitted zero-inflated model are the rows it got
right), so it averages the harder rows only and reads worse than
the fit deserves; n_SMAPE shows how many rows it kept, and the
count is only a label, not a repair.
RMSE, MAE and use every finite row and are
unaffected; prefer them whenever the response can be zero. For a Bayesian
fit, cv_bayes() additionally reports CRPS and interval
coverage, which are proper scoring rules and have no such failure mode.
Other model evaluation:
compare_models_cv(),
evaluate_insample(),
model_metrics(),
residual_morans_i()
if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(60, 0, 1000), y = 5e6 + runif(60, 0, 1000), a = rnorm(60), b = rnorm(60)), coords = c("x", "y"), crs = 32632 ) # An offset keeps the response away from zero, where MAPE is undefined. pts$z <- 10 + 2 * pts$a - pts$b + rnorm(60, 0, 0.3) # Two predictor sets for the same learner: the model that leaves `b` out # against the one that has it. These are in-sample (out-of-bag) numbers; # compare_models_cv() scores the same question on spatial folds. fits <- list(a_only = fit_rf_model(pts, "z", "a", num_trees = 100, seed = 1), a_and_b = fit_rf_model(pts, "z", c("a", "b"), num_trees = 100, seed = 1)) compare_models(fits) }if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(60, 0, 1000), y = 5e6 + runif(60, 0, 1000), a = rnorm(60), b = rnorm(60)), coords = c("x", "y"), crs = 32632 ) # An offset keeps the response away from zero, where MAPE is undefined. pts$z <- 10 + 2 * pts$a - pts$b + rnorm(60, 0, 0.3) # Two predictor sets for the same learner: the model that leaves `b` out # against the one that has it. These are in-sample (out-of-bag) numbers; # compare_models_cv() scores the same question on spatial folds. fits <- list(a_only = fit_rf_model(pts, "z", "a", num_trees = 100, seed = 1), a_and_b = fit_rf_model(pts, "z", c("a", "b"), num_trees = 100, seed = 1)) compare_models(fits) }
Fits and cross-validates one or more model types, returning a unified
comparison table. Unlike compare_models(), this function does
perform fitting (inside CV folds), because CV inherently requires
repeated fitting.
compare_models_cv( data_sf, response_var, predictor_vars, models = c("GWR", "Bayesian"), k = 5, seed = 123, folds = NULL, boundary = NULL, pointize = "auto", gwr_args = list(), bayes_args = list(), rf_args = list(), summary = c("mean", "median"), quiet = FALSE, block_size = NULL, auto_range = FALSE, metrics = NULL )compare_models_cv( data_sf, response_var, predictor_vars, models = c("GWR", "Bayesian"), k = 5, seed = 123, folds = NULL, boundary = NULL, pointize = "auto", gwr_args = list(), bayes_args = list(), rf_args = list(), summary = c("mean", "median"), quiet = FALSE, block_size = NULL, auto_range = FALSE, metrics = NULL )
data_sf |
An sf object. |
response_var |
Response column name. |
predictor_vars |
Predictor column names. |
models |
Character vector: any subset of |
k |
Number of folds. Default 5. |
seed |
RNG seed. Default 123. |
folds |
Optional fold definitions: a |
boundary |
Optional polygon sf/sfc. |
pointize |
Geometry coercion strategy. It also decides where a polygon or line row falls in the shared blocks, so each row is placed by the point every model is fitted at. |
gwr_args |
Extra arguments for |
bayes_args |
Extra arguments for |
rf_args |
Extra arguments for |
summary |
"mean" or "median" for Bayesian predictions. |
quiet |
Logical; suppress this function's progress |
block_size |
Optional minimum block edge length for the shared spatial
CV blocks (projected CRS units), passed to |
auto_range |
Logical. If |
metrics |
Optional scoring function of your own, handed to every
backend's |
A list with overall, by_fold, and per-model cv_results
(gwr_cv, bayes_cv, rf_cv for the models that ran).
overall has one row per model with the pooled metrics,
n_pred (the rows they are computed on), the
coverage and CRPS columns described above when a Bayesian model ran, and
model as its last column.
Shared folds do not guarantee shared rows: a model that fails on a fold
(GWR with a fixed bandwidth across a gap in the data, say) or predicts
NA for some rows pools fewer rows, usually without the hardest
ones. When the models predicted different rows, the function warns and
recomputes every model's pooled metrics, your own metrics
included, on the rows all of them predicted, so n_pred is the same
on every row that has predictions; a model that predicted nothing stays
an NA row. Each model's metrics over all the rows it predicted
stay in its *_cv element and in attr(overall, "all_rows"),
a table of the same shape. by_fold and the Bayesian coverage and
CRPS columns are per fold and are not recomputed.
Only the models that actually ran appear, so check which names are
present; there is not always one entry per requested model, because a
backend whose package is missing is dropped with a message. When no requested backend
is available there is nothing to return and the function errors with
"no viable models." instead of returning an empty comparison.
A model can predict well on average and still be badly calibrated, so a
comparison read from RMSE alone can prefer the model whose uncertainty is
wrong. Heaton et al. (2019) found good point prediction routinely
alongside poor interval coverage. When "Bayesian" is among the
models that ran, overall therefore also carries the columns of
cv_bayes()$predictive_coverage: coverage_50,
coverage_80, coverage_95 (the share of held-out rows inside
the posterior predictive interval at each level, averaged across folds
weighted by each fold's n_pred) and mean_CRPS (the
continuous ranked probability score, lower is better). They are NA
on the GWR and RF rows, which produce point predictions and no draws, and
absent when no Bayesian model ran. Read coverage against its nominal
level: 0.95 at coverage_95 is calibrated, well below it is
overconfident, well above it is wider than it needs to be.
MAPE divides by the observed value and SMAPE by
, so neither is defined where its denominator is zero.
Neither returns Inf or NaN. Both are averaged over the rows
whose denominator is non-zero, and are NA when no row qualifies.
Non-zero is judged at the scale of the data: a denominator no larger
than 100 machine epsilons times the largest one counts as zero, so the
rule does not depend on the units of the response.
The n_MAPE and n_SMAPE columns record how many rows that was;
the n column counts finite observation/prediction pairs. Read a
percentage error next to its count: when n_MAPE < n, MAPE is
an average over a subset of the data, whatever its value.
This bites on any response taking exact zeros: counts, rainfall,
abundance, claim amounts. On a zero-inflated response with 62 zeros out of
120, MAPE is an average over the 58 non-zero rows, which
n_MAPE = 58 now says. SMAPE fails differently and more
subtly: it drops the rows where observation and prediction are both near
zero (which on a well-fitted zero-inflated model are the rows it got
right), so it averages the harder rows only and reads worse than
the fit deserves; n_SMAPE shows how many rows it kept, and the
count is only a label, not a repair.
RMSE, MAE and use every finite row and are
unaffected; prefer them whenever the response can be zero. For a Bayesian
fit, cv_bayes() additionally reports CRPS and interval
coverage, which are proper scoring rules and have no such failure mode.
RMSE and MAE are defined for any numeric response and are what to read for a count, a rate or a bounded outcome. MAPE and SMAPE assume a response that is rarely zero (see the previous section), and R-squared and adjusted R-squared compare residual variance to total variance, which is the right comparison for a Gaussian response and a loose one for anything whose variance tracks its mean. None of the four is wrong to compute; each is Gaussian-shaped thinking, and on a Poisson or zero-inflated response should be read as a rough summary rather than a score.
For the Bayesian backend, cv_bayes() additionally reports
CRPS and interval coverage at 50, 80 and 95 percent. Both are proper
scoring rules computed from posterior draws, so they are meaningful for any
family that predicts one number per row (a count, a rate, a binary
or bounded outcome), and they are the numbers to compare when the response
is not Gaussian. A categorical or ordinal family predicts a probability
per response category instead, so cv_bayes() refuses one before
fitting anything. When every fold fails, the
fold_metrics frame cv_bayes() returns carries the CRPS column
but not the coverage_* columns, so code that reads those columns
must tolerate their absence.
Heaton, M. J., Datta, A., Finley, A. O., Furrer, R., Guinness, J., Guhaniyogi, R., Gerber, F., Gramacy, R. B., Hammerling, D., Katzfuss, M., Lindgren, F., Nychka, D. W., Sun, F. and Zammit-Mangion, A. (2019). A case study competition among methods for analyzing large spatial data. Journal of Agricultural, Biological and Environmental Statistics, 24(3), 398–425. doi:10.1007/s13253-018-00348-w
Other model evaluation:
compare_models(),
evaluate_insample(),
model_metrics(),
residual_morans_i()
if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) n <- 120 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$price <- 10 + 0.01 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) # The random forest, and GWR beside it when GWmodel is installed, on the # same folds. "Bayesian" is left out here because a Stan fit per fold # takes minutes; add it to `models` for a run to report. models <- c("RF", if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE)) "GWR") cmp <- compare_models_cv(dat, "price", "elev", models = models, k = 3, rf_args = list(num_trees = 100), gwr_args = list(bandwidth = 30)) cmp$overall }if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) n <- 120 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$price <- 10 + 0.01 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) # The random forest, and GWR beside it when GWmodel is installed, on the # same folds. "Bayesian" is left out here because a Stan fit per fold # takes minutes; add it to `models` for a run to report. models <- c("RF", if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE)) "GWR") cmp <- compare_models_cv(dat, "price", "elev", models = models, k = 3, rf_args = list(num_trees = 100), gwr_args = list(bandwidth = 30)) cmp$overall }
Lays a regular grid of equal-area cells over boundary and clips it to that
boundary. Reach for this rather than create_voronoi_polygons() when cell
size should be a decision you make, instead of one dictated by where the
observations happen to be. That is the case when you need per-cell rates
comparable across the map, or a resolution that stays fixed as the sample
grows. Hexagons (type = "hex") avoid the axis-aligned artefacts of squares
and give every cell the same distance to all six neighbours, which matters
for anything that reads neighbourhoods.
create_grid_polygons( boundary, target_cells = NULL, type = c("square", "hex"), cellsize = NULL, n = NULL, clip = TRUE, crs = NULL, quiet = FALSE, max_cells = 1e+06 )create_grid_polygons( boundary, target_cells = NULL, type = c("square", "hex"), cellsize = NULL, n = NULL, clip = TRUE, crs = NULL, quiet = FALSE, max_cells = 1e+06 )
boundary |
Polygonal sf or sfc object. |
target_cells |
Optional approximate desired number of cells. The cell
size is derived from it as |
type |
Grid type: |
cellsize |
Optional numeric cell size (length 1 or 2), in the units of
the working CRS. Takes precedence over |
n |
Optional grid resolution (integer, length 1 or 2) giving the number
of columns and rows to divide the boundary's bounding box into; the cell
size is derived from it. |
clip |
Logical; clip grid to boundary. |
crs |
Optional target CRS: anything |
quiet |
Logical; suppress this function's progress |
max_cells |
Upper bound on the number of cells the grid may have,
estimated from the boundary's bounding box before anything is built.
Default |
Size the grid with exactly one of target_cells (roughly how many cells you
want, the package derives the rest), cellsize (a fixed edge length in CRS
units) or n (a fixed number of columns and rows). See @param cellsize
for what happens when more than one is given.
An sf polygon layer with poly_id column.
Other tessellation:
build_tessellation(),
create_grid_polygons_cached(),
create_voronoi_polygons(),
ensure_stable_poly_id(),
get_voronoi_seeds(),
plot_tessellation_map(),
voronoi_seeds_kmeans(),
voronoi_seeds_random()
library(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) grid_sq <- create_grid_polygons(bnd, target_cells = 100, type = "square") grid_hex <- create_grid_polygons(bnd, target_cells = 100, type = "hex") nrow(grid_sq) # Hex counts run above target because clipping keeps every hexagon that # merely overhangs the boundary; the inflation is proportionally larger # at small target_cells. nrow(grid_hex)library(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) grid_sq <- create_grid_polygons(bnd, target_cells = 100, type = "square") grid_hex <- create_grid_polygons(bnd, target_cells = 100, type = "hex") nrow(grid_sq) # Hex counts run above target because clipping keeps every hexagon that # merely overhangs the boundary; the inflation is proportionally larger # at small target_cells. nrow(grid_hex)
Builds a grid via create_grid_polygons() and memoizes the result
so repeated calls with the same inputs return instantly.
create_grid_polygons_cached( boundary, target_cells = NULL, type = c("square", "hex"), ..., cache_env = .gmt_cache, max_entries = 50L )create_grid_polygons_cached( boundary, target_cells = NULL, type = c("square", "hex"), ..., cache_env = .gmt_cache, max_entries = 50L )
boundary |
An sf or sfc polygonal object. |
target_cells |
Approximate desired number of cells. Default |
type |
Grid type: |
... |
Additional arguments forwarded to create_grid_polygons(). |
cache_env |
Environment for memoized grids. Default .gmt_cache. |
max_entries |
Maximum number of grids the cache holds. Default 50.
Once full, adding a grid evicts the one added earliest, so a loop over
many boundaries holds at most this many grids (about 2 MB per 2,500-cell
grid) rather than every grid it ever built for the life of the session.
|
An sf data frame with a stable poly_id column. The rows are
re-ordered and re-numbered by ensure_stable_poly_id,
which create_grid_polygons does not do: the same cell
therefore carries a different poly_id depending on which of the two
builders produced it. Use one builder throughout an analysis; joining a
summary keyed on IDs from one onto geometries from the other draws the
values on the wrong polygons.
Other tessellation:
build_tessellation(),
create_grid_polygons(),
create_voronoi_polygons(),
ensure_stable_poly_id(),
get_voronoi_seeds(),
plot_tessellation_map(),
voronoi_seeds_kmeans(),
voronoi_seeds_random()
Other package options and caches:
clear_fitted_cache(),
clear_grid_cache(),
spatialkit_quiet()
library(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) g <- create_grid_polygons_cached(bnd, target_cells = 16, type = "hex") nrow(g) # The IDs come from ensure_stable_poly_id(), so they follow the geometry: # the same request with the boundary's vertices in another order gives the # same ID to the same cell. bnd2 <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(100, 100), c(0, 100), c(0, 0), c(100, 0), c(100, 100) ))), crs = 32632)) g2 <- create_grid_polygons_cached(bnd2, target_cells = 16, type = "hex") same_cell <- match(st_as_text(st_geometry(g2)), st_as_text(st_geometry(g))) all(g2$poly_id == g$poly_id[same_cell])library(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) g <- create_grid_polygons_cached(bnd, target_cells = 16, type = "hex") nrow(g) # The IDs come from ensure_stable_poly_id(), so they follow the geometry: # the same request with the boundary's vertices in another order gives the # same ID to the same cell. bnd2 <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(100, 100), c(0, 100), c(0, 0), c(100, 0), c(100, 100) ))), crs = 32632)) g2 <- create_grid_polygons_cached(bnd2, target_cells = 16, type = "hex") same_cell <- match(st_as_text(st_geometry(g2)), st_as_text(st_geometry(g))) all(g2$poly_id == g$poly_id[same_cell])
Assigns every location in the study area to its nearest input point, giving
one cell per point. This is the tessellation to reach for when the
observations themselves define the regions of interest (sampling sites,
monitoring stations, service points), because cell size then adapts to
sampling density instead of being imposed by a fixed grid: dense areas get
small cells and sparse areas large ones. Prefer create_grid_polygons()
instead when you need equal-area cells or a resolution independent of where
the data happen to be.
create_voronoi_polygons( points_sf, boundary = NULL, expand = 0, clip = TRUE, keep_duplicates = FALSE, crs = NULL, quiet = FALSE )create_voronoi_polygons( points_sf, boundary = NULL, expand = 0, clip = TRUE, keep_duplicates = FALSE, crs = NULL, quiet = FALSE )
points_sf |
An sf object with POINT/MULTIPOINT geometries. Points with
no CRS whose coordinates look like lon/lat (the heuristic
|
boundary |
Optional polygonal sf object. When exactly one of
|
expand |
Numeric; absolute distance, in the working CRS's units, by
which the boundary (or the hull derived from the points) is grown before
the diagram is built. With |
clip |
Logical; intersect cells with boundary. |
keep_duplicates |
Logical. Has no effect on the result: coincident points are merged before the diagram is built either way, so they share one cell and all of them are indexed to it. |
crs |
Optional target CRS: anything |
quiet |
Logical; suppress this function's progress |
The heavy lifting is sf::st_voronoi(). What this adds is the surrounding
bookkeeping: projecting lon/lat input, building and buffering an envelope
so edge cells are bounded, clipping to boundary, restoring the
point-to-cell correspondence that st_voronoi() scrambles, and stamping
stable cell_id values.
The generators are the points' vertices, not the features. A MULTIPOINT
feature with several vertices therefore gets one cell per vertex, and its
index entry is the smallest cell_id among the cells it touches; the
others are referenced by no feature. Other functions in the package
(prep_model_data(), make_folds()) reduce such a feature to its
centroid instead, so cast to POINT, or take centroids, first if one cell
per feature is what you want.
A list with cells, index, boundary,
method and params. index holds one cell_id
per row of points_sf, and NA for a point that falls outside
every cell, which means outside the study area (grown by expand
when it is positive), so a summary built from it counts only the points
the tessellation actually covers. boundary is the boundary the
cells were built in: the one supplied or derived, grown by
expand.
Other tessellation:
build_tessellation(),
create_grid_polygons(),
create_grid_polygons_cached(),
ensure_stable_poly_id(),
get_voronoi_seeds(),
plot_tessellation_map(),
voronoi_seeds_kmeans(),
voronoi_seeds_random()
library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(15, 0, 100), y = 5e6 + runif(15, 0, 100)), coords = c("x", "y"), crs = 32632 ) res <- create_voronoi_polygons(pts, quiet = TRUE) res$cells # one polygon per unique point, with stable cell_id res$index # cell_id assignment for each input pointlibrary(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(15, 0, 100), y = 5e6 + runif(15, 0, 100)), coords = c("x", "y"), crs = 32632 ) res <- create_voronoi_polygons(pts, quiet = TRUE) res$cells # one polygon per unique point, with stable cell_id res$index # cell_id assignment for each input point
Refits the Gaussian-process model of
fit_bayesian_spatial_model() on each training fold and scores
it on the held-out fold. Beyond the point-prediction metrics the other CV
wrappers report, this one scores the whole predictive distribution:
predictive_coverage says what fraction of held-out observations fell
inside the 50/80/95% intervals, and mean_CRPS rates sharpness and
calibration together. That is the reason to reach for it. A Bayesian model
is usually chosen for its uncertainty, and only held-out coverage shows
whether those intervals are honest at locations the model has not seen.
cv_bayes( data_sf, response_var, predictor_vars, folds = NULL, k = 5, seed = 123, boundary = NULL, pointize = "auto", fit_args = list(), summary = c("mean", "median"), compute_pred_intervals = TRUE, coverage_levels = c(0.5, 0.8, 0.95), block_size = NULL, auto_range = FALSE, parallel = FALSE, metrics = NULL )cv_bayes( data_sf, response_var, predictor_vars, folds = NULL, k = 5, seed = 123, boundary = NULL, pointize = "auto", fit_args = list(), summary = c("mean", "median"), compute_pred_intervals = TRUE, coverage_levels = c(0.5, 0.8, 0.95), block_size = NULL, auto_range = FALSE, parallel = FALSE, metrics = NULL )
data_sf |
An sf object. |
response_var |
Response column name. |
predictor_vars |
Predictor column names. |
folds |
Optional fold definitions, in any of three shapes: a
|
k |
Number of folds. Default 5. |
seed |
RNG seed. Default 123. It seeds fold construction and,
through a per-fold draw, each fold's Stan sampler, so two seeds give
different posteriors even on identical |
boundary |
Optional polygonal sf/sfc for CRS alignment. |
pointize |
Geometry coercion strategy. |
fit_args |
Named list of extra arguments for fit_bayesian_spatial_model().
A user-supplied |
summary |
"mean" or "median" for posterior predictions. |
compute_pred_intervals |
Logical; compute predictive intervals. |
coverage_levels |
Numeric vector of the nominal coverage levels to
score, as proportions strictly between 0 and 1 ( |
block_size |
Optional minimum block edge length for spatial CV blocks (projected CRS units). |
auto_range |
Logical. If |
parallel |
Logical or positive integer. If |
metrics |
Optional scoring function of your own, a
|
It is the most expensive wrapper in the package by a wide margin: every fold
is a full MCMC run. Use few folds, and parallel = TRUE if you have
the cores. For a cheap first pass on the same question, cross-validate a
forest with cv_rf() and come back here once the predictor set
has settled.
A list with overall, fold_metrics,
predictions, folds, n_folds_attempted,
n_folds_succeeded, fold_status, orphan_rows,
n_unknown_ids, n_dropped, formula,
predictive_coverage and coverage_levels (the nominal
levels, named by their coverage_* column).
The two fold counts make a run where every fold failed visible in the
return value itself, beyond the warning, and fold_status (one
row per fold: fold, status, message) keeps the
reason each missing fold is missing, including the error text of a fold
whose sampler failed, where a long run's console output would not.
See cv_spatial for the five statuses and for
orphan_rows. predictions carries,
beyond the columns its siblings share, yhat_sd: the posterior
predictive standard deviation of each held-out row, from the same draws
that give the coverage below (NA when
compute_pred_intervals = FALSE or the draws failed for that fold).
overall$Adj_R2 is always NA, as for every cv_*():
see cv_spatial.
fold_metrics carries, beyond the columns its siblings share,
gp_k, gp_n_basis, n_draws, CRPS, the
coverage_* columns and convergence_ok: TRUE or
FALSE as fit_bayesian_spatial_model() judged that
fold's sampler (R-hat, effective sample size, divergences), NA
when fit_args sets check_convergence = FALSE. A fold that
did not converge is scored like the others, so a run with any
FALSE raises one warning naming those folds.
The predictive_coverage entries (one per
coverage_levels value, plus mean_CRPS) are averages across
folds weighted by each fold's n_pred, because the per-fold
values in fold_metrics are themselves means over that fold's test
rows; an unweighted average would not be the pooled quantity when fold
sizes differ, which for spatially blocked folds they routinely do.
MAPE divides by the observed value and SMAPE by
, so neither is defined where its denominator is zero.
Neither returns Inf or NaN. Both are averaged over the rows
whose denominator is non-zero, and are NA when no row qualifies.
Non-zero is judged at the scale of the data: a denominator no larger
than 100 machine epsilons times the largest one counts as zero, so the
rule does not depend on the units of the response.
The n_MAPE and n_SMAPE columns record how many rows that was;
the n column counts finite observation/prediction pairs. Read a
percentage error next to its count: when n_MAPE < n, MAPE is
an average over a subset of the data, whatever its value.
This bites on any response taking exact zeros: counts, rainfall,
abundance, claim amounts. On a zero-inflated response with 62 zeros out of
120, MAPE is an average over the 58 non-zero rows, which
n_MAPE = 58 now says. SMAPE fails differently and more
subtly: it drops the rows where observation and prediction are both near
zero (which on a well-fitted zero-inflated model are the rows it got
right), so it averages the harder rows only and reads worse than
the fit deserves; n_SMAPE shows how many rows it kept, and the
count is only a label, not a repair.
RMSE, MAE and use every finite row and are
unaffected; prefer them whenever the response can be zero. For a Bayesian
fit, cv_bayes() additionally reports CRPS and interval
coverage, which are proper scoring rules and have no such failure mode.
RMSE and MAE are defined for any numeric response and are what to read for a count, a rate or a bounded outcome. MAPE and SMAPE assume a response that is rarely zero (see the previous section), and R-squared and adjusted R-squared compare residual variance to total variance, which is the right comparison for a Gaussian response and a loose one for anything whose variance tracks its mean. None of the four is wrong to compute; each is Gaussian-shaped thinking, and on a Poisson or zero-inflated response should be read as a rough summary rather than a score.
For the Bayesian backend, cv_bayes() additionally reports
CRPS and interval coverage at 50, 80 and 95 percent. Both are proper
scoring rules computed from posterior draws, so they are meaningful for any
family that predicts one number per row (a count, a rate, a binary
or bounded outcome), and they are the numbers to compare when the response
is not Gaussian. A categorical or ordinal family predicts a probability
per response category instead, so cv_bayes() refuses one before
fitting anything. When every fold fails, the
fold_metrics frame cv_bayes() returns carries the CRPS column
but not the coverage_* columns, so code that reads those columns
must tolerate their absence.
Other cross-validation:
area_of_applicability(),
cv_block_size_sweep(),
cv_gwr(),
cv_rf(),
cv_spatial(),
estimate_sac_range(),
fold_separation(),
gwr_model_selection(),
make_folds(),
sac_nugget(),
select_features_forward()
## Not run: # Not run: fits with Stan, which needs a working C++ toolchain and takes # minutes of MCMC -- both outside what an example may assume. if (requireNamespace("brms", quietly = TRUE)) { library(sf) set.seed(1) n <- 60 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$price <- 10 + 0.01 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) # Two short chains per fold keep this to a few minutes and leave the # posterior rough, so the intervals below will run narrow; a run to # report uses brms's defaults (chains = 4, iter = 2000). cv <- cv_bayes(dat, "price", "elev", k = 2, fit_args = list(chains = 2, iter = 1000)) print(cv$overall) cv$predictive_coverage # coverage at 50/80/95% plus mean CRPS } ## End(Not run)## Not run: # Not run: fits with Stan, which needs a working C++ toolchain and takes # minutes of MCMC -- both outside what an example may assume. if (requireNamespace("brms", quietly = TRUE)) { library(sf) set.seed(1) n <- 60 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$price <- 10 + 0.01 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) # Two short chains per fold keep this to a few minutes and leave the # posterior rough, so the intervals below will run narrow; a run to # report uses brms's defaults (chains = 4, iter = 2000). cv <- cv_bayes(dat, "price", "elev", k = 2, fit_args = list(chains = 2, iter = 1000)) print(cv$overall) cv$predictive_coverage # coverage at 50/80/95% plus mean CRPS } ## End(Not run)
A blocked cross-validation with blocks smaller than the autocorrelation
range leaks: every held-out point has a near-identical neighbour in the
training set, and the score is optimistic in proportion. The single
number a cv_*() call returns cannot show this. This runs the same
cross-validation at a ladder of block sizes, and by default once with
random folds as the fully leaky reference. It returns the metric at each,
with the estimated autocorrelation range alongside so that the curve can be
read against it: it rises as the blocks pass the range and then plateaus,
and the height of the rise is how much the random-fold number overstated
the model.
cv_block_size_sweep( data_sf, response_var, predictor_vars, fit_fn, block_sizes = NULL, n_sizes = 6L, k = 5L, metric = "RMSE", include_random = TRUE, max_fits = 60L, sac = NULL, seed = 123L, quiet = FALSE, ... )cv_block_size_sweep( data_sf, response_var, predictor_vars, fit_fn, block_sizes = NULL, n_sizes = 6L, k = 5L, metric = "RMSE", include_random = TRUE, max_fits = 60L, sac = NULL, seed = 123L, quiet = FALSE, ... )
data_sf |
An sf object with the response and predictors. |
response_var, predictor_vars
|
Column names. |
fit_fn |
A |
block_sizes |
Optional numeric vector of block edge lengths to sweep,
in the CRS units the folds are built in (plain numbers; a |
n_sizes |
Number of sizes in the default ladder. Default 6. |
k |
Folds per cross-validation. Default 5. |
metric |
Which column of |
include_random |
Also cross-validate with random folds, as the leaky
reference. Default |
max_fits |
The fit budget; see above. Default 60. |
sac |
Optional |
seed |
Seed for the fold construction at every size. |
quiet |
Suppress the progress messages. Default |
... |
Passed to |
A data.frame of class "block_size_sweep" with one row per
cross-validation: block_size (NA for the random
reference), method, blocks_used, k (the folds
actually built), n_folds_succeeded, value (the pooled
metric), fold_min, fold_max and fold_sd (its spread
across folds). Attributes: metric, sac_range (the
effective range, or NA), crs, k (the folds
requested, which print() and plot() report),
n_fits, response_var, and results, the full
cv_spatial() result at every size.
plot() draws it.
Each block size is a full cross-validation, so the cost is
length(block_sizes) * k fits, plus k for the random
reference. max_fits caps that (default 60: room for up to eleven
sizes at k = 5 plus the reference; the default six-size ladder at
k = 5 needs at most 35). A sweep that would run past the cap
refuses to start, naming the number of fits it would have needed. Raise
max_fits deliberately; the RF example below takes seconds, a Bayesian
fit_fn takes minutes per fit.
When block_sizes is NULL, n_sizes values are
log-spaced from a twenty-fifth of the shorter side of the data's extent
(of the longer side when the points lie on one line parallel to an axis)
to the largest size, at most half that side, at which the grid still
holds k blocks. Half the side gives a grid two blocks across,
enough for k up to 4 and, for larger k, on an extent long
enough in the other direction; on a roughly square extent at the default
k = 5 the top is about a third of the side. Any size at which the
grid would hold more than the 1,000,000 make_folds() will build is
dropped. The count is of grid cells: on clustered data a grid
of k or more cells can have fewer than k that hold points,
and make_folds() then lowers k at that size, which the
k column shows. On an extent much longer than it is wide every
rung can fall below the autocorrelation range while longer blocks would
still fit k times along the longer side; the sweep warns when
that happens, and block_sizes is then the way to reach past the
range. Sizes are in the units of the CRS the folds are built in
(make_folds()'s params$crs, metres for geographic input),
and the returned table records that CRS. Each is the minimum block
edge handed to make_folds(), which fits a whole number of cells
across the extent, so the cells are somewhat longer than the size on the
axis (up to twice as long).
Other cross-validation:
area_of_applicability(),
cv_bayes(),
cv_gwr(),
cv_rf(),
cv_spatial(),
estimate_sac_range(),
fold_separation(),
gwr_model_selection(),
make_folds(),
sac_nugget(),
select_features_forward()
if (requireNamespace("ranger", quietly = TRUE) && requireNamespace("gstat", quietly = TRUE)) { library(sf) set.seed(1) n <- 200 x <- 5e5 + runif(n, 0, 1000); y <- 5e6 + runif(n, 0, 1000) d <- as.matrix(dist(cbind(x, y))) field <- as.numeric(t(chol(exp(-d / 100) + diag(1e-6, n))) %*% rnorm(n)) dat <- st_as_sf(data.frame(x = x, y = y, a = rnorm(n)), coords = c("x", "y"), crs = 32632) dat$z <- field + 0.5 * dat$a + rnorm(n, 0, 0.2) rf_fn <- function(train_sf) fit_rf_model(train_sf, "z", "a", include_coords = TRUE, num_trees = 100, seed = 1) sw <- cv_block_size_sweep(dat, "z", "a", fit_fn = rf_fn, k = 4, n_sizes = 4, quiet = TRUE) print(sw) # the curve as a table, with the random-fold reference if (requireNamespace("ggplot2", quietly = TRUE)) plot(sw) }if (requireNamespace("ranger", quietly = TRUE) && requireNamespace("gstat", quietly = TRUE)) { library(sf) set.seed(1) n <- 200 x <- 5e5 + runif(n, 0, 1000); y <- 5e6 + runif(n, 0, 1000) d <- as.matrix(dist(cbind(x, y))) field <- as.numeric(t(chol(exp(-d / 100) + diag(1e-6, n))) %*% rnorm(n)) dat <- st_as_sf(data.frame(x = x, y = y, a = rnorm(n)), coords = c("x", "y"), crs = 32632) dat$z <- field + 0.5 * dat$a + rnorm(n, 0, 0.2) rf_fn <- function(train_sf) fit_rf_model(train_sf, "z", "a", include_coords = TRUE, num_trees = 100, seed = 1) sw <- cv_block_size_sweep(dat, "z", "a", fit_fn = rf_fn, k = 4, n_sizes = 4, quiet = TRUE) print(sw) # the curve as a table, with the random-fold reference if (requireNamespace("ggplot2", quietly = TRUE)) plot(sw) }
Refits a geographically weighted regression from scratch on each training
fold and scores it on the held-out fold, so the reported error is what the
model achieves at locations it did not see. Reach for it whenever you need
a defensible accuracy figure for a GWR: the in-sample that
model_metrics() reports on a gwr_fit is close to
meaningless, because a local regression with a small bandwidth can track the
training points almost exactly. Bandwidth is re-selected per fold unless you
fix it with bandwidth, which keeps the selection itself inside the
cross-validation, with no tuning on the full data first.
cv_gwr( data_sf, response_var, predictor_vars, folds = NULL, k = 5, seed = 123, adaptive = TRUE, bandwidth = NULL, kernel = c("bisquare", "gaussian", "tricube", "boxcar", "exponential"), boundary = NULL, pointize = "auto", block_size = NULL, auto_range = FALSE, parallel = FALSE, metrics = NULL )cv_gwr( data_sf, response_var, predictor_vars, folds = NULL, k = 5, seed = 123, adaptive = TRUE, bandwidth = NULL, kernel = c("bisquare", "gaussian", "tricube", "boxcar", "exponential"), boundary = NULL, pointize = "auto", block_size = NULL, auto_range = FALSE, parallel = FALSE, metrics = NULL )
data_sf |
An sf object. |
response_var |
Response column name. |
predictor_vars |
Predictor column names. |
folds |
Optional fold definitions, in any of three shapes: a
|
k |
Number of folds. Default 5. |
seed |
RNG seed. Default 123. |
adaptive |
Logical; use adaptive bandwidth. Default TRUE. |
bandwidth |
Optional bandwidth, applied to every fold. For
|
kernel |
Kernel function type. |
boundary |
Optional polygonal sf/sfc for CRS alignment. |
pointize |
Geometry coercion strategy. |
block_size |
Optional minimum block edge length for spatial CV blocks (projected CRS units). Blocks are then at least as large as the spatial autocorrelation range. |
auto_range |
Logical. If |
parallel |
Accepted so that every |
metrics |
Optional scoring function of your own, a
|
Folds default to spatial blocks (make_folds(method =
"block_kfold")), not random ones. With autocorrelated data a random split
leaves a held-out point's neighbours in the training set and the score comes
back flattering. Use cv_bayes() for the same treatment
of a Bayesian GP model, cv_rf() for a forest, and
compare_models_cv() to score several backends on one set of
folds.
A list with overall, fold_metrics,
predictions, folds, n_folds_attempted,
n_folds_succeeded, fold_status, orphan_rows,
n_unknown_ids, n_dropped, formula and
adaptive. The two
fold counts make a run where every fold failed visible in the return
value itself, beyond the warning, since overall is a
well-formed all-NA row either way, and fold_status (one
row per fold: fold, status, message) says why each
missing fold is missing. See cv_spatial for the five
statuses and for orphan_rows. Adj_R2 is NA
in both overall and fold_metrics: the pooled predictions
have no single parameter count, and a GWR's effective parameter count is
not its predictor count (see cv_spatial).
MAPE divides by the observed value and SMAPE by
, so neither is defined where its denominator is zero.
Neither returns Inf or NaN. Both are averaged over the rows
whose denominator is non-zero, and are NA when no row qualifies.
Non-zero is judged at the scale of the data: a denominator no larger
than 100 machine epsilons times the largest one counts as zero, so the
rule does not depend on the units of the response.
The n_MAPE and n_SMAPE columns record how many rows that was;
the n column counts finite observation/prediction pairs. Read a
percentage error next to its count: when n_MAPE < n, MAPE is
an average over a subset of the data, whatever its value.
This bites on any response taking exact zeros: counts, rainfall,
abundance, claim amounts. On a zero-inflated response with 62 zeros out of
120, MAPE is an average over the 58 non-zero rows, which
n_MAPE = 58 now says. SMAPE fails differently and more
subtly: it drops the rows where observation and prediction are both near
zero (which on a well-fitted zero-inflated model are the rows it got
right), so it averages the harder rows only and reads worse than
the fit deserves; n_SMAPE shows how many rows it kept, and the
count is only a label, not a repair.
RMSE, MAE and use every finite row and are
unaffected; prefer them whenever the response can be zero. For a Bayesian
fit, cv_bayes() additionally reports CRPS and interval
coverage, which are proper scoring rules and have no such failure mode.
Other cross-validation:
area_of_applicability(),
cv_bayes(),
cv_block_size_sweep(),
cv_rf(),
cv_spatial(),
estimate_sac_range(),
fold_separation(),
gwr_model_selection(),
make_folds(),
sac_nugget(),
select_features_forward()
if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE)) { library(sf) set.seed(1) n <- 60 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$price <- 10 + 0.01 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) cv <- cv_gwr(dat, "price", "elev", k = 3, bandwidth = 30) print(cv$overall) # pooled over the held-out rows of every fold cv$fold_metrics # and fold by fold }if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE)) { library(sf) set.seed(1) n <- 60 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$price <- 10 + 0.01 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) cv <- cv_gwr(dat, "price", "elev", k = 3, bandwidth = 30) print(cv$overall) # pooled over the held-out rows of every fold cv$fold_metrics # and fold by fold }
Thin wrapper over cv_spatial that refits a ranger
forest on each training fold. Unlike the out-of-bag error, this holds out
whole spatial blocks, so neighbours of a held-out point are not sitting in
the training set.
cv_rf( data_sf, response_var, predictor_vars, folds = NULL, k = 5, seed = 123, parallel = FALSE, block_size = NULL, auto_range = FALSE, boundary = NULL, pointize = "auto", metrics = NULL, ... )cv_rf( data_sf, response_var, predictor_vars, folds = NULL, k = 5, seed = 123, parallel = FALSE, block_size = NULL, auto_range = FALSE, boundary = NULL, pointize = "auto", metrics = NULL, ... )
data_sf |
An sf object. |
response_var |
Response column name. |
predictor_vars |
Predictor column names. |
folds |
Optional fold definitions, in any of three shapes: a
|
k |
Number of folds when |
seed |
RNG seed. Default 123. It seeds fold construction and,
through a per-fold draw, each fold's forest, so two different seeds give
different results even on identical |
parallel |
Passed to |
block_size, auto_range, boundary
|
Passed to |
pointize |
How non-POINT geometry is reduced to a point before
fitting; passed to |
metrics |
Optional scoring function of your own, passed to
|
... |
Passed to |
The cv_spatial result.
MAPE divides by the observed value and SMAPE by
, so neither is defined where its denominator is zero.
Neither returns Inf or NaN. Both are averaged over the rows
whose denominator is non-zero, and are NA when no row qualifies.
Non-zero is judged at the scale of the data: a denominator no larger
than 100 machine epsilons times the largest one counts as zero, so the
rule does not depend on the units of the response.
The n_MAPE and n_SMAPE columns record how many rows that was;
the n column counts finite observation/prediction pairs. Read a
percentage error next to its count: when n_MAPE < n, MAPE is
an average over a subset of the data, whatever its value.
This bites on any response taking exact zeros: counts, rainfall,
abundance, claim amounts. On a zero-inflated response with 62 zeros out of
120, MAPE is an average over the 58 non-zero rows, which
n_MAPE = 58 now says. SMAPE fails differently and more
subtly: it drops the rows where observation and prediction are both near
zero (which on a well-fitted zero-inflated model are the rows it got
right), so it averages the harder rows only and reads worse than
the fit deserves; n_SMAPE shows how many rows it kept, and the
count is only a label, not a repair.
RMSE, MAE and use every finite row and are
unaffected; prefer them whenever the response can be zero. For a Bayesian
fit, cv_bayes() additionally reports CRPS and interval
coverage, which are proper scoring rules and have no such failure mode.
Other cross-validation:
area_of_applicability(),
cv_bayes(),
cv_block_size_sweep(),
cv_gwr(),
cv_spatial(),
estimate_sac_range(),
fold_separation(),
gwr_model_selection(),
make_folds(),
sac_nugget(),
select_features_forward()
if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) n <- 200 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$z <- 2 * dat$a + rnorm(n, 0, 0.3) cv_rf(dat, "z", "a", k = 4)$overall }if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) n <- 200 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$z <- 2 * dat$a + rnorm(n, 0, 0.3) cv_rf(dat, "z", "a", k = 4)$overall }
Run K-fold CV for any model that returns a spatial_fit object.
This is the extensibility point: to plug in a new model type, supply
a fit_fn(train_sf) that returns a spatial_fit.
cv_spatial( data_sf, response_var, predictor_vars, fit_fn, folds = NULL, k = 5, seed = 123, boundary = NULL, pointize = "auto", predict_args = list(), fold_info_fn = NULL, p = NULL, block_size = NULL, auto_range = FALSE, parallel = FALSE, metrics = NULL, .caller = "cv_spatial" )cv_spatial( data_sf, response_var, predictor_vars, fit_fn, folds = NULL, k = 5, seed = 123, boundary = NULL, pointize = "auto", predict_args = list(), fold_info_fn = NULL, p = NULL, block_size = NULL, auto_range = FALSE, parallel = FALSE, metrics = NULL, .caller = "cv_spatial" )
data_sf |
An sf object. |
response_var |
Response column name. |
predictor_vars |
Predictor column names. |
fit_fn |
A function of one argument, the training slice of
|
folds |
Optional fold definitions, in any of three shapes: a
|
k |
Number of folds. |
seed |
RNG seed. |
boundary |
Optional boundary for fold construction. |
pointize |
Geometry coercion strategy. |
predict_args |
Extra arguments for predict(). |
fold_info_fn |
Optional |
p |
Number of predictors for Adj R² (NULL to skip). Only meaningful for models with a fixed global parameter count; pass NULL for models with spatially varying coefficients (e.g. GWR). |
block_size |
Optional minimum block edge length for spatial CV blocks (projected CRS units). |
auto_range |
Logical. If |
parallel |
Logical or positive integer. If |
metrics |
Optional scoring function of your own; see Your
own metrics below. Default |
.caller |
Internal. The name the messages carry, so a wrapper such as
|
A list with overall, fold_metrics, predictions,
folds, the two fold counts n_folds_attempted and
n_folds_succeeded, and four elements that say what happened to
the difference between them and to the rows: fold_status,
orphan_rows, n_unknown_ids and n_dropped. The
counts are reported deliberately: a
fit_fn that fails on every fold otherwise looks like a successful
run that happened to score NA, so compare them before trusting
overall. fold_status is a data.frame with one row per
fold supplied (fold, status and message), where
status is "ok" (message is empty unless
fold_info_fn failed on the fold); "error" (the fit or its
predict() threw; message is the error text);
"skipped" (nothing scorable: too few matched rows, a prediction
of the wrong length, or no finite observed/predicted pair);
"dropped" (an empty test set, or fewer than two training rows,
once incomplete rows were removed, so the fold never reached the fitter);
or "worker_error" (a parallel worker died, for example killed for
lack of memory). Each fold runs in its own worker, so a failure costs
that fold only, and an error that stops a sequential run (a
metrics or fold_info_fn return value of the wrong shape)
stops a parallel one too, naming the fold. Every fold missing
from fold_metrics has its reason there, which matters most when
the console output of a long run is gone. When some folds, but not
all, end as "error", "skipped" or "worker_error",
the function warns, naming them and how many rows overall
covers: it is pooled over the folds that produced predictions, and the
fold that fails is often the hardest to predict, so it may flatter the
model. (A "dropped" fold has its own warning.)
orphan_rows holds the
..row_ids of rows in the data that no fold names (they enter no
training set and are never scored; non-empty only when the folds were
built on a different or subsetted layer), and n_unknown_ids
counts the distinct row IDs the folds name that the data does not have.
Each such row is named by every fold, once as a test row and once in
each other fold's training set, and this counts the row, not the
mentions (expected when rows were removed for missing values).
n_dropped is how many rows prep_model_data() removed for
missing or non-finite values or a bad geometry before any fold was
fitted. The fold column of
fold_metrics, predictions and fold_status carries
the fold's index in the folds object that was supplied, so it
lines up with make_folds()$assignment$fold even when some folds
were unusable and dropped. Splits that already carry a fold_id,
as the folds of a cv_*() result do, keep it: handing
one run's folds to another labels each fold as the first run
and fold_separation() do, a dropped fold's gap
included. R2 is out-of-sample : the
total sum of squares is taken about the mean of the training rows
(in overall, each held-out row about its own fold's training
mean), the null prediction available when the fold is predicted, and
not about the held-out rows' own mean, a null model that would know the
test data. On spatial blocks the two can differ widely; R2 is
below 0 when the model predicts worse than the training mean.
model_metrics(newdata = ) uses the same baseline.
overall$Adj_R2 is always NA: the
pooled out-of-sample predictions come from k separately fitted
models and have no single parameter count to adjust for. The per-fold
fold_metrics$Adj_R2 carries the adjusted value when p is
supplied, and is NA otherwise.
The blockCV package exports a function of the same name that does
the opposite job: blockCV::cv_spatial() builds spatial
folds, where this function runs a cross-validation over folds it is
given. With both packages attached, whichever was attached last masks the
other; spatialkit::cv_spatial() always resolves to this one. The
two cooperate: blockCV::cv_spatial() returns
its fold assignment as $folds_ids, a vector of fold labels, and
that vector is accepted directly as the folds argument here and in
every other cv_*() function. Fold construction is
blockCV's home ground and this package does not try to match its
breadth there; what this package adds is the path from irregular points
through data-drawn regions to a cross-validated, compared model.
The built-in columns (RMSE, MAE, MAPE,
SMAPE, R2, Adj_R2) are the Gaussian regression
set, and the section below says which of them survive a count or a
bounded response. metrics is the way to score what they cannot: a
function(y, yhat) that returns a named numeric vector (a named
list of scalars, or a one-row data frame, also serve), for example a
Poisson deviance, a log score on a probability, or a loss with your own
weights. It is applied twice, in the same way the built-in metrics are:
once per fold, to that fold's held-out rows, so each name becomes a column
of fold_metrics; and once to the pooled out-of-sample predictions
of every fold, so each name becomes a column of overall. Only the
pairs the built-in metrics use reach the function (both y and
yhat finite), so its columns describe the same rows as
RMSE, and n_pred counts them.
The contract: every element named, names unique and not one of the
built-in column names, one number per name. The built-in names include
the per-fold extras of the backend or of your fold_info_fn
(bandwidth for cv_gwr(); CRPS, coverage_*,
gp_k, gp_n_basis and n_draws for cv_bayes()),
and mean_CRPS, which compare_models_cv() writes. Anything
else is an error,
because a scoring function that returns the wrong shape is a mistake to
surface instead of a fold to skip. A function that throws on a
fold is logged and its columns are NA for that fold (and for
overall, if it throws on the pooled predictions); a fold is never
dropped for it. When no fold produced a prediction the empty
fold_metrics frame still carries the function's columns, typed,
provided the function can be called on zero-length input.
fold_info_fn is the per-fold half of the same mechanism, with
access to the fitted object and the held-out layer; metrics sees
only the two vectors but is also pooled. compare_models_cv()
hands one metrics to every backend, so the columns are comparable
across the rows of its overall.
MAPE divides by the observed value and SMAPE by
, so neither is defined where its denominator is zero.
Neither returns Inf or NaN. Both are averaged over the rows
whose denominator is non-zero, and are NA when no row qualifies.
Non-zero is judged at the scale of the data: a denominator no larger
than 100 machine epsilons times the largest one counts as zero, so the
rule does not depend on the units of the response.
The n_MAPE and n_SMAPE columns record how many rows that was;
the n column counts finite observation/prediction pairs. Read a
percentage error next to its count: when n_MAPE < n, MAPE is
an average over a subset of the data, whatever its value.
This bites on any response taking exact zeros: counts, rainfall,
abundance, claim amounts. On a zero-inflated response with 62 zeros out of
120, MAPE is an average over the 58 non-zero rows, which
n_MAPE = 58 now says. SMAPE fails differently and more
subtly: it drops the rows where observation and prediction are both near
zero (which on a well-fitted zero-inflated model are the rows it got
right), so it averages the harder rows only and reads worse than
the fit deserves; n_SMAPE shows how many rows it kept, and the
count is only a label, not a repair.
RMSE, MAE and use every finite row and are
unaffected; prefer them whenever the response can be zero. For a Bayesian
fit, cv_bayes() additionally reports CRPS and interval
coverage, which are proper scoring rules and have no such failure mode.
new_spatial_fit() for the constructor a fit_fn
must use; cv_gwr(), cv_bayes() and
cv_rf() for the built-in backends, which are thin wrappers
over this function.
Other cross-validation:
area_of_applicability(),
cv_bayes(),
cv_block_size_sweep(),
cv_gwr(),
cv_rf(),
estimate_sac_range(),
fold_separation(),
gwr_model_selection(),
make_folds(),
sac_nugget(),
select_features_forward()
library(sf) set.seed(1) n <- 80 site <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) site$price <- 10 + 0.01 * (st_coordinates(site)[, 1] - 5e5) + 2 * site$elev + rnorm(n) # 1. A fit_fn returning a spatial_fit of your own subclass. lm_fit <- function(train_sf) { new_spatial_fit( subclass = "lm_fit", engine = lm(price ~ elev, st_drop_geometry(train_sf)), formula = price ~ elev, response_var = "price", predictor_vars = "elev", data_sf = train_sf ) } # 2. The predict() method cv_spatial() scores each fold with. Without it # every fold fails and `overall` comes back all-NA. predict.lm_fit <- function(object, newdata = NULL, ...) { if (is.null(newdata)) newdata <- object$data_sf as.numeric(stats::predict(object$engine, st_drop_geometry(newdata))) } registerS3method("predict", "lm_fit", predict.lm_fit) cv <- cv_spatial(site, "price", "elev", fit_fn = lm_fit, k = 3, seed = 1) cv$overall # Compare these before trusting the metrics above; fold_status says why # any fold is missing from fold_metrics. c(attempted = cv$n_folds_attempted, succeeded = cv$n_folds_succeeded) cv$fold_status # 3. A metric of your own beside the built-in ones: per fold and pooled. med_ae <- function(y, yhat) c(MedAE = stats::median(abs(y - yhat))) cv2 <- cv_spatial(site, "price", "elev", fit_fn = lm_fit, k = 3, seed = 1, metrics = med_ae) cv2$overall$MedAE cv2$fold_metrics[, c("fold", "RMSE", "MedAE")]library(sf) set.seed(1) n <- 80 site <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) site$price <- 10 + 0.01 * (st_coordinates(site)[, 1] - 5e5) + 2 * site$elev + rnorm(n) # 1. A fit_fn returning a spatial_fit of your own subclass. lm_fit <- function(train_sf) { new_spatial_fit( subclass = "lm_fit", engine = lm(price ~ elev, st_drop_geometry(train_sf)), formula = price ~ elev, response_var = "price", predictor_vars = "elev", data_sf = train_sf ) } # 2. The predict() method cv_spatial() scores each fold with. Without it # every fold fails and `overall` comes back all-NA. predict.lm_fit <- function(object, newdata = NULL, ...) { if (is.null(newdata)) newdata <- object$data_sf as.numeric(stats::predict(object$engine, st_drop_geometry(newdata))) } registerS3method("predict", "lm_fit", predict.lm_fit) cv <- cv_spatial(site, "price", "elev", fit_fn = lm_fit, k = 3, seed = 1) cv$overall # Compare these before trusting the metrics above; fold_status says why # any fold is missing from fold_metrics. c(attempted = cv$n_folds_attempted, succeeded = cv$n_folds_succeeded) cv$fold_status # 3. A metric of your own beside the built-in ones: per fold and pooled. med_ae <- function(y, yhat) c(MedAE = stats::median(abs(y - yhat))) cv2 <- cv_spatial(site, "price", "elev", fit_fn = lm_fit, k = 3, seed = 1, metrics = med_ae) cv2$overall$MedAE cv2$fold_metrics[, c("fold", "RMSE", "MedAE")]
Computes a WSS curve over k=1..K_max using k-means on projected feature coordinates and selects candidate k values around the elbow.
determine_optimal_levels( data_sf, max_levels = 12L, top_n = 3L, sample_n = 1500L, set_seed = 123L, response_var = NULL, predictor_vars = NULL, criterion = c("geometric", "morans_i", "combined"), select_on = c("all", "split") )determine_optimal_levels( data_sf, max_levels = 12L, top_n = 3L, sample_n = 1500L, set_seed = 123L, response_var = NULL, predictor_vars = NULL, criterion = c("geometric", "morans_i", "combined"), select_on = c("all", "split") )
data_sf |
An sf object. Features with empty or non-finite coordinates are dropped with a warning. |
max_levels |
Integer upper bound on levels. Default 12. The sweep also stops at the number of distinct locations (k-means cannot place more centres) and one short of the number of points. |
top_n |
Integer; how many candidates to return. Default 3. Under
|
sample_n |
Integer; subsample size for speed. Default 1500. |
set_seed |
Integer RNG seed for the subsample and the k-means++ restarts; restored afterwards. Default 123. The rows are put in coordinate order before either, so the answer does not depend on the order they come in. |
response_var |
Optional response column name. When provided alongside
|
predictor_vars |
Optional predictor column names. Must be numeric or logical (logicals are read as 0/1); factor/character columns raise an error. |
criterion |
One of |
select_on |
|
The elbow is read on log-log axes, and there may be none. Points
with no cluster structure have a WSS curve close to , and the
classical rule, the point furthest below the chord from the first to the
last k, still finds a "knee" on it on linear axes, at about
: 4 at the default max_levels = 12 and 13 at
160, whatever the data. On against WSS that curve
is a straight line, while separated clusters fall faster than it until
there is one cell per cluster and like it after, a bend at the cluster
count. The elbow is therefore the k whose WSS sags furthest
below the straight line joining k = 1 and on those axes, and
it counts as one only when the sag is at least (the WSS a
fifth below the power law through the ends). Measured on 60 to 1500
points with ladders to 3–40, uniform layouts over squares, discs,
triangles, an L-shape, density gradients and jittered lattices sagged at
most 0.16, and two to ten separated clusters at least 0.6 once
max_levels passed the cluster count. With no elbow the function
warns and returns the linear-axis answer, which the ladder chose, not the
data. An elongated extent also bends, at about its aspect ratio, because
the first cuts go across its long axis (0.2–0.45 for a 4:1 rectangle);
past the threshold that bend is reported as an elbow, and it describes
the extent's shape rather than clusters in it. When locations repeat
(stations visited many times), the sweep can reach one cell per distinct
location, where the WSS is zero (to within of the total).
That k has no place on log axes and is left out of the line, but
the fall to zero is the sharpest bend there is: when the rest of the
curve has no elbow, the number of distinct locations is the elbow.
When response_var and predictor_vars are provided, the
geometric WSS elbow is supplemented with Moran's I computed on OLS
residuals at each candidate k. The Moran's I profile measures how much
spatial autocorrelation in the response remains unexplained at a given
tessellation resolution. It is a direct reflection of the spatial process
being modeled instead of the mere geometric compactness of coordinates.
The combined criterion selects the k that best balances geometric parsimony
and residual spatial independence.
To keep memory use and runtime bounded for large max_levels, the
initial k-means sweep records only within-cluster sum-of-squares (WSS)
without retaining cluster assignments. Moran's I is then evaluated
lazily: k-means is re-run only for a focused neighbourhood around the
elbow (±4 by default, or ±top_n if larger), so that only the most
promising candidate k values incur the cost of the full Moran's I
computation.
The WSS curve is read for its shape, so it is fitted to be
smooth. Every point on it is a k-means local optimum, and with a few
random restarts the curve mixes level effects with optimisation noise:
measured on clustered layouts, a sweep over k = 1..30 at
stats::kmeans(nstart = 5) rose at one or two steps in three
of five draws, and an earlier form of the elbow rule once selected such a
bump. Each k is therefore fitted as the best of 25 restarts seeded
by k-means++ (Arthur and Vassilvitskii 2007), the budget at which the gain
from further restarts saturates (Fränti and Sieranoja 2019; Steinley 2003
on why the usual handful is not enough). The same sweeps then had no
increase at all. A curve that still rises somewhere is reported but not
refused: a bumpy curve is uncertain, not unidentified. A logged warning
names the number of rising steps, and the model-aware paths return it as
wss_bumps in the "diagnostics" attribute beside
wss_spread, the relative spread of WSS across the restarts at each
k. Because the optimiser changed, a selection made by an earlier
version on a curve that had such a bump can differ from the one made now;
where the earlier curve was clean, the selection is the same.
The model-aware criteria rank on the standardised deviate, not on
|Moran's I|. Both and depend on the number of
cells, so falls as k grows whether or not the finer
tessellation is capturing anything. Measured over 300 replicates of a
response with no spatial structure, mean fell monotonically
from 0.114 at k = 10 to 0.050 at k = 60 (), which
made an ranking prefer the largest candidate for arithmetic
reasons alone. Candidates are therefore ordered by
using the Cliff & Ord regression
residual moments. The cell-level residuals are OLS residuals by
construction, which is the case those moments are derived for, but the
derivation also assumes errors of equal variance, and a cell mean over
points has a variance proportional to . Over the
same runs had mean ,
and a two-sided 5% rejection rate of 0.040–0.057 at every k, and
it stayed calibrated on gradient and moderately clustered layouts; with
single-point cells next to cells of 70 or more points its mean rose to
0.2–0.34 and its rejection rate to 7–8% at 20 cells. Where structure
remains, mixes its size with the number of cells it is measured
on, since shrinks as cells are added. Both
quantities are reported in the "diagnostics" attribute, as
moran_i and moran_z.
Resolution floor on the model-aware criteria. Moran's I is
computed on cell-level residuals with an 8-nearest-neighbour weight matrix,
so it only carries information once there are more than nine cells. At nine
or fewer, every cell is a neighbour of every other, the row-standardised
weight matrix is complete, and Moran's I collapses to exactly
for any residual vector (a function of k
alone). The criterion ranks on , not on , and at the
floor the residual moments give and identically (the algebra holds to ), so the standardised
deviate is : it carries no information about the tessellation, and
whichever way rounding noise resolves it those candidates would rank first
or last on nothing. They therefore return NA and are excluded from
the model-aware ranking. When no candidate in the elbow neighbourhood
clears the floor, the whole call falls back to the geometric ranking and
logs a warning that says so. That is the usual outcome well past
max_levels = 10: the neighbourhood is the elbow plus or minus
max(4, top_n), and on points with no cluster structure the elbow
sits near , so the neighbourhood reaches ten cells only
from about max_levels = 40. Measured on 1000 uniform points,
max_levels of 12, 20 and 30 all fell back, and 40 scored
k = 10 and 11 alone. On such a layer
resolution_profile(), which scores Moran's z at every level of
its ladder, is the model-aware view. Under criterion = "combined",
an elbow below ten cells is a count Moran's I cannot score, so it cannot
weigh the response there: the geometric ranking is returned, with a
logged warning, and the diagnostics record it (see Value). (Ranking the
window anyway put the smallest count Moran's I scores, ten, first whatever
the response did.) Otherwise the candidates below the floor that sit
alongside candidates above it all take the last place on the Moran's I
axis, after every candidate it scored, while still competing on the
geometric axis. That axis is the elbow's own log-log sag at each
candidate; when the WSS curve has no elbow it is flat, every candidate
tied, and Moran's I alone orders them.
"combined" is not an estimate of the number of clusters. Where the
elbow is at ten cells or more, Moran's I can move the pick away from it
when the response is still spatially structured at the elbow's
resolution. Use "geometric" when the cell count should follow the
clustering of the points, and resolution_profile() when the
response should drive it.
An integer vector of candidate level counts, best first:
under the geometric criterion the elbow, then its lower and upper
neighbours (with a warning when the WSS curve has no elbow and the first
is the ladder's choice; see Details); under the model-aware criteria the
candidates in rank order.
k[1] is therefore the top-ranked count on every path, and
top_n = 1 returns it alone. When
criterion != "geometric", an attribute "diagnostics" is
attached with per-k Moran's I values (moran_i) and their
standardised deviates (moran_z), the WSS curve (wss) with
the relative between-restart spread at each k (wss_spread),
the number of rising steps on it (wss_bumps), the restart
budget (nstart), the geometric elbow the evaluated
neighbourhood was drawn around (knee_k), the k at which
k-means failed (failed_k; their wss entries are
interpolated from the neighbours, not measured) and the k the
model-aware pass actually scored (eval_ks, the elbow's
neighbourhood). Under "combined" it also carries the WSS of the
re-run clustering at those k (wss_eval), the rank average
that ordered them (combined_rank, named by k) and
criterion = "combined"; when "combined" returned the
geometric ranking because the elbow is below ten cells, it carries
criterion = "geometric" and fallback (the reason) in
place of combined_rank. When the model-aware
path itself falls back to the geometric result (no viable k in the elbow
neighbourhood, or Moran's I could not be computed for any candidate), no
diagnostics are available and the attribute is absent. Both fallbacks are
logged as warnings. The geometric path returns a plain
integer vector; a rising WSS curve is still logged there. With
select_on = "split" every path adds a "split" attribute:
a list with selection and estimation (integer row
positions in data_sf), method and seed. For a full
per-level table of criteria, cell support, restart spread and the flat
region, see resolution_profile().
When the selection reads the response (here, whenever both
response_var and predictor_vars are supplied), everything
estimated afterwards on the chosen cells is estimated on data that already
influenced the choice, and its standard errors are post-selection ones:
descriptive, not at nominal coverage (Gao, Bien and Witten 2022; Chen and
Witten 2023 give the exact selective test for two k-means clusters, the
first link of this chain). The exposure is narrower than "the partition
was chosen on the response": every partition here is k-means on the
coordinates alone, and the response only decides which count is
ranked first. It is not zero, because the count determines every cell the
downstream standard errors are computed over.
select_on = "split" is sample splitting: the layer is cut into two
spatially blocked halves (make_folds(k = 2, method =
"block_kfold")), the criteria that read the response (Moran's I on the
cell means) read the first half only, and the row positions of both
halves come back in the "split" attribute (selection and
estimation). The WSS curve and the k-means cells still use every
point: they read coordinates alone, and the count is for a tessellation
of every point, so it is chosen on that layer's extent and clusters
rather than on half of them. Build the tessellation on
every point (cells are geometry), but aggregate and fit on
data_sf[attr(x, "split")$estimation, ], whose response the
selection never saw. That keeps the selection's use of the response out
of the estimates, but only as far as the two halves are independent: the
split has no buffer, so points near the border between them are
correlated with the selection half over the autocorrelation range, and
coverage is nominal only when that range is short against the blocks.
The price is
precision: half the points estimate, and García Rasines and Young (2023)
show a contiguous spatial half is less efficient than the
exchangeable split the i.i.d. theory assumes, because the two halves are
not interchangeable. Two alternatives keep the whole sample: data
thinning for count responses (Neufeld et al. 2024) and data fission for
Gaussian-like ones (Leiner et al. 2023). Neither is implemented here;
the split needs no distributional assumption, which is why it comes first.
Selection on coordinates alone ("geometric" with no response) is
not exposed in this way, and "split" then changes nothing but the
attribute.
The estimation rows cover only the estimation half's blocks, while the
cells cover the whole layer, so the estimation rows do not fill every
cell. A cell inside the selection half gets none and comes back
NA; a cell across the border between the halves is estimated from
its estimation-half points alone, and its standard error describes that
part, not the cell (on 400 simulated fields a nominal 95% interval covered
0.97 in cells inside the estimation half and 0.88 in cells across the
border). Count, per cell, how many of its points are estimation rows,
and read inferential results only off cells whose points all are.
Arthur, D. and Vassilvitskii, S. (2007). k-means++: the advantages of careful seeding. Proceedings of the 18th Annual ACM-SIAM Symposium on Discrete Algorithms, 1027–1035.
Fränti, P. and Sieranoja, S. (2019). How much can k-means be improved by using better initialization and repeats? Pattern Recognition, 93, 95–112. doi:10.1016/j.patcog.2019.04.014
Steinley, D. (2003). Local optima in K-means clustering: what you don't know may hurt you. Psychological Methods, 8(3), 294–304. doi:10.1037/1082-989X.8.3.294
Gao, L. L., Bien, J. and Witten, D. (2022). Selective inference for hierarchical clustering. Journal of the American Statistical Association, 119, 332–342. doi:10.1080/01621459.2022.2116331
Chen, Y. T. and Witten, D. M. (2023). Selective inference for k-means clustering. Journal of Machine Learning Research, 24(152), 1–41. https://jmlr.org/papers/v24/22-0371.html
García Rasines, D. and Young, G. A. (2023). Splitting strategies for post-selection inference. Biometrika, 110(3), 597–614. doi:10.1093/biomet/asac070
Leiner, J., Duan, B., Wasserman, L. and Ramdas, A. (2023). Data fission: splitting a single data point. Journal of the American Statistical Association, 120(549), 135–146. doi:10.1080/01621459.2023.2270748
Neufeld, A., Dharamshi, A., Gao, L. L. and Witten, D. (2024). Data thinning for convolution-closed distributions. Journal of Machine Learning Research, 25(57), 1–35. https://jmlr.org/papers/v25/23-0446.html
build_tessellation() and get_voronoi_seeds(), which accept
this function's result directly as approx_n_cells and n (the first
candidate is used, and the output records that it came from here);
assign_features_to_polygons() and summarize_by_cell() for the steps
that follow.
Other aggregation:
assign_features_to_polygons(),
kriging_adequacy(),
resolution_profile(),
select_resolution(),
summarize_by_cell(),
summary.resolution_profile()
library(sf) set.seed(1) # Two clearly separated clusters: the elbow should sit near k = 2 pts <- st_as_sf( data.frame(x = 5e5 + c(runif(25, 0, 10), runif(25, 90, 100)), y = 5e6 + c(runif(25, 0, 10), runif(25, 90, 100))), coords = c("x", "y"), crs = 32632 ) determine_optimal_levels(pts, max_levels = 6) # 2 1 3: the elbow firstlibrary(sf) set.seed(1) # Two clearly separated clusters: the elbow should sit near k = 2 pts <- st_as_sf( data.frame(x = 5e5 + c(runif(25, 0, 10), runif(25, 90, 100)), y = 5e6 + c(runif(25, 0, 10), runif(25, 90, 100))), coords = c("x", "y"), crs = 32632 ) determine_optimal_levels(pts, max_levels = 6) # 2 1 3: the elbow first
Coerces spatial objects to a projected coordinate reference system suitable for distance/area calculations.
ensure_projected(x, target_crs = NULL, purpose = c("distance", "area"))ensure_projected(x, target_crs = NULL, purpose = c("distance", "area"))
x |
An sf or sfc object (other objects returned unchanged). |
target_crs |
Optional target CRS (sf object, integer EPSG, or crs).
Must resolve to a usable CRS via |
purpose |
Which property the projection is for. |
An object that already has a projected CRS is returned untouched. Only geographic (lon/lat) input is transformed, and the CRS chosen depends on the extent of the data. It is not always UTM:
The UTM zone containing the data's centre
(EPSG:326xx north of the equator, EPSG:327xx south). Distances and areas
are close to true over a few degrees of longitude, which is the case
this package is usually in. The centre is the centroid on the sphere,
computed with s2 whatever sf::sf_use_s2() is set to, so data near a
zone edge get the same zone in every session.
Once the data reach well beyond the roughly 3 degrees
a UTM zone is designed for, a single zone can distort distances by
several percent, and that error propagates straight into variogram
ranges, block sizes, GWR bandwidths and GP length-scales. Which
projection is actually best is then measured, not assumed: the zone,
a Lambert azimuthal equal-area centred on the data and (where its
standard parallels do not degenerate) an Albers conic are each scored by
projecting representative points of the data (a non-POINT layer is
reduced to one point per feature, plus the vertices of its outline when
it has fewer than 40 features, so a single study-area polygon is scored
too) and comparing planar with geodesic pairwise distances, and the one
that distorts least is used.
The choice, both error figures and this argument are logged (see the
logging note under spatialkit_quiet()); they are not R warnings, so
tryCatch(warning = ) does not see them.
Data straddling ±180° have a bounding box wider than a hemisphere. The wrap is detected from the coordinates (one very large gap in the sorted longitudes) and an equal-area projection centred on the true extent is used.
Data spanning more than 180 degrees of longitude
with no such gap surround a pole. When every point also lies on one
side of the equator (Antarctic stations, a pan-Arctic network), a
Lambert azimuthal equal-area centred on that pole is used, provided it
measures a smaller distance error than the global fallback. Web
Mercator splits such a layer at +/-180 degrees and stretches it
towards the pole: a ring of Antarctic stations measured a worst-case
distance error near 20,000 percent in it, against about 2 percent in
the polar projection. Only the coverage left over, spanning both
hemispheres or a low-latitude belt the polar projection fits worse,
falls back to EPSG:3857 (Equal Earth for purpose = "area").
With no target_crs, a bounding box that looks like
lon/lat means EPSG:4326 is assumed (a real warning) and the rules above
then apply; coordinates the heuristic declines are left exactly as they
are. With target_crs supplied there is no source CRS to reproject
from, so the same heuristic decides between two outcomes: lon/lat-looking
coordinates are read as EPSG:4326 and reprojected to the target (a real
warning), and anything else has the target stamped on without
reprojection. That is a relabel, logged only, so verify the
coordinates really are in that CRS. Set the CRS explicitly to suppress
either.
target_crs overrides all of this. Pass it whenever you need a specific,
reproducible projection: comparing runs, matching an existing layer, or
fixing the units that make_folds()'s block_size will be interpreted in.
x, potentially with a new projected CRS. When a projection was
chosen here (lon/lat input, no target_crs) the result carries
attr(x, "crs_choice"): a data.frame with one row per projection
considered, holding name, crs (its definition), the measured
worst-case distance_error (relative, over sampled pairs;
NA where it could not be measured) and chosen. The
figure the log line quotes for the winner is therefore recoverable for
every candidate, and is measured for the single candidate on the paths
where no comparison runs (a UTM zone on a local extent, the equal-area
projection chosen for a layer straddling the antimeridian). It is
NULL exactly when no local projection was chosen here: input
that already carried a projected CRS, a target_crs you supplied,
or an extent no local projection fits, which falls back to Web Mercator
or Equal Earth. CRS-less input
additionally carries attr(x, "crs_assumed"): "EPSG:4326" when
the lon/lat heuristic fired, "none" when it declined. That
attribute is also read on the way IN. An object already carrying
"none" is returned untouched, with the heuristic skipped, which is
how a predict() method replays a fit's negative decision so that a
subset of the training rows is not judged differently from the whole.
Other spatial data preparation:
clip_target_for(),
coerce_to_points(),
harmonize_crs(),
prep_model_data()
library(sf) pts_ll <- st_as_sf( data.frame(lon = c(9.1, 9.2), lat = c(48.7, 48.8)), coords = c("lon", "lat"), crs = 4326 ) # A local extent gets the containing UTM zone; the zone's measured # distance error over the extent travels with the result. st_crs(ensure_projected(pts_ll))$epsg # 32632 attr(ensure_projected(pts_ll), "crs_choice") # A continental extent is scored against the zone and may get an equal-area # projection instead; the choice and both error figures are LOGGED, not # warned -- see Details and ?spatialkit_quiet. wide <- st_as_sf( data.frame(lon = c(-120, -70), lat = c(30, 48)), coords = c("lon", "lat"), crs = 4326 ) st_crs(ensure_projected(wide))$proj4string # target_crs overrides the choice entirely. st_crs(ensure_projected(pts_ll, target_crs = 3035))$epsg # 3035 # For densities per cell the CRS has to be equal-area. Two candidates are # scored, an Albers conic and a Lambert azimuthal, both centred on the data, # and whichever distorts distance less over the extent is used: here Albers. st_crs(ensure_projected(pts_ll, purpose = "area"))$proj4stringlibrary(sf) pts_ll <- st_as_sf( data.frame(lon = c(9.1, 9.2), lat = c(48.7, 48.8)), coords = c("lon", "lat"), crs = 4326 ) # A local extent gets the containing UTM zone; the zone's measured # distance error over the extent travels with the result. st_crs(ensure_projected(pts_ll))$epsg # 32632 attr(ensure_projected(pts_ll), "crs_choice") # A continental extent is scored against the zone and may get an equal-area # projection instead; the choice and both error figures are LOGGED, not # warned -- see Details and ?spatialkit_quiet. wide <- st_as_sf( data.frame(lon = c(-120, -70), lat = c(30, 48)), coords = c("lon", "lat"), crs = 4326 ) st_crs(ensure_projected(wide))$proj4string # target_crs overrides the choice entirely. st_crs(ensure_projected(pts_ll, target_crs = 3035))$epsg # 3035 # For densities per cell the CRS has to be equal-area. Two candidates are # scored, an Albers conic and a Lambert azimuthal, both centred on the data, # and whichever distorts distance less over the extent is used: here Albers. st_crs(ensure_projected(pts_ll, purpose = "area"))$proj4string
Ensures that a polygon layer has a reproducible, deterministic identifier column by sorting features using representative point coordinates (and secondary tie-breakers) and then assigning sequential IDs.
ensure_stable_poly_id( polygons_sf, id_col = "poly_id", method = c("centroid", "surface_point", "bbox_center"), make_valid = TRUE, transform_for_sort = 4326 )ensure_stable_poly_id( polygons_sf, id_col = "poly_id", method = c("centroid", "surface_point", "bbox_center"), make_valid = TRUE, transform_for_sort = 4326 )
polygons_sf |
An sf or sfc object containing polygonal features. |
id_col |
Character scalar; name of the identifier column. |
method |
One of "centroid", "surface_point", "bbox_center". |
make_valid |
Logical; apply st_make_valid() first. Default TRUE.
The copy the sort key is measured on is repaired as well, after it has
been transformed (see Details); with |
transform_for_sort |
CRS used only for computing sort-key coordinates.
Default 4326. This is the whole mechanism by which the IDs are stable
(sorting in one common CRS is what makes the same layer get the same IDs
whichever projection it arrives in), so if the transform fails the
function says so rather than quietly sorting in the input's own CRS. The
sort key is rounded to 7 decimal degrees (about 1 cm) before ordering, so
the floating-point noise of a round trip through a different projection
does not usually reverse two neighbouring cells. It can where two cells'
centres lie within about that step of the same longitude, as fine cells
stacked north-south near a projection's central meridian do: 36 of 2,500
100 m cells straddling a UTM central meridian changed ID after a
transform to EPSG:3035. No rounding step removes that, so to match cells
computed in different projections, join them on geometry rather than on
the ID. The key is computed on the sphere (s2) whether or not
|
A feature that is valid in its own CRS is not always one s2 can measure.
Only the vertices are transformed to the sort CRS, and on the sphere they
are joined by great-circle arcs, so a long edge that is straight in the
layer's CRS and passes about a metre from another vertex of the same ring
can end up on the other side of that vertex. The ring then crosses itself,
and s2 refuses it (1 of 291 Voronoi cells of Texas clipped to the state
outline in EPSG:5070; "Loop 1 is not valid: Edge 36 crosses edge 52"). The
repair of the sort copy does not split crossing edges, so a feature s2
still refuses is handled on its own. Its sort copy is repaired a second
time with the crossing edges split. If s2 refuses that as well, or with
make_valid = FALSE, which asks for no repair, the feature's
centroid and area are measured as plane geometry in the layer's own CRS
and the centroid is transformed to the sort CRS. With the methods
"surface_point" and "bbox_center" only the area needs this,
because those points are not taken with s2.
The function warns once, saying how many features took each route. Their keys are close to the spherical ones and not equal to them. The second repair moved the Texas cell's area by about 40 square metres in 2,949 square kilometres. Its centroid taken in the plane lay 20 m from the one the second repair gave on the sphere, and the two kinds of centroid lay 5 m apart at the median and 61 m at most for the other 290 cells. A feature measured in the plane can therefore sort on the other side of a neighbour whose centre is that close to its own in longitude. Every other feature's key is unaffected, and the geometry returned is never the sort copy.
An sf polygon layer re-ordered with sequential IDs in id_col. Non-polygonal rows are dropped (with a warning), so the result can have fewer rows than the input; if no polygonal rows remain, an error is raised.
Other tessellation:
build_tessellation(),
create_grid_polygons(),
create_grid_polygons_cached(),
create_voronoi_polygons(),
get_voronoi_seeds(),
plot_tessellation_map(),
voronoi_seeds_kmeans(),
voronoi_seeds_random()
library(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) g <- create_grid_polygons(bnd, target_cells = 9) # Reverse the rows and re-derive the IDs: the SAME cell gets the same ID, # which is the property a row-position ID does not have. The check joins # the two layers on geometry, because comparing sorted ID vectors would # pass for any two permutations of 1:9. fwd <- ensure_stable_poly_id(g) rev <- ensure_stable_poly_id(g[nrow(g):1, ]) same_cell <- match(st_as_text(st_geometry(rev)), st_as_text(st_geometry(fwd))) all(rev$poly_id == fwd$poly_id[same_cell])library(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) g <- create_grid_polygons(bnd, target_cells = 9) # Reverse the rows and re-derive the IDs: the SAME cell gets the same ID, # which is the property a row-position ID does not have. The check joins # the two layers on geometry, because comparing sorted ID vectors would # pass for any two permutations of 1:9. fwd <- ensure_stable_poly_id(g) rev <- ensure_stable_poly_id(g[nrow(g):1, ]) same_cell <- match(st_as_text(st_geometry(rev)), st_as_text(st_geometry(fwd))) all(rev$poly_id == fwd$poly_id[same_cell])
Fits exponential (or spherical) variogram models and returns the effective range: for the exponential model, three times the fitted range parameter, which is where the semivariance reaches ~95 % of the sill; for the spherical model (fitted only when the exponential fit is singular or does not converge) the fitted range itself, which is where the spherical semivariance reaches its sill exactly. Both are the distance beyond which two observations are (near) uncorrelated, which is what a block or a buffer has to exceed.
estimate_sac_range( points_sf, response_var, predictor_vars = NULL, n_max = 5000L, cutoff = 0.5, range_frac = 1, seed = 123L, detrend = c("ols", "reml"), reml_max_n = 400L, keep_directional_fits = FALSE )estimate_sac_range( points_sf, response_var, predictor_vars = NULL, n_max = 5000L, cutoff = 0.5, range_frac = 1, seed = 123L, detrend = c("ols", "reml"), reml_max_n = 400L, keep_directional_fits = FALSE )
points_sf |
An sf object with point geometries (will be projected
automatically if in geographic CRS). Non-POINT geometry is reduced to
representative points; any Z or M dimension is dropped, because
|
response_var |
Character(1) name of the response column. For a count or other response whose variance tracks its mean, see the section on non-Gaussian responses: the range is still estimated, but it is a less reliable number than for a Gaussian response. |
predictor_vars |
Optional character vector. When supplied, the
trend on these predictors is removed first and the variogram describes
the residual autocorrelation, the part a spatial model has to handle
once the covariates have done their work. How the trend is removed is
set by |
n_max |
Maximum number of points to subsample before fitting. Variogram estimation is O(n²) so this keeps runtime bounded. |
cutoff |
Fraction of the maximum inter-point distance to use as the variogram lag cutoff. That distance is the farthest pair, found on the convex hull, and not the bounding-box diagonal, which depends on how the axes are oriented. Default 0.5. |
range_frac |
Positive numeric. A fitted range exceeding
|
seed |
RNG seed for the |
detrend |
How the trend on |
reml_max_n |
Positive integer, at least 30. With |
keep_directional_fits |
Logical. Attach each direction's empirical
variogram and fitted model as |
The exponential model is kept whenever it converges, without comparing it with the spherical fit, and on fields smoother than exponential that makes the range long. Measured on simulated fields (n = 300 on a 1000 m square, 30 draws each): about 1.8–2.1 times the practical range of a Gaussian covariance, and 1.3–1.4 times the range of a spherical one, while an exponential field came back at 0.97 of its effective range. The error is on the safe side (blocks too large, cross-validation pessimistic), and it is kept on purpose: choosing the family by the smaller weighted sum of squares corrects the spherical case but sends exponential fields low, to about 0.82 of the truth, which is the direction that leaks.
The estimate is the omnidirectional (all-pairs) fit. Directional
variograms are fitted as well, at 0° (N–S), 45°, 90° (E–W) and 135°
azimuths with a ±22.5° tolerance. Those four windows tile all 180 distinct
azimuths exactly once, and their ranges are returned in the
directional attribute, with their largest-over-smallest ratio in
anisotropy. They are a diagnostic, not the answer, for two reasons.
Each direction sees about a quarter of the point pairs, and the maximum of
four quarter-sample fits is biased upward: on simulated isotropic
fields it came in about 40% above the truth, and no hurdle placed in
front of it (all four directions fitted, ratio above 1.5, maximum above
1.5× the all-pairs fit) kept it out. One isotropic field rotated in 10°
steps "established" anisotropy in 14 of 18 orientations. And the windows
are fixed to the coordinate axes, so any answer built from them changes
when the layer is rotated, which a property of the field must not do. The
all-pairs fit is the best-powered estimate available and is invariant to
rotation.
Where a field is known to be anisotropic, blocks must be at least
as large as the longest autocorrelation range to avoid leakage, and the
conservative choice is to size them from the longest directional range
explicitly. Read it from directional_fitted, not
directional: on a strongly anisotropic field the major axis is the
direction most likely to run past the fitted lags, which leaves it
NA in directional, so max() of that is NA, or
with na.rm = TRUE the second-longest range. Check
directional_status first: a major axis marked "over_cutoff"
has no identified range at all, and a longer cutoff or
make_folds(method = "nndm") is the way on. A ratio above
1.5 is written to the package log at INFO level with that advice, which
reaches the session log file but not the console (the ratio passes 1.5 on
most isotropic fields too); print() shows the directional ranges
and the ratio, and attr(range, "anisotropy") holds it. Only when
the omnidirectional fit is singular or did not converge is the directional
maximum returned in its place, and anisotropy_used is TRUE
in that case alone. An omnidirectional fit that converged to a range past
the fitted lags is refused (see the Value section) whatever the directions
found: the directions that reached a sill are the shorter ones, so their
maximum is a lower bound, not an estimate.
A direction whose fit fails, does not converge, or reports a range beyond
the longest fitted lag is excluded and recorded as NA in the
directional attribute.
Every variogram model is fitted with a nugget. A nugget-free model
forces the curve through the origin, and on any real measurement (which has
one) gstat's default N/h² weights buy that constraint by collapsing
the range: with a 50% nugget the fitted range came back at about 0.45 of the
truth, so make_folds(auto_range = TRUE) built blocks less than half
the correlation length it reported.
The lags are binned the way gstat bins them by default, 15 bins out
to the cutoff, each cutoff * max_dist / 15 wide (about 47 m on a
1000 m square at the defaults). A range spanning only one or two bins is
resolved coarsely and comes out long: exponential fields with an effective
range of 60 m (n = 300 on a 1000 m square, 30 draws) returned a median of
89–102 m, where the same fields binned over a 200 m cutoff gave 65–68,
and at a range of 300 m there was no bias. A range shorter than the
first bin cannot be resolved at all and can come back several times too
long: an effective range of 24 m (n = 1500 on a 1000 m square, 8 draws)
returned 93–479 m, five of them as the directional maximum, against
19–32 m at cutoff = 0.1; the first bin's semivariance was 92–99
percent of the fitted sill in all eight. So whenever the empirical
variogram is already at its sill in the first one or two bins
(plot() the result), run it again with a smaller cutoff,
whatever range was fitted.
Nothing tests whether the layer has spatial structure at all. On white
noise (n = 300 on a 1000 m square, 30 draws) the estimate was a finite,
spurious range (57–533 m) in 8 draws and a refusal in the rest, mostly
as past the fitted lags or not converged, and only once as no model
fitted; with detrend = "reml" it was finite in 13 of 30 (21–453
m), and 16 of the refusals were ranges of 0.18–12.5 m, too short for 30
pairs of points to lie inside them. A
spurious range errs towards larger blocks, so the harm is mostly lost
training data, but a caller who needs to know whether there is any
structure should look at the variogram (plot() on the result)
rather than at whether the answer is NA.
When the all-pairs fit is singular or did not converge and two or more
directions reached a sill, the directional maximum is returned in its
place (anisotropy_used = TRUE), and a log warning names the
directional ranges when their ratio exceeds 1.5. When the all-pairs
estimate is used and the directional ranges vary by more than 1.5, a log
note (INFO) names them instead.
The returned range is in the coordinate units of the (projected) data and
can be passed directly to make_folds(block_size = ...) so that CV
blocks are at least as wide as the autocorrelation range.
A single number, of class sac_range in the first two of the
three shapes below and an unclassed NA in the third; all three
behave as an ordinary number. The shapes carry different attributes:
A positive effective range in projected coordinate units,
with the fit attached as attributes directional (the 0°, 45°,
90° and 135° ranges, named by azimuth; NA where that
direction's fit was unusable), anisotropy (largest
over smallest), anisotropy_used (logical: TRUE only when
the all-pairs fit was singular or did not converge and the
directional maximum stands in for it), directional_status
(per azimuth, why a direction is
NA in directional: "ok", "over_cutoff"
(its range ran past the largest lag fitted), "not_converged"
or "no_fit"), directional_fitted (the range each
direction's fit reported whether or not it was usable, so a refused
directional range stays recoverable) and directional_fits (a
list by azimuth of each direction's empirical variogram and
fitted model, NULL where there is none, and
NULL altogether unless keep_directional_fits = TRUE),
detrended (logical: whether the variogram is of the
residuals on predictor_vars or of the raw response. A
missing predictor is an error, and a failed detrending fit
warns and falls back to the raw response with this set to
FALSE), detrend_method ("ols" or "reml"
when detrended, NA otherwise), reml (with
detrend = "reml": a list with n_used,
subsampled, nugget_prop and sigma2 from the
REML fit; NULL otherwise),
crs (the projected CRS the variogram was
fitted in: the unit of the range), max_dist,
cutoff_dist, variogram (the empirical variogram),
variogram_model (the fitted gstat model, or with
detrend = "reml" a gstat model built from the REML
parameters) and nugget (that model's nugget variance; see
sac_nugget), so the fit can be inspected and need not
be taken on trust.
NA_real_ when a range was fitted but is
not identified: it exceeds range_frac * cutoff * max_dist (see
range_frac), which applies to the all-pairs fit even when some
directions reached a sill; or too few pairs of points lie inside
it to identify it, because it is shorter than the shortest lag the
empirical variogram resolves (the mean separation in its first
bin) or, with detrend = "reml", than the distance within
which 30 pairs of the points the REML fit used lie, when that is
shorter (the REML range is fitted to the point pairs, not to the
bins). A structure that short cannot be told from a nugget, and
the bound is about identification, not a test for spatial
structure (see above). Or the
model did not converge; or the empirical
variogram decreases with distance over its shorter lags (a
net fall of more than 15 percent of the mean semivariance there,
weighted by pairs), which is the shape of a periodic, hole-effect
structure or of a variance that differs between a dense cluster and
the rest of the layer. Sampling noise in the short-lag bins of a
small sample can make that fall too: on exponential fields it
refused 7–9 of 60 draws at n = 30, 3–6 at n = 50 and 0–1 at
n = 100 (effective range 300 on a 1000 m square), and 15–16 of 60
at n = 30 with a range of 150. An unremoved trend makes the
variogram rise instead; when it rises past the fitted lags the first
test catches it, but a milder trend only lengthens the fitted range
and passes, which is what predictor_vars is for. Last, the
fitted range can be non-positive. It is classed sac_range as
well, so it prints as NA without dumping its
attributes, and it carries max_dist, cutoff_dist,
variogram, variogram_model and nugget (the
evidence for the rejection), plus rejected_range (the value
that was refused), rejected_reason (one of
"fitted range exceeds the largest lag fitted",
"fitted range is below the shortest lag fitted",
"variogram model did not converge",
"empirical variogram decreases with distance",
"fitted range is non-positive or non-finite",
"no variogram model could be fitted (singular fits)"), crs
(so the units the rejected number was in stay recoverable, which is
what plot() labels its axis from),
detrend_method, reml (as on success) and, for
"fitted range is below the shortest lag fitted",
range_floor (the distance the refused range fell short of).
The reason names one ground for the refusal: a falling variogram
first, then a range past the largest lag, and the fit not having
converged only when neither holds. A fit that did not converge is
therefore most often recorded as past the largest lag, and the count
of "variogram model did not converge" understates how many
did not. The model itself says:
attr(attr(range, "variogram_model"), "converged") is
FALSE when no start of either model family converged
(gstat stopped at its iteration limit), whatever the reason,
and its nugget and sill are then where the optimiser halted, as its
range is. A model built from REML parameters carries no such
attribute. resolution_profile() reads it.
The refused range carries directional,
anisotropy, anisotropy_used, directional_status,
directional_fitted and, with
keep_directional_fits = TRUE, directional_fits as well:
the directional sweep runs whatever becomes of the all-pairs fit, and
its per-azimuth outcome is what says whether any direction reached
a sill the pooled variogram did not, or whether every direction ran
past the fitted lags alike. The same shape, with
rejected_range = NA, variogram_model = NULL and
nugget = NA, is returned when no variogram model could be
fitted at all (both the exponential and the spherical fit singular,
which a flat, nugget-only variogram can produce, though on white
noise it was the outcome in only 1 of 30 draws: see above);
rejected_reason says so and the empirical variogram is still
attached.
An unclassed NA_real_ when estimation could not
be attempted at all, whose one attribute, rejected_reason,
says why: "package 'gstat', which the variogram needs, is not
installed", "<n> points, fewer than the 30 a variogram range
is estimated from", "<n> point(s) with a finite value to
model, fewer than the 30 a variogram range is estimated from",
"the response is constant", "the residuals on
predictor_vars are constant: the predictors explain the response
exactly" or "the points have no extent (the largest distance
between them is zero or could not be computed)". Nothing is
fitted, so none of the other attributes above exist: no
variogram, variogram_model or rejected_range,
which is what tells it from a range that was fitted and refused.
Attributes and the class do not affect is.na() or
is.finite(), so every downstream guard treats all three the same
way it always did.
Fitting a variogram to the residuals of a least-squares trend underestimates both the sill and the range, because the trend fit absorbs part of the long-wavelength spatial variation (Lark, Cullis and Welham 2006). Blocks sized from that range are then too small and a blocked validation is less conservative than it claims. How large the effect is depends on how smooth the trend terms are in space, and on how many there are. Measured for this estimator on simulated exponential fields (n = 300, true effective range 300, nugget 0.2, 40–60 draws), as the median ratio of the estimate from the trend-removed data to the estimate from the true field:
a white-noise covariate: OLS 1.00, REML 0.99 (no bias to speak of);
a spatially smooth covariate (a random field with range 300 or 1000): OLS 0.97, REML 0.95–0.98;
a linear trend in the coordinates: OLS 0.92, REML 1.04;
a quadratic trend in the coordinates (five terms): OLS 0.75, REML 1.06.
So for ordinary covariates the OLS bias is a few percent, and for trend
surfaces in the coordinates it is large. Iterating between a GLS trend
fit and a variogram refit (Neuman and Jacobson 1984) recovers only part of
it (0.80 in the quadratic case), because the variogram of GLS residuals
is biased too; REML does not fit a variogram to residuals at all, which is
why detrend = "reml" returns its own range estimate. Its price is
a single family (exponential with nugget), a cubic cost in n, and
a somewhat wider sampling spread. The default stays "ols" so that
existing scripts return what they did; a script that detrends on smooth
or coordinate-based terms should pass detrend = "reml", or size its
blocks with a margin.
The empirical variogram assumes second-order stationarity: a variance that is the same everywhere, so that semivariance depends on separation alone. A count response breaks that assumption by construction, because its variance tracks its mean, and so does any response with a mean-variance relationship (rates, proportions, skewed amounts). On such data the variogram mixes distance-dependence with mean-dependence, and the fitted range is not the thing it claims to be: where the mean is high the semivariance is inflated whatever the distance, which flattens the curve and can lengthen or shorten the apparent range depending on where the high-mean regions sit.
Supplying predictor_vars to detrend helps, because it removes the
part of the mean the covariates explain, but it does not fix the variance
structure: the residuals of an OLS fit to a count still have a variance
that tracks the fitted mean. The principled remedy is a variogram of
Pearson residuals from a model in the right family, which this function
does not compute. Until it does, treat the range from a count response as no
more than an order of magnitude, size blocks conservatively from it, and
prefer make_folds(method = "nndm"), which does not depend on a
fitted range at all.
Lark, R. M., Cullis, B. R. and Welham, S. J. (2006). On spatial prediction of soil properties in the presence of a spatial trend: the empirical best linear unbiased predictor (E-BLUP) with REML. European Journal of Soil Science, 57(6), 787–799. doi:10.1111/j.1365-2389.2005.00768.x
Neuman, S. P. and Jacobson, E. A. (1984). Analysis of nonintrinsic spatial variability by residual kriging with application to regional groundwater levels. Mathematical Geology, 16(5), 499–521. doi:10.1007/BF01886329
sac_nugget for the nugget behind the estimate,
plot.sac_range to see the variogram the estimate rests on.
Other cross-validation:
area_of_applicability(),
cv_bayes(),
cv_block_size_sweep(),
cv_gwr(),
cv_rf(),
cv_spatial(),
fold_separation(),
gwr_model_selection(),
make_folds(),
sac_nugget(),
select_features_forward()
if (requireNamespace("gstat", quietly = TRUE)) { library(sf) # A Gaussian random field with an exponential covariance: range # parameter 100, so the true effective range is 3 x 100 = 300, plus a # small nugget. set.seed(9) n <- 150 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 100) + diag(0.1, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) r <- estimate_sac_range(pts, response_var = "z") # print() throughout, because only the last value of a braced block is # shown on its own, and every one of these is worth reading. print(r) # the effective range, in metres print(attr(r, "directional")) # the four directional ranges print(attr(r, "variogram_model")) # the fitted gstat model behind it # A field whose range the data cannot pin down: the variogram never # reaches a sill within the lags fitted, so the answer is NA with the # refused value attached rather than a long range asserted. xy$trend <- sin(xy$x / 400) + rnorm(n, sd = 0.2) r2 <- estimate_sac_range(st_as_sf(xy, coords = c("x", "y"), crs = 32632), response_var = "trend") print(r2) attr(r2, "rejected_range") }if (requireNamespace("gstat", quietly = TRUE)) { library(sf) # A Gaussian random field with an exponential covariance: range # parameter 100, so the true effective range is 3 x 100 = 300, plus a # small nugget. set.seed(9) n <- 150 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 100) + diag(0.1, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) r <- estimate_sac_range(pts, response_var = "z") # print() throughout, because only the last value of a braced block is # shown on its own, and every one of these is worth reading. print(r) # the effective range, in metres print(attr(r, "directional")) # the four directional ranges print(attr(r, "variogram_model")) # the fitted gstat model behind it # A field whose range the data cannot pin down: the variogram never # reaches a sill within the lags fitted, so the answer is NA with the # refused value attached rather than a long range asserted. xy$trend <- sin(xy$x / 400) + rnorm(n, sd = 0.2) r2 <- estimate_sac_range(st_as_sf(xy, coords = c("x", "y"), crs = 32632), response_var = "trend") print(r2) attr(r2, "rejected_range") }
Accepts a single spatial_fit object or a named list of them.
Does NOT refit. Uses fitted() for in-sample and
predict() for new data.
evaluate_insample(fits, newdata = NULL, ...)evaluate_insample(fits, newdata = NULL, ...)
fits |
A |
newdata |
Optional sf object for out-of-sample evaluation. Must contain the response variable and all predictors. If NULL, in-sample metrics are computed. |
... |
Extra arguments passed to predict(). |
A data.frame with one row per model and columns for
model name, all regression metrics, and metric_basis: what the
row's metrics were computed on, "in-sample" (fitted values),
"out-of-bag" (an rf_fit's fitted values, see "What the
metrics are computed on") or "newdata". Rows with different
bases do not compare like for like. An element that is not a
spatial_fit is skipped, with a logged warning, and has no row;
a list in which no element is a spatial_fit is an error.
With newdata = NULL the metrics come from fitted(object).
That is in-sample for a gwr_fit or a bayesian_fit,
but out-of-bag for an rf_fit, whose fitted() method
returns out-of-bag predictions (see fit_rf_model). The
data.frame model_metrics() returns carries no label distinguishing
the two, so check object$info$fitted_are_oob before comparing
numbers across backends; evaluate_insample() and
compare_models() record it per model in a
metric_basis column. compare_models_cv scores every
backend the same way.
is with the total sum of squares taken about
the mean of the response the model was fitted to. In sample that
is the ordinary . With newdata it is out-of-sample
, the convention every cv_*() function uses: the model is
measured against the prediction it had to beat, the training mean, not
against the new rows' own mean, which it could not have known. It is
below 0 when the model predicts the new rows worse than the training mean
does, and it is NA when the response does not vary about that
baseline by more than rounding error (100 machine epsilons of its
magnitude, whatever its units).
MAPE divides by the observed value and SMAPE by
, so neither is defined where its denominator is zero.
Neither returns Inf or NaN. Both are averaged over the rows
whose denominator is non-zero, and are NA when no row qualifies.
Non-zero is judged at the scale of the data: a denominator no larger
than 100 machine epsilons times the largest one counts as zero, so the
rule does not depend on the units of the response.
The n_MAPE and n_SMAPE columns record how many rows that was;
the n column counts finite observation/prediction pairs. Read a
percentage error next to its count: when n_MAPE < n, MAPE is
an average over a subset of the data, whatever its value.
This bites on any response taking exact zeros: counts, rainfall,
abundance, claim amounts. On a zero-inflated response with 62 zeros out of
120, MAPE is an average over the 58 non-zero rows, which
n_MAPE = 58 now says. SMAPE fails differently and more
subtly: it drops the rows where observation and prediction are both near
zero (which on a well-fitted zero-inflated model are the rows it got
right), so it averages the harder rows only and reads worse than
the fit deserves; n_SMAPE shows how many rows it kept, and the
count is only a label, not a repair.
RMSE, MAE and use every finite row and are
unaffected; prefer them whenever the response can be zero. For a Bayesian
fit, cv_bayes() additionally reports CRPS and interval
coverage, which are proper scoring rules and have no such failure mode.
Other model evaluation:
compare_models(),
compare_models_cv(),
model_metrics(),
residual_morans_i()
if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(60, 0, 1000), y = 5e6 + runif(60, 0, 1000), a = rnorm(60)), coords = c("x", "y"), crs = 32632 ) pts$z <- 2 * pts$a + rnorm(60, 0, 0.3) # Fit on 40 rows and keep 20 back, so the second call really is out of # sample; scoring the training rows again would only re-read the fit. fit <- fit_rf_model(pts[1:40, ], "z", "a", num_trees = 50, seed = 1) print(evaluate_insample(fit)) # in-sample (out-of-bag for RF) evaluate_insample(fit, newdata = pts[41:60, ]) # on the 20 held-out rows }if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(60, 0, 1000), y = 5e6 + runif(60, 0, 1000), a = rnorm(60)), coords = c("x", "y"), crs = 32632 ) pts$z <- 2 * pts$a + rnorm(60, 0, 0.3) # Fit on 40 rows and keep 20 back, so the second call really is out of # sample; scoring the training rows again would only re-read the fit. fit <- fit_rf_model(pts[1:40, ], "z", "a", num_trees = 50, seed = 1) print(evaluate_insample(fit)) # in-sample (out-of-bag for RF) evaluate_insample(fit, newdata = pts[41:60, ]) # on the 20 held-out rows }
Fits a regression whose residual spatial structure is modelled explicitly, as
a Gaussian process over the coordinates, and so kept out of the errors. Two
things follow, and they are the reasons to reach for this backend. First,
every quantity comes with a posterior, so predictions carry calibrated
intervals instead of point estimates. Score them with
cv_bayes(), which reports held-out interval coverage and CRPS.
Second, the fitted length-scale is itself an estimate of how far the spatial
dependence reaches, a number you can read and report.
fit_bayesian_spatial_model( data_sf, response_var, predictor_vars, family = NULL, gp_k = NULL, gp_c = NULL, gp_iso = FALSE, prior = NULL, chains = 4, iter = 2000, warmup = floor(iter/2), cores = getOption("mc.cores", 1L), seed = 123, backend = c("auto", "cmdstanr", "rstan"), control = list(), compute_loo = TRUE, standardize_predictors = FALSE, check_convergence = TRUE, pointize = "auto", boundary = NULL, .already_prepped = FALSE )fit_bayesian_spatial_model( data_sf, response_var, predictor_vars, family = NULL, gp_k = NULL, gp_c = NULL, gp_iso = FALSE, prior = NULL, chains = 4, iter = 2000, warmup = floor(iter/2), cores = getOption("mc.cores", 1L), seed = 123, backend = c("auto", "cmdstanr", "rstan"), control = list(), compute_loo = TRUE, standardize_predictors = FALSE, check_convergence = TRUE, pointize = "auto", boundary = NULL, .already_prepped = FALSE )
data_sf |
An sf object with response, predictors, and geometries. |
response_var |
Response column name. |
predictor_vars |
Predictor column names. May be |
family |
A model family accepted by |
gp_k |
Positive integer giving the number of GP basis functions
per dimension, or NULL (default) to derive it from the
length-scale/domain ratio. The fitted model carries
|
gp_c |
Positive numeric boundary factor for the approximate GP, or
NULL (default) to derive it alongside |
gp_iso |
Logical; passed to |
prior |
Optional brms prior specification. When NULL and
|
chains |
Number of MCMC chains. Default 4. |
iter |
Total iterations per chain. Default 2000. |
warmup |
Warmup iterations. Default floor(iter/2). |
cores |
Number of cores for the sampler, one chain per core. Default
|
seed |
Integer seed. Default 123. |
backend |
"auto" (default), "cmdstanr", or "rstan". "auto" uses
cmdstanr only when a CmdStan build is actually available, and
rstan otherwise, which brms always brings. The cmdstanr
package is a thin interface and can be installed without a CmdStan build
( |
control |
Named list of sampler controls, merged over the
package defaults |
compute_loo |
Logical; compute PSIS-LOO. Default TRUE. |
standardize_predictors |
Logical; center and scale numeric predictors
before fitting. Default FALSE. When TRUE, the scaling parameters are
stored in the return value ( |
check_convergence |
Logical; after fitting, check for divergent
transitions, R-hat above 1.05 and an effective-sample-size ratio below
0.1. Each problem found is written to the log as a WARN line (shown on
the console unless |
pointize |
Strategy for non-point geometry coercion. |
boundary |
Optional polygonal sf/sfc for CRS harmonization. |
.already_prepped |
Logical (internal). If |
Choose it over fit_gwr_model() when you want one global
relationship plus an explicit spatial random field, and uncertainty you can
defend; choose GWR instead when the question is how a coefficient
varies across the map. Choose fit_rf_model() when
predictive accuracy matters more than an interpretable model and the
response is non-linear in the predictors. The cost here is time: this is
full MCMC via 'brms' and Stan, so it is minutes rather than seconds, and the
GP is fitted through a reduced-rank basis approximation whose size
(gp_k) trades fidelity against runtime.
GP basis count and boundary factor.
brms::gp() builds a full tensor grid over its covariates, so a term
gp(..x, ..y, k = gp_k) carries gp_k^2 basis functions: the
gp_k argument is the count per dimension, not the total rank.
Both gp_k and gp_c are therefore chosen from the ratio of the
estimated length-scale to the domain extent, following
Riutort-Mayol et al. (2023): gp_c is set large enough to contain the
upper length-scale bound, and gp_k large enough to resolve the lower
one. The derived value is typically 21-25 per dimension and is largely
independent of n.
The domain extent used is the one brms::gp(c = ) itself multiplies:
the full pooled range of the column-centred coordinates
(brms:::choose_L(), taken over the unique coordinate rows,
because brms:::.data_gp() reduces the covariates to unique rows first
under the default gr = TRUE, so repeat visits to one location do not
widen the domain), not the per-axis half-range in which
Riutort-Mayol et al. state their inequalities. Both constraints are really
constraints on the boundary , so expressing them in
brms's units is what keeps gp_c, gp_k and
$info$gp_ell_min describing the basis brms actually builds. A
gp_c derived on the half-range convention and handed to
brms::gp() produces a boundary twice as wide as intended, against
which gp_k under-resolves by a factor of two.
The GP term is built with scale = FALSE. brms::gp() otherwise
rescales its covariates so the maximum Euclidean distance between two points
is 1, and reports lscale in that space; since this function already
standardises the coordinates, and the length-scale prior, gp_c and the
adequacy check below are all expressed in those standardised units, a second
normalisation would leave every length-scale quantity in the wrong units.
After fitting, the posterior length-scale is compared against the smallest
scale the chosen basis can resolve
(1.75 * gp_c * S / gp_k, stored as $info$gp_ell_min); when
more than 10% of the posterior mass falls below it a WARN line is logged
and the share is recorded as
$info$convergence_diagnostics$gp_lscale_below_resolution, which is
the signal that gp_k should be raised. This runs with the other
checks, so only under check_convergence = TRUE.
Coordinate scaling and anisotropy. Before fitting the GP, X and Y coordinates are each centred and divided by their own standard deviation. This is a conditioning step: easting and northing frequently span very different ranges in a projected CRS, and handing Stan raw metres samples poorly.
Because the axes are scaled independently, a single shared
length-scale in the scaled space corresponds to an anisotropic kernel in the
original CRS, stretched by whatever ratio sd(X)/sd(Y) happens to
take. That ratio is a property of how the sampling locations are laid out,
not of the process being modelled, so it is not a defensible source of
anisotropy.
gp_iso = FALSE (the default) therefore fits one length-scale per
axis, letting the model estimate directional structure from the data instead
of inheriting it from the standardisation. Set gp_iso = TRUE to
recover the previous single-length-scale behaviour.
gp_iso does not affect cost: brms::gp() builds a
tensor grid either way, so the model carries gp_k^2 basis functions
regardless. The stored $info$coord_scaling list records the scaling
strategy, and $info$gp_iso records which kernel was used.
A bayesian_fit object (inherits from spatial_fit).
Supports predict(), fitted(), residuals(),
coef(), summary(), and model_metrics().
Model-specific metadata lives in $info (coords: the names of the
scaled coordinate columns handed to brms::gp(); coord_scaling,
predictor_scaling, gp_k, gp_c, gp_iso, gp_n_basis, gp_ell_min,
gp_S: the pooled centred range brms::gp(c = ) multiplies;
gp_cmeans: the column means brms centred the scaled coordinates on;
gp_xy_range: the training extrema of the scaled coordinates, which
predict() uses, with gp_S and gp_cmeans, to hold the GP boundary
at its fitted value;
gp_lengthscale_bounds: the c(lower, upper) the length-scale prior
was calibrated over; gp_lscale_prior: the length-scale prior
brms::validate_prior() reports the model will actually use,
which is not necessarily the one this function requested (several entries,
semicolon-separated, if brms resolved the axes differently); loo, looic,
convergence_ok (TRUE or FALSE, and NA when nothing
was checked, as under check_convergence = FALSE),
convergence_diagnostics: n_divergent,
max_rhat, min_neff_ratio, and rhat_failed /
neff_failed, the parameters that failed each check by name with
their values (empty when none failed), which is what makes a failed
check actionable; and n_dropped: the rows
prep_model_data() removed for missing or non-finite values or a
bad geometry, so $n can be read against nrow(data_sf)).
The raw brmsfit is in $engine.
Nothing in this function is Gaussian-specific except its default. A
numeric count, binary (0/1 or logical) or bounded response passes straight
through to brms under the family you name; zero-inflated and hurdle
counts, negative binomial, Bernoulli, beta, ordinal, categorical and
mixture families have all been verified to reach brms::brm() with
the GP term intact, the length-scale prior attached to each
distributional parameter's GP.
The response check is family-aware. A factor or character response is
accepted only under brms::categorical() or an ordinal family
(cumulative, sratio, cratio, acat), and refused
under every other family before anything is compiled; under gaussian a
logical response is refused too. The case to watch is a two-level factor
under brms::bernoulli(): brms would fit it, but
residuals(), summary(), model_metrics() and
cv_bayes() could not score a factor, so convert it to 0/1
first.
An ordinal or categorical fit has a probability per response category, not
one expected value per row. predict() with its default
type = "epred", fitted(), residuals(),
summary() and model_metrics() therefore stop with a message
saying so, and cv_bayes() refuses the family before fitting
anything. predict(type = "predict", draws = TRUE) returns the
posterior predicted categories, as category indices, for new rows as well
as the training ones (the share of draws in each category estimates its
probability), and brms::posterior_epred(fit$engine) the
probabilities for the training rows.
For the numeric families two things follow. First, the metrics that come
back from model_metrics() and the cv_*() functions are
not all meaningful for such a response: RMSE and MAE are, MAPE, SMAPE and R-squared
are Gaussian-shaped, and for this backend cv_bayes()'s CRPS
and interval coverage are the proper scores to read. See
model_metrics(), section "Which metrics survive a
non-Gaussian response". Second, estimate_sac_range() fits
a variogram to the raw response (or OLS residuals), whose variance tracks
its mean for a count; the range it reports is then less trustworthy than
for a Gaussian response, and its help page says how.
One trap. The response check reads the family's name through
brms's own accessor; a family object it cannot name is treated as
unknown and the check is skipped entirely, without falling back to either
rule above. A malformed family therefore buys less validation, not
more, and a wrong response type will surface as a Stan error, with no
message from this function.
A fixed-effect coefficient estimated alongside a spatial random effect is a
different quantity from the same coefficient in a non-spatial fit. When a
covariate is itself spatially smooth, the GP term absorbs part of its
effect and the two estimates can disagree sharply; when the response is
smoother than the covariate, the estimate can shrink toward zero regardless
of the true effect (Bolin and Wallin 2025). Under a correctly specified
spatial model this is not a bias but a change of estimand (Zimmerman and
Ver Hoef 2022): the spatial coefficient is the effect net of whatever
the spatial field can explain, and the non-spatial one is not. Which of the
two a user wants depends on the question, so the honest diagnostic is to
report both side by side and leave them unadjusted: fit the same formula
with stats::lm() or stats::glm() and compare. Compare like
with like: under standardize_predictors = TRUE the coefficients here
are per standard deviation of each predictor, so either fit the
non-spatial model on the same standardised columns or divide these slopes
by $info$predictor_scaling[[name]]$scale first (see
coef.bayesian_fit).
The literature on remedies is unsettled and this function takes no side. Restricted spatial regression (Hughes and Haran 2013) projects the spatial effect off the covariate space; later work argues it does not deliver what it promises (Hanks et al. 2015; Khan and Calder 2022). Within this backend, modelling the covariate and the spatial field as explicitly correlated (Marques, Kneib and Klein 2022) is expressible in a brms formula with no new code, and a spectral adjustment (Guan et al. 2023) is the principled route a Hilbert-space basis is well placed to support later.
Riutort-Mayol, G., Burkner, P.-C., Andersen, M. R., Solin, A. and Vehtari, A. (2023). Practical Hilbert space approximate Bayesian Gaussian processes for probabilistic programming. Statistics and Computing 33, 17. doi:10.1007/s11222-022-10167-2
Bolin, D. and Wallin, J. (2025). Spatial self-confounding: smoothness-related estimation bias in spatial regression models. Biometrika 113. doi:10.1093/biomet/asaf076
Guan, Y., Page, G. L., Reich, B. J., Ventrucci, M. and Yang, S. (2023). Spectral adjustment for spatial confounding. Biometrika 110, 699–719. doi:10.1093/biomet/asac069
Hanks, E. M., Schliep, E. M., Hooten, M. B. and Hoeting, J. A. (2015). Restricted spatial regression in practice: geostatistical models, confounding, and robustness under model misspecification. Environmetrics 26, 243–254. doi:10.1002/env.2331
Hughes, J. and Haran, M. (2013). Dimension reduction and alleviation of confounding for spatial generalized linear mixed models. Journal of the Royal Statistical Society: Series B 75, 139–159. doi:10.1111/j.1467-9868.2012.01041.x
Khan, K. and Calder, C. A. (2022). Restricted spatial regression methods: implications for inference. Journal of the American Statistical Association 117, 482–494. doi:10.1080/01621459.2020.1788949
Marques, I., Kneib, T. and Klein, N. (2022). Mitigating spatial confounding by explicitly correlating Gaussian random fields. Environmetrics 33, e2727. doi:10.1002/env.2727
Zimmerman, D. L. and Ver Hoef, J. M. (2022). On deconfounding spatial confounding in linear models. The American Statistician 76, 159–167. doi:10.1080/00031305.2021.1946149
Other model fitting:
fit_gwr_model(),
fit_rf_model(),
gp_lengthscale_bounds(),
new_spatial_fit(),
prep_model_data()
## Not run: # Not run: fits with Stan, which needs a working C++ toolchain and takes # minutes of MCMC -- both outside what an example may assume. if (requireNamespace("brms", quietly = TRUE)) { library(sf) set.seed(1) n <- 60 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$price <- 10 + 0.01 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) # Two short chains keep this to a few minutes; expect Stan to warn about # effective sample size, which is the cost of that. Use its defaults # (chains = 4, iter = 2000) for a fit to report. fit <- fit_bayesian_spatial_model(dat, "price", "elev", chains = 2, iter = 1000, compute_loo = FALSE) print(summary(fit)) head(predict(fit, newdata = dat)) # A zero-inflated count. The family is the only thing that changes. # summary()'s R2 and MAPE assume a Gaussian response, so for a count # read the coefficients here and score the model with cv_bayes(), whose # CRPS and interval coverage are defined for any response. dat$count <- rpois(n, exp(0.5 + 0.8 * dat$elev)) * rbinom(n, 1, 0.7) fit_zip <- fit_bayesian_spatial_model(dat, "count", "elev", family = brms::zero_inflated_poisson(), chains = 2, iter = 1000, compute_loo = FALSE) coef(fit_zip) } ## End(Not run)## Not run: # Not run: fits with Stan, which needs a working C++ toolchain and takes # minutes of MCMC -- both outside what an example may assume. if (requireNamespace("brms", quietly = TRUE)) { library(sf) set.seed(1) n <- 60 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$price <- 10 + 0.01 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) # Two short chains keep this to a few minutes; expect Stan to warn about # effective sample size, which is the cost of that. Use its defaults # (chains = 4, iter = 2000) for a fit to report. fit <- fit_bayesian_spatial_model(dat, "price", "elev", chains = 2, iter = 1000, compute_loo = FALSE) print(summary(fit)) head(predict(fit, newdata = dat)) # A zero-inflated count. The family is the only thing that changes. # summary()'s R2 and MAPE assume a Gaussian response, so for a count # read the coefficients here and score the model with cv_bayes(), whose # CRPS and interval coverage are defined for any response. dat$count <- rpois(n, exp(0.5 + 0.8 * dat$elev)) * rbinom(n, 1, 0.7) fit_zip <- fit_bayesian_spatial_model(dat, "count", "elev", family = brms::zero_inflated_poisson(), chains = 2, iter = 1000, compute_loo = FALSE) coef(fit_zip) } ## End(Not run)
Fits a GWR using GWmodel on an sf dataset with either adaptive or fixed bandwidth.
fit_gwr_model( data_sf, response_var, predictor_vars, adaptive = TRUE, bandwidth = NULL, kernel = c("bisquare", "gaussian", "tricube", "boxcar", "exponential"), .already_prepped = FALSE )fit_gwr_model( data_sf, response_var, predictor_vars, adaptive = TRUE, bandwidth = NULL, kernel = c("bisquare", "gaussian", "tricube", "boxcar", "exponential"), .already_prepped = FALSE )
data_sf |
An sf object with response, predictors, and geometries. |
response_var |
Response column name. |
predictor_vars |
Predictor column names (numeric columns; a name given twice counts once). |
adaptive |
Logical; use adaptive bandwidth. Default TRUE. When TRUE, bandwidth is an integer number of nearest neighbours. When FALSE, bandwidth is a fixed distance in CRS units. |
bandwidth |
Optional numeric bandwidth value. For adaptive mode this
is an integer (number of neighbours); for fixed mode a distance in the
units of the projected CRS the fit runs in. |
kernel |
Kernel function type. One of "bisquare" (default), "gaussian", "tricube", "boxcar", "exponential". |
.already_prepped |
Logical (internal). If |
A gwr_fit object (inherits from spatial_fit).
Supports predict(), fitted(), residuals(),
coef(), summary(), and model_metrics().
Model-specific metadata lives in $info: bandwidth, adaptive,
kernel, AICc (NA, with a warning, where GWmodel's AICc is
undefined: its effective number of parameters is not below
, the local regressions all but interpolate the data, and the
large negative value GWmodel reports would rank the fit above any
other), bandwidth_is_fallback (TRUE when automatic
selection failed and the arbitrary fallback was used),
condition_index (the global index), local_collinearity
(one row per observation: row, x, y,
n_window, cn and cn_slopes; see
Collinearity diagnostics), n_local_collinear (the
locations whose slopes count as collinear), n_local_singular
(the locations whose local coefficients came back non-finite),
nonfinite_coef (a logical matrix, one row per observation and
one column per term, with Intercept first, TRUE where the
local coefficient came back non-finite, so the count in
n_local_singular can be placed) and n_dropped (the rows
prep_model_data() removed for missing or non-finite values or a
bad geometry, so $n can be read against nrow(data_sf)).
The raw GWmodel result is in $engine.
The function computes scaled condition indices of the design and
warns when one exceeds 30, the conventional threshold, which Wheeler &
Tiefelsdorf (2005) carry over to the local designs of GWR. An index is
the ratio of the largest to the smallest singular value (from an SVD) after
each column is scaled (Belsley, Kuh & Welsch 1980); an exactly singular
design gives Inf, which counts as the worst case, not an exempt one.
Scaling makes the index independent of the predictors' units;
kappa() on the raw matrix is not, and a threshold on it is a
threshold on nothing in particular.
A global index is computed on the predictors centred at their means
(with the intercept, which centring makes orthogonal to them), and kept as
info$condition_index. It measures how nearly the predictors are
collinear with one another over the whole study area; it is 1 for a single
predictor, and a change of origin (degrees C or kelvin, a year or years
since 2000) does not move it.
Local indices are then computed at every location, on the design
the local regression there inverts: each row weighted by the square root
of its kernel weight at the bandwidth the model is fitted with (supplied or
selected), with rows of negligible weight dropped. A window left with
fewer rows than columns counts as singular. Two indices are kept for each
window, as columns of info$local_collinearity:
cnBelsley's index of the intercept plus the predictors, scaled to unit length but not centred. A predictor whose values in the window are far from 0 against their spread (a year, a temperature in kelvin) is collinear with the intercept and raises it: the local intercept is then an extrapolation to 0 and is ill-determined, but the slopes are not. It is what GWmodel's own solve sees.
cn_slopesThe index for the slopes: the predictors centred at their weighted mean in the window and each divided by its standard deviation over the whole study area. It is 1 when the predictors vary as much, and as independently, inside the window as they do across the study area; it grows as a predictor becomes nearly constant inside the window (a regional covariate) or two predictors move together there. It does not depend on the predictors' origin or units.
A window's slopes count as collinear when cn_slopes is above 30 or
singular, or when cn is above 1e6, where GWmodel's uncentred solve starts
to lose precision in the slopes too. Those windows are counted in
info$n_local_collinear. A predictor that is constant, or nearly so,
inside a window is caught this way whether it is alone or has company, so a
single predictor is surveyed too.
A warning is issued whenever any location has collinear slopes; the
wording reports a percentage when more than 25% of locations are affected
and a count otherwise. Both are real R warnings, not log lines.
Coefficients at a near-singular window are unstable and can be implausibly
large. An exactly singular window (an indicator that is constant
inside it, or fewer observations than parameters) makes GWmodel stop, so
the fit fails with an error that says so; the window is not returned as
NaN. A window with only cn above 30 raises no warning:
plot(fit, type = "coefficients", term = "Intercept") masks it, and slope
maps do not. Centre such a predictor if you want an interpretable local
intercept.
After the fit, the local coefficient surfaces are scanned and a further
warning counts local regressions that came back non-finite. GWmodel
returns those where the kernel weights are undefined: with an adaptive
bandwidth of k, a location where k or more observations share the same
coordinates has a kernel of zero width, and every kernel but the boxcar
divides 0 by 0 there. The warning names that cause when it applies.
fitted(), residuals(), summary() and model_metrics() all drop those
rows, so when this warning fires the metrics describe only the part of the
study area that fitted.
Other model fitting:
fit_bayesian_spatial_model(),
fit_rf_model(),
gp_lengthscale_bounds(),
new_spatial_fit(),
prep_model_data()
if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE)) { library(sf) set.seed(1) n <- 60 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$price <- 10 + 0.01 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) fit <- fit_gwr_model(dat, "price", "elev", bandwidth = 30) print(summary(fit)) # print(): only a block's last value shows on its own head(predict(fit, newdata = dat)) # newdata is re-projected if needed }if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE)) { library(sf) set.seed(1) n <- 60 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$price <- 10 + 0.01 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) fit <- fit_gwr_model(dat, "price", "elev", bandwidth = 30) print(summary(fit)) # print(): only a block's last value shows on its own head(predict(fit, newdata = dat)) # newdata is re-projected if needed }
Fits a regression random forest on an sf dataset and returns it as a
spatial_fit, so it works with cv_spatial,
predict_surface, area_of_applicability and the
plot() method like any other backend.
fit_rf_model( data_sf, response_var, predictor_vars, num_trees = 500L, mtry = NULL, min_node_size = NULL, importance = c("permutation", "impurity", "none"), include_coords = FALSE, replace = TRUE, sample_fraction = NULL, seed = 123L, num_threads = NULL, .already_prepped = FALSE, ... )fit_rf_model( data_sf, response_var, predictor_vars, num_trees = 500L, mtry = NULL, min_node_size = NULL, importance = c("permutation", "impurity", "none"), include_coords = FALSE, replace = TRUE, sample_fraction = NULL, seed = 123L, num_threads = NULL, .already_prepped = FALSE, ... )
data_sf |
An sf object with response, predictors and geometry. |
response_var |
Response column name. |
predictor_vars |
Predictor column names. |
num_trees |
Number of trees. Default 500. |
mtry |
Predictors sampled per split. |
min_node_size |
Minimum node size. |
importance |
|
include_coords |
Add the coordinates as predictors. Default
|
replace |
Logical; grow each tree on a bootstrap sample drawn
with replacement ( |
sample_fraction |
Fraction of rows drawn for each tree. |
seed |
Seed passed to ranger. Default 123. |
num_threads |
Threads for ranger. Default |
.already_prepped |
Internal; skip |
... |
Passed to |
An rf_fit object (inherits from spatial_fit).
$info carries num_trees, mtry, min_node_size,
importance_type, importance (a named numeric, or
NULL when importance = "none"), include_coords,
replace and sample_fraction (the sampling each tree was
grown on, with sample_fraction resolved to the number ranger
used), oob_rmse and oob_r_squared (each NA_real_ when
ranger did not compute it; forwarding oob.error = FALSE through
... is one way to get there), fitted_are_oob (always
TRUE; summary() reads it to label its metrics),
seed and n_dropped (the rows prep_model_data()
removed for missing or non-finite values or a bad geometry, so $n
can be read against nrow(data_sf)). The raw forest is in
$engine.
Handing a random forest the x and y coordinates lets it reproduce the
training surface almost exactly by memorising location, and then fail badly
anywhere it has not seen. Random cross-validation will not catch this:
nearby points leak between folds, so the memorised surface scores well.
That is how the practice became common. Meyer et al. (2019) show the
collapse directly. include_coords therefore defaults to
FALSE, and setting it to TRUE logs a caution, once per
session (it is a deliberate choice, so it is not raised as an R warning,
and it is not repeated for every fold of a cross-validation). If you do use it,
score the model with cv_spatial and blocked folds, never with
the out-of-bag error.
ranger's OOB error holds each observation out of the trees that did
not sample it. That is a random hold-out, so under spatial autocorrelation
it is optimistic for exactly the reason random k-fold is: the trees that
"did not see" a point almost certainly saw its neighbours. It is reported
as $info$oob_rmse and $info$oob_r_squared and labelled as OOB
everywhere it appears. Use cv_rf for a spatial estimate.
fitted() on an rf_fit returns out-of-bag predictions,
not in-sample ones, following the convention of the random forest packages
themselves. In-sample predictions from a forest are close to memorisation
and would make summary() report a fictitious R-squared. The
consequence is that summary() means something different here than for
a gwr_fit or bayesian_fit, whose fitted values are in-sample:
do not compare the two directly. compare_models_cv exists for
that.
A row that every tree sampled has no out-of-bag prediction, and ranger
reports NaN for it. That is every row under replace = FALSE
with sample_fraction = 1, and a few under a small num_trees.
The fit warns with the count; fitted() and residuals() are
NaN on those rows, and summary() says how many rows its
metrics were computed on. With no row out of bag at all, the OOB error
(NA in $info$oob_rmse and $info$oob_r_squared) and
the permutation importance (NaN) are undefined too, and
print() says so. cv_rf() scores its fold forests on
the held-out rows, never out of bag, so it warns once with the number of
folds affected rather than once per fold.
Meyer, H., Reudenbach, C., Wöllauer, S. and Nauss, T. (2019). Importance of spatial predictor variable selection in machine learning applications – moving from data reproduction to spatial prediction. Ecological Modelling 411, 108815. doi:10.1016/j.ecolmodel.2019.108815
Strobl, C., Boulesteix, A.-L., Zeileis, A. and Hothorn, T. (2007). Bias in random forest variable importance measures: illustrations, sources and a solution. BMC Bioinformatics 8, 25. doi:10.1186/1471-2105-8-25
cv_rf for a spatially blocked performance estimate,
area_of_applicability, which can take
weights = pmax(fit$info$importance, 0) when that importance is
finite (it is NaN when no row is out of bag; see "What
fitted() returns").
Other model fitting:
fit_bayesian_spatial_model(),
fit_gwr_model(),
gp_lengthscale_bounds(),
new_spatial_fit(),
prep_model_data()
if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) n <- 150 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$z <- 2 * dat$a - dat$b + rnorm(n, 0, 0.3) fit <- fit_rf_model(dat, "z", c("a", "b")) print(fit) # print(): only a block's last value shows on its own fit$info$importance # permutation importance, a and b }if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) n <- 150 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$z <- 2 * dat$a - dat$b + rnorm(n, 0, 0.3) fit <- fit_rf_model(dat, "z", c("a", "b")) print(fit) # print(): only a block's last value shows on its own fit$info$importance # permutation importance, a and b }
Posterior expectation at the training locations: the column means of
brms::posterior_epred(). These are in-sample values.
## S3 method for class 'bayesian_fit' fitted(object, ...)## S3 method for class 'bayesian_fit' fitted(object, ...)
object |
A |
... |
Ignored. |
Numeric vector of length object$n. A posterior that cannot
be drawn is an error, as is a family with a probability per response
category (ordinal, categorical), which has no single fitted value per row.
posterior_epred() is O(draws x n), and summary(),
residuals(), model_metrics() and compare_models() each
call fitted() independently, so the value is memoised in an
environment carried in object$info$.cache (reference semantics, so it
survives R's copy-on-modify). The cache holds epred column means only,
which is why predict(object, summary = "median") and
predict(object, type = "predict") recompute it from scratch.
Call clear_fitted_cache if the engine has been mutated by hand
after fitting.
An environment has reference semantics, which is what makes the memo survive
R's copy-on-modify. But it also means fit2 <- fit gives the two
objects the same cache. Assigning a different data_sf to the
copy would then have returned the original's cached values, at the original's
length, which residuals() silently recycled against the copy's shorter
response. The entry therefore carries the n and a digest of the
training data it was computed from, and is recomputed whenever either fails
to match, so a copy with different data recomputes instead of reading the
original's answer.
Two consequences of the shared environment remain and cannot be removed from
here: clear_fitted_cache on one copy empties the cache both
share (harmless, since the other simply recomputes), and identical()
cannot distinguish two fits by their caches. The digest covers
data_sf only. The entry is also tied to the engine that computed
it – a refit or update() of the brmsfit is a different
sampling run and recomputes – but a brmsfit edited by hand in place
is what clear_fitted_cache is for. The entry holds only the
values and a small identifier of the sampling run, so a fit saved with
saveRDS() after fitted() is no larger for it.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
Reads the fitted values out of the GWmodel result, which stores them under
one of several names depending on version and entry point; the extraction
falls back through the local coefficients and the residuals when no direct
column is present. These are in-sample values (each observation
was inside its own bandwidth window), so summary() on a
gwr_fit reports an optimistic fit. Use cv_gwr for a
spatially blocked estimate.
## S3 method for class 'gwr_fit' fitted(object, ...)## S3 method for class 'gwr_fit' fitted(object, ...)
object |
A |
... |
Ignored. |
Numeric vector of length object$n (NA where extraction
failed).
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
Returns out-of-bag predictions rather than in-sample ones. See
fit_rf_model for why, and for what it means for
summary().
## S3 method for class 'rf_fit' fitted(object, ...)## S3 method for class 'rf_fit' fitted(object, ...)
object |
An |
... |
Ignored. |
Numeric vector of length object$n.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
Blocked cross-validation exists to put distance between a test point and
the training points that could tell you its value. Whether it succeeded is
a measurement, and until now the package only offered a proxy for it: the
block size compared against the estimated autocorrelation range, reported
by make_folds() as a warning. That comparison is about the
design. This is about the result: for every held-out point,
the distance to its nearest training point, summarised per fold.
fold_separation(folds, data_sf, sac = NULL)fold_separation(folds, data_sf, sac = NULL)
folds |
A |
data_sf |
The layer the folds were built on. Row identifiers are
matched through |
sac |
Optional: an |
The two can disagree, and the direction is not obvious. Blocks wider than the range still leak wherever a test point sits near a block edge with training data just across it, which is most of the points in a fine block grid; conversely a fold whose blocks are narrower than the range can still separate well if the points inside them are clustered. The share of held-out points closer to training data than the correlation range is the number that settles it, and it is the last column here.
Nothing is estimated: the distances come from the geometry, and the range,
when one is shown, is the one the folds already carry (make_folds(
auto_range = TRUE) records it) or the one you pass as sac.
A data.frame of class fold_separation, one row per fold:
fold (the fold's number: for the $folds of a
cv_*() result, the fold_id its fold_metrics use,
which differs from the list position once a fold has been dropped),
n_train, n_test, n_blocks (NA
for a scheme with no blocks), min_dist and median_dist
(distance from a held-out point to its nearest training point, in the
units of the CRS the crs attribute names), and
within_range (the share of held-out points closer to
training data than sac; NA without one). Attributes:
method, sac_range, crs (the CRS the distances were
measured in) and n_unknown_ids.
make_folds() for the fold schemes and the block
sizing this measures the outcome of; cv_block_size_sweep()
for choosing a block size by cross-validated error instead.
Other cross-validation:
area_of_applicability(),
cv_bayes(),
cv_block_size_sweep(),
cv_gwr(),
cv_rf(),
cv_spatial(),
estimate_sac_range(),
gwr_model_selection(),
make_folds(),
sac_nugget(),
select_features_forward()
library(sf) set.seed(1) n <- 200 pts <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)), coords = c("x", "y"), crs = 32632 ) # Random folds put a training point almost on top of every held-out one. random <- make_folds(pts, k = 4, method = "random_kfold", seed = 1) print(fold_separation(random, pts, sac = 200)) # Blocked folds hold out whole neighbourhoods, so the distances grow. blocked <- make_folds(pts, k = 4, method = "block_kfold", block_size = 250, seed = 1) print(fold_separation(blocked, pts, sac = 200))library(sf) set.seed(1) n <- 200 pts <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)), coords = c("x", "y"), crs = 32632 ) # Random folds put a training point almost on top of every held-out one. random <- make_folds(pts, k = 4, method = "random_kfold", seed = 1) print(fold_separation(random, pts, sac = 200)) # Blocked folds hold out whole neighbourhoods, so the distances grow. blocked <- make_folds(pts, k = 4, method = "block_kfold", block_size = 250, seed = 1) print(fold_separation(blocked, pts, sac = 200))
Creates an sf POINT layer of "seed" locations. Multiple strategies are supported: user-provided points, uniform random sampling within a boundary, or k-means clustering of a sampling cloud.
get_voronoi_seeds( boundary = NULL, method = c("kmeans", "random", "provided"), n = NULL, seeds = NULL, sample_points = NULL, kmeans_nstart = 10, kmeans_iter = 100, set_seed = NULL )get_voronoi_seeds( boundary = NULL, method = c("kmeans", "random", "provided"), n = NULL, seeds = NULL, sample_points = NULL, kmeans_nstart = 10, kmeans_iter = 100, set_seed = NULL )
boundary |
Optional polygonal sf object defining the sampling area. |
method |
One of "kmeans", "random", "provided". |
n |
Integer; number of seeds to return. Required for
For Besides a number, With |
seeds |
sf POINT object of user-provided seeds (method = "provided"). |
sample_points |
Optional sf POINT cloud for k-means clustering. Only
the first two coordinate columns are clustered, so a Z or M dimension does
not join the distance calculation and dominate it; rows with empty or
non-finite coordinates are dropped with a warning, so they never reach
|
kmeans_nstart |
Integer; nstart for kmeans(). Default 10. The
partition is |
kmeans_iter |
Integer; iter.max for kmeans(). Default 100. |
set_seed |
Optional integer RNG seed. |
An sf POINT object with seed_id and method columns. With
method = "kmeans" it also carries attr(, "kmeans"), the run behind
the seeds: cluster (the seed_id each clustered cloud point was
assigned to), rows (those points' positions in the cloud, since rows
with unusable coordinates are dropped first), size (points per seed),
withinss and tot_withinss (the within-cluster sums of squares),
iter and nstart. For the scored centres of a selection or profile
(see n) it describes sample_points assigned to them, each point to
its nearest seed, with iter NA and nstart the profile's restarts,
and is absent when no sample_points were given.
Other tessellation:
build_tessellation(),
create_grid_polygons(),
create_grid_polygons_cached(),
create_voronoi_polygons(),
ensure_stable_poly_id(),
plot_tessellation_map(),
voronoi_seeds_kmeans(),
voronoi_seeds_random()
library(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) get_voronoi_seeds(bnd, method = "random", n = 5, set_seed = 1)library(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) get_voronoi_seeds(bnd, method = "random", n = 5, set_seed = 1)
Computes sensible prior bounds for the GP length-scale parameter
of a squared-exponential (exponentiated-quadratic) kernel,
.
The "effective range" where correlation drops to ~5% is
.
gp_lengthscale_bounds(coords_xy, q_small = 0.25, max_n = 1000L)gp_lengthscale_bounds(coords_xy, q_small = 0.25, max_n = 1000L)
coords_xy |
Numeric matrix or data.frame of coordinates with at least
two columns; the first two are used, and replicated rows are collapsed
before the distance quantiles are taken. |
q_small |
Numeric quantile for the lower bound, a single number in
|
max_n |
Maximum number of distinct points to use in the distance
computation. Default 1000. Set to |
Subsamples large datasets to avoid O(n^2) memory and time cost.
These are the bounds a length-scale prior is calibrated over, not
the scales a fitted model can resolve: that depends on the basis size
(gp_k in fit_bayesian_spatial_model(), which reports
it as $info$gp_ell_min). Both bounds are fixed fractions of the
spread of pairwise distances, so they do not shrink as points are added to
the same area. A surface whose range sits below what the basis resolves
needs a larger gp_k: more points help the data identify a short
range, but they make neither these bounds nor the derived basis finer.
Named numeric vector c(lower, upper) on the length-scale;
c(lower = 0.001, upper = 1) when fewer than two distinct locations
or no positive distances remain.
Other model fitting:
fit_bayesian_spatial_model(),
fit_gwr_model(),
fit_rf_model(),
new_spatial_fit(),
prep_model_data()
set.seed(1) xy <- cbind(runif(50), runif(50)) gp_lengthscale_bounds(xy)set.seed(1) xy <- cbind(runif(50), runif(50)) gp_lengthscale_bounds(xy)
Wraps GWmodel::gwr.model.selection(), which grows a GWR model one
predictor at a time and scores every intermediate model with a corrected
Akaike information criterion. GWmodel returns two loosely-coupled lists;
this function returns a ranked table.
gwr_model_selection( data_sf, response_var, candidate_vars, bandwidth = NULL, adaptive = TRUE, kernel = c("bisquare", "gaussian", "tricube", "boxcar", "exponential"), bw_approach = c("AICc", "CV"), max_models = 200L, dmat_max_n = 2000L, quiet = TRUE, .engine = .gwr_ms_engine )gwr_model_selection( data_sf, response_var, candidate_vars, bandwidth = NULL, adaptive = TRUE, kernel = c("bisquare", "gaussian", "tricube", "boxcar", "exponential"), bw_approach = c("AICc", "CV"), max_models = 200L, dmat_max_n = 2000L, quiet = TRUE, .engine = .gwr_ms_engine )
data_sf |
An |
response_var |
Response column name. |
candidate_vars |
Character vector naming at least two numeric
predictors to choose among. Factor, character and logical candidates are
refused: GWmodel fits a factor as several model-matrix columns while this
sweep counts it as one variable, so the criteria would not be comparable,
and |
bandwidth |
Bandwidth held fixed across all candidate models. If
|
adaptive |
Logical; adaptive (nearest-neighbour) bandwidth. Default
|
kernel |
Weighting kernel. One of |
bw_approach |
Criterion for the bandwidth search: |
max_models |
Refuse the call if the sweep would exceed this many model fits. Default 200, which admits up to 19 candidates. |
dmat_max_n |
Precompute and reuse an |
quiet |
Discard GWmodel's progress output. Default |
.engine |
Internal; injectable backend used for testing. |
An object of class gwr_model_selection, a list with:
best (character vector of the selected predictors);
table (ranked data.frame of every model evaluated, with columns
rank, n_vars, variables and criterion;
criterion is NA, and the model ranked last, where GWmodel
could not evaluate it or where AICc is undefined because the model's
effective number of parameters is not below ,
which raises a warning);
criterion (label for the criterion actually read, noting when it
had to be located positionally);
criterion_by_name (logical: whether that column was found by
its name rather than by the documented position),
criterion_column (the column it was read from) and
criterion_verified (logical: FALSE exactly when the
column was read positionally from a table that did not have the four
documented columns, which is the case the log calls unverified. Gate
a script on this field instead of on the label);
response_var and candidate_vars (the response and the full
candidate set the sweep ran over, both echoed by print());
bandwidth, bandwidth_source, adaptive and
kernel (the smoothing held fixed across the sweep, and where it
came from);
n_obs, n_models, used_dmat; and raw
(GWmodel's unmodified return: the two-element list of its model list and
its diagnostic table).
The criterion is in-sample. AICc penalises the effective number of
parameters, so it is not the same thing as maximising fit, but it is still
computed on the data the model was fitted to, and under spatial
autocorrelation an in-sample criterion is optimistic in a way that a
spatially blocked estimate is not. Treat this as fast screening.
select_features_forward performs the same forward search
against a spatially blocked cross-validated score; it costs far more and is
the one to trust when the answer matters. When the two disagree, the
disagreement is itself informative. It usually means a candidate is
predictive only locally.
Two further limitations follow from the method itself:
One bandwidth for every model. Comparing criteria across
models requires holding the smoothing fixed, but the bandwidth is itself
a fitted quantity, and the value chosen for the full model is not
optimal for a one-predictor model. This is how the method is defined
(Lu et al. 2014); it is not an implementation shortcut. Refit the
selected model with bandwidth = NULL to re-optimise once the
variable set is settled.
The null model is never evaluated. The sweep starts from one predictor, so the result always names at least one. It cannot tell you that none of the candidates help.
The sweep fits p * (p + 1) / 2 GWR models for p candidates
(55 at p = 10, 210 at p = 20), each over all n locations.
max_models stops the call before it runs for hours.
raw is GWmodel's own list(model.list, GWR.df), so its two
elements have to be unpacked before GWmodel's own helpers will take them:
GWmodel::gwr.model.view() takes (DeVar, InDeVars, model.list),
so the call is
GWmodel::gwr.model.view(sel$response_var, sel$candidate_vars, sel$raw[[1]])
Pass sel$raw[[1]], not sel$raw. The diagnostic table is
sel$raw[[2]], an unlabelled numeric matrix whose columns are
bandwidth, AIC, AICc, RSS in that order; the
criterion column of $table is its third column.
Lu, B., Harris, P., Charlton, M. and Brunsdon, C. (2014). The GWmodel R package: further topics for exploring spatial heterogeneity using geographically weighted models. Geo-spatial Information Science 17(2), 85–101. doi:10.1080/10095020.2014.917453
select_features_forward for the blocked
cross-validated counterpart, fit_gwr_model to fit the
selected model.
Other cross-validation:
area_of_applicability(),
cv_bayes(),
cv_block_size_sweep(),
cv_gwr(),
cv_rf(),
cv_spatial(),
estimate_sac_range(),
fold_separation(),
make_folds(),
sac_nugget(),
select_features_forward()
if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE)) { library(sf) set.seed(1) n <- 80 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n), noise = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$z <- 2 * dat$a - dat$b + rnorm(n, 0, 0.5) sel <- gwr_model_selection(dat, "z", c("a", "b", "noise"), bandwidth = 30) print(sel) # every model tried, by AICc print(sel$best) # the winning predictor set: a and b, not noise fit <- fit_gwr_model(dat, "z", sel$best) fit }if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE)) { library(sf) set.seed(1) n <- 80 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n), noise = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$z <- 2 * dat$a - dat$b + rnorm(n, 0, 0.5) sel <- gwr_model_selection(dat, "z", c("a", "b", "noise"), bandwidth = 30) print(sel) # every model tried, by AICc print(sel$best) # the winning predictor set: a and b, not noise fit <- fit_gwr_model(dat, "z", sel$best) fit }
Aligns two sf objects to a common CRS.
harmonize_crs( a, b, prefer = c("a", "b"), target_crs = NULL, on_transform_error = c("stop", "set_crs") )harmonize_crs( a, b, prefer = c("a", "b"), target_crs = NULL, on_transform_error = c("stop", "set_crs") )
a, b
|
Objects of class sf or sfc. |
prefer |
Which object's CRS to keep ("a" or "b"). |
target_crs |
Optional target CRS to apply to both: anything
|
on_transform_error |
What to do when st_transform() fails:
|
When one input carries no CRS, the same lon/lat heuristic
ensure_projected() uses decides what happens, so both entry points place
identical data in the same place: coordinates that look like degrees are
taken as EPSG:4326 and reprojected to the other object's CRS (or
target_crs); coordinates that do not are stamped with
sf::st_set_crs(), which relabels without moving them. Either way the
assumption is announced with a warning.
A named list with components a and b.
Other spatial data preparation:
clip_target_for(),
coerce_to_points(),
ensure_projected(),
prep_model_data()
library(sf) a <- st_as_sf(data.frame(x = c(500000, 500100), y = c(4000000, 4000100)), coords = c("x", "y"), crs = 32632) b <- st_transform(a, 4326) # same points, lon/lat h <- harmonize_crs(a, b) # b is brought into a's CRS c(a = st_crs(h$a)$epsg, b = st_crs(h$b)$epsg) st_crs(h$a) == st_crs(h$b)library(sf) a <- st_as_sf(data.frame(x = c(500000, 500100), y = c(4000000, 4000100)), coords = c("x", "y"), crs = 32632) b <- st_transform(a, 4326) # same points, lon/lat h <- harmonize_crs(a, b) # b is brought into a's CRS c(a = st_crs(h$a)$epsg, b = st_crs(h$b)$epsg) st_crs(h$a) == st_crs(h$b)
summarize_by_cell() aggregates by plain means inside cell
boundaries. A block-kriging aggregator would instead weight observations
by their spatial correlation with the cell, and would return an estimate
and a variance for every cell, thin or empty. Whether that is worth
having on a given layer is a question with a measurable answer, and this
function measures it, changing no cell value: for every cell it reports
the block-kriging estimate and variance implied by a fitted variogram,
that variance as a share of the variance the cell's mean would have with
no data at all, and, where the cell has points,
whether it exceeds the design-based variance of the plain mean,
; and it scores the variogram itself by blocked
cross-validation.
kriging_adequacy( assigned_points_sf, response_var, cells_sf, id_col = "poly_id", sac = NULL, folds = NULL, k = 5L, seed = 123L, nmax = 50L, max_neighbours = 2000L, max_box_ratio = 1000, quiet = TRUE )kriging_adequacy( assigned_points_sf, response_var, cells_sf, id_col = "poly_id", sac = NULL, folds = NULL, k = 5L, seed = 123L, nmax = 50L, max_neighbours = 2000L, max_box_ratio = 1000, quiet = TRUE )
assigned_points_sf |
Points with a cell identifier column, as
|
response_var |
The response column. |
cells_sf |
The cell polygons, with the matching ID column. A layer with no CRS is taken to be in the points' CRS (and points with none in the cells'), with a warning. |
id_col |
Preferred name of the ID column, found as
|
sac |
Optional |
folds |
Optional |
k, seed
|
Folds and seed for that construction. |
nmax |
The number of neighbours each kriging system uses
( |
max_neighbours |
The largest kriging system a cell is given when its
neighbourhood has to grow to hold all its own locations; a cell that
would need more is left out ( |
max_box_ratio |
A cell whose bounding box is more than this many
times its area is left out ( |
quiet |
Suppress progress messages. Default |
An sf object of class "kriging_adequacy", one row
per cell with the cell geometry and: the ID column, n (points in
the cell), mean (the plain mean), se (its naive standard
error), kr_pred, kr_var, kr_ratio,
kr_exceeds_design, kr_shift and kr_n_used (how many
locations the cell was kriged from; NA for a cell left out).
Attributes:
variogram (the model frame), sill, nugget,
range, range_identified, rejected_reason (why the
range was refused, from sac; NA when it was identified),
cv (a list:
zscore_var, zscore_mean, rmse, n_pred,
k, method), nmax, n_points (the points
used), n_locations (the distinct locations among them, which
the kriging used) and cells_left_out (counts of cells left out,
named shape for max_box_ratio and size for
max_neighbours).
kr_ratioThe block-kriging variance over the cell's prior
variance, in . The prior variance is the variance the
cell's mean would have with no data at all, : the
covariance averaged over pairs of points in the cell, on the
discretisation gstat block-kriges with, and without the nugget,
which averages out over a block (gstat leaves it out of the
block variance too). Each cell has its own: a cell's mean varies less
than a single point does, and far less once the cell is wider than the
range, so the point sill is not the scale. It is the coverage score,
and it needs no hand-set threshold in metres or point counts: as it
approaches 1 the estimate carries almost no information from the data
about the cell and is reverting to the estimated mean. Ordinary
kriging adds the variance of that estimated mean, so a cell the data do
not reach comes out at or above its prior variance and reads 1. A
cell at 0.05 is well determined; a cell at 0.8 is mostly prior.
NA when the model is a pure nugget, where a cell mean has no
prior variance to be a share of.
kr_exceeds_designTRUE where the kriging variance is
larger than from the cell's own points: kriging is not
earning its keep there, and that is said per cell instead of
globally. NA for cells with fewer than two points, where
does not exist.
kr_shiftThe kriged estimate minus the plain mean, in
units of the plain mean's standard error (NA where that is not
defined). How much the aggregator would move the value, against the
precision the value has.
The kriging variance is only as good as the variogram. With blocked
folds, each held-out point is kriged from the training folds and the
standardised error is recorded; the
variance of those over all held-out points should be about 1. Its
departure from 1 measures how badly the kriging variance is understated
(or overstated): 1.4 means the variances are roughly 40 percent too
small. Measured on simulated exponential fields (n = 300 on a 1000-unit
extent, sill 1, nugget 0.2, five blocked folds, eight draws per
configuration): 0.93–1.07 with the true variogram, 0.85–1.01 with the
variogram estimated from the same points. With the nugget understated
tenfold it moved only to 0.95–1.24. That is a property of the folds and
not a weakness of the statistic: under blocked folds every held-out point
is far from the training data, where the kriging variance is close to the
sill whatever the nugget, so the blocked statistic checks the sill and
range. To check the nugget, pass random folds
(make_folds(method = "random_kfold")) as folds: the
held-out points are then close to their neighbours, where the nugget
decides the variance. The statistic is computed fold by fold with
gstat::krige() on the splits make_folds() built, each
held-out point kriged from that split's own training set, so the folds
carry the same separation the package uses everywhere else:
"buffered_loo" and "nndm" keep the points they exclude
around each held-out one out of its kriging, and print() names the
scheme that ran. A vector of fold labels is run as k-fold, each fold
kriged from all the others.
On the same simulated fields, with 16, 36 and 64 square cells: under
uniform sampling the kriged and plain cell means differed by more than
one standard error in 11–24 percent of cells and by more than two in
0–7 percent, the kriging variance exceeded in 10–26 percent
of populated cells, and at most one cell was empty. Under clustered
sampling (eight clusters of 60-unit spread) the two aggregators parted:
shifts above one standard error in 34–63 percent of cells and above two
in 9–25 percent, the kriging variance below in only
13–52 percent of populated cells, and 3–27 of the cells empty, each with
a kriged estimate and variance where the plain mean has nothing.
So a block-kriging aggregator earns its place on clustered layers and
rarely on uniform ones, and this function says which kind a layer is.
A variogram model. sac is a estimate_sac_range()
result carrying one (attr(, "variogram_model")); when NULL
it is estimated here from the response. The model families are the ones
the package interprets elsewhere: exponential, spherical and Gaussian
components with a nugget. Anything else is refused by name. A
model whose range was not identified (an NA estimate with the
model attached) is used with a warning that says why it was refused, and
the reason is kept as attr(, "rejected_reason"): a range past the
fitted lags means the sill was never reached and the ratios rest on an
extrapolation; a fit that did not converge stopped wherever the optimiser
halted; a variogram that falls with distance, or a range below the
shortest lag, describes the data poorly at some lags.
The model has to be of the response itself. A variogram of residuals
(estimate_sac_range(predictor_vars = ...), attr(,
"detrended") TRUE) leaves out the variance the predictors
explain, while the response is kriged here without them, so
kr_var, kr_ratio and the cross-validation statistic come out
too small (the statistic at 4.3–5.2 against 0.67–1.53 with a spatially
structured covariate); it is used with a warning. The points are put in
the CRS the variogram was fitted in (attr(sac, "crs")), because
its range is a length in that CRS's units. Requires gstat.
gstat kriges a cell from the nmax locations nearest its
centre. A cell holding more locations than that would be estimated from
its middle alone, which describes the middle rather than the cell (in one
simulated case a cell of 1,500 points came out 0.43 off at
nmax = 50, against 0.07 from all of them and 0.02 for its plain
mean). So wherever the nmax locations nearest a cell's centre
leave out any of the cell's own locations, the cell is kriged from all of
its own locations plus the nmax nearest outside it;
kr_n_used says how many locations each cell was kriged from. The
cost of a kriging system grows with the cube of its size (about 1 s at
2,000 locations and 17 s at 5,000), so a cell that would need more than
max_neighbours is left out with a warning, its kr_ columns
NA.
gstat also discretises each cell into 500 points on a regular grid
laid over the cell's whole bounding box, keeping those inside, so the
memory a cell takes grows with the ratio of that box to its area: about
54 MB more for a thin diagonal strip at a ratio of 708, and 592 MB at
7,072. A cell whose bounding box exceeds its area more than
max_box_ratio times (a sliver, parts far apart, a cell that is
mostly hole) is left out the same way. attr(, "cells_left_out")
counts both kinds, and print() says how many cells have no
estimate and why.
Two observations at the same coordinates (visits to a station, records
geocoded to one address) make a kriging system singular, because
gstat gives them the full sill, nugget included, as their
covariance, as if they were one observation. The kriging and its
cross-validation therefore use one observation per location, the mean of
its replicates, with a warning; n and mean still count every
point. How much of the nugget a mean of replicates
keeps is read off the replicates: their pooled within-location variance
, capped at , is the part that differs from visit to
visit and averages down, and the rest is micro-scale variation the visits
share, so the mean carries error variance .
That goes to gstat as a known measurement error (its
weights) on the model with the nugget set to zero. For replicates
that differ only by measurement error this is exactly the kriging of every
observation, and for identical replicates it is the kriging of one; a
location seen once is kriged as before. In the cross-validation a
location is held out whole, under the fold of its first row, and its
error variance is part of its standardised error. A cell or held-out
location gstat still cannot krige is reported NA with a
warning, and print() says how many.
Cressie, N. (1993). Statistics for Spatial Data, revised edition. Wiley. (Block kriging, chapter 3; cross-validation of the kriging variance, section 2.6.4.)
Other aggregation:
assign_features_to_polygons(),
determine_optimal_levels(),
resolution_profile(),
select_resolution(),
summarize_by_cell(),
summary.resolution_profile()
if (requireNamespace("gstat", quietly = TRUE)) { library(sf) set.seed(1) n <- 200 x <- 5e5 + runif(n, 0, 1000); y <- 5e6 + runif(n, 0, 1000) d <- as.matrix(dist(cbind(x, y))) z <- as.numeric(t(chol(0.8 * exp(-d / 100) + diag(0.2, n))) %*% rnorm(n)) pts <- st_as_sf(data.frame(x = x, y = y, z = z), coords = c("x", "y"), crs = 32632) bnd <- st_sf(geometry = st_as_sfc(st_bbox(pts))) cells <- create_grid_polygons(bnd, target_cells = 16, type = "square") asg <- assign_features_to_polygons(pts, cells) ka <- kriging_adequacy(asg, "z", cells, k = 4) print(ka) # the report; print() because only a block's # last value shows on its own attr(ka, "cv")$zscore_var # about 1 when the variogram is right }if (requireNamespace("gstat", quietly = TRUE)) { library(sf) set.seed(1) n <- 200 x <- 5e5 + runif(n, 0, 1000); y <- 5e6 + runif(n, 0, 1000) d <- as.matrix(dist(cbind(x, y))) z <- as.numeric(t(chol(0.8 * exp(-d / 100) + diag(0.2, n))) %*% rnorm(n)) pts <- st_as_sf(data.frame(x = x, y = y, z = z), coords = c("x", "y"), crs = 32632) bnd <- st_sf(geometry = st_as_sfc(st_bbox(pts))) cells <- create_grid_polygons(bnd, target_cells = 16, type = "square") asg <- assign_features_to_polygons(pts, cells) ka <- kriging_adequacy(asg, "z", cells, k = 4) print(ka) # the report; print() because only a block's # last value shows on its own attr(ka, "cv")$zscore_var # about 1 when the variogram is right }
Builds train/test splits by random k-fold, spatial block k-fold, leave-location-out, buffered leave-one-out or nearest-neighbour distance matching (NNDM) leave-one-out.
make_folds( points_sf, k, method = c("random_kfold", "block_kfold", "buffered_loo", "leave_location_out", "nndm"), seed = NULL, block_nx = NULL, block_ny = NULL, block_multiplier = 3, block_size = NULL, auto_range = FALSE, range_frac = 1, response_var = NULL, group_var = NULL, prediction_points = NULL, predictor_vars = NULL, boundary = NULL, buffer = NULL, min_train = 0.5, phi = NULL, drop_empty_blocks = TRUE, blocks = NULL, balance_tol = 3 )make_folds( points_sf, k, method = c("random_kfold", "block_kfold", "buffered_loo", "leave_location_out", "nndm"), seed = NULL, block_nx = NULL, block_ny = NULL, block_multiplier = 3, block_size = NULL, auto_range = FALSE, range_frac = 1, response_var = NULL, group_var = NULL, prediction_points = NULL, predictor_vars = NULL, boundary = NULL, buffer = NULL, min_train = 0.5, phi = NULL, drop_empty_blocks = TRUE, blocks = NULL, balance_tol = 3 )
points_sf |
An sf object. Any Z or M dimension is dropped before
folding: |
k |
Integer; number of folds. Must be a single whole number >= 1,
and is required except for the two leave-one-out methods.
A fraction, |
method |
One of |
seed |
Optional integer RNG seed. |
block_nx, block_ny
|
Optional grid dimensions for block_kfold, each a
single whole number >= 1. Give both, or give one and the other is
derived from the extent's aspect ratio so that the blocks are roughly
square. Ignored when |
block_multiplier |
A single positive number, default 3. When
neither |
block_size |
Optional positive numeric minimum block edge length,
in the units of the CRS the folds are built in. When supplied,
grid dimensions are clamped so that every block is at least this wide and
tall. Takes precedence over Which CRS that is depends on the input. Projected input is used as it
stands, so A |
auto_range |
Logical. If |
range_frac |
Passed through to |
response_var |
Character(1) response column name. Required when
|
group_var |
Character(1) naming a column of |
prediction_points |
Optional |
predictor_vars |
Optional character vector of predictor column names.
Passed to |
boundary |
Optional polygonal sf/sfc for block_kfold. The grid is
clipped to it; a cell that the boundary only touches at a corner or
along an edge is not a block. A boundary without a CRS is brought into
the points' CRS: reprojected from EPSG:4326 when its coordinates look
like lon/lat, otherwise stamped, with a warning either way. The same
goes for |
buffer |
For |
min_train |
For |
phi |
For |
drop_empty_blocks |
Logical. Default TRUE. With |
blocks |
Optional polygon layer ( |
balance_tol |
A number of at least 1, default 3. For |
For block_kfold, the default grid sizing is purely geometric and
unrelated to the autocorrelation range of the data. When blocks are
smaller than the autocorrelation range, spatially correlated observations
leak across folds and CV metrics become optimistic. Use
block_size to set a minimum block edge length (in CRS units), or
set auto_range = TRUE to estimate the range from an empirical
variogram and enforce it automatically.
Fold methods.
"random_kfold" ignores geography entirely and will overstate
performance on autocorrelated data. "block_kfold" separates folds
geographically. "buffered_loo" holds out one point at a time and
excludes everything within a fixed buffer.
"leave_location_out" groups by group_var, so all
observations from a location share a fold.
"nndm" implements the distance-matching principle of Milà et al.
(2022): it sizes the exclusion around each held-out point, with no arbitrary
buffer, so that the resulting training-to-test distance distribution
approaches the distribution of distances from your actual prediction
locations to the training data.
The procedure is the paper's, and it is deterministic. Let
be the empirical distribution of
prediction-to-nearest-training distances and the distribution
of each held-out point's nearest remaining training point. Starting from
plain leave-one-out, the point with the smallest at which the
realised distribution exceeds the target () has
its nearest training neighbour removed, and this repeats until no such
point remains, subject to two limits: a point is matched only while its
nearest-neighbour distance is at most phi (default: the largest
prediction distance, since a training point already further than every
prediction distance has nothing to match), so the exclusion that takes it
past phi is its last and a realised distance can exceed phi
by up to one neighbour step; and no fold's training set is stripped below
min_train of the data.
It differs from CAST::nndm() in two details, so the folds agree
closely with CAST's but not exactly. The removal rule is strict: a
neighbour is removed whenever the realised distribution exceeds the target,
whereas CAST removes one only while the realised distribution, less the
point about to move, is still at or above the target. The rule here
therefore removes up to one point more per distance value (a few percent
more removals in all on clustered layouts), erring on the pessimistic side.
And ties in are broken by the points' coordinates, not by
their row index as in CAST, so the folds do not depend on the order of the
rows.
Where neither limit binds, the realised distribution is then never
more optimistic than the target: up to
the granularity of the neighbour distances, which is the property the
method exists to deliver. Beyond phi no matching is attempted, by
design. min_train is different: when the prediction locations lie
further from the samples than a fold can be made to hold out (clustered
samples and a prediction domain well beyond them, the layout NNDM is meant
for), it stops the matching early and the realised distances stay
optimistic. params$n_at_min_train counts the folds that were held
at the floor while still closer than the target allows, and
make_folds() warns when that leaves more than one point's worth of
excess at or below phi. Lower min_train to match further, or
read the cross-validated score as an upper bound on performance at the
prediction locations.
An earlier version of this package drew one random radius per point from
and excluded up to the order statistic closest to it,
which rounds down half the time: on a two-cluster layout the realised
distribution exceeded the target by up to 0.17 (13% of folds had a
nearest training point within 50 m against a target of 9%), an
optimistic cross-validation. params$max_ecdf_excess reports
the largest remaining excess; compare params$target_median with
params$realised_median as well.
A list with method, k, folds, assignment, params. The
train/test elements of each fold contain ..row_id
values (equal to row positions when the input has no pre-existing
..row_id column), consistent with the assignment tibble.
The returned k is the number of folds actually built, which is not
always the k that was requested (see the k argument above),
and length(folds) always matches it.
For "block_kfold" the block design is returned with the folds.
assignment has a third column, block_id: the block each
point fell in, numbered as the rows of params$blocks, an
sf layer of the block polygons in the CRS the folds were built
in. Those rows are not the rows of the layer the blocks came
from: drop_empty_blocks = TRUE (the default) removes the blocks
that hold no point and renumbers the rest, so with supplied
blocks a nine-polygon layer of which three are empty comes back
as six rows numbered 1 to 6. params$blocks$source_row is the
row each one came from in that original layer (a grid cell's index in
the full grid_nx by grid_ny grid, or the row of the
blocks argument), so
blocks[params$blocks$source_row, ] recovers them with their own
columns and in their own order. It runs from 1 to
params$n_blocks and is the identity when nothing was dropped.
For a grid params$n_blocks is grid_nx * grid_ny, the
cells a boundary clips away included, and
params$blocks_used is the number of blocks returned.
params$block_sizes is the number of points in each block, indexed
by block_id (zeros are empty blocks that
drop_empty_blocks = FALSE kept), and params$fold_blocks
is a list with one integer vector per fold naming the blocks packed into
it. Between them the folds account for every block exactly once,
empty ones included, so a fold's territory on the map is all of its
blocks and not merely the ones that happen to hold points. So
table(assignment$fold) can be traced back to the blocks
it is made of, a fold can be seen to be one contiguous region or
several, and the blocks can be drawn over the data
(plot_folds() does so).
For "block_kfold", params carries a
params$blocks_supplied that says whether the blocks came from
blocks or from a grid built here, and
params$boundary_supplied whether a boundary was given.
Every method's params carries
params$row_probe, a small sample of row IDs and coordinates
that every cv_*() compares against the data it is handed, so
folds built from a different layer of the same size are refused, never
applied silently.
For the methods that work in projected space, params also carries a
crs element naming the CRS the folds were built in (an
"EPSG:code" string where there is one, otherwise the CRS's input
definition). Every length in params (block_size,
sac_range, buffer, median_buffer) is in that CRS's
units, which for geographic input is a CRS
ensure_projected() chose for you, and not one you passed.
Rows whose geometry is empty or has non-finite coordinates are dropped
before folding, with a logged warning naming the count; they appear in no
fold and in no assignment row.
A polygon layer passed as blocks is aligned to the points the way
boundary is: CRS-less points are aligned to blocks that carry a CRS
(reprojected if they look like lon/lat, otherwise stamped, warning either
way), and the blocks are then brought into the CRS the folds are built in.
A point inside more than one block is given the first (lowest row) that
contains it, as for a point on the shared edge of two grid cells. When the
blocks that caught such a point share area instead of an edge, the layer
overlaps and is not a partition, and this is warned about. A point
inside no block is assigned to the nearest one, by distance to the polygon
itself, and the count of such points is warned about, unless they sit
within a millionth of the extent of a block, which is an edge that
reprojection or clipping moved by a rounding error. Blocks that hold no
point are dropped when drop_empty_blocks = TRUE, and k is
lowered to the number of blocks that hold points when that is smaller.
params$n_blocks is the number of blocks before empties were dropped,
params$blocks_used the number after (so with
drop_empty_blocks = FALSE the two are equal, and
sum(params$block_sizes > 0) is how many of them hold points),
params$grid_nx and
params$grid_ny are NA, and params$block_scale is the
median over blocks that hold points of the side of the square with the
block's area. That is the length compared against the autocorrelation range
for the leakage warning, since a polygon has no single edge length.
The connection to the rest of the package is
build_tessellation(): every shape it builds can be a block
design here, including Voronoi cells around
get_voronoi_seeds(method = "kmeans") seeds, which adapt to the
density of the points.
Only "block_kfold" balances the number of points per fold, and it
does so by packing: blocks are taken largest first and each goes to the
fold with the fewest points so far, ties broken at random. This is the
longest-processing-time rule for multiway partitioning. Measured against
the optimum by enumeration (two folds, up to ten blocks, heavy-tailed block
sizes) it is optimal in 72 percent of cases and within two points of
optimal on average; a local search with 30 random restarts moved the
largest-to-smallest ratio by 0.004 on average and never brought a packing
above the 3:1 tolerance below it. An imbalance past the tolerance is
therefore in the points per block, which no assignment of blocks to folds
can even out, and this function offers no search over packings. The
remedy is the block design: smaller blocks, or blocks that adapt to the
density of the points passed through blocks. On clustered layouts
where the geometric grid exceeded 3:1 in 22 percent of cases (median ratio
1.8, worst 6.3), Voronoi cells around 15 k-means seeds never exceeded 1.3
(median 1.09). Density-adaptive blocks are smaller where points are
dense, so check params$block_scale against the autocorrelation
range as you would a grid.
The other methods do not balance point counts. "random_kfold" is
balanced by construction (fold sizes differ by at most one);
"buffered_loo" and "nndm" hold out one point per fold;
"leave_location_out" gives each fold the same number of
locations (to within one), so folds differ by as much as the
locations' sizes do.
Mila, C., Mateu, J., Pebesma, E. and Meyer, H. (2022). Nearest neighbour distance matching Leave-One-Out Cross-Validation for map validation. Methods in Ecology and Evolution 13, 1304-1316. doi:10.1111/2041-210X.13851
Roberts, D. R., Bahn, V., Ciuti, S., Boyce, M. S., Elith, J., Guillera-Arroita, G., Hauenstein, S., Lahoz-Monfort, J. J., Schroder, B., Thuiller, W., Warton, D. I., Wintle, B. A., Hartig, F. and Dormann, C. F. (2017). Cross-validation strategies for data with temporal, spatial, hierarchical, or phylogenetic structure. Ecography 40, 913-929. doi:10.1111/ecog.02881
Valavi, R., Elith, J., Lahoz-Monfort, J. J. and Guillera-Arroita, G. (2019). blockCV: An R package for generating spatially or environmentally separated folds for k-fold cross-validation of species distribution models. Methods in Ecology and Evolution 10, 225-232. doi:10.1111/2041-210X.13107
Other cross-validation:
area_of_applicability(),
cv_bayes(),
cv_block_size_sweep(),
cv_gwr(),
cv_rf(),
cv_spatial(),
estimate_sac_range(),
fold_separation(),
gwr_model_selection(),
sac_nugget(),
select_features_forward()
library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(30, 0, 1000), y = 5e6 + runif(30, 0, 1000)), coords = c("x", "y"), crs = 32632 ) folds <- make_folds(pts, k = 3, method = "block_kfold", seed = 42) folds$assignment # fold and block membership per row lengths(folds$folds[[1]]) # train/test row-ID splits folds$params$block_sizes # points per block; params$fold_blocks packs them # Buffered leave-one-out: neighbours within 100 units excluded from training loo <- make_folds(pts, k = 1, method = "buffered_loo", buffer = 100)library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(30, 0, 1000), y = 5e6 + runif(30, 0, 1000)), coords = c("x", "y"), crs = 32632 ) folds <- make_folds(pts, k = 3, method = "block_kfold", seed = 42) folds$assignment # fold and block membership per row lengths(folds$folds[[1]]) # train/test row-ID splits folds$params$block_sizes # points per block; params$fold_blocks packs them # Buffered leave-one-out: neighbours within 100 units excluded from training loo <- make_folds(pts, k = 1, method = "buffered_loo", buffer = 100)
Reports RMSE, MAE, MAPE, SMAPE, and adjusted for any
spatial_fit, in one row and on one scale, so that fits from different
backends can be read side by side. Reach for it to score a model on data you
hold out yourself (pass it as newdata), or to get a quick in-sample
reading of how closely a fit tracks its training data.
model_metrics(object, ...) ## S3 method for class 'spatial_fit' model_metrics(object, newdata = NULL, ...)model_metrics(object, ...) ## S3 method for class 'spatial_fit' model_metrics(object, newdata = NULL, ...)
object |
A |
... |
Additional arguments passed to predict(). |
newdata |
Optional sf object for out-of-sample evaluation. If NULL,
fitted values are used (in-sample, or out-of-bag for an |
It is not a substitute for cross-validation. With newdata = NULL the
numbers are in-sample for a gwr_fit or bayesian_fit, and a
GWR can reach a near-perfect in-sample at a small bandwidth
without predicting anything. For a figure you can report, use
cv_gwr(), cv_bayes(), cv_rf()
or compare_models_cv().
A data.frame with n, RMSE, MAE, MAPE, SMAPE, R2, Adj_R2, n_MAPE and
n_SMAPE (the last two are the rows each percentage error was averaged
over; see "Percentage errors on responses with zeros").
Adj_R2 is always NA: GWR's effective parameter count far
exceeds the global predictor count and a GP model has no simple p,
so it is deliberately suppressed. A non-numeric response is an error: a
character or factor response cannot be scored, and used to come back as
n = 0 with every metric NA; a logical response is treated
as 0/1.
With newdata = NULL the metrics come from fitted(object).
That is in-sample for a gwr_fit or a bayesian_fit,
but out-of-bag for an rf_fit, whose fitted() method
returns out-of-bag predictions (see fit_rf_model). The
data.frame model_metrics() returns carries no label distinguishing
the two, so check object$info$fitted_are_oob before comparing
numbers across backends; evaluate_insample() and
compare_models() record it per model in a
metric_basis column. compare_models_cv scores every
backend the same way.
is with the total sum of squares taken about
the mean of the response the model was fitted to. In sample that
is the ordinary . With newdata it is out-of-sample
, the convention every cv_*() function uses: the model is
measured against the prediction it had to beat, the training mean, not
against the new rows' own mean, which it could not have known. It is
below 0 when the model predicts the new rows worse than the training mean
does, and it is NA when the response does not vary about that
baseline by more than rounding error (100 machine epsilons of its
magnitude, whatever its units).
MAPE divides by the observed value and SMAPE by
, so neither is defined where its denominator is zero.
Neither returns Inf or NaN. Both are averaged over the rows
whose denominator is non-zero, and are NA when no row qualifies.
Non-zero is judged at the scale of the data: a denominator no larger
than 100 machine epsilons times the largest one counts as zero, so the
rule does not depend on the units of the response.
The n_MAPE and n_SMAPE columns record how many rows that was;
the n column counts finite observation/prediction pairs. Read a
percentage error next to its count: when n_MAPE < n, MAPE is
an average over a subset of the data, whatever its value.
This bites on any response taking exact zeros: counts, rainfall,
abundance, claim amounts. On a zero-inflated response with 62 zeros out of
120, MAPE is an average over the 58 non-zero rows, which
n_MAPE = 58 now says. SMAPE fails differently and more
subtly: it drops the rows where observation and prediction are both near
zero (which on a well-fitted zero-inflated model are the rows it got
right), so it averages the harder rows only and reads worse than
the fit deserves; n_SMAPE shows how many rows it kept, and the
count is only a label, not a repair.
RMSE, MAE and use every finite row and are
unaffected; prefer them whenever the response can be zero. For a Bayesian
fit, cv_bayes() additionally reports CRPS and interval
coverage, which are proper scoring rules and have no such failure mode.
RMSE and MAE are defined for any numeric response and are what to read for a count, a rate or a bounded outcome. MAPE and SMAPE assume a response that is rarely zero (see the previous section), and R-squared and adjusted R-squared compare residual variance to total variance, which is the right comparison for a Gaussian response and a loose one for anything whose variance tracks its mean. None of the four is wrong to compute; each is Gaussian-shaped thinking, and on a Poisson or zero-inflated response should be read as a rough summary rather than a score.
For the Bayesian backend, cv_bayes() additionally reports
CRPS and interval coverage at 50, 80 and 95 percent. Both are proper
scoring rules computed from posterior draws, so they are meaningful for any
family that predicts one number per row (a count, a rate, a binary
or bounded outcome), and they are the numbers to compare when the response
is not Gaussian. A categorical or ordinal family predicts a probability
per response category instead, so cv_bayes() refuses one before
fitting anything. When every fold fails, the
fold_metrics frame cv_bayes() returns carries the CRPS column
but not the coverage_* columns, so code that reads those columns
must tolerate their absence.
Other model evaluation:
compare_models(),
compare_models_cv(),
evaluate_insample(),
residual_morans_i()
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(60, 0, 1000), y = 5e6 + runif(60, 0, 1000), a = rnorm(60)), coords = c("x", "y"), crs = 32632 ) pts$z <- 2 * pts$a + rnorm(60, 0, 0.3) # Fit on 40 rows and keep 20 back, so the second call really is out of # sample; scoring the training rows again would only re-read the fit. fit <- fit_rf_model(pts[1:40, ], "z", "a", num_trees = 50, seed = 1) print(model_metrics(fit)) # in-sample (out-of-bag for RF) model_metrics(fit, newdata = pts[41:60, ]) # on the 20 held-out rows }if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(60, 0, 1000), y = 5e6 + runif(60, 0, 1000), a = rnorm(60)), coords = c("x", "y"), crs = 32632 ) pts$z <- 2 * pts$a + rnorm(60, 0, 0.3) # Fit on 40 rows and keep 20 back, so the second call really is out of # sample; scoring the training rows again would only re-read the fit. fit <- fit_rf_model(pts[1:40, ], "z", "a", num_trees = 50, seed = 1) print(model_metrics(fit)) # in-sample (out-of-bag for RF) model_metrics(fit, newdata = pts[41:60, ]) # on the 20 held-out rows }
The constructor for the spatial_fit class, and the public entry point
for plugging your own model backend into this package. The three built-in
fitters (fit_gwr_model(),
fit_bayesian_spatial_model() and fit_rf_model())
all end by calling it, and so should a custom fit_fn written for
cv_spatial(): wrapping your model in a spatial_fit is
what lets it use the package's folds, metrics, comparison and
area-of-applicability machinery unchanged.
new_spatial_fit( subclass, engine, formula, response_var, predictor_vars, data_sf, info = list() )new_spatial_fit( subclass, engine, formula, response_var, predictor_vars, data_sf, info = list() )
subclass |
Character scalar naming the class to stamp on the object:
one of the built-ins |
engine |
The raw model object your backend produced (an |
formula |
A formula. |
response_var |
Character(1). |
predictor_vars |
Character vector. |
data_sf |
An sf object used for fitting. |
info |
Named list of model-specific extras. Set
|
There are two obligations. Return an object built here from your
fit_fn, and define a predict() method for the subclass
you chose. cv_spatial() scores folds by calling the
predict() generic on the fit, so without a matching
predict.<subclass>() every fold fails. A
fitted.<subclass>() method returning one value per row of the fit's
data_sf, in the same order, is required by
summary() and model_metrics(): both error,
naming the method to define, without one. residuals() and
coef() methods are optional.
An object of class c(subclass, "spatial_fit").
coef() on one of the three built-in backends either returns the
coefficients or signals an error. It never returns NULL. A custom
subclass inherits stats::coef.default(), which returns NULL,
so define a coef.<subclass>() that errors when your backend has no
coefficients; otherwise the hazard described below applies to your own fits.
coef.rf_fit()
always errors, because a forest has no coefficients; coef.gwr_fit()
and coef.bayesian_fit() error when the backend cannot supply them
(a missing package, an engine without the expected component). A
NULL return would be indistinguishable from "this model has no
fixed effects", so lapply(fits, coef) would quietly produce a
shorter answer than the caller expected. Wrap in try() or
tryCatch() when sweeping over a heterogeneous list of fits.
cv_spatial(), which consumes a custom fit_fn;
fit_rf_model() for a worked built-in fitter.
Other model fitting:
fit_bayesian_spatial_model(),
fit_gwr_model(),
fit_rf_model(),
gp_lengthscale_bounds(),
prep_model_data()
library(sf) set.seed(1) n <- 80 site <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) site$price <- 10 + 0.01 * (st_coordinates(site)[, 1] - 5e5) + 2 * site$elev + rnorm(n) # A custom backend: an ordinary linear model behind the spatial_fit interface. lm_fit <- function(train_sf) { new_spatial_fit( subclass = "lm_fit", engine = lm(price ~ elev, st_drop_geometry(train_sf)), formula = price ~ elev, response_var = "price", predictor_vars = "elev", data_sf = train_sf ) } # Required: cv_spatial() scores each fold through the predict() generic, # which dispatches on the subclass named above. predict.lm_fit <- function(object, newdata = NULL, ...) { if (is.null(newdata)) newdata <- object$data_sf as.numeric(stats::predict(object$engine, st_drop_geometry(newdata))) } registerS3method("predict", "lm_fit", predict.lm_fit) cv <- cv_spatial(site, "price", "elev", fit_fn = lm_fit, k = 3, seed = 1) cv$overall # Always check these two agree before trusting the metrics above. c(attempted = cv$n_folds_attempted, succeeded = cv$n_folds_succeeded)library(sf) set.seed(1) n <- 80 site <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) site$price <- 10 + 0.01 * (st_coordinates(site)[, 1] - 5e5) + 2 * site$elev + rnorm(n) # A custom backend: an ordinary linear model behind the spatial_fit interface. lm_fit <- function(train_sf) { new_spatial_fit( subclass = "lm_fit", engine = lm(price ~ elev, st_drop_geometry(train_sf)), formula = price ~ elev, response_var = "price", predictor_vars = "elev", data_sf = train_sf ) } # Required: cv_spatial() scores each fold through the predict() generic, # which dispatches on the subclass named above. predict.lm_fit <- function(object, newdata = NULL, ...) { if (is.null(newdata)) newdata <- object$data_sf as.numeric(stats::predict(object$engine, st_drop_geometry(newdata))) } registerS3method("predict", "lm_fit", predict.lm_fit) cv <- cv_spatial(site, "price", "elev", fit_fn = lm_fit, k = 3, seed = 1) cv$overall # Always check these two agree before trusting the metrics above. c(attempted = cv$n_folds_attempted, succeeded = cv$n_folds_succeeded)
cv_bayes() scores every fold's posterior predictive intervals
at each of coverage_levels: the share of held-out observations the
interval of that nominal level contained. One coverage number cannot show
a pattern; the pairs can. This draws observed coverage against nominal
with the diagonal, one point per level for the fold-weighted pooled value
(from predictive_coverage) and one faint point per fold, so
systematic over-confidence (points below the line) or intervals wider than
they need to be (above it) are read at a glance. Three levels is a thin
curve; pass coverage_levels = seq(0.1, 0.9, by = 0.1) to
cv_bayes() for a full one. Each level is drawn at the nominal value
cv_bayes() records in coverage_levels, so whatever was
computed is drawn where it belongs (0.975 at 0.975, not rounded).
plot_calibration(cv, ...)plot_calibration(cv, ...)
cv |
The list returned by |
... |
Ignored. |
A ggplot object.
Other plotting:
plot.aoa(),
plot.block_size_sweep(),
plot.feature_selection(),
plot.gwr_model_selection(),
plot.resolution_profile(),
plot.sac_range(),
plot.spatial_fit(),
plot_cv_metrics(),
plot_folds()
## Not run: # Needs brms and a Stan toolchain, and takes minutes: run it, do not check it. library(sf) set.seed(1) n <- 80 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n)), coords = c("x", "y"), crs = 32632) dat$z <- 2 * dat$a + rnorm(n) # Nine coverage levels give a curve rather than three points; chains and # iterations are kept small to keep this to a few minutes, so the intervals # will be rough. cv <- cv_bayes(dat, "z", "a", k = 3, coverage_levels = seq(0.1, 0.9, 0.1), fit_args = list(chains = 2, iter = 1000)) plot_calibration(cv) ## End(Not run)## Not run: # Needs brms and a Stan toolchain, and takes minutes: run it, do not check it. library(sf) set.seed(1) n <- 80 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n)), coords = c("x", "y"), crs = 32632) dat$z <- 2 * dat$a + rnorm(n) # Nine coverage levels give a curve rather than three points; chains and # iterations are kept small to keep this to a few minutes, so the intervals # will be rough. cv <- cv_bayes(dat, "z", "a", k = 3, coverage_levels = seq(0.1, 0.9, 0.1), fit_args = list(chains = 2, iter = 1000)) plot_calibration(cv) ## End(Not run)
A pooled RMSE of 3.2 can come from 3.2 in every fold or from 1.1 in eight
folds and 14 in one (a model that works, and a model that fails in one
region), and the pooled number cannot tell the two apart. This draws the
metric of each fold as a point, sized by the number of held-out
predictions the fold contributed, with the pooled value from
overall as a horizontal line, so the spread behind the number is
visible. A compare_models_cv() result draws one panel per
model on a shared scale, folds aligned, which is the comparison the shared
fold set was built for.
plot_cv_metrics(cv, metric = "RMSE", ...)plot_cv_metrics(cv, metric = "RMSE", ...)
cv |
The list returned by |
metric |
Character(1) naming a column of |
... |
Ignored. |
Any column of fold_metrics can be drawn, including a backend's
extras (bandwidth, CRPS, coverage_95) and columns a
metrics function added. A column that is NA in every fold is
refused with a message saying why rather than drawn as an empty panel:
Adj_R2 is NA for every backend unless p was passed to
cv_spatial(), by design. The pooled line is drawn from
overall when it carries the metric, from
predictive_coverage for cv_bayes()'s coverage and CRPS
columns, and not at all for a per-fold extra that has no pooled
counterpart (a bandwidth) or for a count (n_pred,
n_MAPE, n_SMAPE), whose overall value is the
total over the folds; the caption says which. A model with no finite
per-fold value gets no panel and no pooled line, and the caption
names it.
A ggplot object.
Other plotting:
plot.aoa(),
plot.block_size_sweep(),
plot.feature_selection(),
plot.gwr_model_selection(),
plot.resolution_profile(),
plot.sac_range(),
plot.spatial_fit(),
plot_calibration(),
plot_folds()
if (requireNamespace("ranger", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(1) n <- 150 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$z <- 2 * dat$a + 0.003 * (st_coordinates(dat)[, 1] - 5e5) + rnorm(n, 0, 0.5) cv <- cv_rf(dat, "z", "a", k = 5, num_trees = 100) plot_cv_metrics(cv, "RMSE") }if (requireNamespace("ranger", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(1) n <- 150 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n)), coords = c("x", "y"), crs = 32632 ) dat$z <- 2 * dat$a + 0.003 * (st_coordinates(dat)[, 1] - 5e5) + rnorm(n, 0, 0.5) cv <- cv_rf(dat, "z", "a", k = 5, num_trees = 100) plot_cv_metrics(cv, "RMSE") }
Shows which fold each observation belongs to. This is the fastest way to
see whether spatial blocks are actually separating the data, or whether the
blocks are smaller than the autocorrelation range and therefore leaking.
For "block_kfold" folds the block outlines are drawn too, from
folds$params$blocks, so a fold can be seen to be one region or
several and an empty block can be seen to be empty. The subtitle states
the parameter that decides whether the scheme leaks, read from
folds$params: the block size (and the estimated range when
auto_range found one), the buffer, the number of location groups,
or the median NNDM exclusion.
plot_folds(folds, points_sf, boundary = NULL, blocks = TRUE)plot_folds(folds, points_sf, boundary = NULL, blocks = TRUE)
folds |
A list returned by |
points_sf |
The |
boundary |
Optional polygonal |
blocks |
Logical; draw the block polygons when |
A ggplot object.
Other plotting:
plot.aoa(),
plot.block_size_sweep(),
plot.feature_selection(),
plot.gwr_model_selection(),
plot.resolution_profile(),
plot.sac_range(),
plot.spatial_fit(),
plot_calibration(),
plot_cv_metrics()
if (requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(1) n <- 80 pts <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)), coords = c("x", "y"), crs = 32632 ) f <- make_folds(pts, k = 5, method = "block_kfold", block_size = 300) plot_folds(f, pts) }if (requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(1) n <- 80 pts <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)), coords = c("x", "y"), crs = 32632 ) f <- make_folds(pts, k = 5, method = "block_kfold", block_size = 300) plot_folds(f, pts) }
Builds a layered ggplot2 map of polygon tessellations and optional overlays for a study boundary, seed points, and additional features.
plot_tessellation_map( tessellation_sf, boundary = NULL, seeds_sf = NULL, features_sf = NULL, fill_col = NULL, palette = "viridis", na_fill = "grey90", tile_alpha = 0.9, outline_col = "white", outline_size = 0.2, features_col = "#333333", features_size = 0.5, seeds_col = "#1f77b4", seeds_size = 1.5, boundary_col = "#111111", boundary_size = 0.6, labels = FALSE, label_col = NULL, label_size = 2.7, legend = TRUE, legend_title = NULL, theme = NULL, target_crs = NULL, title = NULL, subtitle = NULL, caption = NULL, xlim = NULL, ylim = NULL, expand = TRUE )plot_tessellation_map( tessellation_sf, boundary = NULL, seeds_sf = NULL, features_sf = NULL, fill_col = NULL, palette = "viridis", na_fill = "grey90", tile_alpha = 0.9, outline_col = "white", outline_size = 0.2, features_col = "#333333", features_size = 0.5, seeds_col = "#1f77b4", seeds_size = 1.5, boundary_col = "#111111", boundary_size = 0.6, labels = FALSE, label_col = NULL, label_size = 2.7, legend = TRUE, legend_title = NULL, theme = NULL, target_crs = NULL, title = NULL, subtitle = NULL, caption = NULL, xlim = NULL, ylim = NULL, expand = TRUE )
tessellation_sf |
An sf POLYGON/MULTIPOLYGON layer. Required. |
boundary |
Optional sf/sfc polygon outline layer. |
seeds_sf |
Optional sf/sfc point layer of seed locations. |
features_sf |
Optional sf/sfc layer of additional features. |
fill_col |
Name of the COLUMN in |
palette |
Viridis palette name. Default "viridis". |
na_fill |
Fill for NA values. Default "grey90". |
tile_alpha |
Alpha for filled polygons. Default 0.9. |
outline_col, outline_size
|
Colour and line width of the tessellation outline. |
features_col, features_size
|
Colour and point size of the feature overlay. |
seeds_col, seeds_size
|
Colour and point size of the seed overlay. |
boundary_col, boundary_size
|
Colour and line width of the boundary outline. |
labels |
Logical; draw per-cell labels. Default FALSE. |
label_col |
Name of the COLUMN holding the label text. Default
|
label_size |
Label text size. Default 2.7. |
legend |
Logical; show fill legend. Default TRUE. |
legend_title |
Optional legend title. |
theme |
A ggplot2 theme, or NULL (the default) to use
|
target_crs |
Optional CRS for plotting. When |
title, subtitle, caption
|
Plot annotations. |
xlim, ylim
|
Optional numeric vectors of length 2 for coordinate limits (in the plot CRS). Default NULL (auto). |
expand |
Logical; expand plot area slightly beyond data limits. Default TRUE. |
A ggplot2 object.
Other tessellation:
build_tessellation(),
create_grid_polygons(),
create_grid_polygons_cached(),
create_voronoi_polygons(),
ensure_stable_poly_id(),
get_voronoi_seeds(),
voronoi_seeds_kmeans(),
voronoi_seeds_random()
if (requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(1) n <- 60 pts <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), z = rnorm(n)), coords = c("x", "y"), crs = 32632 ) # Cells from the points, the response aggregated onto them, and the map # shaded by the cell mean rather than by an arbitrary ID. tess <- build_tessellation(pts, method = "voronoi", quiet = TRUE) cells <- summarize_by_cell(assign_features_to_polygons(pts, tess$cells), response_var = "z", cells_sf = tess$cells) plot_tessellation_map(cells, features_sf = pts, fill_col = "resp_mean_z", legend_title = "mean z") }if (requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(1) n <- 60 pts <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), z = rnorm(n)), coords = c("x", "y"), crs = 32632 ) # Cells from the points, the response aggregated onto them, and the map # shaded by the cell mean rather than by an arbitrary ID. tess <- build_tessellation(pts, method = "voronoi", quiet = TRUE) cells <- summarize_by_cell(assign_features_to_polygons(pts, tess$cells), response_var = "z", cells_sf = tess$cells) plot_tessellation_map(cells, features_sf = pts, fill_col = "resp_mean_z", legend_title = "mean z") }
n_outside says how many prediction locations fall outside the area
of applicability; it does not say whether the rest sit comfortably inside
or crowd against the threshold, nor how far outside the outsiders are.
This draws the dissimilarity index of the prediction locations against
that of the training data, with the threshold marked, so the prediction
set can be read as mostly inside, marginal or largely outside. The
training DI is cross-validated over the folds passed to
area_of_applicability(), or without them is each training
point's distance to its nearest other training point; the legend and the
caption say which. The training curve is the reference the threshold was
derived from: the threshold is the largest training DI inside an outlier
fence, so the curve reaches it exactly when no training value was fenced
off and runs past it, by the tail the fence removed, when some were.
A prediction location outside on a predictor dropped for having no
training variance has DI = Inf: it counts in the prediction curve,
which then tops out below 1, and the caption says how many are off the
axis. A location with a missing predictor (DI = NA) is neither
inside nor outside; it is left out of the curve and the caption counts it.
## S3 method for class 'aoa' plot(x, type = c("ecdf", "histogram"), ...)## S3 method for class 'aoa' plot(x, type = c("ecdf", "histogram"), ...)
x |
An |
type |
|
... |
Ignored. |
A ggplot object.
Other plotting:
plot.block_size_sweep(),
plot.feature_selection(),
plot.gwr_model_selection(),
plot.resolution_profile(),
plot.sac_range(),
plot.spatial_fit(),
plot_calibration(),
plot_cv_metrics(),
plot_folds()
if (requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(2) n <- 200 train <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n)), coords = c("x", "y"), crs = 32632) train$z <- train$a - train$b + rnorm(n, 0, 0.3) # Prediction locations whose predictor `a` drifts beyond the training range. new <- st_as_sf( data.frame(x = 5e5 + runif(100, 0, 1000), y = 5e6 + runif(100, 0, 1000), a = rnorm(100, mean = 2), b = rnorm(100)), coords = c("x", "y"), crs = 32632) aoa <- area_of_applicability(new, train_sf = train, predictor_vars = c("a", "b")) print(plot(aoa)) # the two ECDFs; print() because only a # block's last value is drawn on its own plot(aoa, type = "histogram") }if (requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(2) n <- 200 train <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n)), coords = c("x", "y"), crs = 32632) train$z <- train$a - train$b + rnorm(n, 0, 0.3) # Prediction locations whose predictor `a` drifts beyond the training range. new <- st_as_sf( data.frame(x = 5e5 + runif(100, 0, 1000), y = 5e6 + runif(100, 0, 1000), a = rnorm(100, mean = 2), b = rnorm(100)), coords = c("x", "y"), crs = 32632) aoa <- area_of_applicability(new, train_sf = train, predictor_vars = c("a", "b")) print(plot(aoa)) # the two ECDFs; print() because only a # block's last value is drawn on its own plot(aoa, type = "histogram") }
The curve from cv_block_size_sweep(): the pooled metric at
each block size, the fold-to-fold range as a band, the random-fold
reference as a dashed line, and the estimated autocorrelation range as a
vertical marker. Blocks smaller than the range leak, so the curve rises
from the reference towards the range and plateaus beyond it; the height
of the rise is what the random-fold number overstated. The caption says
which way is better: higher for R2 and Adj_R2, closer to
the nominal level for a coverage_* column (0.975 for
coverage_97.5), since over-coverage is miscalibration too, and
lower for everything else.
## S3 method for class 'block_size_sweep' plot(x, ...)## S3 method for class 'block_size_sweep' plot(x, ...)
x |
A |
... |
Ignored. |
A ggplot object.
Other plotting:
plot.aoa(),
plot.feature_selection(),
plot.gwr_model_selection(),
plot.resolution_profile(),
plot.sac_range(),
plot.spatial_fit(),
plot_calibration(),
plot_cv_metrics(),
plot_folds()
if (requireNamespace("ranger", quietly = TRUE) && requireNamespace("gstat", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(1) n <- 200 x <- 5e5 + runif(n, 0, 1000); y <- 5e6 + runif(n, 0, 1000) d <- as.matrix(dist(cbind(x, y))) field <- as.numeric(t(chol(exp(-d / 100) + diag(1e-6, n))) %*% rnorm(n)) dat <- st_as_sf(data.frame(x = x, y = y, a = rnorm(n)), coords = c("x", "y"), crs = 32632) dat$z <- field + 0.5 * dat$a + rnorm(n, 0, 0.2) rf_fn <- function(train_sf) fit_rf_model(train_sf, "z", "a", include_coords = TRUE, num_trees = 100, seed = 1) sw <- cv_block_size_sweep(dat, "z", "a", fit_fn = rf_fn, k = 4, n_sizes = 4, quiet = TRUE) # Error rises from the random-fold reference (dashed) as the blocks grow # towards the estimated autocorrelation range (vertical marker). The # height of that rise is what random folds were hiding; with four sizes the # ladder stops near the range, so the plateau beyond it is not drawn here. plot(sw) }if (requireNamespace("ranger", quietly = TRUE) && requireNamespace("gstat", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(1) n <- 200 x <- 5e5 + runif(n, 0, 1000); y <- 5e6 + runif(n, 0, 1000) d <- as.matrix(dist(cbind(x, y))) field <- as.numeric(t(chol(exp(-d / 100) + diag(1e-6, n))) %*% rnorm(n)) dat <- st_as_sf(data.frame(x = x, y = y, a = rnorm(n)), coords = c("x", "y"), crs = 32632) dat$z <- field + 0.5 * dat$a + rnorm(n, 0, 0.2) rf_fn <- function(train_sf) fit_rf_model(train_sf, "z", "a", include_coords = TRUE, num_trees = 100, seed = 1) sw <- cv_block_size_sweep(dat, "z", "a", fit_fn = rf_fn, k = 4, n_sizes = 4, quiet = TRUE) # Error rises from the random-fold reference (dashed) as the blocks grow # towards the estimated autocorrelation range (vertical marker). The # height of that rise is what random folds were hiding; with four sizes the # ladder stops near the range, so the plateau beyond it is not drawn here. plot(sw) }
select_features_forward() scores every candidate at every
step and keeps the best; its history holds all of them. This draws
the accepted variable's score at each step as the path, every other
candidate's score at that step as a faint point, and the step at which the
selection stopped in red. When it stopped because no candidate cleared
tol, the path runs one step further to the best of the rejected
candidates, drawn hollow and labelled "not added", so the stop reads as a
flattening rather than a cut. The picture then says whether the last variable
was a clear gain or the first that happened to clear tol, and
whether the runner-up would have done as well. The scores are the
selection's own cross-validated criterion, optimistically biased by the
selection (see the help page's section on that); when a hold-out score
was computed (select_on = "split") it is drawn as a separate mark
at the final step and named in the caption.
## S3 method for class 'feature_selection' plot(x, ...)## S3 method for class 'feature_selection' plot(x, ...)
x |
The list returned by |
... |
Ignored. |
A ggplot object.
Other plotting:
plot.aoa(),
plot.block_size_sweep(),
plot.gwr_model_selection(),
plot.resolution_profile(),
plot.sac_range(),
plot.spatial_fit(),
plot_calibration(),
plot_cv_metrics(),
plot_folds()
if (requireNamespace("ranger", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(4) n <- 150 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n), c = rnorm(n)), coords = c("x", "y"), crs = 32632) dat$z <- 2 * dat$a - dat$b + rnorm(n, 0, 0.5) fit_fn <- function(train_sf, vars) fit_rf_model(train_sf, "z", vars, num_trees = 80, seed = 1) sel <- select_features_forward(dat, "z", c("a", "b", "c"), fit_fn = fit_fn, k = 3, quiet = TRUE) plot(sel) }if (requireNamespace("ranger", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(4) n <- 150 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n), c = rnorm(n)), coords = c("x", "y"), crs = 32632) dat$z <- 2 * dat$a - dat$b + rnorm(n, 0, 0.5) fit_fn <- function(train_sf, vars) fit_rf_model(train_sf, "z", vars, num_trees = 80, seed = 1) sel <- select_features_forward(dat, "z", c("a", "b", "c"), fit_fn = fit_fn, k = 3, quiet = TRUE) plot(sel) }
gwr_model_selection() ranks every model it evaluated on
AICc. This draws each model's criterion against its number of
predictors, the winner in red, so the gap between the best model and the
runners-up (which the ranked table shows only as numbers) is read
as a shape: a winner well below the rest was chosen by the data, a
winner a fraction of an AICc unit ahead of three others was chosen by the
tie-break. The criterion is in-sample and the caption carries the label
gwr_model_selection() attached to it, including any note saying it
was read positionally.
## S3 method for class 'gwr_model_selection' plot(x, ...)## S3 method for class 'gwr_model_selection' plot(x, ...)
x |
A |
... |
Ignored. |
A ggplot object.
Other plotting:
plot.aoa(),
plot.block_size_sweep(),
plot.feature_selection(),
plot.resolution_profile(),
plot.sac_range(),
plot.spatial_fit(),
plot_calibration(),
plot_cv_metrics(),
plot_folds()
if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(1) n <- 80 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n), noise = rnorm(n)), coords = c("x", "y"), crs = 32632) dat$z <- 2 * dat$a - dat$b + rnorm(n, 0, 0.5) sel <- gwr_model_selection(dat, "z", c("a", "b", "noise"), bandwidth = 30) # Every model tried, AICc against its size; the winner (a and b) marked. plot(sel) }if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) set.seed(1) n <- 80 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n), noise = rnorm(n)), coords = c("x", "y"), crs = 32632) dat$z <- 2 * dat$a - dat$b + rnorm(n, 0, 0.5) sel <- gwr_model_selection(dat, "z", c("a", "b", "noise"), bandwidth = 30) # Every model tried, AICc against its size; the winner (a and b) marked. plot(sel) }
The criteria of a resolution_profile() against the number of
cells, one panel per criterion on a shared x axis, with the level each
criterion selects marked and the region over which it is within tol
of its optimum shaded. That shaded band is the flat region
select_resolution() reports. A criterion whose optimum sits
at an end of the levels it was scored at is captioned with which bound that
is (the support ceiling, the range floor, the ladder's own end, or the
first level the criterion is computable at), because there the bound is
choosing, not the criterion. A criterion that is NA at every
level is left out with a log line rather than drawn empty.
## S3 method for class 'resolution_profile' plot(x, criteria = NULL, tol = 0.02, ...)## S3 method for class 'resolution_profile' plot(x, criteria = NULL, tol = 0.02, ...)
x |
A |
criteria |
Character vector of criteria to draw, any of
|
tol |
Passed to |
... |
Ignored. |
A ggplot object.
Other plotting:
plot.aoa(),
plot.block_size_sweep(),
plot.feature_selection(),
plot.gwr_model_selection(),
plot.sac_range(),
plot.spatial_fit(),
plot_calibration(),
plot_cv_metrics(),
plot_folds()
if (requireNamespace("gstat", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) # The same field as ?resolution_profile: an exponential covariance with # range parameter 200 and a nugget of 0.6 on a unit sill. set.seed(4) n <- 400 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 200) + diag(0.6, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) prof <- resolution_profile(pts, response_var = "z", n_levels = 12) print(plot(prof)) # one panel per criterion it scored: no # elbow on these uniform points plot(prof, criteria = c("cp", "wss")) # Cp beside the raw WSS curve }if (requireNamespace("gstat", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) # The same field as ?resolution_profile: an exponential covariance with # range parameter 200 and a nugget of 0.6 on a unit sill. set.seed(4) n <- 400 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 200) + diag(0.6, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) prof <- resolution_profile(pts, response_var = "z", n_levels = 12) print(plot(prof)) # one panel per criterion it scored: no # elbow on these uniform points plot(prof, criteria = c("cp", "wss")) # Cp beside the raw WSS curve }
Draws the empirical variogram that estimate_sac_range()
attaches to its result, with the fitted model and the effective range
overlaid where a range was identified, and a subtitle saying why not where
it was not. A variogram that never reaches a sill, or that has no spatial
structure at the lags resolved, is the single most useful thing to
see when a range comes back NA, so those cases are drawn
rather than refused.
## S3 method for class 'sac_range' plot(x, ...)## S3 method for class 'sac_range' plot(x, ...)
x |
An object of class |
... |
Ignored. |
Nothing is recomputed: the plot reads the variogram,
variogram_model, crs, directional and
anisotropy_used attributes the estimate already carries. The
distance axis is in the units of the CRS the variogram was actually fitted
in, which estimate_sac_range() may have chosen itself for lon/lat
input.
A ggplot object.
plot.spatial_fit(type = "variogram"), which draws
the same picture for a fitted model's residuals.
Other plotting:
plot.aoa(),
plot.block_size_sweep(),
plot.feature_selection(),
plot.gwr_model_selection(),
plot.resolution_profile(),
plot.spatial_fit(),
plot_calibration(),
plot_cv_metrics(),
plot_folds()
if (requireNamespace("gstat", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) # An exponential field with range parameter 150 (effective range about # 450 m) and a nugget of half the sill, so the fitted model, the nugget # and the range line all have something to show. set.seed(3) n <- 250 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 150) + diag(0.5, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) r <- estimate_sac_range(pts, response_var = "z") plot(r) # A field whose variogram never reaches a sill: the plot still draws it, # and the subtitle says why no range is marked. xy$trend <- sin(xy$x / 400) + rnorm(n, sd = 0.2) r2 <- estimate_sac_range(st_as_sf(xy, coords = c("x", "y"), crs = 32632), response_var = "trend") plot(r2) }if (requireNamespace("gstat", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) # An exponential field with range parameter 150 (effective range about # 450 m) and a nugget of half the sill, so the fitted model, the nugget # and the range line all have something to show. set.seed(3) n <- 250 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 150) + diag(0.5, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) r <- estimate_sac_range(pts, response_var = "z") plot(r) # A field whose variogram never reaches a sill: the plot still draws it, # and the subtitle says why no range is marked. xy$trend <- sin(xy$x / 400) + rnorm(n, sd = 0.2) r2 <- estimate_sac_range(st_as_sf(xy, coords = c("x", "y"), crs = 32632), response_var = "trend") plot(r2) }
Diagnostic plots for a spatial_fit. The package previously shipped
print() and summary() methods but no plot(), so the
checks most likely to reveal a problem (is there structure left in the
residuals, and where is it) had to be written by hand each time.
## S3 method for class 'spatial_fit' plot( x, type = c("residuals", "observed_predicted", "variogram", "coefficients"), response = TRUE, term = NULL, mask = TRUE, ... )## S3 method for class 'spatial_fit' plot( x, type = c("residuals", "observed_predicted", "variogram", "coefficients"), response = TRUE, term = NULL, mask = TRUE, ... )
x |
A |
type |
One of:
|
response |
Logical, default |
term |
For |
mask |
For |
... |
Ignored. |
A ggplot object.
coef.gwr_fit() for the coefficients themselves.
Other plotting:
plot.aoa(),
plot.block_size_sweep(),
plot.feature_selection(),
plot.gwr_model_selection(),
plot.resolution_profile(),
plot.sac_range(),
plot_calibration(),
plot_cv_metrics(),
plot_folds()
# Works on any spatial_fit; a forest keeps the example free of the optional # GWR/Stan backends. if (requireNamespace("ranger", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) # price depends on elevation, which the forest sees, and on a spatially # correlated field it does not: that field is what the residual plots # are there to find. set.seed(2) n <- 120 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)) D <- as.matrix(dist(xy[, c("x", "y")])) xy$price <- 10 + 2 * xy$elev + as.numeric(t(chol(exp(-D / 100) + diag(0.3, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) fit <- fit_rf_model(pts, "price", "elev", num_trees = 100, seed = 1) # print() each one: inside a braced block only the last value is drawn. print(plot(fit, type = "residuals")) # structure left in the residuals print(plot(fit, type = "observed_predicted")) if (requireNamespace("gstat", quietly = TRUE)) plot(fit, type = "variogram") # residual and response variograms compared }# Works on any spatial_fit; a forest keeps the example free of the optional # GWR/Stan backends. if (requireNamespace("ranger", quietly = TRUE) && requireNamespace("ggplot2", quietly = TRUE)) { library(sf) # price depends on elevation, which the forest sees, and on a spatially # correlated field it does not: that field is what the residual plots # are there to find. set.seed(2) n <- 120 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)) D <- as.matrix(dist(xy[, c("x", "y")])) xy$price <- 10 + 2 * xy$elev + as.numeric(t(chol(exp(-D / 100) + diag(0.3, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) fit <- fit_rf_model(pts, "price", "elev", num_trees = 100, seed = 1) # print() each one: inside a braced block only the last value is drawn. print(plot(fit, type = "residuals")) # structure left in the residuals print(plot(fit, type = "observed_predicted")) if (requireNamespace("gstat", quietly = TRUE)) plot(fit, type = "variogram") # residual and response variograms compared }
Builds a prediction surface over the extent of the training data (or over a
grid you supply), predicts in chunks, and returns an sf layer.
predict_surface( object, grid = NULL, cell_size = NULL, n_cells = 10000L, boundary = NULL, covariates = NULL, chunk_size = 5000L, se = FALSE, ... )predict_surface( object, grid = NULL, cell_size = NULL, n_cells = 10000L, boundary = NULL, covariates = NULL, chunk_size = 5000L, se = FALSE, ... )
object |
A |
grid |
Optional |
cell_size |
Grid resolution in CRS units. Ignored when |
n_cells |
Approximate cell count used to derive |
boundary |
Optional polygonal |
covariates |
Optional |
chunk_size |
Rows per prediction call. Default 5000. A pure
performance knob: rows do not interact, and for a |
se |
Logical; also return a standard-error/posterior-SD column where the
backend supports it. Default FALSE. For a |
... |
Passed to |
predict() on a spatial_fit requires newdata to be
constructed by hand, which makes the most common downstream task (produce
a map) more work than it should be. This wraps the grid construction,
covariate join, chunking and CRS handling.
Prediction over a grid is embarrassingly parallel in the sense that rows do
not interact, so it is chunked: for bayesian_fit the posterior draw
matrix is n_draws x n_newdata, which will exhaust memory on a fine
grid long before the fit itself would.
An sf POINT layer with a .pred column (and
.pred_se when se = TRUE and available; one a supplied
grid already carried, from an earlier surface, is removed
otherwise). For an
auto-generated grid the resolution is attached as attribute
"cell_size". For a user-supplied grid it is only whatever
"cell_size" attribute that object already carried. That is usually
NULL, and NULL for certain if the grid had to be
re-projected, since st_transform() does not preserve custom
attributes. The resolution of a grid you built is not this function's to
infer.
Other prediction:
area_of_applicability()
# Any spatial_fit works here; a forest keeps the example free of the # optional GWR/Stan backends. if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) n <- 120 pts <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) pts$price <- 10 + 0.01 * (st_coordinates(pts)[, 1] - 5e5) + 2 * pts$elev + rnorm(n) fit <- fit_rf_model(pts, "price", "elev", num_trees = 100, seed = 1) surf <- predict_surface(fit, n_cells = 500, covariates = pts) print(surf[".pred"]) # one prediction per grid cell, as an sf layer # Check where that surface is extrapolating before mapping it. The grid # took its covariates from the nearest observation, so here nothing is # outside; a grid with its own covariate raster is where this bites. area_of_applicability(surf, model = fit) }# Any spatial_fit works here; a forest keeps the example free of the # optional GWR/Stan backends. if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) n <- 120 pts <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) pts$price <- 10 + 0.01 * (st_coordinates(pts)[, 1] - 5e5) + 2 * pts$elev + rnorm(n) fit <- fit_rf_model(pts, "price", "elev", num_trees = 100, seed = 1) surf <- predict_surface(fit, n_cells = 500, covariates = pts) print(surf[".pred"]) # one prediction per grid cell, as an sf layer # Check where that surface is extrapolating before mapping it. The grid # took its covariates from the nearest observation, so here nothing is # outside; a grid with its own covariate raster is where this bites. area_of_applicability(surf, model = fit) }
Applies the same newdata preparation pipeline as predict.gwr_fit():
non-point geometries are coerced to points, the data is projected to the
CRS used during fitting (via ensure_projected()), and rows with
missing or non-finite values are dropped. Coordinate scaling and predictor
standardisation stored at fit time are then applied before delegating to
brms::posterior_epred() or brms::posterior_predict().
## S3 method for class 'bayesian_fit' predict( object, newdata = NULL, summary = c("mean", "median"), type = c("epred", "predict"), draws = FALSE, ... )## S3 method for class 'bayesian_fit' predict( object, newdata = NULL, summary = c("mean", "median"), type = c("epred", "predict"), draws = FALSE, ... )
object |
A |
newdata |
An sf object with the same predictors. The response variable need not be present (true out-of-sample prediction is supported). NULL = fitted values. |
summary |
"mean" (default) or "median" over posterior draws. |
type |
"epred" (default) for expected predictions (no obs noise), or "predict" for full posterior predictive draws (includes obs noise). |
draws |
If TRUE, return the full posterior draw matrix instead of a point summary. Default FALSE. |
... |
Ignored. |
Numeric vector of length nrow(newdata), or a
n_draws x nrow(newdata) matrix when draws = TRUE. If the
posterior draw fails the result is all NA (a 1-row matrix for
draws = TRUE) and the cause is logged. An ordinal or categorical
family is not a failed draw and is an error under
type = "epred": its expected value is a probability per response
category, not one number per row. Use type = "predict",
draws = TRUE for posterior draws of the predicted category, as category
indices; the share of draws in each category estimates its probability.
Without draws = TRUE, type = "predict" returns the mean (or
median) category index, an expected rank for an ordinal family and an
error for brms::categorical(), whose categories have no order.
With newdata = NULL the cached fitted() values are returned only
for the default summary = "mean", type = "epred",
draws = FALSE combination; any other combination is recomputed
against the training data, because the cache holds epred column means and
nothing else.
brms 2.17 to 2.22 do not store the Hilbert-space boundary in a
fitted GP basis, so brms:::.data_gp() recomputes it from whatever
rows predict() is handed, which moved every eigenfunction of the
approximation with the newdata bounding box while the fitted basis
coefficients stayed put. Two synthetic rows at the training coordinate
extrema are therefore appended before the posterior draw and dropped from
the result, and when newdata reaches past the training range the
c of the gp() term is scaled down by as much as the range
grew, so brms rebuilds exactly the boundary the model was fitted with. A
prediction therefore does not depend on which other rows share the call:
chunked, fold-wise and single-call predictions agree, and
predict_surface() does not depend on chunk_size.
brms 2.23.0 and later store and reuse it, so there c is left
alone and the two extra rows change nothing.
Predictions outside the training coordinate envelope are extrapolation (a
notice is written to the log). A row further than from the centre
of the training coordinates on either axis is past the edge of the basis,
where the approximate GP is an odd reflection of the fitted surface rather
than an estimate of anything, so it is returned as NA (a column of
NA with draws = TRUE) with a warning. The default boundary
factor puts that edge well outside the training data, so only
newdata reaching far past it is affected.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
When newdata is NULL, returns the in-sample fitted values.
Otherwise estimates the local coefficients at each new location with
GWmodel::gwr.basic(regression.points = ), with the fit's kernel and
bandwidth (an adaptive bandwidth counts neighbours among the training
points), and returns . These are the
values GWmodel::gwr.predict() returns, without its prediction
variance, which this method never returned and which costs time cubic in
the number of training points. Each location stands alone: one that cannot
be estimated does not affect the others.
newdata is first transformed to the CRS used during fitting
(via ensure_projected()), so predictions are computed in a
single coordinate system regardless of the CRS newdata arrives in.
## S3 method for class 'gwr_fit' predict(object, newdata = NULL, ...)## S3 method for class 'gwr_fit' predict(object, newdata = NULL, ...)
object |
A |
newdata |
An sf object with the same predictors. The response variable need not be present (true out-of-sample prediction is supported). NULL = fitted values. |
... |
Ignored. |
Numeric vector aligned to nrow(newdata), with NA for
rows dropped as missing or non-finite, and for locations whose local
regression cannot be estimated (too few training points within a fixed
bandwidth, or a singular local design); a warning counts those. If the
design matrix for newdata cannot be built, every value is
NA and a warning says why. CRS-less
newdata first receives the interpretation the training data got, so
the same rows land where they did at fit time.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
With newdata = NULL this returns out-of-bag predictions, not
in-sample ones. See fit_rf_model.
## S3 method for class 'rf_fit' predict(object, newdata = NULL, ...)## S3 method for class 'rf_fit' predict(object, newdata = NULL, ...)
object |
An |
newdata |
Optional sf object carrying the same predictors. It is
transformed to the CRS used at fitting time first, so a forest that
includes the coordinates is not fed a different coordinate system.
Categorical predictors must not carry a level the forest was never grown
with, meaning a level with no training rows, not merely one
absent from |
... |
Passed to |
Numeric vector, aligned to nrow(newdata) with NA for
rows dropped as incomplete (so all NA, with a WARN line in the
log, when every row is). A failure inside ranger's predict
method is an error, not an all-NA vector.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
Ensures point geometry, projected CRS, and removes rows with missing or
non-finite values in modeling columns, including rows whose
geometry is empty or whose coordinates are not finite, which no model
backend can use. All non-POINT geometries (including MULTIPOINT) are
coerced to representative points via coerce_to_points(), so
downstream coordinate extraction always aligns one row per observation.
Any Z or M coordinate (POINT Z from a GPS, a GeoPackage or KML) is
dropped, because every backend works in 2-D map distance.
prep_model_data( data_sf, response_var, predictor_vars, boundary = NULL, pointize = c("auto", "surface", "point_on_surface", "centroid", "line_midpoint", "bbox_center"), require_response = TRUE )prep_model_data( data_sf, response_var, predictor_vars, boundary = NULL, pointize = c("auto", "surface", "point_on_surface", "centroid", "line_midpoint", "bbox_center"), require_response = TRUE )
data_sf |
An sf object. |
response_var |
Response variable column name. |
predictor_vars |
Predictor column names. May be |
boundary |
Optional sf/sfc for CRS alignment. |
pointize |
Strategy for non-point geometry coercion, passed to
|
require_response |
Logical; if FALSE the response column is not required to be present (useful for out-of-sample prediction where the response is unknown). Default TRUE. |
The response may not appear in predictor_vars. Using it as its own
predictor is leakage no backend catches: an out-of-bag R^2 near 1 in the
random forest, a silently reduced design matrix in GWR, duplicated rows and
a phantom <none> entry in the GWR selection table. It is refused
here.
Column names must be syntactically valid R names (make.names(x) == x).
Every backend builds a model formula from these names. A name R parses as
an expression would fit a different model from the one requested while the
fit object still recorded the name you asked for: "B5-B4" is
B5 - B4, and "log(a)" is a function call. Rename the column
(for example with make.names()) before fitting.
An sf object with 2-D (XY) POINT geometry, cleaned of rows
carrying missing or non-finite values in the modelling columns or in the
coordinates. What
was removed is recorded on the attribute "dropped", a list with
n (rows dropped), n_geometry (how many of them for an
empty or non-finite geometry), which (their positions in
data_sf), row_id (their ..row_id values when the
layer carries that column, else NULL) and reason (one per
dropped row: "geometry", "missing" or
"non_finite", in that order of precedence when several apply),
plus n_rows, the number of rows returned, which the record was
made for.
Every fit stores n as $info$n_dropped. The record
describes the rows this call returned and does not survive subsetting:
clean[i, ], like dplyr::filter(), slice() or
arrange() of it, is a plain layer with no "dropped"
attribute, and a fit given such a subset with .already_prepped =
TRUE reports n_dropped = 0 even when the parent layer dropped
rows. sf::st_drop_geometry() keeps the record, since the rows are
the same; see [.spatialkit_rows for what binding such data
frames does. The CRS is
projected whenever one can be established. A CRS-less layer is decided by
the lon/lat heuristic (see ensure_projected): if its bounding
box fits the lon/lat envelope and it either spans more than one unit
on some axis or carries decimal-degree-like precision, it is read as
EPSG:4326 and projected, with a warning. A small planar survey inside that
envelope is included in that, deliberately; only coordinates the heuristic
declines are passed through as-is. Set the CRS on data_sf if the
data are planar.
Other model fitting:
fit_bayesian_spatial_model(),
fit_gwr_model(),
fit_rf_model(),
gp_lengthscale_bounds(),
new_spatial_fit()
Other spatial data preparation:
clip_target_for(),
coerce_to_points(),
ensure_projected(),
harmonize_crs()
library(sf) dat <- st_as_sf( data.frame(x = 1:5, y = 5:1, resp = c(1, 2, NA, 4, 5), pred = c(1, 2, 3, 4, Inf)), coords = c("x", "y"), crs = 32632 ) clean <- prep_model_data(dat, "resp", "pred") # drops rows 3 (NA) and 5 (Inf) attr(clean, "dropped")library(sf) dat <- st_as_sf( data.frame(x = 1:5, y = 5:1, resp = c(1, 2, NA, 4, 5), pred = c(1, 2, 3, 4, Inf)), coords = c("x", "y"), crs = 32632 ) clean <- prep_model_data(dat, "resp", "pred") # drops rows 3 (NA) and 5 (Inf) attr(clean, "dropped")
Summarises where the model may be trusted: how many prediction locations fall inside the area of applicability and how many outside, the dissimilarity threshold that separated them, and the predictors the index was computed over (naming any dropped for having no usable variance). The proportion outside is the headline number. A map that extrapolates over much of its extent is reporting predictions its training data cannot support, whatever the cross-validation score said.
## S3 method for class 'aoa' print(x, ...)## S3 method for class 'aoa' print(x, ...)
x |
An |
... |
Ignored. |
x, invisibly.
Other print methods:
print.gwr_model_selection(),
print.morans_i(),
print.resolution_profile(),
print.sac_range()
Shows the forward-selection trail: the response, the candidate predictors, and the top-ranked models with their criterion values, so you can see both which model won and by how much. A shallow gap between the first few rows means the ranking is not well identified and the choice of predictors should not be treated as settled. Check the gap before reporting one model as the selected one.
## S3 method for class 'gwr_model_selection' print(x, n = 10L, ...)## S3 method for class 'gwr_model_selection' print(x, n = 10L, ...)
x |
A |
n |
Number of top-ranked models to show. Default 10. |
... |
Ignored. |
x, invisibly.
Other print methods:
print.aoa(),
print.morans_i(),
print.resolution_profile(),
print.sac_range()
Shows the statistic, the null its moments come from and the evidence
behind them. The weight matrix the result carries gets a one-line
description in place of the matrix: it is , and
autoprinting it buried the statistic under a thousand lines of matrix.
## S3 method for class 'morans_i' print(x, ...)## S3 method for class 'morans_i' print(x, ...)
x |
An object of class |
... |
Ignored. |
x, invisibly.
Other print methods:
print.aoa(),
print.gwr_model_selection(),
print.resolution_profile(),
print.sac_range()
Print a resolution profile
## S3 method for class 'resolution_profile' print(x, digits = 3L, ...)## S3 method for class 'resolution_profile' print(x, digits = 3L, ...)
x |
A |
digits |
Significant digits for the table. Default 3. |
... |
Ignored. |
x, invisibly.
Other print methods:
print.aoa(),
print.gwr_model_selection(),
print.morans_i(),
print.sac_range()
Shows the forest's shape (formula, n, number of trees, mtry, node
size) along with the out-of-bag error and, prominently, whether the
coordinates were used as predictors. That last line is the one to check:
a forest fitted with include_coords = TRUE can memorise location and
score well out-of-bag while failing everywhere it has not been.
## S3 method for class 'rf_fit' print(x, ...)## S3 method for class 'rf_fit' print(x, ...)
x |
An |
... |
Ignored. |
x, invisibly.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
Prints the effective range as a plain number, with the directional fit
summarised beneath it when one is available. A direction whose fit was
unusable is labelled with why (directional_status) and the range
its fit reported (directional_fitted) when the object carries
them, and unidentified otherwise. A last line names the unit and
the CRS the range is a length in (attr(x, "crs"), which for
lon/lat input is the projected CRS the estimate chose; for a layer with
no CRS, it says the range is in that layer's own coordinate units), and
whether the variogram is of the response or of its residuals on
predictor_vars (detrended, detrend_method).
## S3 method for class 'sac_range' print(x, ...)## S3 method for class 'sac_range' print(x, ...)
x |
An object of class |
... |
Ignored. |
x, invisibly.
Other print methods:
print.aoa(),
print.gwr_model_selection(),
print.morans_i(),
print.resolution_profile()
Shows the one-screen summary of a gwr_fit, a bayesian_fit or a
custom subclass: backend, formula, number of observations, CRS, and the few
backend-specific numbers worth seeing immediately (GWR bandwidth, GP basis
size). An rf_fit has its own method (see
print.rf_fit), which shows the same header plus the forest
settings. It is what you get by typing the object's name, and the quickest
way to confirm a fit used the data, predictors and CRS you meant. For fit
quality use model_metrics() or summary()
instead. Nothing printed here is an out-of-sample score.
## S3 method for class 'spatial_fit' print(x, ...)## S3 method for class 'spatial_fit' print(x, ...)
x |
A |
... |
Ignored. |
x, invisibly (called for its side effect).
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
Given a spatial_fit object, extracts the residuals and the
observation coordinates, builds a spatial weight matrix, and computes
Moran's I together with its analytical expectation and variance under a
stated null. A z-score and two-sided p-value are provided so the caller can
assess whether statistically significant spatial autocorrelation remains
after fitting.
residual_morans_i( fit, alternative = c("two.sided", "greater", "less"), weights = NULL, k = 8L, null = c("auto", "randomisation", "residual"), keep_weights = FALSE )residual_morans_i( fit, alternative = c("two.sided", "greater", "less"), weights = NULL, k = 8L, null = c("auto", "randomisation", "residual"), keep_weights = FALSE )
fit |
A |
alternative |
Character: |
weights |
Optional user-supplied n x n weight matrix: a base
matrix or a Matrix-package matrix (e.g. a sparse dgCMatrix).
When |
k |
Integer number of nearest neighbours used when building the
default weight matrix (ignored when |
null |
Which null distribution the expectation, variance and p-value are computed against. One of:
Both |
keep_weights |
Logical. Return the |
By default, weights are constructed as a k-nearest-neighbour (k = 8)
binary matrix, row-standardised. Users may supply their own weight
matrix via the weights argument.
A list with components:
Numeric scalar, Moran's I statistic.
Expected I under the null named by null:
for "randomisation",
for "residual".
Standard deviation of I under that same null.
Standardised z-score, .
Two-sided (or one-sided) p-value from the normal approximation.
Number of observations used.
The null actually used, "randomisation" or
"residual". Check it, since "auto" chooses per fit
and "residual" can fall back.
Residual degrees of freedom behind the moments:
for "residual" (where is the rank of the
design matrix), for "randomisation".
The weight matrix the statistic was
computed with, either the row-standardised k-nearest-neighbour
matrix built here (sparse when Matrix is installed) or the
supplied weights after the diagonal was zeroed. It is
NULL unless keep_weights = TRUE.
The residual kurtosis , which the
randomisation variance conditions on and which says how far the
residuals sit from the Gaussian case the residual moments assume
(3 for a normal sample).
The rank of the rebuilt design matrix behind the
"residual" moments; NA under
"randomisation".
Logical: TRUE when the moments are exact for
these residuals (the residual null on OLS residuals of the response
on the rebuilt design), FALSE when they are an approximation
(the residual null forced onto a non-OLS backend, or the
randomisation null, whose exchangeable moments model residuals do
not satisfy).
What the weight matrix was, present whether or
not the matrix itself was kept: n, storage (its class),
neighbours (the smallest and largest number of neighbours any
row has, and NA for a dense matrix, where counting them would
allocate a second one), kept (whether weights holds
the matrix) and desc, the one line print() shows.
The list is classed "morans_i" and has a print() method,
so the console shows the statistic and its null without printing the
weights matrix; [ drops the class, and
$, [[ and unlist() are unaffected.
A custom fit whose class has no residuals() method (which
new_spatial_fit calls optional) is scored on the
observed response minus fitted(), as plot() does for it.
Returns NULL with a warning if computation fails, saying why:
residuals() raised an error (its message is quoted), or returned
NULL and the response minus fitted() could not be
formed either (the reason is quoted), fewer than 4 valid
residuals, or a residual vector whose length does not match the fit's
data_sf.
Two nulls are available, and the one actually used is reported back in the
null element of the return value.
"randomisation" is the classical exchangeable null:
with the Cliff & Ord randomisation variance,
conditioning on the observed kurtosis. These are the moments of I for a
vector whose elements are equally likely in any order.
Model residuals are not exchangeable. They are orthogonal to the
design matrix, which pushes materially below
whenever the covariates are spatially smooth, and pushes it further the
more covariates there are. In a simulation with , six smooth
covariates and independent errors (so the truth is "no residual
autocorrelation"), OLS residuals had mean against the
exchangeable ; the z-score averaged with
instead of 0 and 1. The cost is power, which is the point
of the test: at a moderate residual autocorrelation the exchangeable null
rejected 13% of the time where the correct one rejected 31%.
"residual" therefore uses the Cliff & Ord (1981) sec. 8.3 moments for
regression residuals, with rebuilt from
predictor_vars and data_sf:
These assume normal errors and do not condition on the observed
kurtosis. On the simulation above they restored the z-score to mean
, , and the rejection rate to 4.3% against a
nominal 5%. They agree with spdep::lm.morantest() to machine
precision.
These moments are exact for and for nothing else, so
null = "auto" does not guess from the fit's class: it rebuilds
X, regresses the response on it, and uses the residual moments only
when the supplied residuals are those OLS residuals to numerical
tolerance. A GWR wide enough to have collapsed to global OLS passes that
test; the same GWR at a working bandwidth does not.
For the flexible backends neither null is exact, and "auto"
leaves them on "randomisation" because forcing the OLS moments on
them measurably makes matters worse, not better. Measured on null data
(, three smooth covariates, independent errors; nominal 5%,
one-sided):
| backend | randomisation | residual |
| OLS | 0.035 | 0.060 |
| random forest | 0.128 | 0.200 |
| GWR | 0.000 | 0.000 |
The random forest is anticonservative under both. Its residuals here are
the out-of-bag ones (residuals.rf_fit()), not in-sample
fits. They are inflated instead of shrunk (measured sd 1.08 against a
true 1.00), but they are not a linear projection of the response and
they are spatially heteroscedastic, so neither set of moments describes
their null distribution and the variance is understated whichever is used
(). GWR is conservative under both, because it
removes far more structure than a rank-p projection does. Treat the
p-value from those backends as a rough indicator, and prefer
spatially-blocked cross-validated residuals or an explicit spatial
covariance model when the answer has to carry weight.
A permutation null was considered and rejected: permuting the residual
vector destroys exactly the orthogonality that causes the bias, so its mean
is the exchangeable by construction (measured:
against ) and it reproduces the
randomisation null without correcting it.
Cliff, A. D. and Ord, J. K. (1981) Spatial Processes: Models and Applications. Pion, London. Section 8.3.
Other model evaluation:
compare_models(),
compare_models_cv(),
evaluate_insample(),
model_metrics()
# Works on any spatial_fit; a forest keeps the example free of the optional # GWR/Stan backends. if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) n <- 120 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) # A strong east-west trend the predictor cannot explain: the residuals # should still carry spatial structure, and this is what detects it. dat$price <- 10 + 0.02 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) fit <- fit_rf_model(dat, "price", "elev", num_trees = 100, seed = 1) # I = 0.64, z = 15.5: strong positive residual autocorrelation, exactly # as constructed. A z near 0 with a large p-value would be the opposite # verdict -- no structure the model failed to capture. residual_morans_i(fit) }# Works on any spatial_fit; a forest keeps the example free of the optional # GWR/Stan backends. if (requireNamespace("ranger", quietly = TRUE)) { library(sf) set.seed(1) n <- 120 dat <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), elev = rnorm(n)), coords = c("x", "y"), crs = 32632 ) # A strong east-west trend the predictor cannot explain: the residuals # should still carry spatial structure, and this is what detects it. dat$price <- 10 + 0.02 * (st_coordinates(dat)[, 1] - 5e5) + 2 * dat$elev + rnorm(n) fit <- fit_rf_model(dat, "price", "elev", num_trees = 100, seed = 1) # I = 0.64, z = 15.5: strong positive residual autocorrelation, exactly # as constructed. A z near 0 with a large p-value would be the opposite # verdict -- no structure the model failed to capture. residual_morans_i(fit) }
Observed response minus fitted.bayesian_fit (the cached
posterior expectation), so these are in-sample residuals.
## S3 method for class 'bayesian_fit' residuals(object, ...)## S3 method for class 'bayesian_fit' residuals(object, ...)
object |
A |
... |
Ignored. |
Numeric vector of length object$n.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.gwr_fit(),
residuals.rf_fit(),
summary.spatial_fit()
Observed response minus fitted.gwr_fit, so these are
in-sample residuals.
## S3 method for class 'gwr_fit' residuals(object, ...)## S3 method for class 'gwr_fit' residuals(object, ...)
object |
A |
... |
Ignored. |
Numeric vector of length object$n.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.rf_fit(),
summary.spatial_fit()
Observed response minus fitted.rf_fit, which for a forest is
the out-of-bag prediction: each observation predicted only by the
trees that did not see it. These are therefore already held-out residuals,
unlike residuals.gwr_fit() and residuals.bayesian_fit(),
which are in-sample. Feed them to residual_morans_i() to test
whether spatial structure the forest failed to capture is still sitting in
the residuals. Out-of-bag is not a substitute for spatial CV: use
cv_rf() for an honest map-accuracy figure.
## S3 method for class 'rf_fit' residuals(object, ...)## S3 method for class 'rf_fit' residuals(object, ...)
object |
An |
... |
Ignored. |
Numeric vector of length object$n.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
summary.spatial_fit()
A tessellation's resolution is the number of cells the points are cut
into, and no single number settles it: the within-cluster sum of squares
of the coordinates has an elbow, a piecewise-constant approximation of the
response has a Mallows , the residual autocorrelation of the cell
means has a , and the cell means have a reliability. This function
computes all of them at every level of a ladder the data bound, and
returns the table, so that the level chosen, by
select_resolution() or by eye, can be defended with the
whole profile in place of one criterion's argmin. Mallows (1973) presents
itself as a display of the bias-variance trade-off across
candidates; he does not offer it as a rule that picks one. This is that
display, with the other criteria alongside.
resolution_profile( data_sf, response_var = NULL, predictor_vars = NULL, levels = NULL, n_levels = 20L, min_cell_n = 9L, sample_n = 1500L, nstart = 25L, seed = 123L, sac = NULL, select_on = c("all", "split"), range_floor = TRUE )resolution_profile( data_sf, response_var = NULL, predictor_vars = NULL, levels = NULL, n_levels = 20L, min_cell_n = 9L, sample_n = 1500L, nstart = 25L, seed = 123L, sac = NULL, select_on = c("all", "split"), range_floor = TRUE )
data_sf |
An sf object of points (other geometries are reduced to representative points). Features with empty or non-finite coordinates are dropped with a warning. |
response_var |
Optional response column name (numeric or logical).
Enables |
predictor_vars |
Optional predictor column names (numeric or
logical). With them, |
levels |
Optional integer vector of level counts to score, replacing
the ladder; values below 2, above the number of distinct locations, or
at or above the number of points, are dropped (k-means cannot place more
centres than there are distinct points, and |
n_levels |
Number of levels on the ladder. Default 20. |
min_cell_n |
Minimum average number of points per cell that a level must keep; sets the ceiling. Default 9. |
sample_n |
Points are subsampled to this many before the k-means
fits, as in |
nstart |
k-means++ restarts per level. Default 25. |
seed |
RNG seed for the subsample and the restarts; restored afterwards. Default 123. The rows are put in coordinate order before either, so the profile does not depend on the order they come in. |
sac |
Optional |
select_on |
|
range_floor |
|
A data.frame of class resolution_profile with one row per
level and columns levels, wss, wss_spread (relative
spread of WSS across the restarts), elbow, cell_n_min,
cell_n_median, cell_diam_median (twice the median RMS
radius of the cells, in coordinate units: about 0.8 of the side of a
square cell of the same area, and less for finer cells, so a size to
compare levels by rather than a width), rss, cp,
moran_i, moran_z and reliability; columns a missing
input leaves undefined are NA. Attributes: bounds (a list
with floor, ceiling, ceiling_from ("min_cell_n",
"distinct locations" or "sample_n", whichever bound it),
supported, area, range, n (the points in the
layer), n_sample (the points the k-means fits ran on),
n_distinct, min_cell_n, range_floor),
variogram (a list with nugget, psill, range
(NA when rejected), model and detrended (whether
the sac says it is a variogram of residuals; NA when it does not
say); NULL when none was usable), variable ("response",
"residuals" or NA), wss_bumps, nstart,
sac (the range object used) and, with select_on =
"split", split (a spatialkit_split: selection
and estimation, integer row positions in data_sf, with
the method and seed that made them), cp_noise (a
list with source, "variogram nugget" or
"finest-level residual mean square", value, the
used, and level, the level it was read at or
NA; NULL when cp has neither), centres
(a list named by level of the two-column matrices of the scored
partitions' centres) and centres_crs (the CRS they are in). A
k-means partition is the Voronoi partition of its centres, so these
seed the very cells each level was scored on: select_resolution()
returns the chosen level's as $seeds, and
get_voronoi_seeds() and voronoi_seeds_kmeans()
use them when given the selection or the profile.
Cells are k-means clusters of the (projected) coordinates, fitted at each
level as the best of nstart k-means++ restarts (see
determine_optimal_levels for why). Levels are spaced
logarithmically, because cell diameter scales as : a unit
step wastes fits at large and starves resolution at small. The
ladder runs from a floor to a ceiling the data impose. The ceiling is
floor(n / min_cell_n), with every point of the layer (not
the subsample): cells with fewer than min_cell_n points
on average have too little support, and Moran's z is not computable at
nine cells or fewer in any case. It is also held to the number of
distinct locations, one cell on each, and to one short of the number of
points, which stats::kmeans() needs. The floor is
ceiling(area / range^2) when an autocorrelation range is available:
cells wider than the range average over more than one patch of the field.
When the floor exceeds the ceiling the data cannot support a tessellation
that respects their own correlation structure; that is reported as a
finding (a logged warning, and attr(x, "bounds")$supported is
FALSE) and the ladder runs from 2 to the ceiling anyway, so the
profile still shows what each level costs. On a layer larger than
sample_n the ceiling is also held to half the subsample (two
subsample points per cell, the least a fitted cell can be scored on);
ceiling_from is then "sample_n", a floor above that is logged
with a request to raise sample_n, and it does not make
supported FALSE.
The floor moves with the range estimate, and the ceiling with , so
two profiles of similar data (the folds of a cross-validation, say) can
sit on either side of the point where the floor applies: one ladder then
starts at the floor and the other at 2, and the criteria that sit near
the bottom of the ladder (reliability, which routinely peaks at the
floor, and elbow) can differ between them by a factor of 10 or
more. To compare profiles, pass the same levels to each, or
range_floor = FALSE to start every ladder at 2 while the floor is
still reported.
elbowHow far the WSS curve sags below a power law: on
log-log axes, WSS below the straight line from
(the total sum of squares) to the last level, in natural-log units
(larger is better). Points with no cluster structure have a WSS close
to , which is straight on those axes, so the column is
NA at every level unless the largest sag reaches
, and the print says there is no elbow (the rule and its
calibration are in determine_optimal_levels). The
classical chord on linear axes found a "knee" on such a layer anyway,
at about , where the ladder's ends put
it. A level whose WSS is 0 (to within of the total),
one cell on every distinct location, which the ladder reaches when
locations repeat, is left out of the line; when the other levels have
no elbow, that fall to zero is the elbow, and its sag is measured with
the WSS floored at of the total, which puts it far
above any other level's. Geometry only; it knows nothing of the
response.
cpMallows' of the piecewise-constant
approximation of the response (or of its OLS residuals on
predictor_vars) by cell means: ,
with the nugget of the fitted variogram (lower is better).
It estimates the error of predicting a new observation by the mean of
its cell. When the cells are fitted to a subsample of of the
points with a response, the penalty is split between the two:
, where the first two
terms estimate the approximation error from the subsample (adding back
the optimism of its own cell means, over the cells its scored
points fall in) and the last is the variance of cell means built from
all , which is what the tessellation will carry. With no
subsample it is the formula above.
Measured on simulated exponential fields (600 points on a
1000-unit extent, sill 1, 20 replicates): with a nugget of 0.3 its
minimum sat at the support ceiling in every replicate at effective
ranges of 90, 300 and 900; with a nugget of 2 it was interior (median
44, range 8–66). On a smooth field with little noise the
approximation keeps improving as cells shrink and the penalty is too
small to stop it, so says "as fine as the support allows"
and min_cell_n is what is choosing; select_resolution()
says so when that happens. It becomes a genuine interior criterion
only when the nugget is a large share of the sill. With a nugget of 0
(under of the sill; usually a fit clipped at its lower
bound) the penalty is 0 and descends to the ceiling whatever
the field; the profile warns. When the variogram gives no nugget (none
was fitted, its fit did not converge, its range is below the shortest
lag, or sac is a range alone), is Mallows' own
choice instead: the residual mean square of the
finest level whose cells hold at least two scored rows on average. It
counts the structure within those cells as noise too, so on average it
is no smaller than the nugget and errs towards fewer cells; the profile warns,
and attr(, "cp_noise") records which was used. When no level's
cells hold that many, cp is NA at every level.
moran_zThe standardised deviate of Moran's I on the
residuals of the cell means regressed on the cell-mean predictors (an
intercept alone when there are none): how much spatial structure the
tessellation has left unexplained ( smaller is better).
Calibrated and flat in on a response with no structure (see
determine_optimal_levels), so it separates levels only
where structure remains. NA at nine cells or fewer.
reliabilityThe between-cell signal's share of the spread
in the cell means, from the fitted variogram alone via Krige's
additivity relation (Cressie 1996), for square cells of the level's
average area holding the level's average share of the layer's points
with a response (larger is better).
This is the shrinkage factor of Fay and Herriot (1979). It has an
interior optimum, and a broad one: validated against the empirical
reliability of true block means on simulated fields, the analytic and
empirical optima agreed to within a level or two where the empirical
estimate was stable, and the band within 2 percent of the maximum
spanned a factor of 3–6 in . Read the flat region, not the
argmax. The domain term is taken over the convex hull the area is
measured on, so rotating the layer does not move it. NA
without a usable variogram, and when the variogram's range was
rejected (see sac).
cp and reliability answer different questions: how well the
cells represent the field, and whether the cell values are distinguishable
from noise. They can disagree, and both are shown so the choice between
them is made knowingly.
Cressie, N. (1996). Change of support and the modifiable areal unit problem. Geographical Systems, 3(2–3), 159–180.
Fay, R. E. and Herriot, R. A. (1979). Estimates of income for small places: an application of James-Stein procedures to census data. Journal of the American Statistical Association, 74(366), 269–277. doi:10.1080/01621459.1979.10482505
Mallows, C. L. (1973). Some comments on . Technometrics,
15(4), 661–675. doi:10.1080/00401706.1973.10489103
select_resolution() to read a level and its flat
region off the profile; determine_optimal_levels() for the
integer-vector interface; build_tessellation() and
get_voronoi_seeds(), which accept the profile or a
select_resolution() result directly as approx_n_cells
and n.
Other aggregation:
assign_features_to_polygons(),
determine_optimal_levels(),
kriging_adequacy(),
select_resolution(),
summarize_by_cell(),
summary.resolution_profile()
if (requireNamespace("gstat", quietly = TRUE)) { library(sf) # An exponential field with range parameter 200 (true effective range # 600 m) on a 1 km square, with a nugget of 0.6 on a unit sill: enough # noise for Mallows' Cp to have an interior optimum rather than descend # to the ceiling. set.seed(4) n <- 400 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 200) + diag(0.6, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) prof <- resolution_profile(pts, response_var = "z", n_levels = 12) print(prof) # one row per level; print() because only the # last value of a braced block is shown select_resolution(prof, criterion = "cp") }if (requireNamespace("gstat", quietly = TRUE)) { library(sf) # An exponential field with range parameter 200 (true effective range # 600 m) on a 1 km square, with a nugget of 0.6 on a unit sill: enough # noise for Mallows' Cp to have an interior optimum rather than descend # to the ceiling. set.seed(4) n <- 400 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 200) + diag(0.6, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) prof <- resolution_profile(pts, response_var = "z", n_levels = 12) print(prof) # one row per level; print() because only the # last value of a braced block is shown select_resolution(prof, criterion = "cp") }
The nugget variance of the variogram model behind a
estimate_sac_range() result: the semivariance at zero
separation, i.e. measurement error plus variation at scales shorter than
the first lag bin of the empirical variogram (gstat's bins are
cutoff / 15 wide, about max_dist / 30 at the default
cutoff), which can be far wider than the spacing of close pairs.
It is extrapolated to zero from that bin, not observed, and a fit that
runs into its lower bound reports exactly 0. It is carried as the
nugget attribute of every classed result, identified or rejected,
because it is the number a resolution criterion for a tessellation needs
(the short-lag variance that no cell can average away).
sac_nugget(x)sac_nugget(x)
x |
A |
A single number: the nugget in the units of the response's
variance; NA_real_ when x carries no fitted model (a bare
NA from a run that could not fit anything, a rejected result whose
fits were all singular, or an object that is not a sac_range).
estimate_sac_range, which produces the object.
Other cross-validation:
area_of_applicability(),
cv_bayes(),
cv_block_size_sweep(),
cv_gwr(),
cv_rf(),
cv_spatial(),
estimate_sac_range(),
fold_separation(),
gwr_model_selection(),
make_folds(),
select_features_forward()
if (requireNamespace("gstat", quietly = TRUE)) { library(sf) # A field with a real nugget: half a unit of white noise on a unit sill. set.seed(3) n <- 250 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 150) + diag(0.5, n))) %*% rnorm(n)) r <- estimate_sac_range(st_as_sf(xy, coords = c("x", "y"), crs = 32632), "z") print(sac_nugget(r)) # the fitted nugget variance, on the sill's scale sac_nugget(NA) # nothing fitted: NA }if (requireNamespace("gstat", quietly = TRUE)) { library(sf) # A field with a real nugget: half a unit of white noise on a unit sill. set.seed(3) n <- 250 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 150) + diag(0.5, n))) %*% rnorm(n)) r <- estimate_sac_range(st_as_sf(xy, coords = c("x", "y"), crs = 32632), "z") print(sac_nugget(r)) # the fitted nugget variance, on the sill's scale sac_nugget(NA) # nothing fitted: NA }
Selects predictors by repeatedly adding whichever candidate most improves a
cross-validated score, stopping when no candidate improves it by more than
tol.
select_features_forward( train_sf, response_var, candidate_vars, fit_fn, k = 5, method = c("block_kfold", "random_kfold"), block_size = NULL, metric = c("RMSE", "MAE", "R2"), tol = 0, max_vars = NULL, max_fits = 5000L, seed = 123, quiet = FALSE, auto_range = FALSE, select_on = c("all", "split") )select_features_forward( train_sf, response_var, candidate_vars, fit_fn, k = 5, method = c("block_kfold", "random_kfold"), block_size = NULL, metric = c("RMSE", "MAE", "R2"), tol = 0, max_vars = NULL, max_fits = 5000L, seed = 123, quiet = FALSE, auto_range = FALSE, select_on = c("all", "split") )
train_sf |
Training data ( |
response_var |
Character(1). |
candidate_vars |
Character vector of predictors to choose among. |
fit_fn |
A function |
k |
Inner fold count. Default 5. |
method |
Inner fold method. Default |
block_size |
Passed to |
metric |
Score to optimise: |
tol |
Minimum improvement required to accept a variable. Default 0,
meaning any improvement is accepted. The first variable is judged against
the null (intercept-only) model, so |
max_vars |
Optional cap on how many predictors to select. |
max_fits |
Abort if the sweep would exceed this many model fits. Default 5000. |
seed |
RNG seed. It governs both the inner fold construction and the
cross-validation itself: it is forwarded to |
quiet |
Logical; suppress this function's progress |
auto_range |
Logical. If |
select_on |
|
The inner folds must be spatial, and that is the entire point.
Nested selection is only worth doing if the inner loop is blocked the same
way the outer one is. Random inner folds inside blocked outer folds select
variables that look predictive only because nearby points leak between
train and test. The outer loop then reports honest-looking numbers for a
dishonestly chosen feature set, which is worse than not selecting at all,
because the dishonesty is now hidden behind a defensible-looking validation.
method therefore defaults to "block_kfold" and logs a loud
caution if set to "random_kfold" (a deliberate choice, so it is not
raised as an R warning).
Call this inside the fit_fn you pass to cv_spatial().
.cv_fit_one_fold() calls fit_fn(train_sf) on the training
slice only, so anything done inside it is automatically nested and
leak-free; no extra plumbing is needed. The cost grows fast: a sweep over
p candidates costs roughly p^2 / 2 * k model fits, and nesting
that inside n outer leave-one-out folds multiplies it by n.
max_fits guards against that.
A list of class "feature_selection" (so that
plot.feature_selection() draws the selection path) with
selected (the chosen predictors, in the order they were added),
score, score_holdout, history, params and
split.
score is the winning set's cross-validated metric at the
final step: the selection-internal optimum, optimistically
biased because it was chosen as the best of many (see the section above),
and NA when nothing was selected. history is a data.frame
with step, variable, score and n_pred,
holding every candidate evaluated at every step; when the null model
could be scored it also carries a step = 0 row named
"<none>" giving that baseline, so the first variable's gain can be
read off directly. Every set is scored on the same rows: those the null
model's cross-validation predicted or, when there is no null model,
those any step-1 set predicted; params$n_scored counts them (a
warning says so when that is fewer than all). n_pred is how many
rows the set's cross-validation predicted. A set that left some of the
scored rows unpredicted, because a fold failed for it, has score
NA, with a warning naming it: scored on the rows it did predict
it would be compared on fewer, usually easier, rows than its rivals. A
factor with a level found in one spatial block only is the usual case,
and cannot be selected.
score_holdout is NA unless select_on = "split", and
then the selected set's metric when fitted on the selection half
and predicted on the estimation half ( against the selection
half's mean, the out-of-sample convention); NA when nothing was
selected or the prediction failed. split is NULL or a
list with selection and estimation, integer row positions
in train_sf as passed; rows the completeness filter above dropped
are in neither.
params records metric, method, k,
tol, seed, auto_range, select_on,
n_candidates, estimated_fits and n_scored.
$score is the cross-validated metric of the winning set at
the final step: the best of every candidate set the sweep scored. That
is the number the selection optimised, and a number optimised over many
candidates is optimistically biased by construction: Cawley and Talbot
(2010) show the bias can exceed the genuine differences between the models
being compared. Quote it as the selection criterion, not as the
performance of the selected model. An honest performance estimate needs
data the selection never saw. Two ways to get one: run this function
inside the fit_fn of cv_spatial(), so the outer
folds score a model whose predictors were chosen on the inner ones alone;
or pass select_on = "split", which selects on one spatial half of
train_sf and returns the selected set's score on the other as
score_holdout.
Cawley, G. C. and Talbot, N. L. C. (2010). On over-fitting in model selection and subsequent selection bias in performance evaluation. Journal of Machine Learning Research, 11, 2079–2107. https://jmlr.org/papers/v11/cawley10a.html
Other cross-validation:
area_of_applicability(),
cv_bayes(),
cv_block_size_sweep(),
cv_gwr(),
cv_rf(),
cv_spatial(),
estimate_sac_range(),
fold_separation(),
gwr_model_selection(),
make_folds(),
sac_nugget()
if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE)) { library(sf) set.seed(1) n <- 120 pts <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n), noise = rnorm(n)), coords = c("x", "y"), crs = 32632 ) pts$resp <- 3 * pts$a + 2 * pts$b + rnorm(n, 0, 0.3) fit_fn <- function(tr, vars) fit_gwr_model(tr, "resp", vars, bandwidth = 30) sel <- select_features_forward(pts, "resp", c("a", "b", "noise"), fit_fn, k = 3, quiet = TRUE) sel$selected }if (requireNamespace("GWmodel", quietly = TRUE) && requireNamespace("sp", quietly = TRUE)) { library(sf) set.seed(1) n <- 120 pts <- st_as_sf( data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000), a = rnorm(n), b = rnorm(n), noise = rnorm(n)), coords = c("x", "y"), crs = 32632 ) pts$resp <- 3 * pts$a + 2 * pts$b + rnorm(n, 0, 0.3) fit_fn <- function(tr, vars) fit_gwr_model(tr, "resp", vars, bandwidth = 30) sel <- select_features_forward(pts, "resp", c("a", "b", "noise"), fit_fn, k = 3, quiet = TRUE) sel$selected }
Picks the level a criterion prefers, together with the flat region:
every level whose criterion value is within tol of the optimum. On
the criteria this package computes the flat region is routinely wide: the
reliability curve is flat to within 2 percent over a factor of 3–6 in the
number of cells, and on a smooth field descends to the support
ceiling. The region is the answer, and the argmin only a point in it.
When the optimum sits at an end of the levels the criterion was scored at,
the result says so and names the bound, because a bound is then doing the
choosing rather than the criterion (see
resolution_profile for what each criterion measures and how
it behaved on simulated fields).
select_resolution( profile, criterion = c("cp", "reliability", "elbow", "moran_z"), tol = 0.02 )select_resolution( profile, criterion = c("cp", "reliability", "elbow", "moran_z"), tol = 0.02 )
profile |
A |
criterion |
Which column decides: |
tol |
Width of the flat region. For |
A list of class resolution_selection with best (the
level), flat (the levels in the flat region, ascending; a set,
which can skip a rung), criterion, value (the optimum),
at_ceiling and at_floor (logical: the optimum is the last
or first of the levels this criterion was scored at, which for
moran_z starts above nine cells), edge (which bound that
is, in words: the support ceiling, the subsample's ceiling, the range
floor, the ladder's own end,
or the first or last level the criterion is computable at; NA for
an interior optimum), n_levels, values (the criterion
at every level, NA where it could not be computed) and
seeds (an sf POINT layer, with seed_id, of the centres of
the partition the profile scored at best, in the CRS it was
computed in; NULL for a profile that does not carry them). Their
Voronoi cells are the cells that were scored;
get_voronoi_seeds(method = "kmeans", n = <this>) and
voronoi_seeds_kmeans(k = <this>) return them.
Other aggregation:
assign_features_to_polygons(),
determine_optimal_levels(),
kriging_adequacy(),
resolution_profile(),
summarize_by_cell(),
summary.resolution_profile()
if (requireNamespace("gstat", quietly = TRUE)) { library(sf) # An exponential field with range parameter 200 (true effective range # 600 m) on a 1 km square, with a nugget of 0.6 on a unit sill: enough # noise for Mallows' Cp to have an interior optimum rather than descend # to the ceiling. set.seed(4) n <- 400 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 200) + diag(0.6, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) prof <- resolution_profile(pts, response_var = "z", n_levels = 12) sel <- select_resolution(prof, criterion = "cp") print(sel) # the level, and the flat region around it print(sel$flat) # every level within `tol` of the optimum print(sel$edge) # NA here: the optimum is interior # Reliability prefers coarse cells and here runs into the floor the # autocorrelation range sets, which the result says in words. rel <- select_resolution(prof, criterion = "reliability") print(rel) c(at_floor = rel$at_floor, edge = rel$edge) }if (requireNamespace("gstat", quietly = TRUE)) { library(sf) # An exponential field with range parameter 200 (true effective range # 600 m) on a 1 km square, with a nugget of 0.6 on a unit sill: enough # noise for Mallows' Cp to have an interior optimum rather than descend # to the ceiling. set.seed(4) n <- 400 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 200) + diag(0.6, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) prof <- resolution_profile(pts, response_var = "z", n_levels = 12) sel <- select_resolution(prof, criterion = "cp") print(sel) # the level, and the flat region around it print(sel$flat) # every level within `tol` of the optimum print(sel$edge) # NA here: the optimum is interior # Reliability prefers coarse cells and here runs into the floor the # autocorrelation range sets, which the result says in words. rel <- select_resolution(prof, criterion = "reliability") print(rel) c(at_floor = rel$at_floor, edge = rel$edge) }
The package writes an INFO+ trace to a session temp file (logger appender
index 1) and echoes WARN+ to the console (index 2). Both
logger::log_appender() and logger::log_threshold() default to
index = 1, so the obvious two-line recipe silences the file and
leaves the console untouched, which is the opposite of what anyone wants.
This helper names the right index.
spatialkit_quiet(quiet = TRUE)spatialkit_quiet(quiet = TRUE)
quiet |
|
These are log records, not R conditions: suppressWarnings()
and tryCatch(warning = ) do not see them. Conditions the package
raises as real R warnings are unaffected by this function.
While a document is being knitted (R Markdown, Quarto, a pkgdown
article) the console echo is also sent as an R message, because
knitr does not capture what is written to the console's error stream
and the cautions would otherwise be missing from the output. They appear
as ## WARN [...] lines, and the chunk option message = FALSE,
like suppressMessages(), keeps them out of the document. A line the
package also raises as an R warning is not repeated, since the document
shows the warning. Outside knitr nothing changes.
Invisibly, the threshold that was in force before the change, a
logger level that can be passed back as quiet.
Other package options and caches:
clear_fitted_cache(),
clear_grid_cache(),
create_grid_polygons_cached()
old <- spatialkit_quiet() # console echo off spatialkit_quiet(old) # back to whatever it was spatialkit_quiet(FALSE) # or back to the WARN+ defaultold <- spatialkit_quiet() # console echo off spatialkit_quiet(old) # back to whatever it was spatialkit_quiet(FALSE) # or back to the WARN+ default
Aggregates an sf point dataset into one row per cell. By default computes counts and means, but the aggregation function is configurable.
summarize_by_cell( assigned_points_sf, response_var = NULL, predictor_vars = NULL, id_col = "poly_id", agg_funs = list(mean = function(x) mean(x, na.rm = TRUE)), cells_sf = NULL, deff = 1, sac = NULL, deff_max_n = 500L, quiet = TRUE, conf_level = NULL, area = FALSE )summarize_by_cell( assigned_points_sf, response_var = NULL, predictor_vars = NULL, id_col = "poly_id", agg_funs = list(mean = function(x) mean(x, na.rm = TRUE)), cells_sf = NULL, deff = 1, sac = NULL, deff_max_n = 500L, quiet = TRUE, conf_level = NULL, area = FALSE )
assigned_points_sf |
An sf object with a cell identifier column. |
response_var |
Optional response column name for per-cell aggregation: a single character string. A name that is not a column, or a column that is not numeric, is skipped with a warning. |
predictor_vars |
Optional predictor column names for per-cell aggregation. Names that are not columns, and columns that are not numeric, are skipped with a warning. |
id_col |
Preferred name of the polygon/cell ID column. |
agg_funs |
Named list of aggregation functions. Default
|
cells_sf |
Optional polygon sf layer to join cell geometries onto
the output. When supplied, the return value is an sf object with
the polygon geometry from cells_sf, with one row per cell in |
deff |
Design-effect adjustment for standard errors. One of:
|
sac |
Optional |
deff_max_n |
Cells with more than this many points are subsampled
before forming the |
quiet |
Logical; suppress this function's progress |
conf_level |
Optional confidence level in (0, 1), such as |
area |
Logical, default |
This is the third step of the package's pipeline, taking the labelled layer
from assign_features_to_polygons() down to cell level. What distinguishes
it from a plain dplyr::group_by() + summarise() is that it carries the
uncertainty of each aggregate with it: alongside every mean it returns a
within-cell standard deviation, a standard error and an observation count,
and it can correct that standard error for within-cell spatial
autocorrelation via deff. Reach for it whenever the cell-level values will
be modelled or mapped, because a cell mean over 2 observations and one over
200 are not the same measurement and nothing downstream can tell them apart
otherwise.
In addition to user-specified aggregation functions, this function always
computes within-cell standard deviation (..sd_<var>) and standard error
(..se_<var>) for every numeric response/predictor column, plus an n
column (rows falling in the cell) and a cell_weight column.
These columns let downstream models account for the fact that a cell with
2 observations carries more aggregation uncertainty than one with 200.
cell_weight is the effective sample size of the primary variable: the
response when one was supplied, otherwise the first predictor. It counts
that variable's non-missing rows, not all rows (a cell of 10 rows with 3
finite responses carries 3 observations' worth of information about the
response, not 10), and it is divided by that cell's design effect when
deff applied one. With deff = 1 and no missing values it equals n.
Pass it as the weights argument of a downstream regression.
A tibble/data.frame (or sf if cells_sf given) with per-cell
summaries: the ID column, n (rows in the cell), one column per
agg_funs entry per variable, ..sd_* / ..se_* for every numeric
response and predictor, ..neff_* / ..df_* / ..ci_lo_* /
..ci_hi_* for the same columns when conf_level is given,
cell_weight, deff_applied when a design effect was requested (any
deff other than 1), and cell_area / n_per_area when
area = TRUE. An input column also called n is not allowed to shadow
the count.
deff_applied is TRUE on every row when the requested correction was
applied (exactly when the "deff_applied" attribute below is attached;
under "kish", when any standard-error column was corrected)
and FALSE when it fell back to the uncorrected standard errors: a
refused deff, a "variogram" request with no usable model, or
"kish" ICCs of 0 for every variable type (and NA on a cells_sf
row no point fell in).
Unlike the attribute it survives rbind() and
dplyr::bind_rows() of many results. A fallback is also signalled by a
warning of class "spatialkit_deff_fallback" (a Kish ICC of 0 is
reported on attr(, "icc") instead), which
tryCatch(spatialkit_deff_fallback = ) catches without matching the
message; it is raised only when the standard errors really are the
uncorrected ones.
When a correction was actually applied, an attribute "deff_applied" is
attached recording it: method plus icc_resp/icc_pred and deff
for "kish" (deff is the primary variable's per-cell design effect,
all 1 when only the predictor ICC was positive),
deff/deff_rows/rbar/crs/max_n for "variogram" (deff is
the design effect at the primary variable's non-missing count per cell,
deff_rows at the cell's row count, which is the vector the log line
summarises as a median and a max), and deff alone for a fixed number.
When cells_sf is supplied, every per-cell vector in that attribute
(deff, deff_rows and rbar alike) is realigned to the joined row
order, so deff[i] and rbar[i] still describe row i; cells with no
observations carry NA. No attribute is attached when no correction was
applied: deff = 1, a deff = "kish" request whose ICCs are all 0, or
a "variogram" request that could not be fitted. A deff = "kish" request always
records the ICCs it estimated on an attribute "icc" (resp and
pred, NA for a variable type with no numeric column), whether or not
they were positive enough to apply, so a result with no "deff_applied"
still says what the ICC came out as.
The ID column keeps its input type when cells_sf's ID column and the
summarised IDs already have the same class. When the classes differ, both
are coerced to character in order to join (logged as a warning), and the
returned ID column is therefore character. Whole numbers are written out
in full for that ("100000", never "1e+05"), so an integer and a
double ID of the same cell still match.
By default (deff = 1), the ..se_* columns are computed as
sd / sqrt(n), which treats the observations within each cell as
independent. When data are spatially autocorrelated (the common case for the
spatial workflows this package supports), within-cell observations are
typically positively correlated: they share the cell's departure from the
population mean, so as an estimate of the population (grand) mean a cell
mean has an effective sample size smaller than n. For that estimand the
naive SE is anticonservative (too small), and a downstream weighted
regression that uses cell_weight or the ..se_* columns for population-level
inference will produce overconfident standard errors for cells with strong
intra-cell correlation. For the cell's own mean the naive SE is the right
one when the cell's points are spread through it, and the corrected SE is too
wide; see "What the standard error estimates" before setting deff.
Setting deff = "kish" applies an approximate correction using Kish's
design effect. Separate intra-class correlations (ICCs) are estimated for
response and predictor variables via a one-way random-effects decomposition
across all cells. Each variable type's ICC is used for its own SE
adjustment, and each cell's effective sample size is reduced to
n_i / (1 + (n_i - 1) * rho). This is a first-order correction that
does not require a full spatial covariance model but does require enough
cells and observations for a stable ICC estimate.
A design effect estimated from the data ("kish" or "variogram") comes
with a second, small-sample correction. The within-cell correlation that
inflates the variance of the mean to sigma^2 * deff / n also biases the
within-cell sample variance downward: under exchangeable correlation rho
(Kish's own assumption), with deff = 1 + (n - 1) * rho,
E[s^2] = sigma^2 * (n - deff) / (n - 1), so s^2 understates sigma^2
by very nearly the factor by which deff inflates the mean's variance, and
the two errors compound rather than cancel. The standard error is therefore
s * sqrt(deff / n) * sqrt((n - 1) / (n - deff)), and NA where
deff >= n (the cell then holds one observation's worth of information
and s carries none about sigma). This is the package's own derivation,
not taken from a reference. Measured 95% interval coverage at n = 30
over 20,000 replicates: 0.921, 0.844 and 0.628 at rho = 0.2, 0.5 and 0.8
with s * sqrt(deff / n) alone, against 0.948, 0.950 and 0.949 with the
rescaling.
You may also pass a fixed numeric design effect (e.g. deff = 2) to
uniformly inflate standard errors: an externally supplied constant is
applied as sd * sqrt(deff / n), exactly sqrt(deff) times the naive SE in
every cell. (The E[s^2] correction that the estimated design effects also
apply is derived from within-cell correlation and would not be justified for
a number the caller chose.)
Even with the Kish correction, the adjusted SE is an approximation.
For rigorous inference under spatial dependence, consider fitting an
explicit spatial covariance model (e.g. via fit_bayesian_spatial_model).
The ..se_* columns are the standard error of the cell mean as an
estimate of the population (grand) mean: the unconditional quantity, in
which the cell's own realised deviation is part of the error. That is the
right quantity when cells are treated as samples from a common population,
and the design-effect correction is calibrated for it: measured 95% interval
coverage of the grand mean is 0.95 with deff = "kish" (and 0.29 with the
naive SE) on exchangeable within-cell correlation, and 0.93 with
deff = "variogram" on a simulated Gaussian field.
It is not the standard error of the cell's own mean (the block average
over that cell), which is what a cell-level map or a regression on cell
values usually wants. For that quantity the naive sd / sqrt(n) is the
better of the two on offer: measured coverage 0.95 under exchangeable
within-cell correlation, against very nearly 1.00 for the
design-effect-corrected SE, which is too wide by the factor
sqrt(deff / (1 - rho)) (4.6 at 20 points a cell and rho = 0.5). That 0.95
is exact under the exchangeable model and holds under a spatial covariance
model only when the cell's points are spread through the cell; with
clustered sampling inside a cell it is anticonservative for the block
average too (measured 0.58), and the right quantity there is a
block-kriging variance, which this function does not compute. Use deff
when the cell means feed a population-level inference; leave it at 1 when
they are measurements of the cells themselves and the sampling within cells
is reasonably uniform.
deff = "kish" estimates a separate ICC for response and predictor
variables and applies each to its own columns. deff = "variogram" fits or
accepts one correlation function and applies it to every numeric column,
because a variogram is a property of the field being modelled rather than of
a variable type; a predictor whose spatial structure differs markedly from
the response's will have its SE corrected by the response's correlation.
The internally estimated variogram is fitted to the response itself,
never to OLS residuals, whatever predictor_vars holds: the ..se_resp_*
columns estimate the grand mean of the response, so the correlation to
correct for is the response's own. (A residual variogram, whose correlation
is that of the part the predictors do not explain, is weaker; using it here
dropped grand-mean coverage from 0.93 to 0.51 the moment a predictor was
listed.) Pass sac explicitly when you want a different variogram, such as
a residual one from estimate_sac_range(..., predictor_vars = ). A sac
whose attr(sac, "detrended") is TRUE is used as given, but when it
corrects response columns a warning says that their standard errors are
understated, as kriging_adequacy() warns about the same mismatch.
With conf_level set, every numeric response and predictor column gains
four more columns: ..neff_*, that column's effective sample size in the
cell (its non-missing count over its design effect, the per-column version
of cell_weight); ..df_*, the degrees of freedom the interval uses; and
..ci_lo_* / ..ci_hi_*, a t interval for the cell mean as an estimate
of the grand mean. The estimand is the same as ..se_*'s, so everything
in "What the standard error estimates" applies to it, including that it is
not an interval for the cell's own block average. The interval is
mean +/- qt((1 + conf_level) / 2, df) * se, centred on the plain mean of
the column's non-missing values whatever agg_funs computes, and is NA
wherever the standard error is (a single observation; complete redundancy
under deff). ..neff_* and ..df_* are NA where the column has one
non-missing value or none, like the standard error; cell_weight still
counts such a cell (1, or 1 / deff for a numeric deff), so the two
differ there.
The degrees of freedom are not neff - 1. The interval's spread comes
from the within-cell sample variance, and under exchangeable correlation
(deff = "kish") that variance keeps its n - 1 degrees of freedom
whatever the design effect. The design effect inflates the mean's
variance and biases s^2, both of which the standard error already
corrects, and the resulting pivot is exactly t on n - 1 df. Measured 95%
coverage of the grand mean on the Kish path, 20 cells of 20 at an ICC of
0.2 / 0.6 / 0.9: 0.954 / 0.953 / 0.952 with n - 1, against
0.992 / 1.000 / 1.000 with neff - 1, which is not an interval so much as
a refusal to say anything. The effective-sample-size degrees of freedom of
Faes et al. (2009) belong to a mean estimated across many correlated units
whose variance is estimated from the spread between them; they do not
transfer to a single cell's mean with a variance estimated from inside it.
..df_* is therefore n - 1 at deff = 1, for a numeric deff and for
"kish". Under deff = "variogram" correlation decays with distance and
the within-cell variance loses degrees of freedom to it; ..df_* is then
the Satterthwaite (1946) moment-matched df of s^2 under the fitted
correlation matrix, a fraction of n - 1 that shrinks as the range grows.
Measured on a Gaussian field with an exponential range of 150 and 400 on a
1000-unit domain, 16 cells of about 25 points: 0.960 and 0.958 with that
df, against 0.931 and 0.918 with n - 1, and 0.34 and 0.19 for the naive
deff = 1 interval. The interval is only as good as the design effect
under it: a mis-specified variogram, or an ICC estimated from too few
cells, moves the coverage with it.
Faes, C., Molenberghs, G., Aerts, M., Verbeke, G. and Kenward, M. G. (2009). The effective sample size and an alternative small-sample degrees-of-freedom method. The American Statistician, 63(4), 389–399. doi:10.1198/tast.2009.08196
Satterthwaite, F. E. (1946). An approximate distribution of estimates of variance components. Biometrics Bulletin, 2(6), 110–114. doi:10.2307/3002019
assign_features_to_polygons(), which produces the input layer;
build_tessellation() for the cells themselves.
Other aggregation:
assign_features_to_polygons(),
determine_optimal_levels(),
kriging_adequacy(),
resolution_profile(),
select_resolution(),
summary.resolution_profile()
library(sf) set.seed(1) n <- 200 east <- runif(n, 0, 100) north <- runif(n, 0, 100) # A response with spatial structure, so the within-cell ICC is not zero. pts <- st_as_sf( data.frame(x = 5e5 + east, y = 5e6 + north, val = 5 + 0.02 * east + 0.02 * north + rnorm(n, sd = 0.5)), coords = c("x", "y"), crs = 32632 ) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(5e5, 5e6), c(5e5 + 100, 5e6), c(5e5 + 100, 5e6 + 100), c(5e5, 5e6 + 100), c(5e5, 5e6) ))), crs = 32632)) grid <- create_grid_polygons(bnd, target_cells = 9, type = "square") assigned <- assign_features_to_polygons(pts, grid) # IID standard errors (default) vs Kish design-effect adjustment. The # correction is large here, and meant to be: about 22 points per cell that # all share the cell's part of the trend carry far fewer than 22 # independent pieces of information about the cell mean. naive <- summarize_by_cell(assigned, response_var = "val") kish <- summarize_by_cell(assigned, response_var = "val", deff = "kish") data.frame(n = naive$n, se_naive = naive$..se_resp_val, se_kish = kish$..se_resp_val) attr(kish, "deff_applied") # method, icc_resp, icc_pred, per-cell deff # A 95% interval for each cell mean as an estimate of the grand mean, on # the Kish-corrected standard error and n - 1 degrees of freedom. ci <- summarize_by_cell(assigned, response_var = "val", deff = "kish", conf_level = 0.95) ci[, c("poly_id", "n", "resp_mean_val", "..neff_resp_val", "..df_resp_val", "..ci_lo_resp_val", "..ci_hi_resp_val")]library(sf) set.seed(1) n <- 200 east <- runif(n, 0, 100) north <- runif(n, 0, 100) # A response with spatial structure, so the within-cell ICC is not zero. pts <- st_as_sf( data.frame(x = 5e5 + east, y = 5e6 + north, val = 5 + 0.02 * east + 0.02 * north + rnorm(n, sd = 0.5)), coords = c("x", "y"), crs = 32632 ) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(5e5, 5e6), c(5e5 + 100, 5e6), c(5e5 + 100, 5e6 + 100), c(5e5, 5e6 + 100), c(5e5, 5e6) ))), crs = 32632)) grid <- create_grid_polygons(bnd, target_cells = 9, type = "square") assigned <- assign_features_to_polygons(pts, grid) # IID standard errors (default) vs Kish design-effect adjustment. The # correction is large here, and meant to be: about 22 points per cell that # all share the cell's part of the trend carry far fewer than 22 # independent pieces of information about the cell mean. naive <- summarize_by_cell(assigned, response_var = "val") kish <- summarize_by_cell(assigned, response_var = "val", deff = "kish") data.frame(n = naive$n, se_naive = naive$..se_resp_val, se_kish = kish$..se_resp_val) attr(kish, "deff_applied") # method, icc_resp, icc_pred, per-cell deff # A 95% interval for each cell mean as an estimate of the grand mean, on # the Kish-corrected standard error and n - 1 degrees of freedom. ci <- summarize_by_cell(assigned, response_var = "val", deff = "kish", conf_level = 0.95) ci[, c("poly_id", "n", "resp_mean_val", "..neff_resp_val", "..df_resp_val", "..ci_lo_resp_val", "..ci_hi_resp_val")]
select_resolution() reads one criterion at a time. This puts
all of them in one table: the level each prefers, the flat region around
it, and whether a ladder bound is doing the choosing rather than the
criterion. The closing line gives the levels that lie in every flat
region, the cell counts no criterion objects to.
## S3 method for class 'resolution_profile' summary(object, criteria = NULL, tol = 0.02, ...)## S3 method for class 'resolution_profile' summary(object, criteria = NULL, tol = 0.02, ...)
object |
A |
criteria |
Character vector, any of |
tol |
Passed to |
... |
Ignored. |
A flat region is a set, not an interval. The criterion curves are not
monotone, so a region can skip a rung of the ladder, and the table prints
what the criterion actually accepts ("26, 31", not "26 to
31") rather than a range that would quietly include the levels it
rejected.
That set is often empty, and an empty one is a result rather than a
failure. The criteria answer different questions: how well the cells
represent the field (), whether the cell values are
distinguishable from noise (reliability), where the within-cluster
sum of squares bends (elbow), and whether the cell means still carry
autocorrelation (moran_z). A field with no single right resolution
shows up here as disjoint bands, and the spread between the picks is
printed for the same reason.
Nothing in the table is a decision procedure. Each flat region is
routinely wide, the choice within it belongs to the analyst, and
plot.resolution_profile() draws the curves the bands were
read from.
A data.frame of class resolution_summary, one row per
criterion in the order given, with columns criterion,
best, flat_min, flat_max, n_flat,
value (the optimum itself, on that criterion's own scale and so
not comparable across rows, which is why the print method leaves it out),
at_floor, at_ceiling and edge (which bound an
edge optimum sits on, as select_resolution() reports it;
NA when interior). Attributes: bands (a named list holding each
criterion's flat region in full, since flat_min and
flat_max are only its ends and the region can have holes in it),
common (the levels in every flat region, an integer vector that is
empty when the regions do not overlap), scored (the levels each
criterion returned a finite value at, which is what decides whether a gap
in a band is a rejection or a level nothing was computed at),
ladder (every level on the profile), n_levels, tol
and variable (what the criteria were scored on). The print method recomputes the closing
comparison from bands, so a row subset of the result reads
honestly.
select_resolution() for one criterion, with the
per-level values attached; plot.resolution_profile() for
the curves behind these bands.
Other aggregation:
assign_features_to_polygons(),
determine_optimal_levels(),
kriging_adequacy(),
resolution_profile(),
select_resolution(),
summarize_by_cell()
if (requireNamespace("gstat", quietly = TRUE)) { library(sf) # An exponential field with range parameter 200 (true effective range # 600 m) on a 1 km square, with a nugget of 0.6 on a unit sill: enough # noise for Mallows' Cp to have an interior optimum rather than descend # to the ceiling. set.seed(4) n <- 400 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 200) + diag(0.6, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) prof <- resolution_profile(pts, response_var = "z", n_levels = 12) # print() is explicit because only the last value of a braced block is # shown, and the table is the thing worth seeing here. print(summary(prof)) print(attr(summary(prof), "common")) # the levels all of them accept, if any attr(summary(prof), "bands") # each criterion's region in full }if (requireNamespace("gstat", quietly = TRUE)) { library(sf) # An exponential field with range parameter 200 (true effective range # 600 m) on a 1 km square, with a nugget of 0.6 on a unit sill: enough # noise for Mallows' Cp to have an interior optimum rather than descend # to the ceiling. set.seed(4) n <- 400 xy <- data.frame(x = 5e5 + runif(n, 0, 1000), y = 5e6 + runif(n, 0, 1000)) D <- as.matrix(dist(xy)) xy$z <- as.numeric(t(chol(exp(-D / 200) + diag(0.6, n))) %*% rnorm(n)) pts <- st_as_sf(xy, coords = c("x", "y"), crs = 32632) prof <- resolution_profile(pts, response_var = "z", n_levels = 12) # print() is explicit because only the last value of a braced block is # shown, and the table is the thing worth seeing here. print(summary(prof)) print(attr(summary(prof), "common")) # the levels all of them accept, if any attr(summary(prof), "bands") # each criterion's region in full }
Computes goodness-of-fit metrics from fitted(object) against the
observed response. A non-numeric response is an error: a character or
factor response cannot be scored, and used to come back as n = 0 with
every metric NA; a logical response is treated as 0/1.
## S3 method for class 'spatial_fit' summary(object, ...)## S3 method for class 'spatial_fit' summary(object, ...)
object |
A |
... |
Ignored. |
An object of class summary.spatial_fit: a list with
class, formula, n, response_var,
predictor_vars, info and in_sample (the metric
data.frame, out-of-bag for an rf_fit).
For a gwr_fit or a bayesian_fit these are in-sample
metrics: fitted() returns values computed at the training locations
from the model that saw them. For an rf_fit they are
out-of-bag, because fitted.rf_fit() returns out-of-bag
predictions in place of in-sample ones (see fit_rf_model).
The two are not comparable, and print() on the result labels which
one it is holding, driven by $info$fitted_are_oob. Use
compare_models_cv to compare backends.
Adjusted R-squared is suppressed: GWR's effective parameter count far
exceeds the global predictor count, and a GP model has no simple p.
MAPE divides by the observed value and SMAPE by
, so neither is defined where its denominator is zero.
Neither returns Inf or NaN. Both are averaged over the rows
whose denominator is non-zero, and are NA when no row qualifies.
Non-zero is judged at the scale of the data: a denominator no larger
than 100 machine epsilons times the largest one counts as zero, so the
rule does not depend on the units of the response.
The n_MAPE and n_SMAPE columns record how many rows that was;
the n column counts finite observation/prediction pairs. Read a
percentage error next to its count: when n_MAPE < n, MAPE is
an average over a subset of the data, whatever its value.
This bites on any response taking exact zeros: counts, rainfall,
abundance, claim amounts. On a zero-inflated response with 62 zeros out of
120, MAPE is an average over the 58 non-zero rows, which
n_MAPE = 58 now says. SMAPE fails differently and more
subtly: it drops the rows where observation and prediction are both near
zero (which on a well-fitted zero-inflated model are the rows it got
right), so it averages the harder rows only and reads worse than
the fit deserves; n_SMAPE shows how many rows it kept, and the
count is only a label, not a repair.
RMSE, MAE and use every finite row and are
unaffected; prefer them whenever the response can be zero. For a Bayesian
fit, cv_bayes() additionally reports CRPS and interval
coverage, which are proper scoring rules and have no such failure mode.
Other methods on a fitted model:
coef.bayesian_fit(),
coef.gwr_fit(),
coef.rf_fit(),
fitted.bayesian_fit(),
fitted.gwr_fit(),
fitted.rf_fit(),
model_metrics(),
predict.bayesian_fit(),
predict.gwr_fit(),
predict.rf_fit(),
print.rf_fit(),
print.spatial_fit(),
residuals.bayesian_fit(),
residuals.gwr_fit(),
residuals.rf_fit()
Places k seed points at k-means cluster centres of the observed
coordinates, so seeds (and the Voronoi cells built from them) follow the
sampling density: clusters of observations attract seeds, empty ground gets
none. Reach for this when you want cells that follow the data, so that
counts per cell vary far less than on a fixed grid over clustered points
(k-means does not equalise them, it minimises the spread of points around
each centre), which is what keeps per-cell aggregates in
summarize_by_cell() from resting on one or two observations. Use
voronoi_seeds_random() instead when you want coverage of the study area
rather than of the data, and get_voronoi_seeds() to pick between them by
name.
voronoi_seeds_kmeans(points_sf, k, set_seed = 456, nstart = 10)voronoi_seeds_kmeans(points_sf, k, set_seed = 456, nstart = 10)
points_sf |
An sf object with POINT geometries. |
k |
Integer; requested number of clusters, treated as an upper bound
only. It is clamped, with a warning, to whichever is smaller of the number
of distinct point positions and |
set_seed |
Optional integer RNG seed. Default 456, so a call gives the
same seeds every time whatever the session's random-number state; an
outer |
nstart |
Number of random starts for |
Lon/lat input is projected first so the k-means distances are metric and not
degrees; so is input with no CRS whose coordinates look like lon/lat (the
heuristic ensure_projected() applies, with its warning), and the seeds
come back in the input's own coordinates. Rows with empty or non-finite
coordinates are dropped with a warning, and k is clamped to the number of
distinct positions.
The partition is stats::kmeans() (Hartigan-Wong) with nstart random
starts. resolution_profile() and determine_optimal_levels() score each
count on a different run, by default the best of 25 k-means++ restarts,
which usually reaches a lower within-cluster sum of squares; the seeds for
a count passed as a number are therefore not the partition that count was
scored on. Raising nstart narrows the gap but does not close it. Pass
the select_resolution() result or the profile itself as k to close
it: the centres of the scored partition are then returned (in the CRS of
points_sf, with attr(, "n_from")), and no k-means is run.
An sf object of at most k cluster-centre POINTs (fewer when
k exceeds the number of distinct positions), with seed_id and
method = "kmeans" columns matching get_voronoi_seeds().
Other tessellation:
build_tessellation(),
create_grid_polygons(),
create_grid_polygons_cached(),
create_voronoi_polygons(),
ensure_stable_poly_id(),
get_voronoi_seeds(),
plot_tessellation_map(),
voronoi_seeds_random()
library(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(100, 0, 1000), y = 5e6 + runif(100, 0, 1000)), coords = c("x", "y"), crs = 32632 ) seeds <- voronoi_seeds_kmeans(pts, k = 8) nrow(seeds) # at most 8: one seed per non-empty cluster seeds # the cluster centres, as an sf POINT layer in the points' CRSlibrary(sf) set.seed(1) pts <- st_as_sf( data.frame(x = 5e5 + runif(100, 0, 1000), y = 5e6 + runif(100, 0, 1000)), coords = c("x", "y"), crs = 32632 ) seeds <- voronoi_seeds_kmeans(pts, k = 8) nrow(seeds) # at most 8: one seed per non-empty cluster seeds # the cluster centres, as an sf POINT layer in the points' CRS
Draws k seed points uniformly at random inside boundary, ignoring where
the observations are. Reach for this when the cells should cover the study
area evenly (so that sparsely sampled ground still gets its own cells and
is visibly under-sampled in the results) instead of concentrating
resolution where the data already are, which is what
voronoi_seeds_kmeans() does. It is also the honest choice for a null or
sensitivity comparison: re-running an analysis over several random seedings
shows how much of a result depends on one particular tessellation.
voronoi_seeds_random(boundary, k, set_seed = NULL)voronoi_seeds_random(boundary, k, set_seed = NULL)
boundary |
An sf or sfc polygonal object. |
k |
Integer; number of random seeds. |
set_seed |
Optional integer RNG seed. Default |
Seeds are drawn uniformly inside the polygon. When a draw falls short of k
it is topped up with further uniform draws from the same polygon, so the
result has exactly k seeds; only a geometry that still yields too few
after ten top-ups returns fewer, and that shortfall is logged.
An sf object of k random POINTs (fewer only in the degenerate
case above), with seed_id and method = "random" columns matching
get_voronoi_seeds().
Other tessellation:
build_tessellation(),
create_grid_polygons(),
create_grid_polygons_cached(),
create_voronoi_polygons(),
ensure_stable_poly_id(),
get_voronoi_seeds(),
plot_tessellation_map(),
voronoi_seeds_kmeans()
library(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) set.seed(1) seeds <- voronoi_seeds_random(bnd, k = 10) nrow(seeds) # 10 seeds # Another call is another seeding; set_seed pins one. identical(st_coordinates(voronoi_seeds_random(bnd, k = 10, set_seed = 7)), st_coordinates(voronoi_seeds_random(bnd, k = 10, set_seed = 7)))library(sf) bnd <- st_sf(geometry = st_sfc(st_polygon(list(rbind( c(0, 0), c(100, 0), c(100, 100), c(0, 100), c(0, 0) ))), crs = 32632)) set.seed(1) seeds <- voronoi_seeds_random(bnd, k = 10) nrow(seeds) # 10 seeds # Another call is another seeding; set_seed pins one. identical(st_coordinates(voronoi_seeds_random(bnd, k = 10, set_seed = 7)), st_coordinates(voronoi_seeds_random(bnd, k = 10, set_seed = 7)))