tests/testthat/test-DecisionTree.R

# arborescences that are not decision trees are rejected
test_that("arborescences that are not decision trees are rejected", {
  # some nodes not {D,C,L}
  d1 <- Node$new("d1")
  c1 <- ChanceNode$new()
  t1 <- LeafNode$new("t1")
  t2 <- LeafNode$new("t2")
  t3 <- LeafNode$new("t3")
  e1 <- Arrow$new(d1, c1, label = "e1")
  e2 <- Arrow$new(c1, t1, label = "e2")
  e3 <- Arrow$new(c1, t2, label = "e3")
  e4 <- Arrow$new(d1, t3, label = "e4")
  expect_error(
    DecisionTree$new(V = list(d1, c1, t1, t2, t3), E = list(e1, e2, e3, e4)),
    class = "incorrect_node_type"
  )
  # terminal nodes are not Leaf
  d1 <- DecisionNode$new("d1")
  e1 <- Arrow$new(d1, c1, label = "e1")
  e4 <- Arrow$new(d1, t3, label = "e4")
  expect_error(
    DecisionTree$new(V = list(d1, c1, t3), E = list(e1, e4)),
    class = "leaf_non-child_sets_unequal"
  )
  # some leafs are not terminal
  e2 <- Arrow$new(c1, t1, label = "e2")
  e3 <- Arrow$new(c1, t2, label = "e3")
  t4 <- LeafNode$new("t4")
  e5 <- Arrow$new(t3, t4, label = "e5")
  expect_error(
    DecisionTree$new(
      V = list(d1, c1, t1, t2, t3, t4),
      E = list(e1, e2, e3, e4, e5)
    ),
    class = "leaf_non-child_sets_unequal"
  )
  # edge (arrow) types must be Action or Reaction
  e1 <- Action$new(d1, c1, label = "e1")
  e2 <- Reaction$new(c1, t1, p = 0.5, label = "e2")
  e3 <- Reaction$new(c1, t2, p = 0.5, label = "e3")
  e4 <- Arrow$new(d1, t3, label = "e4")
  expect_error(
    DecisionTree$new(V = list(d1, c1, t1, t2, t3), E = list(e1, e2, e3, e4)),
    class = "incorrect_edge_type"
  )
  # labels of actions from a common DecisionNode must be unique
  e4 <- Action$new(d1, t3, label = "e1")
  expect_error(
    DecisionTree$new(V = list(d1, c1, t1, t2, t3), E = list(e1, e2, e3, e4)),
    class = "invalid_labels"
  )
  # decision node labels must be unique
  d2 <- DecisionNode$new("d1")
  e4 <- Action$new(d1, d2, label = "e4")
  e5 <- Action$new(d2, t3, label = "e5")
  expect_error(
    DecisionTree$new(
      V = list(d1, d2, c1, t1, t2, t3), E = list(e1, e2, e3, e4, e5)
    ),
    class = "invalid_labels"
  )
  # construct a legal tree
  e4 <- Action$new(d1, t3, label = "e4")
  DT <- DecisionTree$new(V = list(d1, c1, t1, t2, t3), E = list(e1, e2, e3, e4))
  # decision nodes
  expect_error(DT$decision_nodes(42L), class = "unknown_what_value")
  expect_r6_setequal(DT$decision_nodes(), list(d1))
  expect_setequal(DT$decision_nodes(what = "label"), list("d1"))
  ev <- DT$vertex_index(d1)
  expect_setequal(DT$decision_nodes(what = "index"), ev)
  # chance nodes
  expect_error(DT$chance_nodes(42L), class = "unknown_what_value")
  expect_r6_setequal(DT$chance_nodes(), list(c1))
  expect_setequal(DT$chance_nodes(what = "label"), list(""))
  ev <- DT$vertex_index(c1)
  expect_setequal(DT$chance_nodes(what = "index"), ev)
  # leaf nodes
  expect_error(DT$leaf_nodes(42L), class = "unknown_what_value")
  expect_r6_setequal(DT$leaf_nodes(), list(t1, t2, t3))
  expect_setequal(DT$leaf_nodes("label"), list("t1", "t2", "t3"))
  ev <- DT$vertex_index(list(t1, t2, t3))
  expect_setequal(DT$leaf_nodes("index"), ev)
  # actions
  expect_error(DT$actions(d2), class = "not_in_tree")
  expect_error(DT$actions(c1), class = "not_decision_node")
  expect_error(DT$actions(),  class = "decision_node_not_defined")
  expect_r6_setequal(DT$actions(d1), list(e1, e4))
  # evaluate
  expect_error(DT$evaluate(N = "forty two"), class = "N_not_numeric")
  expect_error(DT$evaluate(by = 42L), class = "by_not_character")
  expect_error(DT$evaluate(by = "forty two"), class = "by_invalid")
  expect_error(DT$evaluate(setvars = 42L), class = "setvars_not_character")
  expect_error(DT$evaluate(setvars = "mode"), class = "setvars_invalid")
  expect_silent(DT$evaluate(setvars = "q2.5"))
  expect_silent(DT$evaluate(setvars = "q50"))
  expect_silent(DT$evaluate(setvars = "q97.5"))
  expect_silent(DT$evaluate(setvars = "current"))
  # tornado with no model variables
  grDevices::pdf(file = NULL)
  expect_null(DT$tornado(index = list(e1), ref = list(e4)))
  grDevices::dev.off()
})

# test that using reserved words for decision nodes is detected
test_that("invalid decision node labels are detected", {
  d1 <- DecisionNode$new(label = "Run")
  l1 <- LeafNode$new(label = "l1")
  l2 <- LeafNode$new(label = "l2")
  a1 <- Action$new(source = d1, target = l1, label = "run")
  a2 <- Action$new(source = d1, target = l2, label = "walk")
  expect_error(
    DecisionTree$new(V = list(d1, l1, l2), E = list(a1, a2)),
    class = "invalid_labels"
  )
  d1 <- DecisionNode$new(label = "Benefit")
  a1 <- Action$new(source = d1, target = l1, label = "run")
  a2 <- Action$new(source = d1, target = l2, label = "walk")
  expect_error(
    DecisionTree$new(V = list(d1, l1, l2), E = list(a1, a2)),
    class = "invalid_labels"
  )
})

# tests of basic tree properties (Kaminski et al CEJOR 2018;26:135-139, fig 1)
test_that("simple decision trees are modelled correctly", {
  # nodes and edges
  d1 <- DecisionNode$new("d 1") # replaced by "d.1".
  c1 <- ChanceNode$new("c1")
  t1 <- LeafNode$new("t1")
  t2 <- LeafNode$new("t2")
  t3 <- LeafNode$new("t3")
  e1 <- Action$new(d1, c1, benefit = 10.0, label = "e1")
  e2 <- Reaction$new(
    c1, t1, p = 0.75, cost = 42.0, benefit = 20.0, label = "e2"
  )
  e3 <- Reaction$new(c1, t2, p = 0.25, label = "e3")
  e4 <- Action$new(d1, t3, benefit = 20.0, label = "e4")
  # tree
  V <- list(d1, c1, t1, t2, t3)
  E <- list(e1, e2, e3, e4)
  DT <- DecisionTree$new(V, E)
  # properties
  A <- DT$actions(d1)
  expect_setequal(
    vapply(A, FUN.VALUE = "x", function(a) {
      a$label()
    }),
    c("e1", "e4")
  )
  expect_setequal(DT$decision_nodes("label"), "d.1")
  # edge property matrix
  ep <- DT$edge_properties()
  expect_identical(nrow(ep), 4L)
  expect_identical(ncol(ep), 4L)
  expect_true(is.matrix(ep))
  expect_setequal(colnames(ep), c("index", "probability", "cost", "benefit"))
  expect_setequal(rownames(ep), c("e1", "e2", "e3", "e4"))
  expect_identical(ep["e1", "probability"], 1.0)
  expect_intol(ep["e2", "cost"], 42.0, 0.1)
  expect_intol(ep["e3", "probability"], 0.25, 0.01)
  expect_intol(ep[["e4", "benefit"]], 20.0, 0.1)
  # draw it
  grDevices::pdf(NULL)
  expect_silent(DT$draw(border = TRUE))
  grDevices::dev.off()
  # strategy validity
  expect_true(DT$is_strategy(list(e1)))
  expect_true(DT$is_strategy(e1))
  expect_false(DT$is_strategy(e2))
  expect_true(DT$is_strategy(list(e4)))
  expect_false(DT$is_strategy(list(e2, e3)))
  expect_false(DT$is_strategy(list()))
  expect_false(DT$is_strategy(list(e1, e4)))
  # strategy paths
  P <- DT$root_to_leaf_paths()
  expect_length(P, 3L)
  expect_false(DT$is_strategy(list(e2)))
  expect_false(DT$is_strategy(list(e1, e4)))
  PS <- DT$strategy_paths()
  expect_identical(nrow(PS), 3L)
  # strategy table
  expect_error(DT$strategy_table(42L), class = "unknown_what_value")
  expect_error(
    DT$strategy_table(select = list(e2, e3)), class = "invalid_strategy"
  )
  S <- DT$strategy_table()
  expect_identical(nrow(S), 2L)
  expect_setequal(rownames(S), c("e1", "e4"))
  S <- DT$strategy_table("label", select = e1)
  expect_identical(S[[1L, "d.1"]], "e1")
  expect_setequal(rownames(S), "e1")
  # evaluate valid walks
  paths <- DT$root_to_leaf_paths()
  walks <- lapply(X = paths, FUN = DT$walk)
  expect_length(walks, 3L)
  result <- DT$evaluate_walks(walks)
  expect_identical(nrow(result), 3L)
  ileaf <- DT$vertex_index(list(t1, t2, t3))
  expect_setequal(rownames(result), as.character(ileaf))
  # invalid walks should fail gracefully
  walks <- list()
  result <- DT$evaluate_walks(walks)
  expect_identical(nrow(result), 0L)
  expect_error(DT$evaluate_walks(paths))
  # check row order and results of evaluation
  RES <- DT$evaluate(by = "path")
  expect_identical(RES[, "d.1"], c("e1", "e1", "e4"))
  expect_identical(RES[, "Leaf"], c("t1", "t2", "t3"))
  expect_intol(RES[[which(RES[, "Leaf"] == "t1"), "Benefit"]], 22.5, 0.05)
})

# test that the sum of probabilities from each chance node must be 1, including
# when probability is modelled as a random variable
test_that("chance node probabilities sum to unity", {
  # create a tree
  d1 <- DecisionNode$new("D1")
  c1 <- ChanceNode$new()
  c2 <- ChanceNode$new()
  l11 <- LeafNode$new("L11")
  l12 <- LeafNode$new("L12")
  l21 <- LeafNode$new("L21")
  l22 <- LeafNode$new("L22")
  l23 <- LeafNode$new("L23")
  a1 <- Action$new(source = d1, target = c1, label = "Choice 1")
  a2 <- Action$new(source = d1, target = c2, label = "Choice 2")
  r11 <- Reaction$new(source = c1, target = l11)
  r12 <- Reaction$new(source = c1, target = l12)
  r21 <- Reaction$new(source = c2, target = l21)
  r22 <- Reaction$new(source = c2, target = l22)
  r23 <- Reaction$new(source = c2, target = l23)
  dt <- DecisionTree$new(
    V = list(d1, c1, c2, l11, l12, l21, l22, l23),
    E = list(a1, a2, r11, r12, r21, r22, r23)
  )
  # evaluate with default (zero) probabilities
  expect_error(dt$evaluate(), class = "invalid_probability_sum")
  # set the probabilities and check that the model evaluates when correct
  r11$set_probability(0.5)
  r12$set_probability(0.5)
  expect_error(dt$evaluate(), class = "invalid_probability_sum")
  r21$set_probability(1L / 3L)
  r22$set_probability(1L / 3L)
  r23$set_probability(2L / 3L)
  expect_error(dt$evaluate(), class = "invalid_probability_sum")
  r23$set_probability(1L / 3L)
  expect_no_condition(dt$evaluate())
  # create a Dirichlet distribution and associated ModVars for c1
  dtwo <- DirichletDistribution$new(c(1L, 9L))
  pc11 <- ModVar$new("p(success)", "P", D = dtwo, k = 1L)
  pc12 <- ModVar$new("p(failure)", "P", D = dtwo, k = 2L)
  r11$set_probability(pc11)
  r12$set_probability(pc12)
  expect_no_condition(dt$evaluate(setvars = "random", N = 20L))
  # and for c2
  dthree <- DirichletDistribution$new(c(1L, 9L, 6L))
  pc21 <- ModVar$new("p(outcome 1)", "P", D = dthree, k = 1L)
  pc22 <- ModVar$new("p(outcome 2)", "P", D = dthree, k = 2L)
  pc23 <- ModVar$new("p(outcome 3)", "P", D = dthree, k = 3L)
  r21$set_probability(pc21)
  r22$set_probability(pc22)
  r23$set_probability(pc23)
  expect_no_condition(dt$evaluate(setvars = "random", N = 20L))
})

# test that probabilities of NA are detected and checked
test_that("probabilities of NA are detected and checked", {
  # create a tree
  d1 <- DecisionNode$new("d1")
  c1 <- ChanceNode$new()
  c2 <- ChanceNode$new()
  l1 <- LeafNode$new(label = "l1")
  l2 <- LeafNode$new(label = "l2")
  l3 <- LeafNode$new(label = "l3")
  l4 <- LeafNode$new(label = "l4")
  a1 <- Action$new(s = d1, t = c1, label = "intervention")
  a2 <- Action$new(s = d1, t = c2, label = "comparator")
  r1 <- Reaction$new(s = c1, t = l1)
  r2 <- Reaction$new(s = c1, t = l2)
  r3 <- Reaction$new(s = c2, t = l3)
  r4 <- Reaction$new(s = c2, t = l4)
  dt <- DecisionTree$new(
    V = list(d1, c1, c2, l1, l2, l3, l4),
    E = list(a1, a2, r1, r2, r3, r4)
  )
  # check that evaluation fails due to probabilities not summing to 1
  expect_error(dt$evaluate(), class = "invalid_probability_sum")
  # check evaluation with NA probabilities from a chance node
  r1$set_probability(p = NA_real_)
  r2$set_probability(p = NA_real_)
  r3$set_probability(p = 0.4)
  r4$set_probability(p = NA_real_)
  expect_error(dt$evaluate(), class = "invalid_probability_sum")
  r1$set_probability(p = 0.5)
  dte <- dt$evaluate(by = "path")
  expect_intol(with(dte, Probability[Leaf == "l2"]), 0.5, 0.01)
  expect_intol(with(dte, Probability[Leaf == "l4"]), 0.6, 0.01)
})

# ICER with discounted utility
test_that("ICER with discounted utility is as expected", {
  # variables
  ur <- 1.0
  unr <- 0.7
  dt <- as.difftime(tim = 365.25 * 2.0, units = "days")
  r <- 3.5 / 100.0
  pa <- 0.8
  pb <- 0.7
  ca <- 10000.0
  cb <- 8000.0
  # tree for treatment A versus treatment B, with effect over 2 years
  a_resp <- LeafNode$new(label = "R", utility = ur, interval = dt, ru = r)
  b_resp <- LeafNode$new(label = "R", utility = ur, interval = dt, ru = r)
  a_noresp <- LeafNode$new(label = "NR", utility = unr, interval = dt, ru = r)
  b_noresp <- LeafNode$new(label = "NR", utility = unr, interval = dt, ru = r)
  a_effect <- ChanceNode$new()
  b_effect <- ChanceNode$new()
  d <- DecisionNode$new(label = "Treatment")
  txa <- Action$new(source = d, target = a_effect, label = "a", cost = ca)
  txb <- Action$new(source = d, target = b_effect, label = "b", cost = cb)
  ay <- Reaction$new(source = a_effect, target = a_resp, p = pa)
  an <- Reaction$new(source = a_effect, target = a_noresp, p = NA_real_)
  by <- Reaction$new(source = b_effect, target = b_resp, p = pb)
  bn <- Reaction$new(source = b_effect, target = b_noresp, p = NA_real_)
  dt <- DecisionTree$new(
    V = list(d, a_effect, b_effect, a_resp, b_resp, a_noresp, b_noresp),
    E = list(txa, txb, ay, an, by, bn)
  )
  # evaluate
  res <- dt$evaluate(by = "run")
  # observed ICER
  dc <- res[[1L, "Cost.a"]] - res[[1L, "Cost.b"]]
  dq <- res[[1L, "QALY.a"]] - res[[1L, "QALY.b"]]
  icero <- dc / dq
  # expected ICER
  dc <- ca - cb
  df <- (1.0 - exp(-r * 2.0)) / r
  qa <- pa * ur * df + (1.0 - pa) * unr * df
  qb <- pb * ur * df + (1.0 - pb) * unr * df
  icere <- dc / (qa - qb)
  expect_intol(icero, icere, tol = 1.0)
})

# test with utility > 1
test_that("decision trees with utility > 1 are supported", {
  # fictitious scenario
  u_normdel <- ConstModVar$new("Utility for pregancy", "", const = 2.0)
  u_miscarriage <- 0.8
  c_normdel <- 100.0
  c_miscarriage <- 1000.0
  p_miscarriage_notest <- 0.010
  p_miscarriage_test <- 0.005
  c_test <- 50.0
  # construct model
  ta1 <- LeafNode$new(label = "normdelivery", utility = u_normdel)
  ta2 <- LeafNode$new(label = "miscarriage", utility = u_miscarriage)
  ca <- ChanceNode$new()
  ra1 <- Reaction$new(
    source = ca,
    target = ta1,
    p = NA_real_,
    cost = c_normdel
  )
  ra2 <- Reaction$new(
    source = ca,
    target = ta2,
    p = p_miscarriage_notest,
    cost = c_miscarriage
  )
  tb1 <- LeafNode$new(label = "normdelivery", utility = u_normdel)
  tb2 <- LeafNode$new(label = "miscarriage", utility = u_miscarriage)
  cb <- ChanceNode$new()
  rb1 <- Reaction$new(
    source = cb,
    target = tb1,
    p = NA_real_,
    cost = c_normdel
  )
  rb2 <- Reaction$new(
    source = cb,
    target = tb2,
    p = p_miscarriage_test,
    cost = c_miscarriage
  )
  d1 <- DecisionNode$new("PE")
  a1 <- Action$new(source = d1, target = ca, cost = 0.0, label = "notest")
  a2 <- Action$new(source = d1, target = cb, cost = c_test, label = "PEtest")
  dt <- DecisionTree$new(
    V = list(d1, ca, cb, ta1, ta2, tb1, tb2),
    E = list(a1, a2, ra1, ra2, rb1, rb2)
  )
  # evaluate it
  ev <- dt$evaluate()
  cn <- ev[[which(ev[, "PE"] == "notest"), "Cost"]]
  ct <- ev[[which(ev[, "PE"] == "PEtest"), "Cost"]]
  un <- ev[[which(ev[, "PE"] == "notest"), "QALY"]]
  ut <- ev[[which(ev[, "PE"] == "PEtest"), "QALY"]]
  oicer <- (ct - cn) / (ut - un)
  cn <- c_normdel * (1.0 - p_miscarriage_notest) +
    c_miscarriage * p_miscarriage_notest
  ct <- c_normdel * (1.0 - p_miscarriage_test) +
    c_miscarriage * p_miscarriage_test +
    c_test
  un <- u_normdel$get() * (1.0 - p_miscarriage_notest) +
    u_miscarriage * p_miscarriage_notest
  ut <- u_normdel$get() * (1.0 - p_miscarriage_test) +
    u_miscarriage * p_miscarriage_test
  eicer <- (ct - cn) / (ut - un)
  expect_intol(oicer, eicer, 100.0)
})

# A decision tree with paths common to >1 strategy
test_that("paths common to >1 strategy are analyzed", {
  # variables
  p.disease <- BetaModVar$new("P(Test+ve)", "P", alpha = 10.0, beta = 40.0)
  c.drug <- ConstModVar$new("Drug cost", "GBP", 900.0)
  c.pharm <- ConstModVar$new("Pharmacy cost", "GBP", 100.0)
  c.treat <- ExprModVar$new(
    "Treatment cost", "GBP", rlang::quo(c.drug + c.pharm)
  )
  # create tree
  c1 <- ChanceNode$new("c1")
  d1 <- DecisionNode$new("d1")
  t1 <- LeafNode$new("expensive drug")
  d2 <- DecisionNode$new("d2")
  t2 <- LeafNode$new("conventional management")
  t3 <- LeafNode$new("watchful waiting")
  t4 <- LeafNode$new("discharge")
  e1 <- Reaction$new(c1, d1, p = p.disease, cost = 0.0, label = "test +ve")
  e2 <- Reaction$new(c1, t4, p = NA_real_, cost = 0.0, label = "test -ve")
  e3 <- Action$new(d1, t1, label = "treat", cost = c.treat)
  e4 <- Action$new(d1, d2, label = "manage", cost = 0.0)
  e5 <- Action$new(d2, t2, label = "conservatively", cost = 200.0)
  e6 <- Action$new(d2, t3, label = "watch", cost = 50.0)
  DT <- DecisionTree$new(
    V = list(c1, d1, d2, t1, t2, t3, t4),
    E = list(e1, e2, e3, e4, e5, e6)
  )
  # check the modvar table
  mvtab <- DT$modvar_table()
  expect_s3_class(mvtab, "data.frame")
  expect_identical(nrow(mvtab), 4L)
  expect_setequal(
    mvtab[, "Description"],
    c("Treatment cost", "Drug cost", "Pharmacy cost", "P(Test+ve)")
  )
  # there are 8 paths walked by the strategies (two end on leaf t1, one each on
  # t2 and t3 and four on t4) out of 16 possible (4 paths and 4 strategies);
  # each strategy walks 2 paths
  st <- DT$strategy_table()
  expect_s3_class(st, "data.frame")
  expect_type(st[, "d1"], "integer")
  expect_type(st[, "d2"], "integer")
  expect_identical(nrow(st), 4L)
  expect_setequal(colnames(st), c("d1", "d2"))
  expect_setequal(
    rownames(st),
    c("treat_conservatively", "treat_watch",
      "manage_conservatively", "manage_watch"
    )
  )
  st <- DT$strategy_table(what = "label")
  expect_s3_class(st, "data.frame")
  expect_type(st[, "d1"], "character")
  expect_type(st[, "d2"], "character")
  expect_identical(nrow(st), 4L)
  expect_setequal(colnames(st), c("d1", "d2"))
  expect_setequal(
    rownames(st),
    c("treat_conservatively", "treat_watch",
      "manage_conservatively", "manage_watch"
    )
  )
  # check the strategy paths (2 paths per strategy)
  sp <- DT$strategy_paths()
  expect_s3_class(sp, "data.frame")
  expect_identical(nrow(sp), 8L)
  it1 <- DT$vertex_index(t1)
  it2 <- DT$vertex_index(t2)
  it3 <- DT$vertex_index(t3)
  it4 <- DT$vertex_index(t4)
  expect_identical(sum(sp[, "Leaf"] == it1), 2L)
  expect_identical(sum(sp[, "Leaf"] == it2), 1L)
  expect_identical(sum(sp[, "Leaf"] == it3), 1L)
  expect_identical(sum(sp[, "Leaf"] == it4), 4L)
  ag <- aggregate(x = Leaf ~ d1 + d2, data = sp, FUN = length)
  expect_identical(nrow(ag), 4L)
  expect_true(all(ag[, "Leaf"] == 2L))
  # evaluate by path checking row order follows modified leaf names
  # lexicographically.
  RES <- DT$evaluate(by = "path")
  expect_identical(
    colnames(RES),
    c("Run", "d1", "d2", "Leaf", "Probability",
      "Cost", "Benefit", "Utility", "QALY")
  )
  expect_identical(RES[, "d1"], c(rep("manage", 4L), rep("treat", 4L)))
  expect_identical(
    RES[, "Leaf"],
    c("conventional.management", "discharge", "discharge", "watchful.waiting",
      "discharge", "expensive.drug", "discharge", "expensive.drug"
    )
  )
  # evaluate by strategy (4 strategies x 2 runs).
  RES <- DT$evaluate(N = 2L)
  expect_identical(nrow(RES), 8L)
  expect_identical(RES[, "Run"], c(rep(1L, 4L), rep(2L, 4L)))
  expect_identical(
    RES[, "d1"],
    rep(c("manage", "treat"), each = 2L, times = 2L)
  )
  # evaluate by run and check format of result
  RES <- DT$evaluate(setvars = "random", by = "run", N = 2L)
  expect_s3_class(RES, "data.frame")
  expect_identical(nrow(RES), 2L)
  eg <- expand.grid(
    c("Probability", "Cost", "Benefit", "Utility", "QALY"),
    rownames(st)
  )
  cnames <- sprintf("%s.%s", eg[, 1L], eg[, 2L])
  expect_setequal(colnames(RES), c("Run", cnames))
})

# A decision tree with a long leftmost node label (must be tested by sending
# the plot file to a pdf and reviewing it)
test_that("long node labels are not clipped", {
  d1 <- DecisionNode$new(label = "Home captain decision before the toss")
  l1 <- LeafNode$new("Bat")
  l2 <- LeafNode$new("Bowl")
  l3 <- LeafNode$new("Bat")
  l4 <- LeafNode$new("Bowl")
  l5 <- LeafNode$new("Bowl")
  l6 <- LeafNode$new("Bat")
  c1 <- ChanceNode$new()
  c2 <- ChanceNode$new()
  d2 <- DecisionNode$new("Away captain decision after winning toss")
  d3 <- DecisionNode$new("Away captain decision after losing toss")
  a1 <- Action$new(source = d1, target = c1, "Bat first")
  a2 <- Action$new(source = d1, target = c2, "Bowl first")
  r1 <- Reaction$new(source = c1, target = l1, p = 0.5, label = "Win toss")
  r2 <- Reaction$new(source = c1, target = d2, p = 0.5, label = "Lose toss")
  a3 <- Action$new(source = d2, target = l2, "Elect to bat")
  a4 <- Action$new(source = d2, target = l3, "Elect to bowl")
  r3 <- Reaction$new(source = c2, target = l4, p = 0.5, label = "Win toss")
  r4 <- Reaction$new(source = c2, target = d3, p = 0.5, label = "Lose toss")
  a5 <- Action$new(source = d3, target = l5, "Elect to bat")
  a6 <- Action$new(source = d3, target = l6, "Elect to bowl")
  dt <- DecisionTree$new(
    V = list(d1, d2, d3, c1, c2, l1, l2, l3, l4, l5, l6),
    E = list(a1, a2, a3, a4, a5, a6, r1, r2, r3, r4)
  )
  grDevices::pdf(NULL, width = 7.0, height = 5.0)
  expect_no_condition(dt$draw(border = TRUE, fontsize = 10.0))
  grDevices::dev.off()
})

# test of tornado plot and thresholding
test_that("tornado plots are as expected", {
  # construct a decision tree
  v <- list(
    t1 = LeafNode$new("t1"),
    t2 = LeafNode$new("t2"),
    t3 = LeafNode$new("t3"),
    t4 = LeafNode$new("t4"),
    c1 = ChanceNode$new(),
    c2 = ChanceNode$new(),
    d1 = DecisionNode$new("d1")
  )
  e <- list(
    e1 = Action$new(source = v[["d1"]], target = v[["c1"]], label = "a"),
    e2 = Action$new(source = v[["d1"]], target = v[["c2"]], label = "b"),
    e3 = Reaction$new(source = v[["c1"]], target = v[["t1"]]),
    e4 = Reaction$new(source = v[["c1"]], target = v[["t2"]]),
    e5 = Reaction$new(source = v[["c2"]], target = v[["t3"]]),
    e6 = Reaction$new(source = v[["c2"]], target = v[["t4"]])
  )
  dt <- DecisionTree$new(V = v, E = e)
  # add model variables for probabilities, costs & utilities at chance nodes
  pcomp <- 0.5
  p1 <- BetaModVar$new(
    "c1", "probability", alpha = pcomp * 1000L, beta = (1.0 - pcomp) * 1000L
  )
  e[["e3"]]$set_probability(p = p1)
  e[["e4"]]$set_probability(p = NA_real_)
  pint <- 0.6
  p2 <- BetaModVar$new(
    "c2", "probability", alpha = pint * 100L, beta = (1.0 - pint) * 100L
  )
  e[["e5"]]$set_probability(p = p2)
  e[["e6"]]$set_probability(p = NA_real_)
  # add costs
  ccomp <- 1000.0
  cfail <- 100.0
  e[["e3"]]$set_cost(c = ccomp)
  e[["e4"]]$set_cost(c = ccomp + cfail)
  cint <- 2500.0
  e[["e5"]]$set_cost(c = cint)
  e[["e6"]]$set_cost(c = cint + cfail)
  # time horizon
  th <- as.difftime(365.25, units = "days")
  v[["t1"]]$set_interval(th)
  v[["t2"]]$set_interval(th)
  v[["t3"]]$set_interval(th)
  v[["t4"]]$set_interval(th)
  # set utilities
  ucomps <- 0.7
  ucompf <- 0.6
  v[["t1"]]$set_utility(ucomps)
  v[["t2"]]$set_utility(ucompf)
  uints <- 0.8
  uintf <- 0.6
  v[["t3"]]$set_utility(uints)
  v[["t4"]]$set_utility(uintf)

  # check point estimate calculation
  edc <- (cint * pint + (cint + cfail) * (1.0 - pint)) -
    (ccomp * pcomp + (ccomp + cfail) * (1.0 - pcomp))
  edq <- (uints * pint + uintf * (1.0 - pint)) -
    (ucomps * pcomp + ucompf * (1.0 - pcomp))
  eicer <- edc / edq # ~ 21,g00 GBP / QALY for the given costs and utilities
  o <- dt$evaluate()
  with(data = o, expr = {
    odc <- Cost[[which(d1 == "b")]] - Cost[[which(d1 == "a")]]
    odq <- Utility[[which(d1 == "b")]] - Utility[[which(d1 == "a")]]
    oicer <- odc / odq
    expect_intol(odc, edc, tol = 1.0)
    expect_intol(odq, edq, tol = 0.01)
    expect_intol(oicer, eicer, tole = 10.0)
  })

  # tornado
  grDevices::pdf(file = NULL)
  expect_error(dt$tornado(), class = "missing_strategy")
  expect_error(
    dt$tornado(index = list(e[["e1"]]), ref = list(e[["e3"]])),
    class = "invalid_strategy"
  )
  expect_error(
    dt$tornado(index = e[["e1"]], ref = e[["e3"]]),
    class = "invalid_strategy"
  )
  expect_error(
    dt$tornado(
      index = list(e[["e1"]]), ref = list(e[["e2"]]), outcome = "survival"
    ),
    class = "invalid_outcome"
  )
  expect_error(
    dt$tornado(index = list(e[["e1"]]), ref = list(e[["e2"]]), draw = 42L),
    class = "invalid_draw"
  )
  expect_error(
    dt$tornado(
      index = list(e[["e1"]]), ref = list(e[["e2"]]), exclude = 42L, draw = TRUE
    ),
    class = "exclude_not_list"
  )
  expect_error(
    dt$tornado(
      index = list(e[["e1"]]), ref = list(e[["e2"]]),
      exclude = list("c1", "cx"), draw = TRUE
    ),
    class = "exclude_element_not_modvar"
  )
  expect_no_condition(
    dt$tornado(
      index = list(e[["e1"]]), ref = list(e[["e2"]]),
      exclude = list(p1$description()), draw = FALSE
    )
  )
  expect_no_condition(
    dt$tornado(index = e[["e1"]], ref = e[["e2"]], draw = FALSE)
  )
  to <- dt$tornado(
    index = list(e[["e1"]]), ref = list(e[["e2"]]), draw = TRUE
  )
  with(data = to, expr = {
    # as p1 increases, the cost difference increases
    expect_lt(
      object = outcome.min[[which(Description == "c1")]],
      expected = outcome.max[[which(Description == "c1")]]
    )
    # point estimate cost saving must lie in the interval
    expect_between(
      object = edc,
      lower = outcome.min[[which(Description == "c1")]],
      upper = outcome.max[[which(Description == "c1")]]
    )
    # as p2 increases, the cost difference decreases
    expect_gt(
      object = outcome.min[[which(Description == "c2")]],
      expected = outcome.max[[which(Description == "c2")]]
    )
    # point estimate cost saving must lie in the interval
    expect_between(
      object = edc,
      lower = outcome.max[[which(Description == "c2")]],
      upper = outcome.min[[which(Description == "c2")]]
    )
  })
  to <- dt$tornado(
    index = list(e[["e1"]]), ref = list(e[["e2"]]), outcome = "ICER"
  )
  with(data = to, expr = {
    # as p1 increases, the ICER increases
    expect_lt(
      object = outcome.min[[which(Description == "c1")]],
      expected = outcome.max[[which(Description == "c1")]]
    )
    # point estimate ICER must lie in the interval
    expect_between(
      object = eicer,
      lower = outcome.min[[which(Description == "c1")]],
      upper = outcome.max[[which(Description == "c1")]]
    )
    # as p2 increases, the ICER decreases
    expect_gt(
      object = outcome.min[[which(Description == "c2")]],
      expected = outcome.max[[which(Description == "c2")]]
    )
    # point estimate ICER must lie in the interval
    expect_between(
      object = eicer,
      lower = outcome.max[[which(Description == "c2")]],
      upper = outcome.min[[which(Description == "c2")]]
    )
  })
  grDevices::dev.off()

  # tests of thresholding
  expect_error(
    dt$threshold(
      index = list(e[["e1"]]), ref = list(e[["e2"]]), outcome = "ICER",
      mvd = p2$description(),
      a = pcomp, b = 0.7,
      lambda = -1.0, tol = 0.001
    ),
    class = "invalid_lambda"
  )
  expect_error(
    dt$threshold(
      index = list(e[["e1"]]), ref = list(e[["e2"]]), outcome = "ICER",
      mvd = p2$description(),
      a = pcomp, b = 0.02,
      lambda = 20000.0, tol = 0.001
    ),
    class = "invalid_brackets"
  )
  expect_error(
    dt$threshold(
      index = list(e[["e1"]]), ref = list(e[["e2"]]), outcome = "ICER",
      mvd = p2$description(),
      a = pcomp, b = 0.8,
      lambda = 20000.0, tol = 0.001, nmax = 5L
    ),
    class = "convergence_failure"
  )
  # icer threshold (should be ~61.2% to reduce ICER to WTP threshold)
  pintt <- dt$threshold(
    index = list(e[["e1"]]), ref = list(e[["e2"]]), outcome = "ICER",
    mvd = p2$description(),
    a = pcomp, b = 0.8,
    lambda = 20000.0, tol = 0.0001
  )
  tdc <- (cint * pintt + (cint + cfail) * (1.0 - pintt)) -
    (ccomp * pcomp + (ccomp + cfail) * (1.0 - pcomp))
  tdq <- (uints * pintt + uintf * (1.0 - pintt)) -
    (ucomps * pcomp + ucompf * (1.0 - pcomp))
  ticer <- tdc / tdq
  expect_intol(ticer, 20000.0, tol = 10.0)
  # cost saving threshold (cost of intervention must fall to 1010)
  mvcint_s <- ConstModVar$new("", "", cint)
  mvcint_f <- ExprModVar$new("", "", quo = rlang::quo(mvcint_s + cfail))
  e[["e5"]]$set_cost(c = mvcint_s)
  e[["e6"]]$set_cost(c = mvcint_f)
  cintt <- dt$threshold(
    index = list(e[["e1"]]), ref = list(e[["e2"]]), outcome = "saving",
    mvd = mvcint_s$description(),
    a = pcomp, b = cint, tol = 0.0001
  )
  tdc <- (cintt * pint + (cintt + cfail) * (1.0 - pint)) -
    (ccomp * pcomp + (ccomp + cfail) * (1.0 - pcomp))
  expect_intol(tdc, 0.0, tol = 1.0)
})

Try the rdecision package in your browser

Any scripts or data that you put into this service are public.

rdecision documentation built on April 3, 2025, 6:09 p.m.