---
title: "Robust and sparse LW-PLS"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Robust and sparse LW-PLS}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r}
#| include: false
pkgs <- c("modeldata", "rsample", "tune", "workflows", "ggplot2")
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  fig.width = 7,
  fig.height = 4.5,
  eval = rlang::is_installed(pkgs)
)
```

The local models of LW-PLS can be made **robust** to outliers in the training
data and **sparse**, so that each one keeps only the predictors that matter
around its query. Both are options of the `"lwpls"` engine and of
`lwpls_fit()`, and they can be combined.

```{r}
#| label: setup
#| message: false
library(lwpls)
library(parsnip)
library(rsample)
library(tune)
library(workflows)
library(yardstick)
library(ggplot2)

data(meats, package = "modeldata")
meats <- meats[, !names(meats) %in% c("fat", "protein")]

set.seed(123)
meats_split <- initial_split(meats, prop = 0.75)
meats_train <- training(meats_split)
meats_test <- testing(meats_split)
```

## Robust LW-PLS

### Why

LW-PLS already gives tiny weights to training samples that are far from the
query, so outliers far away have little influence. Outliers **among the
neighbors** of the query are a different matter: a wrong reference value
(vertical outlier) or an unusual spectrum (leverage point) close to the query
enters its local model with a large weight.

With `robust = TRUE`, every local model is fitted by partial robust
M-regression (PRM; Serneels et al., 2005). Each training sample gets the weight

$$
\omega_i \, w_i^r \, w_i^t,
$$

the product of its similarity weight $\omega_i$, a weight $w_i^r$ that
decreases with its residual in the local model, and a weight $w_i^t$ that
decreases with its distance to the center of the local scores. The local model
is refitted with the new weights until it is stable. The weights come from
the Fair function (default) or the Hampel function
(`weight_function = "hampel"`), which gives a zero weight to the most outlying
samples. All the medians that PRM uses are weighted by the similarity
weights, so that the robust model stays local.

### Example: wrong reference values

We replace 20% of the water contents of the training set by wrong values,
shifted by 15 to 30 percentage points, as could happen with sample mix-ups or
transcription errors. The test set is left untouched.

```{r}
#| label: contamination
contaminate <- function(data, fraction, seed = 1) {
  set.seed(seed)
  bad <- sample(nrow(data), round(fraction * nrow(data)))
  shift <- sample(c(-1, 1), length(bad), replace = TRUE) * runif(length(bad), 15, 30)
  data$water[bad] <- data$water[bad] + shift
  data
}

meats_train_20 <- contaminate(meats_train, 0.2)
```

The outliers are also in the assessment sets of the resamples, where their
errors would dominate the usual RMSE and favor the model that follows them.
We tune with the trimmed RMSE instead, which leaves out the 20% largest errors
(`trimmed_rmse()`, provided by lwpls):

```{r}
#| label: robust-tune
set.seed(2)
folds_20 <- vfold_cv(meats_train_20, v = 5)
grid <- expand.grid(num_comp = c(4, 6, 8, 10, 12), localization = c(0.5, 1, 2, 4))

tune_lwpls <- function(robust) {
  wflow <- workflow(
    water ~ .,
    lwpls(num_comp = tune(), localization = tune()) |>
      set_mode("regression") |>
      set_engine("lwpls", robust = robust)
  )
  res <- tune_grid(wflow, folds_20, grid = grid, metrics = metric_set(trimmed_rmse))
  finalize_workflow(wflow, select_best(res, metric = "trimmed_rmse"))
}

lwpls_wflow <- tune_lwpls(robust = FALSE)
robust_wflow <- tune_lwpls(robust = TRUE)
```

Robust models are refitted for each number of components, so tuning them
takes longer than tuning ordinary LW-PLS models.

We then train both tuned workflows on training sets with 0% to 30% of wrong
reference values and measure their errors on the clean test set:

```{r}
#| label: robust-compare
fractions <- c(0, 0.1, 0.2, 0.3)
test_rmse <- function(wflow, fraction) {
  fitted <- fit(wflow, contaminate(meats_train, fraction))
  rmse_vec(meats_test$water, predict(fitted, meats_test)$.pred)
}

results <- data.frame(
  fraction = rep(fractions, 2),
  model = rep(c("LW-PLS", "Robust LW-PLS"), each = length(fractions)),
  rmse = c(
    vapply(fractions, test_rmse, numeric(1), wflow = lwpls_wflow),
    vapply(fractions, test_rmse, numeric(1), wflow = robust_wflow)
  )
)
results
```

```{r}
#| label: robust-plot
#| fig-alt: "Test RMSE versus the fraction of wrong reference values in the training set. The RMSE of LW-PLS increases steadily while that of robust LW-PLS stays nearly constant."
ggplot(results, aes(100 * fraction, rmse, color = model)) +
  geom_line(linewidth = 0.8) +
  geom_point(size = 2) +
  scale_color_manual(values = c("LW-PLS" = "#969696", "Robust LW-PLS" = "#08519C")) +
  labs(
    x = "Wrong reference values in the training set (%)",
    y = "Test RMSE (water, %)",
    color = NULL
  ) +
  theme_bw()
```

The error of ordinary LW-PLS grows with the number of wrong reference values,
while the robust local models are hardly affected. Both models were tuned on
the contaminated training set, which is why the robust model is also better
without contamination here. On clean data, robust models are usually a little
less efficient than ordinary ones: use them when outliers are expected, and
compare both by resampling with a robust metric.

## Sparse LW-PLS

With `sparsity` $\eta > 0$, the local models are fitted by sparse NIPALS
(SNIPLS; Hoffmann et al., 2015): the weight vector of each component is
soft-thresholded at $\eta \max_j |w_j|$, so that the predictors with small
weights are dropped. Each local model then selects its own predictors, here
the wavelengths that matter around the query. `sparsity` is a tuning
parameter:

```{r}
#| label: sparse-tune
set.seed(2)
folds <- vfold_cv(meats_train, v = 5)

sparse_wflow <- workflow(
  water ~ .,
  lwpls(num_comp = tune(), localization = 0.5, sparsity = tune()) |>
    set_mode("regression")
)

sparse_res <- tune_grid(
  sparse_wflow,
  resamples = folds,
  grid = expand.grid(num_comp = 1:15, sparsity = c(0, 0.2, 0.4, 0.6, 0.8)),
  metrics = metric_set(rmse)
)
show_best(sparse_res, metric = "rmse", n = 5)
```

```{r}
#| label: sparse-plot
#| fig-alt: "Cross-validated RMSE versus the number of components, one line per sparsity threshold. Thresholds up to 0.4 give the same error as the dense model; 0.8 is clearly worse."
autoplot(sparse_res, metric = "rmse") +
  scale_color_viridis_d(option = "mako", end = 0.9) +
  theme_bw()
```

Moderate thresholds keep the accuracy of the dense local models while
dropping the wavelengths with small weights from each of them; very large
thresholds drop too much information.
Sparse models without robustness give all the numbers of components from a
single fit, so tuning `num_comp` costs nothing extra.

```{r}
#| label: sparse-final
sparse_wflow |>
  finalize_workflow(select_best(sparse_res, metric = "rmse")) |>
  last_fit(meats_split) |>
  collect_metrics()
```

## Sparse and robust

Both options together give local sparse partial robust M-regression (SPRM;
Hoffmann et al., 2015):

```{r}
#| label: sprm
sprm_fit <-
  lwpls(num_comp = 6, localization = 2, sparsity = 0.4) |>
  set_mode("regression") |>
  set_engine("lwpls", robust = TRUE) |>
  fit(water ~ ., data = meats_train_20)

sprm_fit

rmse_vec(meats_test$water, predict(sprm_fit, meats_test)$.pred)
```

## References

* Serneels, S., Croux, C., Filzmoser, P. and Van Espen, P. J. (2005). Partial
  robust M-regression. *Chemometrics and Intelligent Laboratory Systems*,
  79(1--2), 55--64.
* Hoffmann, I., Serneels, S., Filzmoser, P. and Croux, C. (2015). Sparse
  partial robust M regression. *Chemometrics and Intelligent Laboratory
  Systems*, 149, 50--59.
