Checking a stable isotope mixing model

R
stable isotopes
Bayesian
diet
model checking
ecology tutorial
Three checks for a stable isotope mixing model in R: is the consumer inside the mixing polygon, is the posterior data-driven, and does source aggregation hold.
Author

Tidy Ecology

Published

2026-07-20

A stable isotope mixing model returns diet fractions for any input you give it. That is the problem: it returns them whether or not the input makes sense, whether or not the data inform the answer, and whether or not the quantities you are reading are the ones the data can actually resolve. A confident-looking posterior is not evidence that the fit means anything. This post collects three checks that separate a fit worth reporting from one that only looks like an answer, using the same four-source example as the rest of the mixing-model cluster.

Check one: is the consumer inside the mixing polygon?

A non-negative mixture of the sources can only land inside their convex hull. If the discrimination-corrected consumer falls outside that polygon, no valid diet exists, and yet the sampler will still return a posterior, often a narrow and confident one. The check is geometric and takes a moment; skipping it is how impossible fits get reported as findings.

inpoly <- function(pt, poly) {
  n <- nrow(poly); inside <- FALSE; j <- n
  for (i in 1:n) {
    xi <- poly[i, 1]; yi <- poly[i, 2]; xj <- poly[j, 1]; yj <- poly[j, 2]
    if (((yi > pt[2]) != (yj > pt[2])) &&
        (pt[1] < (xj - xi) * (pt[2] - yi) / (yj - yi) + xi)) inside <- !inside
    j <- i
  }
  inside
}
poly <- Smu[chull(Smu), ]
consumer_in  <- c(d13C = -24.3, d15N = 7.6)              # a plausible consumer
consumer_out <- c(d13C = -33,   d15N = 8)                # more depleted than any source
c(inside = inpoly(consumer_in, poly), outside = inpoly(consumer_out, poly))
 inside outside 
   TRUE   FALSE 

The second consumer sits at d13C = -33, more depleted than every source, so it is outside the polygon: no non-negative diet can reach it. Fit it anyway and read what comes back.

set.seed(701)
y_out <- cbind(rnorm(20, consumer_out["d13C"], res[1]), rnorm(20, consumer_out["d15N"], res[2]))
fit_out <- mix_mh(y_out, Smu, Ssd, res, seed = 702)
do <- fit_out$draws; colnames(do) <- rownames(Smu)
spom_mean <- round(mean(do[, "SPOM"]), 3)
spom_sd <- round(sd(do[, "SPOM"]), 3)
round(rbind(mean = colMeans(do), sd = apply(do, 2, sd)), 3)
        C3    C4 Algae  SPOM
mean 0.032 0.001 0.001 0.966
sd   0.024 0.001 0.001 0.024

The fit is emphatic: it assigns 0.966 of the diet to SPOM with a posterior standard deviation of only 0.024. Taken at face value that reads as a precise, near-certain result. It is an artefact. The sampler cannot reach the consumer with any real mixture, so it collapses onto the single source nearest the consumer in the isotope plane and reports false confidence. Without the polygon check, you would report “SPOM dominates the diet” from a consumer that no combination of these sources can produce. A concentrated posterior on an infeasible consumer is a warning, not a result.

A d13C versus d15N biplot with four sources forming a polygon. One consumer sits inside; a red consumer sits to the left, clearly outside the polygon.
Figure 1: The mixing polygon with a feasible consumer (inside) and an infeasible one (outside, in red). The outside consumer is more depleted in carbon than any source, so no non-negative mixture reaches it. A mixing model fitted to it returns a confident but meaningless posterior.

Check two: is the posterior driven by the data or the prior?

A broad posterior might mean the data are weakly informative, or it might mean you are mostly seeing the prior. The flat Dirichlet is not flat on the individual fractions: for four sources, each fraction has a Beta(1, 3) prior with a standard deviation near 0.19. A useful diagnostic is how much the data shrink that: compare the prior standard deviation to the posterior standard deviation. A quantity the data barely touch stays close to its prior width; a quantity the data pin down shrinks a lot.

p_true <- c(0.40, 0.10, 0.20, 0.30); mu_in <- as.numeric(t(Smu) %*% p_true)
set.seed(303)
y_in <- cbind(rnorm(20, mu_in[1], res[1]), rnorm(20, mu_in[2], res[2]))
fit_in <- mix_mh(y_in, Smu, Ssd, res, seed = 404)
d <- fit_in$draws; colnames(d) <- rownames(Smu)
J <- 4
prior_sd_ind <- sqrt((J - 1) / (J^2 * (J + 1)))          # Beta(1, J-1): single source
prior_sd_agg <- sqrt(2 * (J - 2) / (J^2 * (J + 1)))      # Beta(2, J-2): sum of two sources
g <- d[, "C3"] + d[, "SPOM"]                             # the depleted group
shrink_ind <- round(prior_sd_ind / mean(apply(d, 2, sd)), 1)
shrink_agg <- round(prior_sd_agg / sd(g), 1)
c(prior_sd_individual = round(prior_sd_ind, 3), posterior_sd_individual = round(mean(apply(d, 2, sd)), 3),
  shrink_individual = shrink_ind)
    prior_sd_individual posterior_sd_individual       shrink_individual 
                  0.194                   0.062                   3.100 
c(prior_sd_aggregate = round(prior_sd_agg, 3), posterior_sd_aggregate = round(sd(g), 3),
  shrink_aggregate = shrink_agg)
    prior_sd_aggregate posterior_sd_aggregate       shrink_aggregate 
                 0.224                  0.017                 13.100 

The individual fractions shrink by about 3.1 times relative to their prior; the depleted group shrinks by 13.1 times. The data inform the group decisively and the individual sources only modestly. That is the quantitative version of the resolution problem: the numbers you can trust are the ones the data move far from the prior, and here that is the aggregate, not the individual sources. If a fraction’s posterior barely shrinks from its prior, you are reporting an assumption, not a measurement.

Two panels. Left: a broad dashed prior curve and a somewhat narrower solid posterior for one source. Right: a broad dashed prior and a very narrow solid posterior for the group, near the true value.
Figure 2: Prior (dashed) against posterior (solid) for a single source and for the depleted group. The individual source posterior is only modestly narrower than its broad prior. The group posterior is far tighter than its prior and sits near the true value, so the data, not the prior, are speaking.

Check three: is the resolvable quantity stable across aggregation choices?

The Bayesian post showed that summing the posterior draws for two similar sources gives a well-determined group contribution. A quick stability check is to reach the same quantity by a different route: instead of summing draws afterwards (a posteriori), merge the two sources into one before fitting (a priori) and refit with three sources. If the group contribution is real, the two routes should agree.

Smu3 <- rbind(Depleted = colMeans(Smu[c("C3", "SPOM"), ]), C4 = Smu["C4", ], Algae = Smu["Algae", ])
Ssd3 <- rbind(Depleted = sqrt(colMeans(Ssd[c("C3", "SPOM"), ]^2)), C4 = Ssd["C4", ], Algae = Ssd["Algae", ])
fit3 <- mix_mh(y_in, Smu3, Ssd3, res, alpha = rep(1, 3), seed = 808)
d3 <- fit3$draws; colnames(d3) <- rownames(Smu3)
post_hoc <- round(c(mean = mean(g), sd = sd(g)), 3)
a_priori <- round(c(mean = mean(d3[, "Depleted"]), sd = sd(d3[, "Depleted"])), 3)
rbind(a_posteriori = post_hoc, a_priori = a_priori, truth = c(mean = 0.70, sd = NA))
              mean    sd
a_posteriori 0.703 0.017
a_priori     0.703 0.014
truth        0.700    NA

Both routes put the depleted contribution at a posterior mean of about 0.70 with nearly the same spread, and both bracket the true value. The group contribution does not depend on whether you merge the sources before or after fitting, which is what makes it safe to report. Contrast that with the individual C3 and SPOM fractions, which change completely depending on arbitrary details of the fit: those are the quantities to leave out of the conclusions.

Putting the checks together

Run all three before believing a mixing model. Is the consumer inside the mixing polygon, so a valid diet exists at all? Does the posterior for each reported quantity shrink meaningfully from its prior, so you are reading data rather than assumption? And is the quantity stable to how sources are grouped, so it is not an artefact of one arbitrary aggregation? A fit that passes all three is worth reporting; a fit that fails any of them is telling you to fix the study design, add tracers, or report a coarser quantity, not to trust the numbers as they stand.

Honest limits

These are minimal, transparent versions of checks that mature tools formalise. The polygon test here uses source means; a fuller version simulates the mixing region accounting for source and discrimination uncertainty, which can shrink the feasible area. The prior-shrinkage diagnostic is a rule of thumb, not a formal model comparison. And agreement between the two aggregation routes confirms a quantity is stable, not that it is unbiased, since a wrong discrimination factor or a missing source would bias both routes together. The checks catch the common, fatal mistakes: an impossible consumer, a prior masquerading as a result, and an unresolvable fraction read as if it were pinned down. They do not substitute for a good sampling design and honest sources.

References

Phillips DL, Gregg JW 2003 Oecologia 136:261-269 (10.1007/s00442-003-1218-3).

Phillips DL, Inger R, Bearhop S, Jackson AL, Moore JW, Parnell AC, Semmens BX, Ward EJ 2014 Canadian Journal of Zoology 92:823-835 (10.1139/cjz-2014-0127).

Stock BC, Jackson AL, Ward EJ, Parnell AC, Phillips DL, Semmens BX 2018 PeerJ 6:e5096 (10.7717/peerj.5096).

Moore JW, Semmens BX 2008 Ecology Letters 11:470-480.

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.