In the Your turn boxes you fit a model and pass a number extracted from it — a p-value, coefficients, an interval — to check. All of them use the ponds with canopy recorded, as in this chapter. Start each session with:
Two sample t-test (unequal variance)
------------------------------------
Population details:
parameter of interest: Mean difference
value under h_0: 0
point estimate: 1.01849
95% confidence interval: (-0.452, 2.489)
Test summary:
outcome with 95% confidence: fail to reject h_0
two-sided p-value: 0.1734
Details:
number of observations: [95,78]
t-statistic: 1.3673234271337709
degrees of freedom: 168.03764735213443
empirical standard error: 0.7448775533304762
In R
t.test(richness ~ hydro, data = ponds) uses Welch by default — the same as UnequalVarianceTTest. For the equal-variance test, use EqualVarianceTTest.
NoteYour turn 8.1 — Does hydroperiod affect body size?
Compare body_size between permanent and temporary ponds with a Welch t test. Pass its p-value to check("8.1", ...).
TipHint
Reuse perm and temp_ from the block above. pvalue(test) extracts the p-value.
p ≈ 0.10: no evidence of a difference. Here we can go further than usual, because in the simulation hydroperiod really has no effect on body size. With real data, a non-significant test does not show that there is no effect — only that the data cannot tell.
The generating process (see scripts/make_data.jl) used \(38.0 + 0.9\,(\text{temp} - 24.5) - 0.04\,\text{canopy}\). Compare with the estimates: this is the payoff of teaching with simulated data.
DataFrame( term =coefnames(m2), estimated =round.(coef(m2), digits =3), truth = [38.0-0.9*24.5, 0.9, -0.04],)
3×3 DataFrame
Row
term
estimated
truth
String
Float64
Float64
1
(Intercept)
15.761
15.95
2
temp_c
0.901
0.9
3
canopy
-0.042
-0.04
NoteYour turn 8.3 — Does the interval catch the truth?
Fit body_size ~ temp_c + canopy + hydro and compute the 95% confidence interval for the temp_c coefficient. Does it contain the true value, 0.9? Pass the interval (two numbers) to check("8.3", ...).
TipHint
confint(m) returns one row per coefficient; temp_c is the second row, so confint(m)[2, :].
Julia treats String and CategoricalArray columns as factors automatically, with treatment (dummy) coding and the first level as reference — same as R. To change the reference level or the contrast scheme:
The reference level has no coefficient of its own: it is absorbed by the intercept, and the other regions are compared with it. Without contrasts, the reference is the first level in alphabetical order, Amazon.
models = ["temp"=> m1,"temp+canopy"=> m2,"temp+can+reg"=> m3,"temp*hydro"=> m4]DataFrame( model =first.(models), k = [length(coef(m)) for (_, m) in models], aic =round.([aic(m) for (_, m) in models], digits =1), bic =round.([bic(m) for (_, m) in models], digits =1), adj_r2 =round.([adjr2(m) for (_, m) in models], digits =3),)
4×5 DataFrame
Row
model
k
aic
bic
adj_r2
String
Int64
Float64
Float64
Float64
1
temp
2
801.4
810.9
0.542
2
temp+canopy
3
775.6
788.2
0.607
3
temp+can+reg
5
776.1
795.0
0.611
4
temp*hydro
4
803.5
819.3
0.541
# F test between nested models, equivalent to anova() in Rftest(m1.model, m2.model, m3.model)
res =residuals(m3)fit_ =predict(m3)fig =Figure(size = (860, 350))ax1 =Axis(fig[1, 1], xlabel ="Fitted values", ylabel ="Residuals", title ="(a) Residuals vs fitted")scatter!(ax1, fit_, res, color = (:steelblue, 0.6))hlines!(ax1, 0, color =:grey40, linestyle =:dash)ax2 =Axis(fig[1, 2], xlabel ="Theoretical quantiles", ylabel ="Sample quantiles", title ="(b) QQ plot")# NOTE: Makie exports `qqnorm` as a plot recipe, so the quantiles are# computed explicitly here rather than taken from a helper.n =length(res)theoretical =quantile.(Normal(0, 1), ((1:n) .-0.5) ./ n)scatter!(ax2, theoretical, sort(res), color = (:darkorange, 0.6))ablines!(ax2, 0, std(res), color =:grey40, linestyle =:dash)ax3 =Axis(fig[1, 3], xlabel ="Residuals", ylabel ="Frequency", title ="(c) Distribution")hist!(ax3, res, bins =20, color = (:seagreen, 0.7))fig
Figure 8.1: Residual diagnostics for model m3.
WarningA real gap
R has plot(model), which returns the four diagnostic plots at once, and performance::check_model(), which goes much further. Julia has no consolidated equivalent; the diagnostics above are assembled by hand. This is a genuine gap, and exactly the kind of thing a community package would fix well.
NoteYour turn 8.5 — How many large residuals?
If the residuals of m3 are roughly normal, about 5% of them should lie more than two standard deviations from zero. Compute the proportion that does and pass it to check("8.5", ...).
TipHint
Divide res (from the diagnostics block) by std(res), then take the mean of abs.(z) .> 2 — the mean of true/false values is a proportion.
CautionSolution
z = res ./std(res)prop_large =mean(abs.(z) .>2)
0.057803468208092484
check("8.5", prop_large)
✓ Correct!
About 5.8% — 10 of 173 residuals, close to the 5% expected. This is a numerical counterpart to the QQ plot above.
8.7 Mixed models
MixedModels.jl(Bates et al. 2026) is maintained by Douglas Bates, first author of lme4(Bates et al. 2015), and uses the same formula notation — including (1 | group).
On performance: comparisons between the two depend heavily on the model, the size of the data and the random-effects structure, and I know of no published benchmark I can cite here. If fitting time matters for your problem, measure it on your own case.
Identical, down to the (1 | group) notation. MixedModels.jl also supports (1 + x | group), (1 | a) + (1 | b) and nested terms.
NoteYour turn 8.6 — A random intercept for region
Fit a mixed model of body_size on temp_c with a random intercept for region and pass its fixed effects to check("8.6", ...). Then look at VarCorr of your model: how much variation does region account for?
TipHint
Same structure as mm1, with a shorter formula. fixef(mm) returns the fixed effects.
The region variance is estimated as exactly zero — a singular fit. The simulation has no region effect on body size, and three regions give very little information about a variance anyway. With a zero variance, the mixed model reduces to lm(@formula(body_size ~ temp_c), ponds): compare the coefficients.
# parametric bootstrap intervals — fast enough to be routineusingRandomboot =parametricbootstrap(MersenneTwister(42), 1000, mm1)DataFrame(shortestcovint(boot))
Challenges
Fit body_size ~ canopy separately for each region and compare the slopes with those of a single model with a canopy * region interaction. Why do they agree?
Does m4 have a significant interaction? The simulation has no temp_c × hydro interaction: if yours were significant, what would that mean?
In 8.6 the region variance came out as zero. Compare mm1 with a model that treats region as a fixed effect. With three regions, which would you report, and why?
Bates, Douglas, Phillip Alday, Dave Kleinschmidt, et al. 2026. MixedModels.jl: A Julia Package for Fitting (Statistical) Mixed-Effects Models. V. 5.8.2. Released. https://github.com/JuliaStats/MixedModels.jl.
Bates, Douglas, Martin Mächler, Ben Bolker, and Steve Walker. 2015. “Fitting Linear Mixed-Effects Models Using lme4.”Journal of Statistical Software 67 (1): 1–48. https://doi.org/10.18637/jss.v067.i01.