Skip to content

Commit a58fb22

Browse files
edelaruaMelkiadesddsjoberg
authored
Add ard_abnormal() for abnormality calculations (#311)
**What changes are proposed in this pull request?** * Added function `ard_abnormal()` to calculate ARDs for abnormality analyses. (#310) Closes #310 -------------------------------------------------------------------------------- Pre-review Checklist (if item does not apply, mark is as complete) - [x] **All** GitHub Action workflows pass with a ✅ - [x] PR branch has pulled the most recent updates from master branch: `usethis::pr_merge_main()` - [x] If a bug was fixed, a unit test was added. - [x] If a new `ard_*()` function was added, it passes the ARD structural checks from `cards::check_ard_structure()`. - [x] If a new `ard_*()` function was added, `set_cli_abort_call()` has been set. - [x] If a new `ard_*()` function was added and it depends on another package (such as, `broom`), `is_pkg_installed("broom")` has been set in the function call and the following added to the roxygen comments: `@examplesIf do.call(asNamespace("cardx")$is_pkg_installed, list(pkg = "broom""))` - [x] Code coverage is suitable for any new functions/features (generally, 100% coverage for new code): `devtools::test_coverage()` Reviewer Checklist (if item does not apply, mark is as complete) - [x] If a bug was fixed, a unit test was added. - [x] Code coverage is suitable for any new functions/features: `devtools::test_coverage()` When the branch is ready to be merged: - [ ] Update `NEWS.md` with the changes from this pull request under the heading "`# cardx (development version)`". If there is an issue associated with the pull request, reference it in parentheses at the end update (see `NEWS.md` for examples). - [ ] **All** GitHub Action workflows pass with a ✅ - [ ] Approve Pull Request - [ ] Merge the PR. Please use "Squash and merge" or "Rebase and merge". --------- Signed-off-by: Davide Garolini <dgarolini@gmail.com> Co-authored-by: melkiades <davide.garolini@roche.com> Co-authored-by: Davide Garolini <dgarolini@gmail.com> Co-authored-by: Daniel D. Sjoberg <danield.sjoberg@gmail.com>
1 parent 831f261 commit a58fb22

File tree

9 files changed

+467
-2
lines changed

9 files changed

+467
-2
lines changed

DESCRIPTION

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ BugReports: https://github.com/insightsengineering/cardx/issues
2020
Depends:
2121
R (>= 4.2)
2222
Imports:
23-
cards (>= 0.6.1),
23+
cards (>= 0.6.1.9008),
2424
cli (>= 3.6.1),
2525
dplyr (>= 1.1.2),
2626
glue (>= 1.6.2),
@@ -44,6 +44,8 @@ Suggests:
4444
survival (>= 3.6-4),
4545
testthat (>= 3.2.0),
4646
withr (>= 2.5.0)
47+
Remotes:
48+
insightsengineering/cards
4749
Config/Needs/website: insightsengineering/nesttemplate
4850
Config/testthat/edition: 3
4951
Config/testthat/parallel: true

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export(ard_attributes)
2828
export(ard_car_anova)
2929
export(ard_car_vif)
3030
export(ard_categorical)
31+
export(ard_categorical_abnormal)
3132
export(ard_categorical_ci)
3233
export(ard_categorical_max)
3334
export(ard_continuous)

NEWS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# cardx 0.2.5.9000
22

3+
* Added function `ard_categorical_abnormal()` to calculate ARDs for abnormality analyses. (#310)
4+
35
* Adding `strata` argument to `ard_categorical_max()`. (#445, @jtalboys)
46

57
* Added function `ard_incidence_rate()` to calculate ARDs for incidence rate estimation. (#234)

R/ard_categorical_abnormal.R

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
#' ARD Abnormality Counts
2+
#'
3+
#' @description
4+
#'
5+
#' Function counts participants with abnormal analysis range values.
6+
#'
7+
#' For each abnormality specified via the `abnormal` parameter (e.g. Low or High), statistic `n` is
8+
#' calculated as the number of patients with this abnormality recorded, and statistic `N` is calculated as
9+
#' the total number of patients with at least one post-baseline assessment. `p` is calculated as
10+
#' `n / N`. If `excl_baseline_abn=TRUE` then participants with abnormality at baseline are excluded
11+
#' from all statistic calculations.
12+
#'
13+
#' @param data (`data.frame`)\cr
14+
#' a data frame.
15+
#' @param postbaseline ([`tidy-select`][dplyr::dplyr_tidy_select])\cr
16+
#' column name of post-baseline reference range indicator variable.
17+
#' @param baseline ([`tidy-select`][dplyr::dplyr_tidy_select])\cr
18+
#' column name of baseline reference range indicator variable.
19+
#' @param id ([`tidy-select`][dplyr::dplyr_tidy_select])\cr
20+
#' column name used to identify unique participants in `data`. If `NULL`, each row in `data` is assumed to correspond
21+
#' to a unique participants.
22+
#' @param abnormal (`list`)\cr
23+
#' a named list of abnormalities to assess for. Each element should specify all levels of `postbaseline`/`baseline`
24+
#' that should be included when assessing for a given abnormality, with the name specifying the name of the
25+
#' abnormality. Any levels specified but not present in the data are ignored.
26+
#' @param excl_baseline_abn (`logical`)\cr
27+
#' whether participants with baseline abnormality should be excluded from calculations. Defaults to `TRUE`.
28+
#' @param quiet (scalar `logical`)\cr
29+
#' logical indicating whether to suppress additional messaging. Default is `FALSE`.
30+
#' @inheritParams cards::ard_continuous
31+
#'
32+
#' @return an ARD data frame of class 'card'
33+
#' @export
34+
#'
35+
#' @examples
36+
#' # Load Data -------------------
37+
#' set.seed(1)
38+
#' adlb <- cards::ADLB
39+
#' adlb$BNRIND <- ifelse(
40+
#' adlb$BNRIND != "N", sample(c("LOW", "LOW LOW", "HIGH", "HIGH HIGH"), nrow(adlb), replace = TRUE), "NORMAL"
41+
#' )
42+
#'
43+
#' # Example 1 -------------------
44+
#' adlb |>
45+
#' ard_categorical_abnormal(
46+
#' postbaseline = LBNRIND, baseline = BNRIND, id = USUBJID, by = TRTA,
47+
#' abnormal = list(Low = c("LOW", "LOW LOW"), High = c("HIGH", "HIGH HIGH"))
48+
#' )
49+
ard_categorical_abnormal <- function(data,
50+
postbaseline,
51+
baseline,
52+
id = NULL,
53+
by = NULL,
54+
strata = NULL,
55+
abnormal = list(Low = "LOW", High = "HIGH"),
56+
excl_baseline_abn = TRUE,
57+
quiet = FALSE) {
58+
set_cli_abort_call()
59+
60+
# check inputs ---------------------------------------------------------------
61+
check_data_frame(data)
62+
cards::process_selectors(
63+
data,
64+
postbaseline = {{ postbaseline }}, baseline = {{ baseline }}, id = {{ id }}, by = {{ by }}, strata = {{ strata }}
65+
)
66+
check_not_missing(abnormal)
67+
check_scalar_logical(excl_baseline_abn)
68+
check_scalar_logical(quiet)
69+
check_class(abnormal, "list")
70+
71+
if (!is_named(abnormal)) {
72+
cli::cli_abort(
73+
"{.arg abnormal} must be a named list, where each name corresponds to a different abnormality/direction.",
74+
call = get_cli_abort_call()
75+
)
76+
}
77+
if (!all(is.character(unlist(abnormal)))) {
78+
cli::cli_abort(
79+
"Each abnormal level of {.var {postbaseline}} specified via {.arg abnormal} must be a {.cls string}.",
80+
call = get_cli_abort_call()
81+
)
82+
}
83+
84+
# print abnormality levels ---------------------------------------------------
85+
if (!quiet) {
86+
for (i in seq_along(abnormal)) {
87+
vec <- cli::cli_vec(abnormal[[i]], style = list("vec-sep" = ", ", "vec-sep2" = ", ", "vec-last" = ", "))
88+
cli::cli_inform("Abnormality {.val {names(abnormal)[i]}} created {cli::qty(abnormal[[i]])} {?from/by merging} level{?s}: {.val {vec}}")
89+
}
90+
}
91+
92+
# build ARD ------------------------------------------------------------------
93+
data <- data |>
94+
dplyr::mutate(
95+
dplyr::across(
96+
all_of(c(postbaseline, baseline)),
97+
\(x) {
98+
# combine levels specified for each abnormality
99+
do.call(fct_collapse, args = c(list(f = x), abnormal)) |>
100+
suppressWarnings()
101+
}
102+
)
103+
)
104+
105+
# calculate statistics for each abnormality
106+
lapply(
107+
names(abnormal),
108+
function(abn) {
109+
cards::ard_complex(
110+
data = data,
111+
variables = all_of(postbaseline),
112+
by = any_of(by),
113+
strata = any_of(strata),
114+
statistic = all_of(postbaseline) ~ list(
115+
abnormal =
116+
.calc_abnormal(data, abn, postbaseline, baseline, id, excl_baseline_abn)
117+
)
118+
) |>
119+
dplyr::bind_cols(dplyr::tibble(variable_level = list(abn)))
120+
}
121+
) |>
122+
dplyr::bind_rows() |>
123+
dplyr::mutate(
124+
stat_label = dplyr::coalesce(.data$stat_label, .data$stat_name),
125+
context = "categorical_abnormal",
126+
) |>
127+
cards::as_card() |>
128+
cards::tidy_ard_column_order() |>
129+
cards::tidy_ard_row_order()
130+
}
131+
132+
# function to perform calculations -------------------------------------------
133+
.calc_abnormal <- function(data, abnormality, postbaseline, baseline, id, excl_baseline_abn) {
134+
cards::as_cards_fn(
135+
\(x, data, ...) {
136+
# if `excl_baseline_abn=FALSE` then do not exclude baseline abnormal from numerator/denominator calculations
137+
baseline_not_abn <- if (excl_baseline_abn) !data[[baseline]] %in% abnormality else TRUE # baseline visit not abnormal
138+
postbaseline_abn <- data[[postbaseline]] %in% abnormality # post-baseline visit abnormal
139+
140+
# numerator: unique participants with any abnormal post-baseline visit, baseline visit not abnormal
141+
n <- data |>
142+
dplyr::filter(postbaseline_abn & baseline_not_abn) |>
143+
dplyr::select(all_of(id)) |>
144+
dplyr::distinct() |>
145+
nrow()
146+
147+
# denominator: unique participants with any post-baseline visit, baseline visit not abnormal (if )
148+
N <- data |>
149+
dplyr::filter(baseline_not_abn) |>
150+
dplyr::select(all_of(id)) |>
151+
dplyr::distinct() |>
152+
nrow()
153+
154+
dplyr::tibble(n = n, N = N, p = n / N)
155+
},
156+
stat_names = c("n", "N", "p")
157+
)
158+
}

R/import-standalone-forcats.R

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
# ---
77
# repo: insightsengineering/standalone
88
# file: standalone-forcats.R
9-
# last-updated: 2025-05-03
9+
# last-updated: 2025-06-24
1010
# license: https://unlicense.org
1111
# imports:
1212
# ---
@@ -16,6 +16,8 @@
1616
# of programming.
1717
#
1818
# ## Changelog
19+
# 2025-06-24
20+
# - add `fct_collapse()` function (and its internal helper functions).
1921
# 2025-05-03
2022
# - `add fct_relevel()` fix for non-factor inputs
2123
# 2025-02-24
@@ -91,5 +93,68 @@ fct_relevel <- function(f, ..., after = 0L) {
9193
return(new_factor)
9294
}
9395

96+
# internal forcats function used within `fct_collapse()`
97+
# to re-value factor levels
98+
.lvls_revalue <- function(f, new_levels) {
99+
if (length(new_levels) != nlevels(f)) {
100+
n_new <- length(new_levels)
101+
n_old <- nlevels(f)
102+
cli::cli_abort("{.arg new_levels} must be the same length ({n_new}) as {.code levels(f)} ({n_old}).")
103+
}
104+
if (anyDuplicated(new_levels)) {
105+
u_levels <- unique(new_levels)
106+
index <- match(new_levels, u_levels)
107+
out <- index[f]
108+
attributes(out) <- attributes(f)
109+
attr(out, "levels") <- u_levels
110+
out
111+
} else {
112+
attr(f, "levels") <- new_levels
113+
f
114+
}
115+
}
116+
117+
# internal forcats function used within `fct_collapse()`
118+
# to rename factor levels
119+
.lvls_rename <- function(f, new_levels) {
120+
old_levels <- levels(f)
121+
idx <- match(new_levels, old_levels)
122+
if (any(is.na(idx))) {
123+
bad <- new_levels[is.na(idx)]
124+
warning("Unknown levels in `f`: ", paste(bad, collapse = ", "), call. = FALSE)
125+
new_levels <- new_levels[!is.na(idx)]
126+
idx <- idx[!is.na(idx)]
127+
}
128+
old_levels[idx] <- names(new_levels)
129+
old_levels
130+
}
131+
132+
# internal forcats function used within `fct_collapse()`
133+
# to process other factor levels not being collapsed
134+
.lvls_other <- function(f, keep, other_level = "Other") {
135+
if (all(keep)) {
136+
f
137+
} else {
138+
new_levels <- ifelse(keep, levels(f), other_level)
139+
f <- .lvls_revalue(f, new_levels)
140+
fct_relevel(f, other_level, after = Inf)
141+
}
142+
}
143+
144+
fct_collapse <- function(f, ..., other_level = NULL) {
145+
if (!inherits(f, "factor")) f <- factor(f)
146+
147+
dots <- rlang::list2(...)
148+
old <- unlist(dots, use.names = FALSE) %||% character()
149+
new <- rep(names(dots), lengths(dots))
150+
151+
# collapse/re-value factor levels using new names
152+
out <- .lvls_revalue(f, .lvls_rename(f, rlang::set_names(old, new)))
153+
# add other levels not being collapsed
154+
if (!is.null(other_level)) out <- .lvls_other(out, levels(out) %in% new, other_level)
155+
156+
out
157+
}
158+
94159
# nocov end
95160
# styler: on

_pkgdown.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ reference:
9191
- ard_regression_basic
9292
- ard_categorical_max
9393
- ard_incidence_rate
94+
- ard_categorical_abnormal
9495

9596
- title: "Helpers"
9697
- contents:

man/ard_categorical_abnormal.Rd

Lines changed: 82 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)