Dates, strings and factors
Lesson 7 — R Programming
Learning objectives
- Parse, format and do arithmetic with dates and date-times
- Handle ISO 8601 partial dates the way CDISC requires
- Manipulate strings with
stringrand read basic regular expressions - Control factor levels and ordering with
forcats - Know when a factor helps and when it causes a bug
Dates
A Date is the number of days since 1970-01-01, with a class attribute.
library(lubridate)
d <- as.Date("2026-03-15")
class(d) #> "Date"
as.numeric(d) #> 20527Parsing
lubridate’s parsers are named after the component order:
ymd("2026-03-15") #> "2026-03-15"
ymd("20260315") #> "2026-03-15"
dmy("15/03/2026") #> "2026-03-15"
mdy("March 15, 2026") #> "2026-03-15"
ymd_hms("2026-03-15 14:30:00")
ymd_hm("2026-03-15 14:30")
# Base R equivalent, when you need exact control
as.Date("15MAR2026", format = "%d%b%Y")as.Date("15MAR2026", format = "%d%b%Y") returns NA on a machine with a non-English locale, because %b matches the local abbreviated month name. SAS date literals like 15MAR2026 hit this constantly. Force the locale:
as.Date("15MAR2026", format = "%d%b%Y",
tryFormats = "%d%b%Y") # still locale-dependent!
# Reliable
withr::with_locale(c(LC_TIME = "C"),
as.Date("15MAR2026", format = "%d%b%Y"))
# Or avoid the issue entirely
lubridate::dmy("15MAR2026", locale = "C")Common format codes:
| Code | Means | Example |
|---|---|---|
%Y |
4-digit year | 2026 |
%y |
2-digit year | 26 |
%m |
Month number | 03 |
%b |
Abbreviated month | Mar |
%B |
Full month | March |
%d |
Day | 15 |
%H:%M:%S |
Time | 14:30:00 |
%j |
Day of year | 074 |
Components and arithmetic
d <- ymd("2026-03-15")
year(d) #> 2026
month(d) #> 3
day(d) #> 15
wday(d, label = TRUE) #> Sun
yday(d) #> 74
quarter(d) #> 1
d + 30 #> "2026-04-14"
d + days(30) #> "2026-04-14"
d + months(1) #> "2026-04-15"
d %m+% months(1) #> handles month-end correctly
ymd("2026-01-31") + months(1) #> NA (no Feb 31)
ymd("2026-01-31") %m+% months(1)#> "2026-02-28"
# Differences
d2 <- ymd("2026-06-20")
d2 - d #> Time difference of 97 days
as.numeric(d2 - d, units = "days") #> 97
interval(d, d2) %/% years(1) #> 0Study day is a standard derivation with a specific quirk — there is no day zero:
study_day <- function(date, trtsdt) {
dplyr::if_else(
date >= trtsdt,
as.numeric(date - trtsdt) + 1,
as.numeric(date - trtsdt)
)
}
study_day(ymd("2026-03-15"), ymd("2026-03-15")) #> 1
study_day(ymd("2026-03-14"), ymd("2026-03-15")) #> -1
study_day(ymd("2026-03-20"), ymd("2026-03-15")) #> 6Day 1 is the treatment start date; the day before is day −1. Getting this wrong by one is a classic finding in a QC review.
ISO 8601 and partial dates
CDISC stores dates as ISO 8601 character strings, which may be partial:
2026-03-15 complete
2026-03 day missing
2026 month and day missing
2026---15 month missing, day present
2026-03-15T14:30 with time
Never parse these with as.Date() — it silently returns NA or, worse, guesses. Handle the precision explicitly:
library(stringr)
library(dplyr)
parse_dtc <- function(dtc) {
date_part <- str_sub(dtc, 1, 10)
tibble(
dtc = dtc,
precision = case_when(
str_detect(date_part, "^\\d{4}-\\d{2}-\\d{2}$") ~ "D",
str_detect(date_part, "^\\d{4}-\\d{2}$") ~ "M",
str_detect(date_part, "^\\d{4}$") ~ "Y",
is.na(dtc) | dtc == "" ~ NA_character_,
.default ~ "OTHER"
),
date_first = case_when(
precision == "D" ~ ymd(date_part),
precision == "M" ~ ymd(paste0(date_part, "-01")),
precision == "Y" ~ ymd(paste0(date_part, "-01-01")),
.default ~ NA_Date_
),
date_last = case_when(
precision == "D" ~ ymd(date_part),
precision == "M" ~ ceiling_date(ymd(paste0(date_part, "-01")), "month") - 1,
precision == "Y" ~ ymd(paste0(date_part, "-12-31")),
.default ~ NA_Date_
)
)
}
parse_dtc(c("2026-03-15", "2026-03", "2026", NA))
#> # A tibble: 4 x 4
#> dtc precision date_first date_last
#> <chr> <chr> <date> <date>
#> 1 2026-03-15 D 2026-03-15 2026-03-15
#> 2 2026-03 M 2026-03-01 2026-03-31
#> 3 2026 Y 2026-01-01 2026-12-31
#> 4 NA NA NA NAWhich of date_first / date_last you use depends on the question. For treatment-emergence, an AE with a partial start date is conventionally imputed to the earliest possible date (conservative — more likely to be treatment emergent), while an end date is imputed to the latest. In production, use admiral::derive_vars_dtm() and impute_dtc_dtm(), which implement the conventions and record the imputation flag.
Any imputed date must be accompanied by an imputation flag (ASTDTF, ASTTMF) so a reviewer can identify which values were derived. Imputing silently is a finding.
Time zones
Avoid them if you can. Clinical dates are local site dates and should be Date, not POSIXct.
Sys.timezone()
x <- ymd_hms("2026-03-15 14:30:00", tz = "UTC")
with_tz(x, "America/New_York") # same instant, different display
force_tz(x, "America/New_York") # different instant, same clock timeIf you must use POSIXct, store everything in UTC and convert only for display. Daylight-saving transitions cause duplicated and missing local times, and a date-time arithmetic bug across a DST boundary is unpleasant to find.
Strings
stringr functions are all str_-prefixed, take the string first, and are vectorised.
library(stringr)
x <- c(" Headache ", "NAUSEA", "dizziness")
str_trim(x) #> "Headache" "NAUSEA" "dizziness"
str_squish("a b c") #> "a b c"
str_to_upper(x) #> " HEADACHE " ...
str_to_title(str_trim(x)) #> "Headache" "Nausea" "Dizziness"
str_length(x) #> 11 6 9
str_c("AE", "001", sep = "-") #> "AE-001"
str_c(x, collapse = "; ")
str_sub("STUDY-001-0042", 7, 9) #> "001"
str_pad("7", width = 3, pad = "0") #> "007"
str_pad("7", 3, side = "left", pad = "0")Detecting, extracting, replacing
ae <- c("Mild headache", "Severe nausea", "Headache, moderate")
str_detect(ae, "headache") #> TRUE FALSE FALSE
str_detect(ae, regex("headache", ignore_case = TRUE))
#> TRUE FALSE TRUE
str_which(ae, "nausea") #> 2
str_count(ae, "a") #> 3 3 2
str_extract("Dose 250 mg BID", "\\d+") #> "250"
str_extract_all("10 mg and 20 mg", "\\d+") #> list(c("10","20"))
str_match("STUDY-001-0042", "^(\\w+)-(\\d+)-(\\d+)$")
#> [,1] [,2] [,3] [,4]
#> [1,] "STUDY-001-0042" "STUDY" "001" "0042"
str_replace("a-b-c", "-", "_") #> "a_b_c" (first only)
str_replace_all("a-b-c", "-", "_") #> "a_b_c"
str_remove_all("1,234,567", ",") #> "1234567"
str_split("A;B;C", ";") #> list(c("A","B","C"))
str_split_i("A;B;C", ";", 2) #> "B"Regular expressions, minimally
| Pattern | Matches |
|---|---|
\\d |
Digit |
\\w |
Word character (letter, digit, underscore) |
\\s |
Whitespace |
. |
Any character |
^ / $ |
Start / end of string |
* + ? |
0+, 1+, 0-or-1 repetitions |
{2,4} |
Between 2 and 4 repetitions |
[abc] |
Any of a, b, c |
[^abc] |
Anything except a, b, c |
(...) |
Capture group |
| |
Alternation |
str_detect(usubjid, "^[A-Z]{3}\\d{3}-\\d{3}-\\d{4}$") # validate an ID format
str_detect(dtc, "^\\d{4}(-\\d{2}(-\\d{2})?)?$") # valid ISO date or partial
str_extract(dose, "\\d+\\.?\\d*") # first number, decimals okTest patterns interactively:
# Opens a shiny gadget for building and testing regex
# install.packages("RegExplain") — RStudio addin
str_view(c("Headache", "headache", "HEADACHE"), regex("head", ignore_case = TRUE))R strings use \ as an escape character, so a regex \d is written "\\d" in R code. Use a raw string to avoid double-escaping:
str_detect(x, r"(\d{3}-\d{4})") # R 4.0+, no double backslashesFuzzy matching
Useful when reconciling verbatim terms with a dictionary:
adist("headache", c("headache", "head ache", "backache"))
#> [,1] [,2] [,3]
#> [1,] 0 1 4
# stringdist gives more distance metrics
stringdist::stringdist("headache", "head ache", method = "jw")
#> [1] 0.037Never auto-apply a fuzzy match to coded medical terms. Use it to produce a review list for a medical coder.
Factors
A factor is an integer vector with a levels attribute. Use it when a variable has a fixed, known set of values and the display order matters.
library(forcats)
arm <- factor(c("Drug A", "Placebo", "Drug A", "Drug B"))
levels(arm) #> "Drug A" "Drug B" "Placebo" <- alphabetical!
as.integer(arm) #> 1 3 1 2Alphabetical ordering is almost never what you want in a clinical table — placebo belongs first or last, not in the middle.
arm <- factor(
c("Drug A", "Placebo", "Drug A", "Drug B"),
levels = c("Placebo", "Drug A", "Drug B")
)
levels(arm)
#> [1] "Placebo" "Drug A" "Drug B"forcats verbs
fct_relevel(arm, "Placebo") # move to front
fct_relevel(arm, "Placebo", after = Inf) # move to back
fct_rev(arm) # reverse
fct_infreq(arm) # by frequency, descending
fct_reorder(term, n_events) # by another variable — great for plots
fct_lump_n(term, n = 10) # keep top 10, rest -> "Other"
fct_lump_min(term, min = 5)
fct_recode(arm, "PBO" = "Placebo")
fct_collapse(race, Other = c("Asian", "Other", "Unknown"))
fct_explicit_na(sex, na_level = "Missing") # makes NA a level
fct_drop(arm) # remove unused levels
fct_expand(arm, "Drug C") # add an empty levelfct_reorder() is the one that transforms plots:
library(ggplot2)
adae |>
count(AEDECOD) |>
slice_max(n, n = 15) |>
mutate(AEDECOD = fct_reorder(AEDECOD, n)) |>
ggplot(aes(n, AEDECOD)) +
geom_col()Empty levels are a feature
A treatment arm with zero events must still appear in the table, showing 0. Factors give you this for free:
arm <- factor(c("Drug A", "Drug A"), levels = c("Placebo", "Drug A", "Drug B"))
table(arm)
#> arm
#> Placebo Drug A Drug B
#> 0 2 0
# dplyr::count() drops empty levels unless you ask
count(tibble(arm), arm, .drop = FALSE)
#> # A tibble: 3 x 2
#> arm n
#> <fct> <int>
#> 1 Placebo 0
#> 2 Drug A 2
#> 3 Drug B 0That .drop = FALSE is essential in AE table programming. Without it, a term that occurred only in one arm produces a table row with a missing cell instead of a zero.
f <- factor(c("10", "20", "30"))
as.numeric(f) #> 1 2 3 the codes
as.numeric(as.character(f))#> 10 20 30 correct
c(factor("a"), factor("b")) # R < 4.1 gave integers; now combines levels
# Filtering does not drop levels
subset <- f[f != "30"]
levels(subset) #> "10" "20" "30" still there
fct_drop(subset) #> "10" "20"Use character for identifiers and free text. Use factors only where the level set is deliberate.
Common mistakes
| Mistake | Consequence | Fix |
|---|---|---|
as.Date() on partial DTC |
Silent NA |
Explicit precision handling |
| Imputing a date without a flag | Untraceable derivation | Always set ASTDTF |
%b parsing in a non-English locale |
NA dates on some machines |
locale = "C" |
+ months(1) on 31 Jan |
NA |
%m+% |
as.numeric(factor) |
Level codes, not values | Via as.character() |
count() without .drop = FALSE |
Missing zero rows in tables | Add it |
Forgetting \\ in regex |
Pattern does not match | Raw strings r"(...)" |
| Study day off by one | QC finding | No day zero |
Exercise 7.1 — Treatment-emergent flag with partial dates
Write is_treatment_emergent(aestdtc, trtsdt, trtedt) which returns "Y"/"N" using conservative imputation: a partial AE start date is imputed to the earliest possible date. Also return the imputation flag.
Show solution
library(lubridate); library(stringr); library(dplyr)
impute_start <- function(dtc) {
d <- str_sub(dtc, 1, 10)
case_when(
str_detect(d, "^\\d{4}-\\d{2}-\\d{2}$") ~ ymd(d),
str_detect(d, "^\\d{4}-\\d{2}$") ~ ymd(paste0(d, "-01")),
str_detect(d, "^\\d{4}$") ~ ymd(paste0(d, "-01-01")),
.default ~ NA_Date_
)
}
impute_flag <- function(dtc) {
d <- str_sub(dtc, 1, 10)
case_when(
str_detect(d, "^\\d{4}-\\d{2}-\\d{2}$") ~ NA_character_,
str_detect(d, "^\\d{4}-\\d{2}$") ~ "D", # day imputed
str_detect(d, "^\\d{4}$") ~ "M", # month and day imputed
.default ~ "Y" # everything imputed / missing
)
}
is_treatment_emergent <- function(aestdtc, trtsdt, trtedt) {
astdt <- impute_start(aestdtc)
astdtf <- impute_flag(aestdtc)
tibble(
ASTDT = astdt,
ASTDTF = astdtf,
TRTEMFL = case_when(
is.na(trtsdt) ~ "N",
is.na(astdt) ~ "Y", # fully missing: conservative
astdt >= trtsdt & (is.na(trtedt) | astdt <= trtedt + 30) ~ "Y",
.default ~ "N"
)
)
}
is_treatment_emergent(
c("2026-03-20", "2026-03", "2026", NA),
trtsdt = ymd("2026-03-15"),
trtedt = ymd("2026-06-15")
)
#> # A tibble: 4 x 3
#> ASTDT ASTDTF TRTEMFL
#> <date> <chr> <chr>
#> 1 2026-03-20 NA Y
#> 2 2026-03-01 D N
#> 3 2026-01-01 M N
#> 4 NA Y Yadmiral::derive_vars_dtm() exposes highest_imputation and min_dates arguments: the convention must be chosen and documented, not assumed.
Exercise 7.2 — Parse a dose string
Extract dose amount, unit and frequency from strings like "250 mg BID", "1.5 g QD", "10mg TID", "Placebo". Return NA for non-dose text.
Show solution
library(stringr); library(tibble)
parse_dose <- function(x) {
m <- str_match(
x,
regex(r"(^\s*(\d+\.?\d*)\s*(mg|g|mcg|ug|mL|IU)\s*(QD|BID|TID|QID|PRN)?\s*$)",
ignore_case = TRUE)
)
tibble(
text = x,
amount = as.numeric(m[, 2]),
unit = str_to_lower(m[, 3]),
freq = str_to_upper(m[, 4])
)
}
parse_dose(c("250 mg BID", "1.5 g QD", "10mg TID", "Placebo", "500 mg"))
#> # A tibble: 5 x 4
#> text amount unit freq
#> <chr> <dbl> <chr> <chr>
#> 1 250 mg BID 250 mg BID
#> 2 1.5 g QD 1.5 g QD
#> 3 10mg TID 10 mg TID
#> 4 Placebo NA NA NA
#> 5 500 mg 500 mg NAstr_match() returns a matrix: column 1 is the whole match, columns 2+ are the capture groups. Non-matching rows are all NA, which handles “Placebo” automatically.
Normalising to a common unit is a separate, deliberate step:
to_mg <- function(amount, unit) {
dplyr::case_when(
unit == "g" ~ amount * 1000,
unit %in% c("mcg","ug") ~ amount / 1000,
unit == "mg" ~ amount,
.default ~ NA_real_
)
}Exercise 7.3 — AE frequency plot with correct ordering
From adae, make a horizontal bar chart of the 10 most frequent preferred terms, ordered most-frequent at the top, with treatment arm shown and placebo-first ordering.
Show solution
library(dplyr); library(forcats); library(ggplot2)
plot_data <- adae |>
distinct(USUBJID, AEDECOD, TRT01A) |> # subject-level, not event-level
count(AEDECOD, TRT01A) |>
mutate(
TRT01A = fct_relevel(factor(TRT01A), "Placebo"),
AEDECOD = fct_lump_n(AEDECOD, n = 10, w = n)
) |>
filter(AEDECOD != "Other") |>
mutate(AEDECOD = fct_reorder(AEDECOD, n, .fun = sum))
ggplot(plot_data, aes(x = n, y = AEDECOD, fill = TRT01A)) +
geom_col(position = position_dodge(preserve = "single")) +
labs(
x = "Subjects with at least one event",
y = NULL, fill = "Treatment",
title = "Ten most frequent adverse events by preferred term"
) +
theme_minimal(base_size = 12) +
theme(panel.grid.major.y = element_blank())Three things doing real work here:
distinct(USUBJID, AEDECOD, TRT01A)first, so the bars count subjects, not events. An AE table almost always reports subject incidence.fct_reorder(AEDECOD, n, .fun = sum)orders by the total across arms, not by whichever arm happens to come first.position_dodge(preserve = "single")keeps bar widths consistent when a term has no events in one arm.
count() would need .drop = FALSE with TRT01A as a factor — worth adding if the table must show explicit zeros.
Recap
lubridateparsers are named for component order;%m+%for safe month arithmetic- Partial ISO dates need explicit precision handling and an imputation flag
- Study day has no zero: day 1 is treatment start
stringris consistent and vectorised; use raw strings for regex- Factors are integers plus levels — set the levels deliberately
count(.drop = FALSE)to keep zero rows in tables;fct_reorder()for plots