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.
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).
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.,
lme4in 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).