Returns to schooling: a Mincer equation with a Card-style instrument

A small simulation of OLS, college proximity, and two-stage least squares

Author

Krabbs

Published

August 11, 2026

The setup

The Mincer earnings equation relates log wages to schooling and a quadratic in labor-market experience:

\[ \log(w_i) = \alpha + \beta S_i + \gamma_1 X_i + \gamma_2 X_i^2 + u_i. \]

The coefficient \(\beta\) is approximately the proportional wage return to one more year of schooling. The problem is that schooling is chosen: unobserved ability, family resources, or motivation can affect both schooling and wages. In that case, OLS does not generally recover the causal return.

Following the logic of Card (1993), let \(Z_i\) indicate whether a person grew up near a four-year college. Proximity can serve as an instrument if it:

  1. changes schooling (relevance),
  2. is independent of unobserved wage determinants after conditioning on controls (independence), and
  3. affects wages only through schooling (exclusion).

This is a stylized simulation of that design, not a replication of Card’s data or estimates. The data-generating process sets the causal return to schooling to 8% per year, while unobserved ability raises both schooling and wages. College proximity is randomly assigned in the simulation, shifts schooling, and has no direct wage effect.

Show code
set.seed(930830)

library(ggplot2)
library(fixest)

theme_set(theme_minimal(base_size = 13))

make_data <- function(n = 2000, first_stage = 0.8) {
  ability <- rnorm(n)
  near_college <- rbinom(n, 1, 0.45)
  experience <- runif(n, 5, 35)
  schooling <- 12 + first_stage * near_college +
    0.8 * ability + rnorm(n, sd = 1.2)
  log_wage <- 1.5 + 0.08 * schooling +
    0.035 * experience - 0.0006 * experience^2 +
    0.20 * ability + rnorm(n, sd = 0.35)

  data.frame(log_wage, schooling, experience, near_college, ability)
}

d <- make_data()

The causal graph behind the simulation is:

Show code
flowchart LR
  Z[Near a college, Z] --> S[Schooling, S]
  S --> Y[Log wage, Y]
  A[Unobserved ability, A] --> S
  A --> Y
  X[Experience, X] --> Y

flowchart LR
  Z[Near a college, Z] --> S[Schooling, S]
  S --> Y[Log wage, Y]
  A[Unobserved ability, A] --> S
  A --> Y
  X[Experience, X] --> Y

The backdoor path \(S \leftarrow A \rightarrow Y\) confounds OLS. The instrument isolates the part of schooling induced by proximity.

One simulated sample

First estimate the reduced-form pieces and the two competing returns-to-schooling estimates.

Show code
first_stage_fit <- feols(
  schooling ~ near_college + experience + I(experience^2), data = d
)
reduced_form_fit <- feols(
  log_wage ~ near_college + experience + I(experience^2), data = d
)
ols_fit <- feols(
  log_wage ~ schooling + experience + I(experience^2), data = d
)
iv_fit <- feols(
  log_wage ~ experience + I(experience^2) |
    schooling ~ near_college,
  data = d
)

first_stage_f <- unname((coef(first_stage_fit)["near_college"] /
  se(first_stage_fit)["near_college"])^2)

one_sample <- data.frame(
  Quantity = c(
    "First stage: proximity → schooling",
    "Reduced form: proximity → log wage",
    "OLS return to schooling",
    "2SLS return to schooling"
  ),
  Estimate = c(
    coef(first_stage_fit)["near_college"],
    coef(reduced_form_fit)["near_college"],
    coef(ols_fit)["schooling"],
    coef(iv_fit)["fit_schooling"]
  )
)
one_sample$Estimate <- round(one_sample$Estimate, 3)
knitr::kable(one_sample, align = c("l", "r"))
Quantity Estimate
First stage: proximity → schooling 0.761
Reduced form: proximity → log wage 0.065
OLS return to schooling 0.144
2SLS return to schooling 0.086
Show code
cat("\nFirst-stage F statistic:", round(first_stage_f, 1), "\n")

First-stage F statistic: 142.3 

The Wald ratio is the reduced-form effect divided by the first-stage effect. With one instrument and one endogenous regressor, it equals the 2SLS coefficient (up to numerical precision).

Show code
wald <- coef(reduced_form_fit)["near_college"] /
  coef(first_stage_fit)["near_college"]
c(Wald = unname(wald), `2SLS` = unname(coef(iv_fit)["fit_schooling"]))
      Wald       2SLS 
0.08564567 0.08564567 

For comparison only, if ability were observed and added to the wage equation, OLS would also recover the causal return. In the actual IV problem it is deliberately unavailable.

Show code
oracle_fit <- feols(
  log_wage ~ schooling + ability + experience + I(experience^2), data = d
)
data.frame(
  Estimator = c("OLS (ability omitted)", "2SLS", "Oracle OLS (ability observed)"),
  Estimate = round(c(
    coef(ols_fit)["schooling"],
    coef(iv_fit)["fit_schooling"],
    coef(oracle_fit)["schooling"]
  ), 3),
  Truth = 0.08
) |>
  knitr::kable(align = c("l", "r", "r"))
Estimator Estimate Truth
OLS (ability omitted) 0.144 0.08
2SLS 0.086 0.08
Oracle OLS (ability observed) 0.076 0.08

Monte Carlo experiment

A single sample can flatter or punish any estimator. We therefore repeat the experiment 500 times with 1,000 observations. We compare the intended first stage (0.8 additional years near a college) with a weak first stage (0.15 years).

Show code
estimate_once <- function(first_stage, n = 1000) {
  x <- make_data(n, first_stage)

  ols <- lm(log_wage ~ schooling + experience + I(experience^2), data = x)
  fs <- lm(schooling ~ near_college + experience + I(experience^2), data = x)
  rf <- lm(log_wage ~ near_college + experience + I(experience^2), data = x)

  ols_b <- unname(coef(ols)["schooling"])
  iv_b <- unname(coef(rf)["near_college"] / coef(fs)["near_college"])
  fs_f <- unname(summary(fs)$coefficients["near_college", "t value"]^2)
  c(OLS = ols_b, IV = iv_b, first_stage_F = fs_f)
}

run_mc <- function(reps = 500, first_stage, label) {
  out <- t(replicate(reps, estimate_once(first_stage)))
  data.frame(replication = seq_len(reps), out, scenario = label)
}

set.seed(930811)
mc <- rbind(
  run_mc(500, 0.80, "Relevant instrument (π = 0.80)"),
  run_mc(500, 0.15, "Weak instrument (π = 0.15)")
)

mc_long <- rbind(
  data.frame(replication = mc$replication, estimate = mc$OLS,
             estimator = "OLS", scenario = mc$scenario),
  data.frame(replication = mc$replication, estimate = mc$IV,
             estimator = "2SLS", scenario = mc$scenario)
)
Show code
summarize_estimator <- function(z) {
  c(
    mean = mean(z),
    bias = mean(z) - 0.08,
    sd = sd(z),
    rmse = sqrt(mean((z - 0.08)^2))
  )
}

rows <- lapply(split(mc_long, interaction(mc_long$scenario, mc_long$estimator)),
  function(g) {
    s <- summarize_estimator(g$estimate)
    data.frame(
      Scenario = g$scenario[1], Estimator = g$estimator[1],
      Mean = s["mean"], Bias = s["bias"], SD = s["sd"], RMSE = s["rmse"]
    )
  })
summary_table <- do.call(rbind, rows)
summary_table[3:6] <- lapply(summary_table[3:6], round, 3)
rownames(summary_table) <- NULL
knitr::kable(summary_table, align = c("l", "l", "r", "r", "r", "r"))
Scenario Estimator Mean Bias SD RMSE
Relevant instrument (π = 0.80) 2SLS 0.082 0.002 0.031 0.031
Weak instrument (π = 0.15) 2SLS 0.157 0.077 2.529 2.528
Relevant instrument (π = 0.80) OLS 0.151 0.071 0.008 0.072
Weak instrument (π = 0.15) OLS 0.157 0.077 0.008 0.077
Show code
ggplot(mc_long, aes(estimate, fill = estimator, color = estimator)) +
  geom_density(alpha = 0.22, linewidth = 0.7) +
  geom_vline(xintercept = 0.08, linetype = "dashed", linewidth = 0.8) +
  facet_wrap(~ scenario, scales = "free_y") +
  coord_cartesian(xlim = c(-0.15, 0.30)) +
  scale_fill_manual(values = c("#D55E00", "#0072B2")) +
  scale_color_manual(values = c("#D55E00", "#0072B2")) +
  labs(
    x = "Estimated return per year of schooling",
    y = "Density",
    fill = NULL, color = NULL,
    caption = "Dashed line: true causal return, β = 0.08. Extreme weak-IV draws are clipped from the display only."
  ) +
  theme(legend.position = "top")

Show code
f_table <- do.call(rbind, lapply(split(mc, mc$scenario), function(g) {
  data.frame(
    Scenario = g$scenario[1],
    `Median first-stage F` = median(g$first_stage_F),
    `Share with F < 10` = mean(g$first_stage_F < 10),
    check.names = FALSE
  )
}))
f_table[[2]] <- round(f_table[[2]], 1)
f_table[[3]] <- sprintf("%.1f%%", 100 * f_table[[3]])
rownames(f_table) <- NULL
knitr::kable(f_table, align = c("l", "r", "r"))
Scenario Median first-stage F Share with F < 10
Relevant instrument (π = 0.80) 76.3 0.0%
Weak instrument (π = 0.15) 2.9 92.4%

What the simulation shows

  • OLS is precise but biased upward. Ability is omitted, raises schooling, and independently raises wages. OLS attributes some of ability’s wage effect to schooling.
  • A relevant, valid instrument recenters the estimate. 2SLS uses only proximity-induced schooling variation and is centered near the true 8% return.
  • Identification costs precision. Even with a useful instrument, 2SLS is noisier than OLS because it discards endogenous schooling variation.
  • Weak instruments are dangerous. When proximity barely changes schooling, the first-stage denominator is noisy. The IV distribution becomes wide and heavy-tailed; the familiar \(F<10\) rule is only a diagnostic, not a theorem that validates the design.
  • Instrument validity is substantive, not statistical. A strong first stage cannot prove independence or exclusion. In Card’s application, the argument depends on institutional context and the credibility of the conditional comparisons—not merely on an \(F\) statistic.

Reference

Card, David. 1993. “Using Geographic Variation in College Proximity to Estimate the Return to Schooling.” NBER Working Paper 4483. https://doi.org/10.3386/w4483