admiral 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"))

Coming from SAS

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.

1. Laboratory toxicity grading

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 rows

Read 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      NA

The 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.

Choosing the criteria set

"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   NA

A 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")

2. Adverse event episodes

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 FALSE

Read 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 FALSE

Records 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.

3. Population flags

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     N

Nothing 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.

Handing back to admiral

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
  )

Real data

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