Model discrepancy in simulator calibration

R
simulation models
Gaussian processes
calibration
model misspecification
ecology tutorial
Calibrating a linear intake simulator against a saturating truth in R: a GP discrepancy stays honest only while its amplitude is set from outside the data.
Author

Tidy Ecology

Published

2026-09-16

A shorebird foraging model is built around one assumption about intake: a bird walking a mudflat takes cockles at a rate proportional to their density, so intake is an attack rate times density. The simulator then runs days, tides and a whole wintering population on that line. The attack rate is not known, so it is calibrated against feeding trials in which captive birds are offered cockles at a range of densities and the intake is timed. The trouble is that a real bird has to open each shell. Handling time bends the true intake curve below the line, gently at low density and more at high density, and no value of the attack rate makes the simulator’s straight line go through the trial data.

That is the calibration problem Kennedy and O’Hagan wrote down in 2001: reality equals the simulator at the right parameter value plus a model discrepancy, a function of the input that the simulator does not contain, given a Gaussian process prior. Emulating a slow simulation model builds the other half of their framework, the Gaussian process that stands in for a simulator too slow to run often, and says in its closing limits that it stops well short of calibrating anything against field data. This post starts where that one stops. The simulator here is fast and needs no emulator; the question is what the discrepancy term does to the calibrated parameter.

Two other posts on this site sit next to the question. Light response curves: the curvature you drop shows that fitting a curve of the wrong shape gives a bias that is a projection of the truth onto the wrong family, fixed by the design and not a feature of the noise. That is the plain least-squares calibration here, and it is given one line below rather than a section. Starting values and identifiability in nls ends on parameters that the data cannot separate however the fit is started. A discrepancy flexible enough to absorb any error in the simulator produces exactly that situation for the attack rate, and the likelihood has no way to tell the two apart.

None of the results below are new. Brynjarsdottir and O’Hagan (2014) used this very pair, a simulator theta x against a reality theta x / (1 + x / a), to show that a discrepancy with a vague prior leaves the physical parameter unlearned and that realistic learning about it needs prior information about the discrepancy itself, and they imposed the same zero value and zero slope at the origin that is used below (together with monotonicity). Tuo and Wu (2015) showed that the Kennedy and O’Hagan parameter is not in general the value that makes the simulator closest to reality, and Plumlee (2017) proposed a discrepancy prior orthogonal to the simulator’s gradient so that the parameter has a definite meaning. The constants here differ from theirs: an attack rate of 1.2, a saturation constant of 10, trial densities from 0.1 to 4 and an observation standard deviation of 0.05 that is treated as known. What the post measures is how each of those arguments looks in coverage, standard errors and sample size, and one thing that software defaults decide without asking: whether the size of the discrepancy is fixed in advance or estimated from the trial data it is meant to correct.

library(ggplot2)
library(patchwork)

te_paper  <- "#f5f4ee"
te_ink    <- "#16241d"
te_body   <- "#2c3a31"
te_forest <- "#275139"
te_rust   <- "#b5534e"
te_gold   <- "#c9b458"
te_line   <- "#dad9ca"

theme_datasheet <- function() {
  theme_minimal(base_size = 12) +
    theme(plot.background  = element_rect(fill = te_paper, colour = NA),
          panel.background = element_rect(fill = te_paper, colour = NA),
          panel.grid.major = element_line(colour = te_line, linewidth = 0.3),
          panel.grid.minor = element_blank(),
          text             = element_text(colour = te_body),
          plot.title       = element_text(colour = te_ink, face = "bold"),
          plot.subtitle    = element_text(colour = te_body),
          axis.text        = element_text(colour = te_body),
          strip.text       = element_text(colour = te_ink))
}

A linear simulator and a saturating truth

The true intake is the Holling type II form theta x / (1 + x / 10) with theta = 1.2, which is the disc equation of Functional responses in R written with the attack rate in front. The simulator is theta x. Trials are equally spaced over densities 0.1 to 4 and the intake is measured with independent normal error of standard deviation 0.05. The discrepancy is a squared-exponential Gaussian process, as in Gaussian process regression from scratch, and for any fixed amplitude and length-scale the calibrated attack rate is a generalised least-squares estimate with the discrepancy covariance plus the observation variance as its covariance matrix.

The constrained version, as Brynjarsdottir and O’Hagan did, conditions that process on two facts a behavioural ecologist knows before any trial is run: at zero density both the true intake and the simulator are zero, so the discrepancy is zero there, and at vanishing density a bird is not yet limited by handling, so the discrepancy has zero slope at zero as well. A squared-exponential process and its derivative are jointly Gaussian, and conditioning on the value and the derivative at zero gives a closed-form covariance, which is the k_con function below.

theta_true <- 1.2
sat_k      <- 10
sd_obs     <- 0.05
x_lo       <- 0.1
x_hi       <- 4
n_set      <- c(10, 30, 90, 270)
amp_fix    <- 1
len_fix    <- 1.5
z_95       <- qnorm(0.975)

bound_log       <- 6
x_in            <- 2.05
x_out           <- 6
n_rep_ml        <- c(`10` = 200, `30` = 200, `90` = 200, `270` = 60)
n_rep_bayes     <- 100
amp_prior_sd    <- 1
len_prior_med   <- 1.5
len_prior_sdlog <- 0.75

eta_fun <- function(x, a0 = 0) a0 + theta_true * x / (1 + x / sat_k)
x_design <- function(n) seq(x_lo, x_hi, length.out = n)

k_se <- function(a, b, amp, len) {
  amp^2 * exp(-outer(a, b, "-")^2 / (2 * len^2))
}
k_con <- function(a, b, amp, len) {
  ga <- exp(-a^2 / (2 * len^2))
  gb <- exp(-b^2 / (2 * len^2))
  k_se(a, b, amp, len) - amp^2 * (outer(ga, gb) + outer(ga * a, gb * b) / len^2)
}
k_none <- function(a, b, amp, len) matrix(0, length(a), length(b))
k_pick <- function(kind) switch(kind, none = k_none, gp = k_se, con = k_con)

cov_obs <- function(kind, x, amp, len) {
  k_pick(kind)(x, x, amp, len) + diag(sd_obs^2, length(x))
}
set.seed(4102)
n_demo  <- 30
x_demo  <- x_design(n_demo)
y_demo  <- eta_fun(x_demo) + rnorm(n_demo, 0, sd_obs)
ls_demo <- sum(x_demo * y_demo) / sum(x_demo^2)
gap_4   <- theta_true * x_hi - eta_fun(x_hi)
gap_pct <- 100 * gap_4 / eta_fun(x_hi)
proj_theta <- integrate(function(x) x * eta_fun(x), x_lo, x_hi)$value /
  integrate(function(x) x^2, x_lo, x_hi)$value

At the highest trial density the truth sits 1.37 below the true-rate line, 40 per cent of the true intake there. On one simulated set of 30 trials the least-squares attack rate is 0.922. The no-discrepancy calibration is the case the light response post already covers: its target is the projection of the truth onto straight lines through the origin, which over the trial range is 0.9266, and its interval shrinks around the projection at the design points, which approaches that value as trials are added, so it never covers 1.2 at any n in the table below.

x_line <- seq(0, 6, length.out = 200)
curve_df <- rbind(
  data.frame(x = x_line, y = eta_fun(x_line), what = "truth, saturating"),
  data.frame(x = x_line, y = ls_demo * x_line, what = "simulator, least squares"),
  data.frame(x = x_line, y = theta_true * x_line, what = "simulator, true attack rate"))
ggplot(curve_df, aes(x, y, colour = what, linetype = what)) +
  annotate("rect", xmin = x_hi, xmax = 6, ymin = -Inf, ymax = Inf,
           fill = te_line, alpha = 0.45) +
  geom_line(linewidth = 0.9) +
  geom_point(data = data.frame(x = x_demo, y = y_demo), aes(x, y),
             inherit.aes = FALSE, colour = te_ink, size = 1.6) +
  scale_colour_manual(values = c(te_gold, te_rust, te_forest), name = NULL) +
  scale_linetype_manual(values = c("solid", "dashed", "solid"), name = NULL) +
  labs(x = "prey density", y = "intake rate",
       title = "A straight line against a gentle bend",
       subtitle = "shaded: beyond the trial densities, where the simulator will be used") +
  theme_datasheet() +
  theme(legend.position = "bottom")
A line chart of intake rate against prey density from 0 to 6 on warm off-white paper, with the region beyond density 4 shaded grey. Thirty dark points from density 0.1 to 4 follow a dark green saturating curve that reaches about 3.4 at density 4 and 4.5 at density 6. A gold straight least-squares line runs through the points, slightly below them in the middle and above the green curve beyond 4, ending near 5.5 at density 6. A dashed red straight line for the true attack rate rises more steeply, reaching about 4.8 at density 4 and 7.2 at density 6.
Figure 1: One simulated feeding trial: the saturating truth, the thirty observations, the least-squares fit of the linear simulator and the linear curve with the true attack rate.

The straight line fitted by least squares passes through the trial data closely enough that a residual plot from thirty points would not raise much alarm. Its slope is well below the true attack rate, and beyond the trial range, where a foraging model is run at densities the trials never offered, the fitted line and the truth separate.

With the hyperparameters fixed, the table is arithmetic

Fix the discrepancy amplitude at 1 and the length-scale at 1.5. An amplitude of 1 is generous next to a true intake that rises to 3.43 over the trials, and those are the values used before any data were simulated. With both fixed, every arm is a linear estimator of the data: a weight vector w times y. Its mean is w times the true curve, its sampling standard deviation is 0.05 times the length of w, and the standard error it reports is the GLS value. Coverage then follows from two normal probabilities, and none of the table below comes from a simulation.

linear_arm <- function(kind, n, amp = amp_fix, len = len_fix, a0 = 0,
                       target = theta_true) {
  x  <- x_design(n)
  kx <- solve(cov_obs(kind, x, amp, len), x)
  v  <- 1 / sum(x * kx)
  w  <- v * kx
  m  <- sum(w * eta_fun(x, a0))
  sd_w <- sd_obs * sqrt(sum(w^2))
  se <- sqrt(v)
  data.frame(n = n, arm = kind, mean = m, se = se, sd_true = sd_w,
             cover = pnorm((target - m + z_95 * se) / sd_w) -
                     pnorm((target - m - z_95 * se) / sd_w),
             sum_w = sum(w))
}
cf_tab <- do.call(rbind, lapply(n_set, function(n)
  do.call(rbind, lapply(c("none", "gp", "con"), linear_arm, n = n))))
cf_get <- function(arm, n, col) cf_tab[cf_tab$arm == arm & cf_tab$n == n, col]

n_check <- 400
set.seed(733)
x_chk  <- x_design(30)
ch_chk <- lapply(c(gp = "gp", con = "con"),
                 function(kind) chol(cov_obs(kind, x_chk, amp_fix, len_fix)))
gls_chol <- function(ch, x, y) {
  xs <- backsolve(ch, x, transpose = TRUE)
  ys <- backsolve(ch, y, transpose = TRUE)
  v  <- 1 / sum(xs^2)
  c(est = v * sum(xs * ys), se = sqrt(v))
}
chk <- replicate(n_check, {
  y <- eta_fun(x_chk) + rnorm(30, 0, sd_obs)
  sapply(ch_chk, function(ch) {
    g <- gls_chol(ch, x_chk, y)
    c(g, hit = abs(g[["est"]] - theta_true) < z_95 * g[["se"]])
  })
})
chk_cover <- apply(chk["hit", , ], 1, mean)
chk_mean  <- apply(chk["est", , ], 1, mean)
chk_mcse  <- sqrt(chk_cover * (1 - chk_cover) / n_check)
knitr::kable(cf_tab[, c("n", "arm", "mean", "se", "cover")], digits = 3,
             row.names = FALSE)
n arm mean se cover
10 none 0.916 0.007 0.000
10 gp 0.856 0.223 0.950
10 con 1.081 0.126 0.933
30 none 0.923 0.004 0.000
30 gp 0.857 0.219 0.931
30 con 1.126 0.092 0.934
90 none 0.925 0.002 0.000
90 gp 0.856 0.213 0.930
90 con 1.151 0.066 0.940
270 none 0.926 0.001 0.000
270 gp 0.853 0.208 0.927
270 con 1.170 0.047 0.952

A check that the closed form is right: 400 simulated datasets at n = 30 give coverage 0.938 for the unconstrained discrepancy and 0.935 for the constrained one (Monte Carlo standard errors 0.012 and 0.012), against 0.931 and 0.934 from the formula, with mean estimates 0.855 and 1.125 against 0.857 and 1.126.

The unconstrained discrepancy buys nominal coverage, 0.950 at 10 trials and 0.927 at 270, and it buys it by not learning. Its mean estimate is 0.856 at 10 trials and 0.853 at 270, further from 1.2 than plain least squares, and its standard error goes from 0.223 to 0.208 while the number of trials rises twenty-sevenfold. That plateau is what Brynjarsdottir and O’Hagan describe: a discrepancy that can take any smooth shape can also take the shape of a change in the attack rate, so more trials inform the sum and not its parts. The interval is honest only because it is as wide as the prior on the discrepancy allows.

The constrained discrepancy behaves differently. The estimate moves from 1.081 at 10 trials to 1.170 at 270, the standard error falls from 0.126 to 0.047, and the coverage stays between 0.933 and 0.952. Near zero density a discrepancy with zero value and zero slope cannot imitate a change in slope, so the low-density trials pin the attack rate. The estimate is still below 1.2 at 270 trials; the interval covers because the bias shrinks alongside the standard error, not because it has gone.

arm_lab <- c(none = "no discrepancy", gp = "GP discrepancy",
             con = "GP, zero value and slope at zero density")
cf_plot <- cf_tab
cf_plot$arm_name <- factor(arm_lab[cf_plot$arm], levels = arm_lab)
ggplot(cf_plot, aes(factor(n), mean, colour = arm_name)) +
  geom_hline(yintercept = theta_true, linetype = "dashed", colour = te_ink) +
  geom_errorbar(aes(ymin = mean - z_95 * se, ymax = mean + z_95 * se),
                width = 0.25, linewidth = 0.8,
                position = position_dodge(width = 0.6)) +
  geom_point(size = 2.4, position = position_dodge(width = 0.6)) +
  scale_colour_manual(values = c(te_rust, te_gold, te_forest), name = NULL) +
  labs(x = "feeding trials (n)", y = "attack rate",
       title = "Fixed hyperparameters: honest, but only one arm learns",
       subtitle = "dashed: the true attack rate") +
  guides(colour = guide_legend(nrow = 2)) +
  theme_datasheet() +
  theme(legend.position = "bottom")
A dot and interval chart of the attack rate against the number of feeding trials, 10, 30, 90 and 270, with a dashed horizontal line at 1.2. At each sample size a red point for no discrepancy sits near 0.92 with an interval too short to see beyond the point. A gold point for the GP discrepancy sits near 0.86 with a long interval from about 0.43 to 1.3 that barely changes with sample size. A dark green point for the constrained GP rises from about 1.08 at 10 trials to 1.17 at 270, and its interval, which crosses 1.2 every time, shrinks from about 0.83 to 1.33 down to about 1.08 to 1.26.
Figure 2: Closed-form mean estimate and mean 95 per cent interval for the attack rate, fixed hyperparameters, by number of feeding trials.

Learning the discrepancy from the same data

Nobody running a calibration package types an amplitude of 1. The default is to estimate the discrepancy hyperparameters, and the cheapest way, used here, is maximum likelihood (type II): maximise the likelihood of the trial data over the log amplitude and log length-scale, with the attack rate profiled out by GLS at each step. Kennedy and O’Hagan themselves plugged in estimated hyperparameters rather than integrating over them. The optimiser is Nelder-Mead run from four starting points (amplitude and length-scale 0.5 and 1, 5 and 5, 0.05 and 0.3, 2 and 10), keeping the fit with the highest likelihood, with both log parameters kept inside plus or minus 6. The run uses 200 datasets at each of 10, 30 and 90 trials and 60 at 270, to keep the knit short; each dataset is also calibrated with the fixed hyperparameters so the two can be compared on the same draws.

starts_log <- list(c(log(0.5), log(1)), c(log(5), log(5)),
                   c(log(0.05), log(0.3)), c(log(2), log(10)))

fit_hyper <- function(kind, x, y) {
  sq_dist <- outer(x, x, "-")^2
  x_prod  <- outer(x, x)
  nll <- function(p) {
    if (any(abs(p) > bound_log)) return(1e10)
    amp <- exp(p[1])
    len <- exp(p[2])
    kmat <- exp(-sq_dist / (2 * len^2))
    if (kind == "con") {
      g <- exp(-x^2 / (2 * len^2))
      kmat <- kmat - outer(g, g) * (1 + x_prod / len^2)
    }
    ch <- tryCatch(chol(amp^2 * kmat + diag(sd_obs^2, length(x))),
                   error = function(e) NULL)
    if (is.null(ch)) return(1e10)
    xs <- backsolve(ch, x, transpose = TRUE)
    ys <- backsolve(ch, y, transpose = TRUE)
    r  <- ys - xs * sum(xs * ys) / sum(xs^2)
    0.5 * sum(r^2) + sum(log(diag(ch)))
  }
  fits <- lapply(starts_log, function(s)
    optim(s, nll, control = list(maxit = 200, reltol = 1e-6)))
  vals <- vapply(fits, function(o) o$value, numeric(1))
  opt  <- fits[[which.min(vals)]]
  c(amp = exp(opt$par[1]), len = exp(opt$par[2]),
    at_bound = any(abs(opt$par) > bound_log - 0.05),
    first_worse = vals[1] - min(vals) > 0.01)
}

predict_arm <- function(kind, x, y, amp, len, a0 = 0) {
  kfun <- k_pick(kind)
  ch <- chol(cov_obs(kind, x, amp, len))
  solve_k <- function(b) backsolve(ch, backsolve(ch, b, transpose = TRUE))
  kx  <- solve_k(x)
  v   <- 1 / sum(x * kx)
  est <- v * sum(kx * y)
  x_new <- c(x_in, x_out)
  ks  <- kfun(x_new, x, amp, len)
  p_mean <- drop(x_new * est + ks %*% solve_k(y - x * est))
  p_var  <- diag(kfun(x_new, x_new, amp, len)) -
    rowSums(ks * t(solve_k(t(ks)))) + drop(x_new - ks %*% kx)^2 * v
  p_sd <- sqrt(pmax(p_var, 0))
  hit  <- abs(p_mean - eta_fun(x_new, a0)) < z_95 * p_sd
  c(est = est, se = sqrt(v), hit_in = hit[1], hit_out = hit[2],
    psd_in = p_sd[1], psd_out = p_sd[2])
}

set.seed(9150)
ml_rows <- list()
for (n in n_set) {
  x  <- x_design(n)
  mu <- eta_fun(x)
  for (i in seq_len(n_rep_ml[[as.character(n)]])) {
    y <- mu + rnorm(n, 0, sd_obs)
    for (kind in c("gp", "con")) {
      hp <- fit_hyper(kind, x, y)
      ml_rows[[length(ml_rows) + 1]] <- data.frame(
        n = n, arm = kind, fit = "estimated", t(hp),
        t(predict_arm(kind, x, y, hp[["amp"]], hp[["len"]])))
      ml_rows[[length(ml_rows) + 1]] <- data.frame(
        n = n, arm = kind, fit = "fixed", amp = amp_fix, len = len_fix,
        at_bound = 0, first_worse = 0,
        t(predict_arm(kind, x, y, amp_fix, len_fix)))
    }
  }
}
ml_all <- do.call(rbind, ml_rows)
ml_all$hit <- abs(ml_all$est - theta_true) < z_95 * ml_all$se

ml_sum <- do.call(rbind, lapply(split(ml_all, list(ml_all$n, ml_all$arm, ml_all$fit)),
  function(d) data.frame(n = d$n[1], arm = d$arm[1], fit = d$fit[1],
                         reps = nrow(d), mean = mean(d$est), se = mean(d$se),
                         cover = mean(d$hit),
                         mcse = sqrt(mean(d$hit) * (1 - mean(d$hit)) / nrow(d)),
                         amp = median(d$amp), len = median(d$len),
                         at_bound = mean(d$at_bound),
                         first_worse = mean(d$first_worse),
                         cover_in = mean(d$hit_in), cover_out = mean(d$hit_out),
                         psd_in = mean(d$psd_in), psd_out = mean(d$psd_out))))
ml_sum <- ml_sum[order(ml_sum$fit, ml_sum$arm, ml_sum$n), ]
ml_get <- function(arm, fit, n, col) {
  ml_sum[ml_sum$arm == arm & ml_sum$fit == fit & ml_sum$n == n, col]
}
knitr::kable(ml_sum[ml_sum$fit == "estimated",
                    c("n", "arm", "reps", "mean", "se", "cover", "amp", "len", "at_bound",
                      "first_worse")],
             digits = 3, row.names = FALSE)
n arm reps mean se cover amp len at_bound first_worse
10 con 200 1.148 0.030 0.545 403.330 61.997 0.845 0.375
30 con 200 1.164 0.022 0.415 403.155 62.150 0.670 0.225
90 con 200 1.177 0.017 0.645 8.012 8.999 0.315 0.075
270 con 60 1.182 0.012 0.767 8.990 9.174 0.083 0.033
10 gp 200 0.872 0.044 0.000 0.178 1.484 0.000 0.075
30 gp 200 0.867 0.046 0.000 0.188 1.667 0.000 0.080
90 gp 200 0.860 0.052 0.000 0.224 1.941 0.000 0.080
270 gp 60 0.853 0.059 0.000 0.287 2.254 0.000 0.083

The unconstrained discrepancy collapses. Its median fitted amplitude is 0.178 at 10 trials and 0.287 at 270, less than three tenths of the value fixed above, and with a small discrepancy the estimate is again forced to carry the misfit. The mean estimate stays at 0.872 to 0.853, but the reported standard error is now 0.044 at 10 trials and 0.059 at 270, between 0.20 and 0.28 of its fixed-amplitude value. The interval covers the true attack rate in 0 of 200 datasets at 10 trials, 0 of 200 at 90 and 0 of 60 at 270. The same trial data, calibrated with a fixed amplitude, covered in a fraction 0.950 of the datasets at 90 trials. The only difference is where the size of the discrepancy came from.

The reported standard error is 2.9 times the scatter of the estimates: across the datasets at 90 trials the standard deviation of the estimated-hyperparameter estimate is 0.018, against a mean reported standard error of 0.052. The interval fails on location, not on width: both numbers are small next to a bias of 0.340.

The constrained discrepancy with estimated hyperparameters is in between. Its coverage is 0.545, 0.415, 0.645 and 0.767 at 10, 30, 90 and 270 trials, with mean estimates from 1.148 to 1.182. Its hyperparameters behave differently from the unconstrained ones: the fitted amplitude and length-scale run off together along a ridge towards large values, where the constrained process looks like a curvature term with a variance of its own, and at 10 and 30 trials 85 and 67 per cent of fits ended at the bound on the log parameters. The likelihood is nearly flat along that ridge, so the bound itself changes little; a wider bound on the log parameters, tried outside the post, gave the same coverage within Monte Carlo error. The starting point is not harmless. The first start on its own stopped at a worse optimum (log-likelihood lower by more than 0.01) in 38 and 22 per cent of datasets at 10 and 30 trials, against 7 per cent for the unconstrained discrepancy at 10 trials, which is why every fit keeps the best of four starts.

est_long <- rbind(
  data.frame(ml_sum[, c("n", "arm", "fit")], metric = "coverage of the true rate",
             value = ml_sum$cover, lo = pmax(ml_sum$cover - 2 * ml_sum$mcse, 0),
             hi = pmin(ml_sum$cover + 2 * ml_sum$mcse, 1)),
  data.frame(ml_sum[, c("n", "arm", "fit")], metric = "mean standard error",
             value = ml_sum$se, lo = NA, hi = NA))
est_long$arm_name <- factor(ifelse(est_long$arm == "gp", "GP discrepancy",
                                   "GP, zero value and slope at zero"),
                            levels = c("GP discrepancy", "GP, zero value and slope at zero"))
est_long$fit <- factor(est_long$fit, levels = c("fixed", "estimated"),
                       labels = c("amplitude 1, length 1.5 fixed", "estimated by maximum likelihood"))
ref_df <- data.frame(metric = "coverage of the true rate", value = 0.95)
ggplot(est_long, aes(n, value, colour = fit)) +
  geom_hline(data = ref_df, aes(yintercept = value), linetype = "dashed",
             colour = te_ink) +
  geom_line(linewidth = 0.8) +
  geom_errorbar(aes(ymin = lo, ymax = hi), width = 0.08, linewidth = 0.5,
                na.rm = TRUE) +
  geom_point(size = 2.3) +
  facet_grid(metric ~ arm_name, scales = "free_y", switch = "y") +
  scale_x_log10(breaks = n_set) +
  expand_limits(y = 0) +
  scale_colour_manual(values = c(te_forest, te_rust), name = NULL) +
  labs(x = "feeding trials (n)", y = NULL,
       title = "Learning the discrepancy from the same data",
       subtitle = "bars: two Monte Carlo standard errors; dashed: 0.95") +
  theme_datasheet() +
  theme(legend.position = "bottom", strip.placement = "outside")
A two by two panel chart against feeding trials 10, 30, 90 and 270 on a log axis. Columns are the GP discrepancy and the GP with zero value and slope at zero; the top row is coverage of the true rate with a dashed line at 0.95 and the bottom row is mean standard error. Dark green lines for fixed hyperparameters sit near 0.88 to 0.97 coverage in both top panels, the lowest point being the plain GP at 270 trials. The red line for estimated hyperparameters lies flat at zero coverage for the plain GP, and for the constrained GP goes from about 0.55 at 10 trials down to 0.42 at 30 and up to 0.65 and 0.77, with error bars. In the bottom left panel the fixed standard error is almost flat near 0.21 to 0.22 and the estimated one rises slightly from about 0.04 to 0.06. In the bottom right panel the fixed standard error falls from about 0.13 to 0.05 and the estimated one from about 0.03 to 0.01.
Figure 3: Coverage of the 95 per cent interval for the attack rate and the mean reported standard error, with the discrepancy hyperparameters fixed in advance or estimated by maximum likelihood from the same data.

The lower panels show the mechanism in standard errors. The two fixed-amplitude arms differ in slope: flat for the unconstrained discrepancy, falling for the constrained one. Estimating the hyperparameters moves both curves down, and for the unconstrained discrepancy it moves it far below the size of the bias.

Prediction of intake inside the trial range mostly survives this. At a density of 2.05 the 95 per cent prediction interval for the true intake covers it in between 0.850 and 0.970 of datasets across all four arms and all sample sizes, because the discrepancy absorbs much of what the attack rate gets wrong; the lowest value is the unconstrained discrepancy with estimated hyperparameters at 270 trials, from 60 datasets. Outside the range it does not. At a density of 6 the unconstrained discrepancy with estimated hyperparameters covers the true intake in 0.190 to 0.267 of datasets, and even the constrained discrepancy with fixed hyperparameters falls from 0.885 at 10 trials to 0.383 at 270. The only arm that covers there, in at least 0.995 of datasets, is the unconstrained discrepancy with its amplitude fixed at 1, and it does so with a mean prediction standard deviation of 0.96 at 90 trials against a true intake of 4.50.

Would a weak prior have saved it?

An objection to the section above is fair: maximum likelihood is not what a Bayesian calibration does. A full calibration puts a prior on the hyperparameters and integrates over them. That can be done exactly on a grid for this problem. A flat prior on the attack rate integrates out analytically, leaving the restricted likelihood of each hyperparameter pair; the prior used is half-normal with scale 1 on the amplitude and lognormal with median 1.5 and log-scale standard deviation 0.75 on the length-scale, over an 18 by 18 grid with amplitude from 0.02 to 5 and length-scale from 0.2 to 12, and the posterior for the attack rate is the resulting mixture of normals. The grid bounds are part of the prior.

la_grid <- seq(log(0.02), log(5), length.out = 18)
ll_grid <- seq(log(0.2), log(12), length.out = 18)
hyper_grid <- expand.grid(la = la_grid, ll = ll_grid)
log_prior <- -exp(2 * hyper_grid$la) / (2 * amp_prior_sd^2) + hyper_grid$la +
  dnorm(hyper_grid$ll, log(len_prior_med), len_prior_sdlog, log = TRUE)

bayes_rows <- list()
set.seed(6628)
for (n in c(30, 90)) {
  x  <- x_design(n)
  mu <- eta_fun(x)
  chols <- lapply(c(gp = "gp", con = "con"), function(kind)
    lapply(seq_len(nrow(hyper_grid)), function(j)
      chol(cov_obs(kind, x, exp(hyper_grid$la[j]), exp(hyper_grid$ll[j])))))
  for (i in seq_len(n_rep_bayes)) {
    y <- mu + rnorm(n, 0, sd_obs)
    for (kind in c("gp", "con")) {
      grid_fit <- t(vapply(chols[[kind]], function(ch) {
        xs <- backsolve(ch, x, transpose = TRUE)
        ys <- backsolve(ch, y, transpose = TRUE)
        v  <- 1 / sum(xs^2)
        est <- v * sum(xs * ys)
        r  <- ys - xs * est
        c(ll = -0.5 * sum(r^2) - sum(log(diag(ch))) + 0.5 * log(v),
          est = est, se = sqrt(v))
      }, numeric(3)))
      lw <- grid_fit[, "ll"] + log_prior
      wt <- exp(lw - max(lw))
      wt <- wt / sum(wt)
      post_cdf <- function(th) sum(wt * pnorm(th, grid_fit[, "est"], grid_fit[, "se"]))
      lo <- uniroot(function(th) post_cdf(th) - 0.025, c(-5, 5))$root
      hi <- uniroot(function(th) post_cdf(th) - 0.975, c(-5, 5))$root
      bayes_rows[[length(bayes_rows) + 1]] <- data.frame(
        n = n, arm = kind, mean = sum(wt * grid_fit[, "est"]), lo = lo, hi = hi,
        amp_post = sum(wt * exp(hyper_grid$la)))
    }
  }
}
bayes_all <- do.call(rbind, bayes_rows)
bayes_all$hit <- bayes_all$lo < theta_true & bayes_all$hi > theta_true
bayes_sum <- do.call(rbind, lapply(split(bayes_all, list(bayes_all$n, bayes_all$arm)),
  function(d) data.frame(n = d$n[1], arm = d$arm[1], mean = mean(d$mean),
                         width = mean(d$hi - d$lo), cover = mean(d$hit),
                         mcse = sqrt(mean(d$hit) * (1 - mean(d$hit)) / nrow(d)),
                         amp_post = mean(d$amp_post))))
by_get <- function(arm, n, col) bayes_sum[bayes_sum$arm == arm & bayes_sum$n == n, col]
knitr::kable(bayes_sum, digits = 3, row.names = FALSE)
n arm mean width cover mcse amp_post
30 con 1.137 0.258 0.84 0.037 1.269
90 con 1.160 0.159 0.88 0.032 1.408
30 gp 0.860 0.545 0.10 0.030 0.556
90 gp 0.854 0.551 0.03 0.017 0.612

The weak prior does not save the unconstrained discrepancy. Its equal-tailed 95 per cent posterior interval is 0.545 wide on average at 30 trials, wider than the maximum likelihood interval, yet it contains 1.2 in 0.10 of the 100 datasets at 30 trials and 0.03 at 90 (Monte Carlo standard errors 0.030 and 0.017). The posterior mean amplitude is 0.56 at 30 trials: the data still pull the amplitude well below the prior scale, and the posterior mean attack rate stays at 0.860.

With the constraint, the same prior does most of the repair. Maximum likelihood gave this arm coverage of 0.415 at 30 trials; integrating over the grid gives 0.84 at 30 trials and 0.88 at 90 (standard errors 0.037 and 0.032), still below 0.95, and the interval narrows from 0.258 to 0.159. So in this example integrating over the hyperparameters matters for the constrained discrepancy, and it is the constraint, not the prior on the amplitude, that decides whether the attack rate can be learned at all.

When the constraint is only nearly true

The constraint used above is exactly true of the chosen truth, which flatters it. Suppose instead that the trials record a small intake even at zero density, from a timing convention or from birds taking a few cockles missed in the tray count, so the truth is 0.05 + theta x / (1 + x / 10). The intercept is one observation standard deviation. The constrained discrepancy is still told that the discrepancy is zero at zero density, which is now false by 0.05.

A different kind of prior information is a discrepancy orthogonal to the simulator’s gradient, as Plumlee proposed. Here the gradient of the simulator with respect to the attack rate is x, so the constraint is that the integral of x times the discrepancy over the trial range is zero, imposed on the same squared-exponential process through a 400-point quadrature. Both cases are linear estimators with fixed hyperparameters, so they are closed form again.

a0_small <- 0.05
q_n  <- 400
q_x  <- x_lo + (seq_len(q_n) - 0.5) * (x_hi - x_lo) / q_n
q_wx <- rep((x_hi - x_lo) / q_n, q_n) * q_x
k_orth <- function(a, b, amp, len) {
  ka <- k_se(a, q_x, amp, len) %*% q_wx
  kb <- k_se(b, q_x, amp, len) %*% q_wx
  k_se(a, b, amp, len) - ka %*% t(kb) / drop(t(q_wx) %*% k_se(q_x, q_x, amp, len) %*% q_wx)
}
k_pick <- function(kind) switch(kind, none = k_none, gp = k_se, con = k_con,
                                orth = k_orth)
near_tab <- do.call(rbind, lapply(n_set, function(n) rbind(
  cbind(linear_arm("con", n, a0 = a0_small), case = "constrained, small intercept"),
  cbind(linear_arm("gp", n, a0 = a0_small), case = "unconstrained, small intercept"),
  cbind(linear_arm("orth", n), case = "orthogonal, true rate"),
  cbind(linear_arm("orth", n, target = proj_theta), case = "orthogonal, projection"))))
nr_get <- function(case, n, col) near_tab[near_tab$case == case & near_tab$n == n, col]
knitr::kable(near_tab[, c("n", "case", "mean", "se", "cover", "sum_w")], digits = 3,
             row.names = FALSE)
n case mean se cover sum_w
10 constrained, small intercept 1.187 0.126 0.996 2.125
10 unconstrained, small intercept 0.867 0.223 0.967 0.218
10 orthogonal, true rate 0.926 0.007 0.000 0.374
10 orthogonal, projection 0.926 0.007 0.951 0.374
30 constrained, small intercept 1.262 0.092 0.952 2.721
30 unconstrained, small intercept 0.867 0.219 0.952 0.211
30 orthogonal, true rate 0.926 0.004 0.000 0.375
30 orthogonal, projection 0.926 0.004 0.950 0.375
90 constrained, small intercept 1.313 0.066 0.624 3.225
90 unconstrained, small intercept 0.866 0.213 0.953 0.204
90 orthogonal, true rate 0.927 0.002 0.000 0.375
90 orthogonal, projection 0.927 0.002 0.950 0.375
270 constrained, small intercept 1.359 0.047 0.036 3.768
270 unconstrained, small intercept 0.863 0.208 0.954 0.200
270 orthogonal, true rate 0.927 0.001 0.000 0.375
270 orthogonal, projection 0.927 0.001 0.950 0.375

The intercept breaks the constrained calibration and the damage grows with the number of trials. The estimate is 1.187 at 10 trials and 1.359 at 270, above the true 1.2, and coverage falls from 0.996 to 0.624 at 90 trials and 0.036 at 270. The weights explain it. Constrained GLS weights sum to 2.13 at 10 trials and 3.77 at 270, so an intercept of 0.05 adds that multiple of 0.05 to the estimate, while the standard error shrinks. The unconstrained discrepancy shrugs the intercept off (coverage 0.954 at 270 trials) for the same reason it never learned anything.

The orthogonal discrepancy gives a definite answer, and it is not 1.2. Its estimate is 0.9266 at 270 trials with a standard error of 0.0013, the same as plain least squares, and its interval covers the least-squares projection 0.9266 in 0.950 of datasets and the true attack rate in 0.000. That is the point of the construction: it makes the calibrated parameter the best-fitting simulator value in the sense of the L2 projection, the target Tuo and Wu adopt for an imperfect simulator. For a foraging model the question is then which quantity is wanted. The rate a bird attacks at before handling limits it is 1.2; the slope that makes a straight-line model match intake over densities 0.1 to 4 is 0.927. No discrepancy prior turns one into the other; the choice is made before the data are seen.

near_plot <- rbind(
  cbind(cf_tab[cf_tab$arm == "con", c("n", "mean", "se")],
        case = "constraint exactly true"),
  near_tab[near_tab$case == "constrained, small intercept", c("n", "mean", "se", "case")],
  near_tab[near_tab$case == "orthogonal, projection", c("n", "mean", "se", "case")])
near_plot$case <- factor(near_plot$case,
  levels = c("constraint exactly true", "constrained, small intercept", "orthogonal, projection"),
  labels = c("constrained, truth passes through zero", "constrained, truth has intercept 0.05",
             "orthogonal discrepancy"))
ggplot(near_plot, aes(factor(n), mean, colour = case)) +
  geom_hline(yintercept = theta_true, linetype = "dashed", colour = te_ink) +
  geom_hline(yintercept = proj_theta, linetype = "dotted", colour = te_ink) +
  geom_errorbar(aes(ymin = mean - z_95 * se, ymax = mean + z_95 * se),
                width = 0.25, linewidth = 0.8,
                position = position_dodge(width = 0.6)) +
  geom_point(size = 2.4, position = position_dodge(width = 0.6)) +
  scale_colour_manual(values = c(te_forest, te_rust, te_gold), name = NULL) +
  labs(x = "feeding trials (n)", y = "attack rate",
       title = "The constraint is information, and it can be wrong",
       subtitle = "dashed: true rate; dotted: least-squares projection of the truth") +
  guides(colour = guide_legend(nrow = 2)) +
  theme_datasheet() +
  theme(legend.position = "bottom")
A dot and interval chart of the attack rate against feeding trials 10, 30, 90 and 270, with a dashed line at 1.2 and a dotted line near 0.93. Dark green points for the constrained GP with a truth through zero rise from about 1.08 to 1.17 with intervals that shrink but always cross 1.2. Red points for the constrained GP with a truth that has an intercept of 0.05 rise from about 1.19 to 1.36, with intervals that shrink until at 270 trials the interval, about 1.27 to 1.45, lies wholly above 1.2. Gold points for the orthogonal discrepancy sit on the dotted line at every sample size with very short intervals.
Figure 4: Closed-form mean estimate and mean 95 per cent interval when the zero-density constraint is only nearly true, and under an orthogonal discrepancy, against the true rate and the least-squares projection.

What to report

Say where the discrepancy hyperparameters came from. A calibrated parameter with a Gaussian process discrepancy is a different quantity when the amplitude is fixed from prior knowledge than when it is estimated from the same data, and in this example the difference was coverage near 0.95 against coverage of zero on identical datasets. A methods sentence of the form “a GP discrepancy was included” is not enough to read the interval.

Report the standard error of the parameter at two sample sizes, or its behaviour with added data, when the data allow it. A standard error that does not fall as trials are added means the discrepancy is absorbing the parameter, and the parameter is being reported from its prior.

State the prior information about the discrepancy as a claim about the system and defend it on ecological grounds: here, that intake and the simulator agree at zero density and that handling does not limit intake at vanishing density. Then check what happens if the claim is slightly wrong. In the intercept case above a violation of one observation standard deviation was enough to take coverage from nominal to 0.036 at 270 trials.

Say which parameter is meant. If the target is the physical attack rate, the calibration needs information about the discrepancy from outside the data. If the target is the best-fitting simulator value, plain least squares or an orthogonal discrepancy estimates it, and the report should call it that and not the attack rate.

Keep prediction inside the calibration range apart from extrapolation. Inside the range the prediction intervals covered in 0.850 to 0.970 of datasets across the arms; at a density of 6 only the arm with the widest intervals covered at every sample size, and a foraging model is usually run exactly where the trials were not.

Honest limits

One simulator, one truth and one input dimension. The simulator is linear in its single parameter, so every fixed-hyperparameter calibration is a linear estimator and the tables are exact. A nonlinear simulator with several parameters would need an emulator or repeated runs, the posterior would not be a mixture of normals, and the confounding between parameters and discrepancy can take other forms.

The saturation constant, the trial range and the observation standard deviation were chosen before any run and not varied. A more strongly saturating truth, trials at higher densities, or noisier data would change every number here. The observation variance was treated as known; estimating it as well gives the likelihood a third way to trade misfit, and was not tried.

Maximum likelihood for the hyperparameters used four Nelder-Mead starts per dataset and kept the best. For the constrained discrepancy the best fits often ran to the parameter bound along a ridge where the likelihood is nearly flat, so the supremum is not a finite point and the optimiser reports where it stopped. Four starts do not guarantee the global optimum on every dataset; a finer search could move the constrained coverage values somewhat, though not the zero coverage of the unconstrained arm.

The Bayesian arm uses a grid and a single weak prior. A different weak prior on the amplitude, for example one with heavier upper tail, or a prior informed by what intake curves of other shorebirds look like, would give other coverage, and the grid bounds act as part of the prior. The Monte Carlo standard errors on those coverage values run from 0.017 to 0.037 with 100 datasets, and the grid was run only at 30 and 90 trials.

The orthogonal discrepancy was implemented only with fixed hyperparameters and uniform weighting over the trial range. Plumlee’s construction allows other input distributions, and changing the weighting changes the projection value it targets.

References

Kennedy MC, O’Hagan A 2001 Journal of the Royal Statistical Society Series B 63(3):425-464 (10.1111/1467-9868.00294)

Brynjarsdottir J, O’Hagan A 2014 Inverse Problems 30(11):114007 (10.1088/0266-5611/30/11/114007)

Tuo R, Wu CFJ 2015 Annals of Statistics 43(6) (10.1214/15-AOS1314)

Plumlee M 2017 Journal of the American Statistical Association 112(519):1274-1285 (10.1080/01621459.2016.1211016)

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.