---
title: "Stable isotope mixing models in R"
description: "The geometry behind stable isotope mixing models in R: mass balance, the mixing polygon, and why more diet sources than tracers leave the answer undetermined."
date: "2026-07-19 12:00"
categories: [R, stable isotopes, diet, food web, ecology tutorial]
image: thumbnail.png
image-alt: "A carbon-nitrogen isotope biplot with four source points, a mixing polygon, and one consumer inside it."
---
A stable isotope mixing model answers a simple-sounding question: a consumer's tissue carries a carbon and nitrogen isotope signature, several potential food sources each carry their own, so what fraction of the diet came from each source? The machinery that answers this looks like statistics, but underneath it is geometry, and the geometry sets a hard limit that no amount of clever fitting removes. This post builds the mixing model from mass balance, solves the case where the geometry gives one answer, and shows the case where it gives infinitely many.
Everything here is base R plus `ggplot2`. Later posts in this cluster add a Bayesian version, the assumed inputs that quietly drive the result, and the checks worth running before you trust a mixing model.
## Sources, a consumer, and the mixing space
Two tracers are the classic pair: `d13C` on one axis, `d15N` on the other. Each source is a point in this plane, and a consumer that eats a mixture of the sources sits at a weighted average of their positions. The set of points a consumer could occupy, given non-negative diet fractions that sum to one, is the convex hull of the source points: the **mixing polygon**. A consumer inside the polygon has at least one feasible diet; a consumer outside it has none.
```{r}
#| label: setup
#| echo: false
#| warning: false
#| message: false
library(ggplot2)
theme_te <- function() {
theme_minimal(base_size = 12) +
theme(plot.title = element_text(face = "bold", size = 13),
plot.background = element_rect(fill = "white", colour = NA),
panel.background = element_rect(fill = "white", colour = NA),
panel.grid.minor = element_blank(),
legend.position = "none")
}
te_green <- "#275139"
te_pal <- c("#275139", "#8c6d1f", "#7a1f1f", "#1f5c7a")
```
```{r}
#| label: sources
# Four sources, already corrected to the consumer's trophic level.
# Columns are the two tracers (d13C, d15N).
src <- rbind(C3 = c(-29, 4),
C4 = c(-13, 6),
Algae = c(-15, 12),
SPOM = c(-28, 10))
colnames(src) <- c("d13C", "d15N")
consumer <- c(d13C = -20, d15N = 8)
src
```
```{r}
#| label: fig-space
#| echo: false
#| fig-cap: "Four sources (points) with their mixing polygon (the convex hull), and one consumer (cross) sitting inside it. Any consumer inside the polygon can be written as a non-negative mixture of the sources; a consumer outside it cannot."
#| fig-alt: "A d13C versus d15N biplot. Four labelled source points form a quadrilateral shaded polygon. A cross marks a consumer near the centre of the polygon."
hull <- src[chull(src), ]
hull_df <- as.data.frame(rbind(hull, hull[1, ]))
sd_df <- data.frame(src, lab = rownames(src))
ggplot() +
geom_polygon(data = hull_df, aes(d13C, d15N), fill = te_green, alpha = 0.10,
colour = te_green, linewidth = 0.6) +
geom_point(data = sd_df, aes(d13C, d15N), colour = te_green, size = 3) +
geom_text(data = sd_df, aes(d13C, d15N, label = lab), vjust = -1, size = 3.6) +
geom_point(aes(consumer["d13C"], consumer["d15N"]), shape = 4, size = 4,
stroke = 1.2, colour = "#7a1f1f") +
annotate("text", x = consumer["d13C"], y = consumer["d15N"] - 0.7,
label = "consumer", size = 3.6, colour = "#7a1f1f") +
labs(x = expression(delta^13 * C~"(permil)"), y = expression(delta^15 * N~"(permil)"),
title = "The mixing space") +
theme_te()
```
## When the geometry gives one answer
Write the diet fractions as a vector `p`, one entry per source. Mass balance says that for each tracer, the consumer value equals the fraction-weighted average of the source values, and the fractions sum to one. With `J` sources and `T` tracers, that is `T + 1` linear equations in `J` unknowns:
$$
\sum_j p_j\, s_{jt} = y_t \quad (t = 1 \ldots T), \qquad \sum_j p_j = 1 .
$$
With two tracers and three sources, that is three equations in three unknowns: a square system with a unique solution. The solution is just the consumer's position expressed in the coordinate system of the three source points (its barycentric coordinates). Solving is a one-liner.
```{r}
#| label: determined
S3 <- src[c("C3", "C4", "Algae"), ] # drop the fourth source
M3 <- rbind(t(S3), 1) # 3 x 3: two tracer rows, one sum-to-one row
y3 <- c(consumer, 1)
p3 <- solve(M3, y3)
p3_c3 <- round(p3[1], 3)
p3_c4 <- round(p3[2], 3)
p3_algae <- round(p3[3], 3)
round(p3, 3)
```
The three sources give a single diet: `r p3_c3` C3, `r p3_c4` C4, `r p3_algae` Algae. All three fractions land in `[0, 1]`, so the answer is feasible, and the reconstruction is exact (multiply the sources by these fractions and you recover the consumer). With as many sources as the geometry can carry, the mixing model behaves like ordinary linear algebra: one input, one output.
## When the geometry gives infinitely many answers
Add a fourth source and nothing about the data changes, but the system does. Now there are three equations and four unknowns. The matrix has rank three, so there is one **degree of freedom**: the solutions form a line, and where that line crosses the region of non-negative fractions is a whole segment, not a point.
```{r}
#| label: underdetermined
M4 <- rbind(t(src), 1) # 3 x 4
dof <- ncol(M4) - qr(M4)$rank
pp <- qr.solve(M4, c(consumer, 1)) # one particular solution
ns <- svd(M4, nu = 0, nv = 4)$v[, 4] # direction along the solution line
# walk along the line: p(t) = pp + t * ns, keep only t with all fractions >= 0
t_lo <- -Inf; t_hi <- Inf
for (j in 1:4) {
if (ns[j] > 1e-12) t_lo <- max(t_lo, (-pp[j]) / ns[j])
if (ns[j] < -1e-12) t_hi <- min(t_hi, (-pp[j]) / ns[j])
}
feas <- rbind(end_A = pp + t_lo * ns, end_B = pp + t_hi * ns)
colnames(feas) <- rownames(src)
dof
round(feas, 3)
```
```{r}
#| label: ranges
rng <- apply(feas, 2, range)
width <- rng[2, ] - rng[1, ]
spom_lo <- round(rng[1, "SPOM"], 2); spom_hi <- round(rng[2, "SPOM"], 2)
wid_spom <- round(width["SPOM"], 2)
wid_algae <- round(width["Algae"], 2)
round(rbind(low = rng[1, ], high = rng[2, ], width = width), 3)
```
Both rows of `feas` fit the consumer exactly; they are two ends of a segment of equally valid diets. Read the columns and the problem is stark. The SPOM fraction can be anywhere from `r spom_lo` to `r spom_hi` (a range of `r wid_spom`) while still reproducing the consumer's isotopes to the last decimal. Algae ranges over `r wid_algae`. Two tracers simply do not carry enough information to pin four sources: the consumer's single point in the plane is consistent with a continuum of mixtures.
```{r}
#| label: fig-feasible
#| echo: false
#| fig-cap: "The feasible diets for the four-source problem, as fractions per source. Each source's bar spans the full range of values consistent with the consumer's isotopes; every point along a bar reproduces the data exactly. Two tracers leave a wide band of equally good answers."
#| fig-alt: "A horizontal range plot. Four sources each have a bar spanning a wide interval of diet fractions, showing that each source's contribution is only bounded, not determined."
rdf <- data.frame(src = factor(rownames(src), levels = rev(rownames(src))),
lo = rng[1, ], hi = rng[2, ], mid = colMeans(feas))
ggplot(rdf, aes(y = src)) +
geom_segment(aes(x = lo, xend = hi, yend = src), linewidth = 6,
colour = te_green, alpha = 0.35, lineend = "round") +
geom_point(aes(x = mid), colour = te_green, size = 2.5) +
scale_x_continuous(limits = c(0, 0.6)) +
labs(x = "feasible diet fraction", y = NULL,
title = "Four sources, two tracers: a band of answers") +
theme_te()
```
The single-point estimate in the middle of each bar is not wrong so much as arbitrary: it is one interior solution chosen by the numerical routine, and a different routine would return a different one. Reporting it as "the answer" hides the range. Later this cluster shows how a Bayesian model reports the whole band honestly, and why grouping sources with similar signatures is often the only way to get a resolvable quantity.
## A consumer outside the polygon
Mass balance with non-negative fractions can only produce consumers inside the mixing polygon. If the corrected consumer falls outside it, no feasible diet exists, and the linear routine will happily return a solution with negative fractions. The check is geometric: is the consumer inside the convex hull of the sources?
```{r}
#| label: outside
inpoly <- function(pt, poly) { # ray-casting point-in-polygon
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 <- src[chull(src), ]
out_consumer <- c(d13C = -31, d15N = 8) # more depleted than any source
c(inside = inpoly(consumer, poly), outside = inpoly(out_consumer, poly))
```
A consumer at `d13C = -31` sits to the left of every source and lands outside the polygon. Because all four sources have `d13C` values of `-29` or higher, no weighted average of them can reach `-31`: the fractions that would try are negative. A negative diet fraction is the signature of an impossible mixing problem, and it usually means a missing source, a wrong discrimination correction, or source values measured on the wrong scale. The mixing model cannot rescue a consumer that its sources cannot bracket.
## Where to go next
The geometry is the frame; everything else in this cluster hangs on it. The [Bayesian version](../bayesian-isotope-mixing-models/) turns the feasible band into a posterior distribution and adds source uncertainty. The [assumed inputs post](../discrimination-and-concentration-in-mixing/) shows that the correction you apply to the sources before any of this, the trophic discrimination factor, moves the result more than the data do. And the [checking post](../checking-an-isotope-mixing-model/) collects the diagnostics: is the consumer inside the polygon, is the posterior informed by data, and which contributions are actually resolvable.
## Honest limits
The single-answer case is the exception, not the rule. Real studies usually have more candidate sources than tracers, so the underdetermined case is the normal one, and its wide band of solutions is a feature of the geometry, not a failure of the method. Adding tracers (a third isotope, fatty acids) is the direct fix, because each independent tracer adds a dimension to the mixing space; grouping sources with overlapping signatures is the other. What you cannot do is squeeze a unique diet out of two tracers and four distinct sources, however sophisticated the fitting routine.
## Related tutorials
- [Bayesian stable isotope mixing models](../bayesian-isotope-mixing-models/)
- [Discrimination and concentration in mixing models](../discrimination-and-concentration-in-mixing/)
- [Checking a stable isotope mixing model](../checking-an-isotope-mixing-model/)
- [Log-ratio transformations for compositional data](../log-ratio-transformations-clr-ilr/)
## References
Phillips DL, Gregg JW 2001 Oecologia 127:171-179.
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).
Post DM 2002 Ecology 83:703-718.