Reproducing the Local Pattern Interaction (LPI) Model

A complete walkthrough of the Local Pattern Interaction model — local stratified power (LISP) combined with geocomplexity (GC) patterns — for spatially heterogeneous driver analysis, from raw data to manuscript.
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 & example data (zip, 71 KB)
R/ pipeline scripts · data/processed/analysis-table.csv (600-unit demo) · config/project-config.yaml — unzip, then run Rscript R/00-setup.R from the LPI/ folder root.
To cite the LPI model and its R packages and codes in publications, please use:
Sun Y, Wu G, Song Y, et al. (2026). Local effects of pattern interactions in driving urbanization. Int J Appl Earth Obs Geoinf 146:105072. doi · PDF
Hu J, Song Y, Zhang T (2024). A local indicator of stratified power. IJGIS. doi · PDF
Song Y, Wang J, Ge Y, Xu C (2020). An optimal parameters-based geographical detector model… GIScience & Remote Sensing 57(5):593–610. doi · PDF
Luo P, Song Y, et al. (2022). …geographically optimal zones-based heterogeneity model. ISPRS JPRS 185:111–128. doi · PDF
Zhang Z, Song Y, Luo P, Wu P (2023). Geocomplexity explains spatial errors. IJGIS 37(7):1449–1469. doi · PDF

1 Method Overview & Reproduction Scope

1.1 Core Idea

Given an observed response variable y and explanatory variables x1..xp recorded at n spatial units, the LPI (Local Pattern Interaction) model answers four questions that a single global statistic cannot:

  1. Where does each variable explain the response? (local q-statistic maps)
  2. Do the spatial patterns of the variables — their geocomplexity — explain the response beyond the variable values themselves?
  3. Which pairs of variables and patterns interact, of what type (enhance / weaken), and where?
  4. How much do all variables jointly explain at each location (GOZH total effect), benchmarked against the global OPGD value?
Reader anchor

Treat the spatial pattern of every variable — how much its local arrangement contradicts spatial dependence — as an explanatory variable in its own right, then measure explanatory power locally: where each driver acts, where patterns amplify it, and how much everything jointly explains at each place. Interactions then come in three categories: variable × variable, variable × GC, and GC × GC.

1.2 Method Logic

All power-of-determinant (PD) quantities in this family are variants of q = 1 − SSW/SST (within-strata over total variance). LPI composes four earlier building blocks:

GD 2010geographical detector: global q per stratified variable
Wang et al., IJGIScited
OPGD 2020optimises discretisation & scale parameters
Song et al., GISci RS · PDF
GOZH 2022tree-based optimal zones; reliable multi-variable interaction
Luo et al., ISPRS JPRS · PDF
GEOC 2023geocomplexity indicator Gi; explains model errors
Zhang et al., IJGIS · PDF
LISP 2024makes stratified power local via semivariogram extents
Hu et al., IJGIS · PDF
LPI 2026LISP over {variables ∪ GC patterns} + pattern interactions
Sun et al., JAG · PDFCC BY

Key concepts and equations

Six building blocks drive the whole pipeline, one from each reference:

1.3 Reproduction Scope

Everything tagged Demo run on this page regenerates from the scripts and data in this folder; everything tagged Published reference is shown as a static comparison image or table from the two source articles. The demo runs end to end with R 4.6.0.

ItemDemo templatePublished reference
Data 600 simulated point units, 6 predictors (data/processed/analysis-table.csv, seed 42) LPI: counties of Shanxi–Henan–Hebei, composite urbanization index, 5 economic variables · LISP: gridded lake-terminating glacier units, Greater Himalayas
Tables results/step08-validation-table.csv and robustness summaries, regenerated by the scripts LPI Table 4 and LISP Table 2, shown as excerpts beside the demo table in §3.3
Figures fig01–fig08 PDFs regenerated by R/11-figures.R (web copies in assets/) LPI Figs 3–10 and LISP Figs 2, 4–6, embedded as static reference images
What the template regenerates versus what is shown as published reference.

Published applications of this exact pipeline

ApplicationSpatial unitResponsePredictorsStatus
Urbanization drivers, Shanxi–Henan–Hebei, China (Sun et al. 2026, LPI — reference figures on this page) CountyComposite urbanization index (9 indicators, 5 dimensions) 5 economic variables + their 5 GC patternsPublished (JAG, CC BY)
Glacier thickness change of lake-terminating glaciers, Greater Himalayas (Hu et al. 2024, LISP — reference figures on this page) Gridded glacier unitObserved thickness change 2000–2020 (direct) 8 climate, terrain and environment variablesPublished (IJGIS)
Synthetic demonstration (this page — all demo outputs) 600 pointsObserved response y (direct) 6 variables + 6 GC patternsReproducible demo
Two published applications share the pipeline; the synthetic demo makes it reproducible without external data.

How to use this page

2 Setup, Structure & Data Contract

2.1 Environment & Dependencies

The pipeline is pure R. The pipeline was run with R 4.6.0 and CRAN builds of all packages; the complete environment of that run is recorded in results/session-info.txt. Install everything with the setup script below, or directly from the R console:

R console
install.packages(c("yaml", "sf", "sdsfun", "geocomplexity", "gdverse",
                   "FNN", "rpart", "automap", "dplyr",
                   "ggplot2", "reshape2", "mgcv", "scales", "patchwork",
                   "ggrepel", "localsp"))
# if localsp is unavailable on CRAN for your R version:
# remotes::install_github("stscl/localsp")
PackageRole in the pipelineRequired?
geocomplexitygeocd_vector() — GC pattern of each variable (Step 2)yes
localsplisp() — LISP local factor detector (Step 4)yes
gdversegd() — geographical-detector q for interactions and benchmarks (Steps 5–7)yes
sdsfunspatial weights; in-window quantile discretisationyes
automapautofitVariogram() — local analysis extent (Step 3)yes
rpart, FNNGOZH decision-tree zoning; k-nearest neighbourhoods (Step 6)yes
sf, dplyr, yaml, parallelspatial data, wrangling, config, parallel loopsyes
ggplot2, patchwork, reshape2, mgcv, ggrepel, scalesfigures (Step 10)figures only
Package roles. The three method packages (geocomplexity, localsp, gdverse) carry the statistical core; the rest are infrastructure.

The setup script checks and installs every dependency, then records the session:

R/00-setup.R
# =============================================================================
# 00-setup.R — check and install required packages
# Run once: Rscript R/00-setup.R
# =============================================================================

cran_packages <- c(
  "yaml",          # config loading
  "sf",            # spatial data
  "sdsfun",        # spatial weights, discretize_vector
  "geocomplexity", # geocd_vector — GC patterns
  "gdverse",       # gd() — geographical detector q-statistic
  "FNN",           # k-nearest neighbours
  "rpart",         # GOZH decision tree
  "automap",       # autofitVariogram — local range
  "dplyr",
  "parallel",
  # figures
  "ggplot2", "reshape2", "mgcv", "scales", "patchwork", "ggrepel"
)

# localsp provides lisp(), the LISP factor detector.
# If unavailable on CRAN for your R version, install from GitHub:
#   remotes::install_github("stscl/localsp")
other_packages <- c("localsp")

missing <- setdiff(cran_packages, rownames(installed.packages()))
if (length(missing) > 0) {
  cat("Installing:", paste(missing, collapse = ", "), "\n")
  install.packages(missing)
}

for (pkg in other_packages) {
  if (!pkg %in% rownames(installed.packages())) {
    cat(sprintf("Package '%s' missing — trying CRAN, then GitHub.\n", pkg))
    tryCatch(install.packages(pkg), error = function(e) NULL)
    if (!pkg %in% rownames(installed.packages())) {
      if (!"remotes" %in% rownames(installed.packages())) {
        install.packages("remotes")
      }
      remotes::install_github(paste0("stscl/", pkg))
    }
  }
}

cat("\nAll packages present:\n")
for (pkg in c(cran_packages, other_packages)) {
  ok <- requireNamespace(pkg, quietly = TRUE)
  cat(sprintf("  %-14s %s\n", pkg, ifelse(ok, "OK", "MISSING")))
}

# Record the environment for reproducibility.
dir.create("results", showWarnings = FALSE)
writeLines(capture.output(sessionInfo()), "results/session-info.txt")
cat("\nSession info written to results/session-info.txt\n")
All packages present: yaml OK sf OK sdsfun OK geocomplexity OK gdverse OK ... localsp OK Session info written to results/session-info.txt
!If localsp is not on CRAN for your R version

Install it from GitHub instead: remotes::install_github("stscl/localsp"). The pipeline was run with R 4.6.0 and CRAN builds of all packages (fresh installs needed for yaml, automap, localsp).

2.2 Project Structure & Configuration

Every script runs from the folder root (Rscript R/03-geocomplexity.R), reads the config plus the results/step*.csv files of earlier steps, and can therefore be re-run in isolation at any time. Deleting results/ and figures/ and re-running the scripts regenerates everything shown on this page.

folder tree
LPI/
├── lpi.html                            # this guide
├── assets/                             # figures shown in this guide (web copies)
├── config/project-config.yaml          # the ONLY file to edit for a new domain
├── data/processed/analysis-table.csv   # simulated example data (600 units)
├── R/
│   ├── functions/                      # method core: fn-config.R,
│   │                                   #   fn-lpi-core.R, fn-plot-helpers.R
│   └── 00-setup.R … 11-figures.R       # numbered pipeline scripts
├── results/                            # step02–step10 CSVs + session-info.txt
└── figures/                            # fig01–fig08 PDFs of the run

Main configuration file

config/project-config.yaml
# =============================================================================
# LPI project configuration — the single file to edit when porting the
# pipeline to a new domain. Every R script reads this file; none of them
# hard-code variable names, coordinates, or parameters.
#
# Method: LPI (Local Pattern Interaction) = LISP local stratified power
#         + geocomplexity (GC) patterns + local interactions + GOZH total effect
# =============================================================================

project:
  name: "lpi-application-template"
  domain: "example"              # short label for your application domain
  crs_input: 4326                # CRS of the coordinates in the analysis table
  crs_projected: 32650           # metric CRS for distance computation — EDIT
                                 # to a projected CRS suited to your region
                                 # (units must be metres; the demo data sit in
                                 # UTM zone 50N = 32650)

data:
  use_example: true              # true  = 01-prepare-data.R simulates a demo
                                 #         dataset so the pipeline runs end to end
                                 # false = 01-prepare-data.R reads your raw data
                                 #         (edit that script for your source format)
  analysis_table: "data/processed/analysis-table.csv"
  boundary: ""                   # optional study-area boundary for maps, e.g.
                                 # "data/boundary/study-area.gpkg" ("" = no boundary)
  id_col: "uid"
  x_col: "lon"                   # longitude / easting column in analysis table
  y_col: "lat"                   # latitude / northing column in analysis table

response:
  direct_col: "y"                # observed response column in the analysis table
  response_name: "z"             # standard internal name used by the pipeline

predictors:
  x_vars: ["x1", "x2", "x3", "x4", "x5", "x6"]
  # Display labels used in every figure (keep short; GC labels are derived
  # automatically as "GC(<label>)").
  labels:
    x1: "Variable 1"
    x2: "Variable 2"
    x3: "Variable 3"
    x4: "Variable 4"
    x5: "Variable 5"
    x6: "Variable 6"

params:
  gc:                            # Step 03 geocomplexity
    knn_share: 0.02              # k = max(knn_min, knn_share * n)
    knn_min: 30
    method: "spvar"              # geocomplexity::geocd_vector method
  lisp:                          # Steps 04-05 local range + local q
    threshold_multiplier: 2      # local extent = multiplier * variogram range
    discnum: 4                   # discretisation bins inside local windows
    discmethod: "quantile"
  interaction:                   # Step 06 local pairwise interactions
    top_n_vars: 5                # pairs are formed among the top-N variables
                                 # by mean local q (N=5 gives 10 pairs)
    min_local_n: 10              # fallback neighbourhood size if the distance
                                 # circle contains fewer points
  global:                        # Step 08 global benchmark (OPGD-style)
    n_strata: 5
    top_n_interactions: 10
  cores: "auto"                  # "auto" = detectCores() - 1, or an integer

validation:
  holdout:                       # Step 09 subsample stability
    n_repeats: 20
    drop_share: 0.05
    top_n_vars: 10
    seed: 2026
  sensitivity:                   # Step 10 parameter sensitivity
    threshold_multipliers: [1.5, 2, 3]
    discnums: [3, 4, 5]
    top_n_vars: 5

figures:
  # Fixed six-class palette for quantile-binned maps (colour-method mapping
  # stays constant across all figures).
  palette: ["#1F6B4A", "#4F9C93", "#8FC8BE", "#E6A8B6", "#D85A8E", "#8F1E5F"]
  interaction_colors:
    xx: "#1F6B4A"                # X x X pairs
    xgc: "#8FC8BE"               # X x GC pairs
    gcgc: "#8F1E5F"              # GC x GC pairs
  point_size: 0.6
  width_single: 5                # inches, single-panel figures
  width_full: 14                 # inches, four-panel map rows
Single adaptation point

The config is the only file to edit when porting the pipeline to a new domain — no R script hard-codes variable names, coordinates, or parameters. Every script loads it through the shared loader below. When writing the manuscript, quote parameter values from this file, never from memory.

R/functions/fn-config.R — shared loader, sourced by every script
# =============================================================================
# fn-config.R — configuration loader and small shared utilities
# All pipeline scripts source this file first and run from the project root.
# =============================================================================

load_config <- function(path = "config/project-config.yaml") {
  if (!file.exists(path)) {
    stop("Config not found at '", path,
         "'. Run scripts from the project root, e.g. Rscript R/03-geocomplexity.R")
  }
  yaml::read_yaml(path)
}

get_cores <- function(cfg) {
  if (identical(cfg$params$cores, "auto")) {
    max(1L, parallel::detectCores() - 1L)
  } else {
    as.integer(cfg$params$cores)
  }
}

ensure_dir <- function(path) {
  dir.create(path, showWarnings = FALSE, recursive = TRUE)
  invisible(path)
}

read_analysis_table <- function(cfg) {
  path <- cfg$data$analysis_table
  if (!file.exists(path)) {
    stop("Analysis table not found at '", path,
         "'. Run R/01-prepare-data.R first.")
  }
  read.csv(path)
}

# Column-name helpers -----------------------------------------------------

gc_names_of <- function(cfg) paste0("gc_", cfg$predictors$x_vars)

all_vars_of <- function(cfg) c(cfg$predictors$x_vars, gc_names_of(cfg))

# Named label vectors for figures: x labels from config, GC labels derived.
get_labels <- function(cfg) {
  x_vars   <- cfg$predictors$x_vars
  x_labels <- unlist(cfg$predictors$labels)[x_vars]
  gc_labels <- setNames(paste0("GC(", x_labels, ")"), gc_names_of(cfg))
  c(x_labels, gc_labels)
}

# Read the response (with coordinates) produced by 02-response-intake.R
read_response <- function(cfg) {
  path <- "results/step02-response.csv"
  if (!file.exists(path)) {
    stop("Response file not found at '", path,
         "'. Run R/02-response-intake.R first.")
  }
  read.csv(path)
}

# Read the local extent estimated by 04-local-range.R
read_local_range <- function(cfg) {
  path <- "results/step04-local-range.csv"
  if (!file.exists(path)) {
    stop("Local range file not found at '", path,
         "'. Run R/04-local-range.R first.")
  }
  read.csv(path)
}

# Assemble the modelling frame: analysis table + response + geocomplexity.
# Every script from Step 05 onward starts from this call, so any script can
# be re-run in isolation without recomputing upstream steps.
build_model_frame <- function(cfg) {
  d  <- read_analysis_table(cfg)
  rz <- read_response(cfg)
  d[[cfg$response$response_name]] <- rz[[cfg$response$response_name]]

  gc_path <- "results/step03-geocomplexity.csv"
  if (file.exists(gc_path)) {
    gc <- read.csv(gc_path)
    for (v in gc_names_of(cfg)) d[[v]] <- gc[[v]]
  }
  d
}

Key parameters and where they come from

ParameterDefaultOrigin / rationale
lisp$threshold_multiplier2LISP defines the local extent as d = 2 × semivariogram range (Hu et al. 2024); sensitivity in Step 9
lisp$discnum, discmethod4, quantilein-window discretisation used by both published applications; sensitivity in Step 9
gc$knn_share, knn_min2%, 30neighbourhood for GC and local GOZH: k = max(30, 0.02·n)
interaction$top_n_vars5pairs formed among top-N variables by mean local q (N=5 → 10 pairs) to keep the local loop tractable
global$n_strata5equal-interval strata for the OPGD-style global benchmark
validation$holdout20 × 5%subsample-stability protocol (drop 5%, repeat 20×, fixed seed)
Free parameters, their defaults, and the published rules they follow.

2.3 Data Contract

The whole pipeline consumes one analysis-ready table with one row per spatial unit. Units can be anything point-referenced: counties (urbanization application), 1-km road segments, grid cells (glacier application), stations, polygon centroids. Comfortable sample sizes are roughly 300 ≤ n ≤ 10,000; no missing values (the preparation script keeps complete cases and documents any imputation).

ColumnConfig keyTypeMeaningRequired?
uiddata$id_colinteger / stringunique unit idyes
lon, latdata$x_col, data$y_colnumericcoordinates in project$crs_input (demo: EPSG:4326)yes
yresponse$direct_colnumericthe observed response variable (raw observation, one value per unit)yes
x1…x6predictors$x_varsnumericcontinuous explanatory variables (5–15 recommended)yes
Required schema of data/processed/analysis-table.csv. Column names are free — they are declared in the config, not hard-coded.

Example analysis-ready table

uidlonlaty x1x2x3x4x5x6
1116.91536.4480.789−0.1171.0150.6131.964−1.468−0.531
2116.93736.2540.765−0.5620.067−0.8890.649−1.189−1.561
3116.28636.6990.626−0.8950.6181.723−0.429−1.2870.291
4116.83036.2730.680−0.9400.193−0.5320.446−1.255−1.272
First rows of the demo table. 600 rows total; y is the observed response, taken as-is. Generated by the example branch of 01-prepare-data.R with set.seed(42).

The synthetic ground truth (why the demo is a good test)

The demo generator plants a known spatial structure, so every downstream result can be checked against a "right answer":

Preparation script (demo mode + real-data mode)

R/01-prepare-data.R
# =============================================================================
# 01-prepare-data.R — build the analysis table
#
# Output: data/processed/analysis-table.csv with EXACTLY this schema:
#   <id_col>   unique unit id                     (config: data$id_col)
#   <x_col>    longitude or easting               (config: data$x_col)
#   <y_col>    latitude or northing               (config: data$y_col)
#   y          the observed response variable     (config: response$direct_col)
#   x1..xp     the explanatory variables listed in predictors$x_vars
#
# Two modes (config: data$use_example):
#   true  — simulate a synthetic demo dataset so the whole pipeline runs
#           end to end with no external data (recommended first run)
#   false — adapt the marked section below to your own raw data
# =============================================================================

source("R/functions/fn-config.R")
cfg <- load_config()
ensure_dir("data/processed")

if (isTRUE(cfg$data$use_example)) {

  # ---------------------------------------------------------------------------
  # EXAMPLE MODE — synthetic spatial dataset (n = 600 point units)
  # Design: two latent regimes (west/east) with different dominant drivers,
  # so local q values genuinely vary across space; x5 x x6 carry a joint
  # (interaction) signal; y is the directly observed response.
  # ---------------------------------------------------------------------------
  set.seed(42)
  n <- 600

  lon <- runif(n, 116.0, 117.0)   # ~100 km east-west extent
  lat <- runif(n, 36.0, 36.9)

  east <- (lon - min(lon)) / diff(range(lon))   # 0 west -> 1 east
  north <- (lat - min(lat)) / diff(range(lat))

  smooth_field <- function(fx, fy, phase = 0, noise = 0.15) {
    field <- sin(2 * pi * fx * east + phase) * cos(2 * pi * fy * north) +
      0.5 * north
    as.numeric(scale(field + rnorm(n, 0, noise)))
  }

  x1 <- smooth_field(1.0, 0.5)
  x2 <- smooth_field(0.5, 1.0, phase = 1)
  x3 <- smooth_field(1.5, 0.7, phase = 2)
  x4 <- as.numeric(scale(east + rnorm(n, 0, 0.3)))
  x5 <- smooth_field(0.8, 1.2, phase = 3)
  x6 <- smooth_field(1.2, 0.4, phase = 4)

  # Observed response: driver x1 dominates in the west, x2 in the east,
  # x5*x6 adds a nonlinear-enhance interaction, x3 a weak global effect.
  latent <- (1 - east) * 1.2 * x1 + east * 1.2 * x2 +
    0.8 * x5 * x6 + 0.3 * x3 + rnorm(n, 0, 0.4)
  y_obs <- (latent - min(latent)) / diff(range(latent))

  d <- data.frame(
    uid = seq_len(n),
    lon = lon,
    lat = lat,
    y   = y_obs,
    x1 = x1, x2 = x2, x3 = x3, x4 = x4, x5 = x5, x6 = x6
  )

  write.csv(d, cfg$data$analysis_table, row.names = FALSE)
  cat(sprintf("Example analysis table written: %s (%d rows)\n",
              cfg$data$analysis_table, nrow(d)))
  cat("Set data$use_example to false in config/project-config.yaml\n")
  cat("and adapt this script once your own data are ready.\n")

} else {

  # ---------------------------------------------------------------------------
  # REAL-DATA MODE — adapt this section to your domain.
  # Typical steps:
  #   1. read raw files from data/raw/
  #   2. aggregate to your spatial unit (grid cell, county, segment, station)
  #   3. compute or join coordinates for each unit
  #   4. rename columns to match config (id, coords, response, x_vars)
  #   5. drop units with missing coordinates; document any imputation
  # ---------------------------------------------------------------------------
  raw_path <- "data/raw/raw-data.csv"   # EDIT: your raw source file
  if (!file.exists(raw_path)) {
    stop("Real-data mode: expected raw data at '", raw_path,
         "'. Edit R/01-prepare-data.R for your source format.")
  }
  raw <- read.csv(raw_path)

  # EDIT from here: aggregation and renaming for your domain. The example
  # below simply passes columns through and keeps complete rows.
  needed <- c(cfg$data$id_col, cfg$data$x_col, cfg$data$y_col,
              cfg$response$direct_col, cfg$predictors$x_vars)
  missing_cols <- setdiff(needed, names(raw))
  if (length(missing_cols) > 0) {
    stop("Raw data lacks required columns: ",
         paste(missing_cols, collapse = ", "),
         ". Adapt R/01-prepare-data.R or config/project-config.yaml.")
  }

  d <- raw[complete.cases(raw[, needed]), needed]
  write.csv(d, cfg$data$analysis_table, row.names = FALSE)
  cat(sprintf("Analysis table written: %s (%d rows, %d dropped as incomplete)\n",
              cfg$data$analysis_table, nrow(d), nrow(raw) - nrow(d)))
}
Real-data checklist (replace the example branch)

Read raw files from data/raw/ → aggregate to your spatial unit (the glacier application built gridded units from remote-sensing products; the urbanization application joined county yearbook tables) → attach one coordinate pair per unit → rename columns to the config names → keep complete cases. Then set data$use_example: false. The script validates that every configured column exists before writing.

!Common data gotcha

project$crs_projected must be a metric CRS suited to your region — a projected CRS in degrees silently breaks the variogram range and every local window downstream. Check the response distribution before modelling too: heavy skew or a few extreme units will dominate in-window variance and inflate local q around them (consider a transform, and say so in the manuscript).

3 Reproduction Pipeline, Results & Validation

3.1 Pipeline Execution

Eleven numbered scripts run from the folder root. Steps 1–6 are the analytical core, Steps 08–10 the validation layer, Step 10 regenerates all figures from results/ only. Each step writes its outputs to results/step*.csv, so any step can be re-run in isolation without recomputing upstream steps.

Full run
R/00-setup.R → R/11-figures.R
Results folder
results/
Figures folder
figures/
terminal
cd LPI                       # this folder
Rscript R/00-setup.R         # once: install packages + record session info
for s in R/0[1-9]*.R R/1*.R; do Rscript "$s"; done
Example analysis table written: data/processed/analysis-table.csv (600 rows) Direct response 'y' copied to 'z'. Local neighbourhood size k = 30 (2% of n = 600) Variogram range = 22407 m (22.41 km) Local extent (threshold) = 44814 m (44.81 km) [multiplier = 2] LISP factor detector: 12 variables (6 X + 6 GC), 600 obs, 13 cores Running local interaction for 10 pairs x 600 locations... Global GOZH q = 0.6908 p = 5.819e-10 Saved: results/step08-global-q.csv, results/step08-validation-table.csv Spearman rank preservation: median = 0.988, min = 0.927 === All figures saved to figures/ ===

3.2 Core Analytical Steps

Six stages, each with the same anatomy: purpose → script → output → result → how to read it. Steps are numbered 1–10 on this page while the script files keep their own numbers (Step 1 runs R/02-response-intake.R) — R/00 is setup and R/01 builds the analysis table. The method core lives in one file sourced by all stages:

R/functions/fn-lpi-core.R — spatial scaffolding, LISP wrapper, interaction detector, GOZH, global q
# =============================================================================
# fn-lpi-core.R — the generalizable LPI method core
# Source: cc005 LPI pipeline (main_analysis.R), generalized and config-driven.
#
# Components (in pipeline order):
#   build_spatial()          projected coordinates + full distance matrix
#   estimate_local_range()   semivariogram range -> local extent (threshold)
#   run_lisp_factor()        LISP local q per variable (localsp::lisp)
#   run_local_interaction()  local pairwise interaction q + 5-class typing
#   run_interactions_all()   parallel loop of run_local_interaction()
#   run_gozh()               GOZH total effect: rpart zones -> gdverse::gd
#   run_local_gozh()         local GOZH over k-nearest neighbourhoods
#   run_global_gd()          OPGD-style global q for one variable
#   run_global_interaction_q()  global q for one variable pair
# =============================================================================

# --- Spatial scaffolding -----------------------------------------------------

# Projected coordinate matrix (metres) and full pairwise distance matrix.
build_spatial <- function(d, cfg) {
  sf_pts  <- sf::st_as_sf(d, coords = c(cfg$data$x_col, cfg$data$y_col),
                          crs = cfg$project$crs_input)
  sf_proj <- sf::st_transform(sf_pts, crs = cfg$project$crs_projected)
  coords_mat <- sf::st_coordinates(sf_proj)
  list(sf_proj    = sf_proj,
       coords_mat = coords_mat,
       distmat    = as.matrix(dist(coords_mat)))
}

# Semivariogram of the response on projected coordinates; the local extent
# (distance threshold) is multiplier * range, following LISP (Hu et al. 2024).
estimate_local_range <- function(d, cfg, spatial = NULL) {
  if (is.null(spatial)) spatial <- build_spatial(d, cfg)
  response <- cfg$response$response_name
  sp_obj <- as(spatial$sf_proj, "Spatial")
  v <- automap::autofitVariogram(
    as.formula(paste(response, "~ 1")), sp_obj
  )
  vg_range  <- v$var_model$range[2]
  threshold <- vg_range * cfg$params$lisp$threshold_multiplier
  list(variogram = v, range = vg_range, threshold = threshold)
}

# --- LISP factor detector ------------------------------------------------

# Local q (power of determinant) of every variable at every location.
# Returns list(q = data.frame, sig = data.frame) with NA imputed:
# q -> 0 (no explanatory power), p -> 1 (not significant).
run_lisp_factor <- function(d, cfg, vars, threshold, distmat,
                            discnum = NULL, cores = NULL) {
  response <- cfg$response$response_name
  if (is.null(discnum)) discnum <- cfg$params$lisp$discnum
  if (is.null(cores))   cores   <- get_cores(cfg)

  d_lisp <- d[, c(response, vars)]
  lisp_result <- localsp::lisp(
    formula    = as.formula(paste(response, "~ .")),
    data       = d_lisp,
    threshold  = threshold,
    distmat    = distmat,
    discnum    = discnum,
    discmethod = cfg$params$lisp$discmethod,
    cores      = cores
  )
  lisp_df <- as.data.frame(lisp_result)

  pd_cols  <- grep("^pd_",  names(lisp_df), value = TRUE)
  sig_cols <- grep("^sig_", names(lisp_df), value = TRUE)

  base_cols <- data.frame(
    LocationID = seq_len(nrow(d)),
    Longitude  = d[[cfg$data$x_col]],
    Latitude   = d[[cfg$data$y_col]]
  )
  local_q   <- cbind(base_cols, lisp_df[, pd_cols,  drop = FALSE])
  local_sig <- cbind(base_cols, lisp_df[, sig_cols, drop = FALSE])
  names(local_q)[-(1:3)]   <- sub("^pd_",  "", pd_cols)
  names(local_sig)[-(1:3)] <- sub("^sig_", "", sig_cols)

  var_cols <- setdiff(names(local_q), c("LocationID", "Longitude", "Latitude"))
  local_q[var_cols]   <- lapply(local_q[var_cols],
                                function(x) { x[is.na(x)] <- 0;   x })
  local_sig[var_cols] <- lapply(local_sig[var_cols],
                                function(x) { x[is.na(x)] <- 1.0; x })

  list(q = local_q, sig = local_sig)
}

# --- Local pairwise interaction detector ----------------------------------

classify_interaction <- function(qv1, qv2, qv12) {
  dplyr::case_when(
    qv12 < min(qv1, qv2)                          ~ "Weaken, nonlinear",
    qv12 >= min(qv1, qv2) & qv12 < max(qv1, qv2)  ~ "Weaken, uni-",
    qv12 == qv1 + qv2                             ~ "Independent",
    qv12 > max(qv1, qv2) & qv12 < qv1 + qv2       ~ "Enhance, bi-",
    qv12 > qv1 + qv2                              ~ "Enhance, nonlinear",
    TRUE                                          ~ NA_character_
  )
}

# One location: local window by distance threshold (fallback: min_local_n
# nearest points), quantile discretization of each pair member, single and
# combined-zone q via gdverse::gd, then 5-class interaction typing.
run_local_interaction <- function(i, data, distmat, threshold,
                                  response, var_pairs, discnum = 4,
                                  min_local_n = 10) {
  dists     <- distmat[i, ]
  local_idx <- which(dists <= threshold)
  if (length(local_idx) < min_local_n) {
    local_idx <- order(dists)[1:min(min_local_n, nrow(data))]
  }
  local_data <- data[local_idx, , drop = FALSE]

  empty_row <- function(v1, v2) {
    data.frame(var1 = v1, var2 = v2,
               qv1 = NA, qv2 = NA, qv12 = NA, pv12 = NA,
               interaction = NA, LocationID = i, stringsAsFactors = FALSE)
  }

  pair_results <- lapply(var_pairs, function(pair) {
    v1 <- pair[1]; v2 <- pair[2]
    if (length(unique(local_data[[v1]])) < 2 ||
        length(unique(local_data[[v2]])) < 2) {
      return(empty_row(v1, v2))
    }

    tryCatch({
      disc_v1 <- tryCatch(
        sdsfun::discretize_vector(local_data[[v1]], n = discnum,
                                  method = "quantile"),
        error = function(e) NULL)
      disc_v2 <- tryCatch(
        sdsfun::discretize_vector(local_data[[v2]], n = discnum,
                                  method = "quantile"),
        error = function(e) NULL)
      if (is.null(disc_v1) || is.null(disc_v2)) stop("discretization failed")

      tmp <- local_data
      tmp[["disc_v1"]]  <- disc_v1
      tmp[["disc_v2"]]  <- disc_v2
      tmp[["disc_v12"]] <- paste(disc_v1, disc_v2, sep = "_")

      y <- tmp[[response]]
      if (sum((y - mean(y))^2) == 0) stop("zero total SS")

      gd_v1  <- gdverse::gd(as.formula(paste(response, "~ disc_v1")),  data = tmp)
      gd_v2  <- gdverse::gd(as.formula(paste(response, "~ disc_v2")),  data = tmp)
      gd_v12 <- gdverse::gd(as.formula(paste(response, "~ disc_v12")), data = tmp)

      qv1  <- as.numeric(gd_v1$factor[["Q-statistic"]])
      qv2  <- as.numeric(gd_v2$factor[["Q-statistic"]])
      qv12 <- as.numeric(gd_v12$factor[["Q-statistic"]])
      pv12 <- as.numeric(gd_v12$factor[["P-value"]])

      data.frame(var1 = v1, var2 = v2,
                 qv1 = qv1, qv2 = qv2, qv12 = qv12, pv12 = pv12,
                 interaction = classify_interaction(qv1, qv2, qv12),
                 LocationID = i, stringsAsFactors = FALSE)
    }, error = function(e) empty_row(v1, v2))
  })

  do.call(rbind, pair_results)
}

# Parallel driver over all locations. Returns one long data.frame.
run_interactions_all <- function(d, cfg, var_pairs, distmat, threshold) {
  response    <- cfg$response$response_name
  discnum     <- cfg$params$lisp$discnum
  min_local_n <- cfg$params$interaction$min_local_n
  n_cores     <- get_cores(cfg)

  worker <- function(i) {
    run_local_interaction(
      i = i, data = d, distmat = distmat, threshold = threshold,
      response = response, var_pairs = var_pairs,
      discnum = discnum, min_local_n = min_local_n
    )
  }

  if (n_cores > 1) {
    cl <- parallel::makeCluster(n_cores)
    on.exit(parallel::stopCluster(cl), add = TRUE)
    parallel::clusterExport(
      cl,
      varlist = c("d", "distmat", "threshold", "response", "var_pairs",
                  "discnum", "min_local_n",
                  "run_local_interaction", "classify_interaction"),
      envir = environment()
    )
    parallel::clusterEvalQ(cl, {
      library(gdverse); library(sdsfun); library(dplyr)
    })
    inter_list <- parallel::parLapply(cl, seq_len(nrow(d)), worker)
  } else {
    inter_list <- lapply(seq_len(nrow(d)), function(i) {
      if (i %% 200 == 0) cat(sprintf("  interaction: location %d / %d\n",
                                     i, nrow(d)))
      worker(i)
    })
  }

  do.call(rbind, Filter(Negate(is.null), inter_list))
}

# --- GOZH total effect -----------------------------------------------------

# rpart regression tree on all predictors -> leaf zones -> combined-zone q.
run_gozh <- function(data, response, predictors) {
  formula_tree <- as.formula(
    paste(response, "~", paste(predictors, collapse = " + "))
  )
  tree <- tryCatch(rpart::rpart(formula_tree, data = data, method = "anova"),
                   error = function(e) NULL)
  if (is.null(tree)) return(c(q = NA_real_, p = NA_real_))

  data$.zone <- as.character(as.numeric(tree$where))
  result <- tryCatch(
    gdverse::gd(as.formula(paste(response, "~ .zone")), data = data),
    error = function(e) NULL)
  if (is.null(result)) return(c(q = NA_real_, p = NA_real_))

  qv <- tryCatch(as.numeric(result$factor[["Q-statistic"]]),
                 error = function(e) NA_real_)
  pv <- tryCatch(as.numeric(result$factor[["P-value"]]),
                 error = function(e) NA_real_)
  if (length(qv) == 0) qv <- NA_real_
  if (length(pv) == 0) pv <- NA_real_
  c(q = qv, p = pv)
}

# Local GOZH: run_gozh() inside each location's k-nearest neighbourhood.
run_local_gozh <- function(d, cfg, predictors, coords_mat, k_local) {
  response <- cfg$response$response_name
  n <- nrow(d)
  local_q <- rep(NA_real_, n)
  local_p <- rep(NA_real_, n)
  for (i in seq_len(n)) {
    if (i %% 200 == 0) cat(sprintf("  local GOZH: location %d / %d\n", i, n))
    nn_idx <- FNN::get.knnx(coords_mat, coords_mat[i, , drop = FALSE],
                            k = k_local)$nn.index
    res <- run_gozh(d[nn_idx, ], response, predictors)
    local_q[i] <- res["q"]
    local_p[i] <- res["p"]
  }
  data.frame(local_gozh = local_q, local_gozh_p = local_p)
}

# --- Global benchmarks (OPGD-style) ----------------------------------------

run_global_gd <- function(data, response, predictor, n_strata = 5) {
  tryCatch({
    df <- data.frame(y = data[[response]], x = data[[predictor]])
    df <- df[complete.cases(df), ]
    if (nrow(df) < 30) return(c(q = NA_real_, p = NA_real_))
    df$x_d <- cut(df$x, breaks = n_strata, labels = FALSE,
                  include.lowest = TRUE)
    result <- gdverse::gd(y ~ x_d, data = df)
    qv <- tryCatch(as.numeric(result$factor[["Q-statistic"]]),
                   error = function(e) NA_real_)
    pv <- tryCatch(as.numeric(result$factor[["P-value"]]),
                   error = function(e) NA_real_)
    if (length(qv) == 0) qv <- NA_real_
    if (length(pv) == 0) pv <- NA_real_
    c(q = qv, p = pv)
  }, error = function(e) c(q = NA_real_, p = NA_real_))
}

run_global_interaction_q <- function(data, response, v1, v2, n_strata = 5) {
  tryCatch({
    df <- data.frame(y = data[[response]], x1 = data[[v1]], x2 = data[[v2]])
    df <- df[complete.cases(df), ]
    if (nrow(df) < 30) return(c(q = NA_real_, p = NA_real_))
    df$x1_d <- cut(df$x1, breaks = n_strata, labels = FALSE,
                   include.lowest = TRUE)
    df$x2_d <- cut(df$x2, breaks = n_strata, labels = FALSE,
                   include.lowest = TRUE)
    df$x12 <- interaction(df$x1_d, df$x2_d, drop = TRUE)
    result <- gdverse::gd(y ~ x12, data = df)
    qv <- tryCatch(as.numeric(result$factor[["Q-statistic"]]),
                   error = function(e) NA_real_)
    pv <- tryCatch(as.numeric(result$factor[["P-value"]]),
                   error = function(e) NA_real_)
    if (length(qv) == 0) qv <- NA_real_
    if (length(pv) == 0) pv <- NA_real_
    c(q = qv, p = pv)
  }, error = function(e) c(q = NA_real_, p = NA_real_))
}
Step 1

Response intake

What this step does

Take the observed response y as-is and register it under the pipeline's standard name z (results/step02-response.csv) together with the unit id and coordinates — every later step reads the response from this one file.

Code

R/02-response-intake.R
# =============================================================================
# 02-response-intake.R — register the observed response under the standard name
#
# The analysis table carries the observed response (config: response$direct_col,
# default "y"). This step copies it to the pipeline's standard internal name
# (response$response_name, default "z") together with the unit id and
# coordinates, so every later step reads the response from one file.
#
# Output: results/step02-response.csv
# =============================================================================

source("R/functions/fn-config.R")

cfg <- load_config()
ensure_dir("results")

d    <- read_analysis_table(cfg)
resp <- cfg$response$response_name

out <- d[, c(cfg$data$id_col, cfg$data$x_col, cfg$data$y_col)]
out[[resp]] <- d[[cfg$response$direct_col]]

cat(sprintf("Direct response '%s' copied to '%s'.\n",
            cfg$response$direct_col, resp))
cat(sprintf("Response %s: mean=%.4f  sd=%.4f  min=%.4f  max=%.4f\n",
            resp, mean(out[[resp]]), sd(out[[resp]]),
            min(out[[resp]]), max(out[[resp]])))

write.csv(out, "results/step02-response.csv", row.names = FALSE)
cat("Saved: results/step02-response.csv\n")

Example output

Direct response 'y' copied to 'z'. Response z: mean=0.6564 sd=0.1406 min=0.0000 max=1.0000 Saved: results/step02-response.csv
Demo run Demo response map
fig01 — the observed response y. High values concentrate in the north-east, following the planted regime structure.
Published reference · LISP LISP paper glacier thickness change
LISP paper Fig. 2 — the observed response of the glacier case: thickness change of lake-terminating glaciers, Greater Himalayas, 2000–2020 (a directly observed response, exactly as in this demo).
Published reference · LPI LPI paper urbanization index
LPI paper Fig. 3 — the response variable of the urbanization case (a county-level composite index). South-east high, north-west low.

Paper reference

Both published cases open with the response map as the macro pattern the paper sets out to explain: Hu et al. (2024) Fig. 2 (PDF) and Sun et al. (2026) Fig. 3 (PDF).

How to read this result
  • Pattern: the response map is the premise of everything downstream — a clearly structured surface (demo: north-east high, mean 0.656, full 0–1 range used) is worth local analysis; a salt-and-pepper map with no spatial organisation would not be.
  • Paper's reading: both published cases present the response map first, as the macro spatial pattern the paper sets out to explain — glacier thinning concentrated along the Himalayan arc (LISP Fig. 2), the SE-high / NW-low urbanization gradient (LPI Fig. 3).
  • Deeper thinking: inspect the response distribution before modelling — heavy skew or a few extreme units will dominate in-window variance and inflate local q around them (consider a transform, and say so in the manuscript).
Checkpoint

results/step02-response.csv exists with 600 rows and the console reports mean z = 0.6564, range 0–1, before you move on.

Step 2

Geocomplexity (GC) pattern of every variable

What this step does

Compute the geocomplexity Gi ∈ [0,1] of each explanatory variable (Zhang et al. 2023): high values mark units whose local spatial arrangement contradicts spatial dependence. This step doubles the variable set from {X} to {X, GC(X)} — the defining move of LPI. The LISP glacier case has no counterpart for this stage: pattern augmentation entered the method family with geocomplexity and LPI, so the published references here come from the LPI article only.

Code

R/03-geocomplexity.R
# =============================================================================
# 03-geocomplexity.R — geocomplexity (GC) pattern of every explanatory variable
#
# GC (Zhang et al. 2023, IJGIS) measures local spatial complexity: high values
# mark locations whose local pattern contradicts spatial dependence. LPI treats
# each variable's GC pattern as an additional explanatory variable, so this
# step doubles the variable set from {X} to {X, GC(X)}.
#
# Implementation: geocomplexity::geocd_vector(method = "spvar") with KNN
# spatial weights, k = max(knn_min, knn_share * n)   (cc005 Step 2).
#
# Output: results/step03-geocomplexity.csv
# =============================================================================

source("R/functions/fn-config.R")

library(sf)
library(sdsfun)
library(geocomplexity)

cfg <- load_config()
ensure_dir("results")

d      <- read_analysis_table(cfg)
x_vars <- cfg$predictors$x_vars

sf_data <- st_as_sf(d, coords = c(cfg$data$x_col, cfg$data$y_col),
                    crs = cfg$project$crs_input)

k_local <- max(cfg$params$gc$knn_min,
               round(nrow(d) * cfg$params$gc$knn_share))
cat(sprintf("Local neighbourhood size k = %d (%.0f%% of n = %d)\n",
            k_local, cfg$params$gc$knn_share * 100, nrow(d)))

wt <- sdsfun::spdep_contiguity_swm(sf_data, k = k_local)

cat("Computing geocomplexity for", length(x_vars), "variables...\n")
system.time({
  gc_result <- geocd_vector(sf_data[, x_vars], wt,
                            method    = cfg$params$gc$method,
                            normalize = TRUE)
})

gc_df <- as.data.frame(gc_result)
gc_df <- gc_df[, !grepl("^geometry$", names(gc_df)), drop = FALSE]
colnames(gc_df) <- gc_names_of(cfg)

out <- cbind(
  d[, c(cfg$data$id_col, cfg$data$x_col, cfg$data$y_col)],
  gc_df
)
write.csv(out, "results/step03-geocomplexity.csv", row.names = FALSE)
cat("Saved: results/step03-geocomplexity.csv\n")

Example output

Local neighbourhood size k = 30 (2% of n = 600) Computing geocomplexity for 6 variables... user system elapsed 0.782 0.008 0.833 Saved: results/step03-geocomplexity.csv
Demo run Demo GC trends
fig02 — variable value vs GC(X): 5%-quantile bin means with GAM smoother. Nonlinear throughout; complexity is not a rescaling of the values.
Published reference · LPI LPI paper GC patterns
LPI paper Fig. 4 — county-level geocomplexity patterns of the five economic variables: complexity concentrates along regime boundaries and around hub cities.
Published reference · LPI LPI paper GC trend curves
LPI paper Fig. 5 — the published counterpart of demo fig02: statistical trend of each variable against its geocomplexity pattern (low value ↔ high complexity, nonlinear).
Published reference · GEOC GEOC paper Fig. 5 — variable values vs geocomplexity, coloured by Gini
GEOC paper Fig. 5 (Zhang et al. 2023) — the origin of this diagnostic: geocomplexity of income (a) and of industrial employees (b) against their values in Australian SA3 regions, coloured by Gini coefficient. Complexity peaks at mid-range values and falls at the extremes — the same nonlinear signature as demo fig02.

Paper reference

Sun et al. (2026) Figs. 4–5 (PDF); the geocomplexity indicator and the value-vs-complexity diagnostic (its Fig. 5, reproduced above) are defined in Zhang et al. (2023) (PDF).

How to read this result
  • Pattern: GC(X) is highest where a variable's neighbourhood arrangement is disordered, not where its value is high — the demo trend curves are non-monotonic, confirming GC adds information that no transformation of the raw values could.
  • Paper's reading: the LPI article reads its Fig. 4–5 as evidence that economic variables have "spatial structural effects" distinct from magnitude effects, and the GEOC paper quantifies the stake: geocomplexity explained 17–47% of the residuals that value-based models (OLS, SVR, GWR) left behind.
  • Deeper thinking: expect high GC along administrative or physical transition zones that cut through your study area — if your GC maps instead mirror unit density, revisit the KNN size (gc$knn_share), because irregular unit sizes can masquerade as complexity. High-GC areas are also where any later model error will concentrate, so they are the first places to inspect when validation underperforms.
Checkpoint

results/step03-geocomplexity.csv holds six gc_* columns with k = 30; the demo computes in under a second.

Step 3

Local analysis extent from the semivariogram

What this step does

Define "local" operationally: fit a semivariogram to the response on projected coordinates, take its range a, and set the local window radius d = 2a (the LISP rule). Every local computation downstream uses this window.

Code

R/04-local-range.R
# =============================================================================
# 04-local-range.R — local analysis extent from the response semivariogram
#
# LISP (Hu et al. 2024) fixes the local window as   d = multiplier x range,
# where range comes from a fitted semivariogram of the response on projected
# coordinates (automap::autofitVariogram). The multiplier (default 2) and the
# sensitivity of results to it are examined again in Step 10.
#
# Outputs: results/step04-local-range.csv       (range, multiplier, threshold)
#          results/step04-variogram-points.csv  (empirical variogram, for Fig)
# =============================================================================

source("R/functions/fn-config.R")
source("R/functions/fn-lpi-core.R")

library(sf)
library(automap)

cfg <- load_config()
ensure_dir("results")

d  <- read_analysis_table(cfg)
rz <- read_response(cfg)
d[[cfg$response$response_name]] <- rz[[cfg$response$response_name]]

spatial <- build_spatial(d, cfg)
lr      <- estimate_local_range(d, cfg, spatial)

cat(sprintf("Variogram range = %.0f m (%.2f km)\n",
            lr$range, lr$range / 1000))
cat(sprintf("Local extent (threshold) = %.0f m (%.2f km)  [multiplier = %s]\n",
            lr$threshold, lr$threshold / 1000,
            cfg$params$lisp$threshold_multiplier))

dm <- spatial$distmat
cat(sprintf("Distance matrix (m): min=%.0f  median=%.0f  max=%.0f\n",
            min(dm[dm > 0]), median(dm[dm > 0]), max(dm)))

write.csv(
  data.frame(
    variogram_model      = as.character(lr$variogram$var_model$model[2]),
    range_m              = lr$range,
    threshold_multiplier = cfg$params$lisp$threshold_multiplier,
    threshold_m          = lr$threshold,
    n_obs                = nrow(d)
  ),
  "results/step04-local-range.csv", row.names = FALSE
)

# Empirical variogram points for the diagnostic figure (Step 11).
vg <- lr$variogram$exp_var
write.csv(
  data.frame(dist_m = vg$dist, gamma = vg$gamma, np = vg$np),
  "results/step04-variogram-points.csv", row.names = FALSE
)
cat("Saved: results/step04-local-range.csv, results/step04-variogram-points.csv\n")

Example output

Variogram range = 22407 m (22.41 km) Local extent (threshold) = 44814 m (44.81 km) [multiplier = 2] Distance matrix (m): min=112 median=49453 max=127357 Saved: results/step04-local-range.csv, results/step04-variogram-points.csv
Published reference · LISP LISP paper variogram
LISP paper Fig. 4 — the original definition of this step: semivariogram of glacier thickness change, range a = 309.15 km → local extent d = 618.30 km.
Published reference · LPI LPI paper variogram
LPI paper Fig. 6 — the same rule in the urbanization case: range a = 99.28 km → local extent d = 198.56 km. The demo counterpart appears in fig08 (Step 7), where range = 22.4 km → extent = 44.8 km.

Paper reference

The d = 2a rule comes from Hu et al. (2024) (PDF) and is applied unchanged in Sun et al. (2026) (PDF).

How to read this result
  • Pattern: the range is where the fitted curve levels off — beyond it, response values are no longer spatially correlated; the demo's 22.4 km range reflects the wavelength of the planted regimes.
  • Paper's reading: the LPI article treats d = 2a as the neighbourhood within which stratified power is meaningfully "local" — big enough for stable in-window discretisation, small enough to preserve locality.
  • Deeper thinking: two failure modes to check before trusting the extent: (i) a variogram that keeps climbing (trend, no sill) makes the fitted range unstable — detrend or interpret cautiously; (ii) a projected CRS in degrees silently breaks everything — the range must be in metres. Report the median number of neighbours inside the window; if it falls below ~30, discretisation inside windows becomes fragile (see Step 9 for the multiplier sensitivity).
Checkpoint

results/step04-local-range.csv reports range = 22407 m and threshold = 44814 m with multiplier 2 on the demo data.

Step 4

Local stratified power (LISP factor detector)

What this step does

Compute, at every unit, the local q-statistic of every variable and every GC pattern inside the local window — the core "where does each factor matter" result.

Code

R/05-lisp-factor-detector.R
# =============================================================================
# 05-lisp-factor-detector.R — LISP local q for every variable in {X, GC(X)}
#
# localsp::lisp() runs the geographical detector factor detector inside each
# location's local window (radius = threshold from Step 04), yielding a local
# q value and significance per variable per location (cc005 Step 3).
# NA imputation: q -> 0 (no explanatory power), p -> 1 (not significant).
#
# Outputs: results/step05-local-q-values.csv
#          results/step05-local-q-sig.csv
# =============================================================================

source("R/functions/fn-config.R")
source("R/functions/fn-lpi-core.R")

library(sf)
library(localsp)
library(gdverse)
library(sdsfun)
library(dplyr)

cfg <- load_config()
ensure_dir("results")

d        <- build_model_frame(cfg)
all_vars <- all_vars_of(cfg)
n_cores  <- get_cores(cfg)

spatial   <- build_spatial(d, cfg)
threshold <- read_local_range(cfg)$threshold_m

cat(sprintf("LISP factor detector: %d variables (%d X + %d GC), %d obs, %d cores\n",
            length(all_vars), length(cfg$predictors$x_vars),
            length(gc_names_of(cfg)), nrow(d), n_cores))
cat(sprintf("Threshold = %.0f m\n", threshold))

system.time({
  lisp_out <- run_lisp_factor(
    d, cfg,
    vars      = all_vars,
    threshold = threshold,
    distmat   = spatial$distmat,
    cores     = n_cores
  )
})

write.csv(lisp_out$q,   "results/step05-local-q-values.csv", row.names = FALSE)
write.csv(lisp_out$sig, "results/step05-local-q-sig.csv",    row.names = FALSE)
cat("Saved: results/step05-local-q-values.csv, results/step05-local-q-sig.csv\n")

# Quick console summary: top variables by mean local q.
mean_q <- sort(colMeans(lisp_out$q[, all_vars], na.rm = TRUE),
               decreasing = TRUE)
cat("\nTop variables by mean local q:\n")
for (v in names(head(mean_q, 10))) {
  cat(sprintf("  %-24s %.4f\n", v, mean_q[v]))
}

Example output

LISP factor detector: 12 variables (6 X + 6 GC), 600 obs, 13 cores Threshold = 44814 m user system elapsed 0.125 0.067 7.183 Saved: results/step05-local-q-values.csv, results/step05-local-q-sig.csv Top variables by mean local q: x1 0.2986 gc_x1 0.1195 x2 0.1777 x5 0.1096 x3 0.1457 gc_x6 0.0775 x6 0.1319 gc_x2 0.0706
Demo run Demo local q histograms of variables
fig03a — distribution of local q per variable. Wide spreads (x1: mean 0.299, max 0.490) are the signature of heterogeneous explanatory power.
Demo run Demo local q histograms of GC patterns
fig03b — distribution of local q per GC pattern. Lower means (GC(x1): 0.120) with long right tails: pattern effects are localized, not universal.
Demo run Demo local q maps
fig04 — local q maps, top-4 variables (top row) and top-4 GC patterns (bottom row). x1 dominates the west/north, x2 the east — the planted two-regime structure, recovered.
Published reference · LISP LISP paper local PD maps
LISP paper Fig. 5 — local PD of individual variables and their significance for glacier thickness change: slope has the largest share of significant area.
Published reference · LPI LPI paper local PD maps of variables
LPI paper Fig. 7 — local PD maps + significance for the five economic variables. Tertiary industry and retail sales: highest PD, significant in 100% of counties.
Published reference · LPI LPI paper local PD maps of GC patterns
LPI paper Fig. 8 — local PD of the geocomplexity patterns: modest on average but with sharply localized significant pockets.

Paper reference

Hu et al. (2024) Fig. 5 (PDF); Sun et al. (2026) Figs. 7–8 (PDF).

How to read this result
  • Pattern: read magnitude and significance share together — a variable with modest mean q but near-100% significance is a universal weak driver; one with the same mean but patchy significance is a localized strong driver. In the demo, x1 is both strong and near-universal (mean 0.299, significant at 94.5% of units), while GC(x1) matters only in pockets (mean 0.120, significant at 79.8%).
  • Paper's reading: the LISP glacier case reads its Fig. 5 by exactly this rule — slope is the near-universal driver (significant at 99.34% of locations) while temperature and precipitation matter only in subregions; the LPI article's headline from its Fig. 7–8: tertiary industry (mean PD 0.454) and retail (0.434) dominate everywhere, while GC variables are "concentrated in specific local regions" — discoveries a global model cannot make (see the comparison tables in Step 7).
  • Deeper thinking: look for complementary regimes — where one driver's q falls as another's rises (demo: the x1/x2 west–east handover). Those handover lines are the spatial boundaries your Discussion section should explain mechanistically. Also map the imputed q=0 units (windows with insufficient variation): if they cluster, that subregion's data support is too thin for local claims there.
Checkpoint

results/step05-local-q-values.csv and -sig.csv exist; the mean-q ranking is led by x1 = 0.2986 on the demo data.

Step 5

Local pattern interactions

What this step does

For every unit, discretise each pair of top variables inside the local window, compute single and combined-zone q, and classify the interaction into the five geographical-detector types. Pairs span three categories: X×X, X×GC, GC×GC.

Code

R/06-local-interactions.R
# =============================================================================
# 06-local-interactions.R — local pairwise pattern interactions
#
# Pairs are formed among the top-N variables by mean local q (N from config;
# N=5 gives 10 pairs). For every location, each pair member is discretized
# inside the local window, single and combined-zone q values are computed
# (gdverse::gd), and the interaction is typed into the 5 geographical
# detector classes (cc005 Step 3e / LPI Fig 9).
#
# Outputs: results/step06-all-pairwise-interactions.csv
#          results/step06-local-best-interaction.csv
# =============================================================================

source("R/functions/fn-config.R")
source("R/functions/fn-lpi-core.R")

library(sf)
library(gdverse)
library(sdsfun)
library(dplyr)

cfg <- load_config()
ensure_dir("results")

d        <- build_model_frame(cfg)
all_vars <- all_vars_of(cfg)

spatial   <- build_spatial(d, cfg)
threshold <- read_local_range(cfg)$threshold_m

local_q <- read.csv("results/step05-local-q-values.csv")

# --- Top-N variables by mean local q ----------------------------------------
top_n  <- cfg$params$interaction$top_n_vars
mean_q <- sort(colMeans(local_q[, all_vars], na.rm = TRUE), decreasing = TRUE)
top_vars <- names(mean_q)[1:top_n]

cat(sprintf("Top %d variables by mean local q:\n", top_n))
for (v in top_vars) cat(sprintf("  %-24s %.4f\n", v, mean_q[v]))

var_pairs <- combn(top_vars, 2, simplify = FALSE)
cat(sprintf("\nRunning local interaction for %d pairs x %d locations...\n",
            length(var_pairs), nrow(d)))

system.time({
  inter_all <- run_interactions_all(d, cfg, var_pairs,
                                    spatial$distmat, threshold)
})

# --- Best interaction per location -------------------------------------------
coord_lookup <- data.frame(
  LocationID = seq_len(nrow(d)),
  Longitude  = d[[cfg$data$x_col]],
  Latitude   = d[[cfg$data$y_col]]
)

inter_best <- inter_all %>%
  group_by(LocationID) %>%
  slice_max(qv12, n = 1, with_ties = FALSE) %>%
  ungroup() %>%
  left_join(coord_lookup, by = "LocationID") %>%
  select(LocationID, Longitude, Latitude,
         var1, var2, qv1, qv2, qv12, interaction)

write.csv(inter_all,  "results/step06-all-pairwise-interactions.csv",
          row.names = FALSE)
write.csv(inter_best, "results/step06-local-best-interaction.csv",
          row.names = FALSE)
cat("Saved: step06-all-pairwise-interactions.csv, step06-local-best-interaction.csv\n")

# Console summary: strongest pairs by mean qv12.
pair_summary <- inter_all %>%
  group_by(var1, var2) %>%
  summarise(mean_qv12 = mean(qv12, na.rm = TRUE), .groups = "drop") %>%
  arrange(desc(mean_qv12))
cat("\nPairs by mean interaction q (qv12):\n")
print(as.data.frame(head(pair_summary, 10)))

Example output

x2 × x6 0.5242 x2 × x3 0.3775 x1 × x2 0.4808 x2 × gc_x1 0.3621 x1 × x3 0.4607 x3 × x6 0.3378 x1 × x6 0.4565 x6 × gc_x1 0.3145 x1 × gc_x1 0.3942 x3 × gc_x1 0.3027
Demo run Demo interaction strength
fig05 — pairs ranked by mean interaction q (± SD), coloured by category. Every pair beats the best single factor (0.299).
Demo run Demo interaction type map
fig06 — dominant interaction category per unit and category frequencies: spatially organised, with X×GC dominating transition zones.
Published reference · LISP LISP paper interaction PD
LISP paper Fig. 6 — local PD of interaction determinants in the glacier case: pairwise interactions (slope ∩ lake area, slope ∩ summer temperature, slope ∩ precipitation) and the all-variable interaction, with per-variable contributions.
Published reference · LPI LPI paper interaction PD
LPI paper Fig. 9 — local PD of pattern interactions: interaction strength 48–86%, dominated by nonlinear-enhance and bivariate-enhance types; category shares variable×variable 41.7%, variable×GC 56.7%, GC×GC rare.

Paper reference

Hu et al. (2024) Fig. 6 (PDF); Sun et al. (2026) Fig. 9 (PDF). The five-class interaction typing follows the geographical detector of Wang et al. (2010).

How to read this result
  • Pattern: the informative comparison is not qv12 > max(qv1,qv2) (true for all "enhance" types by construction) but qv12 vs qv1+qv2 — pairs exceeding the sum are nonlinear enhancements, i.e. genuinely synergistic. The demo's top pair x2 × x6 (0.524) far exceeds the sum of its singles (0.178 + 0.132) — the planted interaction pulls x6 into the top five, though the direct x5×x6 pair itself is only tested if you raise top_n_vars (x5 ranks sixth) — a deliberate scope decision to revisit per application.
  • Paper's reading: the LPI article's flagship numbers are interaction gains: retail × GC(tertiary) reaches local PD 0.610 against 0.537 globally, and variable×GC pairs supply the majority (56.7%) of best-per-county interactions — the paper's core claim that patterns amplify variables.
  • Deeper thinking: a dominant X×GC pair reads mechanistically as "the effect of X is amplified where another variable's spatial arrangement is complex" — a hypothesis generator, not a conclusion; nominate a process (spillover, boundary friction, infrastructure mismatch) and test it in the Discussion. Map where the dominant category switches: switch lines that align with the Step 4 regime handover corroborate real structure; scattered confetti suggests window sample sizes are too small (check min_local_n).
Checkpoint

results/step06-all-pairwise-interactions.csv and step06-local-best-interaction.csv exist; the pair ranking is led by x2 × x6 = 0.5242 on the demo data.

Step 6

GOZH total effect (joint explanatory ceiling)

What this step does

Measure how much all variables and patterns jointly explain — globally, and inside each unit's k-nearest neighbourhood — using GOZH: an rpart regression tree zones the data, then the combined-zone q is computed (Luo et al. 2022; no CC-licensed figure available for embedding).

Code

R/07-gozh-total-effect.R
# =============================================================================
# 07-gozh-total-effect.R — GOZH total effect of all variables together
#
# GOZH (Luo et al. 2022) zones the study area with a regression tree over all
# predictors, then measures the combined q of the leaf zones. Run globally
# (one q for the whole area) and locally (q inside each location's k-nearest
# neighbourhood), as in cc005 Step 4.
#
# Outputs: results/step07-gozh-global.csv
#          results/step07-local-gozh.csv
# =============================================================================

source("R/functions/fn-config.R")
source("R/functions/fn-lpi-core.R")

library(sf)
library(rpart)
library(gdverse)
library(FNN)

cfg <- load_config()
ensure_dir("results")

d        <- build_model_frame(cfg)
all_vars <- all_vars_of(cfg)
resp     <- cfg$response$response_name

spatial <- build_spatial(d, cfg)
k_local <- max(cfg$params$gc$knn_min,
               round(nrow(d) * cfg$params$gc$knn_share))

# --- Global GOZH --------------------------------------------------------------
cat("Running global GOZH on the full dataset...\n")
gozh_global <- run_gozh(d, resp, all_vars)
cat(sprintf("Global GOZH q = %.4f  p = %.4g\n",
            gozh_global["q"], gozh_global["p"]))

write.csv(
  data.frame(model = "GOZH_global",
             q_value = gozh_global["q"], p_value = gozh_global["p"]),
  "results/step07-gozh-global.csv", row.names = FALSE
)

# --- Local GOZH ----------------------------------------------------------------
cat(sprintf("Running local GOZH for %d locations (k = %d)...\n",
            nrow(d), k_local))
local_gozh <- run_local_gozh(d, cfg, all_vars, spatial$coords_mat, k_local)

out <- cbind(
  d[, c(cfg$data$id_col, cfg$data$x_col, cfg$data$y_col)],
  local_gozh
)
write.csv(out, "results/step07-local-gozh.csv", row.names = FALSE)
cat("Saved: results/step07-gozh-global.csv, results/step07-local-gozh.csv\n")

cat(sprintf("Local GOZH q: mean=%.4f  min=%.4f  max=%.4f  (%%p<0.05: %.1f)\n",
            mean(local_gozh$local_gozh,   na.rm = TRUE),
            min(local_gozh$local_gozh,    na.rm = TRUE),
            max(local_gozh$local_gozh,    na.rm = TRUE),
            mean(local_gozh$local_gozh_p < 0.05, na.rm = TRUE) * 100))

Example output

Global GOZH q = 0.6908 p = 5.819e-10 Running local GOZH for 600 locations (k = 30)... Local GOZH q: mean=0.5173 min=0.1686 max=0.8695 (%p<0.05: 77.2) Saved: results/step07-gozh-global.csv, results/step07-local-gozh.csv
Demo GOZH total effect
Demo run fig07 — local GOZH map and distribution: the joint ceiling averages 0.517 locally (range 0.169–0.870) against 0.691 globally.

Paper reference

GOZH is defined in Luo et al. (2022) (PDF); the urbanization article uses this machinery inside its interaction step.

How to read this result
  • Pattern: local GOZH is the ceiling of joint explanation at each place. The gap between it and the best single/pair local q measures unexploited joint structure; where local GOZH itself is low (demo minimum 0.169), the drivers of that subregion are outside the variable set — or the response there is noise-dominated.
  • Paper's reading: in the LPI lineage, GOZH supplies the tree-based zoning that makes many-variable interactions reliable (avoiding the fragmented-overlay problem of classic detectors); the urbanization article uses exactly this machinery inside its interaction step.
  • Deeper thinking: global GOZH (0.691) exceeding the local mean (0.517) is not a contradiction — explainable variance is scale-dependent: the global tree exploits between-regime contrasts that vanish inside local windows. Use the low-GOZH map as a data-collection targeting tool: those are the places where adding a covariate would pay most.
Checkpoint

results/step07-gozh-global.csv reports global q = 0.6908; step07-local-gozh.csv gives local mean 0.5173 on the demo data.

3.3 Results, Validation & Interpretation

Three validation layers close the pipeline: the global OPGD-style benchmark that becomes the paper's main table (Step 7), subsample stability (Step 8), and parameter sensitivity (Step 9). Step 10 then regenerates all eight figures from results/ alone.

Step 7

Global benchmark and the paper's main table

What this step does

Benchmark every local result against the global OPGD-style q, producing the validation table that becomes the main table of an LPI application paper (the published article's Table 4).

Code

R/08-global-benchmark.R
# =============================================================================
# 08-global-benchmark.R — OPGD-style global q benchmark + validation table
#
# The LPI paper validates local results against the global OPGD power of
# determinant (LPI Table 4). This script computes global q for every variable
# and for the strongest interaction pairs, then assembles the validation
# table: local mean [min, max], % significant, and global q side by side
# (cc005 Steps 4c + 5).
#
# Outputs: results/step08-global-q.csv
#          results/step08-validation-table.csv
# =============================================================================

source("R/functions/fn-config.R")
source("R/functions/fn-lpi-core.R")

library(gdverse)
library(dplyr)

cfg <- load_config()
ensure_dir("results")

d        <- build_model_frame(cfg)
all_vars <- all_vars_of(cfg)
resp     <- cfg$response$response_name
n_strata <- cfg$params$global$n_strata

local_q   <- read.csv("results/step05-local-q-values.csv")
local_sig <- read.csv("results/step05-local-q-sig.csv")
inter_all <- read.csv("results/step06-all-pairwise-interactions.csv")
gozh_g    <- read.csv("results/step07-gozh-global.csv")
gozh_l    <- read.csv("results/step07-local-gozh.csv")

# --- Global q per variable -----------------------------------------------------
cat("Global q per variable (OPGD-style,", n_strata, "strata):\n")
global_var_df <- do.call(rbind, lapply(all_vars, function(v) {
  res <- run_global_gd(d, resp, v, n_strata = n_strata)
  cat(sprintf("  %-24s q = %.4f  p = %.4g\n", v, res["q"], res["p"]))
  data.frame(Variable = v, Global_q = res["q"], Global_p = res["p"],
             Variable_type = ifelse(grepl("^gc_", v), "GC", "X"),
             stringsAsFactors = FALSE)
}))

# --- Global q for the strongest interaction pairs ------------------------------
top_pairs <- inter_all %>%
  group_by(var1, var2) %>%
  summarise(LPI_mean = mean(qv12, na.rm = TRUE), .groups = "drop") %>%
  arrange(desc(LPI_mean)) %>%
  slice_head(n = cfg$params$global$top_n_interactions)

cat("\nGlobal q per interaction pair:\n")
global_inter_df <- do.call(rbind, lapply(seq_len(nrow(top_pairs)), function(i) {
  v1 <- top_pairs$var1[i]; v2 <- top_pairs$var2[i]
  res <- run_global_interaction_q(d, resp, v1, v2, n_strata = n_strata)
  cat(sprintf("  %-40s q = %.4f\n", paste(v1, "x", v2), res["q"]))
  data.frame(Variable = paste(v1, "×", v2),
             Global_q = res["q"], Global_p = res["p"],
             Variable_type = "Interaction", stringsAsFactors = FALSE)
}))

global_gozh_df <- data.frame(
  Variable = "All variables (GOZH)",
  Global_q = gozh_g$q_value[1], Global_p = gozh_g$p_value[1],
  Variable_type = "Total", stringsAsFactors = FALSE
)

global_q_all <- rbind(global_var_df, global_inter_df, global_gozh_df)
write.csv(global_q_all, "results/step08-global-q.csv", row.names = FALSE)

# --- Validation table: local summary + global benchmark ------------------------
q_summary <- do.call(rbind, lapply(all_vars, function(v) {
  q_vals <- local_q[[v]]; sig_vals <- local_sig[[v]]
  data.frame(
    Variable      = v,
    LPI_mean      = round(mean(q_vals, na.rm = TRUE), 4),
    LPI_min       = round(min(q_vals,  na.rm = TRUE), 4),
    LPI_max       = round(max(q_vals,  na.rm = TRUE), 4),
    Pct_sig_p05   = round(mean(sig_vals < 0.05, na.rm = TRUE) * 100, 1),
    Variable_type = ifelse(grepl("^gc_", v), "GC", "X"),
    stringsAsFactors = FALSE
  )
}))

inter_summary <- inter_all %>%
  group_by(var1, var2) %>%
  summarise(
    LPI_mean    = round(mean(qv12, na.rm = TRUE), 4),
    LPI_min     = round(min(qv12,  na.rm = TRUE), 4),
    LPI_max     = round(max(qv12,  na.rm = TRUE), 4),
    Pct_sig_p05 = round(mean(pv12 < 0.05, na.rm = TRUE) * 100, 1),
    .groups     = "drop"
  ) %>%
  arrange(desc(LPI_mean)) %>%
  slice_head(n = cfg$params$global$top_n_interactions) %>%
  mutate(Variable = paste(var1, "×", var2),
         Variable_type = "Interaction") %>%
  select(Variable, LPI_mean, LPI_min, LPI_max, Pct_sig_p05, Variable_type)

gozh_row <- data.frame(
  Variable      = "All variables (GOZH)",
  LPI_mean      = round(mean(gozh_l$local_gozh, na.rm = TRUE), 4),
  LPI_min       = round(min(gozh_l$local_gozh,  na.rm = TRUE), 4),
  LPI_max       = round(max(gozh_l$local_gozh,  na.rm = TRUE), 4),
  Pct_sig_p05   = round(mean(gozh_l$local_gozh_p < 0.05, na.rm = TRUE) * 100, 1),
  Variable_type = "Total",
  stringsAsFactors = FALSE
)

validation <- rbind(q_summary, as.data.frame(inter_summary), gozh_row) %>%
  left_join(global_q_all[, c("Variable", "Global_q", "Global_p")],
            by = "Variable")

write.csv(validation, "results/step08-validation-table.csv", row.names = FALSE)
cat("\nSaved: results/step08-global-q.csv, results/step08-validation-table.csv\n")
print(head(validation %>% arrange(desc(LPI_mean)), 15))

Demo validation table (results/step08-validation-table.csv, abridged)

Variable / interactionLocal q mean[min, max]% p<0.05Global q
x10.299[0.004, 0.490]94.50.332
x20.178[0.010, 0.630]90.20.179
x60.132[0.006, 0.391]89.80.031 (p=0.085, n.s.)
GC(x1)0.120[0.000, 0.389]79.80.065 (p=0.071, n.s.)
x2 × x60.524[0.220, 0.862]88.20.556
x6 × GC(x1)0.315[0.096, 0.599]79.80.188 (p=0.919, n.s.)
All variables (GOZH)0.517[0.169, 0.870]77.20.691
Demo run, regenerated by the scripts in this folder.

Published counterpart 1 — LISP paper Table 2 (Hu et al. 2024, glacier case; excerpt)

Variable / interactionLocal PD mean [min, max]% p<0.05Global PD
Slope0.266 [0.062, 0.565]99.30.207
Elevation0.181 [0.033, 0.318]95.70.062
Lake area0.154 [0.014, 0.285]75.90.155
Winter temperature0.064 [0.009, 0.363]73.10.008
Slope ∩ Lake area0.441 [0.169, 0.615]98.70.361
Slope ∩ Summer temperature0.427 [0.230, 0.652]100.00.289
Interaction of all variables0.549 [0.284, 0.707]100.00.547
Excerpt from Hu et al. (2024), Table 2 (PDF).

Published counterpart 2 — LPI paper Table 4 (Sun et al. 2026, urbanization case; excerpt)

Variable / interactionLPI mean [min, max]% p<0.05OPGD global q
x3 tertiary industry0.454 [0.298, 0.627]100.00.483 **
x4 retail sales0.434 [0.275, 0.628]100.00.438 **
x1 primary industry0.152 [0.005, 0.431]56.00.113 **
GC(x3)0.146 [0.066, 0.458]27.70.016 (p=0.134, n.s.)
GC(x4)0.159 [0.008, 0.464]17.20.040 **
x1 ∩ x30.641 [0.444, 0.821]100.00.639 **
x4 ∩ GC(x3)0.610 [0.336, 0.783]100.00.537 **
Excerpt from Sun et al. (2026), Table 4 (PDF). x1–x5: gross output of primary/secondary/tertiary industry, total retail sales, public budget expenditure. ** = significant at p < 0.01.
Demo run Demo diagnostics and benchmark
fig08 — (left) demo variogram with range (22.4 km) and extent (44.8 km) marks; (right) local mean [min–max] vs global q on the 1:1 line: local maxima exceed the global value for every variable.
Published reference · LPI LPI paper global PD
LPI paper Fig. 10 — the OPGD global benchmark: (a) individual variables, (b) interactions. The comparison target for all local results.

Paper reference

The benchmark follows OPGD (Song et al. 2020, PDF); the published tables come from Hu et al. (2024) and Sun et al. (2026).

How to read this result
  • Pattern: classify each row into three regimes: (i) local mean ≈ global with a narrow range → homogeneous driver, local analysis adds little; (ii) local mean ≈ global with a wide range → the global number actively misleads (demo x2: global 0.179 hides a 0.010–0.630 spread); (iii) globally non-significant but locally significant in pockets → a discovery only local analysis can make. The demo produces regime (iii) twice: x6 (global q=0.031, p=0.085, n.s. — yet locally significant at 89.8% of units) and the interaction x6 × GC(x1) (global 0.188, p=0.919, n.s. — local mean 0.315).
  • Paper's reading: both published tables lean on the same regimes. LISP's flagship is elevation: local mean 0.181 against a global PD of only 0.062 — "the LISP model effectively avoids underestimation … that can occur with global geographical detector models". LPI's strongest case is regime (iii): GC(x3) is globally n.s. (q=0.016, p=0.134) yet locally reaches PD 0.458 and is significant in 27.7% of counties; interactions tell the same story (0.610 local vs 0.537 global for x4∩GC(x3)).
  • Deeper thinking: use this three-regime classification as the skeleton of your Results narrative — it converts a table of numbers into three claims with different practical consequences (stable policy lever / spatially targeted lever / hidden local lever). And note the demo's global q slightly exceeding the local mean for the top variable (x1: 0.332 vs 0.299): global strata exploit between-regime contrast, so "local < global on average, local > global at maxima" is the expected signature of stratified heterogeneity, not an error.
Checkpoint

results/step08-validation-table.csv matches the demo table above; the two regime-(iii) discovery rows (x6; x6 × GC(x1)) are present.

Step 8

Subsample stability (holdout)

What this step does

Drop 5% of units at random, recompute local and global q for the top-10 variables, repeat 20 times: do the conclusions survive data perturbation?

Code

R/09-validation-holdout.R
# =============================================================================
# 09-validation-holdout.R — subsample stability of local and global q
#
# Robustness protocol from the cc005 R1 revision: randomly drop a share of
# units (default 5%), recompute local q (LISP) and global q for the top-N
# variables, repeat n_repeats times. Reports per-variable mean local q with
# 95% CI, the median change rate against the full-data run, and Spearman
# rank preservation of the variable-importance ordering.
#
# Outputs: results/step09-holdout-local-q.csv
#          results/step09-holdout-global-q.csv
#          results/step09-holdout-summary.csv
# =============================================================================

source("R/functions/fn-config.R")
source("R/functions/fn-lpi-core.R")

library(sf)
library(localsp)
library(gdverse)
library(sdsfun)
library(dplyr)

cfg <- load_config()
ensure_dir("results")

hold      <- cfg$validation$holdout
set.seed(hold$seed)

d        <- build_model_frame(cfg)
all_vars <- all_vars_of(cfg)
resp     <- cfg$response$response_name
n_cores  <- get_cores(cfg)

# Top-N variables by full-data mean local q.
local_q_full <- read.csv("results/step05-local-q-values.csv")
mean_q_full  <- sort(colMeans(local_q_full[, all_vars], na.rm = TRUE),
                     decreasing = TRUE)
top_vars <- names(mean_q_full)[1:hold$top_n_vars]

cat(sprintf("Holdout validation: %d repeats, drop %.0f%%, top %d variables\n",
            hold$n_repeats, hold$drop_share * 100, hold$top_n_vars))

rep_local  <- list()
rep_global <- list()

for (r in seq_len(hold$n_repeats)) {
  cat(sprintf("Repeat %d / %d\n", r, hold$n_repeats))
  keep_idx <- sort(sample(seq_len(nrow(d)),
                          size = round(nrow(d) * (1 - hold$drop_share))))
  d_sub <- d[keep_idx, ]

  # Rebuild spatial scaffolding and local extent on the subsample.
  spatial_sub <- build_spatial(d_sub, cfg)
  lr_sub      <- estimate_local_range(d_sub, cfg, spatial_sub)

  lisp_sub <- run_lisp_factor(
    d_sub, cfg, vars = top_vars,
    threshold = lr_sub$threshold,
    distmat   = spatial_sub$distmat,
    cores     = n_cores
  )

  rep_local[[r]] <- data.frame(
    repeat_id = r,
    Variable  = top_vars,
    mean_local_q = sapply(top_vars, function(v) {
      mean(lisp_sub$q[[v]], na.rm = TRUE)
    })
  )

  rep_global[[r]] <- data.frame(
    repeat_id = r,
    Variable  = top_vars,
    global_q  = sapply(top_vars, function(v) {
      run_global_gd(d_sub, resp, v,
                    n_strata = cfg$params$global$n_strata)["q"]
    })
  )
}

local_df  <- do.call(rbind, rep_local)
global_df <- do.call(rbind, rep_global)
write.csv(local_df,  "results/step09-holdout-local-q.csv",  row.names = FALSE)
write.csv(global_df, "results/step09-holdout-global-q.csv", row.names = FALSE)

# --- Summary: CI, change rate, rank preservation -------------------------------
full_ref <- data.frame(Variable = top_vars,
                       full_mean_q = as.numeric(mean_q_full[top_vars]))

summary_df <- local_df %>%
  group_by(Variable) %>%
  summarise(
    sub_mean_q = mean(mean_local_q),
    ci_lower   = quantile(mean_local_q, 0.025),
    ci_upper   = quantile(mean_local_q, 0.975),
    .groups    = "drop"
  ) %>%
  left_join(full_ref, by = "Variable") %>%
  mutate(change_pct = round((sub_mean_q - full_mean_q) / full_mean_q * 100, 2))

# Spearman rank correlation of variable importance, per repeat.
rank_cor <- local_df %>%
  left_join(full_ref, by = "Variable") %>%
  group_by(repeat_id) %>%
  summarise(spearman = cor(mean_local_q, full_mean_q, method = "spearman"),
            .groups = "drop")

summary_df$spearman_median <- median(rank_cor$spearman)
summary_df$spearman_min    <- min(rank_cor$spearman)

write.csv(summary_df, "results/step09-holdout-summary.csv", row.names = FALSE)
cat("Saved: step09-holdout-local-q / step09-holdout-global-q / step09-holdout-summary\n")
print(as.data.frame(summary_df))
cat(sprintf("\nSpearman rank preservation: median = %.3f, min = %.3f\n",
            median(rank_cor$spearman), min(rank_cor$spearman)))

Example output

VariableFull-data mean qSubsample mean95% CIChange %
x10.29860.2997[0.294, 0.307]+0.4
x20.17770.1758[0.164, 0.188]−1.1
GC(x1)0.11950.1177[0.111, 0.124]−1.5
x50.10960.1020[0.082, 0.111]−6.9
Spearman rank preservation of variable importance across 20 repeats: median ρ = 0.988, minimum 0.927.
How to read this result
  • Pattern: rank preservation matters more than absolute stability — the manuscript's narrative rests on the ordering of drivers, and a median Spearman of 0.988 says that ordering is robust; individual mean-q changes under ±10% are unremarkable.
  • Paper's reading: this protocol is the standard reviewer-requested robustness check in this method family — the published applications report parameter and stability checks as part of their validation; report the CI and the change rate, not just a reassuring sentence.
  • Deeper thinking: whenever the minimum Spearman dips visibly below the median (0.927 vs 0.988 here), find the repeat responsible — it is usually one where the dropped 5% removed a dense cluster, which tells you which subregion your conclusions depend on. That is more informative than the median: consider reporting it as a named limitation ("conclusions in subregion S rest on the densest cluster of observations").
Checkpoint

results/step09-holdout-summary.csv reports median Spearman = 0.988, minimum = 0.927 on the demo data (seed 2026).

Step 9

Parameter sensitivity

What this step does

Vary the two free parameters — window multiplier (1.5 / 2 / 3 × range) and in-window bins (3 / 4 / 5) — and confirm that the variable ordering, not the exact q level, is what the analysis depends on.

Code

R/10-sensitivity-params.R
# =============================================================================
# 10-sensitivity-params.R — sensitivity of local q to the two key parameters
#
# Protocol from the cc005 R1 revision, on the top-N variables:
#   Experiment 1 — vary the local extent multiplier (default grid 1.5 / 2 / 3),
#                  discretization bins fixed at the config value
#   Experiment 2 — vary the discretization bin count (default grid 3 / 4 / 5),
#                  local extent fixed at the config multiplier
#
# Outputs: results/step10-sensitivity-threshold.csv
#          results/step10-sensitivity-discnum.csv
# =============================================================================

source("R/functions/fn-config.R")
source("R/functions/fn-lpi-core.R")

library(sf)
library(localsp)
library(gdverse)
library(sdsfun)
library(dplyr)

cfg <- load_config()
ensure_dir("results")

sens <- cfg$validation$sensitivity

d        <- build_model_frame(cfg)
all_vars <- all_vars_of(cfg)
n_cores  <- get_cores(cfg)

spatial  <- build_spatial(d, cfg)
vg_range <- read_local_range(cfg)$range_m

local_q_full <- read.csv("results/step05-local-q-values.csv")
mean_q_full  <- sort(colMeans(local_q_full[, all_vars], na.rm = TRUE),
                     decreasing = TRUE)
top_vars <- names(mean_q_full)[1:sens$top_n_vars]

cat(sprintf("Sensitivity analysis on top %d variables: %s\n",
            sens$top_n_vars, paste(top_vars, collapse = ", ")))

# --- Experiment 1: local extent multiplier -------------------------------------
cat("\nExperiment 1 — threshold multiplier grid:",
    paste(sens$threshold_multipliers, collapse = " / "), "\n")

exp1 <- do.call(rbind, lapply(sens$threshold_multipliers, function(m) {
  cat(sprintf("  multiplier = %.1f (threshold = %.0f m)\n", m, vg_range * m))
  lisp_out <- run_lisp_factor(
    d, cfg, vars = top_vars,
    threshold = vg_range * m,
    distmat   = spatial$distmat,
    cores     = n_cores
  )
  data.frame(
    threshold_multiplier = m,
    threshold_m = vg_range * m,
    Variable = top_vars,
    mean_local_q = sapply(top_vars, function(v) {
      mean(lisp_out$q[[v]], na.rm = TRUE)
    })
  )
}))
write.csv(exp1, "results/step10-sensitivity-threshold.csv", row.names = FALSE)

# --- Experiment 2: discretization bins ------------------------------------------
cat("\nExperiment 2 — discnum grid:",
    paste(sens$discnums, collapse = " / "), "\n")

threshold_fixed <- vg_range * cfg$params$lisp$threshold_multiplier
exp2 <- do.call(rbind, lapply(sens$discnums, function(k) {
  cat(sprintf("  discnum = %d\n", k))
  lisp_out <- run_lisp_factor(
    d, cfg, vars = top_vars,
    threshold = threshold_fixed,
    distmat   = spatial$distmat,
    discnum   = k,
    cores     = n_cores
  )
  data.frame(
    discnum = k,
    Variable = top_vars,
    mean_local_q = sapply(top_vars, function(v) {
      mean(lisp_out$q[[v]], na.rm = TRUE)
    })
  )
}))
write.csv(exp2, "results/step10-sensitivity-discnum.csv", row.names = FALSE)

cat("\nSaved: step10-sensitivity-threshold.csv, step10-sensitivity-discnum.csv\n")

cat("\nMean local q by threshold multiplier:\n")
print(reshape2::dcast(exp1, Variable ~ threshold_multiplier,
                      value.var = "mean_local_q"))
cat("\nMean local q by discnum:\n")
print(reshape2::dcast(exp2, Variable ~ discnum, value.var = "mean_local_q"))

Example output

Mean local qmultiplier 1.52 (default)3discnum 34 (default)5
x10.2710.2990.3040.2790.2990.314
x20.2230.1780.1680.1450.1780.196
x30.2100.1460.0530.1240.1460.160
x60.1760.1320.0570.0990.1320.154
GC(x1)0.1350.1190.0780.0990.1190.134
Mean local q of the top-5 variables across the multiplier grid (left) and the discretisation grid (right).
How to read this result
  • Pattern: two systematic drifts are expected, not alarming: more bins → mechanically higher q (finer strata always explain more variance), and a larger window → q drifts toward the global value as more heterogeneity is mixed in. The check is whether the ordering of top variables survives — here x1 leads under every setting.
  • Paper's reading: the LPI article reports parameter sensitivity as part of its validation trio (statistical accuracy, spatial consistency, robustness); the convention is to show the drift openly and argue ordering stability.
  • Deeper thinking: the interesting cells are the collapses as the window triples — x3 falls from 0.210 to 0.053, x6 from 0.176 to 0.057, GC(x1) from 0.135 to 0.078 — while the broad regime driver x1 grows (0.271 → 0.304). Which variables are scale-bound tells you the operative scale of each driver: localized and pattern effects dissolve in wide windows, regime-wide effects gain. If such a variable is central to your story, report its q across multipliers, and never compare q values across different discnum settings (the comparison is meaningless by construction).
Checkpoint

results/step10-sensitivity-threshold.csv and step10-sensitivity-discnum.csv exist; x1 leads the ordering in every cell of both grids.

Figure regeneration (all eight figures from results/ only)

R/11-figures.R
# =============================================================================
# 11-figures.R — the eight core figures of an LPI application paper
# Reads results/step*.csv only; no recomputation. Figure plan and the
# argumentation task of each figure: figures/figure-plan.md
#
#   fig01 response index maps            fig05 interaction strength bars
#   fig02 X vs GC(X) trend curves        fig06 interaction type map + counts
#   fig03 local q histograms (X, GC)     fig07 local GOZH map + histogram
#   fig04 local q maps (top X + top GC)  fig08 variogram + local vs global q
# =============================================================================

source("R/functions/fn-config.R")
source("R/functions/fn-plot-helpers.R")

library(ggplot2)
library(reshape2)
library(sf)
library(dplyr)
library(mgcv)
library(patchwork)

cfg <- load_config()
ensure_dir("figures")

x_vars   <- cfg$predictors$x_vars
gc_names <- gc_names_of(cfg)
all_vars <- all_vars_of(cfg)
labels   <- get_labels(cfg)
resp     <- cfg$response$response_name
w_full   <- cfg$figures$width_full
w_single <- cfg$figures$width_single

d_z    <- read.csv("results/step02-response.csv")
d_gc   <- read.csv("results/step03-geocomplexity.csv")
d_q    <- read.csv("results/step05-local-q-values.csv")
d_iall <- read.csv("results/step06-all-pairwise-interactions.csv")
d_ibst <- read.csv("results/step06-local-best-interaction.csv")
d_gozh <- read.csv("results/step07-local-gozh.csv")
d_vt   <- read.csv("results/step08-validation-table.csv")
d_vg   <- read.csv("results/step04-variogram-points.csv")
d_lr   <- read.csv("results/step04-local-range.csv")

xc <- cfg$data$x_col
yc <- cfg$data$y_col

# =============================================================================
# FIGURE 1: Spatial distribution of the response index (and sub-indices)
# =============================================================================

resp_cols <- intersect(
  c(grep(paste0("^", resp, "_"), names(d_z), value = TRUE), resp),
  names(d_z)
)
fig1_panels <- lapply(resp_cols, function(v) {
  make_value_map(d_z[[v]], d_z[[xc]], d_z[[yc]], cfg,
                 title = v, legend_title = "Index")
})
p_fig1 <- wrap_plots(fig1_panels, ncol = min(4, length(fig1_panels)))
ggsave("figures/fig01-response-maps.pdf", p_fig1,
       width = min(w_full, 3.5 * length(fig1_panels) + 1), height = 4.5,
       dpi = 300)
cat("Figure 1 saved.\n")

# =============================================================================
# FIGURE 2: X value vs GC(X), 5%-quantile binned means + GAM smoother
# =============================================================================

# Raw variable values live in the analysis table; GC values in step03.
d_raw  <- read.csv(cfg$data$analysis_table)
d_main <- merge(d_raw, d_gc[, c(cfg$data$id_col, gc_names)],
                by = cfg$data$id_col)
gc_summary <- do.call(rbind, lapply(x_vars, function(v) {
  df <- data.frame(x = d_main[[v]], y = d_main[[paste0("gc_", v)]])
  q_breaks <- quantile(df$x, probs = seq(0, 1, 0.05), na.rm = TRUE)
  df$group <- cut(df$x, breaks = unique(q_breaks),
                  labels = FALSE, include.lowest = TRUE)
  smry <- df %>%
    group_by(group) %>%
    summarise(mean_x = mean(x, na.rm = TRUE),
              mean_y = mean(y, na.rm = TRUE),
              se_y   = sd(y, na.rm = TRUE) / sqrt(sum(!is.na(y))),
              .groups = "drop")
  smry$variable <- labels[[v]]
  smry
}))
gc_summary$variable <- factor(gc_summary$variable, levels = labels[x_vars])

p_fig2 <- ggplot(gc_summary, aes(x = mean_x, y = mean_y)) +
  geom_errorbar(aes(ymin = mean_y - se_y, ymax = mean_y + se_y),
                width = 0, linewidth = 0.4, colour = "grey50") +
  geom_point(size = 1.2, colour = "steelblue") +
  geom_smooth(colour = "red", linewidth = 0.8, se = TRUE,
              fill = "pink", alpha = 0.3) +
  facet_wrap(~ variable, scales = "free", ncol = 3) +
  labs(x = "Variable value", y = "Geocomplexity (GC)") +
  facet_theme()
ggsave("figures/fig02-gc-trends.pdf", p_fig2, width = 8, height = 6, dpi = 300)
cat("Figure 2 saved.\n")

# =============================================================================
# FIGURE 3: Local q distributions — X variables (3a) and GC patterns (3b)
# =============================================================================

plot_q_histograms(d_q, x_vars,   labels, 3, "figures/fig03a-q-hist-x.pdf")
plot_q_histograms(d_q, gc_names, labels, 3, "figures/fig03b-q-hist-gc.pdf")
cat("Figure 3 saved.\n")

# =============================================================================
# FIGURE 4: Spatial maps of local q — top X and top GC variables
# =============================================================================

n_top    <- min(4, length(x_vars))
mean_q_x  <- sort(colMeans(d_q[, x_vars],   na.rm = TRUE), decreasing = TRUE)
mean_q_gc <- sort(colMeans(d_q[, gc_names], na.rm = TRUE), decreasing = TRUE)
top_map_vars <- c(names(mean_q_x)[1:n_top], names(mean_q_gc)[1:n_top])

fig4_panels <- lapply(top_map_vars, function(v) {
  make_value_map(d_q[[v]], d_q$Longitude, d_q$Latitude, cfg,
                 title = labels[[v]], legend_title = "Local q")
})
p_fig4 <- wrap_plots(fig4_panels, ncol = n_top)
ggsave("figures/fig04-local-q-maps.pdf", p_fig4,
       width = w_full, height = 7, dpi = 300)
cat("Figure 4 saved.\n")

# =============================================================================
# FIGURE 5: Interaction strength — top pairs by mean qv12, by pair category
# =============================================================================

pair_label_of <- function(v) {
  ifelse(v %in% names(labels), labels[v], v)
}

top_pairs <- d_iall %>%
  group_by(var1, var2) %>%
  summarise(mean_qv12 = mean(qv12, na.rm = TRUE),
            sd_qv12   = sd(qv12,   na.rm = TRUE),
            .groups = "drop") %>%
  mutate(vartype = vartype_of(var1, var2, x_vars, gc_names)) %>%
  arrange(desc(mean_qv12)) %>%
  slice_head(n = 15) %>%
  mutate(pair_label = paste(pair_label_of(var1), "×", pair_label_of(var2)))
top_pairs$pair_label <- factor(top_pairs$pair_label,
                               levels = rev(top_pairs$pair_label))

p_fig5 <- ggplot(top_pairs,
                 aes(x = pair_label, y = mean_qv12, fill = vartype)) +
  geom_bar(stat = "identity", alpha = 0.7) +
  geom_errorbar(aes(ymin = mean_qv12 - sd_qv12, ymax = mean_qv12 + sd_qv12),
                width = 0.4, colour = "orange", alpha = 0.9, linewidth = 1.0) +
  scale_fill_manual(values = interaction_palette(cfg),
                    name = "Interaction type") +
  coord_flip() +
  labs(x = NULL, y = "Mean interaction q-value (qv12) ± SD") +
  theme_bw() +
  theme(axis.text = element_text(size = 8),
        legend.position = "bottom",
        panel.grid.minor = element_blank())
ggsave("figures/fig05-interaction-strength.pdf", p_fig5,
       width = 8, height = 6, dpi = 300)
cat("Figure 5 saved.\n")

# =============================================================================
# FIGURE 6: Dominant interaction category — spatial map + frequency bars
# =============================================================================

d_ibst$vartype <- vartype_of(d_ibst$var1, d_ibst$var2, x_vars, gc_names)

p_fig6a <- ggplot() +
  boundary_layer(cfg) +
  geom_point(data = d_ibst,
             aes(x = Longitude, y = Latitude, colour = vartype),
             size = cfg$figures$point_size, alpha = 0.8) +
  scale_colour_manual(values = interaction_palette(cfg),
                      name = "Dominant interaction") +
  labs(x = "Longitude", y = "Latitude") +
  map_theme() +
  guides(colour = guide_legend(override.aes = list(size = 3)))

type_count <- as.data.frame(table(d_ibst$vartype))
colnames(type_count) <- c("Type", "Count")
type_count$Pct <- round(type_count$Count / sum(type_count$Count) * 100, 1)

p_fig6b <- ggplot(type_count, aes(x = Type, y = Count, fill = Type)) +
  geom_bar(stat = "identity", colour = "black", linewidth = 0.3) +
  geom_text(aes(label = paste0(Pct, "%")), vjust = -0.4, size = 4) +
  scale_fill_manual(values = interaction_palette(cfg)) +
  labs(x = "Interaction type", y = "Number of locations") +
  theme_bw() +
  theme(legend.position = "none", panel.grid.minor = element_blank())

p_fig6 <- p_fig6a + p_fig6b + plot_layout(widths = c(1.4, 1))
ggsave("figures/fig06-interaction-type.pdf", p_fig6,
       width = 10, height = 4.5, dpi = 300)
cat("Figure 6 saved.\n")

# =============================================================================
# FIGURE 7: Local GOZH total effect — map + distribution
# =============================================================================

p_fig7a <- make_value_map(d_gozh$local_gozh, d_gozh[[xc]], d_gozh[[yc]], cfg,
                          title = NULL, legend_title = "GOZH q (all vars)")

mean_gozh <- mean(d_gozh$local_gozh, na.rm = TRUE)
p_fig7b <- ggplot(d_gozh, aes(x = local_gozh)) +
  geom_histogram(aes(y = after_stat(density)), bins = 40,
                 fill = "lightblue", colour = "black", alpha = 0.7) +
  geom_density(colour = "red", linewidth = 0.8) +
  geom_vline(xintercept = mean_gozh, colour = "blue",
             linetype = "dashed", linewidth = 0.8) +
  annotate("text", x = Inf, y = Inf,
           label = paste("Mean:", round(mean_gozh, 3)),
           hjust = 1.15, vjust = 1.4, size = 4) +
  labs(x = "Local GOZH q-value (all variables)", y = "Density") +
  theme_bw() +
  theme(panel.grid.minor = element_blank())

p_fig7 <- p_fig7a + p_fig7b + plot_layout(widths = c(1.2, 1))
ggsave("figures/fig07-gozh-total-effect.pdf", p_fig7,
       width = 10, height = 4.5, dpi = 300)
cat("Figure 7 saved.\n")

# =============================================================================
# FIGURE 8: Method diagnostics — variogram (8a) + local vs global q (8b)
# =============================================================================

p_fig8a <- ggplot(d_vg, aes(x = dist_m / 1000, y = gamma)) +
  geom_point(size = 1.5, colour = "steelblue") +
  geom_vline(xintercept = d_lr$range_m[1] / 1000,
             colour = "red", linetype = "dashed") +
  geom_vline(xintercept = d_lr$threshold_m[1] / 1000,
             colour = "blue", linetype = "dashed") +
  annotate("text", x = d_lr$range_m[1] / 1000, y = Inf,
           label = "range", hjust = -0.1, vjust = 1.5,
           size = 3, colour = "red") +
  annotate("text", x = d_lr$threshold_m[1] / 1000, y = Inf,
           label = "local extent", hjust = 1.1, vjust = 1.5,
           size = 3, colour = "blue") +
  labs(x = "Distance (km)", y = "Semivariance") +
  theme_bw() +
  theme(panel.grid.minor = element_blank())

d_cmp <- d_vt %>%
  filter(Variable_type %in% c("X", "GC")) %>%
  filter(!is.na(Global_q))
d_cmp$label <- ifelse(d_cmp$Variable %in% names(labels),
                      labels[d_cmp$Variable], d_cmp$Variable)

p_fig8b <- ggplot(d_cmp, aes(x = Global_q, y = LPI_mean,
                             colour = Variable_type)) +
  geom_abline(slope = 1, intercept = 0, linetype = "dashed",
              colour = "grey60") +
  geom_errorbar(aes(ymin = LPI_min, ymax = LPI_max),
                width = 0, linewidth = 0.4, alpha = 0.6) +
  geom_point(size = 2) +
  ggrepel::geom_text_repel(aes(label = label), size = 2.5,
                           show.legend = FALSE) +
  scale_colour_manual(values = c("X" = unlist(cfg$figures$palette)[1],
                                 "GC" = unlist(cfg$figures$palette)[6]),
                      name = "Variable type") +
  labs(x = "Global q (OPGD benchmark)",
       y = "Local q, mean with [min, max]") +
  theme_bw() +
  theme(panel.grid.minor = element_blank())

p_fig8 <- p_fig8a + p_fig8b + plot_layout(widths = c(1, 1.3))
ggsave("figures/fig08-diagnostics-benchmark.pdf", p_fig8,
       width = 10, height = 4.5, dpi = 300)
cat("Figure 8 saved.\n")

cat("\n=== All figures saved to figures/ ===\n")
R/functions/fn-plot-helpers.R
# =============================================================================
# fn-plot-helpers.R — shared plotting components
# Source: cc005 LPI pipeline (plot results.R), generalized.
# Colour-method mapping is bound once from config and reused in every figure
# (writing style guide Rule 26: fixed colour mapping across all figures).
# =============================================================================

map_theme <- function() {
  ggplot2::theme_bw() +
    ggplot2::theme(
      axis.text    = ggplot2::element_text(size = 8),
      legend.title = ggplot2::element_text(size = 9),
      legend.text  = ggplot2::element_text(size = 8),
      panel.grid   = ggplot2::element_blank()
    )
}

facet_theme <- function() {
  ggplot2::theme_bw() +
    ggplot2::theme(
      strip.text       = ggplot2::element_text(size = 7.5),
      axis.text        = ggplot2::element_text(size = 7),
      axis.title       = ggplot2::element_text(size = 9),
      panel.grid.minor = ggplot2::element_blank()
    )
}

# Optional study-area boundary layer ("" in config -> empty layer list).
boundary_layer <- function(cfg) {
  if (is.null(cfg$data$boundary) || cfg$data$boundary == "") return(list())
  boundary <- sf::st_read(cfg$data$boundary, quiet = TRUE)
  list(ggplot2::geom_sf(data = boundary, fill = "grey95",
                        colour = "grey60", linewidth = 0.3))
}

# One map panel with its own 6-quantile legend (equal-frequency bins).
make_value_map <- function(values, x_coords, y_coords, cfg,
                           title = NULL, legend_title = "Value") {
  palette  <- unlist(cfg$figures$palette)
  q_breaks <- unique(quantile(values, probs = seq(0, 1, length.out = 7),
                              na.rm = TRUE))
  n_bins <- length(q_breaks) - 1
  bin_labels <- paste0(round(q_breaks[-(n_bins + 1)], 3), "-",
                       round(q_breaks[-1], 3))

  df_map <- data.frame(
    x_plot  = x_coords,
    y_plot  = y_coords,
    value_q = cut(values, breaks = q_breaks, labels = bin_labels,
                  include.lowest = TRUE)
  )

  ggplot2::ggplot() +
    boundary_layer(cfg) +
    ggplot2::geom_point(
      data = df_map,
      ggplot2::aes(x = x_plot, y = y_plot, colour = value_q),
      size = cfg$figures$point_size, alpha = 0.8
    ) +
    ggplot2::scale_colour_manual(
      values = setNames(palette[1:n_bins], bin_labels),
      name   = legend_title,
      guide  = ggplot2::guide_legend(override.aes = list(size = 2.5))
    ) +
    ggplot2::labs(title = title, x = "Longitude", y = "Latitude") +
    map_theme() +
    ggplot2::theme(
      plot.title      = ggplot2::element_text(size = 9, hjust = 0.5),
      legend.key.size = ggplot2::unit(0.4, "cm"),
      legend.text     = ggplot2::element_text(size = 7)
    )
}

# Faceted histogram + density + mean line of local q values.
plot_q_histograms <- function(q_df, var_list, label_map, ncol_val, fname,
                              width = 8, height = 6) {
  q_long <- reshape2::melt(q_df[, c("LocationID", var_list)],
                           id.vars = "LocationID")
  q_long$variable <- factor(label_map[as.character(q_long$variable)],
                            levels = label_map[var_list])
  mean_vals <- aggregate(value ~ variable, data = q_long,
                         FUN = mean, na.rm = TRUE)
  mean_vals$value <- round(mean_vals$value, 3)

  p <- ggplot2::ggplot(q_long, ggplot2::aes(x = value)) +
    ggplot2::geom_histogram(
      ggplot2::aes(y = ggplot2::after_stat(density)), bins = 40,
      fill = "lightblue", colour = "black", alpha = 0.7) +
    ggplot2::geom_density(colour = "red", linewidth = 0.8) +
    ggplot2::geom_vline(
      data = mean_vals, ggplot2::aes(xintercept = value),
      colour = "blue", linetype = "dashed", linewidth = 0.8) +
    ggplot2::geom_text(
      data = mean_vals,
      ggplot2::aes(x = Inf, y = Inf, label = paste("Mean:", value)),
      hjust = 1.15, vjust = 1.4, size = 3, colour = "black",
      inherit.aes = FALSE) +
    ggplot2::facet_wrap(~ variable, scales = "free", ncol = ncol_val) +
    ggplot2::labs(x = "Local q-value", y = "Density") +
    facet_theme()

  ggplot2::ggsave(fname, p, width = width, height = height, dpi = 300)
  cat("Saved:", fname, "\n")
  invisible(p)
}

# Interaction pair category: "X x X", "X x GC", "GC x GC".
vartype_of <- function(var1, var2, x_vars, gc_names) {
  ifelse(var1 %in% x_vars & var2 %in% x_vars, "X × X",
         ifelse(var1 %in% gc_names & var2 %in% gc_names,
                "GC × GC", "X × GC"))
}

interaction_palette <- function(cfg) {
  setNames(
    c(cfg$figures$interaction_colors$xx,
      cfg$figures$interaction_colors$xgc,
      cfg$figures$interaction_colors$gcgc),
    c("X × X", "X × GC", "GC × GC")
  )
}

Validation summary

Validation itemFile / figureDecision rule
Local vs global benchmarkresults/step08-validation-table.csv, fig08local maxima exceed global q per variable; globally-n.s.-but-locally-significant rows are the discovery cases
Subsample stabilityresults/step09-holdout-summary.csvSpearman rank preservation high (demo: median 0.988, min 0.927); mean-q changes within ±10%
Parameter sensitivityresults/step10-sensitivity-*.csvvariable ordering stable across multipliers 1.5–3 and bins 3–5 (demo: x1 leads in every cell)
Three decision rules that must hold before the results are written up.
Main finding narrative

The demo recovers its planted structure exactly: the x1/x2 west–east regime split appears in the local q maps (Step 4), the planted x5 × x6 signal drives the top interaction pair x2 × x6 (0.524, exceeding the sum of its singles), and two results that are globally non-significant — x6 and the interaction x6 × GC(x1) — turn out strong and significant across most of the map. Local pattern analysis finds what the global statistic discards; the validation trio shows the finding is not an artefact of the sample or the parameters.

4 Adaptation, Writing & Reproducibility

4.1 Port to Your Domain

The pipeline was designed to move between domains without touching the analytical scripts: the config declares the data, the preparation script builds the analysis table, and everything downstream is generic. Two published applications illustrate the span:

DomainResponseSpatial unitKey adaptation
Urbanization drivers (Sun et al. 2026) Composite urbanization indexCounty 5 economic variables from yearbook tables + their GC patterns; regional projected CRS
Glacier thickness change (Hu et al. 2024) Observed thinning 2000–2020Gridded glacier unit 8 climate/terrain variables from remote-sensing products; directly observed response
Any point-referenced unit with one response and 5–15 continuous predictors fits the data contract of §2.3.

What to edit

  1. config/project-config.yaml — the CRS pair, column names, x_vars and their display labels, and the parameters (the single adaptation point of §2.2).
  2. The real-data branch of R/01-prepare-data.R — read data/raw/, aggregate to your spatial unit, rename columns to the config names, keep complete cases; then set data$use_example: false.
  3. Nothing else: re-run Steps 1–10 unchanged. Per application, revisit interaction$top_n_vars and the validation grids of validation$sensitivity.
!Common pitfalls

A projected CRS in degrees silently breaks the variogram range and every local window — crs_projected must be in metres. GC maps that mirror unit density instead of structure mean the KNN size needs revisiting (gc$knn_share). The top_n_vars cut can exclude a genuine interaction partner — in the demo, x5 ranks sixth, so the planted x5×x6 pair is only tested directly if you raise the cut. And never compare q values across different discnum settings: finer strata mechanically explain more variance.

4.2 Write the Paper

The published applications share one manuscript architecture: Methods sections mirror the pipeline steps 1:1, every Results sentence carries a number from a results/ CSV, and the Discussion is built on the three-regime classification of Step 7.

Manuscript sectionTemplate outputWriting job
Methodsconfig/project-config.yaml, results/step04-local-range.csvdescribe the four calculation stages (GC extraction, local extent, local stratified power, interactions + total effect) with parameter values quoted from the config
Resultsfigures/fig01–fig08, results/step08-validation-table.csvmirror the pipeline steps 1:1; one interpretive takeaway per subsection; every finding sentence carries a number
Discussionresults/step09-holdout-summary.csv, step10-sensitivity-*.csvargue the three-regime classification and ordering stability; name the subregion the conclusions depend on
Where each manuscript section draws its material from.

Manuscript skeleton (shared by the published applications)

manuscript outline
1  Introduction            — 6 paragraphs; P4 reviews OPGD/GOZH/GEOC/LISP; P5 gap sentence
                             "However, there are still challenges in {ISSUE} due to
                             {GAP_1}, {GAP_2}, {GAP_3}."; P6 aim + 3 contributions
2  {MODEL NAME}            — 2.1 Concept (no formulas)
                             2.2 Calculation process (ALL formulas live here):
                                 2.2.1 Geocomplexity pattern extraction   (G_i)
                                 2.2.2 Local extent determination         (γ(h); d = 2a)
                                 2.2.3 Local stratified power             (q(u) = 1 − SSW/SST)
                                 2.2.4 Local pattern interaction + total effect
3  Case                    — 3.1 Study area & data · 3.2 Pre-processing
                             3.3 Model-based analysis (1:1 with §2.2 steps)
                             3.4 Validation: OPGD benchmark · holdout · sensitivity
4  Results                 — subsections mirror 3.3 steps 1:1; every finding sentence
                             carries a number; one interpretive takeaway per subsection
5  Discussion              — 4 paragraphs, 2 tables (capability comparison;
                             reproduction guidance), no subsection headings
6  Conclusions             — 8–10 sentences answering: accomplished? contribution?
                             meaning for readers? limitations & future work?

Results-to-manuscript map

Manuscript elementSource fileShown in
Study-area / response figurefigures/fig01-response-maps.pdf§3.1 + §4.1 of paper
GC pattern evidence figurefigures/fig02-gc-trends.pdf§4.2
Local-extent statement (range, d)results/step04-local-range.csv§3.3 parameters
Local q distributions + mapsfig03a/b, fig04§4.3
Interaction ranking, categoriesfig05, fig06§4.4
Total-effect mapfig07§4.4
Main table (local vs global)results/step08-validation-table.csv§4.5 (≈ published Table 4)
Benchmark figurefig08§4.5
Robustness numbersstep09-holdout-summary.csv, step10-sensitivity-*.csv§4.5 / response to reviewers
Every manuscript element traces to one regenerable file.

4.3 Final Reproducibility Package

Everything an editor, reviewer, or reader needs sits in this folder; the three blocks below are what a submission's data-and-code availability statement should point to.

Code
numbered scripts in R/ + method core in R/functions/ + config/project-config.yaml
Data
data/processed/analysis-table.csv (demo) — or your raw data plus the adapted preparation script
Manuscript
figures/fig01–fig08 (PDF) + results/step08-validation-table.csv as the main table
Before claiming reproducible
  • Delete results/ and figures/, re-run the eleven scripts — every number and figure on this page regenerates.
  • results/session-info.txt matches the environment claimed in the manuscript (R 4.6.0, CRAN package builds).
  • Every finding sentence traces to a results/step*.csv; every parameter is quoted from config/project-config.yaml, not from memory.
  • The data availability statement covers the analysis table (or raw data + preparation script) and this code folder.

References & credits

Demo outputs were generated by the scripts in this folder. Reference figures and table excerpts are reproduced from the cited articles for comparison: the LPI paper is open access (CC BY); the remaining figures are © their authors and publishers.

  1. Wang J-F, et al. (2010). Geographical detectors-based health risk assessment… IJGIS 24(1):107–127. doi:10.1080/13658810802443457
  2. Song Y, Wang J, Ge Y, Xu C (2020). An optimal parameters-based geographical detector model… GIScience & Remote Sensing 57(5):593–610. doi:10.1080/15481603.2020.1760434 · PDF
  3. Luo P, Song Y, et al. (2022). …geographically optimal zones-based heterogeneity model. ISPRS JPRS 185:111–128. doi:10.1016/j.isprsjprs.2022.01.009 · PDF
  4. Zhang Z, Song Y, Luo P, Wu P (2023). Geocomplexity explains spatial errors. IJGIS 37(7):1449–1469. doi:10.1080/13658816.2023.2203212 (open access) · PDF
  5. Hu J, Song Y, Zhang T (2024). A local indicator of stratified power. IJGIS. doi:10.1080/13658816.2024.2437811 · PDF (source of the LISP reference figures and Table 2 values on this page) · data & code: figshare 26518651
  6. Sun Y, Wu G, Song Y, et al. (2026). Local effects of pattern interactions in driving urbanization. Int J Appl Earth Obs Geoinf 146:105072. doi:10.1016/j.jag.2025.105072 (open access, CC BY — source of the LPI reference figures and Table 4 values on this page) · PDF