Preparing and checking the analysis dataset

DATA → MODEL → ESTIMATE ⇄ EVALUATE → SIMULATE → REPORT

Where you are

Every analysis starts with a dataset. ferx reads a single CSV file with one row per event: a dose, an observation or another event, per subject and time. Before writing a model it pays to check that the file says what you think it says. Are doses where they should be, are observations and covariates plausible, and are the records you intend to exclude really excluded?

This chapter covers the column layout ferx expects, checking a dataset in R, and the three ways to exclude records without editing the file. It uses the bundled two_cpt_oral_cov dataset, which the rest of The analysis workflow builds on.

The data

ferx_get_columns() reads only the header line and sorts the columns into the standard ones ferx recognises and everything else. Columns it doesn’t recognise are treated as covariates:

ex <- ferx_example("two_cpt_oral_cov")
ferx_get_columns(ex)
#> Data file: /home/runner/work/_temp/Library/ferx/examples/data/two_cpt_oral_cov.csv
#> 10 columns
#> 
#>  Required NONMEM:      [1] ID  [2] TIME  [3] DV  [4] EVID  [5] AMT  [6] CMT
#>  Optional NONMEM:      [7] RATE  [8] MDV
#>  Covariates / other:   [9] WT  [10] CRCL

It accepts the list returned by ferx_example(), a path, or a fitted model (it then reads fit$data_path). Read the full file with read.csv(). . marks an empty cell:

pk <- read.csv(ex$data, na.strings = ".")
head(pk, 12)
#>    ID TIME     DV EVID AMT CMT RATE MDV   WT CRCL
#> 1   1  0.0     NA    1 250   1    0   1 70.6 73.7
#> 2   1  0.5 3.5262    0  NA   1    0   0 70.6 73.7
#> 3   1  1.0 4.1950    0  NA   1    0   0 70.6 73.7
#> 4   1  2.0 3.3759    0  NA   1    0   0 70.6 73.7
#> 5   1  4.0 2.1090    0  NA   1    0   0 70.6 73.7
#> 6   1  6.0 1.4915    0  NA   1    0   0 70.6 73.7
#> 7   1  8.0 1.1890    0  NA   1    0   0 70.6 73.7
#> 8   1 12.0 1.0018    0  NA   1    0   0 70.6 73.7
#> 9   1 24.0 0.6862    0  NA   1    0   0 70.6 73.7
#> 10  1 36.0 0.4900    0  NA   1    0   0 70.6 73.7
#> 11  1 48.0 0.3546    0  NA   1    0   0 70.6 73.7
#> 12  2  0.0     NA    1 250   1    0   1 80.9 80.7

The column layout

The standard columns and their meaning (details: ferx-core data format):

Column Meaning
ID Subject identifier
TIME Event time. It need not start at zero
DV Observed value on observation rows
EVID Event type: 0 observation, 1 dose, 2 other event, 3 system reset, 4 reset and dose. If the column is absent, rows with a nonzero AMT are treated as doses
AMT Dose amount
CMT Compartment the dose goes into or the observation belongs to
RATE 0 for a bolus, > 0 for an infusion at that rate
MDV 1 = the row’s DV is not used in the likelihood
II Interdose interval, used with SS and ADDL
SS Steady-state flag
ADDL Number of additional doses at interval II
CENS 1 = below the lower limit (DV holds the limit), -1 = above the upper limit, 0 = quantified

two_cpt_oral_cov uses only the first eight, plus two covariates. Other bundled datasets show the remaining columns in use, and the scenario chapters cover each one:

Table 3.1: Standard columns beyond the basic eight in some bundled datasets.
basic <- c("ID", "TIME", "DV", "EVID", "AMT", "CMT", "RATE", "MDV")
examples <- c("warfarin_ss", "warfarin_addl", "warfarin_bloq", "warfarin_iov")
data.frame(
  example = examples,
  extra_columns = vapply(examples, function(e) {
    paste(setdiff(names(read.csv(ferx_example(e)$data, nrows = 1)), basic), collapse = ", ")
  }, character(1)),
  row.names = NULL
)
#>         example extra_columns
#> 1   warfarin_ss        SS, II
#> 2 warfarin_addl      II, ADDL
#> 3 warfarin_bloq          CENS
#> 4  warfarin_iov           OCC

SS, II and ADDL are covered in Dosing regimens and exposure metrics, CENS in Censored observations (BLOQ), and the occasion column OCC in Variability: random effects, residual error and IOV.

Two rules about row order and missing values matter when you build a dataset (details on the ferx-core data format page):

  • When an observation and a dose share a TIME, the row order decides: an observation listed before the dose is a pre-dose trough.
  • An observation row (EVID = 0) with an empty DV is skipped rather than scored as zero, and ferx warns about it. Set MDV = 1 to mark such rows explicitly.

Checking the dataset

A few lines of dplyr answer the basic structural questions:

pk |>
  group_by(ID) |>
  summarise(doses = sum(EVID == 1), observations = sum(EVID == 0),
            first_time = min(TIME), last_time = max(TIME)) |>
  summary()
#>        ID            doses    observations   first_time   last_time 
#>  Min.   : 1.00   Min.   :1   Min.   :10    Min.   :0    Min.   :48  
#>  1st Qu.: 8.25   1st Qu.:1   1st Qu.:10    1st Qu.:0    1st Qu.:48  
#>  Median :15.50   Median :1   Median :10    Median :0    Median :48  
#>  Mean   :15.50   Mean   :1   Mean   :10    Mean   :0    Mean   :48  
#>  3rd Qu.:22.75   3rd Qu.:1   3rd Qu.:10    3rd Qu.:0    3rd Qu.:48  
#>  Max.   :30.00   Max.   :1   Max.   :10    Max.   :0    Max.   :48

Every one of the 30 subjects has 1 dose and 10 observations. The observed concentrations range from 0.1185 to 5.4514. Missing DV values occur only on dose rows:

pk |> filter(is.na(DV)) |> count(EVID, MDV)
#>   EVID MDV  n
#> 1    1   1 30

Plot the profiles before any modeling. On a log scale the two-phase decline is already visible:

pk |>
  filter(EVID == 0) |>
  ggplot(aes(TIME, DV, group = ID)) +
  geom_line(alpha = 0.4) +
  geom_point(size = 0.8) +
  scale_y_log10() +
  labs(x = "Time (h)", y = "Concentration")
Figure 3.1: Observed concentrations for the 30 subjects (log scale).

Covariates

WT and CRCL are covariates. Check whether they are constant within subjects, then summarise them one row per subject:

pk |>
  group_by(ID) |>
  summarise(n_wt = n_distinct(WT), n_crcl = n_distinct(CRCL)) |>
  summarise(max(n_wt), max(n_crcl))
#> # A tibble: 1 × 2
#>   `max(n_wt)` `max(n_crcl)`
#>         <int>         <int>
#> 1           1             1

subjects <- pk |> distinct(ID, WT, CRCL)
summary(subjects[, c("WT", "CRCL")])
#>        WT             CRCL       
#>  Min.   :45.00   Min.   : 46.50  
#>  1st Qu.:60.73   1st Qu.: 73.47  
#>  Median :70.85   Median : 82.90  
#>  Mean   :70.43   Mean   : 86.11  
#>  3rd Qu.:80.55   3rd Qu.:102.17  
#>  Max.   :93.70   Max.   :150.00
ggplot(subjects, aes(WT, CRCL)) +
  geom_point() +
  labs(x = "Body weight (kg)", y = "Creatinine clearance")
Figure 3.2: Body weight and creatinine clearance, one point per subject.

The model file for this dataset declares both columns in a [covariates] block. Without such a block, every non-standard column is treated as a covariate automatically. Covariate modeling covers covariate modeling.

Excluding records

WarningMaturity: beta

ferx-core labels data selection beta. See feature maturity.

You often need to leave records out of an analysis: samples below a threshold, a time window, or whole subjects. ferx does this at read time, so the CSV file stays unchanged, and it reports exactly what was excluded. Conditions are comparisons on column names (==, !=, <, <=, >, >=), with && to combine terms within one condition:

  • ignore: a record is excluded when any condition is true.
  • accept: a record is kept only when all conditions are true.
  • ignore_ids: all records of these subjects are excluded.

Preview the selection in R

ferx_apply_selection() shows what a selection would do before you fit. It returns the retained records as a ferx_data object, and print() on it summarises the exclusions. As an example, exclude observations below 0.2 and, suppose, subjects 3 and 7 after a data review:

sel <- ferx_apply_selection(ex$data, ignore = "DV < 0.2", ignore_ids = c(3, 7))
sel
#> <ferx_data>  300 of 330 records retained  (28 obs, 2 doses, 0 other excluded)
#>   Fired ignore: DV < 0.2
#>   Subjects excluded: 3, 7
#>   Pass excluded = TRUE to ferx_apply_selection() to inspect excluded records.
#> 
#>   ID TIME     DV EVID AMT CMT RATE MDV   WT CRCL
#> 1  1  0.0      .    1 250   1    0   1 70.6 73.7
#> 2  1  0.5 3.5262    0   .   1    0   0 70.6 73.7
#> 3  1  1.0 4.1950    0   .   1    0   0 70.6 73.7
#> 4  1  2.0 3.3759    0   .   1    0   0 70.6 73.7
#> 5  1  4.0 2.1090    0   .   1    0   0 70.6 73.7
#> 6  1  6.0 1.4915    0   .   1    0   0 70.6 73.7
#>   ... 294 more rows

With excluded = TRUE you get the excluded records instead, each with the first rule that removed it:

dropped <- ferx_apply_selection(sel, excluded = TRUE)
dropped |> count(.exclude_reason)
#>      .exclude_reason  n
#> 1   ignore: DV < 0.2  8
#> 2 ignore_subjects: 3 11
#> 3 ignore_subjects: 7 11

An accept condition states what to keep. This one keeps only the first 24 hours:

ferx_apply_selection(ex$data, accept = "TIME <= 24")
#> <ferx_data>  270 of 330 records retained  (60 obs, 0 doses, 0 other excluded)
#>   Fired accept: TIME <= 24
#>   Pass excluded = TRUE to ferx_apply_selection() to inspect excluded records.
#> 
#>   ID TIME     DV EVID AMT CMT RATE MDV   WT CRCL
#> 1  1  0.0      .    1 250   1    0   1 70.6 73.7
#> 2  1  0.5 3.5262    0   .   1    0   0 70.6 73.7
#> 3  1  1.0 4.1950    0   .   1    0   0 70.6 73.7
#> 4  1  2.0 3.3759    0   .   1    0   0 70.6 73.7
#> 5  1  4.0 2.1090    0   .   1    0   0 70.6 73.7
#> 6  1  6.0 1.4915    0   .   1    0   0 70.6 73.7
#>   ... 264 more rows

Select at fit time

The fit applies the same selection in the engine. Pass the conditions to ferx_fit() directly, or pass the previewed ferx_data object as data:

fit_sel <- ferx_fit(ex$model, ex$data, ignore = "DV < 0.2", ignore_ids = c(3, 7), verbose = FALSE)
str(fit_sel$exclusions)
#> List of 7
#>  $ n_records_total     : int 330
#>  $ n_obs_excluded      : int 28
#>  $ n_dose_excluded     : int 2
#>  $ n_other_excluded    : int 0
#>  $ excluded_subject_ids: chr [1:2] "3" "7"
#>  $ fired_ignore        : chr [1:3] "ignore_subjects: 3" "ignore: DV < 0.2" "ignore_subjects: 7"
#>  $ fired_accept        : chr(0)
fit_sel2 <- ferx_fit(ex$model, sel, verbose = FALSE)
c(conditions = fit_sel$ofv, ferx_data = fit_sel2$ofv)
#> conditions  ferx_data 
#>  -1044.557  -1044.557

fit$exclusions records how many observation, dose and other records were dropped, which subjects were dropped entirely, and which rules fired. The same conditions can also go through settings as ignore, accept and ignore_subjects.

Select in the model file

A selection that belongs to the analysis itself can live in the model file, in a [data_selection] block. The bundled warfarin_data_selection model drops observations below 1.0:

ws <- ferx_example("warfarin_data_selection")
ferx_model_get_section(ws$model, "data_selection")
#> # [data_selection]
#>   # Drop observations below a surrogate LLOQ of 1.0 mg/L.
#>   ignore = DV < 1.0
fit_ws <- ferx_fit(ws$model, ws$data, verbose = FALSE)
fit_ws$exclusions$fired_ignore
#> [1] "ignore: DV < 1.0"
fit_ws$exclusions$n_obs_excluded
#> [1] 2

ferx_apply_selection() on a fit replays the rules that fired, so you can inspect the excluded rows afterwards:

ferx_apply_selection(fit_ws, excluded = TRUE)
#>    ID TIME     DV EVID AMT CMT RATE MDV  .exclude_reason
#> 84  7  120 0.9761    0   .   1    0   0 ignore: DV < 1.0
#> 96  8  120 0.8700    0   .   1    0   0 ignore: DV < 1.0

Conditions given in the ferx_fit() call are merged with those in [data_selection], not replacing them.

Naming the dataset in the model file

WarningMaturity: beta

ferx-core labels the [data] block beta. See feature maturity.

A [data] block lets a model file name its own dataset, so that model and data travel together. A relative path is resolved from the model file’s directory. None of the bundled models has one, so add one to a copy:

run_dir <- book_tempdir("data-block")
invisible(file.copy(ex$data, file.path(run_dir, "pk.csv"), overwrite = TRUE))
model_with_data <- file.path(run_dir, "two_cpt_oral_cov.ferx")
writeLines(c(readLines(ex$model), "", "[data]", "  path = pk.csv"), model_with_data)

fit_db <- ferx_fit(model_with_data, verbose = FALSE)
basename(fit_db$data_path)
#> [1] "pk.csv"

ferx_fit() was called without data, so the file named in [data] was used. An explicit data argument takes precedence over the block. [data] can also rename columns whose headers differ from the standard names. See the ferx-core [data] block page.

Options that matter

Function Argument Meaning
ferx_get_columns() data Path, ferx_example() list or fit
ferx_apply_selection() data Path, data frame, ferx_data object or fit
ignore Exclude a record when any condition is true
accept Keep a record only when all conditions are true
ignore_ids Subject IDs to exclude entirely
excluded TRUE returns the excluded records with .exclude_reason
ferx_fit() ignore, accept, ignore_ids The same selection, applied by the engine and merged with [data_selection]
settings ignore, accept, ignore_subjects Setting-based spelling of the same selection

In a model file the same selection is written in [data_selection], and the dataset path in [data].

Pitfalls

The R preview is lenient; the fit is strict. ferx_apply_selection() treats a condition on an unknown column as never matching. It excludes nothing and says nothing. The fit applies the same condition but warns. Here CONC is a misspelling of DV:

attr(ferx_apply_selection(ex$data, ignore = "CONC < 0.2"), "exclusions")$n_obs_excluded
#> [1] 0
fit_typo <- ferx_fit(ex$model, ex$data, ignore = "CONC < 0.2", verbose = FALSE)
typo_warning <- ferx_get_warnings(fit_typo, as_df = TRUE)
typo_warning <- typo_warning[grepl("FILTER", typo_warning$message), ]
typo_warning$severity
#> [1] "warning"
strwrap(typo_warning$message, width = 80)
#> [1] "W_FILTER_COLUMN_ABSENT: data-selection filter references column(s) not found in"
#> [2] "the data: conc. These conditions never match, so no rows are excluded for them" 
#> [3] "— check for a typo in the column name. Available columns: ID, TIME, DV, EVID,"  
#> [4] "AMT, CMT, RATE, MDV, WT, CRCL."

Always check fit$exclusions (or the warnings) after fitting with a selection.

Other points:

  • Empty cells never match a comparison. DV < 0.2 does not exclude dose rows, whose DV is empty.
  • || is not supported within a condition. Use several conditions instead, since any matching ignore condition excludes the record.
  • Conditions on ADDL or on the occasion column have no effect.

Warnings you may see here

  • data_quality (warning): the dataset has a problem, such as a missing DV, inconsistent ADDL/II records or a non-positive DV. Fix the data before trusting the fit. See dataset and model structure warnings.

Summary

  • Check the header with ferx_get_columns(), then check structure, missing values, profiles and covariates with ordinary R before modeling.
  • Preview exclusions with ferx_apply_selection(). Apply them with ferx_fit(ignore, accept, ignore_ids) or in [data_selection], and confirm them in fit$exclusions.
  • [data] lets a model name its dataset. An explicit data argument wins.

Next: Writing and managing model files writes the model for this dataset.

TipReference