12  Sparse PK Sampling

12.1 What is sparse PK?

In dense PK, each subject contributes a full concentration-time profile (multiple samples per subject). In sparse PK, each subject contributes only one or a few samples, and the population-level profile is reconstructed by pooling across subjects.

Sparse sampling is common in:

  • Preclinical toxicokinetic studies — serial sacrifice designs where each animal is sampled at only one timepoint
  • Pediatric or special population studies — where blood volume limits the number of draws per patient
  • Large observational studies — with opportunistic sampling

PKNCA handles sparse PK by computing a mean concentration-time profile across all subjects at each timepoint, then running NCA on that mean profile, along with a standard error estimate from the Nedelman & Jia (1998) method. See also the package vignette Sparse NCA Calculations.


12.2 Dataset: serial sacrifice design

We construct a synthetic dataset following the Holder et al. (1999) design: 7 nominal timepoints (including pre-dose t = 0), 3 subjects sampled at each timepoint (21 subjects total, 1 sample each).

set.seed(42)

# True PK parameters (oral, 1-compartment)
ka  <- 1.5   # h⁻¹ absorption rate
kel <- 0.25  # h⁻¹ elimination rate
dose <- 100  # mg
V   <- 50    # L

pk_conc <- function(t, ka, kel, dose, V) {
  (dose / V) * (ka / (ka - kel)) * (exp(-kel * t) - exp(-ka * t))
}

timepoints <- c(0, 0.5, 1, 2, 4, 8, 24)  # include t=0 (pre-dose, conc=0)
n_per_time <- 3

d_sparse <- lapply(timepoints, function(t) {
  true_c <- pk_conc(t, ka, kel, dose, V)
  data.frame(
    time    = t,
    conc    = if (t == 0) rep(0, n_per_time)
              else pmax(0, rnorm(n_per_time, mean = true_c, sd = true_c * 0.25)),
    Subject = paste0("S", sprintf("%02d", which(timepoints == t) * 10 + seq_len(n_per_time)))
  )
}) |> bind_rows()

head(d_sparse, 12)
   time      conc Subject
1   0.0 0.0000000     S11
2   0.0 0.0000000     S12
3   0.0 0.0000000     S13
4   0.5 1.3216758     S21
5   0.5 0.8453529     S22
6   0.5 1.0736708     S23
7   1.0 1.5446074     S31
8   1.0 1.4683935     S32
9   1.0 1.2982273     S33
10  2.0 1.8411027     S41
11  2.0 1.3045641     S42
12  2.0 2.0104313     S43
ggplot(d_sparse, aes(x = time, y = conc)) +
  geom_point(aes(colour = Subject), size = 3, show.legend = FALSE) +
  stat_summary(fun = mean, geom = "line", linewidth = 1, colour = "black") +
  stat_summary(fun = mean, geom = "point", size = 4, colour = "black", shape = 18) +
  labs(
    title = "Sparse PK — individual samples (colored) and mean profile (black)",
    x = "Time (h)", y = "Concentration (mg/L)"
  ) +
  theme_minimal()


12.3 The sparse workflow

The main differences from standard NCA are sparse = TRUE in PKNCAconc() and requesting the sparse_-prefixed interval parameters.

# Step 1: concentration object — flag as sparse
o_conc <- PKNCAconc(d_sparse, conc ~ time | Subject, sparse = TRUE)

# Step 2: dose object — one row per subject (all received the same dose at t=0)
d_dose <- data.frame(Subject = unique(d_sparse$Subject), dose = dose, time = 0)
o_dose <- PKNCAdose(d_dose, dose ~ time | Subject, route = "extravascular")

# Step 3: data object with sparse interval
o_data <- PKNCAdata(
  o_conc, o_dose,
  intervals = data.frame(
    start          = 0,
    end            = Inf,
    sparse_auclast = TRUE
  )
  # no imputation needed — t=0 pre-dose samples are in the dataset
)

# Step 4: run
o_nca <- pk.nca(o_data)
as.data.frame(o_nca)
# A tibble: 3 × 6
  start   end PPTESTCD       PPORRES PPANMETH exclude
  <dbl> <dbl> <chr>            <dbl> <chr>    <chr>  
1     0   Inf sparse_auclast  10.4   ""       <NA>   
2     0   Inf sparse_auc_se    0.644 ""       <NA>   
3     0   Inf sparse_auc_df    5.58  ""       <NA>   

PKNCA returns three values for sparse AUC:

Parameter Meaning
sparse_auclast Mean AUC from 0 to last measurable time (Nedelman & Jia method)
sparse_auc_se Standard error of the AUC estimate
sparse_auc_df Degrees of freedom for confidence interval construction

12.4 How PKNCA computes sparse AUC

  1. At each timepoint, compute the mean concentration across subjects (pooling all subjects sampled at that time)
  2. Apply the linear trapezoidal rule to the mean profile to get AUClast (sparse AUC uses linear weights — see sparse_auc_weight_linear — as required by the Nedelman–Jia variance)
  3. Estimate variance using the Nedelman & Jia (1998) formula, accounting for the covariance between AUC trapezoids via the Holder (2001) method (full citations are in the vignette)
  4. Return AUClast ± SE and degrees of freedom
# Inspect the mean profile PKNCA uses internally
mean_profile <- d_sparse |>
  group_by(time) |>
  summarise(mean_conc = mean(conc), n = n(), se = sd(conc) / sqrt(n()), .groups = "drop")

mean_profile
# A tibble: 7 × 4
   time mean_conc     n      se
  <dbl>     <dbl> <int>   <dbl>
1   0     0           3 0      
2   0.5   1.08        3 0.138  
3   1     1.44        3 0.0728 
4   2     1.72        3 0.213  
5   4     1.13        3 0.149  
6   8     0.276       3 0.0322 
7  24     0.00481     3 0.00146

12.5 BLQ handling in sparse PK

sparse = TRUE affects how below-the-limit-of-quantification (BLQ) values (coded as 0) are treated at each timepoint before averaging. The default BLQ treatment is controlled by PKNCA.options("conc.blq") and applies before the mean is computed.

By default the mean profile uses the arithmetic mean with up-to-50%-BLQ handling ("arithmetic mean, <=50% BLQ"; see ?sparse_mean); the method is not a user-facing option. The two internal methods:

  • "arithmetic mean" — simple mean of all values including BLQs as 0
  • "arithmetic mean, <=50% BLQ" — if strictly more than 50% of subjects at a timepoint are BLQ, the mean concentration at that timepoint is set to 0 (at exactly 50% BLQ the mean is not zeroed)

The second method is conservative: timepoints with majority-BLQ results don’t pull the mean above zero.


12.6 Serial sacrifice vs. repeated sampling

How subjects are grouped for sparse calculations is described in the vignette’s grouping section.

# Serial sacrifice: each subject contributes EXACTLY 1 sample
d_serial <- d_sparse  # our example above — each subject is at one timepoint only

# Mixed design: some subjects contribute multiple samples
# (PKNCA handles this too — subjects can appear at multiple times)
d_mixed <- bind_rows(
  d_sparse,
  data.frame(time = 4, conc = 0.8, Subject = "S11"),  # S11 (t=0 in d_sparse) now has 3 samples: 0, 4, 8
  data.frame(time = 8, conc = 0.3, Subject = "S11")
)

d_dose_mixed <- data.frame(Subject = unique(d_mixed$Subject), dose = dose, time = 0)
o_dose_mixed  <- PKNCAdose(d_dose_mixed, dose ~ time | Subject, route = "extravascular")
o_conc_mixed  <- PKNCAconc(d_mixed, conc ~ time | Subject, sparse = TRUE)
o_data_mixed  <- PKNCAdata(
  o_conc_mixed, o_dose_mixed,
  intervals = data.frame(start = 0, end = Inf, sparse_auclast = TRUE)
)
o_nca_mixed <- pk.nca(o_data_mixed)
as.data.frame(o_nca_mixed)
# A tibble: 3 × 6
  start   end PPTESTCD       PPORRES PPANMETH exclude
  <dbl> <dbl> <chr>            <dbl> <chr>    <chr>  
1     0   Inf sparse_auclast  10.2   ""       <NA>   
2     0   Inf sparse_auc_se    0.573 ""       <NA>   
3     0   Inf sparse_auc_df    7.13  ""       <NA>   

Limitation: sparse_auc_df is NA when any subject contributes more than one sample per interval. This is a known current limitation of the Nedelman & Jia variance estimation in PKNCA.


12.7 Constructing a confidence interval

With AUC, SE, and df you can construct a t-based confidence interval:

res <- as.data.frame(o_nca) |>
  tidyr::pivot_wider(names_from = PPTESTCD, values_from = PPORRES)

auc <- res$sparse_auclast
se  <- res$sparse_auc_se
df  <- res$sparse_auc_df

if (!is.na(df) && !is.na(se)) {
  ci_lo <- auc - qt(0.975, df) * se
  ci_hi <- auc + qt(0.975, df) * se
  cat(sprintf("AUClast = %.2f  95%% CI: [%.2f, %.2f]\n", auc, ci_lo, ci_hi))
} else {
  cat(sprintf("AUClast = %.2f  SE = %.2f  (df unavailable for CI)\n", auc, se))
}
AUClast = 10.40  95% CI: [8.80, 12.00]

12.8 Key differences from dense NCA

Aspect Dense PK Sparse PK
PKNCAconc flag sparse = FALSE (default) sparse = TRUE
Profile per subject Full individual profile 1–few samples only
NCA run on Each subject’s own profile Pooled mean profile
Result One row per subject per parameter One row per parameter (population level)
Half-life ✓ (per subject) ✗ (not available)
AUClast ✓ (sparse_auclast)
SE of AUC ✓ (sparse_auc_se)
Interval col name auclast sparse_auclast

12.9 Available sparse parameters

cols <- get.interval.cols()
sparse_params <- Filter(function(x) isTRUE(x$sparse), cols)
data.frame(
  parameter   = names(sparse_params),
  description = sapply(sparse_params, `[[`, "desc")
) |> knitr::kable()
parameter description
sparse_auclast sparse_auclast For sparse PK sampling, the area under the concentration time curve from the beginning of the interval to the last concentration above the limit of quantification
sparse_aumclast sparse_aumclast For sparse PK sampling, the area under the moment curve from the beginning of the interval to the last concentration above the limit of quantification
cl.sparse.last cl.sparse.last Clearance from sparse sampling calculated with population AUClast
mrt.sparse.last mrt.sparse.last Mean residence time from sparse sampling
vss.sparse.last vss.sparse.last Steady-state volume of distribution from sparse sampling
kel.sparse.last kel.sparse.last Elimination rate (as calculated from the MRTsparse.last)
vz.sparse.last vz.sparse.last Terminal volume of distribution from sparse sampling

12.10 Sparse AUMC and derived parameters (≥ 0.12.2)

PKNCA ≥ 0.12.2 adds sparse AUMC (area under the first-moment curve) and five new derived parameters, enabling MRT, clearance, and volume estimation from sparse data.

12.10.1 Sparse AUMC

Parameter Description
sparse_aumclast AUMC from the pooled mean sparse profile (0 → last measurable)
sparse_aumc_se Standard error of the sparse AUMC estimate
sparse_aumc_df Degrees of freedom for the AUMC variance estimate

Requesting sparse_aumclast = TRUE automatically computes sparse_auclast, sparse_auc_se, sparse_auc_df, sparse_aumc_se, and sparse_aumc_df as dependencies.

sparse_aumc_interval <- data.frame(
  start           = 0,
  end             = Inf,
  sparse_aumclast = TRUE   # AUC and SE are computed automatically as dependencies
)

o_nca_sparse_aumc <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = sparse_aumc_interval))

as.data.frame(o_nca_sparse_aumc) |>
  select(PPTESTCD, PPORRES)
# A tibble: 6 × 2
  PPTESTCD        PPORRES
  <chr>             <dbl>
1 sparse_auclast   10.4  
2 sparse_auc_se     0.644
3 sparse_auc_df     5.58 
4 sparse_aumclast  43.1  
5 sparse_aumc_se    3.21 
6 sparse_aumc_df    3.92 

12.10.2 Sparse-derived PK parameters

Once sparse AUC and AUMC are available, PKNCA can compute additional population-level parameters:

Parameter Description
mrt.sparse.last Mean residence time from sparse sampling
cl.sparse.last Clearance: dose / sparse AUClast
kel.sparse.last Elimination rate constant: 1 / MRT_sparse
vss.sparse.last Steady-state volume of distribution from sparse sampling
vz.sparse.last Terminal volume of distribution from sparse sampling
sparse_full_interval <- data.frame(
  start           = 0,
  end             = Inf,
  sparse_aumclast = TRUE,   # also pulls in sparse_auclast, SE, df
  mrt.sparse.last = TRUE,
  cl.sparse.last  = TRUE,
  kel.sparse.last = TRUE,
  vss.sparse.last = TRUE,
  vz.sparse.last  = TRUE
)

o_nca_sparse_full <- pk.nca(PKNCAdata(o_conc, o_dose, intervals = sparse_full_interval))

as.data.frame(o_nca_sparse_full) |>
  filter(PPTESTCD %in% c("sparse_auclast", "sparse_aumclast",
                          "mrt.sparse.last", "cl.sparse.last", "kel.sparse.last",
                          "vss.sparse.last", "vz.sparse.last")) |>
  select(PPTESTCD, PPORRES)
# A tibble: 7 × 2
  PPTESTCD        PPORRES
  <chr>             <dbl>
1 sparse_auclast   10.4  
2 sparse_aumclast  43.1  
3 cl.sparse.last    9.62 
4 mrt.sparse.last   4.15 
5 vss.sparse.last  39.9  
6 kel.sparse.last   0.241
7 vz.sparse.last   39.9  

vss.sparse.last (= CL × MRT) and vz.sparse.last (= CL / kel) are derived entirely from the sparse AUC and AUMC — no terminal-phase (λz) fit is needed. Because kel.sparse.last is 1/MRT rather than a λz estimate, vz.sparse.last is numerically equal to vss.sparse.last. With extravascular data like this chapter’s, the clearance and volume values are apparent (CL/F, V/F) estimates.