Lab: Quantile Treatment Effects from Scratch
Implement quantile treatment effects step by step. Simulate heterogeneous treatment effects across the earnings distribution, estimate conditional QTEs via quantile regression, compute unconditional QTEs via RIF regression, and compare with OLS ATE.
Overview
The average treatment effect (ATE) summarizes a treatment's impact with a single number — the mean difference. But a treatment can affect different parts of the outcome distribution differently. A job training program might help low earners substantially while barely affecting high earners, or it might compress the distribution by raising the floor without changing the ceiling. Quantile treatment effects (QTEs) reveal these distributional patterns.
What you will learn:
- Why the ATE can miss important heterogeneity across the outcome distribution
- How to estimate conditional QTEs using quantile regression
- How to estimate unconditional QTEs using RIF (Recentered Influence Function) regression
- The critical difference between conditional and unconditional quantile effects
- How to test for treatment effect heterogeneity across quantiles
- How to interpret a QTE curve
Prerequisites: OLS regression, basic understanding of quantiles and distributions.
Step 1: Simulate Heterogeneous Treatment Effects
We simulate a job training program where the treatment effect varies across the earnings distribution: large gains for low earners, modest gains for high earners.
library(quantreg)
set.seed(42)
n <- 5000
# Covariates
educ <- round(rnorm(n, mean = 12, sd = 2))
exper <- pmax(round(rnorm(n, mean = 10, sd = 5)), 0)
female <- rbinom(n, 1, 0.5)
# Treatment assignment (randomized experiment)
treat <- rbinom(n, 1, 0.5)
# Potential outcomes WITHOUT treatment
# Base earnings depend on education, experience, and unobserved heterogeneity
u <- rnorm(n) # Unobserved ability/luck
earnings_0 <- exp(2.5 + 0.08 * educ + 0.03 * exper - 0.15 * female + 0.4 * u)
# Treatment effect: HETEROGENEOUS across the distribution
# Larger effects for low earners (those with low u), smaller for high earners
# Treatment effect in levels: tau(q) ~ 5000 at q=0.1, ~1000 at q=0.9
te_individual <- pmax(6000 - 3000 * pnorm(u), 500)
# Potential outcomes WITH treatment
earnings_1 <- earnings_0 + te_individual
# Observed outcome
earnings <- ifelse(treat == 1, earnings_1, earnings_0)
df <- data.frame(earnings, treat, educ, exper, female, u)
# True ATE
true_ate <- mean(te_individual)
cat("True ATE:", round(true_ate, 0), "\n")
cat("True QTE at q=0.10:", round(quantile(earnings_1, 0.10) - quantile(earnings_0, 0.10), 0), "\n")
cat("True QTE at q=0.50:", round(quantile(earnings_1, 0.50) - quantile(earnings_0, 0.50), 0), "\n")
cat("True QTE at q=0.90:", round(quantile(earnings_1, 0.90) - quantile(earnings_0, 0.90), 0), "\n")Expected output:
| Quantile | True QTE |
|---|---|
| 0.10 | ~4,000–6,000 |
| 0.25 | ~3,500–5,000 |
| 0.50 | ~2,500–4,000 |
| 0.75 | ~1,500–3,000 |
| 0.90 | ~800–2,000 |
| ATE | ~3,000–4,000 |
The treatment effect is largest at the bottom of the distribution (low earners benefit most) and smallest at the top. The ATE masks this important heterogeneity.
Step 2: OLS Average Treatment Effect
Start with the standard ATE estimate for comparison.
# OLS ATE (simple difference in means for RCT)
ate_simple <- mean(df$earnings[df$treat == 1]) - mean(df$earnings[df$treat == 0])
cat("ATE (simple difference):", round(ate_simple, 0), "\n")
# OLS with controls
ols_model <- lm(earnings ~ treat + educ + exper + female, data = df)
cat("ATE (OLS with controls):", round(coef(ols_model)["treat"], 0), "\n")
cat("True ATE:", round(true_ate, 0), "\n")
cat("\nThe ATE tells us the program raises earnings by ~$",
round(coef(ols_model)["treat"], 0), " on average.\n")
cat("But does it help everyone equally? QTEs will answer this.\n")Expected output:
| Estimator | ATE Estimate | True ATE |
|---|---|---|
| Simple difference | ~3,000–4,000 | ~3,500 |
| OLS with controls | ~3,000–4,000 | ~3,500 |
The ATE estimate is a single summary number. It conceals the fact that the treatment effect varies substantially across the earnings distribution.
Step 3: Conditional Quantile Treatment Effects
Conditional QTEs estimate how the treatment shifts the conditional quantile of earnings (conditional on covariates) at each quantile level. This uses standard quantile regression.
# Conditional quantile regression at multiple quantiles
taus <- c(0.10, 0.25, 0.50, 0.75, 0.90)
cqte_results <- data.frame(tau = numeric(), coeff = numeric(),
se = numeric(), lower = numeric(), upper = numeric())
for (tau in taus) {
qr_model <- rq(earnings ~ treat + educ + exper + female, data = df, tau = tau)
s <- summary(qr_model, se = "boot", R = 200)
coeff <- s$coefficients["treat", "Value"]
se <- s$coefficients["treat", "Std. Error"]
cqte_results <- rbind(cqte_results, data.frame(
tau = tau, coeff = coeff, se = se,
lower = coeff - 1.96 * se, upper = coeff + 1.96 * se
))
}
cat("=== Conditional Quantile Treatment Effects ===\n")
cat(sprintf("%-6s %10s %10s %10s\n", "Tau", "CQTE", "SE", "OLS ATE"))
for (i in 1:nrow(cqte_results)) {
cat(sprintf("%-6.2f %10.0f %10.0f %10.0f\n",
cqte_results$tau[i], cqte_results$coeff[i],
cqte_results$se[i], coef(ols_model)["treat"]))
}
cat("\nThe CQTE is larger at lower quantiles: the treatment\n")
cat("helps low earners more than high earners (conditional on X).\n")Expected output:
| Quantile (tau) | CQTE | SE | OLS ATE |
|---|---|---|---|
| 0.10 | ~4,000–5,500 | ~500–800 | ~3,500 |
| 0.25 | ~3,500–4,500 | ~400–600 | ~3,500 |
| 0.50 | ~3,000–4,000 | ~400–600 | ~3,500 |
| 0.75 | ~2,000–3,000 | ~400–700 | ~3,500 |
| 0.90 | ~1,000–2,000 | ~600–1,000 | ~3,500 |
The conditional QTE is monotonically decreasing across quantiles: the treatment helps low earners (conditional on covariates) more than high earners. The ATE of approximately 3,500 is a weighted average that masks this heterogeneity.
What is the difference between a conditional quantile treatment effect (CQTE) and an unconditional quantile treatment effect (UQTE)?
Step 4: Unconditional QTEs via RIF Regression
The unconditional QTE estimates the effect at quantiles of the marginal distribution of Y. We use the Recentered Influence Function (RIF) approach of Firpo et al. (2009).
# RIF regression for unconditional quantile effects
# RIF(Y; q_tau) = q_tau + (tau - I(Y <= q_tau)) / f_Y(q_tau)
# where q_tau is the tau-th quantile of Y and f_Y is the density of Y
uqte_results <- data.frame(tau = numeric(), uqte = numeric(),
se = numeric())
for (tau in taus) {
# Step 1: Estimate the unconditional quantile q_tau
q_tau <- quantile(df$earnings, tau)
# Step 2: Estimate the density at q_tau using kernel density
dens <- density(df$earnings, n = 1024)
f_q <- approx(dens$x, dens$y, xout = q_tau)$y
# Step 3: Compute the RIF for each observation
rif <- q_tau + (tau - as.numeric(df$earnings <= q_tau)) / f_q
# Step 4: Run OLS with RIF as the dependent variable
df$rif <- rif
rif_model <- lm(rif ~ treat + educ + exper + female, data = df)
uqte_results <- rbind(uqte_results, data.frame(
tau = tau,
uqte = coef(rif_model)["treat"],
se = summary(rif_model)$coef["treat", "Std. Error"]
))
}
cat("=== Unconditional QTEs (RIF Regression) ===\n")
cat(sprintf("%-6s %10s %10s %10s\n", "Tau", "UQTE", "CQTE", "OLS ATE"))
for (i in 1:nrow(uqte_results)) {
cat(sprintf("%-6.2f %10.0f %10.0f %10.0f\n",
uqte_results$tau[i], uqte_results$uqte[i],
cqte_results$coeff[i], coef(ols_model)["treat"]))
}Expected output:
| Quantile | UQTE (RIF) | CQTE | OLS ATE |
|---|---|---|---|
| 0.10 | ~4,500–6,000 | ~4,500 | ~3,500 |
| 0.25 | ~3,500–5,000 | ~4,000 | ~3,500 |
| 0.50 | ~3,000–4,000 | ~3,500 | ~3,500 |
| 0.75 | ~2,000–3,000 | ~2,500 | ~3,500 |
| 0.90 | ~800–2,000 | ~1,500 | ~3,500 |
Both CQTE and UQTE show declining treatment effects across quantiles, but the magnitudes may differ because they measure effects at different points — conditional vs. unconditional quantiles.
For policy purposes, a government wants to know: 'Does this job training program help the bottom 10% of earners?' Which estimand should they use?
Step 5: Test for Treatment Effect Heterogeneity
Is the treatment effect truly heterogeneous across quantiles, or is the pattern just noise? We test whether the QTEs are significantly different from each other.
# Formal test: is the QTE at 0.10 different from the QTE at 0.90?
qr_10 <- rq(earnings ~ treat + educ + exper + female, data = df, tau = 0.10)
qr_90 <- rq(earnings ~ treat + educ + exper + female, data = df, tau = 0.90)
cat("CQTE at tau = 0.10:", coef(qr_10)["treat"], "\n")
cat("CQTE at tau = 0.90:", coef(qr_90)["treat"], "\n")
cat("Difference:", coef(qr_10)["treat"] - coef(qr_90)["treat"], "\n")
# Joint test: are all QTEs equal? (Wald test)
qr_process <- rq(earnings ~ treat + educ + exper + female,
data = df, tau = taus)
wald_test <- anova(qr_process, joint = FALSE)
cat("\nWald test for equality of QTEs across quantiles:\n")
print(wald_test)
# Interquartile range effect
qr_25 <- rq(earnings ~ treat + educ + exper + female, data = df, tau = 0.25)
qr_75 <- rq(earnings ~ treat + educ + exper + female, data = df, tau = 0.75)
iqr_effect <- coef(qr_75)["treat"] - coef(qr_25)["treat"]
cat("\nEffect on IQR (q75 - q25):", round(iqr_effect, 0), "\n")
cat("Negative = treatment compresses the distribution\n")Expected output:
| Test | Statistic | p-value | Interpretation |
|---|---|---|---|
| CQTE(0.10) vs CQTE(0.90) | z ~ 3–5 | < 0.01 | Significant heterogeneity |
| Effect on IQR | ~-1,500 to -3,000 | — | Treatment compresses distribution |
The test should reject the null of equal QTEs: the treatment effect is significantly larger at the 10th percentile than at the 90th percentile. The negative IQR effect confirms that the treatment compresses the earnings distribution — it is an equalizing intervention.
Step 6: The QTE Curve
Plot the full QTE curve across all quantiles to visualize the heterogeneity pattern.
# Estimate CQTEs at a fine grid of quantiles
tau_grid <- seq(0.05, 0.95, by = 0.05)
qte_curve <- data.frame(tau = numeric(), qte = numeric(),
lower = numeric(), upper = numeric())
for (tau in tau_grid) {
qr <- rq(earnings ~ treat + educ + exper + female, data = df, tau = tau)
s <- summary(qr, se = "boot", R = 200)
coeff <- s$coefficients["treat", "Value"]
se <- s$coefficients["treat", "Std. Error"]
qte_curve <- rbind(qte_curve, data.frame(
tau = tau, qte = coeff,
lower = coeff - 1.96 * se, upper = coeff + 1.96 * se
))
}
# Plot
plot(qte_curve$tau, qte_curve$qte, type = "l", lwd = 2, col = "blue",
xlab = "Quantile (tau)", ylab = "Treatment Effect ($)",
main = "Quantile Treatment Effect Curve",
ylim = range(c(qte_curve$lower, qte_curve$upper)))
polygon(c(qte_curve$tau, rev(qte_curve$tau)),
c(qte_curve$lower, rev(qte_curve$upper)),
col = rgb(0, 0, 1, 0.1), border = NA)
abline(h = coef(ols_model)["treat"], col = "red", lty = 2, lwd = 2)
legend("topright", legend = c("CQTE", "95% CI", "OLS ATE"),
col = c("blue", rgb(0, 0, 1, 0.3), "red"),
lty = c(1, NA, 2), lwd = c(2, NA, 2),
fill = c(NA, rgb(0, 0, 1, 0.1), NA), border = NA)Expected output:
The QTE curve should show a monotonically declining pattern: large positive effects at low quantiles (around 1,000–2,000 at the 90th percentile). The OLS ATE (horizontal dashed line) runs through the middle, showing how the single average masks the heterogeneity.
Step 7: Comparison Summary
# Final summary table
cat("================================================================\n")
cat(" TREATMENT EFFECT SUMMARY \n")
cat("================================================================\n")
cat(sprintf("%-30s %10s %10s\n", "Estimand", "Estimate", "True"))
cat("----------------------------------------------------------------\n")
cat(sprintf("%-30s %10.0f %10.0f\n", "OLS ATE",
coef(ols_model)["treat"], true_ate))
cat("----------------------------------------------------------------\n")
for (i in 1:nrow(cqte_results)) {
true_qte <- quantile(earnings_1, cqte_results$tau[i]) -
quantile(earnings_0, cqte_results$tau[i])
cat(sprintf("%-30s %10.0f %10.0f\n",
paste0("CQTE at tau=", cqte_results$tau[i]),
cqte_results$coeff[i], true_qte))
}
cat("----------------------------------------------------------------\n")
for (i in 1:nrow(uqte_results)) {
true_qte <- quantile(earnings_1, uqte_results$tau[i]) -
quantile(earnings_0, uqte_results$tau[i])
cat(sprintf("%-30s %10.0f %10.0f\n",
paste0("UQTE (RIF) at tau=", uqte_results$tau[i]),
uqte_results$uqte[i], true_qte))
}
cat("================================================================\n")
cat("\nKey finding: the program reduces earnings inequality by\n")
cat("helping low earners substantially more than high earners.\n")Step 8: Exercises
Interpreting a QTE Curve
You estimate a QTE curve for a minimum wage increase on worker earnings. The UQTE at tau = 0.10 is $800 (significant), at tau = 0.50 is $200 (insignificant), and at tau = 0.90 is -$50 (insignificant). The OLS ATE is $300.
-
Uniform treatment effects. Modify the simulation so that the treatment effect is the same for everyone (no heterogeneity). Re-estimate the QTE curve. It should be approximately flat, confirming that the method does not spuriously detect heterogeneity.
-
Endogenous treatment. In many applications, treatment is not randomly assigned. Implement an IV quantile regression (Chernozhukov and Hansen (2005)) to estimate QTEs with endogenous treatment.
-
Unconditional QTE via CDF inversion. An alternative to RIF regression is to estimate the entire counterfactual CDFs for treated and control groups and then invert them. Implement this approach and compare.
✓Key Takeaways
- The average treatment effect (ATE) is a single summary that can mask important heterogeneity across the outcome distribution
- Conditional quantile treatment effects (CQTEs) estimate the effect at quantiles of Y|X (the conditional distribution); unconditional QTEs (UQTEs) estimate the effect at quantiles of Y (the marginal distribution)
- For policy questions about specific population segments (e.g., the bottom 10% of earners), unconditional QTEs are more directly relevant
- RIF regression provides a straightforward way to estimate unconditional QTEs using standard OLS on a transformed dependent variable
- A declining QTE curve indicates the treatment helps low earners more than high earners, reducing inequality
- Always test for treatment effect heterogeneity — a flat QTE curve means the ATE is sufficient
- QTE curves should be plotted with confidence bands to distinguish genuine heterogeneity from noise
- The distinction between conditional and unconditional quantile effects is fundamental and often confused in applied work