Food web structure: connectance and trophic level

R
food webs
ecological networks
community ecology
ecology tutorial
Build a food web in R and measure its structure: connectance, trophic levels and food-chain length, and why the trophic level you report is a modelling choice.
Author

Tidy Ecology

Published

2026-07-21

A food web is a directed graph: a set of species and a record of who eats whom. Once you write it as a matrix, the whole toolbox of network structure opens up, and two numbers do most of the early work. Connectance says how densely the species are linked, and trophic level says how far up the chain each species sits. Both are easy to compute in a few lines of base R, and both hide a decision that changes the answer. This post builds a small web, measures its structure, and shows why the single most quoted food-web number, the trophic level, is not one number at all.

A web as a matrix

Store the web as a square matrix A where A[i, j] = 1 means species i eats species j. A row is a diet (what a species eats); a column is a predator list (what eats a species). We will use a small terrestrial web with three plants, a set of herbivores, and predators up to two apex consumers with very different feeding habits.

sp <- c("Grass","Sedge","Detritus","Vole","Hare","Grasshopper","Beetle",
        "Weasel","Shrew","Fox","Kestrel","Buzzard","Raven","Eagle")
S <- length(sp)

# prey list: what each species eats (by index)
prey <- list(
  integer(0),  # Grass      (basal)
  integer(0),  # Sedge      (basal)
  integer(0),  # Detritus   (basal)
  c(1, 2),     # Vole
  c(2, 3),     # Hare
  c(1, 3),     # Grasshopper
  c(1, 2),     # Beetle
  c(4, 6, 7),  # Weasel
  c(6, 7),     # Shrew
  c(4, 5, 8),  # Fox
  c(6, 7, 9),  # Kestrel
  c(8, 9, 10), # Buzzard
  c(3, 12),    # Raven   (detritus and buzzard: a broad scavenger)
  c(10)        # Eagle   (a fox specialist)
)

A <- matrix(0L, S, S, dimnames = list(sp, sp))
for (i in seq_len(S)) if (length(prey[[i]])) A[i, prey[[i]]] <- 1L

L <- sum(A)          # number of feeding links
C <- L / S^2         # directed connectance
c(species = S, links = L, connectance = round(C, 3))
    species       links connectance 
     14.000      25.000       0.128 

The web has 14 species and 25 links. Connectance is the fraction of all possible links that are realised. There are several conventions; the most common is directed connectance, C = L / S^2, which here gives 0.128. Real food webs sit mostly between about 0.03 and 0.3, so this small web is unremarkable in density.

Who sits where

Three roles fall out of the row and column sums. A basal species eats nothing in the web (row sum zero); a top species is eaten by nothing (column sum zero); everything else is intermediate. Two companion measures describe the link load: generality is the mean number of prey per consumer, and vulnerability is the mean number of predators per resource.

gen <- rowSums(A)              # generality: number of prey
vul <- colSums(A)              # vulnerability: number of predators
basal <- which(gen == 0)
top   <- which(vul == 0)
inter <- which(gen > 0 & vul > 0)

data.frame(
  role  = c("basal", "intermediate", "top"),
  count = c(length(basal), length(inter), length(top))
)
          role count
1        basal     3
2 intermediate     8
3          top     3
c(mean_generality   = round(mean(gen[gen > 0]), 2),
  mean_vulnerability = round(mean(vul[vul > 0]), 2),
  linkage_density    = round(L / S, 2))
   mean_generality mean_vulnerability    linkage_density 
              2.27               2.27               1.79 

The web has 3 basal species, 8 intermediate, and 3 top. On average a consumer eats 2.27 species and a resource is eaten by 2.27. Linkage density (the ratio L / S, here 1.79) is the average number of links per species and is a common alternative to connectance when comparing webs of different sizes.

library(ggplot2)

# prey-averaged trophic level for vertical placement
Wmat <- A / pmax(rowSums(A), 1)
tl_pa <- solve(diag(S) - Wmat, rep(1, S))

role <- ifelse(gen == 0, "basal", ifelse(vul == 0, "top", "intermediate"))
set.seed(1)
# horizontal spread within trophic bands to reduce overlap
xpos <- ave(tl_pa, round(tl_pa), FUN = function(z) seq_along(z) - mean(seq_along(z)))
nodes <- data.frame(sp = sp, x = xpos, y = tl_pa, role = role)

edges <- do.call(rbind, lapply(seq_len(S), function(i) {
  if (!length(prey[[i]])) return(NULL)
  data.frame(x = nodes$x[prey[[i]]], y = nodes$y[prey[[i]]],
             xend = nodes$x[i], yend = nodes$y[i])
}))

ggplot() +
  geom_segment(data = edges, aes(x, y, xend = xend, yend = yend),
               colour = "grey72", linewidth = 0.4) +
  geom_point(data = nodes, aes(x, y, fill = role), shape = 21,
             size = 6, colour = "grey25") +
  geom_text(data = nodes, aes(x, y, label = sp), size = 2.6, colour = "grey15") +
  scale_fill_manual(values = c(basal = "#4c9f70", intermediate = "#e0c341",
                               top = "#c1523f")) +
  labs(x = NULL, y = "Prey-averaged trophic level", fill = NULL) +
  theme_minimal(base_size = 12) +
  theme(panel.grid.minor = element_blank(),
        axis.text.x = element_blank(),
        plot.background = element_rect(fill = "white", colour = NA),
        panel.background = element_rect(fill = "white", colour = NA))
Network diagram: three basal species at trophic level one, herbivores near two, and predators rising to an eagle and a raven near the top, connected by grey feeding links.
Figure 1: The food web, with species placed at their prey-averaged trophic level and coloured by role. Grey lines are feeding links.

Three trophic levels, one web

Trophic level is meant to answer a simple question: how many steps of eating separate a species from the primary producers? The trouble is that there are at least three sensible ways to count those steps, and for anything that eats across levels they disagree.

The prey-averaged trophic level (the field standard since Williams and Martinez) sets basal species to 1 and every consumer to one plus the mean trophic level of its prey. It is the solution of a linear system, (I - W) t = 1, where W is the diet matrix with each consumer row scaled to sum to one.

The shortest-path level counts the shortest chain from a species down to a basal resource, so a fox that eats a rabbit and also nibbles carrion is only two steps from a plant.

The longest-chain level counts the longest such chain, so the same fox can sit near the top if one of its prey sits atop a long chain.

# (1) prey-averaged: t = 1 + W t, basal = 1  ->  (I - W) t = 1
tl_pa <- solve(diag(S) - Wmat, rep(1, S))

# (2) shortest path to a basal species
tl_sp <- rep(NA_real_, S); tl_sp[basal] <- 1
repeat {
  changed <- FALSE
  for (i in setdiff(seq_len(S), basal)) {
    p <- prey[[i]]
    if (all(!is.na(tl_sp[p]))) {
      v <- 1 + min(tl_sp[p])
      if (is.na(tl_sp[i]) || v < tl_sp[i]) { tl_sp[i] <- v; changed <- TRUE }
    }
  }
  if (!changed) break
}

# (3) longest chain (this web is acyclic, so it is finite)
tl_lc <- rep(NA_real_, S); tl_lc[basal] <- 1
repeat {
  changed <- FALSE
  for (i in setdiff(seq_len(S), basal)) {
    p <- prey[[i]]
    if (all(!is.na(tl_lc[p]))) {
      v <- 1 + max(tl_lc[p])
      if (is.na(tl_lc[i]) || v != tl_lc[i]) { tl_lc[i] <- v; changed <- TRUE }
    }
  }
  if (!changed) break
}

data.frame(species = sp, prey_avg = round(tl_pa, 2),
           shortest = tl_sp, longest = tl_lc)
                species prey_avg shortest longest
Grass             Grass     1.00        1       1
Sedge             Sedge     1.00        1       1
Detritus       Detritus     1.00        1       1
Vole               Vole     2.00        2       2
Hare               Hare     2.00        2       2
Grasshopper Grasshopper     2.00        2       2
Beetle           Beetle     2.00        2       2
Weasel           Weasel     3.00        3       3
Shrew             Shrew     3.00        3       3
Fox                 Fox     3.33        3       4
Kestrel         Kestrel     3.33        3       4
Buzzard         Buzzard     4.11        4       5
Raven             Raven     3.56        2       6
Eagle             Eagle     4.33        4       5

Look at the Raven. It eats detritus (a basal resource) and it eats the Buzzard (which sits atop a long chain). Its shortest-path level is 2: it is one step from detritus, a primary consumer by that measure. Its longest-chain level is 6: near the top. The prey-averaged level splits the difference at 3.56. That is not rounding noise; it is a two-level spread built into the web by a single omnivorous habit.

tl_long <- rbind(
  data.frame(sp = sp, tl = tl_pa, def = "prey-averaged"),
  data.frame(sp = sp, tl = tl_sp, def = "shortest path"),
  data.frame(sp = sp, tl = tl_lc, def = "longest chain")
)
ord <- sp[order(tl_pa)]
tl_long$sp <- factor(tl_long$sp, levels = ord)
tl_long$def <- factor(tl_long$def,
                       levels = c("shortest path", "prey-averaged", "longest chain"))

ggplot(tl_long, aes(tl, sp)) +
  geom_line(aes(group = sp), colour = "grey75", linewidth = 0.5) +
  geom_point(aes(colour = def, shape = def), size = 2.6) +
  scale_colour_manual(values = c("shortest path" = "#4c9f70",
                                 "prey-averaged" = "#3d6cb0",
                                 "longest chain" = "#c1523f")) +
  labs(x = "Trophic level", y = NULL, colour = NULL, shape = NULL) +
  theme_minimal(base_size = 12) +
  theme(panel.grid.minor = element_blank(),
        legend.position = "top",
        plot.background = element_rect(fill = "white", colour = NA),
        panel.background = element_rect(fill = "white", colour = NA))
Dot plot with species on the vertical axis and trophic level on the horizontal axis; each species has three points for the prey-averaged, shortest-path and longest-chain definitions, with wide gaps for the raven and fox.
Figure 2: Three trophic-level definitions for each species, ordered by the prey-averaged value. The line shows the spread; omnivores like the Raven span two whole levels.

The disagreement is not academic. Ask which species has the highest trophic level, a phrase that ends up in abstracts. The prey-averaged measure crowns the Eagle at 4.33, a clean specialist two links above the fox. The longest-chain measure crowns the Raven at 6, because one of the Raven’s prey happens to sit atop the web’s longest chain. Two defensible definitions, two different apex predators.

c(prey_avg_highest = sp[which.max(tl_pa)],
  longest_highest  = sp[which.max(tl_lc)])
prey_avg_highest  longest_highest 
         "Eagle"          "Raven" 
# rank agreement between definitions
round(c(pa_vs_shortest = cor(tl_pa, tl_sp, method = "spearman"),
        pa_vs_longest  = cor(tl_pa, tl_lc, method = "spearman"),
        shortest_vs_longest = cor(tl_sp, tl_lc, method = "spearman")), 3)
     pa_vs_shortest       pa_vs_longest shortest_vs_longest 
              0.893               0.984               0.825 

Across the whole web the three definitions correlate, but not perfectly: the prey-averaged and shortest-path rankings agree at 0.893, and the shortest-path and longest-chain at only 0.825. Omnivory is what pulls them apart, and omnivory is common in real webs.

What to carry forward

Connectance and linkage density are unambiguous once you fix a counting convention, and they are the right first descriptors of a web. Trophic level is different. It reads like a measured quantity but it is a modelling choice, and for any omnivore the choice moves the answer by a level or more. Before you report a food-chain length or name a top predator, state which definition you used, and check that the claim survives the alternatives. The next post builds webs from scratch and asks a harder question: what simple rule reproduces the structure we just measured?

Where to go next

The niche model shows that a single feeding axis generates realistic webs, while a random graph with the same connectance does not. For the undirected, two-mode case (plants and their pollinators rather than a directed food chain), see bipartite network metrics.

References

Martinez ND 1992 American Naturalist 139(6):1208-1218 (10.1086/285382)

Williams RJ, Martinez ND 2004 American Naturalist 163(3):458-468 (10.1086/381964)

Pimm SL, Lawton JH, Cohen JE 1991 Nature 350:669-674 (10.1038/350669a0)

Dunne JA, Williams RJ, Martinez ND 2002 Proceedings of the National Academy of Sciences 99(20):12917-12922 (10.1073/pnas.192407699)

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.