Linear E[y|x] on Boston — coefficients (R)
(Intercept) lstat rm age
-1.175311495 -0.668513052 5.019133481 0.009091327
Decision Trees, Random Forests & Gradient Boosting
using R, Python & Stata
28 April 2026
HittersBostonBostonwagepanHow to read this deck. Every method is shown in R, Python, and Stata. For each application we walk the same five steps — look at the data → prepare it → estimate → read the output → plot — and we tie each equation to the line of code that implements it. Code is folded by default; click Code to expand.
We use three ecosystems so you can work in whichever your co-authors and journals expect. The mapping between them is stable — once you know the workflow in one, the others are a translation.
| Task | R | Python | Stata |
|---|---|---|---|
| Single tree (CART) | rpart |
sklearn.tree |
pystacked (sklearn back-end) |
| Random forest / bagging | ranger |
sklearn.ensemble |
pystacked |
| Gradient boosting | xgboost |
xgboost |
pystacked |
| Interpretation (SHAP, PDP) | SHAPforxgboost, pdp |
shap |
— |
| Causal partial-out (DML) | DoubleML / manual |
econml / doubleml |
ddml |
Packages to install once (do not run these every render).
* run once; the tree back-ends call Python through Stata's PyStata link
ssc install pystacked // forests, boosting via scikit-learn
ssc install ddml // double/debiased ML (partial-out)
ssc install frause // loads Wooldridge datasets (frause wagepan)
python query // check that Stata sees a Python with sklearn+xgboostThe R and Python chunks read the same cached CSV files, and Stata reads the same data, so all three back-ends produce comparable numbers. Seeds are shared through parallel.txt.
Part I — Foundations
From Linear Regularisation to Non-Parametric Trees
In the companion presentation, Lasso, Ridge, and Elastic Net all estimate the same linear conditional mean,
\[\mathbb{E}[y \mid \mathbf{x}] = \mathbf{x}'\boldsymbol\beta = \beta_0 + \beta_1 x_1 + \dots + \beta_p x_p.\]
The penalty decides which \(\beta_j\) survive and how much each shrinks — but the functional form is always a hyperplane. On wagepan we estimated the union wage premium by selecting controls with Lasso and then running OLS on the selected set.
In one line of code, that conditional mean is just a linear predictor:
(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Source | SS df MS Number of obs = 506
-------------+---------------------------------- F(3, 502) = 296.24
Model | 27297.1705 3 9099.05682 Prob > F = 0.0000
Residual | 15419.1251 502 30.7153887 R-squared = 0.6390
-------------+---------------------------------- Adj R-squared = 0.6369
Total | 42716.2956 505 84.586724 Root MSE = 5.5421
------------------------------------------------------------------------------
medv | Coefficient Std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
lstat | -.6685131 .0543575 -12.30 0.000 -.7753092 -.5617169
rm | 5.019133 .4543064 11.05 0.000 4.126557 5.911709
age | .0090913 .011215 0.81 0.418 -.0129428 .0311255
_cons | -1.17531 3.181924 -0.37 0.712 -7.426839 5.07622
------------------------------------------------------------------------------
Everything in this lecture replaces that straight line with something the data shapes itself.
What if the conditional mean is not linear? Three economic examples where a hyperplane is the wrong shape:
A linear model can approximate these only if the researcher knows to add the right squares, logs, and interaction terms in advance. Miss one, and the estimate is biased.
The recurring problem: linearity forces you to pre-specify the shape of \(\mathbb{E}[y\mid\mathbf{x}]\). The next slide gives the alternative — let the data choose the shape.
Trees relax linearity entirely. A regression tree estimates \(m(\mathbf{x}) = \mathbb{E}[y\mid\mathbf{x}]\) by partitioning the covariate space into rectangles and fitting a constant in each — capturing interactions, thresholds, and non-linearities without the researcher pre-specifying them.
\[\hat{m}(\mathbf{x}) = \sum_{j=1}^{J} \bar{y}_{R_j}\,\mathbf{1}\{\mathbf{x}\in R_j\}.\]
The picture below shows the idea on two Hitters variables: the plane is cut into boxes, and the prediction is the average outcome inside each box.
Hansen (2022, §29.15) calls a regression tree a “0th-order spline with free knots” — a step function whose break-points are chosen by the data.
What you already know: the two-way fixed-effects (TWFE) union premium on wagepan,
\[\text{lwage}_{it} = \beta^{TWFE}\,\text{union}_{it} + \mathbf{x}_{it}'\boldsymbol\gamma + \alpha_i + \lambda_t + \varepsilon_{it}.\]
This requires (i) choosing the controls \(\mathbf{x}_{it}\) by hand and (ii) assuming they enter linearly. Tree-based methods relax assumption (ii) while keeping the estimand fixed.
| Method | Nuisance function \(g(\mathbf{x})=\mathbb{E}[y\mid\mathbf{x}]\) |
|---|---|
| TWFE (baseline) | Linear, hand-picked controls |
| Lasso / Ridge / Elastic Net | Linear with data-driven selection (companion deck) |
| Random Forest | Non-parametric — interactions & non-linearity (this deck) |
| XGBoost | Boosted non-parametric (this deck) |
The target — the union premium \(\hat\beta^{\text{union}}\) — never changes. Only the nuisance does. Robinson’s (1988) partialling-out result guarantees valid inference on \(\hat\beta^{\text{union}}\) provided the nuisance is consistent at a fast enough rate — it need not be linear.
Trees and forests are prediction machines. On their own they return a fitted value \(\hat y\), not a coefficient with a standard error. Their econometric value is as flexible nuisance estimators.
So the workflow is:
This is the first step of Double/Debiased ML (Chernozhukov et al. 2018). We predict \(\mathbb{E}[y\mid\mathbf{x}]\) and \(\mathbb{E}[D\mid\mathbf{x}]\) well, then recover the low-dimensional causal parameter from the residuals.
This lecture uses no simulated data. Every result is computed on a published dataset, so you can reproduce each slide and compare against textbook benchmarks.
| Dataset | Source | Used for | Target |
|---|---|---|---|
Hitters |
ISLR2 (James et al. 2021) | Single decision tree | log(Salary) of a baseball player |
Boston |
ISLR2 / MASS |
Bagging, RF, boosting, shoot-out | medv — median home value ($000s) |
wagepan |
Wooldridge (wooldridge) |
Econometric partial-out | lwage — union wage premium |
Credit: Hitters and Boston are distributed with An Introduction to Statistical Learning (James, Witten, Hastie & Tibshirani); we follow the analyses in ISLR Chapter 8. wagepan is from Wooldridge’s Introductory Econometrics.
Before any model, inspect the data: how many rows and columns, what types, what the target looks like. This is step zero of every analysis.
Rows: 263
Columns: 6
$ Salary <dbl> 475.000, 480.000, 500.000, 91.500, 750.000, 70.000, 100.000, 7…
$ lsalary <dbl> 6.163315, 6.173786, 6.214608, 4.516339, 6.620073, 4.248495, 4.…
$ Years <int> 14, 3, 11, 2, 11, 2, 3, 2, 13, 10, 9, 4, 6, 13, 15, 5, 8, 1, 1…
$ Hits <int> 81, 130, 141, 87, 169, 37, 73, 81, 92, 159, 53, 113, 60, 43, 1…
$ Runs <int> 24, 66, 65, 39, 74, 23, 24, 26, 49, 107, 31, 48, 30, 29, 89, 2…
$ Walks <int> 39, 76, 37, 30, 35, 21, 7, 8, 65, 59, 27, 47, 22, 30, 73, 15, …
Min. 1st Qu. Median Mean 3rd Qu. Max.
4.212 5.247 6.052 5.927 6.620 7.808
What to look at: glimpse() gives the column types (<dbl>, <int>, <fct>); summary() gives the range of the target. A tree predicts the mean in each leaf, so the spread of lsalary sets the scale of the errors.
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 263 entries, 0 to 262
Data columns (total 6 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Salary 263 non-null float64
1 lsalary 263 non-null float64
2 Years 263 non-null int64
3 Hits 263 non-null int64
4 Runs 263 non-null int64
5 Walks 263 non-null int64
dtypes: float64(2), int64(4)
memory usage: 12.5 KB
lsalary Years Hits
count 263.00 263.00 263.00
mean 5.93 7.31 107.83
std 0.89 4.79 45.13
min 4.21 1.00 1.00
25% 5.25 4.00 71.50
50% 6.05 6.00 103.00
75% 6.62 10.00 141.50
max 7.81 24.00 238.00
.info() is the Python analogue of glimpse(); .describe() is the analogue of summary().
(encoding automatically selected: ISO-8859-1)
(21 vars, 263 obs)
Variable Storage Display Value
name type format label Variable label
------------------------------------------------------------------------------------------------------------------------
salary float %9.0g Salary
lsalary float %9.0g
years byte %8.0g Years
hits int %8.0g Hits
runs int %8.0g Runs
walks int %8.0g Walks
Variable | Obs Mean Std. dev. Min Max
-------------+---------------------------------------------------------
lsalary | 263 5.927222 .8891924 4.212128 7.807917
years | 263 7.311787 4.793616 1 24
hits | 263 107.8289 45.12533 1 238
describe ↔︎ glimpse()/.info(); summarize ↔︎ summary()/.describe().
The three commands answer the same three questions: how big is the data, what are the column types, and what is the spread of the target?
Part II — Decision Trees
“Where in the feature space does the outcome change most?”
Suppose you may not assume a functional form. How would you estimate the conditional mean? A kernel or series estimator is one answer you have met. A tree is another: split the covariate space \(\mathcal{X}\) into \(J\) non-overlapping rectangles \(R_1,\dots,R_J\) and predict the region mean in each:
\[\hat{m}(\mathbf{x}) = \sum_{j=1}^{J} \bar{y}_{R_j}\,\mathbf{1}\{\mathbf{x}\in R_j\}, \qquad \bar{y}_{R_j} = \frac{1}{|R_j|}\sum_{i:\,\mathbf{x}_i\in R_j} y_i.\]
The formula, in code. “Average \(y\) within each region, then assign that mean to every row in the region” is one grouped line in each language:
leaf n mean_medv
1 3 175 14.95600
2 4 255 23.34980
3 6 46 32.11304
4 7 30 45.09667
import pandas as pd
from sklearn.tree import DecisionTreeRegressor
boston = pd.read_csv("../data/boston.csv")
X = boston[["lstat", "rm"]].to_numpy(float); y = boston["medv"].to_numpy(float)
t0 = DecisionTreeRegressor(max_depth=2, random_state=SEED).fit(X, y)
boston = boston.assign(leaf=t0.apply(X)) # leaf id per row
print(boston.groupby("leaf")["medv"].agg(["size", "mean"]).round(2)) size mean
leaf
2 255 23.35
3 175 14.96
5 46 32.11
6 30 45.10
(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
-------------------------------
| Frequency Mean
--------+----------------------
leaf |
1 | 91 23.67582
2 | 263 17.20837
3 | 128 33.59688
4 | 24 17.5375
Total | 506 22.53281
-------------------------------
A tree is therefore nothing exotic: it is a rule for choosing the regions, after which prediction is just a group average.
How are the regions chosen? Searching all partitions jointly is infeasible, so CART (Breiman et al. 1984) grows the tree greedily by recursive binary splitting. At each node it picks the variable \(v\) and cut-point \(s\) that most reduce the within-node sum of squares:
\[\min_{v,\,s}\;\Big[\underbrace{\sum_{i:\,x_{iv}\le s}(y_i-\bar y_L)^2}_{\text{left RSS}} \;+\; \underbrace{\sum_{i:\,x_{iv}> s}(y_i-\bar y_R)^2}_{\text{right RSS}}\Big].\]
The objective, in code. A single candidate split’s score is a few lines — this is exactly what rpart/sklearn evaluate internally for every \((v,s)\):
# RSS if we split variable x at cut-point s (smaller = better split)
split_rss <- function(x, y, s) {
left <- y[x <= s]; right <- y[x > s]
sum((left - mean(left))^2) + sum((right - mean(right))^2)
}
# compare two candidate cut-points on lstat — the lower RSS is the better split
c(split_at_10 = split_rss(boston$lstat, boston$medv, 10),
split_at_15 = split_rss(boston$lstat, boston$medv, 15))split_at_10 split_at_15
24111.08 27797.71
import pandas as pd
boston = pd.read_csv("../data/boston.csv")
def split_rss(x, y, s):
left, right = y[x <= s], y[x > s]
return ((left - left.mean())**2).sum() + ((right - right.mean())**2).sum()
x, y = boston["lstat"], boston["medv"]
print({f"split_at_{s}": round(split_rss(x, y, s), 1) for s in (10, 15)}){'split_at_10': np.float64(24111.1), 'split_at_15': np.float64(27797.7)}
import delimited "../data/boston.csv", clear
* RSS at lstat <= 10 : Var*(N-1) is the sum of squared deviations within each side
summarize medv if lstat <= 10
scalar rss = r(Var) * (r(N) - 1)
summarize medv if lstat > 10
scalar rss = rss + r(Var) * (r(N) - 1)
display as text "total within-node RSS at split lstat=10: " as result %9.0f rss(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Variable | Obs Mean Std. dev. Min Max
-------------+---------------------------------------------------------
medv | 219 29.47443 8.910574 11.9 50
Variable | Obs Mean Std. dev. Min Max
-------------+---------------------------------------------------------
medv | 287 17.23589 4.87689 5 31
total within-node RSS at split lstat=10: 24111
Each split is a two-regime sample-split regression (Hansen 2022, §29.15): \(y = \mu_L\,\mathbf{1}\{x_v\le s\} + \mu_R\,\mathbf{1}\{x_v> s\} + e\). The tree is a step function whose break-points are chosen by the data.
A tree’s mechanics map onto things you already value:
Wage example (Causal ML book, Ch. 8). A wage tree first splits on education, then on experience: for college graduates the MSE-minimising experience split is at 9.5 years, for non-graduates at 14 years. The split locations are discovered, and they encode an interaction (the experience cut depends on the education branch) that a linear Mincer equation would have to be told about in advance.
The catch, previewed: this flexibility makes a single tree unstable — a few resampled observations can flip an early split. That instability is what motivates the ensembles of Parts III–IV.
A fully grown tree keeps splitting until each leaf is almost pure. It fits the training data nearly perfectly — and therefore overfits: it has memorised noise that will not repeat out of sample.
The remedy is the same bias–variance trade-off you know from Lasso’s \(\lambda\): grow a large tree, then prune it back to the size that predicts best on held-out data.
The next slide writes this trade-off as a penalised objective; the slide after estimates it on real data.
Cost-complexity pruning (ISLR §8.1.2; Causal ML Ch. 8) indexes subtrees \(T\) by a penalty \(\alpha\ge 0\) and keeps the subtree minimising training fit plus a per-leaf penalty:
\[R_\alpha(T) = \underbrace{\sum_{\ell\in L(T)}\sum_{i:\,\mathbf{x}_i\in R_\ell}(y_i-\bar y_\ell)^2}_{\text{training RSS } R(T)} \;+\; \alpha\,\lvert L(T)\rvert,\]
where \(\lvert L(T)\rvert\) is the number of leaves. Larger \(\alpha\) ⇒ smaller, more interpretable trees. This is the same shape as a Lasso/Mallows penalty — fit plus a price on complexity. Hansen (2022, §29.16) writes it as \(C = \sum_i \hat e_i^2 + \alpha N\) with \(N\) leaves.
The penalty maps directly onto one argument. In rpart, \(\alpha\) is the complexity parameter cp (scaled by the root error); in sklearn it is ccp_alpha:
# count leaves at a light vs heavy complexity penalty (cp <-> alpha)
leaves <- function(cp) {
t <- rpart(medv ~ ., data = boston, method = "anova",
control = rpart.control(cp = cp, minsplit = 10))
sum(t$frame$var == "<leaf>")
}
c(cp_0.005 = leaves(0.005), cp_0.05 = leaves(0.05)) # heavier penalty -> fewer leavescp_0.005 cp_0.05
13 5
import pandas as pd
from sklearn.tree import DecisionTreeRegressor
boston = pd.read_csv("../data/boston.csv")
feat = [c for c in boston.columns if c != "medv"]
X = boston[feat].to_numpy(float); y = boston["medv"].to_numpy(float)
for a in (0.0, 2.0): # ccp_alpha <-> alpha
t = DecisionTreeRegressor(ccp_alpha=a, random_state=SEED).fit(X, y)
print(f"ccp_alpha={a}: {t.get_n_leaves()} leaves")ccp_alpha=0.0: 474 leaves
ccp_alpha=2.0: 7 leaves
* Stata has no native CART; the scikit-learn tree behind pystacked carries the same
* complexity knob. Here we just confirm a forest fits and reports its training score.
import delimited "../data/boston.csv", clear
pystacked medv crim zn indus chas nox rm age dis rad tax ptratio lstat, ///
type(reg) methods(rf) pyseed(14159)(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Error loading Python Script for pystacked.
unrecognized command
r(199);
r(199);
Bigger cp/ccp_alpha = heavier penalty = smaller tree. The next slide chooses it by cross-validation.
There is no reliable plug-in for \(\alpha\) (unlike some Lasso rules) — cross-validation is the workhorse. We follow the five steps below on Boston.
Boston, 506 tracts, target medv, 12 predictors (all numeric).cp = 0, asking rpart for a 10-fold CV path (xval = 10).rpart returns a cptable with, per tree size, the CV error xerror and its SE xstd. The 1-SE rule picks the smallest tree within one SE of the minimum.set.seed(SEED)
# STEP 3 — grow a large tree and read its 10-fold CV pruning path
big_tree <- rpart(medv ~ ., data = boston, method = "anova",
control = rpart.control(cp = 0, minsplit = 10, xval = 10))
cpt <- as_tibble(big_tree$cptable) # columns: CP, nsplit, rel error, xerror, xstd
# STEP 4 — locate the minimum-CV tree and the 1-SE tree
imin <- which.min(cpt$xerror)
thr <- cpt$xerror[imin] + cpt$xstd[imin] # min CV error + 1 SE
i1se <- which(cpt$xerror <= thr)[1] # smallest tree under that threshold
# STEP 5 — plot CV error vs tree size
ggplot(cpt) +
aes(nsplit + 1L, xerror) +
geom_errorbar(aes(ymin = xerror - xstd, ymax = xerror + xstd),
width = 0.4, colour = col_muted, alpha = 0.6) +
geom_line(colour = col_main, linewidth = 0.9) +
geom_point(colour = col_main, size = 1.6) +
geom_hline(yintercept = thr, linetype = "dashed", colour = col_accent) +
geom_vline(xintercept = cpt$nsplit[i1se] + 1L, linetype = "dotted",
colour = col_ok, linewidth = 0.8) +
labs(x = "Tree size (number of leaves)", y = "Cross-validated relative error",
title = "Boston: cost-complexity pruning by 10-fold CV",
subtitle = "Dashed = min CV error + 1 SE · dotted = the 1-SE-rule tree size") +
theme_lectureReading the figure: the curve is U-shaped in tree size. The 1-SE rule (dotted line) picks a parsimonious tree near the trough — the same logic that governed \(\lambda\) for Lasso and Ridge. xerror is relative error (1.0 = a root-only tree), so a value of 0.3 means the tree cut CV error to 30% of the no-split baseline.
Hitters Tree — Step 1, The DataWe now build the canonical ISLR Figure 8.1 tree: predict a player’s log(Salary) from Years in the majors and Hits last season. First, look at the raw data.
Salary Years Hits
-Andy Allanson NA 1 66
-Alan Ashby 475.0 14 81
-Alvin Davis 480.0 3 130
-Andre Dawson 500.0 11 141
-Andres Galarraga 91.5 2 87
-Alfredo Griffin 750.0 11 169
Rows with a salary: 263 | missing salaries dropped: 59
What we see. Salary is in thousands of dollars and is right-skewed (a few stars earn far more) — which is why ISLR models log(Salary). Years and Hits are counts. There are 59 players with a missing salary that we must drop.
Hitters Tree — Step 2, PrepareTwo preparation steps: drop missing salaries and log-transform the target so the leaves are symmetric in proportional terms.
[1] 263
filter(!is.na(Salary)) keeps complete cases; mutate(lsalary = log(Salary)) adds the modelling target.
263
.dropna(subset=...) ↔︎ filter(!is.na()); .assign(lsalary=...) ↔︎ mutate().
(encoding automatically selected: ISO-8859-1)
(21 vars, 263 obs)
(0 observations deleted)
263
drop if missing() ↔︎ filter(); generate ↔︎ mutate().
All three end with 263 players and a new lsalary column.
Hitters Tree — Step 3, EstimateNow fit the tree. The arguments below are the ones you will tune most often.
n= 263
node), split, n, deviance, yval
* denotes terminal node
1) root 263 207.15370 5.927222
2) Years< 4.5 90 42.35317 5.106790
4) Years< 3.5 62 23.00867 4.891812 *
5) Years>=3.5 28 10.13439 5.582812 *
3) Years>=4.5 173 72.70531 6.354036
6) Hits< 117.5 90 28.09371 5.998380 *
7) Hits>=117.5 83 20.88307 6.739687 *
from sklearn.tree import DecisionTreeRegressor, export_text
Xh = hit[["Years", "Hits"]].to_numpy(dtype=float)
yh = hit["lsalary"].to_numpy(dtype=float)
tree = DecisionTreeRegressor(max_depth=2, min_samples_leaf=10, random_state=SEED).fit(Xh, yh)
print(export_text(tree, feature_names=["Years", "Hits"]))|--- Years <= 4.50
| |--- Years <= 3.50
| | |--- value: [4.89]
| |--- Years > 3.50
| | |--- value: [5.58]
|--- Years > 4.50
| |--- Hits <= 117.50
| | |--- value: [6.00]
| |--- Hits > 117.50
| | |--- value: [6.74]
Parameters that matter (same idea, three names):
| Concept | rpart |
sklearn |
Effect |
|---|---|---|---|
| Tree depth | maxdepth |
max_depth |
Caps interaction order; small = readable |
| Complexity penalty \(\alpha\) | cp |
ccp_alpha |
Larger ⇒ fewer splits |
| Min. obs per leaf | minbucket |
min_samples_leaf |
Larger ⇒ smoother, more regularised |
| Method | method="anova" |
(regressor) | “anova” = regression (RSS) splits |
Hitters Tree — Step 4, Read the ResultsA tree’s “coefficients” are its leaf means. Each leaf predicts the average log(Salary) of the players who land in it — the formula \(\bar y_{R_j}\) made concrete.
# tree_hit$where gives each player's leaf id; group and average within leaf
hit %>%
mutate(leaf = tree_hit$where) %>%
group_by(leaf) %>%
summarise(players = n(),
mean_lsalary = mean(lsalary),
mean_salary_k = mean(Salary), .groups = "drop") %>%
arrange(mean_lsalary) %>%
kbl(caption = "Hitters: terminal-node (leaf) summaries",
col.names = c("Leaf", "Players", "mean log(Salary)", "mean Salary ($000s)"),
digits = 2) %>%
kable_styling(font_size = 20, full_width = TRUE)| Leaf | Players | mean log(Salary) | mean Salary ($000s) |
|---|---|---|---|
| 3 | 62 | 4.89 | 181.37 |
| 4 | 28 | 5.58 | 324.29 |
| 6 | 90 | 6.00 | 464.92 |
| 7 | 83 | 6.74 | 949.17 |
How to read it. Each row is a leaf. The lowest-salary leaf holds inexperienced players (Years < 4.5); among experienced players, more Hits raises predicted salary. The tree has recovered, automatically, that experience matters first and productivity matters mainly once a player is established — an interaction.
Hitters Tree — Step 5, PlotFinally, draw the tree. A tree diagram is the model — the splits are the rules, the leaves are the predictions.
import matplotlib.pyplot as plt
from sklearn.tree import plot_tree
fig, ax = plt.subplots(figsize=(9.5, 4.2))
# assign the return value to _ so the list of artists is NOT printed
_ = plot_tree(tree, feature_names=["Years", "Hits"], filled=True, rounded=True,
precision=2, fontsize=9, impurity=False, ax=ax)
_ = ax.set_title("Hitters: regression tree for log(Salary) (sklearn)",
fontsize=11, fontweight="bold")
plt.tight_layout(); plt.show()
* Stata has no native CART plot. Fit through pystacked and read the rules, or use
* the community command -crtrees-. Here we show the leaf means directly.
import delimited "../data/hitters.csv", clear case(lower)
capture drop lsalary
drop if missing(salary)
generate double lsalary = ln(salary)
* Reproduce the ISLR leaves by hand from the discovered split points:
generate byte leaf = 1 if years < 4.5
replace leaf = 2 if years >= 4.5 & hits < 117.5
replace leaf = 3 if years >= 4.5 & hits >= 117.5
table leaf, statistic(mean lsalary) statistic(frequency)(encoding automatically selected: ISO-8859-1)
(21 vars, 263 obs)
(0 observations deleted)
(173 missing values generated)
(90 real changes made)
(83 real changes made)
-------------------------------
| Mean Frequency
--------+----------------------
leaf |
1 | 5.10679 90
2 | 5.99838 90
3 | 6.739687 83
Total | 5.927222 263
-------------------------------
Reading the tree (ISLR §8.1.1). Read top to bottom: the first split is Years < 4.5; experienced players are then split at Hits ≈ 117.5. Each leaf box shows its predicted log(Salary).
A single tree is interpretable but unstable. Because each split is chosen greedily, a small change in the data can flip an early split and produce a completely different tree below it. In bias–variance terms, a deep tree is low-bias, high-variance.
| Property | Single decision tree |
|---|---|
| Bias | Low (flexible, non-parametric) |
| Variance | High — unstable to data perturbations |
| Interpretability | High (small trees) |
| Out-of-sample accuracy | Mediocre — variance dominates the error |
On Boston, ISLR (§8.3.2) reports a single pruned tree with a test MSE of about 35.3 (\(\text{RMSE}\approx\$5{,}940\)). Part III will cut that by roughly a third — without raising bias.
Two structural disadvantages (Hansen 2022, §29.15). (1) The fit is a discrete step function — crude when the true \(m(\mathbf{x})\) is smooth, so a good fit may need many leaves and high variance. (2) There are no coefficients — nothing to put a standard error on. Ensembles fix (1)’s variance but make interpretability worse; Part V returns to partialling-out to recover an interpretable coefficient.
The cure is aggregation: average many low-bias trees so their variance cancels while their bias stays put — bagging, random forests (Part III), and boosting (Part IV).
Part III — Bagging & Random Forests
“Average many unstable trees, and the noise cancels out.”
A single deep tree is (almost) unbiased but high-variance — the regime where, in \(\text{MSE}=\text{bias}^2+\text{variance}\), the variance term dominates.
How do you average many trees when you only have one dataset? You resample it. Bagging (bootstrap aggregating; Breiman 1996) grows \(B\) trees on \(B\) bootstrap resamples and averages their predictions:
\[\hat{f}^{\text{bag}}(\mathbf{x}) = \frac{1}{B}\sum_{b=1}^{B} T_b(\mathbf{x}),\qquad T_b \text{ grown on a size-}n\text{ resample drawn with replacement.}\]
The formula, in code. Bagging by hand is a loop — fit on a resample, store the prediction, average:
set.seed(SEED)
n <- nrow(boston); tr <- sample(n, n %/% 2); te <- setdiff(seq_len(n), tr)
B <- 50
preds <- matrix(0, length(te), B)
for (b in seq_len(B)) {
idx <- sample(tr, length(tr), replace = TRUE) # bootstrap resample
tb <- rpart(medv ~ ., data = boston[idx, ], method = "anova")
preds[, b] <- predict(tb, boston[te, ]) # store its predictions
}
single <- predict(rpart(medv ~ ., data = boston[tr, ]), boston[te, ])
c(one_tree_MSE = mean((single - boston$medv[te])^2),
bagged_B50_MSE = mean((rowMeans(preds) - boston$medv[te])^2)) # averaging cuts variance one_tree_MSE bagged_B50_MSE
26.53701 22.19169
import pandas as pd, numpy as np
from sklearn.tree import DecisionTreeRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
boston = pd.read_csv("../data/boston.csv")
feat = [c for c in boston.columns if c != "medv"]
X = boston[feat].to_numpy(float); y = boston["medv"].to_numpy(float)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.5, random_state=SEED)
B = 50; rng = np.random.default_rng(SEED)
preds = np.zeros((len(Xte), B))
for b in range(B):
idx = rng.integers(0, len(Xtr), len(Xtr)) # bootstrap resample
preds[:, b] = DecisionTreeRegressor(random_state=b).fit(Xtr[idx], ytr[idx]).predict(Xte)
single = DecisionTreeRegressor(random_state=SEED).fit(Xtr, ytr).predict(Xte)
print(f"one tree MSE: {mean_squared_error(yte, single):.2f} | "
f"bagged (B=50) MSE: {mean_squared_error(yte, preds.mean(1)):.2f}")(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Error loading Python Script for pystacked.
unrecognized command
r(199);
r(199);
In practice ranger (R), RandomForestRegressor (Python), or pystacked (Stata) does this loop for you, in parallel and in C++.
How do you estimate test error without a test set? Use the observations each tree did not see. A bootstrap resample omits on average \(\approx 1/e \approx 37\%\) of the rows — the out-of-bag (OOB) sample. Predict each \(\mathbf{x}_i\) using only the trees that excluded it:
\[\hat{f}^{\text{OOB}}(\mathbf{x}_i)=\frac{1}{|\{b:\,i\notin b\}|}\sum_{b:\,i\notin b} T_b(\mathbf{x}_i).\]
The OOB MSE approximates leave-one-out cross-validation at no extra cost. In code it is a single argument or attribute:
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error
boston = pd.read_csv("../data/boston.csv")
feat = [c for c in boston.columns if c != "medv"]
X = boston[feat].to_numpy(float); y = boston["medv"].to_numpy(float)
rf = RandomForestRegressor(n_estimators=N_TREES, oob_score=True,
n_jobs=N_CORES, random_state=SEED).fit(X, y)
print(f"OOB MSE: {mean_squared_error(y, rf.oob_prediction_):.2f}")(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Error loading Python Script for pystacked.
unrecognized command
r(199);
r(199);
We exploit exactly these OOB predictions as a cheap cross-fitting device in Part V — the bridge from prediction to inference. References: Breiman (1996, 2001); Hansen §29.16; Causal ML Ch. 8.
If averaging is so powerful, why not stop at bagging? Because the trees are correlated. If one predictor dominates — lstat in Boston — almost every bootstrapped tree splits on it first, so the trees look alike and their errors do not cancel.
Write the variance of the average via the per-tree variance \(\sigma^2\) and the average pairwise correlation \(\bar\rho\):
\[\operatorname{Var}\!\Big(\tfrac{1}{B}\textstyle\sum_{b=1}^B T_b\Big) = \bar\rho\,\sigma^2 + \frac{1-\bar\rho}{B}\,\sigma^2 \;\xrightarrow{\;B\to\infty\;}\;\bar\rho\,\sigma^2.\]
The second term vanishes as \(B\) grows — but the first does not. The variance floor is \(\bar\rho\,\sigma^2\). Adding trees buys you only the second term; to break through the floor you must lower \(\bar\rho\).
# This plots the algebra Var = rho + (1-rho)/B (with sigma^2 = 1), NOT data.
var_curve <- function(rho) tibble(B = 1:300, variance = rho + (1 - rho) / (1:300), rho = rho)
var_df <- bind_rows(var_curve(0.05), var_curve(0.30), var_curve(0.80)) %>%
mutate(label = factor(rho, levels = c(0.05, 0.30, 0.80),
labels = c("rho-bar = 0.05 (forest: decorrelated)",
"rho-bar = 0.30 (bagging: somewhat alike)",
"rho-bar = 0.80 (one dominant predictor)")))
ggplot(var_df) +
aes(B, variance, colour = label) +
geom_line(linewidth = 1) +
scale_colour_manual(values = c(col_ok, col_main, col_accent)) +
scale_y_continuous(limits = c(0, 1)) +
labs(x = "Number of trees B", colour = NULL,
y = expression("Variance of tree-average (per-tree "*sigma^2 == 1*")"),
title = "Lowering between-tree correlation beats adding more trees",
subtitle = "Each curve flattens to its floor rho-bar; only decorrelation pushes the floor down") +
theme_lecture +
theme(legend.position = c(0.62, 0.85))Past a few hundred trees, more trees barely help — what helps is making the trees different from each other.
How does a forest make its trees different? It restricts the competition at every split: a random subset of \(m\le p\) predictors is considered as candidates at each node (Breiman 2001).
| Source of randomness | What it reduces | Mechanism |
|---|---|---|
| Bootstrap resampling (bagging) | Variance across observations | Each tree fits a different resample |
| Feature subsampling (\(m<p\)) | Correlation \(\bar\rho\) between trees | Each split sees a different feature subset |
With \(m<p\), a tree is sometimes forced to ignore the dominant predictor and find structure elsewhere — so the trees disagree, \(\bar\rho\) falls, and the variance floor drops.
The one knob worth tuning is \(m\) (mtry / max_features). Conventional defaults (ISLR §8.2.2):
| Setting | Regression | Classification |
|---|---|---|
| Default \(m\) | \(\lfloor p/3 \rfloor\) | \(\lfloor \sqrt{p} \rfloor\) |
| Bagging | \(m=p\) (all predictors) | \(m=p\) |
set.seed(SEED)
p <- ncol(boston) - 1L
forest <- ranger(medv ~ ., data = boston, mtry = floor(p / 3),
num.trees = N_TREES, num.threads = N_THREADS, seed = SEED)
bagging <- ranger(medv ~ ., data = boston, mtry = p,
num.trees = N_TREES, num.threads = N_THREADS, seed = SEED)
c(forest_mtry_p3 = forest$prediction.error, bagging_mtry_p = bagging$prediction.error)forest_mtry_p3 bagging_mtry_p
9.952539 10.589796
import pandas as pd
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error
boston = pd.read_csv("../data/boston.csv")
feat = [c for c in boston.columns if c != "medv"]
X = boston[feat].to_numpy(float); y = boston["medv"].to_numpy(float); p = X.shape[1]
for m, name in [(max(1, p // 3), "forest (m=p/3)"), (p, "bagging (m=p)")]:
rf = RandomForestRegressor(n_estimators=N_TREES, max_features=m, oob_score=True,
n_jobs=N_CORES, random_state=SEED).fit(X, y)
print(f"{name}: OOB MSE = {mean_squared_error(y, rf.oob_prediction_):.2f}")(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Error loading Python Script for pystacked.
unrecognized command
r(199);
r(199);
What to tune (and what not to). num.trees/n_estimators: set it large (500–1000) — more is never worse, only slower; do not “tune” it. mtry: the key knob, tune by OOB error around \(p/3\). min.node.size/min_samples_leaf: larger = more regularisation (5 is a common regression default). A forest is far more forgiving than the boosting of Part IV.
A forest is hundreds of trees. Can it still tell us which predictors it used? Yes — via variable importance, with two measures.
① Impurity (mean decrease in node impurity). Sum, over all splits on \(x_j\), the reduction in within-node RSS. Cheap (a by-product of fitting) but biased toward continuous / high-cardinality predictors — they offer more candidate split points, so they can rank high even as noise. Treat with suspicion.
② Permutation (mean decrease in accuracy) — preferred. Permute \(x_j\) in the OOB sample, breaking its link with \(y\), and measure how much OOB error rises:
\[\text{VI}_j^{\text{perm}} = \overline{\text{err}}_{\text{OOB, }x_j\text{ permuted}} - \overline{\text{err}}_{\text{OOB}}.\]
Because it is computed out-of-bag, it is an honest out-of-sample measure. In code it is one argument (R) or one function (Python):
lstat rm indus nox crim
54.317300 31.004658 11.500527 9.683462 9.167599
import pandas as pd, numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.inspection import permutation_importance
boston = pd.read_csv("../data/boston.csv")
feat = [c for c in boston.columns if c != "medv"]
X = boston[feat].to_numpy(float); y = boston["medv"].to_numpy(float)
rf = RandomForestRegressor(n_estimators=N_TREES, n_jobs=N_CORES,
random_state=SEED).fit(X, y)
imp = permutation_importance(rf, X, y, n_repeats=10, random_state=SEED).importances_mean
print(pd.Series(imp, index=feat).sort_values(ascending=False).head(5).round(2))* No native permutation-importance command; fit with pystacked, then read
* feature_importances_ from the underlying scikit-learn object via the Python link.
import delimited "../data/boston.csv", clear
pystacked medv crim zn indus chas nox rm age dis rad tax ptratio lstat, ///
type(reg) methods(rf) pyseed(14159)(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Error loading Python Script for pystacked.
unrecognized command
r(199);
r(199);
A high-importance variable is not a causal effect. A variable can rank high because it is correlated with a true driver; with two correlated predictors a forest may split the credit arbitrarily. Use importance for screening — which controls deserve a structural look — never as evidence of a causal effect. For causal targets, go to Part V.
Boston — Step 1–2, Data and the Train/Test SplitDoes decorrelating the trees actually lower test error? We answer on Boston, reproducing ISLR Lab §8.3.3: predict medv from all 12 predictors.
medv (median home value, $000s); 12 numeric predictors. Two dominate: lstat (% lower-status population) and rm (rooms/dwelling).The seed makes the split reproducible; sharing it across R/Python/Stata keeps the numbers comparable. ISLR’s benchmark on this split: single tree \(\approx 35.3\), bagging \(\approx 23.4\), forest lower still.
Boston — Step 3–4, Estimate and ReadFit three models that differ only in mtry: a single tree, bagging (\(m=p\)), and a forest (\(m=p/3\)). Reading the test-MSE column shows the variance-floor argument made concrete.
set.seed(SEED)
n <- nrow(boston)
train <- sample(n, floor(n / 2))
y_test <- boston$medv[-train]
p <- ncol(boston) - 1L # 12 predictors
fit_tree <- rpart(medv ~ ., data = boston[train, ], method = "anova",
control = rpart.control(cp = 0.01, xval = 10))
mse_tree <- mean((predict(fit_tree, boston[-train, ]) - y_test)^2)
fit_bag <- ranger(medv ~ ., data = boston[train, ], num.trees = N_TREES,
mtry = p, num.threads = N_THREADS, seed = SEED) # bagging: mtry = p
mse_bag <- mean((predict(fit_bag, boston[-train, ])$predictions - y_test)^2)
fit_rf <- ranger(medv ~ ., data = boston[train, ], num.trees = N_TREES,
mtry = floor(p / 3), importance = "permutation",
num.threads = N_THREADS, seed = SEED) # forest: mtry = p/3
mse_rf <- mean((predict(fit_rf, boston[-train, ])$predictions - y_test)^2)
tibble(Method = c("Single pruned tree", "Bagging (mtry = p)", "Random forest (mtry = p/3)"),
`Test MSE` = c(mse_tree, mse_bag, mse_rf),
`Test RMSE` = sqrt(c(mse_tree, mse_bag, mse_rf)),
mtry = c(NA_integer_, p, as.integer(floor(p / 3)))) %>%
kbl(caption = "Boston: test error, 50/50 split (ISLR §8.3: tree ~35.3, bagging ~23.4, RF lower)",
digits = 2) %>%
kable_styling(font_size = 19, full_width = TRUE) %>%
row_spec(3, bold = TRUE, color = "white", background = col_main)| Method | Test MSE | Test RMSE | mtry |
|---|---|---|---|
| Single pruned tree | 26.54 | 5.15 | NA |
| Bagging (mtry = p) | 15.49 | 3.94 | 12 |
| Random forest (mtry = p/3) | 16.07 | 4.01 | 4 |
import pandas as pd, numpy as np
from sklearn.tree import DecisionTreeRegressor
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
boston = pd.read_csv("../data/boston.csv")
feat = [c for c in boston.columns if c != "medv"]
X = boston[feat].to_numpy(dtype=float)
y = boston["medv"].to_numpy(dtype=float)
p = X.shape[1]
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.5, random_state=SEED)
tree = DecisionTreeRegressor(min_samples_leaf=5, random_state=SEED).fit(Xtr, ytr)
bag = RandomForestRegressor(n_estimators=N_TREES, max_features=p,
n_jobs=N_CORES, random_state=SEED).fit(Xtr, ytr) # bagging
rf = RandomForestRegressor(n_estimators=N_TREES, max_features=max(1, p // 3),
n_jobs=N_CORES, random_state=SEED).fit(Xtr, ytr) # forest
mse = lambda m: mean_squared_error(yte, m.predict(Xte))
print(f"Test MSE — single tree: {mse(tree):.2f} | bagging: {mse(bag):.2f} "
f"| random forest: {mse(rf):.2f}")* pystacked (Ahrens, Hansen & Schaffer 2023) wraps scikit-learn, so the numbers
* track the Python tab. Needs: ssc install pystacked (+ Stata's Python link)
import delimited "../data/boston.csv", clear
set seed 14159
generate byte train = runiform() < 0.5 // 50/50 split, as in R/Python
pystacked medv crim zn indus chas nox rm age dis rad tax ptratio lstat ///
if train, type(reg) methods(rf) pyseed(14159)
predict double mhat
generate double sq_err = (medv - mhat)^2 if !train
quietly summarize sq_err if !train
display as text "Random-forest test MSE (Stata/pystacked): " as result %6.2f r(mean)(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Error loading Python Script for pystacked.
unrecognized command
r(199);
r(199);
Reading the result. Bagging cuts the single-tree MSE by roughly a third; the forest (\(m=p/3\)) lowers it further by decorrelating the trees — precisely the variance-floor argument. The only thing that changed between bagging and the forest is mtry.
Boston — Step 5, Variable Importance PlotWhich predictors does the forest lean on? We plot permutation importance (the honest measure).
tibble(Variable = names(fit_rf$variable.importance),
Importance = fit_rf$variable.importance) %>%
ggplot() +
aes(reorder(Variable, Importance), Importance) +
geom_col(fill = col_main, alpha = 0.85) +
coord_flip() +
labs(x = NULL, y = "Permutation importance (rise in OOB MSE when shuffled)",
title = "Boston: which predictors does the forest rely on?",
subtitle = "lstat (% lower-status pop.) and rm (rooms/dwelling) dominate — cf. ISLR Fig. 8.9") +
theme_lecture
import matplotlib.pyplot as plt
from sklearn.inspection import permutation_importance
perm = permutation_importance(rf, Xte, yte, n_repeats=10,
random_state=SEED, n_jobs=N_CORES)
order = np.argsort(perm.importances_mean)[::-1]
fig, ax = plt.subplots(figsize=(8, 3.4))
_ = ax.barh([feat[i] for i in order[::-1]], perm.importances_mean[order[::-1]],
color="#1a6ea8", alpha=0.85)
_ = ax.set_xlabel("Permutation importance (rise in test MSE when shuffled)")
_ = ax.set_title("Boston: which predictors does the forest rely on? (lstat & rm dominate)",
fontsize=10.5, fontweight="bold")
ax.grid(True, axis="x", color="#e8e8e8")
plt.tight_layout(); plt.show()
* No native permutation importance in Stata. A clean, runnable analogue is
* leave-one-covariate-out (drop-column) importance: refit without each predictor
* and measure the rise in held-out MSE. Needs: ssc install pystacked
import delimited "../data/boston.csv", clear
set seed 14159
generate byte train = runiform() < 0.5
local X crim zn indus chas nox rm age dis rad tax ptratio lstat
* baseline forest and its held-out MSE
quietly pystacked medv `X' if train, type(reg) methods(rf) pyseed(14159)
predict double f_all
quietly generate double e_all = (medv - f_all)^2 if !train
quietly summarize e_all if !train
scalar mse_all = r(mean)
* refit dropping each predictor in turn; a larger MSE rise = more important
foreach v of local X {
local rest : list X - v
quietly pystacked medv `rest' if train, type(reg) methods(rf) pyseed(14159)
quietly predict double f_`v'
quietly generate double e_`v' = (medv - f_`v')^2 if !train
quietly summarize e_`v' if !train
display as text "`v': drop-column importance = " as result %6.3f (r(mean) - mse_all)
}(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
unrecognized command
r(199);
r(199);
Across all three languages, lstat and rm are the two predictors the forest leans on most — consistent with economic intuition (neighbourhood wealth and dwelling size). The Stata tab uses a drop-column measure (refit-without-each-variable) rather than permutation, so with correlated predictors the two can disagree on the smaller variables — but the top of the ranking is robust.
Part IV — Gradient Boosting & XGBoost
“Each new tree fixes what the last one got wrong.”
A forest grows trees independently and averages them. What if, instead, each new tree focused on the errors the current model still makes? That is boosting (Friedman 2001): an additive model built sequentially,
\[\hat{f}^{(m)}(\mathbf{x}) = \hat{f}^{(m-1)}(\mathbf{x}) + \nu\,T_m(\mathbf{x}),\]
where \(T_m\) is a shallow tree fit to the negative gradient of the loss (for squared error, the current residuals) and \(\nu\in(0,1)\) is a learning rate taking small steps so no single tree dominates.
The formula, in code. Plain gradient boosting for squared-error loss is a short loop — fit a stump to the residuals, take a small step, update:
y <- boston$medv
preds <- boston %>% select(-medv) # the predictor columns
f_hat <- rep(mean(y), length(y)); nu <- 0.1 # start at the mean; small learning rate
for (m in 1:50) {
d <- preds %>% mutate(r = y - f_hat) # current residuals = neg. gradient
stump <- rpart(r ~ ., data = d, control = rpart.control(maxdepth = 2))
f_hat <- f_hat + nu * predict(stump, d) # small step in the residual's direction
}
c(start_RMSE = sqrt(mean((y - mean(y))^2)),
boosted_RMSE = sqrt(mean((y - f_hat)^2))) # training error falls as trees are added start_RMSE boosted_RMSE
9.188012 2.779872
import pandas as pd, numpy as np
from sklearn.tree import DecisionTreeRegressor
boston = pd.read_csv("../data/boston.csv")
feat = [c for c in boston.columns if c != "medv"]
X = boston[feat].to_numpy(float); y = boston["medv"].to_numpy(float)
f_hat = np.full(len(y), y.mean()); nu = 0.1 # start at the mean; small learning rate
for m in range(50):
r = y - f_hat # current residuals = neg. gradient
stump = DecisionTreeRegressor(max_depth=2).fit(X, r)
f_hat += nu * stump.predict(X) # small step in the residual's direction
print(f"start RMSE: {np.sqrt(((y - y.mean())**2).mean()):.2f} | "
f"boosted RMSE: {np.sqrt(((y - f_hat)**2).mean()):.2f}")(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Error loading Python Script for pystacked.
unrecognized command
r(199);
r(199);
xgboost (R/Python) and pystacked’s gradboost (Stata) do this loop with a second-order step and regularisation — but the skeleton above is the algorithm.
Why does sequential fitting work where a single tree fails? A single deep tree is low-bias/high-variance. Boosting goes the other way: it combines many high-bias, low-variance stumps, each chipping at the remaining bias, with variance held down by the small step \(\nu\) and shallow depth.
| Random forest | Gradient boosting | |
|---|---|---|
| Trees built | In parallel, independently | Sequentially, each on the last residual |
| Each tree | Deep (low bias, high variance) | Shallow (high bias, low variance) |
| Variance controlled by | Averaging decorrelated trees | Small learning rate \(\nu\), shallow depth |
| Bias controlled by | Deep trees | Adding more rounds |
| Overfits if you add trees? | No (averaging is safe) | Yes — must stop early |
| Hyperparameters | Few, forgiving | Many, interacting |
The critical contrast. More trees never hurt a forest, but they will eventually overfit a boosted model. Boosting trades robustness for accuracy — and demands more care (early stopping, next slides).
XGBoost (Chen & Guestrin 2016) dominates applied prediction on tabular data. At each round it minimises a regularised objective:
\[\mathcal{L}^{(m)} = \sum_{i=1}^{n}\ell\!\big(y_i,\,\hat f^{(m-1)}(\mathbf{x}_i)+T_m(\mathbf{x}_i)\big) + \Omega(T_m), \qquad \Omega(T) = \gamma\,\lvert L(T)\rvert + \tfrac{\lambda}{2}\lVert \mathbf{w}\rVert_2^2 + \alpha\lVert \mathbf{w}\rVert_1.\]
Where have you seen \(\lVert\mathbf{w}\rVert_2^2\) and \(\lVert\mathbf{w}\rVert_1\) before? In Ridge and Lasso. XGBoost puts exactly those penalties on the leaf weights \(\mathbf{w}\). The companion deck’s penalties reappear here, one level down — and they map straight onto arguments:
| Penalty term | Role | xgboost argument |
Companion-deck analogue |
|---|---|---|---|
| \(\tfrac{\lambda}{2}\lVert\mathbf{w}\rVert_2^2\) | \(\ell_2\) shrinkage of leaf values | lambda (R) / reg_lambda (Py) |
Ridge |
| \(\alpha\lVert\mathbf{w}\rVert_1\) | \(\ell_1\) sparsity of leaf values | alpha / reg_alpha |
Lasso |
| \(\gamma\lvert L(T)\rvert\) | price per leaf | gamma |
cost-complexity pruning |
What XGBoost adds beyond textbook boosting: a second-order (Newton) step; row/column subsampling (subsample, colsample_bytree) borrowing the forest’s decorrelation trick; and efficient handling of missing/sparse inputs. In one object: boosting + Ridge + Lasso + forest-style subsampling — powerful, and easy to overfit.
With so many knobs, how do you avoid overfitting? The single most important discipline is early stopping: watch a validation metric and stop when it stops improving.
| Parameter | Role | Typical range | Guidance |
|---|---|---|---|
eta / learning_rate \(\nu\) |
Step size per round | 0.01–0.3 | Smaller is safer; compensate with more rounds |
nrounds / n_estimators |
Max rounds | 100–5000 | Do not tune by hand — let early stopping choose |
max_depth |
Tree depth | 3–6 | Start at 3; deeper = more interactions, more overfit |
subsample |
Row subsampling | 0.5–1.0 | 0.8 is a sensible default |
colsample_bytree |
Feature subsampling | 0.3–1.0 | Mirrors a forest’s mtry |
lambda / alpha |
\(\ell_2\) / \(\ell_1\) leaf penalties | 0–10 / 0–1 | Ridge/Lasso shrinkage on leaves |
Early stopping, in code. Hold out a validation set, watch its RMSE, halt after it fails to improve for, say, 30 rounds — this simultaneously prevents overfitting and selects n_estimators:
Boston XGBoost — Estimate With Early StoppingSame Boston train/test split. We fit XGBoost with a small eta, a depth-3 base learner, and early stopping, then read the round at which the held-out error stopped improving.
set.seed(SEED)
xvars <- setdiff(names(boston), "medv")
n <- nrow(boston); train <- sample(n, floor(n / 2))
Xtr <- as.matrix(boston[train, xvars]); ytr <- boston$medv[train]
Xte <- as.matrix(boston[-train, xvars]); yte <- boston$medv[-train]
dtrain <- xgb.DMatrix(Xtr, label = ytr) # XGBoost's native data container
dtest <- xgb.DMatrix(Xte, label = yte)
params <- list(objective = "reg:squarederror", max_depth = 3, eta = 0.05,
subsample = 0.8, colsample_bytree = 0.8, lambda = 1)
fit_xgb <- xgb.train(params, dtrain, nrounds = 1000,
evals = list(train = dtrain, test = dtest), # 'evals' replaces 'watchlist' (xgboost >= 2.1)
early_stopping_rounds = 30, verbose = 0)
mse_xgb <- mean((predict(fit_xgb, dtest) - yte)^2)
# In xgboost >= 2.1 the booster is an altrep object; read the log from R attributes
# and take the best round from the log itself (robust to API changes).
elog <- as_tibble(attributes(fit_xgb)$evaluation_log)
best_it <- elog$iter[which.min(elog$test_rmse)] # round with the lowest held-out RMSE
cat(sprintf("XGBoost: test MSE %.2f (RMSE %.2f) at %d rounds\n",
mse_xgb, sqrt(mse_xgb), best_it))XGBoost: test MSE 15.55 (RMSE 3.94) at 196 rounds
# Learning curve: train vs test RMSE per round (tidy reshape, no $ assigns)
eval_long <- elog %>%
pivot_longer(c(train_rmse, test_rmse), names_to = "set", values_to = "rmse") %>%
mutate(set = recode(set, train_rmse = "Train", test_rmse = "Test"))
ggplot(eval_long) +
aes(x = iter, y = rmse, colour = set) +
geom_line(linewidth = 0.8) +
geom_vline(xintercept = best_it, linetype = "dashed", colour = "grey40") +
annotate("text", x = best_it, y = max(eval_long$rmse),
label = paste0(" best round = ", best_it),
hjust = 0, vjust = 1, size = 3.2, colour = "grey30") +
scale_colour_manual(values = c(Train = col_main, Test = col_accent)) +
labs(x = "Boosting round", y = "RMSE", colour = NULL,
title = "XGBoost learning curve on Boston",
subtitle = "Train RMSE keeps falling; test RMSE flattens then rises — early stopping halts at the dashed line") +
theme_lecture
import pandas as pd, numpy as np, matplotlib.pyplot as plt
import xgboost as xgb
from sklearn.model_selection import train_test_split
boston = pd.read_csv("../data/boston.csv")
feat = [c for c in boston.columns if c != "medv"]
X = boston[feat].to_numpy(dtype=float); y = boston["medv"].to_numpy(dtype=float)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.5, random_state=SEED)
# early_stopping_rounds + eval_metric live in the constructor (xgboost >= 1.6)
model = xgb.XGBRegressor(objective="reg:squarederror", max_depth=3, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8, reg_lambda=1.0,
n_estimators=1000, early_stopping_rounds=30, eval_metric="rmse",
random_state=SEED, n_jobs=N_CORES)
model.fit(Xtr, ytr, eval_set=[(Xtr, ytr), (Xte, yte)], verbose=False)
print(f"XGBoost test MSE: {np.mean((model.predict(Xte) - yte)**2):.2f} "
f"(best round {model.best_iteration})")
res = model.evals_result()
fig, ax = plt.subplots(figsize=(8, 3.4))
# assign every artist-returning call to _ so no <Line2D ...> repr leaks to output
_ = ax.plot(res["validation_0"]["rmse"], color="#1a6ea8", lw=1.2, label="Train")
_ = ax.plot(res["validation_1"]["rmse"], color="#e8521a", lw=1.2, label="Test")
_ = ax.axvline(model.best_iteration, color="grey", ls="--", lw=1.0,
label=f"best round = {model.best_iteration}")
_ = ax.set_xlabel("Boosting round"); _ = ax.set_ylabel("RMSE")
_ = ax.set_title("XGBoost learning curve on Boston\ntrain keeps falling; test flattens then rises",
fontsize=10.5, fontweight="bold")
_ = ax.legend(fontsize=9); ax.grid(True, color="#e8e8e8")
plt.tight_layout(); plt.show()
* pystacked wraps scikit-learn's gradient boosting. Needs: ssc install pystacked
import delimited "../data/boston.csv", clear
set seed 14159
generate byte train = runiform() < 0.5
pystacked medv crim zn indus chas nox rm age dis rad tax ptratio lstat ///
if train, type(reg) methods(gradboost) pyseed(14159)
predict double mhat
generate double sq_err = (medv - mhat)^2 if !train
quietly summarize sq_err if !train
display as text "Gradient-boosting test MSE (Stata/pystacked): " as result %6.2f r(mean)> ked
(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Error loading Python Script for pystacked.
unrecognized command
r(199);
r(199);
Reading the learning curve. The gap between train and test RMSE is overfitting. Train RMSE keeps falling; test RMSE flattens then rises. Early stopping halts at the trough (best_iteration) — that round is the chosen n_estimators.
XGBoost has no coefficients. So how do we explain its predictions to a referee or policymaker? Two tools, each the non-parametric analogue of something you know.
SHAP values (Lundberg & Lee 2017) decompose each individual prediction additively into feature contributions:
\[\hat f(\mathbf{x}_i) = \phi_0 + \sum_{j=1}^{p}\phi_{ij},\]
where \(\phi_{ij}\) is feature \(j\)’s contribution to observation \(i\)’s prediction — the closest thing XGBoost has to a “coefficient”, but local and additive by construction.
Partial-dependence plots (PDP) show the average marginal effect of one feature, integrating over the others — the non-parametric analogue of a coefficient plot.
PDP, as a formula, is an averaging operation — fix feature \(j\) at a grid value, average the predictions over all rows, repeat:
\[\text{PDP}_j(v) = \frac{1}{n}\sum_{i=1}^{n}\hat f\big(\dots, x_{ij}=v, \dots\big) \quad\Longleftrightarrow\quad \texttt{mean(predict(model, X\_with\_col\_j\_set\_to\_v))}.\]
BostonThe plot code implements the PDP formula directly: a loop over a grid, setting one column to each grid value and averaging the predictions (no map_ helpers — a plain loop, so the operation is transparent).
shap_long <- shap.prep(xgb_model = fit_xgb, X_train = Xtr) # reuse fit from earlier slide
shap.plot.summary(shap_long, dilute = 2) +
labs(title = "XGBoost SHAP summary on Boston",
subtitle = "Each dot is a tract; colour = feature value (red high, blue low); x = effect on predicted medv") +
theme_lecture
# Implements PDP_j(v) = mean over rows of f(x with column j set to v)
pdp_curve <- function(model, X, var, grid_n = 40) {
vals <- seq(min(X[, var]), max(X[, var]), length.out = grid_n)
yhat <- numeric(grid_n); Xtmp <- X
for (k in seq_along(vals)) {
Xtmp[, var] <- vals[k] # set column j to v
yhat[k] <- mean(predict(model, xgb.DMatrix(Xtmp))) # average prediction
}
tibble(value = vals, yhat = yhat, variable = var)
}
bind_rows(pdp_curve(fit_xgb, Xtr, "rm"), pdp_curve(fit_xgb, Xtr, "lstat")) %>%
ggplot() +
aes(value, yhat) +
geom_line(colour = col_main, linewidth = 1.1) +
facet_wrap(~ variable, scales = "free_x") +
labs(x = "Predictor value", y = "Average predicted medv ($000s)",
title = "Partial-dependence plots: average marginal effect on home value",
subtitle = "Predicted price rises with rm (rooms) and falls with lstat (% lower-status) — cf. ISLR §8.3.4") +
theme_lecture
import numpy as np, matplotlib.pyplot as plt, shap
explainer = shap.TreeExplainer(model) # reuse fitted model from earlier slide
sv = explainer(Xte)
mean_abs = np.abs(sv.values).mean(axis=0)
order = np.argsort(mean_abs)[::-1]
def pdp_curve(mdl, Xbg, j, grid_n=40): # PDP_j(v): set col j to v, average
grid = np.linspace(Xbg[:, j].min(), Xbg[:, j].max(), grid_n)
out = np.empty(grid_n); Xtmp = Xbg.copy()
for k, v in enumerate(grid):
Xtmp[:, j] = v
out[k] = mdl.predict(Xtmp).mean()
return grid, out
fig, axes = plt.subplots(1, 2, figsize=(11, 3.4))
_ = axes[0].barh([feat[i] for i in order[::-1]], mean_abs[order[::-1]],
color="#1a6ea8", alpha=0.85)
_ = axes[0].set_xlabel("Mean |SHAP value|")
_ = axes[0].set_title("Feature importance by SHAP", fontsize=10.5, fontweight="bold")
axes[0].grid(True, axis="x", color="#e8e8e8")
for name, col in [("rm", "#1a6ea8"), ("lstat", "#e8521a")]:
g, yh = pdp_curve(model, Xtr, feat.index(name))
_ = axes[1].plot(g, yh, lw=1.6, color=col, label=name)
_ = axes[1].set_xlabel("Predictor value"); _ = axes[1].set_ylabel("Avg predicted medv")
_ = axes[1].set_title("Partial dependence: rm up, lstat down", fontsize=10.5, fontweight="bold")
_ = axes[1].legend(fontsize=9); axes[1].grid(True, color="#e8e8e8")
plt.tight_layout(); plt.show()
Critical reading. SHAP and PDP describe how the model predicts, not how the world works. A PDP that slopes up for rm says the fitted function increases in rooms holding other features at their observed values — not a causal “effect of adding a room.” For causal questions we need the econometrics of Part V.
A PDP shows the average marginal effect — but averaging can hide heterogeneity. If rm matters steeply for some tracts and barely for others, the PDP shows only the mean and looks deceptively smooth. ICE curves (Goldstein et al. 2015) disaggregate the PDP into one curve per observation:
\[\text{ICE}_i(v) = \hat f\big(x_{i1},\dots,x_{ij}=v,\dots,x_{ip}\big), \qquad \text{PDP}_j(v) = \frac{1}{n}\sum_{i=1}^{n}\text{ICE}_i(v).\]
The PDP is literally the average of the ICE curves. When the ICE curves are parallel, the effect is homogeneous and the PDP tells the whole story; when they fan out or cross, the feature interacts with the others — the same heterogeneity that motivates causal forests in Part V.
set.seed(SEED)
# ICE: vary one feature over a grid for a sample of rows, keep EACH row's curve
ice_curves <- function(model, X, var, n_ice = 40, grid_n = 30) {
rows <- sample(nrow(X), min(n_ice, nrow(X)))
vals <- seq(min(X[, var]), max(X[, var]), length.out = grid_n)
Xsub <- X[rows, , drop = FALSE]
parts <- vector("list", length(vals)) # explicit loop, no map_
for (k in seq_along(vals)) {
Xtmp <- Xsub; Xtmp[, var] <- vals[k]
parts[[k]] <- tibble(id = rows, value = vals[k],
yhat = predict(model, xgb.DMatrix(Xtmp)))
}
bind_rows(parts)
}
ice_df <- ice_curves(fit_xgb, Xtr, "rm")
pdp_df <- ice_df %>% summarise(yhat = mean(yhat), .by = value) # PDP = mean of ICE
ggplot(ice_df) +
aes(value, yhat, group = id) +
geom_line(colour = col_muted, alpha = 0.30) +
geom_line(data = pdp_df, aes(value, yhat), inherit.aes = FALSE,
colour = col_accent, linewidth = 1.3) +
labs(x = "rooms per dwelling (rm)", y = "predicted medv ($000s)",
title = "ICE curves (grey) and their average — the PDP (orange)",
subtitle = "Each grey line is one tract; diverging lines would signal an interaction") +
theme_lecture
import matplotlib.pyplot as plt
from sklearn.inspection import PartialDependenceDisplay
fig, ax = plt.subplots(figsize=(7.5, 3.4))
# kind="both" overlays the individual ICE curves and their average (the PDP)
_ = PartialDependenceDisplay.from_estimator(
model, Xtr, [feat.index("rm")], kind="both",
subsample=40, random_state=SEED, ax=ax)
_ = ax.set_title("ICE curves and their average (PDP) for rooms (rm)",
fontsize=10.5, fontweight="bold")
plt.tight_layout(); plt.show()
Reading note: parallel ICE curves ⇒ a homogeneous effect; curves that fan out or cross ⇒ an interaction the PDP conceals. ICE is the bridge from average description to the heterogeneous causal effects of Part V.
Part V — Trees for Econometrics
“Great predictions — but where is the coefficient?”
Before the econometrics, one honest question: on real data, do trees actually beat the linear methods you already use? We race five methods on the Boston test set: OLS, Lasso, Ridge, a random forest, and XGBoost.
Boston split as before.The next slide runs all five and sorts them. Watch whether the non-linear methods (forest, XGBoost) win — if they do, the conditional mean really is non-linear with interactions.
set.seed(SEED)
xvars <- setdiff(names(boston), "medv")
n <- nrow(boston); train <- sample(n, floor(n / 2))
Xtr <- as.matrix(boston[train, xvars]); ytr <- boston$medv[train]
Xte <- as.matrix(boston[-train, xvars]); yte <- boston$medv[-train]
rmse <- function(a, b) sqrt(mean((a - b)^2))
fit_ols <- lm(medv ~ ., data = boston[train, ])
fit_lasso <- cv.glmnet(Xtr, ytr, alpha = 1, nfolds = N_CV_FOLDS) # alpha=1 -> Lasso
fit_ridge <- cv.glmnet(Xtr, ytr, alpha = 0, nfolds = N_CV_FOLDS) # alpha=0 -> Ridge
fit_forest<- ranger(medv ~ ., data = boston[train, ], num.trees = N_TREES,
mtry = floor(length(xvars) / 3), num.threads = N_THREADS, seed = SEED)
fit_boost <- xgb.train(list(objective = "reg:squarederror", max_depth = 3, eta = 0.05,
subsample = 0.8, colsample_bytree = 0.8, lambda = 1),
xgb.DMatrix(Xtr, label = ytr), nrounds = 1000,
evals = list(train = xgb.DMatrix(Xtr, label = ytr),
test = xgb.DMatrix(Xte, label = yte)),
early_stopping_rounds = 30, verbose = 0)
tibble(Method = c("OLS", "Lasso", "Ridge", "Random forest", "XGBoost"),
`Test RMSE` = c(rmse(yte, predict(fit_ols, boston[-train, ])),
rmse(yte, as.numeric(predict(fit_lasso, Xte, s = "lambda.min"))),
rmse(yte, as.numeric(predict(fit_ridge, Xte, s = "lambda.min"))),
rmse(yte, predict(fit_forest, boston[-train, ])$predictions),
rmse(yte, predict(fit_boost, xgb.DMatrix(Xte))))) %>%
arrange(`Test RMSE`) %>%
mutate(`vs OLS` = sprintf("%+.1f%%", 100 * (`Test RMSE` / `Test RMSE`[Method == "OLS"] - 1))) %>%
kbl(caption = "Boston: out-of-sample RMSE, 50/50 split (lower is better)", digits = 3) %>%
kable_styling(font_size = 20, full_width = TRUE) %>%
row_spec(1, bold = TRUE, color = "white", background = col_main)| Method | Test RMSE | vs OLS |
|---|---|---|
| XGBoost | 3.889 | -26.8% |
| Random forest | 4.009 | -24.6% |
| OLS | 5.314 | +0.0% |
| Lasso | 5.348 | +0.7% |
| Ridge | 5.439 | +2.4% |
import pandas as pd, numpy as np
from sklearn.linear_model import LinearRegression, LassoCV, RidgeCV
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
import xgboost as xgb
boston = pd.read_csv("../data/boston.csv")
feat = [c for c in boston.columns if c != "medv"]
X = boston[feat].to_numpy(dtype=float); y = boston["medv"].to_numpy(dtype=float)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=0.5, random_state=SEED)
rmse = lambda a, b: mean_squared_error(a, b) ** 0.5
ols = LinearRegression().fit(Xtr, ytr)
las = LassoCV(cv=N_CV_FOLDS, max_iter=10000, random_state=SEED).fit(Xtr, ytr)
rid = RidgeCV(alphas=np.logspace(-3, 3, 50)).fit(Xtr, ytr)
rf = RandomForestRegressor(n_estimators=N_TREES, max_features=max(1, X.shape[1] // 3),
n_jobs=N_CORES, random_state=SEED).fit(Xtr, ytr)
xgm = xgb.XGBRegressor(objective="reg:squarederror", max_depth=3, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8, reg_lambda=1.0,
n_estimators=1000, early_stopping_rounds=30, eval_metric="rmse",
random_state=SEED, n_jobs=N_CORES)
xgm.fit(Xtr, ytr, eval_set=[(Xte, yte)], verbose=False)
rows = [("OLS", rmse(yte, ols.predict(Xte))), ("Lasso", rmse(yte, las.predict(Xte))),
("Ridge", rmse(yte, rid.predict(Xte))), ("Random forest", rmse(yte, rf.predict(Xte))),
("XGBoost", rmse(yte, xgm.predict(Xte)))]
rows.sort(key=lambda r: r[1])
from tabulate import tabulate
print(tabulate([(m, f"{r:.3f}") for m, r in rows],
headers=["Method", "Test RMSE"], tablefmt="rounded_outline"))* pystacked fits all base learners in one call; gradboost is scikit-learn's gradient
* boosting (close cousin of XGBoost). Needs: ssc install pystacked
import delimited "../data/boston.csv", clear
set seed 14159
generate byte train = runiform() < 0.5
pystacked medv crim zn indus chas nox rm age dis rad tax ptratio lstat ///
if train, type(reg) methods(ols lassocv ridgecv rf gradboost) pyseed(14159)
* `transform` returns one prediction column per base learner, in the order listed
predict double yh, transform
local names OLS Lasso Ridge RF GradBoost
local k = 1
foreach nm of local names {
quietly generate double se`k' = (medv - yh`k')^2 if !train
quietly summarize se`k' if !train
display as text "`nm' test RMSE: " as result %6.3f sqrt(r(mean))
local ++k
}> radient
(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Error loading Python Script for pystacked.
unrecognized command
r(199);
r(199);
What this shows — and what it does not. On Boston the tree ensembles typically win on pure prediction: the conditional mean is non-linear with interactions, so flexibility pays. But a low RMSE is not an economic finding. None of these is a coefficient with a standard error. To answer “what is the union wage premium?” we must convert prediction power into valid inference.
You already know how to isolate one coefficient from many controls: the Frisch–Waugh–Lovell (FWL) theorem. To get \(\beta\) in \(y = D\beta + \mathbf{X}\boldsymbol\gamma + \varepsilon\) you may
FWL, in code — three regressions, and the third gives the coefficient:
ctrl <- "rm + age + dis + tax + ptratio" # controls X; treat lstat as D
yres <- residuals(lm(as.formula(paste("medv ~", ctrl)), data = boston))
Dres <- residuals(lm(as.formula(paste("lstat ~", ctrl)), data = boston))
beta_fwl <- coef(lm(yres ~ Dres))[[2]]
beta_direct <- coef(lm(as.formula(paste("medv ~ lstat +", ctrl)), data = boston))[["lstat"]]
c(FWL_residual_slope = beta_fwl, direct_coefficient = beta_direct) # identicalFWL_residual_slope direct_coefficient
-0.5951328 -0.5951328
import pandas as pd, statsmodels.api as sm, statsmodels.formula.api as smf
boston = pd.read_csv("../data/boston.csv")
ctrl = "rm + age + dis + tax + ptratio"
yres = smf.ols(f"medv ~ {ctrl}", boston).fit().resid
Dres = smf.ols(f"lstat ~ {ctrl}", boston).fit().resid
beta_fwl = sm.OLS(yres, sm.add_constant(Dres)).fit().params.iloc[1]
beta_direct = smf.ols(f"medv ~ lstat + {ctrl}", boston).fit().params["lstat"]
print(f"FWL residual slope: {beta_fwl:.4f} direct coefficient: {beta_direct:.4f}")import delimited "../data/boston.csv", clear
quietly regress medv rm age dis tax ptratio
predict y_res, residuals
quietly regress lstat rm age dis tax ptratio
predict d_res, residuals
regress y_res d_res // slope on d_res = FWL beta
regress medv lstat rm age dis tax ptratio // compare: coefficient on lstat matches(encoding automatically selected: ISO-8859-1)
(13 vars, 506 obs)
Source | SS df MS Number of obs = 506
-------------+---------------------------------- F(1, 504) = 131.56
Model | 3376.2126 1 3376.2126 Prob > F = 0.0000
Residual | 12934.5032 504 25.6636969 R-squared = 0.2070
-------------+---------------------------------- Adj R-squared = 0.2054
Total | 16310.7158 505 32.2984472 Root MSE = 5.0659
------------------------------------------------------------------------------
y_res | Coefficient Std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
d_res | -.5951328 .051887 -11.47 0.000 -.6970743 -.4931914
_cons | -1.05e-08 .2252083 -0.00 1.000 -.4424627 .4424626
------------------------------------------------------------------------------
Source | SS df MS Number of obs = 506
-------------+---------------------------------- F(6, 499) = 191.49
Model | 29781.7923 6 4963.63205 Prob > F = 0.0000
Residual | 12934.5033 499 25.9208483 R-squared = 0.6972
-------------+---------------------------------- Adj R-squared = 0.6936
Total | 42716.2956 505 84.586724 Root MSE = 5.0913
------------------------------------------------------------------------------
medv | Coefficient Std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
lstat | -.5951328 .0521463 -11.41 0.000 -.6975862 -.4926794
rm | 4.415709 .4260045 10.37 0.000 3.578725 5.252692
age | -.0193475 .0133963 -1.44 0.149 -.0456675 .0069726
dis | -.8430649 .1686935 -5.00 0.000 -1.174502 -.5116279
tax | -.0054857 .0018295 -3.00 0.003 -.0090802 -.0018912
ptratio | -.8414776 .1223353 -6.88 0.000 -1.081833 -.6011219
_cons | 24.60754 4.086002 6.02 0.000 16.57966 32.63543
------------------------------------------------------------------------------
This “partials out” \(\mathbf{X}\) — linearly. The next slide asks what happens when the controls do not enter linearly.
What if the controls do not enter linearly? Robinson (1988) gives the answer with the partially linear model
\[y = D\,\beta + g(\mathbf{X}) + \varepsilon,\qquad \mathbb{E}[\varepsilon\mid D,\mathbf{X}]=0,\]
where \(g(\cdot)\) is an unknown, possibly highly non-linear function. The same three steps work — but the partialling-out now uses non-parametric predictions:
\[\tilde y_i = y_i - \hat{\mathbb{E}}[y\mid\mathbf{X}_i],\qquad \tilde D_i = D_i - \hat{\mathbb{E}}[D\mid\mathbf{X}_i],\qquad \hat\beta = \frac{\sum_i \tilde D_i\,\tilde y_i}{\sum_i \tilde D_i^2}.\]
The only change from the previous slide is how we predict \(\mathbb{E}[y\mid\mathbf{X}]\) and \(\mathbb{E}[D\mid\mathbf{X}]\) — swap lm() for a forest:
This is the whole point of the lecture. A random forest or XGBoost is simply a flexible estimator of the two conditional expectations. Plug them into FWL and you recover a single, interpretable \(\hat\beta\) with a standard error — even though the controls were handled by a black box.
One subtlety separates a valid estimate from a biased one.
If the same observation is used both to fit the nuisance and to form its residual, the flexible learner partly fits the noise in that point — its overfitting bias leaks into \(\hat\beta\) and pulls it toward zero. The cure is to predict each \(\mathbf{X}_i\) with a model that did not see observation \(i\):
Cross-fitting, in code — loop over folds, train on the rest, predict the held-out fold:
This is exactly the cross-fitting step of Double/Debiased ML (Chernozhukov et al. 2018), developed in full in the companion deck. Here we take its first step with tree nuisance.
We now answer the running question: does replacing TWFE’s linear controls with a forest or XGBoost move the union wage premium?
wagepan — 545 young men, 1980–1987 (a balanced panel). Outcome lwage, treatment union, plus demographics and industry/occupation dummies.nr) to remove the person fixed effect — the panel analogue of FWL’s first partialling; (2) build clean year dummies for the time effect. The demeaned outcome/treatment and the controls then feed the tree partial-out.# Within-individual demeaning = remove the nr fixed effect (tidyverse style)
wp_dm <- wp %>%
group_by(nr) %>%
mutate(across(c(lwage, union, all_of(ctrl_vars)),
~ . - mean(.))) %>% # subtract each person's own mean
ungroup()The benchmark is the linear TWFE union premium, \(\hat\beta^{\text{union}}\approx 0.08\). The question is whether a flexible nuisance moves it.
We estimate three ways: linear TWFE (benchmark), RF partial-out using OOB residuals, and XGBoost partial-out using K-fold cross-fitting.
.wpt <- wp_prep() # prepared in the setup: demeaned y, D, controls + TWFE
ctrl_w <- .wpt$ctrl; y_w <- .wpt$y; D_w <- .wpt$D
b_twfe <- .wpt$twfe$b; se_twfe <- .wpt$twfe$se
# One frame per nuisance regression (controls only on the RHS); using `y ~ .` on a
# frame that excludes the other target avoids ranger's fragile `. - var` handling.
df_y <- data.frame(y = y_w, ctrl_w, check.names = TRUE)
df_d <- data.frame(D = D_w, ctrl_w, check.names = TRUE)
set.seed(SEED)
rf_y <- ranger(y ~ ., data = df_y, num.trees = N_TREES, min.node.size = 5,
num.threads = N_THREADS, seed = SEED) # $predictions are OOB (free cross-fit)
rf_d <- ranger(D ~ ., data = df_d, num.trees = N_TREES, min.node.size = 5,
num.threads = N_THREADS, seed = SEED)
ols_rf <- lm((y_w - rf_y$predictions) ~ I(D_w - rf_d$predictions)) # FWL on residuals
b_rf <- coef(ols_rf)[[2]]
se_rf <- sqrt(sandwich::vcovHC(ols_rf, type = "HC3")[2, 2]) # robust SE
# XGBoost nuisance — honest K-fold cross-fitting (out-of-fold predictions)
Xw <- as.matrix(ctrl_w)
xpar <- list(objective = "reg:squarederror", max_depth = 4, eta = 0.05,
subsample = 0.8, colsample_bytree = 0.8, lambda = 1)
crossfit_xgb <- function(X, target, folds) {
pred <- numeric(length(target))
for (k in sort(unique(folds))) {
in_tr <- folds != k
m <- xgb.train(xpar, xgb.DMatrix(X[in_tr, , drop = FALSE], label = target[in_tr]),
nrounds = 300, verbose = 0)
pred[!in_tr] <- predict(m, xgb.DMatrix(X[!in_tr, , drop = FALSE]))
}
pred
}
set.seed(SEED)
folds <- sample(rep_len(seq_len(N_CV_FOLDS), length(y_w)))
ols_xg <- lm((y_w - crossfit_xgb(Xw, y_w, folds)) ~ I(D_w - crossfit_xgb(Xw, D_w, folds)))
b_xgb <- coef(ols_xg)[[2]]
se_xgb <- sqrt(sandwich::vcovHC(ols_xg, type = "HC3")[2, 2])
tibble(Estimator = c("TWFE (linear, hand-picked)", "RF partial-out (OOB)",
"XGBoost partial-out (cross-fit)"),
`Union premium` = c(b_twfe, b_rf, b_xgb),
SE = c(se_twfe, se_rf, se_xgb),
Nuisance = c("Linear FE", "Random forest", "XGBoost")) %>%
mutate(`95% CI` = sprintf("[%.3f, %.3f]", `Union premium` - 1.96 * SE,
`Union premium` + 1.96 * SE)) %>%
kbl(caption = "wagepan: union wage premium — linear vs tree-based nuisance", digits = 4) %>%
kable_styling(font_size = 19, full_width = TRUE) %>%
row_spec(2:3, bold = TRUE, color = "white", background = col_main)| Estimator | Union premium | SE | Nuisance | 95% CI |
|---|---|---|---|---|
| TWFE (linear, hand-picked) | 0.0800 | 0.0227 | Linear FE | [0.036, 0.124] |
| RF partial-out (OOB) | 0.0831 | 0.0180 | Random forest | [0.048, 0.118] |
| XGBoost partial-out (cross-fit) | 0.0872 | 0.0184 | XGBoost | [0.051, 0.123] |
import pandas as pd, numpy as np, warnings
warnings.filterwarnings("ignore")
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import KFold
import xgboost as xgb
try:
import wooldridge as woo; wp = woo.data("wagepan")
except Exception:
wp = pd.read_csv("../data/wagepan.csv")
base = [c for c in ["exper","expersq","married","educ","black","hisp","south","smsa"] if c in wp.columns]
ind = [c for c in ["agric","bus","construc","ndurman","trcommpu","trade",
"services","profserv","profocc","clerocc","servocc"] if c in wp.columns]
ctrl = base + ind
wp = wp[["nr","year","lwage","union"] + ctrl].dropna()
num = ["lwage","union"] + ctrl # within-demean by nr
wp_dm = wp.copy()
wp_dm[num] = wp_dm[num] - wp_dm.groupby("nr")[num].transform("mean")
yr = pd.get_dummies(wp_dm["year"], prefix="yr", drop_first=True).astype(float)
X = np.column_stack([wp_dm[ctrl].to_numpy(dtype=float), yr.to_numpy(dtype=float)])
y = wp_dm["lwage"].to_numpy(dtype=float); D = wp_dm["union"].to_numpy(dtype=float)
b_twfe = np.linalg.lstsq(np.column_stack([D, X]), y, rcond=None)[0][0]
def partial_out(yres, Dres): # FWL slope + HC0 robust SE
b = (Dres @ yres) / (Dres @ Dres)
resid = yres - b * Dres
se = np.sqrt(np.sum(Dres**2 * resid**2)) / (Dres @ Dres)
return b, se
rf_y = RandomForestRegressor(n_estimators=N_TREES, min_samples_leaf=5, oob_score=True,
bootstrap=True, n_jobs=N_CORES, random_state=SEED).fit(X, y)
rf_d = RandomForestRegressor(n_estimators=N_TREES, min_samples_leaf=5, oob_score=True,
bootstrap=True, n_jobs=N_CORES, random_state=SEED).fit(X, D)
b_rf, se_rf = partial_out(y - rf_y.oob_prediction_, D - rf_d.oob_prediction_)
def crossfit_xgb(X, target):
pred = np.zeros_like(target, dtype=float)
for tr, te in KFold(n_splits=N_CV_FOLDS, shuffle=True, random_state=SEED).split(X):
m = xgb.XGBRegressor(objective="reg:squarederror", max_depth=4, learning_rate=0.05,
subsample=0.8, colsample_bytree=0.8, reg_lambda=1.0,
n_estimators=300, random_state=SEED, n_jobs=N_CORES)
m.fit(X[tr], target[tr]); pred[te] = m.predict(X[te])
return pred
b_xgb, se_xgb = partial_out(y - crossfit_xgb(X, y), D - crossfit_xgb(X, D))
from tabulate import tabulate
print(tabulate([["TWFE (linear)", f"{b_twfe:.4f}", "—"],
["RF partial-out (OOB)", f"{b_rf:.4f}", f"{se_rf:.4f}"],
["XGBoost partial-out (cross-fit)", f"{b_xgb:.4f}", f"{se_xgb:.4f}"]],
headers=["Estimator", "Union premium", "Robust SE"], tablefmt="rounded_outline"))frause wagepan, clear
xtset nr year
* TWFE benchmark (same specification as the companion deck)
xtreg lwage union exper expersq married educ i.year, fe vce(cluster nr)
display as text "TWFE union premium: " as result %7.4f _b[union]
* To match the R/Python tabs, FIRST remove the nr fixed effect by within-demeaning,
* THEN partial out the demeaned controls + year dummies with a forest.
* One-time: ssc install ddml ; ssc install pystacked
local ctrls exper expersq married educ black hisp south smsa
foreach v of varlist lwage union `ctrls' {
bysort nr (year): egen double m_`v' = mean(`v')
generate double dm_`v' = `v' - m_`v'
}
local dmctrls
foreach v of local ctrls {
local dmctrls `dmctrls' dm_`v'
}
ddml init partial, kfolds(5)
ddml E[Y|X]: pystacked dm_lwage `dmctrls' i.year, type(reg) methods(rf) pyseed(14159)
ddml E[D|X]: pystacked dm_union `dmctrls' i.year, type(reg) methods(rf) pyseed(14159)
ddml crossfit
ddml estimate, robustPanel variable: nr (strongly balanced)
Time variable: year, 1980 to 1987
Delta: 1 unit
note: educ omitted because of collinearity.
note: 1987.year omitted because of collinearity.
Fixed-effects (within) regression Number of obs = 4,360
Group variable: nr Number of groups = 545
R-squared: Obs per group:
Within = 0.1806 min = 8
Between = 0.0005 avg = 8.0
Overall = 0.0635 max = 8
F(10, 544) = 46.59
corr(u_i, Xb) = -0.1212 Prob > F = 0.0000
(Std. err. adjusted for 545 clusters in nr)
------------------------------------------------------------------------------
| Robust
lwage | Coefficient std. err. t P>|t| [95% conf. interval]
-------------+----------------------------------------------------------------
union | .0800019 .0227431 3.52 0.000 .0353268 .1246769
exper | .1321464 .012008 11.00 0.000 .1085586 .1557342
expersq | -.0051855 .0008102 -6.40 0.000 -.0067771 -.0035939
married | .0466804 .0210038 2.22 0.027 .0054218 .0879389
educ | 0 (omitted)
|
year |
1981 | .0190448 .0227267 0.84 0.402 -.025598 .0636876
1982 | -.011322 .0212167 -0.53 0.594 -.0529987 .0303547
1983 | -.0419955 .0205087 -2.05 0.041 -.0822814 -.0017096
1984 | -.0384709 .0211722 -1.82 0.070 -.0800601 .0031183
1985 | -.0432498 .017595 -2.46 0.014 -.0778122 -.0086874
1986 | -.0273819 .0162181 -1.69 0.092 -.0592396 .0044757
1987 | 0 (omitted)
|
_cons | 1.02764 .0398919 25.76 0.000 .9492785 1.106001
-------------+----------------------------------------------------------------
sigma_u | .4009279
sigma_e | .35099001
rho | .56612236 (fraction of variance due to u_i)
------------------------------------------------------------------------------
TWFE union premium: 0.0800
variable smsa not found
r(111);
r(111);
Parameters worth noting. min.node.size/min_samples_leaf = 5 regularises each tree; kfolds(5) / N_CV_FOLDS sets the cross-fitting folds; vcovHC(., "HC3") and the HC0 formula give heteroskedasticity-robust SEs on the final FWL regression.
How should you read this table? Compare the tree-based premia to the TWFE benchmark of \(\approx 0.08\):
Either way, the honest comparison required OOB / cross-fitted nuisance — in-sample tree fits would have biased \(\hat\beta\) toward zero through overfitting (you verify this in Exercise 5).
The discipline to report: the estimand (\(\hat\beta^{\text{union}}\)), the nuisance learner, the cross-fitting scheme, the robust SE, and the seed. A reader should be able to reproduce the number exactly.
The partial-out estimator returns a single average premium. But what if the union effect differs across workers — larger for low-tenure workers, smaller in some industries? Averaging hides that heterogeneity.
Causal forests (Wager & Athey 2018) adapt the random forest to estimate the conditional average treatment effect
\[\tau(\mathbf{x}) = \mathbb{E}[\,Y(1)-Y(0)\mid \mathbf{X}=\mathbf{x}\,],\]
by growing trees whose splits maximise treatment-effect heterogeneity rather than predictive fit, with honest sample-splitting so each leaf’s estimate is valid. The forest then delivers a personalised \(\hat\tau(\mathbf{x})\) with pointwise confidence intervals.
| Goal | Estimator | Software |
|---|---|---|
| One average effect, flexible controls | Partial-out / DML | R DoubleML, Stata ddml, Py econml |
| Heterogeneous effect \(\tau(\mathbf{x})\) | Causal forest | R grf::causal_forest, Py econml.CausalForestDML |
This is the bridge from prediction (Parts II–IV) through one causal number (Part V) to causal heterogeneity. The full treatment — orthogonal moments, cross-fitting, inference on \(\tau(\mathbf{x})\) — is the subject of the companion deck and Chapter 14 of the Causal ML book.
Estimating \(\tau(\mathbf{x})\) answers “for whom is the effect large?” The next question is a decision: whom should we treat? This is policy learning (Athey & Wager 2021), the payoff of the Stanford ML-CI tutorial.
The building block is the doubly-robust (AIPW) score — combining the outcome model \(\hat g\) and the propensity \(\hat e(\mathbf{x})=\mathbb{P}(D=1\mid\mathbf{x})\) so the estimate stays valid if either is consistent:
\[\hat\Gamma_i = \hat\tau(\mathbf{x}_i) + \frac{D_i-\hat e(\mathbf{x}_i)}{\hat e(\mathbf{x}_i)\,(1-\hat e(\mathbf{x}_i))}\Big(Y_i - \hat g(\mathbf{x}_i, D_i)\Big).\]
A policy \(\pi:\mathcal{X}\to\{0,1\}\) is then chosen to maximise estimated value \(\frac{1}{n}\sum_i \pi(\mathbf{x}_i)\hat\Gamma_i\). Restricting \(\pi\) to a shallow policy tree yields an interpretable, deployable targeting rule — a few yes/no questions a programme administrator can follow.
Why doubly robust? The AIPW score gives valid policy evaluation if either the outcome model or the propensity is right — exactly the robustness Double/Debiased ML buys. References: Athey & Wager (2021); the Stanford ML-CI tutorial, Ch. 5–6.
Part VI — Forecasting Time Series
Where XGBoost wins on tabular data of any kind — including the temporal kind
Trees do not model time. We reframe forecasting as supervised learning: regress \(y_t\) on its own recent past.
\[\hat y_t = f\!\left(y_{t-1},\,y_{t-2},\dots,y_{t-p},\ \text{season}_t,\ \mathbf{x}_t\right).\]
Where XGBoost beats ARIMA:
With lag features the model is a non-linear, non-parametric AR(\(p\)):
\[\Delta y_t = f(\Delta y_{t-1},\dots,\Delta y_{t-p},\,\text{month}_t) + \varepsilon_t, \qquad f(\mathbf{x}) = \sum_{m=1}^{M} f_m(\mathbf{x}),\quad f_m \in \text{trees}.\]
Each round \(m\) adds the tree minimising the second-order objective
\[\mathcal{L}^{(m)} = \sum_t \left[ g_t\, f_m(\mathbf{x}_t) + \tfrac12 h_t\, f_m(\mathbf{x}_t)^2 \right] + \gamma T + \tfrac{\lambda}{2}\sum_{j=1}^{T} w_j^2,\]
with \(g_t = \partial_{\hat y} \ell\), \(h_t = \partial^2_{\hat y} \ell\); the optimal leaf value and split gain are
\[w_j^\ast = -\frac{\sum_{t\in R_j} g_t}{\sum_{t\in R_j} h_t + \lambda}, \qquad \text{Gain} = \tfrac12\!\left[\frac{G_L^2}{H_L+\lambda} + \frac{G_R^2}{H_R+\lambda} - \frac{G^2}{H+\lambda}\right] - \gamma.\]
Reading. Replace the linear \(\sum_k \phi_k \Delta y_{t-k}\) of an AR with a sum of step functions: thresholds, interactions, and seasonal asymmetries enter automatically; \(\lambda,\gamma,\eta\) shrink them like Ridge shrinks coefficients.
\[\Delta y_t = y_t - y_{t-1},\qquad \hat y_t = y_{t-1} + \widehat{\Delta y_t}.\]
The one trick: build a lag matrix, then it is an ordinary regression problem.
# A tibble: 5 × 6
date dy month dy_l1 dy_l2 dy_l3
<date> <dbl> <int> <dbl> <dbl> <dbl>
1 1967-07-01 NA 7 NA NA NA
2 1967-08-01 0.000340 8 NA NA NA
3 1967-09-01 0.00440 9 0.000340 NA NA
4 1967-10-01 0.0607 10 0.00440 0.000340 NA
5 1967-11-01 -0.0248 11 0.0607 0.00440 0.000340
import pandas as pd, numpy as np
econ = pd.read_csv("../data/economics.csv", parse_dates=["date"])
econ["dy"] = np.log(econ["unemploy"]).diff()
econ["month"] = econ["date"].dt.month
for k in range(1, 4):
econ[f"dy_l{k}"] = econ["dy"].shift(k)
print(econ[["date", "dy", "dy_l1", "dy_l2", "dy_l3"]].head())(encoding automatically selected: ISO-8859-1)
(6 vars, 574 obs)
Time variable: t, 1 to 574
Delta: 1 unit
(1 missing value generated)
(2 missing values generated)
(3 missing values generated)
(4 missing values generated)
+------------------------------------------------+
| dy dy_l1 dy_l2 dy_l3 |
|------------------------------------------------|
1. | . . . . |
2. | .00033962 . . . |
3. | .00440455 .00033962 . . |
4. | .06066439 .00440455 .00033962 . |
5. | -.02480398 .06066439 .00440455 .00033962 |
+------------------------------------------------+
Never report an ML forecast without a classical benchmark. Know what each one models:
| Model | Equation | Targets |
|---|---|---|
| ARIMA(\(p,d,q\)) | \(\phi(L)\,(1-L)^d y_t = \theta(L)\,\varepsilon_t\) | linear conditional mean |
| GARCH(1,1) | \(\sigma_t^2 = \omega + \alpha\,\varepsilon_{t-1}^2 + \beta\,\sigma_{t-1}^2\) | conditional variance |
| VAR(\(p\)) | \(\mathbf{y}_t = A_1\mathbf{y}_{t-1} + \cdots + A_p\mathbf{y}_{t-p} + \boldsymbol{\varepsilon}_t\) | several means jointly |
Twelve lags of the monthly log-change plus the calendar month; train on the first 80%, forecast the rest one step ahead; benchmark against a random walk.
econ <- ggplot2::economics %>%
transmute(date, y = log(unemploy), dy = y - lag(y),
month = as.integer(format(date, "%m")))
for (k in 1:12) econ[[paste0("dy_l", k)]] <- lag(econ$dy, k)
econ <- drop_na(econ)
feats <- c(paste0("dy_l", 1:12), "month")
n <- nrow(econ); cut <- floor(0.8 * n); te <- (cut + 1):n
fit_ts <- xgb.train(list(objective = "reg:squarederror", max_depth = 3, eta = 0.05),
xgb.DMatrix(as.matrix(econ[1:cut, feats]), label = econ$dy[1:cut]),
nrounds = 300, verbose = 0)
dy_hat <- predict(fit_ts, xgb.DMatrix(as.matrix(econ[te, feats])))
y_prev <- econ$y[te] - econ$dy[te]
fc_level <- exp(y_prev + dy_hat)
act_level <- exp(econ$y[te])
rw_level <- exp(y_prev)
c(XGBoost_RMSE = sqrt(mean((fc_level - act_level)^2)),
RandomWalk_RMSE = sqrt(mean((rw_level - act_level)^2))) XGBoost_RMSE RandomWalk_RMSE
247.5140 294.4275
import pandas as pd, numpy as np, xgboost as xgb
econ = pd.read_csv("../data/economics.csv", parse_dates=["date"])
econ["y"] = np.log(econ["unemploy"]); econ["dy"] = econ["y"].diff()
econ["month"] = econ["date"].dt.month
for k in range(1, 13):
econ[f"dy_l{k}"] = econ["dy"].shift(k)
econ = econ.dropna().reset_index(drop=True)
feats = [f"dy_l{k}" for k in range(1, 13)] + ["month"]
n = len(econ); cut = int(0.8 * n)
m = xgb.XGBRegressor(objective="reg:squarederror", max_depth=3, learning_rate=0.05,
n_estimators=300, random_state=SEED, n_jobs=N_CORES)
m.fit(econ[feats].iloc[:cut], econ["dy"].iloc[:cut])
dy_hat = m.predict(econ[feats].iloc[cut:])
y_prev = (econ["y"] - econ["dy"]).iloc[cut:].to_numpy()
fc, act, rw = np.exp(y_prev + dy_hat), np.exp(econ["y"].iloc[cut:].to_numpy()), np.exp(y_prev)
print(f"XGBoost RMSE: {np.sqrt(((fc-act)**2).mean()):.0f} | "
f"random-walk RMSE: {np.sqrt(((rw-act)**2).mean()):.0f}")import delimited "../data/economics.csv", clear
generate t = _n
tsset t
generate double dy = log(unemploy) - log(L.unemploy)
generate int month = month(date(date, "YMD"))
forvalues k = 1/12 {
generate double dy_l`k' = L`k'.dy
}
quietly count
scalar cut = floor(0.8 * r(N))
pystacked dy dy_l1-dy_l12 month if t <= cut, type(reg) methods(gradboost) pyseed(14159)
predict double dy_hat
generate double sq = (dy - dy_hat)^2 if t > cut
quietly summarize sq if t > cut
display as text "XGBoost-style RMSE on the change: " as result %6.4f sqrt(r(mean))(encoding automatically selected: ISO-8859-1)
(6 vars, 574 obs)
Time variable: t, 1 to 574
Delta: 1 unit
(1 missing value generated)
(2 missing values generated)
(3 missing values generated)
(4 missing values generated)
(5 missing values generated)
(6 missing values generated)
(7 missing values generated)
(8 missing values generated)
(9 missing values generated)
(10 missing values generated)
(11 missing values generated)
(12 missing values generated)
(13 missing values generated)
Error loading Python Script for pystacked.
unrecognized command
r(199);
r(199);
Same series, same split, same one-step-ahead protocol — only the model changes.
Series: ts(econ$y[1:cut], frequency = 12)
ARIMA(1,1,2)(2,0,0)[12]
Coefficients:
ar1 ma1 ma2 sar1 sar2
0.8929 -0.9002 0.1850 -0.1307 -0.2195
s.e. 0.0391 0.0592 0.0488 0.0480 0.0479
sigma^2 = 0.0006573: log likelihood = 1005.16
AIC=-1998.31 AICc=-1998.12 BIC=-1973.7
Training set error measures:
ME RMSE MAE MPE MAPE MASE
Training set 0.00118576 0.02546544 0.01922357 0.0140486 0.2185712 0.1600653
ACF1
Training set 0.002059743
# refit-free trick: apply the trained model to the full series; fitted() on the
# test window is then a sequence of true one-step-ahead forecasts
fit_full <- Arima(ts(econ$y, frequency = 12), model = fit_ar)
ar_level <- exp(as.numeric(fitted(fit_full))[te])
c(ARIMA_RMSE = sqrt(mean((ar_level - act_level)^2)),
XGBoost_RMSE = sqrt(mean((fc_level - act_level)^2)),
RandomWalk_RMSE = sqrt(mean((rw_level - act_level)^2))) ARIMA_RMSE XGBoost_RMSE RandomWalk_RMSE
244.3091 247.5140 294.4275
from statsmodels.tsa.arima.model import ARIMA
fit_ar = ARIMA(econ["y"].iloc[:cut], order=(2, 1, 2)).fit()
res_full = fit_ar.apply(econ["y"]) # same parameters, full sample
ar_level = np.exp(res_full.fittedvalues.iloc[cut:].to_numpy())
print(f"ARIMA RMSE: {np.sqrt(((ar_level - act)**2).mean()):.0f} | "
f"XGBoost RMSE: {np.sqrt(((fc - act)**2).mean()):.0f}")import delimited "../data/economics.csv", clear
generate t = _n
tsset t
generate double dy = log(unemploy) - log(L.unemploy)
quietly count
scalar cut = floor(0.8 * r(N))
arima dy if t <= cut, ar(1/2) ma(1)
predict double dy_ar, xb // one-step-ahead, parameters frozen
generate double sq = (dy - dy_ar)^2 if t > cut
quietly summarize sq if t > cut
display as text "ARIMA RMSE on the change: " as result %6.4f sqrt(r(mean))(encoding automatically selected: ISO-8859-1)
(6 vars, 574 obs)
Time variable: t, 1 to 574
Delta: 1 unit
(1 missing value generated)
(setting optimization to BHHH)
Iteration 0: Log likelihood = 997.50864
Iteration 1: Log likelihood = 1002.782
Iteration 2: Log likelihood = 1004.096
Iteration 3: Log likelihood = 1004.5364
Iteration 4: Log likelihood = 1004.6212
(switching optimization to BFGS)
Iteration 5: Log likelihood = 1004.659
Iteration 6: Log likelihood = 1004.6806
Iteration 7: Log likelihood = 1004.6819
Iteration 8: Log likelihood = 1004.682
ARIMA regression
Sample: 2 thru 459 Number of obs = 458
Wald chi2(3) = 385.75
Log likelihood = 1004.682 Prob > chi2 = 0.0000
------------------------------------------------------------------------------
| OPG
dy | Coefficient std. err. z P>|z| [95% conf. interval]
-------------+----------------------------------------------------------------
dy |
_cons | .0020473 .0029498 0.69 0.488 -.0037341 .0078288
-------------+----------------------------------------------------------------
ARMA |
ar |
L1. | .6991125 .0933425 7.49 0.000 .5161645 .8820604
L2. | .1608933 .0484591 3.32 0.001 .0659152 .2558714
|
ma |
L1. | -.6831027 .088832 -7.69 0.000 -.8572101 -.5089952
-------------+----------------------------------------------------------------
/sigma | .0269759 .0007608 35.46 0.000 .0254847 .0284671
------------------------------------------------------------------------------
Note: The test of the variance against zero is one sided, and the two-sided
confidence interval is truncated at zero.
(459 missing values generated)
ARIMA RMSE on the change: 0.0244
Eyeballing two RMSEs is not a test. The Diebold–Mariano test asks whether the loss differential \(d_t = e_{1t}^2 - e_{2t}^2\) has mean zero, with HAC-robust inference:
\[\text{DM} = \frac{\bar d}{\sqrt{\widehat{\text{Var}}(\bar d)}} \;\xrightarrow{d}\; N(0,1).\]
Diebold-Mariano Test
data: e_xgbe_ar
DM = 0.27165, Forecast horizon = 1, Loss function power = 2, p-value =
0.7864
alternative hypothesis: two.sided
The model tracks turning points it has never seen — because the patterns in the lagged changes recur, even when the levels do not.
One train/test split is one draw. The honest protocol re-fits at successive origins and averages the forecast errors — \(k\)-fold’s time-respecting cousin:
\[\text{CV} = \frac{1}{H}\sum_{h=1}^{H} L\!\left(y_{t_h+1},\ \hat y_{t_h+1\mid t_h}\right), \qquad t_1 < t_2 < \cdots < t_H.\]
What it is for. Prediction: nowcasting, demand and revenue forecasting, risk inputs, gap-filling — tasks judged purely by out-of-sample loss.
To attach a standard error to one economic parameter while trees absorb the dynamics, partial-out — exactly Part V, with time-series care:
\[\Delta y_t = \beta\, D_t + g(\mathbf{x}_t) + u_t, \qquad \mathbf{x}_t = (\Delta y_{t-1},\dots,\Delta y_{t-12}),\]
cross-fitting in contiguous blocks (not shuffled folds) and Newey–West (HAC) errors for the residual regression.
ec <- ggplot2::economics %>%
transmute(dy = log(unemploy) - lag(log(unemploy)),
dx = log(pce) - lag(log(pce))) # D_t: consumption growth
for (k in 1:12) ec[[paste0("dy_l", k)]] <- lag(ec$dy, k)
ec <- drop_na(ec)
Xc <- as.matrix(ec %>% select(starts_with("dy_l")))
blocks <- cut(seq_len(nrow(ec)), 5, labels = FALSE) # contiguous time blocks
res_y <- res_d <- rep(NA_real_, nrow(ec))
for (b in 1:5) {
inb <- blocks == b
fy <- xgb.train(list(objective = "reg:squarederror", max_depth = 3, eta = 0.05),
xgb.DMatrix(Xc[!inb, ], label = ec$dy[!inb]), nrounds = 200, verbose = 0)
fd <- xgb.train(list(objective = "reg:squarederror", max_depth = 3, eta = 0.05),
xgb.DMatrix(Xc[!inb, ], label = ec$dx[!inb]), nrounds = 200, verbose = 0)
res_y[inb] <- ec$dy[inb] - predict(fy, xgb.DMatrix(Xc[inb, ]))
res_d[inb] <- ec$dx[inb] - predict(fd, xgb.DMatrix(Xc[inb, ]))
}
fit_dml <- lm(res_y ~ res_d)
V <- NeweyWest(fit_dml, lag = 12, prewhite = FALSE)
b <- coef(fit_dml)[["res_d"]]; se <- sqrt(diag(V))[["res_d"]]
c(beta = b, HAC_se = se, t = b / se) beta HAC_se t
-0.2272494 0.2303415 -0.9865760
Daily NYSE, 1962–1986 (ISLR2). Predict log_volume from five lags each of volume, return, and volatility, plus day of week. Already near-stationary, so we model the level directly. Benchmark: lag-1 persistence.
nyse <- ISLR2::NYSE %>% arrange(date) %>%
mutate(dow = as.integer(factor(day_of_week)))
for (k in 1:5) {
nyse[[paste0("vol_l", k)]] <- lag(nyse$log_volume, k)
nyse[[paste0("ret_l", k)]] <- lag(nyse$DJ_return, k)
nyse[[paste0("vlt_l", k)]] <- lag(nyse$log_volatility, k)
}
nyse <- drop_na(nyse)
feats <- c(paste0("vol_l", 1:5), paste0("ret_l", 1:5), paste0("vlt_l", 1:5), "dow")
tr <- nyse$train; te <- !nyse$train # ISLR2's own train/test split
fit_ny <- xgb.train(list(objective = "reg:squarederror", max_depth = 4, eta = 0.05),
xgb.DMatrix(as.matrix(nyse[tr, feats]), label = nyse$log_volume[tr]),
nrounds = 400, verbose = 0)
pred_ny <- predict(fit_ny, xgb.DMatrix(as.matrix(nyse[te, feats])))
act_ny <- nyse$log_volume[te]
r2 <- function(a, p) 1 - sum((a - p)^2) / sum((a - mean(a))^2)
c(XGBoost_R2 = r2(act_ny, pred_ny), Lag1_R2 = r2(act_ny, nyse$vol_l1[te]),
XGBoost_RMSE = sqrt(mean((act_ny - pred_ny)^2))) XGBoost_R2 Lag1_R2 XGBoost_RMSE
0.4332468 0.1802629 0.1805704
import pandas as pd, numpy as np, xgboost as xgb
nyse = pd.read_csv("../data/nyse.csv")
nyse["dow"] = nyse["day_of_week"].astype("category").cat.codes
for k in range(1, 6):
nyse[f"vol_l{k}"] = nyse["log_volume"].shift(k)
nyse[f"ret_l{k}"] = nyse["DJ_return"].shift(k)
nyse[f"vlt_l{k}"] = nyse["log_volatility"].shift(k)
nyse = nyse.dropna().reset_index(drop=True)
feats = ([f"vol_l{k}" for k in range(1, 6)] + [f"ret_l{k}" for k in range(1, 6)] +
[f"vlt_l{k}" for k in range(1, 6)] + ["dow"])
tr = nyse["train"].astype(str).str.upper() == "TRUE" # ISLR2's split; logical writes as text
te = ~tr
m = xgb.XGBRegressor(objective="reg:squarederror", max_depth=4, learning_rate=0.05,
n_estimators=400, random_state=SEED, n_jobs=N_CORES)
m.fit(nyse.loc[tr, feats], nyse.loc[tr, "log_volume"])
pred = m.predict(nyse.loc[te, feats]); act = nyse.loc[te, "log_volume"].to_numpy()
r2 = lambda a, p: 1 - ((a - p) ** 2).sum() / ((a - a.mean()) ** 2).sum()
print(f"XGBoost R2: {r2(act, pred):.3f} | lag-1 R2: {r2(act, nyse.loc[te, 'vol_l1'].to_numpy()):.3f}"
f" | RMSE: {np.sqrt(((act - pred) ** 2).mean()):.3f}")import delimited "../data/nyse.csv", clear case(lower)
generate t = _n
tsset t
encode day_of_week, generate(dow)
forvalues k = 1/5 {
generate double vol_l`k' = L`k'.log_volume
generate double ret_l`k' = L`k'.dj_return
generate double vlt_l`k' = L`k'.log_volatility
}
pystacked log_volume vol_l1-vol_l5 ret_l1-ret_l5 vlt_l1-vlt_l5 i.dow ///
if train == "TRUE", type(reg) methods(gradboost) pyseed(14159)
predict double vhat
generate double sq = (log_volume - vhat)^2 if train != "TRUE"
quietly summarize sq if train != "TRUE"
display as text "NYSE test RMSE (log volume): " as result %6.4f sqrt(r(mean))(encoding automatically selected: ISO-8859-1)
(6 vars, 6,051 obs)
Time variable: t, 1 to 6051
Delta: 1 unit
(1 missing value generated)
(1 missing value generated)
(1 missing value generated)
(2 missing values generated)
(2 missing values generated)
(2 missing values generated)
(3 missing values generated)
(3 missing values generated)
(3 missing values generated)
(4 missing values generated)
(4 missing values generated)
(4 missing values generated)
(5 missing values generated)
(5 missing values generated)
(5 missing values generated)
varlist not allowed
r(101);
r(101);
Volume is highly persistent, so lag-1 is already a tough benchmark; XGBoost adds the non-linear lag interactions and the day-of-week effect on top.
Over regularised (linear) regression, trees and ensembles give us:
The honest summary. Trees do not replace econometrics — they upgrade its nuisance step. The estimator of interest stays low-dimensional and interpretable; the machine learning lives entirely in \(\hat g(\mathbf{x})\) and \(\hat m(\mathbf{x})\).
There is no coefficient table — so what do you report? A credible tree-based write-up states:
| Item | What to report |
|---|---|
| Task | Prediction, screening, or DML nuisance — say which |
| Validation | Held-out test set or \(K\)-fold CV; the seed; the split |
| Accuracy | Out-of-sample RMSE/\(R^2\) vs OLS / Lasso / Ridge benchmarks |
| Importance | Permutation importance (not impurity); SHAP for direction |
| Tuning | mtry (forest); eta, max_depth, early-stopping round (XGBoost) |
| Causal target | Partial-out \(\hat\beta\) with robust SE, and the cross-fitting scheme |
Match the tool to the goal. Best prediction → XGBoost (or a forest, more robustly). Screening controls → forest permutation importance / SHAP. One causal coefficient → tree partial-out / DML with cross-fitting. Heterogeneous effects → causal forest. An interpretable policy model → a small pruned tree or a sparse Lasso.
A coding pitfall, too. In Python, a bare plotting call (e.g. ax.plot(...)) returns artist objects that print as [<matplotlib.lines.Line2D ...>]. Suppress them: assign to _ (_ = ax.plot(...)) and end the chunk with plt.show(). Every Python chunk in this deck follows that rule.
Foundational — trees and ensembles
Trees for causal inference
Econometric perspective
Core textbooks
Hitters and Boston analyses. statlearning.comHands-on, across the three environments
Causal ML — tutorials & software
grf, policytree (Athey, Tibshirani, Wager, Sverdrup). Python: EconML (Microsoft Research / PyWhy), doubleml. Stata: ddml, pystacked (Ahrens, Hansen, Schaffer & Wiemann 2023).Packages & data used here
ranger (doi:10.18637/jss.v077.i01), rpart, xgboost, SHAPforxgboost. Datasets Hitters, Boston (ISLR2); wagepan (wooldridge).machine-learning-regularisation-in-econometrics.Qmd (Lasso, Ridge, Elastic Net, full DML).Hitters). Grow a depth-4 tree of log(Salary) on all predictors, then prune it by 10-fold CV and the 1-SE rule. Does it keep Years and Hits on top? Compare its test RMSE to the depth-2 tree from the slide.mtry (Boston). Fit forests with mtry \(\in\{2,4,6,8,12\}\) and record the OOB MSE. Which value minimises it, and how does it compare to the default \(p/3=4\)? Plot OOB MSE against mtry.Boston). On a fixed 50/50 split, compare the test RMSE of a forest and an early-stopped XGBoost. At which round does the XGBoost test error stop improving?Boston). Compute permutation importance and mean \(|\)SHAP\(|\) for the same model. Do both rank lstat and rm on top? Where do they disagree, and why might impurity importance differ again?wagepan). Re-estimate the RF partial-out premium twice: once with in-sample forest predictions, once with OOB predictions. How much does \(\hat\beta^{\text{union}}\) move, and in which direction? Explain the bias.wagepan). Re-run the RF and XGBoost partial-out with num.trees / n_estimators \(\in\{100, 500, 1000\}\). At what ensemble size does the premium stabilise to three decimals?Boston). Plot ICE curves for lstat from the boosted model. Are they parallel (homogeneous effect) or do they fan out (interaction)? Centre the curves at their left end (c-ICE) and re-examine. What does this imply for trusting the PDP?policy_tree (R) or PolicyTree (Python). Write out the resulting treatment rule in words, and compare its estimated value to “treat everyone” and “treat no one”.Thank You
Athanassios Stavrakoudis
Applied Informatics and Computational Economics Lab
Department of Economics
University of Ioannina
Greece