getting-started.RmdA 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.
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.
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] 8adsl 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] 10tab_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)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%)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%)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%).
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-05Getting 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.
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 0Each 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.
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… FATALThe 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.
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.