---
title: "Optimizer examples"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{Optimizer examples}
  %\VignetteEncoding{UTF-8}
  %\VignetteEngine{knitr::rmarkdown}
---
  
  
```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>"
)
```

```{r, include = FALSE}
# limit threads to avoid CPU time issues on CRAN
data.table::setDTthreads(2)
```
  
```{r setup}
library(designit)
```
  
  
# Sample annotation overview

```{r}
data("multi_trt_day_samples")
```
  
Samples are grouped by Treatment and Collection time with the following group
sizes:
  
```{r}
multi_trt_day_samples |>
  dplyr::count(Time, Treatment) |>
  gt::gt()
```

Total number of samples is: `r nrow(multi_trt_day_samples)`

# Task

Samples are to be blocked in batches for scRNA-seq. 

* 8 samples can be processed per day (batch)
* Within day they need to be split into 2 parallel runs (4 + 4). 

This data set is also used in the nested dimensions example. Here, we focus on
using different methods for the optimization.

# Setting up batch container

We allocate surplus positions in the batch container and some excluded positions
to check that all optimization methods support empty container positions.

```{r}
# Setting up the batch container
bc <- BatchContainer$new(
  dimensions = c(
    batch = ceiling(nrow(multi_trt_day_samples) / 8),
    run = 2, position = 5
  ),
  exclude = tibble::tibble(batch = 4, run = c(1, 2), position = c(5, 5))
) |>
  # Add samples to container
  assign_in_order(samples = multi_trt_day_samples)

bc
```

# First optimization with fixed shuffling protocol

The samples are distributed to 4 batches (processing days). We use the osat
scoring on sample `Treatment` and `Time`, using first a shuffling protocol with
a fixed number of sample swaps on each iteration.

Note that doing 32 swaps on 38 free container positions does not make sense,
since each swapping operation affects two different positions anyway. The upper
limit is reduced to the max number of meaningful swaps (19) on the fly.

Optimization finishes after the list of permutations is exhausted.

```{r}
n_shuffle <- rep(c(32, 10, 5, 2, 1), c(20, 40, 40, 50, 50))

scoring_f <- osat_score_generator(c("batch"), c("Treatment", "Time"))

bc1 <- optimize_design(
  bc,
  scoring = scoring_f,
  n_shuffle = n_shuffle # will implicitly generate a shuffling function according to the provided schedule
)

bc1$trace$elapsed
```

## Optimization trace 

Custom plot with some colours:

```{r, fig.width=5, fig.height= 4}
bc1$scores_table() |>
  dplyr::mutate(
    n_shuffle = c(NA, n_shuffle)
  ) |>
  ggplot2::ggplot(
    ggplot2::aes(step, value, color = factor(n_shuffle))
  ) +
  ggplot2::geom_point() +
  ggplot2::labs(
    title = "Score 1 tracing",
    subtitle = stringr::str_glue("Final score = {bc1$score(scoring_f)}"),
    x = "Iteration",
    y = "Score",
    color = "n_shuffle"
  )
```

Using the internal method...

```{r, fig.width=5, fig.height= 4}
bc1$plot_trace()
```

We may safely apply the batch container methods get_samples() and score() also
after using the new optimization code.

## Final batch layout
```{r, fig.width=6, fig.height=5}
bc1$score(scoring_f)

bc1$get_samples(assignment = TRUE) |>
  dplyr::filter(!is.na(Treatment)) |>
  dplyr::mutate(anno = stringr::str_c(Time, " hr")) |>
  ggplot2::ggplot(ggplot2::aes(x = batch, y = interaction(position, run), fill = Treatment)) +
  ggplot2::geom_tile(color = "white") +
  ggplot2::geom_hline(yintercept = 5.5, size = 1) +
  ggplot2::geom_text(ggplot2::aes(label = anno)) +
  ggplot2::labs(x = "Batch", y = "Position . Run")
```

## Perform new iterations on optimized batch container

Further optimization (using a different shuffling protocol maybe) can be done
immediately on the same batch container.

```{r}
n_shuffle <- rep(c(5, 2, 1), c(30, 30, 30))

bc1 <- optimize_design(
  bc1,
  scoring = scoring_f,
  n_shuffle = n_shuffle
)
```

# Optimization with specified stopping criteria 

Starting optimization from scratch, we are passing now some stopping criteria
that may terminate optimization before a shuffling protocol has been exhausted.

For demonstration, we use a shuffling function now that will do 3 sample
(position) swaps per iteration and can be called an arbitrary number of times.
Thus, iteration has to be stopped by either the max_iter criterion or by
reaching a specific minimum delta threshold (score improvement from one selected
solution to the next).

```{r}
bc2 <- optimize_design(
  bc,
  scoring = scoring_f,
  n_shuffle = 3, # will implicitly generate a shuffling function that will do 3 swaps at each iteration
  max_iter = 2000,
  min_delta = 0.1
)
```


# Optimization with multi-variate scoring function

Instead of passing a single scoring function, a list of multiple scoring
functions can be passed to the optimizer, each of which to return a scalar
value on evaluation.

By default, a strict improvement rule is applied for classifying a potential
solution as "better": each of the individual scores has to be smaller than or
equal to its previous value, and one of the scores has to be changed.

However, the user could specify other methods for aggregating the scores or
defining the acceptance criterion. See later examples.

The second scoring function used here is by the way rather redundant and just
serves for illustration.

```{r}
multi_scoring_f <- list(
  osat_score_generator(c("batch"), c("Treatment", "Time")),
  osat_score_generator(c("batch"), c("Treatment"))
)


bc3 <- optimize_design(
  bc,
  scoring = multi_scoring_f,
  n_shuffle = 3,
  max_iter = 200,
  min_delta = 0.1
)
```

Note that the first score tends to yield higher values than the second one. This
could be a problem when trying to select a solution based on an aggregated,
overall score. We repeat the same optimization now by using the autoscaling
functionality of the optimizer.

# Auto-scaling scores

We're just adding the `autoscale_scores` option here to estimate the
distribution of individual scores on a number of completely random sample
assignments (200 in this case) and then apply a transformation to rescale each
score to a standard normal.

Note that by 'normalizing' the distribution of the scores we obtain values
centered around zero, thus that the optimized scores are likely to be negative.
We may also want to decrease the delta_min parameter to match the new numerical
range.

```{r}
bc3_as <- optimize_design(
  bc,
  scoring = multi_scoring_f,
  n_shuffle = 3,
  max_iter = 200,
  min_delta = 0.01,
  autoscale_scores = T,
  autoscaling_permutations = 200
)
```


Having directly comparable scores, it may be reasonable now to use a function
that somehow aggregates the scores to decide on the best iteration (instead of
looking at the scores individually).

An easy way to do this is to use the built-in worst_score function. This will
simply set the aggregated score to whichever of the individual scores is larger
(i.e. 'worse' in terms of the optimization).


```{r}
bc4 <- optimize_design(
  bc,
  scoring = multi_scoring_f,
  n_shuffle = 3,
  aggregate_scores_func = worst_score,
  max_iter = 200,
  autoscale_scores = TRUE,
  autoscaling_permutations = 200
)
```

Another - more interesting - option would be to aggregate the two scores by
taking their sum. This way both scores will influence the optimization at every
step.

For illustration, we omit the `n_shuffle` parameter here, which will lead by
default to pairwise sample swaps being done on each iteration.

```{r, eval = FALSE}
bc5 <- optimize_design(
  bc,
  scoring = multi_scoring_f,
  aggregate_scores_func = sum_scores,
  max_iter = 200,
  autoscale_scores = TRUE,
  autoscaling_permutations = 200
)
```

As a final example, we calculate the (squared) L2 norm to actually aggregate the two scores. Not that this choice
is not really motivated in this case, but it could be used if optimization was carried on meaningful
distance vectors or normalized n-tuples.

Note that we don't use the auto-scaling in this case as the L2-norm based optimization would force both normalized scores towards zero,
not the minimal (negative) value that would be desired in that case.

```{r, eval = FALSE}
bc5_2 <- optimize_design(
  bc,
  scoring = multi_scoring_f,
  aggregate_scores_func = L2s_norm,
  max_iter = 200,
)
```

# Passing a customized shuffling function

It is recommended to use the `n_shuffle` parameter to steer the optimization
protocol. However, you may also provide a dedicated shuffling function that on
each call has to return a shuffling order (as integer vector) or a list with the
source and destination positions (src and dst) of the sample positions to be
swapped.

The following example uses a template for creating complete random shuffles
across all available positions in the batch container. Note that this is usually
not a good strategy for converging to a solution.

```{r}
bc6 <- optimize_design(
  bc,
  scoring = scoring_f,
  shuffle_proposal_func = complete_random_shuffling,
  max_iter = 200
)
```

# Using simulated annealing (SA) for optimization

Esp. for very large search spaces, better solutions can be quite successfully
obtained by a SA protocol which allows the optimizer to jump over 'energy
barriers' to more likely converge at lower local minima.

The optimizer usually remembers the permutation with the best overall score to
start with, but this behavior can be changed by supplying a simulated annealing
protocol, most simply by generating a ready-made function template.

It is generally recommended for SA to make small changes at each step, like
allowing just 1 sample swap per iteration.

Currently the simulated annealing protocol requires a single double value score
to be optimized. Choose an appropriate aggregation function if you happen to
have multiple scores initially.

```{r}
bc7 <- optimize_design(
  bc,
  scoring = scoring_f,
  n_shuffle = 1,
  acceptance_func = mk_simanneal_acceptance_func(),
  max_iter = 200
)
```
The trace may show a non strictly monotonic behavior now, reflecting the SA
protocol at work.

```{r, fig.width=5, fig.height= 4}
bc7$plot_trace()
```

Better results and quicker convergence may be achieved by playing with the
starting temperature (T0) and cooling speed (alpha) in a specific case.

```{r}
bc8 <- optimize_design(
  bc,
  scoring = scoring_f,
  n_shuffle = 1,
  acceptance_func = mk_simanneal_acceptance_func(mk_simanneal_temp_func(T0 = 100, alpha = 2)),
  max_iter = 150
)

bc8$plot_trace()
```

# Full blown example

The following example puts together all possible options to illustrate the
flexibility of the optimization.

```{r}
n_shuffle <- rep(c(3, 2, 1), c(20, 20, 200))

bc9 <- optimize_design(
  bc,
  scoring = list(
    osat_score_generator(c("batch"), c("Treatment", "Time")),
    osat_score_generator(c("batch"), c("Treatment")),
    osat_score_generator(c("batch"), c("Time"))
  ),
  n_shuffle = n_shuffle,
  aggregate_scores_func = sum_scores,
  acceptance_func = mk_simanneal_acceptance_func(mk_simanneal_temp_func(T0 = 500, alpha = 1)),
  max_iter = 200,
  min_delta = 1e-8,
  autoscale_scores = T
)

bc9$plot_trace()

bc9$get_samples(assignment = TRUE) |>
  dplyr::mutate(batch = factor(batch)) |>
  ggplot2::ggplot(ggplot2::aes(x = batch, fill = Treatment, alpha = factor(Time))) +
  ggplot2::geom_bar()
```
