Checking a dose-response analysis

R
ecotoxicology
GLM
ecology tutorial
ggplot2
Four checks on an ecotoxicology dose-response fit in R: control mortality and Abbott’s correction, vessel overdispersion, tail extrapolation, nominal dose.
Author

Tidy Ecology

Published

2026-07-22

A 96 hour acute test finishes, the counts go into a spreadsheet, and a curve comes back with an LC50 of 12 micrograms per litre and a confidence interval that spans about a third of that either way. The number goes into a risk assessment and stops being a statistic. What nobody looks at again is the set of assumptions that turned twelve columns of dead and alive into a single quantile: that the control animals died for reasons the model does not need to know about, that each animal is an independent draw, that the curve keeps its shape below the lowest concentration anyone tested, and that the concentration written on the beaker is the concentration the animals were in.

The three earlier posts in this cluster build the machinery. Dose-response curves and the LC50 fits the curve and shows that the LC50 is a fitted quantity rather than a measurement. Confidence intervals for effective doses prices the uncertainty around it and shows where the design decides the answer. Hormesis and non-monotonic responses asks whether the curve is monotone at all. This post tries to break all three by measuring what four common defects do to the fitted number: how far the LC50 moves, and how far the stated 95 per cent interval is from covering the truth 95 per cent of the time. Every check is a simulation with a known answer, because coverage cannot be measured any other way.

The machinery underneath is the binomial GLM from Logistic regression for presence-absence data, with a log concentration axis instead of an environmental gradient. One point of vocabulary before the code: the log-logistic curve used here runs on a dose axis, so the proportion killed rises with concentration. In Parametric survival and the AFT model the same distribution runs on a time axis, where the quantile of interest is a survival time. The algebra is shared and the reported number means something different in each.

library(ggplot2)

te_pal <- list(forest = "#275139", green = "#2f8f63", sage = "#93a87f",
               clay = "#b5534e", gold = "#cda23f", line = "#dad9ca",
               ink = "#16241d", paper = "#f5f4ee")

theme_te <- function() {
  theme_minimal(base_size = 12) +
    theme(panel.grid.minor = element_blank(),
          panel.grid.major = element_line(colour = "#e7e6dc"),
          plot.background = element_rect(fill = "#f5f4ee", colour = NA),
          panel.background = element_rect(fill = "#f5f4ee", colour = NA),
          plot.title = element_text(face = "bold", colour = te_pal$ink),
          axis.title = element_text(colour = "#2c3a31"))
}

The assay under test

The test animal is a small crustacean, the endpoint is death at 96 hours, and the design is the one that appears in every guideline: a control plus five concentrations spaced by a factor of about 1.8, with 25 animals at each level. The truth is a log-logistic curve on the concentration axis with an LC50 of 12 and a slope of 2.2 on the natural log scale, and 8 per cent of animals die during the test whatever the exposure.

Two functions do all the fitting. The two-parameter fit is a binomial GLM of dead out of total on log concentration, with a link that the caller chooses; the effective dose at any level comes from inverting the linear predictor, and its standard error from the delta method on the log dose scale. The three-parameter fit adds a lower asymptote for the control mortality and is done by direct maximum likelihood, with parameters on the log, log and probability scales and the concentration of the control set to zero so that its predicted mortality is the asymptote itself.

p_true <- function(x, b, e, c0) c0 + (1 - c0) * plogis(b * (log(x) - log(e)))

fit2 <- function(x, dead, n, link = "logit") {
  X <- cbind(1, log(x))
  fo <- glm.fit(X, dead / n, weights = n, family = binomial(link))
  list(co = fo$coefficients, V = solve(crossprod(X * sqrt(fo$weights))),
       mu = fo$fitted.values, df = fo$df.residual)
}

lc_of <- function(co, V, p, link = "logit") {
  q <- switch(link, logit = qlogis(p), probit = qnorm(p),
              cloglog = log(-log(1 - p)))
  lx <- (q - co[1]) / co[2]
  gr <- c(-1 / co[2], -lx / co[2])
  c(lx = unname(lx), se = unname(sqrt(drop(gr %*% V %*% gr))))
}

nll3 <- function(par, x, dead, n) {
  bb <- exp(par[1]); le <- par[2]; c0 <- par[3]
  p <- c0 + (1 - c0) * plogis(bb * (log(x) - le))
  p <- pmin(pmax(p, 1e-12), 1 - 1e-12)
  -sum(dead * log(p) + (n - dead) * log(1 - p))
}

fit3 <- function(x, dead, n) {
  st <- c(log(2), log(median(x[x > 0])), max(dead[x == 0] / n[x == 0], 0.02))
  o <- optim(st, nll3, x = x, dead = dead, n = n, method = "L-BFGS-B",
             lower = c(-2, log(0.05), 0.001), upper = c(3, log(2000), 0.6),
             control = list(maxit = 1000, factr = 1e9))
  V <- solve(optimHess(o$par, nll3, x = x, dead = dead, n = n))
  c(lx = o$par[2], se = sqrt(V[2, 2]), c0 = o$par[3], b = exp(o$par[1]),
    conv = o$convergence)
}
set.seed(20260801)
b_true <- 2.2; e_true <- 12; c0_true <- 0.08
conc <- c(3.7, 6.7, 12.0, 21.6, 38.9)
n_per <- 25
print(round(c(lc50 = e_true, slope = b_true, control_mortality_pct = 100 * c0_true,
              test_hours = 96, animals_per_level = n_per, levels = length(conc),
              total_animals = n_per * (length(conc) + 1)), 3))
                 lc50                 slope control_mortality_pct 
                 12.0                   2.2                   8.0 
           test_hours     animals_per_level                levels 
                 96.0                  25.0                   5.0 
        total_animals 
                150.0 
print(round(c(spacing_factor = mean(conc[-1] / conc[-length(conc)])), 1))
spacing_factor 
           1.8 
print(round(rbind(concentration = conc,
                  true_mortality = p_true(conc, b_true, e_true, c0_true)), 3))
                [,1] [,2]  [,3]   [,4]   [,5]
concentration  3.700 6.70 12.00 21.600 38.900
true_mortality 0.144 0.28  0.54  0.802  0.936
d_ctrl <- rbinom(1, n_per, c0_true)
d_trt <- rbinom(length(conc), n_per, p_true(conc, b_true, e_true, c0_true))
print(rbind(dead = c(d_ctrl, d_trt), n = rep(n_per, length(conc) + 1)))
     [,1] [,2] [,3] [,4] [,5] [,6]
dead    0    4    7   13   20   24
n      25   25   25   25   25   25
g0 <- fit2(conc, d_trt, rep(n_per, length(conc)))
print(round(c(naive_lc50 = exp(lc_of(g0$co, g0$V, 0.5)["lx"]),
              naive_slope = unname(g0$co[2])), 3))
naive_lc50.lx   naive_slope 
       10.395         1.931 

The single data set drawn here happens to have no deaths at all in the control, and mortality among the treated groups runs from 4 out of 25 to 24 out of 25. A two-parameter fit that drops the control returns an LC50 of 10.395 against a truth of 12 and a slope of 1.931 against a truth of 2.2, so a clean control is not a guarantee of anything: the background deaths are still sitting inside the treated counts. That is one draw, and one draw proves nothing. The rest of the post is about what happens on average, and about how often the interval contains the truth.

Check 1: control mortality and the Abbott correction

If animals die in the control, the observed mortality at a treated level mixes two processes. The standard repair is Abbott’s correction, which is a century old and still in the guidelines: subtract the control mortality and rescale, so that a proportion p observed at a concentration becomes (p minus c) divided by (1 minus c), where c is the control mortality. The corrected proportions then go into an ordinary two-parameter fit.

The alternative is to write the control mortality into the model as a lower asymptote and estimate it from the same data, which is what the three-parameter fit above does. Both are defensible and both are in common use. The difference is not in the point estimate, and this is the part that is easy to miss: Abbott’s correction treats the control mortality as known exactly. The control group is a sample like any other, its mortality has a standard error of its own, and the corrected proportions inherit that error while the fit that follows knows nothing about it.

Three estimators are compared on 1000 simulated data sets from the design above: the naive fit that drops the control, the Abbott fit, and the three-parameter fit. Each returns an LC50 and a delta method standard error on the log scale, and each is scored on bias and on the coverage of its nominal 95 per cent interval. Abbott’s corrected counts are rounded to whole animals, which is what software does when the corrected proportion has to go back into a binomial fit.

set.seed(20260801)
n_rep <- 1000
nn <- rep(n_per, length(conc))
dc_all <- rbinom(n_rep, n_per, c0_true)
dt_all <- matrix(rbinom(n_rep * length(conc), n_per,
                        rep(p_true(conc, b_true, e_true, c0_true), each = n_rep)),
                 n_rep, length(conc))

res1 <- t(vapply(seq_len(n_rep), function(i) {
  dt <- dt_all[i, ]; chat <- dc_all[i] / n_per
  fn <- fit2(conc, dt, nn)
  a_d <- pmin(pmax(round(((dt / n_per - chat) / (1 - chat)) * n_per), 0), n_per)
  fa <- fit2(conc, a_d, nn)
  fj <- fit3(c(0, conc), c(dc_all[i], dt), rep(n_per, length(conc) + 1))
  c(lc_of(fn$co, fn$V, 0.5), lc_of(fa$co, fa$V, 0.5),
    unname(fj[c("lx", "se", "c0", "conv")]))
}, numeric(8)))
colnames(res1) <- c("naive_lx", "naive_se", "abb_lx", "abb_se",
                    "joint_lx", "joint_se", "joint_c0", "conv")

lt <- log(e_true)
summ1 <- function(lx, se) c(
  median_lc50 = median(exp(lx)),
  bias_pct = 100 * (exp(mean(lx)) / e_true - 1),
  sd_log = sd(lx), mean_se_log = mean(se),
  cover_pct = 100 * mean(abs(lx - lt) < 1.96 * se))
print(round(rbind(naive = summ1(res1[, 1], res1[, 2]),
                  abbott = summ1(res1[, 3], res1[, 4]),
                  joint = summ1(res1[, 5], res1[, 6])), 3))
       median_lc50 bias_pct sd_log mean_se_log cover_pct
naive       10.469  -12.380  0.117       0.118      79.9
abbott      11.867   -0.831  0.142       0.106      86.0
joint       11.918   -0.137  0.143       0.140      93.7
print(round(c(naive_bias_pct = 100 * (exp(mean(res1[, 1])) / e_true - 1),
              abbott_bias_pct = 100 * (exp(mean(res1[, 3])) / e_true - 1),
              joint_bias_pct = 100 * (exp(mean(res1[, 5])) / e_true - 1)), 1))
 naive_bias_pct abbott_bias_pct  joint_bias_pct 
          -12.4            -0.8            -0.1 
print(round(c(replicates = n_rep, nonconvergence = sum(res1[, "conv"] != 0),
              zero_control_deaths_pct = 100 * mean(dc_all == 0),
              asymptote_at_bound_pct = 100 * mean(res1[, "joint_c0"] <= 0.0011),
              se_ratio_abbott = mean(res1[, 4]) / sd(res1[, 3]),
              se_ratio_joint = mean(res1[, 6]) / sd(res1[, 5])), 3))
             replicates          nonconvergence zero_control_deaths_pct 
               1000.000                   0.000                  12.200 
 asymptote_at_bound_pct         se_ratio_abbott          se_ratio_joint 
                 12.000                   0.747                   0.981 
meth <- c("naive", "Abbott", "three-parameter")
cols <- c(1, 3, 5)
bars <- do.call(rbind, lapply(seq_along(meth), function(i) {
  lx <- res1[, cols[i]]; se <- res1[, cols[i] + 1]
  qq <- quantile(lx, c(0.025, 0.975))
  data.frame(method = meth[i], row = 4 - i,
             lo = c(unname(qq[1]), median(lx) - 1.96 * mean(se)),
             hi = c(unname(qq[2]), median(lx) + 1.96 * mean(se)),
             mid = median(lx),
             kind = c("central 95 per cent of the estimates",
                      "average interval reported"))
}))
bars$kind <- factor(bars$kind, levels = c("central 95 per cent of the estimates",
                                          "average interval reported"))
bars$ypos <- bars$row + ifelse(bars$kind == levels(bars$kind)[1], 0.14, -0.14)
cover_lab <- 100 * c(mean(abs(res1[, 1] - lt) < 1.96 * res1[, 2]),
                     mean(abs(res1[, 3] - lt) < 1.96 * res1[, 4]),
                     mean(abs(res1[, 5] - lt) < 1.96 * res1[, 6]))
row_lab <- sprintf("%s\n%.1f per cent covered", meth, cover_lab)
brk1 <- c(8, 9, 10, 11, 12, 13, 14, 16, 18)

ggplot(bars, aes(y = ypos, colour = kind)) +
  geom_vline(xintercept = lt, colour = te_pal$ink, linetype = "22",
             linewidth = 0.5) +
  annotate("text", x = lt + 0.012, y = 3.42, label = "true LC50", hjust = 0,
           size = 3, colour = te_pal$ink) +
  geom_segment(aes(x = lo, xend = hi, yend = ypos), linewidth = 2.4,
               lineend = "round") +
  geom_point(aes(x = mid), size = 1.8, colour = te_pal$ink) +
  scale_y_continuous(breaks = 3:1, labels = row_lab, limits = c(0.5, 3.5)) +
  scale_x_continuous(breaks = log(brk1), labels = as.character(brk1)) +
  scale_colour_manual(values = c(te_pal$forest, te_pal$clay), name = NULL) +
  labs(x = "Estimated LC50 (micrograms per litre)", y = NULL,
       title = "Abbott's interval is narrower than Abbott's own scatter") +
  theme_te() +
  theme(legend.position = "top",
        panel.grid.major.y = element_blank())
Three pairs of horizontal bars, one pair per estimator, on a logarithmic concentration axis running from about eight to eighteen. In each pair the upper bar spans the middle 95 per cent of the simulated estimates and the lower bar spans the average reported interval, and a small dark dot on each bar marks the median estimate. A dashed vertical line labelled true LC50 runs down the panel. For the naive estimator both bars sit to the left of that line. For Abbott the two bars are centred on it but the reported bar is visibly shorter. For the three-parameter fit the two bars are almost the same length. Coverage is printed beside each estimator name on the left.
Figure 1: For each estimator, the central 95 per cent of its 1000 LC50 estimates against the average 95 per cent interval it reports, both drawn on the concentration axis. The dashed line labelled true LC50 stands at 12, and the dot on each bar is the median estimate. The naive interval is narrow and in the wrong place, the Abbott interval is narrower than the estimates it comes from, and only the three-parameter fit reports an interval as wide as its own scatter.

The naive fit is wrong, and wrong in a direction that matters. Background mortality lifts the observed proportion at every concentration, and it lifts it most where the toxicant is killing least, so the fitted curve slides towards lower concentrations. Over 1000 data sets the naive LC50 runs 12.4 per cent below the truth and its nominal 95 per cent interval covers the truth in 79.9 per cent of trials. Nothing in the output of that fit is a warning: it converges, its residuals look fine, and the standard error it reports, 0.118 on the log scale, matches the scatter of its own estimates, 0.117, almost exactly. The interval is the right width in the wrong place, which is the one failure that no diagnostic computed from the fit alone can see.

Abbott’s correction removes almost all of the bias. The corrected estimator sits 0.8 per cent below the truth on average, which is the calibration line this check needs. Its interval is another matter. Across the same 1000 data sets the spread of the Abbott estimates on the log scale is 0.142, while the average standard error it reports is 0.106: the reported interval is 0.747 of its honest width, and it covers the truth 86.0 per cent of the time rather than 95. The missing variance is the control group’s own, which the correction spends and never accounts for.

The three-parameter fit gets both parts right. It sits 0.1 per cent below the truth, its estimates have a spread of 0.143 on the log scale, which is no better than Abbott’s, and the average standard error it reports is 0.140, or 0.981 of the honest width, so coverage comes out at 93.7 per cent. Read those lines together and the result is sharper than it first looks. Abbott’s correction is not more precise than estimating the asymptote; the two estimators scatter by the same amount. Abbott’s is only more confident. The extra width of the three-parameter interval is not a cost of the method, it is the cost of not knowing the control mortality, and that cost was there all along.

One honest wrinkle in the three-parameter fit: in 12.2 per cent of these trials the control group shows no deaths at all, and in 12.0 per cent the estimated asymptote finishes on its lower bound. That is not a failure, it is what maximum likelihood does with a zero count, but it does mean the reported standard error for that parameter is not interpretable in those runs. Coverage for the LC50 holds up anyway.

Check 2: overdispersion from the test vessel

Animals are not exposed one at a time. They are exposed in vessels, and everything that varies between vessels while staying constant within them, oxygen, temperature, the actual concentration in that beaker, a batch of animals from the same brood, makes the animals in a vessel more alike than two animals picked from different vessels. The binomial likelihood has no room for this. It counts every animal as an independent trial, so it counts the experiment as bigger than it is.

The simulation puts 4 vessels of 20 animals at each of the same five concentrations, 400 animals in all, and gives each vessel a mortality probability drawn from a beta distribution centred on the true curve with an intraclass correlation of 0.10. Control mortality is switched off here so that only one thing is wrong at a time. Three analyses run on each data set: an ordinary binomial GLM on the vessel-level counts, the same fit with a quasi-binomial dispersion estimate and a t reference distribution, and a beta-binomial fit by maximum likelihood with the correlation as a third parameter. The theoretical design effect for this design, one plus the number of animals per vessel minus one times the correlation, is 2.9.

set.seed(20260801)
k_ves <- 4; m_ves <- 20; rho_true <- 0.10
xv <- rep(conc, each = k_ves)
n_ves <- length(xv); mv <- rep(m_ves, n_ves)
pv <- p_true(xv, b_true, e_true, 0)

nll_bb <- function(par, x, dead, m) {
  bb <- exp(par[1]); le <- par[2]; r <- plogis(par[3])
  p <- pmin(pmax(plogis(bb * (log(x) - le)), 1e-8), 1 - 1e-8)
  s <- (1 - r) / r
  -sum(lbeta(dead + p * s, m - dead + (1 - p) * s) - lbeta(p * s, (1 - p) * s))
}
fit_bb <- function(x, dead, m) {
  gg <- fit2(x, dead, m)
  st <- c(log(max(gg$co[2], 0.3)), -gg$co[1] / gg$co[2], qlogis(0.05))
  o <- optim(st, nll_bb, x = x, dead = dead, m = m, method = "L-BFGS-B",
             lower = c(-2, log(0.05), -10), upper = c(3, log(2000), 2),
             control = list(maxit = 1000, factr = 1e9))
  V <- solve(optimHess(o$par, nll_bb, x = x, dead = dead, m = m))
  c(lx = o$par[2], se = sqrt(V[2, 2]), rho = plogis(o$par[3]),
    conv = o$convergence)
}

n_rep2 <- 1000
sh <- (1 - rho_true) / rho_true
pmat <- matrix(rbeta(n_rep2 * n_ves, rep(pv * sh, each = n_rep2),
                     rep((1 - pv) * sh, each = n_rep2)), n_rep2, n_ves)
ymat <- matrix(rbinom(n_rep2 * n_ves, m_ves, pmat), n_rep2, n_ves)

res2 <- t(vapply(seq_len(n_rep2), function(i) {
  y <- ymat[i, ]
  gg <- fit2(xv, y, mv)
  b0 <- lc_of(gg$co, gg$V, 0.5)
  pr <- (y / m_ves - gg$mu) / sqrt(gg$mu * (1 - gg$mu) / m_ves)
  z <- fit_bb(xv, y, mv)
  c(b0, phi = sum(pr^2) / gg$df, unname(z))
}, numeric(7)))
colnames(res2) <- c("lx", "se", "phi", "bb_lx", "bb_se", "bb_rho", "conv")

tq <- qt(0.975, n_ves - 2)
print(round(c(replicates = n_rep2, vessels_per_level = k_ves,
              animals_per_vessel = m_ves, total_animals = n_ves * m_ves,
              rho = rho_true, design_effect_theory = 1 + (m_ves - 1) * rho_true,
              quasi_df = n_ves - 2,
              nonconvergence = sum(res2[, "conv"] != 0)), 3))
          replicates    vessels_per_level   animals_per_vessel 
              1000.0                  4.0                 20.0 
       total_animals                  rho design_effect_theory 
               400.0                  0.1                  2.9 
            quasi_df       nonconvergence 
                18.0                  0.0 
print(round(c(sd_log_binomial = sd(res2[, "lx"]),
              mean_se_binomial = mean(res2[, "se"]),
              design_effect_measured = (sd(res2[, "lx"]) / mean(res2[, "se"]))^2,
              mean_dispersion = mean(res2[, "phi"]),
              mean_rho_hat = mean(res2[, "bb_rho"]),
              sd_log_betabin = sd(res2[, "bb_lx"]),
              mean_se_betabin = mean(res2[, "bb_se"])), 4))
       sd_log_binomial       mean_se_binomial design_effect_measured 
                0.1052                 0.0596                 3.1135 
       mean_dispersion           mean_rho_hat         sd_log_betabin 
                2.8109                 0.0862                 0.1028 
       mean_se_betabin 
                0.0935 
print(round(c(design_effect_measured = (sd(res2[, "lx"]) / mean(res2[, "se"]))^2,
              mean_dispersion = mean(res2[, "phi"])), 3))
design_effect_measured        mean_dispersion 
                 3.114                  2.811 
print(round(c(binomial = 100 * mean(abs(res2[, "lx"] - lt) < 1.96 * res2[, "se"]),
              quasibinomial = 100 * mean(abs(res2[, "lx"] - lt) <
                                           tq * res2[, "se"] * sqrt(res2[, "phi"])),
              betabinomial = 100 * mean(abs(res2[, "bb_lx"] - lt) <
                                          1.96 * res2[, "bb_se"])), 2))
     binomial quasibinomial  betabinomial 
         75.0          93.3          92.0 
print(round(c(width_factor_quasi = mean(tq * res2[, "se"] * sqrt(res2[, "phi"])) /
                mean(1.96 * res2[, "se"])), 2))
width_factor_quasi 
              1.78 
print(round(c(effective_animals = n_ves * m_ves /
                ((sd(res2[, "lx"]) / mean(res2[, "se"]))^2))))
effective_animals 
              128 
est <- data.frame(lc = res2[, "lx"])
se_bin <- mean(res2[, "se"])
se_qb <- mean(res2[, "se"] * sqrt(res2[, "phi"]))
gl <- seq(log(7.5), log(19), length.out = 400)
dens <- rbind(
  data.frame(lc = gl, d = dnorm(gl, lt, se_bin), what = "binomial"),
  data.frame(lc = gl, d = dnorm(gl, lt, se_qb), what = "quasi-binomial"))
dens$what <- factor(dens$what, levels = c("binomial", "quasi-binomial"))
brk <- c(8, 10, 12, 14, 16, 18)

ggplot(est, aes(lc)) +
  geom_histogram(aes(y = after_stat(density)), bins = 45,
                 fill = te_pal$sage, colour = NA) +
  geom_line(data = dens, aes(lc, d, colour = what), linewidth = 0.9) +
  geom_vline(xintercept = lt, colour = te_pal$ink, linetype = "22",
             linewidth = 0.5) +
  annotate("text", x = lt + 0.06, y = 6.4, label = "true LC50", hjust = 0,
           size = 3, colour = te_pal$ink) +
  scale_x_continuous(breaks = log(brk), labels = as.character(brk)) +
  coord_cartesian(xlim = log(c(7.5, 19))) +
  scale_colour_manual(values = c(binomial = te_pal$clay,
                                 "quasi-binomial" = te_pal$forest), name = NULL) +
  labs(x = "Estimated LC50 (micrograms per litre)",
       y = "Density on the log concentration scale",
       title = "The binomial standard error describes a narrower experiment") +
  theme_te() +
  theme(legend.position = "top")
Histogram of estimated LC50 values on a logarithmic axis centred near twelve, overlaid with two smooth curves and cut by a dashed vertical line labelled true LC50. The taller narrow curve, labelled as the binomial standard error, is much peakier than the histogram and misses its shoulders. The lower wide curve, labelled as the quasi-binomial standard error, follows the histogram closely.
Figure 2: Sampling distribution of the estimated LC50 across 1000 overdispersed data sets, against the two normal densities implied by the average standard error each method reports. The dashed vertical line labelled true LC50 stands at 12. The binomial curve is far too narrow; the quasi-binomial curve matches the spread of the estimates.

The estimates scatter with a standard deviation of 0.1052 on the log scale while the binomial fit reports an average standard error of 0.0596, so the measured design effect is 3.114 against a theoretical 2.9. The nominal 95 per cent interval covers the truth in 75.0 per cent of trials. Put the other way round: 400 animals arranged in 20 vessels carry the information of 128 animals tested one to a beaker, and an analysis that ignores the arrangement will report the interval that 400 independent animals would have earned.

Both repairs help and they do not help equally. The quasi-binomial scales the standard error by the square root of the estimated dispersion, which averages 2.811 here, and refers it to a t distribution on 18 degrees of freedom; that widens the interval by a factor of 1.78 and brings coverage to 93.3 per cent. The beta-binomial fit estimates the intraclass correlation directly and recovers 0.0862 against a true 0.1, and its coverage comes out at 92.0 per cent. The maximum likelihood fit with the correct likelihood is the one that undercovers, which is not what most readers would predict. The reason is that its standard error is a curvature estimate at the maximum that treats the estimated correlation as known, exactly the mistake Abbott’s correction makes in check 1, and it refers the result to a normal quantile rather than a t. The quasi-binomial is cruder and its t reference happens to pay for the estimation of the dispersion.

None of this is visible in the estimate itself. The scatter of the beta-binomial estimates, 0.1028 on the log scale, is no better than the binomial one, and it is not supposed to be: overdispersion does not bias the LC50, it only inflates its variance. The whole defect lives in the second decimal place of a standard error, which is why the standard advice to plot the residuals is not enough here. If you want a diagnostic rather than a repair, the randomised quantile residuals in Checking a bounded-response model will show the extra spread on a scale where it can be read.

Check 3: extrapolating below the lowest tested dose

Regulatory numbers are not always LC50s. An assessment that wants a concentration with little effect asks for an LC10, an LC5 or an LC1, and those live in the lower tail of a curve that was fitted to data in the middle. The check measures two things: how far apart three standard curve shapes end up when they are read below the tested range, and how much a single extra treatment at a very low concentration buys.

Two designs share a total of 200 animals. The classic design puts 40 animals at each of five concentrations spaced by a factor of 1.45, with the lowest chosen so that its true mortality is 25.097 per cent, which is a design that satisfies every guideline for an LC50. The reallocated design drops the five tested levels to 30 animals each and opens a sixth level far below them, 50 animals at 2.5 micrograms per litre, where the true mortality is 3.074 per cent. The same three link functions are fitted to both: logit, which is the generating truth here, probit and complementary log-log.

To separate model shape from sampling noise the check does the arithmetic twice. The deterministic part fits the three shapes to the expected proportions with the sampling noise turned off, so any difference between the fitted curves is pure shape. The stochastic part repeats the whole exercise on 400 simulated data sets per design and records the spread across shapes and the width of each interval.

set.seed(20260801)
links <- c("logit", "probit", "cloglog")
lev <- c(0.50, 0.10, 0.05, 0.01)
ptr <- function(x) plogis(b_true * (log(x) - log(e_true)))
dA <- list(x = c(7.3, 10.6, 15.4, 22.3, 32.3), n = rep(40, 5))
dB <- list(x = c(2.5, 7.3, 10.6, 15.4, 22.3, 32.3), n = c(50, 30, 30, 30, 30, 30))
n_rep3 <- 400

true_lc <- exp(log(e_true) + qlogis(lev) / b_true)
print(round(c(total_animals_A = sum(dA$n), total_animals_B = sum(dB$n),
              per_level_A = dA$n[1], per_level_B = dB$n[2],
              lowest_group_B = dB$n[1],
              spacing_A = mean(dA$x[-1] / dA$x[-length(dA$x)]),
              lowest_A = min(dA$x), response_at_lowest_A_pct = 100 * ptr(min(dA$x)),
              lowest_B = min(dB$x), response_at_lowest_B_pct = 100 * ptr(min(dB$x)),
              expected_deaths_at_lowest_B = dB$n[1] * ptr(min(dB$x)),
              replicates = n_rep3), 3))
            total_animals_A             total_animals_B 
                    200.000                     200.000 
                per_level_A                 per_level_B 
                     40.000                      30.000 
             lowest_group_B                   spacing_A 
                     50.000                       1.450 
                   lowest_A    response_at_lowest_A_pct 
                      7.300                      25.097 
                   lowest_B    response_at_lowest_B_pct 
                      2.500                       3.074 
expected_deaths_at_lowest_B                  replicates 
                      1.537                     400.000 
print(round(setNames(true_lc, paste0("true_LC", 100 * lev)), 3))
true_LC50 true_LC10  true_LC5  true_LC1 
   12.000     4.420     3.147     1.486 
det_lc <- function(d) sapply(links, function(lk) {
  f <- fit2(d$x, round(ptr(d$x) * 1e6), rep(1e6, length(d$x)), lk)
  sapply(lev, function(p) exp(lc_of(f$co, f$V, p, lk)["lx"]))
})
dlA <- det_lc(dA); dlB <- det_lc(dB)
rownames(dlA) <- rownames(dlB) <- paste0("LC", 100 * lev)
print(round(dlA, 3))
      logit probit cloglog
LC50 12.000 12.041  12.508
LC10  4.420  4.560   3.097
LC5   3.147  3.463   1.816
LC1   1.486  2.067   0.543
print(round(dlB, 3))
      logit probit cloglog
LC50 12.000 11.903  12.937
LC10  4.420  4.347   3.546
LC5   3.147  3.267   2.163
LC1   1.486  1.912   0.706
print(round(rbind(design_A = apply(dlA, 1, function(z) max(z) / min(z)),
                  design_B = apply(dlB, 1, function(z) max(z) / min(z))), 3))
          LC50  LC10   LC5   LC1
design_A 1.042 1.473 1.907 3.807
design_B 1.087 1.246 1.511 2.709
sim_design <- function(d) {
  y <- matrix(rbinom(n_rep3 * length(d$x), rep(d$n, each = n_rep3),
                     rep(ptr(d$x), each = n_rep3)), n_rep3, length(d$x))
  out <- vapply(seq_len(n_rep3), function(i) {
    z <- vapply(links, function(lk) {
      f <- fit2(d$x, y[i, ], d$n, lk)
      as.vector(vapply(lev, function(p) lc_of(f$co, f$V, p, lk), numeric(2)))
    }, numeric(2 * length(lev)))
    c(spread = apply(matrix(z[seq(1, 2 * length(lev), 2), ], length(lev)), 1,
                     function(v) exp(max(v) - min(v))),
      fold = exp(2 * 1.96 * z[seq(2, 2 * length(lev), 2), 1]))
  }, numeric(2 * length(lev)))
  round(rbind(shape_spread = rowMeans(out[seq_along(lev), ]),
              fold_width_logit = rowMeans(out[length(lev) + seq_along(lev), ])), 3)
}
sA <- sim_design(dA); sB <- sim_design(dB)
colnames(sA) <- colnames(sB) <- paste0("LC", 100 * lev)
print(sA)
                  LC50  LC10   LC5   LC1
shape_spread     1.042 1.478 1.923 3.933
fold_width_logit 1.365 2.266 2.836 4.814
print(sB)
                  LC50  LC10   LC5   LC1
shape_spread     1.102 1.193 1.413 2.437
fold_width_logit 1.391 1.959 2.328 3.490
print(round(c(width_gain_LC5 = sA["fold_width_logit", "LC5"] /
                sB["fold_width_logit", "LC5"],
              width_cost_LC50 = sB["fold_width_logit", "LC50"] /
                sA["fold_width_logit", "LC50"],
              spread_gain_LC5 = sA["shape_spread", "LC5"] /
                sB["shape_spread", "LC5"]), 3))
 width_gain_LC5 width_cost_LC50 spread_gain_LC5 
          1.218           1.019           1.361 
one_set <- function(d) {
  y <- rbinom(length(d$x), d$n, ptr(d$x))
  cur <- do.call(rbind, lapply(links, function(lk) {
    f <- fit2(d$x, y, d$n, lk)
    gx <- exp(seq(log(1.2), log(40), length.out = 250))
    eta <- f$co[1] + f$co[2] * log(gx)
    mu <- switch(lk, logit = plogis(eta), probit = pnorm(eta),
                 cloglog = 1 - exp(-exp(eta)))
    data.frame(x = gx, y = mu, link = lk)
  }))
  list(cur = cur, obs = data.frame(x = d$x, y = pmax(y / d$n, 0.0012)))
}
set.seed(20260801)
oA <- one_set(dA); oB <- one_set(dB)
lab <- c("classic design", "with a low-dose group")
cur3 <- rbind(cbind(oA$cur, panel = lab[1]), cbind(oB$cur, panel = lab[2]))
obs3 <- rbind(cbind(oA$obs, panel = lab[1]), cbind(oB$obs, panel = lab[2]))
cur3$panel <- factor(cur3$panel, levels = lab)
obs3$panel <- factor(obs3$panel, levels = lab)
vl <- data.frame(panel = factor(lab, levels = lab), x = c(min(dA$x), min(dB$x)))
cur3$link <- factor(cur3$link, levels = links)

ggplot(cur3, aes(x, y, colour = link)) +
  geom_hline(yintercept = c(0.10, 0.05, 0.01), colour = "#b8b6a4",
             linewidth = 0.6) +
  geom_vline(data = vl, aes(xintercept = x), colour = te_pal$ink,
             linetype = "22", linewidth = 0.5) +
  geom_text(data = vl, aes(x = x * 1.08, y = 0.0016), inherit.aes = FALSE,
            label = "lowest tested dose", hjust = 0, size = 3,
            colour = te_pal$ink) +
  geom_line(linewidth = 0.9) +
  geom_point(data = obs3, aes(x, y), inherit.aes = FALSE, colour = te_pal$ink,
             size = 2.2) +
  facet_wrap(~panel) +
  scale_x_log10(breaks = c(1.5, 3, 6, 12, 25),
                labels = c("1.5", "3", "6", "12", "25")) +
  scale_y_log10(breaks = c(0.001, 0.01, 0.05, 0.1, 0.3, 1),
                labels = c("0.001", "0.01", "0.05", "0.10", "0.30", "1.00")) +
  coord_cartesian(ylim = c(0.001, 1)) +
  scale_colour_manual(values = c(logit = te_pal$forest, probit = te_pal$gold,
                                 cloglog = te_pal$clay), name = NULL) +
  labs(x = "Concentration (micrograms per litre)", y = "Proportion dead",
       title = "The shapes agree where the data are and part company below") +
  theme_te() +
  theme(legend.position = "top",
        strip.text = element_text(colour = te_pal$ink, face = "bold"))
Two panels, one per design, with concentration on a logarithmic horizontal axis and proportion dead on a logarithmic vertical axis. Black dots show the observed proportions and a dashed vertical line labelled lowest tested dose stands in each panel. In the left panel the three fitted curves lie on top of each other above that line and separate widely below it, with the complementary log-log curve falling fastest. In the right panel, where a low concentration group has been added, the same three curves stay closer together over the same range.
Figure 3: Three link functions fitted to one data set under each design and extended below the tested range. Black dots are the observed proportions at the tested concentrations. The labelled dashed vertical line in each panel is the lowest tested dose, and the horizontal lines mark the 10, 5 and 1 per cent response levels where the curves are read.

With sampling noise switched off, the three shapes fitted to the classic design return LC50s of 12.000, 12.041 and 12.508, a spread of 1.042. At the LC5 they return 3.147, 3.463 and 1.816, a spread of 1.907, and at the LC1 the spread is 3.807. This is not estimation error. It is what three curve families that agree on the same middle do when they are asked about a region where no animal was tested, and no amount of data at the tested concentrations reduces it, because the deterministic calculation already assumes an infinite sample.

The simulated runs put numbers on the cost. Averaged over 400 data sets from the classic design, the three shapes differ by a factor of 1.923 at the LC5 and 3.933 at the LC1, while the logit fit’s own 95 per cent interval spans a factor of 2.836 at the LC5 and 4.814 at the LC1. Two things follow. The disagreement between the shapes at the LC5 is most of the width of the interval that gets reported, so an assessment that fits one shape and quotes its interval is understating the uncertainty by an amount no residual plot can reveal. And the interval at the LC1 covers a factor of 4.814 from end to end, which is a way of saying that the experiment did not measure it.

Taking 10 animals from each tested level and putting 50 into a sixth group at 2.5 micrograms per litre, where the expected number of deaths is 1.537, does more than it looks like it should. The shape spread at the LC5 falls from 1.923 to 1.413, and the logit interval at the LC5 narrows from a factor of 2.836 to 2.328, a gain of 1.218. The price is paid at the LC50, whose interval widens by a factor of 1.019. A group in which almost nothing happens is not an uninformative group: an observed zero or one death out of 50 at a concentration where the cloglog shape expects several is evidence against the cloglog shape. What it cannot do is close the gap. Even with the extra group the three shapes still differ by a factor of 2.437 at the LC1, so the honest report of an LC1 from any design like this is a range with a curve family attached to it.

Check 4: nominal concentration is not exposure

The concentration on the axis is usually the nominal one: what was weighed out and diluted. What the animals experienced can be lower, because the compound sorbs to glass and to the animals, volatilises, degrades in light, or is taken up. And it varies from vessel to vessel, because none of those processes is identical in two beakers.

Model this as a systematic loss plus multiplicative scatter: the actual concentration in a vessel is the nominal concentration at a recovery of 70 per cent, times a lognormal factor with median one, so that half the vessels sit above the nominal recovery and half below. The design has 4 vessels of 15 animals at each of five nominal levels, and the check fits the same curve three ways: on nominal concentration, on the actual concentration in each vessel, and on a measured concentration, which is the actual one seen through an analytical error with a log standard deviation of 0.15. The scatter is swept from zero to 0.8 and each point on the sweep uses 400 data sets. Estimates are summarised by their geometric mean, which is the natural average on a log concentration axis.

set.seed(20260801)
f_loss <- 0.70; s_an <- 0.15; k_ex <- 4; m_ex <- 15
nom <- conc / f_loss
xn <- rep(nom, each = k_ex); nx <- length(xn); mx <- rep(m_ex, nx)
lt10 <- log(e_true) + qlogis(0.1) / b_true
n_rep4 <- 400
s_grid <- c(0, 0.15, 0.30, 0.45, 0.60, 0.80)
print(round(c(nominal = nom), 2))
nominal1 nominal2 nominal3 nominal4 nominal5 
    5.29     9.57    17.14    30.86    55.57 
print(round(c(vessels_per_level = k_ex, animals_per_vessel = m_ex,
              total_animals = nx * m_ex, recovery_pct = 100 * f_loss,
              analytical_sd = s_an, replicates = n_rep4,
              true_nominal_lc50 = e_true / f_loss,
              true_nominal_lc10 = exp(lt10) / f_loss), 3))
 vessels_per_level animals_per_vessel      total_animals       recovery_pct 
             4.000             15.000            300.000             70.000 
     analytical_sd         replicates  true_nominal_lc50  true_nominal_lc10 
             0.150            400.000             17.143              6.314 
sweep <- t(vapply(s_grid, function(s) {
  del <- matrix(rnorm(n_rep4 * nx, 0, s), n_rep4, nx)
  act <- exp(log(rep(xn, each = n_rep4) * f_loss) + del)
  y <- matrix(rbinom(n_rep4 * nx, m_ex,
                     plogis(b_true * (log(act) - log(e_true)))), n_rep4, nx)
  meas <- act * exp(matrix(rnorm(n_rep4 * nx, 0, s_an), n_rep4, nx))
  z <- t(vapply(seq_len(n_rep4), function(i) {
    cn <- fit2(xn, y[i, ], mx)$co; ca <- fit2(act[i, ], y[i, ], mx)$co
    cm <- fit2(meas[i, ], y[i, ], mx)$co
    c(cn[2], -cn[1] / cn[2], (qlogis(0.1) - cn[1]) / cn[2],
      ca[2], -ca[1] / ca[2], (qlogis(0.1) - ca[1]) / ca[2],
      cm[2], -cm[1] / cm[2], (qlogis(0.1) - cm[1]) / cm[2])
  }, numeric(9)))
  c(exposure_sd = s,
    slope_nominal = mean(z[, 1]) / b_true,
    lc50_nominal = exp(mean(z[, 2]) - log(e_true / f_loss)),
    lc10_nominal = exp(mean(z[, 3]) - (lt10 - log(f_loss))),
    slope_actual = mean(z[, 4]) / b_true,
    lc50_actual = exp(mean(z[, 5]) - log(e_true)),
    lc10_actual = exp(mean(z[, 6]) - lt10),
    slope_measured = mean(z[, 7]) / b_true,
    lc50_measured = exp(mean(z[, 8]) - log(e_true)),
    lc10_measured = exp(mean(z[, 9]) - lt10))
}, numeric(10)))
print(round(sweep, 4))
     exposure_sd slope_nominal lc50_nominal lc10_nominal slope_actual
[1,]        0.00        1.0193       1.0022       1.0085       1.0193
[2,]        0.15        0.9868       1.0031       0.9765       1.0092
[3,]        0.30        0.9453       1.0090       0.9302       1.0115
[4,]        0.45        0.8695       1.0037       0.8325       1.0137
[5,]        0.60        0.7908       0.9933       0.7084       1.0201
[6,]        0.80        0.7153       1.0024       0.5787       1.0111
     lc50_actual lc10_actual slope_measured lc50_measured lc10_measured
[1,]      1.0022      1.0085         0.9909        1.0027        0.9780
[2,]      1.0003      0.9984         0.9818        1.0001        0.9675
[3,]      1.0077      1.0058         0.9847        1.0064        0.9751
[4,]      0.9974      0.9982         0.9830        0.9967        0.9655
[5,]      0.9924      0.9976         0.9892        0.9910        0.9632
[6,]      1.0100      1.0068         0.9877        1.0068        0.9781
print(round(sweep[4, -1], 3))
 slope_nominal   lc50_nominal   lc10_nominal   slope_actual    lc50_actual 
         0.869          1.004          0.832          1.014          0.997 
   lc10_actual slope_measured  lc50_measured  lc10_measured 
         0.998          0.983          0.997          0.965 
print(round(c(quartile_low = exp(qnorm(0.25) * 0.45),
              quartile_high = exp(qnorm(0.75) * 0.45)), 3))
 quartile_low quartile_high 
        0.738         1.355 
print(round(c(attenuation_at_045 = 100 * (1 - sweep[4, "slope_nominal"] /
                                            sweep[1, "slope_nominal"]),
              lc10_shortfall_pct = 100 * (1 - sweep[4, "lc10_nominal"]),
              lc50_shift_pct = 100 * (sweep[4, "lc50_nominal"] - 1)), 1))
attenuation_at_045.slope_nominal  lc10_shortfall_pct.lc10_nominal 
                            14.7                             16.8 
     lc50_shift_pct.lc50_nominal 
                             0.4 
swl <- data.frame(
  s = rep(sweep[, "exposure_sd"], 3),
  ratio = c(sweep[, "slope_nominal"], sweep[, "lc50_nominal"],
            sweep[, "lc10_nominal"]),
  what = rep(c("slope", "LC50", "LC10"), each = nrow(sweep)))
swl$what <- factor(swl$what, levels = c("LC50", "slope", "LC10"))

ggplot(swl, aes(s, ratio, colour = what)) +
  geom_hline(yintercept = 1, colour = "#b8b6a4", linewidth = 0.8) +
  geom_vline(xintercept = 0.45, colour = te_pal$ink, linetype = "22",
             linewidth = 0.5) +
  annotate("text", x = 0.465, y = 1.075, label = "the scatter quoted in the text",
           hjust = 0, size = 3, colour = te_pal$ink) +
  geom_line(linewidth = 0.9) +
  geom_point(size = 2.4) +
  scale_colour_manual(values = c(LC50 = te_pal$forest, slope = te_pal$gold,
                                 LC10 = te_pal$clay), name = NULL) +
  scale_y_continuous(limits = c(0.55, 1.1)) +
  labs(x = "Log standard deviation of actual exposure between vessels",
       y = "Estimate divided by truth",
       title = "The LC50 survives what the LC10 does not") +
  theme_te() +
  theme(legend.position = "top")
Line plot with the log standard deviation of exposure on the horizontal axis and the ratio of estimate to truth on the vertical axis. A horizontal reference line sits at one and a dashed vertical line labelled as the scatter quoted in the text stands at a scatter of nought point four five. The LC50 line stays flat on the reference across the whole range. The slope line and the LC10 line both fall steadily, reaching about seven tenths and six tenths at the right hand edge, with the LC10 line falling faster.
Figure 4: Geometric mean of the estimated slope, LC50 and LC10 relative to their true values, as the vessel to vessel scatter in actual exposure grows. All three are read on the nominal concentration axis, so the systematic shortfall of actual against nominal concentration is already divided out. The labelled dashed line marks the scatter quoted in the text.

The systematic part is the easy part. A recovery of 70 per cent moves the whole curve: the nominal concentration that kills half the animals is 17.143 when the actual LC50 is 12, and dividing by the recovery puts it back. Any laboratory that measures its concentrations can do this arithmetic, and the check scores the fits against the correct nominal target so that the systematic shift does not contaminate the rest.

The scatter is the part that hides. At a log standard deviation of 0.45, which puts the middle half of the vessels between 0.738 and 1.355 times the median exposure, the slope estimated on the nominal axis is 14.7 per cent below what the same fit returns with no scatter at all, and the LC10 comes out 16.8 per cent below its true nominal value of 6.314. The LC50 moves by 0.4 per cent. The curve flattens around a fixed point, and the fixed point is the LC50.

That is a measurement error problem with a wrinkle that is worth naming. In the classical version, described in Measurement error and regression dilution, the recorded predictor is a noisy version of the true one and the slope is pulled towards zero by the ratio of signal to total variance. Here the recorded predictor is the nominal concentration, which is fixed by the experimenter, and the true exposure scatters around it. That is Berkson error, and in a linear model it would leave the slope alone. It flattens this curve anyway, because the fitted curve is the average of logistic curves centred at different places, and an average of sigmoids is a shallower sigmoid. The mechanism is not regression dilution and the consequence looks identical.

Fitting on the vessel-specific actual concentrations removes the flattening entirely: the slope ratio returns to 1.014 at the same scatter, and the LC10 to 0.998. Fitting on measured concentrations with an analytical log standard deviation of 0.15 recovers most of it, a slope ratio of 0.983 and an LC10 ratio of 0.965, with the residual gap now being genuine classical error in the analytical measurement. Measuring the vessels is worth more than any of the modelling in this post.

There is one direction of the result that deserves stating plainly, because it is the opposite of a comforting story. The number that survives the defect is the LC50, which is the number that is easiest to check against other laboratories. The number that fails is the LC10, which is the number an assessment uses when it wants a concentration that does little harm, and it fails in the direction that makes the compound look more toxic than it is. That is conservative, and it is still wrong, and it will not be conservative for a compound whose exposure scatter runs the other way.

The honest limit

Every check above knows the answer. On real data none of them tells you which defect you have, and the reason is that three of the four leave the same fingerprint. Take the exposure scatter from check 4, with no beta-binomial mixture anywhere in the generating model, and run check 2’s diagnostics on it.

set.seed(20260801)
n_rep5 <- 300
del <- matrix(rnorm(n_rep5 * nx, 0, 0.45), n_rep5, nx)
act <- exp(log(rep(xn, each = n_rep5) * f_loss) + del)
y5 <- matrix(rbinom(n_rep5 * nx, m_ex,
                    plogis(b_true * (log(act) - log(e_true)))), n_rep5, nx)
res5 <- t(vapply(seq_len(n_rep5), function(i) {
  gg <- fit2(xn, y5[i, ], mx)
  pr <- (y5[i, ] / m_ex - gg$mu) / sqrt(gg$mu * (1 - gg$mu) / m_ex)
  z <- fit_bb(xn, y5[i, ], mx)
  c(phi = sum(pr^2) / gg$df, rho = unname(z["rho"]), conv = unname(z["conv"]))
}, numeric(3)))
print(round(c(replicates = n_rep5, exposure_sd = 0.45,
              mean_dispersion = mean(res5[, "phi"]),
              mean_rho_hat = mean(res5[, "rho"]),
              implied_design_effect = 1 + (m_ex - 1) * mean(res5[, "rho"]),
              nonconvergence = sum(res5[, "conv"] != 0)), 3))
           replicates           exposure_sd       mean_dispersion 
              300.000                 0.450                 2.713 
         mean_rho_hat implied_design_effect        nonconvergence 
                0.111                 2.552                 0.000 

Exposure scatter of the size used in check 4 produces a dispersion statistic of 2.713 and an estimated intraclass correlation of 0.111, which implies a design effect of 2.552 for its 15 animals per vessel. Check 2’s own data, generated by a genuine beta-binomial mixture, gave a dispersion of 2.811. The two mechanisms are not distinguishable from the dispersion statistic, and that matters because the repairs are different. If the extra variance is heterogeneity in susceptibility between vessels, the quasi-binomial interval is the right answer and the point estimate was never biased. If it is exposure scatter, the interval is still too narrow and the slope is attenuated as well, so widening the interval treats the symptom and leaves the LC10 wrong.

The same ambiguity runs through the rest. Control mortality and a flattened curve both push the low-dose end of the fit upwards. A shape that is wrong in the tail and a genuine change in mechanism at low concentrations look identical in the tested range, which is where check 3 stops being about statistics. And all four checks assume the response is a smooth monotone function of concentration, which is the assumption that Hormesis and non-monotonic responses declines to make. The only check in this post that a laboratory can act on without a simulation is the one that involves no statistics at all: measure the concentrations in the vessels, report them, and fit on them.

Where to go next

Three of these four defects are design problems wearing an analysis costume. The control group carries information about the lower asymptote and should be in the likelihood rather than in a correction factor. The vessel is the experimental unit and either belongs in the model or belongs in the standard error. The lowest tested concentration sets the floor under which every effective dose is an extrapolation, and one cheap group below it buys a measurable amount of tail. Only the exposure question needs a chemist rather than a statistician.

For the interval machinery itself, Confidence intervals for effective doses compares the delta method used throughout this post with Fieller’s theorem and with profile likelihood, and shows how far the delta method drifts in the tail where check 3 works. For the diagnostic side, Checking a bounded-response model covers residuals for proportion data, which is the tool that finds the overdispersion in check 2 before it has cost you an interval.

References

Ritz C 2010 Environmental Toxicology and Chemistry 29(1):220-229 (10.1002/etc.7)

Ritz C, Baty F, Streibig JC, Gerhard D 2015 PLOS ONE 10(12):e0146021 (10.1371/journal.pone.0146021)

Williams DA 1975 Biometrics 31(4):949-952 (10.2307/2529820)

Abbott WS 1925 Journal of Economic Entomology 18(2):265-267 (10.1093/jee/18.2.265a)

Carroll RJ, Ruppert D, Stefanski LA, Crainiceanu CM 2006 Measurement Error in Nonlinear Models: A Modern Perspective. 2nd edition. Chapman and Hall/CRC, ISBN 978-1-58488-633-4

Newsletter

Get new tutorials by email

New R and QGIS tutorials for ecologists, straight to your inbox. No spam; unsubscribe anytime.

By subscribing you agree to receive these emails and confirm your address once. See the privacy policy.