In exercise 1 you adjusted a real estimate — but you could never check whether the adjusted number was right, because nobody knows the true effect of the Bali attack. Here you do know it, because you set it yourself.

  1. Copy in this simulation. It is the lecture's toy example, all three steps in one chunk. Three numbers in it are the knobs you will turn: the true effect (1 * bali), how strongly the confounder affects the outcome (0.3 * age, \(\beta_{C \rightarrow Y}\)), and how strongly it drives selection into treatment (-0.1 * age, \(\beta_{D \rightarrow C}\)).
pacman::p_load(tidyverse, estimatr, modelsummary)

set.seed(831983) # So everyone gets the SAME "random" numbers
toydat <- tibble(
  age = rnorm(n = 500, mean = 40, sd = 5)) %>%     # Step 1: age
  mutate(
    # Step 2: who gets treated (older people are interviewed earlier)
    int_day = (-0.1 * age) + rnorm(n = 500, mean = 0, sd = 1),
    bali = case_when(
      int_day < mean(int_day) ~ 0,   # interviewed before the attack
      int_day >= mean(int_day) ~ 1), # interviewed after the attack
    # Step 3: the outcome. THE TRUTH: the Bali effect is 1
    xeno = (1 * bali) + (0.3 * age) + rnorm(n = 500, mean = 0, sd = 1))
  1. Verify the formula. Fit the three regressions you need, and confirm that \(\tilde\beta = \beta + (\beta_{C \rightarrow Y} \times \beta_{D \rightarrow C})\) actually holds.

You need three models: xeno ~ bali (biased), xeno ~ bali + age (adjusted), and — the one students forget — age ~ bali, which measures the imbalance.

ols_bi   <- lm_robust(xeno ~ bali, data = toydat)        # biased estimate
ols_mult <- lm_robust(xeno ~ bali + age, data = toydat)  # adjusted estimate
ols_dc   <- lm_robust(age ~ bali, data = toydat)         # the imbalance

b_bi <- coef(ols_bi)["bali"]
b_mu <- coef(ols_mult)["bali"]
b_cy <- coef(ols_mult)["age"]
b_dc <- coef(ols_dc)["bali"]

# Does the decomposition close?
c(bivariate = b_bi, adjusted_plus_OVB = b_mu + (b_cy * b_dc))
#         bivariate.bali adjusted_plus_OVB.bali 
#                   0.17                   0.17

The bivariate estimate is , the adjusted estimate is , and the omitted variable bias is . Given that you set the true effect to 1, which estimate got closer?

  1. Predict before you compute. Now make the confounder three times stronger on the outcome — change 0.3 * age to 0.9 * age — but change nothing else. What happens to the bivariate estimate?
set.seed(831983) # So everyone gets the SAME "random" numbers
toy_strong <- tibble(
  age = rnorm(n = 500, mean = 40, sd = 5)) %>%     # Step 1: age
  mutate(
    # Step 2: who gets treated (older people are interviewed earlier)
    int_day = (-0.1 * age) + rnorm(n = 500, mean = 0, sd = 1),
    bali = case_when(
      int_day < mean(int_day) ~ 0,   # interviewed before the attack
      int_day >= mean(int_day) ~ 1), # interviewed after the attack
    # Step 3: the outcome. THE TRUTH: the Bali effect is 1
    xeno = (1 * bali) + (0.9 * age) + rnorm(n = 500, mean = 0, sd = 1)) #<< 0.9 instead of 0.3

lm_robust(xeno ~ bali, data = toy_strong)        # bivariate
lm_robust(xeno ~ bali + age, data = toy_strong)  # adjusted

The bivariate estimate is now about -1.67 — it has the wrong sign. A strong enough confounder does not merely shrink an effect; it can make a genuinely positive effect look negative. The adjusted estimate still lands near 1.14.

  1. Now break one arrow. Change 0.3 * age to 0 * age — age no longer affects xenophobia at all — but leave -0.1 * age in Step 2, so the two groups are still badly imbalanced on age. What is the omitted variable bias now?

    With 0 * age, the bivariate estimate is and the imbalance \(\beta_{D \rightarrow C}\) is still about years. The bias is .

Because the bias is a product: \(\beta_{C \rightarrow Y} \times \beta_{D \rightarrow C}\). Multiply a big imbalance by zero and you get zero. An imbalanced variable is only a confounder if it also affects the outcome. This is why "the groups differ on X" is not, by itself, an argument that your estimate is biased — you have to make the case for both arrows.

  1. Flip the selection. Go back to 0.3 * age, but change -0.1 * age in Step 2 to 0.1 * age — now older people are more likely to end up in the treatment group. Predict the direction first, then run it.

    The bivariate estimate becomes , which is

set.seed(831983) # So everyone gets the SAME "random" numbers
toy_flip <- tibble(
  age = rnorm(n = 500, mean = 40, sd = 5)) %>%     # Step 1: age
  mutate(
    # Step 2: who gets treated (older people are interviewed earlier)
    int_day = (0.1 * age) + rnorm(n = 500, mean = 0, sd = 1),
    bali = case_when(
      int_day < mean(int_day) ~ 0,   # interviewed before the attack
      int_day >= mean(int_day) ~ 1), # interviewed after the attack
    # Step 3: the outcome. THE TRUTH: the Bali effect is 1
    xeno = (1 * bali) + (0.3 * age) + rnorm(n = 500, mean = 0, sd = 1)) #<< Step 2 now uses 0.1

lm_robust(xeno ~ bali, data = toy_flip)
lm_robust(xeno ~ bali + age, data = toy_flip)

Both arrows are now positive (\(+ \times + = +\)), so the bias is positive and the raw estimate overstates the true effect of 1. Same data-generating machinery, one sign flipped, and the error changes direction — exactly as the sign table on the slides predicted.

  1. The last knob is the interesting one. Change Step 2 to 0 * age: interview timing no longer depends on age at all. Fit both models and compare them.
set.seed(831983) # So everyone gets the SAME "random" numbers
toy_rand <- tibble(
  age = rnorm(n = 500, mean = 40, sd = 5)) %>%     # Step 1: age
  mutate(
    # Step 2: who gets treated (older people are interviewed earlier)
    int_day = (0 * age) + rnorm(n = 500, mean = 0, sd = 1),
    bali = case_when(
      int_day < mean(int_day) ~ 0,   # interviewed before the attack
      int_day >= mean(int_day) ~ 1), # interviewed after the attack
    # Step 3: the outcome. THE TRUTH: the Bali effect is 1
    xeno = (1 * bali) + (0.3 * age) + rnorm(n = 500, mean = 0, sd = 1)) #<< Step 2 now uses 0

modelsummary(
  list("Bivariate" = lm_robust(xeno ~ bali, data = toy_rand),
       "Adjusted"  = lm_robust(xeno ~ bali + age, data = toy_rand)),
  coef_rename = c("bali" = "Exposed to Bali", "age" = "Age"),
  stars = TRUE, gof_map = c("nobs"),
  output = "kableExtra"
)
Bivariate Adjusted
(Intercept) 12.064*** −0.262
(0.108) (0.392)
Exposed to Bali 1.044*** 1.044***
(0.158) (0.091)
Age 0.308***
(0.010)
Num.Obs. 500 500
+ p < 0.1, * p < 0.05, ** p < 0.01, *** p < 0.001

Write one sentence as a # comment saying what is special about this case — then compare:

With 0 * age in Step 2 the two models give essentially the same treatment estimate (≈ 1.04), and both are close to the true effect of 1. Nothing is confounded, because age no longer predicts who gets treated — the imbalance term \(\beta_{D \rightarrow C}\) is approximately zero, so the bias is zero no matter how strongly age affects xenophobia.

This is what a randomized controlled trial looks like (Lecture 6). Randomisation sets \(\beta_{D \rightarrow C}\) to zero for every confounder at once — measured or not — which is why an RCT needs no controls, and why it outranks anything you can do with a regression.

  1. Bonus, for the fast. Everything above kept set.seed(831983). Re-run the default simulation with three or four different seeds and collect the adjusted estimate each time. How close to the true value of 1 does it get, and why is it never exactly 1?
for (seed in c(1, 42, 2024, 831983)) { # Repeat the lecture's simulation with four seeds
  set.seed(seed)
  toy <- tibble(age = rnorm(n = 500, mean = 40, sd = 5)) %>%
    mutate(int_day = (-0.1 * age) + rnorm(n = 500, mean = 0, sd = 1),
           bali = case_when(int_day < mean(int_day) ~ 0,
                            int_day >= mean(int_day) ~ 1),
           xeno = (1 * bali) + (0.3 * age) + rnorm(n = 500, mean = 0, sd = 1))
  print(coef(lm_robust(xeno ~ bali + age, data = toy))["bali"]) # The adjusted estimate
}

The adjusted estimate scatters around 1 without ever landing exactly on it — that is sampling error (Lecture 3), not bias. Bias means being systematically off in one direction; sampling error means bouncing around the truth. Multiple OLS removed the bias here; only a larger sample would shrink the bounce.

  1. Discuss with your neighbour. In this exercise you always knew the truth. Work through:

    • In real research, which of the three quantities in the formula can you actually estimate from your data, and which would you have to argue for on theoretical grounds?
    • You adjust for a confounder and your estimate barely moves. Give two different explanations for that.
    • Someone claims: "I controlled for everything in the data set, so my estimate is causal." What is wrong with that sentence?