diff --git a/.lintr b/.lintr index 48c0ac8110..f51e013a38 100644 --- a/.lintr +++ b/.lintr @@ -3,7 +3,7 @@ linters: linters_with_defaults( object_usage_linter=NULL, cyclocomp_linter(complexity_limit = 22), indentation_linter=NULL, - undesirable_function_linter = undesirable_function_linter() + undesirable_function_linter = undesirable_function_linter(symbol_is_undesirable = FALSE) ) exclusions: list( "R/data.R" = Inf, diff --git a/NAMESPACE b/NAMESPACE index 3548055f55..58d568c0ab 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -155,6 +155,7 @@ export(restrict_derivation) export(set_admiral_options) export(signal_duplicate_records) export(slice_derivation) +export(transform_range) export(use_ad_template) export(yn_to_numeric) import(admiraldev) @@ -166,6 +167,7 @@ importFrom(cli,cli_text) importFrom(cli,cli_warn) importFrom(dplyr,across) importFrom(dplyr,arrange) +importFrom(dplyr,between) importFrom(dplyr,bind_cols) importFrom(dplyr,bind_rows) importFrom(dplyr,case_when) diff --git a/NEWS.md b/NEWS.md index 4eb30a7ba0..6a941b2dd3 100644 --- a/NEWS.md +++ b/NEWS.md @@ -6,6 +6,8 @@ `AVALCATx` & `AVALCAxN`. (#2480) - New function `derive_vars_crit_flag()` for deriving criterion flag variables (`CRITy`, `CRITyFL`, `CRITyFLN`). (#2468) +- New function `transform_range()` to transform values from a source range to a +target range. (#2571) - Replace use of `data("sdtm")` with `sdtm <- pharmaverse::sdtm` in templates and vignettes. (#2498) - Remove `dthcaus_source()` calls in `ADSL` template because they are deprecated. (#2517) diff --git a/R/admiral-package.R b/R/admiral-package.R index 321fd90fee..f6866180ff 100644 --- a/R/admiral-package.R +++ b/R/admiral-package.R @@ -2,11 +2,11 @@ #' @family internal #' @import admiraldev #' @importFrom cli cli_abort ansi_collapse cli_div cli_inform cli_text cli_warn -#' @importFrom dplyr across arrange bind_cols bind_rows case_when coalesce -#' desc distinct ends_with everything filter first full_join -#' group_by group_by_at if_else mutate n n_distinct na_if pull -#' rename rename_with row_number select semi_join slice starts_with -#' summarise summarise_all tibble tribble ungroup union lag +#' @importFrom dplyr across arrange between bind_cols bind_rows case_when +#' coalesce desc distinct ends_with everything filter first full_join group_by +#' group_by_at if_else mutate n n_distinct na_if pull rename rename_with +#' row_number select semi_join slice starts_with summarise summarise_all +#' tibble tribble ungroup union lag #' @importFrom hms as_hms #' @importFrom lifecycle deprecate_warn deprecate_stop deprecated #' @importFrom lubridate %--% as_datetime ceiling_date date days duration diff --git a/R/compute_scale.R b/R/compute_scale.R index 5aff81e62b..6f49014866 100644 --- a/R/compute_scale.R +++ b/R/compute_scale.R @@ -74,8 +74,8 @@ compute_scale <- function(source, flip_direction = FALSE, min_n = 1) { # Function argument checks - assert_numeric_vector(source) # nolint: undesirable_function_linter - assert_numeric_vector(source_range, optional = TRUE) + assert_numeric_vector(source) + assert_numeric_vector(source_range, length = 2, optional = TRUE) if (!is.null(target_range) && is.null(source_range)) { cli_abort( c("Argument {.arg source_range} is missing with no default @@ -84,7 +84,7 @@ compute_scale <- function(source, ) ) } - assert_numeric_vector(target_range, optional = TRUE) + assert_numeric_vector(target_range, length = 2, optional = TRUE) if (!is.null(source_range) && is.null(target_range)) { cli_abort( c("Argument {.arg target_range} is missing with no default @@ -97,19 +97,16 @@ compute_scale <- function(source, assert_integer_scalar(min_n, subset = "positive") # Computation - if (sum(!is.na(source)) >= min_n) { # nolint: undesirable_function_linter - target <- mean(source, na.rm = TRUE) # nolint: undesirable_function_linter + if (sum(!is.na(source)) >= min_n) { + target <- mean(source, na.rm = TRUE) if (!is.null(source_range) && !is.null(target_range)) { - scale_constant <- min(target_range) - min(source_range) - scale_coefficient <- (max(target_range) - min(target_range)) / - (max(source_range) - min(source_range)) - - target <- (target + scale_constant) * scale_coefficient - - if (flip_direction == TRUE) { - target <- max(target_range) - target - } + target <- transform_range( + target, + source_range = source_range, + target_range = target_range, + flip_direction = flip_direction + ) } } else { target <- NA diff --git a/R/transform_range.R b/R/transform_range.R new file mode 100644 index 0000000000..545226a37e --- /dev/null +++ b/R/transform_range.R @@ -0,0 +1,114 @@ +#' Transform Range +#' +#' Transforms results from the source range to the target range. For example, +#' for transforming source values 1, 2, 3, 4, 5 to 0, 25, 50, 75, 100. +#' +#' @param source A vector of values to be transformed +#' +#' A numeric vector is expected. +#' +#' @param source_range The permitted source range +#' +#' A numeric vector containing two elements is expected, representing the +#' lower and upper bounds of the permitted source range. +#' +#' @param target_range The target range +#' +#' A numeric vector containing two elements is expected, representing the +#' lower and upper bounds of the target range. +#' +#' @param flip_direction Flip direction of the range? +#' +#' The transformed values will be reversed within the target range, e.g. +#' within the range 0 to 100, 25 would be reversed to 75. +#' +#' *Permitted Values*: `TRUE`, `FALSE` +#' +#' @param outside_range Handling of values outside the source range +#' +#' Values outside the source range (`source_range`) are transformed to `NA`. +#' +#' If `"warning"` or `"error"` is specified, a warning or error is issued if +#' `source` includes any values outside the source range. +#' +#' *Permitted Values*: `"NA"`, `"warning"`, `"error"` +#' +#' @details Returns the values of `source` linearly transformed from the source +#' range (`source_range`) to the target range (`target_range`). Values outside +#' the source range are set to `NA`. +#' +#' @return The source linearly transformed to the target range +#' +#' @keywords com_bds_findings +#' +#' @family com_bds_findings +#' +#' @export +#' +#' @examples +#' transform_range( +#' source = c(1, 4, 3, 6, 5), +#' source_range = c(1, 5), +#' target_range = c(0, 100) +#' ) +#' +#' transform_range( +#' source = c(1, 4, 3, 6, 5), +#' source_range = c(1, 5), +#' target_range = c(0, 100), +#' flip_direction = TRUE +#' ) +transform_range <- function(source, + source_range, + target_range, + flip_direction = FALSE, + outside_range = "NA") { + # Function argument checks + assert_numeric_vector(source) + assert_numeric_vector(source_range, length = 2) + assert_numeric_vector(target_range, length = 2) + assert_logical_scalar(flip_direction) + assert_character_scalar(outside_range, values = c("NA", "error", "warning")) + + outsider <- !(between(source, source_range[[1]], source_range[[2]]) | is.na(source)) + if (any(outsider)) { + outside_index <- which(outsider) + outside_value <- source[outsider] + source <- if_else(outsider, NA, source) + msg <- c( + paste( + "{.arg source} contains values outside the range of {.val {source_range[[1]]}}", + "to {.val {source_range[[2]]}}:" + ), + paste0("source[[", outside_index, "]] = {.val {", outside_value, "}}") + ) + if (outside_range == "warning") { + cli_warn( + msg, + class = c("outside_source_range", "assert-admiral"), + outside_index = outside_index, + outside_value = outside_value + ) + } else if (outside_range == "error") { + cli_abort( + msg, + class = c("outside_source_range", "assert-admiral"), + outside_index = outside_index, + outside_value = outside_value + ) + } + } + + # Computation + range_constant <- min(target_range) - min(source_range) + range_coefficient <- (max(target_range) - min(target_range)) / + (max(source_range) - min(source_range)) + + target <- (source + range_constant) * range_coefficient + + if (flip_direction == TRUE) { + target <- max(target_range) - target + } + + target +} diff --git a/man/compute_bmi.Rd b/man/compute_bmi.Rd index bbe36fdcff..68a12c0248 100644 --- a/man/compute_bmi.Rd +++ b/man/compute_bmi.Rd @@ -43,7 +43,8 @@ BDS-Findings Functions that returns a vector: \code{\link{compute_qual_imputation}()}, \code{\link{compute_qual_imputation_dec}()}, \code{\link{compute_rr}()}, -\code{\link{compute_scale}()} +\code{\link{compute_scale}()}, +\code{\link{transform_range}()} } \concept{com_bds_findings} \keyword{com_bds_findings} diff --git a/man/compute_bsa.Rd b/man/compute_bsa.Rd index 0a31db8d28..f16172a3ef 100644 --- a/man/compute_bsa.Rd +++ b/man/compute_bsa.Rd @@ -73,7 +73,8 @@ BDS-Findings Functions that returns a vector: \code{\link{compute_qual_imputation}()}, \code{\link{compute_qual_imputation_dec}()}, \code{\link{compute_rr}()}, -\code{\link{compute_scale}()} +\code{\link{compute_scale}()}, +\code{\link{transform_range}()} } \concept{com_bds_findings} \keyword{com_bds_findings} diff --git a/man/compute_egfr.Rd b/man/compute_egfr.Rd index 7495f0b5c1..715f956b06 100644 --- a/man/compute_egfr.Rd +++ b/man/compute_egfr.Rd @@ -148,7 +148,8 @@ BDS-Findings Functions that returns a vector: \code{\link{compute_qual_imputation}()}, \code{\link{compute_qual_imputation_dec}()}, \code{\link{compute_rr}()}, -\code{\link{compute_scale}()} +\code{\link{compute_scale}()}, +\code{\link{transform_range}()} } \concept{com_bds_findings} \keyword{com_bds_findings} diff --git a/man/compute_framingham.Rd b/man/compute_framingham.Rd index 017188a58f..fb1894041d 100644 --- a/man/compute_framingham.Rd +++ b/man/compute_framingham.Rd @@ -124,7 +124,8 @@ BDS-Findings Functions that returns a vector: \code{\link{compute_qual_imputation}()}, \code{\link{compute_qual_imputation_dec}()}, \code{\link{compute_rr}()}, -\code{\link{compute_scale}()} +\code{\link{compute_scale}()}, +\code{\link{transform_range}()} } \concept{com_bds_findings} \keyword{com_bds_findings} diff --git a/man/compute_map.Rd b/man/compute_map.Rd index 4c28ac2a56..67980bf21c 100644 --- a/man/compute_map.Rd +++ b/man/compute_map.Rd @@ -54,7 +54,8 @@ BDS-Findings Functions that returns a vector: \code{\link{compute_qual_imputation}()}, \code{\link{compute_qual_imputation_dec}()}, \code{\link{compute_rr}()}, -\code{\link{compute_scale}()} +\code{\link{compute_scale}()}, +\code{\link{transform_range}()} } \concept{com_bds_findings} \keyword{com_bds_findings} diff --git a/man/compute_qtc.Rd b/man/compute_qtc.Rd index 766147d7da..32c3dd0dc6 100644 --- a/man/compute_qtc.Rd +++ b/man/compute_qtc.Rd @@ -58,7 +58,8 @@ BDS-Findings Functions that returns a vector: \code{\link{compute_qual_imputation}()}, \code{\link{compute_qual_imputation_dec}()}, \code{\link{compute_rr}()}, -\code{\link{compute_scale}()} +\code{\link{compute_scale}()}, +\code{\link{transform_range}()} } \concept{com_bds_findings} \keyword{com_bds_findings} diff --git a/man/compute_qual_imputation.Rd b/man/compute_qual_imputation.Rd index 2c8ce68a8c..e85e199c54 100644 --- a/man/compute_qual_imputation.Rd +++ b/man/compute_qual_imputation.Rd @@ -39,7 +39,8 @@ BDS-Findings Functions that returns a vector: \code{\link{compute_qtc}()}, \code{\link{compute_qual_imputation_dec}()}, \code{\link{compute_rr}()}, -\code{\link{compute_scale}()} +\code{\link{compute_scale}()}, +\code{\link{transform_range}()} } \concept{com_bds_findings} \keyword{com_bds_findings} diff --git a/man/compute_qual_imputation_dec.Rd b/man/compute_qual_imputation_dec.Rd index 7aff94c8dc..d8e3965cad 100644 --- a/man/compute_qual_imputation_dec.Rd +++ b/man/compute_qual_imputation_dec.Rd @@ -36,7 +36,8 @@ BDS-Findings Functions that returns a vector: \code{\link{compute_qtc}()}, \code{\link{compute_qual_imputation}()}, \code{\link{compute_rr}()}, -\code{\link{compute_scale}()} +\code{\link{compute_scale}()}, +\code{\link{transform_range}()} } \concept{com_bds_findings} \keyword{com_bds_findings} diff --git a/man/compute_rr.Rd b/man/compute_rr.Rd index 064f1fdc6d..8215947825 100644 --- a/man/compute_rr.Rd +++ b/man/compute_rr.Rd @@ -37,7 +37,8 @@ BDS-Findings Functions that returns a vector: \code{\link{compute_qtc}()}, \code{\link{compute_qual_imputation}()}, \code{\link{compute_qual_imputation_dec}()}, -\code{\link{compute_scale}()} +\code{\link{compute_scale}()}, +\code{\link{transform_range}()} } \concept{com_bds_findings} \keyword{com_bds_findings} diff --git a/man/compute_scale.Rd b/man/compute_scale.Rd index 742fb01e36..c81eeb59d5 100644 --- a/man/compute_scale.Rd +++ b/man/compute_scale.Rd @@ -89,7 +89,8 @@ BDS-Findings Functions that returns a vector: \code{\link{compute_qtc}()}, \code{\link{compute_qual_imputation}()}, \code{\link{compute_qual_imputation_dec}()}, -\code{\link{compute_rr}()} +\code{\link{compute_rr}()}, +\code{\link{transform_range}()} } \concept{com_bds_findings} \keyword{com_bds_findings} diff --git a/man/transform_range.Rd b/man/transform_range.Rd new file mode 100644 index 0000000000..af99bfa2f3 --- /dev/null +++ b/man/transform_range.Rd @@ -0,0 +1,86 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/transform_range.R +\name{transform_range} +\alias{transform_range} +\title{Transform Range} +\usage{ +transform_range( + source, + source_range, + target_range, + flip_direction = FALSE, + outside_range = "NA" +) +} +\arguments{ +\item{source}{A vector of values to be transformed + +A numeric vector is expected.} + +\item{source_range}{The permitted source range + +A numeric vector containing two elements is expected, representing the +lower and upper bounds of the permitted source range.} + +\item{target_range}{The target range + +A numeric vector containing two elements is expected, representing the +lower and upper bounds of the target range.} + +\item{flip_direction}{Flip direction of the range? + +The transformed values will be reversed within the target range, e.g. +within the range 0 to 100, 25 would be reversed to 75. + +\emph{Permitted Values}: \code{TRUE}, \code{FALSE}} + +\item{outside_range}{Handling of values outside the source range + +Values outside the source range (\code{source_range}) are transformed to \code{NA}. + +If \code{"warning"} or \code{"error"} is specified, a warning or error is issued if +\code{source} includes any values outside the source range. + +\emph{Permitted Values}: \code{"NA"}, \code{"warning"}, \code{"error"}} +} +\value{ +The source linearly transformed to the target range +} +\description{ +Transforms results from the source range to the target range. For example, +for transforming source values 1, 2, 3, 4, 5 to 0, 25, 50, 75, 100. +} +\details{ +Returns the values of \code{source} linearly transformed from the source +range (\code{source_range}) to the target range (\code{target_range}). Values outside +the source range are set to \code{NA}. +} +\examples{ +transform_range( + source = c(1, 4, 3, 6, 5), + source_range = c(1, 5), + target_range = c(0, 100) +) + +transform_range( + source = c(1, 4, 3, 6, 5), + source_range = c(1, 5), + target_range = c(0, 100), + flip_direction = TRUE +) +} +\seealso{ +BDS-Findings Functions that returns a vector: +\code{\link{compute_bmi}()}, +\code{\link{compute_bsa}()}, +\code{\link{compute_egfr}()}, +\code{\link{compute_framingham}()}, +\code{\link{compute_map}()}, +\code{\link{compute_qtc}()}, +\code{\link{compute_qual_imputation}()}, +\code{\link{compute_qual_imputation_dec}()}, +\code{\link{compute_rr}()}, +\code{\link{compute_scale}()} +} +\concept{com_bds_findings} +\keyword{com_bds_findings} diff --git a/tests/testthat/_snaps/transform_range.md b/tests/testthat/_snaps/transform_range.md new file mode 100644 index 0000000000..372900873e --- /dev/null +++ b/tests/testthat/_snaps/transform_range.md @@ -0,0 +1,23 @@ +# transform_range Test 3: warning if outside range + + Code + transform_range(c(5, 1, 6, 2, NA), source_range = c(1, 5), target_range = c(0, + 100), outside_range = "warning") + Condition + Warning: + `source` contains values outside the range of 1 to 5: + source[[3]] = 6 + Output + [1] 100 0 NA 25 NA + +# transform_range Test 4: error if outside range + + Code + transform_range(c(5, 1, 6, 2, 7), source_range = c(1, 5), target_range = c(0, + 100), outside_range = "error") + Condition + Error in `transform_range()`: + ! `source` contains values outside the range of 1 to 5: + source[[3]] = 6 + source[[5]] = 7 + diff --git a/tests/testthat/test-transform_range.R b/tests/testthat/test-transform_range.R new file mode 100644 index 0000000000..720243783b --- /dev/null +++ b/tests/testthat/test-transform_range.R @@ -0,0 +1,49 @@ +## Test 1: works as expected ---- +test_that("transform_range Test 1: works as expected", { + expect_equal( + transform_range( + c(5, 1, 6, 2, NA), + source_range = c(1, 5), + target_range = c(0, 100) + ), + c(100, 0, NA, 25, NA) + ) +}) + +## Test 2: range is flipped if flip_direction == TRUE ---- +test_that("transform_range Test 2: range is flipped if flip_direction == TRUE", { + expect_equal( + transform_range( + c(0, 4, 8, 11), + c(0, 10), + c(0, 100), + flip_direction = TRUE + ), + c(100, 60, 20, NA) + ) +}) + +## Test 3: warning if outside range ---- +test_that("transform_range Test 3: warning if outside range", { + expect_snapshot( + transform_range( + c(5, 1, 6, 2, NA), + source_range = c(1, 5), + target_range = c(0, 100), + outside_range = "warning" + ) + ) +}) + +## Test 4: error if outside range ---- +test_that("transform_range Test 4: error if outside range", { + expect_snapshot( + transform_range( + c(5, 1, 6, 2, 7), + source_range = c(1, 5), + target_range = c(0, 100), + outside_range = "error" + ), + error = TRUE + ) +}) diff --git a/vignettes/questionnaires.Rmd b/vignettes/questionnaires.Rmd index 165318f0f0..983211dbb1 100644 --- a/vignettes/questionnaires.Rmd +++ b/vignettes/questionnaires.Rmd @@ -117,6 +117,8 @@ We handle unscheduled visits as normal visits. For deriving visits based on time-windows, see [Visit and Period Variables](visits_periods.html#visits). And for flagging values to be used for analysis, see `derive_var_extreme_flag()`. +# Transformed Items + Please note that in the example data, the numeric values of the answers are mapped in SDTM (`QSSTRESN`) such that they can be used for deriving scores. Depending on the question, `QSORRES == "YES"` is mapped to `QSSTRESN = 0` or @@ -125,6 +127,12 @@ scores and require transformation, it is recommended that `QSSTRESN` is kept in the ADaM dataset for traceability, and the transformed value is stored in `AVAL`, since that's what will be used for the score calculation. +It may also be necessary to transform the range of the numeric values of the +original items. For example if a scale should be derived as the average but the +range of the contributing items varies. In this case the values could be +linearly transformed to a unified range like `[0, 100]`. The computation +function `transform_range()` can be used for the transformation. + # Scales and Scores Scales and Scores are often derived as the sum or the average across a subset of