<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Random Forest | Carlos Mendez</title><link>https://carlos-mendez.org/category/random-forest/</link><atom:link href="https://carlos-mendez.org/category/random-forest/index.xml" rel="self" type="application/rss+xml"/><description>Random Forest</description><generator>Wowchemy (https://wowchemy.com)</generator><language>en-us</language><copyright>Carlos Mendez</copyright><lastBuildDate>Tue, 10 Mar 2026 00:00:00 +0000</lastBuildDate><image><url>https://carlos-mendez.org/media/icon_huedfae549300b4ca5d201a9bd09a3ecd5_79625_512x512_fill_lanczos_center_3.png</url><title>Random Forest</title><link>https://carlos-mendez.org/category/random-forest/</link></image><item><title>Introduction to Causal Inference: Double Machine Learning</title><link>https://carlos-mendez.org/post/python_doubleml/</link><pubDate>Tue, 10 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_doubleml/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>Does a cash bonus actually cause unemployed workers to find jobs faster, or do the workers who receive bonuses simply differ from those who do not? This is the core challenge of &lt;strong>causal inference&lt;/strong>: separating a genuine treatment effect from the influence of &lt;em>confounders&lt;/em> — variables that affect both the treatment and the outcome, creating spurious associations. Standard regression can adjust for these confounders, but when their relationship with the outcome is complex and nonlinear, linear models may fail to fully remove bias.&lt;/p>
&lt;p>&lt;strong>Double Machine Learning (DML)&lt;/strong> addresses this problem by using flexible machine learning models to partial out the confounding variation, then estimating the causal effect on the cleaned residuals. In this tutorial we apply DML to the Pennsylvania Bonus Experiment, a real randomized study where some unemployment insurance claimants received a cash bonus for finding employment quickly. We estimate how much the bonus reduced unemployment duration, and we compare DML estimates against naive and covariate-adjusted OLS to see how debiasing changes the results.&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Understand why prediction and causal inference require different approaches&lt;/li>
&lt;li>Learn the Partially Linear Regression (PLR) model and the partialling-out estimator&lt;/li>
&lt;li>Implement Double Machine Learning with cross-fitting using the &lt;code>doubleml&lt;/code> package&lt;/li>
&lt;li>Interpret causal effect estimates, standard errors, and confidence intervals&lt;/li>
&lt;li>Assess robustness by comparing results across different ML learners&lt;/li>
&lt;/ul>
&lt;h2 id="setup-and-imports">Setup and imports&lt;/h2>
&lt;p>Before running the analysis, install the required package if needed:&lt;/p>
&lt;pre>&lt;code class="language-python">pip install doubleml
&lt;/code>&lt;/pre>
&lt;p>The following code imports all necessary libraries and sets the configuration variables for our analysis. We use &lt;code>RANDOM_SEED = 42&lt;/code> throughout to ensure reproducibility, and define the outcome, treatment, and covariate columns that will be used in all subsequent steps.&lt;/p>
&lt;pre>&lt;code class="language-python">import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.base import clone
from sklearn.ensemble import RandomForestRegressor
from sklearn.linear_model import LassoCV, LinearRegression
from doubleml import DoubleMLData, DoubleMLPLR
from doubleml.datasets import fetch_bonus
# Reproducibility
RANDOM_SEED = 42
np.random.seed(RANDOM_SEED)
# Configuration
OUTCOME = &amp;quot;inuidur1&amp;quot;
OUTCOME_LABEL = &amp;quot;Log Unemployment Duration&amp;quot;
TREATMENT = &amp;quot;tg&amp;quot;
COVARIATES = [
&amp;quot;female&amp;quot;, &amp;quot;black&amp;quot;, &amp;quot;othrace&amp;quot;, &amp;quot;dep1&amp;quot;, &amp;quot;dep2&amp;quot;,
&amp;quot;q2&amp;quot;, &amp;quot;q3&amp;quot;, &amp;quot;q4&amp;quot;, &amp;quot;q5&amp;quot;, &amp;quot;q6&amp;quot;,
&amp;quot;agelt35&amp;quot;, &amp;quot;agegt54&amp;quot;, &amp;quot;durable&amp;quot;, &amp;quot;lusd&amp;quot;, &amp;quot;husd&amp;quot;,
]
&lt;/code>&lt;/pre>
&lt;h2 id="data-loading-the-pennsylvania-bonus-experiment">Data loading: The Pennsylvania Bonus Experiment&lt;/h2>
&lt;p>The Pennsylvania Bonus Experiment is a well-known dataset in labor economics. In this study, a random subset of unemployment insurance claimants was offered a cash bonus if they found a new job within a qualifying period. The dataset records whether each claimant received the bonus offer (treatment) and how long they remained unemployed (outcome), along with demographic and labor market covariates.&lt;/p>
&lt;pre>&lt;code class="language-python">df = fetch_bonus(&amp;quot;DataFrame&amp;quot;)
print(f&amp;quot;Dataset shape: {df.shape}&amp;quot;)
print(f&amp;quot;Observations: {len(df)}&amp;quot;)
print(f&amp;quot;\nTreatment groups:&amp;quot;)
print(df[TREATMENT].value_counts().rename({0: &amp;quot;Control&amp;quot;, 1: &amp;quot;Bonus&amp;quot;}))
print(f&amp;quot;\nOutcome ({OUTCOME}) summary:&amp;quot;)
print(df[OUTCOME].describe().round(3))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Dataset shape: (5099, 26)
Observations: 5099
Treatment groups:
tg
Control 3354
Bonus 1745
Name: count, dtype: int64
Outcome (inuidur1) summary:
count 5099.000
mean 2.028
std 1.215
min 0.000
25% 1.099
50% 2.398
75% 3.219
max 3.951
Name: inuidur1, dtype: float64
&lt;/code>&lt;/pre>
&lt;p>The dataset contains 5,099 unemployment insurance claimants with 26 variables. The treatment is unevenly split: 1,745 claimants received the bonus offer while 3,354 served as controls. The outcome variable, log unemployment duration (&lt;code>inuidur1&lt;/code>), ranges from 0.0 to 3.95 with a mean of 2.028 and standard deviation of 1.215, indicating substantial variation in how long claimants remained unemployed. The median (2.398) exceeds the mean, suggesting a left-skewed distribution where some claimants found jobs very quickly. The interquartile range spans from 1.099 to 3.219, meaning the middle 50% of claimants had log durations in this band.&lt;/p>
&lt;h2 id="exploratory-data-analysis">Exploratory data analysis&lt;/h2>
&lt;h3 id="outcome-distribution-by-treatment-group">Outcome distribution by treatment group&lt;/h3>
&lt;p>Before modeling, we examine whether the outcome distributions differ visibly between treated and control groups. While a randomized experiment should produce balanced groups on average, visualizing the raw data helps us understand the structure of the outcome and spot any obvious patterns.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 5))
for group, label, color in [(0, &amp;quot;Control&amp;quot;, &amp;quot;#6a9bcc&amp;quot;), (1, &amp;quot;Bonus&amp;quot;, &amp;quot;#d97757&amp;quot;)]:
subset = df[df[TREATMENT] == group][OUTCOME]
ax.hist(subset, bins=30, alpha=0.6, label=f&amp;quot;{label} (mean={subset.mean():.3f})&amp;quot;,
color=color, edgecolor=&amp;quot;white&amp;quot;)
ax.set_xlabel(OUTCOME_LABEL)
ax.set_ylabel(&amp;quot;Count&amp;quot;)
ax.set_title(f&amp;quot;Distribution of {OUTCOME_LABEL} by Treatment Group&amp;quot;)
ax.legend()
plt.savefig(&amp;quot;doubleml_outcome_by_treatment.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="doubleml_outcome_by_treatment.png" alt="Distribution of log unemployment duration by treatment group.">&lt;/p>
&lt;p>The histogram reveals that both groups share a similar shape, with a concentration of claimants at higher log durations (around 3.0&amp;ndash;3.5) and a spread of shorter durations below 2.0. The bonus group shows a slightly lower mean (1.971) compared to the control group (2.057), a difference of about 0.09 log points. This raw gap hints at a potential treatment effect, but we cannot yet attribute it to the bonus because confounders may also differ between groups.&lt;/p>
&lt;h3 id="covariate-balance">Covariate balance&lt;/h3>
&lt;p>In a well-designed randomized experiment, the distribution of covariates should be roughly similar across treatment and control groups. We check this balance to verify that randomization worked as expected and to understand which characteristics might confound the treatment-outcome relationship if balance is imperfect.&lt;/p>
&lt;pre>&lt;code class="language-python">covariate_means = df.groupby(TREATMENT)[COVARIATES].mean()
fig, ax = plt.subplots(figsize=(12, 6))
x = np.arange(len(COVARIATES))
width = 0.35
ax.bar(x - width / 2, covariate_means.loc[0], width, label=&amp;quot;Control&amp;quot;,
color=&amp;quot;#6a9bcc&amp;quot;, edgecolor=&amp;quot;white&amp;quot;)
ax.bar(x + width / 2, covariate_means.loc[1], width, label=&amp;quot;Bonus&amp;quot;,
color=&amp;quot;#d97757&amp;quot;, edgecolor=&amp;quot;white&amp;quot;)
ax.set_xticks(x)
ax.set_xticklabels(COVARIATES, rotation=45, ha=&amp;quot;right&amp;quot;)
ax.set_ylabel(&amp;quot;Mean Value&amp;quot;)
ax.set_title(&amp;quot;Covariate Balance: Control vs Bonus Group&amp;quot;)
ax.legend()
plt.savefig(&amp;quot;doubleml_covariate_balance.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="doubleml_covariate_balance.png" alt="Covariate balance between control and bonus groups.">&lt;/p>
&lt;p>The covariate means are nearly identical across treatment and control groups for all 15 covariates, confirming that randomization produced well-balanced groups. Demographic variables like &lt;code>female&lt;/code>, &lt;code>black&lt;/code>, and age indicators show negligible differences, as do the economic indicators (&lt;code>durable&lt;/code>, &lt;code>lusd&lt;/code>, &lt;code>husd&lt;/code>). This balance is reassuring: it means that any difference in unemployment duration between groups is unlikely to be driven by observable confounders. Nevertheless, DML provides a principled way to adjust for these covariates and improve precision.&lt;/p>
&lt;h2 id="why-adjust-for-covariates">Why adjust for covariates?&lt;/h2>
&lt;p>Because the Pennsylvania Bonus Experiment is a randomized trial, treatment assignment is independent of covariates by design — there is no confounding bias to remove. However, adjusting for covariates can still improve the &lt;em>precision&lt;/em> of the causal estimate by absorbing residual variation in the outcome. In observational studies, covariate adjustment is essential to avoid confounding bias, but even in an RCT, it sharpens inference. The question is &lt;em>how&lt;/em> to adjust. Standard OLS assumes a linear relationship between covariates and the outcome, which may miss complex nonlinear patterns. The naive OLS model regresses the outcome $Y$ directly on the treatment $D$:&lt;/p>
&lt;p>$$Y_i = \alpha + \beta \, D_i + \epsilon_i \quad \text{(naive, no covariates)}$$&lt;/p>
&lt;p>Adding covariates $X$ linearly gives:&lt;/p>
&lt;p>$$Y_i = \alpha + \beta \, D_i + X_i' \gamma + \epsilon_i \quad \text{(with covariates)}$$&lt;/p>
&lt;p>In our data, $Y_i$ is &lt;code>inuidur1&lt;/code> (log unemployment duration), $D_i$ is &lt;code>tg&lt;/code> (the bonus indicator), and $X_i$ contains the 15 demographic and labor market covariates. In both cases, $\beta$ is the estimated treatment effect. But if the true relationship between $X$ and $Y$ is nonlinear, the linear specification may leave residual confounding in $\hat{\beta}$.&lt;/p>
&lt;h3 id="naive-ols-baseline">Naive OLS baseline&lt;/h3>
&lt;p>We start with two simple OLS regressions to establish baseline estimates: one with no covariates (naive), and one that linearly adjusts for all 15 covariates. These provide a reference point for evaluating how much DML&amp;rsquo;s flexible adjustment changes the estimated treatment effect.&lt;/p>
&lt;pre>&lt;code class="language-python"># Naive OLS: no covariates
ols = LinearRegression()
ols.fit(df[[TREATMENT]], df[OUTCOME])
naive_coef = ols.coef_[0]
# OLS with covariates
ols_full = LinearRegression()
ols_full.fit(df[[TREATMENT] + COVARIATES], df[OUTCOME])
ols_full_coef = ols_full.coef_[0]
print(f&amp;quot;Naive OLS coefficient (no covariates): {naive_coef:.4f}&amp;quot;)
print(f&amp;quot;OLS with covariates coefficient: {ols_full_coef:.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Naive OLS coefficient (no covariates): -0.0855
OLS with covariates coefficient: -0.0717
&lt;/code>&lt;/pre>
&lt;p>The naive OLS estimate is -0.0855, suggesting that the bonus is associated with an 8.6% reduction in log unemployment duration. Adding covariates shifts the estimate to -0.0717 (7.2% reduction). In a randomized experiment, this shift reflects precision improvement from absorbing residual variation — not confounding bias removal. Even so, linear adjustment may not capture complex nonlinear relationships between covariates and the outcome. Double Machine Learning will use flexible ML models to more thoroughly partial out covariate effects and further sharpen the estimate.&lt;/p>
&lt;h2 id="what-is-double-machine-learning">What is Double Machine Learning?&lt;/h2>
&lt;h3 id="the-partially-linear-regression-plr-model">The Partially Linear Regression (PLR) model&lt;/h3>
&lt;p>Double Machine Learning operates within the &lt;strong>Partially Linear Regression&lt;/strong> framework. The key idea is that the outcome $Y$ depends on the treatment $D$ through a linear coefficient (the causal effect we want) plus a potentially complex, nonlinear function of covariates $X$. The PLR model consists of two structural equations:&lt;/p>
&lt;p>$$Y = D \, \theta_0 + g_0(X) + \varepsilon, \quad E[\varepsilon \mid D, X] = 0$$&lt;/p>
&lt;p>$$D = m_0(X) + V, \quad E[V \mid X] = 0$$&lt;/p>
&lt;p>Here, $\theta_0$ is the causal parameter of interest — the &lt;strong>Average Treatment Effect (ATE)&lt;/strong> of the bonus on unemployment duration. The function $g_0(\cdot)$ is a &lt;em>nuisance function&lt;/em>, meaning it is not our target but something we must estimate along the way; it captures how covariates affect the outcome. Similarly, $m_0(\cdot)$ models how covariates predict treatment assignment. Think of nuisance functions as scaffolding: essential during construction but not part of the final result. The error terms $\varepsilon$ and $V$ are orthogonal to the covariates by construction. In our data, $Y$ = &lt;code>inuidur1&lt;/code>, $D$ = &lt;code>tg&lt;/code>, and $X$ includes the 15 covariate columns in &lt;code>COVARIATES&lt;/code>. The challenge is that both $g_0$ and $m_0$ can be arbitrarily complex — DML uses machine learning to estimate them flexibly.&lt;/p>
&lt;h3 id="the-partialling-out-estimator">The partialling-out estimator&lt;/h3>
&lt;p>The DML algorithm works in two stages. First, it uses ML models to predict the outcome from covariates alone (estimating $E[Y \mid X]$) and to predict the treatment from covariates alone (estimating $E[D \mid X]$). Then it computes residuals from both predictions — the part of $Y$ not explained by $X$, and the part of $D$ not explained by $X$:&lt;/p>
&lt;p>$$\tilde{Y} = Y - \hat{g}_0(X) = Y - \hat{E}[Y \mid X]$$&lt;/p>
&lt;p>$$\tilde{D} = D - \hat{m}_0(X) = D - \hat{E}[D \mid X]$$&lt;/p>
&lt;p>Finally, it regresses the outcome residuals on the treatment residuals to obtain the causal estimate:&lt;/p>
&lt;p>$$\hat{\theta}_0 = \left( \frac{1}{N} \sum_{i=1}^{N} \tilde{D}_i^2 \right)^{-1} \frac{1}{N} \sum_{i=1}^{N} \tilde{D}_i \, \tilde{Y}_i$$&lt;/p>
&lt;p>Think of this like noise-canceling headphones: the ML models learn the &amp;ldquo;noise&amp;rdquo; pattern (how covariates influence both $Y$ and $D$), and we subtract it away so that only the &amp;ldquo;signal&amp;rdquo; — the causal relationship between $D$ and $Y$ — remains.&lt;/p>
&lt;h3 id="cross-fitting-why-it-matters">Cross-fitting: why it matters&lt;/h3>
&lt;p>A naive implementation of partialling-out would use the same data to fit the ML models and compute residuals. This introduces &lt;strong>regularization bias&lt;/strong> — a distortion that occurs because the ML model&amp;rsquo;s complexity penalty contaminates the causal estimate. DML solves this with &lt;strong>cross-fitting&lt;/strong>: the data is split into $K$ folds, and each fold&amp;rsquo;s residuals are computed using ML models trained on the other $K-1$ folds. Think of it like grading exams: to avoid bias, we split the class into groups where each group&amp;rsquo;s predictions are made by a model that never saw their data. The cross-fitted estimator is:&lt;/p>
&lt;p>$$\hat{\theta}_0^{CF} = \left( \frac{1}{N} \sum_{k=1}^{K} \sum_{i \in I_k} \left(\tilde{D}_i^{(k)}\right)^2 \right)^{-1} \frac{1}{N} \sum_{k=1}^{K} \sum_{i \in I_k} \tilde{D}_i^{(k)} \, \tilde{Y}_i^{(k)}$$&lt;/p>
&lt;p>where $\tilde{Y}_i^{(k)}$ and $\tilde{D}_i^{(k)}$ are residuals for observation $i$ in fold $k$, computed using models trained on all folds except $k$. In words, we average the treatment effect estimates across all folds, where each fold&amp;rsquo;s estimate uses residuals computed from models that never saw that fold&amp;rsquo;s data. This ensures that the residuals are computed out-of-sample, eliminating overfitting bias and preserving valid statistical inference (standard errors, p-values, confidence intervals).&lt;/p>
&lt;h2 id="setting-up-doubleml">Setting up DoubleML&lt;/h2>
&lt;p>The &lt;code>doubleml&lt;/code> package provides a clean interface for implementing DML. We first wrap our data into a &lt;code>DoubleMLData&lt;/code> object that specifies the outcome, treatment, and covariate columns. Then we configure the ML learners: Random Forest regressors for both the outcome model &lt;code>ml_l&lt;/code> (estimating $\hat{g}_0$) and the treatment model &lt;code>ml_m&lt;/code> (estimating $\hat{m}_0$).&lt;/p>
&lt;pre>&lt;code class="language-python"># Prepare data for DoubleML
dml_data = DoubleMLData(df, y_col=OUTCOME, d_cols=TREATMENT, x_cols=COVARIATES)
print(dml_data)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>================== DoubleMLData Object ==================
------------------ Data summary ------------------
Outcome variable: inuidur1
Treatment variable(s): ['tg']
Covariates: ['female', 'black', 'othrace', 'dep1', 'dep2', 'q2', 'q3', 'q4', 'q5', 'q6', 'agelt35', 'agegt54', 'durable', 'lusd', 'husd']
Instrument variable(s): None
No. Observations: 5099
&lt;/code>&lt;/pre>
&lt;p>The &lt;code>DoubleMLData&lt;/code> object confirms our setup: &lt;code>inuidur1&lt;/code> as the outcome, &lt;code>tg&lt;/code> as the treatment, and all 15 covariates registered. The object reports 5,099 observations and no instrumental variables, which is correct for the PLR model. Separating the data into these three roles is fundamental to DML: the covariates $X$ will be partialled out from both $Y$ and $D$, while the treatment-outcome relationship $\theta_0$ is the sole target of inference.&lt;/p>
&lt;p>Now we configure the ML learners. We use Random Forest with 500 trees, max depth of 5, and &lt;code>sqrt&lt;/code> feature sampling — a moderate configuration that balances flexibility with regularization.&lt;/p>
&lt;pre>&lt;code class="language-python"># Configure ML learners
learner = RandomForestRegressor(n_estimators=500, max_features=&amp;quot;sqrt&amp;quot;,
max_depth=5, random_state=RANDOM_SEED)
ml_l_rf = clone(learner) # Learner for E[Y|X]
ml_m_rf = clone(learner) # Learner for E[D|X]
print(f&amp;quot;ml_l (outcome model): {type(ml_l_rf).__name__}&amp;quot;)
print(f&amp;quot;ml_m (treatment model): {type(ml_m_rf).__name__}&amp;quot;)
print(f&amp;quot; n_estimators={learner.n_estimators}, max_depth={learner.max_depth}, max_features='{learner.max_features}'&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>ml_l (outcome model): RandomForestRegressor
ml_m (treatment model): RandomForestRegressor
n_estimators=500, max_depth=5, max_features='sqrt'
&lt;/code>&lt;/pre>
&lt;p>Both the outcome and treatment models use &lt;code>RandomForestRegressor&lt;/code> with 500 estimators and max depth 5. The &lt;code>clone()&lt;/code> function creates independent copies so that each model is trained separately during the DML fitting process. The &lt;code>max_features='sqrt'&lt;/code> setting means each split considers only the square root of 15 covariates (about 4 features), adding randomness that reduces overfitting. Capping tree depth at 5 prevents overfitting to individual observations while still capturing nonlinear interactions among covariates — a balance that matters because overly complex nuisance models can destabilize the cross-fitted residuals.&lt;/p>
&lt;h2 id="fitting-the-plr-model">Fitting the PLR model&lt;/h2>
&lt;p>With data and learners configured, we fit the Partially Linear Regression model using 5-fold cross-fitting. The &lt;code>DoubleMLPLR&lt;/code> class handles the full DML pipeline: splitting data into folds, fitting ML models on training folds, computing out-of-sample residuals, and estimating the causal coefficient with valid standard errors.&lt;/p>
&lt;pre>&lt;code class="language-python">np.random.seed(RANDOM_SEED)
dml_plr_rf = DoubleMLPLR(dml_data, ml_l_rf, ml_m_rf, n_folds=5)
dml_plr_rf.fit()
print(dml_plr_rf.summary)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code> coef std err t P&amp;gt;|t| 2.5 % 97.5 %
tg -0.0736 0.0354 -2.077 0.0378 -0.1430 -0.0041
&lt;/code>&lt;/pre>
&lt;p>The DML estimate with Random Forest learners yields a treatment coefficient of -0.0736 with a standard error of 0.0354. The t-statistic is -2.077, producing a p-value of 0.0378, which is statistically significant at the 5% level. The 95% confidence interval is [-0.1430, -0.0041], meaning we are 95% confident that the true causal effect of the bonus lies between a 14.3% and 0.4% reduction in log unemployment duration.&lt;/p>
&lt;h2 id="interpreting-the-results">Interpreting the results&lt;/h2>
&lt;p>Let us extract and interpret the key quantities from the fitted model to understand both the statistical and practical significance of the estimated treatment effect.&lt;/p>
&lt;pre>&lt;code class="language-python">rf_coef = dml_plr_rf.coef[0]
rf_se = dml_plr_rf.se[0]
rf_pval = dml_plr_rf.pval[0]
rf_ci = dml_plr_rf.confint().values[0]
print(f&amp;quot;Coefficient (theta_0): {rf_coef:.4f}&amp;quot;)
print(f&amp;quot;Standard Error: {rf_se:.4f}&amp;quot;)
print(f&amp;quot;p-value: {rf_pval:.4f}&amp;quot;)
print(f&amp;quot;95% CI: [{rf_ci[0]:.4f}, {rf_ci[1]:.4f}]&amp;quot;)
print(f&amp;quot;\nInterpretation:&amp;quot;)
print(f&amp;quot; The bonus reduces log unemployment duration by {abs(rf_coef):.4f}.&amp;quot;)
print(f&amp;quot; This corresponds to approximately a {abs(rf_coef)*100:.1f}% reduction.&amp;quot;)
print(f&amp;quot; We are 95% confident the true effect lies between&amp;quot;)
print(f&amp;quot; {abs(rf_ci[1])*100:.1f}% and {abs(rf_ci[0])*100:.1f}% reduction.&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Coefficient (theta_0): -0.0736
Standard Error: 0.0354
p-value: 0.0378
95% CI: [-0.1430, -0.0041]
Interpretation:
The bonus reduces log unemployment duration by 0.0736.
This corresponds to approximately a 7.4% reduction.
We are 95% confident the true effect lies between
0.4% and 14.3% reduction.
&lt;/code>&lt;/pre>
&lt;p>The estimated causal effect is $\hat{\theta}_0 = -0.0736$, meaning the cash bonus reduces log unemployment duration by approximately 7.4%. Since the outcome is in log scale, this translates to roughly a 7.1% proportional reduction in actual unemployment duration (using $e^{-0.0736} - 1 \approx -0.071$). The effect is statistically significant ($p = 0.0378$), and the 95% confidence interval is constructed as:&lt;/p>
&lt;p>$$\text{CI}_{95\%} = \hat{\theta}_0 \pm 1.96 \times \text{SE}(\hat{\theta}_0) = -0.0736 \pm 1.96 \times 0.0354 = [-0.1430, \; -0.0041]$$&lt;/p>
&lt;p>The interval excludes zero, confirming that the bonus has a genuine causal impact. However, the wide interval — spanning from a 0.4% to 14.3% reduction — reflects meaningful uncertainty about the exact magnitude.&lt;/p>
&lt;h2 id="sensitivity-does-the-choice-of-ml-learner-matter">Sensitivity: does the choice of ML learner matter?&lt;/h2>
&lt;p>A key advantage of DML is that it is &lt;em>agnostic&lt;/em> to the choice of ML learner, as long as the learner is flexible enough to approximate the true confounding function. To verify that our results are not driven by the specific choice of Random Forest, we re-estimate the model using Lasso, a fundamentally different class of learner. Lasso is a linear regression with L1 regularization, meaning it adds a penalty proportional to the absolute size of each coefficient, which drives some coefficients to exactly zero and effectively performs variable selection.&lt;/p>
&lt;pre>&lt;code class="language-python">ml_l_lasso = LassoCV()
ml_m_lasso = LassoCV()
np.random.seed(RANDOM_SEED)
dml_plr_lasso = DoubleMLPLR(dml_data, ml_l_lasso, ml_m_lasso, n_folds=5)
dml_plr_lasso.fit()
print(dml_plr_lasso.summary)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code> coef std err t P&amp;gt;|t| 2.5 % 97.5 %
tg -0.0712 0.0354 -2.009 0.0445 -0.1406 -0.0018
&lt;/code>&lt;/pre>
&lt;p>The Lasso-based DML estimate is -0.0712 with a standard error of 0.0354 and p-value of 0.0445. This is remarkably close to the Random Forest estimate of -0.0736, with a difference of only 0.0024 — less than 7% of the standard error. The 95% confidence interval is [-0.1406, -0.0018], which also excludes zero. The near-identical results across two fundamentally different learners strongly support the robustness of the estimated treatment effect.&lt;/p>
&lt;h2 id="comparing-all-estimates">Comparing all estimates&lt;/h2>
&lt;p>To see how different estimation strategies affect the results, we visualize all four coefficient estimates side by side: naive OLS, OLS with covariates, DML with Random Forest, and DML with Lasso. The DML estimates include confidence intervals derived from valid statistical inference.&lt;/p>
&lt;pre>&lt;code class="language-python">lasso_coef = dml_plr_lasso.coef[0]
lasso_se = dml_plr_lasso.se[0]
lasso_ci = dml_plr_lasso.confint().values[0]
fig, ax = plt.subplots(figsize=(8, 5))
methods = [&amp;quot;Naive OLS&amp;quot;, &amp;quot;OLS + Covariates&amp;quot;, &amp;quot;DoubleML (RF)&amp;quot;, &amp;quot;DoubleML (Lasso)&amp;quot;]
coefs = [naive_coef, ols_full_coef, rf_coef, lasso_coef]
colors = [&amp;quot;#999999&amp;quot;, &amp;quot;#666666&amp;quot;, &amp;quot;#6a9bcc&amp;quot;, &amp;quot;#d97757&amp;quot;]
ax.barh(methods, coefs, color=colors, edgecolor=&amp;quot;white&amp;quot;, height=0.6)
ax.errorbar(rf_coef, 2, xerr=[[rf_coef - rf_ci[0]], [rf_ci[1] - rf_coef]],
fmt=&amp;quot;none&amp;quot;, color=&amp;quot;#141413&amp;quot;, capsize=5, linewidth=2)
ax.errorbar(lasso_coef, 3, xerr=[[lasso_coef - lasso_ci[0]], [lasso_ci[1] - lasso_coef]],
fmt=&amp;quot;none&amp;quot;, color=&amp;quot;#141413&amp;quot;, capsize=5, linewidth=2)
ax.axvline(0, color=&amp;quot;black&amp;quot;, linewidth=0.5, linestyle=&amp;quot;--&amp;quot;)
ax.set_xlabel(&amp;quot;Estimated Coefficient (Effect on Log Unemployment Duration)&amp;quot;)
ax.set_title(&amp;quot;Naive OLS vs Double Machine Learning Estimates&amp;quot;)
plt.savefig(&amp;quot;doubleml_coefficient_comparison.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="doubleml_coefficient_comparison.png" alt="Coefficient comparison across all estimation methods.">&lt;/p>
&lt;p>All four methods agree on the direction and approximate magnitude of the treatment effect: the bonus reduces unemployment duration. The naive OLS estimate (-0.0855) is the largest in absolute terms, while covariate adjustment and DML both shrink it toward -0.07. The DML estimates with Random Forest (-0.0736) and Lasso (-0.0712) cluster closely together and fall between the two OLS benchmarks. Crucially, only the DML estimates come with valid confidence intervals, both of which exclude zero, providing statistical evidence that the effect is real.&lt;/p>
&lt;h2 id="confidence-intervals">Confidence intervals&lt;/h2>
&lt;p>To better visualize the uncertainty around the DML estimates, we plot the 95% confidence intervals for both the Random Forest and Lasso specifications. If both intervals are similar and exclude zero, this strengthens our confidence in the causal conclusion.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 4))
y_pos = [0, 1]
labels = [&amp;quot;DoubleML (Random Forest)&amp;quot;, &amp;quot;DoubleML (Lasso)&amp;quot;]
point_estimates = [rf_coef, lasso_coef]
ci_low = [rf_ci[0], lasso_ci[0]]
ci_high = [rf_ci[1], lasso_ci[1]]
for i, (est, lo, hi, label) in enumerate(zip(point_estimates, ci_low, ci_high, labels)):
ax.plot([lo, hi], [i, i], color=&amp;quot;#6a9bcc&amp;quot; if i == 0 else &amp;quot;#d97757&amp;quot;, linewidth=3)
ax.plot(est, i, &amp;quot;o&amp;quot;, color=&amp;quot;#141413&amp;quot;, markersize=8, zorder=5)
ax.text(hi + 0.005, i, f&amp;quot;{est:.4f} [{lo:.4f}, {hi:.4f}]&amp;quot;, va=&amp;quot;center&amp;quot;, fontsize=9)
ax.axvline(0, color=&amp;quot;black&amp;quot;, linewidth=0.5, linestyle=&amp;quot;--&amp;quot;)
ax.set_yticks(y_pos)
ax.set_yticklabels(labels)
ax.set_xlabel(&amp;quot;Treatment Effect Estimate (95% CI)&amp;quot;)
ax.set_title(&amp;quot;Confidence Intervals: DoubleML Estimates&amp;quot;)
plt.savefig(&amp;quot;doubleml_confint.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="doubleml_confint.png" alt="95% confidence intervals for DoubleML estimates.">&lt;/p>
&lt;p>Both confidence intervals are nearly identical in width and position, spanning from roughly -0.14 to near zero. The Random Forest interval [-0.1430, -0.0041] and Lasso interval [-0.1406, -0.0018] both exclude zero, but just barely — the upper bounds are very close to zero (0.4% and 0.2% reduction, respectively). This tells us that while the bonus has a statistically significant negative effect on unemployment duration, the effect size is modest and estimated with considerable uncertainty.&lt;/p>
&lt;h2 id="summary-table">Summary table&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Coefficient&lt;/th>
&lt;th>Std Error&lt;/th>
&lt;th>p-value&lt;/th>
&lt;th>95% CI&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Naive OLS&lt;/td>
&lt;td>-0.0855&lt;/td>
&lt;td>&amp;ndash;&lt;/td>
&lt;td>&amp;ndash;&lt;/td>
&lt;td>&amp;ndash;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>OLS + Covariates&lt;/td>
&lt;td>-0.0717&lt;/td>
&lt;td>&amp;ndash;&lt;/td>
&lt;td>&amp;ndash;&lt;/td>
&lt;td>&amp;ndash;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DoubleML (RF)&lt;/td>
&lt;td>-0.0736&lt;/td>
&lt;td>0.0354&lt;/td>
&lt;td>0.0378&lt;/td>
&lt;td>[-0.1430, -0.0041]&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>DoubleML (Lasso)&lt;/td>
&lt;td>-0.0712&lt;/td>
&lt;td>0.0354&lt;/td>
&lt;td>0.0445&lt;/td>
&lt;td>[-0.1406, -0.0018]&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The summary table confirms a consistent pattern across all four estimation methods. The naive OLS gives the largest estimate at -0.0855; adjusting for covariates improves precision and shifts the estimate toward -0.07. The two DML specifications produce very similar estimates of -0.0736 and -0.0712. Both DML p-values are below 0.05, providing statistically significant evidence of a causal effect. The standard errors are identical (0.0354), which is expected since both use the same sample size and cross-fitting structure.&lt;/p>
&lt;h2 id="discussion">Discussion&lt;/h2>
&lt;p>The Pennsylvania Bonus Experiment provides a clear demonstration of Double Machine Learning in action. Because the experiment was randomized, the DML estimates are close to the OLS estimates — the confounding function $g_0(X)$ is relatively flat when treatment assignment is independent of covariates. This is actually reassuring: in a well-designed experiment, flexible covariate adjustment should not dramatically change the results, and indeed the DML estimates ($\hat{\theta}_0 = -0.0736, -0.0712$) are close to the covariate-adjusted OLS (-0.0717).&lt;/p>
&lt;p>The key finding is that the cash bonus reduces log unemployment duration by approximately 7.4%, and this effect is statistically significant (p &amp;lt; 0.05). In practical terms, this means the bonus incentive helped claimants find new jobs somewhat faster. However, the wide confidence intervals suggest that the true effect could be as small as 0.4% or as large as 14.3%, so policymakers should be cautious about the precise magnitude.&lt;/p>
&lt;p>The robustness across learners (Random Forest vs. Lasso) is a strength of DML. Both learners capture similar confounding structure, and the near-identical estimates provide evidence that the result is not an artifact of a particular modeling choice.&lt;/p>
&lt;h2 id="summary-and-next-steps">Summary and next steps&lt;/h2>
&lt;p>This tutorial demonstrated Double Machine Learning for causal inference using the Pennsylvania Bonus Experiment. The key takeaways are:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Method:&lt;/strong> DML&amp;rsquo;s main advantage over OLS is not the point estimate (both give ~7% here) but the &lt;em>infrastructure&lt;/em> — valid standard errors, confidence intervals, and robustness to nonlinear confounding. On this RCT the estimates are similar; on observational data where $g_0(X)$ is complex, OLS would break down while DML remains valid&lt;/li>
&lt;li>&lt;strong>Data:&lt;/strong> The cash bonus reduces unemployment duration by 7.4% ($p = 0.038$, 95% CI: [-14.3%, -0.4%]). The wide CI means the true effect could be anywhere from negligible to substantial — policymakers should not over-interpret the point estimate&lt;/li>
&lt;li>&lt;strong>Robustness:&lt;/strong> Random Forest and Lasso produce nearly identical estimates (-0.0736 vs -0.0712), differing by less than 7% of the standard error. This learner-agnosticism is a core strength of the DML framework&lt;/li>
&lt;li>&lt;strong>Limitation:&lt;/strong> The PLR model assumes a constant treatment effect ($\theta_0$ is the same for everyone). If the bonus helps some subgroups more than others (e.g., younger vs. older workers), PLR will average over this heterogeneity — use the Interactive Regression Model (IRM) to detect it&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Limitations:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>The Pennsylvania Bonus Experiment is a randomized trial, which is the easiest setting for causal inference. DML&amp;rsquo;s advantages are more pronounced in observational studies where confounding is severe&lt;/li>
&lt;li>We used the PLR model, which assumes a linear treatment effect ($\theta_0$ is constant). More complex treatment heterogeneity could be explored with the Interactive Regression Model (IRM)&lt;/li>
&lt;li>The confidence intervals are wide, reflecting limited sample size and moderate signal strength&lt;/li>
&lt;li>We did not explore heterogeneous treatment effects — situations where the bonus might help some subgroups (e.g., younger workers, women) more than others&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Next steps:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Apply DML to an observational dataset where confounding is more severe&lt;/li>
&lt;li>Explore the Interactive Regression Model for binary treatments&lt;/li>
&lt;li>Investigate treatment effect heterogeneity using DoubleML&amp;rsquo;s &lt;code>cate()&lt;/code> functionality&lt;/li>
&lt;li>Compare additional ML learners (gradient boosting, neural networks)&lt;/li>
&lt;/ul>
&lt;h2 id="exercises">Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Change the number of folds.&lt;/strong> Re-run the DML analysis with &lt;code>n_folds=3&lt;/code> and &lt;code>n_folds=10&lt;/code>. How do the estimates and standard errors change? What are the tradeoffs of using more vs. fewer folds?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Try a different ML learner.&lt;/strong> Replace the Random Forest with &lt;code>GradientBoostingRegressor&lt;/code> from scikit-learn. Does the estimated treatment effect change? Compare the result to the RF and Lasso estimates.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Investigate heterogeneous effects.&lt;/strong> Split the sample by gender (&lt;code>female&lt;/code>) and estimate the DML treatment effect separately for men and women. Is the bonus more effective for one group? What might explain any differences?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;p>&lt;strong>Academic references:&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>&lt;a href="https://doi.org/10.1111/ectj.12097" target="_blank" rel="noopener">Chernozhukov, V., Chetverikov, D., Demirer, M., Duflo, E., Hansen, C., Newey, W., &amp;amp; Robins, J. (2018). Double/Debiased Machine Learning for Treatment and Structural Parameters. The Econometrics Journal, 21(1), C1&amp;ndash;C68.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.jstor.org/stable/1814176" target="_blank" rel="noopener">Woodbury, S. A., &amp;amp; Spiegelman, R. G. (1987). Bonuses to Workers and Employers to Reduce Unemployment: Randomized Trials in Illinois. American Economic Review, 77(4), 513&amp;ndash;530.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.doubleml.org/stable/api/datasets.html#doubleml.datasets.fetch_bonus" target="_blank" rel="noopener">Pennsylvania Bonus Experiment Dataset &amp;ndash; DoubleML&lt;/a>&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Package and API documentation:&lt;/strong>&lt;/p>
&lt;ol start="4">
&lt;li>&lt;a href="https://docs.doubleml.org/stable/intro/intro.html" target="_blank" rel="noopener">DoubleML &amp;ndash; Python Documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.doubleml.org/stable/api/generated/doubleml.DoubleMLPLR.html" target="_blank" rel="noopener">DoubleMLPLR &amp;ndash; API Reference&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://docs.doubleml.org/stable/api/generated/doubleml.DoubleMLData.html" target="_blank" rel="noopener">DoubleMLData &amp;ndash; API Reference&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestRegressor.html" target="_blank" rel="noopener">scikit-learn &amp;ndash; RandomForestRegressor&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LassoCV.html" target="_blank" rel="noopener">scikit-learn &amp;ndash; LassoCV&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LinearRegression.html" target="_blank" rel="noopener">scikit-learn &amp;ndash; LinearRegression&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://numpy.org/doc/stable/" target="_blank" rel="noopener">NumPy &amp;ndash; Documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://pandas.pydata.org/docs/" target="_blank" rel="noopener">pandas &amp;ndash; Documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://matplotlib.org/stable/" target="_blank" rel="noopener">Matplotlib &amp;ndash; Documentation&lt;/a>&lt;/li>
&lt;/ol>
&lt;h4 id="acknowledgements">Acknowledgements&lt;/h4>
&lt;p>AI tools (Claude Code, Gemini, NotebookLM) were used to make the contents of this post more accessible to students. Nevertheless, the content in this post may still have errors. Caution is needed when applying the contents of this post to true research projects.&lt;/p></description></item><item><title>Introduction to Machine Learning: Random Forest Regression</title><link>https://carlos-mendez.org/post/python_ml_random_forest/</link><pubDate>Tue, 10 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_ml_random_forest/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>Can satellite imagery predict how well a municipality is developing? This notebook explores that question by applying Random Forest regression to predict Bolivia&amp;rsquo;s Municipal Sustainable Development Index (IMDS) from satellite image embeddings. IMDS is a composite index (0&amp;ndash;100 scale) that captures how well each of Bolivia&amp;rsquo;s 339 municipalities is progressing toward sustainable development goals. Satellite embeddings are 64-dimensional feature vectors extracted from 2017 satellite imagery &amp;mdash; they compress visual information about land use, urbanization, and terrain into numbers a model can learn from.&lt;/p>
&lt;p>The Random Forest algorithm is a natural starting point for this kind of tabular prediction task: it handles non-linear relationships, requires minimal preprocessing, and provides built-in measures of feature importance. By the end of this tutorial, we will know how much development-related signal satellite imagery actually contains &amp;mdash; and where its predictive power falls short.&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Understand the Random Forest algorithm and why it works well for tabular data&lt;/li>
&lt;li>Follow ML best practices: train/test split, cross-validation, hyperparameter tuning&lt;/li>
&lt;li>Interpret model performance metrics (R², RMSE, MAE)&lt;/li>
&lt;li>Analyze feature importance and partial dependence plots&lt;/li>
&lt;li>Build intuition for when ML adds value over simpler approaches&lt;/li>
&lt;/ul>
&lt;pre>&lt;code class="language-python">import sys
if &amp;quot;google.colab&amp;quot; in sys.modules:
!git clone --depth 1 https://github.com/cmg777/claude4data.git /content/claude4data 2&amp;gt;/dev/null || true
%cd /content/claude4data/notebooks
sys.path.insert(0, &amp;quot;..&amp;quot;)
from config import set_seeds, RANDOM_SEED, IMAGES_DIR, TABLES_DIR, DATA_DIR
set_seeds()
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from scipy.stats import randint
from sklearn.model_selection import train_test_split, cross_val_score, RandomizedSearchCV
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import r2_score, mean_squared_error, mean_absolute_error
from sklearn.inspection import PartialDependenceDisplay, permutation_importance
# Configuration
TARGET = &amp;quot;imds&amp;quot;
TARGET_LABEL = &amp;quot;IMDS (Municipal Sustainable Development Index)&amp;quot;
FEATURE_COLS = [f&amp;quot;A{i:02d}&amp;quot; for i in range(64)]
DS4BOLIVIA_BASE = &amp;quot;https://raw.githubusercontent.com/quarcs-lab/ds4bolivia/master&amp;quot;
CACHE_PATH = DATA_DIR / &amp;quot;rawData&amp;quot; / &amp;quot;ds4bolivia_merged.csv&amp;quot;
&lt;/code>&lt;/pre>
&lt;h2 id="data-loading">Data Loading&lt;/h2>
&lt;p>The data comes from the &lt;a href="https://github.com/quarcs-lab/ds4bolivia" target="_blank" rel="noopener">DS4Bolivia&lt;/a> repository, which provides standardized datasets for studying Bolivian development. We merge three tables on &lt;code>asdf_id&lt;/code> &amp;mdash; the unique identifier for each municipality: SDG indices (our target variables), satellite embeddings (our features), and region names (for context).&lt;/p>
&lt;pre>&lt;code class="language-python">if CACHE_PATH.exists():
print(f&amp;quot;Loading cached data from {CACHE_PATH}&amp;quot;)
df = pd.read_csv(CACHE_PATH)
else:
print(&amp;quot;Downloading data from DS4Bolivia...&amp;quot;)
sdg = pd.read_csv(f&amp;quot;{DS4BOLIVIA_BASE}/sdg/sdg.csv&amp;quot;)
embeddings = pd.read_csv(
f&amp;quot;{DS4BOLIVIA_BASE}/satelliteEmbeddings/satelliteEmbeddings2017.csv&amp;quot;
)
regions = pd.read_csv(f&amp;quot;{DS4BOLIVIA_BASE}/regionNames/regionNames.csv&amp;quot;)
df = sdg.merge(embeddings, on=&amp;quot;asdf_id&amp;quot;).merge(regions, on=&amp;quot;asdf_id&amp;quot;)
CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(CACHE_PATH, index=False)
print(f&amp;quot;Cached merged data to {CACHE_PATH}&amp;quot;)
X = df[FEATURE_COLS]
y = df[TARGET]
mask = X.notna().all(axis=1) &amp;amp; y.notna()
X = X[mask]
y = y[mask]
print(f&amp;quot;Dataset shape: {df.shape}&amp;quot;)
print(f&amp;quot;Observations after dropping missing: {len(y)}&amp;quot;)
print(f&amp;quot;\nTarget variable ({TARGET}) summary:&amp;quot;)
print(y.describe().round(2))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Downloading data from DS4Bolivia...
Cached merged data to data/rawData/ds4bolivia_merged.csv
Dataset shape: (339, 88)
Observations after dropping missing: 339
Target variable (imds) summary:
count 339.00
mean 51.05
std 6.77
min 35.70
25% 47.00
50% 50.50
75% 54.85
max 80.20
Name: imds, dtype: float64
&lt;/code>&lt;/pre>
&lt;p>All 339 Bolivian municipalities loaded successfully with no missing values &amp;mdash; the dataset provides complete national coverage. The merged data has 88 columns: the 64 satellite embedding features, SDG indices, and region identifiers. IMDS scores range from 35.70 to 80.20 with a mean of 51.05 and standard deviation of 6.77, meaning most municipalities cluster within about 7 points of the national average on the 0&amp;ndash;100 scale.&lt;/p>
&lt;h2 id="exploratory-data-analysis">Exploratory Data Analysis&lt;/h2>
&lt;p>Before building any model, we explore the data to understand its structure. EDA helps us spot issues &amp;mdash; skewed distributions, outliers, or weak feature correlations &amp;mdash; that could affect model performance. It also builds intuition about what patterns the model might find.&lt;/p>
&lt;h3 id="target-distribution">Target Distribution&lt;/h3>
&lt;p>The histogram below shows how IMDS values are distributed across municipalities. The shape of this distribution matters: a highly skewed target can bias predictions toward the majority range.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 5))
ax.hist(y, bins=30, edgecolor=&amp;quot;white&amp;quot;, alpha=0.8, color=&amp;quot;#6a9bcc&amp;quot;)
ax.axvline(y.mean(), color=&amp;quot;#d97757&amp;quot;, linestyle=&amp;quot;--&amp;quot;, linewidth=2, label=f&amp;quot;Mean = {y.mean():.1f}&amp;quot;)
ax.axvline(y.median(), color=&amp;quot;#141413&amp;quot;, linestyle=&amp;quot;:&amp;quot;, linewidth=2, label=f&amp;quot;Median = {y.median():.1f}&amp;quot;)
ax.set_xlabel(TARGET_LABEL)
ax.set_ylabel(&amp;quot;Count&amp;quot;)
ax.set_title(f&amp;quot;Distribution of {TARGET_LABEL}&amp;quot;)
ax.legend()
plt.savefig(IMAGES_DIR / &amp;quot;ml_target_distribution.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="ml_target_distribution.png" alt="Distribution of IMDS scores across Bolivia&amp;rsquo;s municipalities. The dashed line marks the mean, the dotted line marks the median.">&lt;/p>
&lt;p>The distribution is roughly bell-shaped with a slight right skew &amp;mdash; the mean (51.1) sits just above the median (50.5), indicating a small tail of higher-performing municipalities. Most scores fall between 47 and 55, meaning the majority of Bolivia&amp;rsquo;s municipalities have similar mid-range development levels. The handful of outliers above 70 likely correspond to larger urban centers like La Paz, Santa Cruz, and Cochabamba, which have significantly higher development infrastructure.&lt;/p>
&lt;h3 id="embedding-correlations">Embedding Correlations&lt;/h3>
&lt;p>Next we examine which satellite embedding dimensions are most correlated with the target. Strong correlations suggest the model has useful signal to learn from; weak correlations across the board would be a warning sign.&lt;/p>
&lt;pre>&lt;code class="language-python">correlations = X.corrwith(y).abs().sort_values(ascending=False)
top10_features = correlations.head(10).index.tolist()
corr_matrix = df[top10_features + [TARGET]].corr()
fig, ax = plt.subplots(figsize=(10, 8))
sns.heatmap(corr_matrix, annot=True, fmt=&amp;quot;.2f&amp;quot;, cmap=&amp;quot;RdBu_r&amp;quot;, center=0,
square=True, ax=ax, vmin=-1, vmax=1)
ax.set_title(f&amp;quot;Correlations: Top-10 Embeddings &amp;amp; {TARGET_LABEL}&amp;quot;)
plt.savefig(IMAGES_DIR / &amp;quot;ml_embedding_correlations.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="ml_embedding_correlations.png" alt="Correlation matrix of the top-10 most correlated satellite embedding dimensions with IMDS.">&lt;/p>
&lt;p>The heatmap reveals that the strongest individual correlations between embedding dimensions and IMDS are moderate (in the 0.25&amp;ndash;0.40 range), which is typical for satellite-derived features predicting complex socioeconomic outcomes. Several embedding dimensions are also correlated with each other, suggesting they capture overlapping spatial patterns &amp;mdash; the Random Forest can handle this &lt;em>multicollinearity&lt;/em> &amp;mdash; features carrying overlapping information &amp;mdash; well since it selects feature subsets at each split. With these moderate correlations, the model has real signal to work with, so let&amp;rsquo;s proceed to building it.&lt;/p>
&lt;h2 id="traintest-split">Train/Test Split&lt;/h2>
&lt;p>Now that we understand the data&amp;rsquo;s structure, we can prepare it for modeling. We split the data into training (80%) and test (20%) sets &lt;em>before&lt;/em> any model fitting. This is a fundamental ML practice: if the model ever &amp;ldquo;sees&amp;rdquo; the test data during training or tuning, our performance estimate will be overly optimistic &amp;mdash; a problem called &lt;strong>data leakage&lt;/strong>. The &lt;code>random_state&lt;/code> ensures the same split every time we run the notebook, making results reproducible.&lt;/p>
&lt;pre>&lt;code class="language-python">X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=RANDOM_SEED
)
print(f&amp;quot;Training set: {len(X_train)} municipalities&amp;quot;)
print(f&amp;quot;Test set: {len(X_test)} municipalities&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Training set: 271 municipalities
Test set: 68 municipalities
&lt;/code>&lt;/pre>
&lt;p>The split gives us 271 municipalities for training and 68 for testing. With only 339 total observations, this is a relatively small dataset for ML &amp;mdash; the test set of 68 means each test prediction represents about 1.5% of the data. This makes cross-validation especially important for getting reliable performance estimates, since a single 68-sample test set could be unrepresentative by chance.&lt;/p>
&lt;h2 id="baseline-model">Baseline Model&lt;/h2>
&lt;p>Before tuning anything, we establish a baseline using a Random Forest with default hyperparameters. &lt;strong>Random Forest&lt;/strong> works by building many decision trees on random subsets of the data and features, then averaging their predictions. This &amp;ldquo;wisdom of crowds&amp;rdquo; approach reduces overfitting compared to a single decision tree. Formally, the prediction is:&lt;/p>
&lt;p>$$\hat{y} = \frac{1}{B} \sum_{b=1}^{B} T_b(\mathbf{x})$$&lt;/p>
&lt;p>In words, the predicted value $\hat{y}$ is the average of predictions from all $B$ individual trees. Each tree $T_b$ sees a different random subset of training rows and features, so the trees make different errors &amp;mdash; averaging cancels out much of the noise. Here $B$ corresponds to the &lt;code>n_estimators&lt;/code> parameter (100 in our baseline, 500 after tuning) and $\mathbf{x}$ is the 64-dimensional satellite embedding vector for a given municipality.&lt;/p>
&lt;h3 id="cross-validation">Cross-Validation&lt;/h3>
&lt;p>Think of cross-validation as a rotating exam: the model takes turns training on different subsets and testing on the remainder, so no single lucky split determines the score. We evaluate the baseline with 5-fold cross-validation on the training set. Instead of a single train/validation split, k-fold CV rotates through 5 different validation sets and averages the scores. This gives a more reliable and stable performance estimate, especially important with smaller datasets like ours.&lt;/p>
&lt;pre>&lt;code class="language-python">baseline_rf = RandomForestRegressor(n_estimators=100, random_state=RANDOM_SEED)
cv_scores = cross_val_score(baseline_rf, X_train, y_train, cv=5, scoring=&amp;quot;r2&amp;quot;)
print(f&amp;quot;5-Fold CV R² scores: {cv_scores.round(4)}&amp;quot;)
print(f&amp;quot;Mean CV R²: {cv_scores.mean():.4f} (+/- {cv_scores.std():.4f})&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>5-Fold CV R² scores: [0.152 0.1867 0.2704 0.3084 0.3454]
Mean CV R²: 0.2526 (+/- 0.0728)
&lt;/code>&lt;/pre>
&lt;p>The 5-fold CV R² scores range from 0.152 to 0.345, with a mean of 0.2526 (+/- 0.0728). This means the baseline model explains about 25% of the variation in IMDS on average, but the high variability across folds (standard deviation of 0.07) reflects the small dataset &amp;mdash; different subsets of 271 municipalities can look quite different from each other. An R² around 0.25 is a reasonable starting point for predicting a complex social outcome from satellite imagery alone.&lt;/p>
&lt;h3 id="test-evaluation">Test Evaluation&lt;/h3>
&lt;p>We now fit the baseline on the full training set and evaluate on the held-out test data. This gives our first concrete performance estimate &amp;mdash; a reference point that any tuning should improve upon.&lt;/p>
&lt;pre>&lt;code class="language-python">baseline_rf.fit(X_train, y_train)
baseline_pred = baseline_rf.predict(X_test)
baseline_r2 = r2_score(y_test, baseline_pred)
baseline_rmse = np.sqrt(mean_squared_error(y_test, baseline_pred))
baseline_mae = mean_absolute_error(y_test, baseline_pred)
print(f&amp;quot;Baseline Test R²: {baseline_r2:.4f}&amp;quot;)
print(f&amp;quot;Baseline Test RMSE: {baseline_rmse:.2f}&amp;quot;)
print(f&amp;quot;Baseline Test MAE: {baseline_mae:.2f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Baseline Test R²: 0.2307
Baseline Test RMSE: 6.52
Baseline Test MAE: 4.68
&lt;/code>&lt;/pre>
&lt;p>On the held-out test set, the baseline achieves R² = 0.2307, RMSE = 6.52, and MAE = 4.68. In practical terms, the model&amp;rsquo;s predictions are typically off by about 4.7 IMDS points (MAE) on a scale where most values fall between 47 and 55. The RMSE of 6.52 is higher than the MAE, indicating some larger errors are pulling it up. This baseline gives us a concrete reference &amp;mdash; any improvement from tuning should beat these numbers.&lt;/p>
&lt;h2 id="hyperparameter-tuning">Hyperparameter Tuning&lt;/h2>
&lt;p>The baseline model uses scikit-learn&amp;rsquo;s defaults, but we can often do better by searching for optimal hyperparameters. &lt;strong>RandomizedSearchCV&lt;/strong> is more efficient than exhaustive grid search &amp;mdash; it samples random combinations and evaluates each with cross-validation. Here&amp;rsquo;s what each hyperparameter controls:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>n_estimators&lt;/strong>: Number of trees in the forest (more trees = more stable but slower)&lt;/li>
&lt;li>&lt;strong>max_depth&lt;/strong>: How deep each tree can grow (deeper = more complex patterns but risk overfitting)&lt;/li>
&lt;li>&lt;strong>min_samples_split&lt;/strong>: Minimum samples needed to split a node (higher = more regularization)&lt;/li>
&lt;li>&lt;strong>min_samples_leaf&lt;/strong>: Minimum samples in a leaf node (higher = smoother predictions)&lt;/li>
&lt;li>&lt;strong>max_features&lt;/strong>: How many features each tree considers per split (fewer = more diverse trees)&lt;/li>
&lt;/ul>
&lt;pre>&lt;code class="language-python">param_distributions = {
&amp;quot;n_estimators&amp;quot;: [100, 200, 300, 500],
&amp;quot;max_depth&amp;quot;: [None, 10, 20, 30],
&amp;quot;min_samples_split&amp;quot;: randint(2, 11),
&amp;quot;min_samples_leaf&amp;quot;: randint(1, 5),
&amp;quot;max_features&amp;quot;: [&amp;quot;sqrt&amp;quot;, &amp;quot;log2&amp;quot;, None],
}
search = RandomizedSearchCV(
RandomForestRegressor(random_state=RANDOM_SEED),
param_distributions=param_distributions,
n_iter=50,
cv=5,
scoring=&amp;quot;r2&amp;quot;,
random_state=RANDOM_SEED,
n_jobs=-1,
)
search.fit(X_train, y_train)
print(f&amp;quot;Best CV R²: {search.best_score_:.4f}&amp;quot;)
print(f&amp;quot;\nBest parameters:&amp;quot;)
for param, value in search.best_params_.items():
print(f&amp;quot; {param}: {value}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Best CV R²: 0.2721
Best parameters:
max_depth: 30
max_features: sqrt
min_samples_leaf: 1
min_samples_split: 4
n_estimators: 500
&lt;/code>&lt;/pre>
&lt;p>The best configuration found uses 500 trees with max_depth=30, max_features=sqrt, min_samples_leaf=1, and min_samples_split=4. The best CV R² of 0.2721 is modestly higher than the baseline&amp;rsquo;s 0.2526 &amp;mdash; about a 2 percentage point improvement in explained variance. The tuning selected a deeper, more complex model (max_depth=30 vs the default of unlimited) while constraining feature subsampling to sqrt(64)=8 features per split, which encourages tree diversity.&lt;/p>
&lt;h2 id="model-evaluation">Model Evaluation&lt;/h2>
&lt;p>Now we evaluate the tuned model on the held-out test set &amp;mdash; data the model has never seen during training or tuning. Three complementary metrics tell us different things:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>R²&lt;/strong> (coefficient of determination): What fraction of the target&amp;rsquo;s variance the model explains. R² = 1.0 is perfect; R² = 0 means the model is no better than predicting the mean.&lt;/li>
&lt;li>&lt;strong>RMSE&lt;/strong> (Root Mean Squared Error): Average prediction error in the same units as the target. Penalizes large errors more heavily.&lt;/li>
&lt;li>&lt;strong>MAE&lt;/strong> (Mean Absolute Error): Average absolute error. More robust to outliers than RMSE.&lt;/li>
&lt;/ul>
&lt;p>$$R^2 = 1 - \frac{\sum_{i=1}^{n}(y_i - \hat{y}_i)^2}{\sum_{i=1}^{n}(y_i - \bar{y})^2}$$&lt;/p>
&lt;p>$$\text{RMSE} = \sqrt{\frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2} \qquad \text{MAE} = \frac{1}{n}\sum_{i=1}^{n}|y_i - \hat{y}_i|$$&lt;/p>
&lt;p>In these formulas, $y_i$ is the actual IMDS value for municipality $i$, $\hat{y}_i$ is the model&amp;rsquo;s prediction, and $\bar{y}$ is the mean IMDS across all test municipalities. R² compares total prediction error to the naive baseline of always guessing the mean &amp;mdash; higher is better. RMSE and MAE both measure average error in IMDS points, but RMSE penalizes large misses more heavily because it squares the errors before averaging. In code, $y_i$ is &lt;code>y_test&lt;/code>, $\hat{y}_i$ is &lt;code>tuned_pred&lt;/code>, and $n$ is 68 (the test set size).&lt;/p>
&lt;pre>&lt;code class="language-python">best_rf = search.best_estimator_
tuned_pred = best_rf.predict(X_test)
tuned_r2 = r2_score(y_test, tuned_pred)
tuned_rmse = np.sqrt(mean_squared_error(y_test, tuned_pred))
tuned_mae = mean_absolute_error(y_test, tuned_pred)
print(f&amp;quot;Tuned Test R²: {tuned_r2:.4f}&amp;quot;)
print(f&amp;quot;Tuned Test RMSE: {tuned_rmse:.2f}&amp;quot;)
print(f&amp;quot;Tuned Test MAE: {tuned_mae:.2f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Tuned Test R²: 0.2297
Tuned Test RMSE: 6.52
Tuned Test MAE: 4.72
&lt;/code>&lt;/pre>
&lt;p>The tuned model achieves R² = 0.2297, RMSE = 6.52, and MAE = 4.72 on the test set &amp;mdash; essentially identical to the baseline (R² = 0.2307, RMSE = 6.52, MAE = 4.68). This is a common finding with small datasets: the tuning improved CV performance slightly but the gains didn&amp;rsquo;t transfer to the specific test set. The model explains about 23% of IMDS variation, meaning satellite embeddings capture real but limited predictive signal for municipal development.&lt;/p>
&lt;h3 id="actual-vs-predicted">Actual vs Predicted&lt;/h3>
&lt;p>This scatter plot shows how well the model&amp;rsquo;s predictions match reality. Points falling exactly on the dashed 45-degree line would indicate perfect predictions; scatter around the line shows prediction error.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(7, 7))
ax.scatter(y_test, tuned_pred, alpha=0.6, edgecolors=&amp;quot;white&amp;quot;, linewidth=0.5, color=&amp;quot;#6a9bcc&amp;quot;)
lims = [min(y_test.min(), tuned_pred.min()) - 2, max(y_test.max(), tuned_pred.max()) + 2]
ax.plot(lims, lims, &amp;quot;--&amp;quot;, color=&amp;quot;#d97757&amp;quot;, linewidth=2, label=&amp;quot;Perfect prediction&amp;quot;)
ax.set_xlim(lims)
ax.set_ylim(lims)
ax.set_xlabel(f&amp;quot;Actual {TARGET_LABEL}&amp;quot;)
ax.set_ylabel(f&amp;quot;Predicted {TARGET_LABEL}&amp;quot;)
ax.set_title(f&amp;quot;Actual vs Predicted {TARGET_LABEL}&amp;quot;)
ax.legend()
ax.set_aspect(&amp;quot;equal&amp;quot;)
plt.savefig(IMAGES_DIR / &amp;quot;ml_actual_vs_predicted.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="ml_actual_vs_predicted.png" alt="Actual vs predicted IMDS scores on the test set. The dashed line represents perfect prediction.">&lt;/p>
&lt;p>The scatter shows moderate agreement between actual and predicted IMDS values, with noticeable spread around the 45-degree line. Predictions tend to cluster in the 47&amp;ndash;55 range (near the training mean), with the model struggling to predict extreme values &amp;mdash; municipalities with very high or low IMDS scores are pulled toward the center. This &amp;ldquo;regression to the mean&amp;rdquo; effect is typical when the model has limited predictive power.&lt;/p>
&lt;h3 id="residual-analysis">Residual Analysis&lt;/h3>
&lt;p>Residuals (actual minus predicted) should ideally be randomly scattered around zero with no obvious pattern. Patterns in residuals can reveal systematic biases &amp;mdash; for example, if the model consistently underpredicts high-IMDS municipalities, it suggests the features miss something important about well-developed areas.&lt;/p>
&lt;pre>&lt;code class="language-python">residuals = y_test - tuned_pred
fig, ax = plt.subplots(figsize=(8, 5))
ax.scatter(tuned_pred, residuals, alpha=0.6, edgecolors=&amp;quot;white&amp;quot;, linewidth=0.5, color=&amp;quot;#6a9bcc&amp;quot;)
ax.axhline(0, color=&amp;quot;#d97757&amp;quot;, linestyle=&amp;quot;--&amp;quot;, linewidth=2)
ax.set_xlabel(f&amp;quot;Predicted {TARGET_LABEL}&amp;quot;)
ax.set_ylabel(&amp;quot;Residuals&amp;quot;)
ax.set_title(&amp;quot;Residuals vs Predicted Values&amp;quot;)
plt.savefig(IMAGES_DIR / &amp;quot;ml_residuals.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="ml_residuals.png" alt="Residuals (actual minus predicted) vs predicted IMDS values. Random scatter around zero indicates no systematic bias.">&lt;/p>
&lt;p>The residuals appear roughly randomly scattered around zero, which is encouraging &amp;mdash; there&amp;rsquo;s no strong systematic bias. However, the spread is wider at the extremes, suggesting the model&amp;rsquo;s errors are larger for municipalities with unusually high or low predicted IMDS. This pattern &amp;mdash; the spread of errors changing across the prediction range, known as &lt;em>heteroscedasticity&lt;/em> &amp;mdash; is consistent with the regression-to-the-mean effect seen in the scatter plot above.&lt;/p>
&lt;h2 id="feature-importance">Feature Importance&lt;/h2>
&lt;p>Which satellite embedding dimensions matter most for predicting IMDS? We compare two methods that answer this question differently:&lt;/p>
&lt;h3 id="mean-decrease-in-impurity-mdi">Mean Decrease in Impurity (MDI)&lt;/h3>
&lt;p>MDI measures how much each feature reduces prediction error across all splits in all trees. It&amp;rsquo;s fast to compute (built into the trained model) but can be biased toward &lt;em>high-cardinality&lt;/em> features &amp;mdash; those with many distinct values, like continuous numbers &amp;mdash; or correlated features.&lt;/p>
&lt;pre>&lt;code class="language-python">mdi_importance = pd.Series(best_rf.feature_importances_, index=FEATURE_COLS)
top20_mdi = mdi_importance.sort_values(ascending=False).head(20)
fig, ax = plt.subplots(figsize=(10, 6))
top20_mdi.sort_values().plot.barh(ax=ax, color=&amp;quot;#6a9bcc&amp;quot;, edgecolor=&amp;quot;white&amp;quot;)
ax.set_xlabel(&amp;quot;Mean Decrease in Impurity&amp;quot;)
ax.set_title(f&amp;quot;Top-20 Feature Importance (MDI) for {TARGET_LABEL}&amp;quot;)
plt.savefig(IMAGES_DIR / &amp;quot;ml_feature_importance_mdi.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="ml_feature_importance_mdi.png" alt="Top-20 satellite embedding features ranked by Mean Decrease in Impurity.">&lt;/p>
&lt;p>The MDI plot shows that A30 and A59 rank highest, but importance is distributed across many embedding dimensions rather than concentrated in just a few. This suggests the satellite imagery captures multiple independent visual patterns relevant to development &amp;mdash; no single dimension dominates. However, MDI can be inflated for continuous features, so we&amp;rsquo;ll cross-check with permutation importance next.&lt;/p>
&lt;h3 id="permutation-importance">Permutation Importance&lt;/h3>
&lt;p>Permutation importance is more reliable. Imagine scrambling all the values in one column of a spreadsheet &amp;mdash; if the model&amp;rsquo;s accuracy barely changes, that column wasn&amp;rsquo;t contributing much. That&amp;rsquo;s exactly what permutation importance does: it randomly shuffles each feature and measures how much the model&amp;rsquo;s R² drops. Unlike MDI, permutation importance is evaluated on the test set and is not biased by feature scale or cardinality.&lt;/p>
&lt;pre>&lt;code class="language-python">perm_result = permutation_importance(
best_rf, X_test, y_test, n_repeats=10, random_state=RANDOM_SEED, n_jobs=-1
)
perm_importance = pd.Series(perm_result.importances_mean, index=FEATURE_COLS)
top20_perm = perm_importance.sort_values(ascending=False).head(20)
fig, ax = plt.subplots(figsize=(10, 6))
top20_perm.sort_values().plot.barh(ax=ax, color=&amp;quot;#d97757&amp;quot;, edgecolor=&amp;quot;white&amp;quot;)
ax.set_xlabel(&amp;quot;Mean Decrease in R² (Permutation)&amp;quot;)
ax.set_title(f&amp;quot;Top-20 Feature Importance (Permutation) for {TARGET_LABEL}&amp;quot;)
plt.savefig(IMAGES_DIR / &amp;quot;ml_feature_importance_permutation.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="ml_feature_importance_permutation.png" alt="Top-20 satellite embedding features ranked by permutation importance (mean decrease in R² when feature is shuffled).">&lt;/p>
&lt;p>Permutation importance gives a more trustworthy picture. A59 emerges as the clear top feature under both methods, with A42 and A26 also ranking highly. The ranking differs somewhat from MDI (A30 drops considerably), which is expected &amp;mdash; permutation importance is less biased and directly measures predictive contribution on the test set. These top features are the embedding dimensions that genuinely help the model distinguish between municipalities with different IMDS levels. Let&amp;rsquo;s now visualize how these features affect predictions.&lt;/p>
&lt;h2 id="partial-dependence-plots">Partial Dependence Plots&lt;/h2>
&lt;p>Partial dependence plots show the marginal effect of a single feature on predictions, averaging over all other features. They reveal non-linear relationships that a simple correlation coefficient can&amp;rsquo;t capture &amp;mdash; for example, a feature might have no effect below a threshold but a strong effect above it. We plot the top-6 most important features (by permutation importance).&lt;/p>
&lt;pre>&lt;code class="language-python">top6_features = perm_importance.sort_values(ascending=False).head(6).index.tolist()
fig, axes = plt.subplots(2, 3, figsize=(15, 8))
PartialDependenceDisplay.from_estimator(
best_rf, X_train, top6_features, ax=axes.ravel(),
grid_resolution=50, n_jobs=-1
)
fig.suptitle(f&amp;quot;Partial Dependence Plots — Top-6 Features for {TARGET_LABEL}&amp;quot;, fontsize=14)
plt.tight_layout(rect=[0, 0, 1, 0.95])
plt.savefig(IMAGES_DIR / &amp;quot;ml_partial_dependence.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="ml_partial_dependence.png" alt="Partial dependence plots for the top-6 most important satellite embedding features, showing how each feature&amp;rsquo;s value affects the predicted IMDS score.">&lt;/p>
&lt;p>The partial dependence plots reveal non-linear relationships between the top features and predicted IMDS. Some dimensions show threshold effects &amp;mdash; the predicted IMDS changes sharply at certain embedding values then levels off. These non-linearities justify using Random Forest over a linear model, as a linear regression would miss these step-like patterns. The embedding dimensions likely correspond to visual landscape features (urbanization, vegetation cover, infrastructure density) that change abruptly between rural and urban municipalities.&lt;/p>
&lt;h2 id="summary-and-results">Summary and Results&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Metric&lt;/th>
&lt;th>Baseline&lt;/th>
&lt;th>Tuned&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>R²&lt;/td>
&lt;td>0.2307&lt;/td>
&lt;td>0.2297&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>RMSE&lt;/td>
&lt;td>6.52&lt;/td>
&lt;td>6.52&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>MAE&lt;/td>
&lt;td>4.68&lt;/td>
&lt;td>4.72&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The summary table confirms that tuning provided negligible improvement over the baseline for this dataset: both models achieve R² around 0.23, RMSE of 6.52, and MAE near 4.7. Key takeaways from this analysis:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Method insight:&lt;/strong> Random Forest with default hyperparameters performed just as well as the tuned model (R² = 0.2307 vs 0.2297), suggesting the performance ceiling comes from the features themselves, not model configuration. When the signal in the data is limited, sophisticated tuning adds little.&lt;/li>
&lt;li>&lt;strong>Data insight:&lt;/strong> Satellite embeddings explain roughly a quarter of IMDS variation &amp;mdash; a meaningful signal showing that remote sensing captures real development-related patterns. Feature importance is broadly distributed across embedding dimensions (A59, A42, A26 rank highest), meaning IMDS prediction relies on many visual patterns rather than a single dominant signal.&lt;/li>
&lt;li>&lt;strong>Practical limitation:&lt;/strong> The model&amp;rsquo;s regression-to-the-mean behavior (predictions cluster in the 47&amp;ndash;55 range) means it cannot reliably identify the highest- or lowest-performing municipalities individually. A policymaker using these predictions to target aid would miss the most extreme cases.&lt;/li>
&lt;li>&lt;strong>Next step:&lt;/strong> The 77% of unexplained variance likely comes from factors invisible to satellites &amp;mdash; governance quality, migration patterns, informal economies. Combining satellite embeddings with administrative or survey data would be the natural next experiment to boost predictive power.&lt;/li>
&lt;/ul>
&lt;h2 id="limitations-and-next-steps">Limitations and Next Steps&lt;/h2>
&lt;p>This analysis demonstrates that satellite embeddings contain real predictive signal for municipal development outcomes, but several limitations apply:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Moderate R²&lt;/strong>: The model captures meaningful patterns but leaves much variation unexplained &amp;mdash; development is driven by many factors invisible from space (governance, migration, informal economy).&lt;/li>
&lt;li>&lt;strong>Temporal mismatch&lt;/strong>: We use 2017 satellite imagery with SDG indices from a potentially different period.&lt;/li>
&lt;li>&lt;strong>Feature interpretability&lt;/strong>: Embedding dimensions (A00&amp;ndash;A63) are abstract; connecting them to physical landscape features requires further analysis.&lt;/li>
&lt;li>&lt;strong>Small sample&lt;/strong>: With only 339 municipalities, complex models risk overfitting despite cross-validation.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Next steps&lt;/strong> could include: trying other algorithms (gradient boosting, regularized regression), incorporating additional features (geographic, demographic), or using explainability tools like SHAP values for richer interpretation.&lt;/p>
&lt;h2 id="exercises">Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Try a different algorithm.&lt;/strong> Replace &lt;code>RandomForestRegressor&lt;/code> with &lt;code>GradientBoostingRegressor&lt;/code> from scikit-learn. Does the R² improve? How do the feature importance rankings change compared to Random Forest?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Predict a different SDG index.&lt;/strong> The DS4Bolivia dataset contains 15 individual SDG indices (&lt;code>sdg1&lt;/code> through &lt;code>sdg15&lt;/code>) alongside the composite IMDS. Pick one SDG index as the target and re-run the full pipeline. Which SDG dimensions are most predictable from satellite imagery, and which are hardest?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Add geographic features.&lt;/strong> Merge the region names data and create dummy variables for Bolivia&amp;rsquo;s nine departments. Does combining satellite embeddings with administrative region information improve model performance? What does this tell you about spatial patterns in development?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.ensemble.RandomForestRegressor.html" target="_blank" rel="noopener">scikit-learn &amp;mdash; RandomForestRegressor&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.RandomizedSearchCV.html" target="_blank" rel="noopener">scikit-learn &amp;mdash; RandomizedSearchCV&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://scikit-learn.org/stable/modules/permutation_importance.html" target="_blank" rel="noopener">scikit-learn &amp;mdash; Permutation Importance&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://scikit-learn.org/stable/modules/partial_dependence.html" target="_blank" rel="noopener">scikit-learn &amp;mdash; Partial Dependence Plots&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://github.com/quarcs-lab/ds4bolivia" target="_blank" rel="noopener">QUARCS Lab. DS4Bolivia &amp;mdash; Open Data for Bolivian Development.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1023/A:1010933404324" target="_blank" rel="noopener">Breiman, L. (2001). Random Forests. Machine Learning, 45(1), 5&amp;ndash;32.&lt;/a>&lt;/li>
&lt;/ol>
&lt;h4 id="acknowledgements">Acknowledgements&lt;/h4>
&lt;p>AI tools (Claude Code, Gemini, NotebookLM) were used to make the contents of this post more accessible to students. Nevertheless, the content in this post may still have errors. Caution is needed when applying the contents of this post to true research projects.&lt;/p></description></item></channel></rss>