getting-started.Rmdadmiral is deliberately modular, which is why it is
trustworthy. The cost of that modularity is that three derivations get
rewritten as study-specific code on every study: laboratory toxicity
grading, collapsing adverse events into episodes, and population flags.
admiralease makes those three
metadata-driven and returns plain data frames that
admiral carries on with.
Everything below runs on the small fixtures shipped in
inst/extdata, so this vignette needs no study data.
ex <- function(f) system.file("extdata", f, package = "admiralease")
adlb <- utils::read.csv(ex("adlb_example.csv"))
adae <- utils::read.csv(ex("adae_example.csv"))
adsl <- utils::read.csv(ex("adsl_example.csv"))Each derivation records the SAS idiom it replaces.
sas_note()
#>
#> ── grade_lab() ──
#>
#> • SAS idiom replaced: %LABGRADE / %CTCAE - a DATA step of nested IF paramcd =
#> 'ALT' AND aval > 3*anrhi THEN atoxgr = 2; ELSE IF ... branches, one block per
#> parameter, re-typed per study.
#> ✔ The hand-typed branches become published NCI-CTCAE lab criteria (admiral
#> package data, selectable v4/v5/US-conventional/DAIDS); the IF/ELSE cascade
#> becomes one call, and grade 0 is distinguished from a missing or uncovered
#> result rather than both landing on 0. Lab terms only - the criteria cover 40
#> lab terms, not the full CTCAE term set.
#>
#> ── collapse_ae_episodes() ──
#>
#> • SAS idiom replaced: %AE_EPISODE - PROC SORT BY usubjid aedecod astdt, then a
#> BY-group RETAIN of _prev_end with IF astdt - _prev_end > 1 THEN episode + 1;
#> ✔ Merging against the running maximum end date, not the previous record's end
#> date, which is the bug the RETAIN version hides; ongoing events (missing end
#> date) are handled explicitly.
#>
#> ── derive_pop_flags() ──
#>
#> • SAS idiom replaced: %POPFLAG - the ADSL DATA step block of IF trtsdt NE .
#> THEN saffl = 'Y'; ELSE saffl = 'N'; repeated once per analysis population.
#> ✔ Population definitions live in a table that maps one-to-one onto the
#> protocol; an unevaluable condition stops the run naming the flag instead of
#> quietly writing a missing value.grade_lab() grades with published
criteria, which admiral ships as package data, by
calling admiral::derive_var_atoxgr_dir(). The toxicity
logic is admiral’s; this package selects the table, checks the inputs,
and gives you your row order and variable labels back.
grading_table("ctcv5")
#>
#> ── Laboratory toxicity criteria (ctcv5) ──
#>
#> ℹ 40 terms, 40 criteria, 3 SOCs.
#> ! Laboratory terms only - not the full CTCAE term set. Non-lab terms are not graded.
#> # A tibble: 40 × 5
#> SOC TERM DIRECTION UNIT_CHECK VAR_CHECK
#> <chr> <chr> <chr> <chr> <chr>
#> 1 Blood and lymphatic system disorders Anemia L g/L AVAL, AN…
#> 2 Blood and lymphatic system disorders Leukocyt… H 10^9/L AVAL
#> 3 Investigations Activate… H NA AVAL, AN…
#> 4 Investigations Alanine … H NA AVAL, AN…
#> 5 Investigations Alkaline… H NA AVAL, AN…
#> 6 Investigations Aspartat… H NA AVAL, AN…
#> 7 Investigations Blood bi… H NA AVAL, AN…
#> 8 Investigations Blood la… H NA AVAL, AN…
#> 9 Investigations CD4 lymp… L 10^9/L AVAL, AN…
#> 10 Investigations Choleste… H mmol/L AVAL, AN…
#> # ℹ 30 more rowsRead the warning: these are the laboratory criteria only — 40 terms across 3 SOCs, not the full NCI-CTCAE v5 term set. Non-laboratory CTCAE terms (nausea, fatigue, rash, …) are not in the table and cannot be graded here.
Grading needs the criteria term on each record, in
ATOXDSCL/ATOXDSCH, plus whatever the criteria
reference (AVAL, ANRLO, ANRHI,
BASE, BNRIND). The fixture already carries
them; on a real study, merge your own PARAMCD-to-term
lookup on first, as admiral’s ADLB template does.
graded <- grade_lab(adlb) # ctcv5 is the default
#> ℹ No criteria for 1 term: "Hypophosphatemia" (grade left NA).
graded[, c("PARAMCD", "AVAL", "ATOXDSCL", "ATOXDSCH", "ATOXGRL", "ATOXGRH", "ATOXGR")]
#> # A tibble: 17 × 7
#> PARAMCD AVAL ATOXDSCL ATOXDSCH ATOXGRL ATOXGRH ATOXGR
#> <chr> <dbl> <chr> <chr> <chr> <chr> <chr>
#> 1 ALT 20 NA Alanine aminot… NA 0 0
#> 2 ALT 120 NA Alanine aminot… NA 2 2
#> 3 ALT NA NA Alanine aminot… NA NA NA
#> 4 POTAS 5.4 Hypokalemia Hyperkalemia 0 0 0
#> 5 POTAS 5.8 Hypokalemia Hyperkalemia 0 2 2
#> 6 POTAS 3.2 Hypokalemia Hyperkalemia 2 0 -2
#> 7 POTAS 1.9 Hypokalemia Hyperkalemia 4 0 -4
#> 8 SODIUM 128 Hyponatremia Hypernatremia 3 0 -3
#> 9 HGB 6 Anemia Hemoglobin inc… NA NA NA
#> 10 PLAT 400 Platelet count decreased NA 0 NA 0
#> 11 PLAT 60 Platelet count decreased NA 2 NA -2
#> 12 BILI 40 NA Blood bilirubi… NA 2 2
#> 13 CREAT 130 NA Creatinine inc… NA 2 2
#> 14 ALB 30 Hypoalbuminemia NA 1 NA -1
#> 15 HGBC 9 Anemia Hemoglobin inc… NA NA NA
#> 16 PHOS 0.7 Hypophosphatemia NA NA NA NA
#> 17 ANISO 1 NA NA NA NA NAThe convention: ATOXGRL and ATOXGRH hold
the low- and high-direction grade as "0".."4",
and the combined ATOXGR carries a low toxicity as a
negative grade ("-1".."-4")
and a high toxicity as a positive one. This matches
admiral::derive_var_atoxgr() and the ADaM lab data in
pharmaverseadam.
Note the rows that are not graded 0:
ALT with a missing AVAL stays missing in
all three variables;ANISO (“Anisocytes”) is in no criteria table, so it is
NA, not "0";HGB is reported in mmol/L but the ctcv5
Anemia criterion checks for g/L, so the unit does not match and the
grade is NA;PLAT is graded in the low direction only, so
ATOXGRH is NA while ATOXGR still
resolves."ctcv4", "ctcv5" (default),
"ctcv5_uscv" and "daids" are accepted. They
genuinely differ, and the fixture shows where.
data.frame(
PARAMCD = adlb$PARAMCD,
AVAL = adlb$AVAL,
ctcv4 = grade_lab(adlb, "ctcv4")$ATOXGR,
ctcv5 = grade_lab(adlb, "ctcv5")$ATOXGR,
uscv = grade_lab(adlb, "ctcv5_uscv")$ATOXGR
)
#> ℹ No criteria for 1 term: "Hypophosphatemia" (grade left NA).
#> ℹ No criteria for 1 term: "Hypophosphatemia" (grade left NA).
#> PARAMCD AVAL ctcv4 ctcv5 uscv
#> 1 ALT 20.0 0 0 0
#> 2 ALT 120.0 2 2 2
#> 3 ALT NA <NA> <NA> <NA>
#> 4 POTAS 5.4 0 0 0
#> 5 POTAS 5.8 2 2 2
#> 6 POTAS 3.2 -2 -2 -2
#> 7 POTAS 1.9 -4 -4 -4
#> 8 SODIUM 128.0 -3 -3 -3
#> 9 HGB 6.0 <NA> <NA> <NA>
#> 10 PLAT 400.0 0 0 <NA>
#> 11 PLAT 60.0 -2 -2 <NA>
#> 12 BILI 40.0 2 2 2
#> 13 CREAT 130.0 2 2 2
#> 14 ALB 30.0 -1 -1 <NA>
#> 15 HGBC 9.0 <NA> <NA> -2
#> 16 PHOS 0.7 -2 <NA> <NA>
#> 17 ANISO 1.0 <NA> <NA> <NA>PHOS grades under v4.03 and not under v5, because
Hypophosphatemia was dropped from CTCAE v5. HGBC
(haemoglobin in g/dL) grades only under "ctcv5_uscv", whose
unit checks are US conventional.
If your criteria are not one of the published sets, pass a table of numeric cut-points. Thresholds are in the same units as the value being graded, and are strict: a value exactly on a threshold does not qualify.
# DEMO fixture in pharmaverseadam units. NOT NCI-CTCAE - never grade a study
# with it; it exists so this vignette can show the threshold path.
demo <- utils::read.csv(ex("demo_grading_table.csv"))
grade_lab(adlb, demo)$ATOXGR
#> ℹ No grading criteria for 4 parameters: "ALB", "HGBC", "PHOS", and "ANISO" (grade left NA).
#> [1] "0" "2" NA "0" "2" "-1" "-4" "-2" "-2" "0" "-2" "2" "1" NA NA
#> [16] NA NAA threshold table written against the reference range
(> 3 x ULN) is handled by grading a ratio:
adlb$RATIO <- adlb$AVAL / adlb$ANRHI
grade_lab(adlb, ratio_grading, value_var = "RATIO")Multiple AE records that describe one continuing event are collapsed
into one episode. Records merge when the next onset is no more than
gap_days after the running maximum end
date of the episode so far.
collapse_ae_episodes(adae)
#> ! 1 record with a missing ASTDT cannot be merged; kept as single-record episode.
#> # A tibble: 7 × 9
#> USUBJID AEDECOD EPISODE ASTDT AENDT AESEV AEREL NRECORD ONGOING
#> <chr> <chr> <int> <date> <date> <chr> <chr> <int> <lgl>
#> 1 AE01-001 ERYTHEMA 1 2021-01-05 2021-01-15 MODERA… POSS… 2 FALSE
#> 2 AE01-001 HEADACHE 1 2021-03-01 2021-03-02 MILD NONE 1 FALSE
#> 3 AE01-001 HEADACHE 2 2021-03-10 2021-03-12 SEVERE PROB… 1 FALSE
#> 4 AE01-002 DIARRHOEA 1 2021-04-01 NA SEVERE PROB… 2 TRUE
#> 5 AE01-002 NAUSEA 1 2021-02-01 2021-02-06 MODERA… POSS… 2 FALSE
#> 6 AE01-003 DIZZINESS 1 NA 2021-06-05 MODERA… POSS… 1 FALSE
#> 7 AE01-003 FATIGUE 1 2021-05-01 2021-05-25 SEVERE PROB… 3 FALSERead the fixture against that output:
| Case | Records | Result |
|---|---|---|
| Overlapping | ERYTHEMA 05-10 Jan, 08-15 Jan | one episode, 05-15 Jan |
| Adjacent | NAUSEA 01-03 Feb, 04-06 Feb | one episode, 01-06 Feb |
| Gapped | HEADACHE 01-02 Mar, 10-12 Mar | two episodes |
| Open-ended | DIARRHOEA 01 Apr-ongoing, 20-25 Apr | one episode, AENDT NA,
ONGOING
|
| Swallowed | FATIGUE 01-20 May, 05-07 May, 21-25 May | one episode of 3 records |
The FATIGUE case is the one a RETAIN _prev_end
DATA step gets wrong: the second record ends on 05 May, so
comparing the third record against the previous record’s end
date opens a spurious second episode. Comparing against the running
maximum (20 May) does not.
Tighten the rule to overlap-only:
collapse_ae_episodes(adae, gap_days = 0)
#> ! 1 record with a missing ASTDT cannot be merged; kept as single-record episode.
#> # A tibble: 9 × 9
#> USUBJID AEDECOD EPISODE ASTDT AENDT AESEV AEREL NRECORD ONGOING
#> <chr> <chr> <int> <date> <date> <chr> <chr> <int> <lgl>
#> 1 AE01-001 ERYTHEMA 1 2021-01-05 2021-01-15 MODERA… POSS… 2 FALSE
#> 2 AE01-001 HEADACHE 1 2021-03-01 2021-03-02 MILD NONE 1 FALSE
#> 3 AE01-001 HEADACHE 2 2021-03-10 2021-03-12 SEVERE PROB… 1 FALSE
#> 4 AE01-002 DIARRHOEA 1 2021-04-01 NA SEVERE PROB… 2 TRUE
#> 5 AE01-002 NAUSEA 1 2021-02-01 2021-02-03 MILD REMO… 1 FALSE
#> 6 AE01-002 NAUSEA 2 2021-02-04 2021-02-06 MODERA… POSS… 1 FALSE
#> 7 AE01-003 DIZZINESS 1 NA 2021-06-05 MODERA… POSS… 1 FALSE
#> 8 AE01-003 FATIGUE 1 2021-05-01 2021-05-20 MODERA… POSS… 2 FALSE
#> 9 AE01-003 FATIGUE 2 2021-05-21 2021-05-25 SEVERE PROB… 1 FALSERecords with a missing onset date cannot be placed on the timeline. Each is returned as its own single-record episode and reported, never silently merged.
The rules table maps a flag to a condition. Rules are applied in order, so a flag can be used by the rules below it.
rules <- utils::read.csv(ex("pop_rules.csv"))
pop_rules(rules)
#>
#> ── Population flag rules ──
#>
#> ℹ 5 flags, applied in order.
#> • RANDFL = `!is.na(RANDDT)`
#> • SAFFL = `!is.na(TRTSDT)`
#> • ITTFL = `RANDFL == "Y" & TRT01P != "Screen Failure"`
#> • EFFFL = `SAFFL == "Y" & !is.na(BASEEFF)`
#> • COMPLFL = `EOSSTT == "COMPLETED"`
flagged <- derive_pop_flags(adsl, rules)
flagged[, c("USUBJID", "RANDFL", "SAFFL", "ITTFL", "EFFFL", "COMPLFL")]
#> # A tibble: 8 × 6
#> USUBJID RANDFL SAFFL ITTFL EFFFL COMPLFL
#> <chr> <chr> <chr> <chr> <chr> <chr>
#> 1 AE01-001 Y Y Y Y Y
#> 2 AE01-002 Y Y Y Y N
#> 3 AE01-003 Y Y Y Y Y
#> 4 AE01-004 Y Y Y N Y
#> 5 AE01-005 Y N Y N N
#> 6 AE01-006 Y Y Y Y Y
#> 7 AE01-007 N N N N N
#> 8 AE01-008 Y Y Y Y NNothing is hardcoded, so a study-specific population is just another row:
derive_pop_flags(
adsl,
data.frame(FLAG = "ELDERFL", CONDITION = "AGE >= 65")
)$ELDERFL
#> ! ELDERFL: 1 subject where the condition is NA, set to "N".
#> [1] "N" "Y" "N" "N" "Y" "N" "N" "Y"That call also shows the missing-data rule: subject
AE01-006 has no age, so the condition is NA,
the flag is set to "N", and the count is reported. A
condition that cannot be evaluated at all is an error naming the
flag:
derive_pop_flags(adsl, data.frame(FLAG = "BADFL", CONDITION = "WEIGHT > 50"))
#> Error in `value[[3L]]()`:
#> ! Population flag 'BADFL' could not be evaluated.
#> ✖ Condition: WEIGHT > 50
#> ✖ object 'WEIGHT' not found
#> ℹ Every variable used must exist in the data or be a flag defined earlier.Every return is a plain tibble, so the output goes
straight into an admiral pipeline:
adsl |>
derive_pop_flags(rules) |>
admiral::derive_vars_merged(
dataset_add = collapse_ae_episodes(adae),
by_vars = admiral::exprs(USUBJID),
filter_add = EPISODE == 1
)With pharmaverseadam installed, the same three calls run
on realistic CDISC pilot data.
episodes <- collapse_ae_episodes(pharmaverseadam::adae)
c(records = nrow(pharmaverseadam::adae), episodes = nrow(episodes))
#> records episodes
#> 1191 867
# pharmaverseadam::adlb was built by admiral's ADLB template with CTCAE v4.03,
# so grading it with "ctcv4" must reproduce its own ATOXGR exactly.
regraded <- grade_lab(pharmaverseadam::adlb, "ctcv4")$ATOXGR
original <- pharmaverseadam::adlb$ATOXGR
c(
rows = length(original),
agreement_pct = round(100 * mean(
(is.na(original) & is.na(regraded)) |
(!is.na(original) & !is.na(regraded) & original == regraded)
), 2)
)
#> rows agreement_pct
#> 83652 100