# Deterministic Sensitivity Analysis: Generation

#### Fernando Alarid-Escudero, Greg Knowlton, Eva Enns, and the DARTH Team

#### 2024-09-27

# Overview

`dampack` has the functionality to interface with a user-defined decision model in `R` to conduct a deterministic sensitivity analysis (DSA) on parameters of interest. DSA is a method for assessing how the results of a decision analytic model change as a parameter of interest in varied over a specified range of values. This includes both how model outcomes change (e.g. the expected cost of each strategy) as well as how the decision changes (e.g. which strategy is the most cost-effective) with the parameter of interest.

In a DSA, the model is run for each value of the parameter of interest over the specified range, while holding all other parameter values fixed. If one parameter is varied, this is called a one-way DSA. If two parameters are varied, it is called a two-way DSA. We typically do not vary more than two parameters at a time, as the output becomes difficult to visualize and interpret. A more comprehensive assessment of how model outptus depend on model parameters can be done through a probabilistic sensitivity analysis (PSA) (type `vignette("psa_generation", package = "dampack")` in the console after installing the `dampack` package to see a vignette describing how to use `dampack` for PSA).

## Decision Model Format

`dampack` includes functionality to generate DSA results for any user-defined decision analytic model that is written in `R`. The user-defined model must be written as a function takes in a list of all model parameter values as its first argument and outputs a data frame of modeled outcomes (e.g. costs, QALYs, etc.) for each strategy being evaluated by the model. The first column of the output data frame must be the strategy names (as strings) with any number of additional outcomes (costs, QALYs, infections averted, etc.) stored in subsequent columns. Thus, each row of the output data frame consists of the strategy’s name followed by the corresponding outcome values for that strategy. The user-defined model function may have additional required or optional input arguments; values for these additional arguments will need to be passed to the function through the `dampack` DSA functions.

When using a decision analytic model to compare different potential strategies, it is often helpful to construct two functions: the model function that reflects the dynamic process being modeled and a wrapper function that runs the model with parameters that are modified to reflect the impact of different strategies. The example below will illustrate this kind of set up.

In this example, we consider a disease that can be represented as a four-state cohort state transition model (also known as a Markov model) with the health states “Healthy”, “Sick”, “Sicker”, and “Dead”. We aptly call this model the Sick-Sicker model. Individuals who are initially healthy are at risk of becomnig sick (transition to the “Sick” state). In the “Sick” state, individuals can either recover back to “Healthy” or progress to the worse “Sicker” health state, which has a lower utility and higher costs than the “Sick” and “Healthy” states. Once in the “Sicker” health states, individuals cannot recover. In every health state, individuals have a probability of dying, which is elevated for individuals in the “Sick” and “Sicker” states relative to the “Healthy” state. Individuals who die transition to the “Dead” state. For a deeper discusison of state transition models and more complex variations of this state transition model, see Alarid-Escudero F, Krijkamp EM, Enns EA, Yang A, Hunink MGM, Pechlivanoglou P, Jalal H. Cohort state-transition models in R: A Tutorial. arXiv:200107824v1. 2020:1-31.

The Sick-Sicker model is implemented as the function `run_sick_sicker_model` below:

    run_sick_sicker_model <- function(l_params, verbose = FALSE) {
      with(as.list(l_params), {
        # l_params must include:
        # -- disease progression parameters (annual): r_HD, p_S1S2, hr_S1D, hr_S2D,
        # -- initial cohort distribution: v_s_init
        # -- vector of annual state utilities: v_state_utility = c(u_H, u_S1, u_S2, u_D)
        # -- vector of annual state costs: v_state_cost = c(c_H, c_S1, c_S2, c_D)
        # -- time horizon (in annual cycles): n_cyles
        # -- annual discount rate: r_disc

        ####### SET INTERNAL PARAMETERS #########################################

        # state names
        v_names_states <- c("H", "S1", "S2", "D")
        n_states <- length(v_names_states)

        # vector of discount weights
        v_dw  <- 1 / ((1 + r_disc) ^ (0:n_cycles))

        # state rewards
        v_state_cost <- c("H" = c_H, "S1" = c_S1, "S2" = c_S2, "D" = c_D)
        v_state_utility <- c("H" = u_H, "S1" = u_S1, "S2" = u_S2, "D" = u_D)

        # transition probability values
        r_S1D <- hr_S1D * r_HD   # rate of death in sick state
        r_S2D <- hr_S2D * r_HD   # rate of death in sicker state
        p_S1D <- 1 - exp(-r_S1D) # probability of dying when sick
        p_S2D <- 1 - exp(-r_S2D) # probability of dying when sicker
        p_HD  <- 1 - exp(-r_HD)   # probability of dying when healthy

        ## Initialize transition probability matrix
        # all transitions to a non-death state are assumed to be conditional on survival
        m_P <- matrix(0,
                      nrow = n_states, ncol = n_states,
                      dimnames = list(v_names_states, v_names_states)) # define row and column names
        ## Fill in matrix
        # From H
        m_P["H", "H"]   <- (1 - p_HD) * (1 - p_HS1)
        m_P["H", "S1"]  <- (1 - p_HD) * p_HS1
        m_P["H", "D"]   <- p_HD
        # From S1
        m_P["S1", "H"]  <- (1 - p_S1D) * p_S1H
        m_P["S1", "S1"] <- (1 - p_S1D) * (1 - (p_S1H + p_S1S2))
        m_P["S1", "S2"] <- (1 - p_S1D) * p_S1S2
        m_P["S1", "D"]  <- p_S1D
        # From S2
        m_P["S2", "S2"] <- 1 - p_S2D
        m_P["S2", "D"]  <- p_S2D
        # From D
        m_P["D", "D"]   <- 1

        # check that all transition matrix entries are between 0 and 1
        if (!all(m_P <= 1 & m_P >= 0)) {
          stop("This is not a valid transition matrix (entries are not between 0 and 1")
        } else
          # check transition matrix rows add up to 1
          if (!all.equal(as.numeric(rowSums(m_P)), rep(1, n_states))) {
            stop("This is not a valid transition matrix (rows do not sum to 1)")
          }

        ####### INITIALIZATION #########################################
        # create the cohort trace
        m_Trace <- matrix(NA, nrow = n_cycles + 1,
                          ncol = n_states,
                          dimnames = list(0:n_cycles, v_names_states)) # create Markov trace

        # create vectors of costs and QALYs
        v_C <- v_Q <- numeric(length = n_cycles + 1)

        ############# PROCESS ###########################################

        m_Trace[1, ] <- v_s_init # initialize Markov trace
        v_C[1] <- 0 # no upfront costs
        v_Q[1] <- 0 # no upfront QALYs

        for (t in 1:n_cycles){ # throughout the number of cycles
          m_Trace[t + 1, ] <- m_Trace[t, ] %*% m_P # calculate trace for cycle (t + 1) based on cycle t

          v_C[t + 1] <- m_Trace[t + 1, ] %*% v_state_cost

          v_Q[t + 1] <- m_Trace[t + 1, ] %*% v_state_utility

        }

        #############  PRIMARY ECONOMIC OUTPUTS  #########################

        # Total discounted costs
        n_tot_cost <- t(v_C) %*% v_dw

        # Total discounted QALYs
        n_tot_qaly <- t(v_Q) %*% v_dw

        #############  OTHER OUTPUTS   ###################################

        # Total discounted life-years (sometimes used instead of QALYs)
        n_tot_ly <- t(m_Trace %*% c(1, 1, 1, 0)) %*% v_dw

        ####### RETURN OUTPUT  ###########################################
        out <- list(m_Trace = m_Trace,
                    m_P = m_P,
                    l_params,
                    n_tot_cost = n_tot_cost,
                    n_tot_qaly = n_tot_qaly,
                    n_tot_ly = n_tot_ly)

        return(out)
      }
      )
    }

Note that this function outputs costs, QALYs, and LYs for a given set of inputs (passed as the list `l_params`). Different strategies can be modeled by calling `run_sick_sicker_model` with different parameter values reflect the impacts of those strategies. In this example, we will use the Sick-Sicker model to evaluate three strateges: “No_Treatment”, “Treatment_A”, and “Treatment_B”. The “No_Treatment” strategy is just the Sick-Sicker natural history, while both “Treatment_A” and “Treatment_B” improve some aspect of the disease. These treatments are taken whenever someone is ill (Sick or Sicker), which increases the cost of being in those states by the cost of treatment.”Treatment_A” improves the utility for those in the Sick states (but not in the Sicker state), while “Treatment_B” reduces the rate of progressing from the Sick state to the Sicker state. Note that we assume that treatment cannot be targeted to just the Sick state because we assume that for this disease it is difficult to differentiate between patients in the Sick and Sicker states. Thus treatment costs are incurred by those in the Sicker state even if they do not benefit from it.

We define a second function, `simulate_strategies` that calls the Sick-Sicker model with the approrpiate inputs values for these three strategies. The `simulate_strategies` function also takes a list of parameter values (`l_params`), which must include both parameters for the markov model and any parameters governing the three strategies to be evaluated. `simulate_strategies` outputs a data frame with costs, QALYs, life-years (LYs), and net monetary benefit (NMB) for each of the three strategies. It is this function that we will pass to `dampack`’s internal functions for conducting DSA.

    simulate_strategies <- function(l_params, wtp = 100000) {
      # l_params_all must include:
      # -- *** Model parameters ***
      # -- disease progression parameters (annual): r_HD, p_S1S2, hr_S1D, hr_S2D,
      # -- initial cohort distribution: v_s_init
      # -- vector of annual state utilities: v_state_utility = c(u_H, u_S1, u_S2, u_D)
      # -- vector of annual state costs: v_state_cost = c(c_H, c_S1, c_S2, c_D)
      # -- time horizon (in annual cycles): n_cyles
      # -- annual discount rate: r_disc
      # -- *** Strategy specific parameters ***
      # -- treartment costs (applied to Sick and Sicker states): c_trtA, c_trtB
      # -- utility with Treatment_A (for Sick state only): u_trtA
      # -- hazard ratio of progression with Treatment_B: hr_S1S1_trtB

      with(as.list(l_params), {

        ####### SET INTERNAL PARAMETERS #########################################
        # Strategy names
        v_names_strat <- c("No_Treatment", "Treatment_A", "Treatment_B")
        # Number of strategies
        n_strat <- length(v_names_strat)

        ## Treatment_A
        # utility impacts
        u_S1_trtA <- u_trtA
        # include treatment costs
        c_S1_trtA <- c_S1 + c_trtA
        c_S2_trtA <- c_S2 + c_trtA

        ## Treatment_B
        # progression impacts
        r_S1S2_trtB <- -log(1 - p_S1S2) * hr_S1S2_trtB
        p_S1S2_trtB <- 1 - exp(-r_S1S2_trtB)
        # include treatment costs
        c_S1_trtB <- c_S1 + c_trtB
        c_S2_trtB <- c_S2 + c_trtB

        ####### INITIALIZATION #########################################
        # Create cost-effectiveness results data frame
        df_ce <- data.frame(Strategy = v_names_strat,
                            Cost = numeric(n_strat),
                            QALY = numeric(n_strat),
                            LY = numeric(n_strat),
                            stringsAsFactors = FALSE)

        ######### PROCESS ##############################################
        for (i in 1:n_strat){
          l_params_markov <- list(n_cycles = n_cycles, r_disc = r_disc, v_s_init = v_s_init,
                                  c_H =  c_H, c_S1 = c_S2, c_S2 = c_S1, c_D = c_D,
                                  u_H =  u_H, u_S1 = u_S2, u_S2 = u_S1, u_D = u_D,
                                  r_HD = r_HD, hr_S1D = hr_S1D, hr_S2D = hr_S2D,
                                  p_HS1 = p_HS1, p_S1H = p_S1H, p_S1S2 = p_S1S2)

          if (v_names_strat[i] == "Treatment_A") {
            l_params_markov$u_S1 <- u_S1_trtA
            l_params_markov$c_S1 <- c_S1_trtA
            l_params_markov$c_S2 <- c_S2_trtA

          } else if (v_names_strat[i] == "Treatment_B") {
            l_params_markov$p_S1S2 <- p_S1S2_trtB
            l_params_markov$c_S1   <- c_S1_trtB
            l_params_markov$c_S2   <- c_S2_trtB
          }

          l_result <- run_sick_sicker_model(l_params_markov)

          df_ce[i, c("Cost", "QALY", "LY")] <- c(l_result$n_tot_cost,
                                                 l_result$n_tot_qaly,
                                                 l_result$n_tot_ly)
          df_ce[i, "NMB"] <- l_result$n_tot_qaly * wtp - l_result$n_tot_cost
        }

        return(df_ce)
      })
    }

To demonstrate the `simulate_strategies` function, we define a list of model and strategy parameters. We call this our basecase list of parameters, as these will be the default parameter values used when conducting the DSA.

    my_params_basecase <- list(p_HS1 = 0.15,
                               p_S1H = 0.5,
                               p_S1S2 = 0.105,
                               r_HD = 0.002,
                               hr_S1D = 3,
                               hr_S2D = 10,
                               hr_S1S2_trtB = 0.6,
                               c_H = 2000,
                               c_S1 = 4000,
                               c_S2 = 15000,
                               c_D = 0,
                               c_trtA = 12000,
                               c_trtB = 13000,
                               u_H = 1,
                               u_S1 = 0.75,
                               u_S2 = 0.5,
                               u_D = 0,
                               u_trtA = 0.95,
                               n_cycles = 75,
                               v_s_init = c(1, 0, 0, 0),
                               r_disc = 0.03)

If we then call `simulate_strategies` with the list `my_params_basecase`, we will get a data frame of strategy outcomes:

    df_ce <- simulate_strategies(my_params_basecase)

    df_ce
    #>       Strategy     Cost     QALY       LY     NMB
    #> 1 No_Treatment 112005.4 21.66567 26.24113 2054562
    #> 2  Treatment_A 276350.4 23.31164 26.24113 2054814
    #> 3  Treatment_B 248990.2 22.49849 26.95238 2000859

## Generating DSA results

Next, we will use the functionality within `dampack` to generate one- and two-way DSAs using our user-defined function, `simulate_strategies`. A simple one-way DSA starts with choosing a model parameter to be investigated. Next, the modeler specifies a range for this parameter and the number of evenly spaced points along this range at which to evaluate model outcomes. The model is then run for each element of the vector of parameter values by setting the parameter of interest to the value, holding all other model parameters at their default base case values.

The `dampack` functions `run_owsa_det` and `run_twsa_det` are used to execute one- and two-way DSA, respectively.

## One-way DSA generation

To execute a one-way DSA, we first create a data frame that contains the list of parameter names to be varied (first column) and the minimum (second column) and maximum (third column) values of the range of values for each parameter. In the example below, we will conduct four one-way DSAs separately on four different parameters: the utility benefit of Treatment_A; the cost of Treatment_A; the reduction, as a hazard ratio, on the rate of progressing from Sick to Sicker with Treatment_B; and the rate of dying from the Healthy state.

    my_owsa_params_range <- data.frame(pars = c("u_trtA", "c_trtA", "hr_S1S2_trtB", "r_HD"),
                                       min = c(0.9, 9000, 0.3, 0.001),
                                       max = c(1,   24000, 0.9, 0.003))

To conduct the one-way DSA, we then call the function `run_owsa_det` with `my_owsa_params_range`, specifying the parameters to be varied and over which ranges, and `my_params_basecase` as the fixed parameter values to be used in the model when a parameter is not being explicitly varied in the one-way sensitivity analysis. Note that the parameters specified in `my_owsa_params_range$pars` are a subset of the parameters listed in `my_params_basecase`.

Additional inputs are the number of equally-spaced samples (`nsamp`) to be used between the specified minimum and maximum of each range, the user-defined function (`FUN`) to be called to generate model outcomes for each strategy, the vector of outcomes to be stored (must be outcomes generated by the data frame output of the function passed in `FUN`), and the vector of strategy names to be evaluated.

The input `progress = TRUE` allows the user to see a progress bar as the DSA is conducted. When many parameters are being varied, `nsamp` is large, and/or the user-defined function is computationally burdensome, the DSA may take a noticeable amount of time to compute and the progress display is recommended.

    library(dampack)
    l_owsa_det <- run_owsa_det(params_range = my_owsa_params_range,
                               params_basecase = my_params_basecase,
                               nsamp = 100,
                               FUN = simulate_strategies,
                               outcomes = c("Cost", "QALY", "LY", "NMB"),
                               strategies = c("No_Treatment", "Treatment_A", "Treatment_B"),
                               progress = FALSE)

Because we have defined multiple parameters in `my_owsa_params_range`, we have instructed `run_owsa_det` to execute a series of separate one-way sensitivity analyses and compile the results into a single `owsa` object for each requested `outcome`. When only one outcome is specified `run_owsa_det` returns a `owsa` data frame. When more than one outcome is specified, `run_owsa_det` returns a list containing one `owsa` data frame for each outcome. To access the `owsa` object corresponding to a given outcome, one can select the list item with the name “owsa_”. For example, the `owsa` object associated with the `NMB` outcome can be accessed as `l_owsa_det$owsa_NMB`.

Each `owsa` object returned by `run_owsa_det` is a data frame with four columns: `parameter`, `strategy`, `param_val`, and `outcome_val`. For each row, `param_val` is the value used for the parameter listed in `parameter` and `outcome_val` is the value of the specified outcome for the strategy listed in `strategy`. The `owsa` object can now be visualized using the functionality within `dampack`, which includes an `owsa` plot method and a visualization of the optimal strategy over each parameter range (function `owsa_opt_strat`).

    # Select the net monetary benefit (NMB) owsa object
    my_owsa_NMB <- l_owsa_det$owsa_NMB

    # Plot outcome of each strategy over each parameter range
    plot(my_owsa_NMB,
         n_x_ticks = 3)

    # Visualize optimal strategy (max NMB) over each parameter range
    owsa_opt_strat(my_owsa_NMB)

## Two-way DSA Generation

A two-way DSA is used to assess how model outcomes vary over specified ranges of two model parameters jointly. Similar to a one-way DSA, we first create a data frame with the (two) parameters that we wish to vary and their individual ranges. This data frame must have exactly two rows since the two-way DSA requires exactly two parameters.

    my_twsa_params_range <- data.frame(pars = c("hr_S1S2_trtB", "r_HD"),
                                       min = c(0.3, 0.001),
                                       max = c(0.9, 0.003))

To conduct the two-way DSA, we then call the function `run_twsa_det` with `my_twsa_params_range`. The general format of the function arguments for `run_twsa_det` are the same as those for `run_owsa_det`. In `run_twsa_det`, equally spaced sequences of length `nsamp` are created for the two parameters based on the inputs provided in the `params_range` argument. These two sequences of parameter values define an `nsamp` by `nsamp` grid over which `FUN` is applied to produce outcomes for every combination of the two parameters.

    l_twsa_det <- run_twsa_det(params_range = my_twsa_params_range,
                               params_basecase = my_params_basecase,
                               nsamp = 50,
                               FUN = simulate_strategies,
                               outcomes = c("Cost", "QALY", "NMB"),
                               strategies = c("No_Treatment", "Treatment_A", "Treatment_B"),
                               progress = FALSE)

Like in the one-way DSA, if only one outcome is specified, then `run_twsa_det` will return a `twsa` data frame. If more than one outcome is specified, `run_twsa_det` returns a list containing one `twsa` object for each outcome. To access the `twsa` object corresponding to a given outcome, one can select the list item with the name “twsa_”. For example, the `twsa` object associated with the `NMB` outcome can be accessed as `l_twsa_det$owsa_NMB`.

Each `twsa` object returned by `run_twsa_det` is a data frame with four columns. The first two columns are named according to the two parameters specified in `params_range`. Each row is the value of that parameter corresponding to the outcome value in the `outcome_val` (fourth) column for the strategy listed in the `strategy` (third) column. The `twsa` object can now be visualized using the functionality within `dampack`. The `twsa` plot method shows the optimal strategy as a function of the two parameters being varied in the two-way DSA, which is the primary way that two-way analyses are reported.

    my_twsa_NMB <- l_twsa_det$twsa_NMB

    # plot optimal strategy (max NMB) as a function of the two parameters varied in the two-way DSA
    plot(my_twsa_NMB)