| 1 |
#' Input types and model arguments of the pooling functions |
|
| 2 |
#' |
|
| 3 |
#' @description |
|
| 4 |
#' Shared documentation of \code{\link{km_pooling}} and \code{\link{cox_pooling}}.
|
|
| 5 |
#' Both functions fit one model per imputed dataset and then pool the results with Rubin's rules. |
|
| 6 |
#' This page describes which inputs they accept and which arguments are passed on to the |
|
| 7 |
#' per-dataset model. |
|
| 8 |
#' |
|
| 9 |
#' @name pooling_inputs |
|
| 10 |
#' @keywords internal |
|
| 11 |
#' @md |
|
| 12 |
#' |
|
| 13 |
#' @param x The imputed datasets to analyze. One of: |
|
| 14 |
#' - a list of data frames (one per imputed dataset), e.g., the output of |
|
| 15 |
#' \code{\link{raking_weights}} or of \code{MatchThem::complete(..., action = "all")}
|
|
| 16 |
#' - a weighted (\code{wimids}) or matched (\code{mimids}) object created with \pkg{MatchThem}
|
|
| 17 |
#' - an imputed (\code{mids}) object created with \pkg{mice}
|
|
| 18 |
#' |
|
| 19 |
#' See the section "Input types and default model arguments" for how each input is handled. |
|
| 20 |
#' @param ... Additional arguments passed on to the per-dataset model call |
|
| 21 |
#' (\code{\link[ggsurvfit]{survfit2}} for \code{km_pooling()}, \code{\link[survival]{coxph}} for
|
|
| 22 |
#' \code{cox_pooling()}). They are used in addition to, and take precedence over, the default
|
|
| 23 |
#' arguments of the input type (see the section "Input types and default model arguments"). |
|
| 24 |
#' Only \code{formula} and \code{data} are set internally and cannot be supplied here.
|
|
| 25 |
#' Column names are written without quotes and are looked up in each dataset, e.g. |
|
| 26 |
#' \code{weights = w} or \code{id = patientid}.
|
|
| 27 |
#' |
|
| 28 |
#' @section Input types and default model arguments: |
|
| 29 |
#' |
|
| 30 |
#' The datasets are first brought into the form of a list of data frames. Depending on the type of |
|
| 31 |
#' \code{x}, default model arguments are set:
|
|
| 32 |
#' |
|
| 33 |
#' | Input \code{x} | How the datasets are obtained | \code{weights} | \code{cluster} |
|
|
| 34 |
#' |:---|:---|:---|:---| |
|
| 35 |
#' | \code{wimids} | \code{MatchThem::complete(x, action = "all", all = FALSE, include = FALSE)} | \code{weights} | none |
|
|
| 36 |
#' | \code{mimids} | same as \code{wimids} | \code{weights} | \code{subclass} |
|
|
| 37 |
#' | \code{mids} | \code{mice::complete(x, action = "all", include = FALSE)} | none (unweighted) | none |
|
|
| 38 |
#' | list of data frames | used as supplied | none | none | |
|
| 39 |
#' |
|
| 40 |
#' **Lists of data frames are never interpreted automatically.** A list can come from many sources |
|
| 41 |
#' (weighting, matching, raking, your own code) so the function cannot know which columns are meant |
|
| 42 |
#' as weights or clusters. You state this explicitly through \code{...}, for example:
|
|
| 43 |
#' - weighted data: \code{weights = weights}
|
|
| 44 |
#' - matched data: \code{weights = weights, cluster = subclass}
|
|
| 45 |
#' - weights stored in a column with another name: \code{weights = w}
|
|
| 46 |
#' |
|
| 47 |
#' If the datasets contain a column named \code{weights} or \code{subclass} that is not used
|
|
| 48 |
#' because no \code{weights} or \code{cluster} (or \code{id}) argument was specified, a warning
|
|
| 49 |
#' is raised that names the argument to add. Specify \code{weights = NULL} or \code{cluster = NULL}
|
|
| 50 |
#' to confirm that the column should deliberately be ignored. |
|
| 51 |
#' |
|
| 52 |
#' \strong{Important}: when you build the list from a \code{mimids} or \code{wimids} object yourself,
|
|
| 53 |
#' use \code{MatchThem::complete(x, action = "all", all = FALSE, include = FALSE)}. With
|
|
| 54 |
#' \code{all = TRUE}, unmatched patients and patients with zero weight are included in the models.
|
|
| 55 |
#' |
|
| 56 |
#' @section Precedence of arguments in \code{...}:
|
|
| 57 |
#' |
|
| 58 |
#' Default arguments of the input type are applied first, then the arguments you supply via |
|
| 59 |
#' \code{...}. If both contain the same argument, \strong{your argument wins}. For example, for a
|
|
| 60 |
#' \code{mimids} object \code{cluster = NULL} fits the model without clustering on the matched sets
|
|
| 61 |
#' and \code{cluster = pair_id} clusters on another column. \code{formula} and \code{data} are the only
|
|
| 62 |
#' reserved arguments. |
|
| 63 |
#' |
|
| 64 |
#' \code{robust} is set automatically: unless you specify it, \code{robust = TRUE} is used whenever
|
|
| 65 |
#' weights, a cluster or an id are part of the model (the model-based variance is not valid in these |
|
| 66 |
#' cases), otherwise the default of the model function is kept. An explicitly supplied \code{robust}
|
|
| 67 |
#' always takes precedence. |
|
| 68 |
#' |
|
| 69 |
#' @section Transparency: |
|
| 70 |
#' |
|
| 71 |
#' Every call prints a message that lists how the datasets were obtained and exactly which |
|
| 72 |
#' arguments were used for the models, including where each argument comes from (default for the |
|
| 73 |
#' input type, derived, or specified by you). Use \code{suppressMessages()} to silence it.
|
|
| 74 |
NULL |
|
| 75 | ||
| 76 | ||
| 77 |
# prepare_pooling_input() ------------------------------------------------------- |
|
| 78 | ||
| 79 |
#' Turn the input of a pooling function into a list of datasets plus default arguments |
|
| 80 |
#' |
|
| 81 |
#' S3 generic with one small method per supported input type. Every method returns the same |
|
| 82 |
#' three elements so that the pooling functions never need to look at the class of \code{x} again:
|
|
| 83 |
#' \itemize{
|
|
| 84 |
#' \item \code{datasets}: plain list of data frames, one per imputed dataset
|
|
| 85 |
#' \item \code{args}: named list of default model arguments as unevaluated expressions
|
|
| 86 |
#' (e.g., \code{list(weights = quote(weights))}); column names stay symbols so that
|
|
| 87 |
#' they are looked up in each dataset when the model is fitted |
|
| 88 |
#' \item \code{note}: one-line description of the input that is shown to the user
|
|
| 89 |
#' } |
|
| 90 |
#' The methods have no side effects (no messages), which keeps them easy to test. |
|
| 91 |
#' |
|
| 92 |
#' @param x mimids, wimids or mids object, or a list of data frames |
|
| 93 |
#' |
|
| 94 |
#' @return list with elements \code{datasets}, \code{args} and \code{note}
|
|
| 95 |
#' |
|
| 96 |
#' @noRd |
|
| 97 |
prepare_pooling_input <- function(x) {
|
|
| 98 | 93x |
UseMethod("prepare_pooling_input")
|
| 99 |
} |
|
| 100 | ||
| 101 |
#' @noRd |
|
| 102 |
#' @exportS3Method |
|
| 103 |
prepare_pooling_input.wimids <- function(x) {
|
|
| 104 | 26x |
list( |
| 105 | 26x |
datasets = complete_matched_weighted(x), |
| 106 |
# MatchThem always names the weight column "weights" |
|
| 107 | 26x |
args = list(weights = quote(weights)), |
| 108 | 26x |
note = "<wimids>: weighted datasets extracted with MatchThem::complete(action = \"all\", all = FALSE, include = FALSE)" |
| 109 |
) |
|
| 110 |
} |
|
| 111 | ||
| 112 |
#' @noRd |
|
| 113 |
#' @exportS3Method |
|
| 114 |
prepare_pooling_input.mimids <- function(x) {
|
|
| 115 | 11x |
list( |
| 116 | 11x |
datasets = complete_matched_weighted(x), |
| 117 |
# MatchThem always names the weight column "weights" and the matched sets "subclass" |
|
| 118 | 11x |
args = list(weights = quote(weights), cluster = quote(subclass)), |
| 119 | 11x |
note = "<mimids>: matched datasets extracted with MatchThem::complete(action = \"all\", all = FALSE, include = FALSE)" |
| 120 |
) |
|
| 121 |
} |
|
| 122 | ||
| 123 |
#' @noRd |
|
| 124 |
#' @exportS3Method |
|
| 125 |
prepare_pooling_input.mids <- function(x) {
|
|
| 126 | 8x |
list( |
| 127 |
# there are no weights or matched sets, so every observation is used |
|
| 128 | 8x |
datasets = mice::complete(x, action = "all", include = FALSE), |
| 129 | 8x |
args = list(), |
| 130 | 8x |
note = "<mids>: imputed datasets extracted with mice::complete(action = \"all\", include = FALSE); unweighted" |
| 131 |
) |
|
| 132 |
} |
|
| 133 | ||
| 134 |
#' @noRd |
|
| 135 |
#' @exportS3Method |
|
| 136 |
prepare_pooling_input.list <- function(x) {
|
|
| 137 | 42x |
assertthat::assert_that( |
| 138 | 42x |
length(x) > 0 && all(vapply(x, is.data.frame, logical(1))), |
| 139 | 42x |
msg = "<x> needs to be a non-empty list of data frames" |
| 140 |
) |
|
| 141 | ||
| 142 | 37x |
list( |
| 143 | 37x |
datasets = x, |
| 144 |
# a list can come from anywhere (weighting, matching, raking, ...), so nothing |
|
| 145 |
# is assumed here; weights/cluster are specified by the user via `...` |
|
| 146 | 37x |
args = list(), |
| 147 | 37x |
note = "list of data frames supplied by the user; no weights or cluster applied automatically" |
| 148 |
) |
|
| 149 |
} |
|
| 150 | ||
| 151 |
#' @noRd |
|
| 152 |
#' @exportS3Method |
|
| 153 |
prepare_pooling_input.mild <- function(x) {
|
|
| 154 |
# the output of complete(action = "all") has class "mild" (not "list"), but is |
|
| 155 |
# a plain list of data frames, so it is handled like one |
|
| 156 | 28x |
prepare_pooling_input.list(unclass(x)) |
| 157 |
} |
|
| 158 | ||
| 159 |
#' @noRd |
|
| 160 |
#' @exportS3Method |
|
| 161 |
prepare_pooling_input.default <- function(x) {
|
|
| 162 |
# e.g., a single data frame or a vector |
|
| 163 | 6x |
stop( |
| 164 | 6x |
"<x> needs to be a mimids, wimids or mids object or a list of data frames", |
| 165 | 6x |
call. = FALSE |
| 166 |
) |
|
| 167 |
} |
|
| 168 | ||
| 169 |
#' Extract the matched/weighted imputed datasets of a mimids or wimids object |
|
| 170 |
#' |
|
| 171 |
#' @param x mimids or wimids object |
|
| 172 |
#' |
|
| 173 |
#' @return list of data frames, one per imputed dataset |
|
| 174 |
#' |
|
| 175 |
#' @noRd |
|
| 176 |
complete_matched_weighted <- function(x) {
|
|
| 177 |
# MatchThem objects need to contain the columns that the default arguments refer to |
|
| 178 | 37x |
assertthat::assert_that( |
| 179 | 37x |
"weights" %in% names(MatchThem::complete(x)), |
| 180 | 37x |
msg = "<x> needs to contain a weights column" |
| 181 |
) |
|
| 182 | 37x |
if (inherits(x, "mimids")) {
|
| 183 | 11x |
assertthat::assert_that( |
| 184 | 11x |
"subclass" %in% names(MatchThem::complete(x)), |
| 185 | 11x |
msg = "<x> needs to contain a subclass column" |
| 186 |
) |
|
| 187 |
} |
|
| 188 | ||
| 189 |
# action = "all" : produces a mild object (list) of the multiply imputed datasets |
|
| 190 |
# all = FALSE : do NOT include observations with a zero estimated weight |
|
| 191 |
# include = FALSE : do NOT include original data with the missing values |
|
| 192 | ||
| 193 |
# Important: the all = parameter needs to be set to FALSE |
|
| 194 |
# since otherwise unmatched patients or those with zero weight |
|
| 195 |
# will be included, too! |
|
| 196 | 37x |
MatchThem::complete( |
| 197 | 37x |
x, |
| 198 | 37x |
action = "all", |
| 199 | 37x |
all = FALSE, |
| 200 | 37x |
include = FALSE |
| 201 |
) |
|
| 202 |
} |
|
| 203 | ||
| 204 | ||
| 205 |
# fit_models() -------------------------------------------------------------------- |
|
| 206 | ||
| 207 |
#' Fit one model per imputed dataset |
|
| 208 |
#' |
|
| 209 |
#' Shared by \code{km_pooling()} and \code{cox_pooling()}. The steps are:
|
|
| 210 |
#' \enumerate{
|
|
| 211 |
#' \item merge default arguments of the input type with the arguments of the user |
|
| 212 |
#' \item derive \code{robust} if the user did not specify it
|
|
| 213 |
#' \item check the arguments against the data and warn about unused weights/subclass columns |
|
| 214 |
#' \item tell the user how the models are fitted |
|
| 215 |
#' \item fit the model in each dataset |
|
| 216 |
#' } |
|
| 217 |
#' |
|
| 218 |
#' @param input output of \code{prepare_pooling_input()}
|
|
| 219 |
#' @param fit_fun unevaluated name of the model function, e.g., \code{quote(survival::coxph)}
|
|
| 220 |
#' @param model_label model name shown to the user, e.g., "Cox proportional hazards model" |
|
| 221 |
#' @param formula model formula |
|
| 222 |
#' @param user_args named list of the unevaluated arguments the user supplied via \code{...},
|
|
| 223 |
#' captured with \code{as.list(substitute(list(...)))[-1]} in the calling function
|
|
| 224 |
#' |
|
| 225 |
#' @return list with the fitted models (\code{fits}, one per dataset) and the arguments used (\code{args})
|
|
| 226 |
#' |
|
| 227 |
#' @noRd |
|
| 228 |
fit_models <- function(input, fit_fun, model_label, formula, user_args) {
|
|
| 229 | 70x |
datasets <- input$datasets |
| 230 | 70x |
defaults <- input$args |
| 231 | ||
| 232 |
# 1. merge arguments ----------------------------------------------------------- |
|
| 233 |
# defaults first, then the user's arguments; if both contain the same |
|
| 234 |
# argument, the user's argument replaces the default |
|
| 235 | 70x |
args <- c(defaults[setdiff(names(defaults), names(user_args))], user_args) |
| 236 | ||
| 237 |
# 2. derive robust ------------------------------------------------------------- |
|
| 238 |
# a robust (sandwich) variance is required whenever weights, clusters or ids are used. |
|
| 239 |
# `is.null()` is used because weights = NULL / cluster = NULL mean "not used" |
|
| 240 | 70x |
is_used <- function(arg) !is.null(args[[arg]]) |
| 241 | 70x |
derived_robust <- !("robust" %in% names(args)) &&
|
| 242 | 70x |
any(vapply(c("weights", "cluster", "id"), is_used, logical(1)))
|
| 243 | 70x |
if (derived_robust) {
|
| 244 | 56x |
args$robust <- TRUE |
| 245 |
} |
|
| 246 | ||
| 247 |
# 3. checks -------------------------------------------------------------------- |
|
| 248 | 70x |
check_columns_exist(args, datasets) |
| 249 | 68x |
warn_unused_columns(args, datasets) |
| 250 | ||
| 251 |
# 4. tell the user ------------------------------------------------------------ |
|
| 252 | 68x |
report_fit_setup( |
| 253 | 68x |
input = input, |
| 254 | 68x |
model_label = model_label, |
| 255 | 68x |
fit_fun = fit_fun, |
| 256 | 68x |
formula = formula, |
| 257 | 68x |
args = args, |
| 258 | 68x |
user_args = user_args, |
| 259 | 68x |
derived_robust = derived_robust |
| 260 |
) |
|
| 261 | ||
| 262 |
# 5. fit the model in each dataset ------------------------------------------- |
|
| 263 |
# arguments that are NULL (e.g., cluster = NULL) are dropped, they mean "not used" |
|
| 264 | 68x |
args <- args[!vapply(args, is.null, logical(1))] |
| 265 | ||
| 266 |
# The call is built with as.call()/eval() rather than written literally with a |
|
| 267 |
# trailing `...`. ggsurvfit::survfit2() reconstructs its call internally |
|
| 268 |
# (match.call(expand.dots = TRUE)) to dispatch to survival::survfit(); if `...` is |
|
| 269 |
# spliced in literally, that reconstruction degrades NSE arguments like `id` into |
|
| 270 |
# positional `..1`-style placeholders that no longer resolve once the model tries to |
|
| 271 |
# evaluate them as columns of `data = d`. Building the call explicitly keeps |
|
| 272 |
# `formula` and `data` as plain symbols and the remaining arguments (e.g., |
|
| 273 |
# weights = weights) as unevaluated symbols, which the model function then resolves |
|
| 274 |
# against `data = d`, exactly like a literal call would. |
|
| 275 | 68x |
fit_one <- function(d) {
|
| 276 | 328x |
call_expr <- as.call(c( |
| 277 | 328x |
fit_fun, |
| 278 | 328x |
list(formula = quote(formula), data = quote(d)), |
| 279 | 328x |
args |
| 280 |
)) |
|
| 281 | 328x |
eval(call_expr, envir = environment()) |
| 282 |
} |
|
| 283 | ||
| 284 | 68x |
list(fits = lapply(datasets, FUN = fit_one), args = args) |
| 285 |
} |
|
| 286 | ||
| 287 |
#' Check that column names supplied as weights/cluster/id exist in the datasets |
|
| 288 |
#' |
|
| 289 |
#' @param args merged model arguments |
|
| 290 |
#' @param datasets list of data frames |
|
| 291 |
#' |
|
| 292 |
#' @noRd |
|
| 293 |
check_columns_exist <- function(args, datasets) {
|
|
| 294 | 70x |
for (arg in intersect(c("weights", "cluster", "id"), names(args))) {
|
| 295 |
# only bare column names (symbols) can be checked; NULL or expressions are left alone |
|
| 296 | 80x |
if (is.symbol(args[[arg]])) {
|
| 297 | 72x |
column <- as.character(args[[arg]]) |
| 298 | 72x |
missing_in <- !vapply(datasets, function(d) column %in% names(d), logical(1)) |
| 299 | 72x |
if (any(missing_in)) {
|
| 300 | 2x |
cli::cli_abort(c( |
| 301 | 2x |
"Column {.field {column}} specified via {.arg {arg}} not found in the datasets of {.arg x}.",
|
| 302 | 2x |
"i" = "Specify the name of an existing column, e.g. {.code {arg} = <column>}."
|
| 303 |
)) |
|
| 304 |
} |
|
| 305 |
} |
|
| 306 |
} |
|
| 307 |
} |
|
| 308 | ||
| 309 |
#' Warn if a weights or subclass column exists but no matching argument was specified |
|
| 310 |
#' |
|
| 311 |
#' @param args merged model arguments |
|
| 312 |
#' @param datasets list of data frames |
|
| 313 |
#' |
|
| 314 |
#' @noRd |
|
| 315 |
warn_unused_columns <- function(args, datasets) {
|
|
| 316 |
# `%in% names(args)` (rather than is.null) so that weights = NULL / cluster = NULL |
|
| 317 |
# count as a deliberate choice and silence the warning |
|
| 318 | 68x |
columns <- names(datasets[[1]]) |
| 319 | 68x |
fixes <- character() |
| 320 | ||
| 321 | 68x |
if ("weights" %in% columns && !"weights" %in% names(args)) {
|
| 322 | 2x |
fixes <- c(fixes, "weights" = "weights = weights") |
| 323 |
} |
|
| 324 | 68x |
if ("subclass" %in% columns && !any(c("cluster", "id") %in% names(args))) {
|
| 325 | 2x |
fixes <- c(fixes, "subclass" = "cluster = subclass") |
| 326 |
} |
|
| 327 | ||
| 328 | 68x |
if (length(fixes) > 0) {
|
| 329 | 2x |
unused <- names(fixes) |
| 330 | 2x |
use_it <- paste(fixes, collapse = ", ") |
| 331 | 2x |
ignore_it <- paste(sub("=.*", "= NULL", fixes), collapse = ", ")
|
| 332 | 2x |
cli::cli_warn(c( |
| 333 | 2x |
"!" = "Column{?s} {.field {unused}} found in {.arg x} but not used in the models.",
|
| 334 | 2x |
"i" = "To use the column(s), specify {.code {use_it}}.",
|
| 335 | 2x |
"i" = "To ignore the column(s) on purpose, specify {.code {ignore_it}}."
|
| 336 |
)) |
|
| 337 |
} |
|
| 338 |
} |
|
| 339 | ||
| 340 |
#' Tell the user how the models are fitted |
|
| 341 |
#' |
|
| 342 |
#' @inheritParams fit_models |
|
| 343 |
#' @param args merged model arguments (before NULL arguments are dropped) |
|
| 344 |
#' @param derived_robust whether \code{robust} was set automatically
|
|
| 345 |
#' |
|
| 346 |
#' @noRd |
|
| 347 |
report_fit_setup <- function(input, model_label, fit_fun, formula, args, user_args, derived_robust) {
|
|
| 348 |
# curly braces have a meaning in cli messages, so escape them in text we insert |
|
| 349 | 68x |
escape <- function(text) gsub("\\}", "}}", gsub("\\{", "{{", text))
|
| 350 | ||
| 351 |
# where does each argument come from? |
|
| 352 | 68x |
source_label <- function(arg) {
|
| 353 | 139x |
if (arg == "robust" && derived_robust) {
|
| 354 | 54x |
"derived: weights, cluster or id are used" |
| 355 | 85x |
} else if (arg %in% names(user_args) && arg %in% names(input$args)) {
|
| 356 | 5x |
paste0("specified by you, overrides default ", arg, " = ", deparse(input$args[[arg]]))
|
| 357 | 80x |
} else if (arg %in% names(user_args)) {
|
| 358 | 46x |
"specified by you" |
| 359 |
} else {
|
|
| 360 | 34x |
"default for the input type" |
| 361 |
} |
|
| 362 |
} |
|
| 363 | ||
| 364 | 68x |
if (length(args) == 0) {
|
| 365 | 10x |
arg_lines <- "no weights, cluster or other arguments used" |
| 366 |
} else {
|
|
| 367 | 58x |
arg_lines <- vapply( |
| 368 | 58x |
names(args), |
| 369 | 58x |
function(arg) {
|
| 370 | 139x |
paste0(arg, " = ", paste(deparse(args[[arg]]), collapse = ""), " (", source_label(arg), ")")
|
| 371 |
}, |
|
| 372 | 58x |
character(1) |
| 373 |
) |
|
| 374 |
} |
|
| 375 | ||
| 376 | 68x |
m <- length(input$datasets) |
| 377 | 68x |
cli::cli_inform(c( |
| 378 | 68x |
"i" = escape(paste0("Input: ", input$note, " (m = ", m, ")")),
|
| 379 | 68x |
"i" = escape(paste0("Fitting ", model_label, " with ", deparse(fit_fun), "() in each dataset:")),
|
| 380 | 68x |
" " = escape(paste0("formula: ", paste(deparse(formula), collapse = " "))),
|
| 381 | 68x |
stats::setNames(escape(arg_lines), rep(" ", length(arg_lines)))
|
| 382 |
)) |
|
| 383 |
} |
| 1 |
#' Pooled Kaplan-Meier estimate and survival curve |
|
| 2 |
#' |
|
| 3 |
#' @description |
|
| 4 |
#' Computes pooled median survival Kaplan-Meier estimates using Rubin's rule |
|
| 5 |
#' and outputs corresponding Kaplan-Meier curve across imputed and |
|
| 6 |
#' matched/weighted datasets |
|
| 7 |
#' |
|
| 8 |
#' @details |
|
| 9 |
#' The function takes the imputed datasets (\code{x}) as a list of data frames, or as a
|
|
| 10 |
#' mimids, wimids or mids object (see the sections "Input types and default model arguments" and |
|
| 11 |
#' "Precedence of arguments in ..." below). The latter are the output of a workflow that requires |
|
| 12 |
#' imputing multiple (m) datasets using mice or amelia and matching or weighting each imputed dataset |
|
| 13 |
#' via the MatchThem package (see examples). Weights and clusters of lists of data frames are specified |
|
| 14 |
#' explicitly via \code{...}, e.g., \code{weights = weights, cluster = subclass}.
|
|
| 15 |
#' |
|
| 16 |
#' The function fits the pre-specified survfit model (\code{surv_formula}, \code{\link[survival]{survfit}} package)
|
|
| 17 |
#' to compute survival probabilities at each individual time point according to the Kaplan-Meier method. |
|
| 18 |
#' Survival probabilities can be estimated at all observed event times (default) or at user-specified time points |
|
| 19 |
#' using the \code{times} argument, which is passed directly to \code{\link[ggsurvfit]{tidy_survfit}}.
|
|
| 20 |
#' Arguments of the \code{\link[survival]{survfit}} call can be supplied via \code{...},
|
|
| 21 |
#' e.g. \code{type = "fleming-harrington"}. For matched and weighted datasets (mimids and wimids objects),
|
|
| 22 |
#' weights, cluster membership (matching only) and robust variance estimates are considered |
|
| 23 |
#' by default. \code{...} only affects the underlying Kaplan-Meier fit of each imputed dataset;
|
|
| 24 |
#' the complementary log-log transformation, Rubin's rule pooling and confidence interval |
|
| 25 |
#' computation are unaffected. |
|
| 26 |
#' |
|
| 27 |
#' Since survival probabilities typically don't follow normal distributions, |
|
| 28 |
#' these need to be transformed to approximate normality first before pooling |
|
| 29 |
#' across imputed datasets and time points. To that end, the method of Morisot et al. (2015) is |
|
| 30 |
#' implemented, who applied Rubin's rules after a complementary log-log transformation of the |
|
| 31 |
#' survival probabilities, following the recommendation of Marshall et al. (2009) to combine |
|
| 32 |
#' predicted survival probabilities after this transformation. In each imputed dataset |
|
| 33 |
#' \emph{i} and at each time point \emph{t}, the survival probability \eqn{S_i(t)} is transformed to
|
|
| 34 |
#' \deqn{Q_i(t) = \log(-\log(1 - S_i(t)))}
|
|
| 35 |
#' and the corresponding within-dataset variance is derived from the variance of the survival |
|
| 36 |
#' probability (the squared standard error reported by \code{\link[survival]{survfit}}) with the delta method:
|
|
| 37 |
#' \deqn{U_i(t) = \frac{Var[S_i(t)]}{\left[(1 - S_i(t)) \log(1 - S_i(t))\right]^2}}
|
|
| 38 |
#' |
|
| 39 |
#' Note that the complementary log-log transformation is applied to the survival probability |
|
| 40 |
#' \eqn{S} itself, as in Morisot et al. (2015). This is not the same transformation as the
|
|
| 41 |
#' \eqn{\log(-\log(S))} used for the confidence intervals of \code{\link[survival]{survfit}}
|
|
| 42 |
#' with \code{conf.type = "log-log"}. For a single dataset, the point estimates and standard errors
|
|
| 43 |
#' therefore agree with \code{\link[survival]{survfit}}, but the confidence intervals differ slightly.
|
|
| 44 |
#' |
|
| 45 |
#' To pool the transformed estimates across imputed datasets and time points, the \code{\link[mice]{pool.scalar}}
|
|
| 46 |
#' function is used to apply Rubin's rule to combine pooled estimates (qbar) according to |
|
| 47 |
#' formula (3.1.2) Rubin (1987) and to compute the corresponding total variance (t) of the pooled |
|
| 48 |
#' estimate according to formula (3.1.5) Rubin (1987), i.e., |
|
| 49 |
#' \eqn{T = \bar{U} + (1 + 1/m) B} with the average within-dataset variance \eqn{\bar{U}} and
|
|
| 50 |
#' the between-dataset variance \eqn{B} (as in Morisot et al. (2015)). If only a single dataset is supplied (m = 1, e.g., complete
|
|
| 51 |
#' data without imputation), the between-imputation variance is 0 by definition, the total variance equals the |
|
| 52 |
#' within-dataset variance and a warning is raised. When \code{times = NULL}, the pooling is performed
|
|
| 53 |
#' at each unique combination of event times across all imputed datasets. When specific time points are provided |
|
| 54 |
#' via the \code{times} argument, pooling occurs only at those pre-specified time points, ensuring consistency
|
|
| 55 |
#' across datasets and facilitating comparison at clinically relevant follow-up intervals. |
|
| 56 |
#' |
|
| 57 |
#' The pooled survival probabilities are then back-transformed via \code{1-exp(-exp(qbar))}
|
|
| 58 |
#' for pooled survival probability estimates and \code{1-exp(-exp(qbar +/- 1.96*sqrt(t)))}
|
|
| 59 |
#' for lower and upper 95% confidence intervals. As the formula indicates, the pooled standard |
|
| 60 |
#' error is computed as the square root of the total variance. The vertically stacked table |
|
| 61 |
#' with transformed and backtransformed estimates is returned with the |
|
| 62 |
#' \code{km_survival_table} table.
|
|
| 63 |
#' |
|
| 64 |
#' Special scenarios during the transformation, pooling and backtransformation are |
|
| 65 |
#' considered. By convention, Kaplan-Meier survival curves start at time = 0 with survival probability = 1 |
|
| 66 |
#' unless a delayed entry survival model is fitted (not supported yet). In the case that there is no |
|
| 67 |
#' observation in the data at at time = 0 with survival probability = 1, the \code{\link[ggsurvfit]{tidy_survfit}}
|
|
| 68 |
#' extrapolates this across strata. For the complementary log-log transformation this means, that survival probabilities = 1 |
|
| 69 |
#' at time = 0 and corresponding confidence intervals are extrapolated to 1. If the survival proability reaches exactly 0, |
|
| 70 |
#' the survival probability and corresponding confidence intervals are set to 0 as well. |
|
| 71 |
#' |
|
| 72 |
#' Finally, the median survival time is extracted from the \code{km_survival_table} table
|
|
| 73 |
#' by determining the time the survival probability drops below .5 for the first time. |
|
| 74 |
#' For this a sub-function of Terry M. Therneau's \code{\link[survival]{print.survfit}} function
|
|
| 75 |
#' is used. Therneau also considers some edge cases/nuisances (x = time, y = surv): |
|
| 76 |
#' - Nuisance 1: if one of the y's is exactly .5, we want the mean of the corresponding x and the first x for which y<.5. We need to use the equivalent of all.equal to check for a .5 however: survfit(Surv(1:100)~1) gives a value of .5 + 1.1e-16 due to roundoff error. |
|
| 77 |
#' - Nuisance 2: there may by an NA in the y's |
|
| 78 |
#' - Nuisance 3: if no y's are <=.5, then we should return NA |
|
| 79 |
#' - Nuisance 4: the obs (or many) after the .5 may be censored, giving a stretch of values = .5 +- epsilon |
|
| 80 |
#' |
|
| 81 |
#' \strong{Important Gotcha}: When using the \code{times} argument to specify custom time points, ensure that
|
|
| 82 |
#' sufficient time points are requested to capture the range of interest. If too few time points are specified, |
|
| 83 |
#' the \code{km_median_survival} estimates and \code{km_plot} may not represent the true survival trajectory.
|
|
| 84 |
#' Specifically, if the median survival time lies between two requested time points or if requested time points |
|
| 85 |
#' do not adequately cover the follow-up period, the median survival estimates will be inaccurate or return \code{NA}.
|
|
| 86 |
#' Similarly, the Kaplan-Meier curve will appear sparse or disconnected. For most applications, using the default |
|
| 87 |
#' \code{times = NULL} is recommended to automatically use all observed event times. If using custom time points,
|
|
| 88 |
#' ensure they are sufficiently granular to capture the survival dynamics of interest. |
|
| 89 |
#' |
|
| 90 |
#' The function follows the following logic: |
|
| 91 |
#' 1. Fit Kaplan-Meier survival function to each imputed and matched/weighted dataset |
|
| 92 |
#' 2. Transform survival probabilities using complementary log-log transformation |
|
| 93 |
#' 3. Pool transformed survival probabilities and compute total variance using Rubin's rule |
|
| 94 |
#' 4. Back-transform pooled survival probabilities and compute 95% confidence intervals |
|
| 95 |
#' 5. Extract median survival time and corresponding 95% confidence intervals |
|
| 96 |
#' 6. Plot Kaplan-Meier curve with pooled survival probabilities and confidence intervals |
|
| 97 |
#' |
|
| 98 |
#' More references: |
|
| 99 |
#' - https://stefvanbuuren.name/fimd/sec-pooling.html |
|
| 100 |
#' |
|
| 101 |
#' @references |
|
| 102 |
#' Morisot A, Bessaoud F, Landais P, et al. Prostate cancer: net survival and cause-specific survival |
|
| 103 |
#' rates after multiple imputation. \emph{BMC Med Res Methodol}. 2015;15:54.
|
|
| 104 |
#' \doi{10.1186/s12874-015-0048-4}
|
|
| 105 |
#' |
|
| 106 |
#' Marshall A, Altman DG, Holder RL, Royston P. Combining estimates of interest in prognostic |
|
| 107 |
#' modelling studies after multiple imputation: current practice and guidelines. |
|
| 108 |
#' \emph{BMC Med Res Methodol}. 2009;9:57. \doi{10.1186/1471-2288-9-57}
|
|
| 109 |
#' |
|
| 110 |
#' Rubin DB. \emph{Multiple Imputation for Nonresponse in Surveys}. Wiley; 1987.
|
|
| 111 |
#' |
|
| 112 |
#' @seealso |
|
| 113 |
#' \code{\link[survival]{survfit}} \code{\link[mice]{pool.scalar}} \code{\link[MatchThem]{matchthem}} \code{\link[MatchThem]{weightthem}} \code{\link[ggsurvfit]{survfit2}} \code{\link[ggsurvfit]{tidy_survfit}}
|
|
| 114 |
#' |
|
| 115 |
#' @inheritParams pooling_inputs |
|
| 116 |
#' @param surv_formula specification of survival model formula to be fitted |
|
| 117 |
#' @param times numeric vector of follow-up time points at which survival probabilities are to be estimated (propagated to \code{\link[ggsurvfit]{tidy_survfit}}).
|
|
| 118 |
#' If NULL (default), survival probabilities are estimated and pooled across all observed event times. |
|
| 119 |
#' |
|
| 120 |
#' @inheritSection pooling_inputs Input types and default model arguments |
|
| 121 |
#' @inheritSection pooling_inputs Precedence of arguments in \code{...}
|
|
| 122 |
#' @inheritSection pooling_inputs Transparency |
|
| 123 |
#' |
|
| 124 |
#' @return list with pooled median survival estimate and pooled Kaplan-Meier curve |
|
| 125 |
#' \code{km_median_survival}:
|
|
| 126 |
#' - strata = stratum |
|
| 127 |
#' - t_median = median survival time |
|
| 128 |
#' - t_lower = lower 95% CI of median survival time |
|
| 129 |
#' - t_upper = upper 95% CI of median survival time |
|
| 130 |
#' |
|
| 131 |
#' \code{km_survival_table}:
|
|
| 132 |
#' - strata = stratum |
|
| 133 |
#' - time = observed time point |
|
| 134 |
#' - m = number of imputed datasets |
|
| 135 |
#' - qbar = pooled univariate estimate of the complementary log-log transformed survival probabilities \eqn{\log(-\log(1 - S))}, see formula (3.1.2) Rubin (1987)
|
|
| 136 |
#' - t = total variance of the pooled univariate estimate of the complementary log-log transformed survival probabilities, formula (3.1.5) Rubin (1987) |
|
| 137 |
#' - se = total standard error of the pooled estimate (derived as sqrt(t)) |
|
| 138 |
#' - surv = back-transformed pooled survival probability |
|
| 139 |
#' - lower = Wald-type lower 95% confidence interval of back-transformed pooled survival probability |
|
| 140 |
#' - upper = Wald-type upper 95% confidence interval of back-transformed pooled survival probability |
|
| 141 |
#' |
|
| 142 |
#' \code{km_plot}: ggplot2 object with Kaplan-Meier curve
|
|
| 143 |
#' |
|
| 144 |
#' @md |
|
| 145 |
#' |
|
| 146 |
#' @export |
|
| 147 |
#' |
|
| 148 |
#' @examples |
|
| 149 |
#' library(encore.analytics) |
|
| 150 |
#' library(mice) |
|
| 151 |
#' library(MatchThem) |
|
| 152 |
#' |
|
| 153 |
#' # simulate a cohort with 500 patients with 20% missing data |
|
| 154 |
#' data <- simulate_data( |
|
| 155 |
#' n = 500, |
|
| 156 |
#' imposeNA = TRUE, |
|
| 157 |
#' propNA = 0.2 |
|
| 158 |
#' ) |
|
| 159 |
#' |
|
| 160 |
#' # impute the data |
|
| 161 |
#' set.seed(42) |
|
| 162 |
#' mids <- mice(data, m = 5, print = FALSE) |
|
| 163 |
#' |
|
| 164 |
#' # fit a propensity score model |
|
| 165 |
#' fit <- as.formula(treat ~ dem_age_index_cont + dem_sex_cont + c_smoking_history) |
|
| 166 |
#' |
|
| 167 |
#' # weight (or alternatively match) patients within each imputed dataset |
|
| 168 |
#' wimids <- weightthem( |
|
| 169 |
#' formula = fit, |
|
| 170 |
#' datasets = mids, |
|
| 171 |
#' approach = "within", |
|
| 172 |
#' method = "glm", |
|
| 173 |
#' estimand = "ATO" |
|
| 174 |
#' ) |
|
| 175 |
#' |
|
| 176 |
#' # fit a survival model |
|
| 177 |
#' km_fit <- as.formula(survival::Surv(fu_itt_months, death_itt) ~ treat) |
|
| 178 |
#' |
|
| 179 |
#' # 1) weighted (wimids) or matched (mimids) object: |
|
| 180 |
#' # weights (and clusters) are set automatically |
|
| 181 |
#' km_out <- km_pooling( |
|
| 182 |
#' x = wimids, |
|
| 183 |
#' surv_formula = km_fit |
|
| 184 |
#' ) |
|
| 185 |
#' |
|
| 186 |
#' # median survival time |
|
| 187 |
#' km_out$km_median_survival |
|
| 188 |
#' |
|
| 189 |
#' # KM curve |
|
| 190 |
#' km_out$km_plot |
|
| 191 |
#' |
|
| 192 |
#' # 2) list of data frames: weights are specified explicitly via `...` |
|
| 193 |
#' # (all = FALSE excludes observations with zero weight) |
|
| 194 |
#' datasets <- MatchThem::complete(wimids, action = "all", all = FALSE, include = FALSE) |
|
| 195 |
#' km_out_list <- km_pooling( |
|
| 196 |
#' x = datasets, |
|
| 197 |
#' surv_formula = km_fit, |
|
| 198 |
#' weights = weights |
|
| 199 |
#' ) |
|
| 200 |
#' |
|
| 201 |
#' # for matched datasets, additionally specify the matched sets as clusters: |
|
| 202 |
#' # km_pooling(x = datasets, surv_formula = km_fit, weights = weights, cluster = subclass) |
|
| 203 |
#' |
|
| 204 |
#' # weights stored in a column with another name, e.g., `w` |
|
| 205 |
#' datasets_w <- lapply(datasets, function(d) dplyr::rename(d, w = weights)) |
|
| 206 |
#' km_out_w <- km_pooling( |
|
| 207 |
#' x = datasets_w, |
|
| 208 |
#' surv_formula = km_fit, |
|
| 209 |
#' weights = w |
|
| 210 |
#' ) |
|
| 211 |
#' |
|
| 212 |
#' # 3) imputed (mids) object: unweighted Kaplan-Meier curve |
|
| 213 |
#' km_out_mids <- km_pooling( |
|
| 214 |
#' x = mids, |
|
| 215 |
#' surv_formula = km_fit |
|
| 216 |
#' ) |
|
| 217 |
#' |
|
| 218 |
#' # 4) pass additional arguments to the underlying survfit2() call: |
|
| 219 |
#' # column names are resolved in each imputed dataset, e.g. `id = patientid`. |
|
| 220 |
#' # survfit() uses `id` as the subject identifier for its robust/sandwich |
|
| 221 |
#' # variance calculation, clustering rows by patient instead of treating |
|
| 222 |
#' # each row as its own independent cluster (relevant if a patient can |
|
| 223 |
#' # contribute more than one row, e.g. with time-varying covariates). |
|
| 224 |
#' km_out_id <- km_pooling( |
|
| 225 |
#' x = wimids, |
|
| 226 |
#' surv_formula = km_fit, |
|
| 227 |
#' id = patientid |
|
| 228 |
#' ) |
|
| 229 |
#' |
|
| 230 |
km_pooling <- function( |
|
| 231 |
x = NULL, |
|
| 232 |
surv_formula = stats::as.formula( |
|
| 233 |
survival::Surv(fu_itt_months, death_itt) ~ treat |
|
| 234 |
), |
|
| 235 |
times = NULL, |
|
| 236 |
... |
|
| 237 |
) {
|
|
| 238 |
# input checks ------------------------------------------------------------ |
|
| 239 | ||
| 240 |
# check if surv_formula is a formula |
|
| 241 | 48x |
assertthat::assert_that( |
| 242 | 48x |
inherits(surv_formula, "formula"), |
| 243 | 48x |
msg = "<surv_formula> needs to be a formula" |
| 244 |
) |
|
| 245 | ||
| 246 |
# bring the input into the form of a list of datasets plus the default |
|
| 247 |
# model arguments of the input type (weights, cluster, ...) |
|
| 248 | 47x |
input <- prepare_pooling_input(x) |
| 249 | ||
| 250 | 43x |
if (!is.null(times)) {
|
| 251 | 5x |
assertthat::assert_that( |
| 252 | 5x |
is.numeric(times), |
| 253 | 5x |
msg = "<times> needs to be a numeric vector" |
| 254 |
) |
|
| 255 |
} |
|
| 256 | ||
| 257 |
# check that ... does not try to override arguments set internally |
|
| 258 |
# (...names() reads argument names without forcing/evaluating them, since |
|
| 259 |
# some, like `id`, are intended to be resolved as columns of the imputed |
|
| 260 |
# data further down rather than as objects that exist here) |
|
| 261 | 42x |
reserved_dots <- intersect( |
| 262 | 42x |
...names(), |
| 263 | 42x |
c("formula", "data")
|
| 264 |
) |
|
| 265 | 42x |
assertthat::assert_that( |
| 266 | 42x |
length(reserved_dots) == 0, |
| 267 | 42x |
msg = paste0( |
| 268 | 42x |
"arguments passed via `...` must not include arguments that are set internally by km_pooling(): ", |
| 269 | 42x |
paste(reserved_dots, collapse = ", ") |
| 270 |
) |
|
| 271 |
) |
|
| 272 | ||
| 273 |
# capture the *unevaluated* expressions behind `...` (e.g. list(id = patientid) |
|
| 274 |
# becomes list(id = quote(patientid))). This must not be `list(...)`: some |
|
| 275 |
# arguments (like `id`) are column names meant to be resolved against |
|
| 276 |
# `data = d` inside survfit(), not objects that exist in this environment, |
|
| 277 |
# and `list(...)` would force and fail on them immediately. |
|
| 278 | 40x |
user_args <- as.list(substitute(list(...)))[-1] |
| 279 | ||
| 280 |
# Fit Kaplan Meier -------------------------------------------------------- |
|
| 281 | ||
| 282 |
# fit a survival function in each imputed (and weighted/matched) dataset; |
|
| 283 |
# merging of default and user arguments, checks, the console message and the |
|
| 284 |
# construction of the survfit2() call are handled in fit_models() |
|
| 285 | 40x |
survival_fit_list <- fit_models( |
| 286 | 40x |
input = input, |
| 287 | 40x |
fit_fun = quote(ggsurvfit::survfit2), |
| 288 | 40x |
model_label = "Kaplan-Meier survival function", |
| 289 | 40x |
formula = surv_formula, |
| 290 | 40x |
user_args = user_args |
| 291 | 40x |
)$fits |
| 292 | ||
| 293 |
# with a single dataset (e.g., complete data without imputation) there is no |
|
| 294 |
# between-imputation variance; the total variance is then the within-dataset |
|
| 295 |
# variance (see pooling below), which we communicate to the user |
|
| 296 | 39x |
single_dataset <- length(survival_fit_list) == 1 |
| 297 | 39x |
if (single_dataset) {
|
| 298 | 3x |
cli::cli_warn(c( |
| 299 | 3x |
"!" = "{.arg x} contains only one dataset (m = 1).",
|
| 300 | 3x |
"i" = "Rubin's rule needs at least two datasets to estimate the between-imputation variance.", |
| 301 | 3x |
"i" = "The confidence intervals reflect the within-dataset variance only and do not account for imputation uncertainty." |
| 302 |
)) |
|
| 303 |
} |
|
| 304 | ||
| 305 |
# pool survival probabilities --------------------------------------------- |
|
| 306 | ||
| 307 |
# now to combine/pool quantities with non-normal distributions such as |
|
| 308 |
# survival probabilities, we apply Rubin's rules after a complementary log-log |
|
| 309 |
# transformation of the survival probability S, following |
|
| 310 |
# Morisot A, Bessaoud F, Landais P, et al. BMC Med Res Methodol 2015;15:54 |
|
| 311 |
# https://doi.org/10.1186/s12874-015-0048-4 |
|
| 312 |
# who build on the recommendation of |
|
| 313 |
# Marshall A, Altman DG, Holder RL, Royston P. BMC Med Res Methodol 2009;9:57 |
|
| 314 |
# https://doi.org/10.1186/1471-2288-9-57 |
|
| 315 |
# see also https://stefvanbuuren.name/fimd/sec-pooling.html |
|
| 316 |
# |
|
| 317 |
# Per dataset i and time t (Morisot et al., 2015): |
|
| 318 |
# Q_i = log(-log(1 - S_i)) -> q below |
|
| 319 |
# U_i = Var[S_i] / [(1 - S_i) * log(1 - S_i)]^2 (delta method) -> u below |
|
| 320 |
# Note: the transformation is applied to S itself, which is not the |
|
| 321 |
# log(-log(S)) used by survival::survfit(conf.type = "log-log"). |
|
| 322 | ||
| 323 |
# that is, the function cloglog performs the |
|
| 324 |
# transformations on all survival probabilities |
|
| 325 |
# stored in survival_fit_list |
|
| 326 | 39x |
cloglog <- function(i) {
|
| 327 |
# tidy up |
|
| 328 | 183x |
survival_times_df <- ggsurvfit::tidy_survfit( |
| 329 | 183x |
x = i, |
| 330 | 183x |
times = times, |
| 331 | 183x |
type = "survival" |
| 332 |
) |> |
|
| 333 | 183x |
dplyr::mutate( |
| 334 |
# compute transformed qbar |
|
| 335 | 183x |
q = log(-log(1 - estimate)), # will be Inf for estimate = 1 |
| 336 |
# compute transformed U |
|
| 337 | 183x |
u = std.error^2 / ((1 - estimate) * log(1 - estimate))^2 |
| 338 |
) |
|
| 339 | ||
| 340 | 183x |
return(survival_times_df) |
| 341 |
} |
|
| 342 | ||
| 343 |
# we apply this function to all fitted survival curves |
|
| 344 | 39x |
km_survival <- lapply(survival_fit_list, FUN = cloglog) |> |
| 345 | ||
| 346 |
# the resulting list with q and u per stratum and dataset |
|
| 347 |
# is then vertically stacked to a long data frame (using bind_rows). |
|
| 348 | 39x |
dplyr::bind_rows() |> |
| 349 | ||
| 350 |
# group by the exposure levels (stratum) and apply the pool.scalar function |
|
| 351 |
# which pools univariate estimates of m datasets. This returns: |
|
| 352 |
# m = The number of imputations |
|
| 353 |
# qbar = The pooled univariate estimate, formula (3.1.2) Rubin (1987) |
|
| 354 |
# ubar = The average within-imputation variance, formula (3.1.3) Rubin (1987) |
|
| 355 |
# t = The total variance of the pooled estimated, formula (3.1.5) Rubin (1987). |
|
| 356 | 39x |
dplyr::group_by(strata, time) |> |
| 357 | 39x |
dplyr::summarize( |
| 358 | 39x |
data.frame(mice::pool.scalar(Q = q, U = u, k = 1)[c("m", "qbar", "ubar", "t")]),
|
| 359 | 39x |
.groups = "drop" |
| 360 |
) |> |
|
| 361 | ||
| 362 |
# with a single dataset, the between-imputation variance is not defined |
|
| 363 |
# (pool.scalar returns NA for t); it is 0 by definition, so the total variance |
|
| 364 |
# is the within-dataset variance (ubar). This is deliberately based on the |
|
| 365 |
# number of datasets, not on m per time point: with several datasets, a time |
|
| 366 |
# point observed in only one of them stays NA |
|
| 367 | 39x |
dplyr::mutate(t = if (single_dataset) ubar else t) |> |
| 368 | 39x |
dplyr::select(-ubar) |> |
| 369 | ||
| 370 |
# we back-transform back and compute CI's |
|
| 371 | 39x |
dplyr::mutate( |
| 372 | 39x |
surv = 1 - exp(-exp(qbar)), |
| 373 | 39x |
se = sqrt(t), |
| 374 | 39x |
lower = 1 - exp(-exp(qbar - 1.96 * se)), |
| 375 | 39x |
upper = 1 - exp(-exp(qbar + 1.96 * se)) |
| 376 |
) |> |
|
| 377 | ||
| 378 |
# confidence intervals are both 1 for time = 0 and surv = 1 |
|
| 379 | 39x |
dplyr::mutate( |
| 380 | 39x |
lower = dplyr::if_else(time == 0 & surv == 1, 1.0, lower), |
| 381 | 39x |
upper = dplyr::if_else(time == 0 & surv == 1, 1.0, upper) |
| 382 |
) |> |
|
| 383 |
# confidence intervals are both 0 for surv = 0 |
|
| 384 | 39x |
dplyr::mutate( |
| 385 | 39x |
lower = dplyr::if_else(surv == 0, 0, lower), |
| 386 | 39x |
upper = dplyr::if_else(surv == 0, 0, upper) |
| 387 |
) |> |
|
| 388 | ||
| 389 |
# arrange by strata, time and descending survival probability |
|
| 390 | 39x |
dplyr::arrange(strata, time, dplyr::desc(surv)) |
| 391 | ||
| 392 |
# Plot a pooled Kaplan-Meier curve ---------------------------------------- |
|
| 393 | ||
| 394 |
# plot pooled Kaplan-Meier curve based on pooled survival probabilities |
|
| 395 | ||
| 396 | 39x |
km_plot <- km_survival |> |
| 397 | 39x |
ggplot2::ggplot(ggplot2::aes( |
| 398 | 39x |
x = time, |
| 399 | 39x |
y = surv, |
| 400 | 39x |
color = strata, |
| 401 | 39x |
fill = strata |
| 402 |
)) + |
|
| 403 | 39x |
ggplot2::geom_step(linewidth = 1.25) + |
| 404 | 39x |
ggplot2::geom_ribbon( |
| 405 | 39x |
ggplot2::aes(ymin = lower, ymax = upper), |
| 406 | 39x |
linetype = 0, |
| 407 | 39x |
alpha = 0.2 |
| 408 |
) + |
|
| 409 | 39x |
ggplot2::theme_minimal(base_size = 14) + |
| 410 | 39x |
ggplot2::labs( |
| 411 | 39x |
x = "Follow-up time", |
| 412 | 39x |
y = "Pr(Survival) [%]", |
| 413 | 39x |
color = "Exposure", |
| 414 | 39x |
fill = "Exposure" |
| 415 |
) + |
|
| 416 | 39x |
ggplot2::theme(legend.position = "top") |
| 417 | ||
| 418 |
# Extract median survival time -------------------------------------------- |
|
| 419 | ||
| 420 |
# This section is taken from Terry M. Therneau's |
|
| 421 |
# print.survfit function in the survival package! |
|
| 422 | ||
| 423 |
# to extract the median survival times, we |
|
| 424 |
# apply the same sub-functions used in |
|
| 425 |
# print.survfit function of the original survival package |
|
| 426 |
# which defines the median survival time as the time |
|
| 427 |
# the survival probability drops below .5 for the first time |
|
| 428 |
# see https://github.com/cran/survival/blob/master/R/print.survfit.R#L109 |
|
| 429 | ||
| 430 |
# Therneau defined some edge cases (x = time, y = surv): |
|
| 431 |
# Nuisance 1: if one of the y's is exactly .5, we want the mean of |
|
| 432 |
# the corresponding x and the first x for which y<.5. We need to |
|
| 433 |
# use the equivalent of all.equal to check for a .5 however: |
|
| 434 |
# survfit(Surv(1:100)~1) gives a value of .5 + 1.1e-16 due to |
|
| 435 |
# roundoff error. |
|
| 436 |
# Nuisance 2: there may by an NA in the y's |
|
| 437 |
# Nuisance 3: if no y's are <=.5, then we should return NA |
|
| 438 |
# Nuisance 4: the obs (or many) after the .5 may be censored, giving |
|
| 439 |
# a stretch of values = .5 +- epsilon |
|
| 440 | 39x |
minmin <- function(y, x) {
|
| 441 | 234x |
tolerance <- .Machine$double.eps^.5 #same as used in all.equal() |
| 442 | 234x |
keep <- (!is.na(y) & y < (.5 + tolerance)) |
| 443 | 234x |
if (!any(keep)) {
|
| 444 | 39x |
NA |
| 445 |
} else {
|
|
| 446 | 230x |
x <- x[keep] |
| 447 | 230x |
y <- y[keep] |
| 448 | 230x |
if (abs(y[1] - .5) < tolerance && any(y < y[1])) {
|
| 449 | 8x |
(x[1] + x[min(which(y < y[1]))]) / 2 |
| 450 |
} else {
|
|
| 451 | 222x |
x[1] |
| 452 |
} |
|
| 453 |
} |
|
| 454 |
} |
|
| 455 | ||
| 456 |
# apply this across strata listed in the km_survival table |
|
| 457 | 39x |
minmin_strata <- function(i) {
|
| 458 | 78x |
km_survival_stratum <- km_survival |> |
| 459 | 78x |
dplyr::filter(strata == i) |
| 460 | ||
| 461 |
# median survival time |
|
| 462 | 78x |
med <- minmin(y = km_survival_stratum$surv, km_survival_stratum$time) |
| 463 | ||
| 464 |
# 95% CI of median survival time |
|
| 465 |
# = time when lower and upper |
|
| 466 |
# 95% confidence intervals of the survival |
|
| 467 |
# probability drop below .5 |
|
| 468 | 78x |
if (!is.null(km_survival_stratum$upper)) {
|
| 469 | 78x |
upper <- minmin( |
| 470 | 78x |
y = km_survival_stratum$upper, |
| 471 | 78x |
x = km_survival_stratum$time |
| 472 |
) |
|
| 473 | 78x |
lower <- minmin( |
| 474 | 78x |
y = km_survival_stratum$lower, |
| 475 | 78x |
x = km_survival_stratum$time |
| 476 |
) |
|
| 477 | 78x |
minmin_strata_return <- c(med, lower, upper) |
| 478 |
} else {
|
|
| 479 |
# Therneau assigns a 0 if no confidence intervals are available |
|
| 480 | ! |
upper <- 0 |
| 481 | ! |
lower <- 0 |
| 482 |
} |
|
| 483 | ||
| 484 |
# provide output table |
|
| 485 | 78x |
minmin_strata_return_tibble <- tibble::tibble( |
| 486 | 78x |
strata = i, # stratum |
| 487 | 78x |
t_median = med, # median survival time |
| 488 | 78x |
t_lower = lower, # lower 95% CI of median survival time |
| 489 | 78x |
t_upper = upper # upper 95% CI of median survival time |
| 490 |
) |
|
| 491 | ||
| 492 | 78x |
return(minmin_strata_return_tibble) |
| 493 |
} |
|
| 494 | ||
| 495 | 39x |
km_median_survival <- do.call( |
| 496 | 39x |
rbind, |
| 497 | 39x |
lapply(X = unique(km_survival$strata), FUN = minmin_strata) |
| 498 |
) |
|
| 499 | ||
| 500 |
# return results ---------------------------------------------------------- |
|
| 501 | ||
| 502 | 39x |
return( |
| 503 | 39x |
list( |
| 504 | 39x |
km_median_survival = km_median_survival, |
| 505 | 39x |
km_plot = km_plot, |
| 506 | 39x |
km_survival_table = km_survival |
| 507 |
) |
|
| 508 |
) |
|
| 509 |
} |
| 1 |
#' Calculate Standardized Mean Difference (SMD) Between RCT and RWE Results |
|
| 2 |
#' |
|
| 3 |
#' @description |
|
| 4 |
#' This function calculates the standardized mean difference (SMD) between results from |
|
| 5 |
#' a randomized controlled trial (RCT) and its real-world evidence (RWE) emulation. |
|
| 6 |
#' The SMD provides a measure of agreement between the two results, taking into account |
|
| 7 |
#' both the point estimates and their corresponding uncertainty. |
|
| 8 |
#' |
|
| 9 |
#' @details |
|
| 10 |
#' The SMD is calculated as the difference between RCT and RWE estimates, divided by |
|
| 11 |
#' the square root of the sum of their variances. Variances are derived from the |
|
| 12 |
#' confidence intervals assuming normal distribution (using 1.96 for 95% CI). |
|
| 13 |
#' |
|
| 14 |
#' The formula used is: |
|
| 15 |
#' SMD = (theta_RCT - theta_RWE) / sqrt(Var(theta_RCT) + Var(theta_RWE)) |
|
| 16 |
#' |
|
| 17 |
#' @param rct_estimate Numeric. Point estimate from the RCT (typically log hazard ratio or log odds ratio) |
|
| 18 |
#' @param rct_lower Numeric. Lower bound of the confidence interval from RCT |
|
| 19 |
#' @param rct_upper Numeric. Upper bound of the confidence interval from RCT |
|
| 20 |
#' @param rwe_estimate Numeric. Point estimate from the RWE study |
|
| 21 |
#' @param rwe_lower Numeric. Lower bound of the confidence interval from RWE |
|
| 22 |
#' @param rwe_upper Numeric. Upper bound of the confidence interval from RWE |
|
| 23 |
#' |
|
| 24 |
#' @return |
|
| 25 |
#' Returns a numeric value representing the standardized mean difference. |
|
| 26 |
#' Larger absolute values indicate greater disagreement between RCT and RWE results. |
|
| 27 |
#' |
|
| 28 |
#' @examples |
|
| 29 |
#' # Example with hazard ratios (log scale) |
|
| 30 |
#' smd <- smd_agreement( |
|
| 31 |
#' rct_estimate = log(0.87), |
|
| 32 |
#' rct_lower = log(0.78), |
|
| 33 |
#' rct_upper = log(0.97), |
|
| 34 |
#' rwe_estimate = log(0.82), |
|
| 35 |
#' rwe_lower = log(0.76), |
|
| 36 |
#' rwe_upper = log(0.87) |
|
| 37 |
#' ) |
|
| 38 |
#' |
|
| 39 |
#' @references |
|
| 40 |
#' For methodology on comparing RCT and RWE results using standardized differences. |
|
| 41 |
#' |
|
| 42 |
#' @export |
|
| 43 |
smd_agreement <- function(rct_estimate, |
|
| 44 |
rct_lower, |
|
| 45 |
rct_upper, |
|
| 46 |
rwe_estimate, |
|
| 47 |
rwe_lower, |
|
| 48 |
rwe_upper |
|
| 49 |
){
|
|
| 50 | ||
| 51 |
# Input validation |
|
| 52 | 73x |
required_args <- c("rct_estimate", "rct_lower", "rct_upper",
|
| 53 | 73x |
"rwe_estimate", "rwe_lower", "rwe_upper") |
| 54 | ||
| 55 |
# Check for missing values |
|
| 56 | 73x |
missing_args <- required_args[sapply(list(rct_estimate, rct_lower, rct_upper, |
| 57 | 73x |
rwe_estimate, rwe_lower, rwe_upper), |
| 58 | 73x |
is.null)] |
| 59 | 72x |
if (length(missing_args) > 0) {
|
| 60 | ! |
stop(sprintf("Missing required arguments: %s",
|
| 61 | ! |
paste(missing_args, collapse = ", "))) |
| 62 |
} |
|
| 63 | ||
| 64 |
# Check numeric inputs |
|
| 65 | 72x |
if (!all(sapply(list(rct_estimate, rct_lower, rct_upper, |
| 66 | 72x |
rwe_estimate, rwe_lower, rwe_upper), is.numeric))) {
|
| 67 | 1x |
stop("All inputs must be numeric values")
|
| 68 |
} |
|
| 69 | ||
| 70 |
# Check bounds |
|
| 71 | 71x |
if (rct_lower >= rct_upper) {
|
| 72 | 5x |
stop("RCT lower bound must be less than upper bound")
|
| 73 |
} |
|
| 74 | 64x |
if (rwe_lower >= rwe_upper) {
|
| 75 | ! |
stop("RWE lower bound must be less than upper bound")
|
| 76 |
} |
|
| 77 | ||
| 78 |
# Check if estimate is within bounds |
|
| 79 | 64x |
if (rct_estimate < rct_lower || rct_estimate > rct_upper) {
|
| 80 | 1x |
warning("RCT estimate is outside its confidence interval bounds")
|
| 81 |
} |
|
| 82 | 64x |
if (rwe_estimate < rwe_lower || rwe_estimate > rwe_upper) {
|
| 83 | ! |
warning("RWE estimate is outside its confidence interval bounds")
|
| 84 |
} |
|
| 85 | ||
| 86 |
# numerator |
|
| 87 | 62x |
numerator <- (rct_estimate - rwe_estimate) |
| 88 | ||
| 89 |
# variances |
|
| 90 | 62x |
var_rct <- (rct_upper - rct_lower)/(2*1.96) |
| 91 | 62x |
var_rwe <- (rwe_upper - rwe_lower)/(2*1.96) |
| 92 | ||
| 93 |
# denominator |
|
| 94 | 62x |
denominator <- sqrt(var_rct^2 + var_rwe^2) |
| 95 | ||
| 96 |
# Final check for valid calculation |
|
| 97 | 62x |
if (is.infinite(denominator) || is.nan(denominator)) {
|
| 98 | ! |
stop("Invalid calculation: denominator is infinite or NaN")
|
| 99 |
} |
|
| 100 | ||
| 101 |
# smd |
|
| 102 | 62x |
smd <- numerator/denominator |
| 103 | ||
| 104 | 62x |
return(smd) |
| 105 | ||
| 106 |
} |
| 1 |
#' Simulates an artifical EHR-derived analysis-ready oncology dataset |
|
| 2 |
#' |
|
| 3 |
#' @description |
|
| 4 |
#' Parameterized function to quickly create an |
|
| 5 |
#' EHR-derived analytic cohort for analytic code development. |
|
| 6 |
#' |
|
| 7 |
#' @details |
|
| 8 |
#' This function simulates a cohort of patients with |
|
| 9 |
#' oncology data. The cohort is simulated using a Weibull distribution for time to event |
|
| 10 |
#' and a logistic distribution for treatment assignment. |
|
| 11 |
#' The function also allows for missingness to be imposed on the data. |
|
| 12 |
#' The function is parameterized to allow for the number of patients |
|
| 13 |
#' to be simulated, the seed for reproducibility, and whether to |
|
| 14 |
#' include a patient id variable. |
|
| 15 |
#' |
|
| 16 |
#' @param n_total integer, number of total patients |
|
| 17 |
#' @param seed integer, seed for reproducibility |
|
| 18 |
#' @param include_id logical, include a generated patientid variable |
|
| 19 |
#' @param imposeNA logical, set covariates to missing |
|
| 20 |
#' @param propNA numeric, proportion of missingness, needs to be between 0 and 1 |
|
| 21 | ||
| 22 |
#' |
|
| 23 |
#' @return data frame with simulated analytic cohort |
|
| 24 |
#' |
|
| 25 |
#' @export |
|
| 26 |
#' |
|
| 27 |
#' @examples |
|
| 28 |
#' \dontrun{
|
|
| 29 |
#' library(encore.analytics) |
|
| 30 |
#' |
|
| 31 |
#' # Original uniform missingness |
|
| 32 |
#' data_uniform <- simulate_data( |
|
| 33 |
#' n_total = 3500, |
|
| 34 |
#' seed = 41, |
|
| 35 |
#' include_id = FALSE, |
|
| 36 |
#' imposeNA = TRUE, |
|
| 37 |
#' propNA = .33 |
|
| 38 |
#' ) |
|
| 39 |
#' |
|
| 40 |
#' # Now creates variable missingness across columns with average of 0.33 |
|
| 41 |
#' data_variable <- simulate_data( |
|
| 42 |
#' n_total = 3500, |
|
| 43 |
#' seed = 41, |
|
| 44 |
#' include_id = FALSE, |
|
| 45 |
#' imposeNA = TRUE, |
|
| 46 |
#' propNA = .33 |
|
| 47 |
#' ) |
|
| 48 |
#' |
|
| 49 |
#' head(data_variable) |
|
| 50 |
#' |
|
| 51 |
#'} |
|
| 52 | ||
| 53 |
simulate_data <- function(n_total = 3500, |
|
| 54 |
seed = 42, |
|
| 55 |
include_id = TRUE, |
|
| 56 |
imposeNA = TRUE, |
|
| 57 |
propNA = NULL |
|
| 58 |
){
|
|
| 59 | ||
| 60 |
# check input |
|
| 61 | 20x |
assertthat::assert_that(inherits(n_total, "numeric"), msg = "<n_total> needs to be an integer") |
| 62 | 20x |
assertthat::assert_that(n_total > 10, msg = "<n_total> needs to be larger than 10 for meaningful output") |
| 63 | 19x |
assertthat::assert_that(inherits(seed, "numeric"), msg = "<seed> needs to be an integer") |
| 64 | 19x |
assertthat::assert_that(inherits(include_id, "logical"), msg = "<seed> needs to be a logical") |
| 65 | 19x |
assertthat::assert_that(inherits(imposeNA, "logical"), msg = "<imposeNA> needs to be a logical") |
| 66 | ! |
if(isTRUE(imposeNA) && is.null(propNA)){stop("<propNA> needs to be specified when <imposeNA> is TRUE")}
|
| 67 | 11x |
if(!is.null(propNA)) assertthat::assert_that(inherits(propNA, "numeric"), msg = "<propNA> needs to be numeric") |
| 68 | 11x |
if(!is.null(propNA)) assertthat::assert_that(propNA >= 0 & propNA <= 1, msg = "<propNA> needs to be a numeric between 0 and 1") |
| 69 | 10x |
if(!is.null(propNA)) assertthat::assert_that(imposeNA == TRUE, msg = "<propNA> is specified but imposeNA is FALSE") |
| 70 | ||
| 71 |
# set the seed |
|
| 72 | 17x |
set.seed(seed) |
| 73 | ||
| 74 |
# Build the total cohort |
|
| 75 | 17x |
cohort <- tibble::tibble( |
| 76 |
# patient id |
|
| 77 | 17x |
patientid = paste0("Patient", seq(1, n_total, 1)),
|
| 78 |
# age |
|
| 79 | 17x |
dem_age_index_cont = stats::rnorm(n = n_total, mean = 69, sd = 8), |
| 80 |
# sex/male |
|
| 81 | 17x |
dem_sex_cont = stats::rbinom(n = n_total, size = 1, prob = .33), |
| 82 |
# smoking |
|
| 83 | 17x |
c_smoking_history = sample(c(TRUE, FALSE), size = n_total, replace = TRUE, prob = c(.44, .56)), |
| 84 |
# num met sites |
|
| 85 | 17x |
c_number_met_sites = sample(c(1, 2, 3, 4, 5), size = n_total, replace = TRUE, prob = c(.73, .22, .035, .006, .001)), |
| 86 |
# c_ecog_cont |
|
| 87 | 17x |
c_ecog_cont = stats::rbinom(n = n_total, size = 1, prob = .57), |
| 88 |
# c_stage_initial_dx_cont |
|
| 89 | 17x |
c_stage_initial_dx_cont = sample(c(4, 3, 2, 1), size = n_total, replace = TRUE, prob = c(.76, 0.2, 0.028, 0.012)), |
| 90 |
# dem_race |
|
| 91 | 17x |
dem_race = factor(sample(c("Asian", "White", "Other"), size = n_total, replace = TRUE, prob = c(.36, .62, .02))),
|
| 92 |
# dem_region |
|
| 93 | 17x |
dem_region = factor(sample(c("Midwest", "Northeast", "South", "West"), size = n_total, replace = TRUE, prob = c(.14, .20, .39, .27))),
|
| 94 |
# dem_ses |
|
| 95 | 17x |
dem_ses = sample(c(1, 2, 3, 4, 5), size = n_total, replace = TRUE, prob = c(.12, .15, .19, .24, .29)), |
| 96 |
# c_hemoglobin_g_dl_cont |
|
| 97 | 17x |
c_hemoglobin_g_dl_cont = stats::rnorm(n = n_total, mean = 12.90, sd = 1.3), |
| 98 |
# c_urea_nitrogen_mg_dl_cont |
|
| 99 | 17x |
c_urea_nitrogen_mg_dl_cont = stats::rnorm(n = n_total, mean = 2.71, sd = 0.3), |
| 100 |
# c_platelets_10_9_l_cont |
|
| 101 | 17x |
c_platelets_10_9_l_cont = stats::rnorm(n = n_total, mean = 259, sd = 53), |
| 102 |
# c_calcium_mg_dl_cont |
|
| 103 | 17x |
c_calcium_mg_dl_cont = stats::rnorm(n = n_total, mean = 2.23, sd = 0.04), |
| 104 |
# c_glucose_mg_dl_cont |
|
| 105 | 17x |
c_glucose_mg_dl_cont = stats::rnorm(n = n_total, mean = 4.64, sd = 0.11), |
| 106 |
# c_lymphocyte_leukocyte_ratio_cont |
|
| 107 | 17x |
c_lymphocyte_leukocyte_ratio_cont = stats::rnorm(n = n_total, mean = 2.87, sd = 0.17), |
| 108 |
# c_alp_u_l_cont |
|
| 109 | 17x |
c_alp_u_l_cont = stats::rnorm(n = n_total, mean = 4.48, sd = 0.13), |
| 110 |
# c_protein_g_l_cont |
|
| 111 | 17x |
c_protein_g_l_cont = stats::rnorm(n = n_total, mean = 68, sd = 4), |
| 112 |
# c_alt_u_l_cont |
|
| 113 | 17x |
c_alt_u_l_cont = stats::rnorm(n = n_total, mean = 2.89, sd = 0.33), |
| 114 |
# c_albumin_g_l_cont |
|
| 115 | 17x |
c_albumin_g_l_cont = stats::rnorm(n = n_total, mean = 40, sd = 3), |
| 116 |
# c_bilirubin_mg_dl_cont |
|
| 117 | 17x |
c_bilirubin_mg_dl_cont = stats::rnorm(n = n_total, mean = -0.92, sd = 1.28), |
| 118 |
# c_chloride_mmol_l_cont |
|
| 119 | 17x |
c_chloride_mmol_l_cont = stats::rnorm(n = n_total, mean = 102.0, sd = 3), |
| 120 |
# c_monocytes_10_9_l_cont |
|
| 121 | 17x |
c_monocytes_10_9_l_cont = stats::rnorm(n = n_total, mean = -0.51, sd = 0.25), |
| 122 |
# c_eosinophils_leukocytes_ratio_cont |
|
| 123 | 17x |
c_eosinophils_leukocytes_ratio_cont = stats::rnorm(n = n_total, mean = 0.69, sd = 0.59), |
| 124 |
# c_ldh_u_l_cont |
|
| 125 | 17x |
c_ldh_u_l_cont = stats::rnorm(n = n_total, mean = 1.69, sd = 0.05), |
| 126 |
# c_hr_cont |
|
| 127 | 17x |
c_hr_cont = stats::rnorm(n = n_total, mean = 4.43, sd = 0.04), |
| 128 |
# c_sbp_cont |
|
| 129 | 17x |
c_sbp_cont = stats::rnorm(n = n_total, mean = 4.85, sd = 0.09), |
| 130 |
# c_oxygen_cont |
|
| 131 | 17x |
c_oxygen_cont = stats::rnorm(n = n_total, mean = 97.00, sd = 0.01), |
| 132 |
# c_neutrophil_lymphocyte_ratio_cont |
|
| 133 | 17x |
c_neutrophil_lymphocyte_ratio_cont = stats::rnorm(n = n_total, mean = 1.29, sd = 0.38), |
| 134 |
# c_bmi_cont |
|
| 135 | 17x |
c_bmi_cont = stats::rnorm(n = n_total, mean = 3.23, sd = 0.13), |
| 136 |
# c_ast_alt_ratio_cont |
|
| 137 | 17x |
c_ast_alt_ratio_cont = stats::rnorm(n = n_total, mean = 0.12, sd = 0.29), |
| 138 |
# c_time_dx_to_index |
|
| 139 | 17x |
c_time_dx_to_index = stats::rnorm(n = n_total, mean = 44, sd = 17), |
| 140 |
# c_de_novo_mets_dx |
|
| 141 | 17x |
c_de_novo_mets_dx = sample(c(TRUE, FALSE), size = n_total, replace = TRUE, prob = c(.79, .21)), |
| 142 |
# c_height_cont |
|
| 143 | 17x |
c_height_cont = stats::rnorm(n = n_total, mean = 1.65, sd = 0.08), |
| 144 |
# c_weight_cont |
|
| 145 | 17x |
c_weight_cont = stats::rnorm(n = n_total, mean = 69, sd = 11), |
| 146 |
# c_dbp_cont |
|
| 147 | 17x |
c_dbp_cont = stats::rnorm(n = n_total, mean = 76, sd = 6), |
| 148 |
# c_year_index |
|
| 149 | 17x |
c_year_index = factor(sample(c("<2018", "2018+"), size = n_total, replace = TRUE, prob = c(.95, .05)))
|
| 150 |
) |
|
| 151 | ||
| 152 |
# assign treatment probabilities |
|
| 153 | 17x |
cohort <- cohort |> |
| 154 | 17x |
dplyr::mutate( |
| 155 |
# treat (to simplify categorical variables don't get betas) |
|
| 156 | 17x |
treat = stats::rbinom( |
| 157 | 17x |
n = n_total, |
| 158 | 17x |
size = 1, |
| 159 | 17x |
prob = locfit::expit( |
| 160 | 17x |
-5.8 + |
| 161 | 17x |
dem_age_index_cont * log(1.0071) + |
| 162 | 17x |
dem_sex_cont * log(0.9804) + |
| 163 | 17x |
c_smoking_history * log(0.7065) + |
| 164 | 17x |
c_number_met_sites * log(0.9982) + |
| 165 | 17x |
c_hemoglobin_g_dl_cont * log(1.0483) + |
| 166 | 17x |
c_urea_nitrogen_mg_dl_cont * log(0.9198) + |
| 167 | 17x |
c_platelets_10_9_l_cont * log(0.9998) + |
| 168 | 17x |
c_calcium_mg_dl_cont * log(0.9118) + |
| 169 | 17x |
c_glucose_mg_dl_cont * log(1.0954) + |
| 170 | 17x |
c_lymphocyte_leukocyte_ratio_cont * log(1.1192) + |
| 171 | 17x |
c_alp_u_l_cont * log(1.3126) + |
| 172 | 17x |
c_protein_g_l_cont * log(1.0002) + |
| 173 | 17x |
c_alt_u_l_cont * log(0.9679) + |
| 174 | 17x |
c_albumin_g_l_cont * log(1.0296) + |
| 175 | 17x |
c_bilirubin_mg_dl_cont * log(0.9126) + |
| 176 | 17x |
c_chloride_mmol_l_cont * log(1.0076) + |
| 177 | 17x |
c_monocytes_10_9_l_cont * log(1.3919) + |
| 178 | 17x |
c_eosinophils_leukocytes_ratio_cont * log(0.9567) + |
| 179 | 17x |
c_ldh_u_l_cont * log(0.8804) + |
| 180 | 17x |
c_hr_cont * log(1.2264) + |
| 181 | 17x |
c_sbp_cont * log(1.0647) + |
| 182 | 17x |
c_oxygen_cont * log(1.00064) + |
| 183 | 17x |
c_ecog_cont * log(0.7857) + |
| 184 | 17x |
c_neutrophil_lymphocyte_ratio_cont * log(1.0758) + |
| 185 | 17x |
c_bmi_cont * log(1.061) + |
| 186 | 17x |
c_ast_alt_ratio_cont * log(1.0756) + |
| 187 | 17x |
c_stage_initial_dx_cont * log(1.2177) + |
| 188 | 17x |
c_time_dx_to_index * log(1.0001) |
| 189 |
) |
|
| 190 |
) |
|
| 191 |
) |
|
| 192 | ||
| 193 |
#summary(cohort$treat) |
|
| 194 | ||
| 195 |
# simulate HTE by race |
|
| 196 | ||
| 197 |
## Asian patients |
|
| 198 | 17x |
cohort_Asian <- cohort |> |
| 199 | 17x |
dplyr::filter(dem_race == "Asian") |
| 200 | ||
| 201 |
# assign betas for hazard model |
|
| 202 | 17x |
betas_os_Asian <- c( |
| 203 | 17x |
treat = log(1), |
| 204 | 17x |
dem_sex_cont = log(0.79), |
| 205 | 17x |
dem_age_index_cont = log(1.02), |
| 206 | 17x |
c_smoking_history = log(.70), |
| 207 | 17x |
c_ecog_cont = log(.7) |
| 208 |
) |
|
| 209 | ||
| 210 | 17x |
set.seed(seed) |
| 211 | 17x |
cohort_outcome_Asian <- cohort_Asian |> |
| 212 | 17x |
dplyr::bind_cols( |
| 213 | 17x |
simsurv::simsurv( |
| 214 | 17x |
dist = "weibull", |
| 215 | 17x |
gammas = 1.2, # shape parameter |
| 216 | 17x |
lambdas = 0.01, # scale parameter |
| 217 | 17x |
betas = betas_os_Asian, # betas for the model |
| 218 | 17x |
x = cohort_Asian, # data frame with covariates |
| 219 | 17x |
maxt = 120 |
| 220 |
) |
|
| 221 |
) |> |
|
| 222 | 17x |
dplyr::select(-id) |> |
| 223 | 17x |
dplyr::rename( |
| 224 | 17x |
fu_itt_months = eventtime, |
| 225 | 17x |
death_itt = status |
| 226 |
) |
|
| 227 | ||
| 228 |
## Non-Asian patients |
|
| 229 | 17x |
cohort_nonAsian <- cohort |> |
| 230 | 17x |
dplyr::filter(dem_race != "Asian") |
| 231 | ||
| 232 | 17x |
betas_os_nonAsian <- c( |
| 233 | 17x |
treat = log(0.54), |
| 234 | 17x |
dem_sex_cont = log(0.79), |
| 235 | 17x |
dem_age_index_cont = log(1.02), |
| 236 | 17x |
c_smoking_history = log(.70), |
| 237 | 17x |
c_ecog_cont = log(.7) |
| 238 |
) |
|
| 239 | ||
| 240 | 17x |
set.seed(seed) |
| 241 | 17x |
cohort_outcome_nonAsian <- cohort_nonAsian |> |
| 242 | 17x |
dplyr::bind_cols( |
| 243 | 17x |
simsurv::simsurv( |
| 244 | 17x |
dist = "weibull", |
| 245 | 17x |
gammas = 1.2, |
| 246 | 17x |
lambdas = 0.01, |
| 247 | 17x |
betas = betas_os_nonAsian, |
| 248 | 17x |
x = cohort_nonAsian, |
| 249 | 17x |
maxt = 120 |
| 250 |
) |
|
| 251 |
) |> |
|
| 252 | 17x |
dplyr::select(-id) |> |
| 253 | 17x |
dplyr::rename( |
| 254 | 17x |
fu_itt_months = eventtime, |
| 255 | 17x |
death_itt = status |
| 256 |
) |
|
| 257 | ||
| 258 |
# combine Asian and non-Asian |
|
| 259 | 17x |
cohort_outcome <- rbind(cohort_outcome_Asian, cohort_outcome_nonAsian) |
| 260 | ||
| 261 |
# summary(cohort_outcome$death_itt) |
|
| 262 |
# summary(cohort_outcome$fu_itt_months) |
|
| 263 |
# hist(cohort_outcome$fu_itt_months) |
|
| 264 | ||
| 265 | 1x |
if(!include_id) cohort_outcome <- cohort_outcome |> dplyr::select(-patientid) |
| 266 | ||
| 267 | 17x |
if(imposeNA){
|
| 268 | ||
| 269 |
# variables that should always stay complete |
|
| 270 | 9x |
varComplete <- c("patientid", "treat", "c_year_index", "fu_itt_months", "death_itt", "dem_age_index_cont", "dem_sex_cont")
|
| 271 | 9x |
varNA <- setdiff(names(cohort_outcome), varComplete) |
| 272 | ||
| 273 |
# Create variable missingness proportions per column with average = propNA |
|
| 274 | 9x |
set.seed(seed) |
| 275 | 9x |
n_vars_with_missing <- length(varNA) |
| 276 | ||
| 277 |
# Safety check - ensure we have variables to make missing |
|
| 278 | 9x |
if(n_vars_with_missing == 0){
|
| 279 |
# No variables to make missing, skip missingness |
|
| 280 | ! |
return(cohort_outcome) |
| 281 |
} |
|
| 282 | ||
| 283 |
# Generate proportions that average to propNA using a simpler approach |
|
| 284 |
# Start with uniform random values and scale them |
|
| 285 | 9x |
base_props <- stats::runif(n_vars_with_missing, min = 0.1, max = 0.9) |
| 286 | ||
| 287 |
# Scale to have the desired mean |
|
| 288 | 9x |
current_mean <- mean(base_props) |
| 289 | 9x |
var_proportions <- base_props * (propNA / current_mean) |
| 290 | ||
| 291 |
# Apply adaptive bounds based on target propNA |
|
| 292 | 9x |
min_bound <- max(0.01, propNA - 0.3) # Allow lower bound to be closer to target |
| 293 | 9x |
max_bound <- min(0.99, propNA + 0.3) # Allow upper bound to accommodate high targets |
| 294 | 9x |
var_proportions <- pmax(min_bound, pmin(max_bound, var_proportions)) |
| 295 | ||
| 296 |
# Final adjustment to ensure mean equals propNA |
|
| 297 | 9x |
current_mean <- mean(var_proportions) |
| 298 | 9x |
if(length(current_mean) > 0 && !is.na(current_mean) && abs(current_mean - propNA) > 0.001) {
|
| 299 | ! |
adjustment_factor <- propNA / current_mean |
| 300 | ! |
var_proportions <- var_proportions * adjustment_factor |
| 301 | ! |
var_proportions <- pmax(min_bound, pmin(max_bound, var_proportions)) |
| 302 |
} |
|
| 303 | ||
| 304 |
# Apply missingness column by column |
|
| 305 | 9x |
set.seed(seed) |
| 306 | 9x |
for(i in seq_along(varNA)) {
|
| 307 | 297x |
var_name <- varNA[i] |
| 308 | 297x |
prop_missing <- var_proportions[i] |
| 309 | ||
| 310 |
# Skip if proportion is NA or invalid |
|
| 311 | ! |
if(is.na(prop_missing) || prop_missing <= 0) next |
| 312 | ||
| 313 |
# Randomly select rows to set as missing |
|
| 314 | 297x |
n_missing <- round(n_total * prop_missing) |
| 315 | 297x |
if(n_missing > 0) {
|
| 316 | 293x |
missing_rows <- sample(1:n_total, n_missing, replace = FALSE) |
| 317 | 293x |
cohort_outcome[missing_rows, var_name] <- NA |
| 318 |
} |
|
| 319 |
} |
|
| 320 |
} |
|
| 321 | ||
| 322 | 17x |
return(cohort_outcome) |
| 323 | ||
| 324 |
} |
| 1 |
#' Estimate raking weights for a mimids or wimids object |
|
| 2 |
#' |
|
| 3 |
#' @description |
|
| 4 |
#' Function estimates raking weights for multiple imputed and matched (\code{mimids})
|
|
| 5 |
#' or weighted (\code{wimids}) datasets. That is to match the distributions of
|
|
| 6 |
#' certain variables in the imputed and matched/weighted datasets to a distribution of |
|
| 7 |
#' a target population (e.g., clinical trial population). |
|
| 8 |
#' |
|
| 9 |
#' @details |
|
| 10 |
#' The function requires an object of class mimids or wimids (\code{x}), which is the output
|
|
| 11 |
#' of a workflow that requires imputing multiple (m) datasets using mice or amelia |
|
| 12 |
#' and matching or weighting each imputed dataset via the MatchThem package |
|
| 13 |
#' (see examples). |
|
| 14 |
#' |
|
| 15 |
#' The function additionally requires a list of target distributions (\code{targets}) for each variable
|
|
| 16 |
#' that is considered for the raking procedure. The list should contain named vectors with the target |
|
| 17 |
#' distributions for each variable and the names of the vectors should match the variable names in the imputed datasets. |
|
| 18 |
#' |
|
| 19 |
#' In brief, the raking procedure iteratively adjusts the weights to make the weighted sample percentages match the target |
|
| 20 |
#' population percentages for the selected variables.It does this by multiplying the current weight for each case by a |
|
| 21 |
#' factor based on the ratio of the target population proportion to the weighted sample proportion for a given category. |
|
| 22 |
#' This adjustment is performed sequentially for each category of each selected variable. Because adjusting for one |
|
| 23 |
#' variable can disrupt the match for previous variables, the process is repeated through all selected variables in cycles. |
|
| 24 |
#' This iterative process minimizes the Kullback-Leibler (KL) divergence and continues until the weighted sample proportions |
|
| 25 |
#' match the target population proportions for all categories ("full convergence"), or until no further change occurs.
|
|
| 26 |
#' |
|
| 27 |
#' The function follows the following logic: |
|
| 28 |
#' 1. Extract the ith imputed dataset from the mimids or wimids object |
|
| 29 |
#' 2. Create a temporary case/patient ID |
|
| 30 |
#' 3. Apply the \code{\link[anesrake]{anesrake}} function to the ith imputed dataset
|
|
| 31 |
#' 4. Create a temporary dataframe with the case ID and replace the initial weights with the updated raking weights |
|
| 32 |
#' 5. Merge the temporary dataframe with the ith imputed dataset |
|
| 33 |
#' 6. Drop the temporary case ID |
|
| 34 |
#' 7. Return the ith imputed dataset with the raking weights |
|
| 35 |
#' |
|
| 36 |
#' The function returns a list of data frames with the updated raking weights. These |
|
| 37 |
#' updated raking weights overwrite in each data frame the existing \code{weights} column.
|
|
| 38 |
#' This column can then be used in a downstream analysis (e.g., Kaplan-Meier, Cox proportional hazards regression). |
|
| 39 |
#' |
|
| 40 |
#' @seealso |
|
| 41 |
#' \code{\link[anesrake]{anesrake}} \code{\link[MatchThem]{matchthem}} \code{\link[MatchThem]{weightthem}}
|
|
| 42 |
#' |
|
| 43 |
#' @param x imputed and matched (mimids) or weighted (wimids) object |
|
| 44 |
#' @param targets list of all target values for the raking procedure |
|
| 45 |
#' |
|
| 46 |
#' @return list of data frames with updated raking weights (\code{raking_weights})
|
|
| 47 |
#' |
|
| 48 |
#' @md |
|
| 49 |
#' |
|
| 50 |
#' @export |
|
| 51 |
#' |
|
| 52 |
#' @examples |
|
| 53 |
#' |
|
| 54 |
#' library(encore.analytics) |
|
| 55 |
#' library(mice) |
|
| 56 |
#' library(dplyr) |
|
| 57 |
#' library(MatchThem) |
|
| 58 |
#' library(survival) |
|
| 59 |
#' |
|
| 60 |
#' # simulate a cohort with 1,000 patients with 20% missing data |
|
| 61 |
#' data <- simulate_data( |
|
| 62 |
#' n = 500, |
|
| 63 |
#' imposeNA = TRUE, |
|
| 64 |
#' propNA = 0.2 |
|
| 65 |
#' ) |> |
|
| 66 |
#' # anesrake works best with factor variables |
|
| 67 |
#' mutate(c_smoking_history = factor(ifelse(c_smoking_history == TRUE, "Current/former", "Never"))) |
|
| 68 |
#' |
|
| 69 |
#' # impute the data (create mids object) |
|
| 70 |
#' set.seed(42) |
|
| 71 |
#' mids <- mice(data, m = 5, print = FALSE) |
|
| 72 |
#' |
|
| 73 |
#' # define covariates for propensity score model |
|
| 74 |
#' covariates <- data |> |
|
| 75 |
#' select(starts_with("c_"), starts_with("dem_")) |>
|
|
| 76 |
#' colnames() |
|
| 77 |
#' |
|
| 78 |
#' # define propensity score model |
|
| 79 |
#' fit <- as.formula(paste("treat ~", paste(covariates, collapse = " + ")))
|
|
| 80 |
#' |
|
| 81 |
#' # match patients within each imputed dataset |
|
| 82 |
#' mimids <- matchthem( |
|
| 83 |
#' formula = fit, |
|
| 84 |
#' datasets = mids, |
|
| 85 |
#' approach = 'within', |
|
| 86 |
#' method = 'nearest' |
|
| 87 |
#' ) |
|
| 88 |
#' |
|
| 89 |
#' smoker_target <- c(.35, .65) |
|
| 90 |
#' names(smoker_target) <- c("Current/former", "Never")
|
|
| 91 |
#' |
|
| 92 |
#' # summarize target distributions in a named list vector |
|
| 93 |
#' targets <- list(smoker_target) |
|
| 94 |
#' names(targets) <- c("c_smoking_history")
|
|
| 95 |
#' |
|
| 96 |
#' # estimate raking weights |
|
| 97 |
#' mirwds <- raking_weights( |
|
| 98 |
#' x = mimids, |
|
| 99 |
#' targets = targets |
|
| 100 |
#' ) |
|
| 101 |
#' |
|
| 102 |
raking_weights <- function(x, |
|
| 103 |
targets = NULL |
|
| 104 |
){
|
|
| 105 | ||
| 106 |
# input checks---- |
|
| 107 | ||
| 108 |
# check if object is a mimids or wimids object |
|
| 109 | 7x |
assertthat::assert_that(inherits(x, c("mimids", "wimids")), msg = "<object> needs to be a mimids or wimids object")
|
| 110 |
# check if targets is a list |
|
| 111 | 6x |
assertthat::assert_that(is.list(targets), msg = "<targets> needs to be a list") |
| 112 |
# check if targets is a named list |
|
| 113 | 4x |
assertthat::assert_that(all(names(targets) != ""), msg = "<targets> needs to be a named list") |
| 114 |
# check if targets is a list of named vectors |
|
| 115 | 4x |
assertthat::assert_that(all(sapply(targets, is.numeric)), msg = "<targets> needs to be a list of named vectors") |
| 116 |
# check if targets is a list of named vectors |
|
| 117 | 3x |
assertthat::assert_that(all(sapply(targets, function(x) all(names(x) != ""))), msg = "<targets> needs to be a list of named vectors") |
| 118 | ||
| 119 |
# extract imputed and matched/weighted datasets and weights ---- |
|
| 120 | ||
| 121 |
# create a function to indicate which cases should be re-weighted |
|
| 122 |
# since we are only interested in matched/weighted cases |
|
| 123 | 3x |
estimate_raking_weights <- function(i){
|
| 124 | ||
| 125 |
# next, we create a list of datasets to apply the raking procedure one-by-one |
|
| 126 |
# action = "all" : produces a mild object (list) of the multiply imputed datasets |
|
| 127 |
# all = FALSE : do NOT include observations with a zero estimated weight |
|
| 128 |
# include = FALSE : do NOT include original data with the missing values |
|
| 129 | ||
| 130 |
# Important: the all = parameter needs to be set to FALSE |
|
| 131 |
# since otherwise unmatched patients or those zero weight |
|
| 132 |
# will be included, too! |
|
| 133 | 15x |
data <- MatchThem::complete(x, action = i, all = FALSE, include = FALSE) |
| 134 | ||
| 135 |
# create a temporary case ID |
|
| 136 | 15x |
data <- data |> |
| 137 | 15x |
dplyr::mutate(caseid = seq(1, nrow(data), 1)) |
| 138 | ||
| 139 |
# apply anesrake function to re-weight ----------------------------------- |
|
| 140 | 15x |
anesrake_out <- anesrake::anesrake( |
| 141 |
# target distributions for raking procedure |
|
| 142 | 15x |
inputter = targets, |
| 143 |
# data to be weighted |
|
| 144 | 15x |
dataframe = data, |
| 145 |
# patient identifier |
|
| 146 | 15x |
caseid = data$caseid, |
| 147 |
# other weights that should be accounted for before re-weighting is conducted |
|
| 148 |
# for matching this is 0 (unmatched) and 1 (matched) and for weighting this is the actual weights |
|
| 149 | 15x |
weightvec = data$weights, |
| 150 |
# no verbosity |
|
| 151 | 15x |
verbose = FALSE |
| 152 |
) |
|
| 153 | ||
| 154 |
# create a temporary dataframe with id and sampling/re-weighting weights |
|
| 155 | 15x |
caseweights <- data.frame(caseid = anesrake_out$caseid, weights = anesrake_out$weightvec) |
| 156 | ||
| 157 | 15x |
data_return <- data |> |
| 158 |
# remove "old" weights with new weights |
|
| 159 | 15x |
dplyr::select(-weights) |> |
| 160 | 15x |
dplyr::left_join(caseweights, by = "caseid") |> |
| 161 |
# now we can drop the temporary id |
|
| 162 | 15x |
dplyr::select(-caseid) |
| 163 | ||
| 164 | 15x |
return(data_return) |
| 165 | ||
| 166 |
} |
|
| 167 | ||
| 168 |
# return list of imputed, matched/weighted and re-weighted datasets (mirwds) |
|
| 169 | 3x |
mirwds <- lapply( |
| 170 | 3x |
X = 1:length(x$models), |
| 171 | 3x |
FUN = estimate_raking_weights |
| 172 |
) |
|
| 173 | ||
| 174 | 3x |
return(mirwds) |
| 175 | ||
| 176 |
} |
| 1 |
#' Calculate Agreement Metrics Between RCT and RWE Results |
|
| 2 |
#' |
|
| 3 |
#' @description Creates a formatted table comparing results from randomized controlled trials (RCTs) |
|
| 4 |
#' with real-world evidence (RWE) emulation studies by calculating agreement metrics. |
|
| 5 |
#' |
|
| 6 |
#' @details |
|
| 7 |
#' Calculates up to three types of agreement metrics, controlled via the `metrics` |
|
| 8 |
#' argument: |
|
| 9 |
#' - Statistical significance agreement: RCT and RWE confidence intervals fall on the |
|
| 10 |
#' same side of the null value of 1 (both entirely above, both entirely below, or |
|
| 11 |
#' both include the null, i.e. both non-significant) |
|
| 12 |
#' - Estimate agreement: RWE estimate within RCT confidence interval |
|
| 13 |
#' - SMD agreement: Standardized mean difference below threshold (default 1.96) |
|
| 14 |
#' |
|
| 15 |
#' Metrics excluded via `metrics` are not computed at all (not just hidden from |
|
| 16 |
#' the output), which is relevant for the SMD calculation in particular, since it |
|
| 17 |
#' is the most expensive step and may not be meaningful for all effect size types. |
|
| 18 |
#' |
|
| 19 |
#' All estimates should be hazard ratios (HR) and must be positive. |
|
| 20 |
#' Estimates are transformed before the SMD calculation according to `smd_scale` |
|
| 21 |
#' (log-transformed by default). |
|
| 22 |
#' |
|
| 23 |
#' **Known limitation for non-ratio-scale effect sizes**: `smd_scale` only changes |
|
| 24 |
#' the transform used inside the SMD calculation. It does not change the function's |
|
| 25 |
#' other ratio-scale assumptions: the positivity check on `rct_estimate`/`rwe_estimate` |
|
| 26 |
#' and `significance_agreement`'s comparison against a null value of 1 are applied |
|
| 27 |
#' regardless of `smd_scale`. For example, setting `smd_scale = "identity"` to use a |
|
| 28 |
#' risk difference still requires positive input values and still evaluates |
|
| 29 |
#' statistical significance against a null of 1 rather than 0 — both of which are |
|
| 30 |
#' incorrect for a difference-scale measure. This is a known, currently unaddressed |
|
| 31 |
#' limitation; `agreement_metrics()` is designed for ratio-scale effect measures |
|
| 32 |
#' (HR, OR, RR, IRR) beyond just the SMD calculation. |
|
| 33 |
#' |
|
| 34 |
#' @importFrom tidyr pivot_wider pivot_longer |
|
| 35 |
#' @importFrom smd smd |
|
| 36 |
#' |
|
| 37 |
#' @param x A data.frame or tibble containing RCT and RWE results (in wide format, see examples). |
|
| 38 |
#' Must contain these columns: |
|
| 39 |
#' |
|
| 40 |
#' - rct_estimate = Point estimate from the RCT |
|
| 41 |
#' - rct_lower = Lower confidence limit of the RCT result |
|
| 42 |
#' - rct_upper = Upper confidence limit of the RCT result |
|
| 43 |
#' - rwe_estimate = Point estimate from the RWE emulation study |
|
| 44 |
#' - rwe_lower = Lower confidence limit of the RWE result |
|
| 45 |
#' - rwe_upper = Upper confidence limit of the RWE result |
|
| 46 |
#' |
|
| 47 |
#' @param analysis_col Character. Name of column identifying different analyses |
|
| 48 |
#' @param group_col Character. Optional. Name of column for grouping results (e.g., database) |
|
| 49 |
#' @param estimate_label Character. Label for estimates in table header. Default: "HR (95% CI)". |
|
| 50 |
#' If the value is one of "HR (95% CI)", "OR (95% CI)", or "RR (95% CI)", the table footnote |
|
| 51 |
#' includes a matching abbreviation definition (e.g. "HR = Hazard ratio"); any other value is |
|
| 52 |
#' used as-is for the header without adding an abbreviation definition to the footnote. |
|
| 53 |
#' @param smd_threshold Numeric. Threshold for SMD agreement; a single positive number. |
|
| 54 |
#' Default: 1.96 (alpha=0.05) |
|
| 55 |
#' @param smd_scale Character. Which transform to apply to the RCT/RWE estimates and |
|
| 56 |
#' confidence limits before computing the SMD. One of "log" (default), "identity", |
|
| 57 |
#' "logit", or "cloglog". "log" is appropriate for ratio-scale measures (HR, OR, RR, IRR), |
|
| 58 |
#' since their confidence intervals are constructed via the delta method on the log |
|
| 59 |
#' scale. "identity" skips the transform, for measures already on an additive/difference |
|
| 60 |
#' scale (e.g. risk differences). "logit" is for proportions bounded on \[0, 1\]. |
|
| 61 |
#' "cloglog" is the log-log transform of a survival probability, `log(-log(p))`, which |
|
| 62 |
#' is the scale of the "log-log" confidence interval method used by `survival::survfit()` |
|
| 63 |
#' (`conf.type = "log-log"`). Note that [km_pooling()] pools survival probabilities on a |
|
| 64 |
#' different scale, `log(-log(1 - p))` (Morisot et al. 2015), see its documentation. |
|
| 65 |
#' Each option expects inputs in a |
|
| 66 |
#' particular domain (log: > 0; logit and cloglog: strictly between 0 and 1; identity: |
|
| 67 |
#' any real number); values outside that domain will silently produce an "NA" SMD |
|
| 68 |
#' agreement result for that row rather than raising an error. "cloglog" is a decreasing |
|
| 69 |
#' function of its input, so the lower/upper confidence limits are automatically |
|
| 70 |
#' reordered after transforming to preserve the lower-less-than-upper invariant required |
|
| 71 |
#' internally. See Details for a related limitation that `smd_scale` does not address. |
|
| 72 |
#' @param metrics Character vector. Which agreement metrics to compute and display. |
|
| 73 |
#' Must be a subset of "significance_agreement", "estimate_agreement", "smd_agreement". |
|
| 74 |
#' Default: all three. Metrics excluded here are skipped entirely (not computed), which |
|
| 75 |
#' is most relevant for "smd_agreement" since it is the most expensive metric to compute |
|
| 76 |
#' and may not be meaningful for all effect size types. |
|
| 77 |
#' @param show_aggregate Logical. If TRUE, adds a summary row at the bottom of the table |
|
| 78 |
#' with the pooled agreement percentage for each selected metric (Yes / (Yes + No) across |
|
| 79 |
#' all rows, excluding "NA" cells, e.g. failed SMD calculations, from the denominator). |
|
| 80 |
#' Default: FALSE. |
|
| 81 |
#' @param show_aggregate_total Logical. If TRUE, adds a source note below the table with |
|
| 82 |
#' a single agreement percentage pooled across all selected metrics and all rows combined, |
|
| 83 |
#' using the same Yes / (Yes + No) logic as `show_aggregate`. Default: TRUE. When TRUE, the |
|
| 84 |
#' formatted percentage string (e.g. "78% (109/140)") is also attached directly to the |
|
| 85 |
#' returned gt object as `show_aggregate_total`, so it can be accessed programmatically |
|
| 86 |
#' (e.g. `result$show_aggregate_total`) without parsing it back out of the source note text. |
|
| 87 |
#' @param digits Whole number or NULL. If not NULL, all RCT and RWE point estimates and |
|
| 88 |
#' confidence limits are rounded to this many decimal places *before* any agreement |
|
| 89 |
#' metric (significance, estimate, SMD) is computed, so that, for example, a RWE estimate |
|
| 90 |
#' of 0.799 and an RCT confidence limit of 0.80 lead to an estimate agreement when `digits = 2`. |
|
| 91 |
#' The estimates and limits are displayed with this many decimals (the SMD is always shown |
|
| 92 |
#' with 2). Estimates must be positive and each interval must satisfy |
|
| 93 |
#' `lower <= estimate <= upper`, checked after rounding. Missing values are allowed and |
|
| 94 |
#' give an "NA" agreement result for the affected metric. A pre-existing `smd_value` column in `x` |
|
| 95 |
#' is not recomputed or rounded. Default: 2. Set to NULL to disable rounding. |
|
| 96 |
#' |
|
| 97 |
#' @return A gt table object with formatted agreement metrics |
|
| 98 |
#' |
|
| 99 |
#' @md |
|
| 100 |
#' |
|
| 101 |
#' @examples |
|
| 102 |
#' # Example 1: One analysis per database with grouping |
|
| 103 |
#' |
|
| 104 |
#'x <- tibble::tribble( |
|
| 105 |
#'~Analysis, ~Database, ~rct_estimate, ~rct_lower, ~rct_upper, ~rwe_estimate, ~rwe_lower, ~rwe_upper, |
|
| 106 |
#'"Main analysis", "Database 1", 0.87, 0.78, 0.97, 0.82, 0.76, 0.87, |
|
| 107 |
#'"Main analysis", "Database 2", 0.5, 0.4, 0.6, 2.0, 1.8, 2.2, |
|
| 108 |
#'"Main analysis", "Database 3", 0.8, 0.7, 0.9, 1.5, 1.4, 1.6 |
|
| 109 |
#') |
|
| 110 |
#' |
|
| 111 |
#' agreement_metrics(x, analysis_col = "Analysis", group_col = "Database") |
|
| 112 |
#' |
|
| 113 |
#' # Example 2: Three analyses, one database without grouping |
|
| 114 |
#' |
|
| 115 |
#' x <- tibble::tribble( |
|
| 116 |
#' ~Analysis, ~rct_estimate, ~rct_lower, ~rct_upper, ~rwe_estimate, ~rwe_lower, ~rwe_upper, |
|
| 117 |
#' "Main analysis", 0.87, 0.78, 0.97, 0.82, 0.76, 0.87, |
|
| 118 |
#' "Sensitivity analysis 1", 0.5, 0.4, 0.6, 2.0, 1.8, 2.2, |
|
| 119 |
#' "Sensitivity analysis 2", 0.8, 0.7, 0.9, 1.5, 1.4, 1.6 |
|
| 120 |
#' ) |
|
| 121 |
#' |
|
| 122 |
#' agreement_metrics(x, analysis_col = "Analysis") |
|
| 123 |
#' |
|
| 124 |
#' # Example 3: Three analyses per database with grouping |
|
| 125 |
#' x <- tibble::tribble( |
|
| 126 |
#' ~Analysis, ~Database, ~rct_estimate, ~rct_lower, ~rct_upper, ~rwe_estimate, ~rwe_lower, ~rwe_upper, |
|
| 127 |
#' "Main analysis", "Database 1", 0.87, 0.78, 0.97, 0.82, 0.76, 0.87, |
|
| 128 |
#' "Main analysis", "Database 2", 0.5, 0.4, 0.6, 2.0, 1.8, 2.2, |
|
| 129 |
#' "Main analysis", "Database 3", 0.8, 0.7, 0.9, 1.5, 1.4, 1.6, |
|
| 130 |
#' "Sensitivity analysis 1", "Database 1", 0.5, 0.4, 0.6, 2.0, 1.8, 2.2, |
|
| 131 |
#' "Sensitivity analysis 1", "Database 2", 0.8, 0.7, 0.9, 1.5, 1.4, 1.6, |
|
| 132 |
#' "Sensitivity analysis 1", "Database 3", 0.87, 0.78, 0.97, 0.82, 0.76, 0.87, |
|
| 133 |
#' "Sensitivity analysis 2", "Database 1", 0.87, 0.78, 0.97, 0.82, 0.76, 0.87, |
|
| 134 |
#' "Sensitivity analysis 2", "Database 2", 0.5, 0.4, 0.6, 2.0, 1.8, 2.2, |
|
| 135 |
#' "Sensitivity analysis 2", "Database 3", 0.8, 0.7, 0.9, 1.5, 1.4, 1.6 |
|
| 136 |
#' ) |
|
| 137 |
#' |
|
| 138 |
#' agreement_metrics(x, analysis_col = "Analysis", group_col = "Database") |
|
| 139 |
#' |
|
| 140 |
#' @export |
|
| 141 |
agreement_metrics <- function( |
|
| 142 |
x, |
|
| 143 |
analysis_col, |
|
| 144 |
group_col = NULL, # subgroup, e.g, database |
|
| 145 |
estimate_label = "HR (95% CI)", |
|
| 146 |
smd_threshold = 1.96, # customizable threshold for SMD agreement |
|
| 147 |
metrics = c( |
|
| 148 |
"significance_agreement", |
|
| 149 |
"estimate_agreement", |
|
| 150 |
"smd_agreement" |
|
| 151 |
), # which agreement metrics to compute and display |
|
| 152 |
show_aggregate = FALSE, # add a per-metric pooled agreement % summary row |
|
| 153 |
show_aggregate_total = TRUE, # add a pooled agreement % source note across all metrics |
|
| 154 |
smd_scale = c("log", "identity", "logit", "cloglog"), # transform applied before the SMD calculation
|
|
| 155 |
digits = 2 # rounding of estimates/CIs before agreement metrics are computed (NULL = no rounding) |
|
| 156 |
) {
|
|
| 157 |
# input checks |
|
| 158 | 69x |
assertthat::assert_that( |
| 159 | 69x |
is.data.frame(x), |
| 160 | 69x |
msg = "<x> is not a data.frame or tibble" |
| 161 |
) |
|
| 162 | 68x |
value_cols <- c( |
| 163 | 68x |
"rct_estimate", |
| 164 | 68x |
"rct_lower", |
| 165 | 68x |
"rct_upper", |
| 166 | 68x |
"rwe_estimate", |
| 167 | 68x |
"rwe_lower", |
| 168 | 68x |
"rwe_upper" |
| 169 |
) |
|
| 170 | 68x |
assertthat::assert_that( |
| 171 | 68x |
all(value_cols %in% colnames(x)), |
| 172 | 68x |
msg = "<x> does not contain all required columns" |
| 173 |
) |
|
| 174 | 67x |
assertthat::assert_that( |
| 175 | 67x |
all(vapply(x[value_cols], is.numeric, logical(1))), |
| 176 | 67x |
msg = "RCT/RWE estimate and confidence limit columns must be numeric" |
| 177 |
) |
|
| 178 | 66x |
assertthat::assert_that( |
| 179 | 66x |
!is.null(analysis_col), |
| 180 | 66x |
msg = "<analysis_col> cannot be NULL" |
| 181 |
) |
|
| 182 | 66x |
assertthat::assert_that( |
| 183 | 66x |
analysis_col %in% colnames(x), |
| 184 | 66x |
msg = sprintf("<analysis_col> '%s' not found in <x>", analysis_col)
|
| 185 |
) |
|
| 186 | 65x |
if (!is.null(group_col)) {
|
| 187 | 1x |
assertthat::assert_that( |
| 188 | 1x |
group_col %in% colnames(x), |
| 189 | 1x |
msg = "<group_col> not in <x>" |
| 190 |
) |
|
| 191 |
} |
|
| 192 | ||
| 193 | 65x |
valid_metrics <- c( |
| 194 | 65x |
"significance_agreement", |
| 195 | 65x |
"estimate_agreement", |
| 196 | 65x |
"smd_agreement" |
| 197 |
) |
|
| 198 | 65x |
assertthat::assert_that( |
| 199 | 65x |
is.character(metrics) && length(metrics) >= 1, |
| 200 | 65x |
msg = "<metrics> must be a non-empty character vector" |
| 201 |
) |
|
| 202 | 64x |
assertthat::assert_that( |
| 203 | 64x |
all(metrics %in% valid_metrics), |
| 204 | 64x |
msg = sprintf( |
| 205 | 64x |
"<metrics> must be a subset of: %s", |
| 206 | 64x |
paste(valid_metrics, collapse = ", ") |
| 207 |
) |
|
| 208 |
) |
|
| 209 |
# canonicalize order so output columns/labels/styling are stable |
|
| 210 |
# regardless of the order metrics was supplied in |
|
| 211 | 63x |
metrics <- valid_metrics[valid_metrics %in% metrics] |
| 212 | ||
| 213 | 63x |
assertthat::assert_that( |
| 214 | 63x |
is.logical(show_aggregate) && length(show_aggregate) == 1, |
| 215 | 63x |
msg = "<show_aggregate> must be a single logical value" |
| 216 |
) |
|
| 217 | 62x |
assertthat::assert_that( |
| 218 | 62x |
is.logical(show_aggregate_total) && length(show_aggregate_total) == 1, |
| 219 | 62x |
msg = "<show_aggregate_total> must be a single logical value" |
| 220 |
) |
|
| 221 | ||
| 222 | 61x |
smd_scale <- match.arg(smd_scale) |
| 223 | ||
| 224 | 60x |
assertthat::assert_that( |
| 225 | 60x |
is.null(digits) || |
| 226 | 60x |
(is.numeric(digits) && |
| 227 | 60x |
length(digits) == 1 && |
| 228 | 60x |
!is.na(digits) && |
| 229 | 60x |
digits >= 0 && |
| 230 | 60x |
digits == round(digits)), |
| 231 | 60x |
msg = "<digits> must be NULL or a single non-negative whole number" |
| 232 |
) |
|
| 233 | 56x |
assertthat::assert_that( |
| 234 | 56x |
is.numeric(smd_threshold) && |
| 235 | 56x |
length(smd_threshold) == 1 && |
| 236 | 56x |
!is.na(smd_threshold) && |
| 237 | 56x |
smd_threshold > 0, |
| 238 | 56x |
msg = "<smd_threshold> must be a single positive number" |
| 239 |
) |
|
| 240 | ||
| 241 |
# round estimates and confidence limits up front, so that all agreement |
|
| 242 |
# metrics (including the SMD) are computed on the rounded values, e.g. a |
|
| 243 |
# RWE estimate of 0.799 and an RCT limit of 0.80 agree when digits = 2 |
|
| 244 | 53x |
if (!is.null(digits)) {
|
| 245 | 48x |
x <- x |> |
| 246 | 48x |
dplyr::mutate(dplyr::across( |
| 247 | 48x |
dplyr::all_of(value_cols), |
| 248 | 48x |
~ round(.x, digits = digits) |
| 249 |
)) |
|
| 250 |
} |
|
| 251 | ||
| 252 |
# check for non-positive values (after rounding, since a small positive |
|
| 253 |
# estimate can round to 0 and would otherwise silently yield an NA SMD); |
|
| 254 |
# missing values are allowed and yield "NA" agreement results |
|
| 255 | 53x |
rounding_hint <- if (is.null(digits)) {
|
| 256 |
"" |
|
| 257 |
} else {
|
|
| 258 | 48x |
sprintf(" (after rounding to <digits> = %s)", digits)
|
| 259 |
} |
|
| 260 | 53x |
assertthat::assert_that( |
| 261 | 53x |
all(x$rct_estimate > 0, na.rm = TRUE), |
| 262 | 53x |
msg = paste0("RCT estimates must be positive for hazard ratios", rounding_hint)
|
| 263 |
) |
|
| 264 | 51x |
assertthat::assert_that( |
| 265 | 51x |
all(x$rwe_estimate > 0, na.rm = TRUE), |
| 266 | 51x |
msg = paste0("RWE estimates must be positive for hazard ratios", rounding_hint)
|
| 267 |
) |
|
| 268 | ||
| 269 |
# check that each interval is well-formed: lower <= estimate <= upper |
|
| 270 | 51x |
for (src in c("rct", "rwe")) {
|
| 271 | 101x |
est <- x[[paste0(src, "_estimate")]] |
| 272 | 101x |
lo <- x[[paste0(src, "_lower")]] |
| 273 | 101x |
up <- x[[paste0(src, "_upper")]] |
| 274 | 101x |
bad <- which(lo > up | est < lo | est > up) |
| 275 | 101x |
assertthat::assert_that( |
| 276 | 101x |
length(bad) == 0, |
| 277 | 101x |
msg = sprintf( |
| 278 | 101x |
"%s estimate must lie within its confidence limits (lower <= estimate <= upper)%s; check row(s): %s", |
| 279 | 101x |
toupper(src), |
| 280 | 101x |
rounding_hint, |
| 281 | 101x |
paste(bad, collapse = ", ") |
| 282 |
) |
|
| 283 |
) |
|
| 284 |
} |
|
| 285 | ||
| 286 |
# if no smd_value present in the results table, calculate it |
|
| 287 | 49x |
if ("smd_agreement" %in% metrics && !"smd_value" %in% colnames(x)) {
|
| 288 | 45x |
transform_fn <- switch( |
| 289 | 45x |
smd_scale, |
| 290 | 45x |
log = log, |
| 291 | 45x |
identity = identity, |
| 292 | 45x |
logit = stats::qlogis, |
| 293 |
# complementary log-log: correct scale for comparing survival |
|
| 294 |
# probabilities (matches survival::survfit(conf.type = "log-log")) |
|
| 295 | 45x |
cloglog = function(p) log(-log(p)) |
| 296 |
) |
|
| 297 | ||
| 298 | 45x |
x <- x |> |
| 299 | 45x |
dplyr::rowwise() |> |
| 300 | 45x |
dplyr::mutate( |
| 301 | 45x |
smd_value = tryCatch( |
| 302 |
{
|
|
| 303 |
# cloglog is a decreasing function of its input, unlike the other |
|
| 304 |
# transforms; pmin()/pmax() re-sort the transformed bounds so |
|
| 305 |
# <lower> stays below <upper> regardless of the transform's |
|
| 306 |
# direction (a no-op for the increasing transforms above) |
|
| 307 | 64x |
rct_t_lower <- transform_fn(rct_lower) |
| 308 | 64x |
rct_t_upper <- transform_fn(rct_upper) |
| 309 | 64x |
rwe_t_lower <- transform_fn(rwe_lower) |
| 310 | 64x |
rwe_t_upper <- transform_fn(rwe_upper) |
| 311 | 64x |
smd_agreement( |
| 312 | 64x |
rct_estimate = transform_fn(rct_estimate), |
| 313 | 64x |
rct_lower = pmin(rct_t_lower, rct_t_upper), |
| 314 | 64x |
rct_upper = pmax(rct_t_lower, rct_t_upper), |
| 315 | 64x |
rwe_estimate = transform_fn(rwe_estimate), |
| 316 | 64x |
rwe_lower = pmin(rwe_t_lower, rwe_t_upper), |
| 317 | 64x |
rwe_upper = pmax(rwe_t_lower, rwe_t_upper) |
| 318 |
) |
|
| 319 |
}, |
|
| 320 | 45x |
error = function(e) {
|
| 321 | 8x |
warning(sprintf("SMD calculation failed: %s", e$message))
|
| 322 | 45x |
NA_real_ |
| 323 |
} |
|
| 324 |
) |
|
| 325 |
) |> |
|
| 326 | 45x |
dplyr::ungroup() |
| 327 |
} |
|
| 328 | ||
| 329 |
# calculate agreement metrics (only the ones requested via <metrics>) |
|
| 330 | 49x |
if ("significance_agreement" %in% metrics) {
|
| 331 | 42x |
x <- x |> |
| 332 | 42x |
dplyr::mutate( |
| 333 |
# significance agreement: RCT and RWE agree if their CIs fall in the |
|
| 334 |
# same position relative to the null (both entirely above, both |
|
| 335 |
# entirely below, or both straddling/touching it) |
|
| 336 | 42x |
significance_agreement = dplyr::case_when( |
| 337 |
# missing confidence limits: agreement cannot be assessed |
|
| 338 | 42x |
is.na(rct_lower) | is.na(rct_upper) | is.na(rwe_lower) | is.na(rwe_upper) ~ "NA", |
| 339 |
# both significantly above the null |
|
| 340 | 42x |
rct_lower > 1 & rwe_lower > 1 ~ "Yes", |
| 341 |
# both significantly below the null |
|
| 342 | 42x |
rct_upper < 1 & rwe_upper < 1 ~ "Yes", |
| 343 |
# both null (CI includes 1) |
|
| 344 | 42x |
(rct_lower <= 1 & rct_upper >= 1) & |
| 345 | 42x |
(rwe_lower <= 1 & rwe_upper >= 1) ~ "Yes", |
| 346 |
# all other cases |
|
| 347 | 42x |
TRUE ~ "No" |
| 348 |
) |
|
| 349 |
) |
|
| 350 |
} |
|
| 351 | ||
| 352 | 49x |
if ("estimate_agreement" %in% metrics) {
|
| 353 | 42x |
x <- x |> |
| 354 | 42x |
dplyr::mutate( |
| 355 | 42x |
estimate_agreement = dplyr::case_when( |
| 356 | 42x |
is.na(rwe_estimate) | is.na(rct_lower) | is.na(rct_upper) ~ "NA", |
| 357 | 42x |
rwe_estimate >= rct_lower & rwe_estimate <= rct_upper ~ "Yes", |
| 358 | 42x |
TRUE ~ "No" |
| 359 |
) |
|
| 360 |
) |
|
| 361 |
} |
|
| 362 | ||
| 363 | 49x |
if ("smd_agreement" %in% metrics) {
|
| 364 | 45x |
x <- x |> |
| 365 | 45x |
dplyr::mutate( |
| 366 | 45x |
smd_agreement = dplyr::case_when( |
| 367 | 45x |
is.na(smd_value) ~ "NA", |
| 368 | 45x |
abs(smd_value) < smd_threshold ~ "Yes", |
| 369 | 45x |
TRUE ~ "No" |
| 370 |
) |
|
| 371 |
) |
|
| 372 |
} |
|
| 373 | ||
| 374 |
# format table for display |
|
| 375 | 49x |
x_format <- x |> |
| 376 | 49x |
dplyr::mutate( |
| 377 |
# estimates/limits are shown with the same precision they were rounded |
|
| 378 |
# to (2 decimals if rounding is off); the SMD is always shown with 2 |
|
| 379 | 49x |
dplyr::across( |
| 380 | 49x |
dplyr::all_of(value_cols), |
| 381 | 49x |
~ sprintf("%.*f", if (is.null(digits)) 2L else as.integer(digits), .x)
|
| 382 |
), |
|
| 383 | 49x |
dplyr::across( |
| 384 | 49x |
dplyr::any_of("smd_value"),
|
| 385 | 49x |
~ sprintf("%.2f", .x)
|
| 386 |
) |
|
| 387 |
) |> |
|
| 388 | 49x |
dplyr::mutate( |
| 389 | 49x |
RCT = glue::glue("{rct_estimate} ({rct_lower} - {rct_upper})"),
|
| 390 | 49x |
RWE = glue::glue("{rwe_estimate} ({rwe_lower} - {rwe_upper})")
|
| 391 |
) |
|
| 392 | ||
| 393 | 49x |
if ("smd_agreement" %in% metrics) {
|
| 394 | 45x |
x_format <- x_format |> |
| 395 | 45x |
dplyr::mutate( |
| 396 | 45x |
smd_agreement = glue::glue("{smd_agreement} ({smd_value})")
|
| 397 |
) |
|
| 398 |
} |
|
| 399 | ||
| 400 | 49x |
x_format <- x_format |> |
| 401 | 49x |
dplyr::select( |
| 402 | 49x |
dplyr::all_of(c(analysis_col, group_col)), |
| 403 | 49x |
"RCT", |
| 404 | 49x |
"RWE", |
| 405 | 49x |
dplyr::all_of(metrics) |
| 406 |
) |
|
| 407 | ||
| 408 |
# conditional grouping |
|
| 409 | 49x |
if (!is.null(group_col)) {
|
| 410 | 1x |
x_format <- x_format |> |
| 411 | 1x |
dplyr::group_by(dplyr::across(dplyr::all_of(group_col))) |
| 412 |
} |
|
| 413 | ||
| 414 |
# pooled "Yes / (Yes + No)" agreement percentage for a metric column; |
|
| 415 |
# "NA" cells (e.g. failed SMD calculations) are excluded from the |
|
| 416 |
# denominator. Works on both plain "Yes"/"No" columns and the glued |
|
| 417 |
# "Yes (0.32)"-style smd_agreement display column via str_detect |
|
| 418 | 49x |
pct_agreement <- function(col) {
|
| 419 | 47x |
yes <- sum(stringr::str_detect(as.character(col), "Yes")) |
| 420 | 47x |
no <- sum(stringr::str_detect(as.character(col), "No")) |
| 421 | 47x |
n <- yes + no |
| 422 | 47x |
if (n == 0) {
|
| 423 | 49x |
NA_character_ |
| 424 |
} else {
|
|
| 425 | 46x |
sprintf("%.0f%% (%d/%d)", 100 * yes / n, yes, n)
|
| 426 |
} |
|
| 427 |
} |
|
| 428 | ||
| 429 |
# create and format gt table |
|
| 430 | 49x |
all_labels <- list( |
| 431 | 49x |
significance_agreement = gt::md( |
| 432 | 49x |
"Statistical <br> significance <br> agreement" |
| 433 |
), |
|
| 434 | 49x |
estimate_agreement = gt::md("Estimate <br> agreement"),
|
| 435 | 49x |
smd_agreement = "SMD" |
| 436 |
) |
|
| 437 | ||
| 438 | 49x |
x_gt <- x_format |> |
| 439 | 49x |
gt::gt() |> |
| 440 | 49x |
gt::tab_spanner( |
| 441 | 49x |
label = estimate_label, |
| 442 | 49x |
columns = c("RCT", "RWE")
|
| 443 |
) |> |
|
| 444 | 49x |
gt::cols_label(!!!all_labels[metrics]) |
| 445 | ||
| 446 |
# apply styles for agreement columns |
|
| 447 | 49x |
for (col in metrics) {
|
| 448 | 129x |
x_gt <- x_gt |> |
| 449 | 129x |
gt::tab_style( |
| 450 | 129x |
style = gt::cell_text(color = "darkgreen"), |
| 451 | 129x |
locations = gt::cells_body( |
| 452 | 129x |
columns = !!col, |
| 453 | 129x |
rows = if (col == "smd_agreement") {
|
| 454 | 45x |
stringr::str_detect(x_format[[col]], "Yes") |
| 455 |
} else {
|
|
| 456 | 84x |
x_format[[col]] == "Yes" |
| 457 |
} |
|
| 458 |
) |
|
| 459 |
) |> |
|
| 460 | 129x |
gt::tab_style( |
| 461 | 129x |
style = gt::cell_text(color = "darkred"), |
| 462 | 129x |
locations = gt::cells_body( |
| 463 | 129x |
columns = !!col, |
| 464 | 129x |
rows = if (col == "smd_agreement") {
|
| 465 | 45x |
stringr::str_detect(x_format[[col]], "No") |
| 466 |
} else {
|
|
| 467 | 84x |
x_format[[col]] == "No" |
| 468 |
} |
|
| 469 |
) |
|
| 470 |
) |
|
| 471 |
} |
|
| 472 | ||
| 473 |
# per-metric pooled agreement % summary row |
|
| 474 |
# note: the summary function is inlined (rather than calling the local |
|
| 475 |
# pct_agreement() helper) because gt evaluates <fns> lazily at render |
|
| 476 |
# time, outside the scope in which a local closure would be resolvable |
|
| 477 | 49x |
if (show_aggregate) {
|
| 478 | 4x |
x_gt <- x_gt |> |
| 479 | 4x |
gt::grand_summary_rows( |
| 480 | 4x |
columns = dplyr::all_of(metrics), |
| 481 | 4x |
fns = list( |
| 482 | 4x |
`% Agreement` = ~ {
|
| 483 | 9x |
yes <- sum(stringr::str_detect(as.character(.), "Yes")) |
| 484 | 9x |
no <- sum(stringr::str_detect(as.character(.), "No")) |
| 485 | 9x |
n <- yes + no |
| 486 | 9x |
if (n == 0) {
|
| 487 | 4x |
NA_character_ |
| 488 |
} else {
|
|
| 489 | 8x |
sprintf("%.0f%% (%d/%d)", 100 * yes / n, yes, n)
|
| 490 |
} |
|
| 491 |
} |
|
| 492 |
), |
|
| 493 | 4x |
missing_text = "--" |
| 494 |
) |
|
| 495 |
} |
|
| 496 | ||
| 497 |
# footnote estimate label |
|
| 498 | 49x |
footnote_label <- switch( |
| 499 | 49x |
estimate_label, |
| 500 | 49x |
"HR (95% CI)" = "HR = Hazard ratio", |
| 501 | 49x |
"OR (95% CI)" = "OR = Odds ratio", |
| 502 | 49x |
"RR (95% CI)" = "RR = Rate ratio", |
| 503 | 49x |
NULL |
| 504 |
) |
|
| 505 | ||
| 506 | 49x |
smd_scale_label <- switch( |
| 507 | 49x |
smd_scale, |
| 508 | 49x |
log = "based on log-transformed estimates", |
| 509 | 49x |
identity = "based on estimates' original scale", |
| 510 | 49x |
logit = "based on logit-transformed estimates", |
| 511 | 49x |
cloglog = "based on complementary log-log-transformed survival probabilities" |
| 512 |
) |
|
| 513 | ||
| 514 | 49x |
abbrev_parts <- c( |
| 515 | 49x |
"CI = Confidence interval", |
| 516 | 49x |
footnote_label, |
| 517 | 49x |
"RCT = Randomized controlled trial", |
| 518 | 49x |
"RWE = Real-world evidence", |
| 519 | 49x |
if ("smd_agreement" %in% metrics) {
|
| 520 | 45x |
sprintf("SMD = standardized mean difference (%s)", smd_scale_label)
|
| 521 |
} |
|
| 522 |
) |
|
| 523 | 49x |
abbreviations <- paste0( |
| 524 | 49x |
"Abbreviations: ", |
| 525 | 49x |
paste(abbrev_parts, collapse = ", ") |
| 526 |
) |
|
| 527 | ||
| 528 |
# Add bold styling for headers |
|
| 529 | 49x |
x_gt <- x_gt |> |
| 530 | 49x |
gt::tab_style( |
| 531 | 49x |
style = gt::cell_text(weight = "bold"), |
| 532 | 49x |
locations = gt::cells_column_labels() |
| 533 |
) |> |
|
| 534 | 49x |
gt::tab_style( |
| 535 | 49x |
style = gt::cell_text(weight = "bold"), |
| 536 | 49x |
locations = gt::cells_column_spanners() |
| 537 |
) |> |
|
| 538 | 49x |
gt::tab_style( |
| 539 | 49x |
style = gt::cell_text(weight = "bold"), |
| 540 | 49x |
locations = gt::cells_row_groups() |
| 541 |
) |> |
|
| 542 | 49x |
gt::tab_footnote(abbreviations) |
| 543 | ||
| 544 |
# pooled agreement % across all selected metrics combined |
|
| 545 | 49x |
if (show_aggregate_total) {
|
| 546 | 47x |
total_pct <- pct_agreement(unlist(x_format[metrics])) |
| 547 | 47x |
x_gt <- x_gt |> |
| 548 | 47x |
gt::tab_source_note( |
| 549 | 47x |
glue::glue("Overall agreement across all metrics: {total_pct}")
|
| 550 |
) |
|
| 551 |
# also expose the pooled percentage directly on the returned object, |
|
| 552 |
# so callers don't have to parse it back out of the source note text |
|
| 553 | 47x |
x_gt$show_aggregate_total <- total_pct |
| 554 |
} |
|
| 555 | ||
| 556 | 49x |
return(x_gt) |
| 557 |
} |
| 1 |
#' Wrapper around \code{gtsummary::tbl_summary()} to create a beautiful Table 1 quickly
|
|
| 2 |
#' |
|
| 3 |
#' @description |
|
| 4 |
#' Builds a table 1 using the \code{gtsummary} package. The function takes a one-row-per-patient dataframe
|
|
| 5 |
#' and creates a summary table of covariates stratified by treatment group. |
|
| 6 |
#' |
|
| 7 |
#' @details |
|
| 8 |
#' Wrapper for \code{\link[gtsummary]{tbl_summary}}.
|
|
| 9 |
#' |
|
| 10 |
#' The function \code{create_table1} is a wrapper around the \code{gtsummary::tbl_summary()} function.
|
|
| 11 |
#' It is designed to create a summary table of covariates stratified by treatment group. |
|
| 12 |
#' The function requires a one-row-per-patient dataframe and creates a summary table of covariates stratified by treatment group. |
|
| 13 |
#' |
|
| 14 |
#' @importFrom smd smd |
|
| 15 |
#' |
|
| 16 |
#' @param x dataframe with individual-level patient data in a one-row-per-patient format with treatment stratification variable and covariates to be displayed in the Table 1 |
|
| 17 |
#' @param covariates character vector of columns/covariate names to be displayed in Table 1 |
|
| 18 |
#' @param covariates_labels named character vector or list of formulas specifying variables labels of covariate-label pairs to display in table |
|
| 19 |
#' @param treat character specifying column name of treatment variable |
|
| 20 |
#' @param explicit_na_categorical logical, should missings in categorical variables be explicitly included as a separate category (default is TRUE) |
|
| 21 |
#' |
|
| 22 |
#' @return object of class "tbl_summary" "gtsummary" |
|
| 23 |
#' |
|
| 24 |
#' @export |
|
| 25 |
#' |
|
| 26 |
#' @examples |
|
| 27 |
#' \dontrun{
|
|
| 28 |
#' library(encore.analytics) |
|
| 29 |
#' |
|
| 30 |
#' # simulate a cohort with 1,000 patients with 20% missing data |
|
| 31 |
#' data <- simulate_data( |
|
| 32 |
#' n = 1000, |
|
| 33 |
#' imposeNA = TRUE, |
|
| 34 |
#' propNA = 0.2 |
|
| 35 |
#' ) |
|
| 36 |
#' |
|
| 37 |
#' # create a Table 1 |
|
| 38 |
#' create_table1( |
|
| 39 |
#' x = data, |
|
| 40 |
#' covariates = c("dem_age_index_cont", "dem_sex_cont", "c_smoking_history"),
|
|
| 41 |
#' covariates_labels = list( |
|
| 42 |
#' "dem_age_index_cont" = "Age", |
|
| 43 |
#' "dem_sex_cont" = "Sex", |
|
| 44 |
#' "c_smoking_history" = "Smoking history" |
|
| 45 |
#' ), |
|
| 46 |
#' treat = "treat" |
|
| 47 |
#' ) |
|
| 48 |
#'} |
|
| 49 |
create_table1 <- function(x = NULL, |
|
| 50 |
covariates = NULL, |
|
| 51 |
covariates_labels = NULL, |
|
| 52 |
treat = "treat", |
|
| 53 |
explicit_na_categorical = TRUE |
|
| 54 |
){
|
|
| 55 | ||
| 56 | 8x |
install_on_demand("cardx")
|
| 57 | 8x |
install_on_demand("smd")
|
| 58 | ||
| 59 |
# check input |
|
| 60 | 8x |
assertthat::assert_that(any(class(x) %in% c("data.frame", "tibble")), msg = "<x> is not a data.frame or tibble")
|
| 61 | 7x |
assertthat::assert_that(all(covariates %in% colnames(x)), msg = "some or all of <covariates> not in <x>") |
| 62 | 6x |
assertthat::assert_that(is.list(covariates_labels) | is.null(covariates_labels), msg = "<covariates_labels> needs to be a named character vector or list of formulas specifying variables labels") |
| 63 | 5x |
assertthat::assert_that(length(covariates_labels) == length(covariates) | is.null(covariates_labels), msg = "<covariates_labels> must be a named character vector or list of formulas specifying variables labels") |
| 64 | 5x |
assertthat::assert_that(length(treat) == 1, msg = "<treat> must be a single character string") |
| 65 | 5x |
assertthat::assert_that(treat %in% colnames(x), msg = "<treat> not in <x>") |
| 66 | 4x |
assertthat::assert_that(is.logical(explicit_na_categorical), msg = "<explicit_na_categorical> must be a logical TRUE or FALSE") |
| 67 | 3x |
assertthat::assert_that(length(covariates) > 0, msg = "<covariates> must be a non-empty character vector") |
| 68 | ||
| 69 |
# assign treatment variable |
|
| 70 |
# and subset columns |
|
| 71 | 3x |
x[["strata"]] <- x[[treat]] |
| 72 | 3x |
x <- x[c(covariates, "strata")] |
| 73 | ||
| 74 | 3x |
if(explicit_na_categorical){
|
| 75 | ||
| 76 |
# encode NA explicitly as "Missing" (only applicable to categorical) |
|
| 77 | 3x |
x <- x |> |
| 78 |
# logicals into categorical |
|
| 79 | 3x |
dplyr::mutate( |
| 80 | 3x |
dplyr::across( |
| 81 | 3x |
dplyr::where(function(.x) is.logical(.x)), |
| 82 | 3x |
.fns = function(.x) factor(.x, levels = c("TRUE", "FALSE", "Missing"))
|
| 83 |
) |
|
| 84 |
) |> |
|
| 85 |
# for all categorical, assign a "Missing" category |
|
| 86 | 3x |
dplyr::mutate( |
| 87 | 3x |
dplyr::across( |
| 88 | 3x |
dplyr::where(function(.x) is.character(.x) | is.factor(.x)), |
| 89 | 3x |
.fns = function(.x) forcats::fct_na_value_to_level(.x, level = "Missing") |
| 90 |
) |
|
| 91 |
) |> |
|
| 92 |
# drop "Missing" level for covariates which are fully observed/have no NA |
|
| 93 | 3x |
droplevels() |
| 94 |
} |
|
| 95 | ||
| 96 | 3x |
table1 <- x |> |
| 97 | 3x |
gtsummary::tbl_summary( |
| 98 | 3x |
by = "strata", |
| 99 | 3x |
missing = "ifany", |
| 100 | 3x |
missing_text = "Missing", |
| 101 | 3x |
label = covariates_labels |
| 102 |
) |> |
|
| 103 | 3x |
gtsummary::add_difference(test = dplyr::everything() ~ "smd") |> |
| 104 | 3x |
gtsummary::modify_column_hide(conf.low) |> |
| 105 | 3x |
gtsummary::add_overall() |> |
| 106 | 3x |
gtsummary::modify_header( |
| 107 | 3x |
label ~ "**Patient characteristic**", |
| 108 | 3x |
stat_0 ~ "**Total** <br> N = {N}",
|
| 109 | 3x |
stat_1 ~ "**{level}** <br> N = {n} <br> ({style_percent(p, digits=1)}%)",
|
| 110 | 3x |
stat_2 ~ "**{level}** <br> N = {n} <br> ({style_percent(p, digits=1)}%)"#,
|
| 111 |
#test_result ~ "**SMD**" |
|
| 112 |
) |> |
|
| 113 | 3x |
gtsummary::modify_spanning_header(c("stat_1", "stat_2") ~ "**Treatment received**")
|
| 114 | ||
| 115 | 3x |
return(table1) |
| 116 | ||
| 117 |
} |
| 1 |
#' Manually fit and pool Cox proportional hazards model results from multiple imputed datasets |
|
| 2 |
#' |
|
| 3 |
#' @description |
|
| 4 |
#' This function manually fits and pools results from Cox proportional hazards models using |
|
| 5 |
#' a list of imputed datasets. It leverages the `mice::as.mira` and `mice::pool` functions to |
|
| 6 |
#' ensure proper pooling of results across multiple imputations using Rubin's rules. |
|
| 7 |
#' |
|
| 8 |
#' @details |
|
| 9 |
#' The function takes the imputed datasets (\code{x}) as a list of data frames, or as a
|
|
| 10 |
#' mimids, wimids or mids object (see the sections "Input types and default model arguments" and |
|
| 11 |
#' "Precedence of arguments in ..." below), as well as a formula for the Cox proportional hazards model. |
|
| 12 |
#' Weights and clusters of lists of data frames are specified explicitly via \code{...}, e.g.,
|
|
| 13 |
#' \code{weights = weights} for weighted data and \code{weights = weights, cluster = subclass}
|
|
| 14 |
#' for matched data. A list is particularly useful when working with imputed datasets that are not in the |
|
| 15 |
#' form of \code{mimids} or \code{wimids} objects, such as when intermediate steps like raking weights
|
|
| 16 |
#' (via \code{raking_weights}) are applied. Arguments of the \code{\link[survival]{coxph}} call
|
|
| 17 |
#' can be supplied via \code{...}, e.g., \code{ties = "breslow"}.
|
|
| 18 |
#' |
|
| 19 |
#' \strong{Breaking change in 0.4.0}: previously, a list of data frames had to contain a column named
|
|
| 20 |
#' \code{weights}, which was used automatically, as was a column named \code{subclass} (cluster
|
|
| 21 |
#' membership of matched datasets). Weights and clusters are no longer applied automatically to lists, in line |
|
| 22 |
#' with \code{\link{km_pooling}}. Update calls such as \code{cox_pooling(x, surv_formula)} to
|
|
| 23 |
#' \code{cox_pooling(x, surv_formula, weights = weights)} (weighted data) or
|
|
| 24 |
#' \code{cox_pooling(x, surv_formula, weights = weights, cluster = subclass)} (matched data).
|
|
| 25 |
#' Calls that are not updated fit an unweighted model and raise a warning that names the unused columns. |
|
| 26 |
#' mimids and wimids objects are not affected. |
|
| 27 |
#' |
|
| 28 |
#' The function follows these steps: |
|
| 29 |
#' 1. Fit a Cox proportional hazards model to each imputed dataset, using the weights and cluster |
|
| 30 |
#' (if any) of the input type or specified via \code{...}.
|
|
| 31 |
#' 2. Convert the list of fitted models into a \code{mira} object using \code{mice::as.mira}.
|
|
| 32 |
#' 3. Pool the results using \code{mice::pool}, which applies Rubin's rules for combining estimates
|
|
| 33 |
#' and variances across imputations. |
|
| 34 |
#' 4. Format the pooled results, including exponentiating the hazard ratios and calculating confidence intervals. |
|
| 35 |
#' |
|
| 36 |
#' @seealso |
|
| 37 |
#' \code{\link{km_pooling}}, \code{\link{raking_weights}}, \code{\link[survival]{coxph}}, \code{\link[mice]{pool}},
|
|
| 38 |
#' \code{\link[mice]{as.mira}}, \code{\link[MatchThem]{matchthem}}, \code{\link[MatchThem]{weightthem}}
|
|
| 39 |
#' |
|
| 40 |
#' @inheritParams pooling_inputs |
|
| 41 |
#' @param surv_formula A formula for the Cox proportional hazards model (default is \code{Surv(fu_itt_months, death_itt) ~ treat}).
|
|
| 42 |
#' |
|
| 43 |
#' @inheritSection pooling_inputs Input types and default model arguments |
|
| 44 |
#' @inheritSection pooling_inputs Precedence of arguments in \code{...}
|
|
| 45 |
#' @inheritSection pooling_inputs Transparency |
|
| 46 |
#' |
|
| 47 |
#' @return A data frame containing the pooled results, including hazard ratios, confidence intervals, and p-values. |
|
| 48 |
#' |
|
| 49 |
#' @md |
|
| 50 |
#' |
|
| 51 |
#' @export |
|
| 52 |
#' |
|
| 53 |
#' @examples |
|
| 54 |
#' library(encore.analytics) |
|
| 55 |
#' library(mice) |
|
| 56 |
#' library(MatchThem) |
|
| 57 |
#' |
|
| 58 |
#' # Simulate a cohort with 500 patients and 20% missing data |
|
| 59 |
#' data <- simulate_data( |
|
| 60 |
#' n = 500, |
|
| 61 |
#' imposeNA = TRUE, |
|
| 62 |
#' propNA = 0.2 |
|
| 63 |
#' ) |
|
| 64 |
#' |
|
| 65 |
#' # Impute the data |
|
| 66 |
#' set.seed(42) |
|
| 67 |
#' mids <- mice(data, m = 5, print = FALSE) |
|
| 68 |
#' |
|
| 69 |
#' # Fit a propensity score model |
|
| 70 |
#' fit <- as.formula(treat ~ dem_age_index_cont + dem_sex_cont + c_smoking_history) |
|
| 71 |
#' |
|
| 72 |
#' # Weight patients within each imputed dataset |
|
| 73 |
#' wimids <- weightthem( |
|
| 74 |
#' formula = fit, |
|
| 75 |
#' datasets = mids, |
|
| 76 |
#' approach = "within", |
|
| 77 |
#' method = "glm", |
|
| 78 |
#' estimand = "ATO" |
|
| 79 |
#' ) |
|
| 80 |
#' |
|
| 81 |
#' # Define a survival model formula |
|
| 82 |
#' cox_fit <- as.formula(survival::Surv(fu_itt_months, death_itt) ~ treat) |
|
| 83 |
#' |
|
| 84 |
#' # 1) Weighted (wimids) or matched (mimids) object: |
|
| 85 |
#' # weights (and clusters) are set automatically |
|
| 86 |
#' cox_pooling(wimids, surv_formula = cox_fit) |
|
| 87 |
#' |
|
| 88 |
#' # 2) List of data frames: weights are specified explicitly via `...` |
|
| 89 |
#' # (all = FALSE excludes observations with zero weight) |
|
| 90 |
#' wimids_list <- MatchThem::complete(wimids, action = "all", all = FALSE, include = FALSE) |
|
| 91 |
#' cox_pooling(wimids_list, surv_formula = cox_fit, weights = weights) |
|
| 92 |
#' |
|
| 93 |
#' # for matched datasets, additionally specify the matched sets as clusters: |
|
| 94 |
#' # cox_pooling(matched_list, surv_formula = cox_fit, weights = weights, cluster = subclass) |
|
| 95 |
#' |
|
| 96 |
#' # 3) Imputed (mids) object: unweighted Cox model |
|
| 97 |
#' cox_pooling(mids, surv_formula = cox_fit) |
|
| 98 |
#' |
|
| 99 |
#' # 4) Pass additional arguments to the underlying coxph() call |
|
| 100 |
#' cox_pooling(wimids, surv_formula = cox_fit, ties = "breslow") |
|
| 101 |
cox_pooling <- function(x, |
|
| 102 |
surv_formula = stats::as.formula(survival::Surv(fu_itt_months, death_itt) ~ treat), |
|
| 103 |
... |
|
| 104 |
){
|
|
| 105 | ||
| 106 |
# input checks---- |
|
| 107 | ||
| 108 |
# check if surv_formula is a formula |
|
| 109 | 27x |
assertthat::assert_that(inherits(surv_formula, "formula"), msg = "<surv_formula> needs to be a formula") |
| 110 | ||
| 111 |
# bring the input into the form of a list of datasets plus the default |
|
| 112 |
# model arguments of the input type (weights, cluster, ...) |
|
| 113 | 26x |
input <- prepare_pooling_input(x) |
| 114 | ||
| 115 |
# check that ... does not try to override arguments set internally |
|
| 116 |
# (...names() reads argument names without forcing/evaluating them, since |
|
| 117 |
# column names such as `id = patientid` are resolved in each dataset later) |
|
| 118 | 24x |
reserved_dots <- intersect(...names(), c("formula", "data"))
|
| 119 | 24x |
assertthat::assert_that( |
| 120 | 24x |
length(reserved_dots) == 0, |
| 121 | 24x |
msg = paste0( |
| 122 | 24x |
"arguments passed via `...` must not include arguments that are set internally by cox_pooling(): ", |
| 123 | 24x |
paste(reserved_dots, collapse = ", ") |
| 124 |
) |
|
| 125 |
) |
|
| 126 | ||
| 127 |
# capture the *unevaluated* expressions behind `...` (e.g. id = patientid) |
|
| 128 | 23x |
user_args <- as.list(substitute(list(...)))[-1] |
| 129 | ||
| 130 |
# Step 1: fit Cox model to each imputed dataset---- |
|
| 131 |
# merging of default and user arguments, checks, the console message and the |
|
| 132 |
# construction of the coxph() call are handled in fit_models() |
|
| 133 | 23x |
cox_results_list <- fit_models( |
| 134 | 23x |
input = input, |
| 135 | 23x |
fit_fun = quote(survival::coxph), |
| 136 | 23x |
model_label = "Cox proportional hazards model", |
| 137 | 23x |
formula = surv_formula, |
| 138 | 23x |
user_args = user_args |
| 139 | 23x |
)$fits |
| 140 | ||
| 141 |
# Step 2: extract results into a format suitable for pooling |
|
| 142 |
# convert each model into a "mira" object (required for pooling) |
|
| 143 | 22x |
mira_object <- mice::as.mira(unname(cox_results_list)) |
| 144 | ||
| 145 |
# Step 3: pool the results using Rubin's rules |
|
| 146 | 22x |
pooled_results <- mice::pool(mira_object) |
| 147 | ||
| 148 |
# Step 4: format the pooled results |
|
| 149 | 22x |
cox_result <- broom::tidy(pooled_results, exponentiate = TRUE, conf.int = TRUE) |
| 150 | ||
| 151 | 22x |
return(cox_result) |
| 152 | ||
| 153 |
} |
| 1 |
#' Install package on demand |
|
| 2 |
#' |
|
| 3 |
#' @description |
|
| 4 |
#' This function checks if a package is installed. If not, it |
|
| 5 |
#' asks the user if they want to install it. If the user |
|
| 6 |
#' agrees, it installs the package from CRAN. |
|
| 7 |
#' |
|
| 8 |
#' @param pkg a character string with the name of the package |
|
| 9 |
#' @param quiet logical. If TRUE, suppresses messages during |
|
| 10 |
#' @param ... additional arguments passed to \code{install.packages}
|
|
| 11 |
#' |
|
| 12 |
install_on_demand <- function(pkg, quiet = FALSE, ...) {
|
|
| 13 |
# internal function that checks whether package pkg is |
|
| 14 |
# in the library. If not found, it asks the user permission |
|
| 15 |
# to install. |
|
| 16 | 16x |
if (requireNamespace(pkg, quietly = TRUE)) {
|
| 17 | 16x |
return() |
| 18 |
} |
|
| 19 | ! |
if (interactive()) {
|
| 20 | ! |
answer <- utils::askYesNo(paste("Package", pkg, "needed. Install from CRAN?"))
|
| 21 | ! |
if (answer) utils::install.packages(pkg, quiet = quiet) |
| 22 |
} |
|
| 23 |
} |