Repeated measurements and crossover studies

Class 10: one animal, several observations

Gavin Simpson

Aarhus University

Wednesday, 11 November 2026

Today: two different ways to repeat

Time Work
12:30–12:40 Opening recall: allocation and repeated measurements
12:40–13:15 Calves: one diet, several weights
13:15–13:40 Cows: both treatments, in a random order
13:40–13:55 Break
13:55–14:55 Fit and check the crossover model
14:55–15:10 Break
15:10–15:35 Save a project analysis plan
15:35–16:00 Compare designs, report and exit

Opening recall: how did researchers assign diets?

Last class we saw animals sharing pens. A shared feeder means a whole pen may receive one diet.

Today, 32 individually housed calves each receive their own randomly assigned diet, within four rooms. Each calf is weighed five times.

Discuss with your seated group: did researchers choose a diet for each calf or for a whole room? If they assigned 16 calves to each diet, does weighing every calf five times create more diet assignments?

The calf study: what do we want to know?

Control and Enhanced diets start after the baseline weight.

Calves are weighed at weeks 0, 2, 4, 6 and 8.

Our first question: how much heavier, on average, are Enhanced calves at week 8?

These are synthetic teaching data. We are practising analysis, not making a feeding recommendation.

Read the actual observations

Run in-class-student.R from the project root. These packages supply the tools we use directly.

library("lme4")
library("performance")
library("marginaleffects")
d <- readr::read_csv("data/calf-growth.csv")
head(d, 3)

One row is one weight. calf identifies repeated observations of the same animal.

Read the actual observations

# A tibble: 3 × 5
  room  calf  diet    time_week weight_kg
  <chr> <chr> <chr>       <dbl>     <dbl>
1 R1    C01   Control         0      47.9
2 R1    C01   Control         2      58.6
3 R1    C01   Control         4      65.8

Tell R which columns are categories

A category is a label rather than a numerical trend.

library("dplyr")

d <- d |>
  mutate(
    room = factor(room),
    calf = factor(calf),
    diet = factor(diet, levels = c("Control", "Enhanced")),
    time_factor = factor(time_week, levels = c(0, 2, 4, 6, 8))
  )
levels(d$time_factor)
[1] "0" "2" "4" "6" "8"

Here we estimate a mean at each visit. We do not yet choose a growth curve between visits.

Count calves, then count weights

distinct() keeps each calf’s allocation once.

library("dplyr")

allocations <- d |> distinct(calf, room, diet)
allocations |> count(room, diet)
# A tibble: 8 × 3
  room  diet         n
  <fct> <fct>    <int>
1 R1    Control      4
2 R1    Enhanced     4
3 R2    Control      4
4 R2    Enhanced     4
5 R3    Control      4
6 R3    Enhanced     4
7 R4    Control      4
8 R4    Enhanced     4
c(calves = nrow(allocations), weights = nrow(d))
 calves weights 
     32     160 

Four calves per diet per room: 16 calves assigned to each diet, not 80.

Let each calf have its own usual level

Some calves tend to be heavier at every visit. (1 | calf) lets the model represent that shared pattern.

calf_fit <- lmer(
  weight_kg ~ room + diet * time_factor + (1 | calf),
  data = d,
  REML = TRUE
)
summary(calf_fit)

room allows different room means. diet * time_factor allows the diet gap to differ between visits.

Let each calf have its own usual level

Linear mixed model fit by REML ['lmerMod']
Formula: weight_kg ~ room + diet * time_factor + (1 | calf)
   Data: d

REML criterion at convergence: 701.4

Scaled residuals: 
     Min       1Q   Median       3Q      Max 
-2.31755 -0.51062  0.00236  0.64098  1.95874 

Random effects:
 Groups   Name        Variance Std.Dev.
 calf     (Intercept) 29.094   5.394   
 Residual              2.541   1.594   
Number of obs: 160, groups:  calf, 32

Fixed effects:
                          Estimate Std. Error t value
(Intercept)               38.43207    2.18001  17.629
roomR2                     0.05863    2.72041   0.022
roomR3                     2.40960    2.72041   0.886
roomR4                     4.03348    2.72041   1.483
dietEnhanced               0.02500    1.98857   0.013
time_factor2              10.71919    0.56358  19.020
time_factor4              19.11194    0.56358  33.911
time_factor6              28.42313    0.56358  50.433
time_factor8              36.45800    0.56358  64.690
dietEnhanced:time_factor2  0.44562    0.79703   0.559
dietEnhanced:time_factor4  3.22037    0.79703   4.040
dietEnhanced:time_factor6  2.60100    0.79703   3.263
dietEnhanced:time_factor8  3.19725    0.79703   4.011

Correlation matrix not shown by default, as p = 13 > 12.
Use print(x, correlation=TRUE)  or
    vcov(x)        if you need it

Ask for the week-8 comparison directly

avg_comparisons() changes the diet label and compares the resulting predicted weights. by requests a row for each week.

calf_gaps <- avg_comparisons(
  calf_fit,
  variables = "diet",
  by = "time_factor",
  re.form = NA
)
calf_gaps

Positive means Enhanced minus Control in kg. The comparison gives each of the four study rooms equal weight. re.form = NA sets each calf’s fitted weight difference to zero.

Ask for the week-8 comparison directly


 time_factor Estimate Std. Error      z Pr(>|z|)   S  2.5 % 97.5 %
           0    0.025       1.99 0.0126    0.990 0.0 -3.872   3.92
           2    0.471       1.99 0.2367    0.813 0.3 -3.427   4.37
           4    3.245       1.99 1.6320    0.103 3.3 -0.652   7.14
           6    2.626       1.99 1.3206    0.187 2.4 -1.271   6.52
           8    3.222       1.99 1.6204    0.105 3.2 -0.675   7.12

Term: diet
Type: response
Comparison: Enhanced - Control

Check before trusting the comparison

check_convergence(calf_fit)
[1] TRUE
attr(,"gradient")
[1] 2.904132e-09
check_singularity(calf_fit)
[1] FALSE

First result: did the fitting calculation finish successfully?

Second result: did the estimated between-calf variation collapse to zero? Here FALSE means it did not.

A successful calculation cannot tell us whether nearby weights share additional influences. We return to that issue in Class 11.

A different study: every cow receives both treatments

A researcher compares two rations, A and B, using daily feed intake.

In a crossover study, each cow receives A in one treatment period and B in another.

The same cow provides the two measurements. This may help when cows differ greatly in appetite.

It works only if a cow can return to a suitable starting condition between treatments.

Randomise the order, not just the first ration

Assigned order, called the sequence First treatment period Second treatment period
AB: 12 cows A B
BA: 12 cows B A

Randomly assign 24 cows to these two orders.

A period is a scheduled treatment phase. Both treatments appear in both periods, so a general change over time can be separated from the treatment comparison.

Plan the gap between treatments

This synthetic example records average intake over the final three days of each ten-day treatment period. A ten-day treatment-free gap is specified between periods.

A washout is time for the earlier treatment’s influence to wear off. Carryover means that influence remains.

The stated ten days is a teaching assumption. A real study needs evidence about the particular treatment and outcome.

Decide whether crossover is suitable

A ration may have a short-lived effect on intake. A vaccine may cause a lasting change. Growth and recovery may also be irreversible.

For each example, discuss: could the first treatment change the cow’s later outcome even after treatment stops?

If so, extending the gap needs scientific justification; assigning separate animals to treatments may be more appropriate.

Before the break: check the design together

Our cow study has two periods and two assigned orders.

  1. Why would giving all cows A then B prevent a fair treatment comparison?
  2. What evidence would justify the treatment-free gap?
  3. Why are the two observations from one cow related?

After the break, we fit a model that allows for period and cows’ usual differences in appetite.

Break 13:40–13:55.