Section 11.3 Control Variables
In Chapter 4 we saw that first babies are lighter than other babies, on average. And in Chapter 9 we saw that birth weight is correlated with the motherβs age -- older mothers have heavier babies on average.
These results might be related. If mothers of first babies are younger than mothers of other babies -- which seems likely -- that might explain why their babies are lighter. We can use multiple regression to test this conjecture, by estimating the difference in birth weight between first babies and others while controlling for the mothersβ ages.
Instructions for downloading the NSFG data are in the notebook for this chapter.
The following cells download the data files and install
statadict, which we need to read the data.
download("https://github.com/AllenDowney/ThinkStats/raw/v3/nb/nsfg.py")
download("https://github.com/AllenDowney/ThinkStats/raw/v3/data/2002FemPreg.dct")
download("https://github.com/AllenDowney/ThinkStats/raw/v3/data/2002FemPreg.dat.gz")
try:
import statadict
except ImportError:
%pip install statadict
We can use
get_nsfg_groups to read the data, select live births, and group live births into first babies and others.
from nsfg import get_nsfg_groups
live, firsts, others = get_nsfg_groups()
Weβll use
dropna to select the rows with valid birth weights, birth order, and motherβs ages.
valid = live.dropna(subset=["agepreg", "birthord", "totalwgt_lb"]).copy()
Now we can use
StatsModels to confirm that birth weight is correlated with age, and to estimate the slope -- assuming that it is a linear relationship.
formula = "totalwgt_lb ~ agepreg"
result_age = smf.ols(formula, data=valid).fit()
display_summary(result_age)
| coef | std err | t | P>|t| | [0.025 | 0.975] | |
|---|---|---|---|---|---|---|
| Intercept | 6.8304 | 0.068 | 100.470 | 0.000 | 6.697 | 6.964 |
| agepreg | 0.0175 | 0.003 | 6.559 | 0.000 | 0.012 | 0.023 |
| R-squared: | 0.004738 |
The slope is small, only 0.0175 pounds per year. So if two mothers differ in age by a decade, we expect their babies to differ in weight by 0.175 pounds. But the p-value is small, so this slope -- small as it is -- would be unlikely if there were actually no relationship.
The \(R^2\) value is also small, which means that motherβs age is not very useful as a predictive variable. If we know the motherβs age, our ability to predict the babyβs weight is hardly improved at all.
This combination of a small p-value and a small \(R^2\) value is a common source of confusion, because it seems contradictory -- if the relationship is statistically significant, it seems like it should be predictive. But this example shows that there is no contradiction -- a relationship can be statistically significant but not very useful for prediction. If we visualize the results, weβll see why. First letβs select the relevant columns.
totalwgt = valid["totalwgt_lb"]
agepreg = valid["agepreg"]
To compute the fitted line, we could extract the intercept and slope from
result_age, but we donβt have to. The RegressionResults object provides a predict method we can use instead. First weβll compute a range of values for agepreg.
agepreg_range = np.linspace(agepreg.min(), agepreg.max())
df = pd.DataFrame({"agepreg": agepreg_range})
The columns in the
DataFrame have to have the same names as the explanatory variables. Then we can pass it to predict.
fit_ys = result_age.predict(df)
The result is a
Series containing the predicted values. Hereβs what they look like, along with a scatter plot of the data.
plt.scatter(agepreg, totalwgt, marker=".", alpha=0.1, s=5)
plt.plot(agepreg_range, fit_ys, color="C1", label="linear model")
decorate(xlabel="Maternal age", ylabel="Birth weight (pounds)")

Because the slope of the fitted line is small, we can barely see the difference in the expected birth weight between the youngest and oldest mothers. The variation in birth weight, at every maternal age, is much larger.
Next weβll use
StatsModels to confirm that first babies are lighter than others. To make that work, weβll create a Boolean Series that is True for first babies and False for others, and add it as a new column called is_first.
valid["is_first"] = valid["birthord"] == 1
$ from thinkstats import value_counts
# check the results
value_counts(valid["is_first"])
is_first
False 4675
True 4363
Name: count, dtype: int64
Hereβs the formula for a model with birth weight as the response variable and
is_first as the explanatory variable. In the Patsy formula language, C and the parentheses around the variable name indicate that it is categorical -- that is, it represents categories like "first baby" rather than measurements like birth weight.
formula = "totalwgt_lb ~ C(is_first)"
Now we can fit the model and display the results, as usual.
result_first = smf.ols(formula, data=valid).fit()
display_summary(result_first)
| coef | std err | t | P>|t| | [0.025 | 0.975] | |
|---|---|---|---|---|---|---|
| Intercept | 7.3259 | 0.021 | 356.007 | 0.000 | 7.286 | 7.366 |
| C(is_first)[T.True] | -0.1248 | 0.030 | -4.212 | 0.000 | -0.183 | -0.067 |
| R-squared: | 0.00196 |
In the results, the label
C(is_first)[T.True] indicates that is_first is a categorical variable and the coefficient is associated with the value True. The T before True stands for "treatment" -- in the language of a controlled experiment, first babies are considered the treatment group and other babies are considered the reference group. These designations are arbitrary -- we could consider first babies to be the reference group and others to be the treatment group. But we need to know which is which in order to interpret the results.
The intercept is about 7.3, which means that the average weight of the reference group is 7.3 pounds. The coefficient of
is_first is -0.12, which means that the average weight of the treatment group -- first babies -- is 0.12 pounds lighter. We can check both of these results by computing them directly.
$ others["totalwgt_lb"].mean()
np.float64(7.325855614973262)
$ diff_weight = firsts["totalwgt_lb"].mean() - others["totalwgt_lb"].mean()
diff_weight
np.float64(-0.12476118453549034)
In addition to these coefficients,
StatsModels also computes p-values, confidence intervals, and \(R^2\text{.}\) The p-value associated with first babies is small, which means that the difference between the groups is statistically significant. And the \(R^2\) value is small, which means that if weβre trying to guess the weight of a baby, it doesnβt help much to know whether it is a first baby.
Now letβs see if itβs plausible that the difference in birth weight is due to the difference in maternal age. On average, mothers of first babies are about 3.6 years younger than other mothers.
$ diff_age = firsts["agepreg"].mean() - others["agepreg"].mean()
diff_age
np.float64(-3.5864347661500275)
And the slope of birth weight as a function of age is 0.0175 pounds per year.
$ slope = result_age.params["agepreg"]
slope
np.float64(0.017453851471802638)
If we multiply the slope by the difference in ages, we get the expected difference in birth weight for first babies and others, due to motherβs age.
$ slope * diff_age
np.float64(-0.0625970997216918)
The result is 0.063 pounds, which is about half of the observed difference. So it seems like the observed difference in birth weight can be partly explained by the difference in motherβs age.
Using multiple regression, we can estimate coefficients for maternal age and first babies at the same time.
formula = "totalwgt_lb ~ agepreg + C(is_first)"
result = smf.ols(formula, data=valid).fit()
display_summary(result)
| coef | std err | t | P>|t| | [0.025 | 0.975] | |
|---|---|---|---|---|---|---|
| Intercept | 6.9142 | 0.078 | 89.073 | 0.000 | 6.762 | 7.066 |
| C(is_first)[T.True] | -0.0698 | 0.031 | -2.236 | 0.025 | -0.131 | -0.009 |
| agepreg | 0.0154 | 0.003 | 5.499 | 0.000 | 0.010 | 0.021 |
| R-squared: | 0.005289 |
The coefficient of
is_first is -0.0698, which means that first babies are 0.0698 pounds lighter than others, on average, after accounting for the difference due to maternal age. Thatβs about half of the difference we get without accounting for maternal age.
And the p-value is 0.025, which is still considered statistically significant, but it is in the borderline range where we canβt exclude the possibility that a difference this size could happen by chance.
Because this model takes into account the weight difference due to maternal age, we can say that it controls for maternal age. But it assumes that the relationship between weight and maternal age is linear. So letβs see if thatβs true.
