Time-to-Event Data MCP-Mod


In this vignette we illustrate how to use the DoseFinding package with time-to-event (survival) endpoints. We show how to simulate a dose-finding trial, how to apply the generalized MCP-Mod methodology to the log hazard ratios estimated from a Cox proportional hazards model, and how to perform power and sample size calculations following @deng2019.

For continuously distributed data see the analysis of normal data vignette, and for binary data the binary data vignette.

library(DoseFinding)
library(ggplot2)
library(survival)
## set seed and ensure reproducibility across R versions
set.seed(2010, kind = "Mersenne-Twister", sample.kind = "Rejection", normal.kind = "Inversion")

Example trial

We will first generate an appropriate dataset that we can use to illustrate the analysis of time-to-event data.

Assume a dose-finding study is planned for a hypothetical investigational treatment. The endpoint of interest is the time to a certain event. The treatment is tested with doses 0 (placebo), 0.05, 0.2, 0.5 and 1, with an allocation ratio of 2:1:1:1:2. The larger allocation to placebo and highest dose is chosen to increase the power. It is assumed that the hazard rate for the placebo group is $\lambda_0 = 0.6$ and that the hazard ratio for the maximum effect over the dose range is $\text{HR} = 0.5$. This results in a hazard rate of $\lambda_1 = 0.3$.

We define the parameters for the example just described:

doses <- c(0, 0.05, 0.2, 0.5, 1) # placebo and 4 treatment groups
lambda0 <- 0.6 # yearly hazard rate for the placebo group
HR1 <- 0.5 # hazard ratio for the treatment effect at the highest dose
lambda1 <- lambda0 * HR1 # hazard rate for the highest dose
alRatio <- c(2, 1, 1, 1, 2) # allocation ratio across the dose groups

We use the following candidate set of dose-response models for the mean response: an $\text{E}{\text{max}}$, a sigmoidal $\text{E}{\text{max}}$, a quadratic and an exponential model.

In the survival context a decreasing response (a lower hazard) typically corresponds to a beneficial treatment effect, so we specify direction = "decreasing".

The dose-response models are placed on the log hazard scale, because dose-response models in the DoseFinding package assume additive effects. Consequently:

mods <- Mods(
  emax = c(0.1, 0.5),
  sigEmax = c(0.5, 4),
  quadratic = -0.9,
  exponential = 0.2,
  doses = doses,
  direction = "decreasing", # decreasing (lower hazard) is beneficial
  placEff = log(lambda0), # log hazard rate of the placebo arm
  maxEff = log(lambda1) - log(lambda0) # log hazard ratio at the highest dose
)
plotMods(mods, superpose = TRUE, ylab = "log(hazard rate)")

To illustrate the methodology we simulate a survival data set based on the trial assumptions and one of our assumed candidate models. The function below draws survival times from an exponential distribution under one of the candidate models. It supports

#' @param mods `Mods` object defining the candidate models to simulate from
#' @param model name or index of the model whose hazards are used to simulate
#'    (refers to column names or column indices of getResp(mods))
#' @param alRatio allocation ratio across the dose groups
#' @param n total sample size
#' @param etotal number of events at which event-driven censoring is applied
#' @param staggered logical, whether to use staggered study entry
#' @return data.frame with columns group, time, status
simulate_surv_data <- function(mods, model, alRatio, n, etotal,
                               staggered = FALSE) {
  doses <- attr(mods, "doses")
  y <- getResp(mods)
  lambda0 <- exp(attr(mods, "placEff"))
  ## assign each patient to a dose group (log hazard rate) according to alRatio
  log_rate_resp <- rep(y[, model], rndDesign(alRatio/sum(alRatio), n))

  ## entry (calendar) times
  entry <- if (staggered) {
    runif(n, 0, qexp(etotal / n, lambda0))
  } else {
    rep(0, n)
  }

  ## event times on the calendar-time scale
  event <- entry + rexp(n, exp(log_rate_resp))

  ## event-driven censoring at the time of the etotal-th event
  t_cut <- sort(event)[etotal]
  status <- as.numeric(event <= t_cut)
  obs_time <- pmin(event, t_cut) - entry # follow-up on the study-time scale

  data.frame(
    group = factor(names(log_rate_resp), levels = as.character(doses)),
    time = obs_time,
    status = status
  )
}

We simulate a trial of 300 patients using the hazards from the $\text{E}_{\text{max}}$ model (model = 1) and assuming we will require 130 events.

n <- 300
dat <- simulate_surv_data(
  mods, model = 1, alRatio = alRatio, n = n, etotal = 130,
  staggered = TRUE
)
head(dat)

We can use a Kaplan-Meier plot to visualize the survival times by dose.

km <- survfit(Surv(time, status) ~ group, data = dat)
plot(km, col = seq_along(doses), lwd = 2,
     xlab = "Years", ylab = "Survival probability",
     main = "Kaplan-Meier estimator by dose")
legend("topright", legend = paste("dose", doses),
       col = seq_along(doses), lwd = 2, bty = "n")

Analysis with MCP-Mod

We will now walk through the typical steps of a MCP-Mod analysis using our simulated trial data.

MCP step

The generalized MCP-Mod approach requires an estimate $\hat\mu$ of the (in this case placebo-adjusted) dose-group effects together with its covariance matrix $\hat S$. We obtain both from a Cox proportional hazards model, which expresses the hazard as [ \lambda(t \mid \text{dose}) = \lambda_0(t)\exp(\beta_1 x_1 + \dots + \beta_p x_p), ] where the coefficients $\beta$ are the log hazard ratios of each dose group relative to placebo.

coxfit <- coxph(Surv(time, status) ~ group, data = dat)
summary(coxfit)

coef_cox <- coef(coxfit) # placebo-adjusted log hazard ratios
S_cox <- vcov(coxfit) # covariance matrix of the estimates

We now test for a dose-response signal with MCTtest(). Since the coxfit model returns placebo-adjusted estimates (there is no placebo coefficient), we use placAdj = TRUE and drop the placebo dose from dose, we test one-sided.

mct <- MCTtest(
  dose = doses[-1], # placebo-adjusted, so drop placebo
  resp = coef_cox,
  S = S_cox,
  models = mods,
  type = "general",
  alternative = "one.sided",
  placAdj = TRUE
)
mct

The output reports, per candidate model, the optimal-contrast $t$-statistics together with multiplicity-adjusted $p$-values. Small adjusted $p$-values give evidence for a dose-response relationship. If we would have specified a significance level of 0.025 one-sided, then we can consider the null hypothesis of no dose-response effect versus placebo is rejected since several of the adjusted p-values are smaller than 0.025.

Dose-response estimation

Dose-response modeling can then proceed with a combination of bootstrapping and model averaging. For detailed explanations refer to the vignette for analysis of continuous data. Fitting is done on the log-hazard ratio scale so we are using placebo-adjusted models. Since maFitMod does not support placebo-adjusted dose-response estimation we need to define a couple of helper functions to perform the model averaging.

one_bootstrap_prediction <- function(mu_hat, S_hat, doses, bounds, dose_seq) {
  sim <- drop(mvtnorm::rmvnorm(1, mu_hat, S_hat))
  fit <- lapply(c("emax", "sigEmax", "exponential", "quadratic"), function(mod)
    fitMod(doses, sim, model = mod, S = S_hat, type = "general", bnds = bounds[[mod]], placAdj = TRUE))
  index <- which.min(sapply(fit, gAIC))
  pred <- predict(fit[[index]], doseSeq = dose_seq, predType = "effect-curve")
  return(pred)
}

## bs_predictions is a doses x replications matrix,
## probs is a 4-element vector of increasing probabilities for the quantiles
summarize_predictions <- function(bs_predictions, probs) {
  stopifnot(length(probs) == 4)
  med <- apply(bs_predictions, 1, median)
  quants <- apply(bs_predictions, 1, quantile, probs = probs)
  bs_df <- as.data.frame(cbind(med, t(quants)))
  names(bs_df) <- c("median", "low_out", "low_in", "high_in", "high_out")
  return(bs_df)
}

predict_and_plot <- function(mu_hat, S_hat, doses, dose_seq, n_rep) {
  bs_rep <- replicate(
    n_rep, one_bootstrap_prediction(mu_hat, S_hat, doses, defBnds(max(doses)), dose_seq))
  bs_summary <- summarize_predictions(bs_rep, probs = c(0.025, 0.25, 0.75, 0.975))
  bs_summary <- as.data.frame(exp(bs_summary)) # back to hazard ratio scale
  ci_half_width <- qnorm(0.975) * sqrt(diag(S_hat))
  cox_summary <- data.frame(dose = doses, mu_hat = exp(mu_hat),
                            low = exp(mu_hat - ci_half_width),
                            high = exp(mu_hat + ci_half_width))
  gg <- ggplot(cbind(bs_summary, dose_seq = dose_seq)) + geom_line(aes(dose_seq, median)) +
    geom_ribbon(aes(x = dose_seq, ymin = low_in, ymax = high_in), alpha = 0.2) +
    geom_ribbon(aes(x = dose_seq, ymin = low_out, ymax = high_out), alpha = 0.2) +
    geom_point(aes(dose, mu_hat), cox_summary) +
    scale_y_continuous(breaks = seq(0.5, 1.5, 0.25)) + 
    geom_errorbar(aes(dose, ymin = low, ymax = high), cox_summary, width = 0, alpha = 0.5) +
    xlab("Dose") + ylab("Hazard ratio") +
    labs(title = "Bootstrap estimates for population hazard ratio",
         subtitle = "confidence levels 50% and 95%")
  return(gg)
}

We then use these functions to plot the estimated dose-response curve together with the Cox model estimates on the original hazard ratio scale.

predict_and_plot(coef_cox, S_cox, doses[-1], dose_seq = seq(0, 1, 0.01), 1000)

Power and sample size calculations at design stage

For power and sample size calculations in generalized MCP-Mod we need to make an assumption about the covariance matrix $S$ of the placebo-adjusted estimates under the alternative. @deng2019 derive an approximation for the covariance matrix of the estimated log hazard ratios for a Cox model which we will use in the following.

Let $n_k$ be the number of patients in dose group $k$ ($k = 0$ for placebo), $D$ the total number of events, and $\beta_k$ the log hazard ratio of dose group $k$ vs. placebo (with $\beta_0 = 0$). Then, with [ p_k = \frac{(n_k/n_0)\exp(\beta_k)}{1 + \sum_{i=1}^{K}(n_i/n_0)\exp(\beta_i)}, ] the covariance matrix $S$ of $\hat\beta = (\hat\beta_1, \dots, \hat\beta_K)$ is approximately [ S \approx \frac{1}{D} \begin{pmatrix} p_1^{-1} + p_0^{-1} & p_0^{-1} & \cdots & p_0^{-1}\ p_0^{-1} & p_2^{-1} + p_0^{-1} & \cdots & p_0^{-1}\ \vdots & \vdots & \ddots & \vdots\ p_0^{-1} & p_0^{-1} & \cdots & p_K^{-1} + p_0^{-1} \end{pmatrix}. ]

Since this matrix depends on the total number of events rather than the total number of patients, this facilitates the use of event-driven dose-finding designs. Under the null hypothesis of no dose-response effect versus placebo ($\beta_k = 0$ for all $k$) the covariance matrix simplifies to a matrix $S_0$ that depends only on the allocation ratios to each arm and the total number of events, and is used to obtain the critical value for the multiple contrast test.

The following function calculates both $S$ (one per candidate model) and $S_0$. Following @deng2019 we evaluate $S$ at half the assumed effect, 0.5 * beta, an intermediate value between the null and the alternative. Then using $S$ and $S_0$ we compute the power under each candidate model. The function below builds the optimal contrast for each model (using its own $S$), derives the critical value from $S_0$, and evaluates the power for a given number of events with powMCT(). We also define a function that allows us to calculate the required number of events to achieve a target average power using binary search.

#' Power of the multiple contrast test for a time-to-event endpoint
#'
#' @param mods Set of dose-response models; an object of class `Mods`.
#' @param alpha One-sided significance level.
#' @param alRatio Positive allocation weights for all treatment groups,
#'   including placebo.
#' @param etotal Positive number of events.
#'
#' @return A list containing:
#' - `power_table`: power under each candidate model and the average power;
#' - `power`: unrounded power under each candidate model;
#' - `power_av`: unrounded average power;
#' - `contMat`: matrix of optimal contrasts;
#' - `critV`: critical value for the multiple contrast test.
powMCT_TTE <- function(mods, alpha, alRatio, etotal) {

  y <- getResp(mods)
  doses <- attr(mods, "doses")
  n_group <- nrow(y)
  n_model <- ncol(y)
  n_active <- n_group - 1
  model_names <- colnames(y)
  if (is.null(model_names)) {
    model_names <- paste0("model", seq_len(n_model))
  }
  # Placebo-adjusted log hazard ratios.
  beta <- sweep(y, 2, y[1, ], FUN = "-")
  # Group probabilities evaluated at half the assumed treatment effect.
  weighted_hazards <- exp(0.5 * beta) * alRatio
  p <- sweep(
    weighted_hazards,
    2,
    colSums(weighted_hazards),
    FUN = "/"
  )
  # Covariance matrices under each candidate alternative.
  S <- array(
    0,
    dim = c(n_active, n_active, n_model),
    dimnames = list(
      as.character(doses[-1]),
      as.character(doses[-1]),
      model_names
    )
  )

  for (i in seq_len(n_model)) {
    p0 <- p[1, i]
    S_i <- matrix(
      1 / p0,
      nrow = n_active,
      ncol = n_active
    )
    diag(S_i) <- 1 / p[-1, i] + 1 / p0
    S[, , i] <- S_i / etotal
  }
  # Covariance matrix under the null hypothesis.
  p_null <- alRatio / sum(alRatio)
  p0_null <- p_null[1]
  S0 <- matrix(
    1 / p0_null,
    nrow = n_active,
    ncol = n_active
  )
  diag(S0) <- 1 / p_null[-1] + 1 / p0_null
  S0 <- S0 / etotal

  # Obtain the optimal contrast for each candidate model using the
  # covariance matrix associated with that model.
  contMat <- vapply(
    seq_len(n_model),
    FUN = function(i) {
      contrast_i <- optContr(
        mods,
        doses = doses[-1],
        S = S[, , i],
        placAdj = TRUE
      )[[1]]
      contrast_i[, i]
    },
    FUN.VALUE = numeric(n_active)
  )
  dimnames(contMat) <- list(
    as.character(doses[-1]),
    model_names
  )
  # The null correlation matrix and critical value do not depend on which
  # candidate model is treated as the true model.
  covMat0 <- crossprod(contMat, S0 %*% contMat)
  corMat0 <- cov2cor(covMat0)

  integration_control <- mvtnorm.control()
  critV <- critVal(
    corMat0,
    alpha,
    df = 0,
    alternative = "one.sided",
    control = integration_control
  )
  model_power <- numeric(n_model)
  for (i in seq_len(n_model)) {
    model_power[i] <- powMCT(
      contMat,
      alpha = alpha,
      altModels = mods,
      S = S[, , i],
      placAdj = TRUE,
      alternative = "one.sided",
      df = Inf,
      critV = critV,
      control = integration_control
    )[i]
  }
  average_power <- mean(model_power)

  power_table <- data.frame(
    model = c(model_names, "average"),
    power = round(c(model_power, average_power), 3),
    row.names = NULL
  )
  list(
    power_table = power_table,
    power = stats::setNames(model_power, model_names),
    power_av = average_power,
    contMat = contMat,
    critV = critV
  )
}

#' Required number of events for a target power
#'
#' @param mods Set of dose-response models; an object of class `Mods`.
#' @param alpha One-sided significance level.
#' @param alRatio Positive allocation weights for all treatment groups,
#'   including placebo.
#' @param targPow Target average power.
#' @param lowerE Lower bound for the number of events.
#' @param upperE Upper bound for the number of events.
#'
#' @return A list containing:
#' - `events`: smallest integer event count attaining the target power;
#' - `power`: average power at that event count;
#' - `power_table`: model-specific powers at that event count;
#' - `target_power`: requested average power.
eventsMCT_TTE <- function(mods,
                          alpha,
                          alRatio,
                          targPow,
                          lowerE = 1,
                          upperE = 1000) {

  # alRatio = 1 makes sampSize() iterate the total number of events directly;
  # targFunc still uses the alRatio argument of eventsMCT_TTE (lexical scoping)
  events <- sampSize(
    upperN = upperE,
    lowerN = lowerE,
    targFunc = function(e) {
      powMCT_TTE(mods = mods, alpha = alpha, alRatio = alRatio, etotal = e)$power_av
    },
    target = targPow,
    alRatio = 1,
    Ntype = "total"
  )$samp.size

  final_result <- powMCT_TTE(
    mods = mods,
    alpha = alpha,
    alRatio = alRatio,
    etotal = events
  )
  list(
    events = events,
    power = final_result$power_av,
    power_table = final_result$power_table,
    target_power = targPow
  )
}

For example, the power for 120 events at a one-sided significance level of 0.025 is:

powMCT_TTE(mods, alpha = 0.025, alRatio = alRatio, etotal = 120)

And we can calculate the number of required events to achieve an average power of 80%:

eventsMCT_TTE(mods, alpha = 0.025, alRatio = alRatio, targPow = 0.8, lowerE = 100, upperE = 300)

Calculating required sample size for a trial

For trial planning purposes we may want to determine the required sample size for the trial rather than the number of events. To connect the number of events to a sample size, @deng2019 use the relationship between the sample size and the expected number of events under exponentially distributed survival times. More specifically, assume that patients are recruited uniformly over $[0, T_r]$ (end of recruitment) with study end $T_s$, and that survival times in group $k$ are exponential with hazard $\lambda_k$. Then the probability that a patient in group $k$ experiences an event by the end of the study is

$$q_k = 1 - \frac{\exp{-\lambda_k(T_s-T_r)}-\exp(-\lambda_kT_s)}{\lambda_kT_r}.$$ If $w_k=r_k/\sum_i r_i$ is the allocation proportion, the expected total number of events for a sample size $N$ is $$E(D\mid N) = N\sum_{k=0}^{K}w_kq_k.$$ We can use these formulas to calculate the expected number of events for each candidate model for a given sample size and determine the required sample size to achieve a specified target average power. Note that this function could also be easily modified to use the minimum or maximum power instead of the mean across candidate models.

#' Required sample size for target average power with a time-to-event endpoint
#'
#' For each candidate model, the expected number of events is calculated from
#' that model's hazards. Model-specific powers are then evaluated at the
#' corresponding expected event counts and averaged across candidate models.
#'
#' @param mods Set of dose-response models; an object of class `Mods`.
#' @param alpha One-sided significance level.
#' @param alRatio Positive allocation ratios for all treatment groups,
#'   including placebo.
#' @param targPow Target average power across candidate models.
#' @param end_rec End of the recruitment period, in years.
#' @param end_stu End of the study, in years.
#' @param lowerN Lower bound for the total sample size.
#' @param upperN Upper bound for the total sample size.
#'
#' @return A list containing:
#' - `sampSize`: smallest integer sample size attaining the target average power;
#' - `power`: average power at the returned sample size;
#' - `model_power`: model-specific powers at the returned sample size;
#' - `expected_events`: expected events under each candidate model;
#' - `event_probability`: overall event probability under each candidate model;
#' - `event_probability_by_group`: group-specific event probabilities;
#' - `allocation`: allocation proportions;
#' - `power_table`: model-specific expected events and powers;
#' - `target_power`: requested average power.
sampSizeMCT_TTE <- function(mods,
                            alpha,
                            alRatio,
                            targPow,
                            end_rec,
                            end_stu,
                            lowerN = 1,
                            upperN = 1000) {

  y <- as.matrix(getResp(mods))
  n_group <- nrow(y)
  n_model <- ncol(y)
  model_names <- colnames(y)
  if (is.null(model_names)) {
    model_names <- paste0("model", seq_len(n_model))
  }
  allocation <- alRatio / sum(alRatio)
  # Each column contains the group-specific hazards under one candidate model.
  lambda <- exp(y)

  # Group-specific probability of observing an event by the end of the study.
  #
  # This is equivalent to
  #
  # 1 - (
  #   exp(-lambda * (end_stu - end_rec)) -
  #   exp(-lambda * end_stu)
  # ) / (lambda * end_rec)
  #
  # but expm1() is more stable when lambda * end_rec is small.
  event_probability_by_group <-
    1 -
    exp(-lambda * (end_stu - end_rec)) *
    (-expm1(-lambda * end_rec)) /
    (lambda * end_rec)
  # Overall event probability under each candidate model.
  event_probability <- colSums(
    sweep(
      event_probability_by_group,
      MARGIN = 1,
      STATS = allocation,
      FUN = "*"
    )
  )
  names(event_probability) <- model_names
  colnames(event_probability_by_group) <- model_names

  # Evaluate average power for a specified integer sample size.
  evaluate_sample_size <- function(n) {
    expected_events <- n * event_probability
    model_power <- vapply(
      seq_len(n_model),
      FUN = function(j) {
        power_result <- powMCT_TTE(
          mods = mods,
          alpha = alpha,
          alRatio = alRatio,
          etotal = expected_events[j]
        )
        unname(power_result$power[j])
      },
      FUN.VALUE = numeric(1)
    )
    names(expected_events) <- model_names
    names(model_power) <- model_names

    list(
      expected_events = expected_events,
      model_power = model_power,
      power_av = mean(model_power)
    )
  }

  # alRatio = 1 makes sampSize() iterate the total sample size directly;
  # evaluate_sample_size() still uses the alRatio argument of sampSizeMCT_TTE
  required_n <- sampSize(
    upperN = upperN,
    lowerN = lowerN,
    targFunc = function(n) evaluate_sample_size(n)$power_av,
    target = targPow,
    alRatio = 1,
    Ntype = "total"
  )$samp.size
  final_result <- evaluate_sample_size(required_n)

  power_table <- data.frame(
    model = c(model_names, "average"),
    expected_events = c(
      unname(final_result$expected_events),
      mean(final_result$expected_events)
    ),
    power = c(
      unname(final_result$model_power),
      final_result$power_av
    ),
    row.names = NULL
  )
  list(
    sampSize = required_n,
    power = final_result$power_av,
    model_power = final_result$model_power,
    expected_events = final_result$expected_events,
    event_probability = event_probability,
    event_probability_by_group = event_probability_by_group,
    allocation = allocation,
    power_table = power_table,
    target_power = targPow
  )
}

Now we can calculate how many patients we would need to recruit to achieve an average power of 80%, assuming we would recruit for 2 years and we would end the study after 4 years.

sampSizeMCT_TTE(mods, alpha = 0.025, alRatio = alRatio, end_rec = 2, end_stu = 4, targPow = 0.8, lowerN = 100, upperN = 300)

References



Try the DoseFinding package in your browser

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

DoseFinding documentation built on Oct. 16, 2026, 5:08 p.m.