Skip to main content

3. Detection capability (EP17) and linearity (EP06)

Tags: [S] primary public source, [Y] my synthesis. All data below are simulated.

(a) Concept

Term Question it answers Typical statement
LoB Highest result expected from a blank 95th percentile of blank results
LoD Lowest concentration reliably distinguished from the LoB ≥95% of results above the LoB
LoQ Lowest concentration measured with acceptable error (CV or total-error goal) CV ≤ goal, or bias + 1.96·SD ≤ allowable error
Linearity Does response scale proportionally with concentration across the range? polynomial vs linear fit; allowable deviation

Two error types fix LoB and LoD: α = 5% false positive (blank above LoB), β = 5% false negative (low sample below LoB). [Y]

(b) Regulatory anchor

Item Fact Tag
EP17 EP17-A2 (2012, confirmed 2017); no newer edition listed. Evaluates and verifies LoB, LoD, LoQ claims [S] clsi.org
EP06 Ed2 (2020-11-24): low-cost linearity validation incl. LLoQ/ULoQ; two designs (high sample + measurand-free, or high and low samples) [S] clsi.org
IVDR Annex I 9.1(a) lists “limits of detection and quantitation, measuring range, linearity” [S]
Design counts (EP17: blanks, lots, days; EP06 replicates) not verified: paywalled. Simulation sizes below are my assumptions –

(c) R lab

Simulated study

set.seed(17)
n_blank <- 60                      # assumption, not a quoted requirement
blank <- rnorm(n_blank, mean = 0.5, sd = 0.40)        # pg/mL
low_lv <- c(1.0, 1.5, 2.0, 2.5, 3.0); reps <- 12
low <- data.frame(level = rep(low_lv, each = reps))
low$y <- rnorm(nrow(low), low$level, 0.45)

LoB and LoD, parametric and nonparametric

lob_p  <- lob_param(blank)
lob_np <- lob_nonparam(blank)
# pooled within-level SD of low samples
sd_low <- sqrt(mean(tapply(low$y, low$level, var)))
lod_p_check <- lob_p + 1.645 * sd_low
sd_low_lm <- sigma(lm(y ~ factor(level), low))        # pooled SD from an ANOVA fit

# Nonparametric LoD: lowest level whose results are >= 95% above the LoB
hit <- tapply(low$y > lob_np, low$level, mean)
lod_np <- as.numeric(names(hit)[which(hit >= 0.95)[1]])

kable(data.frame(
  Quantity = c("LoB", "LoD"),
  Parametric = round(c(lob_p, lod_p_check), 3),
  Nonparametric = round(c(lob_np, lod_np), 3),
  Formula = c("mean + 1.645·SD_blank", "LoB + 1.645·SD_low (pooled)")),
  caption = "Detection capability (pg/mL)")
Detection capability (pg/mL)
Quantity Parametric Nonparametric Formula
LoB 1.251 1.254 mean + 1.645·SD_blank
LoD 2.040 2.500 LoB + 1.645·SD_low (pooled)
kable(data.frame(level = names(hit), `fraction above LoB (np)` = round(as.numeric(hit), 3),
                 check.names = FALSE), caption = "Hit rate of low-level samples")
Hit rate of low-level samples
level fraction above LoB (np)
1 0.167
1.5 0.583
2 0.917
2.5 1.000
3 1.000
stopifnot(isTRUE(all.equal(sd_low, sd_low_lm)))   # hand pooled SD = lm sigma

Plain 1.645 multipliers are the textbook parametric formulas; small-sample correction factors in EP17 are not verified: paywalled. [Y]

Cross-check of the parametric LoB by simulation

# Under normality the 95th percentile of the blank distribution is mean + 1.645 sd.
big <- rnorm(1e6, 0.5, 0.40)
c(theory = 0.5 + 1.645 * 0.40, big_sim_q95 = unname(quantile(big, .95)),
  estimate_from_n60 = lob_p)
           theory       big_sim_q95 estimate_from_n60 
         1.158000          1.157291          1.250696 

Probit LoD (hit-rate design, molecular assays) [Y]

conc <- c(2, 4, 7, 10, 15, 25, 50); n <- rep(24, length(conc))
b1 <- 2.5; b0 <- qnorm(.95) - b1 * log10(10)              # true LoD95 = 10
hits <- rbinom(length(conc), n, pnorm(b0 + b1 * log10(conc)))
fit <- glm(cbind(hits, n - hits) ~ log10(conc), family = binomial("probit"))
dp <- dose.p(fit, p = 0.95)
lod_dose <- 10^as.numeric(dp)
lod_hand <- lod_probit(conc, hits, n)
ci_log <- as.numeric(dp) + c(-1, 1) * 1.96 * attr(dp, "SE")
kable(data.frame(method = c("MASS::dose.p", "hand inversion"),
                 LoD95 = round(c(lod_dose, lod_hand), 2)))
method LoD95
MASS::dose.p 7.15
hand inversion 7.15
cat(sprintf("Delta-method 95%% CI: %.1f - %.1f copies/mL (true value 10)\n",
            10^ci_log[1], 10^ci_log[2]))
Delta-method 95% CI: 5.3 - 9.7 copies/mL (true value 10)
stopifnot(isTRUE(all.equal(lod_dose, lod_hand)))

This is one realisation; a single 95% CI misses the truth about 1 time in 20. Coverage check over 1000 repeats of the same design:

set.seed(2024)
cover <- replicate(1000, {
  h <- rbinom(length(conc), n, pnorm(b0 + b1 * log10(conc)))
  f <- suppressWarnings(glm(cbind(h, n - h) ~ log10(conc), family = binomial("probit")))
  d <- suppressWarnings(dose.p(f, p = 0.95))
  r <- as.numeric(d) + c(-1, 1) * 1.96 * attr(d, "SE")
  1 >= r[1] && 1 <= r[2]       # log10(10) = 1
})
c(coverage = mean(cover))
coverage 
   0.937 

Parametric bootstrap of the LoD (my method)

Resample blank and low-level results from the fitted normals, recompute LoD, take percentiles.

set.seed(99)
B <- 4000
lod_b <- replicate(B, {
  b <- rnorm(n_blank, mean(blank), sd(blank))
  l <- rnorm(length(low_lv) * reps, 0, sd_low)          # centred; only the SD enters
  lob_param(b) + 1.645 * sd(l)
})
# Cross-check against the exact chi-square distribution of the pooled SD part:
# Var(SD_low*) approx; compare the bootstrap SE with the delta-method SE.
se_delta <- sqrt(sd(blank)^2 / n_blank +
  1.645^2 * sd(blank)^2 / (2 * (n_blank - 1)) +
  1.645^2 * sd_low^2 / (2 * length(low_lv) * (reps - 1)))
c(LoD_point = lod_p_check, boot_median = median(lod_b),
  boot_CI_lo = unname(quantile(lod_b, .025)), boot_CI_hi = unname(quantile(lod_b, .975)),
  boot_SE = sd(lod_b), delta_SE = se_delta)
  LoD_point boot_median  boot_CI_lo  boot_CI_hi     boot_SE    delta_SE 
  2.0396742   2.0359937   1.8172921   2.2430930   0.1092108   0.1124553 

How to defend each (one paragraph). Parametric uses all observations and gives a smooth estimate with a CI, but rests on normality of blank and low-level results; check with a QQ-plot and consider a log scale if blanks are skewed. Nonparametric assumes nothing about the shape but is a coarse rank statistic: with 60 blanks the 95th percentile is determined by about three values and with 12 replicates per level the hit rate can only change in steps of 8%. Probit is the right tool when the outcome is hit/no-hit, as in qPCR. Parametric bootstrap is my uncertainty statement on the parametric LoD: it adds a CI and exposes how few df the SD of the low samples really has; it inherits the normality assumption and is not a CLSI-prescribed procedure. [Y]

LoQ from a precision profile

set.seed(5)
lv <- c(1, 2, 3, 5, 8, 12, 20); r <- 20
sd_true <- function(c) sqrt(0.30^2 + (0.06 * c)^2)
pp <- data.frame(level = rep(lv, each = r))
pp$y <- 1.03 * pp$level + rnorm(nrow(pp), 0, sd_true(pp$level))   # +3% proportional bias
s <- aggregate(y ~ level, pp, function(v) c(m = mean(v), s = sd(v)))
s <- data.frame(level = s$level, mean = s$y[, "m"], sd = s$y[, "s"])
s$cv <- 100 * s$sd / s$mean
s$total_error_pct <- 100 * (abs(s$mean - s$level) + 1.96 * s$sd) / s$level
kable(s, digits = 2, caption = "Precision profile; CV goal 20%, total-error goal 30% (assumed goals)")
Precision profile; CV goal 20%, total-error goal 30% (assumed goals)
level mean sd cv total_error_pct
1 0.94 0.28 30.12 61.31
2 2.19 0.38 17.23 46.78
3 3.13 0.29 9.41 23.65
5 5.05 0.31 6.16 13.29
8 8.31 0.52 6.20 16.53
12 12.39 0.95 7.66 18.76
20 20.47 1.36 6.65 15.67
loq_cv <- s$level[which(s$cv <= 20)[1]]
loq_te <- s$level[which(s$total_error_pct <= 30)[1]]
# model-based: sd^2 = a^2 + b^2 c^2 by weighted lm on variances
m <- lm(I(sd^2) ~ 1 + I(level^2), s)
a2 <- coef(m)[1]; b2 <- coef(m)[2]
loq_model <- sqrt(a2 / (0.04 - b2))
c(LoQ_empirical_CV20 = loq_cv, LoQ_empirical_TE30 = loq_te, LoQ_model_CV20 = unname(loq_model))
LoQ_empirical_CV20 LoQ_empirical_TE30     LoQ_model_CV20 
          2.000000           3.000000           1.375589 

Linearity (EP06 style, polynomial comparison) [Y]

set.seed(8)
x <- rep(seq(0, 100, length.out = 9), each = 2)
mu <- 20 + x - 0.0018 * x^2                 # mild saturation
y <- mu + rnorm(length(x), 0, 0.01 * mu)
chk <- linearity_check(x, y, degree = 3)
# hand F-test
lin <- lm(y ~ x); cub <- lm(y ~ poly(x, 3, raw = TRUE))
F_hand <- ((deviance(lin) - deviance(cub)) / 2) / (deviance(cub) / df.residual(cub))
c(F_function = chk$F, F_hand = F_hand, p = chk$p_nonlinear)
  F_function       F_hand            p 
3.502025e+01 3.502025e+01 3.560210e-06 
stopifnot(isTRUE(all.equal(chk$F, F_hand)))
kable(data.frame(level = names(chk$deviation_pct), deviation_pct = round(chk$deviation_pct, 2),
                 within_5pct = abs(chk$deviation_pct) <= 5),
      caption = "Deviation of linear from cubic fit (allowable ±5%, assumed goal)")
Deviation of linear from cubic fit (allowable ±5%, assumed goal)
level deviation_pct within_5pct
0 0 11.54 FALSE
12.5 12.5 1.33 TRUE
25 25 -1.81 TRUE
37.5 37.5 -2.59 TRUE
50 50 -2.33 TRUE
62.5 62.5 -1.54 TRUE
75 75 -0.50 TRUE
87.5 87.5 0.67 TRUE
100 100 1.87 TRUE
df <- data.frame(x, y)
ggplot(df, aes(x, y)) + geom_point() +
  geom_smooth(method = "lm", se = FALSE, colour = "grey40", linetype = 2) +
  geom_smooth(method = "lm", formula = y ~ poly(x, 3), se = FALSE, colour = "#d95f02") +
  labs(x = "Expected concentration", y = "Measured") + theme_minimal()

The F-test shows real curvature, but the decision uses the deviation limit: here only the lowest level exceeds ±5% (the straight line overshoots at the low end), so a defensible claim restricts the linear range to start above it and re-tests. A significant F-test alone is not a failure, and a non-significant one is not proof of linearity in a small study. [Y]

(d) Claims

EP17-A2, EP06-Ed2 editions and scope, IVDR Annex I 9.1(a) are in the ledger. Design counts for EP17 and EP06: not verified: paywalled.