Reproducing the Second Dimension of Spatial Association (SDA)

A complete walkthrough of the SDA model — which builds explanatory variables from the geographical environment around the samples rather than only at them — from raw data to manuscript. The method is implemented here in a single self-contained R file that needs no package at all.
Yongze Song

Yongze Song

Associate Professor, Curtin University

Special Issue Editor (SIE), Associate Editor: Int. J. Applied Earth Observation (IF 8.2) · Associate Editor: GIScience & Remote Sensing (IF 6.8)

yongze.song@curtin.edu.au · yongzesong.com

⬇ Download code & data (zip, 2.1 MB)
R/ pipeline scripts including the complete method in R/02-sda-core.R · config/project-config.R · data/ (614 samples, 68,757 grid cells, the published variable sets) · tests/ · run-all.R — unzip and run Rscript run-all.R test from the SDA/ folder root.
To cite the SDA model and its R packages and codes in publications, please use:
Song Y (2022). The second dimension of spatial association. Int J Appl Earth Obs Geoinf 111:102834. doi · PDF
Wang X, Wu Y, Shi T, et al. (2026). Explainable second-dimension spatial association between park sentiment and built-social environments. PDF
Ren K, Song Y, Yu Q (2025). Second-dimension outliers for spatial prediction. IJGIS. PDF
Ren K, Song Y, Yang X, et al. (2026). Spatial outliers as a pattern determinant for explaining heterogeneity. IJGIS. PDF

1 Method Overview & Reproduction Scope

1.1 Core Idea

Almost every model of spatial association reads its explanatory variables at the sample locations: one soil sample, one elevation value, one NDVI value, taken at that exact point. Song (2022) names this the first dimension of spatial association (FDA) and observes what it throws away — everything the environment is doing in the neighbourhood around the sample. A grid of 68,757 cells sits under 614 samples in the demo below, and the first dimension uses 614 of those cells.

The second dimension of spatial association (SDA) uses the rest. Around each sample it draws a circle of radius b, takes every grid cell inside, and summarises that local distribution at a quantile τ. Each pair (b, τ) becomes one explanatory variable: the τ-quantile of this surface within b km of the sample. A single surface therefore yields a whole family of variables describing the neighbourhood at several ranges and several parts of its distribution, and the data decide which of them matter.

Published reference · SDA FDA versus SDA concept, Song 2022 Figure 1
SDA paper Fig. 1 — the whole idea in one picture. Above: the first dimension links a response sample to explanatory values at the same points. Below: the second dimension opens a cone from the sample down onto the explanatory surface and summarises everything inside it, so information from locations that were never sampled enters the model. Song (2022), Int J Appl Earth Obs Geoinf 111:102834 (CC BY-NC-ND).
Reader anchor

A sample is a point, but the process that produced it is not. Read the environment around each sample at several ranges b and several quantiles τ → each (b, τ) is a candidate variable → select among them → compare against the same model built from point values alone. Everything on this page serves that line.

What you need

R 4.1+ and the folder from the download button. The method itself is R/02-sda-core.R, about 120 lines of base R with no package dependency at all; ggplot2 and randomForest are used only for the figures and the second model, and every step says so and carries on if they are absent. The full pipeline runs in about 6 seconds.

1.2 Method Logic

SDA is the root of a small family of methods. The idea of looking outside the samples was introduced for spatial prediction, then extended to explanation, to outliers, and to heterogeneity:

SDA 2022second-dimension variables from (b, τ); prediction
Song, JAG · PDFCC BY-NC-ND
ESDA 2026explainable SDA: nonlinear, quantile-asymmetric drivers
Wang, Wu et al.
SDO 2025second-dimension outliers as predictors
Ren, Song & Yu, IJGIS
SOH 2026outlier-driven heterogeneity; has its own tutorial
SOH tutorial · PDF
Published reference · SDA SDA schematic overview, Song 2022 Figure 2
SDA paper Fig. 2 — the second dimension model (SDM), and the plan of §3 of this page: characterise the environment on a fine grid, generate variables over the (searching range b, probability τ) grid, select them in two stages by correlation and multicollinearity, then model and predict. Click to enlarge.
Published reference · SDO SDO schematic, Ren et al. 2025 Figure 1
SDO paper Fig. 1 — the same second-dimension geometry, but summarising the outliers in each neighbourhood instead of its quantiles (Ren, Song & Yu 2025).

Key concepts and equations

1.3 Reproduction Scope

Everything tagged Demo run regenerates from this folder; everything tagged Published reference is a static image from the source articles. The demo runs end to end with R 4.6.0, and it reproduces the published result twice over: the generated variables are identical to the published values, and selecting from all 440 candidates returns exactly the ten variables of the paper's Fig. 5(a).

ItemDemo templatePublished reference
Data 614 soil samples (Cr, Cu) and a 68,757-cell grid, with eight environmental surfaces (data/) the same geochemical case, distributed with the method
Method R/02-sda-core.R — generation, VIF, selection, prediction alignment, outliers, in base R Song (2022) §2, and the SecDim package
Tables results/cross-validation.csv, selected-parameters.csv, buffer-sensitivity.csv Fig. 5(a) variable selection, reproduced exactly by run-all.R test
Figures fig01–fig05 from R/p01–p03 SDA Figs 1, 2, 5, 6 and the family schematics, embedded as reference images
What the template regenerates versus what is shown as published reference.

Published applications of the second dimension

ApplicationResponseSpatial unitStatus
Trace elements in soil (Song 2022 — this demo's case) Cr and Cu concentrationSoil sample pointPublished (JAG)
Park sentiment from volunteered text (Wang, Wu et al. 2026, ESDA) Sentiment scoreUrban parkPublished
Wheat production (Ren, Song & Yu 2025, SDO) Annual productionLocal government areaPublished (IJGIS)
Barley production (Ren et al. 2026, SOH) Production per km²SA2 polygonPublished (IJGIS) · tutorial
The second dimension travels from geochemistry to sentiment to agriculture; only the response and the surfaces change.

How to use this page

2 Setup, Code & Data

2.1 Environment & Dependencies

The method needs nothing. R/02-sda-core.R is written in base R, so source() it and the whole of SDA is available. The pipeline around it uses three optional packages, and each step reports and continues if one is missing.

R console — optional, for the figures and the second model
# Figures
install.packages(c("ggplot2", "ggrepel"))

# The random-forest arm of the cross validation
install.packages("randomForest")

# Optional: used for distances when present. The built-in haversine in
# 02-sda-core.R agrees with it to 1e-6 m, and tests/test-01 checks that.
install.packages("geosphere")
PackageRoleRequired?
R/02-sda-core.R: variable generation, VIF, selection, prediction alignment, outliersbase R only
ggplot2, ggrepelthe five demo figuresfigures only
randomForestthe random-forest comparison in Step 4that model only
geospheredistance computation when installedno — built-in fallback
The method is dependency-free by design, so it can be dropped into any project.
!This tutorial does not call the SecDim package

SDA was released as the R package SecDim, and the implementation below follows it function for function — the generated variables are identical to the values it ships (§3.2). It is reimplemented here for two reasons: the code then has no dependency and can be pasted into any script, and two defects in SecDim 3.2 are corrected. Both are silent — they fit without complaint and damage only prediction on new data — so they are described where the corresponding function appears below.

2.2 The Method in One File

The whole of SDA is printed here so it can be copied out and used directly; it is also R/02-sda-core.R in the download, and the two are the same file. Four pieces: generation, the variance inflation factor, selection, and outliers.

Generating second-dimension variables

The heart of the method. For each sample, distances to every grid cell; for each searching range b, the cells inside; for each quantile τ, one number. Columns come back named b5t0.7 — the 0.7 quantile within 5 km — so a variable's parameters are legible from its name for the rest of the analysis.

R/02-sda-core.R — great-circle distance and second-dimension variables
# -- Great-circle distance ----------------------------------------------------
# Metres between one point (lon, lat) and a matrix of points, on a sphere of
# radius r. Matches geosphere::distHaversine(..., r = 6378137).

sda_haversine <- function(p, pts, r = 6378137) {
  d2r <- pi / 180
  lon1 <- p[[1]] * d2r; lat1 <- p[[2]] * d2r
  lon2 <- pts[[1]] * d2r; lat2 <- pts[[2]] * d2r
  dlon <- lon2 - lon1; dlat <- lat2 - lat1
  a <- sin(dlat / 2)^2 + cos(lat1) * cos(lat2) * sin(dlon / 2)^2
  2 * r * asin(pmin(1, sqrt(a)))
}


# -- 1. Second-dimension variables --------------------------------------------
#' Generate second-dimension variables for one grid surface.
#'
#' For every sample point, the value of a grid variable is summarised over the
#' locations *around* the sample — the second dimension. Each buffer radius b
#' (km) and quantile probability tau gives one variable, so a surface yields
#' length(distbuf) x length(quantileprob) columns named b<b>t<tau>.
#'
#' pointlocation  data.frame/matrix of sample coordinates (lon, lat)
#' gridlocation   data.frame/matrix of grid coordinates (lon, lat)
#' gridvar        numeric vector of the grid variable, one value per grid cell
#' distbuf        buffer radii in km
#' quantileprob   quantile probabilities in [0, 1]
#' Returns the point coordinates column-bound to the generated variables.

sda_variables <- function(pointlocation, gridlocation, gridvar,
                          distbuf = seq(1, 10, 1),
                          quantileprob = seq(0, 1, 0.1),
                          verbose = FALSE) {
  samples <- as.data.frame(pointlocation)
  grids   <- as.data.frame(gridlocation)
  if (nrow(grids) != length(gridvar))
    stop("gridvar must have one value per row of gridlocation")

  nbuf <- length(distbuf); nprob <- length(quantileprob)
  out <- data.frame(matrix(NA_real_, nrow(samples), nbuf * nprob))
  names(out) <- paste0(rep(paste0("b", distbuf), each = nprob),
                       rep(paste0("t", quantileprob), times = nbuf))

  use_geosphere <- requireNamespace("geosphere", quietly = TRUE)
  maxbuf <- max(distbuf)

  for (i in seq_len(nrow(samples))) {
    zi <- if (use_geosphere) {
      geosphere::distHaversine(samples[i, ], grids, r = 6378137) / 1000
    } else {
      sda_haversine(samples[i, ], grids) / 1000
    }
    # One pass per buffer: cells inside b, then the requested quantiles.
    vals <- numeric(0)
    for (b in distbuf) {
      inb <- which(zi < b)
      q <- if (length(inb) > 0) {
        as.numeric(stats::quantile(gridvar[inb], quantileprob, na.rm = TRUE))
      } else {
        rep(NA_real_, nprob)
      }
      vals <- c(vals, q)
    }
    out[i, ] <- vals
    if (verbose && i %% 100 == 0)
      cat(sprintf("   ...%d / %d points\n", i, nrow(samples)))
  }
  cbind(pointlocation, out)
}

The variance inflation factor

Selection leans on the VIF, so it has to be right. R²j must be measured against the total sum of squares of the observed column.

R/02-sda-core.R — variance inflation factor
# -- 2. Variance inflation factor ---------------------------------------------
#' VIF of every column of x, computed with base-R least squares.
#' VIF_j = 1 / (1 - R^2_j) where R^2_j regresses column j on the others.

sda_vif <- function(x) {
  x <- as.data.frame(x)
  nx <- ncol(x)
  if (nx < 2) return(stats::setNames(rep(1, nx), names(x)))
  vapply(seq_len(nx), function(j) {
    y <- x[[j]]
    X <- cbind(1, as.matrix(x[, -j, drop = FALSE]))
    fit <- tryCatch(stats::lm.fit(X, y), error = function(e) NULL)
    if (is.null(fit)) return(Inf)
    r2 <- 1 - sum(fit$residuals^2) / sum((y - mean(y))^2)
    if (!is.finite(r2) || r2 >= 1) Inf else 1 / (1 - r2)
  }, numeric(1))
}
!Correction 1 — the VIF is one too small in SecDim 3.2

SecDim::vif() defines R2(o, p) = 1 - sum((o-p)^2)/sum((o-mean(o))^2) and then calls it as R2(fitted, observed). With the arguments that way round the denominator becomes the sum of squares of the fitted values, so it returns 1 − SSE/SSR instead of 1 − SSE/SST. Because SST = SSR + SSE for a least-squares fit with an intercept, the result is exactly one less than the variance inflation factor:

VIFSecDim = SSR/SSE = (SST − SSE)/SSE = VIF − 1

Verified to 2×10⁻¹⁴ across random datasets. The consequence is mild but worth stating: a threshold of 10 there behaves like a threshold of 11 here, keeping slightly more collinear variables. tests/test-02 locks the standard definition.

Selecting variables, and keeping the names attached to the values

Candidates are ranked by |correlation with the response|, then walked down that order, dropping any that pushes the VIF above the threshold. sda_select() does this within each surface first, so no single surface can flood the model, then again across the survivors. sda_newdata() labels a full variable set the same way, which is what makes prediction on new data line up.

R/02-sda-core.R — variable selection and prediction alignment
# -- 3. Variable selection ----------------------------------------------------
#' Select variables from one set: order by |correlation with y|, then walk down
#' that order dropping any variable that pushes the VIF above ctr.vif.
#'
#' NOTE. The returned columns keep the names of the columns they actually hold.
#' This matters: a selection that returns correctly-ordered values under the
#' wrong names still fits, because the fitted values are self-consistent, but
#' predict() on new data matches columns *by name* and would then read the wrong
#' variable — silently, and catastrophically.

sda_select_one <- function(y, x, ctr.vif = 10) {
  x <- as.data.frame(x)
  keep <- vapply(x, function(v) stats::sd(v, na.rm = TRUE) > 0, logical(1))
  x <- x[, keep, drop = FALSE]
  if (ncol(x) == 0) stop("no variable in x has non-zero variance")

  cors <- abs(stats::cor(cbind(y, x), method = "pearson")[1, -1])
  xs <- x[, rev(order(cors)), drop = FALSE]      # strongest correlation first
  nxs <- ncol(xs)
  if (nxs == 1) return(xs)

  drop <- integer(0)
  for (i in 2:nxs) {
    k <- seq_len(i)
    if (length(drop) > 0) k <- setdiff(k, drop)
    if (length(k) < 2) next
    if (max(sda_vif(xs[, k, drop = FALSE])) > ctr.vif) drop <- c(drop, i)
  }
  kept <- setdiff(seq_len(nxs), drop)
  out <- xs[, kept, drop = FALSE]
  names(out) <- names(xs)[kept]                  # names follow the content
  out
}

#' Select across several variable sets: choose within each set first (so no one
#' surface can flood the model), prefix the survivors by set, then select again
#' across the pooled survivors.

sda_select <- function(y, xlist, ctr.vif = 10) {
  if (!is.list(xlist)) xlist <- list(xlist)
  nms <- names(xlist)
  if (is.null(nms)) nms <- paste0("v", seq_along(xlist))
  if (length(xlist) == 1) {
    out <- sda_select_one(y, xlist[[1]], ctr.vif)
    names(out) <- paste0(nms[1], "_", names(out))
    return(out)
  }
  per_set <- lapply(seq_along(xlist), function(i) {
    d <- sda_select_one(y, xlist[[i]], ctr.vif)
    names(d) <- paste0(nms[i], "_", names(d))
    d
  })
  sda_select_one(y, do.call(cbind, per_set), ctr.vif)
}

#' Name a full variable list the same way sda_select() does, so that new data
#' lines up with a fitted model column for column.

sda_newdata <- function(xlist) {
  if (!is.list(xlist)) xlist <- list(xlist)
  nms <- names(xlist)
  if (is.null(nms)) nms <- paste0("v", seq_along(xlist))
  out <- lapply(seq_along(xlist), function(i) {
    d <- as.data.frame(xlist[[i]])
    names(d) <- paste0(nms[i], "_", names(d))
    d
  })
  do.call(cbind, out)
}
!Correction 2 — column names must follow the values they label

SecDim::selectvarlm() sorts the candidates by correlation into x3, keeps positions xk of that sorted frame, and then labels the result with names(x)[xk] — the names of the unsorted frame. The returned data frame therefore carries the values of one variable under the name of another.

Nothing complains. The fitted model is internally consistent, because the values are, and the fit statistics look normal. But predict() matches new data by name, so on a test set the model reads the wrong columns. Running the package vignette's own cross-validation section verbatim returns R² = −1,569,973, with all 185 predictions landing between 778 and 1366 for a response that spans 3.0 to 6.7. The fix is names(x3)[xk] — take the names from the frame the values came from — which is what sda_select_one() does above, and what makes the cross validation in §3.4 work at all. tests/test-02 asserts that every returned column equals the variable it is named after.

Outliers

R/02-sda-core.R — outliers
# -- 4. Outliers --------------------------------------------------------------
#' Positions of values beyond coef standard deviations from the mean (or NA).

sda_rmoutlier <- function(x, coef = 2.5) {
  m <- mean(x, na.rm = TRUE); s <- stats::sd(x, na.rm = TRUE)
  which(is.na(x) | x > m + coef * s | x < m - coef * s)
}

Calling it: the whole method in one screen

With the file above sourced, a complete SDA analysis — generate, select, fit, predict, compare against the first dimension — is the block below. It uses the data in data/ and reproduces the headline numbers of §3.4, and it is the only code a reader needs in order to apply the method to their own tables.

R — a complete SDA analysis, start to finish
source("R/02-sda-core.R")          # the method: no package required

## 1. Data ------------------------------------------------------------------
pts  <- read.csv("data/obs.csv")     # samples: Lon, Lat, Cr_ppm
grd  <- read.csv("data/grids.csv")   # grid: Lon, Lat, Elevation
pts$y <- log(pts$Cr_ppm)             # right-skewed response

## 2. Build the second dimension --------------------------------------------
## One variable per (searching range b, quantile tau); 5 x 11 = 55 per surface.
elev <- sda_variables(pts[, c("Lon", "Lat")],
                      grd[, c("Lon", "Lat")], grd$Elevation,
                      distbuf = c(1, 3, 5, 7, 9),
                      quantileprob = seq(0, 1, 0.1))[, -(1:2)]

## The published case uses eight surfaces; seven are supplied pre-generated.
surfaces <- c("Elevation","Slope","Aspect","Water","NDVI","pH","SOC","Road")
x <- lapply(surfaces, function(v)
       read.csv(sprintf("data/reference/sda-%s.csv", tolower(v))))
names(x) <- surfaces

## 3. Split, and drop outliers from the TRAINING response only ---------------
set.seed(100)
train <- sample(nrow(pts), floor(0.7 * nrow(pts)))
krm   <- sda_rmoutlier(pts$y[train])          # 2.5 SD
keep  <- if (length(krm)) train[-krm] else train
ytr   <- pts$y[keep]; yte <- pts$y[-train]

## 4. Select on the training data, then align the test data ------------------
sel  <- sda_select(ytr, lapply(x, function(m) m[keep, ]), ctr.vif = 10)
newx <- sda_newdata(lapply(x, function(m) m[-train, ]))[, names(sel)]

## 5. Fit, predict, and compare with the first dimension ---------------------
r2 <- function(o, p) 1 - sum((o - p)^2) / sum((o - mean(o))^2)

sda.lm <- lm(y ~ ., cbind(y = ytr, sel))
r2(yte, predict(sda.lm, newdata = newx))       # 0.3002

fda    <- read.csv("data/sample-vars-fda.csv") # the same surfaces AT the points
fda.lm <- lm(y ~ ., cbind(y = ytr, fda[keep, ]))
r2(yte, predict(fda.lm, newdata = fda[-train, ]))  # 0.2422
The one habit that keeps SDA honest

Select on the training data, then build the test matrix with sda_newdata(...)[, names(sel)]. Selecting on everything leaks the test set into the choice of variables and inflates R²; subsetting new data by names(sel) is what guarantees the model reads the same variables it was fitted on.

2.3 Project Structure & Configuration

One entry script runs the five pipeline steps, the figures, or the tests. Each step reads what the previous one wrote, so any step can be re-run after a config change.

folder tree
SDA/
├── sda.html                       # this guide
├── run-all.R                      # entry point: full run, single steps, figures, test
├── R/
│   ├── 00-config.R, 01-helpers.R  # paths, IO, logging
│   ├── 02-sda-core.R              # THE METHOD — base R, no dependency
│   ├── 10-prepare-data.R          # read points and grid, transform the response
│   ├── 20-generate-sdvars.R       # build the second dimension, check against published
│   ├── 30-select-variables.R      # rank by correlation, drop collinearity
│   ├── 40-cross-validation.R      # SDA vs FDA, linear model and random forest
│   ├── 50-buffer-sensitivity.R    # which searching range carries the signal
│   ├── 90-tables.R                # LaTeX tables + session info
│   └── p01 … p03                  # figure scripts
├── config/project-config.R        # the ONLY file to edit for a new domain
├── data/
│   ├── obs.csv                    # 614 samples: Lon, Lat, Cr_ppm, Cu_ppm
│   ├── grids.csv                  # 68,757 grid cells: Lon, Lat, Elevation
│   ├── sample-vars-fda.csv        # the eight surfaces AT the sample points
│   └── reference/sda-*.csv        # published second-dimension variables (8 x 55)
├── tests/                         # test-01 reproduces published values and selection
├── env/                           # requirements.md, session-info.txt
├── results/  tables/  figs/       # generated output, never hand-edited

Main configuration file

config/project-config.R (key lines)
POINT_FILE <- "data/obs.csv"      # samples: coordinates + response
GRID_FILE  <- "data/grids.csv"    # the surface(s) covering the study area
LON <- "Lon"; LAT <- "Lat"

RESPONSE     <- "Cr_ppm"           # dependent variable
LOG_RESPONSE <- TRUE               # log-transform a right-skewed response
OUTLIER_SD   <- 2.5                # drop TRAINING values beyond this many SD

DIST_BUFFERS <- c(1, 3, 5, 7, 9)   # b, km  — how far outside the sample to look
QUANTILES    <- seq(0, 1, 0.1)     # tau    — which part of the local distribution

GRID_SURFACES  <- c("Elevation")   # surfaces to generate from GRID_FILE
REFERENCE_VARS <- c("Elevation","Slope","Aspect","Water","NDVI","pH","SOC","Road")

VIF_MAX        <- 10               # multicollinearity threshold
TRAIN_FRACTION <- 0.7
SEED           <- 100
ParameterDefaultOrigin / rationale
DIST_BUFFERS1, 3, 5, 7, 9 kmthe searching ranges of Song (2022); Step 5 shows what each one contributes
QUANTILES0 … 1 by 0.1the whole local distribution, from minimum to maximum
VIF_MAX10the conventional threshold; essential here because neighbouring (b, τ) pairs are near-duplicates
OUTLIER_SD2.5applied to the training response only, so the test set stays as observed
Free parameters and the published rules they follow.

2.4 Data Contract

SDA needs two tables. The point table is the usual one; the grid table is what makes the method possible, and it should be as dense and as wide as the study area allows — it is the information the first dimension discards.

TableColumnsMeaningRequired?
POINT_FILELon, Latsample coordinates in degreesyes
RESPONSEthe dependent variableyes
GRID_FILELon, Latgrid cell coordinates in degreesyes
one column per surfacethe environment to summarise around each sample≥ 1
Coordinates are in degrees because distances are computed on a sphere; column names are declared in the config, not hard-coded.
SampleIDLonLatCr_ppmCu_ppm
1117.6417−24.664322364
2117.6562−24.58859147
First rows of data/obs.csv (614 samples, Western Australia). data/grids.csv carries the same coordinate columns plus Elevation across 68,757 cells — 112 grid cells for every sample, which is the reservoir the second dimension draws on and the first dimension leaves untouched.
!The grid decides what the method can see

Every second-dimension variable is a summary of grid cells, so a grid that is coarse relative to the largest buffer gives quantiles computed from a handful of cells, and one that does not extend past the sampled area gives truncated circles at the edges. Check the count of cells falling inside your smallest buffer before trusting τ = 0 or τ = 1: extreme quantiles are the first to become noisy when a neighbourhood is thin.

3 Reproduction Pipeline, Results & Validation

3.1 Pipeline Execution

Five steps, three figure scripts and two test files run from one entry point. Every step writes to results/, tables/, figs/ or data/derived/ and reads what the previous step wrote.

Full run
Rscript run-all.R (~6 s)
Verification
Rscript run-all.R test — do this first
Output folders
results/ tables/ figs/ data/derived/
terminal
cd SDA                     # this folder
Rscript run-all.R test     # 1. check the implementation against the published values
Rscript run-all.R          # 2. full pipeline

Rscript run-all.R 30 40    # re-run single steps (10 20 30 40 50 90)
Rscript run-all.R figures  # only the figure scripts
Project : sda-demo Domain : geochemical mapping (trace elements) == Step 1/5 Prepare data ====================================== samples: 614 rows | grid: 68757 cells (112x denser than the samples) response 'Cr_ppm' log-transformed (skew 1.58 -> -0.85) == Step 2/5 Generate second-dimension variables ================ b = {1, 3, 5, 7, 9} km x tau = {0, 0.1, ... 1} -> 55 variables per surface Elevation 614 points x 68757 cells -> 55 variables in 2.3s Elevation max |generated - published| = 2.27e-13 (identical) cost is linear in sample size: 0.004 s per point on this grid == Step 3/5 Select second-dimension variables ================= candidates: 8 surfaces x 55 variables = 440 selected 10 of 440 variables in 0.17s surfaces represented: NDVI, pH, Road, SOC, Water strongest: pH_b9t0.9 (r = 0.508) == Step 4/5 Cross validation: SDA against FDA ================= SDA linear model R2 = 0.3002 RMSE = 0.6496 FDA linear model R2 = 0.2422 RMSE = 0.6760 SDA random forest R2 = 0.3325 RMSE = 0.6345 FDA random forest R2 = 0.2752 RMSE = 0.6611 == Step 5/5 Sensitivity to the searching range b ============== best single searching range: b = 7 km (R2 = 0.3508) Finished in 6.0 s

3.2 Reproduce the Published Values

The implementation is checked against the published method in two independent ways before any result is believed.

The generated variables are the published variables

Regenerating Elevation's 55 second-dimension variables from obs.csv and grids.csv reproduces the distributed values to 2×10⁻¹³ — floating-point noise, not a difference in method. The built-in haversine also matches geosphere to 3×10⁻¹¹ m, so the dependency-free path gives the same answer.

The selected variables are the published selection

The stronger check. Selecting from all 440 candidates returns exactly the ten variables of Fig. 5(a) of Song (2022) — the same five surfaces, at the same searching ranges, at the same quantiles. Nothing about that was fitted to match; it falls out of the correlation ranking and the VIF walk.

Demo run Selected variables in (b, tau) space, demo
fig01 — the ten selected variables placed at their searching range and quantile, sized by |correlation| and coloured by sign.
Published reference · SDA SDA paper Figure 5, selected second dimension variables
SDA paper Fig. 5 — panel (a) is Cr, the case reproduced here. Compare the positions: pH at (1, 1.0), (3, 0.9), (9, 0.9) and (9, 0.7); NDVI at (5, 0.1), (7, 0.4) and (9, 0.1); Water, SOC at (9, 1.0); Road at (9, 0.8).
VariableSurfaceb (km)τrIn Fig. 5(a)
pH_b9t0.9pH90.90.508yes
pH_b9t0.7pH90.70.485yes
NDVI_b9t0.1NDVI90.1−0.434yes
pH_b3t0.9pH30.90.424yes
NDVI_b5t0.1NDVI50.1−0.416yes
NDVI_b7t0.4NDVI70.4−0.391yes
pH_b1t1pH11.00.358yes
Water_b9t1Water91.0−0.224yes
Road_b9t0.8Road90.8−0.176yes
SOC_b9t1SOC91.00.018yes
results/selected-parameters.csv — ten of ten agree with the published figure. tests/test-01 asserts the set equality, so any future edit to the method that changes the selection is caught.
== test-01 Reproduce the published second-dimension variables =========== generated variable count matches the published set pass generated row count matches the sample count pass values identical to published (max diff 2.27e-13) pass built-in haversine matches geosphere (< 1e-6 m) pass selection returns 10 variables, as published pass the selected variables are exactly those of Fig. 5(a) pass PASS - the implementation reproduces the published method == test-02 Method properties ============================================ quantile 0 never exceeds quantile 1 pass VIF equals 1/(1 - SSE/SST), not the fitted-value variant pass every kept column carries the values of the variable it names pass held-out predictions stay in the range of the response pass ... 15 passed, 0 failed
Checkpoint

21 checks pass: 6 against the published values, 15 on method properties. Two of the property checks exist because of the defects described in §2.2 — they would both have passed silently in a version that fits but cannot predict.

3.3 Core Analytical Steps

Five stages: purpose → code → output → result → how to read it. The step numbers match the script numbers (Step 2 is R/20-generate-sdvars.R).

Step 1

Prepare data — the samples, the grid, and the response

What this step does

Reads both tables, checks the configured columns exist, and transforms the response. Trace elements are right-skewed (Cr runs from 1 to 1048 ppm), so the response is logged, as in the paper. Two samples at 1–2 ppm pull the logged distribution slightly the other way, which is what the 2.5 SD outlier rule in Step 3 removes.

Code

terminal
Rscript run-all.R 10

Example output

== Step 1/5 Prepare data ====================================== samples: 614 rows | grid: 68757 cells (112x denser than the samples) grid surfaces available: Elevation response 'Cr_ppm' log-transformed (skew 1.58 -> -0.85) response y: mean=4.963 sd=0.826 range=[0.000, 6.955] wrote data/derived/points.csv (614 rows)
How to read this result
  • The 112× ratio is the opportunity. Every sample sits on one grid cell and near 111 others. The first dimension uses one of them; the second dimension is a rule for using the rest.
  • Log first, then everything else. Skew of 1.58 means a handful of samples would dominate every correlation in the selection stage, and the quantile structure the method depends on would be read through those few values.
Step 2 · the method

Build the second dimension

What this step does

For every sample, distances to all 68,757 grid cells; for each searching range, the cells inside; for each quantile, one summary. 5 × 11 = 55 variables per surface. The result is compared against the published values, and the cost is measured at three sample sizes so a methods section can state how it scales.

Code

R — the call, from R/20-generate-sdvars.R
z <- sda_variables(pts[, c(LON, LAT)],          # sample coordinates
                   grd[, c(LON, LAT)], grd[[v]], # grid coordinates + surface
                   distbuf      = DIST_BUFFERS,  # c(1, 3, 5, 7, 9) km
                   quantileprob = QUANTILES)     # seq(0, 1, 0.1)
terminal
Rscript run-all.R 20

Example output

== Step 2/5 Generate second-dimension variables ================ b = {1, 3, 5, 7, 9} km x tau = {0, 0.1, ... 1} -> 55 variables per surface Elevation 614 points x 68757 cells -> 55 variables in 2.3s wrote data/derived/sdvars-generated.csv (614 rows) Elevation max |generated - published| = 2.27e-13 (identical) cost is linear in sample size: 0.004 s per point on this grid
Demo run Generation time against number of sample points
fig05 — generation time is linear in the number of samples: every sample needs one distance pass over the grid, so cost is roughly (samples × grid cells).
How to read this result
  • Identical to 2×10⁻¹³ is the licence to continue. Everything downstream is a claim about the published method rather than about a lookalike.
  • The cost is predictable, and it is the one thing that scales badly. 0.004 s per sample on a 68,757-cell grid, linear in samples and linear in cells. Ten thousand samples on this grid is about forty seconds per surface; a finer grid multiplies that directly, which is the trade-off to state when choosing grid resolution.
  • The buffers do the reusing. One distance pass serves every buffer and every quantile, so adding more (b, τ) pairs is nearly free — it is the number of samples and cells that costs, not the number of variables.
Checkpoint

results/generation-check.csv reports identical_to_1e6 = TRUE before you trust any later number.

Step 3

Select variables — 440 candidates down to 10

What this step does

Eight surfaces × 55 (b, τ) pairs is 440 candidates for 614 samples, and they are collinear by construction: the 0.5 quantile within 5 km and the 0.5 quantile within 7 km describe nearly the same neighbourhood. Selection ranks by |correlation| and walks down that order, dropping whatever pushes the VIF above 10 — within each surface first, then across the survivors.

Code

R — from R/30-select-variables.R
krm <- sda_rmoutlier(pts$y, OUTLIER_SD)         # 2.5 SD
y   <- pts$y[-krm]
x   <- lapply(xall, function(m) m[-krm, , drop = FALSE])
sel <- sda_select(y, x, ctr.vif = VIF_MAX)      # 440 -> 10

Example output

== Step 3/5 Select second-dimension variables ================= candidates: 8 surfaces x 55 variables = 440 removed 5 outlier(s) beyond 2.5 SD selected 10 of 440 variables in 0.17s surfaces represented: NDVI, pH, Road, SOC, Water buffers chosen: 1, 3, 5, 7, 9 km | quantiles chosen: 0.1, 0.4, 0.7, 0.8, 0.9, 1 strongest: pH_b9t0.9 (r = 0.508)
How to read this result
  • The chosen quantiles are the extremes, not the middle. 0.9, 1.0, 0.1 — the local maximum of pH, the local minimum of NDVI. What predicts Cr is not the average condition around a sample but the most extreme condition in that neighbourhood, which is exactly the information a point value cannot carry.
  • The chosen ranges are wide. Seven of the ten sit at b = 9 km, the widest searched. The signal lives in the broad neighbourhood, and Step 5 puts a number on that.
  • Three surfaces are dropped entirely. Elevation, Slope and Aspect contribute nothing for Cr once pH and NDVI are in — for Cu the paper's panel (b) selects Elevation and Slope instead, so which surfaces matter is a property of the response, not of the study area.
  • Selection is severe, and should be. 440 → 10 with 609 samples. Keeping more would fit better and predict worse; the VIF walk is what stops neighbouring (b, τ) pairs from entering as if they were independent evidence.
Step 4 · the core result

Cross validation — does the second dimension actually predict better?

What this step does

The same samples, the same 70/30 split, the same two models; only the explanatory variables differ. Selection runs on the training set alone, and the test matrix is built with sda_newdata() so the model reads the variables it was fitted on.

Code

R — from R/40-cross-validation.R
sel  <- sda_select(ytr, xtr, ctr.vif = VIF_MAX)          # training only
newx <- sda_newdata(xte)[, names(sel), drop = FALSE]     # aligned test matrix

fit_eval("SDA", "linear model",  lm(y ~ ., cbind(y = ytr, sel)), newx)
fit_eval("FDA", "linear model",  lm(y ~ ., cbind(y = ytr, ftr)), fte)
fit_eval("SDA", "random forest", randomForest(x = sel, y = ytr), newx)
fit_eval("FDA", "random forest", randomForest(x = ftr, y = ytr), fte)

Example output

DimensionModelVariablesRMSE
SDAlinear model90.30020.6496
FDAlinear model80.24220.6760
SDArandom forest90.33250.6345
FDArandom forest80.27520.6611
results/cross-validation.csv — 185 held-out samples. SDA improves R² by 23.9% for the linear model and 20.8% for the random forest, and lowers RMSE in both.
Demo run Observed versus predicted, SDA and FDA, demo
fig02 — the same test samples under both dimensions. The SDA clouds sit closer to the 1:1 line and their fitted slopes are steeper.
Published reference · SDA SDA paper Figure 6, cross validation
SDA paper Fig. 6 — the published counterpart, for Cr (top block) and Cu. Every SDA panel beats its FDA panel: for Cr, MLR 0.184 → 0.325, RF 0.239 → 0.370, RFK 0.286 → 0.379.
How to read this result
  • The comparison is unusually clean. FDA is SDA at zero searching range, so this is one method against its own restriction. The gap is what the neighbourhood adds, not what a different model adds.
  • The gain survives a change of model. Linear +23.9%, random forest +20.8%. A benefit that appears only under one learner is usually a quirk of that learner; this one is in the variables.
  • Nine variables beat eight. The improvement is not from having more columns — the counts are nearly the same. It is from those columns describing neighbourhoods instead of points.
  • Absolute R² stays modest, and that is honest. ≈0.33 on held-out samples for soil geochemistry from environmental surfaces alone. The published case reaches 0.325–0.379 for Cr with the same structure. SDA moves a weak predictor to a less weak one; it does not manufacture a strong one.
Checkpoint

Predictions must land in the range of the response. If they run to hundreds, the test matrix is misaligned with the fitted model — the failure mode described in §2.2, and the reason sda_newdata() exists.

Step 5

Searching range — how far outside the samples is worth looking?

What this step does

b is the parameter that defines "outside", so it deserves a number rather than a default. The model is rebuilt using one searching range at a time, across all eight surfaces.

Code

terminal
Rscript run-all.R 50

Example output

b (km)Variables selectedRMSE
1130.24280.6757
3170.26380.6663
5150.34300.6294
7100.35080.6256
950.33390.6337
results/buffer-sensitivity.csv — each row uses only the variables generated at that searching range.
Demo run R2 against searching range b, demo
fig04 — R² against the searching range, point size showing how many variables survived selection at that range.
How to read this result
  • This is the method's own argument, measured. At b = 1 km the neighbourhood is barely wider than the sample and R² = 0.243 — statistically the same as the first dimension's 0.242. Widen to 7 km and it reaches 0.351. The gain comes from distance, not from having more columns.
  • It turns over. 9 km is worse than 7 km: eventually the circle averages over land that has nothing to do with the sample. There is a scale that matches the process, and this curve is how you find it rather than assume it.
  • Fewer variables survive at wide ranges. 13 at 1 km, 5 at 9 km — wide neighbourhoods overlap between samples, so their variables are more collinear with one another and the VIF walk discards more.
  • Report this curve. A single b with no sensitivity analysis is the first thing a reviewer will question, and it costs one step to answer.

3.4 Reading the Results Together

Three findings, one argument: the information outside the samples is real, it is reachable, and it is worth the cost of reaching it.

It reproduces
Generated variables identical to the published values (2×10⁻¹³), and the selection returns exactly the ten variables of Fig. 5(a).
It predicts better
R² 0.242 → 0.300 (linear) and 0.275 → 0.333 (random forest) on 185 held-out samples, with RMSE down in both.
It has a scale
R² climbs from 0.243 at b = 1 km to 0.351 at 7 km, then falls — the neighbourhood has a size, and it is measurable.
Demo run R2 comparison SDA vs FDA by model
fig03 — the headline, both models: the second dimension is ahead of the first whichever learner is used.
How to interpret — several angles
  • Mechanism. A sample's value is produced by processes with spatial extent — drainage, weathering, land use. Reading one cell of a covariate records where the sample sits; reading the distribution within b km records what the sample sits in. The quantiles chosen here (local maxima of pH, local minima of NDVI) say the informative feature is the extreme of the neighbourhood, which no point value reports.
  • Why quantiles rather than a mean. A mean would compress each neighbourhood to one number and lose exactly the asymmetry the selection is picking up. Sweeping τ from 0 to 1 lets the data say which part of the local distribution matters, and here it answers "the tails".
  • Decision angle. If a dense covariate grid exists — remote sensing, terrain, climate — the second dimension is close to free information: no new fieldwork, one pass over the grid. If the grid is sparse or barely wider than the sample spacing, there is nothing outside to read and FDA is the honest choice.
  • What SDA is not. It is not a spatial-autocorrelation model and it does not interpolate the response; neighbouring responses are never used. It expands the explanatory side only, so it composes with kriging or a spatial random effect rather than replacing them — the published case adds kriging on top (RFK) and gains again.
  • The cost is real and linear. One distance pass per sample over the whole grid. Cheap here (2.3 s), not cheap for 10⁵ samples on a 10-m grid; that is the trade to state in a methods section.
Reading order for your own run

1) results/generation-check.csv — is the generator faithful. 2) results/selected-parameters.csv — which surfaces, ranges and quantiles the data chose, and whether the chosen τ are extremes or middles. 3) results/cross-validation.csv — the SDA-versus-FDA gap, on more than one model. 4) results/buffer-sensitivity.csv — the scale of the neighbourhood.

4 Adaptation, Writing & Reproducibility

4.1 Port to Your Domain

Nothing in the method is specific to geochemistry. It needs a response at points and at least one dense surface covering the area; the family shows how far that travels:

DomainResponseSurfacesSecond dimension used for
Soil geochemistry (Song 2022 — this demo) Cr, Cu concentrationterrain, NDVI, pH, SOC, water, roads quantiles at (b, τ)
Park sentiment (Wang, Wu et al. 2026) sentiment from volunteered textbuilt and social environment quantiles, read for nonlinear and asymmetric effects
Crop production (Ren, Song & Yu 2025; Ren et al. 2026) wheat, barley productionclimate, terrain, land cover local outliers rather than quantiles
Any point-referenced response with a dense covariate grid fits the contract of §2.4.
Published reference · ESDA ESDA flowchart, Wang and Wu et al. 2026
The explainable branch: second-dimension variables fed to a machine-learning model and read with interpretation tools, so the effect of each (b, τ) can be inspected rather than only scored.
Published reference · SOH SOH conceptual framework, Ren et al. 2026
The heterogeneity branch: second-dimension outlier patterns used to explain spatial heterogeneity, with its own tutorial in this series.

What to edit

  1. config/project-config.RPOINT_FILE, GRID_FILE, the coordinate and response column names, and GRID_SURFACES for every surface your grid carries. Set REFERENCE_VARS to those same surfaces so the pipeline uses what it generates rather than the demo's pre-generated sets.
  2. DIST_BUFFERS — the ranges to search, in km. Start from the scale of the process, not from this demo's values, and let Step 5 tell you which range carries the signal.
  3. Nothing else. Run Rscript run-all.R test, then Rscript run-all.R, and read results/selected-parameters.csv before drafting.
!Common pitfalls

Coordinates must be in degrees — the distance function is spherical, so projected metres silently produce nonsense buffers. Select on training data only, and build the test matrix with sda_newdata(...)[, names(sel)]; selecting on everything leaks the test set into the choice of variables. Watch the cell count inside your smallest buffer, because τ = 0 and τ = 1 over a handful of cells are noise. And keep the VIF walk: with 440 near-duplicate candidates, dropping it produces a model that fits well and predicts badly.

4.2 Write the Paper

The published applications share one architecture: a Methods section that defines the second-dimension variable and states (b, τ), a Results section that mirrors the pipeline, and every number pulled from a results/ file.

Manuscript sectionTemplate outputWriting job
Methodsconfig/project-config.R, R/02-sda-core.Rdefine Xb,τ, state the searching ranges and quantiles searched, the selection rule and the VIF threshold, and the split
Resultsfigs/fig01–fig05, tables/table-cross-validation.tex, table-selected-variables.texmirror the pipeline: which (b, τ) were selected → SDA against FDA on held-out data → the searching-range curve; every finding sentence carries a number
Discussionresults/buffer-sensitivity.csv, timing.csvargue the scale the curve identifies, why the chosen quantiles are the ones they are, and state the computational cost honestly
Where each manuscript section draws its material from.

Manuscript skeleton (shared by the published applications)

manuscript outline
1  Introduction       — spatial association from sample values; the information
                         outside samples; the contribution (3 aims)
2  SDA model          — 2.1 First and second dimensions (concept, no case data)
                         2.2 Second-dimension variables X^{b,tau} (all formulas)
                         2.3 Variable selection (correlation ranking, VIF)
                         2.4 Modelling and validation design
3  Case & data        — study area, response, surfaces, grid resolution
4  Results            — 4.1 selected variables in (b, tau) space
                         4.2 SDA against FDA, more than one model
                         4.3 sensitivity to the searching range
                         4.4 spatial prediction over the grid
5  Discussion         — the scale of the neighbourhood; why those quantiles;
                         computational cost; what SDA does not do
6  Conclusions        — 6-8 sentences answering the aims

Results-to-manuscript map

Manuscript elementSource fileShown in
Selected variables in (b, τ)figs/fig01, tables/table-selected-variables.tex§4.1
Main table (SDA vs FDA)tables/table-cross-validation.tex§4.2
Observed vs predictedfigs/fig02, fig03§4.2
Searching-range curvefigs/fig04, results/buffer-sensitivity.csv§4.3
Computational costfigs/fig05, results/timing.csv§5 / response to reviewers
Every manuscript element traces to one regenerable file.

4.3 Final Reproducibility Package

Code
R/02-sda-core.R (the method, base R) + the numbered pipeline + run-all.R + tests/
Data
data/obs.csv, data/grids.csv, the published variable sets in data/reference/, with provenance.md
Results
figs/fig01–fig05 + tables/*.tex + env/session-info.txt
Before claiming reproducible
  • Rscript run-all.R test passes — 21 checks, including that the generated variables equal the published values and the selection equals Fig. 5(a).
  • Delete results/, tables/, figs/ and data/derived/, re-run Rscript run-all.R — every number and figure on this page regenerates (~6 s).
  • The searching ranges, quantiles, VIF threshold, split fraction and seed are stated in the manuscript and match config/project-config.R.
  • Variable selection is reported as having been done on training data only.
  • env/session-info.txt is included in the deposit.
Anticipate the reviewer

Is this just adding more variables? → no: nine SDA variables against eight FDA variables, and the b = 1 km row of buffer-sensitivity.csv scores the same as FDA, so the gain is distance, not count. Why that searching range?figs/fig04. Did selection see the test set? → no, Step 4 selects inside the training split. Is the improvement model-specific? → no, it holds for a linear model and a random forest. How expensive is it?results/timing.csv.

References & credits

Demo outputs were generated by the scripts in this folder. Reference figures are reproduced from the cited articles for comparison; the SDA article is open access under CC BY-NC-ND, and the remaining figures are © their authors and publishers. The method implementation follows the author's SecDim package, with the two corrections described in §2.2.

  1. Song Y (2022). The second dimension of spatial association. International Journal of Applied Earth Observation and Geoinformation 111:102834. doi:10.1016/j.jag.2022.102834 · PDF (source of the concept, schematic, Fig. 5 and Fig. 6 reference images, and of the case reproduced here)
  2. Song Y (2023). SecDim: The Second Dimension of Spatial Association. R package version 3.2. CRAN archive (the reference implementation and the source of the demo data)
  3. Wang X, Wu Y, Shi T, Zhang R, Chen Y, Song Y (2026). Explainable second-dimension spatial association between park sentiment and built-social environments. PDF
  4. Ren K, Song Y, Yu Q (2025). Second-dimension outliers for spatial prediction. International Journal of Geographical Information Science. PDF
  5. Ren K, Song Y, Yang X, Wang X, Chen M, Yu Q (2026). Spatial outliers as a pattern determinant for explaining heterogeneity. International Journal of Geographical Information Science. PDF · code · tutorial