Spatial Tessellation, Modeling, and Cross-Validation Toolkit
Overview
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.
Everything below is relative to **1.0.0** (published on CRAN 2026-08-07).
The major bump is warranted: three exported functions are removed, and several
defaults change the result of a fit or a comparison, so the same script can get
a different answer. Both are under "Breaking changes" — read that section
before upgrading a running analysis.
These notes describe what changed **for a user of 1.0.0**. A good deal of this
release was written after 1.0.0 and then revised before shipping; defects that
existed only between those points are not listed, since no released version
behaved that way. The commit history has that record in full.
Throughout, *raises a warning* means a genuine R warning() — one
tryCatch(warning = ) catches, suppressWarnings() suppresses and
options(warn = 2) escalates. *Logs a warning* means a logger message in the
Show all changes in 2.0.0Hide the rest of the changes in 2.0.0
"spatialkit" namespace, which none of those touch.
Breaking changes
Statistical corrections that change results (third audit pass)
Each item below was measured, and the measurement is quoted so you can judge
whether it affects an analysis you have already run.
- **
residual_morans_i()no longer puts weight on a point's own residual.**FNN::get.knn()reports a point's OWN index among its neighbours whenever exact duplicate coordinates are present, which put1/kon the diagonal of a matrix Moran's I is only defined for with a zero diagonal. On 40 sites x 4 repeats with a response carrying **no** spatial structure, 120 of 160 rows gained a self-weight, mean I came out at +0.086 against E[I] = -0.0063, and **77% of samples were "significant" at p < 0.05** against a nominal 5%. Repeat observations at one site are exactly whatmake_folds(method = "leave_location_out")is for, so this was a mainstream input. The dense fallback never had the fault, so the statistic also depended silently on whether **FNN** happened to be installed; the two paths now share one neighbour lookup and agree exactly. Requestingk + 1neighbours and dropping self is **not** sufficient on its own — the slot self occupied displaced a genuine co-located neighbour and left a farther point standing in for it (75 of 400 retained pairs sat at distance 121 where a neighbour at distance 0 existed). Duplicate coordinates are now grouped and answered exactly. - **
residual_morans_i()gains anullargument, defaulting to"auto".** Model residuals are not exchangeable — they are orthogonal to the design matrix — so the classical randomisation moments are wrong for them. At n = 120 with six smooth covariates and independent errors, OLS residuals had mean I = -0.031 against the exchangeable E[I] = -0.008, and the z-score averaged -0.54 with sd 0.90 instead of 0 and 1. The Cliff & Ord (1981, sec. 8.3) regression-residual moments restore mean z = -0.09, sd 1.03 and a 4.3% rejection rate against a nominal 5%, and **agree withspdep::lm.morantest()to machine precision** (verified at 1e-16 through the public function)."auto"applies them only when the fit's residuals really are the OLS residuals on the rebuilt design, which a forest's and a working GWR's are not; the null actually used is reported in the return value. - **
summarize_by_cell()standard errors under a design effect were too small.**s / sqrt(n / deff)corrects the mean's variance for clustering but leavess^2biased low by the same clustering: for exchangeable correlation rho,E[s^2] = sigma^2 (n - deff)/(n - 1). The two errors compound. Measured 95% CI coverage at n = 20: **0.905 at rho = 0.3, 0.796 at rho = 0.6, 0.632 at rho = 0.8**; after rescaling bysqrt((n-1)/(n-deff)), 0.952 / 0.952 / 0.953. Applies todeff = "kish",deff = "variogram"and a fixed numericdeff. **The defaultdeff = 1path is bit-identical to before.** - **
determine_optimal_levels()ranks model-aware candidates on the standardised deviate, not on |Moran's I|.** E[I] and Var(I) both depend on the cell count, so |I| shrinks as k grows whether or not the finer tessellation captures anything. Over 300 replicates of a response with **no** spatial structure, mean |I| fell monotonically from 0.114 at k = 10 to 0.050 at k = 60 — an |I| ranking prefers the largest candidate for arithmetic reasons alone. Candidates are now ordered by |z| using the Cliff & Ord residual moments (exact here, since the cell-level residuals are OLS residuals by construction); over the same runs z had mean ~0, sd ~1 and a 5% rejection rate of 0.040-0.057 at every k. The"diagnostics"attribute now carriesmoran_zalongsidemoran_i. - **
estimate_sac_range()sweeps four azimuths, not two.** A +/-22.5 degree window around 0 and 90 covers exactly **90 of the 180 distinct azimuths** — every direction between 23 and 67 degrees, and between 113 and 157, fell into neither. On simulated fields with 3:1 anisotropy and a true major-axis range of 300, the estimate came back at 255 and 249 for major axes at 0 and 90 degrees but **151 and 147 at 45 and 135**. Sincemake_folds(auto_range = TRUE)sizes blocks from this number, a diagonally oriented field silently got blocks half as wide as the correlation they were meant to separate.c(0, 45, 90, 135)tiles all 180 azimuths; the same fields now return 255 / 245 / 249 / 228. A direction whose variogram never reaches a sill is excluded rather than taken as a long range, and thedirectionalattribute now has four named entries. - **
fit_bayesian_spatial_model()'s calibrated length-scale prior never reached Stan.**brms::set_prior(spec, class = "lscale")with nocoefis a *global* prior, and brms applies a global prior only to coefficients with no individual prior of their own — everylscalecoefficient always has one. brms dropped it with a note and Stan received brms's defaults, which madegp_lengthscale_bounds(), the tail calibration and$info$gp_lscale_priordead weight. Confirmed withbrms::make_stancode(): the requested prior is absent under the global form and present under the coefficient-level form, which is now used.$info$gp_lscale_prioris read back frombrms::validate_prior(), so it records what brms will actually use. - **The GP basis was sized against the wrong domain measure.** brms builds the boundary as
choose_L(x, c) = c * max(1, max(x) - min(x))over the pooled, column-centred covariates — the **full range**, not the per-axis half-range in which Riutort-Mayol et al. state their inequalities. Recovering the boundary frommake_standata()'s eigenvalues confirmsL = c * full rangeexactly at everyc, so the old convention built a boundary **twice as wide** asgp_kwas sized for: the GP was under-resolved, and$info$gp_ell_min— the diagnostic meant to catch exactly that — was twice too lenient to fire. Thecfloor is now brms's own default 1.25 rather than 1.2. - **
fitted()on agwr_fitcould return a coefficient surface.** The search for GWmodel's fitted-value column matched the whole candidate name vector with%in%and took the first hit in the *SDF's* column order — and the local coefficients come first. A predictor namedfit,pred,prediction,fittedoryhattherefore returned its own coefficient column, silently: executed in-sample R^2 was **-1.18** against a true 0.981, andresiduals(),summary(),model_metrics(),compare_models()and everycv_gwr()fold consumed it without a warning. The search now runs in preference order and excludes any candidate that is also a model term; all five colliding names now give R^2 = 0.981, identical to the renamed control. - **
coef.gwr_fit()returned GWmodel's whole SDF data slot** — 15 columns for a two-predictor fit, of which 3 are coefficients and the rest are standard errors, t-values, the response, the fitted values, residuals andLocal_R2. It now returns the model terms only; reach forobject$engine$SDFfor the rest. - **
estimate_sac_range()is reproducible, and no longer disturbs the caller's RNG.**seednow defaults to123Lrather thanNULL. Then_maxsubsample is an internal approximation, not part of the answer, and leaving it unseeded made the returned range differ between runs on identical input (19531 / 19589 / 19605 on three calls) while silently advancing the caller's stream — andmake_folds(auto_range = TRUE)sizes its blocks from that number. Passseed = NULLfor the old behaviour. - **
estimate_sac_range()rejects a non-numeric response.**as.numeric()on a factor returns its level codes, so a factor response produced a variogram of an arbitrary integer relabelling of the categories and the estimated range changed when the levels were reordered (3700 against 2497 on the same data). Factors and character columns are now an error naming the column; logicals are read as 0/1. - **Fold sets built from a different dataset are refused.** Fold splits are lists of
..row_idvalues, and row IDs areseq_len(nrow())unless supplied, so passingcv_gwr()afoldsobject built from another dataset of the same size applied cleanly — every ID matched, every fold was populated, and the model was scored on splits describing other observations.make_folds()now records a small projection-invariant row fingerprint inparams$row_probe, andcv_gwr(),cv_bayes(),cv_spatial()andcv_rf()error rather than proceed. Fold objects from earlier versions carry no fingerprint and are passed through unchecked. - **
evaluate_insample()rejects duplicated names infits.**modelis the keycompare_models()joins its metric and Moran's I tables on, so two fits called"GWR"produced a 2x2 cross-join: four rows, every one carrying the first fit's numbers, with the second fit never scored at all. - **
fit_gwr_model()rejects a non-numeric predictor.**gwr.basic()expands contrasts viamodel.matrix()and fits, butgwr.predict()does not and fails, so the model appeared to fit and then silently predicted allNA. - **
fit_gwr_model()no longer rejects a two-valued continuous response.** The "binary" error is now gated on the response being integer-like. A left-censored or saturated measurement (every observation at a detection limit or a ceiling) has two distinct values and is perfectly continuous; it now warns instead. The guard also runs once per fold insidecv_gwr(), where a small training fold can legitimately hold only two distinct values. fitted()returning the wrong length, or nothing, is now an error insummary()andmodel_metrics()rather than a plausible row count over an all-NAcomparison.new_spatial_fit()is the documented extension point, so a subclass with a missing or mis-sizedfitted()method is user-reachable.- The cached
fitted()on abayesian_fitis stamped with thenand a digest of the data it was computed from. The cache environment has reference semantics — which is what makes it survive copy-on-modify — sofit2 <- fitgave both objects the *same* cache, and assigning different data to the copy returned the original's values at the original's length. make_folds()drops rows with empty or non-finite coordinates, with a logged warning naming the count, rather than letting anEMPTYPOINT reachblock_kfold's nearest-block rescue and die with "replacement has length zero".- When every fold fails, the warning now names the first underlying error. Previously "all 5 folds failed" was the whole diagnosis even when the cause was simply that **brms** or **GWmodel** was not installed.
make_folds()records the CRS the folds were built in asparams$crs. Geographic input is projected byensure_projected()to a CRS the caller never chose, andblock_size,sac_rangeandbufferare lengths in *that* CRS..morans_i_for_k()returnsNAat or below nine cells, where every cell neighbours every other and Moran's I collapses to exactly-1/(k-1)for any residual vector — a function of the cell count alone.residual_morans_i(fit, k = 1)works on machines without **FNN**.apply()simplified the length-1 result to a vector, making the neighbour index a 1 x n matrix and every row after the first out of bounds.
Fourth audit pass (adversarial): corrections that change results
Three reviewers were set the task of making the package fail or silently
misbehave on valid input, with their own reproductions. Every finding below
was reproduced here before it was touched; the figures quoted are from those
reproductions.
- **
summarize_by_cell(deff = "kish")under-estimated the predictor ICC by about a factor ofm.** The pooled one-way ANOVA grouped themz-scored predictor columns under the same cell label, so independent per-column cell effects averaged away in the shared cell mean and the between-cell sum of squares shrank by ~1/m. Measured at true rho = 0.5 with m = 4: pooled ICC **0.12** against 0.495 per column, so every predictor SE was ~44% too small. The pooled group is now (variable, cell), which recovers 0.49. - **Design effects are built from what a column actually observes.** A cell of 10 rows with 2 finite responses had its response SE formed at the 10-row design effect, then applied to a 2-observation mean: adding 8 NA-response rows moved the SE from 8.46 to **26.38**. Each column's design effect now uses its own non-missing count, with the mean pairwise correlation recomputed over the observed locations when a cell has NAs;
cell_weightis the effective count of the primary variable, not of rows (nstill counts rows). - **CRS-less coordinates get ONE interpretation, wherever they enter.**
prep_model_data()assumed EPSG:4326 for CRS-less data that looked like lon/lat and projected it, while everypredict()method passed the fit's CRS as a target — a branch that *stamped* it onto the raw numbers. The same rows sat in two different places, andpredict(fit, newdata = training rows)disagreed withfitted(fit)by up to one response SD (R² 0.98 in-sample, 0.64 via newdata). The heuristic is now a single function used by both branches; a fit records the assumption it was built under and replays it on CRS-lessnewdata. Two further symptoms of the same split — CRS-less LINESTRINGs aborting incoerce_to_points()("crs not found"), and hex/squarebuild_tessellation()refusing input voronoi accepted — are fixed with it. The assumption is now announced with a real R warning. - **
residual_morans_i()refuses a malformedweightsmatrix** instead of silently substituting the default k-NN(8) matrix (I = 0.874 returned for four malformed shapes against 0.805 for the weights actually supplied). - **The fold-provenance fingerprint no longer refuses the caller's own data.** Three defects in the version introduced last pass: a character
..row_idwas coerced to all-NA(and matched row 1 everywhere); coordinates were compared as"%.7g"strings and flipped on the ~1 in 5000 that a reprojection moved by 5e-9°; and polygon input was probed *after* pointization, so a differentpointizein the cv call read as different data. Both sides now probe the geometry as supplied, keep IDs in their own type, and compare numerically within 1e-6°. - **
n_folds_attemptedcounts the folds supplied.** A fold whose test rows were all removed as incomplete vanished before fitting and was absent from both counts, so five supplied folds reported4/4. It is announced with a real warning. - **
determine_optimal_levels()'s elbow uses the signed deviation below the chord.**abs()let a concave bump *above* the chord — a k where k-means fell into a worse local optimum — win with the same magnitude. - **
estimate_sac_range()returnsNAfor a constant variable** (an exactly explained response, or a constant one) instead of a fitted "range" of 168 or 673 from a variogram that is identically zero.make_folds(auto_range = TRUE)no longer re-opens the unseeded subsample by forwarding its ownseed = NULL. - **
fit_bayesian_spatial_model()attaches a user-supp
The NEWS section is longer than shown here.
From NEWS.md in the release
Install
Health
ERRORtestsr-devel-linux-x86_64-fedora-clang, r-devel-linux-x86_64-fedora-gcc
r-devel-linux-x86_64-fedora-clang
Running ‘testthat.R’ [91m/54m]
Running the tests in ‘tests/testthat.R’ failed.
Complete output:
> library(testthat)
> library(spatialkit)
>
> test_check("spatialkit")
WARN [2026-10-15 04:13:50] area_of_applicability(): dropping predictor(s) with no variance in the training data: const. A prediction point taking a different value there is extrapolation the dissimilarity index cannot represent.
WARN [2026-10-15 04:13:50] area_of_applicability(): dropping 1 training row(s) with missing or non-finite predictor values.
WARN [2026-10-15 04:13:51] fit_rf_model(): include_coords = TRUE. A forest given the coordinates can reproduce the training surface by memorising location and then fail wherever it has not been; random cross-validation will not detect this. Score the result with cv_rf() and blocked folds, not with the out-of-bag error (Meyer et al. 2019).
WARN [2026-10-15 04:13:51] fit_rf_model(): include_coords = TRUE. A forest given the coordinates can reproduce the trainin
...[truncated]...
rdinates look like lon/lat (xmin=9.10, xmax=9.15, ymin=48.70, ymax=48.75), so they are taken as EPSG:4326 and reprojected to the target CRS ('EPSG:32632'). Set the CRS explicitly to suppress this.
WARN [2026-10-15 04:13:53] fit_rf_model(): include_coords = TRUE. A forest given the coordinates can reproduce the training surface by memorising location and then fail wherever it has not been; random cross-validation will not detect this. Score the result with cv_rf() and blocked folds, not with the out-of-bag error (Meyer et al. 2019).
WARN [2026-10-15 04:13:53] make_folds(): dropping 1 point(s) with empty or non-finite coordinates.
WARN [2026-10-15 04:13:53] prep_model_data(): dropping 1 row(s) with non-finite or missing values (1 of them with an empty or non-finite geometry).
OMP: Warning #96: Cannot form a team with 28 threads, using 2 instead.
OMP: Hint Consider unsetting KMP_DEVICE_THREAD_LIMIT (KMP_ALL_THREADS), KMP_TEAMS_THREAD_LIMIT, and OMP_THREAD_LIMIT (if any are set).r-devel-linux-x86_64-fedora-gcc
Running ‘testthat.R’ [90m/48m]
Running the tests in ‘tests/testthat.R’ failed.
Complete output:
> library(testthat)
> library(spatialkit)
>
> test_check("spatialkit")
WARN [2026-10-19 05:38:42] area_of_applicability(): dropping predictor(s) with no variance in the training data: const. A prediction point taking a different value there is extrapolation the dissimilarity index cannot represent.
WARN [2026-10-19 05:38:42] area_of_applicability(): dropping 1 training row(s) with missing or non-finite predictor values.
WARN [2026-10-19 05:38:44] fit_rf_model(): include_coords = TRUE. A forest given the coordinates can reproduce the training surface by memorising location and then fail wherever it has not been; random cross-validation will not detect this. Score the result with cv_rf() and blocked folds, not with the out-of-bag error (Meyer et al. 2019).
WARN [2026-10-19 05:38:44] fit_rf_model(): include_coords = TRUE. A forest given the coordinates can reproduce the trainin
...[truncated]...
ymax=48.75), so they are taken as EPSG:4326 and reprojected to 'EPSG:32632'. Set the CRS explicitly with sf::st_crs() to suppress this.
WARN [2026-10-19 05:38:45] ensure_projected(): input has no CRS; its coordinates look like lon/lat (xmin=9.10, xmax=9.15, ymin=48.70, ymax=48.75), so they are taken as EPSG:4326 and reprojected to the target CRS ('EPSG:32632'). Set the CRS explicitly to suppress this.
WARN [2026-10-19 05:38:45] fit_rf_model(): include_coords = TRUE. A forest given the coordinates can reproduce the training surface by memorising location and then fail wherever it has not been; random cross-validation will not detect this. Score the result with cv_rf() and blocked folds, not with the out-of-bag error (Meyer et al. 2019).
WARN [2026-10-19 05:38:46] make_folds(): dropping 1 point(s) with empty or non-finite coordinates.
WARN [2026-10-19 05:38:46] prep_model_data(): dropping 1 row(s) with non-finite or missing values (1 of them with an empty or non-finite geometry).- ERROR11 OK, 2 ERROR2026-09-12
- OK6 OK2026-08-08
Documentation
- Help pages
- 61 (1 internal)includes a package overview page
- Examples that run
- 95%40 run in a plain check (17 of them under a condition) · 2 never run
- Exported functions with examples
- 100%
- Documented parameters
- 95%
- Return-value docs
- 100%
- Help pages citing references
- 10%
Downloads
Repository
- PRs merged (all time)
- 100%
- Median days to close, 50 most recently updated closed issues
- 1
- Median days to close, 50 most recently updated closed PRs
- 0
Stars over time
PRs over time
Commits over time
Commit identities over time
7 development-tooling and community-health practices detected across 3 families in the upstream repository
Checks run against github.com/elkronos/gis_modeling_toolkit on 2026-10-18.
The R CMD check workflow runs on Linux, macOS and Windows, and on R-devel
GitHub Pages site last deployed 2026-10-07 · The repository has a site deploy workflow
This repository shows signs of AI coding tools.
- Claude Codefirst seen 2026-08-20Named in .Rbuildignore: .claudePull request it wrote: opened by a project memberCommits: 128 by its own account · at least 151 crediting it in the message
- Claude Opus 5.577 commits · Sep 2026
- Claude Opus 523 commits · Aug 2026
Some of these counts are a minimum, because the search stopped before the oldest commits.
Dependencies
Code & Tests
People & History
Pick two releases to compare their code metrics.
- 2.0.0Latest2026-09-11 · current release · diff ↗ ·
removed 3 exports
evaluate_models, evaluate_models_cv, phi_prior_boundsadded 8 exports
area_of_applicability, cv_rf, fit_rf_model, gwr_model_selection, plot_folds, predict_surface, select_features_forward, spatialkit_quiet - 1.0.0first seen 2026-08-07
- RR 4.6.0 released · 2026-04-24
Package metadata
- First seen
- 2026-08-07
- Total releases
- 2 / 1 yrs
- License
- MIT + file LICENSE OSI
- Additional repositories
- stan-dev.r-universe.dev
- Minimum R
- ≥ 4.1.0
- Download size
- 1.2 MB
Cite
Cite this package
Run in R for the authors' preferred citation:
citation("spatialkit")The package provides a citation file that builds its entry when R runs it. Run citation() to see it.
Cite the R Observatory
For a number measured here: a download total, a coverage figure, an archival date.
From data release v2026-10-21, which the citation names so these numbers can be found later. More on citing and the projects behind them.