DEV Community

TECHWORKSLAB
TECHWORKSLAB

Posted on Originally published at techworkslab.com

Rebuilding the CDISC pilot ADSL with admiral, and reading it like a reviewer

There is a good public dataset for practising this. The CDISC SDTM/ADaM pilot project shipped a small but complete set of SDTM domains, and the R package pharmaversesdtm makes them available as data frames. It is synthetic, it is open, and it is the same data the admiral documentation builds its own examples on. So we can walk through deriving a subject level analysis dataset, ADSL, from it, and nothing here depends on a client study or a number we are not allowed to show.

We want to be careful about what this is. It is a method walkthrough, written the way we approach a build, not a benchmark. We are not going to claim a match percentage against a reference ADSL, because a match percentage without the full provenance of both sides is exactly the kind of figure we argue against. What we will do is build ADSL from the pilot data, and then read the result the way a reviewer reads it.

Setup

Three packages. admiral for the derivations, dplyr for the data manipulation grammar admiral is built on, and pharmaversesdtm for the pilot SDTM data.

install.packages(c("admiral", "dplyr", "pharmaversesdtm"))

library(admiral)
library(dplyr)
library(pharmaversesdtm)

# The public CDISC pilot SDTM domains, as shipped in pharmaversesdtm
data("dm")   # demographics
data("ds")   # disposition
data("ex")   # exposure

# One row per subject in DM; this is the spine ADSL is built on
dm %>% count()  # number of subjects in the pilot DM
Enter fullscreen mode Exit fullscreen mode

The pilot SDTM domains load as plain data frames, so the whole walkthrough runs on a laptop.

The spine: start from DM

ADSL has one row per subject, and DM already has one row per subject, so DM is the spine. The planned and actual treatment variables come straight across from the SDTM arm variables. This is a predecessor origin, and it is worth saying so out loud now, because origin is the first thing a reviewer will check later.

adsl <- dm %>%
  select(-DOMAIN) %>%
  mutate(
    TRT01P = ARM,        # planned treatment, predecessor of DM.ARM
    TRT01A = ACTARM      # actual treatment, predecessor of DM.ACTARM
  )
Enter fullscreen mode Exit fullscreen mode

Treatment start and end, derived from exposure

Treatment start date is not collected, it is derived, and the derivation is the first place real care is needed. We take the first dosing record where the subject actually received drug, impute a missing day to the first of the month so partial dates do not drop out, and carry the datetime onto ADSL. admiral has a dedicated function for the merge so we do not hand roll a join and a de-duplication.

# Impute partial exposure start dates to the month level, then build a datetime
ex_ext <- ex %>%
  derive_vars_dtm(
    dtc = EXSTDTC,
    new_vars_prefix = "EXST",
    highest_imputation = "M"
  )

adsl <- adsl %>%
  derive_vars_merged(
    dataset_add = ex_ext,
    filter_add = (EXDOSE > 0 |
      (EXDOSE == 0 & grepl("PLACEBO", EXTRT))) & !is.na(EXSTDTM),
    new_vars = exprs(TRTSDTM = EXSTDTM),
    order = exprs(EXSTDTM, EXSEQ),
    mode = "first",
    by_vars = exprs(STUDYID, USUBJID)
  ) %>%
  derive_vars_dtm_to_dt(source_vars = exprs(TRTSDTM))  # TRTSDT, a date from the datetime
Enter fullscreen mode Exit fullscreen mode

The filter is the derivation. It encodes a clinical rule: a zero dose still counts as treatment start for placebo.

The same pattern, with mode = "last" and the exposure end date, gives treatment end. Once start and end exist, treatment duration is a single admiral call rather than a date arithmetic expression a reviewer has to verify by hand.

adsl <- adsl %>%
  derive_vars_duration(
    new_var = TRTDURD,
    start_date = TRTSDT,
    end_date = TRTEDT   # derived with the same merge pattern, mode = "last"
  )
Enter fullscreen mode Exit fullscreen mode

Groupings the tables will need

Age group and a few population flags are not interesting statistically, but they are where silent errors hide, because an off by one in a cut point moves subjects between rows of a demographics table without erroring. Writing the cut explicitly, and keeping it next to the data it classifies, is the honest way to do it.

adsl <- adsl %>%
  mutate(
    AGEGR1 = case_when(
      AGE < 65            ~ "<65",
      AGE >= 65 & AGE <= 80 ~ "65-80",
      AGE > 80            ~ ">80",
      TRUE                ~ NA_character_
    ),
    SAFFL = if_else(!is.na(TRTSDT), "Y", "N")  # safety population: took at least one dose
  )
Enter fullscreen mode Exit fullscreen mode

Illustrative. The pilot ADSL assembled from DM and EX through named admiral derivations.

Now read it like a reviewer

Building the dataset is the easy half. The half that matters is whether the result survives someone asking hard questions of it. When we open an ADSL we did not write, these are the first checks we run, and they are the same ones we run on our own.

  • One row per subject, no duplicates. adsl %>% count(USUBJID) %>% filter(n > 1) should return zero rows. If it does not, a merge somewhere fanned out and the whole dataset is suspect.
  • Treatment start is not after treatment end. A single filter(TRTSDT > TRTEDT) finds date logic that reversed on a partial date imputation. It is a two line check that catches a class of error no table would reveal.
  • Population flags are internally consistent. A subject with SAFFL == "Y" and a missing TRTSDT is a contradiction, because the flag was defined from that very date.
  • Every derived variable can name its method. For each column, we should be able to point to the admiral call that produced it or the SDTM variable it came from. Where we cannot, the variable is not ready, however plausible its values look.
# The duplicate-key check every ADSL has to pass
dups <- adsl %>% count(USUBJID) %>% filter(n > 1)
stopifnot(nrow(dups) == 0)

# The date-order check
bad_dates <- adsl %>% filter(!is.na(TRTSDT), !is.na(TRTEDT), TRTSDT > TRTEDT)
stopifnot(nrow(bad_dates) == 0)
Enter fullscreen mode Exit fullscreen mode

These are not conformance checks in the Pinnacle 21 sense. They are the sanity checks that should pass before conformance checking is even worth running.

The question is never whether the numbers look reasonable. It is whether you can say, for each one, exactly how it was produced and show the step that produced it.

Why we work this way

A monolithic ADSL program can produce the same columns. What it cannot do easily is answer the reviewer questions above, because the logic for any one variable is tangled with the logic for the rest. Building from named derivations, as we described in building ADaM datasets with the admiral package, means traceability is a property of the structure rather than something reconstructed afterwards. On the public pilot data that is a convenience. On a live submission it is the difference between a smooth review and a long one.

If you want to pressure test your own ADSL build against this kind of reading, or move a monolithic program onto a derivation library, we are happy to help. You can reach us here.

Top comments (0)