Variable selection
Predictors are selected by recursive elimination on random forest importance: fit a forest on all predictors, record the out-of-bag error, drop the least important 20%, and repeat. The configuration minimising the OOB error is chosen.
from analysis.select_features import load_analysis_data, select_features
wide = load_analysis_data(ROOT / "warehouse.duckdb" )
path = select_features(wide)
path[["n_variables" , "oob_mse" ]]
0
134
0.200
1
107
0.194
2
85
0.186
3
68
0.180
4
54
0.179
5
43
0.175
6
34
0.175
7
27
0.172
8
21
0.165
9
16
0.168
10
12
0.173
11
9
0.182
12
7
0.190
13
5
0.213
14
4
0.228
15
3
0.252
16
2
0.285
17
1
0.493
best = path.loc[path.oob_mse.idxmin()]
fig, ax = plt.subplots(figsize= (8 , 5 ))
ax.scatter(path.n_variables, path.oob_mse, s= 45 , alpha= 0.6 , color= "grey" )
ax.scatter(best.n_variables, best.oob_mse, s= 110 , facecolor= "none" ,
edgecolor= "black" , linewidth= 2 )
ax.annotate(f"Minimum ( { int (best.n_variables)} predictors)" ,
xy= (best.n_variables, best.oob_mse),
xytext= (best.n_variables + 25 , best.oob_mse + 0.05 ),
arrowprops= dict (arrowstyle= "->" ))
ax.invert_xaxis()
ax.set_xlabel("Number of predictors" )
ax.set_ylabel("Out-of-bag error (MSE)" )
ax.spines[["top" , "right" ]].set_visible(False )
plt.show()
selected = list (best.variables)
Hyperparameter tuning
Sixty candidate configurations of trees, mtry and min_n are evaluated by five-fold cross-validation on the training set, selecting on RMSE.
from analysis.tune import stratified_split, tune_forest, tuning_results
split = stratified_split(wide, predictors= selected)
search = tune_forest(split)
tuning_results(search).head()
Fitting 5 folds for each of 60 candidates, totalling 300 fits
0
8
4
1719
0.416
0.733
0.318
1
19
3
995
0.418
0.732
0.316
2
15
4
1425
0.419
0.730
0.318
3
9
5
1942
0.422
0.725
0.322
4
19
5
1923
0.429
0.717
0.325
Model comparison — XGBoost
from analysis.tune import tune_xgboost
xgb_search = tune_xgboost(split)
tuning_results(xgb_search).head()
Fitting 5 folds for each of 60 candidates, totalling 300 fits
0
1.762e-03
0.008
13
18
0.411
0.740
0.321
1
5.864e-05
0.005
11
18
0.412
0.739
0.324
2
1.544e-01
0.038
6
21
0.419
0.730
0.325
3
1.904e-09
0.012
11
32
0.425
0.719
0.325
4
1.100e-07
0.003
12
13
0.432
0.712
0.332
Comparing between RF, XGBoost and OLS
from sklearn.linear_model import LinearRegression
from sklearn.metrics import mean_squared_error, r2_score, mean_absolute_error
import numpy as np, pandas as pd
lm = LinearRegression().fit(split.X_train, split.y_train)
models = {
"rf" : search.best_estimator_,
"lm" : lm,
"xgb" : xgb_search.best_estimator_,
}
rows = []
for name, model in models.items():
pred = model.predict(split.X_test)
rows.append({
"model" : name,
"rmse" : np.sqrt(mean_squared_error(split.y_test, pred)),
"rsq" : r2_score(split.y_test, pred),
"mae" : mean_absolute_error(split.y_test, pred),
})
comparison = pd.DataFrame(rows).round (3 )
comparison
0
rf
0.412
0.748
0.315
1
lm
0.484
0.653
0.373
2
xgb
0.439
0.713
0.327
Plotting the differences
fig, axes = plt.subplots(1 , 3 , figsize= (12 , 4 ), sharex= True , sharey= True )
for ax, (name, model) in zip (axes, models.items()):
pred = model.predict(split.X_test)
lo, hi = split.y_test.min (), split.y_test.max ()
ax.plot([lo, hi], [lo, hi], color= "green" , linestyle= "--" )
ax.scatter(pred, split.y_test, alpha= 0.4 , color= "black" )
ax.set_title(name)
ax.set_aspect("equal" )
ax.spines[["top" , "right" ]].set_visible(False )
axes[0 ].set_ylabel("Observed" )
fig.supxlabel("Predicted" )
plt.tight_layout()
Model agnostics
Variable importance
Permutation importance measures how much the prediction error increases when a predictor’s values are shuffled. Values are reported as a ratio to the original error, so 1.0 means the predictor carries no information. Demographic indicators are shown in bold.
from analysis.importance import feature_importance, plot_importance
X_all = wide[selected]
y_all = wide["SUBJ_LIFE_SAT" ]
imp_rf = feature_importance(models["rf" ], X_all, y_all)
imp_xgb = feature_importance(models["xgb" ], X_all, y_all)
fig, axes = plt.subplots(1 , 2 , figsize= (13 , 6 ))
plot_importance(imp_rf, ax= axes[0 ], title= "Random Forest" )
plot_importance(imp_xgb, ax= axes[1 ], title= "XGBoost" )
plt.tight_layout()
imp_rf
ALE plots
Accumulated Local Effects show the functional form of each predictor. Unlike partial dependence, ALE only evaluates the model on feature combinations that occur in the data — which matters here, since the selected predictors are substantially correlated.
from analysis.ale import ale_1d, plot_ale_1d
order = list (imp_rf.feature)
fig, axes = plt.subplots(4 , 4 , figsize= (12 , 10 ))
for ax, feature in zip (axes.ravel(), order):
plot_ale_1d(ale_1d(models["rf" ], X_all, feature), ax= ax)
fig.supylabel("ALE of subjective well-being" )
plt.tight_layout()
Interactions
from analysis.interactions import interaction_strength, interaction_with, plot_interaction
int_rf = interaction_strength(models["rf" ], X_all)
int_xgb = interaction_strength(models["xgb" ], X_all)
fig, axes = plt.subplots(1 , 2 , figsize= (13 , 6 ))
plot_interaction(int_rf, ax= axes[0 ], title= "Random Forest" )
plot_interaction(int_xgb, ax= axes[1 ], title= "XGBoost" )
plt.tight_layout()
Interactions with the employment rate
from analysis.ale import ale_2d, plot_ale_2d
emp_rf = interaction_with(models["rf" ], X_all, "EMP_RA" )
emp_xgb = interaction_with(models["xgb" ], X_all, "EMP_RA" )
r = ale_2d(models["rf" ], X_all, ("EMP_RA" , "SR_ELD_RA_T" ), grid_size= 20 )
print ("empty:" , (r.counts == 0 ).sum (), "of" , r.counts.size)
fig, axes = plt.subplots(1 , 2 , figsize= (13 , 6 ))
plot_interaction(emp_rf, ax= axes[0 ], title= "Random Forest" ,
xlabel= "Interaction strength with employment rate" )
plot_interaction(emp_xgb, ax= axes[1 ], title= "XGBoost" ,
xlabel= "Interaction strength with employment rate" )
plt.tight_layout()
The interaction surface
from analysis.ale import ale_2d, plot_ale_2d
top_partner = emp_rf.iloc[0 ].feature
fig, axes = plt.subplots(1 , 2 , figsize= (12 , 5 ))
plot_ale_2d(ale_2d(models["rf" ], X_all, ("EMP_RA" , top_partner), grid_size= 20 ), ax= axes[0 ])
axes[0 ].set_title(f"Strongest interaction: { top_partner} " )
plot_ale_2d(ale_2d(models["rf" ], X_all, ("EMP_RA" , "SR_ELD_RA_T" ), grid_size= 20 ), ax= axes[1 ])
axes[1 ].set_title("Manuscript pair: SR_ELD_RA_T" )
plt.tight_layout()
fig = plt.figure(figsize= (12 , 12 ))
gs = fig.add_gridspec(2 , 2 , height_ratios= [1 , 1.2 ], hspace= 0.3 , wspace= 0.4 )
ax_a = fig.add_subplot(gs[0 , 0 ])
ax_b = fig.add_subplot(gs[0 , 1 ])
ax_c = fig.add_subplot(gs[1 , :])
plot_interaction(int_rf, ax= ax_a)
plot_interaction(emp_rf, ax= ax_b,
xlabel= "Interaction strength with employment rate" )
plot_ale_2d(ale_2d(models["rf" ], X_all, ("EMP_RA" , "SR_ELD_RA_T" ), grid_size= 20 ), ax= ax_c)
for ax, tag in zip ((ax_a, ax_b, ax_c), "ABC" ):
ax.text(- 0.1 , 1.05 , tag, transform= ax.transAxes, fontsize= 14 , fontweight= "bold" )