--- a +++ b/R/recruit.R @@ -0,0 +1,554 @@ +#' A class to contain the accrued patient allocations, +#' by site, treatment/control arm and week +#' +#' @slot accrual 3-D array with axes site, experimental arm +#' and week +#' @slot target_arm_size Number of subjects required for each treatment arm. +#' @slot target_control Number of subjects required for control arm(s). +#' @slot target_interim Number of subjects required for treatment arm at +#' interim analysis. +#' @slot accrual_period Number of weeks in recruitment period. +#' @slot interim_period Number of weeks to recruit for interim analysis. +#' @slot phase_changes Vector of week numbers when arms closed +#' @slot site_closures Vector of weeks sites closed; NA indicates open +#' @slot week Current recruitment week +#' @slot active_arms Vector of indices of open arms +#' @slot active_sites Vector of indices of open sites +#' @slot shared_control TRUE if a shared control arm is being used, else FALSE +#' @slot site_in_region Vector of indices for which set of expected +#' prevalences each site should use +#' @slot site_cap Vector of maximum number of patients for each site +#' @slot site_mean_rate Vector of expected recruitment rates for each site +#' @slot site_rate Vector of recruitment rates for each site, drawn from a +#' gamma distribution +#' @slot start_week Vector of weeks that each site opens recruitment +#' @slot site_index Vector of index numbers for each site +#' @slot treatment_arm_ids Named list of lists of recruitment arms by +#' treatment arm. +#' +#' @param treatment_arm_ids Named list of lists of recruitment arms by +#' treatment arm. +#' @param shared_control TRUE if all experimental arms share one control arm; +#' FALSE if each has their own +#' @param target_arm_size Number of subjects required for each treatment arm. +#' @param target_control Number of subjects required for control arm(s). +#' @param target_interim Number of subjects required for treatment arm at +#' interim analysis. +#' @param accrual_period Number of weeks in recruitment period. +#' @param interim_period Number of weeks to recruit for interim analysis. +#' @param centres_df Dataframe with columns "site", "start_month", "mean_rate", +#' "region" and "site_cap" +#' +#' @usage accrual(treatment_arm_ids, shared_control, target_arm_size, +#' target_control, target_interim, accrual_period, interim_period, centres_df) +#' @name accrual +#' +#' @export +#' +accrual <- S7::new_class("accrual", + package = "biomkrAccrual", + properties = list( + accrual = S7::class_integer, + target_arm_size = S7::class_integer, + target_control = S7::class_integer, + target_interim = S7::class_integer, + accrual_period = S7::class_integer, + interim_period = S7::class_integer, + control_ratio = S7::class_double, + phase_changes = S7::class_integer, + site_closures = S7::class_integer, + week = S7::class_integer, + active_arms = S7::class_integer, + active_sites = S7::class_integer, + shared_control = S7::class_logical, + site_in_region = S7::class_integer, + site_cap = S7::class_integer, + site_mean_rate = S7::class_double, + site_rate = S7::class_double, + site_start_week = S7::class_integer, + site_index = S7::class_integer, + treatment_arm_ids = S7::class_list + ), + constructor = function( + treatment_arm_ids = S7::class_missing, + shared_control = S7::class_missing, + target_arm_size = S7::class_missing, + target_control = S7::class_missing, + target_interim = S7::class_missing, + accrual_period = S7::class_missing, + interim_period = S7::class_missing, + control_ratio = S7::class_missing, + centres_df = S7::class_missing + ) { + # Create the object and populate it + S7::new_object( + # Parent class + S7::S7_object(), + # weeks * treatments * no_centres + accrual = array( + # Defaults to 0 for no patients recruited + as.integer(0), + dim = c( + # Max. weeks + accrual_period, + # No. experimental arms including control + length(treatment_arm_ids) + + ifelse(shared_control, 1, length(treatment_arm_ids)), + # No. sites + length(unique(centres_df$site)) + ), + dimnames = list( + Weeks = NULL, + Arms = c( + names(treatment_arm_ids), + if (shared_control) { + "Control" + } else { + paste("C", names(treatment_arm_ids), sep = "-") + } + ), + Centres = c(paste("Centre", unique(centres_df$site))) + ) + ), + target_arm_size = as.integer(target_arm_size), + target_control = as.integer(target_control), + target_interim = as.integer(target_interim), + accrual_period = accrual_period, + interim_period = interim_period, + control_ratio = control_ratio, + phase_changes = rep(NA_integer_, length(treatment_arm_ids)), + site_closures = rep(NA_integer_, length(unique(centres_df$site))), + week = as.integer(1), + active_arms = seq_len(length(treatment_arm_ids)), + active_sites = seq_len(length(unique(centres_df$site))), + shared_control = shared_control, + site_in_region = as.integer(centres_df$region), + site_cap = as.integer(centres_df$site_cap), + site_mean_rate = as.numeric(centres_df$mean_rate), + site_rate = NA_real_, + site_start_week = as.integer(centres_df$start_week), + site_index = as.integer(centres_df$site), + treatment_arm_ids = treatment_arm_ids + ) + } +) + + +#' Sum accrual array by site, to enable checking against site caps +#' @param obj Object of class "accrual" +#' @return vector of total accrual by recruitment site +#' +#' @export +#' +site_sums <- S7::new_generic("site_sums", "obj") +S7::method(site_sums, accrual) <- function(obj) { + # Sum across dimensions 1:dims + colSums(obj@accrual, dims = 2) +} + + +# Sum accrual array by experimental arm (including control). +#' +#' @param x Accrual array with dimensions Weeks, Arms and Centres. +#' @export +treat_sums <- function(x, ...) { + UseMethod("treat_sums", x) +} + +#' Sum accrual array by experimental arm (including control). +#' +#' @param x Accrual array with dimensions Weeks, Arms and Centres. +#' @param no_treat_arms Number of treatment arms (as opposed to control +#' arms). +#' @param shared_control TRUE if all treatment arms share the +#' same control arm; FALSE if each treatment arm has its own +#' control. Defaults to TRUE. +#' @param control_total Logical; if TRUE return single total for all +#' control arms (not used if `shared_control` is TRUE); defaults to FALSE. +#' +#' @return vector of total accrual by experimental arm. +#' +#' @export +#' +treat_sums.array <- function( + x, + control_total = FALSE, + no_treat_arms, + shared_control = TRUE, + na.rm = TRUE +) { + # Permute the array so that the first dimension is the + # dimension you want to get sums for (experimental arms) + arm_sums <- + as.integer(colSums(rowSums(x, dims = 2, na.rm = na.rm))) + + # If want total for control arms rather than separate values + if (!shared_control && control_total) { + arm_sums <- c( + # Treatment arms + arm_sums[seq_along(no_treat_arms)], + # Control + sum(arm_sums[seq(no_treat_arms + 1, length.out = no_treat_arms)]) + ) + } + + return(arm_sums) +} + + +#' Sum accrual array element of accrual object, by experimental +#' arm (including control). +#' +#' @name treat_sums +#' +#' @param x Object of class `accrual`. +#' @param control_total Logical; if TRUE return single total for all +#' control arms +#' +#' @return vector of total accrual by experimental arm +#' +#' @export +#' +`treat_sums.biomkrAccrual::accrual` <- function( + x, + control_total = FALSE, + na.rm = TRUE +) { + + # Call treat_sums.array() on accrual array element + treat_sums( + x@accrual, + control_total, + length(x@phase_changes), + x@shared_control, + na.rm + ) +} + + +#' Select arms to cap for single site, or sites to cap for single arm +#' @param population Vector of allocations of patients this week +#' @param captotal Value of cap for site or arm (as appropriate) +#' @return Vector of arms to cap (can include multiples of the same arm) +#' +do_choose_cap <- function(population, captotal) { + if (length(population) == captotal) { + # Use whole population if correct length + capped <- population + } else { + # Sample + capped <- sample(population, size = captotal) + } + + return(capped) +} + + +#' Get no. weeks from months +#' Currently 4 week month, fix later using lubridate +#' @param months Duration in months +#' @return Duration in weeks +#' +get_weeks <- function(months) { + as.integer(round(months * 4, 0)) +} + + +#' Method to increment site rates by gamma-distributed rates of sites +#' opening an accrual pathway in the current week. +#' @param obj An object of type "accrual" +#' @return Modified object with new site rates +#' +#' @importFrom stats rgamma +#' +set_site_rates <- S7::new_generic("site_start_rates", "obj") +S7::method(set_site_rates, accrual) <- function(obj, fixed_site_rates) { + + # If this is the first time calling this, initialise with rate 0 + if (any(is.na(obj@site_rate))) { + obj@site_rate <- rep(0, dim(obj@accrual)[3]) + } + + indices <- which(obj@site_start_week == obj@week) + + if (length(indices) > 0) { + + # mean_rates are in recruitment per month, convert to weeks + if (fixed_site_rates) { + rates <- obj@site_mean_rate(indices) / 4 + } else { + var_lambda <- 0.25 + rates <- 0.25 * stats::rgamma( + n = length(indices), + shape = obj@site_mean_rate[indices]^2 / var_lambda, + # Per week not per month + rate = obj@site_mean_rate[indices] / var_lambda + ) + } + + # When multiple recruitment sources at a given site, want them + # to stack + obj@site_rate[obj@site_index[indices]] <- + obj@site_rate[obj@site_index[indices]] + rates + + } + + return(obj) +} + + +#' Implement site cap on a week's accrual. +#' @param obj Accrual object +#' @param site_cap Maximum number of patients per site +#' +#' @return Modified accrual object with capped week's accrual and +#' with any capped sites removed from active_sites +#' +apply_site_cap <- S7::new_generic("apply_site_cap", "obj") +S7::method(apply_site_cap, accrual) <- function(obj) { + # If any sites exceed their cap, remove accrual from randomly + # selected arms until sites are at cap + # Represents sites closing during the week + site_captotal <- site_sums(obj) - obj@site_cap + + if (any(site_captotal > 0)) { + # Loop over sites which are above the cap + for (site in which(site_captotal > 0)) { + # Vector of instances of populated arms in week's accrual, + # including control, e.g. c(1, 1, 2, 4) + population <- unlist(sapply( + which(obj@accrual[obj@week, , site] > 0), + function(j) rep(j, obj@accrual[obj@week, j, site]) + )) + + # Randomly select population instances to remove, leaving + # enough to max out the cap + if (length(population) > 0) { + capped <- do_choose_cap(population, site_captotal[site]) + + # Remove those instances + for (i in capped) { + obj@accrual[obj@week, i, site] <- + as.integer(obj@accrual[obj@week, i, site] - 1) + } + } + } + } + + # Don't remove sites from active_sites here, because they may + # be reinstated during arm capping + + return(obj) +} + + +#' Implement arm cap on week's accrual to experimental arms +#' @param accrual_obj Object of class `accrual` +#' @param struct_obj Object of class `trial_structure` +#' +#' @return Modified accrual object with capped week's accrual +#' +#' @importFrom rlang abort +#' +apply_arm_cap <- + S7::new_generic("apply_arm_cap", c("accrual_obj", "struct_obj")) +S7::method(apply_arm_cap, list(accrual, trial_structure)) <- + function(accrual_obj, struct_obj) { + + # Get totals for experimental arms (dropping control) + arm_sums <- + treat_sums(accrual_obj)[seq_len(length(accrual_obj@phase_changes))] + + # Compare with cap + arm_captotal <- arm_sums - accrual_obj@target_arm_size + + # Inactive arms can be at cap but not exceed it + if (any(arm_captotal[-accrual_obj@active_arms] > 0)) { + rlang::abort(paste( + "Inactive arm exceeded cap:", + which(arm_captotal[-accrual_obj@active_arms] > 0) + )) + } + + ### Change references to active arms + + # Active arm indices which exceed cap + active_tocap <- which(arm_captotal > 0) + + if (length(active_tocap) > 0) { + for (arm in active_tocap) { + # Which sites recruited to that arm this week? + population <- as.integer(unlist(sapply( + which(accrual_obj@accrual[accrual_obj@week, arm, ] > 0), + function(j) rep(j, accrual_obj@accrual[accrual_obj@week, arm, j]) + ))) + + # Possible accruals to remove + if (length(population) > 0) { + capped <- do_choose_cap(population, arm_captotal[arm]) + + # Remove capped instances + for (i in capped) { + accrual_obj@accrual[accrual_obj@week, arm, i] <- + as.integer(accrual_obj@accrual[accrual_obj@week, arm, i] - 1) + } + } + } + } + + # Record closing week for capped arms + capped_arms <- arm_captotal[accrual_obj@active_arms] >= 0 + + accrual_obj@phase_changes[accrual_obj@active_arms[capped_arms]] <- + accrual_obj@week + + + # Update active_arms + accrual_obj@active_arms <- which(arm_captotal < 0) + # Also on the trial structure object + struct_obj <- remove_treat_arms(struct_obj, which(arm_captotal >= 0)) + + # Recheck site caps + site_captotal <- site_sums(accrual_obj) - accrual_obj@site_cap + + # Record closing week for capped sites + capped_sites <- site_captotal[accrual_obj@active_sites] >= 0 + accrual_obj@site_closures[accrual_obj@active_sites[capped_sites]] <- + accrual_obj@week + + # Update active sites + accrual_obj@active_sites <- which(site_captotal < 0) + + return(list(accrual_obj, struct_obj)) + } + + + + +#' Randomise a week's expected accrual amongst the sites, according to +#' prevalence. +#' @param accrual_obj An object of class `accrual`. +#' @param struct_obj An object of class `trial_structure`. +#' @param fixed_site_rates TRUE if centre recruitment rates should +#' be treated as exact; FALSE if they should be drawn from a gamma +#' distribution with a mean of the specified rate. +#' +#' @return Matrix of week's accrual by site and recruitment arm. +#' +week_accrue <- S7::new_generic("week_accrue", c("accrual_obj", "struct_obj")) +S7::method(week_accrue, list(accrual, trial_structure)) <- + function(accrual_obj, struct_obj, fixed_site_rates) { + + # Update the site rates + accrual_obj <- set_site_rates(accrual_obj, fixed_site_rates) + + # Initialising (sites * experimental arms) + week_mx <- matrix( + as.integer(0), + nrow = dim(accrual_obj@accrual)[2], + ncol = dim(accrual_obj@accrual)[3] + ) + + week_acc <- as.integer(rep(0, dim(accrual_obj@accrual)[3])) + + # Accrual per site is poisson distributed + week_acc[accrual_obj@active_sites] <- rpois( + n = length(accrual_obj@active_sites), + lambda = accrual_obj@site_rate[accrual_obj@active_sites] + ) + + # Loop over recruiting sites + for (isite in which(week_acc > 0)) { + + # Total probability for each experimental arm for the + # relevant site prevalence set + probs <- colSums(struct_obj@experimental_arm_prevalence[ + , , accrual_obj@site_in_region[isite] + ]) + + + # Sample experimental arms according to probabilities + # Adding a dummy arm to take unassigned allocations due + # to arm closure, representing reduced site recruitment + assigns <- sample( + seq_len(length(probs) + 1), + prob = c(probs, 1 - sum(probs)), + size = week_acc[isite], + replace = TRUE + ) + + # Total assignments to each open arm plus dummy arm + assign_table <- table(assigns) + indices <- as.numeric(names(assign_table)) + + # Drop the dummy arm if anything was assigned to it + if (max(indices) > length(probs)) { + assign_table <- assign_table[-length(assign_table)] + indices <- indices[-length(indices)] + } + + # Increment week's assignment matrix + week_mx[indices, isite] <- + week_mx[indices, isite] + as.vector(assign_table) + } + + return(list(accrual_obj, week_mx)) + } + + +#' Generate accrual for a week and assign it to the +#' accrual object. Increments the property "week", +#' which holds the next week number to accrue. +#' @param accrual_obj An object of class "accrual" +#' @param struct_obj An object of class "trial_structure" +#' @param fixed_site_rates TRUE if expected site rate to be used; FALSE +#' draws the site rate from a gamma distribution +#' +#' @return An object of class "accrual" +#' +accrue_week <- S7::new_generic("accrue_week", c("accrual_obj", "struct_obj")) +S7::method(accrue_week, list(accrual, trial_structure)) <- + function(accrual_obj, struct_obj, fixed_site_rates) { + + # Should not get here if there aren't any but + if (length(accrual_obj@active_sites) > 0) { + + week_acc_ls <- week_accrue(accrual_obj, struct_obj, fixed_site_rates) + accrual_obj <- week_acc_ls[[1]] + week_acc <- week_acc_ls[[2]] + + # Assign the week's accrual to the object + accrual_obj@accrual[accrual_obj@week, , ] <- + week_acc + + # Apply site cap + accrual_obj <- apply_site_cap(accrual_obj) + + # Apply arm cap, and then adjust site cap appropriately + obj_list <- apply_arm_cap(accrual_obj, struct_obj) + accrual_obj <- obj_list[[1]] + struct_obj <- obj_list[[2]] + + } + + return(list(accrual_obj, struct_obj)) + } + + + +#' Check whether an object is of class "accrual". +#' +#' @param x Object to test +#' +#' @return TRUE or FALSE +#' +#' @importFrom S7 new_generic method class_any +#' +#' @export +#' +is.accrual <- S7::new_generic("is.accrual", "x") +S7::method(is.accrual, S7::class_any) <- function(x) { + inherits(x, "biomkrAccrual::accrual") +} + +