ADaM programming with admiral

Lesson 4 — Clinical Programming with R

Lesson 4 of 12 Advanced ~140 min

Learning objectives

  • Describe the ADaM structures and when each applies
  • Build ADSL with admiral derivation functions
  • Build a BDS dataset with baseline, change and analysis flags
  • Build an OCCDS dataset with treatment-emergence logic
  • Handle date imputation with the required flags
  • Read and use the admiral template programs

ADaM structures

Structure One row per Examples
ADSL Subject ADSL
BDS (Basic Data Structure) Subject, parameter, analysis timepoint ADLB, ADVS, ADEG, ADTTE
OCCDS (Occurrence Data Structure) Subject, occurrence ADAE, ADCM, ADMH

ADaM’s two defining principles:

  1. Analysis-ready — a statistician should be able to produce the analysis with a simple filter() and a model call, no further derivation
  2. Traceable — every derived value can be traced back to its SDTM source

Traceability is why ADaM datasets carry --SEQ from the parent domain, why imputation flags exist, and why AVAL sits alongside --STRESN rather than replacing it.

admiral

The pharmaverse package for ADaM derivations. It provides tested, documented implementations of the standard derivations, and — importantly — it is not a black box: every function is a dplyr pipeline you can read.

install.packages("admiral")
library(admiral)
library(dplyr)
library(lubridate)

admiral follows a strict naming convention:

Prefix Returns
derive_var_* One new variable
derive_vars_* Several new variables
derive_param_* New parameter rows in a BDS dataset
compute_* A vector — usable inside mutate()
filter_* A subset of rows
get_* A helper value or object

ADSL

library(admiral)
library(dplyr)
library(pharmaversesdtm)   # example SDTM data

data("dm"); data("ex"); data("ds"); data("ae"); data("lb")

dm <- convert_blanks_to_na(dm)
ex <- convert_blanks_to_na(ex)
ds <- convert_blanks_to_na(ds)
Tipconvert_blanks_to_na() first, always

SAS uses "" for missing character values; R uses NA. Every admiral function assumes NA. Forgetting this conversion produces derivations that silently skip records because "" != NA and is.na("") is FALSE.

Treatment variables

adsl <- dm |>
  mutate(
    TRT01P = ARM,
    TRT01A = ACTARM
  )

Treatment start and end dates

# Exposure records that represent actual dosing
ex_dates <- ex |>
  derive_vars_dtm(
    dtc = EXSTDTC,
    new_vars_prefix = "EXST",
    highest_imputation = "M",     # impute down to month level
    date_imputation = "first",
    time_imputation = "first"
  ) |>
  derive_vars_dtm(
    dtc = EXENDTC,
    new_vars_prefix = "EXEN",
    highest_imputation = "M",
    date_imputation = "last",
    time_imputation = "last"
  )

adsl <- adsl |>
  derive_vars_merged(
    dataset_add = ex_dates,
    filter_add  = (EXDOSE > 0 | (EXDOSE == 0 & str_detect(EXTRT, "PLACEBO"))) &
                  !is.na(EXSTDTM),
    new_vars    = exprs(TRTSDTM = EXSTDTM, TRTSTMF = EXSTTMF),
    order       = exprs(EXSTDTM, EXSEQ),
    mode        = "first",
    by_vars     = exprs(STUDYID, USUBJID)
  ) |>
  derive_vars_merged(
    dataset_add = ex_dates,
    filter_add  = (EXDOSE > 0 | (EXDOSE == 0 & str_detect(EXTRT, "PLACEBO"))) &
                  !is.na(EXENDTM),
    new_vars    = exprs(TRTEDTM = EXENDTM, TRTETMF = EXENTMF),
    order       = exprs(EXENDTM, EXSEQ),
    mode        = "last",
    by_vars     = exprs(STUDYID, USUBJID)
  ) |>
  derive_vars_dtm_to_dt(source_vars = exprs(TRTSDTM, TRTEDTM)) |>
  derive_var_trtdurd()

derive_vars_merged() is the workhorse: it takes the first or last record from another dataset (after filtering and sorting) and merges selected variables. It replaces the SAS pattern of proc sort + data step with first./last..

The EXDOSE > 0 | (EXDOSE == 0 & PLACEBO) filter is a standard idiom — a zero dose is real exposure for a placebo arm but a non-dose for an active arm.

Populations

adsl <- adsl |>
  mutate(
    SAFFL   = if_else(!is.na(TRTSDT), "Y", "N"),
    ITTFL   = if_else(!is.na(ARM) & ARM != "Screen Failure", "Y", "N"),
    RANDFL  = if_else(!is.na(RANDDT), "Y", "N")
  )

Disposition

# Reusable source definitions
ds_death <- date_source(
  dataset_name = "ds",
  date         = convert_dtc_to_dt(DSSTDTC),
  filter       = DSDECOD == "DEATH" & DSCAT == "DISPOSITION EVENT"
)

adsl <- adsl |>
  derive_vars_merged(
    dataset_add = ds,
    filter_add  = DSCAT == "DISPOSITION EVENT" & DSDECOD != "SCREEN FAILURE",
    new_vars    = exprs(EOSSTT = DSDECOD, DCSREAS = DSTERM),
    by_vars     = exprs(STUDYID, USUBJID)
  ) |>
  derive_vars_dt(
    dtc = DSSTDTC,
    new_vars_prefix = "EOS"
  )

Death

adsl <- adsl |>
  derive_vars_extreme_event(
    by_vars = exprs(STUDYID, USUBJID),
    events = list(
      event(dataset_name = "ae",
            condition = AEOUT == "FATAL",
            set_values_to = exprs(DTHDT = convert_dtc_to_dt(AESTDTC),
                                  DTHCAUS = AEDECOD)),
      event(dataset_name = "ds",
            condition = DSDECOD == "DEATH",
            set_values_to = exprs(DTHDT = convert_dtc_to_dt(DSSTDTC),
                                  DTHCAUS = DSTERM))
    ),
    source_datasets = list(ae = ae, ds = ds),
    order = exprs(DTHDT),
    mode = "first",
    new_vars = exprs(DTHDT, DTHCAUS)
  ) |>
  mutate(DTHFL = if_else(!is.na(DTHDT), "Y", "N"))

Grouping variables

agegr_lookup <- exprs(
  ~condition,   ~AGEGR1,  ~AGEGR1N,
  AGE < 18,     "<18",    1,
  AGE >= 18 & AGE < 65, "18-64", 2,
  AGE >= 65,    ">=65",   3,
  is.na(AGE),   "Missing", 4
)

adsl <- adsl |>
  derive_vars_cat(definition = agegr_lookup) |>
  mutate(
    TRT01PN = case_when(TRT01P == "Placebo"           ~ 0,
                        TRT01P == "Xanomeline Low Dose"  ~ 1,
                        TRT01P == "Xanomeline High Dose" ~ 2),
    TRT01AN = case_when(TRT01A == "Placebo"           ~ 0,
                        TRT01A == "Xanomeline Low Dose"  ~ 1,
                        TRT01A == "Xanomeline High Dose" ~ 2)
  )

BDS — laboratory data

adlb <- lb |>
  convert_blanks_to_na() |>
  # Bring in ADSL variables needed for derivations
  derive_vars_merged(
    dataset_add = adsl,
    new_vars    = exprs(TRTSDT, TRTEDT, TRT01A, TRT01AN, SAFFL),
    by_vars     = exprs(STUDYID, USUBJID)
  ) |>
  # Analysis date
  derive_vars_dt(dtc = LBDTC, new_vars_prefix = "A") |>
  derive_vars_dy(reference_date = TRTSDT, source_vars = exprs(ADT)) |>
  # PARAM / PARAMCD from a lookup
  derive_vars_merged_lookup(
    dataset_add = param_lookup,
    new_vars    = exprs(PARAM, PARAMCD, PARAMN),
    by_vars     = exprs(LBTESTCD)
  ) |>
  mutate(
    AVAL   = LBSTRESN,
    AVALU  = LBSTRESU,
    AVISIT = case_when(
      is.na(ADT)      ~ NA_character_,
      ADY <= 1        ~ "Baseline",
      .default        = paste("Week", ceiling(ADY / 7))
    ),
    AVISITN = case_when(
      AVISIT == "Baseline" ~ 0,
      .default = as.numeric(str_extract(AVISIT, "\\d+"))
    ),
    ANRLO = LBSTNRLO,
    ANRHI = LBSTNRHI
  )

Baseline

adlb <- adlb |>
  # Flag the baseline record
  derive_var_extreme_flag(
    by_vars = exprs(STUDYID, USUBJID, PARAMCD),
    order   = exprs(ADT, LBSEQ),
    new_var = ABLFL,
    mode    = "last",
    true_value = "Y",
    false_value = NA_character_
    # restrict to pre-treatment first:
  ) |>
  # Copy the baseline value onto every record
  derive_var_base(
    by_vars   = exprs(STUDYID, USUBJID, PARAMCD),
    source_var = AVAL,
    new_var   = BASE
  ) |>
  derive_var_chg() |>     # CHG  = AVAL - BASE
  derive_var_pchg()       # PCHG = 100 * (AVAL - BASE) / BASE
Warningderive_var_extreme_flag() needs a restriction

As written above, the “last” record is the last overall, not the last pre-treatment. Restrict it:

restrict_derivation(
  derivation = derive_var_extreme_flag,
  args = params(
    by_vars = exprs(STUDYID, USUBJID, PARAMCD),
    order   = exprs(ADT, LBSEQ),
    new_var = ABLFL,
    mode    = "last"
  ),
  filter = (!is.na(AVAL) & ADT <= TRTSDT)
)

restrict_derivation() applies a derivation only to the rows matching the filter and leaves the others untouched — which is exactly the semantics you want for a baseline flag.

Analysis flags and reference ranges

adlb <- adlb |>
  # Normal range indicator
  derive_var_anrind() |>          # uses AVAL, ANRLO, ANRHI -> ANRIND
  # Baseline reference range indicator
  derive_var_base(
    by_vars    = exprs(STUDYID, USUBJID, PARAMCD),
    source_var = ANRIND,
    new_var    = BNRIND
  ) |>
  # One record per subject/parameter/visit for the analysis
  derive_var_extreme_flag(
    by_vars = exprs(USUBJID, PARAMCD, AVISITN),
    order   = exprs(ADT, LBSEQ),
    new_var = ANL01FL,
    mode    = "last"
  ) |>
  mutate(
    ONTRTFL = if_else(!is.na(ADT) & ADT >= TRTSDT &
                        (is.na(TRTEDT) | ADT <= TRTEDT), "Y", NA_character_)
  )

Derived parameters

derive_param_computed() adds new rows, not columns:

advs <- advs |>
  derive_param_computed(
    by_vars = exprs(STUDYID, USUBJID, VISIT, VISITNUM, ADT, ADY),
    parameters = c("SYSBP", "DIABP"),
    set_values_to = exprs(
      AVAL   = (AVAL.SYSBP + 2 * AVAL.DIABP) / 3,
      PARAMCD = "MAP",
      PARAM  = "Mean Arterial Pressure (mmHg)",
      PARAMN = 10
    )
  )

The AVAL.SYSBP syntax refers to AVAL from the SYSBP record within the by-group. This is admiral’s solution to a transposition that would otherwise require a pivot, a computation and a pivot back.

OCCDS — adverse events

adae <- ae |>
  convert_blanks_to_na() |>
  derive_vars_merged(
    dataset_add = adsl,
    new_vars    = exprs(TRTSDT, TRTEDT, TRT01A, TRT01AN, SAFFL, DTHDT),
    by_vars     = exprs(STUDYID, USUBJID)
  ) |>
  # Analysis dates with imputation flags
  derive_vars_dt(
    dtc = AESTDTC,
    new_vars_prefix = "AST",
    highest_imputation = "M",
    date_imputation = "first",
    min_dates = exprs(TRTSDT)        # never impute before treatment start
  ) |>
  derive_vars_dt(
    dtc = AEENDTC,
    new_vars_prefix = "AEN",
    highest_imputation = "M",
    date_imputation = "last",
    max_dates = exprs(DTHDT)          # never impute after death
  ) |>
  derive_vars_dy(
    reference_date = TRTSDT,
    source_vars = exprs(ASTDT, AENDT)
  ) |>
  derive_vars_duration(
    new_var    = ADURN,
    new_var_unit = ADURU,
    start_date = ASTDT,
    end_date   = AENDT
  )
Importantmin_dates and max_dates are the important arguments

min_dates = exprs(TRTSDT) means: when imputing a partial start date, do not produce a date earlier than treatment start. This implements the very common SAP convention that an AE with a partial date consistent with the treatment period is treated as treatment-emergent.

Without it, "2026-03" imputes to "2026-03-01", which may precede a treatment start of "2026-03-15" and be wrongly classified as pre-treatment. Whether that is right depends on the SAP — but it must be a decision, not an accident.

Treatment-emergent flag

adae <- adae |>
  derive_var_trtemfl(
    new_var        = TRTEMFL,
    start_date     = ASTDT,
    end_date       = AENDT,
    trt_start_date = TRTSDT,
    trt_end_date   = TRTEDT,
    end_window     = 30,
    initial_intensity = AESEVN,     # for worsening-of-pre-existing logic
    intensity      = AESEVN
  )

Occurrence flags

The “first occurrence” flags used for incidence tables:

adae <- adae |>
  restrict_derivation(
    derivation = derive_var_extreme_flag,
    args = params(
      by_vars = exprs(USUBJID),
      order   = exprs(ASTDT, AESEQ),
      new_var = AOCCFL,          # first occurrence, any AE
      mode    = "first"
    ),
    filter = TRTEMFL == "Y"
  ) |>
  restrict_derivation(
    derivation = derive_var_extreme_flag,
    args = params(
      by_vars = exprs(USUBJID, AEBODSYS),
      order   = exprs(ASTDT, AESEQ),
      new_var = AOCCSFL,         # first per system organ class
      mode    = "first"
    ),
    filter = TRTEMFL == "Y"
  ) |>
  restrict_derivation(
    derivation = derive_var_extreme_flag,
    args = params(
      by_vars = exprs(USUBJID, AEDECOD),
      order   = exprs(ASTDT, AESEQ),
      new_var = AOCCPFL,         # first per preferred term
      mode    = "first"
    ),
    filter = TRTEMFL == "Y"
  )

An AE incidence table then filters AOCCPFL == "Y" and counts rows — which is subject incidence, computed once and reusable, rather than a distinct() in every table program.

Time-to-event (ADTTE)

# Define the event
death_event <- event_source(
  dataset_name = "adsl",
  filter = DTHFL == "Y",
  date = DTHDT,
  set_values_to = exprs(EVNTDESC = "DEATH", SRCDOM = "ADSL", SRCVAR = "DTHDT")
)

# Define censoring
lastalive_censor <- censor_source(
  dataset_name = "adsl",
  date = LSTALVDT,
  set_values_to = exprs(EVNTDESC = "LAST KNOWN ALIVE", SRCDOM = "ADSL",
                        SRCVAR = "LSTALVDT")
)

adtte <- derive_param_tte(
  dataset_adsl = adsl,
  start_date   = TRTSDT,
  event_conditions  = list(death_event),
  censor_conditions = list(lastalive_censor),
  source_datasets = list(adsl = adsl),
  set_values_to = exprs(PARAMCD = "OS", PARAM = "Overall Survival")
) |>
  derive_vars_duration(
    new_var    = AVAL,
    start_date = STARTDT,
    end_date   = ADT,
    out_unit   = "days"
  )

CNSR is 0 for an event and 1 for censoring — the opposite of the convention in some other tools, and a frequent source of reversed survival curves.

Templates

admiral ships runnable templates:

list_all_templates()
#> [1] "ADAE"  "ADCM"  "ADEG"  "ADEX"  "ADLB"  "ADLBHY"  "ADMH"
#> [8] "ADPC"  "ADPP"  "ADPPK" "ADSL"  "ADVS"

use_ad_template("ADSL", save_path = "programs/adam/ad_adsl.R")

The templates are complete, working programs against the pharmaversesdtm example data. Start every new ADaM program from one — they encode a great deal of accumulated judgement about ordering and edge cases.

Validating the result

# Structure
stopifnot(!anyDuplicated(adsl$USUBJID))
stopifnot(!anyDuplicated(adlb[, c("USUBJID", "PARAMCD", "AVISITN", "ANL01FL")] |>
                           filter(ANL01FL == "Y")))

# Content
adae |> count(TRTEMFL)
adlb |> filter(is.na(BASE), AVISITN > 0) |> count(PARAMCD)   # missing baselines
adsl |> count(SAFFL, ITTFL)

# admiral's own checks
admiral::assert_data_frame(adsl, required_vars = exprs(USUBJID, TRT01P, SAFFL))

Common mistakes

Mistake Consequence Fix
Forgetting convert_blanks_to_na() Derivations silently skip records Do it on every SDTM input
Unrestricted derive_var_extreme_flag Baseline is a post-dose record restrict_derivation()
No min_dates/max_dates on imputation Impossible dates, wrong TRTEMFL Constrain imputation
Imputation without a flag Untraceable derivation --DTF/--TMF always
CNSR reversed Survival curve inverted 0 = event, 1 = censored
Non-deterministic order Different results between runs Include --SEQ in order
Deriving occurrence flags without TRTEMFL filter Counts include pre-treatment events restrict_derivation()
Ignoring admiral templates Reinventing solved problems Start from a template

Exercise 4.1 — Build a minimal ADSL

Using pharmaversesdtm data, build an ADSL with: treatment variables, TRTSDT/TRTEDT/TRTDURD, SAFFL, ITTFL, AGEGR1, and death variables. Validate that it has exactly one row per subject.

Show solution
library(admiral); library(dplyr); library(pharmaversesdtm); library(stringr)

data("dm"); data("ex"); data("ds"); data("ae")

dm <- convert_blanks_to_na(dm)
ex <- convert_blanks_to_na(ex)
ds <- convert_blanks_to_na(ds)
ae <- convert_blanks_to_na(ae)

# --- Exposure dates, imputed to month level --------------------------------
ex_dt <- ex |>
  derive_vars_dt(dtc = EXSTDTC, new_vars_prefix = "EXST",
                 highest_imputation = "M", date_imputation = "first") |>
  derive_vars_dt(dtc = EXENDTC, new_vars_prefix = "EXEN",
                 highest_imputation = "M", date_imputation = "last")

dosing <- expr(
  (EXDOSE > 0 | (EXDOSE == 0 & str_detect(EXTRT, "PLACEBO"))) & !is.na(EXSTDT)
)

# --- ADSL -------------------------------------------------------------------
adsl <- dm |>
  mutate(TRT01P = ARM, TRT01A = ACTARM) |>

  # First dose
  derive_vars_merged(
    dataset_add = ex_dt,
    filter_add  = !!dosing,
    new_vars    = exprs(TRTSDT = EXSTDT),
    order       = exprs(EXSTDT, EXSEQ),
    mode        = "first",
    by_vars     = exprs(STUDYID, USUBJID)
  ) |>

  # Last dose
  derive_vars_merged(
    dataset_add = ex_dt,
    filter_add  = (EXDOSE > 0 | (EXDOSE == 0 & str_detect(EXTRT, "PLACEBO"))) &
                    !is.na(EXENDT),
    new_vars    = exprs(TRTEDT = EXENDT),
    order       = exprs(EXENDT, EXSEQ),
    mode        = "last",
    by_vars     = exprs(STUDYID, USUBJID)
  ) |>

  derive_var_trtdurd() |>

  # Populations
  mutate(
    SAFFL = if_else(!is.na(TRTSDT), "Y", "N"),
    ITTFL = if_else(!is.na(ARMCD) & ARMCD != "Scrnfail", "Y", "N")
  ) |>

  # Age groups
  derive_vars_cat(
    definition = exprs(
      ~condition,            ~AGEGR1,   ~AGEGR1N,
      AGE < 65,              "<65",     1,
      AGE >= 65 & AGE < 80,  "65-80",   2,
      AGE >= 80,             ">80",     3,
      is.na(AGE),            "Missing", 4
    )
  ) |>

  # Death
  derive_vars_extreme_event(
    by_vars = exprs(STUDYID, USUBJID),
    events = list(
      event(dataset_name = "ae", condition = AEOUT == "FATAL",
            set_values_to = exprs(DTHDT = convert_dtc_to_dt(AESTDTC),
                                  DTHCAUS = AEDECOD)),
      event(dataset_name = "ds", condition = DSDECOD == "DEATH",
            set_values_to = exprs(DTHDT = convert_dtc_to_dt(DSSTDTC),
                                  DTHCAUS = DSTERM))
    ),
    source_datasets = list(ae = ae, ds = ds),
    order = exprs(DTHDT), mode = "first",
    new_vars = exprs(DTHDT, DTHCAUS)
  ) |>
  mutate(DTHFL = if_else(!is.na(DTHDT), "Y", "N"))

# --- Validation -------------------------------------------------------------
stopifnot(
  "ADSL must have one row per subject" = !anyDuplicated(adsl$USUBJID),
  "ADSL must have same subjects as DM" = setequal(adsl$USUBJID, dm$USUBJID),
  "TRTEDT must not precede TRTSDT"     =
    all(is.na(adsl$TRTEDT) | is.na(adsl$TRTSDT) | adsl$TRTEDT >= adsl$TRTSDT),
  "SAFFL must be Y or N"               = all(adsl$SAFFL %in% c("Y", "N")),
  "Deaths must have a date"            =
    all(adsl$DTHFL == "N" | !is.na(adsl$DTHDT))
)

adsl |> count(TRT01P, SAFFL)
#> # A tibble: 4 x 3
#>   TRT01P              SAFFL     n
#>   <chr>               <chr> <int>
#> 1 Placebo             Y        86
#> 2 Xanomeline High Dose Y       84
#> 3 Xanomeline Low Dose  Y       84
#> 4 Screen Failure      N        52

The setequal(adsl$USUBJID, dm$USUBJID) assertion is the one that catches the most bugs: a derive_vars_merged() with a mis-specified by_vars can silently drop subjects, and the row count alone will not tell you.

Note that screen failures appear in ADSL with SAFFL = "N". Whether they belong in ADSL at all is a study-level decision — ADaM permits either, but the define.xml must describe what you did.

Exercise 4.2 — Baseline and change in a BDS dataset

Build ADVS from the SDTM VS domain with PARAM/PARAMCD, AVAL, ABLFL (last pre-dose record), BASE, CHG, PCHG and ANL01FL. Explain why restrict_derivation() is needed.

Show solution
library(admiral); library(dplyr); library(pharmaversesdtm)

data("vs")
vs <- convert_blanks_to_na(vs)

param_lookup <- tibble::tribble(
  ~VSTESTCD, ~PARAMCD, ~PARAM,                            ~PARAMN,
  "SYSBP",   "SYSBP",  "Systolic Blood Pressure (mmHg)",   1,
  "DIABP",   "DIABP",  "Diastolic Blood Pressure (mmHg)",  2,
  "PULSE",   "PULSE",  "Pulse Rate (beats/min)",           3,
  "TEMP",    "TEMP",   "Temperature (C)",                  4,
  "WEIGHT",  "WEIGHT", "Weight (kg)",                      5
)

advs <- vs |>
  # ADSL variables needed downstream
  derive_vars_merged(
    dataset_add = adsl,
    new_vars    = exprs(TRTSDT, TRTEDT, TRT01A, TRT01AN, SAFFL),
    by_vars     = exprs(STUDYID, USUBJID)
  ) |>

  # Dates
  derive_vars_dt(dtc = VSDTC, new_vars_prefix = "A") |>
  derive_vars_dy(reference_date = TRTSDT, source_vars = exprs(ADT)) |>

  # Parameters
  derive_vars_merged_lookup(
    dataset_add = param_lookup,
    new_vars    = exprs(PARAMCD, PARAM, PARAMN),
    by_vars     = exprs(VSTESTCD)
  ) |>

  mutate(
    AVAL    = VSSTRESN,
    AVISIT  = case_when(
      is.na(ADT)     ~ NA_character_,
      ADY <= 1       ~ "Baseline",
      .default       = VISIT
    ),
    AVISITN = case_when(
      AVISIT == "Baseline" ~ 0,
      .default             = VISITNUM
    )
  ) |>

  # --- Baseline flag: LAST record on or before first dose ------------------
  restrict_derivation(
    derivation = derive_var_extreme_flag,
    args = params(
      by_vars = exprs(STUDYID, USUBJID, PARAMCD),
      order   = exprs(ADT, VSSEQ),
      new_var = ABLFL,
      mode    = "last"
    ),
    filter = !is.na(AVAL) & !is.na(ADT) & ADT <= TRTSDT
  ) |>

  # --- Baseline value and change -------------------------------------------
  derive_var_base(
    by_vars    = exprs(STUDYID, USUBJID, PARAMCD),
    source_var = AVAL,
    new_var    = BASE
  ) |>
  derive_var_chg() |>
  derive_var_pchg() |>

  # --- Analysis record flag ------------------------------------------------
  restrict_derivation(
    derivation = derive_var_extreme_flag,
    args = params(
      by_vars = exprs(STUDYID, USUBJID, PARAMCD, AVISITN),
      order   = exprs(ADT, VSSEQ),
      new_var = ANL01FL,
      mode    = "last"
    ),
    filter = !is.na(AVAL) & !is.na(AVISITN)
  ) |>

  mutate(
    ONTRTFL = if_else(!is.na(ADT) & ADT >= TRTSDT &
                        (is.na(TRTEDT) | ADT <= TRTEDT), "Y", NA_character_)
  ) |>
  arrange(USUBJID, PARAMN, AVISITN, ADT)

Why restrict_derivation() is necessary

derive_var_extreme_flag(mode = "last") flags the last record in each by-group. Without a restriction, “last” means the last record in the whole dataset for that subject and parameter — which is the final follow-up visit, not baseline.

# WRONG — flags the week 24 record as baseline
derive_var_extreme_flag(by_vars = exprs(USUBJID, PARAMCD),
                        order = exprs(ADT), new_var = ABLFL, mode = "last")

# RIGHT — flags the last of the PRE-DOSE records
restrict_derivation(
  derivation = derive_var_extreme_flag,
  args = params(...),
  filter = ADT <= TRTSDT
)

restrict_derivation() applies the derivation only to rows matching the filter and leaves every other row untouched — so post-baseline records get ABLFL = NA rather than being dropped. That “leaves the others untouched” behaviour is the crucial part; a plain filter() before the derivation would delete the post-baseline records entirely.

Verification

# Exactly one baseline per subject and parameter (where one exists)
advs |>
  filter(ABLFL == "Y") |>
  count(USUBJID, PARAMCD) |>
  filter(n > 1)
#> # A tibble: 0 x 3    <- good

# Baseline is always on or before treatment start
advs |> filter(ABLFL == "Y", ADT > TRTSDT) |> nrow()
#> [1] 0

# CHG is 0 on the baseline record itself
advs |> filter(ABLFL == "Y", CHG != 0) |> nrow()
#> [1] 0

# Subjects with no baseline for a parameter — expected, but worth knowing
advs |>
  summarise(has_base = any(ABLFL == "Y", na.rm = TRUE), .by = c(USUBJID, PARAMCD)) |>
  filter(!has_base) |>
  count(PARAMCD)
That last check is the one to run and then discuss with the statistician: a subject with no baseline for a parameter will have BASE, CHG and PCHG all missing, and whether they are excluded from the analysis or handled some other way is an SAP question, not a programming one.

Recap

  • ADSL is one row per subject; BDS is subject/parameter/timepoint; OCCDS is one row per occurrence
  • convert_blanks_to_na() on every SDTM input, first
  • derive_vars_merged() replaces sort + first./last. from SAS
  • restrict_derivation() applies a derivation to a subset without dropping the rest
  • Constrain imputation with min_dates/max_dates, and always keep the --DTF flag
  • derive_param_computed() adds rows, not columns; AVAL.PARAMCD reaches across the by-group
  • CNSR: 0 is an event, 1 is censored
  • Start from use_ad_template() — the templates encode a lot of hard-won judgement

Next: Metadata-driven programming.

Back to top