Spatial sorting: the front that breeds its speed

R
invasion ecology
dispersal
evolutionary ecology
simulation
ecology tutorial
Simulate spatial sorting in R: a heritable dispersal trait accelerates an invasion front, and only the trait cline separates it from a fat-tailed kernel.
Author

Tidy Ecology

Published

2026-09-22

Modified

2026-11-04

Corrected 4 November 2026: the post said that Shine, Brown and Phillips (2011) demonstrated spatial sorting in toads, and that Perkins and colleagues (2013) let dispersal trade off against life history, changing magnitudes but not the sign; the Shine abstract describes the process without a toad demonstration, and the Perkins paper models dispersal and life-history evolution together with no explicit dispersal cost, so the text now says that.

Corrected 14 October 2026: the post put the control fronts’ exponent below one down to a front still converging upwards on its speed (these fronts already move at a constant speed; the shortfall is an additive offset, mostly the first generation’s head start), and its fat-kernel comparisons rested on sixty replicates per fat arm, with a high draw for the matched fat arm’s exponent: with three hundred per fat arm the extent-matched pair’s mean exponents now differ, the sorting front’s being the larger, and the bulk-matched kernel’s exponent is clearly above the extent-matched one’s, where the post had said the two conventions agree about the power of time. It also said the exponent orders the arm nearest a cline of two and the fat arm against the usual reading, when the fat kernel is the higher, as that reading expects.

Corrected 7 October 2026: the post said both beetle experiments used flour beetles (Ochocki and Miller used bean beetles), and called the arm near a cline of two realistic and field-scale, and the steepest clines hard for a field study to find, although it has no field anchor (the arms are now labelled by their cline alone). It also said the strongest arm’s late speed differs far less from the next arm’s than its early speed does (it differs by more cells per generation, only by a smaller proportion), gave the long-distance jumps post’s factor of nearly ten as a rise in variance (it is the rise in the coefficient of variation), and said the genetic variance can only shrink (the segregation term restores it every generation; only the core mean drifts, and nothing in the model holds it).

A cane toad caught at the leading edge of the Australian invasion covers more ground in a night than one caught in the long occupied country behind it, and its offspring do too. That single sentence breaks an assumption built into most spread models on this site and in most management plans: that the dispersal kernel is a fixed property of the species. If the individuals that arrive first are the ones that move furthest, and if moving far is heritable, then the front is not just a place where the population happens to be. It is a filter that concentrates a trait, and the filter runs again every generation.

The name for this is spatial sorting, and it is not a hypothesis on the site. Shine, Brown and Phillips named and described it, Phillips and colleagues built the evolving-kernel theory, and two beetle experiments, one with bean beetles by Ochocki and Miller and one with flour beetles by Weiss-Lehman and colleagues, showed that populations allowed to evolve spread both faster and more erratically than populations where the trait was shuffled between generations. This post is a demonstration of that published result, not a claim to it. What is measured here is narrower: how large the acceleration actually is as a function of the trait cline the front builds, whether the extent series alone can tell spatial sorting from a fat-tailed kernel, and what the dispersal cost term does to both answers.

The invasion posts already on the site all hold the kernel fixed and let its shape do the work. Fat tails and accelerating spread puts the acceleration in the tail of the kernel and fits a power of time to the resulting front, and its section on that fit is the instrument used again here. The speed of an invasion front derives the constant speed of a thin-tailed pulled front and shows the far tail of a Gaussian is genuinely irrelevant to it. Long-distance jumps and stratified spread shows that rare jumps lift the mean advance by a factor of about two and the coefficient of variation of the advance across replicates by nearly ten. In all three the individuals are interchangeable. Here the kernel is Gaussian and its shape never changes; what changes is which individuals are carrying it.

The arithmetic core is elsewhere too. The breeder’s equation in R is the one-generation response to truncation selection, mid-parent inheritance plus a segregation term, and that is exactly the genetic engine used below. What this post adds is the selection event: at a moving front the truncation is done by geography, not by a breeder, and the cut is applied again from a new position every generation.

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))
}

A corridor where the step length is inherited

The model is a one-dimensional corridor of 400 cells with a reflecting wall at the left end and an absorbing one at the right, seeded with a population filling the first ten cells at carrying capacity. No replicate of the main grid reaches the right-hand end; the deliberate no-cost arm does, and one comparison kernel needs a longer corridor and is given one. Each cell has Beverton-Holt density regulation with a capacity of 50 and a maximum per-parent output of 3. Every individual carries a dispersal trait, the standard deviation in cells of its own Gaussian step, and that trait is inherited on the log scale by the mid-parent rule plus a segregation term of half the genetic variance, with a fresh environmental deviation drawn each generation. Mating is with a random individual from the same cell, which is what makes the selection spatial: at the leading edge the only available mates are the other individuals that got there.

Heritability enters as the genetic share of a fixed total trait variance. Setting h2 to zero leaves the total variance intact, so the population is still a mixture of individuals with different step lengths, but none of that difference passes to the next generation. That arm is the control, and it is the one that decides whether anything below is evolution rather than a kernel mixture.

The last ingredient is a cost. Long moves are dangerous, so survival of a move of length d is exp(-m d). Without it the model has no brake at all, which is shown in its own section further down. The values of m and of the trait variance are the two knobs that set how steep a cline the front can build, and they are fixed before any run below.

n_cell <- 400L
cap_cell <- 50
r_max <- 3
n_gen <- 40L
seed_cells <- 10L
edge_band <- 10L

run_one <- function(h2, v_tot, m_cost, fat_df = NA, fat_scale = NA,
                    keep_profile = FALSE, cells = n_cell) {
  v_gen <- h2 * v_tot
  v_env <- (1 - h2) * v_tot
  pos <- rep(seq_len(seed_cells), each = cap_cell)
  g_val <- rnorm(length(pos), 0, sqrt(v_gen))
  e_val <- rnorm(length(pos), 0, sqrt(v_env))
  front <- numeric(n_gen)
  move_front <- NA_real_
  move_core <- NA_real_
  move_front_s <- NA_real_
  move_core_s <- NA_real_
  profile <- NULL
  for (tt in seq_len(n_gen)) {
    occ <- tabulate(pos, cells)
    fec <- r_max / (1 + (r_max - 1) * occ[pos] / cap_cell)
    n_off <- rpois(length(pos), fec)
    mum <- rep(seq_along(pos), n_off)
    if (length(mum) == 0L) break
    ord <- order(pos)
    cell_start <- c(1L, cumsum(occ)[-cells] + 1L)
    dad <- ord[cell_start[pos[mum]] + floor(runif(length(mum)) * occ[pos[mum]])]
    g_off <- (g_val[mum] + g_val[dad]) / 2 + rnorm(length(mum), 0, sqrt(v_gen / 2))
    e_off <- rnorm(length(mum), 0, sqrt(v_env))
    trait <- exp(g_off + e_off)
    move <- if (is.na(fat_df)) rnorm(length(mum), 0, trait) else
      fat_scale * rt(length(mum), fat_df)
    new_pos <- round(pos[mum] + move)
    new_pos <- ifelse(new_pos < 1L, 2L - new_pos, new_pos)
    alive <- new_pos <= cells & runif(length(new_pos)) < exp(-m_cost * abs(move))
    if (tt == n_gen) {
      home <- pos[mum]
      at_edge <- home >= max(home) - edge_band
      at_core <- home <= min(home) + edge_band
      move_front <- mean(abs(move[at_edge]))
      move_core <- mean(abs(move[at_core]))
      move_front_s <- mean(abs(move[at_edge & alive]))
      move_core_s <- mean(abs(move[at_core & alive]))
      if (keep_profile) profile <- data.frame(home = home, trait = trait, moved = abs(move))
    }
    pos <- new_pos[alive]
    g_val <- g_off[alive]
    e_val <- e_off[alive]
    if (length(pos) == 0L) break
    front[tt] <- max(pos)
  }
  gen_fit <- 10:n_gen
  list(front = front, extent = front[n_gen],
       early = (front[15] - front[6]) / 9,
       late = (front[40] - front[31]) / 9,
       b = coef(lm(log(pmax(front[gen_fit] - seed_cells, 0.5)) ~ log(gen_fit)))[[2]],
       move_front = move_front, move_core = move_core,
       move_front_s = move_front_s, move_core_s = move_core_s,
       wall_gen = if (any(front >= cells - 1)) which(front >= cells - 1)[1] else NA_integer_,
       profile = profile)
}

Four numbers come out of each replicate. The early speed is the average cell gain per generation over generations 6 to 15, the late speed the same over generations 31 to 40, and their ratio is the acceleration. The exponent b is the slope of log front position on log generation over generations 10 to 40, measuring the front from the edge of the seeded block, which is the fit used in the fat-tails post and for the same reason: an additive offset otherwise drags the exponent below one even for a straight line. The cline is the mean absolute realised move of the offspring born within ten cells of the leading edge divided by the same quantity within ten cells of the trailing edge, which is what a field study measures when it radio-tracks animals at the front and in the core. It is the attempted step: offspring that the cost then kills are counted in it, and the survivors-only version is reported alongside it further down.

n_rep_grid <- 60L

run_arm <- function(arm, h2, v_tot, m_cost, n_rep = n_rep_grid, fat_df = NA,
                    fat_scale = NA, cells = n_cell) {
  rr <- lapply(seq_len(n_rep), function(i)
    run_one(h2, v_tot, m_cost, fat_df, fat_scale, cells = cells))
  list(per_rep = data.frame(arm = arm, h2 = h2, v_tot = v_tot, m_cost = m_cost,
         rep = seq_len(n_rep), extent = sapply(rr, `[[`, "extent"),
         early = sapply(rr, `[[`, "early"), late = sapply(rr, `[[`, "late"),
         b = sapply(rr, `[[`, "b"), move_front = sapply(rr, `[[`, "move_front"),
         move_core = sapply(rr, `[[`, "move_core"),
         move_front_s = sapply(rr, `[[`, "move_front_s"),
         move_core_s = sapply(rr, `[[`, "move_core_s"),
         wall_gen = sapply(rr, `[[`, "wall_gen")),
       fronts = sapply(rr, `[[`, "front"))
}

arm_summary <- function(pr) {
  boot_ratio <- replicate(2000, {
    idx <- sample(nrow(pr), nrow(pr), replace = TRUE)
    mean(pr$late[idx]) / mean(pr$early[idx])
  })
  data.frame(arm = pr$arm[1], h2 = pr$h2[1], v_tot = pr$v_tot[1], m_cost = pr$m_cost[1],
    extent = mean(pr$extent), sd_extent = sd(pr$extent),
    early = mean(pr$early), late = mean(pr$late),
    ratio = mean(pr$late) / mean(pr$early), ratio_se = sd(boot_ratio),
    b = mean(pr$b), b_se = sd(pr$b) / sqrt(nrow(pr)),
    cline = mean(pr$move_front / pr$move_core),
    cline_surv = mean(pr$move_front_s / pr$move_core_s),
    cv_extent = sd(pr$extent) / mean(pr$extent),
    walls = sum(!is.na(pr$wall_gen)))
}

settings <- data.frame(v_tot = c(0.20, 0.05, 0.05), m_cost = c(0.05, 0.20, 0.05))
h2_grid <- c(0, 0.1, 0.25, 0.5)
set.seed(4271)
arms <- list()
for (s in seq_len(nrow(settings))) {
  for (h in h2_grid) {
    lab <- sprintf("s%d_h%03.0f", s, 100 * h)
    arms[[lab]] <- run_arm(lab, h, settings$v_tot[s], settings$m_cost[s])
  }
}
sorting_tab <- do.call(rbind, lapply(arms, function(a) arm_summary(a$per_rep)))
n_rep_used <- nrow(arms[[1]]$per_rep)
sd_rel_unc <- 1 / sqrt(2 * (n_rep_used - 1))
print(round(sorting_tab[, c("h2", "v_tot", "m_cost", "extent", "sd_extent",
                            "ratio", "b", "cline")], 3))
          h2 v_tot m_cost  extent sd_extent ratio     b  cline
s1_h000 0.00  0.20   0.05  75.233     4.382 1.007 0.944  1.008
s1_h010 0.10  0.20   0.05 110.233    23.976 1.650 1.190  2.185
s1_h025 0.25  0.20   0.05 172.100    33.871 2.628 1.457  7.351
s1_h050 0.50  0.20   0.05 263.750    39.905 2.359 1.646 14.265
s2_h000 0.00  0.05   0.20  55.967     2.940 1.005 0.928  1.008
s2_h010 0.10  0.05   0.20  60.267     5.554 1.092 0.978  1.187
s2_h025 0.25  0.05   0.20  62.533     9.021 1.164 0.998  1.458
s2_h050 0.50  0.05   0.20  69.283    10.511 1.318 1.072  1.951
s3_h000 0.00  0.05   0.05  63.133     2.925 0.968 0.947  1.005
s3_h010 0.10  0.05   0.05  70.517     6.619 1.180 1.013  1.265
s3_h025 0.25  0.05   0.05  82.067    13.205 1.349 1.095  1.665
s3_h050 0.50  0.05   0.05 102.200    21.793 2.013 1.278  2.962

Twelve arms, 60 replicates each, 40 generations: three trait-variance and cost settings crossed with four heritabilities. The whole grid runs in well under a minute because everything inside a generation is vectorised over individuals.

traj_of <- function(lab, tag) {
  fr <- arms[[lab]]$fronts
  data.frame(gen = rep(seq_len(n_gen), ncol(fr)),
             front = as.vector(fr),
             rep = rep(seq_len(ncol(fr)), each = n_gen),
             arm = tag)
}
traj <- rbind(traj_of("s1_h000", "h2 = 0"), traj_of("s1_h025", "h2 = 0.25"),
              traj_of("s1_h050", "h2 = 0.5"))
traj$arm <- factor(traj$arm, levels = c("h2 = 0", "h2 = 0.25", "h2 = 0.5"))

set.seed(9014)
prof_runs <- lapply(c(0, 0.25, 0.5), function(h)
  run_one(h, settings$v_tot[1], settings$m_cost[1], keep_profile = TRUE))
prof <- do.call(rbind, Map(function(z, h) {
  pf <- z$profile
  pf$bin <- cut(pf$home, breaks = seq(0, n_cell, by = 20), labels = FALSE)
  agg <- aggregate(moved ~ bin, pf, mean)
  agg$cell <- 20 * agg$bin - 10
  agg$arm <- sprintf("h2 = %s", h)
  agg
}, prof_runs, c(0, 0.25, 0.5)))
prof$arm <- factor(prof$arm, levels = c("h2 = 0", "h2 = 0.25", "h2 = 0.5"))
arm_cols <- c("h2 = 0" = te_rust, "h2 = 0.25" = te_gold, "h2 = 0.5" = te_forest)
p_traj <- ggplot(traj, aes(gen, front, group = interaction(arm, rep), colour = arm)) +
  geom_line(linewidth = 0.3, alpha = 0.6) +
  scale_colour_manual(values = arm_cols, name = NULL) +
  labs(x = "Generation", y = "Front position (cells)",
       title = "Replicate fronts") +
  guides(colour = guide_legend(override.aes = list(linewidth = 0.9, alpha = 1))) +
  theme_datasheet() + theme(legend.position = "bottom")

p_prof <- ggplot(prof, aes(cell, moved, colour = arm)) +
  geom_line(linewidth = 0.7) +
  geom_point(size = 1.3, show.legend = FALSE) +
  scale_colour_manual(values = arm_cols, name = NULL) +
  labs(x = "Position in corridor (cells)", y = "Mean realised move (cells)",
       title = "The cline at generation 40") +
  guides(colour = guide_legend(override.aes = list(linewidth = 0.9, alpha = 1))) +
  theme_datasheet() + theme(legend.position = "bottom")

(p_traj | p_prof) +
  plot_layout(guides = "collect") +
  plot_annotation(theme = theme_datasheet()) &
  theme(legend.position = "bottom")
Two panels on warm off-white paper sharing one legend below. In the left panel, sixty red lines for zero heritability rise as a straight tight bundle to between about sixty-five and eighty-five cells at generation forty; sixty gold lines for heritability zero point two five curve upwards into a fan ending mostly between about one hundred and twenty and two hundred and fifty cells; sixty dark green lines for zero point five curve up more steeply into a wider fan ending between about one hundred and fifty and three hundred and seventy. In the right panel the red curve runs flat at about one cell and stops at seventy cells, the gold curve rises gently to about three cells by one hundred and thirty, and the dark green curve rises from about one cell to about four by the middle of the corridor, dips slightly, then climbs steeply to about twenty-one cells at the leading edge near three hundred and ten.
Figure 1: Left: front position against generation for sixty replicates at each of three heritabilities, all with the same total trait variance and the same dispersal cost. Right: mean realised move length against position in the corridor at generation 40, one replicate per heritability.

The control: a mixture of kernels is not evolution

The three zero-heritability arms keep the full spread of individual step lengths and destroy only the inheritance. If the acceleration came from the mixture of kernels, from the fact that some individuals simply move further than others, these arms would accelerate too.

ctrl <- sorting_tab[sorting_tab$h2 == 0, ]
ctrl_ratio_lo <- min(ctrl$ratio)
ctrl_ratio_hi <- max(ctrl$ratio)
ctrl_b_lo <- min(ctrl$b)
ctrl_b_hi <- max(ctrl$b)
ctrl_cline_lo <- min(ctrl$cline)
ctrl_cline_hi <- max(ctrl$cline)
ctrl_sd_lo <- min(ctrl$sd_extent)
ctrl_sd_hi <- max(ctrl$sd_extent)
ctrl_ratio_se <- max(ctrl$ratio_se)
ctrl_jump <- sapply(arms[rownames(ctrl)], function(a) mean(a$fronts[1, ]) - seed_cells)
ctrl_jump_lo <- min(ctrl_jump)
ctrl_jump_hi <- max(ctrl_jump)
ctrl_speed_lo <- min(ctrl$early, ctrl$late)
ctrl_speed_hi <- max(ctrl$early, ctrl$late)
print(round(ctrl[, c("v_tot", "m_cost", "extent", "sd_extent", "ratio",
                     "ratio_se", "b", "cline")], 3))
        v_tot m_cost extent sd_extent ratio ratio_se     b cline
s1_h000  0.20   0.05 75.233     4.382 1.007    0.032 0.944 1.008
s2_h000  0.05   0.20 55.967     2.940 1.005    0.029 0.928 1.008
s3_h000  0.05   0.05 63.133     2.925 0.968    0.027 0.947 1.005

They do not. Across the three control arms the speed ratio runs from 0.97 to 1.01, with a bootstrap standard error of at most 0.03, so none of them is distinguishable from a constant speed. The log-log exponent runs from 0.93 to 0.95, that is, just under one, even though these fronts already move at a constant speed. The shortfall is the additive offset described with the definition of b, which measuring from the edge of the seeded block does not wholly remove: in the first generation, when the whole seeded block disperses at once, the front gains 2.4 to 3.4 cells, against 1.1 to 1.6 a generation in the two speed windows. The measured cline is 1.00 to 1.01, so individuals at the front move as far as individuals at the back to within a few per cent, because the trait is redrawn from the same distribution every generation wherever you stand. The standard deviation of the final extent across replicates is 2.9 to 4.4 cells.

That last number is the denominator for everything that follows. A standard deviation estimated from 60 replicates carries a relative uncertainty of about 9 per cent, so variance ratios below about one and a half should not be read as real.

Three signatures against one axis

With inheritance switched on, the interesting question is not whether the front accelerates but by how much, and the honest answer is that there is no single number. The acceleration is set by the trait cline the front manages to build, and the cline is set by how much trait variance there is and how expensive long moves are. So the three settings and four heritabilities are plotted against the cline they produce rather than against h2.

sorting_tab$sd_ratio <- NA_real_
for (i in seq_len(nrow(sorting_tab))) {
  base <- sorting_tab$sd_extent[sorting_tab$v_tot == sorting_tab$v_tot[i] &
                                sorting_tab$m_cost == sorting_tab$m_cost[i] &
                                sorting_tab$h2 == 0]
  sorting_tab$sd_ratio[i] <- sorting_tab$sd_extent[i] / base
}
field_row <- sorting_tab[sorting_tab$v_tot == 0.05 & sorting_tab$m_cost == 0.20 &
                         sorting_tab$h2 == 0.5, ]
alt_row <- sorting_tab[sorting_tab$v_tot == 0.20 & sorting_tab$m_cost == 0.05 &
                       sorting_tab$h2 == 0.1, ]
mid_row <- sorting_tab[sorting_tab$v_tot == 0.05 & sorting_tab$m_cost == 0.05 &
                       sorting_tab$h2 == 0.5, ]
peak_row <- sorting_tab[sorting_tab$v_tot == 0.20 & sorting_tab$m_cost == 0.05 &
                        sorting_tab$h2 == 0.25, ]
top_row <- sorting_tab[sorting_tab$v_tot == 0.20 & sorting_tab$m_cost == 0.05 &
                       sorting_tab$h2 == 0.5, ]
evo <- sorting_tab[sorting_tab$h2 > 0, ]
slope_sum <- summary(lm(log(ratio) ~ log(cline), evo))
slope_fit <- coef(slope_sum)[2, 1]
slope_se <- coef(slope_sum)[2, 2]
slope_r2 <- slope_sum$r.squared
ctrl_cv <- ctrl$cv_extent[ctrl$v_tot == field_row$v_tot & ctrl$m_cost == field_row$m_cost]
cv_ratio_field <- field_row$cv_extent / ctrl_cv
b_top_gap <- top_row$b - peak_row$b
b_top_gap_se <- sqrt(top_row$b_se^2 + peak_row$b_se^2)
cline_drop <- 100 * (1 - field_row$cline_surv / field_row$cline)
walls_total <- sum(sorting_tab$walls)
n_rep_total <- n_rep_used * nrow(sorting_tab)
ratio_top_gap <- peak_row$ratio - top_row$ratio
ratio_top_gap_se <- sqrt(peak_row$ratio_se^2 + top_row$ratio_se^2)
print(round(sorting_tab[, c("h2", "v_tot", "m_cost", "cline", "ratio",
                            "b", "sd_ratio", "early", "late")], 3))
          h2 v_tot m_cost  cline ratio     b sd_ratio early  late
s1_h000 0.00  0.20   0.05  1.008 1.007 0.944    1.000 1.569 1.580
s1_h010 0.10  0.20   0.05  2.185 1.650 1.190    5.472 1.963 3.239
s1_h025 0.25  0.20   0.05  7.351 2.628 1.457    7.730 2.498 6.565
s1_h050 0.50  0.20   0.05 14.265 2.359 1.646    9.107 3.930 9.269
s2_h000 0.00  0.05   0.20  1.008 1.005 0.928    1.000 1.089 1.094
s2_h010 0.10  0.05   0.20  1.187 1.092 0.978    1.889 1.204 1.315
s2_h025 0.25  0.05   0.20  1.458 1.164 0.998    3.068 1.220 1.420
s2_h050 0.50  0.05   0.20  1.951 1.318 1.072    3.575 1.244 1.641
s3_h000 0.00  0.05   0.05  1.005 0.968 0.947    1.000 1.311 1.269
s3_h010 0.10  0.05   0.05  1.265 1.180 1.013    2.263 1.400 1.652
s3_h025 0.25  0.05   0.05  1.665 1.349 1.095    4.514 1.565 2.111
s3_h050 0.50  0.05   0.05  2.962 2.013 1.278    7.450 1.587 3.194

Where the realistic end of that axis sits is a field question, and this post does not settle it. No published front-to-core contrast in individual dispersal was verified for this post. The toad study by Phillips and colleagues cited below reports a steady increase in dispersal tendency with distance from the origin, and a front speed that rose from about 10 kilometres a year to more than 55, but that is the speed of the front, not a ratio between individuals caught at the front and in the core. So the rows are labelled by their measured cline and by nothing else, and the two arms nearest a cline of two are the ones to read first.

Two arms land near a cline of two and they do not agree. One sits at 1.95 with a speed ratio of 1.32, the other at 2.19 with a ratio of 1.65 (bootstrap standard errors 0.06 and 0.09), so a cline of two does not fix an acceleration: the trait variance and the cost that produced the cline still matter. The first of the two is the one carried through the rest of the post, and picking the second instead would have changed the headline. At that arm the late speed is 1.32 times the early speed (bootstrap standard error 0.06), the log-log exponent is 1.07 with a standard error of 0.01, and the extent standard deviation is 3.6 times its own control. The arms at 7.4 and 14.3 are the strong-cline rows, and whether a real front builds a cline that steep is the field question left open above.

Those three numbers say different things about the same run. An acceleration of 32 per cent between the two windows is real but not dramatic, and an exponent of 1.07, although 60 replicates separate it from one, sits below the range the fat-tails post measures for its fat kernels, so a single series of this shape would not send anyone looking for a tail. The variance signature, by contrast, is large: replicate extents spread 3.6 times as widely as under the control in absolute cells, which is well past the 9 per cent uncertainty on a standard deviation from 60 replicates. Part of that is a mean effect, because the evolving arm also travels further: on the coefficient of variation the same comparison is 2.9-fold. In this arm the acceleration is modest and the unpredictability is what a manager would actually notice, which is the direction Ochocki and Miller report from their beetle experiment.

Push the cline harder and the acceleration arrives. At a cline of 3.0 the speed ratio is 2.01 and the exponent 1.28; at 7.4 the ratio is 2.63 and the exponent 1.46. Across the nine arms with non-zero heritability the speed ratio scales as the cline to the power 0.35, with a standard error of 0.06 and an R-squared of 0.84. The line accounts for most of the spread but not all of it, and the next paragraph shows where it fails. Taken as a direction rather than a law, it is the compact statement of the headline: the acceleration is a function of the cline, not a constant of the mechanism.

The scaling is not monotone at the top. The strongest arm, cline 14.3, gives a speed ratio of only 2.36, below the 2.63 of the arm one heritability step down. The reason is in the denominator, and the early speeds show it: 3.9 cells per generation in the strongest arm against 2.5 one heritability step down and 1.6 in the matching control, so by generation 6 the strongest arm’s early window is already measuring a fast front, while in the late window its lead is proportionally smaller (9.3 against 6.6). It is not the corridor wall either: 0 of the 720 replicates in the grid reach the far end. The drop itself is small against its own error, 0.27 with a combined bootstrap standard error of 0.18, so it is the flatness rather than the fall that is established. The exponent, which uses the whole trajectory rather than two windows, does not flatten at all: 1.65 against 1.46, a difference of 0.19 against a combined standard error of 0.03. Any single ratio quoted for spatial sorting therefore depends on where the early window was put.

set_lab <- c("Vtot 0.2, m 0.05", "Vtot 0.05, m 0.2", "Vtot 0.05, m 0.05")
sorting_tab$setting <- factor(set_lab[match(paste(sorting_tab$v_tot, sorting_tab$m_cost),
                                            paste(settings$v_tot, settings$m_cost))],
                              levels = set_lab)
set_cols <- c(te_forest, te_rust, te_gold)
names(set_cols) <- set_lab

sig_panel <- function(yvar, ylab, hline, ttl) {
  ggplot(sorting_tab, aes(cline, .data[[yvar]], colour = setting)) +
    geom_hline(yintercept = hline, linetype = 2, colour = te_body, linewidth = 0.3) +
    geom_point(size = 2.2) +
    scale_x_log10() +
    scale_colour_manual(values = set_cols, name = NULL) +
    labs(x = "Front / core move length", y = ylab, title = ttl) +
    theme_datasheet() + theme(legend.position = "bottom")
}
(sig_panel("ratio", "Late speed / early speed", 1, "Acceleration") |
 sig_panel("b", "Exponent b", 1, "Power of time") |
 sig_panel("sd_ratio", "SD relative to control", 1, "Replicate spread")) +
  plot_layout(guides = "collect") +
  plot_annotation(theme = theme_datasheet()) &
  theme(legend.position = "bottom")
Three panels side by side on warm off-white paper with one shared legend below, each with the front to core cline on a logarithmic horizontal axis from one to about fifteen. In the left panel the speed ratio rises from about one at a cline of one to about two point six at a cline of seven, then falls back slightly to about two point four at the right edge. In the middle panel the exponent rises steadily from about zero point nine three to about one point six five, crossing a dashed line at one near a cline of one and a half. In the right panel the extent standard deviation relative to the matching control climbs from one to about nine, with a dashed line at one. Point colour marks the three variance and cost settings.
Figure 2: Three summaries of the same twelve runs plotted against the measured front to core cline in realised move length: the speed ratio, the log-log exponent of extent against time, and the extent standard deviation relative to the matching zero-heritability control.

What the extent series cannot tell you

A spread modeller who is handed the front positions and nothing else will reach for the power-of-time fit from the fat-tails post, and an exponent above one is usually read as evidence for a fat kernel. The question is whether that reading can be wrong. The test is to run the same corridor with no heritable trait at all and a genuinely fat kernel instead: a Student t with two degrees of freedom, whose density falls off as the cube of distance.

Comparing two kernels means deciding what to hold equal, and that decision turns out to carry the answer. Two conventions are used here. The first matches the outcome: three t scales chosen from a short pre-run so that the mean extents land in the same range as the sorting arms. The second is the convention the fat-tails post uses, which gives every kernel the same median absolute displacement, so that the bulk of the dispersal is held fixed and only the far tail varies. Under both, the fat arms carry no dispersal cost, matching the way the fat-tails post’s model is built, so the comparison differs in kernel family and in the cost term as well as in scale.

set.seed(5507)
n_rep_fat <- 300L
fat_scales <- c(0.15, 0.20, 0.25)
fat_arms <- lapply(fat_scales, function(sc)
  run_arm(sprintf("fat%03.0f", 100 * sc), 0, 0, 0, n_rep = n_rep_fat, fat_df = 2,
          fat_scale = sc))
fat_tab <- do.call(rbind, lapply(fat_arms, function(a) arm_summary(a$per_rep)))
fat_tab$scale <- fat_scales
fat_b_lo <- min(fat_tab$b); fat_b_hi <- max(fat_tab$b)
fat_cline_lo <- min(fat_tab$cline); fat_cline_hi <- max(fat_tab$cline)
fat_sd_lo <- min(fat_tab$sd_extent); fat_sd_hi <- max(fat_tab$sd_extent)
pair_fat <- fat_tab[fat_tab$scale == 0.20, ]
pair_sort <- mid_row
b_gap <- pair_sort$b - pair_fat$b
b_gap_se <- sqrt(pair_fat$b_se^2 + pair_sort$b_se^2)
b_gap_field <- field_row$b - pair_fat$b
b_gap_field_se <- sqrt(pair_fat$b_se^2 + field_row$b_se^2)
kern_draws <- 2e5
sort_step <- abs(rnorm(kern_draws, 0,
                       exp(rnorm(kern_draws, 0, sqrt(pair_sort$v_tot)))))
fat_step <- abs(pair_fat$scale * rt(kern_draws, 2))
med_sort <- median(sort_step)
med_fat <- median(fat_step)
zero_sort <- mean(round(sort_step) == 0)
zero_fat <- mean(round(fat_step) == 0)
fat_ratios <- paste(sprintf("%.2f", fat_tab$ratio), collapse = ", ")
fat_ratio_se_lo <- min(fat_tab$ratio_se)
fat_ratio_se_hi <- max(fat_tab$ratio_se)
b_overlap <- mean(outer(fat_arms[[2]]$per_rep$b, arms[["s3_h050"]]$per_rep$b, ">"))
b_sd_fat <- sd(fat_arms[[2]]$per_rep$b)
b_sd_sort <- sd(arms[["s3_h050"]]$per_rep$b)
ratio_gap <- pair_sort$ratio - pair_fat$ratio
ratio_gap_se <- sqrt(pair_sort$ratio_se^2 + pair_fat$ratio_se^2)
print(round(fat_tab[, c("scale", "extent", "sd_extent", "ratio", "ratio_se",
                        "b", "b_se", "cline")], 3))
  scale  extent sd_extent ratio ratio_se     b  b_se cline
1  0.15  76.687    40.447 1.376    0.172 1.130 0.028 1.002
2  0.20 110.293    62.728 1.456    0.160 1.156 0.031 1.009
3  0.25 141.430    69.162 1.166    0.118 1.146 0.028 0.998

Start with the pair matched on extent. The fat arm at scale 0.20 finishes at a mean of 110 cells and the sorting arm with a cline of 3.0 at 102 cells. Each fat arm is run with 300 replicates against the sorting arms’ 60, because the exponent of a single fat-kernel run scatters far more: its standard deviation across replicates is 0.54 against 0.16 for the sorting arm. Their exponents are 1.16 for the fat kernel and 1.28 for spatial sorting, a gap of 0.12 against a combined standard error of 0.04: the mean exponents of this pair do differ, but in the direction opposite to the usual reading, since the sorting front, with its Gaussian kernel, has the larger power of time. Per replicate they overlap widely: the fat arm’s exponent exceeds the sorting arm’s 37 per cent of the time. The two-window speed ratio differs in this pair too, 2.01 for sorting against 1.46 for the fat kernel, a gap of 0.56 against a combined standard error of 0.17, but the three fat scales give 1.38, 1.46, 1.17 with bootstrap standard errors of 0.12 to 0.17, a range that overlaps the sorting arms at clines near two, so that gap is a property of this pair and not a test.

Matching on extent is not matching on dispersal, and the price is paid in the bulk of the kernel. Drawn from the two kernels as they are specified, the sorting arm’s step has a median absolute length of 0.67 cells against 0.16 for the fat arm, and 87 per cent of the fat arm’s offspring round back into the cell they were born in, against 39 per cent for the sorting arm. Almost the whole fat population is standing still and the front moves only when a draw from the tail carries it, which is what the staircases in the figure below are. The obvious objection is that the comparison has been rigged by the scale, so the other convention is worth running.

set.seed(6142)
bulk_scale <- med_sort / qt(0.75, 2)
long_cells <- 4000L
bulk_arm <- run_arm("fatbulk", 0, 0, 0, n_rep = n_rep_fat, fat_df = 2,
                    fat_scale = bulk_scale, cells = long_cells)
bulk_tab <- arm_summary(bulk_arm$per_rep)
b_bulk_gap <- bulk_tab$b - pair_fat$b
b_bulk_gap_se <- sqrt(bulk_tab$b_se^2 + pair_fat$b_se^2)
bulk_over <- sum(apply(bulk_arm$fronts, 2, max) > n_cell)
print(round(c(scale = bulk_scale, extent = bulk_tab$extent,
              sd_extent = bulk_tab$sd_extent, ratio = bulk_tab$ratio,
              ratio_se = bulk_tab$ratio_se, b = bulk_tab$b, b_se = bulk_tab$b_se,
              cline = bulk_tab$cline, past_400_cells = bulk_over,
              reached_4000 = bulk_tab$walls), 3))
         scale         extent      sd_extent          ratio       ratio_se 
         0.817        938.827        588.523          1.737          0.192 
             b           b_se          cline past_400_cells   reached_4000 
         1.386          0.032          0.983        294.000          0.000 
bulk_cline_se <- sd(bulk_arm$per_rep$move_front / bulk_arm$per_rep$move_core) /
  sqrt(nrow(bulk_arm$per_rep))

Giving the t kernel the same median absolute step as the sorting arm needs a scale of 0.82, four times the extent-matched one, and it changes the distance travelled out of all recognition: a mean extent of 939 cells against the sorting arm’s 102, with 294 of the 300 replicates past the 400-cell mark, which is why this arm is run in a corridor of 4000 cells. The sorting arms of the main grid do not need the longer corridor, since none of their replicates reaches the far end of the 400-cell one. The exponent moves as well: 1.39 with a standard error of 0.03, against 1.16 for the same kernel matched on extent and 1.28 for spatial sorting. The two conventions disagree about how far the fat kernel goes by a factor of 8.5 and about its power of time by 0.23, against a combined standard error of 0.04, so the exponent of one and the same kernel depends on its scale, and an exponent cannot be read back to a tail without knowing that scale.

What does not discriminate is one exponent without a matched control. The fat arms give exponents from 1.13 to 1.16; the sorting arms with non-zero heritability give 0.98 to 1.65, a range that contains the whole fat range. The sorting arm nearest a cline of two sits at 1.07, which is 0.08 below the fat arm’s 1.16 against a combined standard error of 0.03: the exponent does order these two, but this time in the direction the usual reading expects, the reverse of the matched pair above. An observed exponent between 1.13 and 1.16 is compatible with either mechanism.

Nor does the replicate spread. The fat arms’ extent standard deviations run from 40.4 to 69.2 cells and the sorting arms’ from 5.6 to 39.9; in the matched pair the fat arm is at 63 cells against the sorting arm’s 22, so on that axis the fat kernel is the more erratic of the two.

The cline does separate them, and it is the only quantity here that does cleanly. Across all three fat arms the measured front-to-core move ratio is 1.00 to 1.01, indistinguishable from the zero-heritability control, because every individual in those runs draws from the same kernel wherever it stands, and the bulk-matched arm gives 0.98, with a standard error of 0.03 because a mean of Student t steps is itself a noisy quantity. The sorting arm they are matched to has a cline of 3.0. That is a quantity you get by measuring animals, not by fitting the front, and it is the reason field studies of spatial sorting track individuals at the edge and in the core rather than mapping the range boundary faster.

series_of <- function(arm_obj, tag) {
  fr <- arm_obj$fronts
  gg <- 10:n_gen
  data.frame(gen = rep(gg, ncol(fr)),
             adv = as.vector(fr[gg, ]) - seed_cells,
             rep = rep(seq_len(ncol(fr)), each = length(gg)),
             arm = tag)
}
lab_sort <- sprintf("Spatial sorting, b = %.2f", pair_sort$b)
lab_fat <- sprintf("Fat kernel, extent matched, b = %.2f", pair_fat$b)
lab_bulk <- sprintf("Fat kernel, bulk matched, b = %.2f", bulk_tab$b)
ser <- rbind(series_of(arms[["s3_h050"]], lab_sort),
             series_of(fat_arms[[2]], lab_fat),
             series_of(bulk_arm, lab_bulk))
ser$arm <- factor(ser$arm, levels = c(lab_sort, lab_fat, lab_bulk))
ser <- ser[ser$adv > 0, ]
ggplot(ser, aes(gen, adv, group = rep)) +
  geom_line(linewidth = 0.35, alpha = 0.7, colour = te_forest) +
  facet_wrap(~ arm, nrow = 1) +
  scale_x_log10() + scale_y_log10() +
  labs(x = "Generation (log scale)", y = "Front beyond the seeded block (log scale)") +
  theme_datasheet() +
  theme(strip.text = element_text(colour = te_ink, face = "bold"))
Three panels on warm off-white paper with generation on a logarithmic horizontal axis from ten to forty and front position beyond the seeded block on a shared logarithmic vertical axis from about five to three and a half thousand. The left panel, spatial sorting, holds sixty dark green lines in a tight bundle of nearly straight curves rising from about twelve to between sixty and one hundred and forty. The middle panel, the fat kernel matched on extent, holds three hundred staircases that start anywhere between about five and two hundred, stay flat for long stretches and then jump, and end between about thirty and four hundred. The right panel, the same kernel matched on the bulk, has the same staircase shape shifted bodily upwards, starting between about forty and nine hundred and ending between about three hundred and fifty and three and a half thousand.
Figure 3: Log-log extent trajectories for the sorting arm and for the same fat-tailed kernel under the two matching conventions, every replicate of each arm, with the fitted power of time in the panel titles.

The figure adds a difference that is real but not usable on one invasion: the sorting replicates are a tight bundle of smooth curves, the fat-kernel replicates are staircases scattered over a wide band, under either matching. A manager watching a real invasion sees one of those lines, not the bundle, and one staircase with two flat decades in it is not distinguishable from one smooth curve with two bad survey years in it.

Without a dispersal cost the front leaves the map

Everything above carries a survival cost that falls with move length. That term is not decoration. Remove it and the sorting has no brake at all: each generation the furthest movers found the edge, their offspring inherit a longer step and pay nothing for it, and the trait ratchets until the corridor runs out.

set.seed(8821)
nocost <- run_arm("nocost", 0.5, 0.20, 0)
nocost_sum <- arm_summary(nocost$per_rep)
wall_gen_med <- median(nocost$per_rep$wall_gen)
wall_gen_min <- min(nocost$per_rep$wall_gen)
cost_row <- top_row
print(round(c(replicates = nrow(nocost$per_rep), reached_wall = nocost_sum$walls,
              first_wall_gen = wall_gen_min, median_wall_gen = wall_gen_med,
              cline = nocost_sum$cline), 2))
     replicates    reached_wall  first_wall_gen median_wall_gen           cline 
          60.00           60.00           19.00           28.50           15.94 

All 60 replicates of the no-cost arm reach the far end of the 400-cell corridor, the first at generation 19 and the median at generation 28.5, while the same trait variance and heritability with a cost of 0.05 per cell finishes at 264 cells. Nothing about the no-cost run can be quoted as a speed, because the number that comes out is a property of the corridor length. Any spatial sorting model reported without its cost term is in that situation whether or not the wall is visible in the output.

The cost used here is mortality that rises with distance moved, which is the simplest form and the one with the clearest mechanism: a long move is time spent outside a territory. Perkins and colleagues model dispersal and life-history evolution together in cane toads, with no explicit dispersal cost, and a different cost structure would be expected to change the magnitudes. The point for a reader is that the cost is a free parameter of any spatial sorting model, and the acceleration it permits is what your model reports.

What to report

A spread model with evolving dispersal needs four things stated before any speed can be interpreted.

The cost term comes first: its form (mortality per unit distance, or a fecundity trade-off), its magnitude, and what the run does without it. If every replicate hits the edge of the domain, the speed you measured is the domain’s.

The second is the cline the model builds, measured as a front-to-core ratio of realised move length rather than of the latent trait, because that is the quantity a field study can report back. Say whether it is the attempted step or the surviving one: restricting the ratio to the offspring that survive the cost lowers it by 10 per cent in the row quoted above, since the front’s long movers are also the ones the cost kills. A model whose cline reaches 14 is not describing the same population as one whose cline reaches 2.0, even though both are called spatial sorting.

Third, the speed ratio needs its windows. The ratio here is late speed over early speed with the early window at generations 6 to 15, and at the steepest cline that window is already measuring a fast front, which is why the ratio stops rising there.

Fourth, report the replicate spread and its denominator. The interesting claim in the row carried through this post is not the 32 per cent acceleration but the 3.6-fold rise in the standard deviation of where the front ends up, and that number only means something against a control with the same trait variance and no inheritance.

Honest limits

The model is one dimensional, which favours sorting: in two dimensions the leading edge is a line rather than a point, more individuals hold it, and the trait cline builds more slowly. The one-dimensional corridor is the same simplification the other spread posts on this site make, so the comparison between them is fair, but the magnitudes here are upper bounds.

There is no mutation and no stabilising selection on the trait. The genetic variance does not run down, because the segregation term described next restores it every generation, but the core trait mean does drift, and nothing in the model would hold it in place over a long run. The segregation variance is held at half the initial genetic variance rather than recomputed from the current allele frequencies, which is the standard infinitesimal approximation and the same one the breeder’s equation post uses; it overstates the variance available at the front once the front population is strongly related.

Mating is with a random individual from the same cell, including possibly itself, and there is no separate sex. That makes the assortment by position complete: an edge individual cannot mate with a core individual at all. Real fronts are leakier, and any leakage slows the ratchet.

The fat-kernel comparison depends on what is held equal, and this post runs both conventions rather than choosing one. Neither is the right one: matching on extent compares two runs that went the same distance by different means, and matching on the median step compares a kernel that carries a cost with one that does not. They disagree about the distance travelled and about the exponent, and nothing in this post proves that no statistic of the extent series could separate the mechanisms.

The fat arm is also the most favourable version of its own side. A stretched exponential with shape 0.5 and 0.4, the family the fat-tails post uses, was tried first as the comparison kernel, in an exploratory run that is not part of this post’s code: under the same mortality cost as the sorting arms it showed no clear acceleration. The Student t with two degrees of freedom and no cost was used instead because it is the version that does accelerate, so the fat-tail side here is a heavier tail than that post’s, carrying no cost.

There is no field anchor here. No published front-to-core contrast in individual dispersal was verified for this post, so the rows carry their measured cline and no claim about which of them a real species resembles. That matters for the size of everything quoted: if the real contrast in a given species were five rather than two, the relevant rows would be the strong-cline ones, with speed ratios of 2.01 to 2.63 rather than 1.32.

Finally, the numbers of the main grid are from 60 replicates at each of 12 parameter combinations, with the fixed design constants stated in the code rather than chosen after looking at results. The speed ratios carry bootstrap standard errors of 0.03 to 0.13, and the extent standard deviations carry roughly 9 per cent relative uncertainty each, so small differences between neighbouring arms are not worth interpreting.

References

Shine R, Brown GP, Phillips BL 2011 Proceedings of the National Academy of Sciences 108(14):5708-5711 (10.1073/pnas.1018989108)

Phillips BL, Brown GP, Travis JMJ, Shine R 2008 American Naturalist 172(S1):S34-S48 (10.1086/588255)

Ochocki BM, Miller TEX 2017 Nature Communications 8:14315 (10.1038/ncomms14315)

Weiss-Lehman C, Hufbauer RA, Melbourne BA 2017 Nature Communications 8:14303 (10.1038/ncomms14303)

Perkins TA, Phillips BL, Baskett ML, Hastings A 2013 Ecology Letters 16(8):1079-1087 (10.1111/ele.12136)

Newsletter

Get updates by email

An occasional email when tutorials are added or substantially corrected. No spam; unsubscribe anytime.

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