Linear Mixed Models#

The lmer() class makes it easy to work with linear-mixed models (multi-level models) estimated via maximum-likelihood or restricted-maximum-likelihood estimation

Random-effects can be specified using the same formula syntax as lme4 in R and all methods are the same as lm models.

Core Methods and Attributes

Method

Description

.fit()

Fit a model using lmer() & lmerTest()

.anova()

Calculate a Type-III ANOVA table using joint_tests() in R

.summary()

Get a nicely formatted table containing .result_fit

.empredict()

Compute marginal predictions are arbitrary values of predictors

.emmeans()

Compute marginal means/contrasts as specific factor levels

.simulate()

Simulate new observations from the fitted model

.predict()

Make predictions given a new dataset

lmer models store the results of their last estimation as polars Dataframes like lm models, but with a few more attributes:

Attribute

Stores

.params

parameter estimates

.ranef

cluster-level deviances for random-effects terms

.ranef_var

variances and co-variance for random-effects terms

.fixef

cluster-level estimates for random-effects terms (.params + .ranef); also commonly called BLUPs

.result_fit

parameter estimates, errors, & inferential statistics

.result_fit_stats

model fit statistics

.result_anova

ANOVA table

from pymer4.models import lmer
from pymer4 import load_dataset
sleep = load_dataset('sleep')

Random Intercepts#

model_i = lmer('Reaction ~ Days + (1 | Subject)', data=sleep)
model_i.fit()

As with lm() models, we can inspect estimates with the .params attribute. In the current model these are the estimates for the overall model intercept and slope for \(Days\)

model_i.params
shape: (2, 2)
termestimate
strf64
"(Intercept)"251.405105
"Days"10.467286

We can see estimates for each cluster (each \(Subject\)), commonly called BLUPs, in the .fixef attribute

Because we only estimated a random intercept per \(Subject\) all estimates (slopes) for \(Days\) are the same

model_i.fixef.head()
shape: (5, 3)
level(Intercept)Days
strf64f64
"308"292.18881510.467286
"309"173.55555110.467286
"310"188.29653710.467286
"330"255.81154710.467286
"331"261.62129410.467286

These estimates are derived from the random deviances of each \(Subject\) around the fixed parameter estimates. We can see those with .ranef which only contains a column for random intercepts

model_i.ranef.head()
shape: (5, 2)
level(Intercept)
strf64
"308"40.78371
"309"-77.849554
"310"-63.108567
"330"4.406442
"331"10.216189

We can see the variability across \(Subject\)’s using .ranef_var

model_i.ranef_var
shape: (2, 5)
grouptermestimateconf_lowconf_high
strstrf64f64f64
"Subject""sd__(Intercept)"37.123827nullnull
"Residual""sd__Observation"30.991234nullnull

Of course it’s often easiest to see all this information at once using .summary()

model_i.summary()
Formula: lmer(Reaction~Days+(1|Subject))
Number of observations: 180
Confidence intervals: parametric
---------------------
Log-likelihood: -893
AIC: 1794 | BIC: 1807
Residual error: 30.991
Random Effects: Estimate SE CI-low CI-high T-stat DF p
Subject-sd (Intercept) 37.124
Residual-sd Observation 30.991
Fixed Effects:
(Intercept) 251.405 9.747 231.233 271.577 25.794 22.810 <.001 ***
Days 10.467 0.804 8.879 12.055 13.015 161.000 <.001 ***
Signif. codes: 0 *** 0.001 ** 0.01 * 0.05 . 0.1

Random Slopes#

We can easily extend our model with random slopes by adding them to the formula. Before inspecting the parameter estimates it can be helpful to test whether we really need the extra parameters by comparing this model to our first model using a likelihood ratio test.

from pymer4.models import compare

model_s = lmer('Reaction ~ Days + (Days | Subject)', data=sleep)
compare(model_i, model_s)
Analysis of Deviance Table
Model 1: lmer(Reaction~Days+(1|Subject))
Model 2: lmer(Reaction~Days+(Days|Subject))
AIC BIC logLik npar -2*log(L) Chisq Df Pr(>Chisq)
1.00 1794.5 1807.2 −893.2 4.00 1.79K
2.00 1755.6 1774.8 −871.8 6.00 1.75K 42.14 2.00 <.001 ***
Signif. codes: 0 *** 0.001 ** 0.01 * 0.05 . 0.1

It looks it’s worth it!

Let’s calculate bootstrapped confidence intervals, which will give us some uncertainty estimates around both fixed and random effects terms. By default we don’t get confidence-intervals around random effects terms, but we can with bootstrapping.

We can see that our estimate of the standard-deviation across \(Subject\)’s intercepts is about ~25 with 95% CI of [11 36], while the standard-deviation of their slopes for \(Days\) is about 6 with 95% CI of [3, 9]

model_s.fit(conf_method='boot', summary=True)
Formula: lmer(Reaction~Days+(Days|Subject))
Number of observations: 180
Confidence intervals: boot
Bootstrap Iterations: 1000
---------------------
Log-likelihood: -871
AIC: 1755 | BIC: 1774
Residual error: 25.592
Random Effects: Estimate CI-low CI-high SE T-stat DF p
Subject-sd (Intercept) 24.741 12.992 35.750
Subject-sd Days 5.922 3.512 8.450
Subject-cor (Intercept) 0.066 −0.500 0.906
Residual-sd Observation 25.592 22.530 28.605
Fixed Effects:
(Intercept) 251.405 237.684 265.801 6.825 36.838 17.000 <.001 ***
Days 10.467 7.462 13.584 1.546 6.771 17.000 <.001 ***
Signif. codes: 0 *** 0.001 ** 0.01 * 0.05 . 0.1

Variance Partitioning#

Of particular interest with multi-level models is variance partitioning or how much of the explained variability is attributable to the fixed vs random portions of the model. These are included in .result_fit_stats captured by three statistics:

  • Total Explained Variance: R2_conditional what’s explained by fixed effects & random effects

  • Fixed Effects Explained Variance: R2_marginal what’s explained by fixed effects only

  • Intraclass Correlation Coefficient: ICC what’s explained by random effects only; also known as: variance partition coefficient (VPC) or measurement repeatability

You can read more details about the implementation we use here

model_s.result_fit_stats
shape: (1, 13)
AICAICcBICICCR2_conditionalR2_marginalREMLcritRMSESigmadf_residuallogLiknobssigma
f64f64f64f64f64f64f64f64f64i64f64i64f64
1755.6282721756.1138211774.7860130.721660.799220.2786511743.62827223.43804725.591796174-871.81413618025.591796

Model issues#

When fitting more complex models or models with redundant parameters you’ll often run into model convergence issues and/or model singularity issues.

Models will print out warning messages from R when this happens to let you know. But you can also suppress these by passing verbose = False to .fit()

bad_model = lmer('Reaction ~ Days + (Days | Subject) + (1 | Days)', data=sleep)
bad_model.fit()
R messages: 
boundary (singular) fit: see help('isSingular')

R messages: 
boundary (singular) fit: see help('isSingular')
# No warning!
bad_model.fit(verbose=False)

You can always see if there were any suppressed warnings using .show_logs()

bad_model.show_logs()
R messages: 
boundary (singular) fit: see help('isSingular')

Convergence issues can often be due to a lack of variance for some of the random-effects terms. The “gradient” value should be a very very tiny number that reflects the change is the optimizer’s error during fitting. Values close to or > 0 suggest the optimizer struggled to find the “best” parameter estimates.

mtcars = load_dataset('mtcars')

bad_model = lmer('mpg ~ 1 + (hp | cyl)', data=mtcars)
bad_model.set_factors('cyl')
bad_model.fit()
R messages: 
Convergence status
: [1] FALSE
attr(,"gradient")
[1] 1.145261

We can see that the variability around the random slope across \(hp\) for \(cyl\) is very small (~0.034) which makes this challenging for the optimizer

bad_model.ranef_var
shape: (4, 5)
grouptermestimateconf_lowconf_high
strstrf64f64f64
"cyl""sd__(Intercept)"6.192348nullnull
"cyl""cor__(Intercept).hp"-0.869679nullnull
"cyl""sd__hp"0.034038nullnull
"Residual""sd__Observation"3.120614nullnull

We can attempt to bootstrap the model to see how much the variability changes. We again see covergence warnings and that the range of estimates includes very small values for the random slope.

We’re also having hard time estimating the correlation between intercept and slope leading to a huge confidence interval

bad_model.fit(conf_method='boot')
bad_model.ranef_var
R messages: 
Convergence status
: [1] FALSE
attr(,"gradient")
[1] 1.145261
shape: (4, 5)
grouptermestimateconf_lowconf_high
strstrf64f64f64
"cyl""sd__(Intercept)"6.1923480.013.387067
"cyl""cor__(Intercept).hp"-0.869679-1.01.0
"cyl""sd__hp"0.0340383.3944e-70.087685
"Residual""sd__Observation"3.1206142.2875593.949821

We can try to simplify the model by remove the correlation between random-effects terms. The model still fails to convergence, but the gradient is much smaller now.

It looks like the lack of variability in the random slope is still giving the optimizer issues:

simple_model = lmer('mpg ~ 1 + (1 | cyl) + (0 + hp | cyl)', data=mtcars)
simple_model.set_factors('cyl')
simple_model.fit(conf_method='boot')
simple_model.ranef_var
R messages: 
Convergence status
: [1] FALSE
attr(,"gradient")
[1] 0.1034371
shape: (3, 5)
grouptermestimateconf_lowconf_high
strstrf64f64f64
"cyl""sd__(Intercept)"5.7062310.011.294004
"cyl.1""sd__hp"0.0120590.00.04829
"Residual""sd__Observation"3.1826682.2668044.016254

Let’s simplify further by retaining only random-intercepts:

simpler_model = lmer('mpg ~ 1 + (1 | cyl)', data=mtcars)
simpler_model.set_factors('cyl')
simpler_model.fit(conf_method='boot')
simpler_model.ranef_var
shape: (2, 5)
grouptermestimateconf_lowconf_high
strstrf64f64f64
"cyl""sd__(Intercept)"5.7614250.39290111.721516
"Residual""sd__Observation"3.2224682.3493874.015793

No more warnings! We can verify by checking out the logs:

# Empty
simpler_model.show_logs()

And convergence status, which shows successful convergence and a tiny gradient:

print(simpler_model.convergence_status)
Convergence status
: [1] TRUE
attr(,"gradient")
[1] 7.001001e-06

Summary#

lmer() models support all the same features as lm() models for working with categorical predictors and generating marginal estimates and comparisons.

So be sure to check out the linear regression and ANOVA tutorials for more details.