Aberfoyle Species Classification — tidymodels Edition

Random Forest classification with spatial cross-validation

Author

Forest Research

Published

9 September 2026

Show code
#| include: false

# Spatial data
library(terra)
library(sf)

# Modelling
library(tidymodels)
library(blockCV)

# Remote sensing
library(RStoolbox)

# Data wrangling / visualisation
library(tidyverse)
library(ranger)
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

Why this version exists

This document reproduces the original caret + CAST random forest workflow (aberfoile_SAMPLE.qmd) using tidymodels. The overall structure is kept identical — vegetation indices, spatial cross-validation, feature selection, a wall-to-wall predicted map, and an accuracy assessment — but the implementation is modernised:

  • raster::stack() is replaced entirely by terra::SpatRaster, including for RStoolbox::spectralIndices(), which accepts SpatRaster directly.
  • Spatial blocking still relies on blockCV, but on its current API (cv_spatial_autocor() / cv_spatial()) rather than the retired spatialAutoRange()/spatialBlock() pair, and the resulting folds are wrapped as an rsample resampling object so they plug directly into tidymodels workflows (fit_resamples(), tune_grid()).
  • CAST::ffs() (exhaustive forward feature selection) has no direct tidymodels equivalent; feature selection here uses spatial-CV-averaged impurity importance from ranger instead, which is far cheaper and, as shown below, gives a comparable predictor subset. This is a deliberate substitution, not a like-for-like reproduction of ffs.
  • The original workflow also carved out a random 80/20 train/validation split. Because the data are spatially structured (pixels from the same forest sub-compartment are almost identical), a random split leaks information between train and validation. That step is dropped here; the accuracy assessment instead relies solely on pooled, spatially-blocked cross-validation predictions.

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"
)

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 creat 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
#)
#
## Guardar raster
#writeRaster(
#  layer_stack,
#  file.path(dir_outputs, "layer_stack.tif"),
#  overwrite = TRUE
#)
#
## Guardar nombres
#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
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"
)

Only 25 sample polygons are available across 5 species, and they are unevenly distributed — Sitka spruce has 12 polygons, Larch has only 1, Birch 2. This matters a great deal for what follows: with so few independent spatial units, any cross-validation fold that happens to contain the single Larch polygon will have no Larch training data, 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 extrac data: ")
#samples_vect <- vect(samples)
#
#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 range from ~100 to ~1,500 — 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 block) 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

Winter, spring and autumn signatures follow the same pattern and are available via season_signature_plots$winter, $spring, $autumn.

Random Forest classification

Spatial cross-validation with blockCV

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. blockCV estimates the spatial autocorrelation range of the predictor stack, and folds are then built so that whole sub-compartments — not individual pixels — are held out together.2

Show code
#tic("time to stimate grid: ")
#set.seed(4127)
#sac <- cv_spatial_autocor(r = layer_stack, num_sample = #nrow(extracted), plot = TRUE)
#range_m <- round(sac$range, 0)
#range_m
#
#saveRDS(sac, file = file.path(dir_outputs, "sac_object.RData"))
#
#toc()
sac <- readRDS(file.path(dir_outputs, "sac_object.RData"))
plot(sac)

Show code
range_m <- round(sac$range, 0)
range_m
[1] 1653

The suggested block size (1653 m) is the median autocorrelation range across all 36 predictor layers. Blocks of at least this size are then assigned to k cross-validation folds. With only 25 sample polygons spread across 5 classes, 10 folds (as in the original script) leaves several folds missing entire classes; k = 5 is used instead as a compromise, though even so, some folds still miss the rarest classes — printed below as a diagnostic rather than hidden.

Show code
set.seed(4127)
blocks <- cv_spatial(
  x = samples,
  column = "species",
  r = layer_stack,
  size = range_m,
  k =5,
  selection = "random",
  progress = FALSE,
  report = TRUE,
  plot = TRUE
)

  train_Birch train_Larch train_Lodgepole pine train_Norway spruce train_Oak
1          32          55                   18                  27        34
2          43          49                   14                  31        32
3          39          65                   18                  33        43
4          45          60                   17                  34        46
5          41          59                   13                  27        45
  train_Open train_Other broadleaves train_Other conifers train_Scots pine
1         17                      11                   26               64
2         17                      15                   24               64
3         17                      18                   28               56
4         15                      18                   20               67
5         18                      14                   18               69
  train_Sitka spruce test_Birch test_Larch test_Lodgepole pine
1                137         18         17                   2
2                143          7         23                   6
3                140         11          7                   2
4                149          5         12                   3
5                151          9         13                   7
  test_Norway spruce test_Oak test_Open test_Other broadleaves
1                 11       16         4                      8
2                  7       18         4                      4
3                  5        7         4                      1
4                  4        4         6                      1
5                 11        5         3                      5
  test_Other conifers test_Scots pine test_Sitka spruce
1                   3              16                43
2                   5              16                37
3                   1              24                40
4                   9              13                31
5                  11              11                29

Show code
samples$spatial_fold <- factor(blocks$folds_ids)
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 18 17 2 11 16 4 8 3 16 43
2 7 23 6 7 18 4 4 5 16 37
3 11 7 2 5 7 4 1 1 24 40
4 5 12 3 4 4 6 1 9 13 31
5 9 13 7 11 5 3 5 11 11 29

Build the predictor table and fold assignment

Each pixel inherits the spatial-block 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 2705 1529 47 1191 1124 1026 349 88 1832 7395
2 659 1674 793 687 2461 356 315 85 1963 6363
3 1087 804 42 748 640 637 13 42 2681 6892
4 623 982 56 498 464 1233 139 273 1779 5323
5 1388 1344 206 1204 507 617 259 385 1375 5027
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
Other broadleaves 13 349 215.0
Lodgepole pine 42 793 228.8
Other conifers 42 385 174.6
Open 356 1233 773.8
Oak 464 2461 1039.2
Norway spruce 498 1204 865.6
Birch 623 2705 1292.4
Larch 804 1674 1266.6
Scots pine 1375 2681 1926.0
Sitka spruce 5027 7395 6200.0
Show code
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()) #Eliminates 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
## Usar todos los cores menos uno
#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,bal_accuracy, kap),# Accuracy = #aciertos /total
#  control = control_grid(
#    save_pred = TRUE,
#    verbose = TRUE,
#    allow_par = TRUE
#  )
#)
#
#toc()
#
## Volver a ejecución secuencial
#plan(sequential)
#
## Ver resultados ordenados por Kappa
#collect_metrics(tune_full) |>
#  filter(.metric == "accuracy") |>
#  arrange(desc(mean))
#
## Guardar resultado
#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
## Paralelización
#plan(
#  multisession,
#  workers = parallel::detectCores() - 1
#)
#
#cat("Workers disponibles:", future::nbrOfWorkers(), "\n")
#
#tic("time to importance-extraction")
#
## Fijar mejor workflow
#wf_final_full <- finalize_workflow(
#  wf_full,
#  best_mtry_full
#)
#
#set.seed(4127)
#
## Evaluación espacial paralelizada
#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
#  )
#)
#
## Guardar inmediatamente por si acaso
#saveRDS(
#  res_full,
#  file.path(dir_outputs, "res_full_spatial_rf.rds")
#)
#
## Extraer importancia de variables
#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))
#
## Guardar resultados
#saveRDS(
#  importance_tbl,
#  file.path(dir_outputs, "importance_tbl.rds")
#)
#
## Mostrar importancia
#print(importance_tbl)
#
#toc()
#
## Volver a modo secuencial
#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] 13
Show code
selected_predictors
 [1] "red_winter"     "rededge_spring" "nir_spring"     "red_autumn"    
 [5] "blue_winter"    "red_spring"     "red_summer"     "nir_winter"    
 [9] "NDVI"           "MSAVI2"         "yellow_winter"  "nir_autumn"    
[13] "green_spring"  

Refit and compare against the full-predictor model

Show code
## Paralelización
#plan(
#  multisession,
#  workers = parallel::detectCores() - 1
#)
#
#cat("Workers disponibles:", future::nbrOfWorkers(), "\n")
#
#tic("time to reduced-model-tuning")
#
## Recipe reducida
#rec_reduced <- recipe(
#  as.formula(
#    str_c(
#      "species ~ ",
#      str_c(selected_predictors, collapse = " + ")
#    )
#  ),
#  data = trainDat
#) |>
#  step_zv(all_predictors())
#
## Workflow reducido
#wf_reduced <- workflow() |>
#  add_recipe(rec_reduced) |>
#  add_model(rf_spec)
#
#set.seed(4127)
#
## Tuning paralelizado
#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
#  )
#)
#
## Guardar inmediatamente
#saveRDS(
#  tune_reduced,
#  file.path(dir_outputs, "tune_reduced_spatial_rf.rds")
#)
#
#tune_reduced <- readRDS(
#  file.path(dir_outputs, "tune_reduced_spatial_rf.rds")
#)
#
## Mejor mtry
#best_mtry_reduced <- select_best(
#  tune_reduced,
#  metric = "kap"
#)
#
## Comparación Full vs Reduced
#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)
#  )
#
## Guardar tabla de comparación
#saveRDS(
#  comparison_tbl,
#  file.path(dir_outputs, "comparison_tbl.rds")
#)

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


# Mostrar tabla
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 (13 predictors) 2 0.805 NA 0.729 0.012 NA 0.014
Show code
toc()

# Volver a modo secuencial
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
## Paralelización
#plan(
#  multisession,
#  workers = parallel::detectCores() - 1
#)
#
#cat("Workers disponibles:", future::nbrOfWorkers(), "\n")
#
#tic("time to final-model-evaluation")
#
## Fijar el mejor workflow
#wf_final <- finalize_workflow(
#  wf_reduced,
#  best_mtry_reduced
#)
#
#set.seed(4127)
#
## Evaluación espacial paralelizada
#res_final <- fit_resamples(
#  wf_final,
#  resamples = spatial_folds,
#  metrics = metric_set(accuracy, kap),
#  control = control_resamples(
#    save_pred = TRUE,
#    allow_par = TRUE
#  )
#)
#
#toc()
#
## Guardar resultados por si cierras la sesión
#saveRDS(
#  res_final,
#  file.path(dir_outputs, "res_final.rds")
#)
#
res_final <- readRDS(
  file.path(dir_outputs, "res_final.rds")
)

# Ver métricas
collect_metrics(res_final)
.metric .estimator mean n std_err .config
accuracy multiclass 0.8048540 5 0.0123149 pre0_mod0_post0
kap multiclass 0.7293287 5 0.0142942 pre0_mod0_post0
Show code
# Volver a modo secuencial
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)

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.882 0.789
Larch 6333 0.919 0.936
Lodgepole pine 1144 0.210 0.680
Norway spruce 4328 0.261 0.500
Oak 5196 0.895 0.884
Open 3869 0.918 0.995
Other broadleaves 1075 0.214 0.697
Other conifers 873 0.000 0.000
Scots pine 9630 0.682 0.748
Sitka spruce 31000 0.916 0.793

Producer’s/user’s accuracy for Larch and Birch should be treated cautiously: with only 1 and 2 sample polygons respectively, their out-of-fold predictions come from at most one or two folds each, so these numbers are based on very 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
#)

library(Polychrome)

species_colours <- setNames(
  createPalette(
    N = length(classes),
    seedcolors = c("#000000", "#E41A1C", "#377EB8")
  ),
  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. 25 sub-compartment polygons across 5 species is a small and uneven training set (see the class counts above). Treat the accuracy figures as indicative, not as a rigorous validation — more independent polygons per class, especially for Larch and Birch, would materially improve confidence in the results.
  • Spatial CV, not random CV. Folds are built from spatially blocked sub-compartments so accuracy reflects prediction to new areas, not just new pixels within already-seen sub-compartments.
  • 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.