Skip to content

r2_nakagawa() is **NOT** location invariant when COV is not modelled (and is wrong?) #428

Closed

Description

@mattansb

This is some weird stuff.

library(lme4)
#> Loading required package: Matrix
library(performance)

r2_nakagawa2 <- function(model) {
  y <- insight::get_response(model)
  c(
    Cond. = cor(predict(model, re.form = NULL), y)^2,
    Marg. = cor(predict(model, re.form = NA), y)^2
  )
}


# With COV =================================
m1 <- lmer(Reaction ~ Days + (Days|Subject),
          data = sleepstudy)


# Change location -------
sleepstudy$Days0 <- sleepstudy$Days - 10
m2 <- lmer(Reaction ~ Days0 + (Days0|Subject),
          data = sleepstudy)
#> Warning in checkConv(attr(opt, "derivs"), opt$par, ctrl = control$checkConv, :
#> Model failed to converge with max|grad| = 0.00422952 (tol = 0.002, component 1)

r2_nakagawa(m1)
#> # R2 for Mixed Models
#> 
#>   Conditional R2: 0.799
#>      Marginal R2: 0.279
r2_nakagawa(m2) # same
#> # R2 for Mixed Models
#> 
#>   Conditional R2: 0.799
#>      Marginal R2: 0.279

# Validate:
r2_nakagawa2(m1)
#>     Cond.     Marg. 
#> 0.8271703 0.2864714
r2_nakagawa2(m2)
#>     Cond.     Marg. 
#> 0.8271702 0.2864714


# Without COV =================================
m1b <- lmer(Reaction ~ Days + (Days||Subject),
           data = sleepstudy)


# Change location -------
m2b <- lmer(Reaction ~ Days0 + (Days0||Subject),
           data = sleepstudy)


r2_nakagawa(m1b) # different
#> # R2 for Mixed Models
#> 
#>   Conditional R2: 0.702
#>      Marginal R2: 0.415
r2_nakagawa(m2b) # even more different
#> # R2 for Mixed Models
#> 
#>   Conditional R2: 0.864
#>      Marginal R2: 0.183

r2_nakagawa2(m1b) # same as with cov
#>     Cond.     Marg. 
#> 0.8279419 0.2864714
r2_nakagawa2(m2b) # same as with cov
#>     Cond.     Marg. 
#> 0.8244819 0.2864714

Created on 2022-06-03 by the reprex package (v2.0.1)

Activity

  1. changed the title [-]`r2_nakagawa()` is **NOT** location invariant when COV is not modelled?[/-] [+]`r2_nakagawa()` is **NOT** location invariant when COV is not modelled (and is wrong?)[/+] on Jun 3, 2022
  2. strengejacke commented on Jun 9, 2022

    @strengejacke
    Member

    Let's look at some more output:

    library(lme4)
    #> Loading required package: Matrix
    library(parameters)
    data("sleepstudy")
    # With COV =================================
    m1 <- lmer(Reaction ~ Days + (Days|Subject),
               data = sleepstudy)
    
    # Change location -------
    sleepstudy$Days0 <- sleepstudy$Days - 10
    m2 <- lmer(Reaction ~ Days0 + (Days0|Subject),
               data = sleepstudy)
    #> Warning in checkConv(attr(opt, "derivs"), opt$par, ctrl = control$checkConv, :
    #> Model failed to converge with max|grad| = 0.00422952 (tol = 0.002, component 1)
    
    model_parameters(m1, effects = "random")
    #> # Random Effects
    #> 
    #> Parameter                     | Coefficient |   SE |         95% CI
    #> -------------------------------------------------------------------
    #> SD (Intercept: Subject)       |       24.74 | 5.84 | [15.58, 39.28]
    #> SD (Days: Subject)            |        5.92 | 1.25 | [ 3.92,  8.95]
    #> Cor (Intercept~Days: Subject) |        0.07 | 0.32 | [-0.51,  0.60]
    #> SD (Residual)                 |       25.59 | 1.51 | [22.80, 28.72]
    #> 
    #> Uncertainty intervals (equal-tailed) and p-values (two-tailed) computed
    #>   using a Wald t-distribution approximation.
    model_parameters(m2, effects = "random")
    #> # Random Effects
    #> 
    #> Parameter                      | Coefficient |    SE |         95% CI
    #> ---------------------------------------------------------------------
    #> SD (Intercept: Subject)        |       65.66 | 12.06 | [45.81, 94.12]
    #> SD (Days0: Subject)            |        5.92 |  1.25 | [ 3.92,  8.95]
    #> Cor (Intercept~Days0: Subject) |        0.93 |  0.38 | [-1.00,  1.00]
    #> SD (Residual)                  |       25.59 |  1.51 | [22.80, 28.72]
    #> 
    #> Uncertainty intervals (equal-tailed) and p-values (two-tailed) computed
    #>   using a Wald t-distribution approximation.
    
    cbind(insight::get_variance(m1) |> as.data.frame() |> t(),
          insight::get_variance(m2) |> as.data.frame() |> t()) |> 
      as.data.frame() |> 
      datawizard::rownames_as_column() |> 
      insight::export_table()
    #> rowname             | Subject | Subject
    #> ---------------------------------------
    #> var.fixed           |  908.95 |  908.95
    #> var.random          | 1698.08 | 1698.26
    #> var.residual        |  654.94 |  654.93
    #> var.distribution    |  654.94 |  654.93
    #> var.dispersion      |    0.00 |    0.00
    #> var.intercept       |  612.10 | 4311.55
    #> var.slope           |   35.07 |   35.07
    #> cor.slope_intercept |    0.07 |    0.93
    
    # Without COV =================================
    m1b <- lmer(Reaction ~ Days + (Days||Subject),
                data = sleepstudy)
    
    # Change location -------
    m2b <- lmer(Reaction ~ Days0 + (Days0||Subject),
                data = sleepstudy)
    
    model_parameters(m1b, effects = "random")
    #> # Random Effects
    #> 
    #> Parameter               | Coefficient |   SE |         95% CI
    #> -------------------------------------------------------------
    #> SD (Intercept: Subject) |       25.05 | 5.66 | [16.09, 39.01]
    #> SD (Days: Subject)      |        5.99 | 1.21 | [ 4.03,  8.91]
    #> SD (Residual)           |       25.57 | 1.50 | [22.79, 28.68]
    #> 
    #> Uncertainty intervals (equal-tailed) and p-values (two-tailed) computed
    #>   using a Wald t-distribution approximation.
    model_parameters(m2b, effects = "random")
    #> # Random Effects
    #> 
    #> Parameter               | Coefficient |    SE |         95% CI
    #> --------------------------------------------------------------
    #> SD (Intercept: Subject) |       58.12 | 10.73 | [40.48, 83.45]
    #> SD (Days0: Subject)     |        5.12 |  1.14 | [ 3.31,  7.91]
    #> SD (Residual)           |       25.98 |  1.53 | [23.15, 29.15]
    #> 
    #> Uncertainty intervals (equal-tailed) and p-values (two-tailed) computed
    #>   using a Wald t-distribution approximation.
    
    cbind(insight::get_variance(m1b) |> as.data.frame() |> t(),
          insight::get_variance(m2b) |> as.data.frame() |> t()) |> 
      as.data.frame() |> 
      datawizard::rownames_as_column() |> 
      insight::export_table()
    #> rowname          | Subject | Subject
    #> ------------------------------------
    #> var.fixed        |  908.95 |  908.95
    #> var.random       |  627.57 | 3377.69
    #> var.residual     |  653.58 |  674.88
    #> var.distribution |  653.58 |  674.88
    #> var.dispersion   |    0.00 |    0.00
    #> var.intercept    |  627.57 | 3377.69
    #> var.slope        |   35.86 |   26.22

    Created on 2022-06-09 by the reprex package (v2.0.1)

  3. strengejacke commented on Jun 9, 2022

    @strengejacke
    Member

    The formulas for R2 are:

    r2_marginal <- vars$var.fixed/(vars$var.fixed + vars$var.random + vars$var.residual)
    r2_conditional <- (vars$var.fixed + vars$var.random)/(vars$var.fixed + vars$var.random + vars$var.residual)

    This explains why m1 and m2 return same results, while we find those differences for m1b and m2b.

    • Residual variance differs, so we don't expect m1 be equal to m1b.
    • RE variance is much higher when location changes - not sure if this is expected or not?
  4. bwiernik commented on Jun 9, 2022

    @bwiernik
    Contributor

    Okay that explains it.

    It makes sense that the intercept variance is a lot different when the predictor is re-centered. The huge slope-intercept correlation the re-centering induces compensates for that. But if that's constrained to zero, there is no place for the extra variance to go

  5. mattansb commented on Jun 9, 2022

    @mattansb
    MemberAuthor

    A fix for the marginal R2 would be:

    vars <- insight::get_variance(model)
    var_tot <- var(insight::get_response(model))
    r2_marginal <- vars$var.fixed/var_tot

    The issue is how do we get a correct estimate of the variance accounted for by random effects?
    (Other than using the predict(..., re.form=) solution above?)


    Note that this also affects the validity of the icc() output.

  6. self-assigned this
    on Oct 10, 2026
  7. jmgirard commented on Oct 10, 2026

    @jmgirard
    Contributor

    I looked into this again with the current development versions (performance from main, insight 1.5.4.24). There are two separate things going on.

    First, there is a bug in insight::get_variance(). For uncorrelated random slopes ((Days || Subject)), lme4 splits the terms into Subject and Subject.1. var.random only includes the intercept variance and leaves out the slope variance. For m1b, var.random is 627.6, which equals var.intercept alone. The fix for easystats/insight#304 in 2021 corrected var.intercept and var.slope but not var.random. The fix is open in easystats/insight#1263, because performance only uses the variance components that insight returns.

    With Johnson's (2014) mean random-effect variance, which r2_nakagawa() says it uses (see R/r2_nakagawa.R, details section), the value is:

    library(lme4)
    m <- lmer(Reaction ~ Days + (Days || Subject), data = sleepstudy)
    vc <- as.data.frame(VarCorr(m))
    x <- model.matrix(m)[, 2]
    var_random <- vc$vcov[1] + vc$vcov[2] * mean(x^2)  # 1649.5, insight gives 627.6

    With easystats/insight#1263, get_variance() returns this value. Conditional R2 is then 0.797 and marginal R2 is 0.283 for m1b. Those values are close to the correlated model (0.799 and 0.279), and r2_nakagawa() currently returns 0.702 and 0.415.

    Second, even after that fix, m2b (centered Days0) gives different values: 0.887 conditional and 0.152 marginal. I do not think that this part is a bug. bwiernik explained it above: when the slope-intercept correlation is fixed at zero, shifting the predictor changes the model itself. The intercept variance (3377.7 vs. 627.6) cannot be absorbed by the correlation, so the variance decomposition really changes. Models with uncorrelated random slopes are not location invariant. A note in the docs for r2_nakagawa() and icc() could say that.

    Replacing the denominator with var(y), as suggested above, would move away from the Nakagawa et al. (2017) definition, so that is a separate design question.

  8. mattansb commented on Oct 11, 2026

    @mattansb
    MemberAuthor

    Thanks for looking into this @jmgirard - does this mean that once easystats/insight#1263 is resolved this issue should be closed? Or is there any fix required on the performance side?

  9. jmgirard commented on Oct 11, 2026

    @jmgirard
    Contributor

    Mostly yes. The numeric bug is in insight. r2_nakagawa() and icc() use var.random from insight::get_variance() as is. So after easystats/insight#1263 merges, both return the corrected values for m1b (conditional R2 0.797, marginal R2 0.283).

    On the performance side, I suggest two small changes in one PR after that merge, and that PR can close this issue:

    1. Raise the insight requirement in DESCRIPTION to the release with #1263, and add a regression test for m1b.
    2. Add a note to r2_nakagawa() and icc(): models with uncorrelated random slopes are not location invariant. A shift of a predictor changes the model, so R2 and the ICC change too.

    On var(y) as the denominator: probably not. It makes the marginal R2 stable (0.286 for m1b and m2b), but it divides model estimates by the observed spread of the data. The parts then do not add up to the total, and the conditional R2 for m2b becomes 1.669. For non-Gaussian models, var.fixed and var.random are on the link scale, and var(y) is not. The cause is the model itself, so the docs note is the better fix.

  10. mattansb commented on Oct 11, 2026

    @mattansb
    MemberAuthor

    Yes to 1+2. Thanks @jmgirard !

    I agree about not using $Var[Y]$ - that's too messy.

  11. strengejacke commented on Oct 11, 2026

    @strengejacke
    Member

    Raise the insight requirement in DESCRIPTION to the release with #1263, and add a regression test for m1b.

    I think we should just add a Remotes-field to the DESCRIPTION file.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions