ADaM programming with admiral
Lesson 4 — Clinical Programming with R
Learning objectives
- Describe the ADaM structures and when each applies
- Build ADSL with
admiralderivation 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
admiraltemplate 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:
- Analysis-ready — a statistician should be able to produce the analysis with a simple
filter()and a model call, no further derivation - 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)convert_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) / BASEderive_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
)min_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 52The 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.
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)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, firstderive_vars_merged()replaces sort + first./last. from SASrestrict_derivation()applies a derivation to a subset without dropping the rest- Constrain imputation with
min_dates/max_dates, and always keep the--DTFflag derive_param_computed()adds rows, not columns;AVAL.PARAMCDreaches across the by-groupCNSR: 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.