Answer

What does it mean to control for a variable in regression?

Inspired by a question on Cross Validated ·

regressioncausal inference

The short answer

You usually do not split the data into subgroups. You put the control variables into a regression model alongside the predictor you care about. The predictor's coefficient then uses only the part of its variation that the controls cannot explain, so it estimates the association between predictor and outcome among people with the same values of the controls. It removes confounding by measured variables only, and only as well as the model describes them.

The short answer

Suppose you want to know whether study time raises exam scores, but stronger students both study more and score higher anyway. A plain comparison of hours and scores mixes the two influences together. Controlling for prior ability means asking a narrower question: among students with the same prior grade, do the ones who study more score higher?

The intuitive way to do that is to sort people into groups that match on the control variable and compare within each group. That works for one or two coarse variables, but with several controls the number of cells explodes and most of them end up with one person or none. Multiple regression gets the same kind of answer without building cells: you fit score ~ hours + prior, and the coefficient on hours is the expected change in score for one more hour of study with the prior grade held fixed. The model borrows strength across everyone by assuming a simple form for how each variable relates to the outcome (a straight line, unless you say otherwise).

What the model actually does

There is a precise way to see what 'held fixed' means, known as the Frisch-Waugh-Lovell theorem. The adjusted coefficient on hours is exactly what you get from three simple steps:

So controlling does not compare two particular people with equal incomes or equal grades. It strips out of both variables whatever the controls account for, then looks at whether what is left over still moves together. With many controls the logic is the same; the first two steps just use all of them at once. This is also why a predictor that is almost completely explained by the controls gets a large standard error: very little independent variation remains to learn from.

See it in R and Python

The example simulates 2,000 students. Prior grade raises both study hours and the exam score, and the true effect of one extra hour is exactly 1 point. It compares the naive slope, the adjusted slope, the same slope rebuilt from residuals, and a subgroup version that controls for prior grade using five bands. The Python version fits the same models with NumPy least squares.

R

set.seed(17336)
n <- 2000
prior <- rnorm(n, 70, 10)                          # confounder: prior grade
hours <- 2 + 0.1 * prior + rnorm(n, sd = 1.5)      # better students study more
score <- 20 + 1.0 * hours + 0.6 * prior + rnorm(n, sd = 5)  # true effect of hours = 1

naive    <- lm(score ~ hours)
adjusted <- lm(score ~ hours + prior)

# 1. Slope of hours without and with the control variable
round(c(naive = coef(naive)[["hours"]], adjusted = coef(adjusted)[["hours"]]), 3)

# 2. The same adjusted slope by hand: remove what prior explains from both
r_hours <- resid(lm(hours ~ prior))
r_score <- resid(lm(score ~ prior))
round(coef(lm(r_score ~ r_hours))[["r_hours"]], 3)

# 3. The subgroup approach: five bands of prior grade
band <- cut(prior, quantile(prior, 0:5 / 5), include.lowest = TRUE)
round(coef(lm(score ~ hours + band))[["hours"]], 3)

# Inference for the adjusted slope
round(summary(adjusted)$coefficients, 3)
round(confint(adjusted)["hours", ], 2)
round(summary(adjusted)$r.squared, 3)

Python

import numpy as np
import pandas as pd
from scipy import stats

rng = np.random.default_rng(17336)
n = 2000
prior = rng.normal(70, 10, n)                           # confounder: prior grade
hours = 2 + 0.1 * prior + rng.normal(0, 1.5, n)         # better students study more
score = 20 + 1.0 * hours + 0.6 * prior + rng.normal(0, 5, n)  # true effect of hours = 1

def ols(X, y):
    X = np.column_stack([np.ones(len(y)), X])           # add the intercept
    b, *_ = np.linalg.lstsq(X, y, rcond=None)
    return b, y - X @ b, X

# 1. Slope of hours without and with the control variable
b_naive, _, _ = ols(hours, score)
b_adj, res, X = ols(np.column_stack([hours, prior]), score)
print("naive:", round(b_naive[1], 3), "adjusted:", round(b_adj[1], 3))

# 2. The same adjusted slope by hand: remove what prior explains from both
_, r_hours, _ = ols(prior, hours)
_, r_score, _ = ols(prior, score)
print("residual-on-residual:", round(ols(r_hours, r_score)[0][1], 3))

# 3. The subgroup approach: five bands of prior grade
band = pd.qcut(prior, 5, labels=False)
dummies = pd.get_dummies(band, drop_first=True).to_numpy(dtype=float)
print("banded:", round(ols(np.column_stack([hours, dummies]), score)[0][1], 3))

# Inference for the adjusted slope
df = n - 3
se = np.sqrt(np.diag(res @ res / df * np.linalg.inv(X.T @ X)))
t = b_adj / se
print(pd.DataFrame({"est": b_adj, "se": se, "t": t, "p": 2 * stats.t.sf(abs(t), df)},
                   index=["(Intercept)", "hours", "prior"]).round(3))
tq = stats.t.ppf(0.975, df)
print("95% CI hours:", np.round([b_adj[1] - tq * se[1], b_adj[1] + tq * se[1]], 2))
print("R2:", round(1 - res @ res / np.sum((score - score.mean()) ** 2), 3))
   naive adjusted 
   2.788    0.915 
[1] 0.915
[1] 1.154
            Estimate Std. Error t value Pr(>|t|)
(Intercept)   20.750      0.772  26.876        0
hours          0.915      0.071  12.847        0
prior          0.601      0.013  44.750        0
 2.5 % 97.5 % 
  0.78   1.05 
[1] 0.685

The figures come from one seeded run of the R code. Python's random numbers differ from R's, so its figures differ slightly (a naive slope of 2.699, an adjusted slope of 0.945 that the residual method reproduces exactly, and 1.149 with five bands), but they show the same pattern.

What the example shows

What controlling cannot do

Adjustment is powerful, but it is only as good as the variables you include and the way you model them. The adjusted coefficient can still be biased when:

Randomised experiments sidestep all of this, because random assignment balances measured and unmeasured variables alike. For reading the rest of the model output, see how to interpret regression output in R; the DASS blog has more on building and checking regression models.

How to report an adjusted regression coefficient in APA style (7th edition)

Name the covariates you adjusted for, then give the coefficient with its standard error or confidence interval and the test. Using the example above:

"Controlling for prior grade, each additional hour of study was associated with a 0.915-point higher exam score, b = 0.915, SE = 0.071, 95% CI [0.78, 1.05], t(1997) = 12.85, p < .001. Without the adjustment the estimate was 2.79 points per hour. The model explained 68.5% of the variance in scores, R² = .685."

In the method section, list every covariate and explain why it was included (for example, as a likely confounder). With several models, a table showing the unadjusted and adjusted coefficients side by side lets readers see how much the adjustment changed the estimate.

Related tools and guides

More answered questions

Working with your own data?

General answers only go so far. Send us your situation and we'll reply by email within two business days.

Ask your question

Written by AskStats with AI assistance. This is general information, not advice for your specific data or study. When the results matter, check your approach with a qualified statistician.