Estimate the spatial autocorrelation range from data
Source:R/cross-validation.R
estimate_sac_range.RdFits 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.
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.
Usage
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
)Arguments
- 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
gstat::variogram()uses every coordinate dimension and an XYZ layer would otherwise return a range in 3-D while every consumer of it works in 2-D map distance; and rows with empty or non-finite coordinates are dropped with a logged count.- 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
detrend, and it matters: see "Detrending and the residual-variogram bias". Rows whose response or predictor is missing or infinite are left out of that fit, and so of the variogram, with a logged count.- 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
range_frac * cutoff * max_dist(that is, beyond the longest lag the empirical variogram was actually fitted over) is treated as unidentified andNA_real_is returned.gstat::fit.variogram()yields a finite number even when the variogram never reaches a sill, and such a value extrapolates past the observed lags instead of measuring a long autocorrelation range. Passing it tomake_folds(auto_range = TRUE)would collapse the block grid to a single block. Default 1.0; raise it to accept ranges extrapolated beyond the fitted lags. The bound does not guarantee room for two blocks: at the defaults it is half the farthest-pair distance, about 0.71 of the side of a square layer, and a block grid needs a range below half the width of the bounding box in one direction or the other. An accepted range between the two leavesmake_folds(auto_range = TRUE)room for a single block of that size (see itsauto_rangeargument for what it does then). On a 1000 m square with an exponential field of effective range 570 (n = 300), 9 of 30 draws were accepted in that band. Loweringrange_fracto fit the grid would turn those estimates intoNAand the blocks into geometric ones smaller than the range.- seed
RNG seed for the
n_maxsubsample, restored afterwards so the caller's random stream is untouched. Default123L: the subsample is an internal approximation and no part of the answer, and leaving it unseeded made the returned range differ between runs on identical input (19531, 19589, 19605 on three calls) and silently advanced the caller's RNG. PassNULLfor the old unseeded behaviour, or a different number to check how sensitive the estimate is to the subsample. Ignored whennrow(points_sf) <= n_max, where nothing is sampled. Thereml_max_nsubsample uses the same seed.- detrend
How the trend on
predictor_varsis removed; ignored when there are none."ols"(default) fits it by ordinary least squares and fits the variogram to the residuals. This is the long-standing behaviour, which underestimates the range (see the section below)."reml"fits the trend and an exponential-plus-nugget covariance together by residual maximum likelihood withnlme::gls(), returns the REML range, and attaches the empirical variogram of the REML residuals for inspection. It needs nlme, costs \(O(n^3)\) (about 2 s at 300 points, 12 s at 500, 45 s at 800), and so runs on at mostreml_max_npoints; when it does not converge the"ols"path runs instead with an R warning saying so.- reml_max_n
Positive integer, at least 30. With
detrend = "reml", the trend and covariance are fitted on a seeded random subsample of this many points when the layer has more (exact duplicate locations are dropped first); the fitted trend is then removed from every point. Default 400. Raise it for a better-determined fit at the cost above.- keep_directional_fits
Logical. Attach each direction's empirical variogram and fitted model as
directional_fits? Defaults toFALSE: the four variograms are most of the object's size (42.1 KB of 59.3 KB at \(n = 400\), and the difference between a 52.4 KB and a 94.6 KBmake_folds(auto_range = TRUE)result), while the numbers read from them (directional,directional_fitted,directional_status,anisotropy) are attached either way, andplot()draws the effective variogram from its own attribute. SetTRUEto inspect the directional curves.
Value
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:
- Success
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;NAwhere that direction's fit was unusable),anisotropy(largest over smallest),anisotropy_used(logical:TRUEonly 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 isNAindirectional:"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) anddirectional_fits(a list by azimuth of each direction's empiricalvariogramand fittedmodel,NULLwhere there is none, andNULLaltogether unlesskeep_directional_fits = TRUE),detrended(logical: whether the variogram is of the residuals onpredictor_varsor 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 toFALSE),detrend_method("ols"or"reml"when detrended,NAotherwise),reml(withdetrend = "reml": a list withn_used,subsampled,nugget_propandsigma2from the REML fit;NULLotherwise),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 fittedgstatmodel, or withdetrend = "reml"agstatmodel built from the REML parameters) andnugget(that model's nugget variance; seesac_nugget), so the fit can be inspected and need not be taken on trust.- Rejected range
NA_real_when a range was fitted but is not identified: it exceedsrange_frac * cutoff * max_dist(seerange_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, withdetrend = "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 whatpredictor_varsis for. Last, the fitted range can be non-positive. It is classedsac_rangeas well, so it prints asNAwithout dumping its attributes, and it carriesmax_dist,cutoff_dist,variogram,variogram_modelandnugget(the evidence for the rejection), plusrejected_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 whatplot()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). It carriesdirectional,anisotropy,anisotropy_used,directional_status,directional_fittedand, withkeep_directional_fits = TRUE,directional_fitsas 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, withrejected_range = NA,variogram_model = NULLandnugget = 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_reasonsays so and the empirical variogram is still attached.- No fit
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: novariogram,variogram_modelorrejected_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.
Details
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.
Detrending and the residual-variogram bias
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.
Count and other non-Gaussian responses
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.
References
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
See also
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()
Examples
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")
}
#> 304.4623
#> directional: 0 deg = 267.5557, 45 deg = 439.1852, 90 deg = 370.9347, 135 deg = 328.2169 (ratio 1.64)
#> in metres of EPSG:32632; variogram of the response itself
#> 0 45 90 135
#> 267.5557 439.1852 370.9347 328.2169
#> model psill range
#> 1 Nug 0.000000 0.0000
#> 2 Exp 1.286699 101.4874
#> NA
#> directional: 0 deg = 13190.44 (not converged), 45 deg = 42407.17 (not converged), 90 deg = 114115.2 (not converged), 135 deg = 45807.9 (not converged)
#> in metres of EPSG:32632; variogram of the response itself
#> [1] 47261.27