A SAS programmer moving to R is used to one macro call producing a finished table. The open-source pharmaverse (rtables, tern) gives you the correct building blocks, but assembling a standard display is a multi-step pipeline. sasparity is the opinionated front door over that pipeline: one function per standard display, sensible defaults, and ... as the escape hatch back to the full rtables/tern API whenever a study needs something different.

Scope

Six displays, and nothing else:

Function Display
tab_demographics() Table 14.1.x demographics and baseline characteristics
tab_disposition() Table 14.1.x subject disposition
tab_ae_summary() Table 14.3.1 AE overview
tab_ae_soc() Table 14.3.2 AEs by SOC and preferred term
tab_ae_severity() Table 14.3.x AEs by SOC, PT and maximum severity
lst_ae() Listing 16.2.x adverse events

Anything tern/rtables cannot produce in a single layout call is out of scope — efficacy, labs and vitals, exposure, shift tables, figures, multi-section outputs, RTF/PDF rendering. Every function returns the plain rtables object in $table, so those are a build_table() or an r2rtf::rtf_body() away; they are just not this package’s job. Nor is derivation: adsl must arrive already filtered to the analysis population and adae already flagged (TRTEMFL, AESER, ASEV). That is admiral’s job.

Data

Every example below runs against a small, self-contained fixture bundled in the package (inst/extdata), so it works with no network access and no dependency on pharmaverseadam being installed. sasparity_example() loads it:

adsl <- sasparity_example("adsl")
adae <- sasparity_example("adae")
nrow(adsl); nrow(adae)
#> [1] 11
#> [1] 8

adsl includes one screen-failure subject (SAFFL == "N", TRT01P missing) who was never randomised. Every tab_*() function uses adsl exactly as given to compute its denominators, so filter to your analysis population first, the same way you would restrict a PROC FREQ input with a WHERE clause in SAS:

adsl <- adsl[adsl$SAFFL == "Y", ]
nrow(adsl)
#> [1] 10

Demographics: tab_demographics()

SAS equivalent: a %demog macro — PROC MEANS for continuous variables, PROC FREQ for categorical ones, stacked by treatment group. Here, one call does both; the statistic shown for each variable is chosen automatically from its type via tern::analyze_vars().

tab_demographics(adsl)
#> Registered S3 method overwritten by 'tern':
#>   method   from 
#>   tidy.glm broom
#> 
#> ── Demographics and Baseline Characteristics ──
#> 
#>  SAS equivalent: %demog macro / PROC MEANS + PROC FREQ by treatment group (Table 14.1.x Demographics)
#>  Denominator: n per column = subjects in `adsl` (10 total)
#>                                    A             B        All Patients
#>                                  (N=5)         (N=5)         (N=10)   
#> ——————————————————————————————————————————————————————————————————————
#> AGE                                                                   
#>   n                                5             5             10     
#>   Mean (SD)                   66.2 (6.5)    64.8 (8.5)     65.5 (7.2) 
#>   Median                         66.0          63.0           64.5    
#>   Min - Max                   58.0 - 74.0   55.0 - 77.0   55.0 - 77.0 
#> SEX                                                                   
#>   n                                5             5             10     
#>   F                             3 (60%)       2 (40%)       5 (50%)   
#>   M                             2 (40%)       3 (60%)       5 (50%)   
#> RACE                                                                  
#>   n                                5             5             10     
#>   ASIAN                         1 (20%)       1 (20%)       2 (20%)   
#>   BLACK OR AFRICAN AMERICAN     1 (20%)       1 (20%)       2 (20%)   
#>   WHITE                         3 (60%)       3 (60%)       6 (60%)   
#> ETHNIC                                                                
#>   n                                5             5             10     
#>   HISPANIC OR LATINO            1 (20%)       1 (20%)       2 (20%)   
#>   NOT HISPANIC OR LATINO        4 (80%)       4 (80%)       8 (80%)

Restrict or change the variables with vars; anything else — a different statistic, a custom format — goes through ... straight to tern::analyze_vars():

tab_demographics(adsl, vars = "AGE", .stats = "mean_sd")
#> 
#> ── Demographics and Baseline Characteristics ──
#> 
#>  SAS equivalent: %demog macro / PROC MEANS + PROC FREQ by treatment group (Table 14.1.x Demographics)
#>  Denominator: n per column = subjects in `adsl` (10 total)
#>                 A            B        All Patients
#>               (N=5)        (N=5)         (N=10)   
#> ——————————————————————————————————————————————————
#> Mean (SD)   66.2 (6.5)   64.8 (8.5)    65.5 (7.2)

Disposition: tab_disposition()

SAS equivalent: PROC FREQ TABLES EOSSTT (+ DCSREAS when collected) by treatment group.

tab_disposition(adsl)
#> 
#> ── Subject Disposition ──
#> 
#>  SAS equivalent: %disposition macro / PROC FREQ on EOSSTT (+ DCSREAS) by treatment group (Table 14.1.x Subject Disposition)
#>  Denominator: n per column = subjects in `adsl` (10 total)
#>                   A         B   
#>                 (N=5)     (N=5) 
#> ————————————————————————————————
#> n                 5         5   
#> COMPLETED      3 (60%)   3 (60%)
#> DISCONTINUED   2 (40%)   2 (40%)

AE overview: tab_ae_summary()

SAS equivalent: a %aesumm macro producing the Table 14.3.1 AE overview — subjects with at least one adverse event, at least one serious adverse event, at least one related adverse event, and a fatal adverse event.

tab_ae_summary(adae, adsl)
#> 
#> ── Overview of Adverse Events ──
#> 
#>  SAS equivalent: %aesumm macro / PROC FREQ overview counts, subjects de-duplicated per criterion (Table 14.3.1 AE Overview)
#>  Denominator: n per column = subjects in `adsl` (10 total); each row counts a subject at most once
#>                                          A           B       All Patients
#>                                        (N=5)       (N=5)        (N=10)   
#> —————————————————————————————————————————————————————————————————————————
#> At least one adverse event           3 (60.0%)   3 (60.0%)    6 (60.0%)  
#> At least one serious adverse event   1 (20.0%)   1 (20.0%)    2 (20.0%)  
#> At least one related adverse event   2 (40.0%)   2 (40.0%)    4 (40.0%)  
#> At least one fatal adverse event         0       1 (20.0%)    1 (10.0%)

Why the denominator matters here

adae is record-level and, in this fixture, only contains subjects who had an event — 3 of the 5 subjects per arm. If a table’s percentages were computed against “subjects present in adae”, every criterion satisfied by everyone in adae would read 100%, which is wrong and exactly the kind of error a SAS programmer is trained to catch with a PROC FREQ WHERE mismatch. tab_ae_summary() builds the table with alt_counts_df = adsl and pins denom = "N_col" (not exposed via ... — it is the one thing this function will not let you get wrong), so “at least one adverse event” reads 3 (60.0%), i.e. 3 of the 5 subjects in adsl, never 3 (100%).

AEs by SOC and PT: tab_ae_soc()

SAS equivalent: a %aebodsys macro — PROC FREQ by AEBODSYS*AEDECOD with a first.usubjid-within-term de-duplication step.

tab_ae_soc(adae, adsl)
#> 
#> ── Adverse Events by System Organ Class and Preferred Term ──
#> 
#>  SAS equivalent: %aebodsys macro / PROC FREQ by AEBODSYS*AEDECOD with first.usubjid dedup (Table 14.3.2 AEs by SOC and PT)
#>  Denominator: n per column = subjects in `adsl` (10 total); each SOC/PT count is subjects, not AE records
#>                                              A           B       All Patients
#>                                            (N=5)       (N=5)        (N=10)   
#> —————————————————————————————————————————————————————————————————————————————
#> Subjects with ≥ 1 adverse event          3 (60.0%)   3 (60.0%)    6 (60.0%)  
#> Total adverse events                         5           3            8      
#> GASTROINTESTINAL DISORDERS                                                   
#>   Subjects with ≥ 1 adverse event        2 (40.0%)   1 (20.0%)    3 (30.0%)  
#>   Total adverse events                       4           1            5      
#>     NAUSEA                               2 (40.0%)       0        2 (20.0%)  
#>     VOMITING                             1 (20.0%)   1 (20.0%)    2 (20.0%)  
#> SKIN AND SUBCUTANEOUS TISSUE DISORDERS                                       
#>   Subjects with ≥ 1 adverse event        1 (20.0%)   1 (20.0%)    2 (20.0%)  
#>   Total adverse events                       1           1            2      
#>     RASH                                 1 (20.0%)   1 (20.0%)    2 (20.0%)  
#> CARDIAC DISORDERS                                                            
#>   Subjects with ≥ 1 adverse event            0       1 (20.0%)    1 (10.0%)  
#>   Total adverse events                       0           1            1      
#>     CARDIAC ARREST                           0       1 (20.0%)    1 (10.0%)

Subject S01 has two separate “NAUSEA” records in adae:

adae[adae$USUBJID == "S01" & adae$AEDECOD == "NAUSEA", c("USUBJID", "AEDECOD", "AESTDTC")]
#>   USUBJID AEDECOD    AESTDTC
#> 1     S01  NAUSEA 2021-01-02
#> 2     S01  NAUSEA 2021-01-05

Getting subject-level vs. record-level counting right here is the whole point of the function. Combined with subject S03’s one “NAUSEA” record, arm A has 3 raw AE records of “NAUSEA” but only 2 subjects — and the table above shows 2 (40.0%), not 3. Every count in this table, at both the SOC and PT level, is of subjects (via tern::summarize_num_patients() and tern::count_occurrences()), never of rows.

AEs by SOC, PT and maximum severity: tab_ae_severity()

SAS equivalent: an %aesev macro — PROC FREQ by AEBODSYS*AEDECOD*ASEV after sorting on a numeric severity code and keeping the worst record per subject and term. PhUSE’s own contributed/AE/AE_Severity/ scripts build exactly this table in SAS, which is why it is here at all.

tab_ae_severity(adae, adsl)
#> 
#> ── Adverse Events by System Organ Class, Preferred Term and Maximum Severity ──
#> 
#>  SAS equivalent: %aesev macro / PROC FREQ by AEBODSYS*AEDECOD*ASEV after keeping the worst severity per subject and term (Table 14.3.x AEs by SOC, PT and maximum severity)
#>  Denominator: n per column = subjects in `adsl` (10 total); each subject counts once per term, at their worst of MILD < MODERATE < SEVERE
#>                                              A           B       All Patients
#>                                            (N=5)       (N=5)        (N=10)   
#> —————————————————————————————————————————————————————————————————————————————
#> Subjects with ≥ 1 adverse event          3 (60.0%)   3 (60.0%)    6 (60.0%)  
#> CARDIAC DISORDERS                                                            
#>   MILD                                       0           0            0      
#>   MODERATE                                   0           0            0      
#>   SEVERE                                     0       1 (20.0%)    1 (10.0%)  
#>     CARDIAC ARREST                                                           
#>       MILD                                   0           0            0      
#>       MODERATE                               0           0            0      
#>       SEVERE                                 0       1 (20.0%)    1 (10.0%)  
#> GASTROINTESTINAL DISORDERS                                                   
#>   MILD                                   1 (20.0%)       0        1 (10.0%)  
#>   MODERATE                               1 (20.0%)   1 (20.0%)    2 (20.0%)  
#>   SEVERE                                     0           0            0      
#>     NAUSEA                                                                   
#>       MILD                               2 (40.0%)       0        2 (20.0%)  
#>       MODERATE                               0           0            0      
#>       SEVERE                                 0           0            0      
#>     VOMITING                                                                 
#>       MILD                                   0           0            0      
#>       MODERATE                           1 (20.0%)   1 (20.0%)    2 (20.0%)  
#>       SEVERE                                 0           0            0      
#> SKIN AND SUBCUTANEOUS TISSUE DISORDERS                                       
#>   MILD                                       0       1 (20.0%)    1 (10.0%)  
#>   MODERATE                               1 (20.0%)       0        1 (10.0%)  
#>   SEVERE                                     0           0            0      
#>     RASH                                                                     
#>       MILD                                   0       1 (20.0%)    1 (10.0%)  
#>       MODERATE                           1 (20.0%)       0        1 (10.0%)  
#>       SEVERE                                 0           0            0

Each subject is counted once per term, at their worst severity: S01’s two mild “NAUSEA” records give one subject in the MILD row, and at the SOC level S01’s moderate “VOMITING” moves them to the MODERATE row for gastrointestinal disorders. tern decides “worst” by factor level order, so sev_levels (default c("MILD", "MODERATE", "SEVERE")) is what defines the ordering — pass as.character(1:5) with sev_var = "AETOXGR" for CTCAE grades. A severity value outside sev_levels is an error, not a silently mis-ranked row.

The listing: lst_ae()

SAS equivalent: a %aelist macro / PROC REPORT listing sorted BY USUBJID — Listing 16.2.x. Unlike the tables above, a listing is deliberately not de-duplicated: every AE record gets its own row.

lst_ae(adae)
#> 
#> ── Adverse Event Listing ──
#> 
#>  SAS equivalent: %aelist macro / PROC REPORT listing sorted BY USUBJID (Listing 16.2.x Adverse Events)
#>  Denominator: 8 record(s) across 6 subject(s); not de-duplicated (a listing, not a count)
#> # A tibble: 8 × 9
#>   USUBJID AEBODSYS               AEDECOD AESTDTC AEENDTC ASEV  AESER AEREL AEOUT
#>   <chr>   <chr>                  <chr>   <chr>   <chr>   <chr> <chr> <chr> <chr>
#> 1 S01     GASTROINTESTINAL DISO… NAUSEA  2021-0… 2021-0… MILD  N     POSS… RECO…
#> 2 S01     GASTROINTESTINAL DISO… NAUSEA  2021-0… 2021-0… MILD  N     POSS… RECO…
#> 3 S01     GASTROINTESTINAL DISO… VOMITI… 2021-0… 2021-0… MODE… N     NONE  RECO…
#> 4 S02     SKIN AND SUBCUTANEOUS… RASH    2021-0… 2021-0… MODE… Y     PROB… RECO…
#> 5 S03     GASTROINTESTINAL DISO… NAUSEA  2021-0… 2021-0… MILD  N     NONE  RECO…
#> 6 S06     SKIN AND SUBCUTANEOUS… RASH    2021-0… 2021-0… MILD  N     POSS… RECO…
#> 7 S07     GASTROINTESTINAL DISO… VOMITI… 2021-0… 2021-0… MODE… N     NONE  RECO…
#> 8 S08     CARDIAC DISORDERS      CARDIA… 2021-0… NA      SEVE… Y     PROB… FATAL

The SAS↔︎R equivalence table

The mapping is shipped as package data (sas_r_map, 51 rows) and queried with parity_note(). It covers this package’s six displays and the wider set of SAS idioms a migration runs into — PROC SORT NODUPKEY, first./ last., PROC TRANSPOSE, %sysfunc, LIBNAME XPORT, PROC LIFETEST and so on:

parity_note("tab_ae_soc")$sas_equivalent
#> [1] "%aebodsys macro / PROC FREQ by AEBODSYS*AEDECOD with first.usubjid dedup (Table 14.3.2 AEs by SOC and PT)"
parity_note(sas = "PROC SORT")[c("sas_idiom", "r_function")]
#> # A tibble: 1 × 2
#>   sas_idiom          r_function       
#>   <chr>              <chr>            
#> 1 PROC SORT NODUPKEY dplyr::distinct()

The difference column is the reason the table exists. A mapping that claims equivalence where behaviour actually diverges is worse than no mapping, so every row says where the two part company:

parity_note(sas = "nodupkey")$difference
#> [1] "NODUPKEY sorts first, keeps the first record per key and can write the rest to DUPOUT= for review; distinct() keeps the first row in the current row order and discards the duplicates with no record of what was dropped."
parity_note("case_when")$difference
#> [1] "A missing numeric in SAS sorts below every number, so IF age < 18 quietly catches missing ages; case_when() returns NA for an NA input, so the missing rows fall through to the default unless you test is.na() yourself."

The SAS side comes only from the public, MIT-licensed PhUSE repositories (phuse-org/phuse-scripts, phuse-org/TestDataFactory); phuse_files and phuse_example record how many of those 75 .sas files contain the idiom and name one of them, and data-raw/sas_r_map.R refuses to rebuild the dataset if an idiom cannot be found in that corpus.

head(parity_note(sas = "PROC FREQ")[c("sas_idiom", "phuse_files", "phuse_example")])
#> # A tibble: 6 × 3
#>   sas_idiom                                            phuse_files phuse_example
#>   <chr>                                                      <int> <chr>        
#> 1 %aebodsys / PROC FREQ AEBODSYS*AEDECOD                         9 phuse-script…
#> 2 %aesev / PROC FREQ AEBODSYS*AEDECOD*ASEV worst grade           3 phuse-script…
#> 3 %aesumm / PROC FREQ AE overview counts                         1 phuse-script…
#> 4 %demog / PROC MEANS + PROC FREQ by treatment                   2 phuse-script…
#> 5 %disposition / PROC FREQ on EOSSTT                             1 phuse-script…
#> 6 PROC FREQ TABLES a*b                                          20 phuse-script…

Rows outside topic == "display" point at packages sasparity does not depend on (tidyr, haven, xportr, survival, mmrm, r2rtf, …). They are documentation, not a runtime contract: the difference text tells you where to look, it does not prove numerical equivalence for your study.

Bring your own data

Every function above works identically on a full ADaM adsl/adae pair — e.g. from pharmaverseadam or your own study — as long as the columns it needs are present. If a required column is missing, or by (default "TRT01P") has missing values in adsl, the function stops with a message naming exactly what to fix, rather than producing a table with a silently wrong denominator.