Clinical workflow with ADaM data

Purpose

This vignette shows a realistic pattern for producing a mirror slope plot from an analysis dataset structured along ADaM conventions. It covers merging a population flag from a subject-level dataset, filtering to the analysis population, and supplying model-based estimates.

The package does not fit models. Estimates and their precision are computed upstream (for example from a mixed model for repeated measures) and passed in. This keeps the tool model-agnostic and the figure fully traceable to the values supplied.

A simulated ADaM-style dataset

We construct a basic-data-structure analysis dataset (adqs) with one record per subject per visit, and a subject-level dataset (adsl) carrying treatment assignment and a safety population flag.

set.seed(7)
n_subj <- 60

adsl <- data.frame(
  USUBJID = paste0("SUBJ-", sprintf("%03d", seq_len(n_subj))),
  TRT01P  = rep(c("Placebo", "Active"), each = n_subj / 2),
  SAFFL   = "Y",
  ITTFL   = "Y",
  stringsAsFactors = FALSE
)
# A few subjects out of the safety population
adsl$SAFFL[c(3, 17, 42)] <- "N"

base_val <- rnorm(n_subj, 0, 4)
chg_val  <- ifelse(adsl$TRT01P == "Placebo",
                   rnorm(n_subj, -11, 6),
                   rnorm(n_subj,  -6, 6))

adqs <- data.frame(
  USUBJID  = rep(adsl$USUBJID, each = 2),
  AVISIT   = rep(c("Baseline", "Month 12"), times = n_subj),
  PARAMCD  = "FUNCSCORE",
  CHG      = as.numeric(rbind(rep(0, n_subj), chg_val)),
  stringsAsFactors = FALSE
)
head(adqs, 4)
#>    USUBJID   AVISIT   PARAMCD       CHG
#> 1 SUBJ-001 Baseline FUNCSCORE   0.00000
#> 2 SUBJ-001 Month 12 FUNCSCORE -10.54177
#> 3 SUBJ-002 Baseline FUNCSCORE   0.00000
#> 4 SUBJ-002 Month 12 FUNCSCORE -10.04507

Merging the population flag from ADSL

Population flags and planned treatment usually live in ADSL. Merge the needed columns into the analysis dataset before preparing the plot. Base R merge() or a dplyr join both work; here we use base R to avoid assumptions.

adqs <- merge(
  adqs,
  adsl[, c("USUBJID", "TRT01P", "SAFFL", "ITTFL")],
  by = "USUBJID",
  all.x = TRUE
)
head(adqs, 4)
#>    USUBJID   AVISIT   PARAMCD       CHG  TRT01P SAFFL ITTFL
#> 1 SUBJ-001 Baseline FUNCSCORE   0.00000 Placebo     Y     Y
#> 2 SUBJ-001 Month 12 FUNCSCORE -10.54177 Placebo     Y     Y
#> 3 SUBJ-002 Baseline FUNCSCORE   0.00000 Placebo     Y     Y
#> 4 SUBJ-002 Month 12 FUNCSCORE -10.04507 Placebo     Y     Y

Preparing the data

prep_mirror_data() filters to the two named arms, applies the population flags with AND logic, retains only the baseline and endpoint visits, and counts the analysed N per arm. Any subject lacking both timepoints is dropped with a message, so the reported N reflects the subjects actually contributing.

md <- prep_mirror_data(
  adqs,
  subject_var      = "USUBJID",
  trt_var          = "TRT01P",
  trt_levels       = c("Placebo", "Active"),
  time_var         = "AVISIT",
  time_baseline    = "Baseline",
  time_endpoint    = "Month 12",
  value_var        = "CHG",
  pop_flags        = list(SAFFL = "Y", ITTFL = "Y"),
  population_label = "Safety / ITT",
  value_label      = "Change from Baseline (Functional Score)"
)
md
#> <mirror_data> schema 1.0
#>   Reference (left)  : Placebo  (n=28)
#>   Comparator (right): Active  (n=29)
#>   Timepoints        : Baseline  ->  Month 12
#>   Population        : Safety / ITT
#>   Filters applied   : SAFFL=Y; ITTFL=Y
#>   Observations      : 114 OBS rows
#>   Summary           : no
#>   Precision         : no
#>   Difference        : no

Attaching model-based estimates

In practice these come from your model output. If you use emmeans, extract the estimates and confidence limits and pass them as named vectors and lists. The named-vector interface shown here is stable, and a future release may add a convenience method to consume a model object directly.

md <- add_summary(
  md,
  estimates  = c("Placebo" = -11.3, "Active" = -6.1),
  stat_label = "LS-Mean"
)

md <- add_precision(
  md,
  ci = list("Placebo" = c(-14.0, -8.6),
            "Active"  = c(-8.7, -3.5))
)

md <- add_difference(
  md,
  estimate = 5.2,
  ci_lower = 1.9,
  ci_upper = 8.5,
  p_value  = 0.003
)
md
#> <mirror_data> schema 1.0
#>   Reference (left)  : Placebo  (n=28)
#>   Comparator (right): Active  (n=29)
#>   Timepoints        : Baseline  ->  Month 12
#>   Population        : Safety / ITT
#>   Filters applied   : SAFFL=Y; ITTFL=Y
#>   Observations      : 114 OBS rows
#>   Summary           : yes (LS-Mean)
#>   Precision         : yes
#>   Difference        : yes

Producing the figure

mirror_slope_plot(
  md,
  trt_colors      = mirror_palette("colorblind"),
  show_difference = TRUE,
  show_slowing    = TRUE,
  show_precision  = TRUE,
  title           = "Functional Score: Change from Baseline at Month 12",
  footnote        = paste(
    "LS-means and 95% CIs from a mixed model for repeated measures.",
    "Safety/ITT population. Positive difference favours the active arm."
  )
)

A note on direction and terminology

direction describes the direction of numeric movement, derived by comparing the reference arm’s endpoint estimate to its baseline. The default benefit wording follows from it (for example \dQuote{% less decline} when values move down). For an outcome where the favourable direction is unusual, set outcome_label and benefit_label explicitly rather than relying on the defaults.

Traceability

The mirror_data object records provenance in its metadata: the schema version, the population filters applied, the analysed N per arm, a creation timestamp, and the originating call. This supports reconstructing exactly which inputs produced a given figure, which is useful in regulated settings.