Fixed vs Random Effects

Panel data: the same units over time (or nested units)

Most of the methods in this course use cross-sectional data — you see each unit once. But often you observe units repeatedly:

  • The same people surveyed each year (panel)
  • Students nested within schools (clusters)
  • Census tracts nested within counties (hierarchical)

Panel/grouped data has a superpower: you can separate within-group variation (what changes over time for the same person) from between-group variation (how different people differ from each other). This distinction is the key to fixed vs random effects.

Example: dollar stores and obesity. You have census tracts nested within counties, observed over multiple years. Some tracts get a new dollar store, others don’t. The question: does dollar store presence cause higher obesity? The problem: counties that attract dollar stores may already be different — poorer, more rural, less access to grocery stores. That’s a group-level confounder. Fixed effects can absorb it.

Fixed effects (FE)

The idea: include a separate intercept \(\alpha_j\) for each group. This absorbs everything that is constant within the group — observed or unobserved.

\[Y_{it} = \alpha_i + \tau D_{it} + \beta X_{it} + \varepsilon_{it}\]

In practice, you demean each variable by its group average:

\[\tilde{Y}_{it} = Y_{it} - \bar{Y}_i, \quad \tilde{D}_{it} = D_{it} - \bar{D}_i, \quad \tilde{X}_{it} = X_{it} - \bar{X}_i\]

Then regress \(\tilde{Y}\) on \(\tilde{D}\) and \(\tilde{X}\). The demeaning eliminates \(\alpha_i\) entirely — you only use within-group variation.

What FE eliminates

Any variable that is constant within a group gets differenced out:

  • County-level: food culture, geography, baseline poverty level
  • Person-level (in a panel): ability, motivation, family background
  • Firm-level: management quality, industry

This is the power of FE: you don’t need to measure or even name the confounders. If they’re constant within groups, they’re gone.

What FE can’t do

  • Can’t estimate effects of group-level variables. If you want to know the effect of being rural vs urban, FE absorbs that — it’s constant within county.
  • Can’t handle time-varying unobservables. If something changes within the group and is correlated with treatment, FE doesn’t help.
  • Uses only within-group variation. If treatment barely varies within groups, FE has little to work with and estimates are imprecise.

Random effects (RE)

The idea: instead of treating each \(\alpha_j\) as a fixed parameter, assume the group effects are random draws from a population:

\[\alpha_j \sim N(\mu, \tau^2)\]

This is the same structure as the hierarchical model on the Bayesian course — RE is its frequentist counterpart. It produces partial pooling: small groups are shrunk toward the grand mean.

The critical assumption

RE assumes group effects are uncorrelated with the covariates:

\[E[\alpha_j \mid X_{it}, D_{it}] = \mu \quad \text{(no correlation)}\]

If this holds, RE is more efficient than FE — it uses both within-group and between-group variation, so estimates are more precise.

If this fails — if groups with higher \(\alpha_j\) systematically differ in their \(X\) or \(D\) values — RE is biased. This is the same selection bias problem from the potential outcomes page, but at the group level.

The tradeoff

Fixed effects Random effects
Eliminates group-level confounders Yes Only if uncorrelated with X
Uses between-group variation No (only within) Yes (within + between)
Efficiency Lower (discards information) Higher (if assumption holds)
Can estimate group-level predictors No Yes
Bias when \(\alpha_j\) correlated with X None Biased

The rule: if you’re after a causal effect and you’re worried about group-level confounders, use FE. If you believe the RE assumption (no correlation) or you need group-level predictors, use RE. When in doubt, FE is the safer choice.

#| standalone: true
#| viewerHeight: 650

library(shiny)

ui <- fluidPage(
  tags$head(tags$style(HTML("
    .stats-box {
      background: #f0f4f8; border-radius: 6px; padding: 14px;
      margin-top: 12px; font-size: 14px; line-height: 1.9;
    }
    .stats-box b { color: #2c3e50; }
    .good { color: #27ae60; font-weight: bold; }
    .bad  { color: #e74c3c; font-weight: bold; }
  "))),

  sidebarLayout(
    sidebarPanel(
      width = 3,

      sliderInput("n_groups", "Number of groups:",
                  min = 10, max = 50, value = 20, step = 5),

      sliderInput("n_per", "Observations per group:",
                  min = 5, max = 50, value = 10, step = 5),

      sliderInput("ate", "True ATE:",
                  min = 0, max = 5, value = 2, step = 0.5),

      sliderInput("corr_alpha", "Correlation (\u03B1 with D):",
                  min = 0, max = 3, value = 0, step = 0.25),

      actionButton("go", "New draw", class = "btn-primary", width = "100%"),

      uiOutput("results")
    ),

    mainPanel(
      width = 9,
      fluidRow(
        column(6, plotOutput("scatter_plot", height = "420px")),
        column(6, plotOutput("compare_plot", height = "420px"))
      )
    )
  )
)

server <- function(input, output, session) {

  dat <- reactive({
    input$go
    J     <- input$n_groups
    n_per <- input$n_per
    ate   <- input$ate
    rho   <- input$corr_alpha

    # Group effects
    alpha <- rnorm(J, mean = 0, sd = 3)

    # Expand to individual level
    group <- rep(1:J, each = n_per)
    alpha_i <- rep(alpha, each = n_per)
    N <- J * n_per

    # Treatment: correlated with group effect (the confounding)
    x <- rnorm(N)
    p_treat <- pnorm(0.5 * x + rho * alpha_i / 3)
    treat <- rbinom(N, 1, p_treat)

    # Outcome
    y <- alpha_i + ate * treat + 1.5 * x + rnorm(N)

    # Naive (pooled OLS, no group effects)
    naive <- coef(lm(y ~ treat + x))["treat"]

    # Fixed effects (demean within group)
    y_dm <- y - ave(y, group)
    d_dm <- treat - ave(treat, group)
    x_dm <- x - ave(x, group)
    fe_est <- coef(lm(y_dm ~ d_dm + x_dm - 1))["d_dm"]

    # Random effects (partial pooling via weighted average)
    # Simple RE: GLS with estimated variance components
    # Use between and within estimators
    y_bar_j <- ave(y, group)
    d_bar_j <- ave(treat, group)
    x_bar_j <- ave(x, group)

    # Within estimator = FE
    # Between estimator
    uj <- tapply(y, group, mean)
    dj <- tapply(treat, group, mean)
    xj <- tapply(x, group, mean)
    if (sd(dj) > 0.01) {
      be_est <- coef(lm(uj ~ dj + xj))["dj"]
    } else {
      be_est <- naive
    }

    # RE is a weighted combo of within and between
    sigma2_e <- var(y_dm - fe_est * d_dm - coef(lm(y_dm ~ d_dm + x_dm - 1))["x_dm"] * x_dm)
    sigma2_a <- max(var(uj - fe_est * dj) - sigma2_e / n_per, 0.01)
    theta <- 1 - sqrt(sigma2_e / (sigma2_e + n_per * sigma2_a))

    y_re <- y - theta * y_bar_j
    d_re <- treat - theta * d_bar_j
    x_re <- x - theta * x_bar_j
    re_est <- coef(lm(y_re ~ d_re + x_re))["d_re"]

    list(y = y, treat = treat, x = x, group = group, alpha_i = alpha_i,
         naive = naive, fe_est = fe_est, re_est = re_est, be_est = be_est,
         ate = ate, rho = rho, J = J, n_per = n_per,
         alpha = alpha)
  })

  output$scatter_plot <- renderPlot({
    d <- dat()
    par(mar = c(4.5, 4.5, 3, 1))

    # Color by group (cycle through colors)
    palette <- rep(c("#3498db", "#e74c3c", "#27ae60", "#f39c12",
                     "#9b59b6", "#1abc9c", "#e67e22", "#2c3e50"),
                   length.out = d$J)
    cols <- paste0(palette[d$group], "50")

    plot(d$x, d$y, pch = 16, cex = 0.4, col = cols,
         xlab = "X (covariate)", ylab = "Y (outcome)",
         main = "Data by Group (colors = groups)")

    # Show a few group means
    show_groups <- 1:min(5, d$J)
    for (j in show_groups) {
      idx <- d$group == j
      points(mean(d$x[idx]), mean(d$y[idx]),
             pch = 17, cex = 1.5, col = palette[j])
    }

    if (d$rho > 0) {
      mtext(expression(alpha[j] * " correlated with D — RE is biased"),
            side = 3, line = 0, cex = 0.8, col = "#e74c3c", font = 2)
    } else {
      mtext(expression(alpha[j] * " independent of D — RE is valid"),
            side = 3, line = 0, cex = 0.8, col = "#27ae60", font = 2)
    }
  })

  output$compare_plot <- renderPlot({
    d <- dat()
    par(mar = c(4.5, 9, 3, 2))

    ests <- c(d$fe_est, d$re_est, d$naive)
    labels <- c("Fixed effects", "Random effects", "Pooled OLS")
    cols <- c("#27ae60", "#3498db", "#e74c3c")

    xlim <- range(c(ests, d$ate))
    pad  <- max(diff(xlim) * 0.4, 0.5)
    xlim <- xlim + c(-pad, pad)

    plot(ests, 1:3, pch = 19, cex = 2, col = cols,
         xlim = xlim, ylim = c(0.5, 3.5),
         yaxt = "n", xlab = "Estimated treatment effect",
         ylab = "", main = "Estimator Comparison")
    axis(2, at = 1:3, labels = labels, las = 1, cex.axis = 0.9)

    abline(v = d$ate, lty = 2, col = "#2c3e50", lwd = 2)
    text(d$ate, 3.45, paste0("True ATE = ", d$ate),
         cex = 0.85, font = 2, col = "#2c3e50")

    segments(d$ate, 1:3, ests, 1:3, col = cols, lwd = 2, lty = 2)
  })

  output$results <- renderUI({
    d <- dat()
    b_naive <- d$naive - d$ate
    b_fe    <- d$fe_est - d$ate
    b_re    <- d$re_est - d$ate

    fe_class <- if (abs(b_fe) < abs(b_naive) * 0.5) "good" else "bad"
    re_class <- if (abs(b_re) < abs(b_naive) * 0.5) "good" else "bad"

    hausman <- abs(d$fe_est - d$re_est)
    h_class <- if (hausman < 0.3) "good" else "bad"
    h_label <- if (hausman < 0.3) "FE \u2248 RE (RE likely OK)" else "FE \u2260 RE (use FE)"

    tags$div(class = "stats-box",
      HTML(paste0(
        "<b>True ATE:</b> ", d$ate, "<br>",
        "<hr style='margin:6px 0'>",
        "<b>Pooled OLS:</b> <span class='bad'>", round(d$naive, 3), "</span>",
        " (bias: ", round(b_naive, 3), ")<br>",
        "<b>Fixed effects:</b> <span class='", fe_class, "'>",
        round(d$fe_est, 3), "</span>",
        " (bias: ", round(b_fe, 3), ")<br>",
        "<b>Random effects:</b> <span class='", re_class, "'>",
        round(d$re_est, 3), "</span>",
        " (bias: ", round(b_re, 3), ")<br>",
        "<hr style='margin:6px 0'>",
        "<b>|FE \u2212 RE|:</b> <span class='", h_class, "'>",
        round(hausman, 3), "</span><br>",
        "<span class='", h_class, "'>", h_label, "</span>"
      ))
    )
  })
}

shinyApp(ui, server)

Things to try

  • Correlation = 0: group effects are independent of treatment. All three estimators work, but RE and FE are close to the truth while pooled OLS may show some bias. The |FE − RE| gap is small — Hausman says RE is fine.
  • Correlation = 1.5: now groups with higher \(\alpha_j\) are more likely to get treated. Pooled OLS is badly biased. FE still works (it eliminates \(\alpha_j\)). RE is biased — it uses between-group variation that’s contaminated by the correlation. The |FE − RE| gap is large — Hausman says use FE.
  • Correlation = 3: extreme confounding. RE is nearly as biased as pooled OLS. FE remains unbiased. This is why FE is the default in applied economics.
  • Small groups (5 obs per group): FE is noisier because it uses less data. RE is more precise when valid — that’s the efficiency gain. But precision doesn’t help if the estimate is biased.

The Hausman test

You’ve seen it informally in the simulation: when |FE − RE| is large, RE is suspect. The Hausman test formalizes this.

The logic

Under the RE assumption (\(\alpha_j\) uncorrelated with \(X\) and \(D\)):

  • FE is consistent (unbiased) but inefficient (throws away between-group info)
  • RE is consistent and efficient (uses all the data)
  • So FE and RE should give approximately the same answer

If the RE assumption fails:

  • FE is still consistent (within-group variation is clean)
  • RE is inconsistent (between-group variation is contaminated)
  • FE and RE will diverge

The Hausman test exploits this:

\[H = (\hat{\tau}_{FE} - \hat{\tau}_{RE})' \, [\text{Var}(\hat{\tau}_{FE}) - \text{Var}(\hat{\tau}_{RE})]^{-1} \, (\hat{\tau}_{FE} - \hat{\tau}_{RE})\]

Under \(H_0\) (RE is valid), \(H \sim \chi^2_k\) where \(k\) is the number of coefficients being compared.

  • Large \(H\) (small p-value): reject \(H_0\). FE and RE disagree → the RE assumption fails → use FE.
  • Small \(H\) (large p-value): fail to reject. FE and RE agree → RE assumption is plausible → RE is OK (and more efficient).

Example: returns to education. You have panel data on workers observed over multiple years. You want the causal effect of an extra year of education on wages.

  • RE assumes: workers who get more education don’t have systematically higher unobserved ability. Ability is random across education levels.
  • FE says: doesn’t matter — I’ll control for each worker’s fixed ability by only using within-person wage changes when they get more education.

You run both. FE gives \(\hat{\tau} = 0.06\) (6% wage increase per year of education). RE gives \(\hat{\tau} = 0.10\) (10%). The gap is large.

Hausman test: \(H = 15.3\), \(p < 0.001\). Reject \(H_0\). The RE estimate is inflated because high-ability workers get more education and earn more — classic selection bias at the group level. Use FE.

The FE estimate (6%) is the return to education holding ability constant. The RE estimate (10%) is contaminated by ability differences.

Hausman test in practice

Result Interpretation Action
\(p < 0.05\) FE and RE significantly differ Use FE (RE assumption violated)
\(p > 0.05\) No significant difference RE is OK — more efficient

Caveats:

  • The Hausman test has low power with small samples — it may fail to reject even when RE is biased.
  • A non-rejection doesn’t prove RE is valid. It means you can’t detect the violation with your data.
  • In practice, most applied economists default to FE for causal questions and use the Hausman test as a check, not as the primary decision tool.

Connection to other methods

Method What it controls for Assumption
Fixed effects All time-invariant group confounders No time-varying confounders
Random effects Group-level variation (partial pooling) Group effects uncorrelated with X
DID Group FE + time FE Parallel trends
Hierarchical models Bayesian version of RE Same as RE, with priors
Clustered SEs Not a model — fixes standard errors Doesn’t change point estimates

FE is an identification strategy. It tells you why your comparison is valid (within-group variation eliminates group-level confounders). RE is a modeling assumption that may or may not hold. When in doubt, FE is the conservative choice for causal inference.


In Stata

* Declare panel structure
xtset unit_id year

* Fixed effects
xtreg outcome treatment x1, fe cluster(unit_id)

* Random effects
xtreg outcome treatment x1, re

* Hausman test (FE vs RE)
quietly xtreg outcome treatment x1, fe
estimates store fe_model
quietly xtreg outcome treatment x1, re
estimates store re_model
hausman fe_model re_model

* First-difference (alternative to FE)
reg D.outcome D.treatment D.x1, cluster(unit_id)

If the Hausman test rejects, use FE — the RE assumption that group effects are uncorrelated with regressors doesn’t hold. For causal inference, FE is almost always the safer choice.


Did you know?

  • The Hausman test was introduced by Jerry Hausman (1978) in one of the most cited papers in econometrics. The general principle extends beyond FE/RE: any time you have a consistent-but-inefficient estimator and an efficient-but-possibly-inconsistent estimator, you can compare them. If they agree, the efficient one is probably fine.

  • Mundlak (1978) showed a clever alternative: add the group means \(\bar{X}_j\) as regressors in the RE model. If the coefficient on \(\bar{X}_j\) is significant, the RE assumption is violated (same information as the Hausman test, but easier to implement and interpret). This is called the correlated random effects approach.

  • In the machine learning world, random effects are closely related to mixed-effects models (e.g., lme4 in R). The terminology differs across fields: what economists call “fixed effects” (unit dummies), statisticians sometimes call “fixed effects” too, but the random effects tradition is much more developed in biostatistics and multilevel modeling (Raudenbush & Bryk, 2002).