Every drug and diagnostic that gets submitted to the FDA (and most other regulators) arrives with its clinical trial data in a very specific shape. That shape is SDTM — the Study Data Tabulation Model, a CDISC standard. If you work in clinical data science, biostatistics, or statistical programming, SDTM is the lingua franca. This page is a from-scratch introduction: what it is, why it exists, and how to build SDTM-style datasets in R using a handful of small, reusable functions.
You don’t need any clinical background. If you’ve done a little tidyverse (see the Learn R course), you can follow everything here.
Imagine every pharma company invented its own column names, table layouts, and coding conventions for their trial data. A reviewer at the FDA would have to relearn the data from scratch for every single submission. SDTM exists to stop that.
SDTM standardizes the content and structure of the raw, collected trial data so that any reviewer, any validation tool, and any downstream program knows exactly where to look. It defines:
SDTM is “one tidy table per kind of thing that happened in the trial.” Demographics is one table. Adverse events is another. Vital signs is another. Every table shares a common backbone (subject ID, study ID) so they can be joined back together.
| Code | Domain | One row per… |
|---|---|---|
DM |
Demographics | subject |
AE |
Adverse Events | adverse event a subject had |
VS |
Vital Signs | vital-sign measurement |
LB |
Laboratory | lab test result |
CM |
Concomitant Meds | medication a subject took |
EX |
Exposure | dose of study drug administered |
There are dozens more, but if you understand these you understand the model.
SDTM variable names follow a pattern. Once you see it, the columns stop looking like alphabet soup:
--SEQ — a sequence number, unique
within a subject and domain (AESEQ,
VSSEQ).--TESTCD / --TEST — the
short code and long name of what was measured
(VSTESTCD = "SYSBP",
VSTEST = "Systolic Blood Pressure").--ORRES / --ORRESU — the
original result as collected, and its unit
(VSORRES = "120", VSORRESU = "mmHg").--STRESC / --STRESN — the
result standardized to a character and a numeric form.--DTC — a date/time in ISO 8601 text
(AESTDTC = "2023-04-12").STUDYID / USUBJID — the
study ID and the unique subject ID, the keys that tie every
domain together.The leading -- is a placeholder for the domain code. So
the “test code” variable is VSTESTCD in Vital Signs and
LBTESTCD in Labs.
In real life the raw data comes out of an electronic data capture (EDC) system and looks nothing like SDTM. Our job is to reshape it. Let’s fabricate a tiny, messy “raw” export so we have something concrete to transform.
library(dplyr)
library(tidyr)
set.seed(42)
raw_demographics <- tibble::tribble(
~subject, ~site, ~birth_date, ~sex, ~race, ~arm,
"001", "01", "1968-03-11", "Male", "White", "Active",
"002", "01", "1975-11-30", "Female", "Asian", "Placebo",
"003", "02", "1954-07-02", "Female", "White", "Active",
"004", "02", "1981-01-19", "Male", "Black", "Placebo",
"005", "03", "1990-09-25", "Male", "White", "Active"
)
raw_demographics
#> # A tibble: 5 × 6
#> subject site birth_date sex race arm
#> <chr> <chr> <chr> <chr> <chr> <chr>
#> 1 001 01 1968-03-11 Male White Active
#> 2 002 01 1975-11-30 Female Asian Placebo
#> 3 003 02 1954-07-02 Female White Active
#> 4 004 02 1981-01-19 Male Black Placebo
#> 5 005 03 1990-09-25 Male White Active
A raw vital-signs export, in “long” form, one measurement per row:
raw_vitals <- tibble::tribble(
~subject, ~visit, ~measure, ~value, ~units, ~date,
"001", "Screening", "SYSBP", 128, "mmHg", "2023-01-05",
"001", "Screening", "DIABP", 82, "mmHg", "2023-01-05",
"001", "Week 4", "SYSBP", 122, "mmHg", "2023-02-02",
"001", "Week 4", "DIABP", 79, "mmHg", "2023-02-02",
"002", "Screening", "SYSBP", 141, "mmHg", "2023-01-06",
"002", "Screening", "DIABP", 90, "mmHg", "2023-01-06",
"003", "Screening", "SYSBP", 118, "mmHg", "2023-01-09",
"003", "Week 4", "SYSBP", 115, "mmHg", "2023-02-07"
)
raw_vitals
#> # A tibble: 8 × 6
#> subject visit measure value units date
#> <chr> <chr> <chr> <dbl> <chr> <chr>
#> 1 001 Screening SYSBP 128 mmHg 2023-01-05
#> 2 001 Screening DIABP 82 mmHg 2023-01-05
#> 3 001 Week 4 SYSBP 122 mmHg 2023-02-02
#> 4 001 Week 4 DIABP 79 mmHg 2023-02-02
#> 5 002 Screening SYSBP 141 mmHg 2023-01-06
#> 6 002 Screening DIABP 90 mmHg 2023-01-06
#> 7 003 Screening SYSBP 118 mmHg 2023-01-09
#> 8 003 Week 4 SYSBP 115 mmHg 2023-02-07
And a raw adverse-event log:
raw_ae <- tibble::tribble(
~subject, ~event, ~severity, ~start, ~end,
"001", "Headache", "Mild", "2023-01-20", "2023-01-22",
"001", "Nausea", "Moderate", "2023-02-05", NA,
"003", "Fatigue", "Mild", "2023-01-15", "2023-01-30",
"004", "Dizziness", "Severe", "2023-01-25", "2023-01-26"
)
raw_ae
#> # A tibble: 4 × 5
#> subject event severity start end
#> <chr> <chr> <chr> <chr> <chr>
#> 1 001 Headache Mild 2023-01-20 2023-01-22
#> 2 001 Nausea Moderate 2023-02-05 <NA>
#> 3 003 Fatigue Mild 2023-01-15 2023-01-30
#> 4 004 Dizziness Severe 2023-01-25 2023-01-26
DM is the anchor domain: exactly one row per subject. Everything else
joins back to it. Building it means renaming to SDTM variables,
constructing the USUBJID key, and deriving AGE
from the birth date.
Here’s a reusable function:
make_dm <- function(raw, studyid) {
raw |>
mutate(
STUDYID = studyid,
DOMAIN = "DM",
SITEID = site,
SUBJID = subject,
USUBJID = paste(studyid, site, subject, sep = "-"),
RFSTDTC = birth_date, # reference start (placeholder)
BRTHDTC = birth_date,
AGE = as.integer(
floor(as.numeric(Sys.Date() - as.Date(birth_date)) / 365.25)
),
AGEU = "YEARS",
SEX = dplyr::recode(sex, "Male" = "M", "Female" = "F"),
RACE = toupper(race),
ARM = arm,
ACTARM = arm
) |>
select(
STUDYID, DOMAIN, USUBJID, SUBJID, SITEID,
BRTHDTC, AGE, AGEU, SEX, RACE, ARM, ACTARM
)
}
dm <- make_dm(raw_demographics, studyid = "DEMO01")
dm
#> # A tibble: 5 × 12
#> STUDYID DOMAIN USUBJID SUBJID SITEID BRTHDTC AGE AGEU SEX RACE ARM
#> <chr> <chr> <chr> <chr> <chr> <chr> <int> <chr> <chr> <chr> <chr>
#> 1 DEMO01 DM DEMO01-01-… 001 01 1968-0… 58 YEARS M WHITE Acti…
#> 2 DEMO01 DM DEMO01-01-… 002 01 1975-1… 50 YEARS F ASIAN Plac…
#> 3 DEMO01 DM DEMO01-02-… 003 02 1954-0… 72 YEARS F WHITE Acti…
#> 4 DEMO01 DM DEMO01-02-… 004 02 1981-0… 45 YEARS M BLACK Plac…
#> 5 DEMO01 DM DEMO01-03-… 005 03 1990-0… 35 YEARS M WHITE Acti…
#> # ℹ 1 more variable: ACTARM <chr>
A few SDTM conventions on display here:
USUBJID must be unique across the whole
study, so we build it from study + site + subject. Two sites
could both have a subject “001”; the USUBJID keeps them
distinct.SEX is coded to controlled terminology
(M/F), not free text.AGE is derived, not collected. SDTM is
full of derived variables like this.Real SDTM uses CDISC controlled terminology — fixed,
published code lists for values like SEX,
RACE, and units. You can’t put “Male” in SEX;
it has to be M. Here we’re doing a simplified
recode(), but in a real study you’d validate every coded
column against the official code list.
Vital signs are a findings domain: one row per
measurement, using the --TESTCD / --ORRES
pattern. Our raw data is already long, which is the right shape — we
mostly need to rename and add the standard result columns.
vs_test_labels <- c(
SYSBP = "Systolic Blood Pressure",
DIABP = "Diastolic Blood Pressure",
PULSE = "Pulse Rate",
TEMP = "Temperature",
RESP = "Respiratory Rate"
)
make_vs <- function(raw, dm, studyid) {
subject_key <- dm |>
select(subject = SUBJID, USUBJID)
raw |>
left_join(subject_key, by = "subject") |>
mutate(
STUDYID = studyid,
DOMAIN = "VS",
VSTESTCD = measure,
VSTEST = unname(vs_test_labels[measure]),
VSORRES = as.character(value),
VSORRESU = units,
VSSTRESC = as.character(value),
VSSTRESN = as.numeric(value),
VSSTRESU = units,
VISIT = visit,
VSDTC = date
) |>
arrange(USUBJID, VSDTC, VSTESTCD) |>
group_by(USUBJID) |>
mutate(VSSEQ = row_number()) |>
ungroup() |>
select(
STUDYID, DOMAIN, USUBJID, VSSEQ, VSTESTCD, VSTEST,
VSORRES, VSORRESU, VSSTRESC, VSSTRESN, VSSTRESU, VISIT, VSDTC
)
}
vs <- make_vs(raw_vitals, dm, studyid = "DEMO01")
vs
#> # A tibble: 8 × 13
#> STUDYID DOMAIN USUBJID VSSEQ VSTESTCD VSTEST VSORRES VSORRESU VSSTRESC
#> <chr> <chr> <chr> <int> <chr> <chr> <chr> <chr> <chr>
#> 1 DEMO01 VS DEMO01-01-001 1 DIABP Diastol… 82 mmHg 82
#> 2 DEMO01 VS DEMO01-01-001 2 SYSBP Systoli… 128 mmHg 128
#> 3 DEMO01 VS DEMO01-01-001 3 DIABP Diastol… 79 mmHg 79
#> 4 DEMO01 VS DEMO01-01-001 4 SYSBP Systoli… 122 mmHg 122
#> 5 DEMO01 VS DEMO01-01-002 1 DIABP Diastol… 90 mmHg 90
#> 6 DEMO01 VS DEMO01-01-002 2 SYSBP Systoli… 141 mmHg 141
#> 7 DEMO01 VS DEMO01-02-003 1 SYSBP Systoli… 118 mmHg 118
#> 8 DEMO01 VS DEMO01-02-003 2 SYSBP Systoli… 115 mmHg 115
#> # ℹ 4 more variables: VSSTRESN <dbl>, VSSTRESU <chr>, VISIT <chr>, VSDTC <chr>
The key moves:
dm to attach the proper
USUBJID — findings domains never re-derive the subject key,
they inherit it.VSSEQ is generated per subject with
group_by() + row_number(), giving each row a
unique sequence number within the subject.VSORRES, text) and
standardized (VSSTRESN, numeric) forms. Downstream analysis
uses the numeric one.Adverse events are an events domain: one row per event, with start/end dates and a severity. AE also carries a couple of the most-scrutinized variables in all of clinical data — seriousness and severity.
make_ae <- function(raw, dm, studyid) {
subject_key <- dm |>
select(subject = SUBJID, USUBJID)
raw |>
left_join(subject_key, by = "subject") |>
mutate(
STUDYID = studyid,
DOMAIN = "AE",
AETERM = event,
AESEV = toupper(severity),
AESTDTC = start,
AEENDTC = end,
AEONGO = if_else(is.na(end), "Y", "N") # ongoing at data cutoff?
) |>
arrange(USUBJID, AESTDTC) |>
group_by(USUBJID) |>
mutate(AESEQ = row_number()) |>
ungroup() |>
select(
STUDYID, DOMAIN, USUBJID, AESEQ,
AETERM, AESEV, AESTDTC, AEENDTC, AEONGO
)
}
ae <- make_ae(raw_ae, dm, studyid = "DEMO01")
ae
#> # A tibble: 4 × 9
#> STUDYID DOMAIN USUBJID AESEQ AETERM AESEV AESTDTC AEENDTC AEONGO
#> <chr> <chr> <chr> <int> <chr> <chr> <chr> <chr> <chr>
#> 1 DEMO01 AE DEMO01-01-001 1 Headache MILD 2023-01-… 2023-0… N
#> 2 DEMO01 AE DEMO01-01-001 2 Nausea MODERATE 2023-02-… <NA> Y
#> 3 DEMO01 AE DEMO01-02-003 1 Fatigue MILD 2023-01-… 2023-0… N
#> 4 DEMO01 AE DEMO01-02-004 1 Dizziness SEVERE 2023-01-… 2023-0… N
Note how a missing end date becomes a derived “ongoing” flag rather
than just an NA — deriving meaningful flags from raw gaps
is a huge part of the real work.
Because every domain shares STUDYID and
USUBJID, answering cross-domain questions is just a join.
For example: “for each subject, how many adverse events did they have,
and what arm were they in?”
dm_arm <- dm |>
select(USUBJID, ARM)
dm_arm |>
left_join(
ae |> count(USUBJID, name = "n_ae"),
by = "USUBJID"
) |>
mutate(n_ae = tidyr::replace_na(n_ae, 0)) |>
arrange(desc(n_ae))
#> # A tibble: 5 × 3
#> USUBJID ARM n_ae
#> <chr> <chr> <int>
#> 1 DEMO01-01-001 Active 2
#> 2 DEMO01-02-003 Active 1
#> 3 DEMO01-02-004 Placebo 1
#> 4 DEMO01-01-002 Placebo 0
#> 5 DEMO01-03-005 Active 0
Or a quick clinical summary — mean systolic blood pressure at screening by treatment arm:
vs |>
filter(VSTESTCD == "SYSBP", VISIT == "Screening") |>
left_join(dm_arm, by = "USUBJID") |>
group_by(ARM) |>
summarise(
n = n(),
mean_sysbp = mean(VSSTRESN),
.groups = "drop"
)
#> # A tibble: 2 × 3
#> ARM n mean_sysbp
#> <chr> <int> <dbl>
#> 1 Active 2 123
#> 2 Placebo 1 141
This is the whole point of SDTM: once the data is in this shape, any analysis is a predictable combination of filters, joins, and summaries.
Regulators run automated conformance checks (Pinnacle 21 is the famous one) against submitted SDTM. You can write lightweight versions of those checks yourself. Here’s one that flags the most common structural problems:
check_domain <- function(df, domain, key_vars = c("STUDYID", "USUBJID")) {
issues <- character(0)
missing_keys <- setdiff(key_vars, names(df))
if (length(missing_keys) > 0) {
issues <- c(issues, paste("Missing key variable(s):",
paste(missing_keys, collapse = ", ")))
}
if ("DOMAIN" %in% names(df) && any(df$DOMAIN != domain)) {
issues <- c(issues, "DOMAIN value does not match expected domain")
}
seq_var <- paste0(domain, "SEQ")
if (seq_var %in% names(df)) {
dup <- df |>
count(USUBJID, .data[[seq_var]]) |>
filter(n > 1)
if (nrow(dup) > 0) {
issues <- c(issues, paste0(seq_var, " is not unique within USUBJID"))
}
}
if (length(issues) == 0) {
cat("PASS:", domain, "looks structurally valid\n")
} else {
cat("ISSUES in", domain, ":\n")
cat(paste0(" - ", issues, collapse = "\n"), "\n")
}
invisible(issues)
}
check_domain(dm, "DM")
#> PASS: DM looks structurally valid
check_domain(vs, "VS")
#> PASS: VS looks structurally valid
check_domain(ae, "AE")
#> PASS: AE looks structurally valid
Real conformance rules number in the hundreds, but the idea is the same: programmatic, repeatable checks that either pass or point you at the exact problem.
Everything here is hand-rolled to show the mechanics, but you rarely
start from zero in practice. The open-source
pharmaverse — packages like
admiral (for
building the analysis-ready ADaM datasets that come after
SDTM), sdtm.oak
(for building SDTM itself), and pharmaverseadam /
pharmaversesdtm (realistic example data) — gives you
tested, regulator-aware tooling. This page is the “under the hood”
version so those packages make sense when you reach for them.
The functions above work on clean, well-behaved data. Real trial data is never clean. This is the part nobody warns you about — the edge cases that quietly corrupt an analysis and the issues that resurface on every single study. Each one below comes with a demonstration of how it bites and a defensible fix.
This is the single most persistent issue in all of SDTM. Sites
collect dates that are incomplete (“the patient started the med sometime
in March 2023, they don’t remember the day”), and the ISO 8601
--DTC format is built to represent exactly that:
2023-03 (no day), 2023 (year only), or even
--03-15 (no year). Naively parsing these with
as.Date() silently turns them into NA, and
every downstream calculation that touches them breaks.
messy_dates <- c(
"2023-04-12", # complete
"2023-03", # missing day
"2023", # year only
"", # entirely missing
"2023-02-30" # IMPOSSIBLE: February has no 30th
)
as.Date(messy_dates) # naive parse: silent NAs, no signal about *why*
#> [1] "2023-04-12" NA NA NA NA
Notice the trap: the missing-day date, the year-only date,
and the impossible Feb 30th all collapse to NA
indistinguishably. You can’t tell a legitimately-partial date from
genuinely-bad data. The fix is to classify the completeness first, then
impute deterministically (the convention is to impute to the
first of the period for start dates and the last for
end dates), and flag what you did:
classify_dtc <- function(dtc) {
dplyr::case_when(
is.na(dtc) | dtc == "" ~ "missing",
grepl("^\\d{4}-\\d{2}-\\d{2}$", dtc) ~ "complete",
grepl("^\\d{4}-\\d{2}$", dtc) ~ "no_day",
grepl("^\\d{4}$", dtc) ~ "year_only",
TRUE ~ "other"
)
}
impute_start_dtc <- function(dtc) {
precision <- classify_dtc(dtc)
imputed <- dplyr::case_when(
precision == "complete" ~ dtc,
precision == "no_day" ~ paste0(dtc, "-01"),
precision == "year_only" ~ paste0(dtc, "-01-01"),
TRUE ~ NA_character_
)
tibble::tibble(
dtc_raw = dtc,
precision = precision,
dtc_imputed = imputed,
# as.Date on a well-formed string still catches Feb 30th as NA:
date = as.Date(imputed),
imputed_flag = precision %in% c("no_day", "year_only")
)
}
impute_start_dtc(messy_dates)
#> # A tibble: 5 × 5
#> dtc_raw precision dtc_imputed date imputed_flag
#> <chr> <chr> <chr> <date> <lgl>
#> 1 "2023-04-12" complete 2023-04-12 2023-04-12 FALSE
#> 2 "2023-03" no_day 2023-03-01 2023-03-01 TRUE
#> 3 "2023" year_only 2023-01-01 2023-01-01 TRUE
#> 4 "" missing <NA> NA FALSE
#> 5 "2023-02-30" complete 2023-02-30 NA FALSE
Now the year-only and missing-day dates are imputed and
flagged (imputed_flag = TRUE), the truly-missing one
is honestly NA, and Feb 30th is caught as bad data
(date is NA even though precision
said “complete”) — a signal you can act on rather than a silent
hole.
Every imputed date must carry a flag (in ADaM these become
--DTF / --TMF, the date/time imputation
flags). If a statistician later computes “days on treatment” from an
imputed start date, they need to know it was imputed. Silently guessing
is how you end up with a finding in an FDA audit.
Sites re-enter data. A subject’s screening visit gets keyed twice, or
an AE gets logged by two coordinators. The --SEQ variable
is supposed to be unique within subject — but if you generate it with
row_number() after the duplicates are already in,
you’ll happily assign 1 and 2 to two rows that
are actually the same event, and the duplicate becomes invisible.
raw_vitals_dupes <- dplyr::bind_rows(
raw_vitals,
raw_vitals |> dplyr::slice(1) # subject 001's screening SYSBP, re-entered
)
# Wrong: seq assigned over the dupes, so they look like distinct records
raw_vitals_dupes |>
dplyr::filter(subject == "001", measure == "SYSBP", visit == "Screening")
#> # A tibble: 2 × 6
#> subject visit measure value units date
#> <chr> <chr> <chr> <dbl> <chr> <chr>
#> 1 001 Screening SYSBP 128 mmHg 2023-01-05
#> 2 001 Screening SYSBP 128 mmHg 2023-01-05
The fix is to define what makes a record unique (the
“natural key” — here subject + visit + measure + date) and de-duplicate
on it before assigning --SEQ:
natural_key_vs <- c("subject", "visit", "measure", "date")
dedup_report <- raw_vitals_dupes |>
dplyr::add_count(dplyr::across(dplyr::all_of(natural_key_vs)),
name = "n_with_key") |>
dplyr::filter(n_with_key > 1) |>
dplyr::distinct(dplyr::across(dplyr::all_of(natural_key_vs)))
dedup_report # exactly which records collide
#> # A tibble: 1 × 4
#> subject visit measure date
#> <chr> <chr> <chr> <chr>
#> 1 001 Screening SYSBP 2023-01-05
raw_vitals_clean <- raw_vitals_dupes |>
dplyr::distinct(dplyr::across(dplyr::all_of(natural_key_vs)),
.keep_all = TRUE)
nrow(raw_vitals_dupes)
#> [1] 9
nrow(raw_vitals_clean) # one fewer: the re-entered row is gone
#> [1] 8
Report the collisions (never delete silently — a data-management
query goes back to the site), then de-duplicate on the natural key, and
only then assign --SEQ.
Findings domains are where labs live, and labs are a unit nightmare.
The same test arrives from different sites in different units: glucose
in mg/dL from one lab and mmol/L from another.
If you compare --ORRES numerically without standardizing,
you’re comparing apples to a number that’s ~18x different. This is what
--STRESN / --STRESU (the standardized
result) exists to solve, and getting the conversion factors right is a
perpetual source of bugs.
raw_labs <- tibble::tribble(
~subject, ~test, ~result, ~unit,
"001", "GLUC", 99, "mg/dL",
"002", "GLUC", 5.5, "mmol/L", # same magnitude, different unit
"003", "GLUC", 110, "mg/dL"
)
# Naive: treat the numbers as comparable -> subject 002 looks impossibly low
raw_labs |>
dplyr::group_by(test) |>
dplyr::summarise(mean_result = mean(result), .groups = "drop")
#> # A tibble: 1 × 2
#> test mean_result
#> <chr> <dbl>
#> 1 GLUC 71.5
A mean of a mg/dL value and a mmol/L value
is meaningless. Standardize to a single unit per test with an explicit,
auditable conversion table:
# Conversion factors TO the standard unit for each test
lab_conversions <- tibble::tribble(
~test, ~from_unit, ~std_unit, ~factor,
"GLUC", "mg/dL", "mg/dL", 1,
"GLUC", "mmol/L", "mg/dL", 18.0182 # 1 mmol/L glucose = 18.0182 mg/dL
)
lb <- raw_labs |>
dplyr::left_join(lab_conversions, by = c("test", "unit" = "from_unit")) |>
dplyr::mutate(
LBORRES = as.character(result),
LBORRESU = unit,
LBSTRESN = result * factor,
LBSTRESU = std_unit
)
lb |>
dplyr::select(subject, test, LBORRES, LBORRESU, LBSTRESN, LBSTRESU)
#> # A tibble: 3 × 6
#> subject test LBORRES LBORRESU LBSTRESN LBSTRESU
#> <chr> <chr> <chr> <chr> <dbl> <chr>
#> 1 001 GLUC 99 mg/dL 99 mg/dL
#> 2 002 GLUC 5.5 mmol/L 99.1 mg/dL
#> 3 003 GLUC 110 mg/dL 110 mg/dL
# Now the summary is meaningful because everything is in mg/dL
lb |>
dplyr::group_by(test) |>
dplyr::summarise(mean_std = mean(LBSTRESN), .groups = "drop")
#> # A tibble: 1 × 2
#> test mean_std
#> <chr> <dbl>
#> 1 GLUC 103.
If a unit shows up that isn’t in your conversion table, the
left_join gives factor = NA and
LBSTRESN silently becomes NA. Always assert
that every --ORRES produced a non-missing
--STRESN (or a documented reason why not), or an entire
lab’s worth of results can vanish without a warning.
The other half of the labs problem: not every result is a number.
Results come back as "<0.5" (below the limit of
detection), "POSITIVE", ">1000", or
"NEGATIVE". --ORRES is character precisely so
it can hold these, but --STRESN is numeric — and
as.numeric("<0.5") is NA with a warning.
You have to decide, per test, how each non-numeric value maps.
raw_results <- c("42", "<0.5", ">1000", "POSITIVE", "12.7", "NEGATIVE")
suppressWarnings(as.numeric(raw_results)) # everything non-numeric -> NA
#> [1] 42.0 NA NA NA 12.7 NA
A defensible approach keeps the character result verbatim in
--STRESC, derives --STRESN only where a number
is genuinely present, and records why a value is non-numeric so
it isn’t mistaken for missing:
standardize_result <- function(orres) {
is_below <- grepl("^<", orres)
is_above <- grepl("^>", orres)
numeric_part <- suppressWarnings(as.numeric(gsub("[<>]", "", orres)))
tibble::tibble(
STRESC = orres, # always keep the original
STRESN = numeric_part, # NA for POSITIVE / NEGATIVE
result_type = dplyr::case_when(
is_below ~ "below_limit",
is_above ~ "above_limit",
!is.na(numeric_part) ~ "numeric",
TRUE ~ "qualitative"
)
)
}
standardize_result(raw_results)
#> # A tibble: 6 × 3
#> STRESC STRESN result_type
#> <chr> <dbl> <chr>
#> 1 42 42 numeric
#> 2 <0.5 0.5 below_limit
#> 3 >1000 1000 above_limit
#> 4 POSITIVE NA qualitative
#> 5 12.7 12.7 numeric
#> 6 NEGATIVE NA qualitative
Now "<0.5" is flagged below_limit (a
stats analysis might substitute half the detection limit), and
"POSITIVE" is qualitative rather than a
mysterious NA. The information about why the
number is absent survives.
DM is supposed to be the master list of subjects, and
every other domain should only contain subjects who are in
DM. In practice, an AE gets logged against a subject ID
that was mistyped, or a subject is in DM but has an AE row
that references a slightly different ID. These orphan
records are a top conformance finding and they silently drop
out of any inner join.
ae_with_orphan <- dplyr::bind_rows(
ae,
ae |> dplyr::slice(1) |> dplyr::mutate(USUBJID = "DEMO01-99-999") # typo'd ID
)
# Orphans: AE subjects with no matching DM record
orphans <- dplyr::anti_join(ae_with_orphan, dm, by = "USUBJID")
orphans |> dplyr::select(USUBJID, AETERM)
#> # A tibble: 1 × 2
#> USUBJID AETERM
#> <chr> <chr>
#> 1 DEMO01-99-999 Headache
# And the reverse: DM subjects with zero AEs (often fine, but worth knowing)
dm_no_ae <- dplyr::anti_join(dm, ae_with_orphan, by = "USUBJID")
dm_no_ae |> dplyr::select(USUBJID)
#> # A tibble: 2 × 1
#> USUBJID
#> <chr>
#> 1 DEMO01-01-002
#> 2 DEMO01-03-005
The anti_join in both directions is the workhorse
referential-integrity check. An orphan AE is never dropped silently —
it’s surfaced as a query. The reverse (subjects with no AEs) is usually
legitimate but is exactly the kind of thing you confirm rather than
assume.
left_join vs inner_join trapFollowing directly from the orphan problem: the choice between
left_join and inner_join when combining
domains is one of the most consequential and most-overlooked decisions
in the whole pipeline. An inner_join silently drops
subjects, which can bias a result — and because nothing errors, it can
survive all the way to a submitted table.
# inner_join drops the orphan AE and any DM subject without an AE
dm |>
dplyr::inner_join(ae_with_orphan, by = "USUBJID") |>
dplyr::distinct(USUBJID) |>
nrow()
#> [1] 3
# left_join from DM keeps every enrolled subject (correct denominator)
dm |>
dplyr::left_join(ae_with_orphan, by = "USUBJID") |>
dplyr::distinct(USUBJID) |>
nrow()
#> [1] 5
The safety habit: decide the denominator explicitly.
For a “% of subjects with an AE” table, the denominator is enrolled
subjects (DM), so you left_join from
DM and count non-matches as “no event,” never as “absent.”
An inner_join here would quietly shrink your denominator
and inflate the rate.
Every coded variable (SEX, RACE, severity,
units, …) must match a published CDISC code list exactly —
including case and spelling. Sites and labs supply free-ish text that
drifts: "Male", "MALE", "m",
"Man". Recoding only the values you happened to see is a
recipe for a value slipping through unmapped on the next data cut.
incoming_sex <- c("Male", "MALE", "m", "Female", "F", "Man", "")
# A closed allow-list, not an open recode: anything unmapped is caught
sex_map <- c(
"male" = "M", "m" = "M", "man" = "M",
"female" = "F", "f" = "F", "woman" = "F"
)
map_controlled <- function(x, map) {
key <- tolower(trimws(x))
mapped <- unname(map[key])
tibble::tibble(
raw = x,
mapped = mapped,
unmapped = is.na(mapped) & !(key %in% c("", NA))
)
}
map_controlled(incoming_sex, sex_map)
#> # A tibble: 7 × 3
#> raw mapped unmapped
#> <chr> <chr> <lgl>
#> 1 "Male" M FALSE
#> 2 "MALE" M FALSE
#> 3 "m" M FALSE
#> 4 "Female" F FALSE
#> 5 "F" F FALSE
#> 6 "Man" M FALSE
#> 7 "" <NA> FALSE
"Man" maps fine (it’s in the allow-list), the empty
string is honestly unmapped-but-not-flagged (genuinely missing), and if
"Intersex" or a typo like "Femal" showed up it
would surface as unmapped = TRUE instead of being silently
dropped or, worse, passed through as invalid terminology. The principle:
validate against a closed code list and flag the
misses, rather than an open-ended recode() that
only handles today’s values.
Every one of these has the same shape: the naive operation
succeeds silently and produces plausible-looking garbage — an
NA that means three different things, a mean across
incompatible units, a denominator that quietly shrank. The discipline
that makes SDTM trustworthy isn’t clever code; it’s refusing to
let anything happen silently: classify before you convert, flag
every imputation, assert referential integrity, and validate coded
values against closed lists. That mindset is most of what separates a
production clinical programmer from someone who merely knows the
tidyverse.
One more thing worth knowing: SDTM is not the end of the pipeline. It’s the tabulation layer — a faithful, standardized record of what was collected. For the actual statistical analysis, that data gets transformed again into ADaM (Analysis Data Model) datasets, which are built to be one-row-per-analysis-record and carry all the derived flags and baselines a statistician needs.
The typical flow:
Raw EDC data -> SDTM (standardized tabulation) -> ADaM (analysis-ready) -> Tables, Figures, Listings
Each arrow is a set of documented, validated R (or SAS) programs. If you can build the SDTM step, the ADaM step is the same skills applied one layer up.
DM, AE,
VS, LB, …) with standardized variable
names.STUDYID and USUBJID,
so cross-domain analysis is just a join.VS, LB) use the
--TESTCD / --ORRES / --STRESN
pattern; events domains (AE) use start/end dates;
DM is one row per subject.If you found this useful or spotted something to fix, email me at cavandonohoe@gmail.com or open an issue on GitHub.