Mixed-Effects Logistic Regression

Accounting for patients grouped within clinics

Author

Samer El Kababji

Learning goals

By the end of this lesson, you should be able to:

  1. explain why patients within a clinic may not be independent;
  2. distinguish fixed effects from random effects;
  3. fit a random-intercept logistic model;
  4. interpret the treatment effect and clinic variation;
  5. explain why a mixed model may change standard errors and p-values.

This lesson uses exactly the same fictional study and simulation as the first lesson. The outcome is 30-day recovery, and 300 patients are nested in 10 clinics.

Recreate the shared medical data

For patient \(i\) in clinic \(j\), the simulation follows

\[ Y_{ij}\mid p_{ij},u_j\sim\operatorname{Bernoulli}(p_{ij}), \qquad u_j\sim N(0,0.65^2), \]

\[ \operatorname{logit}(p_{ij}) =-0.40+\log(1.8)T_{ij}-0.035(Age_{ij}-60)+u_j. \]

Using the same random seed and the same equations as Lesson 1 produces exactly the same patients. The only change in this lesson is the model used to analyze them.

Code
set.seed(2026)

n_clinics <- 10
patients_per_clinic <- 30
n <- n_clinics * patients_per_clinic

clinic <- factor(rep(seq_len(n_clinics), each = patients_per_clinic))
clinic_effect <- rnorm(n_clinics, mean = 0, sd = 0.65)
treatment <- rbinom(n, size = 1, prob = 0.50)
age <- round(pmin(pmax(rnorm(n, mean = 60, sd = 11), 30), 85))
linear_predictor <- -0.40 + log(1.8) * treatment - 0.035 * (age - 60) +
  clinic_effect[as.integer(clinic)]
probability_recovery <- plogis(linear_predictor)
recovered <- rbinom(n, size = 1, prob = probability_recovery)

medical <- data.frame(
  clinic = clinic,
  treatment = treatment,
  age = age,
  age_centered = age - 60,
  recovered = recovered
)

head(medical)
  clinic treatment age age_centered recovered
1      1         0  75           15         0
2      1         0  50          -10         0
3      1         0  50          -10         0
4      1         0  69            9         1
5      1         0  56           -4         0
6      1         0  58           -2         0

Why ordinary logistic regression may be insufficient

Patients in one clinic share an environment. Even after accounting for treatment and age, two patients from the same clinic may be more alike than two patients from different clinics. Ordinary logistic regression assumes the remaining observations are independent, so its uncertainty calculation can be misleading when outcomes are clustered.

Compare recovery proportions across clinics:

For clinic \(j\), with \(n_j\) patients, the observed clinic recovery proportion is

\[ \widehat p_j=\frac{1}{n_j}\sum_{i=1}^{n_j}Y_{ij}. \]

The next cell evaluates \(n_j\) and \(\widehat p_j\) for all ten clinics.

Code
clinic_summary <- aggregate(
  recovered ~ clinic,
  data = medical,
  FUN = function(x) c(n = length(x), recovery = mean(x))
)

clinic_summary <- data.frame(
  clinic = clinic_summary$clinic,
  n = clinic_summary$recovered[, "n"],
  recovery_proportion = clinic_summary$recovered[, "recovery"]
)
clinic_summary
   clinic  n recovery_proportion
1       1 30           0.5333333
2       2 30           0.4333333
3       3 30           0.4000000
4       4 30           0.4666667
5       5 30           0.2666667
6       6 30           0.1333333
7       7 30           0.4000000
8       8 30           0.3333333
9       9 30           0.3333333
10     10 30           0.3000000

Some variation is sampling noise, and some may reflect real clinic-to-clinic differences. A mixed-effects model estimates the amount of extra variation.

The random-intercept model

We extend the earlier equation:

\[ \log\left(\frac{p_{ij}}{1-p_{ij}}\right) = \beta_0 + \beta_1\text{Treatment}_{ij} + \beta_2(\text{Age}_{ij}-60) + u_j, \]

where patient \(i\) belongs to clinic \(j\). The new term is

\[u_j \sim N(0, \sigma^2_{clinic}).\]

The parts play different roles:

  • Fixed intercept \(\beta_0\): average baseline log-odds for a 60-year-old standard-care patient in a typical clinic.
  • Fixed treatment effect \(\beta_1\): average adjusted treatment association across clinics.
  • Fixed age effect \(\beta_2\): average adjusted association for one more year of age.
  • Random intercept \(u_j\): clinic \(j\)’s deviation from the average baseline.
  • Random-effect SD \(\sigma_{clinic}\): how much baseline log-odds vary among clinics.

The model is called “mixed” because it contains both fixed and random effects.

Fit the model

We use glmer() from lme4. Install the package once with install.packages("lme4") if needed.

Conditional on a clinic effect \(u_j\), the contribution of patient \(i\) is

\[ P(Y_{ij}=y_{ij}\mid u_j) =p_{ij}^{y_{ij}}(1-p_{ij})^{1-y_{ij}}. \]

Because \(u_j\) is unobserved, the mixed model integrates it out. The marginal likelihood is

\[ L(\boldsymbol\beta,\sigma_{clinic}) =\prod_{j=1}^{J} \int_{-\infty}^{\infty} \left[\prod_{i=1}^{n_j} p_{ij}(u)^{y_{ij}}\{1-p_{ij}(u)\}^{1-y_{ij}} \right] \phi(u;0,\sigma_{clinic}^2)\,du. \]

glmer() numerically approximates these integrals while estimating the fixed effects and clinic variance. The ordinary glm() is also fitted below so both models use the identical outcome and predictors.

Code
if (!requireNamespace("lme4", quietly = TRUE)) {
  stop("This lesson requires the lme4 package. Run install.packages('lme4').")
}

ordinary_model <- glm(
  recovered ~ treatment + age_centered,
  data = medical,
  family = binomial
)

mixed_model <- lme4::glmer(
  recovered ~ treatment + age_centered + (1 | clinic),
  data = medical,
  family = binomial(link = "logit")
)

summary(mixed_model)
Generalized linear mixed model fit by maximum likelihood (Laplace
  Approximation) [glmerMod]
 Family: binomial  ( logit )
Formula: recovered ~ treatment + age_centered + (1 | clinic)
   Data: medical

      AIC       BIC    logLik -2*log(L)  df.resid 
    376.6     391.4    -184.3     368.6       296 

Scaled residuals: 
    Min      1Q  Median      3Q     Max 
-1.3629 -0.7439 -0.5103  0.9906  2.7792 

Random effects:
 Groups Name        Variance Std.Dev.
 clinic (Intercept) 0.179    0.4231  
Number of obs: 300, groups:  clinic, 10

Fixed effects:
             Estimate Std. Error z value Pr(>|z|)    
(Intercept)  -0.91117    0.23550  -3.869 0.000109 ***
treatment     0.59788    0.26507   2.256 0.024096 *  
age_centered -0.04999    0.01233  -4.053 5.05e-05 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Correlation of Fixed Effects:
            (Intr) trtmnt
treatment   -0.616       
age_centerd  0.103 -0.088

The notation (1 | clinic) means: allow every clinic to have its own intercept, while estimating those clinic intercepts as draws from one shared distribution.

Interpret the fixed effects

As before, exponentiate fixed-effect coefficients to obtain odds ratios.

Within a clinic, the random intercept cancels from the treatment odds ratio:

\[ OR_{treatment\mid clinic} =\frac{e^{\beta_0+\beta_1+\beta_2A+u_j}} {e^{\beta_0+\beta_2A+u_j}} =e^{\beta_1}. \]

For fixed effect \(k\), the cell calculates

\[ \widehat{OR}_k=e^{\widehat\beta_k},\qquad CI_{95\%}=\left[ e^{\widehat\beta_k-1.96SE_k}, e^{\widehat\beta_k+1.96SE_k} \right], \]

and the two-sided Wald p-value

\[ p_k=2\Phi\left(-\left|\frac{\widehat\beta_k}{SE_k}\right|\right). \]

Code
mixed_coefficients <- summary(mixed_model)$coefficients

mixed_results <- data.frame(
  term = rownames(mixed_coefficients),
  estimate_log_odds = mixed_coefficients[, "Estimate"],
  odds_ratio = exp(mixed_coefficients[, "Estimate"]),
  confidence_low = exp(mixed_coefficients[, "Estimate"] -
                         1.96 * mixed_coefficients[, "Std. Error"]),
  confidence_high = exp(mixed_coefficients[, "Estimate"] +
                          1.96 * mixed_coefficients[, "Std. Error"]),
  p_value = mixed_coefficients[, "Pr(>|z|)"],
  row.names = NULL
)

mixed_results
          term estimate_log_odds odds_ratio confidence_low confidence_high
1  (Intercept)       -0.91116609  0.4020551      0.2534109       0.6378902
2    treatment        0.59788067  1.8182612      1.0815037       3.0569234
3 age_centered       -0.04999151  0.9512375      0.9285172       0.9745137
       p_value
1 1.092399e-04
2 2.409592e-02
3 5.054062e-05

The treatment OR is a clinic-specific, or conditional, odds ratio: it compares two otherwise similar patients in the same clinic. As in Lesson 1, the p-value tests a no-association null hypothesis; it does not measure effect size, clinical importance, or the probability that the null is true. Interpret it with the OR, confidence interval, design, and clinical threshold.

The Wald intervals above are convenient teaching approximations. For important research, consider likelihood-profile or bootstrap intervals and a pre-specified analysis plan.

Interpret the random effect

The fitted model estimates the clinic variance \(\widehat\sigma_{clinic}^2\). Its square root returns variation to the log-odds scale:

\[ \widehat\sigma_{clinic} =\sqrt{\widehat{\operatorname{Var}}(u_j)}. \]

The following cell extracts these two quantities from VarCorr().

Code
clinic_variance <- as.data.frame(lme4::VarCorr(mixed_model))$vcov[1]
clinic_sd <- sqrt(clinic_variance)

c(clinic_variance = clinic_variance, clinic_sd = clinic_sd)
clinic_variance       clinic_sd 
      0.1789731       0.4230522 

The clinic SD is measured on the log-odds scale. A value near zero suggests little residual clinic-to-clinic variation after adjustment. A larger value suggests that baseline recovery odds differ meaningfully among clinics.

For logistic mixed models, a commonly used latent-scale intraclass correlation coefficient (ICC) is

\[ ICC \approx \frac{\sigma^2_{clinic}} {\sigma^2_{clinic}+\pi^2/3}. \]

Code
icc <- clinic_variance / (clinic_variance + pi^2 / 3)
icc
[1] 0.0515945

The ICC approximates the proportion of latent-scale variation attributable to clinics. It is not literally the observed correlation between two binary outcomes, so state the scale and approximation when reporting it.

Clinic estimates and partial pooling

The estimated clinic deviations are:

For clinic \(j\), ranef() returns the conditional mode

\[ \widehat u_j =\arg\max_{u_j} \left\{ \log P(\mathbf y_j\mid u_j,\widehat{\boldsymbol\beta}) +\log\phi(u_j;0,\widehat\sigma_{clinic}^2) \right\}. \]

The normal-density term penalizes extreme clinic effects. This is the mathematical source of partial pooling: estimates with weak clinic-level information are pulled more strongly toward \(0\).

Code
clinic_estimates <- data.frame(
  clinic = rownames(lme4::ranef(mixed_model)$clinic),
  random_intercept = lme4::ranef(mixed_model)$clinic[, "(Intercept)"],
  row.names = NULL
)

clinic_estimates
   clinic random_intercept
1       1       0.53057519
2       2       0.16631542
3       3       0.10023340
4       4       0.34959768
5       5      -0.19814555
6       6      -0.63452190
7       7       0.09703816
8       8      -0.13495118
9       9      -0.08336057
10     10      -0.13847110

Mixed models use partial pooling. A clinic’s estimate is informed by its own patients but pulled toward the overall mean, especially when the clinic has little information. This is usually more stable than fitting an entirely separate model for every clinic.

Compare ordinary and mixed models

To compare like with like, define for each fitted model \(m\)

\[ \widehat{OR}_m=e^{\widehat\beta_{1m}},\qquad SE_m=SE(\widehat\beta_{1m}),\qquad p_m=2\Phi\left(-\left|\widehat\beta_{1m}/SE_m\right|\right). \]

The helper function below applies these same equations to glm and glmer objects and stacks the results.

Code
extract_treatment <- function(model) {
  model_table <- summary(model)$coefficients
  estimate <- model_table["treatment", "Estimate"]
  standard_error <- model_table["treatment", "Std. Error"]
  p <- model_table["treatment", "Pr(>|z|)"]

  c(
    odds_ratio = exp(estimate),
    confidence_low = exp(estimate - 1.96 * standard_error),
    confidence_high = exp(estimate + 1.96 * standard_error),
    standard_error = standard_error,
    p_value = p
  )
}

comparison <- rbind(
  ordinary_logistic = extract_treatment(ordinary_model),
  mixed_effects_logistic = extract_treatment(mixed_model)
)

comparison
                       odds_ratio confidence_low confidence_high standard_error
ordinary_logistic        1.643701       1.007315        2.682132      0.2498275
mixed_effects_logistic   1.818261       1.081504        3.056923      0.2650655
                          p_value
ordinary_logistic      0.04668201
mixed_effects_logistic 0.02409592

The two estimates need not be identical. More importantly, the mixed model’s standard error acknowledges the grouping structure. Clustering often reduces the effective amount of independent information, although the exact change depends on the data and model. A p-value crossing 0.05 after accounting for clustering does not mean the effect disappeared; it means the evidence and its uncertainty were evaluated under a different, more suitable model.

Do not choose between these models based on which produces the smaller p-value. Choose based on the study design and how the observations were generated.

Population-average and clinic-specific predictions

A mixed model supports different prediction questions:

  • Typical-clinic prediction: set the clinic random effect to zero. This is conditional on a clinic with average random intercept.
  • Known-clinic prediction: include that clinic’s estimated random effect.

Here are typical-clinic probabilities for a 60-year-old:

Setting the clinic deviation to its population mean, \(u_j=0\), gives

\[ \widehat p_{typical}(t,a) =\operatorname{logit}^{-1} \left(\widehat\beta_0+\widehat\beta_1t+\widehat\beta_2a\right). \]

In predict(), re.form = NA excludes all fitted random effects and implements this equation. Setting type = "response" returns probability rather than log-odds.

Code
new_patients <- data.frame(
  treatment = c(0, 1),
  age_centered = c(0, 0),
  clinic = factor(c(1, 1), levels = levels(medical$clinic))
)

new_patients$group <- c("Standard care", "New treatment")
new_patients$predicted_recovery_typical_clinic <- predict(
  mixed_model,
  newdata = new_patients,
  type = "response",
  re.form = NA
)

new_patients[, c("group", "predicted_recovery_typical_clinic")]
          group predicted_recovery_typical_clinic
1 Standard care                         0.2867613
2 New treatment                         0.4223130

These are not fully marginal, population-averaged probabilities: simply setting \(u_j=0\) does not integrate over the distribution of clinics. That distinction matters when selecting a prediction method for a clinical report.

When should you use a mixed-effects model?

A mixed model is useful when observations are naturally grouped or repeated, for example:

  • patients within clinics or hospitals;
  • repeated visits within patients;
  • students within schools;
  • measurements made by different clinicians or devices.

The grouping variable should represent a meaningful source of dependence. With very few groups, random-effect variance and small-sample inference can be unstable; alternatives may include clinic fixed effects, cluster-robust methods, or small-sample corrections, depending on the research question and design.

Our random-intercept model assumes the treatment effect is the same across clinics. If the treatment effect plausibly varies by clinic and enough clinics and data are available, a random-slope model may be appropriate:

\[ \operatorname{logit}(p_{ij}) =\beta_0+\beta_1T_{ij}+\beta_2A_{ij} +u_{0j}+u_{1j}T_{ij}, \]

\[ \begin{pmatrix}u_{0j}\\u_{1j}\end{pmatrix} \sim N\left[ \begin{pmatrix}0\\0\end{pmatrix}, \begin{pmatrix} \sigma_0^2 & \sigma_{01}\\ \sigma_{01} & \sigma_1^2 \end{pmatrix} \right]. \]

Here \(u_{0j}\) changes clinic \(j\)’s baseline, while \(u_{1j}\) changes its treatment effect. The R notation (1 + treatment | clinic) estimates both variation terms and their correlation.

Code
random_slope_model <- lme4::glmer(
  recovered ~ treatment + age_centered + (1 + treatment | clinic),
  data = medical,
  family = binomial
)

That is a more demanding model and should be motivated by the design, not added only because the syntax is available.

Reporting template

We fitted a mixed-effects logistic regression with fixed effects for treatment and age and a random intercept for clinic. The adjusted clinic-specific treatment odds ratio was [OR] (95% CI [lower, upper], Wald p = [p]). The estimated between-clinic random-intercept SD was [SD], corresponding to a latent-scale ICC of [ICC]. Effect magnitude, precision, clinical importance, and study limitations were considered together.

The reporting cell collects the mathematical summaries already derived:

\[ e^{\widehat\beta_1},\quad e^{\widehat\beta_1\pm1.96SE_1},\quad 2\Phi(-|\widehat\beta_1/SE_1|),\quad \widehat\sigma_{clinic},\quad \frac{\widehat\sigma_{clinic}^2} {\widehat\sigma_{clinic}^2+\pi^2/3}. \]

Code
treatment_result <- mixed_results[mixed_results$term == "treatment", ]

c(
  odds_ratio = treatment_result$odds_ratio,
  confidence_low = treatment_result$confidence_low,
  confidence_high = treatment_result$confidence_high,
  p_value = treatment_result$p_value,
  clinic_sd = clinic_sd,
  latent_scale_icc = icc
)
      odds_ratio   confidence_low  confidence_high          p_value 
      1.81826123       1.08150368       3.05692337       0.02409592 
       clinic_sd latent_scale_icc 
      0.42305217       0.05159450 

Check your understanding

  1. What dependence does (1 | clinic) represent?
  2. Why is the treatment OR described as clinic-specific?
  3. If the mixed-model p-value is larger than the ordinary-model p-value, did the treatment effect necessarily become smaller?
  4. What does partial pooling do to clinic estimates?
  5. When might a random treatment slope be scientifically justified?