Aberfoyle Species Classification — tidymodels Edition

Random Forest classification with kNNDM spatial cross-validation

Author

Forest Research

Published

14 September 2026

Show code
#| include: false

# Spatial data
library(terra)
library(sf)

# Modelling
library(tidymodels)

# Spatial cross-validation by nearest-neighbour distance matching
library(CAST)

# Remote sensing
library(RStoolbox)

# Data wrangling / visualisation
library(tidyverse)
library(mapview)
library(rasterVis)
library(plotly)

#Access performing
library(tictoc)
library(future)

tidymodels_prefer()

set.seed(4127)

# --- Project paths ---------------------------------------------------------
dir_raster  <- "Planet"
dir_vectors <- "Shapefiles"
dir_vi      <- "VI"
dir_outputs <- "OutputsRF"

Forest Research

Generate predictor variables from the Planet images

Planet images

Having images from different seasons provides a greater diversity of data, which can help train a more robust model. Bands are loaded as SpatRaster objects and renamed per season.

Show code
band_names <- c(
  "coastal_blue", "blue", "green_I", "green",
  "yellow", "red", "rededge", "nir"
)

season_dirs <- c(
  winter = "10 March 2023_psscene_analytic_8b_sr_udm2",
  spring = "30 May 23_psscene_analytic_8b_sr_udm2",
  summer = "6sep24_ConvexHull_psscene_analytic_8b_sr_udm2",
  autumn = "11 November 2023_psscene_analytic_8b_sr_udm2"
)

# Read one season's 8-band composite and give each band a season-tagged name
load_season <- function(season) {
  r <- rast(file.path(dir_raster, season_dirs[[season]], "composite.tif"))
  names(r) <- str_c(band_names, season, sep = "_")
  r
}

seasons <- names(season_dirs)
season_rasters <- map(seasons, load_season) |> set_names(seasons)

The following is a false-colour (red / NIR / blue) composite of Aberfoyle Forest for each season.

Show code
par(mfrow = c(2, 2), mar = c(2, 2, 2, 2))

season_labels <- c(
  winter = "Winter - 10th March 2023",
  spring = "Spring - 30th May 2023",
  summer = "Summer - 06th September 2024",
  autumn = "Autumn - 11th November 2023"
)

iwalk(season_rasters, \(r, season) {
  plotRGB(r, r = 6, g = 8, b = 2, stretch = "lin",
          main = season_labels[[season]], smooth = TRUE, axes = FALSE)
})

Create vegetation indices (Summer)

Vegetation indices from the summer scene can improve the random forest model by providing features that capture the peak of vegetative growth, when foliage is densest and most uniform — this allows for a clearer distinction between species. Vegetation indices also help reduce noise from raw spectral data. See the index reference for details.1

Since the closed canopy limits soil influence, indices sensitive to chlorophyll content, canopy structure, and vegetative vigour are prioritised over indices designed to correct for soil or water effects:

  • MCARI is highly sensitive to variations in chlorophyll and canopy structure, useful for distinguishing species with subtle differences in greenness.
  • GNDVI uses the green band and is more responsive to chlorophyll levels than NDVI, helping differentiate species with similar biomass but differing health.
  • NDVI remains a reliable general-purpose baseline, complementing MCARI and GNDVI.
  • MSAVI2 adds structural contrast, valuable when species vary in crown density or canopy cover.
Show code
#tic("time to create indexes: ")
#VI <- spectralIndices(
#  season_rasters$summer,
#  redEdge1 = "rededge_summer",
#  green    = "green_summer",
#  red      = "red_summer",
#  nir      = "nir_summer",
#  blue     = "blue_summer",
#  indices  = c("NDVI", "GNDVI", "MCARI", "MSAVI2"),
#  scaleFactor  = 1,
#  skipRefCheck = TRUE
#)
#
#writeRaster(VI, file.path(dir_vi, "Indices_summer.tif"), overwrite = TRUE)
#toc()
VI <- rast(file.path(dir_vi, "Indices_summer.tif"))
names(VI)
[1] "NDVI"   "GNDVI"  "MCARI"  "MSAVI2"
Show code
vi_list <- as.list(VI)
names(vi_list) <- names(VI)

par(mfrow = c(2, 2), mar = c(2, 2, 2, 2))

iwalk(vi_list, \(lyr, nm) {
  plot(lyr,
       main = nm,
       smooth = TRUE,
       axes = FALSE)
})

Create the predictor stack

The predictor stack combines the original Planet bands for each season with the summer vegetation indices.

Show code
#layer_stack <- c(
#  season_rasters$winter,
#  season_rasters$spring,
#  season_rasters$summer,
#  season_rasters$autumn,
#  VI
#)
#
## Save the predictor stack to disk
#writeRaster(
#  layer_stack,
#  file.path(dir_outputs, "layer_stack.tif"),
#  overwrite = TRUE
#)
#
## Save the layer names separately (GeoTIFF does not preserve them)
#saveRDS(
#  names(layer_stack),
#  file.path(dir_outputs, "layer_stack_names.rds")
#)


layer_stack <- rast(
  file.path(dir_outputs, "layer_stack.tif")
)

names(layer_stack) <- readRDS(
  file.path(dir_outputs, "layer_stack_names.rds")
)

nlyr(layer_stack)
[1] 36

Field data: response variable

The response variable is tree species from the Forester sub-compartment database, restricted to sub-compartments covered by a single species so that spectral signatures are as clean as possible.

Show code
# One polygon per sub-compartment; ID is the join key back to extracted pixels
samples <- read_sf(file.path(dir_vectors, "V7_samples_aberfoyle.shp")) |>
  mutate(species = as.factor(species), ID = row_number())

count(st_drop_geometry(samples), species)
species n
Birch 50
Larch 72
Lodgepole pine 20
Norway spruce 38
Oak 50
Open 21
Other broadleaves 19
Other conifers 29
Scots pine 80
Sitka spruce 180
Show code
mapview(
  samples, zcol = "species",
  col.regions = rainbow(n_distinct(samples$species)),
  layer.name = "Samples", map.types = "Esri.WorldImagery"
)

The sample polygons are unevenly distributed across species. This matters a great deal for what follows: with few independent spatial units per class, any cross-validation fold that happens to contain all polygons of a rare species will leave the model with no training data for it, and vice versa. Metrics below should be read with this limitation in mind, not as a fully resolved, high-confidence classification.

Extract predictor values at sample locations

Show code
tic("Time to extract data: ")
samples_vect <- vect(samples)

## Pull every predictor value for every pixel falling inside a sample polygon.
## xy = TRUE keeps pixel coordinates; ID links each pixel to its polygon.
#extracted <- terra::extract(layer_stack, samples_vect, xy = TRUE, ID = TRUE) |>
#  as_tibble() |>
#  drop_na()
#toc()
#saveRDS(extracted, file.path(dir_outputs, "extracted.rds"))



extracted <-  readRDS(file.path(dir_outputs,"extracted.rds"))
nrow(extracted)
[1] 69910
Show code
label_lookup <- samples |> st_drop_geometry() |> select(ID, species)

trainDat_raw <- extracted |> left_join(label_lookup, by = "ID")

count(trainDat_raw, species)
species n
Birch 6462
Larch 6333
Lodgepole pine 1144
Norway spruce 4328
Oak 5196
Open 3869
Other broadleaves 1075
Other conifers 873
Scots pine 9630
Sitka spruce 31000

At the pixel level, class sizes are much healthier than the polygon counts above — but recall that pixels within one polygon are highly spatially autocorrelated, which is exactly why the folds below are built at the polygon (spatial unit) level rather than by randomly shuffling pixels.

Spectral signatures by species

Show code
season_bands <- function(season) str_c(band_names, season, sep = "_")

plot_signature <- function(season) {
  data <- trainDat_raw |>
    select(species, all_of(season_bands(season))) |>
    summarise(across(everything(), median), .by = species) |>
    pivot_longer(-species, names_to = "band", values_to = "reflectance") |>
    mutate(band = factor(band, levels = season_bands(season)))

  p <- ggplot(data, aes(band, reflectance, group = species, color = species)) +
    geom_line() +
    geom_point() +
    labs(
      title = str_c("Spectral signatures of species — ", str_to_title(season)),
      x = "Planet spectral bands", y = "Median reflectance per species"
    ) +
    theme_minimal() +
    theme(
      legend.position = "bottom",
      axis.text.x = element_text(angle = 45, hjust = 1)
    ) +
    scale_color_brewer(palette = "Set3")

  ggplotly(p)
}

season_signature_plots <- map(seasons, plot_signature) |> set_names(seasons)
season_signature_plots$summer
Show code
season_signature_plots$autumn
Show code
season_signature_plots$winter
Show code
season_signature_plots$spring

Random Forest classification

Spatial cross-validation with kNNDM

Random shuffling of pixels for cross-validation would place near-duplicate, spatially autocorrelated pixels from the same sub-compartment in both the training and assessment folds, giving an overly optimistic estimate of accuracy. kNNDM (k-fold nearest-neighbour distance matching) builds the folds so that the geometry of the validation problem resembles the geometry of the real prediction problem: it arranges the folds so that the distances from held-out points to their nearest training point follow the same distribution as the distances from the prediction locations to their nearest sample point.2

Prediction domain and sample representatives

kNNDM needs two sets of locations:

  • the training points — the labelled samples. We use one representative point per sub-compartment polygon (its centroid). These polygons are the genuinely independent spatial units: pixels within a polygon are near-duplicates, so folding at the polygon level keeps a whole sub-compartment together and reflects the true amount of independent data.
  • the prediction domain — the area the final map is produced for. The map is clipped to the forest sub-compartments, so those polygons define the domain, and kNNDM samples prediction points from within it.
Show code
# Prediction domain: the sub-compartments the final map is clipped to
subcompartments <- read_sf(file.path(dir_vectors, "subcompartmentCLIP.shp")) |>
  st_transform(st_crs(samples))

# One representative point per independent spatial unit (sub-compartment)
sample_points <- st_centroid(samples)

Generate the folds

knndm() clusters the sample points and searches for the fold assignment whose held-out-to-training distance distribution best matches the prediction distance distribution. k = 5 is used: with the class imbalance present here, more folds would leave several folds missing entire classes.

Show code
tic("time to build kNNDM folds: ")
set.seed(4127)
knndm_folds <- knndm(
  tpoints     = sample_points,      # labelled sample representatives
  modeldomain = subcompartments,    # area we predict to
  dist_space  = "geographical",     # match distances in geographic space
  k           = 5                   # number of cross-validation folds
)

# Persist the CV design so it can be reloaded without recomputing
saveRDS(knndm_folds, file = file.path(dir_outputs, "knndm_folds.rds"))
toc()
time to build kNNDM folds: : 1.756 sec elapsed
Show code
knndm_folds <- readRDS(file.path(dir_outputs, "knndm_folds.rds"))
knndm_folds
knndm object
Space: 
Clustering algorithm: hierarchical
Intermediate clusters (q): 346
W statistic: 85.1676
Number of folds: 5
Observations in each fold:  122 112 99 114 112 

Inspect the quality of the distance matching

kNNDM reports a W statistic and produces three empirical cumulative distribution functions (ECDFs) that let us judge whether the folds represent the prediction problem well.

Show code
# The plot method returns a ggplot comparing the three distance ECDFs.
plot(knndm_folds)

The three curves are:

  • Ĝij (prediction) — distances from prediction points to their nearest sample point. This is the target distribution: the distances the model must actually predict at.
  • **Ĝ*j (kNNDM CV) — distances from held-out points to their nearest training point under the kNNDM folds. We want this curve to lie on top of** the prediction curve.
  • Ĝj (LOO) — the leave-one-out reference, shown as the optimistic extreme (held-out points sit very close to a training point).
Show code
# W is the Wasserstein distance (area between the ECDFs) separating the kNNDM CV
# curve from the prediction curve, in the units of the distance axis (metres).
# Smaller W means the CV folds reproduce prediction conditions more faithfully.
knndm_folds$W
[1] 85.16762

Assign folds to samples

Each sample point corresponds to one polygon, so knndm_folds$clusters is that polygon’s fold. Because sample_points was built in the same row order as samples, we attach the fold directly. The column is named spatial_fold so the rest of the workflow can consume it unchanged.

Show code
samples$spatial_fold <- factor(knndm_folds$clusters)

# Class representation per fold — confirm no fold is missing a class entirely
count(st_drop_geometry(samples), spatial_fold, species) |>
  pivot_wider(names_from = species, values_from = n, values_fill = 0)
spatial_fold Birch Larch Lodgepole pine Norway spruce Oak Open Other broadleaves Other conifers Scots pine Sitka spruce
1 13 14 7 9 7 3 2 12 22 33
2 9 13 2 14 15 1 8 4 8 38
3 8 7 6 4 8 7 1 4 17 37
4 10 18 2 7 10 4 6 6 18 33
5 10 20 3 4 10 6 2 3 15 39
Show code
# Spatial view of the fold assignment across the forest
mapview(
  samples, zcol = "spatial_fold",
  col.regions = rainbow(n_distinct(samples$spatial_fold)),
  layer.name = "kNNDM fold", map.types = "Esri.WorldImagery"
)

Build the predictor table and fold assignment

Each pixel inherits the spatial fold of the polygon it was extracted from, so a whole sub-compartment is always held out together.

Show code
label_lookup <- samples |> st_drop_geometry() |> select(ID, species, spatial_fold)

trainDat <- extracted |>
  left_join(label_lookup, by = "ID") |>
  select(-x, -y)

predictor_names <- setdiff(names(trainDat), c("ID", "species", "spatial_fold"))
count(trainDat, spatial_fold, species) |>
  pivot_wider(names_from = species, values_from = n, values_fill = 0)
spatial_fold Birch Larch Lodgepole pine Norway spruce Oak Open Other broadleaves Other conifers Scots pine Sitka spruce
1 1866 1087 164 693 635 139 84 367 2562 5691
2 1053 1245 342 1543 1956 84 483 148 647 6548
3 861 446 531 559 695 1940 139 175 2306 6376
4 1392 1486 40 1003 1047 272 277 127 1991 5692
5 1290 2069 67 530 863 1434 92 56 2124 6693
Show code
# Summary of the minimum number of samples per class and fold
count(trainDat, spatial_fold, species) |>
  group_by(species) |>
  summarise(
    min_fold = min(n),
    max_fold = max(n),
    mean_fold = mean(n)
  ) |>
  arrange(min_fold)
species min_fold max_fold mean_fold
Lodgepole pine 40 531 228.8
Other conifers 56 367 174.6
Open 84 1940 773.8
Other broadleaves 84 483 215.0
Larch 446 2069 1266.6
Norway spruce 530 1543 865.6
Oak 635 1956 1039.2
Scots pine 647 2562 1926.0
Birch 861 1866 1292.4
Sitka spruce 5691 6693 6200.0
Show code
# Turn the fold labels into an rsample object: for each fold, everything else is
# the analysis (training) set and the fold itself is the assessment set.
make_fold <- function(f) {
  make_splits(
    list(
      analysis   = which(trainDat$spatial_fold != f),
      assessment = which(trainDat$spatial_fold == f)
    ),
    data = trainDat
  )
}

fold_ids <- sort(unique(trainDat$spatial_fold))
spatial_folds <- manual_rset(
  map(fold_ids, make_fold),
  ids = str_c("Fold", fold_ids)
)
spatial_folds

Baseline model: all 36 predictors

A random forest (ranger engine) is tuned over mtry using the spatial folds above, optimising Cohen’s kappa (more informative than accuracy here, given the class imbalance).

Show code
rec_full <- recipe(species ~ ., data = trainDat) |>
  update_role(ID, spatial_fold, new_role = "id") |> # ID and spatial_fold should not be used to train the model. It marks them as identification variables.
  step_zv(all_predictors()) # Removes variables with zero variance.

rf_spec <- rand_forest(mtry = tune(), trees = 300) |>
  set_engine("ranger", importance = "impurity", num.threads = 1) |>
  set_mode("classification")

wf_full <- workflow() |> add_recipe(rec_full) |> add_model(rf_spec)
Show code
## Use all cores but one
#workers <- parallel::detectCores() - 1
#
#plan(multisession, workers = workers)
#
#tic("time to tune")
#
#set.seed(4127)
#
#tune_full <- tune_grid(
#  wf_full,
#  resamples = spatial_folds,
#  grid = tibble(mtry = c(2, 4, 6, 9, 12, 18)),
#  metrics = metric_set(accuracy, kap),
#  control = control_grid(
#    save_pred = TRUE,
#    verbose = TRUE,
#    allow_par = TRUE
#  )
#)
#
#toc()
#
## Back to sequential execution
#plan(sequential)
#
# View results ordered by kappa
#collect_metrics(tune_full) |>
#  filter(.metric == "kap") |>
#  arrange(desc(mean))
#
## Save result
#saveRDS(
#  tune_full,
#  file.path(dir_outputs, "tune_full_spatial_rf.rds")
#)
Show code
tune_full <- readRDS(
  file.path(dir_outputs, "tune_full_spatial_rf.rds")
)

best_mtry_full <- select_best(tune_full, metric = "kap")
best_mtry_full
mtry .config
6 pre0_mod3_post0

Improving the model: importance-based feature selection

Highly correlated, redundant predictors (e.g. the same band across four seasons) add noise without adding information, and can dilute variable importance. Instead of CAST::ffs()’s exhaustive forward search, impurity importance is extracted from the ranger engine fitted in every spatial fold and averaged, giving an importance ranking that is itself cross-validated rather than computed on a single fit.

Show code
## Parallelisation
#plan(
#  multisession,
#  workers = parallel::detectCores() - 1
#)
#
#cat("Workers available:", future::nbrOfWorkers(), "\n")
#
#tic("time to importance-extraction")
#
## Finalise the best workflow
#wf_final_full <- finalize_workflow(
#  wf_full,
#  best_mtry_full
#)
#
#set.seed(4127)
#
## Parallelised spatial evaluation
#res_full <- fit_resamples(
#  wf_final_full,
#  resamples = spatial_folds,
#  metrics = metric_set(accuracy, kap),
#  control = control_resamples(
#    save_pred = TRUE,
#    allow_par = TRUE,
#    extract = \(x) extract_fit_engine(x)$variable.importance
#  )
#)
#
## Save immediately, just in case
#saveRDS(
#  res_full,
#  file.path(dir_outputs, "res_full_spatial_rf.rds")
#)
#
## Extract variable importance
#importance_tbl <- res_full |>
#  select(id, .extracts) |>
#  unnest(.extracts) |>
#  unnest_longer(
#    .extracts,
#    values_to = "importance",
#    indices_to = "predictor"
#  ) |>
#  summarise(
#    mean_importance = mean(importance),
#    .by = predictor
#  ) |>
#  arrange(desc(mean_importance))
#
## Save results
#saveRDS(
#  importance_tbl,
#  file.path(dir_outputs, "importance_tbl.rds")
#)
#
## Show importance
#print(importance_tbl)
#
#toc()
#
## Back to sequential mode
#plan(sequential)
Show code
res_full <- readRDS(
  file.path(dir_outputs, "res_full_spatial_rf.rds")
)

importance_tbl <- readRDS(
  file.path(dir_outputs, "importance_tbl.rds")
)
Show code
importance_tbl |>
  slice_max(mean_importance, n = 20) |>
  ggplot(aes(mean_importance, fct_reorder(predictor, mean_importance))) +
  geom_col() +
  labs(
    title = "Mean impurity importance across 5 spatial CV folds",
    x = "Importance", y = NULL
  )

Predictors with above-average importance are kept as the reduced set — a simple, transparent filter rather than a tuned threshold, given how few independent spatial units are available to tune one reliably.

Show code
importance_threshold <- mean(importance_tbl$mean_importance)

selected_predictors <- importance_tbl |>
  filter(mean_importance > importance_threshold) |>
  pull(predictor)

length(selected_predictors)
[1] 14
Show code
selected_predictors
 [1] "red_winter"     "rededge_spring" "blue_winter"    "nir_spring"    
 [5] "red_autumn"     "red_spring"     "yellow_winter"  "nir_winter"    
 [9] "NDVI"           "red_summer"     "MSAVI2"         "nir_autumn"    
[13] "green_spring"   "yellow_autumn" 

Refit and compare against the full-predictor model

Show code
## Parallelisation
#plan(
#  multisession,
#  workers = parallel::detectCores() - 1
#)
#
#cat("Workers available:", future::nbrOfWorkers(), "\n")
#
#tic("time to reduced-model-tuning")
#
## Reduced recipe
#rec_reduced <- recipe(
#  as.formula(
#    str_c(
#      "species ~ ",
#      str_c(selected_predictors, collapse = " + ")
#    )
#  ),
#  data = trainDat
#) |>
#  step_zv(all_predictors())
#
## Reduced workflow
#wf_reduced <- workflow() |>
#  add_recipe(rec_reduced) |>
#  add_model(rf_spec)
#
#set.seed(4127)
#
## Parallelised tuning
#tune_reduced <- tune_grid(
#  wf_reduced,
#  resamples = spatial_folds,
#  grid = tibble(mtry = c(2, 4, 6, 9, 13)),
#  metrics = metric_set(accuracy, kap),
#  control = control_grid(
#    save_pred = TRUE,
#    allow_par = TRUE,
#    verbose = TRUE
#  )
#)
#
## Save immediately
#saveRDS(
#  tune_reduced,
#  file.path(dir_outputs, "tune_reduced_spatial_rf.rds")
#)

tune_reduced <- readRDS(
  file.path(dir_outputs, "tune_reduced_spatial_rf.rds")
)

# Best mtry
best_mtry_reduced <- select_best(
  tune_reduced,
  metric = "kap"
)

## Full vs reduced comparison
#comparison_tbl <- bind_rows(
#  collect_metrics(tune_full) |>
#    filter(mtry == best_mtry_full$mtry) |>
#    mutate(
#      model = str_c(
#        "Full (",
#        length(predictor_names),
#        " predictors)"
#      )
#    ),
#
#  collect_metrics(tune_reduced) |>
#    filter(mtry == best_mtry_reduced$mtry) |>
#    mutate(
#      model = str_c(
#        "Reduced (",
#        length(selected_predictors),
#        " predictors)"
#      )
#    )
#) |>
#  select(
#    model,
#    mtry,
#    .metric,
#    mean,
#    std_err
#  ) |>
#  pivot_wider(
#    names_from = .metric,
#    values_from = c(mean, std_err)
#  )
#
## Save comparison table
#saveRDS(
#  comparison_tbl,
#  file.path(dir_outputs, "comparison_tbl.rds")
#)

comparison_tbl <- readRDS(
  file.path(dir_outputs, "comparison_tbl.rds")
)


# Show table
knitr::kable(
  comparison_tbl,
  digits = 3,
  caption = "Best-mtry spatial-CV performance: full vs. reduced predictor set"
)
Best-mtry spatial-CV performance: full vs. reduced predictor set
model mtry mean_accuracy mean_bal_accuracy mean_kap std_err_accuracy std_err_bal_accuracy std_err_kap
Full (36 predictors) 6 0.807 0.772 0.732 0.011 0.01 0.013
Reduced (14 predictors) 2 0.815 NA 0.745 0.017 NA 0.023
Show code
toc()
Time to extract data: : 73.324 sec elapsed
Show code
# Back to sequential mode
plan(sequential)

The reduced predictor set performs comparably to (and, on this run, no worse than) the full set at its best mtry, so it is carried forward — fewer, less redundant predictors also make the final map faster to produce and somewhat easier to reason about.

Accuracy assessment

Because a random hold-out would leak spatially autocorrelated pixels between train and test, accuracy here is assessed from pooled, out-of-fold predictions of the 5-fold spatial cross-validation on the reduced model — every prediction comes from a fold that never saw that sub-compartment during training.

Show code
## Parallelisation
#plan(
#  multisession,
#  workers = parallel::detectCores() - 1
#)
#
#cat("Workers available:", future::nbrOfWorkers(), "\n")
#
#tic("time to final-model-evaluation")
#
## Finalise the best workflow
#wf_final <- finalize_workflow(
#  wf_reduced,
#  best_mtry_reduced
#)
#
#set.seed(4127)
#
## Parallelised spatial evaluation
#res_final <- fit_resamples(
#  wf_final,
#  resamples = spatial_folds,
#  metrics = metric_set(accuracy, kap),
#  control = control_resamples(
#    save_pred = TRUE,
#    allow_par = TRUE
#  )
#)
#
#toc()
#
## Save results in case the session closes
#saveRDS(
#  res_final,
#  file.path(dir_outputs, "res_final.rds")
#)

res_final <- readRDS(
  file.path(dir_outputs, "res_final.rds")
)

# View metrics
collect_metrics(res_final)
.metric .estimator mean n std_err .config
accuracy multiclass 0.8152806 5 0.0169227 pre0_mod0_post0
kap multiclass 0.7447034 5 0.0226680 pre0_mod0_post0
Show code
# Back to sequential mode
plan(sequential)
Show code
oof_preds <- collect_predictions(res_final)

conf_mat(oof_preds, truth = species, estimate = .pred_class) |>
  autoplot(type = "heatmap") +
  scale_fill_gradient(low = "white", high = "steelblue") +
  labs(title = "Confusion matrix — pooled 5-fold spatial CV predictions") +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))

Show code
species_levels <- levels(oof_preds$species)

# Producer's accuracy = recall; user's accuracy = precision, computed one class
# at a time in a one-vs-rest fashion.
per_class_accuracy <- map_dfr(species_levels, \(cl) {
  truth_bin <- factor(if_else(oof_preds$species == cl, cl, "other"), levels = c(cl, "other"))
  pred_bin  <- factor(if_else(oof_preds$.pred_class == cl, cl, "other"), levels = c(cl, "other"))
  tibble(
    species = cl,
    n = sum(oof_preds$species == cl),
    producers_accuracy = sens_vec(truth_bin, pred_bin, event_level = "first"),
    users_accuracy = precision_vec(truth_bin, pred_bin, event_level = "first")
  )
})

per_class_accuracy |>
  knitr::kable(digits = 3, caption = "Per-class producer's (recall) and user's (precision) accuracy")
Per-class producer’s (recall) and user’s (precision) accuracy
species n producers_accuracy users_accuracy
Birch 6462 0.865 0.734
Larch 6333 0.905 0.939
Lodgepole pine 1144 0.631 0.872
Norway spruce 4328 0.284 0.521
Oak 5196 0.905 0.903
Open 3869 0.875 0.997
Other broadleaves 1075 0.096 0.273
Other conifers 873 0.000 0.000
Scots pine 9630 0.731 0.800
Sitka spruce 31000 0.922 0.812

Producer’s/user’s accuracy for the rarest species should be treated cautiously: with few sample polygons, their out-of-fold predictions come from very few folds, so these numbers are based on little independent evidence rather than a stable estimate.

Spatial model predictions

The final workflow is refit on all training pixels using the reduced predictor set and the selected mtry, then applied to the full raster stack and clipped to the forest sub-compartments.

Show code
#set.seed(4127)
#final_fit <- fit(wf_final, data = trainDat)

#saveRDS(
#  final_fit,
#  file.path(dir_outputs, "final_fit.rds")
#)

final_fit <- readRDS(
  file.path(dir_outputs, "final_fit.rds")
)
Show code
#prediction <- terra::predict(
#  layer_stack[[selected_predictors]],
#  final_fit,
#  fun = \(model, ...) predict(model, ...)$.pred_class,
#  na.rm = TRUE
#)
#
#subcompartments <- st_read(file.path(dir_vectors, "subcompartmentCLIP.shp"), quiet = TRUE) |>
#  st_transform(crs(prediction))
#
#prediction_clip <- prediction |>
#  crop(subcompartments) |>
#  mask(subcompartments)
#
#writeRaster(prediction_clip, file.path(dir_outputs, "RF_AberfoyleCLIP_tidymodels.tif"), overwrite = TRUE)
Show code
prediction_clip <- rast(
  file.path(dir_outputs, "RF_AberfoyleCLIP_tidymodels.tif")
)

classes <- levels(prediction_clip)[[1]]$class

species_colours <- setNames(
  hcl.colors(length(classes), "Dark 3"),
  classes
)

rasterVis::levelplot(
  raster::raster(prediction_clip),
  maxpixel = 1e6,
  col.regions = species_colours[classes],
  scales = list(draw = FALSE),
  main = "Aberfoyle forest — species classification (tidymodels)"
)

Final model: variable importance

Show code
rf_engine <- extract_fit_engine(final_fit)

tibble(
  predictor  = names(rf_engine$variable.importance),
  importance = rf_engine$variable.importance
) |>
  slice_max(importance, n = 20) |>
  ggplot(aes(importance, fct_reorder(predictor, importance))) +
  geom_col() +
  labs(
    title = "Final model: predictor importance (Gini impurity)",
    subtitle = str_c(
      "Random forest, ", rf_engine$num.trees, " trees, ",
      length(rf_engine$variable.importance), " predictors, mtry = ", rf_engine$mtry
    ),
    x = "Importance", y = NULL
  )

Notes and caveats

  • Sample size. The training set is small and uneven across species (see the class counts above). Treat the accuracy figures as indicative, not as a rigorous validation — more independent polygons per class would materially improve confidence in the results.
  • Spatial CV, not random CV. Folds are built by kNNDM at the sub-compartment level and distance-matched to the prediction domain, so accuracy reflects prediction to new areas, not just new pixels within already-seen sub-compartments. The W statistic and ECDF plot above document how closely the CV represents those prediction conditions.
  • Feature selection is importance-based, not ffs. This is faster and reuses the same spatial folds, but it is a filter method (keep predictors above average importance) rather than the wrapper-style forward search CAST::ffs() performs; it can miss interactions that only add value in combination with specific other predictors.