15  Concentration Imputation

15.1 Why imputation is needed

NCA intervals are defined by a start and end time. For AUC and related parameters, PKNCA needs a concentration at the exact interval start. Two common situations require imputation:

  1. No t = 0 sample. The earliest measured concentration is after dosing, so there is no observed value at the interval start (time 0).
  2. Pre-dose sample. A concentration was measured before the nominal dose time (e.g. a residual carry-over sample). The interval starts at time 0 but the only nearby observation has a negative relative time.

Imputation is applied just before each individual interval is calculated, on a per-subject, per-interval basis. The original data frame is never modified. The package’s Data Imputation vignette describes the same mechanism from the implementation side.

15.1.1 Example data: Theoph with t = 0 removed

Theoph_df <- as.data.frame(Theoph)

# Remove the t = 0 observation to create a missing-baseline scenario
d_conc_no0 <- Theoph_df |> filter(Time > 0)
d_dose      <- Theoph_df |>
  filter(Time == 0) |>
  mutate(amt = Dose * Wt)

# Without imputation there is no concentration at the interval start
o_conc <- PKNCAconc(d_conc_no0, conc ~ Time | Subject)
o_dose <- PKNCAdose(d_dose, amt ~ Time | Subject)
# What does subject 1's profile look like?
d_conc_no0 |>
  filter(Subject == "1") |>
  head(5)
  Subject   Wt Dose Time  conc
1       1 79.6 4.02 0.25  2.84
2       1 79.6 4.02 0.57  6.57
3       1 79.6 4.02 1.12 10.50
4       1 79.6 4.02 2.02  9.66
5       1 79.6 4.02 3.82  8.58

15.2 The three built-in start-of-interval strategies

You can inspect the signatures with args():

args(PKNCA_impute_method_start_conc0)
function (conc, time, start = 0, ..., options = list()) 
NULL
args(PKNCA_impute_method_start_predose)
function (conc, time, start, end, conc.group, time.group, ..., 
    max_shift = NA_real_, options = list()) 
NULL
args(PKNCA_impute_method_start_cmin)
function (conc, time, start, end, ..., options = list()) 
NULL
Name String to use What it does
PKNCA_impute_method_start_conc0 "start_conc0" Inserts a concentration of 0 at the interval start time
PKNCA_impute_method_start_predose "start_predose" Uses the last observed concentration before the interval start
PKNCA_impute_method_start_cmin "start_cmin" Uses the minimum concentration observed within the interval

All three built-in methods impute a concentration at the start of the interval; newer development versions of PKNCA also provide PKNCA_impute_method_end_conc_drop, which instead drops a next-dose sample observed exactly at the interval end (not shown here, as it is not yet in a released version).

15.2.1 start_conc0 — zero concentration at interval start

Appropriate in a single-dose setting when no drug is expected before dosing. The drug concentration at time 0 is assumed to be 0.

o_data_c0 <- PKNCAdata(o_conc, o_dose, impute = "start_conc0")
o_data_c0$impute   # verify the impute string is stored
[1] "start_conc0"
res_c0 <- pk.nca(o_data_c0)

as.data.frame(res_c0) |>
  filter(Subject == "1",
                PPTESTCD %in% c("auclast", "cmax", "tmax")) |>
  select(Subject, PPTESTCD, PPORRES)
# A tibble: 3 × 3
  Subject PPTESTCD PPORRES
  <ord>   <chr>      <dbl>
1 1       auclast    92.3 
2 1       cmax       10.5 
3 1       tmax        1.12

15.2.2 start_predose — last pre-dose concentration

Appropriate in multiple-dose or steady-state settings where a true pre-dose (trough) sample was collected just before administration. The method looks outside the current interval for the most recent observed concentration. The max_shift argument caps how far forward that pre-dose sample may be shifted to the interval start: it defaults to 5% of the interval duration (0.05 * (end - start)), or, when end is infinite, 5% of the time from start to the last sample.

o_data_pd <- PKNCAdata(o_conc, o_dose, impute = "start_predose")
o_data_pd$impute
[1] "start_predose"

15.2.3 start_cmin — minimum concentration in interval

Appropriate as a conservative fallback: if no pre-dose value is available and zero cannot be assumed (e.g. metabolite or endogenous compound), use the minimum observed concentration in the interval as a lower-bound estimate.

o_data_cmin <- PKNCAdata(o_conc, o_dose, impute = "start_cmin")
o_data_cmin$impute
[1] "start_cmin"

15.2.4 The three strategies side by side

Running all three on subject 1 (whose first sample is at t = 0.25) makes the differences concrete:

d1_conc <- d_conc_no0 |> filter(Subject == "1")
d1_dose <- d_dose |> filter(Subject == "1")
o_conc1 <- PKNCAconc(d1_conc, conc ~ Time | Subject)
o_dose1 <- PKNCAdose(d1_dose, amt ~ Time | Subject)
ivl1    <- data.frame(start = 0, end = Inf, auclast = TRUE)

auclast_with <- function(method) {
  res <- as.data.frame(pk.nca(PKNCAdata(o_conc1, o_dose1, intervals = ivl1,
                                        impute = method)))
  data.frame(impute = method, auclast = res$PPORRES[res$PPTESTCD == "auclast"])
}

bind_rows(auclast_with("start_conc0"),
                 auclast_with("start_predose"),
                 auclast_with("start_cmin"))
Warning: Requesting an AUC range starting (0) before the first measurement
(0.25) is not allowed
         impute  auclast
1   start_conc0 147.1422
2 start_predose       NA
3    start_cmin 147.4972

start_conc0 adds conc = 0 at t = 0, while start_cmin adds the minimum concentration observed in the interval (here 2.84 mg/L, the first sample) — so start_cmin gives the slightly larger AUC. start_predose looks for a sample at or before the interval start, and this dataset has none: it imputes nothing, the interval still has no concentration at its start time, and auclast is NA (the warning printed by the chunk explains why).


15.3 Applying imputation globally

The single-strategy examples above already used the global form: pass a method name string to the impute argument of PKNCAdata(), and the same strategy is applied to every subject and every interval — the side-by-side comparison restricted itself to subject 1 only to keep the output small.

o_data_global <- PKNCAdata(o_conc, o_dose, impute = "start_conc0")

res_global <- pk.nca(o_data_global)
df_global  <- as.data.frame(res_global)

# auclast is populated for all subjects despite the missing t = 0
# (arrange by subject number — Theoph's factor levels are not in numeric order)
df_global |>
  filter(PPTESTCD == "auclast") |>
  select(Subject, PPORRES) |>
  arrange(as.numeric(as.character(Subject))) |>
  head(6)
# A tibble: 6 × 2
  Subject PPORRES
  <ord>     <dbl>
1 1          92.3
2 2          67.2
3 3          70.6
4 4          72.8
5 5          84.4
6 6          71.7

15.4 Chaining methods

The side-by-side comparison left start_predose with an NA because this dataset has no pre-dose sample. Chaining resolves exactly that situation: provide a comma-separated string to apply methods in sequence. Methods are applied in order; a later method has no effect if an earlier one already added a point at the interval start.

# Try start_predose first; if it cannot find a pre-dose observation,
# fall back to inserting zero
o_data_chain <- PKNCAdata(o_conc, o_dose,
                          impute = "start_predose,start_conc0")
o_data_chain$impute
[1] "start_predose,start_conc0"
res_chain <- pk.nca(o_data_chain)

as.data.frame(res_chain) |>
  filter(Subject == "1", PPTESTCD == "auclast") |>
  select(Subject, PPTESTCD, PPORRES)
# A tibble: 1 × 3
  Subject PPTESTCD PPORRES
  <ord>   <chr>      <dbl>
1 1       auclast     92.3

For subject 1 the chained result matches the global start_conc0 run in the previous section — the same auclast (92.3) appears in the first row of both displays: start_predose found no pre-dose sample to carry forward, so the fallback inserted the zero.


15.5 Per-interval imputation

Different intervals in the same dataset can use different strategies. Add an impute column to the intervals data frame, then pass the column name (as a string) to the impute argument of PKNCAdata(). The same setup is shown in the vignette’s per-interval section.

my_intervals <- data.frame(
  start  = c(0,   0),
  end    = c(24,  Inf),
  auclast = c(TRUE,  FALSE),
  aucinf.obs = c(FALSE, TRUE),
  cmax   = c(TRUE,  TRUE),
  impute = c("start_conc0", "start_predose,start_conc0")
)

o_data_per_interval <- PKNCAdata(
  o_conc, o_dose,
  intervals = my_intervals,
  impute    = "impute"         # name of the column to read
)

o_data_per_interval$impute
[1] "impute"
o_data_per_interval$intervals[, c("start", "end", "impute")]
  start end                    impute
1     0  24               start_conc0
2     0 Inf start_predose,start_conc0
res_per <- pk.nca(o_data_per_interval)

as.data.frame(res_per) |>
  filter(Subject == "1",
                PPTESTCD %in% c("auclast", "aucinf.obs", "cmax")) |>
  select(Subject, start, end, PPTESTCD, PPORRES)  # start/end distinguish the two cmax rows
# A tibble: 4 × 5
  Subject start   end PPTESTCD   PPORRES
  <ord>   <dbl> <dbl> <chr>        <dbl>
1 1           0    24 auclast       92.3
2 1           0    24 cmax          10.5
3 1           0   Inf cmax          10.5
4 1           0   Inf aucinf.obs   215. 

15.6 Writing a custom imputation function

15.6.1 Naming convention

The function name must follow the pattern PKNCA_impute_method_<name>. PKNCA prepends PKNCA_impute_method_ to the string you supply and looks up that single function on the search path, so the convention is mandatory.

15.6.2 Required signature

Modeled after the built-in functions:

# Minimal required arguments (from args(PKNCA_impute_method_start_conc0)):
PKNCA_impute_method_<name> <- function(conc, time, start = 0, ..., options = list())

The function receives:

Argument Description
conc Numeric vector of concentrations for the current subject/interval
time Numeric vector of times (same length as conc)
start Interval start time
... Additional arguments (must be accepted and ignored)
options Named list of PKNCA options

15.6.3 Return value

A data frame with exactly two columns, conc and time, sorted by time. It should include all original observations plus any imputed points.

15.6.4 Example: impute with the mean of the first two observations

PKNCA_impute_method_start_mean2 <- function(conc, time, start = 0, ...,
                                             options = list()) {
  # Only impute if the interval start is not already observed
  if (!start %in% time) {
    # Use the mean of the first two observed concentrations
    imputed_conc <- mean(conc[order(time)][seq_len(min(2, length(conc)))])
    conc <- c(imputed_conc, conc)
    time <- c(start, time)
  }
  result <- data.frame(conc = conc, time = time)
  result[order(result$time), ]
}
# Use the custom method by its suffix
o_data_custom <- PKNCAdata(o_conc, o_dose, impute = "start_mean2")
res_custom     <- pk.nca(o_data_custom)

as.data.frame(res_custom) |>
  filter(Subject == "1", PPTESTCD == "auclast") |>
  select(Subject, PPTESTCD, PPORRES)
# A tibble: 1 × 3
  Subject PPTESTCD PPORRES
  <ord>   <chr>      <dbl>
1 1       auclast     92.8

The vignette’s advanced section shows another worked example.


15.7 When to use each strategy

Situation Recommended strategy
Single-dose study, no pre-dose drug expected start_conc0
Multiple-dose or steady-state, trough sample collected start_predose
Endogenous analyte or metabolite, zero implausible start_cmin
Uncertain: try pre-dose, fall back to zero "start_predose,start_conc0"
Non-standard logic required Custom PKNCA_impute_method_* function

15.8 Summary

  • Imputation is triggered when no observation exists at the interval start time.
  • The three built-in start-of-interval methods are referenced by their suffix strings: "start_conc0", "start_predose", "start_cmin".
  • Global imputation: PKNCAdata(..., impute = "start_conc0").
  • Chained methods: impute = "start_predose,start_conc0".
  • Per-interval: add an impute column to the intervals data frame and pass that column name to the impute argument of PKNCAdata().
  • Custom functions: name them PKNCA_impute_method_<name> and return a two-column data frame (conc, time).