Creating an exposure-response dataset

Introduction

An exposure-response (ER) dataset links a measure of drug exposure (for example a model-derived AUC or CMAX) to one or more response endpoints, so that the relation between the two can be analysed. Typically the exposure metrics and the response data live in two separate datasets: the exposure metrics are derived by a modeler from a pharmacokinetic model, while the endpoint data are prepared by a programmer. Building the ER dataset is the step that brings the two together and gets everything ready for analysis.

This vignette focuses on the parts of that step that are specific to an ER dataset:

  • combining the exposure and response data into one dataset, and
  • preparing for the analysis (inspecting and lumping sparse categories, imputing missing values and flagging outliers).

The parts of the workflow that are the same as for any other dataset – reading in source data, attaching meta data through attributes, tabulating and documenting the result, and writing the analysis file – are described in the vignette("getting_started") and vignette("example_study") vignettes and are therefore not repeated here. To keep this example self-contained we simulate two small source datasets rather than read them from file.

library(dplyr)
library(amp.dm)

Example data

The exposure dataset has one row per subject. Besides the exposure metrics (AUC, CMAX) it carries the subject-level covariates that the ER analysis may need (RACE, SEX, AGE, WT, ALT).

set.seed(1234)
n <- 30
exposure <- tibble(
  STDY = 1L,
  ID   = 1:n,
  TRT  = rep(1:3, each = 10),                  # dose group
  RACE = sample(rep(1:4, c(14, 10, 4, 2)))     # 4 = a rare category
) |>
  mutate(
    SEX  = rbinom(n(), 1, 0.45),
    AGE  = round(rnorm(n(), 55, 12)),
    WT   = round(rnorm(n(), 78, 14), 1),
    AUC  = round(TRT * rlnorm(n(), log(450), 0.25)),
    CMAX = round(AUC / rnorm(n(), 9, 1.2), 1),
    ALT  = round(runif(n(), 18, 40)),
    ALT  = replace(ALT, ID %in% c(5, 22), c(120, 150)),   # two subjects with elevated ALT
    AGE  = replace(AGE, ID %in% c(15, 16), NA)            # two subjects with a missing age
  ) |>
  select(STDY, ID, TRT, AUC, CMAX, RACE, SEX, AGE, WT, ALT)
head(exposure)
#> # A tibble: 6 × 10
#>    STDY    ID   TRT   AUC  CMAX  RACE   SEX   AGE    WT   ALT
#>   <int> <int> <int> <dbl> <dbl> <int> <int> <dbl> <dbl> <dbl>
#> 1     1     1     1   578  58.4     3     0    49  68.6    22
#> 2     1     2     1   398  35.6     2     0    35  77.9    40
#> 3     1     3     1   492  54.1     3     1    41 103.     30
#> 4     1     4     1   339  41.1     2     1    29  62.1    35
#> 5     1     5     1   560  78       1     1    39  97.1   120
#> 6     1     6     1   574  69.7     1     1    51  96.6    33

The response dataset has one row per subject per endpoint. Exposure-response endpoints are often binary, so here DV is a 0/1 indicator. Every subject has two endpoints (EP 1: an efficacy response, EP 2: a safety event), each with its own response rate.

response <- tibble(
  STDY = 1L,
  ID   = rep(1:n, each = 2),
  EP   = rep(1:2, times = n)
) |>
  mutate(DV = rbinom(n(), 1, if_else(EP == 1, 0.5, 0.25)))   # binary response
head(response)
#> # A tibble: 6 × 4
#>    STDY    ID    EP    DV
#>   <int> <int> <int> <int>
#> 1     1     1     1     0
#> 2     1     1     2     0
#> 3     1     2     1     1
#> 4     1     2     2     0
#> 5     1     3     1     1
#> 6     1     3     2     0

Combining exposure and response

The two datasets are combined with left_joinr(). Keeping the response data on the left means every endpoint record is retained and the matching exposure and covariates are attached to it. left_joinr() is a thin wrapper around dplyr::left_join() that additionally logs the join, which is worth doing here: joining exposure onto repeated endpoint records is a classic place to accidentally drop subjects or create a cartesian product, and the message tells you immediately how many records went in and came out.

er <- left_joinr(response, exposure, by = c("STDY", "ID"),
                 comment = "Combine response with exposure")
#> ℹ Output data contains 60 records
#> ℹ response contained 60 records
#> ℹ exposure contained 30 records
head(er)
#>   STDY ID EP DV TRT AUC CMAX RACE SEX AGE    WT ALT
#> 1    1  1  1  0   1 578 58.4    3   0  49  68.6  22
#> 2    1  1  2  0   1 578 58.4    3   0  49  68.6  22
#> 3    1  2  1  1   1 398 35.6    2   0  35  77.9  40
#> 4    1  2  2  0   1 398 35.6    2   0  35  77.9  40
#> 5    1  3  1  1   1 492 54.1    3   1  41 102.9  30
#> 6    1  3  2  0   1 492 54.1    3   1  41 102.9  30

Notice that the exposure metrics and covariates are now repeated for each of a subject’s endpoint records (AUC, RACE, … are identical on the two rows of every ID). This repetition is the one thing to keep in mind while preparing the covariates below.

Preparing the covariates

Because each subject contributes more than one row, any summary of a covariate over the combined dataset counts every subject as many times as it has endpoints. The natural fix is to prepare the covariates within each endpoint: dplyr::group_by(EP) splits the data so that each subject appears exactly once per group. This is also what you want when the endpoints are measured in different subsets of subjects, so that lumping and imputation follow each endpoint’s own data.

Inspecting the categories

check_cat() gives a quick overview of a categorical covariate. Running it on the per-subject exposure data shows the true subject counts and highlights that races 3 and 4 are sparse.

check_cat(exposure$RACE)
#> ── freq (prop) for x ───────────────────────────────────────────────────────────
#> 1: 14 (46.7%)
#> 2: 10 (33.3%)
#> 3: 4 (13.3%)
#> 4: 2 (6.7%)
#> ── missing values x ────────────────────────────────────────────────────────────
#> Total of 0 missing values resulting in 0.0%
#> ── String copy for lumping ─────────────────────────────────────────────────────
#> c("1" = 1, "2" = 2, "3" = 3, "4" = 4)

Lumping sparse categories

Sparse categories often cannot support a covariate effect and are usually lumped together. num_lump() does this; a category is lumped when it stays below the min count (or prop proportion) you specify.

Running it over the whole combined dataset is misleading, because every subject is counted once per endpoint: race 3 has 4 subjects but 8 records, so with min = 5 it is not lumped:

table(num_lump(er$RACE, lumpcat = 99, min = 5))
#> ! Lumping performed but there are still categories < min (99)
#> ℹ Numbers lumped, returned 4 categories
#> 
#>  1  2  3 99 
#> 28 20  8  4

Lumping within each endpoint solves this. Grouped by EP each subject appears once, so min acts on the subject counts and both sparse races move into the new category 99:

er <- er |>
  group_by(EP) |>
  mutate(RACE_L = num_lump(RACE, lumpcat = 99, min = 5)) |>
  ungroup()
#> ℹ Numbers lumped, returned 3 categories
#> ℹ Numbers lumped, returned 3 categories
table(er$RACE_L)
#> 
#>  1  2 99 
#> 28 20 12

lumpcat controls where the lumped subjects go. Instead of a new category you can send them to the largest existing group, or map specific categories yourself – again within each endpoint:

# move the sparse races into the largest group
er2 <- er |> group_by(EP) |> mutate(RACE_L = num_lump(RACE, lumpcat = "largest", min = 5))
#> ℹ Numbers lumped, returned 2 categories
#> ℹ Numbers lumped, returned 2 categories
table(er2$RACE_L)
#> 
#>  1  2 
#> 40 20
# or map specific categories by hand (3 and 4 both become 2)
er2 <- er |> group_by(EP) |> mutate(RACE_L = num_lump(RACE, lumpcat = c("3" = 2, "4" = 2), min = 5))
#> ℹ Numbers lumped, returned 2 categories
#> ℹ Numbers lumped, returned 2 categories
table(er2$RACE_L)
#> 
#>  1  2 
#> 28 32

Imputing missing covariates

Missing covariate values can be filled in with impute_covar(), which replaces NA with a statistic (the median by default, or the largest group for a categorical covariate). Done within each endpoint, the statistic is again computed over subjects rather than records. The two subjects whose age was missing receive the median age:

filter(er, ID%in%15:16) |> pull(AGE)            # missing before
#> [1] NA NA NA NA
er <- er |> group_by(EP) |> mutate(AGE = impute_covar(AGE, type = "median")) |> ungroup()
filter(er, ID%in%15:16) |> pull(AGE)            # imputed after
#> [1] 47 47 47 47

Flagging outliers

Finally, flag_outliers() marks records whose value is a boxplot outlier. This does not remove anything; it adds a 0/1 flag you can use later to review or exclude the points. The two subjects with an elevated ALT are picked up (two endpoint records each, so four rows):

er$ALT_OUT <- flag_outliers(er$ALT)
table(er$ALT_OUT)
#> 
#>  0  1 
#> 56  4

The analysis-ready dataset

The result is a single dataset with one row per subject and endpoint, each response value sitting next to the subject’s exposure and cleaned-up covariates – ready for the exposure-response analysis.

head(er)
#> # A tibble: 6 × 14
#>    STDY    ID    EP    DV   TRT   AUC  CMAX  RACE   SEX   AGE    WT   ALT RACE_L
#>   <int> <int> <int> <int> <int> <dbl> <dbl> <int> <int> <dbl> <dbl> <dbl>  <dbl>
#> 1     1     1     1     0     1   578  58.4     3     0    49  68.6    22     99
#> 2     1     1     2     0     1   578  58.4     3     0    49  68.6    22     99
#> 3     1     2     1     1     1   398  35.6     2     0    35  77.9    40      2
#> 4     1     2     2     0     1   398  35.6     2     0    35  77.9    40      2
#> 5     1     3     1     1     1   492  54.1     3     1    41 103.     30     99
#> 6     1     3     2     0     1   492  54.1     3     1    41 103.     30     99
#> # ℹ 1 more variable: ALT_OUT <dbl>

From here the dataset is finished like any other: attach meta data with attr_xls() / attr_add(), review it with define_tbl(), stats_df() and counts_df(), and write it out with output_data(). Those steps are covered in the vignette("getting_started") and vignette("example_study") vignettes.