Dates, strings and factors

Lesson 7 — R Programming

Lesson 7 of 12 Intermediate ~90 min

Learning objectives

  • Parse, format and do arithmetic with dates and date-times
  • Handle ISO 8601 partial dates the way CDISC requires
  • Manipulate strings with stringr and 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) #> 20527

Parsing

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")
WarningLocale-dependent month names

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)    #> 0

Study 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"))   #> 6

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

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

NoteAlways keep the 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 time

If 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 ok

Test 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))
TipEscaping in R

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 backslashes

Fuzzy 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.037

Never 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 2

Alphabetical 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 level

fct_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      0

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

ImportantWhen factors bite
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      Y
Row 2 is the interesting one: an AE recorded as “March 2026” imputes to 1 March, which is before treatment start on the 15th, so it is not treatment emergent. Whether that is the right convention is a protocol/SAP decision — some studies impute to the treatment start date when the partial date is consistent with it, precisely to avoid this. That is why admiral::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    NA

str_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.
For the zero-count arms to appear at all, count() would need .drop = FALSE with TRT01A as a factor — worth adding if the table must show explicit zeros.

Recap

  • lubridate parsers 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
  • stringr is 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

Next: Functions and tidy evaluation.

Back to top