Tutorials › Biostatistics › Data Manipulation with dplyr for Clinical Data

R Clinical Programming

Data Manipulation with dplyr for Clinical Data

A practical, clinical-programming-focused guide to using dplyr for filtering, deriving, joining, grouping, sorting, and validating clinical-trial data, with examples spanning subject-level, longitudinal, laboratory, vital-sign, treatment, and ADaM-style analysis datasets.

Intermediate 25 min read

What You'll Learn

  • How dplyr fits into clinical statistical programming workflows
  • How to filter, select, arrange, and derive clinical variables
  • How grouped operations and window functions solve common clinical problems
  • How to perform safe one-to-one, one-to-many, and many-to-one joins
  • How to derive baseline, change, analysis flags, and selected records
  • How to build robust, traceable dplyr pipelines for clinical datasets

Introduction

Clinical trial programming involves transforming raw and standardized observations into analysis-ready datasets, summary tables, listings, and figures.

Although many clinical programmers historically perform these transformations using SAS DATA steps and PROC SQL, R provides a powerful alternative through the tidyverse ecosystem.

Within that ecosystem, dplyr is the primary package for data manipulation.

The important point for a clinical programmer is that dplyr is not simply a collection of convenient commands.

Used correctly, it provides a structured language for expressing operations such as:

  • selecting analysis populations;
  • deriving analysis variables;
  • identifying baseline observations;
  • selecting records by visit;
  • joining subject-level and observation-level data;
  • creating treatment variables;
  • calculating within-subject changes;
  • identifying first and last observations;
  • detecting duplicates;
  • creating analysis flags; and
  • validating intermediate datasets.
Clinical-programming perspective: The goal is not merely to make code shorter. The goal is to produce correct, reproducible, traceable, and reviewable data transformations.

The Core dplyr Verbs

Most clinical data manipulation tasks can be expressed using a relatively small set of dplyr verbs.

Verb Purpose Clinical example
filter() Keep rows Select treated patients
select() Keep or reorder columns Create a subject-level analysis subset
mutate() Create or modify variables Derive CHG or PCHG
arrange() Sort rows Sort observations by subject and analysis day
summarise() Reduce rows to summaries Calculate subject-level minima or maxima
group_by() Define groups Perform calculations within subject
rename() Rename variables Standardize source-variable names
distinct() Remove duplicate combinations Check unique subject-treatment combinations
slice() Select rows by position Select first or last observation

A Clinical Dataset Example

Consider a simplified longitudinal laboratory dataset.

lab <- tibble::tribble(
  ~USUBJID, ~TRT01P, ~AVISIT, ~ADY, ~PARAMCD, ~AVAL,
  "01-001", "Drug A", "Baseline",   1, "ALT",  32,
  "01-001", "Drug A", "Week 4",    29, "ALT",  41,
  "01-001", "Drug A", "Week 8",    57, "ALT",  58,
  "01-002", "Drug A", "Baseline",   1, "ALT",  27,
  "01-002", "Drug A", "Week 4",    29, "ALT",  25,
  "01-002", "Drug A", "Week 8",    57, "ALT",  21,
  "01-003", "Placebo", "Baseline", 1, "ALT",  35,
  "01-003", "Placebo", "Week 4",  29, "ALT",  37,
  "01-003", "Placebo", "Week 8",  57, "ALT",  39
)

The data are in long format: each row represents one patient, parameter, and assessment.

This structure is particularly convenient for dplyr because grouped operations naturally map onto the hierarchy of clinical observations.

Filtering Clinical Data

The filter() function selects rows satisfying logical conditions.

lab %>%
  filter(PARAMCD == "ALT")

Multiple conditions can be combined.

lab %>%
  filter(
    PARAMCD == "ALT",
    ADY > 0,
    AVAL > 40
  )

This retains ALT observations after baseline where the value exceeds 40.

OR Conditions

The logical operator | represents OR.

lab %>%
  filter(
    PARAMCD == "ALT" | PARAMCD == "AST"
  )

The equivalent membership expression is usually cleaner.

lab %>%
  filter(
    PARAMCD %in% c("ALT", "AST")
  )

Excluding Values

lab %>%
  filter(
    !PARAMCD %in% c("ALP", "BILI")
  )

Negation should be used carefully when missing values are possible.

Missing Values in Clinical Data

R represents missing values using NA.

A common mistake is writing:

filter(AVAL == NA)

This does not correctly identify missing values.

Instead use:

filter(is.na(AVAL))

or:

filter(!is.na(AVAL))

This distinction becomes particularly important when deriving analysis populations and selecting records.

Clinical implication: Missingness is usually part of the analysis definition. Do not silently replace missing clinical observations with zero, the previous value, or another value unless the statistical analysis plan explicitly specifies such a rule.

Selecting Variables

The select() function controls which columns are retained.

lab %>%
  select(
    USUBJID,
    TRT01P,
    AVISIT,
    ADY,
    PARAMCD,
    AVAL
  )

Variables can also be removed.

lab %>%
  select(
    -PARAMCD
  )

A useful clinical-programming pattern is to keep the variables required for traceability while dropping unnecessary source columns only at a deliberate dataset boundary.

Renaming Variables

lab %>%
  rename(
    SUBJECT = USUBJID,
    VALUE = AVAL
  )

In clinical programming, however, renaming should be done intentionally.

For CDISC-oriented workflows, standardized variable names such as USUBJID, AVAL, BASE, CHG, and PCHG often carry important semantic meaning.

Sorting Data

Use arrange() to control row order.

lab %>%
  arrange(
    USUBJID,
    ADY
  )

Descending order can be specified using desc().

lab %>%
  arrange(
    USUBJID,
    desc(ADY)
  )

Sorting is particularly important before position-based operations such as selecting the first or last assessment.

Creating Variables With mutate()

The mutate() function creates derived variables.

For example, suppose a laboratory value has a reference upper limit of 40.

lab %>%
  mutate(
    ABOVE_ULN = AVAL > 40
  )

A categorical interpretation can then be created.

lab %>%
  mutate(
    ALT_STATUS = case_when(
      is.na(AVAL) ~ NA_character_,
      AVAL > 40 ~ "High",
      TRUE ~ "Not High"
    )
  )

case_when() for Clinical Derivations

Complex clinical derivations often require several conditions.

lab %>%
  mutate(
    TOX_GRADE = case_when(
      is.na(AVAL) ~ NA_integer_,
      AVAL < 40 ~ 0L,
      AVAL < 80 ~ 1L,
      AVAL < 120 ~ 2L,
      AVAL < 200 ~ 3L,
      TRUE ~ 4L
    )
  )

The example is illustrative rather than a substitute for an actual toxicity grading algorithm.

Ordering matters: case_when() evaluates conditions from top to bottom. The first matching condition is used.

The TRUE Condition

A final TRUE condition acts as a catch-all.

mutate(
  CATEGORY = case_when(
    AVAL < 10 ~ "Low",
    AVAL < 20 ~ "Moderate",
    TRUE ~ "High"
  )
)

This pattern is useful when every nonmissing observation should receive a category.

Deriving Change From Baseline

One of the most common clinical-analysis derivations is change from baseline.

If:

$$ BASE = \text{baseline value} $$

and:

$$ AVAL = \text{post-baseline value} $$

then:

$$ CHG = AVAL - BASE $$

In dplyr:

analysis <- analysis %>%
  mutate(
    CHG = AVAL - BASE
  )

Deriving Percent Change From Baseline

Percentage change is:

$$ PCHG = \frac{AVAL-BASE}{BASE}\times100 $$

The corresponding dplyr expression is:

analysis <- analysis %>%
  mutate(
    PCHG = 100 * (AVAL - BASE) / BASE
  )

In production programming, the denominator should be explicitly protected against missing or zero baseline values.

analysis <- analysis %>%
  mutate(
    PCHG = case_when(
      is.na(AVAL) | is.na(BASE) ~ NA_real_,
      BASE == 0 ~ NA_real_,
      TRUE ~ 100 * (AVAL - BASE) / BASE
    )
  )

Why Explicit Missing-Value Logic Matters

Consider a baseline value of zero.

$$ \frac{AVAL-0}{0} $$

The percentage change is undefined.

A robust clinical derivation should not allow an invalid denominator to silently propagate through the analysis dataset.

group_by(): The Core of Subject-Level Clinical Programming

Clinical datasets are frequently hierarchical.

For example:

  • study;
  • treatment;
  • subject;
  • parameter;
  • visit;
  • assessment.

The group_by() function allows calculations to be performed within these groups.

lab %>%
  group_by(USUBJID) %>%
  summarise(
    N = n(),
    MIN_VALUE = min(AVAL, na.rm = TRUE),
    MAX_VALUE = max(AVAL, na.rm = TRUE)
  )

This produces one row per subject.

Grouping by Multiple Variables

Often the correct unit is more specific than subject.

For laboratory data, a useful grouping may be:

lab %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  summarise(
    N = sum(!is.na(AVAL)),
    MIN_VALUE = min(AVAL, na.rm = TRUE),
    MAX_VALUE = max(AVAL, na.rm = TRUE),
    .groups = "drop"
  )

This creates one summary per subject and parameter.

The Importance of .groups

After summarise(), grouping behavior matters.

For explicit clinical programming, it is often useful to write:

summarise(
  ...,
  .groups = "drop"
)

This makes it clear that the resulting dataset is intentionally ungrouped.

Programming habit: Do not leave grouping state implicit when downstream derivations depend on whether operations are performed globally or within groups.

Counting Records

The n() function counts rows within each group.

lab %>%
  group_by(USUBJID) %>%
  summarise(
    N_RECORDS = n(),
    N_NONMISSING = sum(!is.na(AVAL)),
    .groups = "drop"
  )

The distinction between total records and nonmissing records can be important in clinical data review.

Counting Distinct Patients

lab %>%
  summarise(
    N_SUBJECTS = n_distinct(USUBJID)
  )

This is preferable to counting rows when multiple records can exist per patient.

distinct() and Duplicate Detection

The distinct() function can identify unique combinations.

lab %>%
  distinct(
    USUBJID,
    TRT01P
  )

This can be useful for validating that each subject maps to a single planned treatment.

However, distinct() should not be used blindly to remove duplicates.

Two apparently duplicate records may actually represent different assessments or different collection events.

Finding Potential Duplicates

A better QC pattern is to count records at the expected uniqueness level.

lab %>%
  count(
    USUBJID,
    PARAMCD,
    ADY,
    name = "N"
  ) %>%
  filter(N > 1)

This identifies subject-parameter-day combinations occurring more than once.

Never deduplicate clinical data merely because duplicate rows exist. First determine whether the duplication represents replicate laboratory measurements, multiple collection times, unscheduled assessments, amended records, or a genuine data issue.

Filtering After Grouping

The behavior of filter() changes when data are grouped.

For example, to retain subjects whose maximum ALT exceeds 100:

lab %>%
  group_by(USUBJID) %>%
  filter(
    max(AVAL, na.rm = TRUE) > 100
  ) %>%
  ungroup()

Every record for qualifying subjects is retained.

This is different from filtering individual observations with:

filter(AVAL > 100)

The first asks:

$$ \text{Which subjects ever exceeded 100?} $$

The second asks:

$$ \text{Which observations exceeded 100?} $$

This distinction is fundamental in clinical data programming.

slice_min() and slice_max()

Modern dplyr provides useful functions for selecting extreme observations.

For example, identify the lowest ALT measurement for each subject:

lab %>%
  group_by(USUBJID) %>%
  slice_min(
    order_by = AVAL,
    n = 1,
    with_ties = TRUE
  ) %>%
  ungroup()

The corresponding maximum is:

lab %>%
  group_by(USUBJID) %>%
  slice_max(
    order_by = AVAL,
    n = 1,
    with_ties = TRUE
  ) %>%
  ungroup()

Why with_ties Matters

Suppose two observations have the same maximum value.

With:

with_ties = TRUE

both records can be returned.

With:

with_ties = FALSE

only one record is retained.

For clinical programming, arbitrary tie-breaking should generally be avoided unless the specification explicitly defines how the tie is resolved.

Selecting the First Record

After sorting:

data %>%
  arrange(
    USUBJID,
    ADY
  ) %>%
  group_by(USUBJID) %>%
  slice_head(n = 1) %>%
  ungroup()

This selects the earliest record according to the current sort order.

Selecting the Last Record

data %>%
  arrange(
    USUBJID,
    ADY
  ) %>%
  group_by(USUBJID) %>%
  slice_tail(n = 1) %>%
  ungroup()

Again, the definition of "last" depends entirely on the ordering variables.

Clinical warning: If two observations have the same analysis day, sorting only by ADY may not uniquely determine which record is first or last. Include the additional variables required by the derivation specification.

Baseline Selection

Baseline derivation is one of the most important uses of grouped dplyr operations.

A simplified rule might be:

$$ \text{Baseline} = \text{last nonmissing assessment before first dose} $$

Suppose:

lab %>%
  filter(
    !is.na(AVAL),
    ADY <= 1
  ) %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  slice_max(
    order_by = ADY,
    n = 1,
    with_ties = FALSE
  ) %>%
  ungroup()

This is only an illustrative baseline rule.

A real study might define baseline using:

  • date/time relative to first dose;
  • nominal versus actual assessment date;
  • multiple assessments on the same date;
  • screening measurements;
  • unscheduled assessments;
  • duplicate or replicate results;
  • eligibility criteria; or
  • a parameter-specific derivation.

Creating BASE and Merging It Back

One robust pattern is to derive baseline separately and then join it back.

baseline <- lab %>%
  filter(
    !is.na(AVAL),
    ADY <= 1
  ) %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  slice_max(
    order_by = ADY,
    n = 1,
    with_ties = FALSE
  ) %>%
  ungroup() %>%
  select(
    USUBJID,
    PARAMCD,
    BASE = AVAL
  )

analysis <- lab %>%
  left_join(
    baseline,
    by = c("USUBJID", "PARAMCD")
  ) %>%
  mutate(
    CHG = AVAL - BASE
  )

This pattern has a major advantage: the baseline derivation is isolated and can be independently validated.

Joining Clinical Data

Clinical programming frequently requires combining datasets.

Typical examples include:

  • subject-level treatment data with laboratory data;
  • demographics with adverse events;
  • exposure with laboratory observations;
  • baseline values with post-baseline records;
  • analysis population flags with parameter-level data.

dplyr provides several join functions.

Join Purpose
left_join() Keep all rows from the left dataset
inner_join() Keep only matching rows
right_join() Keep all rows from the right dataset
full_join() Keep all rows from both datasets
semi_join() Keep rows with a match without adding columns
anti_join() Find rows without a match

left_join(): The Most Common Clinical Join

Suppose a subject-level dataset contains treatment assignment.

adsl <- tibble::tribble(
  ~USUBJID, ~TRT01P, ~SAFFL,
  "01-001", "Drug A", "Y",
  "01-002", "Drug A", "Y",
  "01-003", "Placebo", "Y",
  "01-004", "Placebo", "N"
)

Treatment can be attached to laboratory data:

lab_analysis <- lab %>%
  left_join(
    adsl %>%
      select(
        USUBJID,
        TRT01P,
        SAFFL
      ),
    by = "USUBJID"
  )

This is conceptually similar to merging subject-level attributes onto observation-level data.

The Most Important Join Question

Before performing a join, ask:

$$ \text{What is the expected cardinality of the relationship?} $$

For example:

  • One subject in ADSL → many observations in ADLB
  • One subject in ADSL → many adverse events in ADAE
  • One subject + parameter → many longitudinal observations
  • One subject + parameter → one baseline record

Understanding this relationship is essential because an incorrect join can silently multiply records.

Join Multiplication

Suppose:

a

contains two rows for subject 001 and:

b

also contains two rows for subject 001.

A simple join on USUBJID can produce four combinations.

Clinical QC rule: Whenever a join unexpectedly increases the number of records, investigate whether the join keys uniquely identify the intended relationship.

Checking Join Keys Before Joining

Suppose a baseline dataset is expected to contain one row per subject and parameter.

baseline %>%
  count(
    USUBJID,
    PARAMCD
  ) %>%
  filter(n > 1)

If this returns records, the baseline dataset does not satisfy the expected one-row-per-subject-parameter structure.

semi_join(): Population Filtering

Suppose adsl defines the safety population.

safety_subjects <- adsl %>%
  filter(SAFFL == "Y")

ae_safety <- ae %>%
  semi_join(
    safety_subjects,
    by = "USUBJID"
  )

The advantage is that semi_join() filters the rows without bringing treatment variables into the adverse-event dataset.

anti_join(): A Powerful QC Tool

Suppose every subject in an analysis dataset should exist in ADSL.

analysis %>%
  distinct(USUBJID) %>%
  anti_join(
    adsl %>% distinct(USUBJID),
    by = "USUBJID"
  )

Any returned subjects are not present in ADSL.

This is an excellent way to identify unexpected subject mismatches.

Join Validation

For important joins, dplyr supports explicit relationship specifications in modern versions.

analysis %>%
  left_join(
    baseline,
    by = c("USUBJID", "PARAMCD"),
    relationship = "many-to-one"
  )

The statement communicates an important assumption:

$$ \text{many analysis records} \rightarrow \text{one baseline record} $$

If the data violate that expected relationship, the join can be flagged rather than silently producing an unintended result.

Joining With Multiple Keys

Clinical datasets often require composite keys.

analysis %>%
  left_join(
    baseline,
    by = c(
      "USUBJID",
      "PARAMCD"
    )
  )

Other possible keys include:

  • USUBJID + PARAMCD
  • USUBJID + AVISIT
  • USUBJID + PARAMCD + AVISIT
  • USUBJID + ADY + PARAMCD

The correct key is determined by the business and analysis definition of the records, not by whichever variables happen to produce a successful join.

Window Functions

Some of the most powerful dplyr functionality comes from window functions.

Unlike summarise(), which reduces multiple rows to fewer rows, window functions can calculate information across rows while retaining the original observations.

Examples include:

  • lag()
  • lead()
  • row_number()
  • min_rank()
  • dense_rank()
  • percent_rank()
  • cumsum()

lag(): Previous Clinical Observation

Suppose laboratory observations are ordered chronologically.

lab %>%
  arrange(
    USUBJID,
    PARAMCD,
    ADY
  ) %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  mutate(
    PREV_AVAL = lag(AVAL)
  ) %>%
  ungroup()

The previous observation can then be used to calculate within-patient change.

lab %>%
  arrange(
    USUBJID,
    PARAMCD,
    ADY
  ) %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  mutate(
    PREV_AVAL = lag(AVAL),
    CHANGE_FROM_PREVIOUS = AVAL - PREV_AVAL
  ) %>%
  ungroup()

lead(): The Next Observation

lab %>%
  arrange(
    USUBJID,
    PARAMCD,
    ADY
  ) %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  mutate(
    NEXT_ADY = lead(ADY)
  ) %>%
  ungroup()

This can be useful for identifying the interval until the next assessment.

Calculating Time Between Assessments

lab %>%
  arrange(
    USUBJID,
    PARAMCD,
    ADY
  ) %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  mutate(
    DAYS_TO_NEXT = lead(ADY) - ADY
  ) %>%
  ungroup()

This can help identify unusually long gaps between assessments.

row_number()

Within each patient, observations can be numbered.

lab %>%
  arrange(
    USUBJID,
    ADY
  ) %>%
  group_by(USUBJID) %>%
  mutate(
    RECORD_NUMBER = row_number()
  ) %>%
  ungroup()

This can be useful for QC and exploratory review.

Identifying the First Post-Baseline Assessment

lab %>%
  filter(
    ADY > 1,
    !is.na(AVAL)
  ) %>%
  arrange(
    USUBJID,
    ADY
  ) %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  slice_head(n = 1) %>%
  ungroup()

This selects the earliest qualifying post-baseline assessment.

Creating Analysis Flags

Clinical analysis datasets often contain flags such as:

  • SAFFL
  • ITTFL
  • EFFFL
  • ANL01FL
  • ONTRTFL
  • ABLFL

A simplified example:

analysis <- analysis %>%
  mutate(
    ANL01FL = if_else(
      !is.na(AVAL) & AVISIT != "Baseline",
      "Y",
      NA_character_
    )
  )

The actual derivation must follow the study-specific analysis specification.

if_else() vs. ifelse()

dplyr's if_else() is generally preferable in typed clinical programming because it is stricter about data types.

mutate(
  FLAG = if_else(
    AVAL > 100,
    "Y",
    "N"
  )
)

The stricter type behavior can help expose unexpected derivation problems.

Creating Subject-Level Flags From Longitudinal Data

Suppose a subject should receive an indicator if any ALT measurement exceeds 100.

alt_flags <- lab %>%
  group_by(USUBJID) %>%
  summarise(
    ALT_HIGH_FL = if_else(
      any(AVAL > 100, na.rm = TRUE),
      "Y",
      "N"
    ),
    .groups = "drop"
  )

The resulting dataset has one row per subject.

Any vs. All

Clinical derivations frequently ask whether:

$$ \text{any observation satisfies a condition} $$

or:

$$ \text{all observations satisfy a condition} $$

dplyr can express both:

group_by(USUBJID) %>%
summarise(
  ANY_HIGH = any(AVAL > 100, na.rm = TRUE),
  ALL_HIGH = all(AVAL > 100, na.rm = TRUE),
  .groups = "drop"
)

Be particularly careful with missing values when using these functions.

Handling Dates

Clinical datasets contain multiple date concepts:

  • date of randomization;
  • date of first dose;
  • assessment date;
  • adverse-event start date;
  • adverse-event end date.

Dates should be represented using appropriate date classes rather than treated as arbitrary character strings.

analysis <- analysis %>%
  mutate(
    ADT = as.Date(ADT)
  )

Once dates are represented appropriately, date arithmetic becomes much safer.

Deriving Study Day

A simplified study-day derivation is:

$$ ADY = ADT - TRTSDT + 1 $$

for dates on or after treatment start, depending on the study's day-numbering convention.

analysis <- analysis %>%
  mutate(
    ADY = as.integer(ADT - TRTSDT) + 1
  )
Important: Study-day conventions are specification-dependent. Some analyses use +1, while others define pre-treatment days and post-treatment days differently. The programming specification should control the implementation.

Working With Visit Windows

Clinical assessments are frequently collected near, but not exactly on, their nominal visit days.

Suppose Week 4 is nominally Day 29 with an allowed window of ±7 days.

analysis <- analysis %>%
  mutate(
    WEEK4_FL = ADY >= 22 & ADY <= 36
  )

A more scalable approach is to maintain a visit-window lookup table.

visit_windows <- tibble::tribble(
  ~AVISIT, ~TARGET_DAY, ~LOWER_DAY, ~UPPER_DAY,
  "Week 4",  29, 22, 36,
  "Week 8",  57, 50, 64,
  "Week 12", 85, 78, 92
)

The assessment can then be compared against the appropriate window.

Why Visit Assignment Is More Complicated Than It Looks

Suppose an assessment occurs on Day 57.

It might map exactly to Week 8.

But if a patient has assessments on Days 55 and 60, the analysis specification must determine:

  • whether both are eligible;
  • which is closest to target;
  • whether earlier or later values are preferred in a tie;
  • whether the visit is nominal or analysis-derived; and
  • whether unscheduled assessments can contribute.

These decisions should not be hidden inside an arbitrary dplyr pipeline.

Selecting the Observation Closest to Target Day

A common derivation is to calculate the absolute deviation from the target.

analysis %>%
  mutate(
    DIST_FROM_TARGET = abs(ADY - 57)
  ) %>%
  filter(
    ADY >= 50,
    ADY <= 64
  ) %>%
  group_by(USUBJID, PARAMCD) %>%
  slice_min(
    order_by = DIST_FROM_TARGET,
    n = 1,
    with_ties = FALSE
  ) %>%
  ungroup()

If ties matter, the tie-breaker should be explicitly encoded.

Explicit Tie-Breaking

Suppose two observations are equally close to the target day.

You might specify preference for the earlier observation:

analysis %>%
  filter(
    ADY >= 50,
    ADY <= 64
  ) %>%
  mutate(
    DIST_FROM_TARGET = abs(ADY - 57)
  ) %>%
  arrange(
    USUBJID,
    PARAMCD,
    DIST_FROM_TARGET,
    ADY
  ) %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  slice_head(n = 1) %>%
  ungroup()

The important point is not the exact tie-breaker; it is that the tie-breaker should be deliberate.

The Pipe Operator

The pipe:

%>%

allows a sequence of operations to be expressed as a readable workflow.

analysis %>%
  filter(SAFFL == "Y") %>%
  arrange(USUBJID, ADY) %>%
  mutate(
    CHG = AVAL - BASE
  )

The equivalent sequence of intermediate assignments would be considerably more verbose.

The Native Pipe

Modern R also supports the native pipe:

analysis |>
  filter(SAFFL == "Y") |>
  arrange(USUBJID, ADY) |>
  mutate(
    CHG = AVAL - BASE
  )

Both styles are valid.

A clinical programming team should generally establish a consistent coding standard rather than mixing styles arbitrarily.

Building a Complete Clinical Pipeline

Consider the following conceptual workflow:

1
Start with standardized or analysis-ready observations.
2
Restrict to the required analysis population.
3
Attach subject-level treatment and population variables.
4
Identify the appropriate baseline record.
5
Derive baseline, change, and percentage change.
6
Apply visit and analysis-selection rules.
7
Create analysis flags.
8
Validate record counts, keys, and derived variables.

Complete Example: Laboratory Analysis Dataset

The following example demonstrates a more realistic sequence.

library(dplyr)

analysis <- lb %>%

  filter(
    PARAMCD == "ALT",
    !is.na(AVAL)
  ) %>%

  left_join(
    adsl %>%
      select(
        USUBJID,
        TRT01P,
        SAFFL,
        TRTSDT
      ),
    by = "USUBJID",
    relationship = "many-to-one"
  ) %>%

  mutate(
    ADY = as.integer(ADT - TRTSDT) + 1
  ) %>%

  group_by(
    USUBJID,
    PARAMCD
  ) %>%

  arrange(
    ADY,
    .by_group = TRUE
  ) %>%

  mutate(
    BASE = if_else(
      ADY <= 1,
      AVAL,
      NA_real_
    )
  ) %>%

  ungroup()

The example illustrates an important concept: clinical derivations are often best understood as a series of explicit transformations rather than one enormous expression.

A Better Baseline Pattern

For production work, isolate the baseline derivation.

baseline <- lb %>%

  filter(
    PARAMCD == "ALT",
    ADY <= 1,
    !is.na(AVAL)
  ) %>%

  arrange(
    USUBJID,
    PARAMCD,
    desc(ADY)
  ) %>%

  group_by(
    USUBJID,
    PARAMCD
  ) %>%

  slice_head(n = 1) %>%

  ungroup() %>%

  select(
    USUBJID,
    PARAMCD,
    BASE = AVAL,
    ABLFL = "Y"
  )

Then attach the baseline:

analysis <- lb %>%

  filter(
    PARAMCD == "ALT"
  ) %>%

  left_join(
    baseline,
    by = c(
      "USUBJID",
      "PARAMCD"
    ),
    relationship = "many-to-one"
  ) %>%

  mutate(
    CHG = AVAL - BASE,
    PCHG = case_when(
      is.na(AVAL) | is.na(BASE) ~ NA_real_,
      BASE == 0 ~ NA_real_,
      TRUE ~ 100 * CHG / BASE
    )
  )

Why This Is More Robust

The pipeline separates three logically distinct operations:

  1. Identify baseline.
  2. Attach baseline to observations.
  3. Calculate derived values.

This makes each component easier to test independently.

Grouped mutate() vs. summarise()

This distinction is essential.

Suppose there are five laboratory observations per subject.

Using:

group_by(USUBJID) %>%
summarise(
  MAX_AVAL = max(AVAL, na.rm = TRUE)
)

produces one row per subject.

Using:

group_by(USUBJID) %>%
mutate(
  MAX_AVAL = max(AVAL, na.rm = TRUE)
)

retains all five observations and attaches the subject-level maximum to each one.

Rule of thumb: Use summarise() when you want fewer rows. Use grouped mutate() when you want to retain the original rows while adding group-level information.

Subject-Level Maximum With mutate()

analysis %>%
  group_by(USUBJID) %>%
  mutate(
    MAX_PCHG = max(PCHG, na.rm = TRUE)
  ) %>%
  ungroup()

Every observation now carries the patient's maximum percentage change.

Subject-Level Nadir

For tumor-response data, the minimum percentage change can represent the observed maximum tumor shrinkage.

analysis %>%
  group_by(USUBJID) %>%
  mutate(
    NADIR_PCHG = min(
      PCHG,
      na.rm = TRUE
    )
  ) %>%
  ungroup()

Production code should explicitly handle subjects for whom all values are missing.

Handling All-Missing Groups

A safer pattern is:

analysis %>%
  group_by(USUBJID) %>%
  mutate(
    NADIR_PCHG = if_else(
      all(is.na(PCHG)),
      NA_real_,
      min(PCHG, na.rm = TRUE)
    )
  ) %>%
  ungroup()

This avoids generating misleading values when no usable observations exist.

Creating a Best-Response Record

Suppose the analysis requires the observation corresponding to the maximum tumor shrinkage.

best_change <- analysis %>%
  filter(
    !is.na(PCHG)
  ) %>%
  group_by(
    USUBJID
  ) %>%
  slice_min(
    order_by = PCHG,
    n = 1,
    with_ties = FALSE
  ) %>%
  ungroup()

The important distinction is that this is selecting an observation, whereas the subject-level minimum could be calculated with min().

Finding the Date of Maximum Toxicity

Suppose the analysis dataset contains a toxicity grade.

max_tox <- ae_analysis %>%
  group_by(USUBJID) %>%
  slice_max(
    order_by = ATOXGR,
    n = 1,
    with_ties = TRUE
  ) %>%
  ungroup()

If several events share the same maximum grade, multiple records may be returned.

Whether that is appropriate depends on the downstream analysis.

When You Need Exactly One Record

If the specification requires exactly one record, a deterministic tie-breaker should be defined.

max_tox <- ae_analysis %>%
  arrange(
    USUBJID,
    desc(ATOXGR),
    AESTDT,
    AESEQ
  ) %>%
  group_by(USUBJID) %>%
  slice_head(n = 1) %>%
  ungroup()

Here the ordering hierarchy is:

  1. highest toxicity grade;
  2. earliest start date;
  3. lowest sequence number.

This is substantially safer than relying on arbitrary row order.

Working With Treatment Groups

Treatment variables often need to be transformed for reporting.

analysis %>%
  mutate(
    TRTGRP = case_when(
      TRT01P == "Placebo" ~ "Control",
      TRT01P == "Drug A" ~ "Active",
      TRT01P == "Drug B" ~ "Active",
      TRUE ~ NA_character_
    )
  )

For controlled clinical reporting, treatment labels should normally come from a controlled specification or metadata source rather than being repeatedly hard-coded throughout programs.

Factor Ordering

The display order of treatment groups can matter in tables and figures.

analysis <- analysis %>%
  mutate(
    TRT01P = factor(
      TRT01P,
      levels = c(
        "Placebo",
        "Drug A",
        "Drug B"
      )
    )
  )

This can make downstream reporting deterministic.

Across-Patient Summaries

Once patient-level values have been derived, dplyr can summarize them by treatment.

analysis %>%
  group_by(TRT01P) %>%
  summarise(
    N = n_distinct(USUBJID),
    MEAN = mean(AVAL, na.rm = TRUE),
    SD = sd(AVAL, na.rm = TRUE),
    MEDIAN = median(AVAL, na.rm = TRUE),
    MIN = min(AVAL, na.rm = TRUE),
    MAX = max(AVAL, na.rm = TRUE),
    .groups = "drop"
  )

This is a useful starting point for exploratory analysis, although production clinical tables usually require more sophisticated handling of denominators, missingness, precision, and analysis populations.

Conditional Summaries

Suppose we want the number of subjects with an ALT value greater than 100.

lab %>%
  group_by(TRT01P) %>%
  summarise(
    N_SUBJECTS = n_distinct(USUBJID),
    N_HIGH = n_distinct(
      USUBJID[AVAL > 100]
    ),
    .groups = "drop"
  )

This distinguishes the number of qualifying subjects from the number of qualifying observations.

Observation Counts vs. Subject Counts

This distinction is one of the most common sources of clinical-programming errors.

If a patient has five abnormal ALT measurements:

$$ \text{Number of abnormal observations}=5 $$

but:

$$ \text{Number of subjects with an abnormal observation}=1 $$

The correct denominator depends on the analysis.

Counting Subjects With dplyr

analysis %>%
  filter(
    AVAL > 100
  ) %>%
  summarise(
    N = n_distinct(USUBJID)
  )

The use of n_distinct() is often critical when multiple records can exist per subject.

A Common Anti-Pattern

Avoid assuming:

n()

means number of patients.

It means number of rows in the current group.

For patient-level counts, use:

n_distinct(USUBJID)

when appropriate.

Clinical Population Selection

Suppose ADSL contains:

  • SAFFL — safety population;
  • ITTFL — intent-to-treat population;
  • EFFFL — efficacy population.

A safety analysis can begin with:

adsl_safety <- adsl %>%
  filter(
    SAFFL == "Y"
  )

An observation-level dataset can then be restricted using:

analysis <- observations %>%
  semi_join(
    adsl_safety,
    by = "USUBJID"
  )

This provides a clear separation between defining the population and manipulating observations.

Filtering by Treatment and Population

adsl %>%
  filter(
    SAFFL == "Y",
    TRT01P == "Drug A"
  )

Multiple conditions inside filter() are evaluated as AND conditions.

Using Between()

For numeric ranges:

filter(
  ADY %in% 1:7
)

or:

filter(
  between(ADY, 1, 7)
)

The latter can improve readability.

String Operations

Clinical data frequently contain character variables requiring classification.

Useful functions include:

  • str_detect()
  • str_starts()
  • str_ends()
  • str_to_upper()
  • str_trim()

For example:

analysis %>%
  filter(
    stringr::str_detect(
      PARAM,
      "Alanine"
    )
  )

Character normalization can be useful when integrating data from multiple sources.

Missing Character Values

Do not assume an empty string and NA mean the same thing.

analysis %>%
  mutate(
    PARAM = na_if(
      stringr::str_trim(PARAM),
      ""
    )
  )

This converts empty strings into missing values.

Coalesce()

The coalesce() function returns the first nonmissing value.

analysis %>%
  mutate(
    FINAL_VALUE = coalesce(
      AVAL,
      AVALC
    )
  )

This can be useful when integrating data sources, but it should not be used to arbitrarily substitute one clinical measurement for another.

Across() for Multiple Variables

dplyr can apply the same operation across multiple columns.

data %>%
  mutate(
    across(
      c(AVAL, BASE, CHG),
      as.numeric
    )
  )

For descriptive summaries:

data %>%
  summarise(
    across(
      c(AVAL, BASE, CHG),
      ~ mean(.x, na.rm = TRUE)
    )
  )

This is useful for exploratory data processing but should be used cautiously when variables have different semantic meanings.

Using across() With Naming

data %>%
  summarise(
    across(
      c(AVAL, CHG),
      list(
        N = ~ sum(!is.na(.x)),
        MEAN = ~ mean(.x, na.rm = TRUE),
        SD = ~ sd(.x, na.rm = TRUE)
      ),
      .names = "{.col}_{.fn}"
    )
  )

This creates variables such as:

  • AVAL_N
  • AVAL_MEAN
  • AVAL_SD
  • CHG_N
  • CHG_MEAN
  • CHG_SD

Reusable Functions

If the same derivation appears repeatedly, write a function rather than copying code.

derive_pchg <- function(aval, base) {

  case_when(
    is.na(aval) | is.na(base) ~ NA_real_,
    base == 0 ~ NA_real_,
    TRUE ~ 100 * (aval - base) / base
  )

}

Then:

analysis <- analysis %>%
  mutate(
    PCHG = derive_pchg(
      AVAL,
      BASE
    )
  )

This can improve consistency across analysis programs.

Custom Functions and Traceability

Reusable functions are particularly useful when they encapsulate stable, well-defined rules.

However, avoid hiding study-specific logic inside opaque functions that make the derivation difficult for independent programmers or reviewers to inspect.

Good abstraction: A function that calculates percentage change using explicitly supplied values.

Risky abstraction: A function named derive_response() that silently contains dozens of study-specific assumptions about missing data, visit windows, progression, and response confirmation.

Programming With Temporary Variables

Temporary variables can make complex derivations easier to understand.

analysis <- analysis %>%
  mutate(
    DIST = abs(ADY - TARGET_DAY),
    IN_WINDOW = between(
      ADY,
      LOWER_DAY,
      UPPER_DAY
    )
  ) %>%
  filter(
    IN_WINDOW
  ) %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  slice_min(
    DIST,
    n = 1,
    with_ties = FALSE
  ) %>%
  ungroup() %>%
  select(
    -DIST,
    -IN_WINDOW
  )

Temporary variables make the logic inspectable and can then be removed once they have served their purpose.

Clinical Data Reshaping

dplyr itself primarily manipulates data in their existing structure.

For reshaping between long and wide formats, the companion tidyr package is commonly used.

For example, converting selected laboratory visits into columns:

analysis %>%
  tidyr::pivot_wider(
    id_cols = USUBJID,
    names_from = AVISIT,
    values_from = AVAL
  )

This can be useful for exploratory work.

However, long format is often preferable for analysis programming because it supports grouped operations and longitudinal procedures naturally.

Long vs. Wide Clinical Data

Structure Strength Typical use
Long Flexible longitudinal processing ADaM-style observations, plotting, modeling
Wide Easy side-by-side comparison Some exploratory analyses and reporting

Programmatic QC With count()

One of the simplest and most powerful QC techniques is to count expected keys.

analysis %>%
  count(
    USUBJID,
    PARAMCD
  ) %>%
  summarise(
    MAX_RECORDS = max(n),
    MIN_RECORDS = min(n),
    N_GROUPS = n()
  )

Unexpected counts can reveal duplicate or missing observations.

Checking One Treatment Per Subject

adsl %>%
  group_by(USUBJID) %>%
  summarise(
    N_TREATMENTS = n_distinct(TRT01P),
    .groups = "drop"
  ) %>%
  filter(
    N_TREATMENTS > 1
  )

This is a simple but valuable validation check.

Checking One Baseline Per Subject and Parameter

baseline %>%
  count(
    USUBJID,
    PARAMCD
  ) %>%
  filter(
    n != 1
  )

A zero-row result is expected if every subject-parameter combination should have exactly one baseline record.

Checking Missing Baselines

analysis %>%
  distinct(
    USUBJID,
    PARAMCD,
    BASE
  ) %>%
  filter(
    is.na(BASE)
  )

Missing baseline values should be investigated rather than automatically replaced.

Checking Impossible Values

Clinical programmers should create domain-specific QC checks.

For example:

analysis %>%
  filter(
    AVAL < 0
  ) %>%
  select(
    USUBJID,
    PARAMCD,
    AVISIT,
    ADY,
    AVAL
  )

Whether a negative value is impossible depends on the parameter.

The important principle is to encode scientifically meaningful expectations where possible.

Checking Chronological Ordering

analysis %>%
  arrange(
    USUBJID,
    PARAMCD,
    ADY
  ) %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  mutate(
    PREVIOUS_ADY = lag(ADY),
    ORDER_ERROR = !is.na(PREVIOUS_ADY) &&
      ADY < PREVIOUS_ADY
  ) %>%
  ungroup()

The exact implementation can be simplified after sorting, but the concept is important: longitudinal calculations require deliberate ordering.

Ungrouping Is a Defensive Practice

Suppose:

data %>%
  group_by(USUBJID) %>%
  mutate(
    MAX_AVAL = max(AVAL, na.rm = TRUE)
  ) %>%
  mutate(
    GRAND_MEAN = mean(AVAL, na.rm = TRUE)
  )

The second calculation is still grouped by subject.

If the intention was a study-wide mean, the grouping must be removed.

data %>%
  group_by(USUBJID) %>%
  mutate(
    MAX_AVAL = max(AVAL, na.rm = TRUE)
  ) %>%
  ungroup() %>%
  mutate(
    GRAND_MEAN = mean(AVAL, na.rm = TRUE)
  )
Practical rule: After a grouped derivation, explicitly use ungroup() when the next operation is intended to operate across the entire dataset.

Grouped Sorting With .by_group

When working with grouped data:

data %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  arrange(
    ADY,
    .by_group = TRUE
  )

This ensures that rows are ordered within each group.

The .by Argument

Modern dplyr also supports temporary grouping through the .by argument.

data %>%
  mutate(
    MAX_AVAL = max(AVAL, na.rm = TRUE),
    .by = USUBJID
  )

This can be convenient when the grouping applies to only one operation.

For long, complex clinical derivations, explicit group_by() and ungroup() may remain easier to read because the grouping state is visible.

When .by Is Useful

analysis %>%
  mutate(
    BASE = first(AVAL),
    .by = c(USUBJID, PARAMCD)
  )

This avoids changing the persistent grouping state of the data frame.

Building ADaM-Style Variables

A simplified analysis dataset might contain:

Variable Purpose
USUBJID Unique subject identifier
TRT01P Planned treatment
ADT Analysis date
ADY Analysis day
AVAL Analysis value
BASE Baseline value
CHG Change from baseline
PCHG Percent change from baseline
AVISIT Analysis visit
ANL01FL Analysis record selection flag

dplyr is particularly effective for implementing these row-level and subject-level derivations.

ADSL + BDS-Style Workflow

A common pattern is:

1
ADSL defines subject-level treatment and population attributes.
2
The observation dataset contains repeated measurements.
3
A subject-level left join attaches treatment and population variables.
4
Grouped logic derives baseline and longitudinal variables.
5
Selection logic creates analysis flags.
6
QC checks confirm expected keys and record counts.

Traceability in dplyr Pipelines

A clinical-programming pipeline should make it possible to answer:

  • Where did this variable come from?
  • Which source records contributed?
  • What rule selected this observation?
  • Which population flag was applied?
  • How was baseline determined?
  • How was the derived value calculated?

For example:

analysis <- lb %>%

  # Restrict to the parameter
  filter(
    PARAMCD == "ALT"
  ) %>%

  # Attach subject-level attributes
  left_join(
    adsl %>%
      select(
        USUBJID,
        TRT01P,
        SAFFL,
        TRTSDT
      ),
    by = "USUBJID",
    relationship = "many-to-one"
  ) %>%

  # Derive analysis day
  mutate(
    ADY = as.integer(ADT - TRTSDT) + 1
  ) %>%

  # Attach baseline
  left_join(
    baseline,
    by = c(
      "USUBJID",
      "PARAMCD"
    ),
    relationship = "many-to-one"
  ) %>%

  # Derive change variables
  mutate(
    CHG = AVAL - BASE,
    PCHG = derive_pchg(
      AVAL,
      BASE
    )
  )

Comments should explain the clinical purpose of a transformation rather than merely restating the function name.

Good Comment

# Select the last nonmissing assessment
# before first dose as baseline

Weak Comment

# Use slice_max()

The first comment explains the analysis rule.

Modular Clinical Programming

Large dplyr programs should generally be divided into logical stages.

raw_data
    ↓
population_data
    ↓
baseline_data
    ↓
analysis_data
    ↓
analysis_flags
    ↓
reporting_data

Each stage should have a clear purpose.

Example of Modular Code

population <- adsl %>%
  filter(
    SAFFL == "Y"
  )

baseline <- derive_baseline(
  lb,
  population
)

analysis <- derive_analysis(
  lb,
  population,
  baseline
)

qc <- validate_analysis(
  analysis
)

This architecture can be easier to maintain than one very long pipeline.

Common dplyr Mistake: Filtering Too Early

Consider a baseline rule requiring the last measurement before treatment.

If post-baseline observations are filtered out before the baseline derivation, that may be harmless.

But if the filter accidentally removes a valid baseline candidate, the derived baseline can change.

Always identify which records are needed for each derivation before applying population or timing filters.

Common Mistake: Joining Before Establishing Uniqueness

Suppose baseline contains duplicate subject-parameter records.

Joining it onto the longitudinal dataset can multiply records.

The safer sequence is:

  1. derive baseline;
  2. validate baseline uniqueness;
  3. join baseline;
  4. validate post-join counts.

Common Mistake: Using distinct() to Hide a Join Problem

A programmer may notice that a join created extra rows and respond with:

distinct()

This can make the dataset appear correct while silently discarding records.

Never use distinct() as a generic cleanup step after an unexpected join. Find out why the additional rows were created.

Common Mistake: Implicit Row Order

Code such as:

group_by(USUBJID) %>%
slice_head(n = 1)

does not have a meaningful clinical interpretation unless the data have already been sorted according to the intended definition.

Instead:

arrange(
  USUBJID,
  ADY
) %>%
group_by(
  USUBJID
) %>%
slice_head(n = 1)

makes the ordering explicit.

Common Mistake: Ignoring Ties

Suppose two laboratory results occur at the same time.

Using:

slice_min(
  AVAL,
  n = 1,
  with_ties = FALSE
)

arbitrarily chooses one if they tie.

If the study specifies a rule for resolving replicate observations, that rule must be encoded explicitly.

Common Mistake: Forgetting Missingness

Code such as:

min(AVAL)

returns NA when missing values are present.

Using:

min(
  AVAL,
  na.rm = TRUE
)

ignores missing values.

But this does not mean the result is always clinically valid.

If every value is missing, additional handling may be required.

Common Mistake: Confusing Observation and Subject-Level Logic

This:

filter(AVAL > 100)

selects abnormal observations.

This:

group_by(USUBJID) %>%
filter(
  any(AVAL > 100, na.rm = TRUE)
)

selects all observations belonging to subjects who ever had an abnormal value.

Those are fundamentally different operations.

Performance Considerations

Clinical datasets can become large, particularly with:

  • laboratory data;
  • ECG data;
  • vital signs;
  • pharmacokinetic observations;
  • exposure records;
  • high-frequency device data.

For many ordinary clinical-trial datasets, dplyr performs well.

For very large datasets, consider:

  • reducing columns early when safe;
  • filtering populations deliberately;
  • avoiding unnecessary repeated joins;
  • using database-backed workflows when appropriate;
  • profiling rather than prematurely optimizing.

Keep Only What You Need — But Not Too Early

Selecting relevant variables can reduce memory usage:

lb_small <- lb %>%
  select(
    USUBJID,
    PARAMCD,
    ADT,
    AVAL
  )

However, do not remove variables needed for traceability or downstream derivations merely to make the dataset smaller.

Database-Backed dplyr

One of dplyr's important architectural strengths is that many operations can be translated to SQL for database-backed data.

Conceptually:

$$ \text{dplyr expression} \rightarrow \text{database query} \rightarrow \text{result} $$

This can become important when clinical data are stored in large relational systems rather than local R data frames.

Testing a Pipeline With Small Known Data

Before applying complex derivations to a large clinical dataset, construct a small dataset with known expected results.

test_data <- tibble::tribble(
  ~USUBJID, ~ADY, ~AVAL,
  "001",  -1, 100,
  "001",   1,  90,
  "001",  29,  80,
  "002",  -2, 200,
  "002",   1, 180,
  "002",  29, 160
)

The expected percentage changes are easy to calculate manually.

$$ PCHG_{001,29} = \frac{80-100}{100}\times100 = -20\% $$

Such small deterministic examples are extremely useful for unit testing.

Assertions for Clinical QC

Simple checks can be written directly in R.

stopifnot(
  all(
    analysis$BASE >= 0 |
    is.na(analysis$BASE)
  )
)

Another example:

stopifnot(
  nrow(
    baseline %>%
      count(USUBJID, PARAMCD) %>%
      filter(n != 1)
  ) == 0
)

This causes the program to stop if the expected baseline uniqueness rule is violated.

Comparing Program Outputs

Independent validation can compare datasets generated by different programs.

For example, a reference implementation might produce:

reference <- ...

programmer_output <- ...

dplyr::anti_join(
  reference,
  programmer_output,
  by = c(
    "USUBJID",
    "PARAMCD",
    "AVISIT"
  )
)

A reciprocal comparison can identify records present only in the programmer's output.

dplyr::anti_join(
  programmer_output,
  reference,
  by = c(
    "USUBJID",
    "PARAMCD",
    "AVISIT"
  )
)

This is conceptually similar to a dataset-level comparison.

Comparing Derived Values

For numeric variables, exact equality may be inappropriate when different numeric representations or rounding conventions are involved.

A tolerance-based comparison can be implemented explicitly.

comparison <- reference %>%
  inner_join(
    programmer_output,
    by = "USUBJID",
    suffix = c("_REF", "_OUT")
  ) %>%
  mutate(
    MATCH = abs(
      AVAL_REF - AVAL_OUT
    ) < 1e-8
  )

The tolerance should reflect the scientific and computational requirements of the analysis.

A Production-Oriented dplyr Checklist

1
Define the expected observation grain before writing code.
2
Identify the required join keys and expected cardinality.
3
Define baseline and visit-selection rules independently.
4
Handle missing values explicitly.
5
Sort before position-dependent operations.
6
Resolve ties using an explicit clinical or programming rule.
7
Use grouped operations deliberately.
8
Ungroup when the next calculation should be global.
9
Validate record counts after important transformations.
10
Validate the final dataset against independent expectations.

A More Advanced Example: Selecting the Analysis Record

Suppose an analysis requires one record per subject and parameter at a nominal visit.

The specification states:

  • Only observations inside the visit window are eligible.
  • The observation closest to the target day is selected.
  • If tied, the earlier observation is preferred.
  • Missing values are not eligible.

The implementation can be expressed as:

analysis_visit <- data %>%

  filter(
    !is.na(AVAL),
    between(
      ADY,
      LOWER_DAY,
      UPPER_DAY
    )
  ) %>%

  mutate(
    DIST_FROM_TARGET =
      abs(ADY - TARGET_DAY)
  ) %>%

  arrange(
    USUBJID,
    PARAMCD,
    DIST_FROM_TARGET,
    ADY
  ) %>%

  group_by(
    USUBJID,
    PARAMCD
  ) %>%

  slice_head(
    n = 1
  ) %>%

  ungroup() %>%

  mutate(
    ANL01FL = "Y"
  )

This is a good example of translating a written statistical-programming specification into a sequence of explicit transformations.

Why Explicit Pipelines Are Valuable in Clinical Programming

A clinical programmer should be able to read the code and reconstruct the derivation.

For example:

filter(!is.na(AVAL))
    ↓
filter(visit window)
    ↓
calculate distance to target
    ↓
sort by distance
    ↓
sort by tie-breaker
    ↓
select first record
    ↓
set ANL01FL = "Y"

This is much easier to review than a compressed expression whose intent is difficult to determine.

When a Single Pipeline Becomes Too Long

A pipeline containing dozens of transformations may become difficult to debug.

Break it into meaningful intermediate objects:

eligible <- data %>%
  filter(
    !is.na(AVAL)
  )

windowed <- eligible %>%
  filter(
    between(
      ADY,
      LOWER_DAY,
      UPPER_DAY
    )
  )

ranked <- windowed %>%
  mutate(
    DIST = abs(
      ADY - TARGET_DAY
    )
  ) %>%
  arrange(
    USUBJID,
    PARAMCD,
    DIST,
    ADY
  )

selected <- ranked %>%
  group_by(
    USUBJID,
    PARAMCD
  ) %>%
  slice_head(n = 1) %>%
  ungroup()

This may be longer, but it can be substantially easier to validate.

dplyr and SAS Conceptual Mapping

For programmers transitioning from SAS, the following conceptual mapping can be useful.

Clinical programming task dplyr SAS concept
Keep observations filter() DATA step IF / WHERE
Create variable mutate() DATA step assignment
Sort arrange() PROC SORT
Aggregate summarise() PROC SUMMARY / SQL aggregation
Group calculations group_by() BY-group processing
Merge left_join() MERGE / PROC SQL
Conditional logic case_when() IF/THEN/ELSE
Previous record lag() LAG()
Distinct count n_distinct() SQL DISTINCT / summary logic

The mapping is conceptual rather than one-to-one.

dplyr Is Not a Replacement for Clinical Specifications

One of the most important principles is that dplyr implements a derivation; it does not define the derivation.

The specification should determine:

  • population;
  • baseline;
  • visit windows;
  • analysis records;
  • missing-data handling;
  • treatment assignment;
  • endpoint definitions;
  • censoring rules; and
  • other analysis conventions.

dplyr is the programming language used to translate those decisions into data transformations.

Think specification first, code second. The best dplyr program is not the shortest program. It is the program whose logic can be clearly traced back to the approved analysis specification.

Recommended Clinical dplyr Workflow

A robust workflow can be summarized as:

$$ \text{Specification} \rightarrow \text{Source Data} \rightarrow \text{Eligibility} \rightarrow \text{Derivations} \rightarrow \text{Selection} \rightarrow \text{QC} \rightarrow \text{Reporting} $$

Each stage should have a clear input, output, and validation strategy.

Final Example: End-to-End Analysis Pipeline

The following example combines many of the concepts discussed in this tutorial.

library(dplyr)

#--------------------------------------------------
# 1. Define analysis population
#--------------------------------------------------

population <- adsl %>%
  filter(
    SAFFL == "Y"
  ) %>%
  select(
    USUBJID,
    TRT01P,
    TRTSDT
  )

#--------------------------------------------------
# 2. Identify eligible observations
#--------------------------------------------------

eligible <- lb %>%
  filter(
    PARAMCD == "ALT",
    !is.na(AVAL)
  ) %>%

  semi_join(
    population,
    by = "USUBJID"
  )

#--------------------------------------------------
# 3. Attach treatment variables
#--------------------------------------------------

analysis_base <- eligible %>%

  left_join(
    population,
    by = "USUBJID",
    relationship = "many-to-one"
  ) %>%

  mutate(
    ADY = as.integer(
      ADT - TRTSDT
    ) + 1
  )

#--------------------------------------------------
# 4. Derive baseline
#--------------------------------------------------

baseline <- analysis_base %>%

  filter(
    ADY <= 1
  ) %>%

  arrange(
    USUBJID,
    PARAMCD,
    desc(ADY)
  ) %>%

  group_by(
    USUBJID,
    PARAMCD
  ) %>%

  slice_head(
    n = 1
  ) %>%

  ungroup() %>%

  select(
    USUBJID,
    PARAMCD,
    BASE = AVAL
  )

#--------------------------------------------------
# 5. Attach baseline
#--------------------------------------------------

analysis <- analysis_base %>%

  left_join(
    baseline,
    by = c(
      "USUBJID",
      "PARAMCD"
    ),
    relationship = "many-to-one"
  )

#--------------------------------------------------
# 6. Derive change variables
#--------------------------------------------------

analysis <- analysis %>%

  mutate(
    CHG = AVAL - BASE,

    PCHG = case_when(
      is.na(AVAL) |
        is.na(BASE) |
        BASE == 0 ~ NA_real_,

      TRUE ~
        100 * (AVAL - BASE) / BASE
    )
  )

#--------------------------------------------------
# 7. Identify analysis records
#--------------------------------------------------

analysis <- analysis %>%

  arrange(
    USUBJID,
    ADY
  ) %>%

  group_by(
    USUBJID,
    PARAMCD
  ) %>%

  mutate(
    ANL01FL = if_else(
      !is.na(AVAL) &
        ADY > 1,
      "Y",
      NA_character_
    )
  ) %>%

  ungroup()

#--------------------------------------------------
# 8. QC baseline uniqueness
#--------------------------------------------------

baseline_qc <- baseline %>%

  count(
    USUBJID,
    PARAMCD
  ) %>%

  filter(
    n != 1
  )

#--------------------------------------------------
# 9. QC subject membership
#--------------------------------------------------

subject_qc <- analysis %>%

  distinct(
    USUBJID
  ) %>%

  anti_join(
    population %>%
      distinct(USUBJID),
    by = "USUBJID"
  )

#--------------------------------------------------
# 10. QC duplicate analysis records
#--------------------------------------------------

duplicate_qc <- analysis %>%

  filter(
    ANL01FL == "Y"
  ) %>%

  count(
    USUBJID,
    PARAMCD,
    AVISIT
  ) %>%

  filter(
    n > 1
  )

What Makes This Pipeline Production-Friendly?

Several features are worth emphasizing.

  • The analysis population is explicitly defined.
  • Population membership is applied using semi_join().
  • Treatment information comes from the subject-level dataset.
  • Baseline is derived separately.
  • The baseline join explicitly declares the expected relationship.
  • Change and percentage change are derived with explicit missing-value logic.
  • Analysis flags are created separately.
  • QC datasets are generated alongside the analysis dataset.

This is much closer to how dplyr should be used in a serious clinical programming environment than simply chaining a few filtering commands together.

Final Clinical Programming Checklist

Question Check
What is one row? Clearly define the observation grain.
What is the population? Apply the correct population flag.
What are the join keys? Confirm expected cardinality.
Is baseline defined? Implement the prespecified baseline rule.
Are missing values handled? Do not rely on accidental R behavior.
Are rows sorted? Sort before position-dependent operations.
Are ties possible? Implement a deterministic tie-breaker.
Is grouping correct? Check whether operations are subject-, parameter-, or study-level.
Are groups removed? Use ungroup() when appropriate.
Did record counts change? Investigate unexpected changes after joins or filters.
Are analysis flags correct? Validate their derivation independently.
Is the output traceable? Every derived variable should have a documented origin and rule.

The Most Important Concepts

If you remember only a few ideas from this tutorial, remember these:

  1. Know the grain of your data.
  2. Understand the expected cardinality before performing a join.
  3. Use group_by() deliberately for within-subject or within-parameter calculations.
  4. Use summarise() when reducing records and mutate() when retaining them.
  5. Sort explicitly before selecting first, last, closest, or best records.
  6. Define tie-breaking rules instead of relying on arbitrary row order.
  7. Handle missing values explicitly.
  8. Separate baseline derivation from downstream calculations when possible.
  9. Use semi_join() and anti_join() as clinical QC tools.
  10. Validate the data after every major transformation.
Bottom line: dplyr provides a powerful and expressive framework for clinical data manipulation in R. Its greatest value is not simply replacing traditional data-step syntax, but providing a consistent language for expressing subject-level, observation-level, longitudinal, and relational transformations. For clinical programmers, the most important skills are understanding data grain, grouped operations, join cardinality, baseline selection, deterministic record selection, missing-data handling, and independent validation. When these principles are combined with explicit analysis specifications and disciplined QC, dplyr can support robust, traceable clinical analysis programming.

References

Wickham, H., François, R., Henry, L., Müller, K., & Vaughan, D. dplyr: A Grammar of Data Manipulation. R package documentation and reference materials.

Wickham, H., Averick, M., Bryan, J., et al. (2019). Welcome to the Tidyverse. Journal of Open Source Software, 4(43), 1686.

Wickham, H. (2014). Tidy Data. Journal of Statistical Software, 59(10), 1–23.

R Core Team. R: A Language and Environment for Statistical Computing. R Foundation for Statistical Computing, Vienna, Austria.

CDISC. Analysis Data Model (ADaM). Clinical Data Interchange Standards Consortium.

CDISC. Study Data Tabulation Model (SDTM). Clinical Data Interchange Standards Consortium.