Multiple OLS Regression

Adjusting for the confounders you can see

Merlin Schaeffer · Department of Sociology

2026-10-26

By the end of today you can …

  1. explain omitted variable bias: why leaving a confounder out of a regression shifts a coefficient, and in which direction;

  2. read a multiple OLS coefficient as “holding the other variables constant”, and show what that means with the Frisch–Waugh three-step;

  3. estimate an adjusted effect in R, and say honestly what multiple OLS does not fix.

Closer to home: Copenhagen, February 2015

A gunman attacked a debate on free speech at Krudttønden, then the Great Synagogue in Krystalgade. Two people were killed.

Discuss: did the attacks make people in Denmark more hostile towards immigrants? How could you find out?

Krudttønden, 15 February 2015.
Photo: Kim Bach.

Great Synagogue, 22 February 2015.
Photo: Kim Bach.

Why not for Denmark? Of everyone the ESS interviewed in the five weeks around the Bali attack, only 2 were living in Denmark; interviewing only got going at the very end of October, after the window had closed. Natural experiments need lucky timing.

Where we are

Three lectures, three ways to estimate causal effects:

Beware: read from left to right in descending order of credibility. Today’s tool is the weakest of the three, and the one you will use most often.

The crack in last week’s natural experiment

Part 1 of 3

Legewie’s Bali design assumed interview date was as-if random. The balance table says: almost, but not quite.

The Bali bombing memorial, Kuta. Photo: Jorge Láscar, CC BY 2.0.

Almost random is not random

Last week we treated the day of a respondent’s ESS interview as as-if random: the people interviewed after the Bali attack should look like those interviewed before.

Legewie did not just assume it. The paper checks it, and honestly reports that they do not quite:

“The treatment group is on average slightly younger, and the proportion of people who are retired and who work from home is lower” Legewie (2013, p. 1211).

Discuss: why would people interviewed early in a survey period differ systematically from those interviewed late?

Who opens the door first?

An ESS interviewer rings at two doors on the first day of fieldwork.

Reachability bias: the easy to reach (retired, at home, older) are interviewed early, before the attack. The hard to reach (working, out, younger) only after several visits, often after it.

So the interview day is not quite a coin flip: it depends on age.

Age, the confounder

  1. Xenophobia tends to increase with age.

  2. The younger a respondent, the more likely they were still to be interviewed after the attack.

\[\Rightarrow \text{Avg}_n[Y_{0i} \mid D_i = 0] \;\color{#901A1E}{>}\; \text{Avg}_n[Y_{0i} \mid D_i = 1]\]

In Portugal the imbalance runs the other way. Older respondents were more likely to land in the treatment group. Same logic, opposite sign.

Discuss: does this imbalance make our estimate of the Bali effect too large or too small?

The treatment group had a lower baseline xenophobia to begin with \(\rightarrow\) the raw comparison understates the attack’s effect.

The hidden term, selection bias, is back

Lecture 5 split group comparisons into two parts:

\[\underbrace{\text{Avg}_n[Y_{1i} \mid D_i = 1] - \text{Avg}_n[Y_{0i} \mid D_i = 0]}_{\text{what we compare}} = \text{ATT} \; + \; \underbrace{\text{Avg}_n[Y_{0i} \mid D_i = 1] - \text{Avg}_n[Y_{0i} \mid D_i = 0]}_{\text{Selection Bias } = \; 0 \text{ if exposure is as-if random (L6, L7)}}\]

\[\underbrace{\text{Avg}_n[Y_{1i} \mid D_i = 1] - \text{Avg}_n[Y_{0i} \mid D_i = 0]}_{\text{what we compare}} = \text{ATT} \; + \; \color{#901A1E}{\underbrace{\text{Avg}_n[Y_{0i} \mid D_i = 1] - \text{Avg}_n[Y_{0i} \mid D_i = 0]}_{< \; 0 \text{: the treated are younger}}}\]

Last week: we assumed the interview day was as-if random, and that both groups shared the same baseline \(Y_0\). We thought the selection-bias term was zero and the raw difference was the effect.

Beware: in Legewie’s data the term is not quite zero. The treated are younger, so their baseline \(Y_0\) is lower, and the raw comparison understates the effect.

Today: for a confounder we measured, we can put a number on this hidden term (Part 2) and remove it (Part 3).

A laboratory where we know the truth

Like engineers who crash a car with a dummy before they trust it with people, we simulate data where we set the true effect, then check how OLS works.

pacman::p_load(
  tidyverse,    # Data manipulation and visualization
  estimatr,     # OLS with robust standard errors
  modelr,       # Residuals and predictions from a model
  modelsummary  # Nicely formatted regression tables
)

set.seed(831983) # So everyone gets the SAME "random" numbers

toydat <- tibble(
  age = rnorm( # Random age
    n = 500,   # for 500 fictitious people
    mean = 40, # Average age 40
    sd = 5     # Standard deviation 5 years
  )
)

# Older people are interviewed EARLIER, so they less often
# end up in the post-attack (treatment) group.
toydat <- toydat %>%
  mutate(
    int_day = (-0.1 * age) + rnorm(n = 500, mean = 0, sd = 1),
    bali    = case_when(
      int_day < mean(int_day) ~ 0,   # before the attack
      int_day >= mean(int_day) ~ 1)  # after the attack
  )

The older someone is, the less likely they land in the post-attack group.

# In this simulation, 
# WE decide the truth: the Bali effect is exactly 1,
# and each year of age adds 0.3 to xenophobia.
toydat <- toydat %>%
  mutate(
    xeno = (1 * bali) + (0.3 * age) +
      rnorm(n = 500, mean = 0, sd = 1)
  )

Two overlapping clouds. The red ones sit a little further left: they are younger.

Which model is right?

# Bivariate: the treatment only
ols_bi <- lm_robust(xeno ~ bali, data = toydat)

# Multiple: add the confounder
ols_mult <- lm_robust(xeno ~ bali + age, data = toydat)

modelsummary(
  list("Bivariate" = ols_bi, "Multiple" = ols_mult),
  coef_rename = c("bali" = "Exposed to Bali", "age" = "Age"),
  stars = TRUE, gof_map = c("nobs", "r.squared"),
  output = "kableExtra"
)
Bivariate Multiple
(Intercept) 12.504*** −0.499
(0.113) (0.428)
Exposed to Bali 0.174 1.135***
(0.154) (0.096)
Age 0.313***
(0.010)
Num.Obs. 500 500
R2 0.003 0.649
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001

The bivariate model is badly wrong. It misses the truth by a wide margin. Adding one variable, age, recovers ≈ 1.

Omitted variable bias

Part 2 of 3

The gap between those two numbers has a formula.

Where the gap comes from

The exposed and the unexposed differ in two ways:

  1. In exposure: the causal effect. We know it is 1; the multiple model’s 1.135 is 1 plus a little sampling error (Lecture 3).
  1. In age: the exposed are on average 3.07 years younger (the table).

Discuss: each year of age adds ≈ 0.313 xenophobia. How much xenophobia does that age gap alone account for?

Take 1 and 2 together: 1.135 + (-0.961) = 0.174, exactly the bivariate estimate.

# How much do the two groups differ in AGE?
ols_age <- lm_robust(age ~ bali, data = toydat)

modelsummary(
  list("Age" = ols_age),
  coef_rename = c("bali" = "Exposed to Bali"),
  coef_omit = "(Intercept)",
  stars = TRUE, gof_map = c("nobs"),
  output = "kableExtra"
)
Age
Exposed to Bali −3.070***
(0.395)
Num.Obs. 500
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001

The age gap alone: \(-3.07 \times 0.313 = -0.961\) xenophobia.

Seeing the gap

  1. Compare the two group averages: they are only 0.174 apart. That is the bivariate estimate.
  1. But the groups are not similarly old: the exposed are 3.07 years younger on average.
  1. Now compare people of the same age. Multiple OLS fits two parallel lines; at any age they are 1.135 apart. That is the multiple OLS estimate, holding age constant.
  1. Walk along the red line from the exposed group’s average age to the other group’s: it climbs 0.313 × 3.07 = 0.961. That is omitted variable bias: 1.135 − 0.961 = 0.174.

Zoomed in on the middle of the toy data.

The formula

\[\underbrace{\tilde\beta_{D \rightarrow Y}}_{\text{what the biased model gives}} = \underbrace{\beta_{D \rightarrow Y}}_{\text{the truth}} + \underbrace{(\beta_{C \rightarrow Y} \times \beta_{D \rightarrow C})}_{\textbf{omitted variable bias}}\]

Put our simulated numbers in: the two paths, multiplied:

\[\underbrace{0.174}_{\text{bivariate}} \;=\; \underbrace{1.135}_{\text{multiple}} \;+\; (\underbrace{0.313}_{C \rightarrow Y} \times \underbrace{-3.07}_{D \rightarrow C})\]

It adds up, exactly. The bias is the confounder’s effect on the outcome, times the imbalance in the confounder between the groups. It is the size of Lecture 5’s hidden term, for one confounder.

Which way does the bias run?

The formula also tells you the sign:

\(\beta_{C \rightarrow Y}\) \(\beta_{D \rightarrow C}\) Bias
\(+\) \(+\) too large
\(+\) \(-\) too small
\(-\) \(+\) too small
\(-\) \(-\) too large

Naming things

These terms name the same problem, but from different angles:

  • Selection bias (L5): the general problem, groups differ at baseline.
  • Confounder bias: named after the variable that causes it.
  • Omitted variable bias: named after the fact that it is missing from your model.

“Omitted variable bias” is the useful name today, because it points at the fix: the variable is omitted, so put it in.

Beware: that only works for variables you actually measured. There is no formula for the confounders you never recorded.

Break

Your turn: exercise 1

You estimate the adjusted Bali effect for Portugal, controlling for age, gender and employment status. And compare it to the unadjusted one.

Open exercise 1 in a new tab ↗

How multiple OLS solves omitted variable bias — and what it can’t do

Part 3 of 3

“Holding age constant”: Frisch and Waugh (1933) showed it is an arithmetic procedure.

Ragnar Frisch (1895–1973), a Norwegian, coined the word econometrics, was the first editor of the journal Econometrica, where this theorem appeared in volume 1, and shared the very first Nobel prize in economics, 1969. His co-author Frederick Waugh was an agricultural economist in the US.

Photo: Borgens Atelier.

“Holding age constant”

“comparing people with the same values on the other variables, …”. That is what holding constant, controlling for and adjusted for mean.

The multiple model gave 1.135 for Exposed to Bali. And 0.313 for Age: the slope of both lines.

Discuss: say in one sentence what each of the two coefficients means.

“Comparing people of the same age, the ones interviewed after the attack score 1.14 points higher on xenophobia.”

“Among people interviewed before/after the attack, each extra year of age goes with 0.31 points more.”*

Frisch–Waugh, step 1

Frisch and Waugh (1933) demonstrated that “controlling” for a confounder is mathematically equivalent to a three-step procedure.

Regress the outcome on the confounder, keep the residuals: the part of xenophobia that age does not explain.

(toydat <- toydat %>%
   add_residuals(
     model = lm(xeno ~ age, data = .),
     var   = "e_xeno" # A new column
   ))
# # A tibble: 500 × 5
#     age int_day  bali  xeno   e_xeno
#   <dbl>   <dbl> <dbl> <dbl>    <dbl>
# 1  42.5   -3.90     1  15.2  1.97   
# 2  45.3   -5.10     0  13.3 -0.764  
# 3  42.7   -2.89     1  12.8 -0.532  
# 4  37.1   -4.40     0  11.8 -0.00133
# # ℹ 496 more rows

Each red line is one person’s residual: how far they sit from what their age alone predicted.

The double circle: we control for age, which closes the backdoor path.

Frisch–Waugh, step 2

Regress the treatment on the confounder, keep the residuals: the part of Bali exposure that age does not explain.

(toydat <- toydat %>%
   add_residuals(
     model = lm(bali ~ age, data = .),
     var   = "e_bali" # Another new column
   ))
# # A tibble: 500 × 6
#     age int_day  bali  xeno   e_xeno e_bali
#   <dbl>   <dbl> <dbl> <dbl>    <dbl>  <dbl>
# 1  42.5   -3.90     1  15.2  1.97     0.582
# 2  45.3   -5.10     0  13.3 -0.764   -0.318
# 3  42.7   -2.89     1  12.8 -0.532    0.590
# 4  37.1   -4.40     0  11.8 -0.00133 -0.609
# # ℹ 496 more rows

Discuss: what kind of person has a large positive e_bali?

Frisch–Waugh, step 3

Regress the two sets of residuals on each other. Its slope is identical to the multiple OLS coefficient.

ols_resid <- lm_robust(e_xeno ~ e_bali, data = toydat)

modelsummary(
  list("Multiple OLS" = ols_mult, "Residualised" = ols_resid),
  coef_rename = c("bali" = "Exposed to Bali", "age" = "Age",
                  "e_bali" = "Exposed to Bali (resid.)"),
  coef_omit = "(Intercept)",
  stars = TRUE, gof_map = c("nobs"),
  output = "kableExtra"
)

Multiple OLS Residualised
Exposed to Bali 1.135***
(0.096)
Age 0.313***
(0.010)
Exposed to Bali (resid.) 1.135***
(0.096)
Num.Obs. 500 500
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001

Same number, two routes. That is what “controlling for age” does: it removes age from both variables and fits a line to what is left.

Why it works

Look at step 2 again: after removing age, who still has a lot of unexplained exposure? The untypical cases: the older people who were still interviewed after the attack, and the younger ones interviewed before it.

Multiple OLS quietly down-weights typical cases and up-weights untypical ones. The comparison is carried by people who broke the pattern.

Beware: if nobody breaks the pattern (if age predicted exposure perfectly) there would be no residual variation left, and nothing to estimate from.

Still just OLS

Nothing new is being minimised. The best-fitting plane still minimises the sum of squared residuals:

\[\min \sum_{i=1}^{n} \left(y_i - (\alpha + \beta_1 x_{1i} + \dots + \beta_k x_{ki})\right)^2\]

\[y_{i} = \alpha + \beta_{1}x_{1} + \beta_{2}x_{2} + \ldots + \beta_{k}x_{k} + \epsilon_{i}\]

With a 0/1 treatment and one control, the “plane” is simply the two parallel lines from Seeing the gap.

Beware — two things that follow:

  1. Multiple OLS assumes a linear relation for every continuous predictor, not just the one you care about.

  2. Every coefficient is adjusted for all the others. There is no such thing as adding a control “just for one variable”.

What multiple OLS cannot fix

It can close a backdoor path through a confounder you measured.

It cannot do anything about a confounder you never measured. And no test will tell you one is there.

The streetlight effect. A man searches for his lost keys under a street lamp. Did he lose them here? “No, but this is where the light is.” We control for what we happened to measure, not necessarily for what matters.

The double circle is a closed backdoor path: we controlled for it. The red dashed one stays wide open.

Discuss: if the confounders you can see turned out to be imbalanced, is it likely that the ones you cannot see are perfectly balanced?

Back to Bali: the Portuguese sample

The same wrangling as last week. Now keeping age as well, and restricting to Portugal.

Before the attack (N=187)
After the attack (N=104)
Mean Std. Dev. Mean Std. Dev. Diff. in Means Std. Error
age 45.8 19.4 48.5 17.6 2.6 3.0
anti_immi 3.6 0.9 4.0 0.8 0.3 0.1
ESS %>%
  select(treat, age, anti_immi, pspwght) %>%
  rename(weights = pspwght) %>% # Used as survey weights
  datasummary_balance(~ treat, data = .,
                      output = "kableExtra")

Discuss: in Portugal, is the raw estimate too large or too small?

Careful: the direction is the opposite of the all-country pattern Legewie reports. Portugal is its own case.

The adjusted effect

ols_pt_bi   <- lm_robust(anti_immi ~ treat,
                         weights = pspwght, data = ESS)
ols_pt_mult <- lm_robust(anti_immi ~ treat + age,
                         weights = pspwght, data = ESS)

modelsummary(
  list("Bivariate" = ols_pt_bi, "Adjusted" = ols_pt_mult),
  coef_rename = c(
    "treatAfter the attack" = "After the attack",
    "age" = "Age"),
  stars = TRUE, gof_map = c("nobs", "r.squared"),
  output = "kableExtra"
)

\[\underbrace{0.330}_{\text{bivariate}} \;\approx\; \underbrace{0.313}_{\text{adjusted}} \;+\; (\underbrace{0.0064}_{C \rightarrow Y} \times \underbrace{2.62}_{D \rightarrow C})\]

Bivariate Adjusted
(Intercept) 3.621*** 3.328***
(0.082) (0.175)
After the attack 0.330** 0.313**
(0.116) (0.118)
Age 0.006+
(0.003)
Num.Obs. 291 291
R2 0.032 0.050
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001

Adjusting for age moves the estimate by only -0.017 SD. Why so little? The bias is a product, and in Portugal age barely predicts xenophobia (0.0064 per year).

The adjusted effect, drawn

plotdata <- bind_rows( # Stack the two tidy tables on top of each other
  tidy(ols_pt_bi),     # Model 1: bivariate
  tidy(ols_pt_mult),   # Model 2: adjusted for age
  .id = "model") %>%   # ... and remember which row came from which model
  filter(term != "(Intercept)") %>%
  mutate(model = case_when(model == "1" ~ "Bivariate",
                           model == "2" ~ "Adjusted for age"),
         term = case_when(term == "treatAfter the attack" ~ "After the attack",
                          term == "age" ~ "Age"))

ggplot(data = plotdata, aes(y = estimate, x = term, color = model)) +
  geom_hline(yintercept = 0, color = "#901A1E", lty = "dashed") +
  geom_pointrange(aes(ymin = conf.low, ymax = conf.high),
                  position = position_dodge(width = 0.5)) + # Side by side
  coord_flip() +
  labs(x = "", y = "Difference in xenophobia (SD)", color = "") +
  theme_minimal(base_size = 15)

It’s real research

Source: Legewie (2013, p. 1210)

Legewie’s published models adjust for a whole battery of observed confounders: age, gender, education, employment, and more.

Alternative names for the same thing, all of which you will meet in papers: controlling for \(X\) · partialling out \(X\) · adjusted for \(X\) · conditional on \(X\).

Your turn: exercise 2

You become the simulator: set the confounder’s strength and sign yourself, predict the bias from the formula, then check whether OLS agrees.

Open exercise 2 in a new tab ↗

Today’s general lessons

  1. A confounder you leave out of your model creates omitted variable bias: \(\tilde\beta = \beta + (\beta_{C \rightarrow Y} \times \beta_{D \rightarrow C})\). It is the same problem as selection bias, named after the missing variable.

  2. Because the bias is a product of two effects, you can predict its sign before running anything. And it is zero if either effect is zero.

  3. Holding \(C\) constant means comparing people with the same \(C\): the gap between two parallel lines. Frisch–Waugh shows how OLS does it: residualise \(Y\) on \(C\), residualise \(D\) on \(C\), and regress the leftovers.

  4. Multiple OLS is still OLS: minimising squared residuals, with every coefficient adjusted for all the others.

  5. It can repair imbalance in observed confounders. It can do nothing about unobserved ones, which is why a good design still beats a good regression.

Check yourself: today’s goals

  • Write down the omitted variable bias formula, and say what makes the bias zero.
  • Given a DAG and the signs of two arrows, say whether an unadjusted estimate is too large or too small.
  • Say in one sentence what a multiple OLS coefficient means: “comparing people with the same …”.
  • Explain in the three Frisch–Waugh steps what “controlling for age” does, and name one thing multiple OLS cannot fix.

Shaky on any of these? That is what this week’s Absalon quiz and the Friday exercise class are for.

Today’s important functions

  • lm_robust(y ~ d + c, ...): multiple OLS — the coefficient on d holds c constant.
  • modelr::add_residuals(model = ..., var = ...): keep a model’s residuals (the Frisch–Waugh workflow).
  • modelsummary::datasummary_balance(~ group, data = ...): the balance table — your empirical read on \(\beta_{D \rightarrow C}\).
  • modelsummary(list("Bivariate" = ..., "Adjusted" = ...)): put the unadjusted and adjusted models side by side.
  • set.seed() + rnorm(): simulate data where you know the true effect.
  • bind_rows(tidy(m1), tidy(m2), .id = "model") + position_dodge(): two models in one coefficient plot.

References

Frisch, R. and F. V. Waugh (1933). “Partial Time Regressions as Compared with Individual Trends”. In: Econometrica, pp. 387-401.

Legewie, J. (2013). “Terrorist Events and Attitudes toward Immigrants: A Natural Experiment”. In: American Journal of Sociology, pp. 1199-1245.