Every test in Chapter 8 and Chapter 9 leans on a theoretical distribution: the t, the normal, the \(\chi^2\). This chapter builds those distributions from the data instead, by shuffling and resampling, and then turns the idea around: simulating data from a known process to ask how often a study of a given size would find an effect.
Julia is a natural fit. Resampling means running the same calculation thousands of times, and in Julia the plain loop that expresses the idea is also fast (Section 4.7), so there is no need to contort the code to avoid it.
NoteWhere this chapter comes from
Like Chapter 10, this chapter is inspired byecoR(Oliveira 2026), the R course wiki of the Instituto de Biociências, Universidade de São Paulo, produced and maintained by Alexandre Adalardo de Oliveira. The sequence — permutation, then bootstrap, then simulation from known distributions, with a null model for species co-occurrence — follows its resampling module. The text, examples and exercises here are written from scratch for Julia; the authors of ecoR have not reviewed this book.
If you read Portuguese and want to go further, the original material is freely available (CC BY-SA 4.0):
Most answers here come from random draws, so yours will not match the solution to the last digit, and check accepts anything within the expected random variation. Use at least as many permutations or simulations as the exercise asks for: with too few, the answer is too noisy to pass. Start each session with:
Three functions do almost all the work in this chapter. shuffle reorders a vector; sample, from StatsBase.jl, draws from it with or without replacement:
x = [1, 2, 3, 4, 5](shuffled =shuffle(x), without_replace =sample(x, 3; replace =false), with_replace =sample(x, 5)) # replace = true is the default
Random numbers in Julia come from a random number generator (RNG). Setting its seed, as the setup block does with Random.seed!(2026), makes a script produce the same draws each time it runs. For code you want to control more tightly, create a generator of your own and pass it as the first argument:
rng =Xoshiro(42)sample(rng, x, 3)
3-element Vector{Int64}:
4
3
3
WarningSame seed, different Julia, different numbers
The Julia manual states that a minor release (1.11 to 1.12, say) may change the sequence of random numbers generated from a given seed. A seed makes your analysis reproducible on your setup, not across all versions. For exact reproducibility, save the random data, or pin the Julia version and the Manifest.toml. This is also why the exercises here accept a range of answers.
11.2 Permutation tests
In Section 8.1 a Welch t test asked whether body size differs between permanent and temporary ponds. A permutation test asks the same question with no distributional assumption, in five steps:
Choose a statistic that measures the effect: here, the difference in mean body size between permanent and temporary ponds.
Compute it for the real data.
State the null hypothesis as a shuffle: if hydroperiod has no effect, the labels “permanent” and “temporary” are interchangeable.
Shuffle the labels many times, recomputing the statistic each time. This builds the distribution of the statistic under the null hypothesis.
The p-value is the proportion of shuffles with a statistic at least as extreme as the observed one.
Two details. abs makes the test two-sided: a difference of −0.9 counts as extreme as one of +0.9. And the + 1 in numerator and denominator counts the observed data as one of the possible arrangements, which keeps the p-value from ever being exactly zero (Phipson and Smyth 2010).
fig =Figure(size = (700, 380))ax =Axis(fig[1, 1], xlabel ="Difference in mean body size (mm), permanent − temporary", ylabel ="Shuffles")hist!(ax, null_diffs, bins =50, color = (:grey60, 0.8))vlines!(ax, [observed, -observed], color =:darkorange, linestyle =:dash, linewidth =2)fig
Figure 11.1: Null distribution of the difference in mean body size, from 9 999 shuffles of the hydroperiod labels. The dashed lines mark the observed difference and its mirror image.
The p-value, about 0.10, is close to the 0.103 of the Welch test: with 173 ponds and roughly symmetric data, the theory and the shuffling agree. That will not always be true with small samples or skewed data, which is where permutation earns its keep. HypothesisTests.jl packages the same idea:
Is canopy cover different in ponds where the focal species occurs? Run a two-sided permutation test on the difference in mean canopy between ponds with focal == 1 and focal == 0, with 9 999 shuffles, and pass the p-value to check("11.1", ...).
TipHint
Reuse mean_diff and the loop above, with ponds.canopy as the response and ponds.focal .== 1 as the groups.
About 0.04: ponds with the focal species have about 7 points more canopy. The simulation built exactly that in: occurrence rises with canopy (scripts/make_data.jl). The comprehension does the same as the loop, in one line.
11.3 Permuting a regression
The same logic tests a slope. If body size does not depend on canopy, any pairing of body sizes with canopy values is as likely as the observed one, so shuffling the response breaks the relationship while keeping both distributions intact:
(slope = -0.04966562886720383, extreme = 0, p = 0.0001)
cov(x, y) / var(x) is the least-squares slope, the same number lm estimates in Section 8.2. Almost no shuffle produces a slope as steep as the observed one — the count is in extreme — so the p-value is at or near the smallest that 9 999 shuffles can show, 1/10 000. To say more you would need more shuffles, but “about 1 in 10 000 or less” is usually all a result needs.
NoteYour turn 11.2 — Body size and pond area
Test by permutation whether body size is related to pond area: use the slope of body_size on log_area, two-sided, with 9 999 shuffles. Pass the p-value to check("11.2", ...).
TipHint
The block above with ponds.log_area in place of ponds.canopy. Shuffle a copy of body_size, not the column itself, so the data stay intact.
About 0.26. The simulation has no effect of area on body size, and the test finds none — correctly, this time. One test in twenty would find one anyway.
11.4 The bootstrap
A permutation test asks what the data would look like if there were no effect. The bootstrap(Efron 1979) asks a different question: how much would an estimate change if we had collected another sample? We cannot collect it, but we can imitate it by drawing a new sample of the same size from the data themselves, with replacement. Some ponds appear twice, others not at all, and the spread of the estimate across many such samples measures its uncertainty.
n =nrow(ponds)boot_medians = [median(sample(ponds.body_size, n)) for _ in1:9_999](median =median(ponds.body_size), ci_95 =quantile(boot_medians, [0.025, 0.975]))
(median = 35.56, ci_95 = [35.08, 36.54])
The 2.5% and 97.5% quantiles of the bootstrap medians give a 95% percentile interval — for a statistic, the median, that has no simple formula for its standard error.
The bootstrap also checks a model’s own uncertainty. Resample whole ponds (rows), refit the Poisson model g1 of Section 9.2 each time, and compare the spread of the canopy coefficient with the standard error the model reports:
(bootstrap_se = 0.0018378945669366084, model_se = 0.0013361976263705473, ratio = 1.375466121676026)
The bootstrap standard error is about 1.4 times larger than the one the Poisson model reports — close to \(\sqrt{1.95}\), the square root of the dispersion statistic of g1 in Section 9.3. The model’s standard errors assume the variance equals the mean; the bootstrap assumes nothing about it, and sees the extra variation the model leaves out.
Tipdo blocks
map(1:2_000) do _ ... end is map(_ -> ..., 1:2_000) written as a block, for anonymous functions longer than one line. _ names an argument you do not use.
NoteYour turn 11.3 — A bootstrap function
Write boot_means(x, B), which returns a vector with the means of B bootstrap samples of x. Check it with check("11.3", boot_means).
TipHint
One comprehension: [mean(sample(x, length(x))) for _ in 1:B]. Each bootstrap sample has the same size as x and is drawn with replacement.
CautionSolution
boot_means(x, B) = [mean(sample(x, length(x))) for _ in1:B]check("11.3", boot_means)
✓ Correct!
NoteYour turn 11.4 — How rich are Amazonian ponds?
Using boot_means with 9 999 samples, compute a 95% percentile interval for the mean richness of the Amazon ponds. Pass it as [lower, upper] to check("11.4", ...). Compare it with the usual t interval.
TipHint
ponds.richness[ponds.region .== "Amazon"] selects the Amazon ponds. quantile(v, [0.025, 0.975]) returns both ends at once.
The two intervals nearly coincide, from about 9.4–9.5 to 13. The bootstrap one starts a little higher: richness is a skewed count, and the bootstrap follows the skew where the t interval is symmetric by construction.
11.5 How many ponds do I need?
Resampling reuses the data you have. Simulation creates data you do not have yet, from a process you specify — and because you chose the process, you know the right answer. That is the logic of this whole book (Section 5.1), and it answers a question every field project faces before it starts: how many sampling units are enough?
Suppose permanent ponds hold about 25% more species than temporary ones, as in the simulation behind ponds.csv (\(e^{0.22} \approx 1.25\)). This function generates a survey of n ponds from that process:
For a survey of n ponds, fit the model and record whether it detects the effect of hydroperiod at the 5% level. The proportion of simulated surveys that do is the power of that design:
functiondetects_hydro(n; kwargs...) d =simulate_ponds(n; kwargs...) m =glm(@formula(richness ~ log_area + hydro), d, Poisson(), LogLink())returncoeftable(m).cols[4][3] <0.05# p-value of the hydro coefficientendsizes = [20, 40, 60, 80, 100, 140, 180]power = [mean(detects_hydro(n) for _ in1:500) for n in sizes]DataFrame(n = sizes, power = power)
7×2 DataFrame
Row
n
power
Int64
Float64
1
20
0.35
2
40
0.616
3
60
0.796
4
80
0.886
5
100
0.954
6
140
0.992
7
180
1.0
fig =Figure(size = (700, 380))ax =Axis(fig[1, 1], xlabel ="Number of ponds", ylabel ="Power")lines!(ax, sizes, power, color =:steelblue, linewidth =2.5)scatter!(ax, sizes, power, color =:steelblue)hlines!(ax, 0.8, color =:grey40, linestyle =:dash)ylims!(ax, 0, 1)fig
Figure 11.2: Power to detect a 25% difference in richness between permanent and temporary ponds, from 500 simulated surveys per sample size. The dashed line marks the conventional target of 80%.
About 60 ponds reach the usual 80% power; with 20, two surveys in three would miss an effect that is really there. And a non-significant result from a small survey says little: most of them are non-significant whether or not the effect exists.
NoteYour turn 11.5 — When the design is unbalanced
In the field you rarely get equal numbers of each kind of pond. Estimate the power with 60 ponds when only 20% are temporary (p_temporary = 0.2), from at least 1 000 simulated surveys, and pass it to check("11.5", ...).
TipHint
detects_hydro passes its keywords on to simulate_ponds (Section 10.2), so detects_hydro(60; p_temporary = 0.2) already does what you need.
CautionSolution
power_unbalanced =mean(detects_hydro(60; p_temporary =0.2) for _ in1:2_000)
0.5885
check("11.5", power_unbalanced)
✓ Correct!
About 0.60, down from 0.80 with a balanced design: the same 60 ponds tell you less, because the comparison rests on only about 12 temporary ones.
11.6 A null model for species co-occurrence
Permutation is also the basis of null models in community ecology. The question here is whether species co-occur less than expected by chance, which could indicate competition or different habitat requirements. The data are a presence–absence matrix, one row per species and one column per site.
ponds.csv has no species lists, so this section uses a small matrix simulated for the purpose: six species in twelve ponds.
M = [111011000001;101111010000;011111000100;100000111011;001000110111;010000101111](species_occurrences =vec(sum(M, dims =2)), pond_richness =vec(sum(M, dims =1)))
A common statistic is the C-score(Stone and Roberts 1990). For each pair of species, count the “checkerboard units”: with \(r_i\) and \(r_j\) the number of sites each occupies and \(S\) the number of sites they share, the pair contributes \((r_i - S)(r_j - S)\). The C-score is the mean over all pairs. The less species share sites, the higher it is.
NoteYour turn 11.6 — The C-score
Write cscore(M) for a matrix with species in rows and sites in columns, and check it with check("11.6", cscore). The rest of this section uses it.
TipHint
r = vec(sum(M, dims = 2)) gives the occurrences of each species. Loop over pairs with for i in 1:s-1, j in i+1:s, where s = size(M, 1); the shared sites are sum(M[i, :] .& M[j, :]).
CautionSolution
functioncscore(M) r =vec(sum(M, dims =2)) s =size(M, 1) total, pairs =0.0, 0for i in1:s-1, j in i+1:s shared =sum(M[i, :] .& M[j, :]) total += (r[i] - shared) * (r[j] - shared) pairs +=1endreturn total / pairsendcscore(M)
13.866666666666667
check("11.6", cscore)
✓ Correct!
Is a C-score of about 13.9 high? It depends on what random assemblages of these species in these ponds would give. Common species share more sites than rare ones by chance alone, and rich ponds hold more species than poor ones, so a fair null model keeps both the number of occurrences of each species and the richness of each pond fixed, and randomizes only which species go where (Gotelli 2000).
The swap algorithm does exactly that. It picks two species and two ponds at random; if they form a checkerboard — [1 0; 0 1] or [0 1; 1 0] — it flips it. Rows and column totals never change:
Figure 11.3: C-scores of 999 null assemblages with the same species frequencies and pond richness as the observed one. The dashed line marks the observed C-score.
The observed C-score sits in the upper tail: about 1% of null assemblages are as segregated. The test is one-sided, because the question was about less co-occurrence than expected. The standardized effect size (ses), the distance from the null mean in null standard deviations, is the usual way to compare such results between matrices of different sizes.
TipDoing this for real
This is a teaching version. For a real analysis, R’s vegan package (nullmodel, oecosimu) implements many null algorithms with the care they need — including how many swaps are enough for the null matrices to be independent, which this version glosses over. Section 9.7.1 shows how to call it from Julia.
Challenges
Repeat the permutation test of Section 11.2 with median instead of mean as the statistic. Which would you report for body size, and why?
Rerun the power analysis for a 10% effect (\(e^{\beta} = 1.10\)). How many ponds would you need? What would you tell a colleague planning a survey of 40 ponds?
Build a matrix in which only one pair of species is perfectly segregated and the others are scattered at random. Does the fixed–fixed null model detect it? What does that say about the C-score as a measure of a single pair’s interaction?
Oliveira, Alexandre Adalardo de. 2026. ecoR: Curso de Introdução à Linguagem R. Wiki, Instituto de Biociências, Universidade de São Paulo. https://ecor.ib.usp.br/.
Phipson, Belinda, and Gordon K. Smyth. 2010. “Permutation P-values should never be zero: calculating exact P-values when permutations are randomly drawn.”Statistical Applications in Genetics and Molecular Biology 9 (1). https://doi.org/10.2202/1544-6115.1585.
Stone, Lewi, and Alan Roberts. 1990. “The checkerboard score and species distributions.”Oecologia 85 (1): 74–79. https://doi.org/10.1007/BF00317345.