The Zero Conditional Mean Assumption

What it says

The assumption is written \(E(u \mid X) = 0\), and it is the one that makes OLS trustworthy. In words:

  • Pick any value of \(X\) — say \(X = 8\) — and look at the unobserved errors \(u\) for all the observations that have it. Their average is zero.
  • The same is true at \(X = 2\), at \(X = 5\), everywhere. In every slice of \(X\), the errors average to zero.
  • It is assumption SLR.4, and it is the reason \(\hat{\beta}_1\) lands on the truth.

Why it is the whole ballgame

Recall from the sampling distribution of OLS that

\[\hat{\beta}_1 = \beta_1 + \sum_i w_i u_i, \qquad w_i = \frac{x_i - \bar{x}}{SST_x}.\]

Take expectations. The bias is \(\sum_i w_i \, E(u_i \mid X)\).

  • If \(E(u \mid X) = 0\), every term is zero, so \(E(\hat{\beta}_1) = \beta_1\) — unbiased.
  • If \(E(u \mid X)\) is not flat — if the errors drift with \(X\) — that bias term survives, and \(\hat{\beta}_1\) is systematically off, no matter how much data you have.

It is stronger than “u is uncorrelated with X”

Two conditions get mixed up all the time. They are not the same:

  • Zero correlation: \(\mathrm{Cov}(X, u) = 0\). Across the whole sample, the errors don’t line up with \(X\).
  • Zero conditional mean: \(E(u \mid X) = 0\). The errors average to zero inside every slice of \(X\).

\(E(u \mid X) = 0\) is the stronger statement: it implies \(\mathrm{Cov}(X, u) = 0\), but the reverse is not true. You can have zero overall correlation while the errors still bend with \(X\) within the range (up on the left, down on the right, cancelling out on average). OLS needs the stronger one to be unbiased.

The classic example: wages and ability

Take \(\text{wage} = \beta_0 + \beta_1 \, \text{educ} + u\), where \(u\) holds everything else that affects wages — including ability.

  • \(E(u \mid \text{educ}) = 0\) would require the average ability to be the same for people with 8 years of schooling as for people with 16.
  • That is almost certainly false: people who get more education tend to have higher ability, so \(E(u \mid \text{educ})\) rises with \(\text{educ}\).
  • The assumption fails, and \(\hat{\beta}_1\) quietly soaks up part of ability’s effect. That is omitted variable bias, seen from the error side.

See it

The left panel is the usual scatter with the true line (slope 1) and the OLS line. The right panel is the thing the assumption is actually about: the errors \(u\) plotted against \(X\), with the conditional mean \(E(u \mid X)\) drawn in red. Slide the tilt: at 0 the red line is flat on zero and OLS sits right on the truth; tilt it and the red line slopes, the assumption breaks, and the OLS slope drifts away by exactly that tilt.

#| standalone: true
#| viewerHeight: 560

library(shiny)

set.seed(7)
n <- 120
x <- runif(n, 0, 10)
base_noise <- rnorm(n, sd = 1.5)

ui <- fluidPage(
  tags$head(tags$style(HTML("
    .eq-box { background:#f0f4f8; border-radius:6px; padding:14px;
              margin-top:14px; font-size:14px; line-height:1.8; }
    .eq-box b { color:#2c3e50; }
    .bad { color:#e74c3c; font-weight:bold; }
    .ok  { color:#27ae60; font-weight:bold; }
  "))),
  sidebarLayout(
    sidebarPanel(
      width = 3,
      sliderInput("g", "Tilt of E(u | X):", min = -1.2, max = 1.2, value = 0, step = 0.1),
      helpText("0 = the assumption holds: errors average to zero in every slice of X. Tilt it and E(u|X) slopes with X."),
      uiOutput("box")
    ),
    mainPanel(
      width = 9,
      fluidRow(
        column(6, plotOutput("fit",   height = "380px")),
        column(6, plotOutput("uplot", height = "380px"))
      )
    )
  )
)

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

  d <- reactive({
    g <- input$g
    u <- g * (x - 5) + base_noise
    y <- 2 + 1 * x + u
    b <- coef(lm(y ~ x))
    list(y = y, u = u, b0 = b[1], b1 = b[2], g = g)
  })

  output$fit <- renderPlot({
    s <- d(); par(mar = c(4.5, 4.5, 3, 1))
    plot(x, s$y, pch = 19, col = "#95a5a6",
         main = "Data: true line vs OLS line", xlab = "X", ylab = "Y")
    abline(a = 2, b = 1, col = "#27ae60", lwd = 2, lty = 2)
    abline(a = s$b0, b = s$b1, col = "#2c3e50", lwd = 2.5)
    legend("topleft", bty = "n", cex = 0.9,
           legend = c("true line (slope 1)", "OLS line"),
           col = c("#27ae60", "#2c3e50"), lwd = c(2, 2.5), lty = c(2, 1))
  })

  output$uplot <- renderPlot({
    s <- d(); par(mar = c(4.5, 4.5, 3, 1))
    plot(x, s$u, pch = 19, col = adjustcolor("#2980b9", 0.5),
         main = "Errors u vs X: is E(u | X) flat?", xlab = "X", ylab = "error u")
    abline(h = 0, col = "#7f8c8d", lty = 3)
    abline(a = -5 * s$g, b = s$g, col = "#e74c3c", lwd = 2.5)
    legend("topleft", bty = "n", cex = 0.9, legend = "E(u | X)",
           col = "#e74c3c", lwd = 2.5)
  })

  output$box <- renderUI({
    s <- d(); bias <- s$b1 - 1
    cls <- if (abs(bias) < 0.12) "ok" else "bad"
    lab <- if (abs(bias) < 0.12) "holds" else "violated"
    tags$div(class = "eq-box",
      HTML(sprintf(
        "<b>Assumption:</b> <span class='%s'>%s</span><br><br><b>True &beta;<sub>1</sub>:</b> 1.00<br><b>OLS &beta;&#770;<sub>1</sub>:</b> %.2f<br><b>Bias:</b> <span class='%s'>%.2f</span>",
        cls, lab, s$b1, cls, bias)))
  })
}

shinyApp(ui, server)

When the red \(E(u \mid X)\) line is flat, the errors carry no information about \(X\) and OLS is unbiased. When it slopes, part of what looks like the effect of \(X\) is really the errors moving with \(X\) — and OLS cannot tell the two apart.


Connections