Chemometrics in Analytical Chemistry 2026
2026-06-29
\[ \text{y} = {\color{red} f}(\text{DRS spectrum}) + \epsilon \]
\[\text{y} = {\color{red} f}({\color{dodgerblue} g}(\text{DRS spectrum})) + \epsilon\]
\({\color{dodgerblue} g}\): signal processing, e.g derivatives, scatter correction, normalisation
\({\color{red} f}\): chemometric model, e.g. regression, classification, local methods
\(\epsilon\): irreducible error
These are distinct, composable steps.
A simplified view of the chemometric modelling pipeline:
A simplified view of the prediction pipeline:
Proprietary chemometrics software…
Cost and access
Licence fees
Institutional dependency
Risk of discontinuation
Limited user base → limited community support
Proprietary chemometrics software…
Transparency and trust
Closed source
Bugs hard to spot or report
Reproducibility limited
No audit trail
Less amenable to peer scrutiny
Proprietary chemometrics software…
Flexibility and integration
Poor interoperability
No version control
Cannot script end-to-end pipelines
Disconnected from modern AI/ML tools
Model deployment requires additional licences
Slow response time to new developments
| Aspect | Proprietary | Open source (e.g. R) |
|---|---|---|
| Cost | Licence required | Free |
| Source | Closed | Open |
| Reproducibility | Limited | Full scripting |
| AI/ML integration | Restricted | Native |
| Customisation | Little | Unlimited |
| Community | Small | Large, active |
| Longevity | Uncertain | Community-sustained |
prospectrReleased in 2013…
resembleReleased in 2014…
proximetricsR[First implemented: 2020] [Released today🙂] [Soon on CRAN]
Comprehensive machine learning framework:
chemtools: chemometric methods and preprocessingscikit-learn: PCA, PLS, regression, classificationnumpy, scipy: numerical and signal processingscikit-image: image and signal preprocessingspectral: hyperspectral data analysistensorflow, pytorch: deep learning for spectroscopyStrength: seamless integration with modern ML/AI ecosystem and data science workflows
High-performance numerical computing for chemometrics:
MultivariateStats.jl: PCA, PLS, ICAMLJ.jl: machine learning meta-frameworkDSP.jl: signal processingJchemo.jl: a toolbox for chemometricsSpectroscopiCal.jl: chemometics in Julia_ (coming soon)SpectralArrays.jl: matrices for spectral data information (coming very soon)Note: chemometrics ecosystem smaller than R/Python, but growing rapidly
\[\text{y} = {\color{red} f}({\color{dodgerblue} g}(\text{DRS spectrum})) + \epsilon\]
A dataset of soil samples from Spain
Target properties: organic carbon (OC) and organic matter
Spectral data/predictors: NIR (1350 - 2550 nm or 7407.4 - 3921.5 cm\(^{-1}\)) collected with a ProxiScout (BUCHI) sensor
364 samples for calibration of models
150 samples for validation of models
In the folder /data/local-samples-spain/ you will find:
local-spectra-cal.xlsx: raw spectra for the calibration set
local-properties-cal.xlsx: reference properties for the calibration set
local-spectra-val.xlsx: raw spectra for the validation set
local-properties-val.xlsx: reference properties for the validation set
Create a folder manually on your computer and organise it like this:
my_folder/
├── data/
│ └── local-samples-spain/
│ ├── local-spectra-cal.xlsx
│ ├── local-properties-cal.xlsx
│ ├── local-spectra-val.xlsx
│ └── local-properties-val.xlsx
├── R/
└── outputs/
Tell R to use your folder as the working directory:
Verify (you should get the path to your folder):
proximetricsR package in RInstall proximetricsR from GitHub:
proximetricsR package in RInstall proximetricsR from GitHub:
Once installed, load it:
Read the calibration data (samples with spectra and their corresponding reference values):
Check how many rows and variables are in the loaded data:
Check the name of the variables in the loaded data:
View the table:
View the spectra only:
The proxiscout_read_data() function returns a special object that combines:
Overall structure:
364 samples (rows) and 6 variables (columns)
Class: proxiscout_data (a data.frame)
The 6 variables include:
Sample identifier, device identifier, a ref column, OC, OM
One spectra matrix with 257 wavenumbers: mlocal_cal$spc
So when you access mlocal_cal$spc, you get the 364 × 257 matrix of raw NIR reflectance values. The other columns contain reference property values (e.g. OC, organic matter) and sample identifiers.
Read the validation data (samples with spectra and their corresponding reference values):
Check the dimensions of the validation data:
The wavenumbers are stored as the column names of the spectra matrix in the calibration data. We can extract them and convert them to numeric values:
matplot(
wavs,
t(mlocal_cal$spc),
type = "l",
lty = 1,
col = rgb(1, 0, 0, 0.5),
xlab = expression("Wavenumber ("*cm^{-1}*")"),
ylab = "Reflectance",
main = "Calibration data",
xlim = c(7500, 4000)
)
matplot(
wavs,
t(mlocal_val$spc),
type = "l",
lty = 1,
col = rgb(1, 0, 0, 0.5),
xlab = expression("Wavenumber ("*cm^{-1}*")"),
ylab = "Reflectance",
main = "Validation data",
xlim = c(7500, 4000)
)\[\text{y} = {\color{red} f}({\color{dodgerblue} g}(\text{DRS spectrum})) + \epsilon\]
Define what to model (e.g. OC, OM, etc.)
Define a set of preprocessing methods \({\color{dodgerblue} g}\) to apply to the spectra
Define a set of modelling methods \({\color{red} f}\) to apply to the preprocessed spectra
Define additional aspects of the calibtration pipeline (e.g. cross-validation, hyperparameter tuning, etc.)
Here you can define in a list a set of formulas to model, e.g. OC, OM, etc. For example, if you want to model OC, you can define the following formula:
Note you can also include more than one formula in the list, e.g. if you want to model both OC and OM:
In proximetricsR, you can define a set of preprocessing methods/recipes.
We use the concept of “constructors” to define preprocessing methods. A constructor is a function that takes some parameters and returns a preprocessing method. The preprocessing constructors have a prep_* prefix. For example:
Other examples of construtors:
Check more:
Let’s create a recipe of preprocessing methods to apply to the spectra:
Spectral preprocessing recipe (device: "proxiscout"):
- Step 1: prep_resample
- Step 2: prep_snv
- Step 3: prep_derivative
m: 1; w: 11; p: 1; algorithm: 'savitzky-golay'
Let’s create a list of various preprocessing recipes (to test later):
recipe_2 <- preprocess_recipe(
prep_resample(grid = "proxiscout"),
prep_snv(),
prep_derivative(m = 2, w = 11, p = 2, algorithm = "savitzky-golay"),
device = "proxiscout"
)
recipe_3 <- preprocess_recipe(
prep_resample(grid = "proxiscout"),
prep_detrend(p = 2),
prep_derivative(m = 2, w = 11, p = 2, algorithm = "savitzky-golay"),
device = "proxiscout"
)
recipe_4 <- preprocess_recipe(
prep_resample(grid = "proxiscout"),
prep_transform(to = "absorbance"),
prep_derivative(m = 2, w = 11, p = 2, algorithm = "savitzky-golay"),
device = "proxiscout"
)
recipe_5 <- preprocess_recipe(
prep_resample(grid = "proxiscout"),
prep_transform(to = "absorbance"),
prep_derivative(m = 1, w = 11, p = 2, algorithm = "savitzky-golay"),
prep_snv(),
device = "proxiscout"
)
recipe_6 <- preprocess_recipe(
prep_resample(grid = "proxiscout"),
prep_transform(to = "absorbance"),
prep_derivative(m = 2, w = 11, p = 2, algorithm = "savitzky-golay"),
prep_snv(),
device = "proxiscout"
)
my_preprocessings <- list(recipe_1, recipe_2, recipe_3, recipe_4, recipe_5)Here we also use the concept of constructors:
Create a list of modeling methods (to test later):
NOTE: check the vignette made for regression methods
Now, the calibration of a model becomes a simple integration of instructions:
Plot detailed modelling results:
Inspect the results in plots:
one_model <- my_model$final_models$`OC ~ spc`
y_hat <- predict(one_model, newdata = mlocal_val)
axes_range <- range(c(y_hat$predictions, mlocal_val$OC), na.rm = TRUE)
plot(
y_hat$predictions,
mlocal_val$OC,
xlab = "Predicted OC (%)",
ylab = "Reference OC (%)",
main = "External validation",
xlim = axes_range,
ylim = axes_range,
pch = 16,
cex = 2,
col = "#F0AD00"
)
abline(0, 1, col = "red")R2 = 0.734
RMSE = 1.24
Or
Imagine we only have few samples with reference values (as if we were just starting with a new project)…
Local samples: Only 30 samples with reference values (OC) and their corresponding spectra. However, many more with spectra but without reference values.
Spectral library: We have a spectral library with a considerable amount of calibration samples.
GOAL: We want to select the most relevant samples from the spectral library to build a calibration model for our local samples.
These files come from a soil spectral library from USA:
my_folder/
├── data/
│ ├── local-samples-spain/
│ │ ├── local-spectra-cal.xlsx
│ │ ├── local-properties-cal.xlsx
│ │ ├── local-spectra-val.xlsx
│ │ └── local-properties-val.xlsx
│ └── spectral-library-usa/
│ ├── spectral-library-spectra.xlsx
│ └── spectral-library-properties.xlsx
├── R/
└── outputs/
Ramirez-Lopez et al. (2026)
Topic: beers of the world
Groups: several groups of 5 people at the bar
Sessions: every Friday
Gens: persons
Individuals: groups
Evaluation period: every year
Warning
After 1 year, there is an evaluation of every single person. The worst are expelled.
The gesearch algorithm is based on a novel evolutionary search method that allow us to identify the most relevant training samples from a large spectral library to be used to build linear models for a given target domain.
In the resemble package, the gesearch() function implements this search method.
Check the name of the variables in the loaded data:
[1] "sampleName" "deviceId"
[3] "OC" "oc_usda.c729_w.pct"
[5] "c.tot_usda.a622_w.pct" "n.tot_usda.a623_w.pct"
[7] "s.tot_usda.a624_w.pct" "ph.h2o_usda.a268_index"
[9] "bd_usda.a4_g.cm3" "clay.tot_usda.a334_w.pct"
[11] "silt.tot_usda.c62_w.pct" "sand.tot_usda.c60_w.pct"
[13] "caco3_usda.a54_w.pct" "efferv_usda.a479_class"
[15] "cec_usda.a723_cmolc.kg" "ca.ext_usda.a722_cmolc.kg"
[17] "mg.ext_usda.a724_cmolc.kg" "k.ext_usda.a725_cmolc.kg"
[19] "na.ext_usda.a726_cmolc.kg" "wr.33kPa_usda.a415_w.pct"
[21] "wr.1500kPa_usda.a417_w.pct" "al.dith_usda.a65_w.pct"
[23] "p.ext_usda.a652_mg.kg" "p.ext_usda.a1070_mg.kg"
[25] "k.ext_usda.a1065_mg.kg" "ec_usda.a364_ds.m"
[27] "spc"
View the table:
to plot the raw spectra of the library…
and to plot the second derivative spectra:
As we had just few calibration samples with reference values (30), the rest only with spectra…
from the original data, overwrite organic carbon values with missing values (e.g. NAs)…
mlocal_cal <- proxiscout_read_data(
file = "data/local-samples-spain/local-spectra-cal.xlsx",
references_file = "data/local-samples-spain/local-properties-cal.xlsx"
)
mlocal_cal$OC[mlocal_cal$ref == 0] <- NA
mlocal_val <- proxiscout_read_data(
file = "data/local-samples-spain/local-spectra-val.xlsx",
references_file = "data/local-samples-spain/local-properties-val.xlsx"
)The search is computationally intensive. The gesearch() function supports parallel computing.
Load the doParallel package
Here, we define the wavenumber from which we will be trimming our spectral data:
and extract (again, just in case) the vector of wavenumbers:
Get a vector of TRUE for the wavenumbers to keep and FALSE for the wavenumbes to ignore:
We will use our previous preprocessing recipe identified as the optimal (recipe_5):
search_recipe <- preprocess_recipe(
prep_resample(grid = "proxiscout"),
prep_transform(to = "absorbance"),
prep_derivative(m = 2, w = 11, p = 2, algorithm = "savitzky-golay"),
prep_snv(),
device = "proxiscout"
)
search_results <- gesearch(
Xr = process(mlibrary$spc[, wav_sel], search_recipe),
Yr = mlibrary$OC,
Xu = process(mlocal_cal$spc[, wav_sel], search_recipe),
Yu = mlocal_cal$OC,
Yu_lims = c(0.02, 15), # New!!
k = 30, b = 200, retain = 0.95, # the key gesearch parameters
target_size = 80,
fit_method = fit_pls(ncomp = 10, method = "mpls"),
optimization = c("similarity", "reconstruction", "response", "range"),
control = gesearch_control(retain_by = "probability"),
seed = 1410,
pchunks = 5
)stop using parallel computing:
The gesearch() function builds a model with the samples found in combination with the samples from the local domain used to guide the search. SO we can use that model to predict the validation samples:
We could go on and do the validation directly here, but this will be using resemble… Since we want to deploy our model, this is way easier if we switch back to proximetricsR.
proximetricsRsearch_results$indices contains the indices of the samples found in the spectral library, so, create a hybrid modelling dataset (the samples found + the few samples from the local domain):
my_model_hybrid <- calibrate_models(
formulas = my_formulas,
data = mhybrid_cal, # just this is different to what we did previously
preprocess_recipes = my_preprocessings,
methods = my_fittings,
control = my_control,
verbose = FALSE
)
one_model_hybrid <- my_model_hybrid$final_models$`OC ~ spc`
y_hat_hybrid <- predict(one_model_hybrid, newdata = mlocal_val, verbose = FALSE)
axes_range <- range(c(y_hat_hybrid$predictions, mlocal_val$OC), na.rm = TRUE)
plot(
y_hat_hybrid$predictions,
mlocal_val$OC,
xlab = "Predicted OC (%)",
ylab = "Reference OC (%)",
main = "External validation",
xlim = axes_range,
ylim = axes_range,
pch = 16,
cex = 2,
col = "#F0AD00"
)
abline(0, 1, col = "red")