Diff of /R/recruit.R [000000] .. [d9ee58]

Switch to unified view

a b/R/recruit.R
1
#' A class to contain the accrued patient allocations,
2
#' by site, treatment/control arm and week
3
#' 
4
#' @slot accrual 3-D array with axes site, experimental arm
5
#' and week
6
#' @slot target_arm_size Number of subjects required for each treatment arm.
7
#' @slot target_control Number of subjects required for control arm(s).
8
#' @slot target_interim Number of subjects required for treatment arm at 
9
#' interim analysis.
10
#' @slot accrual_period Number of weeks in recruitment period.
11
#' @slot interim_period Number of weeks to recruit for interim analysis.
12
#' @slot phase_changes Vector of week numbers when arms closed
13
#' @slot site_closures Vector of weeks sites closed; NA indicates open
14
#' @slot week Current recruitment week
15
#' @slot active_arms Vector of indices of open arms
16
#' @slot active_sites Vector of indices of open sites
17
#' @slot shared_control TRUE if a shared control arm is being used, else FALSE
18
#' @slot site_in_region Vector of indices for which set of expected 
19
#' prevalences each site should use 
20
#' @slot site_cap Vector of maximum number of patients for each site
21
#' @slot site_mean_rate Vector of expected recruitment rates for each site
22
#' @slot site_rate Vector of recruitment rates for each site, drawn from a 
23
#' gamma distribution
24
#' @slot start_week Vector of weeks that each site opens recruitment
25
#' @slot site_index Vector of index numbers for each site
26
#' @slot treatment_arm_ids Named list of lists of recruitment arms by 
27
#' treatment arm.
28
#' 
29
#' @param treatment_arm_ids Named list of lists of recruitment arms by 
30
#' treatment arm.
31
#' @param shared_control TRUE if all experimental arms share one control arm;
32
#' FALSE if each has their own
33
#' @param target_arm_size Number of subjects required for each treatment arm.
34
#' @param target_control Number of subjects required for control arm(s).
35
#' @param target_interim Number of subjects required for treatment arm at 
36
#' interim analysis.
37
#' @param accrual_period Number of weeks in recruitment period.
38
#' @param interim_period Number of weeks to recruit for interim analysis.
39
#' @param centres_df Dataframe with columns "site", "start_month", "mean_rate", 
40
#' "region" and "site_cap"
41
#' 
42
#' @usage accrual(treatment_arm_ids, shared_control, target_arm_size, 
43
#' target_control, target_interim, accrual_period, interim_period, centres_df)
44
#' @name accrual
45
#'
46
#' @export
47
#' 
48
accrual <- S7::new_class("accrual",
49
  package = "biomkrAccrual",
50
  properties = list(
51
    accrual = S7::class_integer,
52
    target_arm_size = S7::class_integer,
53
    target_control = S7::class_integer,
54
    target_interim = S7::class_integer,
55
    accrual_period = S7::class_integer,
56
    interim_period = S7::class_integer,
57
    control_ratio = S7::class_double,
58
    phase_changes = S7::class_integer,
59
    site_closures = S7::class_integer,
60
    week = S7::class_integer,
61
    active_arms = S7::class_integer,
62
    active_sites = S7::class_integer,
63
    shared_control = S7::class_logical,
64
    site_in_region = S7::class_integer,
65
    site_cap = S7::class_integer,
66
    site_mean_rate = S7::class_double,
67
    site_rate = S7::class_double,
68
    site_start_week = S7::class_integer,
69
    site_index = S7::class_integer,
70
    treatment_arm_ids = S7::class_list
71
  ),
72
  constructor = function(
73
    treatment_arm_ids = S7::class_missing,
74
    shared_control = S7::class_missing,
75
    target_arm_size = S7::class_missing,
76
    target_control = S7::class_missing,
77
    target_interim = S7::class_missing,
78
    accrual_period = S7::class_missing,
79
    interim_period = S7::class_missing,
80
    control_ratio = S7::class_missing,
81
    centres_df = S7::class_missing
82
  ) {
83
    # Create the object and populate it
84
    S7::new_object(
85
      # Parent class
86
      S7::S7_object(),
87
      # weeks * treatments * no_centres
88
      accrual = array(
89
        # Defaults to 0 for no patients recruited
90
        as.integer(0), 
91
        dim = c(
92
          # Max. weeks
93
          accrual_period,
94
          # No. experimental arms including control
95
          length(treatment_arm_ids) + 
96
            ifelse(shared_control, 1, length(treatment_arm_ids)),
97
          # No. sites 
98
          length(unique(centres_df$site))
99
        ),
100
        dimnames = list(
101
          Weeks = NULL,
102
          Arms = c(
103
            names(treatment_arm_ids), 
104
            if (shared_control) {
105
              "Control"
106
            } else {
107
              paste("C", names(treatment_arm_ids), sep = "-")
108
            }
109
          ),
110
          Centres = c(paste("Centre", unique(centres_df$site)))
111
        )
112
      ),
113
      target_arm_size = as.integer(target_arm_size),
114
      target_control = as.integer(target_control),
115
      target_interim = as.integer(target_interim),
116
      accrual_period = accrual_period,
117
      interim_period = interim_period,
118
      control_ratio = control_ratio,
119
      phase_changes = rep(NA_integer_, length(treatment_arm_ids)),
120
      site_closures = rep(NA_integer_, length(unique(centres_df$site))),
121
      week = as.integer(1),
122
      active_arms = seq_len(length(treatment_arm_ids)),
123
      active_sites = seq_len(length(unique(centres_df$site))),
124
      shared_control = shared_control,
125
      site_in_region = as.integer(centres_df$region),
126
      site_cap = as.integer(centres_df$site_cap),
127
      site_mean_rate = as.numeric(centres_df$mean_rate),
128
      site_rate = NA_real_,
129
      site_start_week = as.integer(centres_df$start_week),
130
      site_index = as.integer(centres_df$site),
131
      treatment_arm_ids = treatment_arm_ids
132
    )
133
  }
134
)
135
136
137
#' Sum accrual array by site, to enable checking against site caps
138
#' @param obj Object of class "accrual"
139
#' @return vector of total accrual by recruitment site
140
#' 
141
#' @export 
142
#' 
143
site_sums <- S7::new_generic("site_sums", "obj")
144
S7::method(site_sums, accrual) <- function(obj) {
145
  # Sum across dimensions 1:dims
146
  colSums(obj@accrual, dims = 2)
147
}
148
149
150
# Sum accrual array by experimental arm (including control).
151
#' 
152
#' @param x Accrual array with dimensions Weeks, Arms and Centres.
153
#' @export
154
treat_sums <- function(x, ...) {
155
  UseMethod("treat_sums", x)
156
}
157
158
#' Sum accrual array by experimental arm (including control).
159
#' 
160
#' @param x Accrual array with dimensions Weeks, Arms and Centres.
161
#' @param no_treat_arms Number of treatment arms (as opposed to control 
162
#' arms).
163
#' @param shared_control TRUE if all treatment arms share the
164
#' same control arm; FALSE if each treatment arm has its own 
165
#' control. Defaults to TRUE.
166
#' @param control_total Logical; if TRUE return single total for all 
167
#' control arms (not used if `shared_control` is TRUE); defaults to FALSE.
168
#' 
169
#' @return vector of total accrual by experimental arm.
170
#' 
171
#' @export
172
#' 
173
treat_sums.array <- function(
174
  x, 
175
  control_total = FALSE,
176
  no_treat_arms,
177
  shared_control = TRUE,
178
  na.rm = TRUE
179
) {
180
  # Permute the array so that the first dimension is the
181
  # dimension you want to get sums for (experimental arms)
182
  arm_sums <-
183
    as.integer(colSums(rowSums(x, dims = 2, na.rm = na.rm)))
184
185
  # If want total for control arms rather than separate values
186
  if (!shared_control && control_total) {
187
    arm_sums <- c(
188
      # Treatment arms
189
      arm_sums[seq_along(no_treat_arms)],
190
      # Control
191
      sum(arm_sums[seq(no_treat_arms + 1, length.out = no_treat_arms)])
192
    )
193
  }
194
195
  return(arm_sums)
196
}
197
198
199
#' Sum accrual array element of accrual object, by experimental 
200
#' arm (including control).
201
#' 
202
#' @name treat_sums
203
#' 
204
#' @param x Object of class `accrual`. 
205
#' @param control_total Logical; if TRUE return single total for all 
206
#' control arms
207
#' 
208
#' @return vector of total accrual by experimental arm
209
#' 
210
#' @export
211
#' 
212
`treat_sums.biomkrAccrual::accrual` <- function(
213
  x,
214
  control_total = FALSE,
215
  na.rm = TRUE
216
) {
217
218
  # Call treat_sums.array() on accrual array element
219
  treat_sums(
220
    x@accrual,
221
    control_total, 
222
    length(x@phase_changes), 
223
    x@shared_control,
224
    na.rm 
225
  )
226
}
227
228
229
#' Select arms to cap for single site, or sites to cap for single arm
230
#' @param population Vector of allocations of patients this week
231
#' @param captotal Value of cap for site or arm (as appropriate)
232
#' @return Vector of arms to cap (can include multiples of the same arm)
233
#' 
234
do_choose_cap <- function(population, captotal) {
235
  if (length(population) == captotal) { 
236
    # Use whole population if correct length
237
    capped <- population
238
  } else {  
239
    # Sample      
240
    capped <- sample(population, size = captotal)
241
  }
242
243
  return(capped)
244
}
245
246
247
#' Get no. weeks from months
248
#' Currently 4 week month, fix later using lubridate
249
#' @param months Duration in months
250
#' @return Duration in weeks
251
#' 
252
get_weeks <- function(months) {
253
  as.integer(round(months * 4, 0))
254
}
255
256
257
#' Method to increment site rates by gamma-distributed rates of sites
258
#' opening an accrual pathway in the current week.
259
#' @param obj An object of type "accrual"
260
#' @return Modified object with new site rates
261
#' 
262
#' @importFrom stats rgamma
263
#' 
264
set_site_rates <- S7::new_generic("site_start_rates", "obj")
265
S7::method(set_site_rates, accrual) <- function(obj, fixed_site_rates) {
266
267
  # If this is the first time calling this, initialise with rate 0
268
  if (any(is.na(obj@site_rate))) {
269
    obj@site_rate <- rep(0, dim(obj@accrual)[3])
270
  }
271
272
  indices <- which(obj@site_start_week == obj@week)
273
274
  if (length(indices) > 0) {
275
276
    # mean_rates are in recruitment per month, convert to weeks
277
    if (fixed_site_rates) {
278
      rates <- obj@site_mean_rate(indices) / 4
279
    } else {
280
      var_lambda <- 0.25
281
      rates <- 0.25 * stats::rgamma(
282
        n = length(indices),
283
        shape = obj@site_mean_rate[indices]^2 / var_lambda,
284
        # Per week not per month
285
        rate = obj@site_mean_rate[indices] / var_lambda
286
      )
287
    }
288
289
    # When multiple recruitment sources at a given site, want them
290
    # to stack
291
    obj@site_rate[obj@site_index[indices]] <- 
292
      obj@site_rate[obj@site_index[indices]] + rates
293
294
  }
295
296
  return(obj)
297
}
298
299
300
#' Implement site cap on a week's accrual. 
301
#' @param obj Accrual object
302
#' @param site_cap Maximum number of patients per site
303
#' 
304
#' @return Modified accrual object with capped week's accrual and 
305
#' with any capped sites removed from active_sites
306
#' 
307
apply_site_cap <- S7::new_generic("apply_site_cap", "obj")
308
S7::method(apply_site_cap, accrual) <- function(obj) {
309
  # If any sites exceed their cap, remove accrual from randomly
310
  # selected arms until sites are at cap 
311
  # Represents sites closing during the week
312
  site_captotal <- site_sums(obj) - obj@site_cap
313
314
  if (any(site_captotal > 0)) {
315
    # Loop over sites which are above the cap
316
    for (site in which(site_captotal > 0)) {
317
      # Vector of instances of populated arms in week's accrual, 
318
      # including control, e.g. c(1, 1, 2, 4)
319
      population <- unlist(sapply(
320
        which(obj@accrual[obj@week, , site] > 0), 
321
        function(j) rep(j, obj@accrual[obj@week, j, site])
322
      ))
323
324
      # Randomly select population instances to remove, leaving
325
      # enough to max out the cap
326
      if (length(population) > 0) {
327
        capped <- do_choose_cap(population, site_captotal[site])
328
329
        # Remove those instances
330
        for (i in capped) {
331
          obj@accrual[obj@week, i, site] <- 
332
            as.integer(obj@accrual[obj@week, i, site] - 1)
333
        }
334
      }
335
    }
336
  }
337
338
  # Don't remove sites from active_sites here, because they may
339
  # be reinstated during arm capping
340
  
341
  return(obj)
342
}
343
344
345
#' Implement arm cap on week's accrual to experimental arms
346
#' @param accrual_obj Object of class `accrual`
347
#' @param struct_obj Object of class `trial_structure`
348
#' 
349
#' @return Modified accrual object with capped week's accrual
350
#' 
351
#' @importFrom rlang abort
352
#' 
353
apply_arm_cap <- 
354
  S7::new_generic("apply_arm_cap", c("accrual_obj", "struct_obj"))
355
S7::method(apply_arm_cap, list(accrual, trial_structure)) <- 
356
  function(accrual_obj, struct_obj) {
357
    
358
    # Get totals for experimental arms (dropping control)
359
    arm_sums <- 
360
      treat_sums(accrual_obj)[seq_len(length(accrual_obj@phase_changes))]
361
362
    # Compare with cap
363
    arm_captotal <- arm_sums - accrual_obj@target_arm_size
364
365
    # Inactive arms can be at cap but not exceed it
366
    if (any(arm_captotal[-accrual_obj@active_arms] > 0)) {
367
      rlang::abort(paste(
368
        "Inactive arm exceeded cap:", 
369
        which(arm_captotal[-accrual_obj@active_arms] > 0)
370
      ))
371
    }
372
373
    ### Change references to active arms
374
375
    # Active arm indices which exceed cap
376
    active_tocap <- which(arm_captotal > 0)
377
378
    if (length(active_tocap) > 0) {
379
      for (arm in active_tocap) {
380
        # Which sites recruited to that arm this week?
381
        population <- as.integer(unlist(sapply(
382
          which(accrual_obj@accrual[accrual_obj@week, arm, ] > 0), 
383
          function(j) rep(j, accrual_obj@accrual[accrual_obj@week, arm, j])
384
        )))
385
386
        # Possible accruals to remove
387
        if (length(population) > 0) {
388
          capped <- do_choose_cap(population, arm_captotal[arm])
389
390
          # Remove capped instances
391
          for (i in capped) {
392
            accrual_obj@accrual[accrual_obj@week, arm, i] <- 
393
              as.integer(accrual_obj@accrual[accrual_obj@week, arm, i] - 1)
394
          }
395
        }
396
      }
397
    }
398
399
    # Record closing week for capped arms
400
    capped_arms <- arm_captotal[accrual_obj@active_arms] >= 0
401
402
    accrual_obj@phase_changes[accrual_obj@active_arms[capped_arms]] <- 
403
      accrual_obj@week
404
    
405
406
    # Update active_arms
407
    accrual_obj@active_arms <- which(arm_captotal < 0)
408
    # Also on the trial structure object
409
    struct_obj <- remove_treat_arms(struct_obj, which(arm_captotal >= 0))
410
411
    # Recheck site caps
412
    site_captotal <- site_sums(accrual_obj) - accrual_obj@site_cap
413
    
414
    # Record closing week for capped sites
415
    capped_sites <- site_captotal[accrual_obj@active_sites] >= 0
416
    accrual_obj@site_closures[accrual_obj@active_sites[capped_sites]] <-
417
      accrual_obj@week
418
419
    # Update active sites
420
    accrual_obj@active_sites <- which(site_captotal < 0)
421
422
    return(list(accrual_obj, struct_obj)) 
423
  }
424
425
426
427
428
#' Randomise a week's expected accrual amongst the sites, according to 
429
#' prevalence.
430
#' @param accrual_obj An object of class `accrual`.
431
#' @param struct_obj An object of class `trial_structure`.
432
#' @param fixed_site_rates TRUE if centre recruitment rates should 
433
#' be treated as exact; FALSE if they should be drawn from a gamma
434
#' distribution with a mean of the specified rate.
435
#' 
436
#' @return Matrix of week's accrual by site and recruitment arm.
437
#' 
438
week_accrue <- S7::new_generic("week_accrue", c("accrual_obj", "struct_obj"))
439
S7::method(week_accrue, list(accrual, trial_structure)) <- 
440
  function(accrual_obj, struct_obj, fixed_site_rates) {
441
442
    # Update the site rates
443
    accrual_obj <- set_site_rates(accrual_obj, fixed_site_rates)
444
445
    # Initialising (sites * experimental arms)
446
    week_mx <- matrix(
447
      as.integer(0), 
448
      nrow = dim(accrual_obj@accrual)[2], 
449
      ncol = dim(accrual_obj@accrual)[3]
450
    )
451
452
    week_acc <- as.integer(rep(0, dim(accrual_obj@accrual)[3]))
453
454
    # Accrual per site is poisson distributed
455
    week_acc[accrual_obj@active_sites] <- rpois(
456
      n = length(accrual_obj@active_sites), 
457
      lambda = accrual_obj@site_rate[accrual_obj@active_sites]
458
    )
459
460
    # Loop over recruiting sites
461
    for (isite in which(week_acc > 0)) {
462
463
      # Total probability for each experimental arm for the 
464
      # relevant site prevalence set
465
      probs <- colSums(struct_obj@experimental_arm_prevalence[
466
        , , accrual_obj@site_in_region[isite]
467
      ])
468
469
470
      # Sample experimental arms according to probabilities
471
      # Adding a dummy arm to take unassigned allocations due 
472
      # to arm closure, representing reduced site recruitment
473
      assigns <- sample(
474
        seq_len(length(probs) + 1),
475
        prob = c(probs, 1 - sum(probs)),
476
        size = week_acc[isite],
477
        replace = TRUE
478
      )
479
      
480
      # Total assignments to each open arm plus dummy arm
481
      assign_table <- table(assigns)
482
      indices <- as.numeric(names(assign_table))
483
    
484
      # Drop the dummy arm if anything was assigned to it
485
      if (max(indices) > length(probs)) {
486
        assign_table <- assign_table[-length(assign_table)]
487
        indices <- indices[-length(indices)]
488
      }
489
490
      # Increment week's assignment matrix
491
      week_mx[indices, isite] <- 
492
        week_mx[indices, isite] + as.vector(assign_table)
493
    }
494
495
    return(list(accrual_obj, week_mx))
496
  }
497
498
499
#' Generate accrual for a week and assign it to the 
500
#' accrual object. Increments the property "week",
501
#' which holds the next week number to accrue.
502
#' @param accrual_obj An object of class "accrual"
503
#' @param struct_obj An object of class "trial_structure"
504
#' @param fixed_site_rates TRUE if expected site rate to be used; FALSE 
505
#' draws the site rate from a gamma distribution
506
#' 
507
#' @return An object of class "accrual"
508
#'
509
accrue_week <- S7::new_generic("accrue_week", c("accrual_obj", "struct_obj"))
510
S7::method(accrue_week, list(accrual, trial_structure)) <- 
511
  function(accrual_obj, struct_obj, fixed_site_rates) {
512
513
    # Should not get here if there aren't any but
514
    if (length(accrual_obj@active_sites) > 0) {
515
516
      week_acc_ls <- week_accrue(accrual_obj, struct_obj, fixed_site_rates)
517
      accrual_obj <- week_acc_ls[[1]]
518
      week_acc <- week_acc_ls[[2]]
519
520
      # Assign the week's accrual to the object
521
      accrual_obj@accrual[accrual_obj@week, , ] <-
522
        week_acc
523
524
      # Apply site cap
525
      accrual_obj <- apply_site_cap(accrual_obj)
526
527
      # Apply arm cap, and then adjust site cap appropriately
528
      obj_list <- apply_arm_cap(accrual_obj, struct_obj)
529
      accrual_obj <- obj_list[[1]]
530
      struct_obj <- obj_list[[2]]
531
532
    }
533
534
    return(list(accrual_obj, struct_obj))
535
  }
536
537
538
539
#' Check whether an object is of class "accrual".
540
#' 
541
#' @param x Object to test
542
#' 
543
#' @return TRUE or FALSE
544
#' 
545
#' @importFrom S7 new_generic method class_any
546
#' 
547
#' @export
548
#' 
549
is.accrual <- S7::new_generic("is.accrual", "x")
550
S7::method(is.accrual, S7::class_any) <- function(x) {
551
  inherits(x, "biomkrAccrual::accrual")
552
}
553
554