---
title: "When variance carries signal, Part 2: location-scale-scale models"
output: rmarkdown::html_vignette
vignette: >
  %\VignetteIndexEntry{When variance carries signal, Part 2: location-scale-scale models}
  %\VignetteEngine{knitr::rmarkdown}
  %\VignetteEncoding{UTF-8}
---

```{r, include = FALSE}
knitr::opts_chunk$set(
  collapse = TRUE,
  comment = "#>",
  fig.width = 7,
  fig.height = 4.1,
  dpi = 144
)
if (!"package:drmTMB" %in% search()) {
  library(drmTMB)
}
```

A location-scale model asks whether predictors change the expected response
`mu` and the residual standard deviation `sigma`. A location-scale-scale model
adds a third submodel: predictors can also change the standard deviation of a
latent random effect. `drmTMB` writes this third submodel as
`sd(group) ~ predictors`.

This is Part 2 of the variance sequence. Return to [Part 1: location-scale
models](location-scale.html) if `mu` and `sigma` are new to you or if the
scientific question concerns only mean and residual variation. Read [Which scale are you
modelling?](which-scale.html) when you need to distinguish residual SD,
group-level SD, known sampling variance, and likelihood weights.

## Personality, predictability, and repeatability

Suppose an exploration score is recorded repeatedly for each individual. We
want to ask whether sex predicts three different features of behaviour:

1. the mean exploration score;
2. between-individual variation in expected scores; and
3. within-individual variation around each expected score.

For observation $j$ from individual $i$, write

\[
\begin{aligned}
y_{ij} &\sim \operatorname{Normal}(\mu_{ij}, \sigma_{e,i}^2),\\
\mu_{ij} &= \beta_0 + \beta_1\operatorname{sex}_i + b_i,\\
b_i &\sim \operatorname{Normal}(0, \sigma_{b,i}^2),\\
\log(\sigma_{e,i}) &= \gamma_0 + \gamma_1\operatorname{sex}_i,\\
\log(\sigma_{b,i}) &= \alpha_0 + \alpha_1\operatorname{sex}_i.
\end{aligned}
\]

Here, $\operatorname{sex}_i=0$ denotes female and
$\operatorname{sex}_i=1$ denotes male. $\sigma_{b,i}$ is the
between-individual SD and $\sigma_{e,i}$ is the within-individual residual SD.
The matching `drmTMB` formula is

```r
bf(
  exploration_score ~ sex + (1 | individual),
  sigma ~ sex,
  sd(individual) ~ sex
)
```

The same predictor appears in three formulas, but its coefficients answer
three separate questions. Sex must be constant within each individual because
it is used to model `sd(individual)`.

If `drmTMB()` reports that an `sd(individual)` predictor varies within an
individual, check the grouping variable and source data. Correct miscoded
values or remove that predictor from the `sd()` formula. Do not average a
genuinely within-individual predictor to silence the error, because doing so
changes the scientific question. See [Errors, warnings, and
convergence](convergence.html) for the next diagnostic steps.

## Simulate and fit the three submodels

The example below gives females and males different means, between-individual
SDs, and within-individual SDs. It is intentionally simple enough that the
three sources of signal remain visible.

```{r personality-simulate}
set.seed(20260715)
n_individual <- 80L
n_each <- 6L

individual_info <- data.frame(
  individual = factor(seq_len(n_individual)),
  sex = factor(
    rep(c("female", "male"), each = n_individual / 2),
    levels = c("female", "male")
  )
)

mean_by_sex <- c(female = 0.35, male = 0.70)
between_sd_by_sex <- c(female = 0.65, male = 0.40)
within_sd_by_sex <- c(female = 0.35, male = 0.60)

individual_effect <- stats::rnorm(
  n_individual,
  sd = between_sd_by_sex[individual_info$sex]
)

personality <- individual_info[rep(seq_len(n_individual), each = n_each), ]
personality$exploration_score <-
  mean_by_sex[personality$sex] +
  individual_effect[as.integer(personality$individual)] +
  stats::rnorm(
    nrow(personality),
    sd = within_sd_by_sex[personality$sex]
  )
```

```{r personality-fit}
fit_personality <- drmTMB(
  bf(
    exploration_score ~ sex + (1 | individual),
    sigma ~ sex,
    sd(individual) ~ sex
  ),
  family = gaussian(),
  data = personality
)

check_drm(fit_personality)
round(coef(fit_personality, "mu"), 3)
round(coef(fit_personality, "sigma"), 3)
round(coef(fit_personality, "sd(individual)"), 3)
```

Inspect interval availability before requesting an interval. The two slope
rows below are coefficients on log-SD scales; `profile_ready` says whether the
fitted object retained what a direct profile interval needs.

```{r personality-interval-targets}
personality_targets <- profile_targets(fit_personality)
personality_targets[
  personality_targets$parm %in% c(
    "fixef:sigma:sexmale",
    "fixef:sd(individual):sexmale"
  ),
  c("parm", "estimate", "profile_ready", "profile_note")
]
```

For a quick first interval, request the named coefficients and retain the
status and method columns returned with the endpoints:

```{r personality-component-intervals}
personality_ci <- confint(
  fit_personality,
  parm = c(
    "fixef:sigma:sexmale",
    "fixef:sd(individual):sexmale"
  ),
  method = "wald"
)
personality_ci
```

The `sigma` and `sd(individual)` coefficients use log-SD links. For example,
exponentiating the male coefficient from `sigma` gives the male-to-female
ratio of within-individual SDs. Exponentiating the corresponding
`sd(individual)` coefficient gives the male-to-female ratio of
between-individual SDs.

When an interval row has a successful `conf.status`, exponentiating both
log-SD endpoints gives an interval for the corresponding male-to-female SD
ratio. Keep the returned interval method and status with the reported result.
If the status marks a boundary or failure, do not transform missing or
unreliable endpoints; inspect the profile target and follow the suggested
profile or diagnostic route instead.

```{r personality-ratio-intervals}
interval_ok <- personality_ci$conf.status == "wald" &
  stats::complete.cases(personality_ci[c("lower", "upper")])
data.frame(
  parm = personality_ci$parm[interval_ok],
  sd_ratio_lower = exp(personality_ci$lower[interval_ok]),
  sd_ratio_upper = exp(personality_ci$upper[interval_ok]),
  method = personality_ci$method[interval_ok],
  conf.status = personality_ci$conf.status[interval_ok]
)
```

Before reporting either contrast, inspect the diagnostics printed by
`check_drm()`. A warning about weak replication, a non-positive-definite
Hessian, or a large terminal gradient means the fitted SD surface may be
unstable. Simplify the SD formula or increase group-level information, refit,
and use the [errors, warnings, and convergence guide](convergence.html) rather
than interpreting the coefficient anyway.

```{r personality-data-figure, echo = FALSE, fig.width = 7, fig.height = 4.1, fig.cap = "Repeated exploration scores for females and males. Grey points are observations, blue ticks are individual means, and the vermillion line is the fitted sex-specific mean. The spread of individual means represents between-individual variation; scatter around each individual mean represents within-individual variation. Both panels use the same vertical scale.", fig.alt = "Two panels show repeated exploration scores for female and male individuals. Grey observations cluster around blue individual-mean ticks, while a vermillion horizontal line marks the fitted sex-specific mean. The female panel has wider spread among individual means, while the male panel has more scatter within individuals."}
individual_summary <- stats::aggregate(
  exploration_score ~ individual + sex,
  data = personality,
  FUN = mean
)
individual_summary <- individual_summary[
  order(individual_summary$sex, individual_summary$exploration_score),
]
individual_summary$display_id <- ave(
  individual_summary$exploration_score,
  individual_summary$sex,
  FUN = seq_along
)

plot_data <- merge(
  personality,
  individual_summary[c("individual", "sex", "display_id")],
  by = c("individual", "sex"),
  sort = FALSE
)

sex_grid <- data.frame(
  sex = factor(c("female", "male"), levels = levels(personality$sex))
)
sex_grid$fitted_mean <- predict(
  fit_personality,
  newdata = sex_grid,
  dpar = "mu"
)

ggplot2::ggplot(
  plot_data,
  ggplot2::aes(x = display_id, y = exploration_score)
) +
  ggplot2::geom_point(
    position = ggplot2::position_jitter(width = 0.08, height = 0, seed = 1),
    colour = "grey55",
    alpha = 0.42,
    size = 0.8
  ) +
  ggplot2::geom_segment(
    data = individual_summary,
    ggplot2::aes(
      x = display_id - 0.26,
      xend = display_id + 0.26,
      y = exploration_score,
      yend = exploration_score
    ),
    inherit.aes = FALSE,
    colour = "#0072B2",
    linewidth = 0.65
  ) +
  ggplot2::geom_hline(
    data = sex_grid,
    ggplot2::aes(yintercept = fitted_mean),
    inherit.aes = FALSE,
    colour = "#D55E00",
    linewidth = 0.8
  ) +
  ggplot2::geom_text(
    data = sex_grid,
    ggplot2::aes(
      x = Inf,
      y = fitted_mean,
      label = "fitted sex mean"
    ),
    inherit.aes = FALSE,
    colour = "#D55E00",
    hjust = 1.05,
    vjust = -0.6,
    size = 3
  ) +
  ggplot2::facet_wrap(
    ~sex,
    nrow = 1,
    labeller = ggplot2::as_labeller(c(female = "Female", male = "Male"))
  ) +
  ggplot2::labs(
    x = "Individuals, ordered within sex by their observed mean",
    y = "Exploration score"
  ) +
  ggplot2::theme_minimal(base_size = 11) +
  ggplot2::theme(
    panel.grid.minor = ggplot2::element_blank(),
    panel.grid.major.x = ggplot2::element_blank(),
    axis.text.x = ggplot2::element_blank(),
    axis.ticks.x = ggplot2::element_blank()
  )
```

## Read the fitted scales and calculate repeatability

Predictions with `newdata` are population-level values. They give the three
fitted quantities for each sex directly on their natural scales.

```{r personality-components}
sex_grid$mean_score <- predict(
  fit_personality, newdata = sex_grid, dpar = "mu"
)
sex_grid$between_individual_sd <- predict(
  fit_personality, newdata = sex_grid, dpar = "sd(individual)"
)
sex_grid$within_individual_sd <- predict(
  fit_personality, newdata = sex_grid, dpar = "sigma"
)

sex_grid$repeatability <- with(
  sex_grid,
  between_individual_sd^2 /
    (between_individual_sd^2 + within_individual_sd^2)
)

repeatability_table <- sex_grid[c(
  "sex",
  "mean_score",
  "between_individual_sd",
  "within_individual_sd",
  "repeatability"
)]
repeatability_table[-1] <- lapply(
  repeatability_table[-1],
  round,
  digits = 3
)
repeatability_table
```

In behavioural ecology, this intraclass correlation is usually called
**repeatability**. For sex $s$,

\[
R_s = \frac{\sigma_{b,s}^2}
{\sigma_{b,s}^2 + \sigma_{e,s}^2}.
\]

Repeatability is derived from the two fitted scale submodels; it is not a
fourth formula. The calculation above is a point estimate. It does not supply
an uncertainty interval for this nonlinear ratio.

Do not create a repeatability interval by combining the separate endpoints of
the two SD intervals. That ignores their covariance and does not produce an
interval for the ratio itself. Report the repeatability calculation as a point
estimate unless the analysis includes a validated joint uncertainty method.

```{r personality-component-figure, echo = FALSE, fig.width = 7, fig.height = 2.9, fig.cap = "Model-implied values for females and males. Panels show the expected exploration score, between-individual SD from `sd(individual)`, and within-individual residual SD from `sigma`. Lines aid comparison and are not uncertainty intervals.", fig.alt = "Three small panels compare fitted female and male values. The expected score and within-individual standard deviation are higher for males, while the between-individual standard deviation is higher for females. No uncertainty intervals are shown."}
component_surface <- rbind(
  data.frame(
    sex = sex_grid$sex,
    component = "Expected score",
    estimate = sex_grid$mean_score
  ),
  data.frame(
    sex = sex_grid$sex,
    component = "Between-individual SD",
    estimate = sex_grid$between_individual_sd
  ),
  data.frame(
    sex = sex_grid$sex,
    component = "Within-individual SD",
    estimate = sex_grid$within_individual_sd
  )
)
component_surface$component <- factor(
  component_surface$component,
  levels = c(
    "Expected score",
    "Between-individual SD",
    "Within-individual SD"
  )
)

ggplot2::ggplot(
  component_surface,
  ggplot2::aes(x = sex, y = estimate, group = 1)
) +
  ggplot2::geom_line(colour = "grey65", linewidth = 0.55) +
  ggplot2::geom_point(colour = "#0072B2", size = 2.4) +
  ggplot2::geom_text(
    ggplot2::aes(label = sprintf("%.2f", estimate)),
    vjust = -0.8,
    colour = "grey20",
    size = 3.1
  ) +
  ggplot2::facet_wrap(~component, scales = "free_y", nrow = 1) +
  ggplot2::expand_limits(y = 0) +
  ggplot2::scale_y_continuous(
    expand = ggplot2::expansion(mult = c(0.02, 0.18))
  ) +
  ggplot2::scale_x_discrete(labels = c(female = "Female", male = "Male")) +
  ggplot2::labs(x = NULL, y = "Fitted value") +
  ggplot2::theme_minimal(base_size = 11) +
  ggplot2::theme(
    panel.grid.minor = ggplot2::element_blank(),
    panel.grid.major.x = ggplot2::element_blank(),
    strip.text = ggplot2::element_text(face = "bold")
  )
```

## A short phylogenetic extension

The same three-part logic applies when the latent location effect follows a
phylogeny. Start from the familiar constant-scale model

\[
\begin{aligned}
y_i &= \mu_i + a_i + e_i,\\
\mu_i &= \beta_0 + \beta_1 T_i,\\
\mathbf a &\sim \operatorname{MVN}(\mathbf 0, \sigma_a^2 A),\\
e_i &\sim \operatorname{Normal}(0, \sigma_e^2),
\end{aligned}
\]

where $A$ is the phylogenetic correlation matrix and $T_i$ is temperature. A
location-scale-scale extension lets both SDs change linearly with temperature:

\[
\log(\sigma_{e,i}) = \gamma_0 + \gamma_1 T_i,
\qquad
\log(\sigma_{a,i}) = \alpha_0 + \alpha_1 T_i.
\]

The matching syntax is deliberately simple:

```r
fit_phylo_lss <- drmTMB(
  bf(
    trait ~ temperature + phylo(1 | species, tree = tree),
    sigma ~ temperature,
    sd(species, level = "phylogenetic") ~ temperature
  ),
  family = gaussian(),
  data = dat
)
```

| Formula | Model quantity | Interpretation |
|---|---|---|
| `trait ~ temperature + phylo(...)` | $\mu_i$ and $a_i$ | expected trait and phylogenetically correlated location deviation |
| `sigma ~ temperature` | $\sigma_{e,i}$ | independent SD |
| `sd(species, level = "phylogenetic") ~ temperature` | $\sigma_{a,i}$ | SD of the phylogenetic location deviation |

The scalar covariance $\sigma_a^2 A$ is the constant-SD starting point. The
third formula generalizes it by allowing the phylogenetic-effect SD to vary
among species with temperature. The older formula spelling
`sd_phylo(species) ~ temperature` is soft-deprecated; fitted objects retain
the output label `sd_phylo(species)` for extractor compatibility.

With repeated observations within species, `sigma` is the within-species
residual SD. With one response row per species, it is the independent
non-phylogenetic species-level deviation. It should not automatically be
described as measurement error.

## What this example supports

- Ordinary `sd(group) ~ predictors` is implemented for distinct unlabelled
  Gaussian `mu` random intercepts. The matching `(1 | group)` term must appear
  in the location formula, and scale predictors must be constant within group.
- `sd(group, level = "phylogenetic") ~ predictors` targets the location
  phylogenetic effect introduced by `phylo()` in `mu`; predictors must be
  constant within species.
- This article teaches the Gaussian route. Check narrow non-Gaussian cells in
  [What can I fit today?](model-map.html) rather than generalizing from it.
- Random effects on the right-hand side of an `sd()` formula and generic
  direct-SD levels for `spatial()`, `animal()`, and `relmat()` remain separate
  implementation and validation questions.
- Use `check_drm()`, inspect the terminal gradient and Hessian diagnostics,
  and retain failed fits. A clean optimizer code alone does not establish that
  every SD surface is well identified.

Return to [Part 1: location-scale models](location-scale.html) for residual
variability without a model for the group-level SD. For broader phylogenetic
syntax, continue to [Phylogenetic mixed models](phylogenetic-models.html). For
prediction tables and the distinction between `sigma` and `sd(group)`, use
[Which scale are you modelling?](which-scale.html).

## Reference

Nakagawa, S., Mizuno, A., Williams, C., Lagisz, M., Yang, Y., and Drobniak,
S. M. (2025). Quantifying macro-evolutionary patterns of trait mean and
variance with phylogenetic location-scale models. *Methods in Ecology and
Evolution*. [doi:10.1111/2041-210X.70160](https://doi.org/10.1111/2041-210X.70160).
