Multiple OLS Regression

Adjusting for the confounders you can see

Merlin Schaeffer · Department of Sociology

2026-07-28

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.

One part per goal. Our running question: back to Legewie’s Bali attack — but this time the natural experiment is not quite as-if random.

Where we are

Three lectures, three ways to earn a causal claim:

  • L6 — randomise it. A coin balances everything, observed and not. Gold standard, rarely available.
  • L7 — find a natural experiment. Let the world randomise for you; use an instrument if compliance is broken.
  • Today — adjust for it. No coin, no instrument: measure the confounder and hold it constant.

Beware: these are 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.

Almost random is not random

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

Legewie checked. 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?

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}[Y_{0i} \mid D = 0] \;\color{#901A1E}{>}\; \text{Avg}[Y_{0i} \mid D = 1]\]

The treatment group had a lower baseline to begin with — so the raw comparison understates the attack’s effect.

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 problem, stated plainly

We cannot re-run the survey. The confounder is already baked into the data.

Discuss: age is measured — it is sitting right there in the data set. Can we use that somehow to repair the comparison after the fact?

Yes — that is exactly what multiple OLS does. It compares people of the same age who differ only in whether they were interviewed after the attack. The rest of today is how, and how much you should trust it.

A laboratory where we know the truth

Real data never tells you the right answer. So let’s simulate data where we set the true effect — then check whether OLS finds it.

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( # 500 fictitious people
    n = 500,
    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    = if_else(int_day >= mean(int_day), 1, 0)
  )
# 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)
  )

The true causal effect of Bali is 1. Remember that number — it is the only time in this course you will know it.

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"
)

Discuss: we know the true effect is 1. Which of these two models found it?

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. Same data, same outcome, one extra column.

Omitted variable bias

Part 2 of 3

The gap between those two numbers is not random noise. It has a formula.

Where the gap comes from

When we compare xenophobia between the exposed and the unexposed, we are comparing people who differ in two ways:

  1. They differ in exposure — that is the causal effect we want, 1.14 in our simulation.

  2. They also differ in age. How much? Regress the confounder on the treatment:

Discuss: the exposed are on average 3.1 years younger. Given that each year of age adds ≈ 0.3 xenophobia, how much xenophobia does that age gap alone account for?

# 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"),
  stars = TRUE, gof_map = c("nobs"),
  output = "kableExtra"
)
Age
(Intercept) 41.546***
(0.289)
Exposed to Bali −3.070***
(0.395)
Num.Obs. 500
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001

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.17}_{\text{bivariate}} \;\approx\; \underbrace{1.14}_{\text{multiple}} \;+\; (\underbrace{0.31}_{C \rightarrow Y} \times \underbrace{-3.07}_{D \rightarrow C})\]

It adds up. The bias is not a mystery: it is the confounder’s effect on the outcome, times the imbalance in the confounder between the groups.

Which way does the bias run?

The formula also tells you the sign — before you run anything:

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

Same rule as multiplying two signed numbers — because that is exactly what it is.

Discuss: in Legewie’s data, age raises xenophobia (\(+\)) and the exposed group is younger (\(-\)). Was his raw estimate too large or too small?

Too small. The bias is negative, so the unadjusted estimate understates the effect of the attack. Adjusting for age should push it up.

Naming things

You will meet the same idea under several names. They are the same problem seen 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 OLS does it — and what it can’t do

Part 3 of 3

“Holding age constant” sounds like a metaphor. Frisch and Waugh (1933) showed it is an arithmetic procedure.

Frisch–Waugh, step 1

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"
  )

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

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"
  )

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-regression 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 all “controlling for age” does — it removes age from both variables and fits a line to what is left.

We meet this again in Lecture 9, on real cross-country data.

Why it works

Look at step 2 again: after removing age, the people with the most residual variation in treatment are the untypical ones — the older person who was still interviewed late, the younger one interviewed early.

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}\]

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 — even long after the data were collected.

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

If the confounders you can see turned out to be imbalanced — is it likely that the ones you cannot see are perfectly balanced?

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

Back to Bali: the Portuguese sample

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

Before Bali (N=187)
After Bali (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, which group is older — and so, by the sign rule, is the raw estimate too large or too small here?

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 Bali" = "After Bali", "age" = "Age"),
  stars = TRUE, gof_map = c("nobs", "r.squared"),
  output = "kableExtra"
)
Bivariate Adjusted
(Intercept) 3.621*** 3.328***
(0.082) (0.175)
After Bali 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

The same decomposition, now on real data:

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

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.006 per year). A big imbalance times a tiny effect is still a tiny bias.

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. Frisch–Waugh: “controlling for \(C\)” means residualise \(Y\) on \(C\), residualise \(D\) on \(C\), and regress the leftovers. Nothing more mysterious than that.

  4. Multiple OLS is still OLS — one plane, 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.
  • 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.

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.