import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import statsmodels.api as sm
from scipy import stats
BLUE, RED, GREEN, ORANGE, GRAY = "#1f6fb2", "#c92a1a", "#1c7a43", "#e8871a", "#8a8a8a"
plt.rcParams["figure.figsize"] = (6.5, 3.6)
plt.rcParams["axes.spines.top"] = plt.rcParams["axes.spines.right"] = False
def resid_vs_fitted(fit, ax, title=""):
"""Residuals against fitted values, with the spread in three fitted-value terciles."""
r, f = fit.resid, fit.fittedvalues
ax.scatter(f, r, s=8, color=GRAY, alpha=0.6)
ax.axhline(0, color=RED, lw=1)
terc = pd.qcut(f, 3, labels=False)
sds = [r[terc == k].std() for k in range(3)]
ax.set_title(f"{title}\nresidual sd by fitted tercile: " +
", ".join(f"{s:.3g}" for s in sds), fontsize=9)
ax.set_xlabel("fitted"); ax.set_ylabel("residual")
def qq(fit, ax, title=""):
"""QQ plot of the standardized residuals against Gaussian quantiles."""
z = fit.get_influence().resid_studentized_internal
z = z[np.isfinite(z)] # a row with leverage 1 has no studentized residual
sm.qqplot(z, line="45", ax=ax, markersize=2)
ax.set_title(f"{title}\nexcess kurtosis {stats.kurtosis(z):.1f}, skew {stats.skew(z):.2f}",
fontsize=9)
def resid_vs_x(fit, x, ax, title="", bins=10):
"""Residuals against one covariate, with the mean residual per decile of x."""
ax.scatter(x, fit.resid, s=6, color=GRAY, alpha=0.4)
ax.axhline(0, color=RED, lw=1)
b = pd.qcut(x, bins, labels=False, duplicates="drop")
ax.plot(x.groupby(b).median(), fit.resid.groupby(b).mean(), "o-", color=BLUE, ms=4)
ax.set_title(title, fontsize=10); ax.set_xlabel(x.name); ax.set_ylabel("residual")Lab 3 — Reading regression output like a statistician
You’re a statistician analyzing house sales in Ames, Iowa (2006–2010). ames_housing.csv holds one row per sale: the price and eight covariates. It is a cleaned subset of the data published by De Cock (2011) and used in the Kaggle competition House Prices. Prices are in dollars, areas in m².
Boxes as always: 🔮 predict before running (no AI) · 🧠 explain in your own words · ✍️ short task, boilerplate given · 🤖 a prompt that sends you to find something the lab did not hand you. AI is fine for syntax; the written interpretations must be yours.
Part 1 — The data, and one column that is not a number
df = pd.read_csv("https://hichamjanati.github.io/media/teaching/SMV/ames_housing.csv")
print(df.shape)
df.head()| column | meaning |
|---|---|
SalePrice
|
sale price, dollars |
GrLivArea
|
above-ground living area, m² |
OverallQual
|
overall material and finish quality, rated 1 (very poor) to 10 (very excellent) |
YearBuilt
|
construction year |
LotArea
|
lot size, m² |
GarageCars
|
garage capacity, cars |
BedroomAbvGr
|
bedrooms above ground |
PoolArea
|
pool area, m² (0 when there is no pool) |
BldgType
|
building type, five categories (see below) |
The five building types: 1Fam is a detached single-family house. 2fmCon is a two-family conversion: a house built for one family and later split into two units (two kitchens, two entrances, one building). Duplex was built from the start as two side-by-side units under one roof. A townhouse is a row house sharing walls with its neighbors: Twnhs is an inside unit (a neighbor on both sides, windows only front and back), TwnhsE an end unit (one shared wall, one free side, usually a bigger lot).
df.describe().round(1)1.1 Dummy variables
BldgType is text. A regression needs numbers, and the wrong fix is to code the five types 1, 2, 3, 4, 5: that would say a duplex is “one more” than a two-family conversion, and a townhouse “twice” a duplex. The right fix is one column per category, holding 1 if the house is of that type and 0 otherwise: dummy variables, also called one-hot encoding.
print(df["BldgType"].value_counts())
X_dum = pd.get_dummies(df["BldgType"], dtype=float)
X_dum.iloc[[0, 5, 9]] # three houses, five columns🔮 Predict. The five dummy columns of one house always sum to 1. The model also has an intercept column, all ones. What does the lecture say about a design matrix (matrix X) whose columns are linearly dependent? So how many of the five columns can we keep?
✍️ Your answer:
…
D = pd.get_dummies(df, columns=["BldgType"], drop_first=True, dtype=float)
features = [c for c in D.columns if c != "SalePrice"]
print(features)drop_first=True removes BldgType_1Fam: a house is now a single-family house when all the other kept dummies are 0. This is the reference level.
🧠 Explain. In a regression of price on these features, what exactly will the coefficient of BldgType_Duplex measure? Say what “other covariates” are held fixed.
✍️ Your answer:
…
1.2 Look before you fit: the pair plot
🔮 Predict. Of the four covariates in the plot below, which correlates most with price? And name one pair of covariates you expect to be correlated with each other, with a one-line reason.
✍️ Your answer:
…
numeric = ["SalePrice", "OverallQual", "GrLivArea", "LotArea", "YearBuilt"]
pd.plotting.scatter_matrix(df[numeric], figsize=(10, 10), s=4, alpha=0.4,
color=GRAY, hist_kwds={"bins": 30, "color": GRAY})
plt.suptitle("pair plot: scatter of each pair, histogram on the diagonal", y=0.92)
plt.show()
df[numeric].corr().round(2)🧠 Explain. Look at the pair plot. Which two covariates are most correlated? What does it mean for the coefficients of these two covariates?
✍️ Your answer:
…
🧠 Explain. The diagonal: which two histograms are far from symmetric? Keep them in mind for Part 3.
✍️ Your answer:
…
Part 2 — First fit: the output table, line by line
X = sm.add_constant(D[features])
y = D["SalePrice"]
fit_raw = sm.OLS(y, X).fit()
print(fit_raw.summary().tables[1])
print(f"R² = {fit_raw.rsquared:.3f}")🧠 Explain. PoolArea has p = 0.83. Does that mean a swimming pool adds nothing to the price of a house?
✍️ Your answer:
…
🧠 Explain. Your manager wants to remove every non-significant variable and refit. Is this justified in general?
✍️ Your answer:
…
🧠 Explain. Exactly 5 of the 1 456 houses have a pool. Explain the size of that standard error in one sentence. Then say what you would need in order to answer “does a pool add value”, and whether any amount of cleverness on this file could get you there.
✍️ Your answer:
…
🧠 Explain. LotArea is significant, p < 0.001, but its coefficient is 8.9 $ per m² against 789 $ per m² of living area, 89 times smaller. Does that make lot area practically unimportant to a house price? Answer yes or no, in one sentence, before you run the next cell.
✍️ Your answer:
…
A coefficient is a price per one more m², and no house differs from another by a single m². A room is about 20 m² of living area; lots differ by hundreds of m². The statistical version of “how much does this column actually move around” is to shift each column by one standard deviation, its typical variation: coef × column std. We do this for all numerical variables:
numerical_features = ["GrLivArea", "LotArea", "PoolArea"]
sd = D[numerical_features].std()
impact_numerical = (fit_raw.params[numerical_features] * sd)
print(impact_numerical.round(2))Part 3 — Diagnostics, one plot at a time
3.1 The spread of the residuals
fig, ax = plt.subplots()
resid_vs_fitted(fit_raw, ax, "raw price model")
plt.show()🧠 Explain. Describe the plot. Which columns of the table you just read stop being trustworthy?
✍️ Your answer:
…
3.2 A usual fix
The funnel says: the error the model makes scales with the price. An agent who misjudges a house by 10 % is off by 15 000 $ on a 150 000 $ house and by 50 000 $ on a 500 000 $ house. The noise is multiplicative:
\[\text{price} = e^{\beta_0 + \beta^\top x}\cdot e^{\varepsilon} \quad\Longleftrightarrow\quad \log \text{price} = \beta_0 + \beta^\top x + \varepsilon .\]
On the log scale the same 10 % error is the same 0.1 everywhere: a constant spread is exactly (A3).
✍️ Your turn. Refit the model of Part 2 on log(SalePrice): same X, logy in place of y. Print the coefficient table, then draw the residuals against the fitted values with resid_vs_fitted, as in 3.1.
logy = np.log(y)
# TODO: fit_log = ... (Part 2's fit, with logy in place of y)
# TODO: print the coefficient table of fit_log
fig, ax = plt.subplots()
# TODO: residuals vs fitted of fit_log (resid_vs_fitted, as in 3.1)
plt.show()🧠 Explain. Verdict on (A3) now: did the log transformation fix the funnel ?
✍️ Your answer:
…
3.3 The tails
fig, axes = plt.subplots(1, 2, figsize=(10, 3.8))
qq(fit_raw, axes[0], "raw price model")
qq(fit_log, axes[1], "log price model")
plt.tight_layout(); plt.show()🧠 Explain. Verdict on (A4) for each model, with the plot as evidence. Which side of the log model’s QQ plot breaks, and how many points are responsible?
✍️ Your answer:
…
3.4 Without (A4): the asymptotic table
Section 3.3 said (A4) cannot reasonably be assumed for either model. Every p-value and confidence interval read so far rests on the law \(T_j = \hat\beta_j / \widehat{\mathrm{SE}}(\hat\beta_j) \sim t(n-p-1)\), which is exact under (A1)–(A4) and lost without (A4). The lecture’s answer: for large \(n\) the central limit theorem makes \(T_j\) approximately Gaussian, so the asymptotic p-values stay valid. statsmodels switches to that law with use_t=False: the column t becomes z, and the Gaussian quantile replaces the \(t\) quantile in the p-values and the confidence intervals.
🔮 Predict. The log model has about 1 450 rows and 12 coefficients. Before running: will the SEs change? How much will the p-values and the intervals change?
✍️ Your answer:
…
fit_log_z = sm.OLS(logy, X).fit(use_t=False)
print(fit_log_z.summary().tables[1])
print(f"97.5 % quantile: t({int(fit_log.df_resid)}) = {stats.t.ppf(0.975, fit_log.df_resid):.4f}"
f" Gaussian = {stats.norm.ppf(0.975):.4f}")🧠 Explain. Compare with the table of 3.2, column by column: what changed, what did not, and why is the difference this size?
✍️ Your answer:
…
3.5 Beyond homoscedasticity: robust standard errors
Here the log repaired the funnel. But what if it does not? We need to make the variance estimator robust to heteroscedasticity. There are several Heteroscedasticity-Consistent (HC) estimators. We will use HC3, a simple and popular one.
The idea is to stop estimating a single \(\sigma^2\) and to estimate \(\operatorname{Var}(\hat\beta)\) directly, with an estimator that converges to the true \(\operatorname{Var}(\hat\beta)\) whatever the spread does.
🤖 With AI. Find the API rather than being told it. Ask an assistant: “In Python statsmodels, how do I fit an OLS model with HC3 heteroscedasticity-robust standard errors?” Run what it gives you below, then compare the table with the one from Part 2: which columns changed, and which did not?
✍️ Your answer:
…
# TODO: fit_raw_hc = ... (the raw model, HC3 robust covariance)
# TODO: print its coefficient table and compare with Part 23.6 The bootstrap
We end this lab with a more general method for uncertainty estimation that works with any model: the bootstrap.
A standard error answers: if I could collect another 1 456 houses from the same market, how much would \(\hat\beta\) move? We cannot collect them. So we draw a new sample of 1 456 rows from the rows we have, with replacement, refit, and repeat. The spread of those refits estimates the spread we could not observe.
🔮 Predict. Draw 1 456 rows with replacement from 1 456 rows. Roughly what fraction of the original houses ends up in the draw at least once?
✍️ Your answer:
…
✍️ Your turn. Draw one resample of row positions (integers) and count how many distinct houses it contains. Hint: you can use rng.integers for generating the row positions and np.unique to count the distinct houses.
rng = np.random.default_rng(0)
# TODO: the array idx to be defined as n row positions, drawn with replacement
# TODO: how many distinct houses is that?One resample is one imaginary alternative dataset. Refit on it and you get one alternative \(\hat\beta\). Do it a thousand times and the standard deviation of those thousand estimates is the standard error, measured rather than derived.
✍️ Your turn. Write the loop: at each of the B rounds, draw a fresh resample, refit the raw model on it, and keep the coefficients in row b of boot.
rng = np.random.default_rng(0)
B = 1000
boot = np.zeros((B, X.shape[1]))
# TODO: loop over the B rounds
# TODO: idx, a fresh resample
# TODO: boot[b], the coefficients refit on the resample✍️ Your turn. The bootstrap standard error of a coefficient is the standard deviation of its B estimates. Below you have the code to compute the SE and store it in a pandas Series, all you have to do is decide which axis ?
# TODO: the int axis to be defined as the axis along which to compute the SE
boot_se = pd.Series(boot.std(axis=axis, ddof=1), index=X.columns)
compare = pd.DataFrame({"classical": fit_raw.bse, "HC3": fit_raw_hc.bse,
"bootstrap": boot_se})
print(compare.drop("const").round(3))