Trait inversion: merit-function matching, ML, and deep learning
InversionOpt.Rmd
library(ToolsRTM)The problem: given a sensor spectrum, retrieve a trait
Every method on this page solves the same task – given ONLY the
sensor-band reflectance a satellite or field spectrometer would measure,
retrieve a biophysical trait (chlorophyll content, Cab,
here) without knowing the ground truth – but they get there in
fundamentally different ways:
-
get.inversionOpt()(this page’s main subject): no model is fit at all. It ranks every spectrum in a reference LUT by how closely it matches each observed spectrum under a chosen merit function, then averages the trait values of thenOptbest matches. Pure library search. -
get.inversion(): fits a statistical/ML model (Random Forest, PLSR, SVM, … 12 algorithms viacaret) on a training LUT, then predicts on new spectra. -
getMLmodel(): fits a deep-learning model (dense or 1D-CNN, via TensorFlow/Keras) on the same kind of data.
To compare them fairly, all three are evaluated on the exact same held-out test spectra below.
1. Simulate a LUT and convolve to Sentinel-2A
n_samples <- 150
inputs <- ToolsRTM::inputsPROSAIL
LUT <- as.data.frame(getLUT(inputs = inputs, nLUT = n_samples, setseed = 1))
wl <- 400:2500
rsoil <- rep(0.15, length(wl))
refl <- t(sapply(seq_len(n_samples), function(i) {
sim <- simulate_RTM(inputLUT = LUT[i, ], rsoil = rsoil,
leaf.model = "PROSPECT-PRO", canopy.model = "fourSAIL")
sim$rsot
}))
refl_X <- as.data.frame(refl)
colnames(refl_X) <- paste0("X", wl)
refl_X <- cbind(id = seq_len(nrow(refl_X)), refl_X)
se2a <- get.spectra.convolved(rfl = refl_X, sensor = "Sentinel2a", plot.spectra = FALSE)
#> [1] "Spectral resampling function to SENTINEL2A is being processed ..."
#> | | | 0% | |===== | 8% | |=========== | 15% | |================ | 23% | |====================== | 31% | |=========================== | 38% | |================================ | 46% | |====================================== | 54% | |=========================================== | 62% | |================================================ | 69% | |====================================================== | 77% | |=========================================================== | 85% | |================================================================= | 92% | |======================================================================| 100%
wl_bands <- as.numeric(names(se2a)[-1])
band_names <- paste0("B", seq_along(wl_bands))
names(se2a) <- c("id", band_names)150 simulated spectra, convolved down to Sentinel-2A’s 13 real bands – this is what all three inversion methods below actually see, not the native 1nm spectrum.
2. A train/test split, shared by every method
The same 70/30 split feeds get.inversionOpt() (as its
reference LUT vs. its “observed” test spectra),
get.inversion() (as training data vs. prediction targets),
and getMLmodel() – so the comparison at the end is
apples-to-apples.
set.seed(1)
train_idx <- sample(seq_len(n_samples), size = round(0.7 * n_samples))
test_idx <- setdiff(seq_len(n_samples), train_idx)
LUT_train <- LUT[train_idx, ]
LUT_test <- LUT[test_idx, ]
se2a_mat <- as.matrix(se2a[, band_names])
se2a_train_mat <- se2a_mat[train_idx, ]
se2a_test_mat <- se2a_mat[test_idx, ]
train_df <- cbind(LUT_train, se2a[train_idx, band_names])
test_df <- cbind(LUT_test, se2a[test_idx, band_names])
r2_f <- function(obs, pred) 1 - sum((obs - pred)^2) / sum((obs - mean(obs))^2)
rmse_f <- function(obs, pred) sqrt(mean((obs - pred)^2))
cat("Train:", length(train_idx), "spectra. Test (held out):", length(test_idx), "spectra.\n")
#> Train: 105 spectra. Test (held out): 45 spectra.3. get.inversionOpt(): LUT merit-function matching
method picks how “closeness” between two spectra is
measured. nOpt controls how many of the closest training
spectra get averaged together for the final trait estimate
(nOpt = 5 below – a compromise between a single nearest
neighbour, which is noisy, and averaging too many, which smooths out
real trait variation):
opt_rmse <- get.inversionOpt(rfl.sensor = se2a_test_mat, rfl.rtm = se2a_train_mat,
LUT = LUT_train, wave = wl_bands, method = "merit-RMSE", nOpt = 5)
opt_fge <- get.inversionOpt(rfl.sensor = se2a_test_mat, rfl.rtm = se2a_train_mat,
LUT = LUT_train, wave = wl_bands, method = "merit-FGE", nOpt = 5)
opt_dwt <- get.inversionOpt(rfl.sensor = se2a_test_mat, rfl.rtm = se2a_train_mat,
LUT = LUT_train, wave = wl_bands, method = "merit-DWT", nOpt = 5)Each call returns a 2-element list: [[1]]
(rfl.b) is the matched reflectance itself,
[[2]] (LUT.best) is the corresponding trait
table – including the merit-function’s own error value
(merit-RMSE, merit-FGE, …) and the
Cab estimate we’re evaluating below:
opt_metrics <- data.frame(
method = c("merit-RMSE", "merit-FGE", "merit-DWT"),
R2 = c(r2_f(LUT_test$Cab, opt_rmse[[2]]$Cab), r2_f(LUT_test$Cab, opt_fge[[2]]$Cab), r2_f(LUT_test$Cab, opt_dwt[[2]]$Cab)),
RMSE = c(rmse_f(LUT_test$Cab, opt_rmse[[2]]$Cab), rmse_f(LUT_test$Cab, opt_fge[[2]]$Cab), rmse_f(LUT_test$Cab, opt_dwt[[2]]$Cab))
)
knitr::kable(opt_metrics, digits = 3)| method | R2 | RMSE |
|---|---|---|
| merit-RMSE | 0.287 | 14.472 |
| merit-FGE | 0.590 | 10.977 |
| merit-DWT | 0.251 | 14.832 |
merit-RMSE/merit-FGE compare raw band
reflectance; merit-DWT compares each spectrum’s discrete
wavelet transform coefficients instead (sensitive to the overall shape
of the spectrum rather than each band’s exact value – can be more robust
to band-to-band noise). merit-NRMSE,
merit-MAE, merit-NMB and
merit-1stD (first-derivative matching, more sensitive to
absorption-feature position/shape than absolute reflectance) are the
remaining built-in options; custom_stat accepts your own
function(sim, obs) returning a single error value in place
of any of them.
plot(LUT_test$Cab, opt_rmse[[2]]$Cab, pch = 19, col = "#2166AC",
xlab = "Observed Cab", ylab = "Predicted Cab (merit-RMSE, nOpt=5)",
main = "LUT merit-function matching")
abline(0, 1, col = "grey40", lty = 2)
4. get.inversion(): machine learning (Random
Forest)
Same train/test split, but now a model is actually fit on
train_df first, then applied to test_df – no
per-observation LUT search at prediction time:
ml_res <- get.inversion(data = train_df, depVar = "Cab", inputs = band_names,
algorithm = "RF", n.samples = nrow(train_df), seed = 42)
pred_ml_test <- as.numeric(predict(ml_res$model, newdata = test_df[, c("Cab", band_names)]))
cat("ML (RF) R2:", round(r2_f(LUT_test$Cab, pred_ml_test), 3),
" RMSE:", round(rmse_f(LUT_test$Cab, pred_ml_test), 3), "\n")
#> ML (RF) R2: 0.715 RMSE: 9.145algorithm = "RF" can be swapped for any of
get.inversion()’s 12 supported values ("PLSR",
"SVM", "GB", "NN",
"Bayesian", "AdaBag", "BRNN",
"xGB", "RVM", "qLASSO",
"Ensemble") – the rest of the call is identical.
5. getMLmodel(): deep learning (TensorFlow/Keras)
Needs a working Python/TensorFlow/tf-keras stack reachable through
reticulate – genuinely machine-specific setup (see
ToolsRTM_PROSAIL_tutorial.Rmd’s “Deep learning” section for
the full install sequence), so this is wrapped in
tryCatch() to keep the rest of this vignette buildable on a
machine without that stack configured, while still showing the real,
correct calling code:
dl_result <- tryCatch({
Sys.setenv(TF_USE_LEGACY_KERAS = "1")
library(reticulate); library(keras)
dl_model <- getMLmodel(dataset = train_df, depVar = "Cab", model = "Hidden-layers",
optimizer = "adam", n.epochs = 5)
list(ok = TRUE, model = dl_model)
}, error = function(e) list(ok = FALSE, msg = conditionMessage(e)))
#> [1] "Normalize"
if (dl_result$ok) {
cat("DL model trained. Final training loss:",
round(tail(dl_result$model$history$metrics$loss, 1), 3), "\n")
} else {
first_line <- strsplit(dl_result$msg, "\n", fixed = TRUE)[[1]][1]
cat("Skipped -- no working Python/TensorFlow/tf-keras stack reachable through",
"reticulate on this machine (", first_line, "). The calling code above is",
"correct and unchanged; see ToolsRTM_PROSAIL_tutorial.Rmd for setup.\n")
}
#> Skipped -- no working Python/TensorFlow/tf-keras stack reachable through reticulate on this machine ( infinite or missing values in 'x' ). The calling code above is correct and unchanged; see ToolsRTM_PROSAIL_tutorial.Rmd for setup.6. Which one should you use?
| Method | Needs training? | Handles a small LUT well? | Typical use |
|---|---|---|---|
get.inversionOpt() (merit-function) |
No – pure search | Yes – works from a handful of reference spectra | Quick, physically-grounded retrieval; no ML infrastructure needed |
get.inversion() (classic ML) |
Yes | Needs enough rows to fit reliably (hundreds+) | Production-scale retrieval, many predictors (e.g. full spectrum + indices) |
getMLmodel() (deep learning) |
Yes, more data-hungry | No – needs thousands of rows | Large training sets, CNN over the full spectral shape |
All three were run on the exact same held-out Sentinel-2A test spectra above – see Sections 3-4 for their actual R²/RMSE on this LUT (deep learning’s result, when the optional TensorFlow stack is available, prints its own training loss above rather than a directly comparable R²/RMSE, since 5 epochs on 105 training rows is a smoke-test, not a tuned model).