A calibration relates a signal to the concentration of an element, from samples of known composition, and predicts the concentration of new samples. This vignette builds univariate calibration curves from line intensities, with their limits of detection and quantification, and compares them with a multivariate model of the whole spectrum, whose figures of merit come from the net analyte signal.
library(specProc)
library(dplyr)
library(tidyr)
library(ggplot2)
data("forageLIBS")
spectra_id <- forageLIBS |> select(1:2) |> names()
minerals <- forageLIBS |> select(3:14) |> names()
spectra <- forageLIBS |> select(-all_of(c(spectra_id, minerals)))We remove the baseline and measure, in every spectrum, the
intensities of a potassium line, an iron line and the carbon line C I
247.86 nm. Carbon is the main element of the forage matrix, and dividing
by its line compensates for the changes of ablated mass and plasma from
spectrum to spectrum (the internal standard of
vignette("preprocessing")):
corrected <- baseline_arpls(spectra, lambda = 1e5, max.iter = 20)$correction
lines <- c(C = 247.856, K = 769.896, Fe = 259.940)
intensities <- line_intensities(corrected, lines) |>
select(spectrum, line, intensity) |>
pivot_wider(names_from = line, values_from = intensity)
data <- forageLIBS |>
select(all_of(spectra_id), K, Fe) |>
mutate(K_signal = intensities$K / intensities$C,
Fe_signal = intensities$Fe / intensities$C)We set aside a quarter of the samples to test the calibrations:
calibration_curve() fits the signal as a function of the
concentration, and returns the coefficients, the figures of merit and
tests of linearity:
k_curve <- calibration_curve(calibration, K_signal, K)
k_curve
#> Linear calibration curve (275 standards)
#>
#> term estimate std_error lower upper
#> intercept 1.2570 0.03232 1.1934 1.3206
#> slope 0.3087 0.01524 0.2787 0.3387
#>
#> R-squared: 0.60055
#> Sensitivity: 0.3087
#> LOD: 1.45 (residual)
#> LOQ: 4.39
#> Mandel: F = 4.52, p = 0.0345
#> Lack of fit: F = 1.12, p = 0.258plot_calibration() draws the curve with its confidence
band (where the mean signal lies) and its prediction band (where a
single new measurement is expected):
predict() inverts the curve: it gives the concentration
of new samples from their signals, with a prediction interval, and flags
the values below the limits of detection and quantification:
k_predicted <- predict(k_curve, test$K_signal)
head(k_predicted)
#> # A tibble: 6 × 7
#> signal concentration se lower upper below_lod below_loq
#> <dbl> <dbl> <dbl> <dbl> <dbl> <lgl> <lgl>
#> 1 2.23 3.17 0.443 2.29 4.04 FALSE TRUE
#> 2 1.89 2.05 0.439 1.18 2.91 FALSE TRUE
#> 3 2.06 2.61 0.440 1.74 3.48 FALSE TRUE
#> 4 2.16 2.93 0.442 2.06 3.80 FALSE TRUE
#> 5 1.83 1.86 0.439 0.996 2.73 FALSE TRUE
#> 6 1.85 1.92 0.439 1.06 2.79 FALSE TRUE
sqrt(mean((k_predicted$concentration - test$K)^2)) # RMSEP, % K
#> [1] 0.4828623The limit of quantification of this curve, 4.4% K, is above the
potassium content of almost every sample, so 100% of the predictions are
flagged below_loq: a single line ranks the samples, but
does not quantify them individually with the usual \(10\,\sigma\) margin.
Iron ranges from 50 to 2060 ppm, and its signal is noisier at high
concentration. An unweighted fit is then dominated by the few rich
samples, and so is its residual standard deviation, which inflates the
limit of detection. Weights "1/x" give each sample a weight
inversely proportional to its concentration. With weights, the residual
standard deviation depends on their scale, so the blank standard
deviation is taken from the standard error of the intercept
(lod_method = "intercept"):
fe_calibration <- filter(calibration, !is.na(Fe))
fe_unweighted <- calibration_curve(fe_calibration, Fe_signal, Fe)
fe_weighted <- calibration_curve(fe_calibration, Fe_signal, Fe, weights = "1/x",
lod_method = "intercept")
bind_rows(unweighted = fe_unweighted$figures_of_merit,
weighted = fe_weighted$figures_of_merit, .id = "fit") |>
select(fit, n, r_squared, lod, loq)
#> # A tibble: 2 × 5
#> fit n r_squared lod loq
#> <chr> <int> <dbl> <dbl> <dbl>
#> 1 unweighted 272 0.852 295. 895.
#> 2 weighted 272 0.788 17.4 52.8The weighted fit gives a limit of detection of 17 ppm, instead of 295 ppm. Its R² is lower, because it no longer favors the rich samples, which dominate R².
fe_test <- filter(test, !is.na(Fe))
fe_predicted <- predict(fe_weighted, fe_test$Fe_signal)
count(fe_predicted, below_loq)
#> # A tibble: 2 × 2
#> below_loq n
#> <lgl> <int>
#> 1 FALSE 84
#> 2 TRUE 9
median(abs(fe_predicted$concentration - fe_test$Fe) / fe_test$Fe) # relative error
#> [1] 0.2138503A single line uses a small part of the spectrum. Partial least squares (PLS) regression uses all of it: we divide the baseline-corrected spectra by the carbon line, and choose the number of components by cross-validation on the calibration samples, with the pls package:
x <- as.matrix(corrected) / intensities$C
is_calibration <- !data$Sample %in% test_samples
x_calibration <- x[is_calibration, ]
cv <- pls::plsr(K ~ x, data = data.frame(K = data$K[is_calibration], x = I(x_calibration)),
ncomp = 15, validation = "CV", segments = 10)
ncomp <- pls::selectNcomp(cv, method = "onesigma")
ncomp
#> [1] 7nas() fits the same model and computes its net analyte
signal (NAS): the part of each spectrum that is unique to potassium,
orthogonal to the contributions of the other constituents. The figures
of merit follow from it:
noise).We estimate the noise from the three samples measured twice, as the standard deviation of their difference spectra divided by \(\sqrt{2}\):
repeated <- data$Sample[duplicated(data$Sample)]
differences <- sapply(repeated, \(s) {
rows <- which(data$Sample == s)
x[rows[1], ] - x[rows[2], ]
})
noise <- sd(differences) / sqrt(2)
k_nas <- nas(x_calibration, data$K[is_calibration], ncomp = ncomp, noise = noise)
k_nas$figures_of_merit
#> sensitivity selectivity analytical_sensitivity
#> 4.0183608 0.1183193 29.9163896
#> lod loq
#> 0.1103074 0.3342649nas_predicted <- predict(k_nas, x[!is_calibration, ])
sqrt(mean((nas_predicted$predicted - test$K)^2)) # RMSEP, % K
#> [1] 0.2964224
summary(nas_predicted$selectivity)
#> Min. 1st Qu. Median Mean 3rd Qu. Max.
#> 0.0009985 0.0479909 0.0875087 0.1082689 0.1542453 0.3520663bind_rows(
`K I 769.90 nm curve` = tibble(laboratory = test$K, predicted = k_predicted$concentration),
`PLS (NAS)` = tibble(laboratory = test$K, predicted = nas_predicted$predicted),
.id = "model"
) |>
ggplot(aes(laboratory, predicted)) +
geom_abline(colour = "grey50", linetype = "dashed") +
geom_point(alpha = 0.6) +
facet_wrap(~ model) +
coord_equal() +
labs(x = "Laboratory K (%)", y = "Predicted K (%)") +
theme_bw()The PLS model predicts the test samples with an error of 0.30% K, against 0.48% for the calibration curve. Its limit of detection, 0.11% K, accounts for the noise of the spectra only, so it is a lower bound, far below the limit of the calibration curve, which includes all the scatter of the samples. Its mean selectivity, 0.12, means that only about a tenth of each spectrum is specific to potassium: the rest is shared with the other constituents and the plasma.
calibration_curve(); resonance lines
often curve at high concentration.nas()), they are lower bounds.The last vignette, vignette("plasma-diagnostics"),
measures the plasma itself: electron density, temperature and
self-absorption.