<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Cross-sectional Data | Carlos Mendez</title><link>https://carlos-mendez.org/category/cross-sectional-data/</link><atom:link href="https://carlos-mendez.org/category/cross-sectional-data/index.xml" rel="self" type="application/rss+xml"/><description>Cross-sectional Data</description><generator>Wowchemy (https://wowchemy.com)</generator><language>en-us</language><copyright>Carlos Mendez</copyright><lastBuildDate>Sun, 29 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>Cross-sectional Data</title><link>https://carlos-mendez.org/category/cross-sectional-data/</link></image><item><title>Taming Model Uncertainty in the Environmental Kuznets Curve: BMA and Double-Selection LASSO with Panel Data</title><link>https://carlos-mendez.org/post/stata_bma_dsl/</link><pubDate>Sun, 29 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/stata_bma_dsl/</guid><description>&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>Can countries grow their way out of pollution? The &lt;strong>Environmental Kuznets Curve (EKC)&lt;/strong> hypothesis says yes &amp;mdash; up to a point. As economies develop, pollution first rises with industrialization and then falls as countries grow wealthy enough to afford cleaner technology. But recent research suggests a more complex &lt;strong>inverted-N&lt;/strong> shape: pollution falls at very low incomes, rises through industrialization, and then falls again at high incomes.&lt;/p>
&lt;p>Testing for this shape requires a cubic polynomial in GDP per capita &amp;mdash; and beyond GDP, many other factors might affect CO&lt;sub>2&lt;/sub> emissions. With 12 candidate control variables, there are $2^{12} = 4{,}096$ possible regression models. &lt;strong>Which model should we estimate?&lt;/strong> This is the &lt;strong>model uncertainty problem&lt;/strong>.&lt;/p>
&lt;p>This tutorial introduces two principled solutions:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Bayesian Model Averaging (BMA)&lt;/strong> estimates thousands of models and averages the results, weighting each by how well it fits the data. Each variable gets a &lt;strong>Posterior Inclusion Probability (PIP)&lt;/strong> &amp;mdash; the fraction of high-quality models that include it.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Post-Double-Selection LASSO (DSL)&lt;/strong> uses LASSO to automatically select which controls matter &amp;mdash; once for the outcome, once for each variable of interest &amp;mdash; then runs OLS with the union of all selected controls. This &amp;ldquo;select, then regress&amp;rdquo; approach protects against omitted variable bias.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>We use &lt;strong>synthetic panel data&lt;/strong> with a known &amp;ldquo;answer key&amp;rdquo; &amp;mdash; we designed the data so that 5 controls truly affect CO&lt;sub>2&lt;/sub> and 7 are pure noise. This lets us grade each method: does it correctly identify the true predictors? The data is inspired by the panel dataset of Gravina and Lanzafame (2025) but is fully synthetic and not identical to the original.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Companion tutorial.&lt;/strong> For a cross-sectional perspective using R with BMA, LASSO, and WALS, see the &lt;a href="https://carlos-mendez.org/post/r_bma_lasso_wals/">R tutorial on variable selection&lt;/a>.&lt;/p>
&lt;/blockquote>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Understand the EKC hypothesis and why a cubic polynomial tests for an inverted-N shape&lt;/li>
&lt;li>Recognize model uncertainty as a practical challenge when many controls are available&lt;/li>
&lt;li>Implement BMA with &lt;code>bmaregress&lt;/code> and interpret PIPs and coefficient densities&lt;/li>
&lt;li>Implement post-double-selection LASSO with &lt;code>dsregress&lt;/code> and understand its four-step algorithm: LASSO on outcome, LASSO on each variable of interest, union, then OLS&lt;/li>
&lt;li>Evaluate both methods against a known ground truth to assess their accuracy&lt;/li>
&lt;/ul>
&lt;p>The following diagram summarizes the methodological sequence of this tutorial. We begin with exploratory data analysis to visualize the raw income&amp;ndash;pollution relationship, then estimate baseline fixed effects regressions to expose the model uncertainty problem. Next, we apply BMA and DSL as two alternative solutions, and finally compare both methods against the known answer key.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
A[&amp;quot;&amp;lt;b&amp;gt;EDA&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Scatter plot&amp;quot;] --&amp;gt; B[&amp;quot;&amp;lt;b&amp;gt;Baseline FE&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Standard panel&amp;lt;br/&amp;gt;regressions&amp;quot;]
B --&amp;gt; C[&amp;quot;&amp;lt;b&amp;gt;BMA&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Bayesian Model&amp;lt;br/&amp;gt;Averaging&amp;quot;]
C --&amp;gt; D[&amp;quot;&amp;lt;b&amp;gt;DSL&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Double-Selection&amp;lt;br/&amp;gt;LASSO&amp;quot;]
D --&amp;gt; E[&amp;quot;&amp;lt;b&amp;gt;Comparison&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Check against&amp;lt;br/&amp;gt;answer key&amp;quot;]
style A fill:#141413,stroke:#141413,color:#fff
style B fill:#6a9bcc,stroke:#141413,color:#fff
style C fill:#d97757,stroke:#141413,color:#fff
style D fill:#00d4c8,stroke:#141413,color:#141413
style E fill:#1a3a8a,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;h2 id="2-setup-and-synthetic-data">2. Setup and Synthetic Data&lt;/h2>
&lt;h3 id="21-why-synthetic-data">2.1 Why synthetic data?&lt;/h3>
&lt;p>Real-world datasets rarely come with an answer key. We never know which control variables &lt;em>truly&lt;/em> belong in the model. By generating synthetic data with a known data-generating process (DGP), we can verify whether BMA and DSL correctly recover the truth. This is the same &amp;ldquo;answer key&amp;rdquo; approach used in the &lt;a href="https://carlos-mendez.org/post/r_bma_lasso_wals/">companion R tutorial&lt;/a>, applied here to panel data.&lt;/p>
&lt;h3 id="22-the-data-generating-process">2.2 The data-generating process&lt;/h3>
&lt;p>The outcome &amp;mdash; log CO&lt;sub>2&lt;/sub> per capita &amp;mdash; follows a cubic EKC with country and year fixed effects:&lt;/p>
&lt;p>$$\ln(\text{CO2})_{it} = \beta_1 \ln(\text{GDP})_{it} + \beta_2 [\ln(\text{GDP})_{it}]^2 + \beta_3 [\ln(\text{GDP})_{it}]^3 + \mathbf{X}_{it}^{\text{true}} \boldsymbol{\gamma} + \alpha_i + \delta_t + \varepsilon_{it}$$&lt;/p>
&lt;p>In words, log CO&lt;sub>2&lt;/sub> depends on a cubic function of log GDP (producing the inverted-N shape), five true control variables $\mathbf{X}^{\text{true}}$, country fixed effects $\alpha_i$, year fixed effects $\delta_t$, and random noise $\varepsilon_{it}$.&lt;/p>
&lt;p>The &lt;strong>answer key&lt;/strong> &amp;mdash; which variables are true predictors and which are noise:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Variable&lt;/th>
&lt;th>Group&lt;/th>
&lt;th>In DGP?&lt;/th>
&lt;th>True coef.&lt;/th>
&lt;th>GDP corr.&lt;/th>
&lt;th>Role&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>fossil_fuel&lt;/code>&lt;/td>
&lt;td>Energy&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>+0.015&lt;/td>
&lt;td>moderate&lt;/td>
&lt;td>More fossil fuels → more CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>renewable&lt;/code>&lt;/td>
&lt;td>Energy&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>&amp;ndash;0.010&lt;/td>
&lt;td>moderate&lt;/td>
&lt;td>More renewables → less CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>urban&lt;/code>&lt;/td>
&lt;td>Socio&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>+0.007&lt;/td>
&lt;td>moderate&lt;/td>
&lt;td>More urbanization → more CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>democracy&lt;/code>&lt;/td>
&lt;td>Institutional&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>&amp;ndash;0.005&lt;/td>
&lt;td>low&lt;/td>
&lt;td>More democracy → less CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>industry&lt;/code>&lt;/td>
&lt;td>Economic&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong>&lt;/td>
&lt;td>+0.010&lt;/td>
&lt;td>moderate&lt;/td>
&lt;td>More industry → more CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>globalization&lt;/code>&lt;/td>
&lt;td>Socio&lt;/td>
&lt;td>No&lt;/td>
&lt;td>0&lt;/td>
&lt;td>&lt;strong>high&lt;/strong>&lt;/td>
&lt;td>Noise &amp;mdash; tricky (correlated with GDP)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>pop_density&lt;/code>&lt;/td>
&lt;td>Socio&lt;/td>
&lt;td>No&lt;/td>
&lt;td>0&lt;/td>
&lt;td>low&lt;/td>
&lt;td>Noise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>corruption&lt;/code>&lt;/td>
&lt;td>Institutional&lt;/td>
&lt;td>No&lt;/td>
&lt;td>0&lt;/td>
&lt;td>low&lt;/td>
&lt;td>Noise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>services&lt;/code>&lt;/td>
&lt;td>Economic&lt;/td>
&lt;td>No&lt;/td>
&lt;td>0&lt;/td>
&lt;td>&lt;strong>high&lt;/strong>&lt;/td>
&lt;td>Noise &amp;mdash; tricky (correlated with GDP)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>trade&lt;/code>&lt;/td>
&lt;td>Economic&lt;/td>
&lt;td>No&lt;/td>
&lt;td>0&lt;/td>
&lt;td>moderate&lt;/td>
&lt;td>Noise &amp;mdash; tricky (correlated with GDP)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>fdi&lt;/code>&lt;/td>
&lt;td>Economic&lt;/td>
&lt;td>No&lt;/td>
&lt;td>0&lt;/td>
&lt;td>low&lt;/td>
&lt;td>Noise&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>credit&lt;/code>&lt;/td>
&lt;td>Economic&lt;/td>
&lt;td>No&lt;/td>
&lt;td>0&lt;/td>
&lt;td>moderate&lt;/td>
&lt;td>Noise &amp;mdash; tricky (correlated with GDP)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &amp;ldquo;GDP corr.&amp;rdquo; column is key to understanding why this problem is non-trivial. Four noise variables (&lt;code>globalization&lt;/code>, &lt;code>services&lt;/code>, &lt;code>trade&lt;/code>, &lt;code>credit&lt;/code>) are deliberately correlated with GDP. A naive regression would find them &amp;ldquo;significant&amp;rdquo; because they piggyback on GDP&amp;rsquo;s true effect. The challenge for BMA and DSL is to see through this correlation and correctly identify that only the 5 true controls belong in the model.&lt;/p>
&lt;p>With the DGP and answer key defined, we now load the synthetic data and set up the Stata environment.&lt;/p>
&lt;h3 id="23-load-the-data">2.3 Load the data&lt;/h3>
&lt;p>The synthetic data is hosted on GitHub for reproducibility. It was generated by &lt;code>generate_data.do&lt;/code> (see the link above).&lt;/p>
&lt;pre>&lt;code class="language-stata">* Load synthetic data from GitHub
import delimited &amp;quot;https://github.com/cmg777/starter-academic-v501/raw/master/content/post/stata_bma_dsl/synthetic_ekc_panel.csv&amp;quot;, clear
xtset country_id year, yearly
&lt;/code>&lt;/pre>
&lt;h3 id="24-define-macros">2.4 Define macros&lt;/h3>
&lt;p>We define all variable groups as global macros &amp;mdash; used in every command throughout the tutorial:&lt;/p>
&lt;pre>&lt;code class="language-stata">global outcome &amp;quot;ln_co2&amp;quot;
global gdp_vars &amp;quot;ln_gdp ln_gdp_sq ln_gdp_cb&amp;quot;
global energy &amp;quot;fossil_fuel renewable&amp;quot;
global socio &amp;quot;urban globalization pop_density&amp;quot;
global inst &amp;quot;democracy corruption&amp;quot;
global econ &amp;quot;industry services trade fdi credit&amp;quot;
global controls &amp;quot;$energy $socio $inst $econ&amp;quot;
global fe &amp;quot;i.country_id i.year&amp;quot;
* Ground truth (for evaluation)
global true_vars &amp;quot;fossil_fuel renewable urban democracy industry&amp;quot;
global noise_vars &amp;quot;globalization pop_density corruption services trade fdi credit&amp;quot;
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-stata">summarize $outcome $gdp_vars $controls
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Variable | Obs Mean Std. dev. Min Max
-------------+---------------------------------------------------------
ln_co2 | 1,600 -19.0385 .7863276 -21.03685 -16.8315
ln_gdp | 1,600 9.58387 1.329675 6.974263 11.9704
ln_gdp_sq | 1,600 93.6174 25.55106 48.64035 143.2904
ln_gdp_cb | 1,600 931.105 373.829 339.2306 1715.243
fossil_fuel | 1,600 54.7724 19.14168 6.36807 95
renewable | 1,600 29.5413 11.96568 1 64.2207
urban | 1,600 53.6742 14.778 15.95174 91.63234
globalizat~n | 1,600 57.6498 12.71537 26.75758 95
pop_density | 1,600 121.344 210.2646 1 1571.771
democracy | 1,600 2.33346 4.179503 -6.12244 10
corruption | 1,600 52.3523 28.52792 0 100
industry | 1,600 24.6433 6.180478 5.843938 45.32926
services | 1,600 43.5598 9.366089 17.82623 64.07455
trade | 1,600 67.4355 19.36148 10.04306 128.0595
fdi | 1,600 2.98237 4.373857 -11.50437 16.19903
credit | 1,600 53.4402 18.20204 11.32991 123.2399
&lt;/code>&lt;/pre>
&lt;p>The dataset contains 1,600 observations from 80 countries over 20 years (1995&amp;ndash;2014). Log GDP per capita ranges from 6.97 to 11.97, spanning the full income spectrum from about \$1,065 to \$158,000 in synthetic international dollars. Log CO&lt;sub>2&lt;/sub> has a mean of &amp;ndash;19.04 with substantial variation (standard deviation 0.79), reflecting the wide range of development levels in our synthetic panel. With the data loaded, we next visualize the raw income&amp;ndash;pollution relationship.&lt;/p>
&lt;h2 id="3-exploratory-data-analysis">3. Exploratory Data Analysis&lt;/h2>
&lt;p>Before modeling, let us look at the raw relationship between income and emissions.&lt;/p>
&lt;pre>&lt;code class="language-stata">twoway (scatter $outcome ln_gdp, ///
msize(vsmall) mcolor(&amp;quot;106 155 204&amp;quot;%40) msymbol(circle)), ///
ytitle(&amp;quot;Log CO2 per capita&amp;quot;) ///
xtitle(&amp;quot;Log GDP per capita&amp;quot;) ///
title(&amp;quot;Synthetic Data: CO2 vs. Income&amp;quot;, size(medium)) ///
subtitle(&amp;quot;80 countries, 1995-2014 (N = 1,600)&amp;quot;, size(small)) ///
scheme(s2color)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_bma_dsl_fig1_scatter.png" alt="Scatter plot of log CO2 per capita versus log GDP per capita for 80 synthetic countries. The cloud of points shows a clear nonlinear pattern consistent with the inverted-N EKC shape.">&lt;/p>
&lt;p>The scatter reveals a distinctly nonlinear pattern. At low income levels, CO&lt;sub>2&lt;/sub> emissions increase steeply with GDP. At higher income levels, the relationship flattens and bends. This curvature motivates the cubic EKC specification. The diagram below shows the two competing EKC shapes &amp;mdash; the classic inverted-U (quadratic) and the more complex inverted-N (cubic) with its three distinct phases:&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
EKC[&amp;quot;&amp;lt;b&amp;gt;Environmental Kuznets Curve&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;How does pollution change&amp;lt;br/&amp;gt;as income grows?&amp;quot;]
EKC --&amp;gt; IU[&amp;quot;&amp;lt;b&amp;gt;Inverted-U&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Quadratic: β₁ &amp;gt; 0, β₂ &amp;lt; 0&amp;lt;br/&amp;gt;One turning point&amp;quot;]
EKC --&amp;gt; IN[&amp;quot;&amp;lt;b&amp;gt;Inverted-N&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Cubic: β₁ &amp;lt; 0, β₂ &amp;gt; 0, β₃ &amp;lt; 0&amp;lt;br/&amp;gt;Two turning points&amp;quot;]
IN --&amp;gt; P1[&amp;quot;&amp;lt;b&amp;gt;Phase 1: Declining&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Very poor countries&amp;quot;]
IN --&amp;gt; P2[&amp;quot;&amp;lt;b&amp;gt;Phase 2: Rising&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Industrializing countries&amp;quot;]
IN --&amp;gt; P3[&amp;quot;&amp;lt;b&amp;gt;Phase 3: Declining&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Wealthy countries&amp;quot;]
style EKC fill:#141413,stroke:#141413,color:#fff
style IU fill:#6a9bcc,stroke:#141413,color:#fff
style IN fill:#d97757,stroke:#141413,color:#fff
style P1 fill:#00d4c8,stroke:#141413,color:#141413
style P2 fill:#d97757,stroke:#141413,color:#fff
style P3 fill:#00d4c8,stroke:#141413,color:#141413
&lt;/code>&lt;/pre>
&lt;p>For an inverted-N, we need $\beta_1 &amp;lt; 0$, $\beta_2 &amp;gt; 0$, $\beta_3 &amp;lt; 0$. Our synthetic DGP was designed with exactly this sign pattern ($\beta_1 = -7.1$, $\beta_2 = 0.81$, $\beta_3 = -0.03$), so BMA and DSL should recover it &amp;mdash; but can they also correctly identify which of the 12 controls truly matter? Let us start with standard panel regressions to see how sensitive the GDP coefficients are to the choice of controls.&lt;/p>
&lt;h2 id="4-baseline-----standard-fixed-effects">4. Baseline &amp;mdash; Standard Fixed Effects&lt;/h2>
&lt;p>Before reaching for sophisticated methods, let us see what standard panel regressions say. We run two specifications using macros:&lt;/p>
&lt;h3 id="41-sparse-specification">4.1 Sparse specification&lt;/h3>
&lt;pre>&lt;code class="language-stata">reghdfe $outcome $gdp_vars, absorb(country_id year) vce(cluster country_id)
estimates store fe_sparse
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">HDFE Linear regression Number of obs = 1,600
R-squared = 0.9620
Within R-sq. = 0.0354
Number of clusters (country_id) = 80
(Std. err. adjusted for 80 clusters in country_id)
------------------------------------------------------------------------------
| Robust
ln_co2 | Coefficient std. err. t P&amp;gt;|t| [95% conf. interval]
-------------+----------------------------------------------------------------
ln_gdp | -7.498046 1.623988 -4.62 0.000 -10.73051 -4.26558
ln_gdp_sq | .848967 .1704533 4.98 0.000 .5096881 1.188246
ln_gdp_cb | -.0314993 .005931 -5.31 0.000 -.0433047 -.019694
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The sparse model finds the inverted-N sign pattern ($\beta_1 &amp;lt; 0$, $\beta_2 &amp;gt; 0$, $\beta_3 &amp;lt; 0$), all significant at the 0.1% level with cluster-robust standard errors (clustered at the country level). The within R² is just 0.035 &amp;mdash; the GDP polynomial alone explains only about 3.5% of within-country CO&lt;sub>2&lt;/sub> variation after absorbing country and year fixed effects. The overall R² of 0.96 is high because the country fixed effects capture most of the variation.&lt;/p>
&lt;h3 id="42-kitchen-sink-specification">4.2 Kitchen-sink specification&lt;/h3>
&lt;pre>&lt;code class="language-stata">reghdfe $outcome $gdp_vars $controls, absorb(country_id year) vce(cluster country_id)
estimates store fe_kitchen
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">HDFE Linear regression Number of obs = 1,600
R-squared = 0.9655
Within R-sq. = 0.1249
Number of clusters (country_id) = 80
(Std. err. adjusted for 80 clusters in country_id)
------------------------------------------------------------------------------
| Robust
ln_co2 | Coefficient std. err. t P&amp;gt;|t| [95% conf. interval]
-------------+----------------------------------------------------------------
ln_gdp | -7.130693 1.562581 -4.56 0.000 -10.24093 -4.020453
ln_gdp_sq | .8059928 .1647973 4.89 0.000 .477972 1.134014
ln_gdp_cb | -.0298133 .0057365 -5.20 0.000 -.0412314 -.0183951
fossil_fuel | .0138444 .0014853 9.32 0.000 .010888 .0168008
renewable | -.006795 .0019322 -3.52 0.001 -.0106409 -.0029491
urban | .0057534 .0021432 2.68 0.009 .0014875 .0100192
globalizat~n | .0015186 .0012832 1.18 0.240 -.0010357 .0040728
pop_density | .0000794 .0002303 0.34 0.731 -.000379 .0005378
democracy | -.0002971 .007735 -0.04 0.969 -.0156933 .0150991
corruption | .0009812 .0008415 1.17 0.247 -.0006936 .0026561
industry | .0086336 .0017848 4.84 0.000 .0050811 .0121861
services | -.0005642 .0017205 -0.33 0.744 -.0039889 .0028604
trade | -.0002458 .0007695 -0.32 0.750 -.0017774 .0012858
fdi | -.0017599 .0019509 -0.90 0.370 -.005643 .0021232
credit | -.00139 .0007516 -1.85 0.068 -.002886 .0001061
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>Adding all 12 controls raises the within R² from 0.035 to 0.125 &amp;mdash; a meaningful improvement, though the country and year FE still dominate the overall explanatory power (R² = 0.966). The three strongest true predictors (fossil fuel, industry, urban) are clearly significant, while most noise variables are statistically insignificant. Democracy&amp;rsquo;s estimate (&amp;ndash;0.0003, p = 0.97) is far from its true value (&amp;ndash;0.005) and indistinguishable from zero &amp;mdash; illustrating why weak signals are hard to detect even with the correct model.&lt;/p>
&lt;p>The critical question is: which specification should we trust? The next subsection shows that the GDP coefficients &amp;mdash; and hence the EKC shape &amp;mdash; shift depending on which controls we include.&lt;/p>
&lt;h3 id="43-the-model-uncertainty-problem">4.3 The model uncertainty problem&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Coefficient&lt;/th>
&lt;th>Sparse FE&lt;/th>
&lt;th>Kitchen-Sink FE&lt;/th>
&lt;th>True DGP&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>$\beta_1$ (GDP)&lt;/td>
&lt;td>&amp;ndash;7.498&lt;/td>
&lt;td>&amp;ndash;7.131&lt;/td>
&lt;td>&amp;ndash;7.100&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\beta_2$ (GDP²)&lt;/td>
&lt;td>0.849&lt;/td>
&lt;td>0.806&lt;/td>
&lt;td>0.810&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\beta_3$ (GDP³)&lt;/td>
&lt;td>&amp;ndash;0.031&lt;/td>
&lt;td>&amp;ndash;0.030&lt;/td>
&lt;td>&amp;ndash;0.030&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Both specifications recover the correct sign pattern, but the magnitudes shift. The kitchen-sink FE estimates (&amp;ndash;7.131, 0.806, &amp;ndash;0.030) are closer to the true DGP values (&amp;ndash;7.100, 0.810, &amp;ndash;0.030) than the sparse FE (&amp;ndash;7.498, 0.849, &amp;ndash;0.031), because the omitted true controls create bias in the sparse model. But which of the 12 controls actually belongs?&lt;/p>
&lt;pre>&lt;code class="language-stata">* Compare coefficients side by side (simplified from analysis.do)
graph twoway ///
(bar value order if spec == &amp;quot;Sparse FE&amp;quot;, ///
barwidth(0.35) color(&amp;quot;106 155 204&amp;quot;)) ///
(bar value order if spec == &amp;quot;Kitchen-Sink FE&amp;quot;, ///
barwidth(0.35) color(&amp;quot;217 119 87&amp;quot;)), ///
xlabel(1 `&amp;quot;&amp;quot;b1&amp;quot; &amp;quot;(GDP)&amp;quot;&amp;quot;' 2 `&amp;quot;&amp;quot;b2&amp;quot; &amp;quot;(GDP sq)&amp;quot;&amp;quot;' 3 `&amp;quot;&amp;quot;b3&amp;quot; &amp;quot;(GDP cb)&amp;quot;&amp;quot;' ///
4 `&amp;quot;&amp;quot;b1&amp;quot; &amp;quot;(GDP)&amp;quot;&amp;quot;' 5 `&amp;quot;&amp;quot;b2&amp;quot; &amp;quot;(GDP sq)&amp;quot;&amp;quot;' 6 `&amp;quot;&amp;quot;b3&amp;quot; &amp;quot;(GDP cb)&amp;quot;&amp;quot;') ///
xline(3.5, lcolor(gs10) lpattern(dash)) ///
ytitle(&amp;quot;Coefficient value&amp;quot;) ///
title(&amp;quot;Coefficient Instability Across Specifications&amp;quot;) ///
legend(order(1 &amp;quot;Sparse FE (no controls)&amp;quot; 2 &amp;quot;Kitchen-Sink FE (all 12 controls)&amp;quot;) ///
rows(1) position(6)) ///
scheme(s2color)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_bma_dsl_fig2_instability.png" alt="Bar chart comparing GDP polynomial coefficients between sparse and kitchen-sink fixed effects specifications. The coefficients shift between the two models, demonstrating model uncertainty.">&lt;/p>
&lt;p>To understand the practical implications of these coefficient shifts, we compute the income thresholds where emissions change direction. The &lt;strong>turning points&lt;/strong> are found by setting the first derivative of the cubic to zero:&lt;/p>
&lt;p>$$x^* = \frac{-\hat{\beta}_2 \pm \sqrt{\hat{\beta}_2^2 - 3\hat{\beta}_1\hat{\beta}_3}}{3\hat{\beta}_3}, \quad \text{GDP}^* = \exp(x^*)$$&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Turning point&lt;/th>
&lt;th>Sparse FE&lt;/th>
&lt;th>Kitchen-Sink FE&lt;/th>
&lt;th>True DGP&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Minimum (CO&lt;sub>2&lt;/sub> starts rising)&lt;/td>
&lt;td>\$2,478&lt;/td>
&lt;td>\$2,426&lt;/td>
&lt;td>\$1,895&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Maximum (CO&lt;sub>2&lt;/sub> starts falling)&lt;/td>
&lt;td>\$25,656&lt;/td>
&lt;td>\$27,694&lt;/td>
&lt;td>\$34,647&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The turning points shift modestly between specifications &amp;mdash; the minimum stays near \$2,400&amp;ndash;\$2,500 while the maximum moves from \$25,656 to \$27,694 depending on controls. Neither matches the true DGP values perfectly, motivating BMA and DSL as principled alternatives to ad hoc control selection.&lt;/p>
&lt;h2 id="5-bayesian-model-averaging">5. Bayesian Model Averaging&lt;/h2>
&lt;h3 id="51-the-idea">5.1 The idea&lt;/h3>
&lt;p>Think of BMA as betting on a horse race. Instead of putting all your money on one model, BMA spreads bets across the field, wagering more on models with better track records.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
Start[&amp;quot;&amp;lt;b&amp;gt;12 Candidate Controls&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;2¹² = 4,096&amp;lt;br/&amp;gt;possible models&amp;quot;] --&amp;gt; MCMC[&amp;quot;&amp;lt;b&amp;gt;MCMC Sampling&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Draw 50,000 models&amp;quot;]
MCMC --&amp;gt; Post[&amp;quot;&amp;lt;b&amp;gt;Posterior Probability&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Weight by fit × parsimony&amp;quot;]
Post --&amp;gt; Avg[&amp;quot;&amp;lt;b&amp;gt;Weighted Average&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Coefficients averaged&amp;lt;br/&amp;gt;across models&amp;quot;]
Post --&amp;gt; PIP[&amp;quot;&amp;lt;b&amp;gt;PIPs&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Inclusion probability&amp;lt;br/&amp;gt;for each variable&amp;quot;]
style Start fill:#141413,stroke:#141413,color:#fff
style MCMC fill:#6a9bcc,stroke:#141413,color:#fff
style Post fill:#d97757,stroke:#141413,color:#fff
style Avg fill:#00d4c8,stroke:#141413,color:#141413
style PIP fill:#00d4c8,stroke:#141413,color:#141413
&lt;/code>&lt;/pre>
&lt;p>Formally, this betting process follows Bayes' rule, which tells us how to weight models by their fit and complexity.&lt;/p>
&lt;p>&lt;strong>Step 1: Model posterior probabilities.&lt;/strong> The posterior probability of model $M_k$ is:&lt;/p>
&lt;p>$$P(M_k | \text{data}) = \frac{P(\text{data} | M_k) \cdot P(M_k)}{\sum_{l=1}^{K} P(\text{data} | M_l) \cdot P(M_l)}$$&lt;/p>
&lt;p>In words, the probability of model $k$ being correct equals how well it fits the data (the &lt;em>marginal likelihood&lt;/em> $P(\text{data} | M_k)$) times our prior belief ($P(M_k)$), divided by the total across all models. Models that fit the data well &lt;em>and&lt;/em> are parsimonious receive higher posterior weight &amp;mdash; this is BMA&amp;rsquo;s built-in Occam&amp;rsquo;s razor.&lt;/p>
&lt;p>The marginal likelihood $P(\text{data} | M_k)$ is not the same as the ordinary likelihood. It integrates over all possible coefficient values, penalizing models with many parameters that &amp;ldquo;waste&amp;rdquo; probability mass on parameter regions the data does not support:&lt;/p>
&lt;p>$$P(\text{data} | M_k) = \int P(\text{data} | \boldsymbol{\beta}_k, M_k) \, P(\boldsymbol{\beta}_k | M_k) \, d\boldsymbol{\beta}_k$$&lt;/p>
&lt;p>In words, the marginal likelihood asks: &amp;ldquo;If we averaged this model&amp;rsquo;s fit across all plausible coefficient values (weighted by the prior $P(\boldsymbol{\beta}_k | M_k)$), how well does it explain the data?&amp;rdquo; This integral is what makes BMA automatically penalize overly complex models &amp;mdash; a model with many parameters spreads its prior probability thinly across a high-dimensional space, and only recovers that probability if the data strongly supports those extra dimensions.&lt;/p>
&lt;p>&lt;strong>Step 2: Posterior Inclusion Probabilities.&lt;/strong> The &lt;strong>PIP&lt;/strong> for variable $j$ sums the posterior probabilities across all models that include it:&lt;/p>
&lt;p>$$\text{PIP}_j = \sum_{k:\, x_j \in M_k} P(M_k | \text{data})$$&lt;/p>
&lt;p>In words, PIP answers: &amp;ldquo;Across all the models BMA considered, what fraction of the total posterior weight belongs to models that include variable $j$?&amp;rdquo; If fossil fuel appears in every high-probability model, its PIP approaches 1.0. If democracy only appears in low-probability models, its PIP stays near 0.&lt;/p>
&lt;p>&lt;strong>Step 3: BMA posterior mean.&lt;/strong> BMA does not just select variables &amp;mdash; it also produces model-averaged coefficient estimates. The posterior mean of coefficient $\beta_j$ averages across all models, weighted by their posterior probabilities:&lt;/p>
&lt;p>$$\hat{\beta}_j^{\text{BMA}} = \sum_{k=1}^{K} P(M_k | \text{data}) \cdot \hat{\beta}_{j,k}$$&lt;/p>
&lt;p>where $\hat{\beta}_{j,k}$ is the coefficient estimate of variable $j$ in model $M_k$ (set to zero if $j$ is not in $M_k$). In words, the BMA estimate is a weighted average of the coefficient across all models, including models where the variable is absent (contributing zero). This shrinks the coefficient toward zero in proportion to the evidence against inclusion &amp;mdash; a variable with PIP = 0.5 has its BMA coefficient shrunk by roughly half compared to its conditional estimate.&lt;/p>
&lt;p>Think of PIP as a &lt;strong>democratic vote&lt;/strong> across all candidate models. Each model casts a weighted vote for which variables matter, with better-fitting models getting louder voices. &lt;a href="https://doi.org/10.2307/271063" target="_blank" rel="noopener">Raftery (1995)&lt;/a> proposed standard interpretation thresholds based on the strength of evidence:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>PIP range&lt;/th>
&lt;th>Evidence&lt;/th>
&lt;th>Analogy&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>$\geq 0.99$&lt;/td>
&lt;td>Decisive&lt;/td>
&lt;td>Beyond reasonable doubt&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$0.95 - 0.99$&lt;/td>
&lt;td>Very strong&lt;/td>
&lt;td>Strong consensus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$0.80 - 0.95$&lt;/td>
&lt;td>Strong (robust)&lt;/td>
&lt;td>Clear majority&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$0.50 - 0.80$&lt;/td>
&lt;td>Borderline&lt;/td>
&lt;td>Split vote&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$&amp;lt; 0.50$&lt;/td>
&lt;td>Weak/none (fragile)&lt;/td>
&lt;td>Minority opinion&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>We use &lt;strong>PIP $\geq$ 0.80&lt;/strong> as our robustness threshold throughout this tutorial &amp;mdash; a variable with PIP above 0.80 appears in the vast majority of the probability-weighted model space, providing &amp;ldquo;strong evidence&amp;rdquo; by Raftery&amp;rsquo;s classification. This is the most widely used cutoff in applied BMA studies.&lt;/p>
&lt;p>A key assumption underlying BMA is that the true data-generating process is well-approximated by a weighted combination of the candidate models (the &amp;ldquo;M-closed&amp;rdquo; assumption). When the candidate set omits important functional forms or interactions, BMA&amp;rsquo;s posterior probabilities may be unreliable.&lt;/p>
&lt;h3 id="52-key-options">5.2 Key options&lt;/h3>
&lt;p>With the conceptual framework in place, we now turn to implementation. Stata 18&amp;rsquo;s &lt;a href="https://www.stata.com/manuals/bmabmaregress.pdf" target="_blank" rel="noopener">&lt;code>bmaregress&lt;/code>&lt;/a> command has three families of options: &lt;strong>priors&lt;/strong> (what you believe before seeing the data), &lt;strong>MCMC controls&lt;/strong> (how the algorithm explores the model space), and &lt;strong>output formatting&lt;/strong> (what gets displayed). The full option list is in the &lt;a href="https://www.stata.com/manuals/bmabmaregress.pdf" target="_blank" rel="noopener">Stata manual&lt;/a>; here we explain the ones used in this tutorial:&lt;/p>
&lt;p>&lt;strong>Prior specifications&lt;/strong> (see &lt;a href="https://www.stata.com/manuals/bmabmaregresspostestimation.pdf" target="_blank" rel="noopener">&lt;code>bmaregress&lt;/code> priors&lt;/a> for alternatives):&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://www.stata.com/manuals/bmabmaregress.pdf" target="_blank" rel="noopener">&lt;code>gprior(uip)&lt;/code>&lt;/a>&lt;/strong> &amp;mdash; Unit Information Prior: sets the prior precision on coefficients equal to the information in one observation ($g = N$). This is a standard, relatively uninformative choice that lets the data dominate. Alternatives include &lt;code>gprior(bric)&lt;/code> (benchmark risk inflation criterion, $g = \max(N, p^2)$), &lt;code>gprior(zs)&lt;/code> (Zellner-Siow), and &lt;code>gprior(hyper)&lt;/code> (hyper-g prior with data-driven $g$)&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.stata.com/manuals/bmabmaregress.pdf" target="_blank" rel="noopener">&lt;code>mprior(uniform)&lt;/code>&lt;/a>&lt;/strong> &amp;mdash; all $2^{12} = 4{,}096$ models are equally likely a priori; no model is privileged before seeing the data. The alternative &lt;code>mprior(binomial)&lt;/code> applies a beta-binomial prior that penalizes very large or very small models, often producing more conservative PIPs&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>MCMC controls:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>mcmcsize(50000)&lt;/code>&lt;/strong> &amp;mdash; draws 50,000 models from the model space using MC$^3$ (Markov chain Monte Carlo model composition) sampling. Larger values improve posterior estimates but increase computation time&lt;/li>
&lt;li>&lt;strong>&lt;code>burnin(5000)&lt;/code>&lt;/strong> &amp;mdash; discards the first 5,000 draws to allow the chain to reach its stationary distribution before collecting samples&lt;/li>
&lt;li>&lt;strong>&lt;code>rseed(9988)&lt;/code>&lt;/strong> &amp;mdash; fixes the random number seed for exact reproducibility. Students running the same command will get identical results&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.stata.com/manuals/bmabmaregress.pdf" target="_blank" rel="noopener">&lt;code>groupfv&lt;/code>&lt;/a>&lt;/strong> &amp;mdash; treats all dummies from a single factor variable as one group that enters or exits models together. Without &lt;code>groupfv&lt;/code>, writing &lt;code>i.country_id&lt;/code> would create 80 individual dummy variables, and BMA would consider including or excluding each one independently &amp;mdash; producing an astronomical model space ($2^{80}$ combinations of country dummies alone) that is both computationally infeasible and conceptually meaningless. With &lt;code>groupfv&lt;/code>, the 80 country dummies move as a &lt;em>package&lt;/em>: either all 80 are in the model or none are. Think of it like hiring a sports team &amp;mdash; you recruit the whole roster, not individual players one by one. In the output, this is why you see &amp;ldquo;Groups = 15&amp;rdquo; instead of 113: BMA treats the 80 country dummies as 1 group, the 19 year dummies as 1 group, and each of the 12 candidate controls + 3 GDP terms as their own groups ($1 + 1 + 15 = 17$, minus 2 that are &amp;ldquo;always&amp;rdquo; included = 15 groups subject to selection)&lt;/li>
&lt;li>&lt;strong>&lt;code>($fe, always)&lt;/code>&lt;/strong> &amp;mdash; country and year fixed effects are always included in every model; they are not subject to model selection. This is standard practice in panel data BMA: we want to control for unobserved country and time heterogeneity in &lt;em>every&lt;/em> model, and only let BMA decide about the candidate controls&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Output formatting:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>pipcutoff(0.8)&lt;/code>&lt;/strong> &amp;mdash; display only variables with PIP above 0.80 in the output table. This is a &lt;em>display&lt;/em> threshold only &amp;mdash; it does not affect the underlying estimation&lt;/li>
&lt;li>&lt;strong>&lt;code>inputorder&lt;/code>&lt;/strong> &amp;mdash; display variables in the order they were specified in the command, rather than sorted by PIP&lt;/li>
&lt;/ul>
&lt;h3 id="53-estimation">5.3 Estimation&lt;/h3>
&lt;pre>&lt;code class="language-stata">bmaregress $outcome $gdp_vars $controls ///
($fe, always), ///
mprior(uniform) groupfv gprior(uip) ///
mcmcsize(50000) rseed(9988) inputorder pipcutoff(0.8)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Bayesian model averaging No. of obs = 1,600
Linear regression No. of predictors = 113
MC3 sampling Groups = 15
Always = 98
No. of models = 163
Priors: Mean model size = 104.578
Models: Uniform MCMC sample size = 50,000
Coef.: Zellner's g Acceptance rate = 0.0904
g: Unit-information, g = 1,600 Shrinkage, g/(1+g) = 0.9994
Sampling correlation = 0.9997
------------------------------------------------------------------------------
ln_co2 | Mean Std. dev. Group PIP
-------------+----------------------------------------------------------------
ln_gdp | -7.13901 1.811093 1 .99401
ln_gdp_sq | .8078437 .1892418 2 .99991
ln_gdp_cb | -.0299182 .0065105 3 .99976
fossil_fuel | .0138139 .001283 4 1
renewable | -.0068332 .0023506 5 .95945
industry | .0085503 .0019766 11 .99867
------------------------------------------------------------------------------
Note: 9 predictors with PIP less than .8 not shown.
&lt;/code>&lt;/pre>
&lt;blockquote>
&lt;p>The Stata output says &amp;ldquo;PIP less than .8&amp;rdquo; because we set &lt;code>pipcutoff(0.8)&lt;/code> as the display threshold &amp;mdash; only variables exceeding this stricter robustness criterion appear in the table. The 9 hidden variables are the two weak true controls (urban, democracy) and all 7 noise variables (services, trade, FDI, credit, population density, corruption, globalization). Figure 3 below shows PIP values for all 15 variables.&lt;/p>
&lt;/blockquote>
&lt;p>The output shows 113 predictors in 15 groups: the 80 country dummies (grouped as 1 by &lt;code>groupfv&lt;/code>) + 19 year dummies (grouped as 1) + 12 candidate controls (each its own group) + the 3 GDP terms (each its own group) = 15 selection groups total, with 98 variables &amp;ldquo;always&amp;rdquo; included (the country and year FE). BMA sampled 163 distinct models out of 4,096 possible. This might seem low, but the MC$^3$ algorithm does not need to visit every model &amp;mdash; it concentrates on the high-posterior-probability region. The sampling correlation of 0.9997 (very close to 1.0) confirms that the MC$^3$ chain adequately explored the model space &amp;mdash; the posterior probability is concentrated on a relatively small number of high-quality models. The acceptance rate of 0.09 is below the typical 20&amp;ndash;40% range, but the high sampling correlation provides reassurance that the results are reliable. Six variables have PIP above the 0.80 robustness threshold: the three GDP terms (PIP = 0.994&amp;ndash;1.000) and three of the five true controls &amp;mdash; fossil fuel (PIP = 1.000), industry (PIP = 0.999), and renewable energy (PIP = 0.959). The BMA posterior means (&amp;ndash;7.139, 0.808, &amp;ndash;0.030) are remarkably close to the true DGP values (&amp;ndash;7.100, 0.810, &amp;ndash;0.030), substantially closer than the sparse FE estimates.&lt;/p>
&lt;p>Two true controls &amp;mdash; urban (coefficient 0.007) and democracy (coefficient &amp;ndash;0.005) &amp;mdash; have PIPs well below 0.80. Their true effects are small, making them hard to distinguish from noise. This is a realistic limitation: even a powerful method like BMA struggles with weak signals.&lt;/p>
&lt;h3 id="54-turning-points">5.4 Turning points&lt;/h3>
&lt;p>Using the BMA posterior means, the turning points are:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Minimum:&lt;/strong> \$2,411 GDP per capita (true: \$1,895)&lt;/li>
&lt;li>&lt;strong>Maximum:&lt;/strong> \$27,269 GDP per capita (true: \$34,647)&lt;/li>
&lt;/ul>
&lt;p>Both turning points are in the right ballpark but not exact. The turning point formula amplifies small differences across all three coefficients &amp;mdash; even though each BMA posterior mean is within 1% of the true DGP value, the compound effect shifts the maximum turning point from \$34,647 (true) to \$27,269 (BMA). The inverted-N shape is clearly recovered.&lt;/p>
&lt;h3 id="55-posterior-inclusion-probabilities">5.5 Posterior Inclusion Probabilities&lt;/h3>
&lt;p>The PIP chart is BMA&amp;rsquo;s signature output. We extract PIPs from the estimation results, label each variable, and color-code bars by ground truth: steel blue for true predictors, gray for noise.&lt;/p>
&lt;pre>&lt;code class="language-stata">* Extract PIPs and create a horizontal bar chart
matrix pip_mat = e(pip)
* ... (create dataset of variable names and PIPs, add readable labels) ...
* Mark true vs noise predictors
gen is_true = inlist(varname, &amp;quot;fossil_fuel&amp;quot;, &amp;quot;renewable&amp;quot;, &amp;quot;urban&amp;quot;, ///
&amp;quot;democracy&amp;quot;, &amp;quot;industry&amp;quot;, &amp;quot;ln_gdp&amp;quot;, &amp;quot;ln_gdp_sq&amp;quot;, &amp;quot;ln_gdp_cb&amp;quot;)
gsort -pip
graph twoway ///
(bar pip order if is_true == 1, horizontal barwidth(0.6) ///
color(&amp;quot;106 155 204&amp;quot;)) ///
(bar pip order if is_true == 0, horizontal barwidth(0.6) ///
color(gs11)), ///
xline(0.8, lcolor(&amp;quot;217 119 87&amp;quot;) lpattern(dash) lwidth(medium)) ///
ylabel(1(1)15, valuelabel angle(0) labsize(small)) ///
xlabel(0(0.2)1, format(%3.1f)) ///
xtitle(&amp;quot;Posterior Inclusion Probability (PIP)&amp;quot;) ///
title(&amp;quot;BMA: Which Variables Matter?&amp;quot;) ///
legend(order(1 &amp;quot;True predictor (in DGP)&amp;quot; 2 &amp;quot;Noise variable (not in DGP)&amp;quot;) ///
rows(1) position(6)) ///
scheme(s2color)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_bma_dsl_fig3_pip.png" alt="Horizontal bar chart showing Posterior Inclusion Probabilities for all 15 variables. True predictors are colored in steel blue, noise variables in gray. A dashed orange line marks the 0.80 robustness threshold.">&lt;/p>
&lt;p>The PIP chart cleanly separates the variables into two groups. At the top (PIP near 1.0): fossil fuel share, GDP terms, industry, and renewable energy &amp;mdash; all true predictors correctly identified. At the bottom (PIP near 0.0): the seven noise variables (globalization, corruption, services, trade, FDI, credit, population density) plus urban population and democracy. BMA correctly assigns zero-like PIPs to all noise variables, and correctly flags 3 of 5 true predictors as robust. The two misses (urban, democracy) have small true coefficients (0.007 and &amp;ndash;0.005), making them genuinely hard to detect.&lt;/p>
&lt;h3 id="56-coefficient-density-plots">5.6 Coefficient density plots&lt;/h3>
&lt;p>The &lt;a href="https://www.stata.com/manuals/bmabmagraphcoefdensity.pdf" target="_blank" rel="noopener">&lt;code>bmagraph coefdensity&lt;/code>&lt;/a> command shows the posterior distribution of each coefficient across all sampled models. We plot all six variables with PIP above 0.80 in a 3x2 grid &amp;mdash; the three GDP polynomial terms (top row) and the three robust controls (bottom row). In each panel, the blue curve shows the density conditional on the variable being included in the model, and the red horizontal line shows the probability of noninclusion (1 &amp;ndash; PIP). When the red line is flat near zero and the blue curve is far from zero, the variable is strongly supported.&lt;/p>
&lt;pre>&lt;code class="language-stata">* Consistent formatting for all panels
local panel_opts `&amp;quot; xtitle(&amp;quot;Coefficient value&amp;quot;, size(vsmall)) &amp;quot;'
local panel_opts `&amp;quot; `panel_opts' ytitle(&amp;quot;Density&amp;quot;, size(vsmall)) &amp;quot;'
local panel_opts `&amp;quot; `panel_opts' ylabel(, labsize(vsmall) angle(0)) &amp;quot;'
local panel_opts `&amp;quot; `panel_opts' xlabel(, labsize(vsmall)) &amp;quot;'
local panel_opts `&amp;quot; `panel_opts' legend(off) scheme(s2color) &amp;quot;'
* Generate density for all 6 robust variables (PIP &amp;gt; 0.80)
bmagraph coefdensity ln_gdp, title(&amp;quot;GDP per capita (log)&amp;quot;, size(small)) `panel_opts' name(dens_gdp, replace)
bmagraph coefdensity ln_gdp_sq, title(&amp;quot;GDP squared (log)&amp;quot;, size(small)) `panel_opts' name(dens_gdp_sq, replace)
bmagraph coefdensity ln_gdp_cb, title(&amp;quot;GDP cubed (log)&amp;quot;, size(small)) `panel_opts' name(dens_gdp_cb, replace)
bmagraph coefdensity fossil_fuel, title(&amp;quot;Fossil fuel share (%)&amp;quot;, size(small)) `panel_opts' name(dens_fossil, replace)
bmagraph coefdensity renewable, title(&amp;quot;Renewable energy (%)&amp;quot;, size(small)) `panel_opts' name(dens_renewable, replace)
bmagraph coefdensity industry, title(&amp;quot;Industry VA (% GDP)&amp;quot;, size(small)) `panel_opts' name(dens_industry, replace)
graph combine dens_gdp dens_gdp_sq dens_gdp_cb ///
dens_fossil dens_renewable dens_industry, ///
cols(3) rows(2) imargin(small) ///
title(&amp;quot;BMA: Posterior Coefficient Densities&amp;quot;, size(medsmall)) ///
subtitle(&amp;quot;All 6 robust variables (PIP &amp;gt; 0.80)&amp;quot;, size(small)) ///
note(&amp;quot;Blue curve = posterior density conditional on inclusion.&amp;quot; ///
&amp;quot;Red line = probability of noninclusion (1 - PIP).&amp;quot; ///
&amp;quot;Near-zero red line + blue curve far from zero = strong evidence.&amp;quot;, size(vsmall)) ///
scheme(s2color) xsize(12) ysize(7)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_bma_dsl_fig4_coefdensity.png" alt="Posterior coefficient density plots for all six robust variables in a 3x2 grid. Top row: GDP linear, squared, and cubic terms. Bottom row: fossil fuel, renewable energy, and industry. All densities are concentrated well away from zero.">&lt;/p>
&lt;p>All six densities are concentrated well away from zero, confirming that every variable with PIP above 0.80 has a genuinely non-zero effect. The three GDP terms (top row) form the inverted-N polynomial: the linear term is centered near &amp;ndash;7.1 (true: &amp;ndash;7.1), the squared term near +0.81 (true: +0.81), and the cubic term near &amp;ndash;0.030 (true: &amp;ndash;0.030). The three controls (bottom row) show tight, unimodal densities: fossil fuel near +0.014 (true: +0.015), renewable energy near &amp;ndash;0.007 (true: &amp;ndash;0.010), and industry near +0.009 (true: +0.010). Renewable energy&amp;rsquo;s posterior mean (&amp;ndash;0.007) is slightly attenuated compared to the true value (&amp;ndash;0.010), reflecting the BMA shrinkage that occurs when a variable&amp;rsquo;s PIP is below 1.0 &amp;mdash; models that exclude it pull the average toward zero.&lt;/p>
&lt;h3 id="57-pooled-bma-without-fixed-effects">5.7 Pooled BMA (without fixed effects)&lt;/h3>
&lt;p>To parallel the pooled DSL comparison in Section 6.6, we also run BMA without country or year fixed effects &amp;mdash; treating the panel as a pooled cross-section. This removes the &lt;code>($fe, always)&lt;/code> and &lt;code>groupfv&lt;/code> options, leaving only the 12 candidate controls and 3 GDP terms as predictors (15 total, vs 113 with FE).&lt;/p>
&lt;pre>&lt;code class="language-stata">* BMA without FE -- pooled cross-section
bmaregress ln_co2 ln_gdp ln_gdp_sq ln_gdp_cb ///
fossil_fuel renewable urban industry democracy ///
services trade fdi credit pop_density ///
corruption globalization, ///
mprior(uniform) gprior(uip) ///
mcmcsize(50000) rseed(9988) pipcutoff(0.5) burnin(5000)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Bayesian model averaging No. of obs = 1,600
Linear regression No. of predictors = 15
MC3 sampling Groups = 15
Always = 0
No. of models = 34
Priors: Mean model size = 11.978
Models: Uniform MCMC sample size = 50,000
Coef.: Zellner's g Acceptance rate = 0.0733
g: Unit-information, g = 1,600 Shrinkage, g/(1+g) = 0.9994
Sampling correlation = 0.9996
------------------------------------------------------------------------------
ln_co2 | Mean Std. dev. Group PIP
-------------+----------------------------------------------------------------
ln_gdp | -21.25807 1.641676 1 1
ln_gdp_sq | 2.284729 .1748838 2 1
ln_gdp_cb | -.0813937 .0061308 3 1
fossil_fuel | .0188853 .0010554 4 1
renewable | -.0192089 .0013911 5 1
urban | .0103139 .0012072 6 1
industry | .0138361 .0023478 7 1
services | .0164633 .0016573 9 1
pop_density | -.0004314 .0000567 13 1
credit | .0041017 .0008414 12 .99984
trade | -.0020939 .001084 10 .86009
democracy | .007879 .0042984 8 .84142
------------------------------------------------------------------------------
Note: 3 predictors with PIP less than .5 not shown.
&lt;/code>&lt;/pre>
&lt;p>The pooled BMA results are striking in two ways. First, the GDP coefficients are severely biased &amp;mdash; the same pattern as pooled DSL: $\beta_1 = -21.26$ (true: &amp;ndash;7.10), $\beta_2 = 2.28$ (true: 0.81), $\beta_3 = -0.081$ (true: &amp;ndash;0.03). Without country fixed effects, the GDP terms absorb persistent cross-country differences in emissions levels, inflating the coefficients by a factor of 2&amp;ndash;3x.&lt;/p>
&lt;p>Second, the PIPs tell a completely different story than with FE. Without fixed effects, &lt;strong>12 of 15 variables have PIP above 0.80&lt;/strong> &amp;mdash; including noise variables like services (PIP = 1.000), population density (PIP = 1.000), credit (PIP = 1.000), and trade (PIP = 0.860). With FE, only 6 variables cleared the 0.80 threshold and all 7 noise variables had PIPs near zero. The pooled BMA commits &lt;strong>5 false positives&lt;/strong> (services, pop_density, credit, trade, and democracy incorrectly flagged as robust noise variables or given inflated PIPs) compared to &lt;strong>zero&lt;/strong> false positives with FE. This happens because the noise variables are correlated with omitted country effects &amp;mdash; without FE to absorb those effects, the correlations create spurious associations that BMA interprets as genuine predictive power.&lt;/p>
&lt;p>The turning points (\$5,752 minimum, \$23,298 maximum) are far from the truth, and the 95% credible intervals fail to cover the true values for all three GDP terms &amp;mdash; the same coverage failure seen in pooled DSL. The lesson is clear: &lt;strong>fixed effects are not optional in panel BMA&lt;/strong>. They are essential for correct variable selection, not just coefficient estimation.&lt;/p>
&lt;h2 id="6-post-double-selection-lasso">6. Post-Double-Selection LASSO&lt;/h2>
&lt;h3 id="61-the-idea">6.1 The idea&lt;/h3>
&lt;p>Stata&amp;rsquo;s &lt;a href="https://www.stata.com/manuals/lassodsregress.pdf" target="_blank" rel="noopener">&lt;code>dsregress&lt;/code>&lt;/a> implements the &lt;strong>post-double-selection&lt;/strong> method of Belloni, Chernozhukov, and Hansen (2014). Think of it as a smart research assistant who reads the data twice &amp;mdash; once to find controls that predict the outcome (CO&lt;sub>2&lt;/sub>), and again to find controls that predict the variables of interest (GDP terms) &amp;mdash; then runs a clean OLS regression using only the controls that survived at least one selection.&lt;/p>
&lt;p>The &amp;ldquo;double&amp;rdquo; in double-selection refers to the &lt;strong>union&lt;/strong> of two separate LASSO selections. Why is this union necessary? If a control variable predicts both CO&lt;sub>2&lt;/sub> &lt;em>and&lt;/em> GDP but a single LASSO run on CO&lt;sub>2&lt;/sub> happens to miss it, omitting it from the final regression would bias the GDP coefficient. The second LASSO step (on GDP) catches variables that the first step might miss, and vice versa.&lt;/p>
&lt;p>The algorithm has four steps:&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
Controls[&amp;quot;&amp;lt;b&amp;gt;12 Candidate Controls&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;+ country &amp;amp; year FE&amp;quot;]
Controls --&amp;gt; Step1[&amp;quot;&amp;lt;b&amp;gt;Step 1: LASSO on Outcome&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;CO2 ~ all controls&amp;lt;br/&amp;gt;→ Selected set X̃y&amp;quot;]
Controls --&amp;gt; Step2[&amp;quot;&amp;lt;b&amp;gt;Step 2: LASSO on Each Variable of Interest&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;GDP ~ all controls → X̃₁&amp;lt;br/&amp;gt;GDP² ~ all controls → X̃₂&amp;lt;br/&amp;gt;GDP³ ~ all controls → X̃₃&amp;quot;]
Step1 --&amp;gt; Union[&amp;quot;&amp;lt;b&amp;gt;Step 3: Take the Union&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;X̂ = X̃y ∪ X̃₁ ∪ X̃₂ ∪ X̃₃&amp;lt;br/&amp;gt;Only controls surviving&amp;lt;br/&amp;gt;at least one selection&amp;quot;]
Step2 --&amp;gt; Union
Union --&amp;gt; OLS[&amp;quot;&amp;lt;b&amp;gt;Step 4: Final OLS&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;CO2 ~ GDP + GDP² + GDP³ + X̂&amp;lt;br/&amp;gt;Standard OLS with valid&amp;lt;br/&amp;gt;inference on GDP terms&amp;quot;]
style Controls fill:#141413,stroke:#141413,color:#fff
style Step1 fill:#6a9bcc,stroke:#141413,color:#fff
style Step2 fill:#d97757,stroke:#141413,color:#fff
style Union fill:#1a3a8a,stroke:#141413,color:#fff
style OLS fill:#00d4c8,stroke:#141413,color:#141413
&lt;/code>&lt;/pre>
&lt;p>At the heart of each LASSO step is a penalized regression that shrinks irrelevant coefficients to exactly zero:&lt;/p>
&lt;p>$$\hat{\boldsymbol{\beta}}^{\text{LASSO}} = \arg\min_{\boldsymbol{\beta}} \left\{ \frac{1}{2N} \sum_{i=1}^{N}(y_i - \mathbf{x}_i'\boldsymbol{\beta})^2 + \lambda \sum_{j=1}^{p} |\beta_j| \right\}$$&lt;/p>
&lt;p>In words, LASSO minimizes the sum of squared residuals (the usual OLS objective) plus a penalty term $\lambda \sum |\beta_j|$ that charges a cost proportional to the &lt;em>absolute value&lt;/em> of each coefficient. The tuning parameter $\lambda$ controls how harsh this penalty is &amp;mdash; think of it as a &amp;ldquo;strictness dial.&amp;rdquo; When $\lambda = 0$, LASSO is just OLS. As $\lambda$ increases, more coefficients are forced to exactly zero. The L1 (absolute value) penalty is what makes LASSO a variable selector: unlike the L2 (squared) penalty used in Ridge regression, the L1 penalty has sharp corners at zero that drive weak coefficients to exactly zero rather than merely shrinking them.&lt;/p>
&lt;p>&lt;strong>Why &amp;ldquo;double&amp;rdquo; selection?&lt;/strong> The key insight of Belloni, Chernozhukov, and Hansen (2014) is that a single LASSO selection can miss important confounders. Consider our panel setting. We want to estimate the effect of GDP terms ($\mathbf{D}$) on CO&lt;sub>2&lt;/sub> ($Y$), controlling for other variables ($\mathbf{W}$). The model is:&lt;/p>
&lt;p>$$Y_i = \mathbf{D}_i' \boldsymbol{\alpha} + \mathbf{W}_i' \boldsymbol{\beta} + \varepsilon_i$$&lt;/p>
&lt;p>A confounder $W_j$ that affects both $Y$ and $\mathbf{D}$ must be included to avoid omitted variable bias. But if $W_j$ has a weak effect on $Y$, the LASSO on $Y$ might miss it. The double-selection strategy solves this by running LASSO twice:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Step 1&lt;/strong> selects controls that predict $Y$: $\quad \hat{S}_Y = \{j : \hat{\beta}_j^{\text{LASSO}(Y)} \neq 0\}$&lt;/li>
&lt;li>&lt;strong>Step 2&lt;/strong> selects controls that predict each $D_k$: $\quad \hat{S}_{D_k} = \{j : \hat{\gamma}_{j,k}^{\text{LASSO}(D_k)} \neq 0\}$&lt;/li>
&lt;li>&lt;strong>Step 3&lt;/strong> takes the union: $\quad \hat{S} = \hat{S}_Y \cup \hat{S}_{D_1} \cup \hat{S}_{D_2} \cup \hat{S}_{D_3}$&lt;/li>
&lt;li>&lt;strong>Step 4&lt;/strong> runs OLS of $Y$ on $\mathbf{D}$ and $\mathbf{W}_{\hat{S}}$ with standard inference&lt;/li>
&lt;/ul>
&lt;p>The union in Step 3 ensures that a confounder missed by the $Y$-LASSO but caught by the $D$-LASSO is still included. This &amp;ldquo;safety net&amp;rdquo; property is what gives post-double-selection its valid inference guarantees &amp;mdash; the final OLS produces consistent estimates of $\boldsymbol{\alpha}$ even if each individual LASSO makes some selection mistakes.&lt;/p>
&lt;p>The &lt;code>dsregress&lt;/code> command uses a &amp;ldquo;plugin&amp;rdquo; method to choose $\lambda$ &amp;mdash; an analytical formula that sets the penalty based on the sample size and noise level, without requiring cross-validation. A key assumption underlying DSL is &lt;em>approximate sparsity&lt;/em>: only a small number of controls truly matter, so LASSO can safely set the rest to zero. When the true model is dense (many small effects rather than a few large ones), LASSO may struggle to select the right variables.&lt;/p>
&lt;p>Before implementing DSL, it helps to see the two methods side by side:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Feature&lt;/th>
&lt;th>BMA&lt;/th>
&lt;th>Post-Double-Selection&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Philosophy&lt;/td>
&lt;td>Bayesian (posteriors)&lt;/td>
&lt;td>Frequentist (p-values)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Strategy&lt;/td>
&lt;td>Average across models&lt;/td>
&lt;td>Select controls, then OLS&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Output&lt;/td>
&lt;td>PIPs for every variable&lt;/td>
&lt;td>Set of selected controls&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Speed&lt;/td>
&lt;td>Minutes (MCMC)&lt;/td>
&lt;td>Seconds (optimization)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Reference&lt;/td>
&lt;td>Raftery et al. (1997)&lt;/td>
&lt;td>Belloni, Chernozhukov, Hansen (2014)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="62-key-options">6.2 Key options&lt;/h3>
&lt;p>With the algorithm clear, let us examine the Stata implementation. The &lt;a href="https://www.stata.com/manuals/lassodsregress.pdf" target="_blank" rel="noopener">&lt;code>dsregress&lt;/code>&lt;/a> command has a concise syntax, but each element plays a specific role. The full option list is in the &lt;a href="https://www.stata.com/manuals/lasso.pdf" target="_blank" rel="noopener">Stata LASSO manual&lt;/a>; here we explain the ones used in this tutorial:&lt;/p>
&lt;p>&lt;strong>Syntax structure:&lt;/strong> &lt;code>dsregress depvar varsofinterest, controls(controlvars) [options]&lt;/code>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>$outcome&lt;/code>&lt;/strong> (&lt;code>ln_co2&lt;/code>) &amp;mdash; the dependent variable. DSL will run LASSO on this variable against all controls (Step 1)&lt;/li>
&lt;li>&lt;strong>&lt;code>$gdp_vars&lt;/code>&lt;/strong> (&lt;code>ln_gdp ln_gdp_sq ln_gdp_cb&lt;/code>) &amp;mdash; the &lt;em>variables of interest&lt;/em>. These are never penalized by LASSO; they always appear in the final OLS. DSL runs a separate LASSO for each one against all controls (Steps 2a&amp;ndash;2c)&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.stata.com/manuals/lassodsregress.pdf" target="_blank" rel="noopener">&lt;code>controls(($fe) $controls)&lt;/code>&lt;/a>&lt;/strong> &amp;mdash; the candidate controls subject to LASSO selection. Parentheses around &lt;code>$fe&lt;/code> tell Stata to treat factor variables (country and year dummies) as always-included in the LASSO penalty but available for selection. The 12 candidate controls are subject to the standard LASSO penalty&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.stata.com/manuals/lassodsregress.pdf" target="_blank" rel="noopener">&lt;code>vce(cluster country_id)&lt;/code>&lt;/a>&lt;/strong> &amp;mdash; compute cluster-robust standard errors at the country level in the final OLS (Step 4). This also affects the LASSO penalty through the &lt;a href="https://www.stata.com/manuals/lassolasso.pdf" target="_blank" rel="noopener">&lt;code>selection(plugin)&lt;/code>&lt;/a> method, which adjusts $\lambda$ for cluster dependence&lt;/li>
&lt;li>&lt;strong>&lt;code>selection(plugin)&lt;/code>&lt;/strong> (default) &amp;mdash; choose $\lambda$ using a data-driven analytical formula rather than cross-validation. The alternative &lt;a href="https://www.stata.com/manuals/lassolasso.pdf" target="_blank" rel="noopener">&lt;code>selection(cv)&lt;/code>&lt;/a> uses cross-validation but is slower&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.stata.com/manuals/lassolassoinfo.pdf" target="_blank" rel="noopener">&lt;code>lassoinfo&lt;/code>&lt;/a>&lt;/strong> (post-estimation) &amp;mdash; reports the number of selected controls and the $\lambda$ value for each LASSO step&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.stata.com/manuals/lassolassocoef.pdf" target="_blank" rel="noopener">&lt;code>lassocoef&lt;/code>&lt;/a>&lt;/strong> (post-estimation) &amp;mdash; displays which specific variables were selected or dropped by LASSO&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&lt;strong>Related commands.&lt;/strong> Stata also offers &lt;a href="https://www.stata.com/manuals/lassoporegress.pdf" target="_blank" rel="noopener">&lt;code>poregress&lt;/code>&lt;/a> (partialing-out regression), which &lt;em>residualizes&lt;/em> both the outcome and the treatment against all controls instead of selecting then regressing. Both methods provide valid inference. &lt;a href="https://www.stata.com/manuals/lassoxporegress.pdf" target="_blank" rel="noopener">&lt;code>xporegress&lt;/code>&lt;/a> extends this to cross-fit partialing-out for even more robust inference. This tutorial uses &lt;code>dsregress&lt;/code> because its select-then-regress logic is more intuitive for beginners.&lt;/p>
&lt;/blockquote>
&lt;h3 id="63-estimation">6.3 Estimation&lt;/h3>
&lt;pre>&lt;code class="language-stata">dsregress $outcome $gdp_vars, ///
controls(($fe) $controls) ///
vce(cluster country_id)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Double-selection linear model Number of obs = 1,600
Number of controls = 112
Number of selected controls = 102
Wald chi2(3) = 53.15
Prob &amp;gt; chi2 = 0.0000
(Std. err. adjusted for 80 clusters in country_id)
------------------------------------------------------------------------------
| Robust
ln_co2 | Coefficient std. err. z P&amp;gt;|z| [95% conf. interval]
-------------+----------------------------------------------------------------
ln_gdp | -7.433319 1.628321 -4.57 0.000 -10.62477 -4.241868
ln_gdp_sq | .8401567 .1713522 4.90 0.000 .5043126 1.176001
ln_gdp_cb | -.0310764 .005952 -5.22 0.000 -.0427421 -.0194107
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>Post-double-selection completed in seconds with cluster-robust standard errors at the country level. Internally, &lt;code>dsregress&lt;/code> ran four separate LASSO regressions (Step 1 on CO&lt;sub>2&lt;/sub>, Steps 2a&amp;ndash;2c on each GDP term), took the union of all selected controls, and then ran a final OLS of CO&lt;sub>2&lt;/sub> on the GDP terms plus that union. All three GDP terms are significant at the 0.1% level. The Wald test strongly rejects the null that GDP terms are jointly zero ($\chi^2 = 53.15$, p &amp;lt; 0.001).&lt;/p>
&lt;h3 id="64-turning-points">6.4 Turning points&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>Minimum:&lt;/strong> \$2,429 GDP per capita (true: \$1,895)&lt;/li>
&lt;li>&lt;strong>Maximum:&lt;/strong> \$27,672 GDP per capita (true: \$34,647)&lt;/li>
&lt;/ul>
&lt;p>The post-double-selection turning points (\$2,429 and \$27,672) fall between the sparse FE and kitchen-sink estimates, closer to the BMA values. With cluster-robust standard errors, the LASSO selection retained 102 of 112 controls for the outcome equation and 100 for each GDP term. The union of selected controls in Step 3 includes a few more candidate variables than without clustering, producing coefficients (&amp;ndash;7.433, 0.840, &amp;ndash;0.031) that lie between the sparse and kitchen-sink specifications.&lt;/p>
&lt;h3 id="65-lasso-selection">6.5 LASSO selection&lt;/h3>
&lt;p>To understand which controls LASSO kept and which it dropped, we inspect the selection details:&lt;/p>
&lt;pre>&lt;code class="language-stata">lassoinfo
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Estimate: active
Command: dsregress
------------------------------------------------------
| No. of
| Selection selected
Variable | Model method lambda variables
------------+-----------------------------------------
ln_co2 | linear plugin .3818852 102
ln_gdp | linear plugin .3818852 100
ln_gdp_sq | linear plugin .3818852 100
ln_gdp_cb | linear plugin .3818852 100
------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The &lt;code>lassoinfo&lt;/code> output shows each of the four LASSO steps. The outcome equation selected 102 of 112 controls, while each GDP equation selected 100. The 112 candidates include 80 country dummies + 19 year dummies = 99 FE dummies, plus the 12 candidate variables and the constant. LASSO retains nearly all informative FE dummies and drops about 10&amp;ndash;12 of the weakest candidates at each step. The union across all four steps (Step 3) yields the final control set for Step 4&amp;rsquo;s OLS. With cluster-robust standard errors, the lambda is larger (0.382 vs 0.090 without clustering), leading to slightly different selection and producing DSL coefficients (&amp;ndash;7.433, 0.840, &amp;ndash;0.031) that fall between the sparse and kitchen-sink FE.&lt;/p>
&lt;p>Why does DSL not match BMA&amp;rsquo;s accuracy here? In panel data settings where FE dummies dominate the control set (99 of 112 variables), LASSO retains nearly all FE dummies and has limited room to discriminate among the 12 candidate controls of interest &amp;mdash; it dropped only 10&amp;ndash;12 variables at each step, most of them weak FE dummies rather than noise controls. This &amp;ldquo;almost everything selected&amp;rdquo; outcome means DSL&amp;rsquo;s final OLS is close to the kitchen-sink specification, which explains why its coefficients (&amp;ndash;7.433, 0.840, &amp;ndash;0.031) fall between sparse and kitchen-sink FE rather than converging to the true DGP. To see LASSO&amp;rsquo;s selection power unleashed, we next run DSL &lt;em>without&lt;/em> fixed effects.&lt;/p>
&lt;h3 id="66-pooled-dsl-without-fixed-effects">6.6 Pooled DSL (without fixed effects)&lt;/h3>
&lt;p>What happens when LASSO has only 12 candidate controls instead of 112? To answer this, we run DSL on the pooled data &amp;mdash; treating the panel as a cross-sectional dataset without country or year fixed effects. This gives LASSO full room to discriminate among the candidate controls, but at the cost of omitting the unobserved country heterogeneity that fixed effects would absorb.&lt;/p>
&lt;pre>&lt;code class="language-stata">* DSL without FE -- pooled cross-section with cluster-robust SEs
dsregress $outcome $gdp_vars, ///
controls($controls) ///
vce(cluster country_id)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Double-selection linear model Number of obs = 1,600
Number of controls = 12
Number of selected controls = 7
Wald chi2(3) = 25.05
Prob &amp;gt; chi2 = 0.0000
(Std. err. adjusted for 80 clusters in country_id)
------------------------------------------------------------------------------
| Robust
ln_co2 | Coefficient std. err. z P&amp;gt;|z| [95% conf. interval]
-------------+----------------------------------------------------------------
ln_gdp | -22.03297 5.277295 -4.18 0.000 -32.37628 -11.68966
ln_gdp_sq | 2.366878 .5652276 4.19 0.000 1.259052 3.474703
ln_gdp_cb | -.084224 .0199055 -4.23 0.000 -.1232381 -.04521
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The pooled DSL still finds the correct inverted-N sign pattern ($\beta_1 &amp;lt; 0$, $\beta_2 &amp;gt; 0$, $\beta_3 &amp;lt; 0$), but the magnitudes are dramatically different from the true DGP. The linear coefficient (&amp;ndash;22.03) is more than &lt;em>three times&lt;/em> the true value (&amp;ndash;7.10), and the other terms are similarly inflated. This is &lt;strong>omitted variable bias&lt;/strong>: without country fixed effects, the GDP terms absorb not only their own effect on CO&lt;sub>2&lt;/sub> but also the persistent cross-country differences in emissions levels that fixed effects would have captured.&lt;/p>
&lt;pre>&lt;code class="language-stata">lassoinfo
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Estimate: active
Command: dsregress
------------------------------------------------------
| No. of
| Selection selected
Variable | Model method lambda variables
------------+-----------------------------------------
ln_co2 | linear plugin .3818852 5
ln_gdp | linear plugin .3818852 7
ln_gdp_sq | linear plugin .3818852 7
ln_gdp_cb | linear plugin .3818852 7
------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>Now the contrast with the FE-based DSL is stark. The outcome LASSO selected only &lt;strong>5 of 12&lt;/strong> controls (vs 102 of 112 with FE), and the GDP LASSOes selected &lt;strong>7 of 12&lt;/strong> (vs 100 of 112). Without FE dummies flooding the candidate set, LASSO can genuinely discriminate &amp;mdash; it zeroed out 5&amp;ndash;7 controls as irrelevant. The turning points are \$5,581 (minimum) and \$24,532 (maximum), far from the true values.&lt;/p>
&lt;p>This comparison illustrates a fundamental tradeoff in panel data econometrics: &lt;strong>fixed effects remove bias but limit LASSO&amp;rsquo;s selection power&lt;/strong>. With FE, the estimates are unbiased but LASSO selects almost everything. Without FE, LASSO selects sharply but the estimates are biased by unobserved heterogeneity. The FE-based DSL from Section 6.3 is the correct specification for this data, even though LASSO&amp;rsquo;s selection looks less impressive.&lt;/p>
&lt;h2 id="7-head-to-head-comparison">7. Head-to-Head Comparison&lt;/h2>
&lt;h3 id="71-coefficient-comparison">7.1 Coefficient comparison&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Sparse FE&lt;/th>
&lt;th>Kitchen-Sink FE&lt;/th>
&lt;th>BMA (FE)&lt;/th>
&lt;th>DSL (FE)&lt;/th>
&lt;th>BMA (pooled)&lt;/th>
&lt;th>DSL (pooled)&lt;/th>
&lt;th>True DGP&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>$\beta_1$ (GDP)&lt;/td>
&lt;td>&amp;ndash;7.498&lt;/td>
&lt;td>&amp;ndash;7.131&lt;/td>
&lt;td>&amp;ndash;7.139&lt;/td>
&lt;td>&amp;ndash;7.433&lt;/td>
&lt;td>&amp;ndash;21.258&lt;/td>
&lt;td>&amp;ndash;22.033&lt;/td>
&lt;td>&amp;ndash;7.100&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\beta_2$ (GDP²)&lt;/td>
&lt;td>0.849&lt;/td>
&lt;td>0.806&lt;/td>
&lt;td>0.808&lt;/td>
&lt;td>0.840&lt;/td>
&lt;td>2.285&lt;/td>
&lt;td>2.367&lt;/td>
&lt;td>0.810&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\beta_3$ (GDP³)&lt;/td>
&lt;td>&amp;ndash;0.031&lt;/td>
&lt;td>&amp;ndash;0.030&lt;/td>
&lt;td>&amp;ndash;0.030&lt;/td>
&lt;td>&amp;ndash;0.031&lt;/td>
&lt;td>&amp;ndash;0.081&lt;/td>
&lt;td>&amp;ndash;0.084&lt;/td>
&lt;td>&amp;ndash;0.030&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Min TP&lt;/strong>&lt;/td>
&lt;td>\$2,478&lt;/td>
&lt;td>\$2,426&lt;/td>
&lt;td>\$2,411&lt;/td>
&lt;td>\$2,429&lt;/td>
&lt;td>\$5,752&lt;/td>
&lt;td>\$5,581&lt;/td>
&lt;td>\$1,895&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Max TP&lt;/strong>&lt;/td>
&lt;td>\$25,656&lt;/td>
&lt;td>\$27,694&lt;/td>
&lt;td>\$27,269&lt;/td>
&lt;td>\$27,672&lt;/td>
&lt;td>\$23,298&lt;/td>
&lt;td>\$24,532&lt;/td>
&lt;td>\$34,647&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The table reveals a sharp divide between FE-based and pooled specifications. The four FE-based methods (columns 2&amp;ndash;5) all produce GDP coefficients within a narrow range of the true values &amp;mdash; BMA (FE) and Kitchen-Sink FE are closest, with estimates within 1% of the truth. The two pooled methods (columns 6&amp;ndash;7) are dramatically biased, with coefficients inflated 2&amp;ndash;3x. Strikingly, BMA (pooled) and DSL (pooled) agree closely with &lt;em>each other&lt;/em> (&amp;ndash;21.26 vs &amp;ndash;22.03 for $\beta_1$), confirming that the bias comes from omitting fixed effects, not from the choice of variable selection method. Both pooled methods produce turning points displaced from the truth (\$5,600&amp;ndash;5,800 vs true \$1,895 for the minimum).&lt;/p>
&lt;h3 id="72-uncertainty-confidence-and-credible-intervals">7.2 Uncertainty: confidence and credible intervals&lt;/h3>
&lt;p>Point estimates tell only half the story. How &lt;em>uncertain&lt;/em> is each method, and does the interval actually contain the truth? The table below shows 95% confidence intervals (for the frequentist methods) and approximate 95% credible intervals (for BMA, computed as posterior mean $\pm$ 2 posterior SD). The last column checks whether the true DGP value falls inside the interval.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>$\beta_1$ (GDP) interval&lt;/th>
&lt;th>Covers true?&lt;/th>
&lt;th>$\beta_2$ (GDP²) interval&lt;/th>
&lt;th>Covers true?&lt;/th>
&lt;th>$\beta_3$ (GDP³) interval&lt;/th>
&lt;th>Covers true?&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Sparse FE&lt;/strong>&lt;/td>
&lt;td>[&amp;ndash;10.731, &amp;ndash;4.266]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>[0.510, 1.188]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>[&amp;ndash;0.043, &amp;ndash;0.020]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Kitchen-Sink FE&lt;/strong>&lt;/td>
&lt;td>[&amp;ndash;10.241, &amp;ndash;4.021]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>[0.478, 1.134]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>[&amp;ndash;0.041, &amp;ndash;0.018]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>BMA (FE)&lt;/strong> (credible)&lt;/td>
&lt;td>[&amp;ndash;10.761, &amp;ndash;3.517]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>[0.429, 1.186]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>[&amp;ndash;0.043, &amp;ndash;0.017]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DSL (FE)&lt;/strong>&lt;/td>
&lt;td>[&amp;ndash;10.625, &amp;ndash;4.242]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>[0.504, 1.176]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;td>[&amp;ndash;0.043, &amp;ndash;0.019]&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>BMA (pooled)&lt;/strong> (credible)&lt;/td>
&lt;td>[&amp;ndash;24.541, &amp;ndash;17.975]&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>[1.935, 2.635]&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>[&amp;ndash;0.094, &amp;ndash;0.069]&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>DSL (pooled)&lt;/strong>&lt;/td>
&lt;td>[&amp;ndash;32.376, &amp;ndash;11.690]&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>[1.259, 3.475]&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;td>[&amp;ndash;0.123, &amp;ndash;0.045]&lt;/td>
&lt;td>&lt;strong>No&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>True DGP&lt;/strong>&lt;/td>
&lt;td>&amp;ndash;7.100&lt;/td>
&lt;td>&lt;/td>
&lt;td>0.810&lt;/td>
&lt;td>&lt;/td>
&lt;td>&amp;ndash;0.030&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The four FE-based methods all produce intervals that contain the true parameter values &amp;mdash; a reassuring result. Both pooled methods, however, &lt;strong>fail to cover the truth for any of the three coefficients&lt;/strong>. The pooled DSL intervals are wide (the $\beta_1$ interval spans 20.7 units) but centered so far from the truth that even this width cannot compensate. The pooled BMA credible intervals are actually &lt;em>narrower&lt;/em> (spanning 6.6 units for $\beta_1$) but even more precisely wrong &amp;mdash; they are tightly concentrated around the biased estimate. This is the worst-case scenario: &lt;strong>false precision from a misspecified model&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Width reflects uncertainty.&lt;/strong> Among the FE-based methods, BMA produces the widest interval for $\beta_1$ (width = 7.24), followed by Sparse FE (6.47), DSL with FE (6.38), and Kitchen-Sink FE (6.22). BMA&amp;rsquo;s wider intervals reflect its honest accounting of model uncertainty &amp;mdash; it averages across thousands of models, each contributing slightly different coefficient estimates, which inflates the posterior standard deviation. The frequentist methods condition on a single model and therefore understate the total uncertainty.&lt;/p>
&lt;p>&lt;strong>Centering reflects bias.&lt;/strong> Kitchen-Sink FE and BMA center their intervals closest to the true value (&amp;ndash;7.131 and &amp;ndash;7.139 vs. true &amp;ndash;7.100), while Sparse FE (&amp;ndash;7.498) and DSL with FE (&amp;ndash;7.433) are slightly further away. The pooled DSL (&amp;ndash;22.033) is dramatically off-center, illustrating that omitted variable bias overwhelms any precision gained from better variable selection.&lt;/p>
&lt;p>&lt;strong>Coverage requires correct specification.&lt;/strong> The pooled DSL result drives home a critical lesson: a confidence interval is only as good as the model behind it. The 95% label promises that, in repeated sampling, 95% of intervals would contain the truth &amp;mdash; but this guarantee holds only if the model is correctly specified. When country fixed effects are omitted, the model is misspecified, and the intervals fail despite being statistically &amp;ldquo;valid&amp;rdquo; within the pooled framework.&lt;/p>
&lt;p>&lt;strong>Bayesian vs frequentist interpretation.&lt;/strong> BMA&amp;rsquo;s credible intervals have a different interpretation: a 95% BMA credible interval says &amp;ldquo;given the data and priors, there is a 95% posterior probability the true coefficient lies in this range,&amp;rdquo; while a 95% confidence interval says &amp;ldquo;if we repeated this procedure many times, 95% of the intervals would contain the truth.&amp;rdquo; In practice, both require correct model specification to be reliable.&lt;/p>
&lt;h3 id="73-predicted-ekc-curves">7.3 Predicted EKC curves&lt;/h3>
&lt;p>The curves are normalized to zero at the sample-mean GDP so both methods are directly comparable:&lt;/p>
&lt;pre>&lt;code class="language-stata">* Generate predicted EKC curves for BMA and DSL, normalized at mean GDP
summarize ln_gdp
local xmin = r(min)
local xmax = r(max)
local xmean = r(mean)
clear
set obs 500
gen lngdp = `xmin' + (_n - 1) * (`xmax' - `xmin') / 499
* Cubic component for each method (using stored coefficients)
gen fit_bma = `b1_bma' * lngdp + `b2_bma' * lngdp^2 + `b3_bma' * lngdp^3
gen fit_dsl = `b1_dsl' * lngdp + `b2_dsl' * lngdp^2 + `b3_dsl' * lngdp^3
* Normalize: subtract value at sample-mean GDP
local norm_bma = `b1_bma' * `xmean' + `b2_bma' * `xmean'^2 + `b3_bma' * `xmean'^3
local norm_dsl = `b1_dsl' * `xmean' + `b2_dsl' * `xmean'^2 + `b3_dsl' * `xmean'^3
replace fit_bma = fit_bma - `norm_bma'
replace fit_dsl = fit_dsl - `norm_dsl'
twoway ///
(line fit_bma lngdp, lcolor(&amp;quot;106 155 204&amp;quot;) lwidth(medthick)) ///
(line fit_dsl lngdp, lcolor(&amp;quot;217 119 87&amp;quot;) lwidth(medthick) lpattern(dash)), ///
xline(`lnmin_bma', lcolor(&amp;quot;106 155 204&amp;quot;%50) lpattern(shortdash)) ///
xline(`lnmax_bma', lcolor(&amp;quot;106 155 204&amp;quot;%50) lpattern(shortdash)) ///
ytitle(&amp;quot;Predicted log CO2 (normalized at mean GDP)&amp;quot;) ///
xtitle(&amp;quot;Log GDP per capita&amp;quot;) ///
title(&amp;quot;Predicted EKC Shape: BMA vs. DSL&amp;quot;) ///
legend(order(1 &amp;quot;BMA&amp;quot; 2 &amp;quot;DSL&amp;quot;) rows(1) position(6)) ///
scheme(s2color)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_bma_dsl_fig5_ekc_curves.png" alt="Predicted EKC curves from BMA and DSL, normalized at the sample mean. Both methods trace a clear inverted-N shape with closely aligned turning points.">&lt;/p>
&lt;p>Both curves trace a clear inverted-N: CO&lt;sub>2&lt;/sub> falls at low incomes, rises through industrialization, and falls again at high incomes. The BMA curve (solid blue) and DSL curve (dashed orange) are nearly indistinguishable, with turning points closely aligned. The normalization at mean GDP makes the shape immediately visible &amp;mdash; a major improvement over plotting raw cubic components that would sit at different y-levels.&lt;/p>
&lt;h3 id="74-answer-key-grading-the-methods">7.4 Answer key: grading the methods&lt;/h3>
&lt;p>The ultimate test: do BMA and DSL correctly identify the 5 true predictors and reject the 7 noise variables?&lt;/p>
&lt;pre>&lt;code class="language-stata">* Dot plot: BMA PIPs color-coded by ground truth
* (extract PIPs, label variables, mark true vs noise --- see analysis.do)
graph twoway ///
(scatter order pip if is_true == 1, ///
mcolor(&amp;quot;106 155 204&amp;quot;) msymbol(circle) msize(large)) ///
(scatter order pip if is_true == 0, ///
mcolor(gs9) msymbol(diamond) msize(large)), ///
xline(0.8, lcolor(&amp;quot;217 119 87&amp;quot;) lpattern(dash) lwidth(medium)) ///
ylabel(1(1)15, valuelabel angle(0) labsize(small)) ///
xlabel(0(0.2)1, format(%3.1f)) ///
xtitle(&amp;quot;BMA Posterior Inclusion Probability&amp;quot;) ///
title(&amp;quot;Answer Key: Do BMA and DSL Recover the Truth?&amp;quot;) ///
legend(order(1 &amp;quot;True predictor&amp;quot; 2 &amp;quot;Noise variable&amp;quot;) ///
rows(1) position(6)) ///
scheme(s2color)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_bma_dsl_fig6_answer_key.png" alt="Dot plot showing BMA Posterior Inclusion Probabilities for each variable, color-coded by ground truth. True predictors (circles, blue) cluster above the 0.80 threshold; noise variables (diamonds, gray) cluster below it.">&lt;/p>
&lt;p>&lt;strong>BMA&amp;rsquo;s report card:&lt;/strong> Of the 8 true predictors (3 GDP terms + 5 controls), BMA correctly assigns PIP &amp;gt; 0.80 to 6 &amp;mdash; the three GDP terms, fossil fuel, industry, and renewable energy. It misses urban (PIP ~ 0.27) and democracy (PIP ~ 0.02), whose true coefficients are small (0.007 and &amp;ndash;0.005). All 7 noise variables receive PIPs well below 0.80. BMA makes &lt;strong>zero false positives&lt;/strong> (no noise variable incorrectly flagged as robust) and &lt;strong>two false negatives&lt;/strong> (two weak true predictors missed).&lt;/p>
&lt;p>&lt;strong>Post-double-selection&amp;rsquo;s report card:&lt;/strong> With cluster-robust SEs, the union of all four LASSO steps selected 102 of 112 total controls (including FE dummies). The resulting DSL coefficients (&amp;ndash;7.433, 0.840, &amp;ndash;0.031) fall between the sparse and kitchen-sink FE, closer to the true DGP than the sparse specification. The entire procedure runs in seconds rather than minutes.&lt;/p>
&lt;p>&lt;strong>Bottom line:&lt;/strong> Both methods recover the inverted-N EKC shape. BMA provides more granular variable-level inference (PIPs), while DSL provides fast, valid coefficient estimates. The synthetic data &amp;ldquo;answer key&amp;rdquo; confirms that both are doing their job &amp;mdash; with the expected limitation that weak signals are hard to detect.&lt;/p>
&lt;h2 id="8-discussion">8. Discussion&lt;/h2>
&lt;h3 id="81-what-the-results-mean-for-the-ekc">8.1 What the results mean for the EKC&lt;/h3>
&lt;p>Both BMA and DSL identify the &lt;strong>inverted-N&lt;/strong> EKC shape with turning points close to the true DGP values. BMA correctly identifies 6 of 8 true predictors (3 GDP terms + fossil fuel, industry, renewable) with zero false positives among noise variables. The inverted-N shape implies three phases of the income&amp;ndash;pollution relationship:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Declining phase&lt;/strong> (below ~\$2,400): Very poor countries where CO&lt;sub>2&lt;/sub> may fall as subsistence agriculture shifts toward slightly cleaner energy.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Rising phase&lt;/strong> (~\$2,400 to ~\$27,000): Industrializing countries where emissions rise sharply. Most of the world&amp;rsquo;s population lives here.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Declining phase&lt;/strong> (above ~\$27,000): Wealthy countries where clean technology and regulation reduce emissions.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>The policy implication is important: the inverted-N suggests that the &amp;ldquo;environmental improvement&amp;rdquo; phase is not automatic. Unlike the simpler inverted-U hypothesis, which predicts a single turning point after which pollution monotonically declines, the inverted-N warns that countries at very low income levels may &lt;em>already&lt;/em> be on a declining emissions path that reverses once industrialization begins. This makes the middle-income range &amp;mdash; where emissions rise steeply &amp;mdash; the critical window for environmental policy intervention.&lt;/p>
&lt;p>The three robust control variables identified by BMA reinforce this narrative:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Fossil fuel dependence&lt;/strong> (PIP = 1.000) is the single strongest predictor of CO&lt;sub>2&lt;/sub> emissions, with a coefficient close to the true DGP value.&lt;/li>
&lt;li>&lt;strong>Renewable energy share&lt;/strong> (PIP = 0.959) enters with a negative sign, confirming that energy mix transitions reduce emissions.&lt;/li>
&lt;li>&lt;strong>Industry value-added&lt;/strong> (PIP = 0.999) captures the composition effect &amp;mdash; economies dominated by manufacturing produce more CO&lt;sub>2&lt;/sub> per unit of GDP than service-based economies.&lt;/li>
&lt;/ul>
&lt;h3 id="82-when-to-use-bma-vs-post-double-selection">8.2 When to use BMA vs post-double-selection&lt;/h3>
&lt;p>The two methods answer fundamentally different research questions:&lt;/p>
&lt;p>&lt;strong>Use BMA&lt;/strong> when the question is &lt;em>&amp;ldquo;which variables robustly predict the outcome?&amp;quot;&lt;/em> BMA provides PIPs, coefficient densities, and a complete picture of the model space. It excels in exploratory settings where variable importance is the goal. In our simulation, BMA produced the most accurate coefficient estimates (&amp;ndash;7.139 vs true &amp;ndash;7.100) and provided rich diagnostics (PIP chart, density plots) that make the evidence for each variable transparent. The cost is computational: BMA requires MCMC sampling (minutes to hours depending on the model space).&lt;/p>
&lt;p>&lt;strong>Use post-double-selection&lt;/strong> when the question is &lt;em>&amp;ldquo;what is the causal effect of a specific variable of interest, controlling for high-dimensional confounders?&amp;quot;&lt;/em> DSL provides fast, valid inference on the coefficients of interest with standard errors and confidence intervals. It is designed for settings where you have a clear treatment variable and many potential controls. In our simulation, DSL completed in seconds and produced valid standard errors, but its coefficient estimates (&amp;ndash;7.433) were less accurate than BMA&amp;rsquo;s because LASSO had limited room to discriminate among controls in the FE-heavy panel setting.&lt;/p>
&lt;p>&lt;strong>Use both together&lt;/strong> (as in this tutorial) when you want the strongest possible evidence. If a Bayesian and a frequentist method agree on the sign, magnitude, and significance of an effect, the finding is unlikely to be an artifact of any single modeling choice. Disagreements between the methods are also informative &amp;mdash; they signal areas where the evidence is sensitive to assumptions.&lt;/p>
&lt;h3 id="83-pooled-vs-fixed-effects-a-cautionary-comparison">8.3 Pooled vs fixed effects: a cautionary comparison&lt;/h3>
&lt;p>The pooled specifications (Sections 5.7 and 6.6) provide a powerful pedagogical contrast. When we strip away fixed effects and run both BMA and DSL on pooled data, three things happen simultaneously:&lt;/p>
&lt;p>&lt;strong>LASSO selection improves but estimates worsen.&lt;/strong> Without 99 FE dummies diluting the candidate set, LASSO in pooled DSL selected only 5&amp;ndash;7 of 12 controls (vs 102 of 112 with FE). This is closer to the &amp;ldquo;textbook&amp;rdquo; LASSO scenario where the method has genuine discriminating power. Yet the resulting coefficient estimates are 2&amp;ndash;3x the true values because omitted country heterogeneity biases everything.&lt;/p>
&lt;p>&lt;strong>BMA PIPs become unreliable.&lt;/strong> With fixed effects, BMA assigned PIP near zero to all 7 noise variables &amp;mdash; zero false positives. Without FE, 5 noise variables (services, pop_density, credit, trade, and inflated democracy) received PIPs above 0.80. The noise variables are correlated with omitted country effects, and BMA interprets these spurious correlations as genuine predictive power. This demonstrates that &lt;strong>PIP thresholds are only meaningful when the model set is correctly specified&lt;/strong>.&lt;/p>
&lt;p>&lt;strong>Both methods agree on the bias.&lt;/strong> Pooled BMA and pooled DSL produce remarkably similar biased coefficients ($\beta_1 = -21.26$ vs $-22.03$), confirming that the problem is not the variable selection method but the omitted fixed effects. The agreement between a Bayesian and a frequentist method on the &lt;em>wrong&lt;/em> answer reinforces the lesson: &lt;strong>method agreement is not a substitute for correct model specification&lt;/strong>.&lt;/p>
&lt;p>The practical takeaway for applied researchers: in panel data settings, always include entity fixed effects (or equivalent controls for unobserved heterogeneity) before applying BMA or DSL. Running these methods on pooled data without FE will produce misleading results &amp;mdash; not because the methods fail, but because the models they average over or select from are all misspecified.&lt;/p>
&lt;h3 id="84-limitations-and-caveats">8.4 Limitations and caveats&lt;/h3>
&lt;p>&lt;strong>Synthetic vs real data.&lt;/strong> This is synthetic data &amp;mdash; the patterns are sharper than real-world data, and we can verify ground truth only because we designed the DGP. With real data, model uncertainty is genuinely unresolvable, and there is no answer key to check against. The separation between true predictors and noise variables is cleaner here than in most applications.&lt;/p>
&lt;p>&lt;strong>Weak signals are hard to detect.&lt;/strong> Both methods missed urban population (PIP = 0.27) and democracy (PIP = 0.02), whose true coefficients are small (0.007 and &amp;ndash;0.005). This is not a failure of the methods &amp;mdash; it is a fundamental statistical limitation. Detecting a coefficient of 0.005 in the presence of panel-level noise requires either a much larger sample or a stronger signal.&lt;/p>
&lt;p>&lt;strong>Panel FE and LASSO.&lt;/strong> In our panel setting, 99 of 112 candidate controls are FE dummies that LASSO retains almost entirely. This limits DSL&amp;rsquo;s ability to discriminate among the 12 candidate controls. In cross-sectional settings or settings with many genuinely irrelevant variables, DSL would have more room to operate and potentially match BMA&amp;rsquo;s accuracy.&lt;/p>
&lt;p>&lt;strong>Extensions.&lt;/strong> Researchers working with real EKC data should also consider endogeneity (via 2SLS-BMA, as in Gravina and Lanzafame, 2025), alternative pollutants (SO&lt;sub>2&lt;/sub>, PM2.5), spatial dependence across countries, and structural breaks in the income&amp;ndash;pollution relationship.&lt;/p>
&lt;h2 id="9-summary-and-next-steps">9. Summary and Next Steps&lt;/h2>
&lt;h3 id="takeaways">Takeaways&lt;/h3>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>Both methods confirm the inverted-N shape.&lt;/strong> BMA (Bayesian, averaging across models) and post-double-selection (frequentist, LASSO-based) both recover the inverted-N EKC. BMA produces coefficients closest to the true DGP (&amp;ndash;7.139 vs &amp;ndash;7.100 for $\beta_1$). DSL with cluster-robust SEs gives &amp;ndash;7.433, falling between the sparse and kitchen-sink FE. Both methods outperform the naive sparse specification.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Both methods recover the ground truth.&lt;/strong> BMA correctly identifies 6 of 8 true predictors with zero false positives. The three strongest true controls (fossil fuel, industry, renewable energy) all receive PIPs above 0.95. The two misses (urban, democracy) have small true coefficients, illustrating that even good methods have limits with weak signals.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Model uncertainty is real.&lt;/strong> The GDP linear coefficient shifts from &amp;ndash;7.498 (sparse) to &amp;ndash;7.131 (kitchen-sink) depending on which controls are included. The maximum turning point moves by \$2,000. BMA and DSL provide principled solutions.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>BMA and post-double-selection serve different purposes.&lt;/strong> BMA excels at variable selection (PIPs, coefficient densities) and produced the most accurate coefficient estimates in this setting. Post-double-selection is fastest and provides standard frequentist inference with cluster-robust SEs. In panel settings dominated by FE dummies, LASSO has limited room to discriminate among candidate controls; DSL would be more powerful in cross-sectional settings with many irrelevant variables.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Fixed effects are essential, not optional.&lt;/strong> Running either method on pooled data without FE produces coefficients inflated 2&amp;ndash;3x (BMA pooled: &amp;ndash;21.26, DSL pooled: &amp;ndash;22.03, vs true &amp;ndash;7.10 for $\beta_1$). Worse, pooled BMA assigns high PIPs to 5 noise variables that the FE-based BMA correctly rejects. Confidence and credible intervals from pooled models fail to cover the true values for all three coefficients. The lesson: always include fixed effects in panel data before applying variable selection methods.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h3 id="exercises">Exercises&lt;/h3>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Sensitivity to the g-prior.&lt;/strong> Re-run &lt;code>bmaregress&lt;/code> with &lt;code>gprior(bric)&lt;/code> instead of &lt;code>gprior(uip)&lt;/code>. The BIC prior penalizes model complexity more heavily. Do the PIPs change? Does it still identify fossil fuel, industry, and renewable as robust? (&lt;em>Hint:&lt;/em> BIC priors tend to be more conservative, so borderline variables may drop below the threshold.)&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Test for inverted-U.&lt;/strong> Drop &lt;code>ln_gdp_cb&lt;/code> and re-run with only linear and squared GDP terms. What do BMA and DSL say about the simpler quadratic specification? (&lt;em>Hint:&lt;/em> since the DGP includes a cubic term, the quadratic model is misspecified &amp;mdash; check whether the coefficients absorb the cubic effect or produce a visibly different EKC shape.)&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Increase noise.&lt;/strong> Re-generate the synthetic data with &lt;code>sigma_eps = 0.30&lt;/code> (double the noise) in &lt;code>generate_data.do&lt;/code> and re-run the full analysis. How does this affect BMA&amp;rsquo;s ability to distinguish true predictors from noise? (&lt;em>Hint:&lt;/em> expect more variables with PIPs in the ambiguous 0.3&amp;ndash;0.7 range, and possibly some noise variables crossing the 0.80 threshold &amp;mdash; false positives become more likely with noisier data.)&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="appendix-a-first-differences-analysis">Appendix A: First-Differences Analysis&lt;/h2>
&lt;h3 id="a1-motivation">A.1 Motivation&lt;/h3>
&lt;p>The fixed effects estimator removes time-invariant country heterogeneity by demeaning each variable within country. An alternative approach is &lt;strong>first differencing&lt;/strong>: computing the change between the last and first year for each country ($\Delta x_i = x_{i,2014} - x_{i,1995}$). This also removes time-invariant effects and produces a pure &lt;strong>cross-sectional&lt;/strong> dataset of 80 observations &amp;mdash; one per country. The cross-sectional setting is where LASSO-based methods are most powerful, because there are no FE dummies diluting the candidate set.&lt;/p>
&lt;p>The tradeoff is statistical power: first differencing uses only two data points per country (discarding 18 intermediate years), while the within-estimator uses all 20. We expect noisier estimates but cleaner variable selection.&lt;/p>
&lt;h3 id="a2-constructing-the-first-difference-dataset">A.2 Constructing the first-difference dataset&lt;/h3>
&lt;pre>&lt;code class="language-stata">* Keep only first (1995) and last (2014) years, reshape, compute differences
keep if year == 1995 | year == 2014
reshape wide $outcome $gdp_vars $controls, i(country_id) j(year)
foreach v in $outcome $gdp_vars $controls {
gen d_`v' = `v'2014 - `v'1995
}
&lt;/code>&lt;/pre>
&lt;p>This produces 80 observations, each representing how much a country&amp;rsquo;s variables changed over the 20-year period. For example, &lt;code>d_ln_gdp&lt;/code> measures the log growth in GDP per capita from 1995 to 2014.&lt;/p>
&lt;h3 id="a3-baseline-ols-on-first-differences">A.3 Baseline OLS on first differences&lt;/h3>
&lt;pre>&lt;code class="language-stata">* Sparse: GDP terms only
regress d_ln_co2 d_ln_gdp d_ln_gdp_sq d_ln_gdp_cb, robust
* Kitchen-sink: all 12 controls
regress d_ln_co2 d_ln_gdp d_ln_gdp_sq d_ln_gdp_cb ///
d_fossil_fuel d_renewable d_urban d_industry d_democracy ///
d_services d_trade d_fdi d_credit d_pop_density ///
d_corruption d_globalization, robust
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>FD Sparse OLS:&lt;/strong>&lt;/p>
&lt;pre>&lt;code class="language-text">Linear regression Number of obs = 80
Prob &amp;gt; F = 0.0009
R-squared = 0.1433
------------------------------------------------------------------------------
| Robust
d_ln_co2 | Coefficient std. err. t P&amp;gt;|t| [95% conf. interval]
-------------+----------------------------------------------------------------
d_ln_gdp | -10.36189 4.092422 -2.53 0.013 -18.51265 -2.211121
d_ln_gdp_sq | 1.155962 .4223643 2.74 0.008 .3147506 1.997173
d_ln_gdp_cb | -.0414947 .0143721 -2.89 0.005 -.0701192 -.0128702
_cons | -.3036562 .0724366 -4.19 0.000 -.4479262 -.1593861
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>FD Kitchen-sink OLS:&lt;/strong>&lt;/p>
&lt;pre>&lt;code class="language-text">Linear regression Number of obs = 80
Prob &amp;gt; F = 0.0029
R-squared = 0.3707
------------------------------------------------------------------------------
| Robust
d_ln_co2 | Coefficient std. err. t P&amp;gt;|t| [95% conf. interval]
-------------+----------------------------------------------------------------
d_ln_gdp | -8.109709 5.031758 -1.61 0.112 -18.1618 1.942382
d_ln_gdp_sq | .9238864 .5213262 1.77 0.081 -.1175823 1.965355
d_ln_gdp_cb | -.0336221 .0179583 -1.87 0.066 -.0694979 .0022536
d_fossil_f~l | .0147108 .0067313 2.19 0.033 .0012635 .0281582
d_renewable | -.0237808 .0110384 -2.15 0.035 -.0458327 -.001729
d_urban | .0002501 .014913 0.02 0.987 -.0295421 .0300424
d_industry | .0309085 .0105974 2.92 0.005 .0097377 .0520793
d_democracy | .019337 .0290345 0.67 0.508 -.038666 .07734
d_services | -.0047239 .0098816 -0.48 0.634 -.0244647 .0150169
d_trade | .006726 .0044062 1.53 0.132 -.0020764 .0155284
d_fdi | .0000124 .0091898 0.00 0.999 -.0183463 .0183712
d_credit | .0028644 .0043456 0.66 0.512 -.0058169 .0115457
d_pop_dens~y | .0006396 .0004991 1.28 0.205 -.0003575 .0016366
d_corruption | -.0036115 .0033497 -1.08 0.285 -.0103033 .0030803
d_globaliz~n | -.0004567 .0082494 -0.06 0.956 -.0169368 .0160235
_cons | -.0085823 .1746184 -0.05 0.961 -.3574226 .340258
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The FD sparse OLS finds the inverted-N sign pattern with all three terms significant at the 5% level &amp;mdash; but the coefficients are noisier than the FE estimates (e.g., $\beta_1 = -10.36$ vs &amp;ndash;7.50 for sparse FE). The R² of 0.14 is low, reflecting the loss of within-country time-series variation when collapsing 20 years into a single difference.&lt;/p>
&lt;p>Adding controls in the kitchen-sink raises R² to 0.37 but makes the GDP terms individually insignificant (p = 0.07&amp;ndash;0.11) &amp;mdash; a consequence of having only 80 observations and 15 regressors. Among the controls, fossil fuel (p = 0.033), renewable energy (p = 0.035), and industry (p = 0.005) are significant &amp;mdash; the same three strong predictors identified by BMA with fixed effects.&lt;/p>
&lt;h3 id="a4-bma-on-first-differences">A.4 BMA on first differences&lt;/h3>
&lt;pre>&lt;code class="language-stata">bmaregress d_ln_co2 d_ln_gdp d_ln_gdp_sq d_ln_gdp_cb ///
d_fossil_fuel d_renewable d_urban d_industry d_democracy ///
d_services d_trade d_fdi d_credit d_pop_density ///
d_corruption d_globalization, ///
mprior(uniform) gprior(uip) ///
mcmcsize(50000) rseed(9988) pipcutoff(0.5) burnin(5000)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Bayesian model averaging No. of obs = 80
Linear regression No. of predictors = 15
MC3 sampling Groups = 15
Always = 0
No. of models = 2,317
For CPMP &amp;gt;= .9 = 581
Priors: Mean model size = 3.304
Models: Uniform Burn-in = 5,000
Cons.: Noninformative MCMC sample size = 50,000
Coef.: Zellner's g Acceptance rate = 0.3080
g: Unit-information, g = 80 Shrinkage, g/(1+g) = 0.9877
sigma2: Noninformative Mean sigma2 = 0.051
Sampling correlation = 0.9958
------------------------------------------------------------------------------
d_ln_co2 | Mean Std. dev. Group PIP
-------------+----------------------------------------------------------------
d_industry | .0364834 .0090778 7 .99823
------------------------------------------------------------------------------
Note: 14 predictors with PIP less than .5 not shown.
&lt;/code>&lt;/pre>
&lt;p>The FD-BMA result is dramatically different from the FE-based BMA. Only &lt;strong>one variable&lt;/strong> passes the 0.50 PIP display threshold: the change in industry share (PIP = 0.998). The three GDP polynomial terms all have PIPs below 0.30:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Variable&lt;/th>
&lt;th>PIP (FD-BMA)&lt;/th>
&lt;th>PIP (FE-BMA)&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>d_ln_gdp&lt;/td>
&lt;td>0.298&lt;/td>
&lt;td>0.994&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>d_ln_gdp_sq&lt;/td>
&lt;td>0.267&lt;/td>
&lt;td>1.000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>d_ln_gdp_cb&lt;/td>
&lt;td>0.271&lt;/td>
&lt;td>1.000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>d_fossil_fuel&lt;/td>
&lt;td>0.183&lt;/td>
&lt;td>1.000&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>d_renewable&lt;/td>
&lt;td>0.350&lt;/td>
&lt;td>0.959&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>d_urban&lt;/td>
&lt;td>0.096&lt;/td>
&lt;td>0.268&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>d_industry&lt;/td>
&lt;td>&lt;strong>0.998&lt;/strong>&lt;/td>
&lt;td>0.999&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>d_democracy&lt;/td>
&lt;td>0.094&lt;/td>
&lt;td>0.023&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>With only 80 cross-sectional observations, BMA&amp;rsquo;s evidence threshold is much harder to clear. The GDP terms &amp;mdash; which are &lt;em>the core of the EKC&lt;/em> &amp;mdash; do not survive because the 20-year differences are noisy and the cubic polynomial requires precise estimation of three correlated terms simultaneously.&lt;/p>
&lt;p>The change in industry share is the only variable with a strong enough signal-to-noise ratio to clear BMA&amp;rsquo;s bar. The FE-based BMA (N = 1,600) has 20x more observations to work with, which is why it identifies 6 robust variables.&lt;/p>
&lt;h3 id="a5-dsl-on-first-differences">A.5 DSL on first differences&lt;/h3>
&lt;pre>&lt;code class="language-stata">dsregress d_ln_co2 d_ln_gdp d_ln_gdp_sq d_ln_gdp_cb, ///
controls(d_fossil_fuel d_renewable d_urban d_industry d_democracy ///
d_services d_trade d_fdi d_credit d_pop_density ///
d_corruption d_globalization) ///
rseed(9988)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Double-selection linear model Number of obs = 80
Number of controls = 12
Number of selected controls = 1
Wald chi2(3) = 10.65
Prob &amp;gt; chi2 = 0.0138
------------------------------------------------------------------------------
| Robust
d_ln_co2 | Coefficient std. err. z P&amp;gt;|z| [95% conf. interval]
-------------+----------------------------------------------------------------
d_ln_gdp | -5.047196 4.558593 -1.11 0.268 -13.98187 3.887483
d_ln_gdp_sq | .5943786 .4700569 1.26 0.206 -.326916 1.515673
d_ln_gdp_cb | -.0220809 .0160386 -1.38 0.169 -.0535159 .0093541
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-stata">lassoinfo
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Estimate: active
Command: dsregress
------------------------------------------------------
| No. of
| Selection selected
Variable | Model method lambda variables
------------+-----------------------------------------
d_ln_co2 | linear plugin .3818852 1
d_ln_gdp | linear plugin .3818852 0
d_ln_gdp_sq | linear plugin .3818852 0
d_ln_gdp_cb | linear plugin .3818852 0
------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>FD-DSL selected only &lt;strong>1 control&lt;/strong> for the outcome equation (likely d_industry, consistent with BMA) and &lt;strong>zero controls&lt;/strong> for each of the three GDP equations. With such sparse selection, the final OLS is essentially a regression of d_ln_co2 on the three GDP terms plus one control &amp;mdash; and none of the three GDP terms are individually significant (p = 0.17&amp;ndash;0.27). The Wald test for joint significance is borderline (p = 0.014), suggesting the GDP terms collectively have some explanatory power, but the individual estimates are too noisy for inference.&lt;/p>
&lt;h3 id="a6-comparison-first-differences-vs-fixed-effects">A.6 Comparison: first differences vs fixed effects&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>FD Sparse&lt;/th>
&lt;th>FD Kitchen&lt;/th>
&lt;th>FD BMA&lt;/th>
&lt;th>FD DSL&lt;/th>
&lt;th>FE BMA&lt;/th>
&lt;th>FE DSL&lt;/th>
&lt;th>True DGP&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>$\beta_1$ (GDP)&lt;/td>
&lt;td>&amp;ndash;10.362&lt;/td>
&lt;td>&amp;ndash;8.110&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&amp;ndash;5.047&lt;/td>
&lt;td>&amp;ndash;7.139&lt;/td>
&lt;td>&amp;ndash;7.433&lt;/td>
&lt;td>&amp;ndash;7.100&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\beta_2$ (GDP²)&lt;/td>
&lt;td>1.156&lt;/td>
&lt;td>0.924&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>0.594&lt;/td>
&lt;td>0.808&lt;/td>
&lt;td>0.840&lt;/td>
&lt;td>0.810&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\beta_3$ (GDP³)&lt;/td>
&lt;td>&amp;ndash;0.041&lt;/td>
&lt;td>&amp;ndash;0.034&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>&amp;ndash;0.022&lt;/td>
&lt;td>&amp;ndash;0.030&lt;/td>
&lt;td>&amp;ndash;0.031&lt;/td>
&lt;td>&amp;ndash;0.030&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>GDP terms robust?&lt;/strong>&lt;/td>
&lt;td>Yes (p &amp;lt; 0.05)&lt;/td>
&lt;td>No (p &amp;gt; 0.05)&lt;/td>
&lt;td>&lt;strong>No&lt;/strong> (PIP &amp;lt; 0.30)&lt;/td>
&lt;td>No (p &amp;gt; 0.05)&lt;/td>
&lt;td>&lt;strong>Yes&lt;/strong> (PIP &amp;gt; 0.99)&lt;/td>
&lt;td>Yes (p &amp;lt; 0.001)&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Controls selected&lt;/strong>&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>1 of 12&lt;/td>
&lt;td>1 of 12&lt;/td>
&lt;td>6 of 12&lt;/td>
&lt;td>102 of 112&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Min TP&lt;/strong>&lt;/td>
&lt;td>\$1,913&lt;/td>
&lt;td>\$1,465&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>\$987&lt;/td>
&lt;td>\$2,411&lt;/td>
&lt;td>\$2,429&lt;/td>
&lt;td>\$1,895&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Max TP&lt;/strong>&lt;/td>
&lt;td>\$60,817&lt;/td>
&lt;td>\$61,655&lt;/td>
&lt;td>n/a&lt;/td>
&lt;td>\$62,983&lt;/td>
&lt;td>\$27,269&lt;/td>
&lt;td>\$27,672&lt;/td>
&lt;td>\$34,647&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;blockquote>
&lt;p>&lt;strong>Note.&lt;/strong> FD-BMA posterior means for the GDP terms are heavily shrunk toward zero (because their PIPs are ~0.27&amp;ndash;0.30), so we report &amp;ldquo;n/a&amp;rdquo; rather than misleading point estimates.&lt;/p>
&lt;/blockquote>
&lt;p>The comparison reveals a stark trade-off between the two identification strategies:&lt;/p>
&lt;p>&lt;strong>Fixed effects win on accuracy.&lt;/strong> The FE-based estimates are close to the true DGP values, with BMA (FE) achieving the best accuracy ($\beta_1 = -7.139$ vs true &amp;ndash;7.100). The FD estimates are noisier: FD-sparse overshoots ($\beta_1 = -10.36$), while FD-DSL undershoots (&amp;ndash;5.05). The FD turning points are wildly inaccurate &amp;mdash; the maximum turning point is \$61,000&amp;ndash;63,000 in first differences vs \$27,000 with FE (true: \$34,647).&lt;/p>
&lt;p>&lt;strong>First differences struggle with the cubic polynomial.&lt;/strong> Estimating a cubic EKC requires precise measurement of three highly correlated terms ($\ln GDP$, $(\ln GDP)^2$, $(\ln GDP)^3$). With only 80 observations (one 20-year change per country), the multicollinearity among differenced GDP terms is severe. Both BMA and DSL respond rationally: BMA gives all three terms PIPs below 0.30, and DSL selects zero controls for the GDP equations. Neither method &amp;ldquo;trusts&amp;rdquo; the cubic specification in this small sample.&lt;/p>
&lt;p>&lt;strong>Industry is the strongest cross-sectional signal.&lt;/strong> Both FD-BMA (PIP = 0.998) and FD-DSL (selected as the sole control) identify the change in industry share as the most important cross-sectional predictor of CO&lt;sub>2&lt;/sub> change. This makes economic sense: countries that industrialized the most over 1995&amp;ndash;2014 also increased their emissions the most, regardless of their income trajectory.&lt;/p>
&lt;p>&lt;strong>Practical implication.&lt;/strong> First differences are appropriate when the research question is about &lt;em>long-run changes&lt;/em> rather than &lt;em>levels&lt;/em>. But for testing the EKC cubic shape, the panel FE approach is far more powerful because it uses all 1,600 observations rather than collapsing to 80. The FD analysis confirms that the inverted-N result in the main body is robust to the identification strategy in spirit (the signs are correct in FD-sparse OLS), but the magnitudes and statistical power are substantially weaker.&lt;/p>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://doi.org/10.1016/j.eneco.2025.108649" target="_blank" rel="noopener">Gravina, A. F. &amp;amp; Lanzafame, M. (2025). What&amp;rsquo;s your shape? Bayesian model averaging and double machine learning for the Environmental Kuznets Curve. &lt;em>Energy Economics&lt;/em>, 108649.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1002/jae.623" target="_blank" rel="noopener">Fernandez, C., Ley, E., &amp;amp; Steel, M. F. J. (2001). Model uncertainty in cross-country growth regressions. &lt;em>Journal of Applied Econometrics&lt;/em>, 16(5), 563&amp;ndash;576.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1093/restud/rdt044" target="_blank" rel="noopener">Belloni, A., Chernozhukov, V., &amp;amp; Hansen, C. (2014). Inference on treatment effects after selection among high-dimensional controls. &lt;em>Review of Economic Studies&lt;/em>, 81(2), 608&amp;ndash;650.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1080/01621459.1997.10473615" target="_blank" rel="noopener">Raftery, A. E., Madigan, D., &amp;amp; Hoeting, J. A. (1997). Bayesian model averaging for linear regression models. &lt;em>Journal of the American Statistical Association&lt;/em>, 92(437), 179&amp;ndash;191.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.2307/271063" target="_blank" rel="noopener">Raftery, A. E. (1995). Bayesian model selection in social research. &lt;em>Sociological Methodology&lt;/em>, 25, 111&amp;ndash;163.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.stata.com/manuals/bmabmaregress.pdf" target="_blank" rel="noopener">Stata 18 Manual: &lt;code>bmaregress&lt;/code> &amp;mdash; Bayesian Model Averaging regression&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.stata.com/manuals/lassodsregress.pdf" target="_blank" rel="noopener">Stata 18 Manual: &lt;code>dsregress&lt;/code> &amp;mdash; Double-Selection LASSO linear regression&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>Visualizing Regression with the FWL Theorem in R</title><link>https://carlos-mendez.org/post/r_fwlplot/</link><pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/r_fwlplot/</guid><description>&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>&amp;ldquo;What does it actually mean to &lt;em>control for&lt;/em> a variable?&amp;rdquo; This is perhaps the most common question in applied regression &amp;mdash; and one of the hardest to answer intuitively. When we say &amp;ldquo;the effect of coupons on sales, controlling for income,&amp;rdquo; we are describing a relationship that lives in multidimensional space and cannot be directly plotted on a 2D scatter plot. Or can it?&lt;/p>
&lt;p>The &lt;strong>Frisch-Waugh-Lovell (FWL) theorem&lt;/strong> provides the answer. It says that the coefficient on any variable in a multiple regression equals the slope from a simple bivariate regression &amp;mdash; after first &amp;ldquo;partialling out&amp;rdquo; the other variables from both the outcome and the variable of interest. Partialling out means regressing a variable on the controls and keeping only the leftover (residual) variation &amp;mdash; the part that the controls cannot explain. This means we &lt;em>can&lt;/em> visualize any regression coefficient as a 2D scatter plot, as long as we first remove the influence of the controls from both axes.&lt;/p>
&lt;p>The &lt;a href="https://cran.r-project.org/package=fwlplot" target="_blank" rel="noopener">fwlplot&lt;/a> R package (Butts &amp;amp; McDermott, 2024) turns this into a one-liner. It uses the same formula syntax as &lt;a href="https://lrberge.github.io/fixest/reference/feols.html" target="_blank" rel="noopener">&lt;code>fixest::feols()&lt;/code>&lt;/a> &amp;mdash; including the &lt;code>|&lt;/code> operator for fixed effects &amp;mdash; and produces a scatter plot of the residualized data with the regression line overlaid. The result is a visual answer to &amp;ldquo;what does controlling for X look like?&amp;rdquo;&lt;/p>
&lt;p>This tutorial builds intuition progressively. We start with simulated data where we &lt;em>know&lt;/em> the true effect, show how confounding creates a misleading picture, and use &lt;code>fwl_plot()&lt;/code> to reveal the truth. We then extend to real data with high-dimensional fixed effects &amp;mdash; first flights data (controlling for origin and destination airports) and then panel wage data (controlling for unobserved individual ability).&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>State the FWL theorem and explain its geometric intuition&lt;/li>
&lt;li>Use &lt;code>fwl_plot()&lt;/code> to visualize a bivariate relationship before and after controlling for confounders&lt;/li>
&lt;li>Demonstrate that manual FWL residualization reproduces &lt;code>feols()&lt;/code> coefficients exactly&lt;/li>
&lt;li>Visualize what fixed effects &amp;ldquo;do&amp;rdquo; to data by comparing raw vs. residualized scatter plots&lt;/li>
&lt;li>Apply &lt;code>fwl_plot()&lt;/code> to real panel data with high-dimensional fixed effects&lt;/li>
&lt;li>Connect FWL to omitted variable bias and Simpson&amp;rsquo;s paradox&lt;/li>
&lt;/ul>
&lt;h2 id="2-the-modeling-pipeline">2. The Modeling Pipeline&lt;/h2>
&lt;pre>&lt;code class="language-mermaid">graph LR
A[&amp;quot;Simulated&amp;lt;br/&amp;gt;Data&amp;lt;br/&amp;gt;(Section 3)&amp;quot;] --&amp;gt; B[&amp;quot;fwl_plot()&amp;lt;br/&amp;gt;Naive vs. FWL&amp;lt;br/&amp;gt;(Section 4)&amp;quot;]
B --&amp;gt; C[&amp;quot;Manual FWL&amp;lt;br/&amp;gt;Verification&amp;lt;br/&amp;gt;(Section 5)&amp;quot;]
C --&amp;gt; D[&amp;quot;Fixed Effects&amp;lt;br/&amp;gt;Flights Data&amp;lt;br/&amp;gt;(Section 6)&amp;quot;]
D --&amp;gt; E[&amp;quot;Panel Data&amp;lt;br/&amp;gt;Wages&amp;lt;br/&amp;gt;(Section 7)&amp;quot;]
E --&amp;gt; F[&amp;quot;ggplot2&amp;lt;br/&amp;gt;&amp;amp; Recipe&amp;lt;br/&amp;gt;(Section 8)&amp;quot;]
style A fill:#6a9bcc,stroke:#141413,color:#fff
style B fill:#d97757,stroke:#141413,color:#fff
style C fill:#d97757,stroke:#141413,color:#fff
style D fill:#6a9bcc,stroke:#141413,color:#fff
style E fill:#6a9bcc,stroke:#141413,color:#fff
style F fill:#00d4c8,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>We start where the answer is known (simulated data), see the result with &lt;code>fwl_plot()&lt;/code> first, then peek under the hood with manual FWL verification. From there we apply the same one-liner to increasingly complex real-world settings.&lt;/p>
&lt;h2 id="3-setup-and-data">3. Setup and Data&lt;/h2>
&lt;h3 id="31-install-and-load-packages">3.1 Install and load packages&lt;/h3>
&lt;pre>&lt;code class="language-r"># Install packages if needed
cran_packages &amp;lt;- c(&amp;quot;fwlplot&amp;quot;, &amp;quot;fixest&amp;quot;, &amp;quot;ggplot2&amp;quot;, &amp;quot;patchwork&amp;quot;,
&amp;quot;nycflights13&amp;quot;, &amp;quot;wooldridge&amp;quot;)
missing &amp;lt;- cran_packages[!sapply(cran_packages, requireNamespace, quietly = TRUE)]
if (length(missing) &amp;gt; 0) install.packages(missing)
library(fwlplot)
library(fixest)
library(ggplot2)
library(patchwork)
library(nycflights13)
library(wooldridge)
&lt;/code>&lt;/pre>
&lt;p>The &lt;code>fwlplot&lt;/code> package provides the &lt;code>fwl_plot()&lt;/code> function for FWL-residualized scatter plots. It is built on &lt;code>fixest&lt;/code>, which handles the residualization computation using fast demeaning algorithms. The &lt;code>patchwork&lt;/code> package lets us combine multiple ggplot2 plots side by side. The &lt;code>nycflights13&lt;/code> and &lt;code>wooldridge&lt;/code> packages provide the real datasets we will use later.&lt;/p>
&lt;h3 id="32-simulated-confounding-data">3.2 Simulated confounding data&lt;/h3>
&lt;p>To build intuition, we simulate a retail scenario where a store manager wants to know whether distributing coupons increases sales. The catch: &lt;strong>income is a confounder&lt;/strong> &amp;mdash; wealthier neighborhoods receive fewer coupons (the store targets promotions at lower-income areas) but have higher baseline sales. This creates a spurious negative correlation between coupons and sales, even though coupons genuinely boost sales.&lt;/p>
&lt;p>The causal structure looks like this:&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
Income[&amp;quot;Income&amp;lt;br/&amp;gt;(confounder)&amp;quot;]
Coupons[&amp;quot;Coupons&amp;lt;br/&amp;gt;(treatment)&amp;quot;]
Sales[&amp;quot;Sales&amp;lt;br/&amp;gt;(outcome)&amp;quot;]
Income --&amp;gt;|&amp;quot;-0.5&amp;lt;br/&amp;gt;(fewer coupons&amp;lt;br/&amp;gt;to rich areas)&amp;quot;| Coupons
Income --&amp;gt;|&amp;quot;+0.3&amp;lt;br/&amp;gt;(rich areas&amp;lt;br/&amp;gt;buy more)&amp;quot;| Sales
Coupons --&amp;gt;|&amp;quot;+0.2&amp;lt;br/&amp;gt;(true causal&amp;lt;br/&amp;gt;effect)&amp;quot;| Sales
style Income fill:#d97757,stroke:#141413,color:#fff
style Coupons fill:#6a9bcc,stroke:#141413,color:#fff
style Sales fill:#00d4c8,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>Income opens a &amp;ldquo;backdoor path&amp;rdquo; from coupons to sales: coupons ← income → sales. Unless we block this path by controlling for income, the naive estimate will be biased. The data generating process is:&lt;/p>
&lt;p>$$\text{income} \sim N(50, 10)$$&lt;/p>
&lt;p>$$\text{coupons} = 60 - 0.5 \times \text{income} + \epsilon_1, \quad \epsilon_1 \sim N(0, 5)$$&lt;/p>
&lt;p>$$\text{sales} = 10 + 0.2 \times \text{coupons} + 0.3 \times \text{income} + \epsilon_2, \quad \epsilon_2 \sim N(0, 3)$$&lt;/p>
&lt;p>In words, the true causal effect of coupons on sales is &lt;strong>+0.2&lt;/strong>: each additional coupon increases sales by 0.2 units. But because income negatively drives coupons ($-0.5$) and positively drives sales ($+0.3$), a naive regression of sales on coupons alone will confound the coupon effect with the income effect, producing a biased estimate. The noise terms $\epsilon_1$ and $\epsilon_2$ correspond to the &lt;code>rnorm()&lt;/code> calls in the code below.&lt;/p>
&lt;pre>&lt;code class="language-r">set.seed(42)
n &amp;lt;- 200
income &amp;lt;- rnorm(n, mean = 50, sd = 10)
dayofweek &amp;lt;- sample(1:7, n, replace = TRUE)
coupons &amp;lt;- 60 - 0.5 * income + rnorm(n, 0, 5)
sales &amp;lt;- 10 + 0.2 * coupons + 0.3 * income + 0.5 * dayofweek + rnorm(n, 0, 3)
store_data &amp;lt;- data.frame(
sales = round(sales, 2),
coupons = round(coupons, 2),
income = round(income, 2),
dayofweek = dayofweek
)
head(store_data)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> sales coupons income dayofweek
1 40.02 27.79 63.71 4
2 31.37 34.03 44.35 5
3 31.30 28.01 53.63 6
4 34.37 28.68 56.33 4
5 42.62 35.91 54.04 5
6 39.50 33.45 48.94 4
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-r">round(cor(store_data[, c(&amp;quot;sales&amp;quot;, &amp;quot;coupons&amp;quot;, &amp;quot;income&amp;quot;)]), 3)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> sales coupons income
sales 1.000 -0.166 0.500
coupons -0.166 1.000 -0.709
income 0.500 -0.709 1.000
&lt;/code>&lt;/pre>
&lt;p>The correlation matrix confirms the confounding structure. Coupons and sales have a &lt;em>negative&lt;/em> raw correlation (-0.166), even though the true causal effect is positive (+0.2). This is because income is strongly negatively correlated with coupons (-0.709) and strongly positively correlated with sales (0.500). A naive analysis would conclude that coupons hurt sales &amp;mdash; a classic instance of &lt;strong>Simpson&amp;rsquo;s paradox&lt;/strong>, where the direction of an association reverses when a confounding variable is accounted for.&lt;/p>
&lt;h2 id="4-fwl_plot-in-action-naive-vs-controlled">4. fwl_plot() in Action: Naive vs. Controlled&lt;/h2>
&lt;h3 id="41-the-naive-scatter">4.1 The naive scatter&lt;/h3>
&lt;p>The simplest way to see why confounding is dangerous: plot the raw relationship with &lt;code>fwl_plot()&lt;/code>. When no controls are specified, &lt;code>fwl_plot()&lt;/code> produces a standard scatter plot with a regression line:&lt;/p>
&lt;pre>&lt;code class="language-r">fwl_plot(sales ~ coupons, data = store_data, ggplot = TRUE)
&lt;/code>&lt;/pre>
&lt;p>The slope is &lt;strong>-0.093&lt;/strong> ($p = 0.019$): coupons appear to &lt;em>reduce&lt;/em> sales. This is statistically significant but substantively wrong &amp;mdash; the true effect is +0.2. The store manager who trusts this analysis would cancel the coupon program, losing real revenue.&lt;/p>
&lt;h3 id="42-controlling-for-income-one-line-of-code">4.2 Controlling for income: one line of code&lt;/h3>
&lt;p>Now watch what happens when we add &lt;code>income&lt;/code> as a control &amp;mdash; just add it to the formula:&lt;/p>
&lt;pre>&lt;code class="language-r">fwl_plot(sales ~ coupons + income, data = store_data, ggplot = TRUE)
&lt;/code>&lt;/pre>
&lt;p>The slope reverses to &lt;strong>+0.212&lt;/strong> ($p &amp;lt; 0.001$) &amp;mdash; close to the true value of +0.2. The &lt;code>fwl_plot()&lt;/code> function residualized both coupons and sales on income behind the scenes, then plotted the residuals. The figure below shows both panels side by side:&lt;/p>
&lt;p>&lt;img src="r_fwlplot_fig1_naive_vs_controlled.png" alt="Naive scatter (left) shows a negative slope; after FWL residualization on income (right), the slope reverses to positive">&lt;/p>
&lt;p>The left panel shows the raw relationship: more coupons, lower sales (a downward slope). The right panel shows the &lt;em>same&lt;/em> data after removing the influence of income from both axes. Once income is partialled out, the true positive effect of coupons emerges clearly. This is what &amp;ldquo;controlling for income&amp;rdquo; looks like geometrically &amp;mdash; and &lt;code>fwl_plot()&lt;/code> produces it in a single line.&lt;/p>
&lt;h3 id="43-the-regression-table-confirms">4.3 The regression table confirms&lt;/h3>
&lt;p>The &lt;code>fixest::feols()&lt;/code> function produces the same coefficient, confirmed by &lt;code>etable()&lt;/code> for side-by-side comparison:&lt;/p>
&lt;pre>&lt;code class="language-r">fe_naive &amp;lt;- feols(sales ~ coupons, data = store_data)
fe_full &amp;lt;- feols(sales ~ coupons + income, data = store_data)
etable(fe_naive, fe_full, headers = c(&amp;quot;Naive&amp;quot;, &amp;quot;Controlled&amp;quot;))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> fe_naive fe_full
Naive Controlled
Dependent Var.: sales sales
Constant 36.93*** (1.397) 11.34*** (3.008)
coupons -0.0934* (0.0393) 0.2123*** (0.0467)
income 0.3004*** (0.0325)
_______________ _________________ __________________
S.E. type IID IID
Observations 200 200
R2 0.02768 0.32148
Adj. R2 0.02277 0.31459
&lt;/code>&lt;/pre>
&lt;p>Adding income as a control flips the coupon coefficient from -0.093 to +0.212 and increases the R-squared from 0.028 to 0.321. The income coefficient (0.300) is close to the true value of 0.3. Every number in this table corresponds to a visual feature of the &lt;code>fwl_plot()&lt;/code> scatter plots above.&lt;/p>
&lt;h2 id="5-under-the-hood-manual-fwl-verification">5. Under the Hood: Manual FWL Verification&lt;/h2>
&lt;h3 id="51-the-three-step-recipe">5.1 The three-step recipe&lt;/h3>
&lt;p>The FWL theorem can be stated as a simple recipe. Think of it like measuring height &lt;em>for your age&lt;/em>: instead of comparing raw heights, you compare how much taller or shorter each person is than the average for their age group. Similarly, FWL compares how much more or fewer coupons a store had &lt;em>for its income level&lt;/em>, against how much more or fewer sales it had &lt;em>for its income level&lt;/em>.&lt;/p>
&lt;p>The three steps are:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Regress sales on income&lt;/strong>, save the residuals (the part of sales that income cannot explain)&lt;/li>
&lt;li>&lt;strong>Regress coupons on income&lt;/strong>, save the residuals (the part of coupons that income cannot explain)&lt;/li>
&lt;li>&lt;strong>Regress the sales residuals on the coupon residuals&lt;/strong> &amp;mdash; the slope is the coupon coefficient&lt;/li>
&lt;/ol>
&lt;pre>&lt;code class="language-r"># Step 1: Residualize sales on income
resid_y &amp;lt;- resid(lm(sales ~ income, data = store_data))
# Step 2: Residualize coupons on income
resid_x &amp;lt;- resid(lm(coupons ~ income, data = store_data))
# Step 3: Regress residuals on residuals
fwl_manual &amp;lt;- lm(resid_y ~ resid_x)
# Compare coefficients
cat(&amp;quot;feols coefficient: &amp;quot;, round(coef(fe_full)[&amp;quot;coupons&amp;quot;], 6), &amp;quot;\n&amp;quot;)
cat(&amp;quot;Manual FWL coefficient:&amp;quot;, round(coef(fwl_manual)[&amp;quot;resid_x&amp;quot;], 6), &amp;quot;\n&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">feols coefficient: 0.212288
Manual FWL coefficient: 0.212288
&lt;/code>&lt;/pre>
&lt;p>The coefficients match to six decimal places. This is not an approximation &amp;mdash; it is an exact algebraic identity. Every time you run a multiple regression, the software is implicitly performing these three steps for each coefficient.&lt;/p>
&lt;h3 id="52-the-formal-theorem">5.2 The formal theorem&lt;/h3>
&lt;p>For those who want the math, the FWL theorem states that in the regression $Y = X_1 \beta_1 + X_2 \beta_2 + \epsilon$, the coefficient $\hat{\beta}_1$ equals:&lt;/p>
&lt;p>$$\hat{\beta}_1 = (\tilde{X}_1' \tilde{X}_1)^{-1} \tilde{X}_1' \tilde{Y}, \quad \text{where} \quad \tilde{Y} = M_{X_2} Y, \quad \tilde{X}_1 = M_{X_2} X_1$$&lt;/p>
&lt;p>Here $M_{X_2} = I - X_2(X_2&amp;rsquo;X_2)^{-1}X_2'$ is the &amp;ldquo;residual-maker&amp;rdquo; matrix that projects out the effect of $X_2$. In our example, $Y$ is &lt;code>sales&lt;/code>, $X_1$ is &lt;code>coupons&lt;/code>, and $X_2$ is &lt;code>income&lt;/code>. The tilded variables $\tilde{Y}$ and $\tilde{X}_1$ are the residuals from the &lt;code>resid()&lt;/code> calls above.&lt;/p>
&lt;h3 id="53-omitted-variable-bias-predicting-the-error">5.3 Omitted variable bias: predicting the error&lt;/h3>
&lt;p>The confounding we saw is not mysterious &amp;mdash; the &lt;strong>omitted variable bias (OVB) formula&lt;/strong> predicts it exactly. When we omit income from the regression, the bias on the coupon coefficient is:&lt;/p>
&lt;p>$$\text{bias} = \hat{\gamma} \times \hat{\delta}$$&lt;/p>
&lt;p>In words, the bias equals the effect of the omitted variable on the outcome ($\hat{\gamma}$) multiplied by the relationship between the omitted variable and the treatment ($\hat{\delta}$). Here $\hat{\gamma}$ is the effect of income on sales (in the full model) and $\hat{\delta}$ is the coefficient from regressing coupons on income.&lt;/p>
&lt;pre>&lt;code class="language-r">gamma_hat &amp;lt;- coef(fe_full)[&amp;quot;income&amp;quot;] # 0.3004
delta_hat &amp;lt;- coef(lm(coupons ~ income, data = store_data))[&amp;quot;income&amp;quot;] # -0.4937
ovb &amp;lt;- gamma_hat * delta_hat # -0.1483
cat(&amp;quot;OVB = gamma * delta:&amp;quot;, round(ovb, 4), &amp;quot;\n&amp;quot;)
cat(&amp;quot;Naive ≈ True + OVB:&amp;quot;, round(coef(fe_full)[&amp;quot;coupons&amp;quot;] + ovb, 4), &amp;quot;\n&amp;quot;)
cat(&amp;quot;Actual naive:&amp;quot;, round(coef(fe_naive)[&amp;quot;coupons&amp;quot;], 4), &amp;quot;\n&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">OVB = gamma * delta: -0.1483
Naive ≈ True + OVB: 0.064
Actual naive: -0.0934
&lt;/code>&lt;/pre>
&lt;p>The OVB formula predicts a bias of -0.148: income&amp;rsquo;s positive effect on sales ($\hat{\gamma} = 0.300$) times its negative relationship with coupons ($\hat{\delta} = -0.494$) produces a large negative bias. The predicted naive coefficient (true + bias = 0.212 + (-0.148) = 0.064) is close to the actual naive coefficient (-0.093) &amp;mdash; the small discrepancy comes from sampling variation with $n = 200$. The key insight: the bias is &lt;em>predictable&lt;/em>. If you know the direction of the confounder&amp;rsquo;s effects on both the treatment and the outcome, you know which way the naive estimate is biased.&lt;/p>
&lt;h3 id="54-adding-more-controls">5.4 Adding more controls&lt;/h3>
&lt;p>The FWL theorem extends naturally to any number of controls. The &lt;code>fwl_plot()&lt;/code> call handles it automatically:&lt;/p>
&lt;pre>&lt;code class="language-r">fe_full3 &amp;lt;- feols(sales ~ coupons + income + dayofweek, data = store_data)
etable(fe_naive, fe_full, fe_full3,
headers = c(&amp;quot;Naive&amp;quot;, &amp;quot;+ Income&amp;quot;, &amp;quot;+ Income + Day&amp;quot;))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> fe_naive fe_full fe_full3
Naive + Income + Income + Day
Dependent Var.: sales sales sales
Constant 36.93*** (1.397) 11.34*** (3.008) 9.640** (2.953)
coupons -0.0934* (0.0393) 0.2123*** (0.0467) 0.2219*** (0.0454)
income 0.3004*** (0.0325) 0.2961*** (0.0316)
dayofweek 0.4029*** (0.1095)
_______________ _________________ __________________ __________________
S.E. type IID IID IID
Observations 200 200 200
R2 0.02768 0.32148 0.36535
Adj. R2 0.02277 0.31459 0.35564
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_fwlplot_fig2_fwl_verification.png" alt="Three-panel FWL progression: no controls (left), controlling for income (center), controlling for income + day of week (right)">&lt;/p>
&lt;p>The coupon coefficient progresses from -0.093 (naive, wrong sign), to +0.212 (controlling for income), to +0.222 (adding day of week). The R-squared jumps from 0.028 to 0.365 as we add controls. Each &lt;code>fwl_plot()&lt;/code> panel shows a tighter cloud as more variation is absorbed by the controls &amp;mdash; the residualized scatter becomes more focused on the &lt;em>coupon-specific&lt;/em> variation in sales.&lt;/p>
&lt;h2 id="6-visualizing-fixed-effects">6. Visualizing Fixed Effects&lt;/h2>
&lt;h3 id="61-what-are-fixed-effects">6.1 What are fixed effects?&lt;/h3>
&lt;p>Fixed effects are a special case of the FWL theorem applied to group dummy variables. When we include airport fixed effects in a regression, we are &amp;ldquo;partialling out&amp;rdquo; airport-specific means &amp;mdash; in other words, &lt;strong>demeaning&lt;/strong>. Demeaning means subtracting each group&amp;rsquo;s average from every observation in that group. The result is that we compare each airport to &lt;em>itself&lt;/em> rather than comparing different airports to each other.&lt;/p>
&lt;p>Think of it like a race handicap. Raw times compare runners who started at different positions. Demeaning each runner&amp;rsquo;s times converts them to &amp;ldquo;how much faster or slower than their personal average,&amp;rdquo; making the comparison fair. The FWL theorem guarantees that this demeaning procedure produces the same coefficients as including a full set of dummy variables in the regression.&lt;/p>
&lt;h3 id="62-flights-data-progressive-fixed-effects">6.2 Flights data: progressive fixed effects&lt;/h3>
&lt;p>The &lt;code>nycflights13&lt;/code> dataset contains all domestic flights from New York&amp;rsquo;s three airports (EWR, JFK, LGA) in 2013. We ask: what is the relationship between air time and departure delay?&lt;/p>
&lt;pre>&lt;code class="language-r">data(&amp;quot;flights&amp;quot;, package = &amp;quot;nycflights13&amp;quot;)
flights_clean &amp;lt;- flights[complete.cases(flights[, c(&amp;quot;dep_delay&amp;quot;, &amp;quot;air_time&amp;quot;, &amp;quot;origin&amp;quot;, &amp;quot;dest&amp;quot;)]), ]
flights_clean &amp;lt;- flights_clean[flights_clean$dep_delay &amp;lt; 120 &amp;amp; flights_clean$dep_delay &amp;gt; -30, ]
# Remove singleton origin-dest combos for stable FE estimation
od_counts &amp;lt;- table(paste(flights_clean$origin, flights_clean$dest))
flights_clean &amp;lt;- flights_clean[paste(flights_clean$origin, flights_clean$dest) %in%
names(od_counts[od_counts &amp;gt; 1]), ]
cat(&amp;quot;Observations:&amp;quot;, nrow(flights_clean), &amp;quot;\n&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Observations: 317578
&lt;/code>&lt;/pre>
&lt;p>We sample 5,000 flights for plotting (the regression line uses all data, only the plotted points are sampled to avoid overplotting):&lt;/p>
&lt;pre>&lt;code class="language-r">set.seed(123)
flights_sample &amp;lt;- flights_clean[sample(nrow(flights_clean), 5000), ]
&lt;/code>&lt;/pre>
&lt;p>Now the power of &lt;code>fwl_plot()&lt;/code> &amp;mdash; three one-liners that progressively add fixed effects. In &lt;code>fixest&lt;/code> syntax, the &lt;code>|&lt;/code> operator separates regular covariates (left) from fixed effects (right), so &lt;code>dep_delay ~ air_time | origin + dest&lt;/code> means &amp;ldquo;regress departure delay on air time, with origin and destination fixed effects&amp;rdquo;:&lt;/p>
&lt;pre>&lt;code class="language-r"># No fixed effects
fwl_plot(dep_delay ~ air_time, data = flights_sample, ggplot = TRUE)
# Origin airport FE
fwl_plot(dep_delay ~ air_time | origin, data = flights_sample, ggplot = TRUE)
# Origin + destination FE
fwl_plot(dep_delay ~ air_time | origin + dest, data = flights_sample, ggplot = TRUE)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_fwlplot_fig3_fixed_effects.png" alt="Progressive FWL plots: no FE (left), origin FE (center), origin + destination FE (right)">&lt;/p>
&lt;p>The visual transformation is striking. Panel A (no FE) shows a vague cloud with a nearly flat slope. Panel B (origin FE) removes the three origin-airport means, tightening the horizontal spread. Panel C (origin + destination FE) removes the 103 destination means as well, collapsing the air-time variation to &lt;em>within-route&lt;/em> deviations.&lt;/p>
&lt;h3 id="63-comparing-regression-tables">6.3 Comparing regression tables&lt;/h3>
&lt;pre>&lt;code class="language-r">fe_none &amp;lt;- feols(dep_delay ~ air_time, data = flights_clean)
fe_origin &amp;lt;- feols(dep_delay ~ air_time | origin, data = flights_clean)
fe_both &amp;lt;- feols(dep_delay ~ air_time | origin + dest, data = flights_clean)
etable(fe_none, fe_origin, fe_both,
headers = c(&amp;quot;No FE&amp;quot;, &amp;quot;Origin FE&amp;quot;, &amp;quot;Origin + Dest FE&amp;quot;))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> fe_none fe_origin fe_both
No FE Origin FE Origin + Dest FE
Dependent Var.: dep_delay dep_delay dep_delay
air_time -0.0031*** (0.0004) -0.0061*** (0.0005) -0.0067. (0.0034)
Fixed-Effects: ------------------- ------------------- -----------------
origin No Yes Yes
dest No No Yes
_______________ ___________________ ___________________ _________________
Observations 317,578 317,578 317,578
R2 0.00016 0.00594 0.01296
Within R2 -- 0.00058 1.19e-5
&lt;/code>&lt;/pre>
&lt;p>The air time coefficient changes as we add fixed effects: -0.003 (no FE), -0.006 (origin FE), -0.007 (origin + destination FE, significant at the 10% level only &amp;mdash; the &lt;code>.&lt;/code> marker indicates $p &amp;lt; 0.10$). The residualized scatter in Panel C answers a sharper question: &amp;ldquo;For flights on the &lt;em>same route&lt;/em>, does longer-than-usual air time predict higher-than-usual departure delay?&amp;rdquo; The answer is weakly negative &amp;mdash; routes with variable air times show slightly less delay when the flight takes longer, possibly because longer air times reflect favorable wind conditions.&lt;/p>
&lt;h2 id="7-panel-data-returns-to-experience">7. Panel Data: Returns to Experience&lt;/h2>
&lt;h3 id="71-the-wage-panel">7.1 The wage panel&lt;/h3>
&lt;p>The &lt;code>wagepan&lt;/code> dataset from the Wooldridge textbook contains panel data on 545 individuals observed over 8 years (1980&amp;ndash;1987). A classic question in labor economics is: what is the return to experience?&lt;/p>
&lt;p>The challenge is &lt;strong>unobserved ability&lt;/strong>. Two people with 5 years of experience may earn very different wages because one is more talented, motivated, or well-connected. These personal traits &amp;mdash; which we cannot directly measure &amp;mdash; are the &amp;ldquo;unobserved ability&amp;rdquo; that creates omitted variable bias. More talented workers earn higher wages &lt;em>and&lt;/em> tend to accumulate experience in higher-paying jobs, so the naive correlation between experience and wages confounds ability with genuine experience effects.&lt;/p>
&lt;pre>&lt;code class="language-r">data(&amp;quot;wagepan&amp;quot;, package = &amp;quot;wooldridge&amp;quot;)
cat(&amp;quot;Observations:&amp;quot;, nrow(wagepan), &amp;quot;\n&amp;quot;)
cat(&amp;quot;Individuals:&amp;quot;, length(unique(wagepan$nr)), &amp;quot;\n&amp;quot;)
cat(&amp;quot;Years:&amp;quot;, length(unique(wagepan$year)), &amp;quot;\n&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Observations: 4360
Individuals: 545
Years: 8
&lt;/code>&lt;/pre>
&lt;h3 id="72-pooled-ols-vs-individual-fixed-effects">7.2 Pooled OLS vs. individual fixed effects&lt;/h3>
&lt;pre>&lt;code class="language-r">fe_pool &amp;lt;- feols(lwage ~ educ + exper + expersq, data = wagepan)
fe_fe &amp;lt;- feols(lwage ~ exper + expersq | nr, data = wagepan)
fe_twfe &amp;lt;- feols(lwage ~ exper + expersq | nr + year, data = wagepan)
etable(fe_pool, fe_fe, fe_twfe,
headers = c(&amp;quot;Pooled OLS&amp;quot;, &amp;quot;Individual FE&amp;quot;, &amp;quot;Individual + Year FE&amp;quot;))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> fe_pool fe_fe fe_twfe
Pooled OLS Individual FE Individual + Year FE
Dependent Var.: lwage lwage lwage
Constant -0.0564 (0.0639)
educ 0.1021*** (0.0047)
exper 0.1050*** (0.0102) 0.1223*** (0.0082)
expersq -0.0036*** (0.0007) -0.0045*** (0.0006) -0.0054*** (0.0007)
Fixed-Effects: ------------------- ------------------- -------------------
nr No Yes Yes
year No No Yes
_______________ ___________________ ___________________ ___________________
Observations 4,360 4,360 4,360
R2 0.14772 0.61727 0.61850
Within R2 -- 0.17270 0.01534
&lt;/code>&lt;/pre>
&lt;p>Several things change as we add fixed effects. First, the &lt;code>educ&lt;/code> coefficient disappears from the individual FE column &amp;mdash; education is time-invariant for most individuals, so it is perfectly collinear with person dummies. Second, the &lt;code>exper&lt;/code> linear term disappears from the two-way FE column &amp;mdash; because experience increments by exactly one year for everyone, it is perfectly collinear with year dummies. Only &lt;code>expersq&lt;/code> (which varies non-linearly across individuals) survives.&lt;/p>
&lt;p>In the individual FE model, the experience coefficient &lt;em>increases&lt;/em> from 0.105 to 0.122. This means the within-person return to experience is larger than the pooled estimate. The R-squared jumps from 0.148 to 0.617, showing that individual fixed effects explain the majority of wage variation &amp;mdash; most of the &amp;ldquo;action&amp;rdquo; in wages comes from &lt;em>who you are&lt;/em>, not &lt;em>how many years you have worked&lt;/em>.&lt;/p>
&lt;h3 id="73-visualizing-the-within-person-variation">7.3 Visualizing the within-person variation&lt;/h3>
&lt;p>Again, &lt;code>fwl_plot()&lt;/code> produces the before/after comparison in two one-liners. We sample 150 individuals for visual clarity (with 545 individuals the plot would be too dense):&lt;/p>
&lt;pre>&lt;code class="language-r">set.seed(456)
sample_ids &amp;lt;- sample(unique(wagepan$nr), 150)
wage_sample &amp;lt;- wagepan[wagepan$nr %in% sample_ids, ]
# Raw bivariate relationship
fwl_plot(lwage ~ exper, data = wage_sample, ggplot = TRUE)
# With individual fixed effects
fwl_plot(lwage ~ exper | nr, data = wage_sample, ggplot = TRUE)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_fwlplot_fig4_panel_data.png" alt="Raw pooled cross-section (left) vs. individual fixed-effects residualized scatter (right) for log wage vs. experience">&lt;/p>
&lt;p>The visual difference is dramatic. Panel A plots the raw bivariate relationship with a shallow slope of about 0.03. The wide fan of points reflects unobserved ability differences: individuals at the same experience level have wildly different wages. Panel B (individual FE) strips away each person&amp;rsquo;s average wage and average experience, leaving only the &lt;em>within-person&lt;/em> deviations. The slope steepens to 0.122 &amp;mdash; more than three times larger &amp;mdash; showing that a one-year increase in experience raises wages by about 12.2% &lt;em>within the same individual&lt;/em>. The tighter cloud in Panel B shows that once we account for who each person is, the experience-wage relationship is much more precisely identified.&lt;/p>
&lt;h2 id="8-customization-and-quick-reference">8. Customization and Quick Reference&lt;/h2>
&lt;h3 id="81-ggplot2-integration">8.1 ggplot2 integration&lt;/h3>
&lt;p>The &lt;code>fwl_plot()&lt;/code> function can return a ggplot2 object by setting &lt;code>ggplot = TRUE&lt;/code>, allowing full customization with ggplot2 layers and themes. This is useful for publication-quality figures with consistent styling, faceting, or combining multiple plots with &lt;code>patchwork&lt;/code>:&lt;/p>
&lt;pre>&lt;code class="language-r">p &amp;lt;- fwl_plot(sales ~ coupons + income, data = store_data, ggplot = TRUE)
fig5 &amp;lt;- p +
labs(title = &amp;quot;FWL Visualization: Coupons Effect on Sales&amp;quot;,
subtitle = &amp;quot;After residualizing on income&amp;quot;) +
theme_minimal(base_size = 13)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="r_fwlplot_fig5_ggplot_custom.png" alt="FWL scatter plot with ggplot2 customization showing coupons effect on sales after residualizing on income">&lt;/p>
&lt;h3 id="82-quick-reference-fwl_plot-recipes">8.2 Quick reference: fwl_plot() recipes&lt;/h3>
&lt;p>Here are the most common &lt;code>fwl_plot()&lt;/code> patterns you will use:&lt;/p>
&lt;pre>&lt;code class="language-r"># 1. Raw scatter (no controls)
fwl_plot(y ~ x, data = df)
# 2. Control for one or more variables
fwl_plot(y ~ x + control1 + control2, data = df)
# 3. Fixed effects (use | to separate)
fwl_plot(y ~ x | group_fe, data = df)
# 4. Multiple fixed effects
fwl_plot(y ~ x | fe1 + fe2, data = df)
# 5. Return ggplot2 object for customization
fwl_plot(y ~ x + control, data = df, ggplot = TRUE) + theme_minimal()
# 6. Sample points for large datasets (line uses all data)
fwl_plot(y ~ x | fe, data = big_data, n_sample = 5000)
&lt;/code>&lt;/pre>
&lt;h3 id="83-key-arguments">8.3 Key arguments&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Argument&lt;/th>
&lt;th>Purpose&lt;/th>
&lt;th>Example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>formula&lt;/code>&lt;/td>
&lt;td>Same as &lt;code>feols()&lt;/code>: &lt;code>y ~ x + controls | FE&lt;/code>&lt;/td>
&lt;td>&lt;code>sales ~ coupons + income&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>data&lt;/code>&lt;/td>
&lt;td>Input data frame&lt;/td>
&lt;td>&lt;code>store_data&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>ggplot&lt;/code>&lt;/td>
&lt;td>Return ggplot2 object (default: base R)&lt;/td>
&lt;td>&lt;code>ggplot = TRUE&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>n_sample&lt;/code>&lt;/td>
&lt;td>Sample N points for large datasets&lt;/td>
&lt;td>&lt;code>n_sample = 5000&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>vcov&lt;/code>&lt;/td>
&lt;td>Variance-covariance specification&lt;/td>
&lt;td>&lt;code>vcov = &amp;quot;hetero&amp;quot;&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>For large datasets like the flights data (317K+ observations), the &lt;code>n_sample&lt;/code> argument is essential to avoid overplotting. The regression line is always computed on the full data &amp;mdash; only the &lt;em>plotted points&lt;/em> are sampled, so the slope is unaffected.&lt;/p>
&lt;h2 id="9-discussion">9. Discussion&lt;/h2>
&lt;p>The FWL theorem is not just a mathematical curiosity &amp;mdash; it is the foundation of how modern regression software works. When &lt;code>fixest::feols()&lt;/code> estimates a model with fixed effects, it does not literally create and invert a matrix with thousands of dummy variables. Instead, it uses the FWL logic to demean the data and run OLS on the residuals. This is why &lt;code>fixest&lt;/code> can handle millions of observations with hundreds of thousands of fixed effects: the demeaning step is $O(N)$, while creating the full dummy matrix would be $O(N \times K)$.&lt;/p>
&lt;p>As a diagnostic tool, FWL scatter plots reveal problems that regression tables hide. If the residualized scatter shows a curved relationship, your linear specification may be wrong. If it shows outliers, they may be driving the coefficient. If the cloud collapses to a near-vertical line (as in Panel C of the flights figure), the within-group variation may be too small to identify the effect reliably.&lt;/p>
&lt;p>The FWL theorem also connects to more advanced methods. &lt;strong>Double Machine Learning&lt;/strong> (Chernozhukov et al., 2018) generalizes the partialling-out idea by using machine learning models instead of linear regression to residualize the data. The Python FWL tutorial on this site takes that next step. The &lt;code>fwlplot&lt;/code> package does not do DML, but the visual intuition &amp;mdash; &amp;ldquo;look at the residualized scatter to see the conditional relationship&amp;rdquo; &amp;mdash; carries over directly.&lt;/p>
&lt;p>One limitation: the FWL theorem applies only to linear regression. For logistic regression, Poisson regression, or other nonlinear models, the partialling-out logic does not hold exactly. The residualized scatter plot for a nonlinear model is at best an approximation of the conditional relationship, not an exact representation.&lt;/p>
&lt;h2 id="10-summary-and-next-steps">10. Summary and Next Steps&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Confounding produces misleading regressions:&lt;/strong> in our simulated data, the naive coupon coefficient was -0.093 (coupons &amp;ldquo;hurt&amp;rdquo; sales), while the true causal effect is +0.2. After controlling for income via &lt;code>fwl_plot()&lt;/code>, the estimate was +0.212, recovering the true effect.&lt;/li>
&lt;li>&lt;strong>The OVB formula predicts the bias exactly:&lt;/strong> the bias was $0.300 \times (-0.494) = -0.148$, correctly predicting the negative direction and approximate magnitude of the confounding.&lt;/li>
&lt;li>&lt;strong>FWL is not an approximation &amp;mdash; it is an exact algebraic identity:&lt;/strong> the coefficient from partialling out controls matches &lt;code>feols()&lt;/code> to six decimal places. Every multiple regression coefficient &lt;em>can&lt;/em> be visualized as a bivariate scatter plot.&lt;/li>
&lt;li>&lt;strong>Fixed effects are FWL applied to group dummies:&lt;/strong> the flights data showed how adding origin and destination FE progressively transformed the scatter. The air-time coefficient changed from -0.003 (no FE) to -0.007 (origin + destination FE).&lt;/li>
&lt;li>&lt;strong>Panel FE reveal within-person effects:&lt;/strong> the wage data showed that controlling for individual ability via FE steepened the bivariate experience slope from 0.03 (pooled, no controls) to 0.122 (within-person), more than tripling the estimated return to experience.&lt;/li>
&lt;/ul>
&lt;p>For further study, see the companion &lt;a href="https://carlos-mendez.org/post/python_fwl/">Python FWL tutorial&lt;/a> that extends the partialling-out logic to Double Machine Learning, and the &lt;a href="https://carlos-mendez.org/post/r_did/">R DID tutorial&lt;/a> that uses &lt;code>fixest&lt;/code> for difference-in-differences with staggered treatment adoption.&lt;/p>
&lt;h2 id="11-exercises">11. Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Omitted variable direction.&lt;/strong> Use the OVB formula from Section 5.3 to predict what happens if you also omit &lt;code>dayofweek&lt;/code> (in addition to income). Run the naive regression &lt;code>lm(sales ~ coupons)&lt;/code> and compare the bias to $\hat{\gamma}_{income} \times \hat{\delta}_{income} + \hat{\gamma}_{day} \times \hat{\delta}_{day}$. Does the extended OVB formula still predict the direction correctly?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Multiple controls.&lt;/strong> Use &lt;code>fwl_plot()&lt;/code> to visualize the coupon effect after controlling for both income and &lt;code>dayofweek&lt;/code>. Compare this to controlling for income alone. Does the scatter change visually? Does the coefficient change?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Your own data.&lt;/strong> Pick a dataset from the &lt;code>wooldridge&lt;/code> package (e.g., &lt;code>hprice1&lt;/code>, &lt;code>wage2&lt;/code>, &lt;code>crime2&lt;/code>) and use &lt;code>fwl_plot()&lt;/code> to visualize a regression relationship before and after adding controls. Does the coefficient change substantially? Can you identify what the confounder is doing?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="12-datasets">12. Datasets&lt;/h2>
&lt;p>The datasets used in this tutorial are saved as CSV files in the post directory for reuse in other tutorials:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>File&lt;/th>
&lt;th>Rows&lt;/th>
&lt;th>Description&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>store_data.csv&lt;/code>&lt;/td>
&lt;td>200&lt;/td>
&lt;td>Simulated retail data (sales, coupons, income, dayofweek)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>flights_sample.csv&lt;/code>&lt;/td>
&lt;td>5,000&lt;/td>
&lt;td>Cleaned NYC flights sample (delays, air time, origin, dest)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>wagepan.csv&lt;/code>&lt;/td>
&lt;td>4,360&lt;/td>
&lt;td>Wooldridge wage panel (545 individuals, 8 years)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="13-references">13. References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://cran.r-project.org/package=fwlplot" target="_blank" rel="noopener">Butts, K. &amp;amp; McDermott, G. (2024). fwlplot: Scatter Plot After Residualizing. CRAN.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.2307/1907330" target="_blank" rel="noopener">Frisch, R. &amp;amp; Waugh, F. V. (1933). Partial Time Regressions as Compared with Individual Trends. &lt;em>Econometrica&lt;/em>, 1(4), 387&amp;ndash;401.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1080/01621459.1963.10480682" target="_blank" rel="noopener">Lovell, M. C. (1963). Seasonal Adjustment of Economic Time Series and Multiple Regression Analysis. &lt;em>JASA&lt;/em>, 58(304), 993&amp;ndash;1010.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://cran.r-project.org/package=fixest" target="_blank" rel="noopener">Berge, L. (2018). fixest: Fast Fixed-Effects Estimations in R. CRAN.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://press.princeton.edu/books/paperback/9780691120355/mostly-harmless-econometrics" target="_blank" rel="noopener">Angrist, J. D. &amp;amp; Pischke, J.-S. (2009). &lt;em>Mostly Harmless Econometrics.&lt;/em> Princeton University Press.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1111/ectj.12097" target="_blank" rel="noopener">Chernozhukov, V. et al. (2018). Double/Debiased Machine Learning for Treatment and Structural Parameters. &lt;em>The Econometrics Journal&lt;/em>, 21(1), C1&amp;ndash;C68.&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>Visualizing Regression with the FWL Theorem in Stata</title><link>https://carlos-mendez.org/post/stata_fwl/</link><pubDate>Fri, 27 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/stata_fwl/</guid><description>&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>&amp;ldquo;What does it actually mean to &lt;em>control for&lt;/em> a variable?&amp;rdquo; This question appears in every applied regression course, and the answer is surprisingly hard to visualize. When we say &amp;ldquo;the effect of coupons on sales, controlling for income,&amp;rdquo; we are describing a relationship in multidimensional space. This relationship cannot be directly plotted on a two-dimensional scatter. The &lt;strong>Frisch-Waugh-Lovell (FWL) theorem&lt;/strong> changes this: it shows that the coefficient from a multiple regression equals the slope of a simple bivariate regression &amp;mdash; after first &lt;em>residualizing&lt;/em> (partialling out) the control variables from both the outcome and the variable of interest.&lt;/p>
&lt;p>The &lt;a href="https://github.com/leojahrens/scatterfit" target="_blank" rel="noopener">scatterfit&lt;/a> Stata package (Ahrens, 2024) makes this visual in one command. It takes a dependent variable, an independent variable, and optional controls or fixed effects, then produces a scatter plot of the residualized data with a fitted regression line. Built on &lt;code>reghdfe&lt;/code>, it handles high-dimensional fixed effects efficiently. It also offers features beyond what R&amp;rsquo;s &lt;code>fwl_plot()&lt;/code> or Python&amp;rsquo;s manual FWL can do: &lt;strong>binned scatter plots&lt;/strong> for large datasets, &lt;strong>regression parameters printed directly on the plot&lt;/strong>, and &lt;strong>multiple fit types&lt;/strong> (linear, quadratic, lowess).&lt;/p>
&lt;p>This tutorial is the third in a trilogy &amp;mdash; see the companion &lt;a href="https://carlos-mendez.org/post/r_fwlplot/">R tutorial&lt;/a> and &lt;a href="https://carlos-mendez.org/post/python_fwl/">Python tutorial&lt;/a> &amp;mdash; and uses the &lt;strong>same datasets&lt;/strong> for cross-language comparability. All data are loaded from GitHub URLs so the analysis is fully reproducible.&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Use &lt;code>scatterfit&lt;/code> to visualize bivariate relationships with and without controls&lt;/li>
&lt;li>Demonstrate FWL residualization with &lt;code>controls()&lt;/code> and &lt;code>fcontrols()&lt;/code>&lt;/li>
&lt;li>Verify manually that FWL reproduces &lt;code>reghdfe&lt;/code> coefficients exactly&lt;/li>
&lt;li>Visualize fixed effects using &lt;code>fcontrols()&lt;/code> on flights data&lt;/li>
&lt;li>Use binned scatter plots to summarize patterns in large datasets&lt;/li>
&lt;li>Show regression parameters directly on plots with &lt;code>regparameters()&lt;/code>&lt;/li>
&lt;/ul>
&lt;h2 id="2-the-modeling-pipeline">2. The Modeling Pipeline&lt;/h2>
&lt;pre>&lt;code class="language-mermaid">graph LR
A[&amp;quot;Load Data&amp;lt;br/&amp;gt;from GitHub&amp;lt;br/&amp;gt;(Section 3)&amp;quot;] --&amp;gt; B[&amp;quot;Naive vs.&amp;lt;br/&amp;gt;FWL Scatter&amp;lt;br/&amp;gt;(Section 4)&amp;quot;]
B --&amp;gt; C[&amp;quot;Manual FWL&amp;lt;br/&amp;gt;Verification&amp;lt;br/&amp;gt;(Section 5)&amp;quot;]
C --&amp;gt; D[&amp;quot;Binned&amp;lt;br/&amp;gt;Scatter&amp;lt;br/&amp;gt;(Section 6)&amp;quot;]
D --&amp;gt; E[&amp;quot;Fixed Effects&amp;lt;br/&amp;gt;Flights&amp;lt;br/&amp;gt;(Section 7)&amp;quot;]
E --&amp;gt; F[&amp;quot;Panel Data&amp;lt;br/&amp;gt;Wages&amp;lt;br/&amp;gt;(Section 8)&amp;quot;]
style A fill:#6a9bcc,stroke:#141413,color:#fff
style B fill:#d97757,stroke:#141413,color:#fff
style C fill:#d97757,stroke:#141413,color:#fff
style D fill:#00d4c8,stroke:#141413,color:#fff
style E fill:#6a9bcc,stroke:#141413,color:#fff
style F fill:#6a9bcc,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>We start where the answer is known (simulated data), see the result with &lt;code>scatterfit&lt;/code>, verify manually, then apply the same tool to real flights data and panel wage data.&lt;/p>
&lt;h2 id="3-setup-and-data">3. Setup and Data&lt;/h2>
&lt;h3 id="31-install-packages">3.1 Install packages&lt;/h3>
&lt;p>The &lt;code>scatterfit&lt;/code> command requires &lt;code>reghdfe&lt;/code> and &lt;code>ftools&lt;/code> for high-dimensional fixed effects estimation. All packages are installed from SSC or GitHub:&lt;/p>
&lt;pre>&lt;code class="language-stata">* Install packages if not already installed
capture ssc install reghdfe, replace
capture ssc install ftools, replace
capture ssc install estout, replace
capture net install scatterfit, ///
from(&amp;quot;https://raw.githubusercontent.com/leojahrens/scatterfit/master&amp;quot;) replace
&lt;/code>&lt;/pre>
&lt;h3 id="32-load-the-simulated-store-data">3.2 Load the simulated store data&lt;/h3>
&lt;p>We load the same simulated retail dataset used in the R and Python FWL tutorials. The data are hosted on GitHub for reproducibility:&lt;/p>
&lt;pre>&lt;code class="language-stata">import delimited &amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/master/content/post/r_fwlplot/store_data.csv&amp;quot;, clear
&lt;/code>&lt;/pre>
&lt;p>The data simulate a scenario where a store manager wants to know whether distributing coupons increases sales. &lt;strong>Income is a confounder&lt;/strong> &amp;mdash; wealthier neighborhoods receive fewer coupons (the store targets promotions at lower-income areas) but have higher baseline sales:&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
Income[&amp;quot;Income&amp;lt;br/&amp;gt;(confounder)&amp;quot;]
Coupons[&amp;quot;Coupons&amp;lt;br/&amp;gt;(treatment)&amp;quot;]
Sales[&amp;quot;Sales&amp;lt;br/&amp;gt;(outcome)&amp;quot;]
Income --&amp;gt;|&amp;quot;-0.5&amp;lt;br/&amp;gt;(fewer coupons&amp;lt;br/&amp;gt;to rich areas)&amp;quot;| Coupons
Income --&amp;gt;|&amp;quot;+0.3&amp;lt;br/&amp;gt;(rich areas&amp;lt;br/&amp;gt;buy more)&amp;quot;| Sales
Coupons --&amp;gt;|&amp;quot;+0.2&amp;lt;br/&amp;gt;(true causal&amp;lt;br/&amp;gt;effect)&amp;quot;| Sales
style Income fill:#d97757,stroke:#141413,color:#fff
style Coupons fill:#6a9bcc,stroke:#141413,color:#fff
style Sales fill:#00d4c8,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>The arrows in this diagram show causal relationships, and the numbers are the true effect sizes in the data generating process. The true causal effect of coupons on sales is &lt;strong>+0.2&lt;/strong>, but income opens a &lt;strong>backdoor path&lt;/strong> &amp;mdash; an indirect route from coupons to sales that goes &lt;em>through&lt;/em> income (coupons $\leftarrow$ income $\rightarrow$ sales). Unless we block this path by controlling for income, the naive estimate will be biased downward.&lt;/p>
&lt;pre>&lt;code class="language-stata">summarize sales coupons income dayofweek
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Variable | Obs Mean Std. dev. Min Max
-------------+---------------------------------------------------------
sales | 200 33.6747 3.811032 24.89 45.23
coupons | 200 34.85685 6.788834 18.72 53.25
income | 200 49.72545 9.745807 20.07 77.02
dayofweek | 200 3.915 1.996926 1 7
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-stata">correlate sales coupons income
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> | sales coupons income
-------------+---------------------------
sales | 1.0000
coupons | -0.1664 1.0000
income | 0.5003 -0.7087 1.0000
&lt;/code>&lt;/pre>
&lt;p>The correlation matrix confirms the confounding structure. Coupons and sales have a &lt;em>negative&lt;/em> raw correlation (-0.166), even though the true effect is positive (+0.2). Income is strongly negatively correlated with coupons (-0.709) and positively correlated with sales (0.500). A naive regression would wrongly conclude that coupons hurt sales.&lt;/p>
&lt;h2 id="4-scatterfit-in-action-naive-vs-controlled">4. scatterfit in Action: Naive vs. Controlled&lt;/h2>
&lt;h3 id="41-the-naive-scatter">4.1 The naive scatter&lt;/h3>
&lt;p>The simplest &lt;code>scatterfit&lt;/code> call plots the raw relationship. The &lt;code>regparameters()&lt;/code> option prints the regression coefficient, p-value, and R-squared directly on the plot &amp;mdash; a feature unique to this Stata package:&lt;/p>
&lt;pre>&lt;code class="language-stata">scatterfit sales coupons, regparameters(coef pval r2) ///
opts(name(naive, replace) title(&amp;quot;A. Naive: No Controls&amp;quot;))
&lt;/code>&lt;/pre>
&lt;p>The slope is &lt;strong>-0.093&lt;/strong> ($p = 0.018$, $R^2 = 0.028$): coupons appear to &lt;em>reduce&lt;/em> sales. This is statistically significant but substantively wrong &amp;mdash; the true effect is +0.2. The near-zero R-squared confirms that the naive model explains almost none of the variation in sales.&lt;/p>
&lt;h3 id="42-controlling-for-income-one-option">4.2 Controlling for income: one option&lt;/h3>
&lt;p>Now add income as a control. In &lt;code>scatterfit&lt;/code>, the &lt;code>controls()&lt;/code> option specifies continuous variables to partial out using the FWL procedure. Behind the scenes, &lt;code>scatterfit&lt;/code> calls &lt;code>reghdfe&lt;/code> to residualize both sales and coupons on income, then plots the residuals:&lt;/p>
&lt;pre>&lt;code class="language-stata">scatterfit sales coupons, controls(income) regparameters(coef pval r2) ///
opts(name(controlled, replace) title(&amp;quot;B. FWL: Controlling for Income&amp;quot;))
&lt;/code>&lt;/pre>
&lt;p>The slope reverses to &lt;strong>+0.212&lt;/strong> ($p &amp;lt; 0.001$, $R^2 = 0.32$) &amp;mdash; close to the true value of +0.2. The R-squared jumps from 0.03 to 0.32, showing that controlling for income explains a large share of the variation. Combining both panels:&lt;/p>
&lt;pre>&lt;code class="language-stata">graph combine naive controlled, ///
title(&amp;quot;What Does 'Controlling for Income' Look Like?&amp;quot;) rows(1)
graph export &amp;quot;stata_fwl_fig1_naive_vs_controlled.png&amp;quot;, replace
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_fwl_fig1_naive_vs_controlled.png" alt="Naive scatter (left) shows a negative slope with R2 of 0.028; after FWL residualization with controls(income), the slope reverses to positive with R2 of 0.32">&lt;/p>
&lt;p>The left panel shows the raw relationship: more coupons, lower sales ($R^2 = 0.028$). The right panel shows the &lt;em>same&lt;/em> data after removing the influence of income from both axes via &lt;code>controls(income)&lt;/code>. The true positive effect of coupons emerges clearly, and the $R^2$ rises to 0.32.&lt;/p>
&lt;h3 id="43-the-regression-table-confirms">4.3 The regression table confirms&lt;/h3>
&lt;p>We can compare the naive and controlled regressions side by side using Stata&amp;rsquo;s &lt;code>estimates store&lt;/code> and &lt;code>estimates table&lt;/code> workflow. The &lt;code>estimates store&lt;/code> command saves regression results under a name, and &lt;code>estimates table&lt;/code> displays multiple stored results in columns &amp;mdash; similar to R&amp;rsquo;s &lt;code>etable()&lt;/code> or Python&amp;rsquo;s &lt;code>stargazer&lt;/code>:&lt;/p>
&lt;pre>&lt;code class="language-stata">regress sales coupons
estimates store naive_ols
regress sales coupons income
estimates store full_ols
estimates table naive_ols full_ols, stats(r2 N) b(%9.4f) se(%9.4f)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">--------------------------------------
Variable | naive_ols full_ols
-------------+------------------------
coupons | -0.0934 0.2123
| 0.0393 0.0467
income | 0.3004
| 0.0325
_cons | 36.9301 11.3352
| 1.3969 3.0080
-------------+------------------------
r2 | 0.0277 0.3215
N | 200 200
--------------------------------------
&lt;/code>&lt;/pre>
&lt;p>Adding income as a control flips the coupon coefficient from -0.093 to +0.212 and increases the R-squared from 0.028 to 0.321. The income coefficient (0.300) is close to the true value of 0.3.&lt;/p>
&lt;h3 id="44-omitted-variable-bias-predicting-the-error">4.4 Omitted variable bias: predicting the error&lt;/h3>
&lt;p>The confounding is not mysterious &amp;mdash; the &lt;strong>omitted variable bias (OVB) formula&lt;/strong> predicts it exactly:&lt;/p>
&lt;p>$$\text{bias} = \hat{\gamma} \times \hat{\delta}$$&lt;/p>
&lt;p>In words, the bias equals the effect of the omitted variable on the outcome ($\hat{\gamma}$) multiplied by the relationship between the omitted variable and the treatment ($\hat{\delta}$).&lt;/p>
&lt;pre>&lt;code class="language-stata">* gamma = effect of income on sales (in full model)
regress sales coupons income
local gamma = _b[income] // 0.3004
* delta = regression of coupons on income
regress coupons income
local delta = _b[income] // -0.4937
* OVB = gamma * delta
display &amp;quot;OVB = &amp;quot; %9.4f `gamma' * `delta'
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">OVB = -0.1483
&lt;/code>&lt;/pre>
&lt;p>The OVB formula predicts a bias of -0.148: income&amp;rsquo;s positive effect on sales ($\hat{\gamma} = 0.300$) times its negative relationship with coupons ($\hat{\delta} = -0.494$) produces a large negative bias. The predicted naive coefficient (true + bias = 0.212 + (-0.148) = 0.064) is close to the actual naive coefficient (-0.093) &amp;mdash; the discrepancy comes from sampling variation with $n = 200$.&lt;/p>
&lt;h2 id="5-under-the-hood-manual-fwl-verification">5. Under the Hood: Manual FWL Verification&lt;/h2>
&lt;h3 id="51-the-three-step-recipe">5.1 The three-step recipe&lt;/h3>
&lt;p>The FWL theorem can be implemented manually in Stata using &lt;code>regress&lt;/code> and &lt;code>predict&lt;/code>:&lt;/p>
&lt;pre>&lt;code class="language-stata">* Step 1: Residualize sales on income
regress sales income
predict resid_sales, residuals
* Step 2: Residualize coupons on income
regress coupons income
predict resid_coupons, residuals
* Step 3: Regress residuals on residuals
regress resid_sales resid_coupons
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">------------------------------------------------------------------------------
resid_sales | Coefficient Std. err. t P&amp;gt;|t| [95% conf. interval]
-------------+----------------------------------------------------------------
resid_coup~s | .2122882 .046581 4.56 0.000 .1204297 .3041466
_cons | -2.87e-09 .222537 -0.00 1.000 -.4388468 .4388468
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The FWL coefficient on &lt;code>resid_coupons&lt;/code> is &lt;strong>0.212288&lt;/strong> &amp;mdash; exactly the same as the full regression coefficient on &lt;code>coupons&lt;/code> (0.212288). This is not an approximation; it is an algebraic identity. Formally, the FWL theorem says:&lt;/p>
&lt;p>$$\hat{\beta}_1 = \frac{\text{Cov}(\tilde{Y}, \tilde{X}_1)}{\text{Var}(\tilde{X}_1)}$$&lt;/p>
&lt;p>where $\tilde{Y}$ and $\tilde{X}_1$ are the residuals from regressing $Y$ and $X_1$ on the controls $Z$. In our example, $\tilde{Y}$ is &lt;code>resid_sales&lt;/code> (the part of sales that income cannot explain) and $\tilde{X}_1$ is &lt;code>resid_coupons&lt;/code> (the part of coupons that income cannot explain). The ratio of their covariance to the variance of $\tilde{X}_1$ gives the slope we see in the regression above.&lt;/p>
&lt;p>Think of it like measuring height &lt;em>for your age&lt;/em>: instead of comparing raw heights, you compare how much taller or shorter each person is than the average for their age group.&lt;/p>
&lt;h3 id="52-adding-more-controls">5.2 Adding more controls&lt;/h3>
&lt;p>The &lt;code>scatterfit&lt;/code> command handles any number of controls automatically:&lt;/p>
&lt;pre>&lt;code class="language-stata">scatterfit sales coupons, ///
regparameters(coef pval r2) opts(name(panel_a, replace) title(&amp;quot;A. No Controls&amp;quot;))
scatterfit sales coupons, controls(income) ///
regparameters(coef pval r2) opts(name(panel_b, replace) title(&amp;quot;B. + Income&amp;quot;))
scatterfit sales coupons, controls(income dayofweek) ///
regparameters(coef pval r2) opts(name(panel_c, replace) title(&amp;quot;C. + Income + Day&amp;quot;))
graph combine panel_a panel_b panel_c, ///
title(&amp;quot;Progressive Controls: How the Scatter Changes&amp;quot;) rows(1)
graph export &amp;quot;stata_fwl_fig2_three_panels.png&amp;quot;, replace
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_fwl_fig2_three_panels.png" alt="Three-panel progression showing coefficient, p-value, and R2: no controls (left, R2 = 0.028), controlling for income (center, R2 = 0.32), controlling for income and day of week (right, R2 = 0.37)">&lt;/p>
&lt;pre>&lt;code class="language-stata">estimates table m1_naive m2_income m3_full, stats(r2 r2_a N)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">--------------------------------------------------
Variable | m1_naive m2_income m3_full
-------------+------------------------------------
coupons | -0.0934 0.2123 0.2219
| 0.0393 0.0467 0.0454
income | 0.3004 0.2961
| 0.0325 0.0316
dayofweek | 0.4029
| 0.1095
_cons | 36.9301 11.3352 9.6398
| 1.3969 3.0080 2.9527
-------------+------------------------------------
r2 | 0.0277 0.3215 0.3654
r2_a | 0.0228 0.3146 0.3556
N | 200 200 200
--------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The coupon coefficient progresses from -0.093 (naive, wrong sign), to +0.212 (controlling for income), to +0.222 (adding day of week). The R-squared &amp;mdash; now visible directly on each panel &amp;mdash; jumps from 0.028 to 0.32 to 0.37. Each scatterfit panel shows a tighter cloud as more variation is absorbed by the controls.&lt;/p>
&lt;h2 id="6-binned-scatter-plots">6. Binned Scatter Plots&lt;/h2>
&lt;h3 id="61-why-binned-scatters">6.1 Why binned scatters?&lt;/h3>
&lt;p>With large datasets (thousands or millions of observations), scatter plots become useless &amp;mdash; individual points merge into a solid blob. &lt;strong>Binned scatter plots&lt;/strong> solve this by grouping observations into quantile bins along the x-axis and plotting the bin means. The regression line is still estimated on the full data, so the slope is unaffected. This is one of &lt;code>scatterfit&lt;/code>&amp;rsquo;s key advantages over R&amp;rsquo;s &lt;code>fwl_plot()&lt;/code>.&lt;/p>
&lt;h3 id="62-unbinned-vs-binned">6.2 Unbinned vs. binned&lt;/h3>
&lt;pre>&lt;code class="language-stata">scatterfit sales coupons, controls(income) ///
regparameters(coef pval r2) opts(name(unbinned, replace) title(&amp;quot;A. Unbinned (all points)&amp;quot;))
scatterfit sales coupons, controls(income) binned ///
regparameters(coef pval r2) opts(name(binned, replace) title(&amp;quot;B. Binned (20 quantiles)&amp;quot;))
graph combine unbinned binned, ///
title(&amp;quot;Binned Scatter: Summarizing Patterns in Large Data&amp;quot;) rows(1)
graph export &amp;quot;stata_fwl_fig3_binned_scatter.png&amp;quot;, replace
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_fwl_fig3_binned_scatter.png" alt="Unbinned scatter (left) vs. binned scatter with 20 quantiles (right), both showing the same FWL-residualized relationship with coefficient, p-value, and R2 annotations">&lt;/p>
&lt;p>Both panels show the same FWL-residualized relationship ($\beta = 0.21$, $R^2 = 0.32$), but the binned version (right) replaces 200 individual points with 20 bin-mean markers. For our small dataset the difference is modest, but for the flights data (5,000+ observations) or production datasets (millions of rows), binning is essential. The &lt;code>nquantiles()&lt;/code> option controls how many bins to use:&lt;/p>
&lt;pre>&lt;code class="language-stata">* Fewer bins = smoother but less detail
scatterfit sales coupons, controls(income) binned nquantiles(10)
* More bins = more detail but noisier
scatterfit sales coupons, controls(income) binned nquantiles(30)
&lt;/code>&lt;/pre>
&lt;h2 id="7-visualizing-fixed-effects">7. Visualizing Fixed Effects&lt;/h2>
&lt;h3 id="71-load-the-flights-data">7.1 Load the flights data&lt;/h3>
&lt;p>We load the NYC flights sample &amp;mdash; 5,000 flights from New York&amp;rsquo;s three airports (EWR, JFK, LGA) in 2013:&lt;/p>
&lt;pre>&lt;code class="language-stata">import delimited &amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/master/content/post/r_fwlplot/flights_sample.csv&amp;quot;, clear
summarize dep_delay air_time
tabulate origin
* Encode string variables for fixed effects (needed by scatterfit/reghdfe)
encode origin, gen(origin_fe)
encode dest, gen(dest_fe)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Variable | Obs Mean Std. dev. Min Max
-------------+---------------------------------------------------------
dep_delay | 5,000 7.3172 22.83736 -20 119
air_time | 5,000 150.3636 93.47726 22 650
&lt;/code>&lt;/pre>
&lt;h3 id="72-progressive-fixed-effects">7.2 Progressive fixed effects&lt;/h3>
&lt;p>The &lt;code>fcontrols()&lt;/code> option specifies categorical variables to absorb as fixed effects. This is analogous to &lt;code>feols(...| FE)&lt;/code> in R&amp;rsquo;s fixest:&lt;/p>
&lt;pre>&lt;code class="language-stata">* No fixed effects
scatterfit dep_delay air_time, regparameters(coef pval r2) ///
opts(name(fe_none, replace) title(&amp;quot;A. No Fixed Effects&amp;quot;))
* Origin airport FE
scatterfit dep_delay air_time, fcontrols(origin_fe) ///
regparameters(coef pval r2) opts(name(fe_origin, replace) title(&amp;quot;B. Origin FE&amp;quot;))
* Origin + destination FE
scatterfit dep_delay air_time, fcontrols(origin_fe dest_fe) ///
regparameters(coef pval r2) opts(name(fe_both, replace) title(&amp;quot;C. Origin + Dest FE&amp;quot;))
graph combine fe_none fe_origin fe_both, ///
title(&amp;quot;What Do Fixed Effects 'Do' to the Data?&amp;quot;) rows(1)
graph export &amp;quot;stata_fwl_fig4_fixed_effects.png&amp;quot;, replace
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_fwl_fig4_fixed_effects.png" alt="Progressive FWL plots with coefficient, p-value, and R2: no FE (left, R2 near 0), origin FE (center), origin + destination FE (right)">&lt;/p>
&lt;p>Panel A shows the raw cloud with a nearly flat slope ($R^2 \approx 0$). Panel B removes the three origin-airport means, tightening the horizontal spread. Panel C removes the destination means as well, collapsing the variation to &lt;em>within-route&lt;/em> deviations and increasing $R^2$ substantially. The &lt;code>fcontrols()&lt;/code> option handles all the demeaning internally using &lt;code>reghdfe&lt;/code>.&lt;/p>
&lt;h3 id="73-regression-table">7.3 Regression table&lt;/h3>
&lt;pre>&lt;code class="language-stata">regress dep_delay air_time
estimates store fe0
reghdfe dep_delay air_time, absorb(origin_fe) vce(robust)
estimates store fe1
reghdfe dep_delay air_time, absorb(origin_fe dest_fe) vce(robust)
estimates store fe2
estimates table fe0 fe1 fe2, stats(r2 N) b(%9.4f) se(%9.4f)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">--------------------------------------------------
Variable | fe0 fe1 fe2
-------------+------------------------------------
air_time | -0.0050 -0.0079 -0.0324
| 0.0035 0.0034 0.0265
_cons | 8.0669 8.5072 12.1416
| 0.6117 0.6449 4.0186
-------------+------------------------------------
r2 | 0.0004 0.0055 0.0310
N | 5000 5000 4994
--------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The air time coefficient changes as we add fixed effects: -0.005 (no FE), -0.008 (origin FE), -0.032 (origin + destination FE). Note that these are estimated on the 5,000-observation sample, so the coefficients differ somewhat from the full-data estimates in the R tutorial. The key pattern is the same: adding fixed effects absorbs between-group variation and changes both the magnitude and precision of the coefficient. With origin + destination FE, 6 singleton observations are dropped (N = 4,994) &amp;mdash; singletons are routes with only one flight in the sample, where within-group variation cannot be estimated.&lt;/p>
&lt;h2 id="8-panel-data-returns-to-experience">8. Panel Data: Returns to Experience&lt;/h2>
&lt;h3 id="81-load-the-wage-panel">8.1 Load the wage panel&lt;/h3>
&lt;p>The wage panel contains 545 individuals observed over 8 years (1980&amp;ndash;1987). The classic question: what is the return to experience? The challenge is &lt;strong>unobserved ability&lt;/strong> &amp;mdash; two people with the same experience may earn very different wages because one is more talented, motivated, or well-connected. These unmeasured personal traits are the &amp;ldquo;unobserved ability&amp;rdquo; that individual fixed effects absorb.&lt;/p>
&lt;pre>&lt;code class="language-stata">import delimited &amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/master/content/post/r_fwlplot/wagepan.csv&amp;quot;, clear
xtset nr year
summarize lwage exper expersq educ
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Variable | Obs Mean Std. dev. Min Max
-------------+---------------------------------------------------------
lwage | 4,360 1.649147 .5326094 -3.579079 4.05186
exper | 4,360 6.514679 2.825873 0 18
expersq | 4,360 50.42477 40.78199 0 324
educ | 4,360 11.76697 1.746181 3 16
&lt;/code>&lt;/pre>
&lt;h3 id="82-pooled-ols-vs-individual-fixed-effects">8.2 Pooled OLS vs. individual fixed effects&lt;/h3>
&lt;pre>&lt;code class="language-stata">regress lwage educ exper expersq
estimates store pool
reghdfe lwage exper expersq, absorb(nr)
estimates store fe_ind
reghdfe lwage exper expersq, absorb(nr year)
estimates store fe_twfe
estimates table pool fe_ind fe_twfe, stats(r2 N)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">--------------------------------------------------
Variable | pool fe_ind fe_twfe
-------------+------------------------------------
educ | 0.1021
| 0.0047
exper | 0.1050 0.1223 (omitted)
| 0.0102 0.0082
expersq | -0.0036 -0.0045 -0.0054
| 0.0007 0.0006 0.0007
_cons | -0.0564 1.0807 1.9223
| 0.0639 0.0263 0.0359
-------------+------------------------------------
r2 | 0.1477 0.6173 0.6185
N | 4360 4360 4360
--------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>Several things change as we add fixed effects. The &lt;code>educ&lt;/code> coefficient disappears from the individual FE column &amp;mdash; education is time-invariant (it does not change over the 8 years for any individual), so it is perfectly collinear with person dummies. Stata marks &lt;code>exper&lt;/code> as &lt;code>(omitted)&lt;/code> in the two-way FE column &amp;mdash; because experience increments by one year for everyone, it is perfectly collinear with year dummies. Only &lt;code>expersq&lt;/code> (which varies non-linearly) survives both sets of fixed effects. The R-squared jumps from 0.148 to 0.617, showing that individual fixed effects explain the majority of wage variation.&lt;/p>
&lt;h3 id="83-scatterfit-with-individual-fe">8.3 scatterfit with individual FE&lt;/h3>
&lt;pre>&lt;code class="language-stata">* Sample 150 individuals for visual clarity
preserve
set seed 456
bysort nr: gen first = (_n == 1)
gen rand = runiform() if first
bysort nr (rand): replace rand = rand[1]
sort rand nr year
egen rank = group(rand) if first
bysort nr (rank): replace rank = rank[1]
keep if rank &amp;lt;= 150
scatterfit lwage exper, regparameters(coef pval r2) ///
opts(name(wage_raw, replace) title(&amp;quot;A. Raw: Pooled Cross-Section&amp;quot;))
scatterfit lwage exper, fcontrols(nr) regparameters(coef pval r2) ///
opts(name(wage_fe, replace) title(&amp;quot;B. FWL: Individual Fixed Effects&amp;quot;))
graph combine wage_raw wage_fe, ///
title(&amp;quot;Controlling for Unobserved Ability&amp;quot;) rows(1)
graph export &amp;quot;stata_fwl_fig5_panel_data.png&amp;quot;, replace
restore
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_fwl_fig5_panel_data.png" alt="Raw pooled cross-section (left, R2 = 0.043) vs. individual fixed-effects residualized scatter (right, R2 = 0.59) for log wage vs. experience">&lt;/p>
&lt;p>The visual difference is dramatic. Panel A shows a wide fan with a shallow slope ($R^2 = 0.043$) &amp;mdash; individuals at the same experience level have wildly different wages, reflecting unobserved ability. Panel B applies &lt;code>fcontrols(nr)&lt;/code> to strip away each person&amp;rsquo;s average wage and experience, leaving only &lt;em>within-person&lt;/em> deviations. The $R^2$ jumps from 0.04 to 0.59, showing that individual fixed effects explain most of the wage variation. The slope steepens sharply: the within-person return to experience is about 0.07 log points per year (roughly 7%), and the relationship is much more precisely identified once we control for who each person is.&lt;/p>
&lt;h2 id="9-advanced-fit-types-and-regression-parameters">9. Advanced: Fit Types and Regression Parameters&lt;/h2>
&lt;h3 id="91-multiple-fit-types">9.1 Multiple fit types&lt;/h3>
&lt;p>The &lt;code>regparameters()&lt;/code> option displays the coefficient, standard error, p-value, R-squared, and sample size directly on the plot. The &lt;code>scatterfit&lt;/code> command also supports fit types beyond linear &amp;mdash; quadratic and lowess &amp;mdash; as diagnostics for nonlinearity:&lt;/p>
&lt;pre>&lt;code class="language-stata">* Linear fit with all regression parameters displayed on the plot
scatterfit sales coupons, controls(income) ///
regparameters(coef se pval r2 n)
graph export &amp;quot;stata_fwl_fig6_advanced.png&amp;quot;, replace
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="stata_fwl_fig6_advanced.png" alt="Linear FWL fit with regression parameters (coefficient, SE, p-value, R-squared, N) displayed directly on the plot">&lt;/p>
&lt;pre>&lt;code class="language-stata">* Lowess fit: nonparametric check (note: lowess does not support controls())
scatterfit sales coupons, fit(lowess)
&lt;/code>&lt;/pre>
&lt;p>The quadratic fit serves as a diagnostic. If the relationship looks curved in the residualized scatter, your linear specification may be misspecified. Note that &lt;code>fit(lowess)&lt;/code> and &lt;code>fit(lpoly)&lt;/code> do not support &lt;code>controls()&lt;/code> in the current version of &lt;code>scatterfit&lt;/code> &amp;mdash; use them on raw or manually residualized data. For our simulated data (which is truly linear), the quadratic fit closely follows the linear fit, confirming the specification is appropriate.&lt;/p>
&lt;h3 id="92-regression-parameters-on-the-plot">9.2 Regression parameters on the plot&lt;/h3>
&lt;p>The &lt;code>regparameters()&lt;/code> option displays statistical information directly on the scatter plot. Available parameters:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Parameter&lt;/th>
&lt;th>Display&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>coef&lt;/code>&lt;/td>
&lt;td>Slope coefficient&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>se&lt;/code>&lt;/td>
&lt;td>Standard error&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>pval&lt;/code>&lt;/td>
&lt;td>P-value&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>r2&lt;/code>&lt;/td>
&lt;td>R-squared&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>n&lt;/code>&lt;/td>
&lt;td>Sample size&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;pre>&lt;code class="language-stata">* Show everything
scatterfit sales coupons, controls(income) regparameters(coef se pval r2 n)
&lt;/code>&lt;/pre>
&lt;p>This is especially useful for presentations and papers where you want to communicate both the visual pattern and the statistical evidence in a single figure.&lt;/p>
&lt;h3 id="93-quick-reference-scatterfit-recipes">9.3 Quick reference: scatterfit recipes&lt;/h3>
&lt;pre>&lt;code class="language-stata">* 1. Raw scatter (no controls)
scatterfit y x
* 2. Control for continuous variables (FWL)
scatterfit y x, controls(z1 z2)
* 3. Control for fixed effects (categorical)
scatterfit y x, fcontrols(group_fe)
* 4. Both continuous controls and fixed effects
scatterfit y x, controls(z1) fcontrols(group_fe)
* 5. Binned scatter (for large datasets)
scatterfit y x, controls(z1) binned nquantiles(20)
* 6. Show regression parameters on the plot
scatterfit y x, controls(z1) regparameters(coef pval r2)
* 7. Quadratic fit (works with controls)
scatterfit y x, controls(z1) fit(quadratic)
* 8. Lowess fit (does NOT support controls — use on raw data)
scatterfit y x, fit(lowess)
&lt;/code>&lt;/pre>
&lt;h2 id="10-discussion">10. Discussion&lt;/h2>
&lt;p>The FWL theorem is not just a pedagogical tool &amp;mdash; it is the computational engine behind Stata&amp;rsquo;s &lt;code>reghdfe&lt;/code> command. When &lt;code>reghdfe&lt;/code> estimates a model with fixed effects, it does not create a matrix with thousands of dummy variables. Instead, it uses an iterative demeaning algorithm (a generalization of FWL) to absorb the fixed effects, then runs OLS on the residuals. This is why &lt;code>reghdfe&lt;/code> can handle millions of observations with tens of thousands of fixed effects.&lt;/p>
&lt;p>The &lt;code>scatterfit&lt;/code> package offers three advantages over the R and Python implementations of FWL visualization. First, &lt;strong>binned scatter plots&lt;/strong> (Section 6) are essential for large datasets where individual points merge into an unreadable blob. Second, &lt;strong>regression parameters on the plot&lt;/strong> (&lt;code>regparameters()&lt;/code>) combine the visual and statistical evidence in a single figure, reducing the back-and-forth between plots and tables. Third, &lt;strong>multiple fit types&lt;/strong> (&lt;code>fit(quadratic)&lt;/code>, &lt;code>fit(lowess)&lt;/code>) serve as built-in diagnostics for linearity.&lt;/p>
&lt;p>Across the three tutorials (Python, R, Stata), the key numbers are the same because we use the same datasets: the naive coupon coefficient is -0.093, the true effect is +0.212 after controlling for income, and the OVB is -0.148. The FWL theorem is the same in every language &amp;mdash; only the syntax changes:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Task&lt;/th>
&lt;th>Python&lt;/th>
&lt;th>R&lt;/th>
&lt;th>Stata&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Raw scatter&lt;/td>
&lt;td>&lt;code>plt.scatter(x, y)&lt;/code>&lt;/td>
&lt;td>&lt;code>fwl_plot(y ~ x)&lt;/code>&lt;/td>
&lt;td>&lt;code>scatterfit y x&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Control for Z&lt;/td>
&lt;td>manual &lt;code>resid()&lt;/code>&lt;/td>
&lt;td>&lt;code>fwl_plot(y ~ x + z)&lt;/code>&lt;/td>
&lt;td>&lt;code>scatterfit y x, controls(z)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Fixed effects&lt;/td>
&lt;td>not supported&lt;/td>
&lt;td>&lt;code>fwl_plot(y ~ x | fe)&lt;/code>&lt;/td>
&lt;td>&lt;code>scatterfit y x, fcontrols(fe)&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Binned scatter&lt;/td>
&lt;td>not supported&lt;/td>
&lt;td>not supported&lt;/td>
&lt;td>&lt;code>scatterfit y x, binned&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Stats on plot&lt;/td>
&lt;td>not supported&lt;/td>
&lt;td>not supported&lt;/td>
&lt;td>&lt;code>regparameters(coef pval r2)&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Students who learn FWL in one language can immediately apply it in another.&lt;/p>
&lt;p>One limitation: the FWL theorem applies only to linear regression. For logistic, Poisson, or other nonlinear models, the partialling-out logic does not hold exactly. Stata&amp;rsquo;s &lt;code>scatterfit&lt;/code> does support &lt;code>fitmodel(logit)&lt;/code> and &lt;code>fitmodel(poisson)&lt;/code>, but these are direct fits, not FWL residualizations.&lt;/p>
&lt;h2 id="11-summary-and-next-steps">11. Summary and Next Steps&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Confounding produces misleading regressions:&lt;/strong> the naive coupon coefficient was -0.093 (wrong sign), while the true causal effect is +0.2. After FWL residualization with &lt;code>controls(income)&lt;/code>, the estimate was +0.212.&lt;/li>
&lt;li>&lt;strong>The OVB formula predicts the bias exactly:&lt;/strong> $0.300 \times (-0.494) = -0.148$, correctly predicting the negative direction and approximate magnitude of the confounding.&lt;/li>
&lt;li>&lt;strong>FWL is an exact identity:&lt;/strong> the manual three-step procedure in Stata (&lt;code>regress&lt;/code> + &lt;code>predict resid&lt;/code> + &lt;code>regress&lt;/code>) matches the full regression to six decimal places (0.212288).&lt;/li>
&lt;li>&lt;strong>Fixed effects are FWL applied to group dummies:&lt;/strong> &lt;code>fcontrols()&lt;/code> in &lt;code>scatterfit&lt;/code> calls &lt;code>reghdfe&lt;/code> internally to demean the data, equivalent to &lt;code>feols(... | FE)&lt;/code> in R.&lt;/li>
&lt;li>&lt;strong>Binned scatter plots and on-plot statistics are Stata&amp;rsquo;s advantage:&lt;/strong> the &lt;code>binned&lt;/code> and &lt;code>regparameters()&lt;/code> options provide capabilities that the R and Python FWL tools lack.&lt;/li>
&lt;/ul>
&lt;p>For further study, see the companion &lt;a href="https://carlos-mendez.org/post/r_fwlplot/">R FWL tutorial&lt;/a> using &lt;code>fwl_plot()&lt;/code> and the &lt;a href="https://carlos-mendez.org/post/python_fwl/">Python FWL tutorial&lt;/a> that extends FWL to Double Machine Learning.&lt;/p>
&lt;h2 id="12-exercises">12. Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>OVB direction.&lt;/strong> In our simulation, predict the direction of the OVB if you also omit &lt;code>dayofweek&lt;/code>. Compute $\hat{\gamma}_{day} \times \hat{\delta}_{day}$ and add it to the income OVB. Does the total bias match the difference between the naive and the fully controlled coefficient?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Binned scatter with different bins.&lt;/strong> Re-run &lt;code>scatterfit sales coupons, controls(income) binned nquantiles(k)&lt;/code> for $k = 5, 10, 20, 50$. How does the visual change? At what point do you lose meaningful information?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>slopefit: heterogeneous effects.&lt;/strong> Use the &lt;code>slopefit&lt;/code> command: &lt;code>slopefit sales coupons income&lt;/code>. This shows how the coupon-sales slope varies across income levels. Do coupons work better in low-income or high-income neighborhoods?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="13-references">13. References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://github.com/leojahrens/scatterfit" target="_blank" rel="noopener">Ahrens, L. (2024). scatterfit: Scatter Plots with Fit Lines and Regression Results. GitHub.&lt;/a>&lt;/li>
&lt;li>&lt;a href="http://scorreia.com/software/reghdfe/" target="_blank" rel="noopener">Correia, S. (2016). reghdfe: Linear Models with Many Levels of Fixed Effects. Stata Journal.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.2307/1907330" target="_blank" rel="noopener">Frisch, R. &amp;amp; Waugh, F. V. (1933). Partial Time Regressions as Compared with Individual Trends. &lt;em>Econometrica&lt;/em>, 1(4), 387&amp;ndash;401.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1080/01621459.1963.10480682" target="_blank" rel="noopener">Lovell, M. C. (1963). Seasonal Adjustment of Economic Time Series and Multiple Regression Analysis. &lt;em>JASA&lt;/em>, 58(304), 993&amp;ndash;1010.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://press.princeton.edu/books/paperback/9780691120355/mostly-harmless-econometrics" target="_blank" rel="noopener">Angrist, J. D. &amp;amp; Pischke, J.-S. (2009). &lt;em>Mostly Harmless Econometrics.&lt;/em> Princeton University Press.&lt;/a>&lt;/li>
&lt;li>Datasets: simulated store data, NYC flights sample, and Wooldridge wage panel from the companion &lt;a href="https://carlos-mendez.org/post/r_fwlplot/">R FWL tutorial&lt;/a> on this site.&lt;/li>
&lt;/ol></description></item><item><title>Three Methods for Robust Variable Selection: BMA, LASSO, and WALS</title><link>https://carlos-mendez.org/post/r_bma_lasso_wals/</link><pubDate>Mon, 23 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/r_bma_lasso_wals/</guid><description>&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>Imagine you are an economist advising a government on climate policy. Your team has collected cross-country data on a dozen potential drivers of CO&lt;sub>2&lt;/sub> emissions: GDP per capita, fossil fuel dependence, urbanization, industrial output, democratic governance, trade networks, agricultural activity, trade openness, foreign direct investment, corruption, tourism, and domestic credit. The government has a limited budget and wants to know: &lt;strong>which of these factors truly drive CO&lt;sub>2&lt;/sub> emissions, and which are red herrings?&lt;/strong>&lt;/p>
&lt;p>This is the &lt;strong>variable selection&lt;/strong> problem, and it is harder than it sounds. With 12 candidate variables, each either included or excluded from a regression, there are $2^{12} = 4,096$ possible models you could estimate. Run one model and report it as &amp;ldquo;the answer,&amp;rdquo; and you have implicitly assumed the other 4,095 models are wrong. That is a very strong assumption &amp;mdash; and almost certainly unjustified.&lt;/p>
&lt;p>In practice, researchers handle this by &lt;em>specification searching&lt;/em>: they try many models, drop insignificant variables, and report whichever specification &amp;ldquo;works best.&amp;rdquo; This process inflates false discoveries. A noise variable that happens to look significant in one specification gets reported, while the many failed specifications are hidden in the researcher&amp;rsquo;s desk drawer. This is sometimes called the &lt;strong>file drawer problem&lt;/strong> or &lt;strong>pretesting bias&lt;/strong>.&lt;/p>
&lt;p>This tutorial introduces three principled approaches to the variable selection problem:&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
Q[&amp;quot;&amp;lt;b&amp;gt;Variable Selection&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Which of 12 variables&amp;lt;br/&amp;gt;truly matter?&amp;quot;] --&amp;gt; BMA
Q --&amp;gt; LASSO
Q --&amp;gt; WALS
BMA[&amp;quot;&amp;lt;b&amp;gt;BMA&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Bayesian Model Averaging&amp;lt;br/&amp;gt;PIPs from 4,096 models&amp;quot;] --&amp;gt; R[&amp;quot;&amp;lt;b&amp;gt;Convergence&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Variables identified&amp;lt;br/&amp;gt;by all 3 methods&amp;quot;]
LASSO[&amp;quot;&amp;lt;b&amp;gt;LASSO&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;L1 penalized regression&amp;lt;br/&amp;gt;Automatic selection&amp;quot;] --&amp;gt; R
WALS[&amp;quot;&amp;lt;b&amp;gt;WALS&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Frequentist averaging&amp;lt;br/&amp;gt;t-statistics&amp;quot;] --&amp;gt; R
style Q fill:#141413,stroke:#141413,color:#fff
style BMA fill:#6a9bcc,stroke:#141413,color:#fff
style LASSO fill:#d97757,stroke:#141413,color:#fff
style WALS fill:#00d4c8,stroke:#141413,color:#fff
style R fill:#1a3a8a,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Bayesian Model Averaging (BMA)&lt;/strong>: Average across all 4,096 models, weighting each by how well it fits the data. Variables that appear important across many models earn a high &amp;ldquo;inclusion probability.&amp;rdquo;&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>LASSO (Least Absolute Shrinkage and Selection Operator)&lt;/strong>: Add a penalty to the regression that forces the coefficients of irrelevant variables to be &lt;em>exactly zero&lt;/em>, performing automatic selection.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Weighted Average Least Squares (WALS)&lt;/strong>: A fast frequentist model-averaging method that transforms the problem so each variable can be evaluated independently.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>We use &lt;strong>synthetic data&lt;/strong> throughout this tutorial. This means we &lt;em>know the true data-generating process&lt;/em> &amp;mdash; which variables truly matter and which do not. This &amp;ldquo;answer key&amp;rdquo; lets us verify whether each method correctly recovers the truth. By the end, you will understand not just &lt;em>how&lt;/em> to run each method, but &lt;em>why&lt;/em> it works and &lt;em>when&lt;/em> to prefer one over the others.&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Understand the variable selection problem and why running a single model is insufficient when model uncertainty is large&lt;/li>
&lt;li>Implement Bayesian Model Averaging in R and interpret Posterior Inclusion Probabilities (PIPs)&lt;/li>
&lt;li>Apply LASSO with cross-validation to perform automatic variable selection and use Post-LASSO for unbiased estimation&lt;/li>
&lt;li>Run WALS as a fast frequentist model-averaging alternative and interpret its t-statistics&lt;/li>
&lt;li>Compare results across all three methods to identify truly robust determinants via methodological triangulation&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Content outline.&lt;/strong> Section 2 sets up the R environment. Section 3 introduces the synthetic dataset and its built-in &amp;ldquo;answer key&amp;rdquo; &amp;mdash; 7 true predictors and 5 noise variables with realistic multicollinearity. Section 4 runs naive OLS to illustrate the spurious significance problem. Sections 5&amp;ndash;8 cover BMA: Bayes' rule foundations, the PIP framework, a toy example, and full implementation. Sections 9&amp;ndash;12 cover LASSO: the bias-variance tradeoff, L1/L2 geometry, cross-validated implementation, and Post-LASSO. Sections 13&amp;ndash;16 cover WALS: frequentist model averaging, the semi-orthogonal transformation, the Laplace prior, and implementation. Section 17 brings all three methods together for a grand comparison. Section 18 summarizes key takeaways and provides further reading.&lt;/p>
&lt;h2 id="2-setup">2. Setup&lt;/h2>
&lt;p>Before running the analysis, install the required packages if needed. The following code checks for missing packages and installs them automatically.&lt;/p>
&lt;pre>&lt;code class="language-r"># List all packages needed for this tutorial
required_packages &amp;lt;- c(
&amp;quot;tidyverse&amp;quot;, # data manipulation and ggplot2 visualization
&amp;quot;BMS&amp;quot;, # Bayesian Model Averaging via the bms() function
&amp;quot;glmnet&amp;quot;, # LASSO and Ridge regression via coordinate descent
&amp;quot;WALS&amp;quot;, # Weighted Average Least Squares estimation
&amp;quot;scales&amp;quot;, # nice axis formatting in plots
&amp;quot;patchwork&amp;quot;, # combine multiple ggplot panels
&amp;quot;ggrepel&amp;quot;, # non-overlapping text labels on plots
&amp;quot;corrplot&amp;quot;, # correlation matrix heatmaps
&amp;quot;broom&amp;quot; # tidy model summaries
)
# Install any packages not yet available
missing &amp;lt;- required_packages[!sapply(required_packages, requireNamespace, quietly = TRUE)]
if (length(missing) &amp;gt; 0) {
install.packages(missing, repos = &amp;quot;https://cloud.r-project.org&amp;quot;)
}
# Load libraries
library(tidyverse)
library(BMS)
library(glmnet)
library(WALS)
library(scales)
library(patchwork)
library(ggrepel)
library(corrplot)
library(broom)
&lt;/code>&lt;/pre>
&lt;h2 id="3-the-synthetic-dataset">3. The Synthetic Dataset&lt;/h2>
&lt;h3 id="31-the-data-generating-process-our-answer-key">3.1 The data-generating process (our &amp;ldquo;answer key&amp;rdquo;)&lt;/h3>
&lt;p>We use a cross-sectional dataset of 120 fictional countries. The key design choices:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>7 variables have true nonzero effects&lt;/strong> on CO&lt;sub>2&lt;/sub> emissions&lt;/li>
&lt;li>&lt;strong>5 variables are pure noise&lt;/strong> (their true coefficients are exactly zero)&lt;/li>
&lt;li>The noise variables are &lt;strong>correlated with GDP and other true predictors&lt;/strong>, creating realistic multicollinearity. This makes variable selection genuinely challenging &amp;mdash; naive OLS will find spurious &amp;ldquo;significant&amp;rdquo; results for noise variables.&lt;/li>
&lt;/ul>
&lt;p>Think of this as setting up a controlled experiment. We know the answer before we begin, so we can grade each method&amp;rsquo;s performance.&lt;/p>
&lt;p>The data-generating process below shows exactly how the synthetic dataset was built. The CSV file &lt;code>synthetic-co2-cross-section.csv&lt;/code> was generated with &lt;code>set.seed(2017)&lt;/code> and can be loaded directly from GitHub for full reproducibility.&lt;/p>
&lt;pre>&lt;code class="language-r"># --- DATA-GENERATING PROCESS (reference) ---
set.seed(2017)
n &amp;lt;- 120 # number of &amp;quot;countries&amp;quot;
# GDP drives many other variables (realistic: richer countries
# have higher urbanization, more industry, etc.)
log_gdp &amp;lt;- rnorm(n, mean = 8.5, sd = 1.5)
# --- TRUE PREDICTORS (correlated with GDP) ---
fossil_fuel &amp;lt;- 30 + 3 * log_gdp + rnorm(n, 0, 10) # higher in richer countries
urban_pop &amp;lt;- 20 + 5 * log_gdp + rnorm(n, 0, 12) # increases with income
industry &amp;lt;- 15 + 1.5 * log_gdp + rnorm(n, 0, 6) # industry share
democracy &amp;lt;- 5 + 2 * log_gdp + rnorm(n, 0, 8) # democracy index
trade_network &amp;lt;- 0.2 + 0.05 * log_gdp + rnorm(n, 0, 0.15) # trade centrality
agriculture &amp;lt;- 40 - 3 * log_gdp + rnorm(n, 0, 8) # negatively correlated with GDP
# --- NOISE VARIABLES (correlated with GDP but NO true effect) ---
log_trade &amp;lt;- 3.5 + 0.1 * log_gdp + rnorm(n, 0, 0.5)
fdi &amp;lt;- 2 + rnorm(n, 0, 4)
corruption &amp;lt;- 0.8 - 0.05 * log_gdp + rnorm(n, 0, 0.15)
log_tourism &amp;lt;- 12 + 0.3 * log_gdp + rnorm(n, 0, 1.2)
log_credit &amp;lt;- 2.5 + 0.15 * log_gdp + rnorm(n, 0, 0.6)
# --- TRUE DATA-GENERATING PROCESS ---
log_co2 &amp;lt;- 2.0 + # intercept
1.200 * log_gdp + # GDP: strong positive (elasticity)
0.008 * industry + # industry: positive
0.012 * fossil_fuel + # fossil fuel: positive
0.010 * urban_pop + # urbanization: positive
0.004 * democracy + # democracy: small positive
0.500 * trade_network + # trade network: moderate positive
0.005 * agriculture + # agriculture: weak positive
# NOISE VARIABLES HAVE ZERO TRUE EFFECT
rnorm(n, 0, 0.3) # random noise (sigma = 0.3)
&lt;/code>&lt;/pre>
&lt;p>The true coefficients serve as our &amp;ldquo;answer key&amp;rdquo;:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Variable&lt;/th>
&lt;th style="text-align:left">True $\beta$&lt;/th>
&lt;th style="text-align:left">Role&lt;/th>
&lt;th style="text-align:left">Interpretation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">log_gdp&lt;/td>
&lt;td style="text-align:left">1.200&lt;/td>
&lt;td style="text-align:left">True predictor&lt;/td>
&lt;td style="text-align:left">1% more GDP $\to$ 1.2% more CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">trade_network&lt;/td>
&lt;td style="text-align:left">0.500&lt;/td>
&lt;td style="text-align:left">True predictor&lt;/td>
&lt;td style="text-align:left">Moderate positive effect&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">fossil_fuel&lt;/td>
&lt;td style="text-align:left">0.012&lt;/td>
&lt;td style="text-align:left">True predictor&lt;/td>
&lt;td style="text-align:left">1 pp more fossil fuel $\to$ 1.2% more CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">urban_pop&lt;/td>
&lt;td style="text-align:left">0.010&lt;/td>
&lt;td style="text-align:left">True predictor&lt;/td>
&lt;td style="text-align:left">1 pp more urbanization $\to$ 1.0% more CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">industry&lt;/td>
&lt;td style="text-align:left">0.008&lt;/td>
&lt;td style="text-align:left">True predictor&lt;/td>
&lt;td style="text-align:left">Positive composition effect&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">agriculture&lt;/td>
&lt;td style="text-align:left">0.005&lt;/td>
&lt;td style="text-align:left">True predictor&lt;/td>
&lt;td style="text-align:left">Weak positive effect&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">democracy&lt;/td>
&lt;td style="text-align:left">0.004&lt;/td>
&lt;td style="text-align:left">True predictor&lt;/td>
&lt;td style="text-align:left">Small positive effect&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">log_trade&lt;/td>
&lt;td style="text-align:left">0&lt;/td>
&lt;td style="text-align:left">Noise&lt;/td>
&lt;td style="text-align:left">No true effect&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">fdi&lt;/td>
&lt;td style="text-align:left">0&lt;/td>
&lt;td style="text-align:left">Noise&lt;/td>
&lt;td style="text-align:left">No true effect&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">corruption&lt;/td>
&lt;td style="text-align:left">0&lt;/td>
&lt;td style="text-align:left">Noise&lt;/td>
&lt;td style="text-align:left">No true effect&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">log_tourism&lt;/td>
&lt;td style="text-align:left">0&lt;/td>
&lt;td style="text-align:left">Noise&lt;/td>
&lt;td style="text-align:left">No true effect&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">log_credit&lt;/td>
&lt;td style="text-align:left">0&lt;/td>
&lt;td style="text-align:left">Noise&lt;/td>
&lt;td style="text-align:left">No true effect&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Now let us load the pre-generated dataset:&lt;/p>
&lt;pre>&lt;code class="language-r"># Load the synthetic dataset directly from GitHub
DATA_URL &amp;lt;- &amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/master/content/post/r_bma_lasso_wals/synthetic-co2-cross-section.csv&amp;quot;
synth_data &amp;lt;- read.csv(DATA_URL)
cat(&amp;quot;Dataset:&amp;quot;, nrow(synth_data), &amp;quot;countries,&amp;quot;, ncol(synth_data), &amp;quot;variables\n&amp;quot;)
head(synth_data)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Dataset: 120 countries, 14 variables
country log_co2 log_gdp industry fossil_fuel urban_pop democracy trade_network
1 Country_001 13.27 9.47 29.25 66.94 67.97 25.67 0.77
2 Country_002 12.18 8.44 24.97 51.43 66.14 20.51 0.85
3 Country_003 13.50 10.16 28.19 50.62 73.91 29.08 0.73
...
&lt;/code>&lt;/pre>
&lt;h3 id="32-descriptive-statistics">3.2 Descriptive statistics&lt;/h3>
&lt;p>The following summary statistics give us a first look at the data structure. Note the wide range of scales: GDP is in log units (mean around 8.5), while percentage variables like fossil fuel share and urbanization range from single digits to near 100.&lt;/p>
&lt;pre>&lt;code class="language-r"># Descriptive statistics for all 13 numeric variables
synth_data |&amp;gt;
select(-country) |&amp;gt;
pivot_longer(everything(), names_to = &amp;quot;variable&amp;quot;, values_to = &amp;quot;value&amp;quot;) |&amp;gt;
summarise(
n = n(),
mean = round(mean(value), 2),
sd = round(sd(value), 2),
min = round(min(value), 2),
max = round(max(value), 2),
.by = variable
)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> variable n mean sd min max
log_co2 120 14.22 2.11 8.76 20.36
log_gdp 120 8.53 1.57 4.61 13.21
industry 120 27.87 6.21 8.32 44.98
fossil_fuel 120 55.49 9.62 24.72 81.22
urban_pop 120 62.52 13.25 29.81 97.62
democracy 120 22.94 8.32 3.10 45.00
trade_network 120 0.64 0.17 0.18 1.04
agriculture 120 13.87 8.11 1.00 37.11
log_trade 120 4.43 0.46 3.45 5.84
fdi 120 2.23 4.19 -5.00 13.62
corruption 120 0.37 0.16 0.05 0.71
log_tourism 120 14.61 1.32 11.54 19.63
log_credit 120 3.83 0.65 2.30 5.50
&lt;/code>&lt;/pre>
&lt;p>The dataset has 120 observations and 14 variables (1 dependent, 12 candidate regressors, 1 country identifier). The dependent variable &lt;code>log_co2&lt;/code> has a mean of 14.22 with a standard deviation of 2.11 log points, reflecting substantial cross-country variation in emissions. The candidate regressors span very different scales &amp;mdash; trade_network ranges from 0.18 to 1.04, while urban_pop ranges from 29.8 to 97.6 &amp;mdash; which is why BMA, LASSO, and WALS each handle scaling internally.&lt;/p>
&lt;h3 id="33-correlation-structure">3.3 Correlation structure&lt;/h3>
&lt;p>A key feature of our synthetic data is that the noise variables are correlated with the true predictors &amp;mdash; especially with GDP. This correlation is what makes variable selection difficult: in a standard OLS regression, the noise variables will &amp;ldquo;borrow&amp;rdquo; explanatory power from the true predictors.&lt;/p>
&lt;pre>&lt;code class="language-r"># Compute correlation matrix for all 12 candidate regressors
cor_matrix &amp;lt;- synth_data |&amp;gt;
select(-country, -log_co2) |&amp;gt;
cor()
# Draw the heatmap
corrplot(cor_matrix, method = &amp;quot;color&amp;quot;, type = &amp;quot;lower&amp;quot;,
addCoef.col = &amp;quot;black&amp;quot;, number.cex = 0.7,
col = colorRampPalette(c(&amp;quot;#d97757&amp;quot;, &amp;quot;white&amp;quot;, &amp;quot;#6a9bcc&amp;quot;))(200),
diag = FALSE)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="bma_lasso_wals_01_correlation.png" alt="Correlation matrix heatmap showing that noise variables like trade openness, tourism, and credit are correlated with GDP and other true predictors, creating the multicollinearity that makes variable selection challenging.">&lt;/p>
&lt;p>The correlation heatmap reveals the realistic structure we built into the data. GDP is positively correlated with fossil fuel use, urbanization, industry, and the trade network &amp;mdash; but also with the noise variables like trade openness, tourism, and credit. This multicollinearity is precisely what makes a naive &amp;ldquo;throw everything into OLS&amp;rdquo; approach unreliable. For example, log_tourism has a correlation of approximately 0.3 with log_gdp, which means it can pick up GDP&amp;rsquo;s signal even though its true effect is zero.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Note.&lt;/strong> We created a synthetic dataset where we &lt;em>know&lt;/em> which 7 variables truly affect CO&lt;sub>2&lt;/sub> emissions and which 5 are noise. The noise variables are deliberately correlated with the true predictors, mimicking the multicollinearity found in real cross-country data.&lt;/p>
&lt;/blockquote>
&lt;h2 id="4-the-general-model">4. The General Model&lt;/h2>
&lt;p>Our goal is to estimate the following linear model:&lt;/p>
&lt;p>$$
\log(\text{CO}_{2,i}) = \beta_0 + \sum_{j=1}^{12} \beta_j x_{j,i} + \varepsilon_i
$$&lt;/p>
&lt;p>where:&lt;/p>
&lt;ul>
&lt;li>$\log(\text{CO}_{2,i})$ is the log of CO&lt;sub>2&lt;/sub> emissions for country $i$&lt;/li>
&lt;li>$\beta_0$ is the &lt;strong>intercept&lt;/strong> (the predicted log CO&lt;sub>2&lt;/sub> when all regressors are zero)&lt;/li>
&lt;li>$\beta_j$ is the &lt;strong>coefficient&lt;/strong> on the $j$-th regressor: the change in log CO&lt;sub>2&lt;/sub> associated with a one-unit increase in $x_j$, holding all other variables constant&lt;/li>
&lt;li>$\varepsilon_i$ is the &lt;strong>error term&lt;/strong>: everything that affects CO&lt;sub>2&lt;/sub> emissions but is not captured by the 12 regressors&lt;/li>
&lt;/ul>
&lt;p>Because the dependent variable is in logs, the interpretation of each coefficient depends on whether the regressor is also in logs:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Regressor type&lt;/th>
&lt;th style="text-align:left">Interpretation of $\beta_j$&lt;/th>
&lt;th style="text-align:left">Example&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">Log-log (e.g., log GDP)&lt;/td>
&lt;td style="text-align:left">&lt;strong>Elasticity&lt;/strong>: a 1% increase in GDP is associated with a $\beta_j$% change in CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;td style="text-align:left">$\beta = 1.2$ means 1% more GDP $\to$ 1.2% more CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">Level-log (e.g., fossil fuel %)&lt;/td>
&lt;td style="text-align:left">&lt;strong>Semi-elasticity&lt;/strong>: a 1-unit increase in the regressor is associated with a $100 \times \beta_j$% change in CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;td style="text-align:left">$\beta = 0.012$ means 1 pp more fossil fuel $\to$ 1.2% more CO&lt;sub>2&lt;/sub>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>We want to determine &lt;strong>which $\beta_j$ are truly nonzero&lt;/strong>. We know the answer (we designed the data), but let us first see what happens if we just run OLS with all 12 variables.&lt;/p>
&lt;pre>&lt;code class="language-r"># Run OLS with all 12 candidate regressors
ols_full &amp;lt;- lm(log_co2 ~ log_gdp + industry + fossil_fuel + urban_pop +
democracy + trade_network + agriculture +
log_trade + fdi + corruption + log_tourism + log_credit,
data = synth_data)
# Display summary
summary(ols_full)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Coefficients:
Estimate Std. Error t value Pr(&amp;gt;|t|)
(Intercept) 2.283773 0.494736 4.616 1.06e-05 ***
log_gdp 1.163669 0.032747 35.537 &amp;lt; 2e-16 ***
industry 0.017577 0.005004 3.513 0.000661 ***
fossil_fuel 0.011988 0.003240 3.698 0.000349 ***
urban_pop 0.008221 0.002689 3.057 0.002794 **
democracy 0.010497 0.003975 2.640 0.009549 **
trade_network 0.912828 0.203681 4.482 1.94e-05 ***
agriculture -0.000629 0.004242 -0.148 0.882568
log_trade -0.055738 0.064829 -0.860 0.391509
fdi 0.000789 0.007045 0.112 0.910964
corruption 0.010767 0.201954 0.053 0.957573
log_tourism -0.028025 0.024415 -1.148 0.253610
log_credit 0.045689 0.049690 0.919 0.360252
---
Multiple R-squared: 0.9801, Adjusted R-squared: 0.9779
&lt;/code>&lt;/pre>
&lt;p>Look carefully at the noise variables. For example, log_trade has a t-statistic of $-0.86$ (p = 0.392) and corruption has a t-statistic of $0.05$ (p = 0.958). None reach conventional significance in this sample. However, their estimated coefficients can be non-negligible in magnitude &amp;mdash; and in a different random sample, some noise variables could easily cross the 5% threshold. This is the risk of &lt;strong>spurious significance&lt;/strong>, caused by the correlation between noise variables and the true predictors. It is precisely this problem that motivates the three methods we study next.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Warning.&lt;/strong> With 12 correlated regressors and only 120 observations, OLS can produce misleading significance levels. A variable with a true coefficient of zero may appear significant simply because it is correlated with a genuinely important predictor. This is why we need principled variable selection methods.&lt;/p>
&lt;/blockquote>
&lt;div style="background: linear-gradient(135deg, #6a9bcc 0%, #00d4c8 100%); padding: 1.5em 2em; border-radius: 8px; margin: 2em 0; color: #fff; font-size: 1.3em; font-weight: 600;">
PART 1: Bayesian Model Averaging
&lt;/div>
&lt;h2 id="5-bayes-rule-----the-foundation">5. Bayes' Rule &amp;mdash; The Foundation&lt;/h2>
&lt;p>Before we can understand Bayesian Model Averaging, we need to understand &lt;strong>Bayes' rule&lt;/strong> &amp;mdash; the mathematical machinery that powers the entire framework.&lt;/p>
&lt;h3 id="51-a-coin-flip-example">5.1 A coin-flip example&lt;/h3>
&lt;p>Suppose a friend gives you a coin. You want to know: &lt;strong>is this coin fair&lt;/strong> (probability of heads = 0.5), or is it &lt;strong>biased&lt;/strong> (probability of heads = 0.7)?&lt;/p>
&lt;p>Before flipping, you have no strong opinion. You assign equal &lt;strong>prior probabilities&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>$P(\text{fair}) = 0.5$ (50% chance the coin is fair)&lt;/li>
&lt;li>$P(\text{biased}) = 0.5$ (50% chance the coin is biased)&lt;/li>
&lt;/ul>
&lt;p>Now you flip the coin 10 times and observe &lt;strong>7 heads&lt;/strong>. How should you update your beliefs?&lt;/p>
&lt;p>The &lt;strong>likelihood&lt;/strong> of seeing 7 heads in 10 flips is:&lt;/p>
&lt;ul>
&lt;li>If the coin is fair ($p = 0.5$): $P(\text{7 heads} | \text{fair}) = \binom{10}{7} (0.5)^{10} = 0.1172$&lt;/li>
&lt;li>If the coin is biased ($p = 0.7$): $P(\text{7 heads} | \text{biased}) = \binom{10}{7} (0.7)^7 (0.3)^3 = 0.2668$&lt;/li>
&lt;/ul>
&lt;p>The biased coin makes the data more likely. Bayes' rule combines the prior and the likelihood:&lt;/p>
&lt;p>$$
P(H|D) = \frac{P(D|H) \cdot P(H)}{P(D)}
$$&lt;/p>
&lt;p>where:&lt;/p>
&lt;ul>
&lt;li>$P(H|D)$ = &lt;strong>posterior probability&lt;/strong> (what we believe &lt;em>after&lt;/em> seeing the data)&lt;/li>
&lt;li>$P(D|H)$ = &lt;strong>likelihood&lt;/strong> (how probable the data is under hypothesis $H$)&lt;/li>
&lt;li>$P(H)$ = &lt;strong>prior probability&lt;/strong> (what we believed &lt;em>before&lt;/em> seeing the data)&lt;/li>
&lt;li>$P(D)$ = &lt;strong>marginal likelihood&lt;/strong> (a normalizing constant that ensures probabilities sum to 1)&lt;/li>
&lt;/ul>
&lt;p>For our coin:&lt;/p>
&lt;p>$$
P(\text{fair}|\text{7H}) = \frac{0.1172 \times 0.5}{0.1172 \times 0.5 + 0.2668 \times 0.5} = \frac{0.0586}{0.1920} = 0.305
$$&lt;/p>
&lt;p>$$
P(\text{biased}|\text{7H}) = \frac{0.2668 \times 0.5}{0.1920} = 0.695
$$&lt;/p>
&lt;p>After seeing 7 heads, we update from 50&amp;ndash;50 to roughly 30&amp;ndash;70 in favor of the biased coin. &lt;strong>The data shifted our beliefs, but did not erase the prior entirely.&lt;/strong>&lt;/p>
&lt;h3 id="52-the-bridge-to-model-averaging">5.2 The bridge to model averaging&lt;/h3>
&lt;p>Now replace &amp;ldquo;fair coin&amp;rdquo; and &amp;ldquo;biased coin&amp;rdquo; with &lt;em>regression models&lt;/em>:&lt;/p>
&lt;ul>
&lt;li>Hypothesis = &amp;ldquo;Which variables belong in the model?&amp;rdquo;&lt;/li>
&lt;li>Prior = &amp;ldquo;Before seeing data, any combination of variables is equally plausible&amp;rdquo;&lt;/li>
&lt;li>Likelihood = &amp;ldquo;How well does each model fit the data?&amp;rdquo;&lt;/li>
&lt;li>Posterior = &amp;ldquo;After seeing data, which models are most credible?&amp;rdquo;&lt;/li>
&lt;/ul>
&lt;p>This is exactly what BMA does. Instead of two coin hypotheses, we have 4,096 model hypotheses &amp;mdash; but the logic of Bayes' rule is identical.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Note.&lt;/strong> Bayes' rule updates prior beliefs using data. The posterior probability of any hypothesis is proportional to its prior probability times its likelihood. BMA applies this same logic to regression models instead of coin flips.&lt;/p>
&lt;/blockquote>
&lt;h2 id="6-the-bma-framework">6. The BMA Framework&lt;/h2>
&lt;h3 id="61-posterior-model-probability">6.1 Posterior model probability&lt;/h3>
&lt;p>With 12 candidate variables, there are $K = 12$ regressors and $2^K = 4,096$ possible models. Denote the $k$-th model as $M_k$. BMA assigns each model a &lt;strong>posterior probability&lt;/strong>:&lt;/p>
&lt;p>$$
P(M_k | y) = \frac{P(y | M_k) \cdot P(M_k)}{\sum_{l=1}^{2^K} P(y | M_l) \cdot P(M_l)}
$$&lt;/p>
&lt;p>This is just Bayes' rule applied to models. Let us unpack each piece:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;strong>$P(y | M_k)$&lt;/strong> is the &lt;strong>marginal likelihood&lt;/strong> of model $M_k$. It measures how well the model fits the data, &lt;em>automatically penalizing complexity&lt;/em>. A model with many parameters can fit the data closely, but the marginal likelihood integrates over all possible parameter values, spreading the probability thin. This acts as a built-in &lt;strong>Occam&amp;rsquo;s razor&lt;/strong>: simpler models that fit the data well receive higher marginal likelihoods than complex models that fit only slightly better.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>$P(M_k)$&lt;/strong> is the &lt;strong>prior model probability&lt;/strong>. With no prior information, we use a &lt;strong>uniform prior&lt;/strong>: every model is equally likely, so $P(M_k) = 1/4,096$ for all $k$. This means the posterior is driven entirely by the data.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>The &lt;strong>denominator&lt;/strong> is a normalizing constant that ensures all posterior model probabilities sum to 1.&lt;/p>
&lt;/li>
&lt;/ul>
&lt;h3 id="62-posterior-inclusion-probability-pip">6.2 Posterior Inclusion Probability (PIP)&lt;/h3>
&lt;p>We do not really care about individual models &amp;mdash; we care about individual &lt;em>variables&lt;/em>. The &lt;strong>Posterior Inclusion Probability&lt;/strong> of variable $j$ is the sum of the posterior probabilities of all models that include variable $j$:&lt;/p>
&lt;p>$$
\text{PIP}_j = \sum_{k:\, j \in M_k} P(M_k | y)
$$&lt;/p>
&lt;p>Think of it as a &lt;strong>democratic vote&lt;/strong>. Each of the 4,096 models casts a vote for which variables matter. But the votes are &lt;em>weighted&lt;/em>: models that fit the data well get louder voices. If variable $j$ appears in most of the high-probability models, it earns a high PIP.&lt;/p>
&lt;p>The standard interpretation thresholds (Raftery, 1995):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">PIP range&lt;/th>
&lt;th style="text-align:left">Interpretation&lt;/th>
&lt;th style="text-align:left">Analogy&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">$\geq 0.99$&lt;/td>
&lt;td style="text-align:left">Decisive evidence&lt;/td>
&lt;td style="text-align:left">Beyond reasonable doubt&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">$0.95 - 0.99$&lt;/td>
&lt;td style="text-align:left">Very strong evidence&lt;/td>
&lt;td style="text-align:left">Strong consensus&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">$0.80 - 0.95$&lt;/td>
&lt;td style="text-align:left">Strong evidence (robust)&lt;/td>
&lt;td style="text-align:left">Clear majority&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">$0.50 - 0.80$&lt;/td>
&lt;td style="text-align:left">Borderline evidence&lt;/td>
&lt;td style="text-align:left">Split vote&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">$&amp;lt; 0.50$&lt;/td>
&lt;td style="text-align:left">Weak/no evidence (fragile)&lt;/td>
&lt;td style="text-align:left">Minority opinion&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>We will use &lt;strong>PIP $\geq$ 0.80&lt;/strong> as our threshold for &amp;ldquo;robust&amp;rdquo; throughout this tutorial.&lt;/p>
&lt;h3 id="63-posterior-mean">6.3 Posterior mean&lt;/h3>
&lt;p>Once we know which variables matter, we want to know &lt;em>how much&lt;/em> they matter. The &lt;strong>posterior mean&lt;/strong> of coefficient $j$ is:&lt;/p>
&lt;p>$$
E[\beta_j | y] = \sum_{k=1}^{2^K} \hat{\beta}_{j,k} \cdot P(M_k | y)
$$&lt;/p>
&lt;p>where $\hat{\beta}_{j,k}$ is the estimated coefficient of variable $j$ in model $k$ (and zero if $j$ is not in model $k$). This is a weighted average of the coefficient across all models. Variables with high PIPs get posterior means close to their &amp;ldquo;full model&amp;rdquo; estimates; variables with low PIPs get posterior means shrunk toward zero.&lt;/p>
&lt;h2 id="7-toy-example-----bma-on-3-variables">7. Toy Example &amp;mdash; BMA on 3 Variables&lt;/h2>
&lt;p>Before running BMA on all 12 variables, let us work through a small example by hand. We pick just 3 variables: &lt;strong>log_gdp&lt;/strong> and &lt;strong>fossil_fuel&lt;/strong> (true predictors) and &lt;strong>log_trade&lt;/strong> (noise). With 3 variables, each can be either IN or OUT of the model, giving us $2^3 = 8$ possible models &amp;mdash; small enough to examine every single one.&lt;/p>
&lt;p>Here are all 8 models written out explicitly:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Model&lt;/th>
&lt;th style="text-align:left">Formula&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">$M_1$&lt;/td>
&lt;td style="text-align:left">log_co2 $\sim$ 1 (intercept only)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">$M_2$&lt;/td>
&lt;td style="text-align:left">log_co2 $\sim$ log_gdp&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">$M_3$&lt;/td>
&lt;td style="text-align:left">log_co2 $\sim$ fossil_fuel&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">$M_4$&lt;/td>
&lt;td style="text-align:left">log_co2 $\sim$ log_trade&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">$M_5$&lt;/td>
&lt;td style="text-align:left">log_co2 $\sim$ log_gdp + fossil_fuel&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">$M_6$&lt;/td>
&lt;td style="text-align:left">log_co2 $\sim$ log_gdp + log_trade&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">$M_7$&lt;/td>
&lt;td style="text-align:left">log_co2 $\sim$ fossil_fuel + log_trade&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">$M_8$&lt;/td>
&lt;td style="text-align:left">log_co2 $\sim$ log_gdp + fossil_fuel + log_trade&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="71-step-1-----fit-every-model-and-compute-bic">7.1 Step 1 &amp;mdash; Fit every model and compute BIC&lt;/h3>
&lt;p>We fit each of the 8 models using OLS and compute its BIC score. Remember: &lt;strong>lower BIC = better&lt;/strong> (the model explains the data well without unnecessary complexity).&lt;/p>
&lt;pre>&lt;code class="language-r"># Select our 3 variables
toy_data &amp;lt;- synth_data |&amp;gt;
select(log_co2, log_gdp, fossil_fuel, log_trade)
# Write out all 8 model formulas explicitly
model_formulas &amp;lt;- c(
&amp;quot;log_co2 ~ 1&amp;quot;, # M1: intercept only
&amp;quot;log_co2 ~ log_gdp&amp;quot;, # M2
&amp;quot;log_co2 ~ fossil_fuel&amp;quot;, # M3
&amp;quot;log_co2 ~ log_trade&amp;quot;, # M4
&amp;quot;log_co2 ~ log_gdp + fossil_fuel&amp;quot;, # M5
&amp;quot;log_co2 ~ log_gdp + log_trade&amp;quot;, # M6
&amp;quot;log_co2 ~ fossil_fuel + log_trade&amp;quot;, # M7
&amp;quot;log_co2 ~ log_gdp + fossil_fuel + log_trade&amp;quot; # M8
)
# Fit each model and extract its BIC
bic_values &amp;lt;- sapply(model_formulas, function(f) {
BIC(lm(as.formula(f), data = toy_data))
})
# Organize results in a table
toy_results &amp;lt;- tibble(
model = paste0(&amp;quot;M&amp;quot;, 1:8),
formula = model_formulas,
bic = round(bic_values, 1)
) |&amp;gt;
arrange(bic)
print(toy_results)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> model formula bic
M5 log_co2 ~ log_gdp + fossil_fuel 114.1
M8 log_co2 ~ log_gdp + fossil_fuel + log_trade 118.5
M2 log_co2 ~ log_gdp 120.7
M6 log_co2 ~ log_gdp + log_trade 125.4
M3 log_co2 ~ fossil_fuel 514.4
M7 log_co2 ~ fossil_fuel + log_trade 519.0
M1 log_co2 ~ 1 528.3
M4 log_co2 ~ log_trade 533.0
&lt;/code>&lt;/pre>
&lt;p>The winner is $M_5$ (log_gdp + fossil_fuel) with BIC = 114.1 &amp;mdash; exactly the two true predictors, no noise. The runner-up $M_8$ adds log_trade but its BIC is worse (118.5), meaning the extra variable does not improve the fit enough to justify the added complexity. Models without GDP ($M_1$, $M_3$, $M_4$, $M_7$) have dramatically worse BIC scores, confirming GDP&amp;rsquo;s dominant role.&lt;/p>
&lt;h3 id="72-step-2-----convert-bic-to-posterior-probabilities">7.2 Step 2 &amp;mdash; Convert BIC to posterior probabilities&lt;/h3>
&lt;p>Now we turn each BIC into a posterior model probability. The formula is:&lt;/p>
&lt;p>$$
P(M_k | y) = \frac{\exp(-0.5 \cdot \text{BIC}_k)}{\sum_{l=1}^{8} \exp(-0.5 \cdot \text{BIC}_l)}
$$&lt;/p>
&lt;p>Because the BIC values can be very large, we work with &lt;strong>differences from the best model&lt;/strong> to avoid numerical overflow. Subtracting the minimum BIC from all values does not change the probabilities:&lt;/p>
&lt;p>$$
P(M_k | y) = \frac{\exp\bigl(-0.5 \cdot (\text{BIC}_k - \text{BIC}_{\min})\bigr)}{\sum_{l=1}^{8} \exp\bigl(-0.5 \cdot (\text{BIC}_l - \text{BIC}_{\min})\bigr)}
$$&lt;/p>
&lt;p>Let us plug in the numbers. The best model ($M_5$) has BIC = 114.1, so $\Delta_5 = 0$. The runner-up ($M_8$) has $\Delta_8 = 118.5 - 114.1 = 4.4$:&lt;/p>
&lt;p>$$
w_5 = \exp(-0.5 \times 0) = 1.000, \quad w_8 = \exp(-0.5 \times 4.4) = 0.111
$$&lt;/p>
&lt;p>The remaining models have much larger $\Delta$ values, so their weights are essentially zero. After normalizing by the sum of all weights ($1.000 + 0.111 + 0.037 + \ldots \approx 1.151$):&lt;/p>
&lt;p>$$
P(M_5 | y) = \frac{1.000}{1.151} = 0.869, \quad P(M_8 | y) = \frac{0.111}{1.151} = 0.096
$$&lt;/p>
&lt;pre>&lt;code class="language-r"># Convert BIC to posterior probabilities using the delta-BIC trick
toy_results &amp;lt;- toy_results |&amp;gt;
mutate(
delta_bic = bic - min(bic), # difference from best
weight = exp(-0.5 * delta_bic), # unnormalized weight
post_prob = round(weight / sum(weight), 4) # normalize to sum to 1
)
toy_results |&amp;gt; select(model, bic, delta_bic, weight, post_prob)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> model bic delta_bic weight post_prob
M5 114.1 0.0 1.0000 0.8687
M8 118.5 4.4 0.1108 0.0962
M2 120.7 6.6 0.0369 0.0320
M6 125.4 11.3 0.0035 0.0031
M3 514.4 400.3 0.0000 0.0000
M7 519.0 404.9 0.0000 0.0000
M1 528.3 414.2 0.0000 0.0000
M4 533.0 418.9 0.0000 0.0000
&lt;/code>&lt;/pre>
&lt;p>One model dominates: $M_5$ captures 86.9% of the posterior probability &amp;mdash; exactly the two true predictors. The runner-up $M_8$ (adding log_trade) gets only 9.6%, and $M_2$ (GDP alone) gets 3.2%. The remaining 5 models share less than 0.4% of the total weight. BMA&amp;rsquo;s Occam&amp;rsquo;s razor is at work: adding log_trade to the model ($M_8$) does not improve the fit enough to overcome the complexity penalty, so the simpler model ($M_5$) wins decisively.&lt;/p>
&lt;h3 id="73-step-3-----compute-posterior-inclusion-probabilities">7.3 Step 3 &amp;mdash; Compute Posterior Inclusion Probabilities&lt;/h3>
&lt;p>Finally, we compute the PIP of each variable by summing the posterior probabilities of all models that include it. For example, log_trade appears in models $M_4$, $M_6$, $M_7$, and $M_8$, so:&lt;/p>
&lt;p>$$
\text{PIP}_{\text{log_trade}} = P(M_4 | y) + P(M_6 | y) + P(M_7 | y) + P(M_8 | y) = 0.000 + 0.003 + 0.000 + 0.096 = 0.099
$$&lt;/p>
&lt;p>That is well below the 0.50 threshold &amp;mdash; fragile evidence, exactly what we expect for a noise variable.&lt;/p>
&lt;pre>&lt;code class="language-r"># Compute PIPs: for each variable, sum P(M|y) across models that include it
pip_toy &amp;lt;- tibble(
variable = c(&amp;quot;log_gdp&amp;quot;, &amp;quot;fossil_fuel&amp;quot;, &amp;quot;log_trade&amp;quot;),
true_effect = c(&amp;quot;True&amp;quot;, &amp;quot;True&amp;quot;, &amp;quot;Noise&amp;quot;),
pip = c(
# log_gdp appears in M2, M5, M6, M8
sum(toy_results$post_prob[toy_results$model %in% c(&amp;quot;M2&amp;quot;,&amp;quot;M5&amp;quot;,&amp;quot;M6&amp;quot;,&amp;quot;M8&amp;quot;)]),
# fossil_fuel appears in M3, M5, M7, M8
sum(toy_results$post_prob[toy_results$model %in% c(&amp;quot;M3&amp;quot;,&amp;quot;M5&amp;quot;,&amp;quot;M7&amp;quot;,&amp;quot;M8&amp;quot;)]),
# log_trade appears in M4, M6, M7, M8
sum(toy_results$post_prob[toy_results$model %in% c(&amp;quot;M4&amp;quot;,&amp;quot;M6&amp;quot;,&amp;quot;M7&amp;quot;,&amp;quot;M8&amp;quot;)])
)
)
print(pip_toy)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> variable true_effect pip
log_gdp True 1.000
fossil_fuel True 0.965
log_trade Noise 0.099
&lt;/code>&lt;/pre>
&lt;p>Even with this simple 3-variable example, BMA correctly identifies the two true predictors. GDP has a PIP of 1.000 (decisive evidence) and fossil_fuel has a PIP of 0.965 (robust) &amp;mdash; they appear in every high-probability model. Log_trade has a PIP of only 0.099 (fragile) &amp;mdash; well below the 0.50 threshold. BMA&amp;rsquo;s built-in Occam&amp;rsquo;s razor penalizes models that include noise variables without substantially improving the fit.&lt;/p>
&lt;h2 id="8-bma-on-all-12-variables">8. BMA on All 12 Variables&lt;/h2>
&lt;h3 id="81-running-bma">8.1 Running BMA&lt;/h3>
&lt;p>Now we apply BMA to the full dataset with all 12 candidate regressors using the &lt;code>BMS&lt;/code> package. Because 4,096 models is computationally manageable, the MCMC sampler explores the full model space efficiently.&lt;/p>
&lt;pre>&lt;code class="language-r">set.seed(2021) # reproducibility for MCMC sampling
# Prepare the data matrix: DV in first column, regressors follow
bma_data &amp;lt;- synth_data |&amp;gt;
select(log_co2, log_gdp, industry, fossil_fuel, urban_pop,
democracy, trade_network, agriculture,
log_trade, fdi, corruption, log_tourism, log_credit) |&amp;gt;
as.data.frame()
# Run BMA
bma_fit &amp;lt;- bms(
X.data = bma_data, # data with DV in column 1
burn = 50000, # burn-in iterations
iter = 200000, # post-burn-in iterations
g = &amp;quot;BRIC&amp;quot;, # BRIC g-prior (robust default)
mprior = &amp;quot;uniform&amp;quot;, # uniform model prior
nmodel = 2000, # store top 2000 models
mcmc = &amp;quot;bd&amp;quot;, # birth-death MCMC sampler
user.int = FALSE # suppress interactive output
)
&lt;/code>&lt;/pre>
&lt;p>The key parameters deserve explanation:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>burn = 50,000&lt;/strong>: the first 50,000 MCMC draws are discarded as &amp;ldquo;burn-in&amp;rdquo; to ensure the sampler has converged to the posterior distribution&lt;/li>
&lt;li>&lt;strong>iter = 200,000&lt;/strong>: the next 200,000 draws are used for inference&lt;/li>
&lt;li>&lt;strong>g = &amp;ldquo;BRIC&amp;rdquo;&lt;/strong>: the Benchmark Risk Inflation Criterion prior on the regression coefficients, a robust default choice&lt;/li>
&lt;li>&lt;strong>mprior = &amp;ldquo;uniform&amp;rdquo;&lt;/strong>: every model is equally likely a priori, so the posterior is driven entirely by the data&lt;/li>
&lt;/ul>
&lt;h3 id="82-pip-bar-chart">8.2 PIP bar chart&lt;/h3>
&lt;p>The PIP bar chart classifies each variable as robust (PIP $\geq$ 0.80), borderline (0.50&amp;ndash;0.80), or fragile (PIP $&amp;lt;$ 0.50). This visualization makes it easy to see which variables earn strong support across the model space and which are effectively irrelevant.&lt;/p>
&lt;pre>&lt;code class="language-r"># Extract PIPs and posterior means
bma_coefs &amp;lt;- coef(bma_fit)
bma_df &amp;lt;- as.data.frame(bma_coefs) |&amp;gt;
rownames_to_column(&amp;quot;variable&amp;quot;) |&amp;gt;
as_tibble() |&amp;gt;
rename(pip = PIP, post_mean = `Post Mean`, post_sd = `Post SD`) |&amp;gt;
select(variable, pip, post_mean, post_sd) |&amp;gt;
mutate(
true_beta = true_beta_lookup[variable],
robustness = case_when(
pip &amp;gt;= 0.80 ~ &amp;quot;Robust (PIP &amp;gt;= 0.80)&amp;quot;,
pip &amp;gt;= 0.50 ~ &amp;quot;Borderline&amp;quot;,
TRUE ~ &amp;quot;Fragile (PIP &amp;lt; 0.50)&amp;quot;
),
ci_low = post_mean - 2 * post_sd,
ci_high = post_mean + 2 * post_sd
)
# Plot PIPs
ggplot(bma_df, aes(x = reorder(variable, pip), y = pip, fill = robustness)) +
geom_col(width = 0.65) +
geom_hline(yintercept = 0.80, linetype = &amp;quot;dashed&amp;quot;) +
coord_flip() +
labs(x = NULL, y = &amp;quot;Posterior Inclusion Probability (PIP)&amp;quot;)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="bma_lasso_wals_04_bma_pip.png" alt="BMA Posterior Inclusion Probabilities. Green bars indicate robust variables with PIP greater than or equal to 0.80; teal bars indicate borderline variables; orange bars indicate fragile variables with PIP less than 0.50.">&lt;/p>
&lt;p>The PIP bar chart reveals a clear separation between signal and noise. GDP dominates with a PIP of 1.00, followed by trade_network (0.986), fossil_fuel (0.948), and industry (0.841) &amp;mdash; all with PIPs above the 0.80 robustness threshold. The noise variables (log_trade, fdi, corruption, log_tourism, log_credit) all have PIPs well below 0.15, confirming that BMA correctly classifies them as fragile. Urban_pop ($\beta = 0.010$, PIP = 0.648) and democracy ($\beta = 0.004$, PIP = 0.607) land in the borderline range &amp;mdash; true predictors whose effects are moderate enough that BMA hedges between including and excluding them. Agriculture ($\beta = 0.005$, PIP = 0.087) is classified as fragile, an honest reflection of the sample&amp;rsquo;s limited power to detect its very small effect.&lt;/p>
&lt;h3 id="83-posterior-coefficient-plot">8.3 Posterior coefficient plot&lt;/h3>
&lt;p>Beyond knowing &lt;em>which&lt;/em> variables matter, we want to know &lt;em>how much&lt;/em> they matter and how precisely they are estimated. The posterior coefficient plot displays the BMA-estimated effect size for each variable along with approximate 95% credible intervals (posterior mean $\pm$ 2 posterior standard deviations).&lt;/p>
&lt;pre>&lt;code class="language-r"># Coefficient plot with 95% credible intervals
ggplot(bma_df, aes(x = reorder(variable, pip), y = post_mean, color = robustness)) +
geom_pointrange(aes(ymin = ci_low, ymax = ci_high)) +
geom_hline(yintercept = 0, linetype = &amp;quot;solid&amp;quot;, color = &amp;quot;gray50&amp;quot;) +
coord_flip()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="bma_lasso_wals_05_bma_coefs.png" alt="BMA posterior mean coefficients with approximate 95 percent credible intervals. Variables ordered by PIP. Robust variables have intervals that do not cross zero.">&lt;/p>
&lt;p>The posterior coefficient plot shows the BMA-estimated effect sizes with uncertainty bands. GDP&amp;rsquo;s posterior mean of approximately 1.19 closely recovers the true value of 1.200, and its 95% credible interval is narrow, reflecting high precision. Trade_network has a posterior mean of 0.87, overshooting its true value of 0.500 &amp;mdash; but its wide credible interval honestly reflects substantial estimation uncertainty. The noise variables and low-PIP variables like agriculture have posterior means shrunk very close to zero &amp;mdash; this is BMA&amp;rsquo;s shrinkage at work. Variables with low PIPs appear in few high-probability models, so their posterior means are averaged with many models where the coefficient is zero, pulling the estimate toward zero.&lt;/p>
&lt;h3 id="84-variable-inclusion-map">8.4 Variable-inclusion map&lt;/h3>
&lt;p>The variable-inclusion map shows &lt;em>which&lt;/em> variables appear in the highest-probability models and whether their coefficients are positive or negative. Unlike a simple heatmap, the &lt;strong>width of each column is proportional to the model&amp;rsquo;s posterior probability&lt;/strong> &amp;mdash; so wide columns represent models that the data strongly supports. The x-axis shows cumulative posterior model probability: if the first model has PMP = 0.15, it occupies the region from 0 to 0.15; the second model fills from 0.15 to 0.15 + its PMP, and so on. A solid band of color stretching across most of the x-axis means the variable appears in virtually every high-probability model.&lt;/p>
&lt;pre>&lt;code class="language-r"># Extract top 100 models and their coefficient estimates
top_coefs &amp;lt;- topmodels.bma(bma_fit)
n_top &amp;lt;- min(100, ncol(top_coefs))
top_coefs &amp;lt;- top_coefs[, 1:n_top]
# Extract posterior model probabilities (MCMC-based)
model_pmps &amp;lt;- pmp.bma(bma_fit)[1:n_top, 1]
# Cumulative x positions: each model's width = its PMP
cum_pmp &amp;lt;- c(0, cumsum(model_pmps))
# Order variables by PIP (highest at top)
var_order &amp;lt;- bma_df |&amp;gt; arrange(desc(pip)) |&amp;gt; pull(variable)
# Build rectangle data for every variable × model combination
rect_data &amp;lt;- expand.grid(
var_idx = seq_len(nrow(top_coefs)),
model_idx = seq_len(n_top)
) |&amp;gt;
mutate(
variable = rownames(top_coefs)[var_idx],
coef_value = mapply(function(v, m) top_coefs[v, m], var_idx, model_idx),
sign = case_when(
coef_value &amp;gt; 0 ~ &amp;quot;Positive&amp;quot;,
coef_value &amp;lt; 0 ~ &amp;quot;Negative&amp;quot;,
TRUE ~ &amp;quot;Not included&amp;quot;
),
xmin = cum_pmp[model_idx],
xmax = cum_pmp[model_idx + 1],
variable = factor(variable, levels = rev(var_order))
)
# Plot the variable-inclusion map
ggplot(rect_data, aes(xmin = xmin, xmax = xmax,
ymin = as.numeric(variable) - 0.45,
ymax = as.numeric(variable) + 0.45,
fill = sign)) +
geom_rect() +
scale_fill_manual(
name = &amp;quot;Coefficient&amp;quot;,
values = c(&amp;quot;Positive&amp;quot; = &amp;quot;#6a9bcc&amp;quot;,
&amp;quot;Negative&amp;quot; = &amp;quot;#d97757&amp;quot;,
&amp;quot;Not included&amp;quot; = &amp;quot;#d0cdc8&amp;quot;)
) +
scale_x_continuous(expand = c(0, 0),
labels = scales::label_number(accuracy = 0.1)) +
scale_y_continuous(breaks = seq_along(var_order),
labels = rev(var_order),
expand = c(0, 0)) +
labs(title = &amp;quot;Variable-Inclusion Map&amp;quot;,
subtitle = paste0(&amp;quot;Top &amp;quot;, n_top, &amp;quot; models shown out of &amp;quot;,
nrow(pmp.bma(bma_fit)), &amp;quot; visited&amp;quot;),
x = &amp;quot;Cumulative posterior model probability&amp;quot;,
y = NULL)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="bma_lasso_wals_06_bma_inclusion.png" alt="Variable-inclusion map showing the top 100 BMA models. The x-axis is cumulative posterior model probability, so wider columns represent more probable models. Blue indicates a positive coefficient, orange indicates a negative coefficient, and gray indicates the variable is not included. Variables are ordered by PIP from top to bottom.">&lt;/p>
&lt;p>The variable-inclusion map reveals clear structure. The top variables &amp;mdash; log_gdp, trade_network, fossil_fuel, and industry &amp;mdash; form solid blue bands stretching across nearly the entire x-axis, meaning they appear with positive coefficients in virtually every high-probability model. Urban_pop and democracy also show substantial inclusion, consistent with their borderline PIPs. In contrast, the noise variables (log_trade, fdi, corruption, log_tourism, log_credit) appear as mostly gray with occasional patches of blue or orange, indicating they enter and exit models sporadically and sometimes with the wrong sign. The fact that noise variables occasionally appear with negative coefficients (orange patches) is another sign of fragility &amp;mdash; their coefficient estimates are unstable because they have no true effect.&lt;/p>
&lt;h3 id="85-bma-results-vs-known-truth">8.5 BMA results vs. known truth&lt;/h3>
&lt;pre>&lt;code class="language-r"># Compare BMA results with the true DGP
bma_summary &amp;lt;- bma_df |&amp;gt;
mutate(
bma_robust = pip &amp;gt;= 0.80,
true_nonzero = true_beta != 0,
correct = bma_robust == true_nonzero
) |&amp;gt;
select(variable, true_beta, pip, post_mean, bma_robust, true_nonzero, correct)
print(bma_summary)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> variable true_beta pip post_mean bma_robust true_nonzero correct
log_gdp 1.200 1.000 1.1854 TRUE TRUE TRUE
trade_network 0.500 0.986 0.8727 TRUE TRUE TRUE
fossil_fuel 0.012 0.948 0.0117 TRUE TRUE TRUE
industry 0.008 0.841 0.0142 TRUE TRUE TRUE
urban_pop 0.010 0.648 0.0049 FALSE TRUE FALSE
democracy 0.004 0.607 0.0066 FALSE TRUE FALSE
log_tourism 0.000 0.130 -0.0039 FALSE FALSE TRUE
log_credit 0.000 0.104 0.0051 FALSE FALSE TRUE
agriculture 0.005 0.087 -0.0002 FALSE TRUE FALSE
log_trade 0.000 0.084 -0.0037 FALSE FALSE TRUE
corruption 0.000 0.078 0.0026 FALSE FALSE TRUE
fdi 0.000 0.077 -0.0000 FALSE FALSE TRUE
&lt;/code>&lt;/pre>
&lt;p>BMA correctly classifies 9 of 12 variables. The four strongest true predictors (GDP, trade_network, fossil_fuel, industry) all receive PIPs above 0.80 &amp;mdash; these are the &amp;ldquo;robust&amp;rdquo; determinants. All five noise variables receive PIPs below 0.15 &amp;mdash; correctly identified as fragile. Urban_pop (PIP = 0.648) and democracy (PIP = 0.607) fall in the borderline range &amp;mdash; they are true predictors, but BMA&amp;rsquo;s conservative Occam&amp;rsquo;s razor hedges because their effects are moderate. Agriculture ($\beta = 0.005$, PIP = 0.087) is missed entirely. This reveals an important nuance: BMA prioritizes precision over sensitivity. It would rather miss a small true effect than falsely include a noise variable.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Note.&lt;/strong> BMA on all 12 variables correctly gives high PIPs to the strong true predictors (GDP, trade network, fossil fuel, industry) and low PIPs to the noise variables. Variables with moderate or small true effects may land in the borderline zone. The variable-inclusion map shows that the top models consistently include the core predictors.&lt;/p>
&lt;/blockquote>
&lt;div style="background: linear-gradient(135deg, #d97757 0%, #d97757 100%); padding: 1.5em 2em; border-radius: 8px; margin: 2em 0; color: #fff; font-size: 1.3em; font-weight: 600;">
PART 2: LASSO
&lt;/div>
&lt;h2 id="9-regularization-----adding-a-penalty">9. Regularization &amp;mdash; Adding a Penalty&lt;/h2>
&lt;h3 id="91-the-bias-variance-tradeoff">9.1 The bias-variance tradeoff&lt;/h3>
&lt;p>OLS is an &lt;strong>unbiased&lt;/strong> estimator &amp;mdash; on average, it gets the coefficients right. But with many correlated regressors, OLS coefficients have &lt;strong>high variance&lt;/strong>: they bounce around from sample to sample. Adding or removing a single variable can drastically change the estimates.&lt;/p>
&lt;p>The key insight of regularization is that a &lt;strong>little bias can buy a lot of variance reduction&lt;/strong>, lowering the overall prediction error. The &lt;strong>total error&lt;/strong> of a prediction decomposes as:&lt;/p>
&lt;p>$$
\text{MSE} = \text{Bias}^2 + \text{Variance} + \text{Irreducible noise}
$$&lt;/p>
&lt;p>&lt;img src="bma_lasso_wals_02_bias_variance.png" alt="The bias-variance tradeoff. As model complexity increases (more variables, less regularization), bias decreases but variance increases. The optimal point is a compromise between the two, minimizing total MSE.">&lt;/p>
&lt;p>The figure illustrates the fundamental tradeoff. At low complexity (strong regularization), bias is high but variance is low. At high complexity (weak or no regularization, like OLS), bias is near zero but variance explodes. The optimal point lies in between &amp;mdash; this is exactly where regularized methods like LASSO operate. Think of the penalty as a &amp;ldquo;budget constraint&amp;rdquo; on coefficient sizes: variables that do not contribute enough to prediction are not worth the cost, so their coefficients are set to zero.&lt;/p>
&lt;h2 id="10-l1-vs-l2-geometry">10. L1 vs. L2 Geometry&lt;/h2>
&lt;h3 id="101-the-lasso-l1-penalty">10.1 The LASSO (L1) penalty&lt;/h3>
&lt;p>The LASSO solves the following optimization problem:&lt;/p>
&lt;p>$$
\hat{\beta}_{\text{LASSO}} = \arg\min_\beta \; \frac{1}{2n}\|y - X\beta\|^2 + \lambda \|\beta\|_1
$$&lt;/p>
&lt;p>where:&lt;/p>
&lt;ul>
&lt;li>$\frac{1}{2n}\|y - X\beta\|^2$ is the &lt;strong>sum of squared residuals&lt;/strong> (the usual OLS loss, scaled)&lt;/li>
&lt;li>$\|\beta\|_1 = \sum_{j=1}^{p} |\beta_j|$ is the &lt;strong>L1 norm&lt;/strong> (sum of absolute values)&lt;/li>
&lt;li>$\lambda \geq 0$ is the &lt;strong>regularization parameter&lt;/strong>: it controls how much we penalize large coefficients. When $\lambda = 0$, LASSO reduces to OLS. As $\lambda \to \infty$, all coefficients are shrunk to zero.&lt;/li>
&lt;/ul>
&lt;h3 id="102-the-ridge-l2-penalty">10.2 The Ridge (L2) penalty&lt;/h3>
&lt;p>For comparison, &lt;strong>Ridge regression&lt;/strong> uses the L2 norm instead:&lt;/p>
&lt;p>$$
\hat{\beta}_{\text{Ridge}} = \arg\min_\beta \; \frac{1}{2n}\|y - X\beta\|^2 + \lambda \|\beta\|_2^2
$$&lt;/p>
&lt;p>where $\|\beta\|_2^2 = \sum_{j=1}^{p} \beta_j^2$ is the sum of squared coefficients.&lt;/p>
&lt;h3 id="103-why-lasso-selects-variables-and-ridge-does-not">10.3 Why LASSO selects variables and Ridge does not&lt;/h3>
&lt;p>The geometric explanation is one of the most elegant ideas in modern statistics. The constraint region for LASSO (L1) is a &lt;strong>diamond&lt;/strong>, while the constraint region for Ridge (L2) is a &lt;strong>circle&lt;/strong>. When the elliptical OLS contours meet the diamond, they typically hit a &lt;strong>corner&lt;/strong>, where one or more coefficients are exactly zero. When they meet the circle, they hit a smooth curve &amp;mdash; coefficients are shrunk but never exactly zero.&lt;/p>
&lt;p>&lt;img src="bma_lasso_wals_03_l1_l2_geometry.png" alt="Side-by-side comparison of L1 and L2 constraint geometry. Left panel shows the LASSO diamond where OLS contours hit a corner, setting beta-1 to exactly zero. Right panel shows the Ridge circle where contours hit a smooth boundary, producing no exact zeros.">&lt;/p>
&lt;p>The key insight: &lt;strong>the L1 diamond has corners where coefficients are exactly zero &amp;mdash; this is why LASSO selects variables.&lt;/strong> The L2 circle has no corners, so Ridge shrinks coefficients toward zero but never reaches it. LASSO performs &lt;em>simultaneous estimation and variable selection&lt;/em>; Ridge only estimates.&lt;/p>
&lt;h2 id="11-lasso-on-all-12-variables">11. LASSO on All 12 Variables&lt;/h2>
&lt;h3 id="111-running-lasso-with-cross-validation">11.1 Running LASSO with cross-validation&lt;/h3>
&lt;p>The LASSO has one tuning parameter: $\lambda$, which controls the strength of the penalty. Too small and we include noise; too large and we exclude true predictors. We choose $\lambda$ using &lt;strong>10-fold cross-validation&lt;/strong>: split the data into 10 folds, train on 9, predict the 10th, and repeat. The $\lambda$ that minimizes the average prediction error across folds is called &lt;strong>lambda.min&lt;/strong>.&lt;/p>
&lt;pre>&lt;code class="language-r">set.seed(2021) # reproducibility for cross-validation folds
# Prepare the design matrix X and response vector y
X &amp;lt;- synth_data |&amp;gt;
select(log_gdp, industry, fossil_fuel, urban_pop, democracy,
trade_network, agriculture, log_trade, fdi, corruption,
log_tourism, log_credit) |&amp;gt;
as.matrix()
y &amp;lt;- synth_data$log_co2
# Run LASSO (alpha = 1) with 10-fold cross-validation
lasso_cv &amp;lt;- cv.glmnet(
x = X,
y = y,
alpha = 1, # alpha=1 is LASSO (alpha=0 is Ridge)
nfolds = 10,
standardize = TRUE # standardize predictors internally
)
&lt;/code>&lt;/pre>
&lt;h3 id="112-regularization-path">11.2 Regularization path&lt;/h3>
&lt;pre>&lt;code class="language-r"># Fit the full LASSO path
lasso_full &amp;lt;- glmnet(X, y, alpha = 1, standardize = TRUE)
# Plot coefficient paths
ggplot(path_df, aes(x = log_lambda, y = coefficient, color = variable)) +
geom_line() +
geom_vline(xintercept = log(lasso_cv$lambda.min), linetype = &amp;quot;dashed&amp;quot;) +
geom_vline(xintercept = log(lasso_cv$lambda.1se), linetype = &amp;quot;dotted&amp;quot;)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="bma_lasso_wals_07_lasso_path.png" alt="LASSO regularization path showing how each variable&amp;rsquo;s coefficient changes as the penalty lambda increases from left to right. Steel blue lines represent true predictors, orange lines represent noise variables. GDP (the strongest predictor) is the last to be shrunk to zero.">&lt;/p>
&lt;p>The regularization path reveals the story of LASSO variable selection. Reading from left to right (increasing penalty), the noise variables (orange lines) are the first to be driven to zero &amp;mdash; they provide too little predictive value to justify their &amp;ldquo;cost&amp;rdquo; under the penalty. GDP (the strongest predictor with $\beta = 1.200$) persists the longest, requiring the largest penalty to be eliminated. The vertical lines mark lambda.min (minimum CV error) and lambda.1se (most parsimonious model within 1 SE of the minimum). The gap between them represents the tension between fitting the data well and keeping the model simple.&lt;/p>
&lt;h3 id="113-cross-validation-curve">11.3 Cross-validation curve&lt;/h3>
&lt;pre>&lt;code class="language-r"># Plot the CV curve
ggplot(cv_df, aes(x = log_lambda, y = mse)) +
geom_ribbon(aes(ymin = mse_lo, ymax = mse_hi), fill = &amp;quot;gray85&amp;quot;, alpha = 0.5) +
geom_line(color = &amp;quot;#6a9bcc&amp;quot;) +
geom_vline(xintercept = log(lasso_cv$lambda.min), linetype = &amp;quot;dashed&amp;quot;)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="bma_lasso_wals_08_lasso_cv.png" alt="Ten-fold cross-validation curve for LASSO. The left dashed line marks lambda.min (minimum CV error); the right dotted line marks lambda.1se (most parsimonious model within 1 standard error of the minimum). The shaded band shows plus or minus 1 standard error.">&lt;/p>
&lt;p>The cross-validation curve shows how prediction error varies with the penalty strength. The curve has a characteristic U-shape: too little penalty (left) allows overfitting (high error from variance), while too much penalty (right) underfits (high error from bias). The &amp;ldquo;1 standard error rule&amp;rdquo; is a common default: since CV error estimates are noisy, any model within 1 SE of the best is statistically indistinguishable from the best. We prefer the simpler one (lambda.1se).&lt;/p>
&lt;h3 id="114-selected-variables">11.4 Selected variables&lt;/h3>
&lt;pre>&lt;code class="language-r"># Extract LASSO coefficients at lambda.1se
lasso_coefs_1se &amp;lt;- coef(lasso_cv, s = &amp;quot;lambda.1se&amp;quot;)
lasso_df &amp;lt;- tibble(
variable = rownames(lasso_coefs_1se)[-1],
lasso_coef = as.numeric(lasso_coefs_1se)[-1]
) |&amp;gt;
mutate(
selected = lasso_coef != 0,
true_beta = true_beta_lookup[variable],
is_noise = true_beta == 0,
bar_color = case_when(
!selected ~ &amp;quot;Not selected&amp;quot;,
is_noise ~ &amp;quot;Noise (false positive)&amp;quot;,
TRUE ~ &amp;quot;True predictor (correct)&amp;quot;
)
)
# Plot selected variables
ggplot(lasso_df, aes(x = reorder(variable, abs(lasso_coef)), y = lasso_coef, fill = bar_color)) +
geom_col(width = 0.6) + coord_flip()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="bma_lasso_wals_09_lasso_selected.png" alt="LASSO-selected variables at lambda.1se. Steel blue bars indicate true predictors correctly retained; orange bars indicate noise variables falsely included (if any). Gray bars show variables not selected.">&lt;/p>
&lt;p>At lambda.1se, LASSO selects a sparse subset of the 12 candidate variables. The selected variables are shown with colored bars: steel blue for true predictors correctly retained, orange for any noise variables falsely included. Variables with zero coefficients (gray) have been excluded by the LASSO penalty. The key question is: did LASSO keep the right variables and drop the right ones?&lt;/p>
&lt;h2 id="12-post-lasso">12. Post-LASSO&lt;/h2>
&lt;p>LASSO coefficients are &lt;strong>biased&lt;/strong> because the L1 penalty shrinks them toward zero. The selected variables are correct (we hope), but the coefficient values are too small. This is by design &amp;mdash; the penalty trades bias for variance reduction &amp;mdash; but for &lt;em>interpretation&lt;/em> we want unbiased estimates.&lt;/p>
&lt;p>The fix is simple: &lt;strong>Post-LASSO&lt;/strong> (Belloni and Chernozhukov, 2013). Run OLS using only the variables that LASSO selected. The LASSO does the selection; OLS does the estimation.&lt;/p>
&lt;pre>&lt;code class="language-r"># Identify which variables LASSO selected at lambda.1se
selected_vars &amp;lt;- lasso_df |&amp;gt; filter(selected) |&amp;gt; pull(variable)
# Build the Post-LASSO formula
post_lasso_formula &amp;lt;- as.formula(
paste(&amp;quot;log_co2 ~&amp;quot;, paste(selected_vars, collapse = &amp;quot; + &amp;quot;))
)
# Run OLS on the selected variables only
post_lasso_fit &amp;lt;- lm(post_lasso_formula, data = synth_data)
# Compare: LASSO vs Post-LASSO vs True coefficients
post_lasso_summary &amp;lt;- broom::tidy(post_lasso_fit) |&amp;gt;
filter(term != &amp;quot;(Intercept)&amp;quot;) |&amp;gt;
rename(variable = term, post_lasso_coef = estimate) |&amp;gt;
select(variable, post_lasso_coef) |&amp;gt;
left_join(lasso_df |&amp;gt; select(variable, lasso_coef, true_beta), by = &amp;quot;variable&amp;quot;)
print(post_lasso_summary)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> variable lasso_coef post_lasso_coef true_beta
log_gdp 1.1899 1.1646 1.200
industry 0.0090 0.0176 0.008
fossil_fuel 0.0072 0.0118 0.012
urban_pop 0.0041 0.0078 0.010
democracy 0.0046 0.0113 0.004
trade_network 0.6309 0.8978 0.500
&lt;/code>&lt;/pre>
&lt;p>Notice how the Post-LASSO coefficients are closer to the true values than the raw LASSO coefficients. For example, fossil_fuel&amp;rsquo;s LASSO coefficient is 0.007 (shrunk from the true 0.012), but the Post-LASSO estimate is 0.012 &amp;mdash; recovering the truth almost exactly. Similarly, urban_pop recovers from 0.004 (LASSO) to 0.008 (Post-LASSO), closer to the true value of 0.010. Trade_network&amp;rsquo;s Post-LASSO estimate (0.898) overshoots the true value (0.500), reflecting the difficulty of precisely estimating a coefficient on a low-variance variable. The LASSO selected the right variables; Post-LASSO recovered unbiased magnitudes.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Note.&lt;/strong> LASSO coefficients are shrunk toward zero by design. Post-LASSO runs OLS on only the LASSO-selected variables, producing unbiased coefficient estimates while retaining the variable selection from LASSO.&lt;/p>
&lt;/blockquote>
&lt;div style="background: linear-gradient(135deg, #00d4c8 0%, #00d4c8 100%); padding: 1.5em 2em; border-radius: 8px; margin: 2em 0; color: #141413; font-size: 1.3em; font-weight: 600;">
PART 3: Weighted Average Least Squares (WALS)
&lt;/div>
&lt;h2 id="13-frequentist-model-averaging">13. Frequentist Model Averaging&lt;/h2>
&lt;p>WALS (Weighted Average Least Squares) is a &lt;strong>frequentist&lt;/strong> approach to model averaging. Like BMA, it averages over models instead of selecting just one. But unlike BMA, it does not require MCMC sampling or the specification of a full Bayesian prior.&lt;/p>
&lt;p>The key structural assumption is that regressors are split into two groups:&lt;/p>
&lt;p>$$
y = X_1 \beta_1 + X_2 \beta_2 + \varepsilon
$$&lt;/p>
&lt;p>where:&lt;/p>
&lt;ul>
&lt;li>$X_1$ are &lt;strong>focus regressors&lt;/strong>: variables you are certain belong in the model. In a cross-sectional setting, this is typically just the &lt;strong>intercept&lt;/strong>.&lt;/li>
&lt;li>$X_2$ are &lt;strong>auxiliary regressors&lt;/strong>: the 12 candidate variables whose inclusion is uncertain.&lt;/li>
&lt;li>$\beta_1$ are always estimated; $\beta_2$ are the coefficients we are uncertain about.&lt;/li>
&lt;/ul>
&lt;p>WALS was introduced by Magnus, Powell, and Prufer (2010) and offers a compelling advantage over BMA: &lt;strong>it is extremely fast&lt;/strong>. While BMA explores thousands or millions of models via MCMC, WALS uses a mathematical trick to reduce the problem to $K$ independent averaging problems &amp;mdash; one per auxiliary variable.&lt;/p>
&lt;h2 id="14-the-semi-orthogonal-transformation">14. The Semi-Orthogonal Transformation&lt;/h2>
&lt;h3 id="why-correlated-variables-make-averaging-hard">Why correlated variables make averaging hard&lt;/h3>
&lt;p>In our synthetic data, GDP is correlated with fossil fuel use, urbanization, and even with the noise variables. This means that the decision to include one variable affects the importance of another. If GDP is in the model, fossil fuel&amp;rsquo;s coefficient is partially &amp;ldquo;absorbed&amp;rdquo; by GDP.&lt;/p>
&lt;p>In BMA, this problem is handled by averaging over all model combinations &amp;mdash; but at a high computational cost ($2^{12} = 4,096$ models). WALS uses a different strategy: &lt;strong>transform the auxiliary variables so they become orthogonal&lt;/strong> (uncorrelated with each other). Once orthogonal, each variable can be averaged independently.&lt;/p>
&lt;h3 id="the-mathematical-trick">The mathematical trick&lt;/h3>
&lt;p>The semi-orthogonal transformation works as follows:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Remove the influence of focus regressors&lt;/strong>: project out $X_1$ from both $y$ and $X_2$, obtaining residuals $\tilde{y}$ and $\tilde{X}_2$.&lt;/li>
&lt;li>&lt;strong>Orthogonalize the auxiliaries&lt;/strong>: apply a rotation matrix $P$ (from the eigendecomposition of $\tilde{X}_2'\tilde{X}_2$) to create $Z = \tilde{X}_2 P$, where $Z&amp;rsquo;Z$ is diagonal.&lt;/li>
&lt;li>&lt;strong>Average independently&lt;/strong>: because the columns of $Z$ are orthogonal, the model-averaging problem decomposes into $K$ independent problems. Each transformed variable is averaged separately.&lt;/li>
&lt;/ol>
&lt;p>The computational savings grow dramatically: with 12 variables, we solve &lt;strong>12 independent problems&lt;/strong> instead of enumerating 4,096 models. Think of it as untangling a web of correlated strings until each hangs independently &amp;mdash; once separated, you can measure each string&amp;rsquo;s pull without interference from the others.&lt;/p>
&lt;h2 id="15-the-laplace-prior">15. The Laplace Prior&lt;/h2>
&lt;p>WALS requires a prior distribution for the transformed coefficients. The default and recommended choice is the &lt;strong>Laplace (double-exponential) prior&lt;/strong>:&lt;/p>
&lt;p>$$
p(\gamma_j) \propto \exp(-|\gamma_j| / \tau)
$$&lt;/p>
&lt;p>where $\gamma_j$ is the transformed coefficient and $\tau$ controls the spread. The Laplace prior has two key features:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Peaked at zero&lt;/strong>: it encodes &lt;em>skepticism&lt;/em> &amp;mdash; the prior believes most variables probably have small effects&lt;/li>
&lt;li>&lt;strong>Heavy tails&lt;/strong>: it allows large effects if the data strongly supports them &amp;mdash; variables with strong signal can &amp;ldquo;break through&amp;rdquo; the prior&lt;/li>
&lt;/ol>
&lt;p>&lt;img src="bma_lasso_wals_11_priors.png" alt="Three prior distributions used in model averaging. The Laplace prior (used by WALS) is peaked at zero with heavy tails. The Normal prior (used by BMA g-prior) is also centered at zero but has thinner tails. The Uniform prior assigns equal weight everywhere.">&lt;/p>
&lt;h3 id="the-deep-connection-to-lasso">The deep connection to LASSO&lt;/h3>
&lt;p>Here is a remarkable fact: &lt;strong>the LASSO&amp;rsquo;s L1 penalty is the negative log of a Laplace prior&lt;/strong>. The MAP (maximum a posteriori) estimate under a Laplace prior is:&lt;/p>
&lt;p>$$
\hat{\beta}_{\text{MAP}} = \arg\min_\beta \; \frac{1}{2n}\|y - X\beta\|^2 + \frac{\sigma^2}{\tau} \sum_{j=1}^{p}|\beta_j|
$$&lt;/p>
&lt;p>This is identical to the LASSO objective with $\lambda = \sigma^2 / \tau$. The LASSO penalty and the Laplace prior are two sides of the same coin.&lt;/p>
&lt;p>This means &lt;strong>LASSO and WALS encode the same prior belief&lt;/strong> &amp;mdash; that most coefficients are probably zero or small &amp;mdash; but they use it differently:&lt;/p>
&lt;ul>
&lt;li>LASSO uses the Laplace prior for &lt;strong>selection&lt;/strong>: it finds the single most probable model (the MAP estimate), which sets some coefficients to exactly zero&lt;/li>
&lt;li>WALS uses the Laplace prior for &lt;strong>averaging&lt;/strong>: it averages over all models, weighted by the Laplace prior, producing continuous (nonzero) coefficient estimates with uncertainty measures&lt;/li>
&lt;/ul>
&lt;blockquote>
&lt;p>&lt;strong>Note.&lt;/strong> The Laplace prior is peaked at zero (skeptical) with heavy tails (open-minded). It is the same prior that underlies LASSO&amp;rsquo;s L1 penalty. LASSO uses it for hard selection (zeros vs. nonzeros); WALS uses it for soft averaging (continuous weights).&lt;/p>
&lt;/blockquote>
&lt;h2 id="16-wals-on-all-12-variables">16. WALS on All 12 Variables&lt;/h2>
&lt;h3 id="161-running-wals">16.1 Running WALS&lt;/h3>
&lt;pre>&lt;code class="language-r"># WALS splits regressors into two groups:
# X1 = focus regressors (always included): just the intercept
# X2 = auxiliary regressors (uncertain): our 12 candidate variables
# Prepare the focus regressor matrix (intercept only)
X1_wals &amp;lt;- matrix(1, nrow = nrow(synth_data), ncol = 1)
colnames(X1_wals) &amp;lt;- &amp;quot;(Intercept)&amp;quot;
# Prepare the auxiliary regressor matrix (all 12 candidates)
X2_wals &amp;lt;- synth_data |&amp;gt;
select(log_gdp, industry, fossil_fuel, urban_pop, democracy,
trade_network, agriculture, log_trade, fdi, corruption,
log_tourism, log_credit) |&amp;gt;
as.matrix()
y_wals &amp;lt;- synth_data$log_co2
# Fit WALS with the Laplace prior (the recommended default)
wals_fit &amp;lt;- wals(
x = X1_wals, # focus regressors (intercept)
x2 = X2_wals, # auxiliary regressors (12 candidates)
y = y_wals, # response variable
prior = laplace() # Laplace prior for auxiliaries
)
wals_summary &amp;lt;- summary(wals_fit)
&lt;/code>&lt;/pre>
&lt;p>The WALS function call is remarkably concise. Unlike BMA, there is no MCMC sampling, no burn-in period, and no convergence diagnostics to worry about. The computation is essentially instantaneous.&lt;/p>
&lt;pre>&lt;code class="language-r"># Extract results
aux_coefs &amp;lt;- wals_summary$auxCoefs
wals_df &amp;lt;- tibble(
variable = rownames(aux_coefs),
estimate = aux_coefs[, &amp;quot;Estimate&amp;quot;],
se = aux_coefs[, &amp;quot;Std. Error&amp;quot;],
t_stat = estimate / se
) |&amp;gt;
mutate(
true_beta = true_beta_lookup[variable],
abs_t = abs(t_stat),
wals_robust = abs_t &amp;gt;= 2
)
print(wals_df |&amp;gt; arrange(desc(abs_t)) |&amp;gt; select(variable, estimate, t_stat, true_beta))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> variable estimate t_stat true_beta
log_gdp 1.1333 34.62 1.200
trade_network 0.8458 4.39 0.500
industry 0.0187 4.01 0.008
fossil_fuel 0.0099 3.26 0.012
urban_pop 0.0082 3.11 0.010
democracy 0.0097 2.58 0.004
log_credit 0.0659 1.43 0.000
agriculture -0.0046 -1.13 0.005
log_tourism -0.0148 -0.64 0.000
log_trade 0.0196 0.31 0.000
fdi -0.0011 -0.17 0.000
corruption -0.0165 -0.09 0.000
&lt;/code>&lt;/pre>
&lt;p>WALS produces familiar t-statistics for each auxiliary variable. Using the $|t| \geq 2$ threshold as our robustness criterion (analogous to BMA&amp;rsquo;s PIP $\geq$ 0.80), we can classify each variable as robust or fragile.&lt;/p>
&lt;h3 id="162-t-statistic-bar-chart">16.2 t-statistic bar chart&lt;/h3>
&lt;p>The t-statistic bar chart provides a visual summary of WALS robustness classification. Variables with $|t| \geq 2$ pass the robustness threshold (analogous to BMA&amp;rsquo;s PIP $\geq$ 0.80), while those below the threshold are considered fragile.&lt;/p>
&lt;pre>&lt;code class="language-r"># Classify each variable for the bar chart
wals_df &amp;lt;- wals_df |&amp;gt;
mutate(
bar_color = case_when(
wals_robust &amp;amp; true_nonzero ~ &amp;quot;True positive&amp;quot;,
wals_robust &amp;amp; !true_nonzero ~ &amp;quot;False positive&amp;quot;,
!wals_robust &amp;amp; true_nonzero ~ &amp;quot;False negative&amp;quot;,
TRUE ~ &amp;quot;True negative&amp;quot;
)
)
ggplot(wals_df, aes(x = reorder(variable, abs_t), y = t_stat, fill = bar_color)) +
geom_col(width = 0.6) +
geom_hline(yintercept = c(-2, 2), linetype = &amp;quot;dashed&amp;quot;) +
coord_flip()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="bma_lasso_wals_10_wals_tstat.png" alt="WALS t-statistics for all 12 variables. The dashed lines mark the t equals 2 robustness threshold. Variables with absolute t-statistic greater than or equal to 2 are considered robust.">&lt;/p>
&lt;p>The t-statistic bar chart shows a clear separation. GDP towers above all others with $|t| = 34.62$, followed by trade_network ($|t| = 4.39$), industry ($|t| = 4.01$), fossil_fuel ($|t| = 3.26$), urban_pop ($|t| = 3.11$), and democracy ($|t| = 2.58$). These six variables pass the $|t| \geq 2$ threshold. The noise variables all have $|t| &amp;lt; 1.5$, confirming they are not robust determinants. Agriculture ($|t| = 1.13$) falls just below the robustness threshold &amp;mdash; its true effect ($\beta = 0.005$) is simply too small to detect reliably with this sample size.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Note.&lt;/strong> WALS produces t-statistics for each auxiliary variable. Using the $|t| \geq 2$ threshold, we can classify variables as robust or fragile. WALS is extremely fast (no MCMC) and provides a frequentist complement to BMA&amp;rsquo;s Bayesian PIPs.&lt;/p>
&lt;/blockquote>
&lt;div style="background: linear-gradient(135deg, #1a3a8a 0%, #141413 100%); padding: 1.5em 2em; border-radius: 8px; margin: 2em 0; color: #fff; font-size: 1.3em; font-weight: 600;">
PART 4: Grand Comparison
&lt;/div>
&lt;h2 id="17-three-methods-same-question-same-data">17. Three Methods, Same Question, Same Data&lt;/h2>
&lt;p>We have now applied all three methods to the same synthetic dataset. Time for the moment of truth: &lt;strong>which variables do all three methods agree on?&lt;/strong>&lt;/p>
&lt;h3 id="171-comprehensive-comparison-table">17.1 Comprehensive comparison table&lt;/h3>
&lt;pre>&lt;code class="language-r"># Merge all results
grand_table &amp;lt;- bma_compare |&amp;gt;
left_join(lasso_compare, by = &amp;quot;variable&amp;quot;) |&amp;gt;
left_join(wals_compare, by = &amp;quot;variable&amp;quot;) |&amp;gt;
mutate(
true_beta = true_beta_lookup[variable],
bma_robust = bma_pip &amp;gt;= 0.80,
n_methods = bma_robust + lasso_selected + wals_robust,
triple_robust = n_methods == 3,
true_nonzero = true_beta != 0
)
print(grand_table |&amp;gt;
select(variable, true_beta, bma_pip, bma_robust, lasso_selected, wals_t, wals_robust, n_methods) |&amp;gt;
arrange(desc(n_methods)))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> variable true_beta bma_pip bma_robust lasso_selected wals_t wals_robust n_methods
log_gdp 1.200 1.000 TRUE TRUE 34.62 TRUE 3
trade_network 0.500 0.986 TRUE TRUE 4.39 TRUE 3
fossil_fuel 0.012 0.948 TRUE TRUE 3.26 TRUE 3
industry 0.008 0.841 TRUE TRUE 4.01 TRUE 3
urban_pop 0.010 0.648 FALSE TRUE 3.11 TRUE 2
democracy 0.004 0.607 FALSE TRUE 2.58 TRUE 2
log_tourism 0.000 0.130 FALSE FALSE -0.64 FALSE 0
log_credit 0.000 0.104 FALSE FALSE 1.43 FALSE 0
agriculture 0.005 0.087 FALSE FALSE -1.13 FALSE 0
log_trade 0.000 0.084 FALSE FALSE 0.31 FALSE 0
corruption 0.000 0.078 FALSE FALSE -0.09 FALSE 0
fdi 0.000 0.077 FALSE FALSE -0.17 FALSE 0
&lt;/code>&lt;/pre>
&lt;p>The results are striking. Four variables are &lt;strong>triple-robust&lt;/strong> &amp;mdash; identified by all three methods: log_gdp, trade_network, fossil_fuel, and industry. Two more variables &amp;mdash; urban_pop and democracy &amp;mdash; are &lt;strong>double-robust&lt;/strong>, selected by LASSO and WALS but landing in BMA&amp;rsquo;s borderline zone (PIPs of 0.648 and 0.607). All five noise variables are correctly excluded by all three methods. Agriculture ($\beta = 0.005$) is the only true predictor missed by all methods &amp;mdash; its effect is simply too small to detect.&lt;/p>
&lt;h3 id="172-method-agreement-heatmap">17.2 Method agreement heatmap&lt;/h3>
&lt;p>&lt;img src="bma_lasso_wals_12_heatmap.png" alt="Method agreement heatmap showing 12 variables by 3 methods. Steel blue indicates the variable was identified as robust; orange indicates it was not. True predictors are in the top rows, noise variables in the bottom rows.">&lt;/p>
&lt;p>The heatmap provides a visual summary of agreement. The top four rows (GDP, trade_network, fossil_fuel, industry) are solid steel blue across all three columns &amp;mdash; unanimous agreement that these variables matter. Urban_pop and democracy show steel blue for LASSO and WALS but orange for BMA, visualizing BMA&amp;rsquo;s greater conservatism. The bottom five rows (noise) are solid orange &amp;mdash; unanimous agreement that they do not matter. Agriculture is also orange throughout, reflecting all methods' consensus that its tiny effect ($\beta = 0.005$) cannot be reliably distinguished from zero.&lt;/p>
&lt;h3 id="173-bma-pip-vs-wals-t-statistic">17.3 BMA PIP vs. WALS |t-statistic|&lt;/h3>
&lt;p>&lt;img src="bma_lasso_wals_13_pip_vs_t.png" alt="BMA PIP plotted against WALS absolute t-statistic. Point color indicates true status (steel blue for true predictors, orange for noise). Point shape indicates LASSO selection (triangle for selected, cross for not selected). The upper-right quadrant contains variables robust by both BMA and WALS.">&lt;/p>
&lt;p>The scatter plot reveals a strong positive relationship between BMA PIP and WALS $|t|$. Variables in the upper-right quadrant are robust by both methods &amp;mdash; GDP, trade_network, fossil_fuel, and industry. Urban_pop and democracy sit in an interesting middle zone: high WALS $|t|$ (above 2) but moderate BMA PIP (below 0.80), illustrating BMA&amp;rsquo;s more conservative threshold. The noise variables cluster in the lower-left corner (low PIP, low $|t|$). LASSO selection (triangle markers) aligns with the WALS threshold, selecting the same six variables that pass $|t| \geq 2$.&lt;/p>
&lt;h3 id="174-coefficient-comparison">17.4 Coefficient comparison&lt;/h3>
&lt;p>&lt;img src="bma_lasso_wals_14_coef_comparison.png" alt="Coefficient estimates from the three methods compared to the true values in a three-panel faceted scatter plot. Points close to the dashed 45-degree line indicate accurate coefficient recovery.">&lt;/p>
&lt;p>The coefficient comparison plot shows how well each method recovers the true effect sizes. Points on the dashed 45-degree line represent perfect recovery. GDP ($\beta = 1.200$) is recovered almost exactly by all three methods. The smaller coefficients (fossil_fuel at 0.012, urban_pop at 0.010) are also well-estimated. Trade_network&amp;rsquo;s coefficient is overestimated by all methods (true 0.500, estimates around 0.85&amp;ndash;0.90), reflecting the difficulty of precisely estimating an effect on a low-variance variable. BMA&amp;rsquo;s posterior means are slightly attenuated for variables with PIPs below 1.0 (the averaging shrinks them toward zero).&lt;/p>
&lt;h3 id="175-agreement-summary">17.5 Agreement summary&lt;/h3>
&lt;p>&lt;img src="bma_lasso_wals_15_agreement.png" alt="Bar chart showing how many methods (out of 3) identified each variable as robust. Steel blue bars are true predictors, orange bars are noise variables. Four variables achieve triple-robust status and two achieve double-robust status.">&lt;/p>
&lt;p>The agreement bar chart tells a nuanced story: four variables are triple-robust (identified by all three methods), two are double-robust (identified by LASSO and WALS but not BMA), and six are identified by none. The &amp;ldquo;split votes&amp;rdquo; on urban_pop and democracy reveal a genuine methodological difference: LASSO and WALS are more liberal in including moderate-effect variables, while BMA&amp;rsquo;s Bayesian Occam&amp;rsquo;s razor demands stronger evidence. This pattern &amp;mdash; where methods &lt;em>mostly&lt;/em> agree but diverge on borderline cases &amp;mdash; is what makes methodological triangulation valuable.&lt;/p>
&lt;h3 id="176-method-performance">17.6 Method performance&lt;/h3>
&lt;pre>&lt;code class="language-r"># Sensitivity, specificity, and accuracy for each method
results_by_method &amp;lt;- tibble(
method = c(&amp;quot;BMA&amp;quot;, &amp;quot;LASSO&amp;quot;, &amp;quot;WALS&amp;quot;),
true_pos = c(4, 6, 6), # true predictors correctly identified
false_pos = c(0, 0, 0), # noise variables falsely identified
false_neg = c(3, 1, 1), # true predictors missed
true_neg = c(5, 5, 5), # noise variables correctly excluded
sensitivity = true_pos / 7,
specificity = true_neg / 5,
accuracy = (true_pos + true_neg) / 12
)
print(results_by_method)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> method true_pos false_pos false_neg true_neg sensitivity specificity accuracy
BMA 4 0 3 5 0.571 1.000 0.750
LASSO 6 0 1 5 0.857 1.000 0.917
WALS 6 0 1 5 0.857 1.000 0.917
&lt;/code>&lt;/pre>
&lt;p>All three methods achieve &lt;strong>perfect specificity&lt;/strong> (zero false positives) &amp;mdash; none mistakenly identifies a noise variable as robust. The key difference is in &lt;strong>sensitivity&lt;/strong>: LASSO and WALS each detect 6 of 7 true predictors (85.7%), while BMA detects only 4 (57.1%). BMA&amp;rsquo;s lower sensitivity reflects its conservative Bayesian Occam&amp;rsquo;s razor: it places urban_pop and democracy in the &amp;ldquo;borderline&amp;rdquo; zone rather than committing to their inclusion. The one variable missed by all methods &amp;mdash; agriculture ($\beta = 0.005$) &amp;mdash; has an effect so small that it is indistinguishable from noise given our sample size.&lt;/p>
&lt;h3 id="177-when-to-use-which-method">17.7 When to use which method&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th style="text-align:left">Method&lt;/th>
&lt;th style="text-align:left">Best for&lt;/th>
&lt;th style="text-align:left">Strengths&lt;/th>
&lt;th style="text-align:left">Limitations&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td style="text-align:left">BMA&lt;/td>
&lt;td style="text-align:left">Full uncertainty quantification&lt;/td>
&lt;td style="text-align:left">Probabilistic (PIPs), handles model uncertainty formally, coefficient intervals&lt;/td>
&lt;td style="text-align:left">Slower (MCMC), requires prior specification&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">LASSO&lt;/td>
&lt;td style="text-align:left">Prediction, sparse models&lt;/td>
&lt;td style="text-align:left">Fast, automatic selection, works with many variables&lt;/td>
&lt;td style="text-align:left">Binary (in/out), biased coefficients (use Post-LASSO)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td style="text-align:left">WALS&lt;/td>
&lt;td style="text-align:left">Speed, frequentist inference&lt;/td>
&lt;td style="text-align:left">Very fast, produces t-statistics, no MCMC&lt;/td>
&lt;td style="text-align:left">Less common, limited software support&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The strongest recommendation: &lt;strong>use all three&lt;/strong>. When they converge on the same variables (as with our four triple-robust predictors), you have the strongest possible evidence. When they disagree (as with urban_pop and democracy, where LASSO and WALS say &amp;ldquo;yes&amp;rdquo; but BMA hedges), the disagreement itself is informative &amp;mdash; it tells you the evidence is real but not overwhelming. In real-world data, complications such as nonlinearity, heteroskedasticity, and endogeneity may affect method performance and should be addressed before applying these techniques.&lt;/p>
&lt;h2 id="18-conclusion">18. Conclusion&lt;/h2>
&lt;h3 id="181-summary">18.1 Summary&lt;/h3>
&lt;p>This tutorial introduced three principled approaches to the variable selection problem:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Bayesian Model Averaging (BMA)&lt;/strong> averages over all possible models, weighting each by its posterior probability. It produces Posterior Inclusion Probabilities (PIPs) that quantify how robust each variable is across the entire model space. Variables with PIP $\geq$ 0.80 are considered robust.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>LASSO&lt;/strong> adds an L1 penalty to the OLS objective, forcing irrelevant coefficients to exactly zero. Cross-validation selects the penalty strength. Post-LASSO recovers unbiased coefficient estimates for the selected variables.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>WALS&lt;/strong> uses a semi-orthogonal transformation to decompose the model-averaging problem into independent subproblems &amp;mdash; one per variable. It is extremely fast and produces familiar t-statistics for robustness assessment.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h3 id="182-key-takeaways">18.2 Key takeaways&lt;/h3>
&lt;p>&lt;strong>The methods mostly converge &amp;mdash; and their disagreements are informative.&lt;/strong> Four variables are identified by all three methods (triple-robust), and all methods achieve perfect specificity (zero false positives). LASSO and WALS are more sensitive (detecting 6 of 7 true predictors), while BMA is more conservative (detecting 4). The two variables where they disagree &amp;mdash; urban_pop and democracy &amp;mdash; have moderate effects that BMA&amp;rsquo;s Bayesian Occam&amp;rsquo;s razor treats as borderline. This pattern illustrates the value of methodological triangulation across fundamentally different statistical paradigms.&lt;/p>
&lt;p>&lt;strong>Model uncertainty is real but addressable.&lt;/strong> With 12 candidate variables, there are 4,096 possible models. Rather than pretending one of them is &amp;ldquo;the&amp;rdquo; model, these methods account for the uncertainty explicitly. The result is more honest inference.&lt;/p>
&lt;p>&lt;strong>Synthetic data lets us verify.&lt;/strong> Because we designed the data-generating process, we could check each method&amp;rsquo;s performance against the known truth. In practice, the truth is unknown &amp;mdash; which is precisely why using multiple methods is so valuable.&lt;/p>
&lt;h3 id="183-applying-this-to-your-own-research">18.3 Applying this to your own research&lt;/h3>
&lt;p>The code in this tutorial is designed to be modular. To apply these methods to your own data:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Replace the CSV&lt;/strong>: load your own cross-sectional dataset instead of the synthetic one&lt;/li>
&lt;li>&lt;strong>Define the variable list&lt;/strong>: specify which variables are candidates for selection&lt;/li>
&lt;li>&lt;strong>Run the three methods&lt;/strong>: use the same &lt;code>bms()&lt;/code>, &lt;code>cv.glmnet()&lt;/code>, and &lt;code>wals()&lt;/code> function calls&lt;/li>
&lt;li>&lt;strong>Compare results&lt;/strong>: build the same comparison table and heatmap&lt;/li>
&lt;/ol>
&lt;p>The interpretation framework &amp;mdash; PIPs for BMA, selection for LASSO, t-statistics for WALS &amp;mdash; applies regardless of the specific dataset.&lt;/p>
&lt;h3 id="184-further-reading">18.4 Further reading&lt;/h3>
&lt;ul>
&lt;li>&lt;strong>BMA&lt;/strong>: Hoeting, J.A., Madigan, D., Raftery, A.E., and Volinsky, C.T. (1999). &amp;ldquo;Bayesian Model Averaging: A Tutorial.&amp;rdquo; &lt;em>Statistical Science&lt;/em>, 14(4), 382&amp;ndash;417.&lt;/li>
&lt;li>&lt;strong>LASSO&lt;/strong>: Tibshirani, R. (1996). &amp;ldquo;Regression Shrinkage and Selection via the Lasso.&amp;rdquo; &lt;em>Journal of the Royal Statistical Society, Series B&lt;/em>, 58(1), 267&amp;ndash;288.&lt;/li>
&lt;li>&lt;strong>WALS&lt;/strong>: Magnus, J.R., Powell, O., and Prufer, P. (2010). &amp;ldquo;A Comparison of Two Model Averaging Techniques with an Application to Growth Empirics.&amp;rdquo; &lt;em>Journal of Econometrics&lt;/em>, 154(2), 139&amp;ndash;153.&lt;/li>
&lt;li>&lt;strong>Application&lt;/strong>: Aller, C., Ductor, L., and Grechyna, D. (2021). &amp;ldquo;Robust Determinants of CO&lt;sub>2&lt;/sub> Emissions.&amp;rdquo; &lt;em>Energy Economics&lt;/em>, 96, 105154.&lt;/li>
&lt;li>&lt;strong>Post-LASSO&lt;/strong>: Belloni, A. and Chernozhukov, V. (2013). &amp;ldquo;Least Squares After Model Selection in High-Dimensional Sparse Models.&amp;rdquo; &lt;em>Bernoulli&lt;/em>, 19(2), 521&amp;ndash;547.&lt;/li>
&lt;li>&lt;strong>R Packages&lt;/strong>: &lt;a href="https://cran.r-project.org/web/packages/BMS/vignettes/bms.pdf" target="_blank" rel="noopener">BMS vignette&lt;/a>, &lt;a href="https://glmnet.stanford.edu/articles/glmnet.html" target="_blank" rel="noopener">glmnet vignette&lt;/a>, &lt;a href="https://cran.r-project.org/package=WALS" target="_blank" rel="noopener">WALS package&lt;/a>&lt;/li>
&lt;/ul>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>Hoeting, J.A., Madigan, D., Raftery, A.E., and Volinsky, C.T. (1999). Bayesian Model Averaging: A Tutorial. &lt;em>Statistical Science&lt;/em>, 14(4), 382&amp;ndash;417.&lt;/li>
&lt;li>Tibshirani, R. (1996). Regression Shrinkage and Selection via the Lasso. &lt;em>Journal of the Royal Statistical Society, Series B&lt;/em>, 58(1), 267&amp;ndash;288.&lt;/li>
&lt;li>Magnus, J.R., Powell, O., and Prufer, P. (2010). A Comparison of Two Model Averaging Techniques with an Application to Growth Empirics. &lt;em>Journal of Econometrics&lt;/em>, 154(2), 139&amp;ndash;153.&lt;/li>
&lt;li>Raftery, A.E. (1995). Bayesian Model Selection in Social Research. &lt;em>Sociological Methodology&lt;/em>, 25, 111&amp;ndash;163.&lt;/li>
&lt;li>Aller, C., Ductor, L., and Grechyna, D. (2021). Robust Determinants of CO&lt;sub>2&lt;/sub> Emissions. &lt;em>Energy Economics&lt;/em>, 96, 105154.&lt;/li>
&lt;li>Belloni, A. and Chernozhukov, V. (2013). Least Squares After Model Selection in High-Dimensional Sparse Models. &lt;em>Bernoulli&lt;/em>, 19(2), 521&amp;ndash;547.&lt;/li>
&lt;/ol></description></item><item><title>Exploratory Spatial Data Analysis: Spatial Clusters and Dynamics of Human Development in South America</title><link>https://carlos-mendez.org/post/python_esda2/</link><pubDate>Sun, 22 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_esda2/</guid><description>&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>When we look at a map of human development across South America, a pattern immediately stands out: prosperous regions tend to cluster together, and so do lagging regions. But is this clustering statistically significant, or could it arise by chance? And how have these spatial clusters evolved over time?&lt;/p>
&lt;p>&lt;strong>Exploratory Spatial Data Analysis (ESDA)&lt;/strong> provides the tools to answer these questions. ESDA is a set of techniques for visualizing spatial distributions, identifying patterns of spatial clustering, and detecting spatial outliers. Unlike standard exploratory data analysis, which treats observations as independent, ESDA explicitly accounts for the geographic location of each observation and the relationships between neighbors.&lt;/p>
&lt;p>This tutorial uses the &lt;a href="https://globaldatalab.org/shdi/" target="_blank" rel="noopener">Subnational Human Development Index&lt;/a> (SHDI) from &lt;a href="https://doi.org/10.1038/sdata.2019.38" target="_blank" rel="noopener">Smits and Permanyer (2019)&lt;/a> for &lt;strong>153 sub-national regions across 12 South American countries&lt;/strong> in 2013 and 2019 &amp;mdash; the same dataset from the &lt;a href="https://carlos-mendez.org/post/python_pca2/">Pooled PCA tutorial&lt;/a>. We progress from simple scatter plots and choropleth maps to formal tests of spatial dependence (Moran&amp;rsquo;s I), local cluster identification (LISA maps), and space-time dynamics. By the end, you will be able to answer: &lt;strong>do nearby regions in South America share similar development levels, and how have these spatial clusters evolved between 2013 and 2019?&lt;/strong>&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Understand the concept of spatial autocorrelation and why it matters for regional analysis&lt;/li>
&lt;li>Create choropleth maps and scatter plots to visualize spatial distributions&lt;/li>
&lt;li>Build and interpret a spatial weights matrix using Queen contiguity&lt;/li>
&lt;li>Compute and interpret global Moran&amp;rsquo;s I for spatial dependence testing&lt;/li>
&lt;li>Identify local spatial clusters (HH, LL) and outliers (HL, LH) using LISA statistics&lt;/li>
&lt;li>Explore space-time dynamics of spatial clusters using directional Moran scatter plots&lt;/li>
&lt;li>Compare country-level development trajectories within the spatial framework&lt;/li>
&lt;/ul>
&lt;h2 id="2-the-esda-pipeline">2. The ESDA pipeline&lt;/h2>
&lt;p>The analysis follows a natural progression from visualization to formal testing. Each step builds on the previous one, moving from &amp;ldquo;what does the data look like?&amp;rdquo; to &amp;ldquo;is the spatial pattern statistically significant?&amp;rdquo; to &amp;ldquo;where exactly are the clusters?&amp;rdquo;&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
A[&amp;quot;&amp;lt;b&amp;gt;Step 1&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Load &amp;amp;&amp;lt;br/&amp;gt;Explore&amp;quot;] --&amp;gt; B[&amp;quot;&amp;lt;b&amp;gt;Step 2&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Visualize&amp;lt;br/&amp;gt;Maps&amp;quot;]
B --&amp;gt; C[&amp;quot;&amp;lt;b&amp;gt;Step 3&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Spatial&amp;lt;br/&amp;gt;Weights&amp;quot;]
C --&amp;gt; D[&amp;quot;&amp;lt;b&amp;gt;Step 4&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Global&amp;lt;br/&amp;gt;Moran's I&amp;quot;]
D --&amp;gt; E[&amp;quot;&amp;lt;b&amp;gt;Step 5&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Local&amp;lt;br/&amp;gt;LISA&amp;quot;]
E --&amp;gt; F[&amp;quot;&amp;lt;b&amp;gt;Step 6&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Space-Time&amp;lt;br/&amp;gt;Dynamics&amp;quot;]
style A fill:#141413,stroke:#6a9bcc,color:#fff
style B fill:#d97757,stroke:#141413,color:#fff
style C fill:#6a9bcc,stroke:#141413,color:#fff
style D fill:#6a9bcc,stroke:#141413,color:#fff
style E fill:#00d4c8,stroke:#141413,color:#fff
style F fill:#1a3a8a,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>Steps 1&amp;ndash;2 are purely visual &amp;mdash; they build intuition about where high and low values are concentrated. Step 3 formalizes the notion of &amp;ldquo;neighbors&amp;rdquo; through a spatial weights matrix. Steps 4&amp;ndash;5 use that matrix to compute statistics that quantify spatial clustering, first globally (one number for the whole map) and then locally (one number per region). Step 6 connects the spatial and temporal dimensions by tracking how regions move through the Moran scatter plot between periods.&lt;/p>
&lt;h2 id="3-setup-and-imports">3. Setup and imports&lt;/h2>
&lt;p>The analysis uses &lt;a href="https://geopandas.org/" target="_blank" rel="noopener">GeoPandas&lt;/a> for spatial data handling, &lt;a href="https://pysal.org/" target="_blank" rel="noopener">PySAL&lt;/a> for spatial statistics, and &lt;a href="https://splot.readthedocs.io/" target="_blank" rel="noopener">splot&lt;/a> for specialized spatial visualizations.&lt;/p>
&lt;pre>&lt;code class="language-python">import numpy as np
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
from libpysal.weights import Queen
from libpysal.weights import lag_spatial
from esda.moran import Moran, Moran_Local
from splot.esda import moran_scatterplot, lisa_cluster
from splot.libpysal import plot_spatial_weights
from adjustText import adjust_text
import mapclassify
# Reproducibility
RANDOM_SEED = 42
# Site color palette
STEEL_BLUE = &amp;quot;#6a9bcc&amp;quot;
WARM_ORANGE = &amp;quot;#d97757&amp;quot;
NEAR_BLACK = &amp;quot;#141413&amp;quot;
TEAL = &amp;quot;#00d4c8&amp;quot;
&lt;/code>&lt;/pre>
&lt;details>
&lt;summary>Dark theme figure styling (click to expand)&lt;/summary>
&lt;pre>&lt;code class="language-python"># Dark theme palette (consistent with site navbar/dark sections)
DARK_NAVY = &amp;quot;#0f1729&amp;quot;
GRID_LINE = &amp;quot;#1f2b5e&amp;quot;
LIGHT_TEXT = &amp;quot;#c8d0e0&amp;quot;
WHITE_TEXT = &amp;quot;#e8ecf2&amp;quot;
# Plot defaults — minimal, spine-free, dark background
plt.rcParams.update({
&amp;quot;figure.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.edgecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.linewidth&amp;quot;: 0,
&amp;quot;axes.labelcolor&amp;quot;: LIGHT_TEXT,
&amp;quot;axes.titlecolor&amp;quot;: WHITE_TEXT,
&amp;quot;axes.spines.top&amp;quot;: False,
&amp;quot;axes.spines.right&amp;quot;: False,
&amp;quot;axes.spines.left&amp;quot;: False,
&amp;quot;axes.spines.bottom&amp;quot;: False,
&amp;quot;axes.grid&amp;quot;: True,
&amp;quot;grid.color&amp;quot;: GRID_LINE,
&amp;quot;grid.linewidth&amp;quot;: 0.6,
&amp;quot;grid.alpha&amp;quot;: 0.8,
&amp;quot;xtick.color&amp;quot;: LIGHT_TEXT,
&amp;quot;ytick.color&amp;quot;: LIGHT_TEXT,
&amp;quot;xtick.major.size&amp;quot;: 0,
&amp;quot;ytick.major.size&amp;quot;: 0,
&amp;quot;text.color&amp;quot;: WHITE_TEXT,
&amp;quot;font.size&amp;quot;: 12,
&amp;quot;legend.frameon&amp;quot;: False,
&amp;quot;legend.fontsize&amp;quot;: 11,
&amp;quot;legend.labelcolor&amp;quot;: LIGHT_TEXT,
&amp;quot;figure.edgecolor&amp;quot;: DARK_NAVY,
&amp;quot;savefig.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;savefig.edgecolor&amp;quot;: DARK_NAVY,
})
&lt;/code>&lt;/pre>
&lt;/details>
&lt;h2 id="4-data-loading-and-exploration">4. Data loading and exploration&lt;/h2>
&lt;p>The dataset is a GeoJSON file containing polygon geometries and development indicators for 153 sub-national regions across South America. It is a spatial version of the data from the &lt;a href="https://carlos-mendez.org/post/python_pca2/">Pooled PCA tutorial&lt;/a>, sourced from the &lt;a href="https://globaldatalab.org/shdi/" target="_blank" rel="noopener">Global Data Lab&lt;/a> (&lt;a href="https://doi.org/10.1038/sdata.2019.38" target="_blank" rel="noopener">Smits and Permanyer, 2019&lt;/a>). Each region has the Subnational Human Development Index (SHDI) and its three component indices &amp;mdash; Health, Education, and Income &amp;mdash; for 2013 and 2019.&lt;/p>
&lt;pre>&lt;code class="language-python">DATA_URL = &amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/master/content/post/python_esda2/data.geojson&amp;quot;
gdf = gpd.read_file(DATA_URL)
print(f&amp;quot;Loaded: {gdf.shape[0]} rows, {gdf.shape[1]} columns&amp;quot;)
print(f&amp;quot;Countries: {gdf['country'].nunique()}&amp;quot;)
print(f&amp;quot;CRS: {gdf.crs}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Loaded: 153 rows, 25 columns
Countries: 12
CRS: EPSG:4326
&lt;/code>&lt;/pre>
&lt;p>Before computing change columns, we prepare the data for labeling. Some region names in the raw data are very long (e.g., &amp;ldquo;Chubut, Neuquen, Rio Negro, Santa Cruz, Tierra del Fuego&amp;rdquo;), so we simplify them. We also create a &lt;code>region_country&lt;/code> column that appends the ISO country code to each region name &amp;mdash; this makes labels immediately informative when regions from different countries appear on the same plot.&lt;/p>
&lt;pre>&lt;code class="language-python"># Country name → ISO 3166-1 alpha-3 code
COUNTRY_ISO = {
&amp;quot;Argentina&amp;quot;: &amp;quot;ARG&amp;quot;, &amp;quot;Bolivia&amp;quot;: &amp;quot;BOL&amp;quot;, &amp;quot;Brazil&amp;quot;: &amp;quot;BRA&amp;quot;,
&amp;quot;Chili&amp;quot;: &amp;quot;CHL&amp;quot;, &amp;quot;Colombia&amp;quot;: &amp;quot;COL&amp;quot;, &amp;quot;Ecuador&amp;quot;: &amp;quot;ECU&amp;quot;,
&amp;quot;Guyana&amp;quot;: &amp;quot;GUY&amp;quot;, &amp;quot;Paraguay&amp;quot;: &amp;quot;PRY&amp;quot;, &amp;quot;Peru&amp;quot;: &amp;quot;PER&amp;quot;,
&amp;quot;Suriname&amp;quot;: &amp;quot;SUR&amp;quot;, &amp;quot;Uruguay&amp;quot;: &amp;quot;URY&amp;quot;, &amp;quot;Venezuela&amp;quot;: &amp;quot;VEN&amp;quot;,
}
gdf[&amp;quot;country_iso&amp;quot;] = gdf[&amp;quot;country&amp;quot;].map(COUNTRY_ISO)
# Simplify long region names
RENAME = {
&amp;quot;Catamarca, La Rioja, San Juan&amp;quot;: &amp;quot;Catamarca-La Rioja&amp;quot;,
&amp;quot;Corrientes, Entre Rios, Misiones&amp;quot;: &amp;quot;Corrientes-Misiones&amp;quot;,
&amp;quot;Chubut, Neuquen, Rio Negro, Santa Cruz, Tierra del Fuego&amp;quot;: &amp;quot;Patagonia&amp;quot;,
&amp;quot;La Pampa, San Luis, Mendoza&amp;quot;: &amp;quot;La Pampa-Mendoza&amp;quot;,
&amp;quot;Santiago del Estero, Tucuman&amp;quot;: &amp;quot;Tucuman-Sgo Estero&amp;quot;,
&amp;quot;Tarapaca (incl Arica and Parinacota)&amp;quot;: &amp;quot;Tarapaca&amp;quot;,
&amp;quot;Valparaiso (former Aconcagua)&amp;quot;: &amp;quot;Valparaiso&amp;quot;,
&amp;quot;Los Lagos (incl Los Rios)&amp;quot;: &amp;quot;Los Lagos&amp;quot;,
&amp;quot;Magallanes and La Antartica Chilena&amp;quot;: &amp;quot;Magallanes&amp;quot;,
&amp;quot;Antioquia (incl Medellin)&amp;quot;: &amp;quot;Antioquia&amp;quot;,
&amp;quot;Atlantico (incl Barranquilla)&amp;quot;: &amp;quot;Atlantico&amp;quot;,
&amp;quot;Bolivar (Sur and Norte)&amp;quot;: &amp;quot;Bolivar&amp;quot;,
&amp;quot;Essequibo Islands-West Demerara&amp;quot;: &amp;quot;Essequibo-W Demerara&amp;quot;,
&amp;quot;East Berbice-Corentyne&amp;quot;: &amp;quot;E Berbice-Corentyne&amp;quot;,
&amp;quot;Upper Takutu-Upper Essequibo&amp;quot;: &amp;quot;Upper Takutu-Essequibo&amp;quot;,
&amp;quot;Upper Demerara-Berbice&amp;quot;: &amp;quot;Upper Demerara&amp;quot;,
&amp;quot;Cuyuni-Mazaruni-Upper Essequibo&amp;quot;: &amp;quot;Cuyuni-Mazaruni&amp;quot;,
&amp;quot;Region Metropolitana&amp;quot;: &amp;quot;R. Metropolitana&amp;quot;,
&amp;quot;Federal District&amp;quot;: &amp;quot;Federal Dist.&amp;quot;,
&amp;quot;City of Buenos Aires&amp;quot;: &amp;quot;C. Buenos Aires&amp;quot;,
&amp;quot;Brokopondo and Sipaliwini&amp;quot;: &amp;quot;Brokopondo-Sipaliwini&amp;quot;,
&amp;quot;Montevideo and Metropolitan area&amp;quot;: &amp;quot;Montevideo&amp;quot;,
}
gdf[&amp;quot;region&amp;quot;] = gdf[&amp;quot;region&amp;quot;].replace(RENAME)
# Create region_country label column
gdf[&amp;quot;region_country&amp;quot;] = gdf[&amp;quot;region&amp;quot;] + &amp;quot; (&amp;quot; + gdf[&amp;quot;country_iso&amp;quot;] + &amp;quot;)&amp;quot;
&lt;/code>&lt;/pre>
&lt;p>We then compute the change in SHDI and its components between the two periods.&lt;/p>
&lt;pre>&lt;code class="language-python">gdf[&amp;quot;shdi_change&amp;quot;] = gdf[&amp;quot;shdi2019&amp;quot;] - gdf[&amp;quot;shdi2013&amp;quot;]
gdf[&amp;quot;health_change&amp;quot;] = gdf[&amp;quot;healthindex2019&amp;quot;] - gdf[&amp;quot;healthindex2013&amp;quot;]
gdf[&amp;quot;educ_change&amp;quot;] = gdf[&amp;quot;edindex2019&amp;quot;] - gdf[&amp;quot;edindex2013&amp;quot;]
gdf[&amp;quot;income_change&amp;quot;] = gdf[&amp;quot;incindex2019&amp;quot;] - gdf[&amp;quot;incindex2013&amp;quot;]
print(gdf[[&amp;quot;shdi2013&amp;quot;, &amp;quot;shdi2019&amp;quot;, &amp;quot;shdi_change&amp;quot;]].describe().round(4).to_string())
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> shdi2013 shdi2019 shdi_change
count 153.0000 153.0000 153.0000
mean 0.7424 0.7477 0.0053
std 0.0594 0.0613 0.0319
min 0.5540 0.5580 -0.0670
25% 0.7070 0.7150 0.0090
50% 0.7430 0.7440 0.0150
75% 0.7740 0.7840 0.0250
max 0.8780 0.8830 0.0450
&lt;/code>&lt;/pre>
&lt;p>The dataset covers 153 regions across 12 South American countries. Mean SHDI increased modestly from 0.7424 in 2013 to 0.7477 in 2019 (+0.0053), but the change varied widely: from a maximum decline of -0.0670 to a maximum improvement of +0.0450. The standard deviation of SHDI also increased slightly (0.0594 to 0.0613), hinting that regional disparities may have widened.&lt;/p>
&lt;h2 id="5-exploratory-scatter-plots">5. Exploratory scatter plots&lt;/h2>
&lt;h3 id="51-hdi-scatter-2013-vs-2019">5.1 HDI scatter: 2013 vs 2019&lt;/h3>
&lt;p>A scatter plot of SHDI in 2013 against SHDI in 2019 provides a quick overview of temporal dynamics. Points above the 45-degree line represent regions that improved; points below represent regions that declined.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 7))
ax.scatter(gdf[&amp;quot;shdi2013&amp;quot;], gdf[&amp;quot;shdi2019&amp;quot;],
color=STEEL_BLUE, edgecolors=DARK_NAVY, s=45, alpha=0.75, zorder=3)
lims = [min(gdf[&amp;quot;shdi2013&amp;quot;].min(), gdf[&amp;quot;shdi2019&amp;quot;].min()) - 0.01,
max(gdf[&amp;quot;shdi2013&amp;quot;].max(), gdf[&amp;quot;shdi2019&amp;quot;].max()) + 0.01]
ax.plot(lims, lims, color=WARM_ORANGE, linewidth=1.5, linestyle=&amp;quot;--&amp;quot;,
label=&amp;quot;45° line (no change)&amp;quot;, zorder=2)
ax.set_xlabel(&amp;quot;SHDI 2013&amp;quot;)
ax.set_ylabel(&amp;quot;SHDI 2019&amp;quot;)
ax.set_title(&amp;quot;Subnational HDI: 2013 vs 2019&amp;quot;)
ax.legend()
# Label extreme regions (biggest gains, biggest losses, highest, lowest)
residual = gdf[&amp;quot;shdi2019&amp;quot;] - gdf[&amp;quot;shdi2013&amp;quot;]
extremes = set()
extremes.update(residual.nlargest(3).index.tolist())
extremes.update(residual.nsmallest(3).index.tolist())
extremes.update(gdf[&amp;quot;shdi2019&amp;quot;].nlargest(2).index.tolist())
extremes.update(gdf[&amp;quot;shdi2019&amp;quot;].nsmallest(2).index.tolist())
texts = []
for i in extremes:
texts.append(ax.text(gdf.loc[i, &amp;quot;shdi2013&amp;quot;], gdf.loc[i, &amp;quot;shdi2019&amp;quot;],
gdf.loc[i, &amp;quot;region_country&amp;quot;], fontsize=8, color=LIGHT_TEXT))
adjust_text(texts, ax=ax, arrowprops=dict(arrowstyle=&amp;quot;-&amp;quot;, color=LIGHT_TEXT,
alpha=0.5, lw=0.5))
plt.savefig(&amp;quot;esda2_scatter_hdi.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="esda2_scatter_hdi.png" alt="Scatter plot of SHDI 2013 vs SHDI 2019 with 45-degree reference line and labeled extreme regions.">&lt;/p>
&lt;p>Of 153 regions, &lt;strong>126 improved&lt;/strong> their SHDI between 2013 and 2019, while &lt;strong>27 declined&lt;/strong>. The labels identify key cases: at the top, &lt;strong>C. Buenos Aires (ARG)&lt;/strong> and &lt;strong>R. Metropolitana (CHL)&lt;/strong> lead with SHDI above 0.88. At the bottom, &lt;strong>Potaro-Siparuni (GUY)&lt;/strong> and &lt;strong>Barima-Waini (GUY)&lt;/strong> remain the least developed. The biggest decliners &amp;mdash; &lt;strong>Federal Dist. (VEN)&lt;/strong>, &lt;strong>Carabobo (VEN)&lt;/strong>, and &lt;strong>Aragua (VEN)&lt;/strong> &amp;mdash; are all Venezuelan states, falling well below the 45-degree line. The biggest improvers &amp;mdash; &lt;strong>Meta (COL)&lt;/strong>, &lt;strong>Vichada (COL)&lt;/strong>, and &lt;strong>Brokopondo-Sipaliwini (SUR)&lt;/strong> &amp;mdash; rose above the line, with gains up to +0.045 points.&lt;/p>
&lt;h3 id="52-component-scatter-plots">5.2 Component scatter plots&lt;/h3>
&lt;p>The SHDI is a composite of three sub-indices: Health, Education, and Income. Breaking down the change by component reveals which dimensions drove the aggregate patterns.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, axes = plt.subplots(1, 3, figsize=(18, 5.5))
components = [
(&amp;quot;healthindex2013&amp;quot;, &amp;quot;healthindex2019&amp;quot;, &amp;quot;Health Index&amp;quot;),
(&amp;quot;edindex2013&amp;quot;, &amp;quot;edindex2019&amp;quot;, &amp;quot;Education Index&amp;quot;),
(&amp;quot;incindex2013&amp;quot;, &amp;quot;incindex2019&amp;quot;, &amp;quot;Income Index&amp;quot;),
]
for ax, (col13, col19, label) in zip(axes, components):
ax.scatter(gdf[col13], gdf[col19],
color=STEEL_BLUE, edgecolors=DARK_NAVY, s=40, alpha=0.7, zorder=3)
lims = [min(gdf[col13].min(), gdf[col19].min()) - 0.02,
max(gdf[col13].max(), gdf[col19].max()) + 0.02]
ax.plot(lims, lims, color=WARM_ORANGE, linewidth=1.5, linestyle=&amp;quot;--&amp;quot;, zorder=2)
ax.set_xlabel(f&amp;quot;{label} 2013&amp;quot;)
ax.set_ylabel(f&amp;quot;{label} 2019&amp;quot;)
ax.set_title(label)
# Label extreme regions per component
comp_residual = gdf[col19] - gdf[col13]
comp_extremes = set()
comp_extremes.update(comp_residual.nlargest(2).index.tolist())
comp_extremes.update(comp_residual.nsmallest(2).index.tolist())
texts = []
for i in comp_extremes:
texts.append(ax.text(gdf.loc[i, col13], gdf.loc[i, col19],
gdf.loc[i, &amp;quot;region_country&amp;quot;], fontsize=7, color=LIGHT_TEXT))
adjust_text(texts, ax=ax, arrowprops=dict(arrowstyle=&amp;quot;-&amp;quot;, color=LIGHT_TEXT,
alpha=0.5, lw=0.5))
fig.suptitle(&amp;quot;HDI components: 2013 vs 2019&amp;quot;, fontsize=14, y=1.02)
plt.tight_layout()
plt.savefig(&amp;quot;esda2_scatter_components.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="esda2_scatter_components.png" alt="Three-panel scatter plot comparing Health, Education, and Income indices between 2013 and 2019.">&lt;/p>
&lt;p>The three components tell very different stories. Health and Education improved almost universally &amp;mdash; the vast majority of points lie above the 45-degree line. Income, however, tells a starkly different story: &lt;strong>71 of 153 regions (46.4%) experienced a decline&lt;/strong> in their income index between 2013 and 2019. This mixed signal &amp;mdash; education and health gains partially offset by income losses &amp;mdash; explains why the aggregate SHDI improvement was so modest (+0.005 on average). The income panel also shows wider scatter, indicating greater heterogeneity in economic trajectories across the continent.&lt;/p>
&lt;h2 id="6-choropleth-maps">6. Choropleth maps&lt;/h2>
&lt;h3 id="61-hdi-levels-across-south-america">6.1 HDI levels across South America&lt;/h3>
&lt;p>The scatter plots tell us &lt;em>what&lt;/em> changed, but not &lt;em>where&lt;/em>. Choropleth maps add the geographic dimension by coloring each region according to its SHDI value. To make the two years directly comparable, we use &lt;a href="https://pysal.org/mapclassify/generated/mapclassify.FisherJenks.html" target="_blank" rel="noopener">Fisher-Jenks natural breaks&lt;/a> computed from 2013 and held constant for 2019. Fisher-Jenks is a classification method that finds natural groupings in data by minimizing within-class variance &amp;mdash; it places break points where the data naturally separates into clusters. This way, a color change between maps reflects a genuine shift in development class, not a shifting classification scheme. The legend shows the number of regions in each class, making it easy to see how the distribution shifted.&lt;/p>
&lt;pre>&lt;code class="language-python">import mapclassify
from matplotlib.patches import Patch
# Fisher-Jenks breaks from 2013 (5 classes)
fj = mapclassify.FisherJenks(gdf[&amp;quot;shdi2013&amp;quot;].values, k=5)
breaks = fj.bins.tolist()
# Extend upper break to cover 2019 max
max_val = max(gdf[&amp;quot;shdi2013&amp;quot;].max(), gdf[&amp;quot;shdi2019&amp;quot;].max())
if max_val &amp;gt; breaks[-1]:
breaks[-1] = float(round(max_val + 0.001, 3))
# Apply same breaks to 2019
fj_2019 = mapclassify.UserDefined(gdf[&amp;quot;shdi2019&amp;quot;].values, bins=breaks)
# Class transitions
classes_2013 = fj.yb
classes_2019 = fj_2019.yb
improved = (classes_2019 &amp;gt; classes_2013).sum()
stayed = (classes_2019 == classes_2013).sum()
declined = (classes_2019 &amp;lt; classes_2013).sum()
print(f&amp;quot;Breaks (from 2013): {[round(b, 3) for b in breaks]}&amp;quot;)
print(f&amp;quot; Improved (moved up): {improved}&amp;quot;)
print(f&amp;quot; Stayed same: {stayed}&amp;quot;)
print(f&amp;quot; Declined (moved down): {declined}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Breaks (from 2013): [0.622, 0.693, 0.734, 0.789, 0.884]
Improved (moved up): 43
Stayed same: 86
Declined (moved down): 24
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python"># Class labels
class_labels = []
lower = round(gdf[&amp;quot;shdi2013&amp;quot;].min(), 2)
for b in breaks:
class_labels.append(f&amp;quot;{lower:.2f} – {b:.2f}&amp;quot;)
lower = round(b, 2)
fig, axes = plt.subplots(1, 2, figsize=(16, 12))
cmap = plt.cm.coolwarm
norm = plt.Normalize(vmin=0, vmax=len(breaks) - 1)
for ax, year_col, title, year_fj in [
(axes[0], &amp;quot;shdi2013&amp;quot;, &amp;quot;SHDI 2013&amp;quot;, fj),
(axes[1], &amp;quot;shdi2019&amp;quot;, &amp;quot;SHDI 2019&amp;quot;, fj_2019),
]:
colors = [cmap(norm(c)) for c in year_fj.yb]
gdf.plot(ax=ax, color=colors, edgecolor=GRID_LINE, linewidth=0.3)
ax.set_title(title, fontsize=14, pad=10)
ax.set_axis_off()
# Legend with region counts per class
counts = np.bincount(year_fj.yb, minlength=len(breaks))
handles = [Patch(facecolor=cmap(norm(i)), edgecolor=GRID_LINE,
label=f&amp;quot;{cl} (n={c})&amp;quot;)
for i, (cl, c) in enumerate(zip(class_labels, counts))]
ax.legend(handles=handles, title=&amp;quot;SHDI Class&amp;quot;, loc=&amp;quot;lower right&amp;quot;,
fontsize=10, title_fontsize=11)
# Label extreme regions on both maps
map_extremes = gdf[&amp;quot;shdi2019&amp;quot;].nlargest(3).index.tolist() + \
gdf[&amp;quot;shdi2019&amp;quot;].nsmallest(3).index.tolist()
for ax_map in axes:
texts = []
for i in map_extremes:
centroid = gdf.geometry.iloc[i].centroid
texts.append(ax_map.text(centroid.x, centroid.y,
gdf.loc[i, &amp;quot;region_country&amp;quot;],
fontsize=7, color=WHITE_TEXT, weight=&amp;quot;bold&amp;quot;))
adjust_text(texts, ax=ax_map, arrowprops=dict(arrowstyle=&amp;quot;-|&amp;gt;&amp;quot;,
color=LIGHT_TEXT, alpha=0.9, lw=1.2, mutation_scale=8))
plt.savefig(&amp;quot;esda2_choropleth_hdi.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="esda2_choropleth_hdi.png" alt="Side-by-side choropleth maps of SHDI in 2013 and 2019 with Fisher-Jenks classification, showing region counts per class.">&lt;/p>
&lt;p>The Fisher-Jenks classification reveals both persistence and change in South America&amp;rsquo;s development geography. Using the same 2013 breaks for both maps, &lt;strong>43 regions moved up&lt;/strong> at least one class between 2013 and 2019, &lt;strong>86 stayed&lt;/strong> in the same class, and &lt;strong>24 declined&lt;/strong>. The legend counts make the shifts visible: the lowest class shrank from n=6 to n=4, while the middle classes absorbed most of the movement. The Southern Cone and southern Brazil consistently occupy the highest class (red tones), while the Amazon basin, Guyana, and parts of Venezuela anchor the lowest class (blue tones). This visual clustering is precisely what spatial autocorrelation statistics will later quantify &amp;mdash; high values are surrounded by high values, and low values are surrounded by low values.&lt;/p>
&lt;h3 id="62-mapping-hdi-change">6.2 Mapping HDI change&lt;/h3>
&lt;p>A map of SHDI change (2019 minus 2013) reveals the geographic distribution of gains and losses, using a diverging color scale centered at zero.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(1, 1, figsize=(10, 10))
abs_max = max(abs(gdf[&amp;quot;shdi_change&amp;quot;].min()), abs(gdf[&amp;quot;shdi_change&amp;quot;].max()))
gdf.plot(column=&amp;quot;shdi_change&amp;quot;, cmap=&amp;quot;RdYlGn&amp;quot;, ax=ax, legend=False,
edgecolor=DARK_NAVY, linewidth=0.3, vmin=-abs_max, vmax=abs_max)
ax.set_title(&amp;quot;Change in SHDI (2019 - 2013)&amp;quot;, fontsize=14, pad=10)
ax.set_axis_off()
# Label biggest gainers and losers
change_top = gdf[&amp;quot;shdi_change&amp;quot;].nlargest(3).index.tolist()
change_bot = gdf[&amp;quot;shdi_change&amp;quot;].nsmallest(3).index.tolist()
texts = []
for i in change_top + change_bot:
centroid = gdf.geometry.iloc[i].centroid
texts.append(ax.text(centroid.x, centroid.y, gdf.loc[i, &amp;quot;region&amp;quot;],
fontsize=7, color=WHITE_TEXT, weight=&amp;quot;bold&amp;quot;))
adjust_text(texts, ax=ax, arrowprops=dict(arrowstyle=&amp;quot;-|&amp;gt;&amp;quot;,
color=LIGHT_TEXT, alpha=0.9, lw=1.2,
mutation_scale=8))
sm = plt.cm.ScalarMappable(cmap=&amp;quot;RdYlGn&amp;quot;,
norm=plt.Normalize(vmin=-abs_max, vmax=abs_max))
cbar = fig.colorbar(sm, ax=ax, orientation=&amp;quot;horizontal&amp;quot;,
fraction=0.03, pad=0.02, aspect=40)
cbar.set_label(&amp;quot;SHDI change (2019 - 2013)&amp;quot;)
plt.savefig(&amp;quot;esda2_choropleth_change.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="esda2_choropleth_change.png" alt="Choropleth map of SHDI change between 2013 and 2019, with diverging red-green color scale and labeled extremes.">&lt;/p>
&lt;p>The change map reveals that &lt;strong>development losses are geographically concentrated&lt;/strong>, not randomly scattered. The labels pinpoint the extremes: &lt;strong>Federal Dist. (VEN)&lt;/strong>, &lt;strong>Carabobo (VEN)&lt;/strong>, and &lt;strong>Aragua (VEN)&lt;/strong> show the deepest red (declines of up to -0.067 points), while &lt;strong>Vichada (COL)&lt;/strong>, &lt;strong>Meta (COL)&lt;/strong>, and &lt;strong>Brokopondo-Sipaliwini (SUR)&lt;/strong> show the brightest green (improvements of up to +0.045). The geographic concentration of gains and losses suggests that spatial proximity plays a role in development trajectories &amp;mdash; a hypothesis that we formalize in the next sections.&lt;/p>
&lt;h2 id="7-spatial-weights">7. Spatial weights&lt;/h2>
&lt;h3 id="71-what-is-a-spatial-weights-matrix">7.1 What is a spatial weights matrix?&lt;/h3>
&lt;p>To test for spatial clustering formally, we first need to define what &amp;ldquo;neighbor&amp;rdquo; means. A &lt;strong>spatial weights matrix&lt;/strong> $W$ is an $n \times n$ matrix where each entry $w_{ij}$ encodes the spatial relationship between regions $i$ and $j$. If two regions are neighbors, $w_{ij} &amp;gt; 0$; if not, $w_{ij} = 0$.&lt;/p>
&lt;p>The most common approach for polygon data is &lt;strong>contiguity-based weights&lt;/strong>:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Queen contiguity:&lt;/strong> Two regions are neighbors if they share any boundary point (even a single corner). Named after the queen in chess, which can move in any direction.&lt;/li>
&lt;li>&lt;strong>Rook contiguity:&lt;/strong> Two regions are neighbors only if they share an edge (not just a corner). More restrictive than Queen.&lt;/li>
&lt;/ul>
&lt;p>We use Queen contiguity because it captures the broadest definition of adjacency, which is appropriate for irregular administrative boundaries.&lt;/p>
&lt;h3 id="72-building-queen-contiguity-weights">7.2 Building Queen contiguity weights&lt;/h3>
&lt;p>PySAL&amp;rsquo;s &lt;a href="https://pysal.org/libpysal/generated/libpysal.weights.contiguity.Queen.html" target="_blank" rel="noopener">&lt;code>Queen.from_dataframe()&lt;/code>&lt;/a> builds the weights matrix directly from a GeoDataFrame. After construction, we &lt;strong>row-standardize&lt;/strong> the matrix so that each region&amp;rsquo;s neighbor weights sum to 1. This makes the spatial lag (the weighted average of neighbors' values) directly interpretable as the mean neighbor value.&lt;/p>
&lt;pre>&lt;code class="language-python">from libpysal.weights import Queen
W = Queen.from_dataframe(gdf)
W.transform = &amp;quot;r&amp;quot; # Row-standardize
print(f&amp;quot;Number of regions: {W.n}&amp;quot;)
print(f&amp;quot;Min neighbors: {W.min_neighbors}&amp;quot;)
print(f&amp;quot;Max neighbors: {W.max_neighbors}&amp;quot;)
print(f&amp;quot;Mean neighbors: {W.mean_neighbors:.2f}&amp;quot;)
print(f&amp;quot;Islands: {W.islands}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Number of regions: 153
Min neighbors: 0
Max neighbors: 11
Mean neighbors: 4.93
Islands: [87, 145]
&lt;/code>&lt;/pre>
&lt;p>The Queen contiguity matrix connects 153 regions with an average of 4.93 neighbors each (minimum 0, maximum 11). Two regions have &lt;strong>no neighbors&lt;/strong> (islands): &lt;strong>San Andres (COL)&lt;/strong> (index 87) and &lt;strong>Nueva Esparta (VEN)&lt;/strong> (index 145) &amp;mdash; both are island territories separated from the mainland by water. PySAL excludes these isolates from spatial autocorrelation calculations, as they have no defined spatial relationship with other regions. Row-standardization ensures that each region&amp;rsquo;s spatial lag is the simple average of its neighbors' values, regardless of how many neighbors it has.&lt;/p>
&lt;h3 id="73-visualizing-the-connectivity-structure">7.3 Visualizing the connectivity structure&lt;/h3>
&lt;p>The &lt;a href="https://splot.readthedocs.io/en/latest/generated/splot.libpysal.plot_spatial_weights.html" target="_blank" rel="noopener">&lt;code>plot_spatial_weights()&lt;/code>&lt;/a> function from splot overlays the weights network on the map, drawing lines between each region&amp;rsquo;s centroid and its neighbors' centroids.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(10, 10))
gdf.plot(ax=ax, facecolor=&amp;quot;none&amp;quot;, edgecolor=GRID_LINE, linewidth=0.5)
plot_spatial_weights(W, gdf, ax=ax)
ax.set_title(&amp;quot;Queen contiguity weights&amp;quot;, fontsize=14, pad=10)
ax.set_axis_off()
plt.savefig(&amp;quot;esda2_spatial_weights.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="esda2_spatial_weights.png" alt="Map of South America with Queen contiguity network overlaid, showing lines connecting neighboring region centroids.">&lt;/p>
&lt;p>The network visualization shows the connectivity structure underlying all spatial statistics in this tutorial. Denser networks appear in areas with many small regions (e.g., southern Brazil, northern Argentina), while sparser connections appear in areas with large administrative units (e.g., the Amazon basin). The two island territories (San Andres and Nueva Esparta) appear as isolated dots with no connecting lines. This network is the foundation for computing spatial lags &amp;mdash; the weighted average of neighbors' values &amp;mdash; which is the building block of Moran&amp;rsquo;s I.&lt;/p>
&lt;h2 id="8-global-spatial-autocorrelation">8. Global spatial autocorrelation&lt;/h2>
&lt;h3 id="81-morans-i-concept-and-intuition">8.1 Moran&amp;rsquo;s I: concept and intuition&lt;/h3>
&lt;p>&lt;strong>Moran&amp;rsquo;s I&lt;/strong> is the most widely used measure of global spatial autocorrelation. It answers a simple question: &lt;strong>do similar values tend to cluster together more than expected by chance?&lt;/strong> Think of it like temperature on a weather map &amp;mdash; if it is hot in one city, nearby cities are likely hot too. Moran&amp;rsquo;s I measures how strongly this &amp;ldquo;neighbor similarity&amp;rdquo; holds for development levels across South American regions.&lt;/p>
&lt;p>The statistic is defined as:&lt;/p>
&lt;p>$$I = \frac{n}{\sum_{i} \sum_{j} w_{ij}} \cdot \frac{\sum_{i} \sum_{j} w_{ij} (x_i - \bar{x})(x_j - \bar{x})}{\sum_{i} (x_i - \bar{x})^2}$$&lt;/p>
&lt;p>where $n$ is the number of regions, $w_{ij}$ are the spatial weights, $x_i$ is the value at region $i$, and $\bar{x}$ is the overall mean. In plain language: Moran&amp;rsquo;s I compares the product of deviations from the mean for each pair of neighbors. If high-value regions tend to be next to high-value regions (and low next to low), these products are positive, and $I$ is positive.&lt;/p>
&lt;ul>
&lt;li>$I \approx +1$: strong positive spatial autocorrelation (clustering of similar values)&lt;/li>
&lt;li>$I \approx 0$: no spatial pattern (random arrangement)&lt;/li>
&lt;li>$I \approx -1$: strong negative spatial autocorrelation (checkerboard pattern)&lt;/li>
&lt;/ul>
&lt;p>The expected value under spatial randomness is $E(I) = -1/(n-1)$, which approaches zero for large $n$.&lt;/p>
&lt;h3 id="82-morans-i-for-hdi-2013-and-2019">8.2 Moran&amp;rsquo;s I for HDI (2013 and 2019)&lt;/h3>
&lt;p>We compute Moran&amp;rsquo;s I with 999 random permutations to generate a reference distribution and assess statistical significance. A &lt;strong>permutation test&lt;/strong> works by randomly shuffling all the SHDI values across the map 999 times &amp;mdash; like dealing cards to random seats. If the real Moran&amp;rsquo;s I is more extreme than almost all the shuffled values, we can be confident the spatial pattern is real, not coincidence.&lt;/p>
&lt;pre>&lt;code class="language-python">from esda.moran import Moran
moran_2013 = Moran(gdf[&amp;quot;shdi2013&amp;quot;], W, permutations=999)
moran_2019 = Moran(gdf[&amp;quot;shdi2019&amp;quot;], W, permutations=999)
print(f&amp;quot;SHDI 2013: I = {moran_2013.I:.4f}, p-value = {moran_2013.p_sim:.4f}, &amp;quot;
f&amp;quot;z-score = {moran_2013.z_sim:.4f}&amp;quot;)
print(f&amp;quot;SHDI 2019: I = {moran_2019.I:.4f}, p-value = {moran_2019.p_sim:.4f}, &amp;quot;
f&amp;quot;z-score = {moran_2019.z_sim:.4f}&amp;quot;)
print(f&amp;quot;Expected I (random): {moran_2013.EI:.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">SHDI 2013: I = 0.5680, p-value = 0.0010, z-score = 10.7661
SHDI 2019: I = 0.6320, p-value = 0.0010, z-score = 11.9890
Expected I (random): -0.0066
&lt;/code>&lt;/pre>
&lt;p>Moran&amp;rsquo;s I for SHDI is &lt;strong>strongly positive and highly significant&lt;/strong> in both years. In 2013, $I = 0.5680$ (p = 0.001, z = 10.77), and in 2019, $I = 0.6320$ (p = 0.001, z = 11.99). Both values are far above the expected value under spatial randomness ($E(I) = -0.0066$), confirming that regions with similar development levels are spatially clustered. Notably, &lt;strong>spatial autocorrelation strengthened&lt;/strong> from 2013 to 2019 ($I$ increased from 0.568 to 0.632), suggesting that development clusters became more pronounced over the period &amp;mdash; the spatial divide deepened.&lt;/p>
&lt;h3 id="83-moran-scatter-plot">8.3 Moran scatter plot&lt;/h3>
&lt;p>The &lt;strong>Moran scatter plot&lt;/strong> visualizes the spatial relationship by plotting each region&amp;rsquo;s standardized value ($z_i$) against the spatial lag of its neighbors ($Wz_i$). The slope of the regression line through the scatter equals Moran&amp;rsquo;s I. The four quadrants identify the type of spatial association for each region:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>HH (top-right):&lt;/strong> High values surrounded by high neighbors&lt;/li>
&lt;li>&lt;strong>LL (bottom-left):&lt;/strong> Low values surrounded by low neighbors&lt;/li>
&lt;li>&lt;strong>LH (top-left):&lt;/strong> Low values surrounded by high neighbors (spatial outlier)&lt;/li>
&lt;li>&lt;strong>HL (bottom-right):&lt;/strong> High values surrounded by low neighbors (spatial outlier)&lt;/li>
&lt;/ul>
&lt;pre>&lt;code class="language-python">from scipy import stats as scipy_stats
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
for ax, moran_obj, year in [
(axes[0], moran_2013, &amp;quot;2013&amp;quot;),
(axes[1], moran_2019, &amp;quot;2019&amp;quot;),
]:
# Standardize values and compute spatial lag
y = gdf[f&amp;quot;shdi{year}&amp;quot;].values
z = (y - y.mean()) / y.std()
wz = lag_spatial(W, z)
ax.scatter(z, wz, color=STEEL_BLUE, s=35, alpha=0.7,
edgecolors=GRID_LINE, linewidths=0.3, zorder=3)
# Regression line (slope = Moran's I)
slope, intercept, _, _, _ = scipy_stats.linregress(z, wz)
x_range = np.array([z.min(), z.max()])
ax.plot(x_range, intercept + slope * x_range, color=WARM_ORANGE,
linewidth=1.5, zorder=2)
# Quadrant dividers at origin
ax.axhline(0, color=LIGHT_TEXT, linewidth=0.8, alpha=0.5, zorder=1)
ax.axvline(0, color=LIGHT_TEXT, linewidth=0.8, alpha=0.5, zorder=1)
# Quadrant labels
xlim, ylim = ax.get_xlim(), ax.get_ylim()
pad_x = (xlim[1] - xlim[0]) * 0.05
pad_y = (ylim[1] - ylim[0]) * 0.05
ax.text(xlim[1] - pad_x, ylim[1] - pad_y, &amp;quot;HH&amp;quot;, fontsize=13,
ha=&amp;quot;right&amp;quot;, va=&amp;quot;top&amp;quot;, color=LIGHT_TEXT, alpha=0.5)
ax.text(xlim[0] + pad_x, ylim[1] - pad_y, &amp;quot;LH&amp;quot;, fontsize=13,
ha=&amp;quot;left&amp;quot;, va=&amp;quot;top&amp;quot;, color=LIGHT_TEXT, alpha=0.5)
ax.text(xlim[0] + pad_x, ylim[0] + pad_y, &amp;quot;LL&amp;quot;, fontsize=13,
ha=&amp;quot;left&amp;quot;, va=&amp;quot;bottom&amp;quot;, color=LIGHT_TEXT, alpha=0.5)
ax.text(xlim[1] - pad_x, ylim[0] + pad_y, &amp;quot;HL&amp;quot;, fontsize=13,
ha=&amp;quot;right&amp;quot;, va=&amp;quot;bottom&amp;quot;, color=LIGHT_TEXT, alpha=0.5)
ax.set_xlabel(f&amp;quot;SHDI {year} (standardized)&amp;quot;)
ax.set_ylabel(f&amp;quot;Spatial lag of SHDI {year}&amp;quot;)
ax.set_title(f&amp;quot;({'a' if year == '2013' else 'b'}) Moran scatter plot &amp;quot;
f&amp;quot;— {year} (I = {moran_obj.I:.4f})&amp;quot;)
plt.tight_layout()
plt.savefig(&amp;quot;esda2_moran_global.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="esda2_moran_global.png" alt="Two-panel Moran scatter plot for SHDI in 2013 and 2019.">&lt;/p>
&lt;p>Both Moran scatter plots show a clear positive slope, with the majority of regions falling in the &lt;strong>HH and LL quadrants&lt;/strong> (positive spatial autocorrelation). The steeper slope in the 2019 panel visually confirms the increase in Moran&amp;rsquo;s I from 0.5680 to 0.6320. Regions in the HH quadrant (top-right) represent the Southern Cone prosperity cluster, while regions in the LL quadrant (bottom-left) represent the Amazon/Guyana deprivation cluster. The relatively few points in the LH and HL quadrants are spatial outliers &amp;mdash; regions whose development level diverges sharply from their neighbors.&lt;/p>
&lt;h2 id="9-local-spatial-autocorrelation-lisa">9. Local spatial autocorrelation (LISA)&lt;/h2>
&lt;h3 id="91-from-global-to-local-why-lisa-matters">9.1 From global to local: why LISA matters&lt;/h3>
&lt;p>Global Moran&amp;rsquo;s I gives us &lt;strong>one number&lt;/strong> for the entire map, confirming that spatial clustering exists. But it does not tell us &lt;strong>where&lt;/strong> the clusters are located. &lt;strong>Local Indicators of Spatial Association (LISA)&lt;/strong> decompose the global statistic into a contribution from each individual region (&lt;a href="https://doi.org/10.1111/j.1538-4632.1995.tb00338.x" target="_blank" rel="noopener">Anselin, 1995&lt;/a>).&lt;/p>
&lt;p>The local Moran statistic for region $i$ is:&lt;/p>
&lt;p>$$I_i = z_i \sum_{j} w_{ij} z_j$$&lt;/p>
&lt;p>where $z_i = (x_i - \bar{x}) / s$ is the standardized value at region $i$ and $\sum_{j} w_{ij} z_j$ is its spatial lag (the weighted average of neighbors' standardized values). In plain language: each region&amp;rsquo;s local statistic is the product of its own deviation from the mean and the average deviation of its neighbors. In the code, $x_i$ corresponds to &lt;code>gdf[&amp;quot;shdi2019&amp;quot;]&lt;/code> and $w_{ij}$ to the row-standardized Queen weights &lt;code>W&lt;/code>.&lt;/p>
&lt;p>Each region receives a local Moran&amp;rsquo;s I statistic and is classified into one of four types based on its quadrant in the Moran scatter plot:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>HH (High-High):&lt;/strong> A high-value region surrounded by high-value neighbors &amp;mdash; a &amp;ldquo;hot spot&amp;rdquo; or prosperity cluster&lt;/li>
&lt;li>&lt;strong>LL (Low-Low):&lt;/strong> A low-value region surrounded by low-value neighbors &amp;mdash; a &amp;ldquo;cold spot&amp;rdquo; or deprivation trap&lt;/li>
&lt;li>&lt;strong>HL (High-Low):&lt;/strong> A high-value region surrounded by low-value neighbors &amp;mdash; a positive spatial outlier&lt;/li>
&lt;li>&lt;strong>LH (Low-High):&lt;/strong> A low-value region surrounded by high-value neighbors &amp;mdash; a negative spatial outlier&lt;/li>
&lt;/ul>
&lt;p>Statistical significance is assessed via permutation tests. Only regions with p-values below a chosen threshold (here, $p &amp;lt; 0.10$) are classified as belonging to a cluster.&lt;/p>
&lt;h3 id="92-lisa-for-hdi-2019">9.2 LISA for HDI 2019&lt;/h3>
&lt;p>We compute the local Moran&amp;rsquo;s I for SHDI in 2019 and visualize the results as a Moran scatter plot with significant regions colored by quadrant (left panel) and a cluster map (right panel).&lt;/p>
&lt;pre>&lt;code class="language-python">localMoran_2019 = Moran_Local(gdf[&amp;quot;shdi2019&amp;quot;], W, permutations=999, seed=12345)
wlag_2019 = lag_spatial(W, gdf[&amp;quot;shdi2019&amp;quot;].values)
sig_2019 = localMoran_2019.p_sim &amp;lt; 0.10
q_labels = {1: &amp;quot;HH&amp;quot;, 2: &amp;quot;LH&amp;quot;, 3: &amp;quot;LL&amp;quot;, 4: &amp;quot;HL&amp;quot;}
for q_val, q_name in q_labels.items():
count = ((localMoran_2019.q == q_val) &amp;amp; sig_2019).sum()
print(f&amp;quot; {q_name}: {count}&amp;quot;)
print(f&amp;quot; Not significant: {(~sig_2019).sum()}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> HH: 30
LH: 1
LL: 37
HL: 5
Not significant: 80
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">LISA_COLORS = {1: &amp;quot;#d7191c&amp;quot;, 2: &amp;quot;#89cff0&amp;quot;, 3: &amp;quot;#2c7bb6&amp;quot;, 4: &amp;quot;#fdae61&amp;quot;}
fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(14, 6))
# (a) LISA scatter plot with colored quadrants
ax = axes[0]
slope, intercept, _, _, _ = scipy_stats.linregress(gdf[&amp;quot;shdi2019&amp;quot;].values, wlag_2019)
# Non-significant points (grey)
ns_mask = ~sig_2019
ax.scatter(gdf.loc[ns_mask, &amp;quot;shdi2019&amp;quot;], wlag_2019[ns_mask],
color=&amp;quot;#bababa&amp;quot;, s=30, alpha=0.4, edgecolors=GRID_LINE,
linewidths=0.3, label=&amp;quot;ns&amp;quot;, zorder=2)
# Significant points colored by quadrant
for q_val, q_name in q_labels.items():
mask = (localMoran_2019.q == q_val) &amp;amp; sig_2019
if mask.any():
ax.scatter(gdf.loc[mask, &amp;quot;shdi2019&amp;quot;], wlag_2019[mask],
color=LISA_COLORS[q_val], s=40, alpha=0.8,
edgecolors=GRID_LINE, linewidths=0.3,
label=q_name, zorder=3)
# Regression line
x_range = np.array([gdf[&amp;quot;shdi2019&amp;quot;].min(), gdf[&amp;quot;shdi2019&amp;quot;].max()])
ax.plot(x_range, intercept + slope * x_range, color=WARM_ORANGE,
linewidth=1.2, zorder=1)
# Crosshairs at mean
ax.axhline(wlag_2019.mean(), color=GRID_LINE, linewidth=0.8, linestyle=&amp;quot;--&amp;quot;, zorder=0)
ax.axvline(gdf[&amp;quot;shdi2019&amp;quot;].mean(), color=GRID_LINE, linewidth=0.8, linestyle=&amp;quot;--&amp;quot;, zorder=0)
ax.set_xlabel(&amp;quot;SHDI 2019&amp;quot;)
ax.set_ylabel(&amp;quot;Spatial lag of SHDI 2019&amp;quot;)
ax.set_title(f&amp;quot;(a) Moran scatter plot (I = {moran_2019.I:.4f})&amp;quot;)
# (b) LISA cluster map
lisa_cluster(localMoran_2019, gdf, p=0.10,
legend_kwds={&amp;quot;bbox_to_anchor&amp;quot;: (0.02, 0.90)}, ax=axes[1])
axes[1].set_facecolor(DARK_NAVY)
axes[1].set_title(&amp;quot;(b) LISA clusters (p &amp;lt; 0.10)&amp;quot;)
# Label extreme LISA regions on both panels
label_idx = []
hh_mask = (localMoran_2019.q == 1) &amp;amp; sig_2019
if hh_mask.any():
label_idx += gdf.loc[hh_mask, &amp;quot;shdi2019&amp;quot;].nlargest(3).index.tolist()
ll_mask = (localMoran_2019.q == 3) &amp;amp; sig_2019
if ll_mask.any():
label_idx += gdf.loc[ll_mask, &amp;quot;shdi2019&amp;quot;].nsmallest(3).index.tolist()
hl_mask = (localMoran_2019.q == 4) &amp;amp; sig_2019
if hl_mask.any():
label_idx.append(gdf.loc[hl_mask, &amp;quot;shdi2019&amp;quot;].idxmax())
lh_mask = (localMoran_2019.q == 2) &amp;amp; sig_2019
if lh_mask.any():
label_idx.append(gdf.loc[lh_mask, &amp;quot;shdi2019&amp;quot;].idxmin())
# Scatter labels
texts = [axes[0].text(gdf.loc[i, &amp;quot;shdi2019&amp;quot;], wlag_2019[i], gdf.loc[i, &amp;quot;region&amp;quot;],
fontsize=7, color=LIGHT_TEXT) for i in label_idx]
adjust_text(texts, ax=axes[0], arrowprops=dict(arrowstyle=&amp;quot;-&amp;quot;, color=LIGHT_TEXT,
alpha=0.5, lw=0.5))
# Map labels
texts = [axes[1].text(gdf.geometry.iloc[i].centroid.x, gdf.geometry.iloc[i].centroid.y,
gdf.loc[i, &amp;quot;region_country&amp;quot;], fontsize=7, color=WHITE_TEXT, weight=&amp;quot;bold&amp;quot;)
for i in label_idx]
adjust_text(texts, ax=axes[1], arrowprops=dict(arrowstyle=&amp;quot;-|&amp;gt;&amp;quot;, color=LIGHT_TEXT,
alpha=0.9, lw=1.2, mutation_scale=8))
plt.tight_layout()
plt.savefig(&amp;quot;esda2_lisa_2019.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="esda2_lisa_2019.png" alt="Two-panel LISA analysis for SHDI 2019: Moran scatter plot with labeled extreme regions (left) and LISA cluster map (right).">&lt;/p>
&lt;p>At the 10% significance level, the 2019 LISA analysis identifies &lt;strong>30 HH regions&lt;/strong>, &lt;strong>37 LL regions&lt;/strong>, &lt;strong>5 HL outliers&lt;/strong>, &lt;strong>1 LH outlier&lt;/strong>, and &lt;strong>80 non-significant regions&lt;/strong>. The labels highlight the extremes of each cluster type. The &lt;strong>three highest HH regions&lt;/strong> &amp;mdash; R. Metropolitana (CHL, SHDI = 0.883), C. Buenos Aires (ARG, 0.882), and Antofagasta (CHL, 0.875) &amp;mdash; anchor the Southern Cone prosperity core. The &lt;strong>three lowest LL regions&lt;/strong> &amp;mdash; Potaro-Siparuni (GUY, 0.558), Barima-Waini (GUY, 0.592), and Upper Takutu-Essequibo (GUY, 0.601) &amp;mdash; anchor the deprivation cluster in northern South America. &lt;strong>San Andres (COL)&lt;/strong> (0.789) appears as an HL outlier: a high-development island surrounded by lower-development mainland neighbors. &lt;strong>Potosi (BOL)&lt;/strong> (0.631) is the lone LH outlier: a lagging region surrounded by better-performing neighbors.&lt;/p>
&lt;h3 id="93-lisa-for-hdi-2013">9.3 LISA for HDI 2013&lt;/h3>
&lt;p>Repeating the analysis for 2013 allows us to compare how clusters have evolved over time.&lt;/p>
&lt;pre>&lt;code class="language-python">localMoran_2013 = Moran_Local(gdf[&amp;quot;shdi2013&amp;quot;], W, permutations=999, seed=12345)
wlag_2013 = lag_spatial(W, gdf[&amp;quot;shdi2013&amp;quot;].values)
sig_2013 = localMoran_2013.p_sim &amp;lt; 0.10
for q_val, q_name in q_labels.items():
count = ((localMoran_2013.q == q_val) &amp;amp; sig_2013).sum()
print(f&amp;quot; {q_name}: {count}&amp;quot;)
print(f&amp;quot; Not significant: {(~sig_2013).sum()}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> HH: 31
LH: 0
LL: 29
HL: 5
Not significant: 88
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(14, 6))
# (a) LISA scatter plot with colored quadrants
ax = axes[0]
slope, intercept, _, _, _ = scipy_stats.linregress(gdf[&amp;quot;shdi2013&amp;quot;].values, wlag_2013)
ns_mask = ~sig_2013
ax.scatter(gdf.loc[ns_mask, &amp;quot;shdi2013&amp;quot;], wlag_2013[ns_mask],
color=&amp;quot;#bababa&amp;quot;, s=30, alpha=0.4, edgecolors=GRID_LINE,
linewidths=0.3, label=&amp;quot;ns&amp;quot;, zorder=2)
for q_val, q_name in q_labels.items():
mask = (localMoran_2013.q == q_val) &amp;amp; sig_2013
if mask.any():
ax.scatter(gdf.loc[mask, &amp;quot;shdi2013&amp;quot;], wlag_2013[mask],
color=LISA_COLORS[q_val], s=40, alpha=0.8,
edgecolors=GRID_LINE, linewidths=0.3,
label=q_name, zorder=3)
x_range = np.array([gdf[&amp;quot;shdi2013&amp;quot;].min(), gdf[&amp;quot;shdi2013&amp;quot;].max()])
ax.plot(x_range, intercept + slope * x_range, color=WARM_ORANGE,
linewidth=1.2, zorder=1)
ax.axhline(wlag_2013.mean(), color=GRID_LINE, linewidth=0.8, linestyle=&amp;quot;--&amp;quot;, zorder=0)
ax.axvline(gdf[&amp;quot;shdi2013&amp;quot;].mean(), color=GRID_LINE, linewidth=0.8, linestyle=&amp;quot;--&amp;quot;, zorder=0)
ax.set_xlabel(&amp;quot;SHDI 2013&amp;quot;)
ax.set_ylabel(&amp;quot;Spatial lag of SHDI 2013&amp;quot;)
ax.set_title(f&amp;quot;(a) Moran scatter plot (I = {moran_2013.I:.4f})&amp;quot;)
# (b) LISA cluster map
lisa_cluster(localMoran_2013, gdf, p=0.10,
legend_kwds={&amp;quot;bbox_to_anchor&amp;quot;: (0.02, 0.90)}, ax=axes[1])
axes[1].set_facecolor(DARK_NAVY)
axes[1].set_title(&amp;quot;(b) LISA clusters (p &amp;lt; 0.10)&amp;quot;)
# Label extreme LISA regions (3 HH, 3 LL, 1 HL; no LH in 2013)
label_idx = []
hh_mask = (localMoran_2013.q == 1) &amp;amp; sig_2013
if hh_mask.any():
label_idx += gdf.loc[hh_mask, &amp;quot;shdi2013&amp;quot;].nlargest(3).index.tolist()
ll_mask = (localMoran_2013.q == 3) &amp;amp; sig_2013
if ll_mask.any():
label_idx += gdf.loc[ll_mask, &amp;quot;shdi2013&amp;quot;].nsmallest(3).index.tolist()
hl_mask = (localMoran_2013.q == 4) &amp;amp; sig_2013
if hl_mask.any():
label_idx.append(gdf.loc[hl_mask, &amp;quot;shdi2013&amp;quot;].idxmax())
lh_mask = (localMoran_2013.q == 2) &amp;amp; sig_2013
if lh_mask.any():
label_idx.append(gdf.loc[lh_mask, &amp;quot;shdi2013&amp;quot;].idxmin())
texts = [axes[0].text(gdf.loc[i, &amp;quot;shdi2013&amp;quot;], wlag_2013[i], gdf.loc[i, &amp;quot;region&amp;quot;],
fontsize=7, color=LIGHT_TEXT) for i in label_idx]
adjust_text(texts, ax=axes[0], arrowprops=dict(arrowstyle=&amp;quot;-&amp;quot;, color=LIGHT_TEXT,
alpha=0.5, lw=0.5))
texts = [axes[1].text(gdf.geometry.iloc[i].centroid.x, gdf.geometry.iloc[i].centroid.y,
gdf.loc[i, &amp;quot;region_country&amp;quot;], fontsize=7, color=WHITE_TEXT, weight=&amp;quot;bold&amp;quot;)
for i in label_idx]
adjust_text(texts, ax=axes[1], arrowprops=dict(arrowstyle=&amp;quot;-|&amp;gt;&amp;quot;, color=LIGHT_TEXT,
alpha=0.9, lw=1.2, mutation_scale=8))
plt.tight_layout()
plt.savefig(&amp;quot;esda2_lisa_2013.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="esda2_lisa_2013.png" alt="Two-panel LISA analysis for SHDI 2013: Moran scatter plot with labeled regions (left) and LISA cluster map (right).">&lt;/p>
&lt;p>The 2013 LISA analysis identifies &lt;strong>31 HH regions&lt;/strong>, &lt;strong>29 LL regions&lt;/strong>, &lt;strong>5 HL outliers&lt;/strong>, &lt;strong>0 LH outliers&lt;/strong>, and &lt;strong>88 non-significant regions&lt;/strong>. The same three HH leaders appear: C. Buenos Aires (ARG, 0.878), R. Metropolitana (CHL, 0.857), and Antofagasta (CHL, 0.852). The same three LL anchors persist: Potaro-Siparuni (GUY, 0.554), Barima-Waini (GUY, 0.577), and Upper Takutu-Essequibo (GUY, 0.585). The HL outlier in 2013 is &lt;strong>Nueva Esparta (VEN)&lt;/strong> (0.797) &amp;mdash; an island state that performed well despite its mainland neighbors. Comparing with 2019, the most striking change is the &lt;strong>expansion of the LL cluster&lt;/strong> from 29 to 37 regions, while the HH cluster remained roughly stable (31 to 30). This asymmetric evolution is consistent with the income decline concentrated in Venezuela, which pulled more regions into the deprivation cluster.&lt;/p>
&lt;h3 id="94-comparing-lisa-clusters-across-time">9.4 Comparing LISA clusters across time&lt;/h3>
&lt;p>A transition table reveals how regions moved between LISA categories from 2013 to 2019.&lt;/p>
&lt;pre>&lt;code class="language-python">sig_2013 = localMoran_2013.p_sim &amp;lt; 0.10
sig_2019 = localMoran_2019.p_sim &amp;lt; 0.10
q_labels = {1: &amp;quot;HH&amp;quot;, 2: &amp;quot;LH&amp;quot;, 3: &amp;quot;LL&amp;quot;, 4: &amp;quot;HL&amp;quot;}
labels_2013 = [&amp;quot;ns&amp;quot; if not sig_2013[i] else q_labels[localMoran_2013.q[i]]
for i in range(len(gdf))]
labels_2019 = [&amp;quot;ns&amp;quot; if not sig_2019[i] else q_labels[localMoran_2019.q[i]]
for i in range(len(gdf))]
transition_df = pd.crosstab(
pd.Series(labels_2013, name=&amp;quot;2013&amp;quot;),
pd.Series(labels_2019, name=&amp;quot;2019&amp;quot;)
)
print(transition_df.to_string())
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">2019 HH HL LH LL ns
2013
HH 27 0 0 0 4
HL 0 2 0 2 1
LL 0 2 0 18 9
ns 3 1 1 17 66
&lt;/code>&lt;/pre>
&lt;p>The transition table reveals strong &lt;strong>cluster persistence&lt;/strong>. Of the 31 regions in the HH cluster in 2013, &lt;strong>27 remained HH&lt;/strong> in 2019 (87% persistence), while only 4 became non-significant. Of the 29 LL regions in 2013, &lt;strong>18 remained LL&lt;/strong> (62% persistence). The most notable transition is from non-significant to LL: &lt;strong>17 regions&lt;/strong> that were not part of any significant cluster in 2013 joined the low-development cluster by 2019. This expansion of the LL cluster, combined with the high persistence of HH, paints a picture of entrenched spatial inequality &amp;mdash; prosperity clusters are stable, and deprivation clusters are growing.&lt;/p>
&lt;h2 id="10-space-time-dynamics">10. Space-time dynamics&lt;/h2>
&lt;h3 id="101-directional-moran-scatter-plot">10.1 Directional Moran scatter plot&lt;/h3>
&lt;p>The LISA transition table tracks changes in statistical significance, but regions can also move &lt;em>within&lt;/em> the Moran scatter plot even without crossing significance thresholds. A &lt;strong>directional Moran scatter plot&lt;/strong> shows the movement vector for each region from its 2013 position to its 2019 position in the (standardized value, spatial lag) space. The arrows reveal the direction and magnitude of change in both a region&amp;rsquo;s own development and its neighbors' development.&lt;/p>
&lt;p>To make the two periods comparable, we standardize both years using the &lt;strong>pooled mean and standard deviation&lt;/strong> (across both periods combined), following the same logic as the &lt;a href="https://carlos-mendez.org/post/python_pca2/">Pooled PCA tutorial&lt;/a>.&lt;/p>
&lt;pre>&lt;code class="language-python">from libpysal.weights import lag_spatial
# Standardize using pooled parameters
mean_all = np.mean(np.concatenate([gdf[&amp;quot;shdi2013&amp;quot;].values, gdf[&amp;quot;shdi2019&amp;quot;].values]))
std_all = np.std(np.concatenate([gdf[&amp;quot;shdi2013&amp;quot;].values, gdf[&amp;quot;shdi2019&amp;quot;].values]))
z_2013 = (gdf[&amp;quot;shdi2013&amp;quot;].values - mean_all) / std_all
z_2019 = (gdf[&amp;quot;shdi2019&amp;quot;].values - mean_all) / std_all
# Spatial lags
wz_2013 = lag_spatial(W, z_2013)
wz_2019 = lag_spatial(W, z_2019)
fig, ax = plt.subplots(figsize=(9, 8))
for i in range(len(gdf)):
ax.annotate(&amp;quot;&amp;quot;, xy=(z_2019[i], wz_2019[i]),
xytext=(z_2013[i], wz_2013[i]),
arrowprops=dict(arrowstyle=&amp;quot;-&amp;gt;&amp;quot;, color=STEEL_BLUE,
alpha=0.5, lw=0.8))
ax.scatter(z_2013, wz_2013, color=WARM_ORANGE, s=20, alpha=0.6,
label=&amp;quot;2013&amp;quot;, zorder=4)
ax.scatter(z_2019, wz_2019, color=TEAL, s=20, alpha=0.6,
label=&amp;quot;2019&amp;quot;, zorder=4)
ax.axhline(0, color=GRID_LINE, linewidth=1)
ax.axvline(0, color=GRID_LINE, linewidth=1)
ax.set_xlabel(&amp;quot;SHDI (standardized)&amp;quot;)
ax.set_ylabel(&amp;quot;Spatial lag of SHDI&amp;quot;)
ax.set_title(&amp;quot;Directional Moran scatter plot: movements from 2013 to 2019&amp;quot;)
ax.legend()
plt.savefig(&amp;quot;esda2_directional_moran.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="esda2_directional_moran.png" alt="Directional Moran scatter plot showing movement vectors from 2013 to 2019 positions for each region.">&lt;/p>
&lt;pre>&lt;code class="language-python"># Classify quadrant transitions
q_2013 = np.where((z_2013 &amp;gt;= 0) &amp;amp; (wz_2013 &amp;gt;= 0), &amp;quot;HH&amp;quot;,
np.where((z_2013 &amp;lt; 0) &amp;amp; (wz_2013 &amp;gt;= 0), &amp;quot;LH&amp;quot;,
np.where((z_2013 &amp;lt; 0) &amp;amp; (wz_2013 &amp;lt; 0), &amp;quot;LL&amp;quot;, &amp;quot;HL&amp;quot;)))
q_2019 = np.where((z_2019 &amp;gt;= 0) &amp;amp; (wz_2019 &amp;gt;= 0), &amp;quot;HH&amp;quot;,
np.where((z_2019 &amp;lt; 0) &amp;amp; (wz_2019 &amp;gt;= 0), &amp;quot;LH&amp;quot;,
np.where((z_2019 &amp;lt; 0) &amp;amp; (wz_2019 &amp;lt; 0), &amp;quot;LL&amp;quot;, &amp;quot;HL&amp;quot;)))
transition_moran = pd.crosstab(
pd.Series(q_2013, name=&amp;quot;2013&amp;quot;),
pd.Series(q_2019, name=&amp;quot;2019&amp;quot;)
)
print(transition_moran.to_string())
stayed = (q_2013 == q_2019).sum()
moved = (q_2013 != q_2019).sum()
print(f&amp;quot;\nStayed in same quadrant: {stayed} ({stayed/len(gdf)*100:.1f}%)&amp;quot;)
print(f&amp;quot;Moved to different quadrant: {moved} ({moved/len(gdf)*100:.1f}%)&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">2019 HH HL LH LL
2013
HH 41 1 2 10
HL 9 6 0 5
LH 0 0 2 3
LL 7 10 11 46
Stayed in same quadrant: 95 (62.1%)
Moved to different quadrant: 58 (37.9%)
&lt;/code>&lt;/pre>
&lt;p>The directional Moran scatter plot reveals the space-time dynamics of South American development. &lt;strong>95 regions (62.1%)&lt;/strong> remained in the same Moran scatter plot quadrant between 2013 and 2019, while &lt;strong>58 (37.9%)&lt;/strong> crossed quadrant boundaries. The most stable quadrants are HH (41 of 54 stayed, 76%) and LL (46 of 74 stayed, 62%), confirming that both prosperity and deprivation clusters are persistent. The most common transitions are LL to LH (11 regions) and HL to HH (9 regions), suggesting some upward mobility at the boundary of the prosperity cluster. However, the 10 HH-to-LL transitions highlight that the Venezuelan crisis pulled previously well-performing regions into the low-development quadrant &amp;mdash; a dramatic downward trajectory that affected both the regions themselves and their neighbors.&lt;/p>
&lt;h3 id="102-country-focus-venezuela-vs-bolivia">10.2 Country focus: Venezuela vs Bolivia&lt;/h3>
&lt;p>Venezuela and Bolivia offer a stark contrast in subnational development trajectories. In 2013, Venezuela&amp;rsquo;s regions were spread across the upper half of the Moran scatter plot &amp;mdash; 13 of 24 regions sat in the HH quadrant, reflecting relatively high development levels and high-development neighbors. Bolivia&amp;rsquo;s 9 regions, by contrast, were concentrated in the lower-left corner (8 in LL, 1 in LH). By 2019, these two countries had moved in opposite directions. We isolate them in the directional Moran scatter plot to compare their movement vectors.&lt;/p>
&lt;pre>&lt;code class="language-python"># Filter Venezuela and Bolivia regions
ven_mask = gdf[&amp;quot;country&amp;quot;] == &amp;quot;Venezuela&amp;quot;
bol_mask = gdf[&amp;quot;country&amp;quot;] == &amp;quot;Bolivia&amp;quot;
# Shared axis limits (from the full dataset, for comparability)
all_z = np.concatenate([z_2013, z_2019])
all_wz = np.concatenate([wz_2013, wz_2019])
pad = 0.3
shared_xlim = (all_z.min() - pad, all_z.max() + pad)
shared_ylim = (all_wz.min() - pad, all_wz.max() + pad)
fig, axes = plt.subplots(nrows=1, ncols=2, figsize=(16, 7))
for ax, mask, title in [
(axes[0], bol_mask, &amp;quot;(a) Bolivia&amp;quot;),
(axes[1], ven_mask, &amp;quot;(b) Venezuela&amp;quot;),
]:
# Background: all regions (grey, faded)
for i in range(len(gdf)):
ax.annotate(&amp;quot;&amp;quot;, xy=(z_2019[i], wz_2019[i]),
xytext=(z_2013[i], wz_2013[i]),
arrowprops=dict(arrowstyle=&amp;quot;-&amp;gt;&amp;quot;, color=GRID_LINE,
alpha=0.15, lw=0.5))
ax.scatter(z_2013, wz_2013, color=GRID_LINE, s=10, alpha=0.15, zorder=2)
ax.scatter(z_2019, wz_2019, color=GRID_LINE, s=10, alpha=0.15, zorder=2)
# Highlighted country
for i in gdf.index[mask]:
ax.annotate(&amp;quot;&amp;quot;, xy=(z_2019[i], wz_2019[i]),
xytext=(z_2013[i], wz_2013[i]),
arrowprops=dict(arrowstyle=&amp;quot;-&amp;gt;&amp;quot;, color=STEEL_BLUE,
alpha=0.7, lw=1.0))
ax.scatter(z_2013[mask], wz_2013[mask], color=WARM_ORANGE, s=30,
alpha=0.8, edgecolors=GRID_LINE, linewidths=0.3,
label=&amp;quot;2013&amp;quot;, zorder=5)
ax.scatter(z_2019[mask], wz_2019[mask], color=TEAL, s=30,
alpha=0.8, edgecolors=GRID_LINE, linewidths=0.3,
label=&amp;quot;2019&amp;quot;, zorder=5)
# Labels at 2019 positions
texts = []
for i in gdf.index[mask]:
texts.append(ax.text(z_2019[i], wz_2019[i], gdf.loc[i, &amp;quot;region&amp;quot;],
fontsize=7, color=LIGHT_TEXT))
adjust_text(texts, ax=ax, arrowprops=dict(arrowstyle=&amp;quot;-&amp;quot;, color=LIGHT_TEXT,
alpha=0.5, lw=0.5))
# Quadrant lines and labels
ax.axhline(0, color=GRID_LINE, linewidth=1, zorder=1)
ax.axvline(0, color=GRID_LINE, linewidth=1, zorder=1)
ax.set_xlim(shared_xlim)
ax.set_ylim(shared_ylim)
ox = (shared_xlim[1] - shared_xlim[0]) * 0.05
oy = (shared_ylim[1] - shared_ylim[0]) * 0.05
for lbl, ha, va, x, y in [
(&amp;quot;HH&amp;quot;, &amp;quot;right&amp;quot;, &amp;quot;top&amp;quot;, shared_xlim[1] - ox, shared_ylim[1] - oy),
(&amp;quot;LH&amp;quot;, &amp;quot;left&amp;quot;, &amp;quot;top&amp;quot;, shared_xlim[0] + ox, shared_ylim[1] - oy),
(&amp;quot;LL&amp;quot;, &amp;quot;left&amp;quot;, &amp;quot;bottom&amp;quot;, shared_xlim[0] + ox, shared_ylim[0] + oy),
(&amp;quot;HL&amp;quot;, &amp;quot;right&amp;quot;, &amp;quot;bottom&amp;quot;, shared_xlim[1] - ox, shared_ylim[0] + oy),
]:
ax.text(x, y, lbl, fontsize=14, ha=ha, va=va,
color=LIGHT_TEXT, alpha=0.6)
ax.set_xlabel(&amp;quot;SHDI (standardized)&amp;quot;)
ax.set_ylabel(&amp;quot;Spatial lag of SHDI&amp;quot;)
ax.set_title(title)
ax.legend(fontsize=8)
plt.tight_layout()
plt.savefig(&amp;quot;esda2_directional_ven_bol.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="esda2_directional_ven_bol.png" alt="Side-by-side directional Moran scatter plots for Bolivia (left) and Venezuela (right), showing movement vectors from 2013 to 2019.">&lt;/p>
&lt;pre>&lt;code class="language-python"># Summary statistics for Venezuela and Bolivia
for country, mask in [(&amp;quot;Venezuela&amp;quot;, ven_mask), (&amp;quot;Bolivia&amp;quot;, bol_mask)]:
n = mask.sum()
mean_change = gdf.loc[mask, &amp;quot;shdi_change&amp;quot;].mean()
min_change = gdf.loc[mask, &amp;quot;shdi_change&amp;quot;].min()
max_change = gdf.loc[mask, &amp;quot;shdi_change&amp;quot;].max()
# Quadrant transitions
q13 = q_2013[mask]
q19 = q_2019[mask]
stayed = (q13 == q19).sum()
moved = (q13 != q19).sum()
print(f&amp;quot;\n{country} ({n} regions):&amp;quot;)
print(f&amp;quot; Mean SHDI change: {mean_change:+.4f}&amp;quot;)
print(f&amp;quot; Range: [{min_change:+.4f}, {max_change:+.4f}]&amp;quot;)
print(f&amp;quot; Quadrant stability: {stayed} stayed, {moved} moved&amp;quot;)
print(f&amp;quot; 2013 quadrants: {', '.join(f'{q}={c}' for q, c in zip(*np.unique(q13, return_counts=True)))}&amp;quot;)
print(f&amp;quot; 2019 quadrants: {', '.join(f'{q}={c}' for q, c in zip(*np.unique(q19, return_counts=True)))}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Venezuela (24 regions):
Mean SHDI change: -0.0653
Range: [-0.0670, -0.0640]
Quadrant stability: 3 stayed, 21 moved
2013 quadrants: HH=13, HL=5, LH=3, LL=3
2019 quadrants: HL=1, LH=2, LL=21
Bolivia (9 regions):
Mean SHDI change: +0.0333
Range: [+0.0300, +0.0350]
Quadrant stability: 7 stayed, 2 moved
2013 quadrants: LH=1, LL=8
2019 quadrants: HL=1, LH=2, LL=6
&lt;/code>&lt;/pre>
&lt;p>Panel (a) shows Bolivia&amp;rsquo;s modest but consistent rightward movement. All 9 regions started in the lower-left portion of the plot (8 in LL, 1 in LH) and shifted rightward by 2019, reflecting genuine improvement in own-region development. The mean SHDI change was &lt;strong>+0.033&lt;/strong>, with a remarkably tight range ([+0.030, +0.035]) indicating that the gains were broad-based across all Bolivian regions. &lt;strong>Seven of 9 regions (78%) remained in the same quadrant&lt;/strong>, with 2 moving out of LL &amp;mdash; one to LH and one to HL. The arrows are short and point consistently to the right, meaning Bolivia improved its own development levels without substantially changing the spatial lag (its neighbors' conditions remained similar). This pattern suggests steady, internally driven progress that has not yet been large enough to escape the low-development spatial cluster.&lt;/p>
&lt;p>Panel (b) tells the opposite story. &lt;strong>Venezuela&amp;rsquo;s 24 regions experienced the most dramatic downward shift&lt;/strong> in the entire dataset, with a mean SHDI change of &lt;strong>-0.065&lt;/strong>. In 2013, Venezuelan regions were spread across the upper portion of the plot &amp;mdash; 13 in HH, 5 in HL, 3 in LH, and only 3 in LL. By 2019, the picture had completely inverted: &lt;strong>21 of 24 regions (88%) crossed quadrant boundaries&lt;/strong>, with 21 ending in the LL quadrant. The arrows sweep uniformly downward and to the left, reflecting both the collapse of each region&amp;rsquo;s own development level and the negative spillover onto its neighbors' spatial lags. The narrow range of change ([-0.067, -0.064]) reveals that the crisis was not localized to a few regions &amp;mdash; it was a near-uniform national collapse that dragged every Venezuelan region, regardless of its 2013 starting point, into the low-development quadrant.&lt;/p>
&lt;p>The juxtaposition is instructive. Bolivia&amp;rsquo;s arrows are short, rightward, and clustered &amp;mdash; a country making incremental gains within a stable spatial structure. Venezuela&amp;rsquo;s arrows are long, southwest-pointing, and tightly bundled &amp;mdash; a country experiencing systemic collapse that erased decades of development advantage in just six years. The contrast highlights how economic crises can propagate spatially: Venezuela&amp;rsquo;s decline did not just reduce its own regions' development, it also pulled down the spatial lags of neighboring Colombian and Brazilian border regions, contributing to the expansion of the LL cluster documented in Section 9.&lt;/p>
&lt;h2 id="11-discussion">11. Discussion&lt;/h2>
&lt;p>&lt;strong>Spatial autocorrelation in South American human development is strong and persistent.&lt;/strong> Global Moran&amp;rsquo;s I increased from 0.568 in 2013 to 0.632 in 2019 (both p = 0.001), indicating that the spatial clustering of development levels strengthened over the period. This means the development gap between prosperous and lagging regions is not only large but spatially structured &amp;mdash; high-development regions form a contiguous band across the Southern Cone, while low-development regions form an equally contiguous band across the Amazon basin and northern South America.&lt;/p>
&lt;p>The LISA analysis pinpoints these clusters with precision. In 2019, 30 regions form a significant HH cluster (high development surrounded by high-development neighbors) and 37 regions form a significant LL cluster (low development surrounded by low-development neighbors). The LL cluster expanded from 29 to 37 regions between 2013 and 2019, driven primarily by Venezuela&amp;rsquo;s economic crisis and its spillover effects on neighboring regions. The HH cluster remained stable (31 to 30), with 87% persistence &amp;mdash; a sign that prosperity corridors in the Southern Cone are structurally entrenched.&lt;/p>
&lt;p>The space-time analysis reveals that 62% of regions stayed in the same Moran scatter plot quadrant, but the 38% that moved tell an important story. The most concerning transitions are the 10 regions that moved from HH to LL and the 17 previously non-significant regions that joined the LL LISA cluster. These movements are concentrated in Venezuela and its neighbors, illustrating how economic shocks can propagate spatially.&lt;/p>
&lt;p>The &lt;strong>Venezuela&amp;ndash;Bolivia comparison&lt;/strong> crystallizes the two forces shaping South America&amp;rsquo;s spatial development landscape. Venezuela&amp;rsquo;s 24 regions collapsed nearly uniformly (mean SHDI change of -0.065, with 88% crossing quadrant boundaries), transforming a country that was largely in the HH quadrant in 2013 into one almost entirely in the LL quadrant by 2019. Bolivia&amp;rsquo;s 9 regions, starting from a much lower base, improved steadily (+0.033) with 78% quadrant stability. These divergent trajectories illustrate that spatial clusters are not static: they can expand rapidly through crisis-driven contagion (Venezuela pulling its neighbors downward) or contract slowly through sustained internal improvement (Bolivia gradually lifting its regions rightward in the Moran scatter plot). The fact that Venezuela&amp;rsquo;s decline was spatially contagious &amp;mdash; dragging down the spatial lags of neighboring Colombian and Brazilian border regions &amp;mdash; while Bolivia&amp;rsquo;s improvement remained spatially contained underscores an asymmetry: negative shocks propagate faster and farther across borders than positive ones.&lt;/p>
&lt;p>For policy, these findings suggest that &lt;strong>spatially targeted interventions&lt;/strong> may be more effective than uniform national programs. The persistent LL clusters represent development traps where a region&amp;rsquo;s own conditions are reinforced by the equally poor conditions of its neighbors. Breaking these traps may require coordinated cross-regional or cross-border programs that address the spatial dimension of underdevelopment. Bolivia&amp;rsquo;s experience suggests that broad-based national improvement can lift all regions, but escaping the low-development spatial cluster may require the additional step of improving neighbors' conditions simultaneously &amp;mdash; a challenge that calls for cross-border cooperation.&lt;/p>
&lt;h2 id="12-summary-and-next-steps">12. Summary and next steps&lt;/h2>
&lt;p>&lt;strong>Key takeaways:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Method insight:&lt;/strong> ESDA reveals spatial patterns invisible in aspatial analysis. The same dataset that shows a modest aggregate improvement (+0.005 SHDI) conceals a deepening spatial divide &amp;mdash; Moran&amp;rsquo;s I increased from 0.568 to 0.632, meaning spatial clustering strengthened between 2013 and 2019.&lt;/li>
&lt;li>&lt;strong>Data insight:&lt;/strong> 30 HH and 37 LL regions form statistically significant clusters at the 10% level. The LL cluster expanded by 8 regions (from 29 to 37), while the HH cluster remained stable. Cluster persistence is high: 87% for HH and 62% for LL, indicating entrenched spatial inequality.&lt;/li>
&lt;li>&lt;strong>Country insight:&lt;/strong> Venezuela and Bolivia illustrate contrasting development dynamics. Venezuela&amp;rsquo;s 24 regions collapsed nearly uniformly (mean -0.065), with 88% crossing quadrant boundaries from the upper to the lower portion of the Moran scatter plot. Bolivia&amp;rsquo;s 9 regions improved steadily (+0.033) with 78% quadrant stability, showing broad-based gains that have not yet been large enough to escape the LL spatial cluster.&lt;/li>
&lt;li>&lt;strong>Limitation:&lt;/strong> Queen contiguity assumes shared borders, which excludes island territories (San Andres, Nueva Esparta) and may not capture cross-water economic linkages. With only two time periods (2013 and 2019), we cannot distinguish permanent structural clusters from temporary effects of the Venezuelan crisis. The p = 0.10 significance threshold is relatively permissive.&lt;/li>
&lt;li>&lt;strong>Next step:&lt;/strong> Extend the analysis with spatial regression models (spatial lag and spatial error models) to test whether a region&amp;rsquo;s development is directly influenced by its neighbors' development, or whether the clustering is driven by shared underlying factors. Bivariate LISA could reveal whether income clusters coincide with education clusters. Adding more time periods (2000&amp;ndash;2019) from the full Global Data Lab series would enable Spatial Markov chain analysis of cluster transition probabilities.&lt;/li>
&lt;/ul>
&lt;h2 id="13-exercises">13. Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Income clusters.&lt;/strong> Repeat the LISA analysis for the income index (&lt;code>incindex2019&lt;/code>) instead of SHDI. Are income clusters in the same locations as HDI clusters? How many regions belong to both an income LL and an HDI LL cluster?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Alternative weights.&lt;/strong> Build k-nearest neighbors weights (&lt;code>KNN&lt;/code> from &lt;code>libpysal.weights&lt;/code>) with $k = 5$ and Rook contiguity (&lt;code>Rook&lt;/code> from &lt;code>libpysal.weights&lt;/code>) instead of Queen contiguity. How does Moran&amp;rsquo;s I change under each specification? Does the KNN approach resolve the island problem?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Bivariate Moran.&lt;/strong> Use &lt;a href="https://pysal.org/esda/generated/esda.Moran_BV.html" target="_blank" rel="noopener">&lt;code>Moran_BV&lt;/code>&lt;/a> from esda to compute the bivariate Moran&amp;rsquo;s I between education and income indices. Are regions with high education surrounded by regions with high income, or are the two dimensions spatially independent?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Spatial autocorrelation of change.&lt;/strong> Compute Moran&amp;rsquo;s I for &lt;code>shdi_change&lt;/code> instead of the level variables. Is the &lt;em>change&lt;/em> in SHDI between 2013 and 2019 itself spatially clustered? Compare the result with the change choropleth from Section 6.2. Hint: &lt;code>Moran(gdf[&amp;quot;shdi_change&amp;quot;], W, permutations=999)&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Component-level Moran&amp;rsquo;s I.&lt;/strong> Compute Moran&amp;rsquo;s I for the health, education, and income indices separately in both 2013 and 2019. Which component shows the strongest spatial autocorrelation? Does the income index &amp;mdash; which declined in 46% of regions &amp;mdash; show a different spatial pattern than health or education?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Multiple testing sensitivity.&lt;/strong> Re-run the 2019 LISA analysis at $p &amp;lt; 0.05$ instead of $p &amp;lt; 0.10$. How many HH and LL regions survive the stricter threshold? Research the Bonferroni correction ($0.05 / 153 \approx 0.0003$) and the False Discovery Rate (FDR) procedure &amp;mdash; how would these affect the cluster counts?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Neighbor count distribution.&lt;/strong> Plot a histogram of the number of neighbors per region from the Queen weights matrix (use &lt;code>W.cardinalities&lt;/code>). What is the shape of the distribution? Which regions have the most and fewest neighbors, and why?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Is the Moran&amp;rsquo;s I increase significant?&lt;/strong> Moran&amp;rsquo;s I rose from 0.568 to 0.632 between 2013 and 2019. But does this difference pass a significance test? Try a bootstrap approach: pool the 2013 and 2019 SHDI values, randomly assign them to the two periods 999 times, and compute the difference in Moran&amp;rsquo;s I each time. Where does the observed difference (0.064) fall in the bootstrap distribution?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Moran&amp;rsquo;s I excluding Venezuela.&lt;/strong> Recompute Moran&amp;rsquo;s I for 2013 and 2019 after dropping Venezuela&amp;rsquo;s 24 regions (rebuild the Queen weights on the subset GeoDataFrame). Does the increase in spatial autocorrelation survive? If not, the &amp;ldquo;deepening spatial divide&amp;rdquo; may be driven by a single country&amp;rsquo;s crisis rather than a continent-wide trend.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>LISA significance map.&lt;/strong> Create a choropleth map coloring each region by its LISA p-value (&lt;code>localMoran_2019.p_sim&lt;/code>) using a sequential colormap. How many regions have $p &amp;lt; 0.01$ vs $p &amp;lt; 0.05$ vs $p &amp;lt; 0.10$? Are the deeply significant regions ($p &amp;lt; 0.01$) concentrated in the same locations as the cluster map from Section 9.2?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="14-references">14. References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://doi.org/10.1111/j.1538-4632.1995.tb00338.x" target="_blank" rel="noopener">Anselin, L. (1995). Local Indicators of Spatial Association &amp;mdash; LISA. &lt;em>Geographical Analysis&lt;/em>, 27(2), 93&amp;ndash;115.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1038/sdata.2019.38" target="_blank" rel="noopener">Smits, J. and Permanyer, I. (2019). The Subnational Human Development Database. &lt;em>Scientific Data&lt;/em>, 6, 190038.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://rrs.scholasticahq.com/article/8285" target="_blank" rel="noopener">Rey, S. J. and Anselin, L. (2007). PySAL: A Python Library of Spatial Analytical Methods. &lt;em>Review of Regional Studies&lt;/em>, 37(1), 5&amp;ndash;27.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://globaldatalab.org/shdi/" target="_blank" rel="noopener">Global Data Lab &amp;mdash; Subnational Human Development Index&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://pysal.org/esda/" target="_blank" rel="noopener">PySAL ESDA documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://splot.readthedocs.io/" target="_blank" rel="noopener">splot documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://carlos-mendez.org/post/python_pca2/">Mendez, C. (2026). Pooled PCA for Building Development Indicators Across Time.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://carlos-mendez.org/publication/20210318-economia/" target="_blank" rel="noopener">Mendez, C. and Gonzales, E. (2021). Human Capital Constraints, Spatial Dependence, and Regionalization in Bolivia. &lt;em>Economia&lt;/em>, 44(87).&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://carlos-mendez.org/post/python_monitor_regional_development/">Mendez, C. (2026). Monitoring Regional Development with Python.&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>Multiscale Geographically Weighted Regression: Spatially Varying Economic Convergence in Indonesia</title><link>https://carlos-mendez.org/post/python_mgwr/</link><pubDate>Sun, 22 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_mgwr/</guid><description>&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>When we ask &amp;ldquo;do poorer regions catch up to richer ones?&amp;rdquo;, the standard approach is to run a single regression across all regions and report one coefficient. But what if the answer depends on &lt;em>where&lt;/em> you look? A negative coefficient in Sumatra does not mean the same process is at work in Papua. A global regression forces every district onto the same line &amp;mdash; and in doing so, it may hide the most interesting part of the story.&lt;/p>
&lt;p>&lt;strong>Multiscale Geographically Weighted Regression (MGWR)&lt;/strong> addresses this by estimating a separate set of coefficients at every location, weighted by proximity. Its key innovation over standard GWR is that each variable is allowed to operate at its own spatial scale. The intercept (representing baseline growth conditions) might vary smoothly across large regions, while the convergence coefficient might shift sharply between neighboring districts. MGWR discovers these scales from the data rather than imposing a single bandwidth on all variables.&lt;/p>
&lt;p>This tutorial applies MGWR to &lt;strong>514 Indonesian districts&lt;/strong> to answer: &lt;strong>does economic catching-up happen at the same pace everywhere in Indonesia, or does geography shape how fast poorer districts close the gap?&lt;/strong> We progress from a global regression baseline through MGWR estimation and coefficient mapping, revealing that the global R² of 0.214 jumps to 0.762 once we allow the relationship to vary across space.&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Understand why a single regression coefficient may hide important spatial variation&lt;/li>
&lt;li>Estimate location-specific relationships with spatially varying coefficients&lt;/li>
&lt;li>Apply MGWR to allow each variable to operate at its own spatial scale&lt;/li>
&lt;li>Map and interpret spatially varying coefficients across Indonesia&lt;/li>
&lt;li>Compare global OLS vs MGWR model fit and diagnostics&lt;/li>
&lt;/ul>
&lt;h2 id="2-the-modeling-pipeline">2. The modeling pipeline&lt;/h2>
&lt;p>The analysis follows a natural progression: start with a simple global model, visualize the spatial patterns it cannot capture, then let MGWR reveal the local structure.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
A[&amp;quot;&amp;lt;b&amp;gt;Step 1&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Load &amp;amp;&amp;lt;br/&amp;gt;Explore&amp;quot;] --&amp;gt; B[&amp;quot;&amp;lt;b&amp;gt;Step 2&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Map&amp;lt;br/&amp;gt;Variables&amp;quot;]
B --&amp;gt; C[&amp;quot;&amp;lt;b&amp;gt;Step 3&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Global&amp;lt;br/&amp;gt;OLS&amp;quot;]
C --&amp;gt; D[&amp;quot;&amp;lt;b&amp;gt;Step 4&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;MGWR&amp;lt;br/&amp;gt;Estimation&amp;quot;]
D --&amp;gt; E[&amp;quot;&amp;lt;b&amp;gt;Step 5&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Map&amp;lt;br/&amp;gt;Coefficients&amp;quot;]
E --&amp;gt; F[&amp;quot;&amp;lt;b&amp;gt;Step 6&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Significance&amp;lt;br/&amp;gt;&amp;amp; Compare&amp;quot;]
style A fill:#141413,stroke:#6a9bcc,color:#fff
style B fill:#d97757,stroke:#141413,color:#fff
style C fill:#6a9bcc,stroke:#141413,color:#fff
style D fill:#00d4c8,stroke:#141413,color:#fff
style E fill:#00d4c8,stroke:#141413,color:#fff
style F fill:#1a3a8a,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;h2 id="3-setup-and-imports">3. Setup and imports&lt;/h2>
&lt;p>The analysis uses &lt;a href="https://mgwr.readthedocs.io/" target="_blank" rel="noopener">mgwr&lt;/a> for multiscale regression, &lt;a href="https://geopandas.org/" target="_blank" rel="noopener">GeoPandas&lt;/a> for spatial data, and &lt;a href="https://pysal.org/mapclassify/" target="_blank" rel="noopener">mapclassify&lt;/a> for choropleth classification.&lt;/p>
&lt;pre>&lt;code class="language-python">import numpy as np
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
import mapclassify
from scipy import stats
from mgwr.gwr import MGWR
from mgwr.sel_bw import Sel_BW
import warnings
warnings.filterwarnings(&amp;quot;ignore&amp;quot;)
# Site color palette
STEEL_BLUE = &amp;quot;#6a9bcc&amp;quot;
WARM_ORANGE = &amp;quot;#d97757&amp;quot;
NEAR_BLACK = &amp;quot;#141413&amp;quot;
TEAL = &amp;quot;#00d4c8&amp;quot;
&lt;/code>&lt;/pre>
&lt;details>
&lt;summary>Dark theme figure styling (click to expand)&lt;/summary>
&lt;pre>&lt;code class="language-python">DARK_NAVY = &amp;quot;#0f1729&amp;quot;
GRID_LINE = &amp;quot;#1f2b5e&amp;quot;
LIGHT_TEXT = &amp;quot;#c8d0e0&amp;quot;
WHITE_TEXT = &amp;quot;#e8ecf2&amp;quot;
plt.rcParams.update({
&amp;quot;figure.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.edgecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.linewidth&amp;quot;: 0,
&amp;quot;axes.labelcolor&amp;quot;: LIGHT_TEXT,
&amp;quot;axes.titlecolor&amp;quot;: WHITE_TEXT,
&amp;quot;axes.spines.top&amp;quot;: False,
&amp;quot;axes.spines.right&amp;quot;: False,
&amp;quot;axes.spines.left&amp;quot;: False,
&amp;quot;axes.spines.bottom&amp;quot;: False,
&amp;quot;axes.grid&amp;quot;: True,
&amp;quot;grid.color&amp;quot;: GRID_LINE,
&amp;quot;grid.linewidth&amp;quot;: 0.6,
&amp;quot;grid.alpha&amp;quot;: 0.8,
&amp;quot;xtick.color&amp;quot;: LIGHT_TEXT,
&amp;quot;ytick.color&amp;quot;: LIGHT_TEXT,
&amp;quot;xtick.major.size&amp;quot;: 0,
&amp;quot;ytick.major.size&amp;quot;: 0,
&amp;quot;text.color&amp;quot;: WHITE_TEXT,
&amp;quot;font.size&amp;quot;: 12,
&amp;quot;legend.frameon&amp;quot;: False,
&amp;quot;legend.fontsize&amp;quot;: 11,
&amp;quot;legend.labelcolor&amp;quot;: LIGHT_TEXT,
&amp;quot;figure.edgecolor&amp;quot;: DARK_NAVY,
&amp;quot;savefig.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;savefig.edgecolor&amp;quot;: DARK_NAVY,
})
&lt;/code>&lt;/pre>
&lt;/details>
&lt;h2 id="4-data-loading-and-exploration">4. Data loading and exploration&lt;/h2>
&lt;p>The dataset covers &lt;strong>514 Indonesian districts&lt;/strong> with GDP per capita in 2010 and the subsequent growth rate through 2018. Indonesia is an ideal setting for studying spatial heterogeneity: it spans over 17,000 islands across 5,000 km of ocean, with enormous variation in economic structure, geography, and institutional capacity.&lt;/p>
&lt;p>The core idea behind convergence is straightforward: if poorer districts tend to grow faster than richer ones, the income gap narrows over time. In a regression framework, this means we expect a &lt;strong>negative relationship&lt;/strong> between initial income (log GDP per capita in 2010) and subsequent growth. The question is whether that negative relationship holds uniformly across the archipelago &amp;mdash; or whether it is stronger in some places and weaker (or even reversed) in others.&lt;/p>
&lt;pre>&lt;code class="language-python">CSV_URL = (&amp;quot;https://github.com/quarcs-lab/data-quarcs/raw/refs/heads/&amp;quot;
&amp;quot;master/indonesia514/dataBeta.csv&amp;quot;)
GEO_URL = (&amp;quot;https://github.com/quarcs-lab/data-quarcs/raw/refs/heads/&amp;quot;
&amp;quot;master/indonesia514/mapIdonesia514-opt.geojson&amp;quot;)
df = pd.read_csv(CSV_URL)
geo = gpd.read_file(GEO_URL)
gdf = geo.merge(df, on=&amp;quot;districtID&amp;quot;, how=&amp;quot;left&amp;quot;)
print(f&amp;quot;Loaded: {gdf.shape[0]} districts, {gdf.shape[1]} columns&amp;quot;)
print(gdf[[&amp;quot;ln_gdppc2010&amp;quot;, &amp;quot;g&amp;quot;]].describe().round(4).to_string())
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Loaded: 514 districts, 16 columns
ln_gdppc2010 g
count 514.0000 514.0000
mean 9.8371 0.3860
std 0.7603 0.3205
min 7.1657 -2.0452
25% 9.3983 0.2583
50% 9.7626 0.3453
75% 10.1739 0.4158
max 13.4438 2.0563
&lt;/code>&lt;/pre>
&lt;p>The 514 districts span a wide range of initial income: log GDP per capita ranges from 7.17 (the poorest district, roughly \$1,300 per capita) to 13.44 (the richest, roughly \$690,000 &amp;mdash; likely a resource-extraction enclave). Growth rates also vary enormously, from -2.05 (severe contraction) to +2.06 (rapid expansion), with a mean of 0.39. This high variance in both variables suggests that a single regression line will struggle to capture the full picture.&lt;/p>
&lt;h2 id="5-exploratory-maps">5. Exploratory maps&lt;/h2>
&lt;p>Before fitting any model, we map the two key variables to see whether spatial patterns are visible to the naked eye. If initial income and growth are geographically clustered, that is already a hint that spatial models will outperform global ones.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, axes = plt.subplots(2, 1, figsize=(14, 14))
for ax, col, title in [
(axes[0], &amp;quot;ln_gdppc2010&amp;quot;, &amp;quot;(a) Log GDP per capita, 2010&amp;quot;),
(axes[1], &amp;quot;g&amp;quot;, &amp;quot;(b) GDP growth rate, 2010–2018&amp;quot;),
]:
fj = mapclassify.FisherJenks(gdf[col].dropna().values, k=5)
classified = mapclassify.UserDefined(gdf[col].values, bins=fj.bins.tolist())
cmap = plt.cm.coolwarm
norm = plt.Normalize(vmin=0, vmax=4)
colors = [cmap(norm(c)) for c in classified.yb]
gdf.plot(ax=ax, color=colors, edgecolor=GRID_LINE, linewidth=0.2)
ax.set_title(title, fontsize=14, pad=10)
ax.set_axis_off()
plt.tight_layout()
plt.savefig(&amp;quot;mgwr_map_xy.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="mgwr_map_xy.png" alt="Two-panel choropleth map of Indonesia showing log GDP per capita in 2010 and GDP growth rate 2010-2018.">&lt;/p>
&lt;p>The maps reveal clear spatial structure. Initial income (panel a) is highest in Jakarta and resource-rich districts in Kalimantan and Papua (warm red), while the lowest-income districts cluster in eastern Nusa Tenggara and parts of Maluku (cool blue). Growth rates (panel b) show a different pattern: some of the poorest districts in Papua and Sulawesi experienced rapid growth (suggesting catching-up), while several high-income resource districts saw contraction. The fact that these patterns are geographically organized &amp;mdash; not randomly scattered &amp;mdash; motivates the use of spatially varying models.&lt;/p>
&lt;h2 id="6-global-regression-baseline">6. Global regression baseline&lt;/h2>
&lt;p>The simplest test for economic convergence fits a single regression line through all 514 districts. If the slope is negative, poorer districts (low initial income) tend to grow faster than richer ones.&lt;/p>
&lt;p>$$g_i = \alpha + \beta \cdot \ln(y_{i,2010}) + \varepsilon_i$$&lt;/p>
&lt;p>where $g_i$ is the growth rate, $\ln(y_{i,2010})$ is log initial income, and $\beta &amp;lt; 0$ indicates convergence. In the code, $g_i$ corresponds to the column &lt;code>g&lt;/code> and $\ln(y_{i,2010})$ to &lt;code>ln_gdppc2010&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-python">slope, intercept, r_value, p_value, std_err = stats.linregress(
gdf[&amp;quot;ln_gdppc2010&amp;quot;], gdf[&amp;quot;g&amp;quot;]
)
print(f&amp;quot;Slope (convergence coefficient): {slope:.4f}&amp;quot;)
print(f&amp;quot;R-squared: {r_value**2:.4f}&amp;quot;)
print(f&amp;quot;p-value: {p_value:.6f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Slope (convergence coefficient): -0.1948
R-squared: 0.2135
p-value: 0.000000
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(10, 7))
ax.scatter(gdf[&amp;quot;ln_gdppc2010&amp;quot;], gdf[&amp;quot;g&amp;quot;],
color=STEEL_BLUE, edgecolors=GRID_LINE, s=35, alpha=0.6, zorder=3)
x_range = np.linspace(gdf[&amp;quot;ln_gdppc2010&amp;quot;].min(), gdf[&amp;quot;ln_gdppc2010&amp;quot;].max(), 100)
ax.plot(x_range, intercept + slope * x_range, color=WARM_ORANGE,
linewidth=2, zorder=2)
ax.set_xlabel(&amp;quot;Log GDP per capita (2010)&amp;quot;)
ax.set_ylabel(&amp;quot;GDP growth rate (2010–2018)&amp;quot;)
ax.set_title(&amp;quot;Global convergence regression&amp;quot;)
plt.savefig(&amp;quot;mgwr_scatter_global.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="mgwr_scatter_global.png" alt="Scatter plot of log GDP per capita 2010 vs growth rate with OLS regression line.">&lt;/p>
&lt;p>The global regression confirms that convergence exists &lt;strong>on average&lt;/strong>: the slope is $-0.195$ (p &amp;lt; 0.001), meaning a 1-unit increase in log initial income is associated with a 0.195 percentage-point lower growth rate. However, the R² of only 0.214 means this single line explains just 21% of the variation in growth rates. The scatter plot shows enormous dispersion around the regression line &amp;mdash; many districts with similar initial income experienced vastly different growth trajectories. This low explanatory power is the motivation for MGWR: perhaps the relationship is not weak everywhere, but rather strong in some regions and absent in others, and a single coefficient is simply averaging over this heterogeneity.&lt;/p>
&lt;h2 id="7-from-global-to-local-why-mgwr">7. From global to local: why MGWR?&lt;/h2>
&lt;h3 id="71-the-limitation-of-a-single-coefficient">7.1 The limitation of a single coefficient&lt;/h3>
&lt;p>The global regression tells us that $\beta = -0.195$ on average across Indonesia. But consider two districts with the same initial income &amp;mdash; one in Java, where infrastructure and market access are strong, and one in Papua, where remoteness and institutional challenges dominate. There is no reason to expect the same convergence dynamic in both places. A single coefficient forces them onto the same line.&lt;/p>
&lt;p>&lt;strong>Geographically Weighted Regression (GWR)&lt;/strong> addresses this by estimating a separate regression at each location, using a kernel function &amp;mdash; a distance-decay weighting scheme (typically Gaussian or bisquare) that gives more weight to nearby observations and less to distant ones. The result is a set of &lt;strong>location-specific coefficients&lt;/strong> &amp;mdash; each district gets its own slope and intercept:&lt;/p>
&lt;p>$$g_i = \alpha(u_i, v_i) + \beta(u_i, v_i) \cdot \ln(y_{i,2010}) + \varepsilon_i$$&lt;/p>
&lt;p>where $(u_i, v_i)$ are the geographic coordinates of district $i$, and both $\alpha$ and $\beta$ are now functions of location rather than fixed constants. In the code, $(u_i, v_i)$ correspond to &lt;code>COORD_X&lt;/code> and &lt;code>COORD_Y&lt;/code>. The &lt;strong>bandwidth&lt;/strong> parameter $h$ controls how many neighbors contribute to each local regression &amp;mdash; a small bandwidth means only very close districts matter (highly local), while a large bandwidth approaches the global model.&lt;/p>
&lt;p>However, standard GWR uses a single bandwidth for all variables, which means the intercept and the convergence coefficient are forced to vary at the same spatial scale.&lt;/p>
&lt;p>&lt;strong>MGWR&lt;/strong> removes this constraint. It allows each variable to find its own optimal bandwidth through an iterative back-fitting procedure &amp;mdash; a process that cycles through each variable, optimizing its bandwidth while holding the others fixed, until all bandwidths converge. If baseline growth conditions vary smoothly across large regions (large bandwidth), while the convergence speed varies sharply between neighboring districts (small bandwidth), MGWR will discover this from the data. This makes MGWR a more flexible and realistic model for processes that operate at multiple spatial scales. The key assumption is that spatial relationships are &lt;strong>locally stationary&lt;/strong> within each kernel window &amp;mdash; the relationship between income and growth is approximately constant among the nearest $h$ districts, even if it differs across the full map.&lt;/p>
&lt;h3 id="72-mgwr-estimation">7.2 MGWR estimation&lt;/h3>
&lt;p>The &lt;code>mgwr&lt;/code> package requires variables to be &lt;strong>standardized&lt;/strong> (zero mean, unit variance) before multiscale bandwidth selection. This ensures that the bandwidths are comparable across variables measured in different units. The &lt;code>spherical=True&lt;/code> flag tells the algorithm to compute great-circle distances rather than Euclidean distances, which is essential when working with geographic coordinates spanning a large area like Indonesia.&lt;/p>
&lt;pre>&lt;code class="language-python"># Prepare variables
y = gdf[&amp;quot;g&amp;quot;].values.reshape((-1, 1))
X = gdf[[&amp;quot;ln_gdppc2010&amp;quot;]].values
coords = list(zip(gdf[&amp;quot;COORD_X&amp;quot;], gdf[&amp;quot;COORD_Y&amp;quot;]))
# Standardize (required for MGWR)
Zy = (y - y.mean(axis=0)) / y.std(axis=0)
ZX = (X - X.mean(axis=0)) / X.std(axis=0)
# Bandwidth selection and model fitting
mgwr_selector = Sel_BW(coords, Zy, ZX, multi=True, spherical=True)
mgwr_bw = mgwr_selector.search()
mgwr_results = MGWR(coords, Zy, ZX, mgwr_selector, spherical=True).fit()
mgwr_results.summary()
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">===========================================================================
Model type Gaussian
Number of observations: 514
Number of covariates: 2
Global Regression Results
---------------------------------------------------------------------------
R2: 0.214
Adj. R2: 0.212
Multi-Scale Geographically Weighted Regression (MGWR) Results
---------------------------------------------------------------------------
Spatial kernel: Adaptive bisquare
MGWR bandwidths
---------------------------------------------------------------------------
Variable Bandwidth ENP_j Adj t-val(95%) Adj alpha(95%)
X0 44.000 26.805 3.127 0.002
X1 44.000 25.271 3.109 0.002
Diagnostic information
---------------------------------------------------------------------------
Residual sum of squares: 122.081
Effective number of parameters (trace(S)): 52.076
Sigma estimate: 0.514
R2 0.762
Adjusted R2 0.736
AICc: 838.405
===========================================================================
&lt;/code>&lt;/pre>
&lt;p>The MGWR results are striking. &lt;strong>R² jumps from 0.214 (global) to 0.762 (MGWR)&lt;/strong> &amp;mdash; the spatially varying model explains more than three times as much variation as the global regression. Both the intercept and the convergence coefficient receive a bandwidth of 44, meaning each local regression draws on the 44 nearest districts. This is a relatively local scale (44 out of 514 districts, or about 8.6% of the sample), confirming that the convergence relationship varies substantially across the archipelago. The effective number of parameters is 52.1, reflecting the cost of estimating location-specific coefficients instead of two global ones.&lt;/p>
&lt;h3 id="73-mapping-mgwr-coefficients">7.3 Mapping MGWR coefficients&lt;/h3>
&lt;p>The power of MGWR lies in the coefficient maps. Instead of a single number for the whole country, we can now visualize how the convergence relationship changes from district to district. Because MGWR is estimated on standardized variables, the mapped coefficients are in &lt;strong>standard-deviation units&lt;/strong>: a coefficient of $-1.0$ means that a one-standard-deviation increase in log initial income is associated with a one-standard-deviation decrease in growth at that location.&lt;/p>
&lt;pre>&lt;code class="language-python">gdf[&amp;quot;mgwr_intercept&amp;quot;] = mgwr_results.params[:, 0]
gdf[&amp;quot;mgwr_slope&amp;quot;] = mgwr_results.params[:, 1]
&lt;/code>&lt;/pre>
&lt;p>&lt;strong>Intercept map&lt;/strong> &amp;mdash; the intercept captures baseline growth conditions after accounting for initial income. Positive values indicate districts that grew faster than expected given their income level; negative values indicate underperformance.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(14, 8))
# Fisher-Jenks classification with Patch legend (see script.py for details)
gdf.plot(ax=ax, column=&amp;quot;mgwr_intercept&amp;quot;, scheme=&amp;quot;FisherJenks&amp;quot;, k=5,
cmap=&amp;quot;coolwarm&amp;quot;, edgecolor=GRID_LINE, linewidth=0.2, legend=True)
ax.set_title(f&amp;quot;MGWR intercept (bandwidth = {int(mgwr_bw[0])})&amp;quot;)
ax.set_axis_off()
plt.savefig(&amp;quot;mgwr_mgwr_intercept.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="mgwr_mgwr_intercept.png" alt="MGWR intercept map across Indonesia&amp;rsquo;s 514 districts.">&lt;/p>
&lt;p>The intercept map reveals a clear east&amp;ndash;west gradient. Districts in &lt;strong>western Indonesia&lt;/strong> (Sumatra and Java) tend to have negative intercepts &amp;mdash; they grew &lt;strong>less&lt;/strong> than the convergence model would predict based on their initial income alone. Districts in &lt;strong>eastern Indonesia&lt;/strong> (Papua, Maluku, Nusa Tenggara) show positive intercepts, indicating growth that &lt;strong>exceeded&lt;/strong> what initial income would predict. This pattern may reflect the role of resource extraction, infrastructure investment, and fiscal transfers that disproportionately boosted growth in less-developed eastern regions during the 2010&amp;ndash;2018 period.&lt;/p>
&lt;p>&lt;strong>Convergence coefficient map&lt;/strong> &amp;mdash; the slope captures how strongly initial income predicts subsequent growth at each location. Large negative values indicate rapid catching-up; values near zero or positive indicate no convergence or divergence.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(14, 8))
gdf.plot(ax=ax, column=&amp;quot;mgwr_slope&amp;quot;, scheme=&amp;quot;FisherJenks&amp;quot;, k=5,
cmap=&amp;quot;coolwarm&amp;quot;, edgecolor=GRID_LINE, linewidth=0.2, legend=True)
ax.set_title(f&amp;quot;MGWR convergence coefficient (bandwidth = {int(mgwr_bw[1])})&amp;quot;)
ax.set_axis_off()
plt.savefig(&amp;quot;mgwr_mgwr_slope.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="mgwr_mgwr_slope.png" alt="MGWR convergence coefficient map across Indonesia.">&lt;/p>
&lt;p>The convergence coefficient map is the central finding of this analysis. The global regression reported a single $\beta = -0.195$, but MGWR reveals that this average hides enormous spatial variation. The &lt;strong>strongest catching-up&lt;/strong> (deepest blue, coefficients as negative as $-1.74$) concentrates in &lt;strong>western Sumatra and parts of Kalimantan&lt;/strong> &amp;mdash; districts where poorer areas grew much faster than richer neighbors. In contrast, most of &lt;strong>Java, eastern Indonesia, and the Maluku islands&lt;/strong> show coefficients near zero (light pink), indicating that the convergence relationship is essentially absent in these areas. A handful of districts show weakly positive coefficients (up to 0.42), suggesting localized divergence where richer districts pulled further ahead. The coefficient ranges from $-1.74$ to $+0.42$, with a median of $-0.085$ and a standard deviation of 0.553 &amp;mdash; far from the single value of $-0.195$ reported by the global model.&lt;/p>
&lt;h3 id="74-statistical-significance">7.4 Statistical significance&lt;/h3>
&lt;p>Not all local coefficients are statistically distinguishable from zero. MGWR provides t-values corrected for multiple testing, which we use to classify each district&amp;rsquo;s convergence coefficient as significantly negative (catching-up), not significant, or significantly positive (diverging).&lt;/p>
&lt;pre>&lt;code class="language-python">mgwr_filtered_t = mgwr_results.filter_tvals()
t_sig = mgwr_filtered_t[:, 1] # Slope t-values
sig_cats = np.where(t_sig &amp;lt; 0, &amp;quot;Negative (catching-up)&amp;quot;,
np.where(t_sig &amp;gt; 0, &amp;quot;Positive (diverging)&amp;quot;, &amp;quot;Not significant&amp;quot;))
print(f&amp;quot;Negative (catching-up): {(sig_cats == 'Negative (catching-up)').sum()}&amp;quot;)
print(f&amp;quot;Not significant: {(sig_cats == 'Not significant').sum()}&amp;quot;)
print(f&amp;quot;Positive (diverging): {(sig_cats == 'Positive (diverging)').sum()}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Negative (catching-up): 149
Not significant: 365
Positive (diverging): 0
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(14, 8))
cat_colors = {
&amp;quot;Negative (catching-up)&amp;quot;: &amp;quot;#2c7bb6&amp;quot;,
&amp;quot;Not significant&amp;quot;: GRID_LINE,
&amp;quot;Positive (diverging)&amp;quot;: &amp;quot;#d7191c&amp;quot;,
}
colors_sig = [cat_colors[c] for c in sig_cats]
gdf.plot(ax=ax, color=colors_sig, edgecolor=GRID_LINE, linewidth=0.2)
ax.set_title(&amp;quot;MGWR convergence coefficient: statistical significance&amp;quot;)
ax.set_axis_off()
plt.savefig(&amp;quot;mgwr_mgwr_significance.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="mgwr_mgwr_significance.png" alt="Significance map showing districts with statistically significant catching-up.">&lt;/p>
&lt;p>Of 514 districts, &lt;strong>149 (29%)&lt;/strong> show statistically significant convergence at the corrected 5% level &amp;mdash; concentrated in &lt;strong>Sumatra, western Kalimantan, and Sulawesi&lt;/strong>. The remaining &lt;strong>365 districts (71%)&lt;/strong> have convergence coefficients that are not distinguishable from zero after correcting for multiple comparisons. &lt;strong>No district&lt;/strong> shows significant divergence. This means that while the global regression detects convergence on average, it is actually driven by a minority of districts &amp;mdash; primarily in western Indonesia &amp;mdash; while the majority of the archipelago shows no significant relationship between initial income and growth.&lt;/p>
&lt;h2 id="8-model-comparison">8. Model comparison&lt;/h2>
&lt;p>The table below summarizes how much explanatory power the spatially varying model adds over the global baseline.&lt;/p>
&lt;pre>&lt;code class="language-python">print(f&amp;quot;{'Metric':&amp;lt;25} {'Global OLS':&amp;gt;12} {'MGWR':&amp;gt;12}&amp;quot;)
print(f&amp;quot;{'R²':&amp;lt;25} {0.2135:&amp;gt;12.4f} {0.7625:&amp;gt;12.4f}&amp;quot;)
print(f&amp;quot;{'Adj. R²':&amp;lt;25} {0.2120:&amp;gt;12.4f} {0.7357:&amp;gt;12.4f}&amp;quot;)
print(f&amp;quot;{'AICc':&amp;lt;25} {1341.25:&amp;gt;12.2f} {838.41:&amp;gt;12.2f}&amp;quot;)
print(f&amp;quot;{'Bandwidth (intercept)':&amp;lt;25} {'all (514)':&amp;gt;12} {'44':&amp;gt;12}&amp;quot;)
print(f&amp;quot;{'Bandwidth (slope)':&amp;lt;25} {'all (514)':&amp;gt;12} {'44':&amp;gt;12}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Metric Global OLS MGWR
R² 0.2135 0.7625
Adj. R² 0.2120 0.7357
AICc 1341.25 838.41
Bandwidth (intercept) all (514) 44
Bandwidth (slope) all (514) 44
&lt;/code>&lt;/pre>
&lt;p>MGWR more than triples the explained variance ($R^2$: 0.214 to 0.762) and dramatically reduces the AICc from 1341 to 838, confirming that the improvement in fit is not merely due to additional flexibility. The bandwidth of 44 for both variables means each local regression uses the nearest 44 districts (about 8.6% of the sample), confirming that the convergence process is highly localized. The adjusted $R^2$ of 0.736 accounts for the additional complexity (52 effective parameters vs 2 in OLS) and still shows a massive improvement, indicating that the spatial variation in coefficients is genuine and not overfitting.&lt;/p>
&lt;h2 id="9-discussion">9. Discussion&lt;/h2>
&lt;p>&lt;strong>Economic catching-up in Indonesia is not uniform &amp;mdash; it is concentrated in western Sumatra and parts of Kalimantan, while most of the archipelago shows no significant convergence.&lt;/strong> The global regression&amp;rsquo;s $\beta = -0.195$ suggests a moderate convergence tendency, but MGWR reveals that this average is driven by a subset of 149 districts (29%) with strong catching-up dynamics. The remaining 365 districts have convergence coefficients indistinguishable from zero.&lt;/p>
&lt;p>The intercept map adds another dimension: eastern Indonesian districts tend to have positive intercepts (above-expected growth), while western districts have negative intercepts (below-expected growth). This east&amp;ndash;west gradient likely reflects the impact of fiscal transfers, resource booms, and infrastructure programs that targeted less-developed regions during the 2010&amp;ndash;2018 period. Combined with the convergence coefficient map, the picture is nuanced: eastern Indonesia grew faster than expected (high intercept), but not because of convergence dynamics (near-zero slope) &amp;mdash; rather, because of other factors captured by the intercept.&lt;/p>
&lt;p>For policy, these findings challenge the assumption that national-level convergence statistics reflect what is happening locally. A policymaker looking at $\beta = -0.195$ might conclude that Indonesia&amp;rsquo;s development strategy is successfully closing regional gaps. MGWR reveals that catching-up is geographically selective, and the majority of districts are not on a convergence path at all. Spatially targeted interventions &amp;mdash; rather than uniform national programs &amp;mdash; may be needed to address this uneven landscape.&lt;/p>
&lt;h2 id="10-summary-and-next-steps">10. Summary and next steps&lt;/h2>
&lt;p>&lt;strong>Key takeaways:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Method insight:&lt;/strong> MGWR reveals spatial heterogeneity invisible to global regression. R² improves from 0.214 to 0.762 by allowing location-specific coefficients. Both variables operate at a bandwidth of 44 districts (~8.6% of the sample), indicating highly localized economic dynamics. Variable standardization is essential before MGWR estimation.&lt;/li>
&lt;li>&lt;strong>Data insight:&lt;/strong> Only 149 of 514 Indonesian districts (29%) show statistically significant convergence, concentrated in Sumatra and Kalimantan. The convergence coefficient ranges from $-1.74$ to $+0.42$, far from the global average of $-0.195$. Eastern Indonesia grows faster than expected (positive intercepts) but not through convergence &amp;mdash; the catching-up mechanism is absent there.&lt;/li>
&lt;li>&lt;strong>Limitation:&lt;/strong> The bivariate model (one independent variable) is intentionally simple for pedagogical purposes. Real convergence analysis would include controls for human capital, infrastructure, institutional quality, and sectoral composition. The bandwidth of 44 applies to both variables in this case, but with additional covariates, MGWR&amp;rsquo;s ability to assign different bandwidths per variable would be more visible.&lt;/li>
&lt;li>&lt;strong>Next step:&lt;/strong> Extend the model with additional covariates (education, investment, fiscal transfers) to disentangle the sources of spatial heterogeneity. Apply MGWR to panel data with multiple time periods. Compare MGWR results with the spatial clusters identified in the &lt;a href="https://carlos-mendez.org/post/python_esda2/">ESDA tutorial&lt;/a> to see whether convergence hotspots align with LISA clusters.&lt;/li>
&lt;/ul>
&lt;h2 id="11-exercises">11. Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Add a second variable.&lt;/strong> Include an education indicator (e.g., years of schooling) as a second independent variable and re-run MGWR. Do the two covariates receive different bandwidths? What does that tell you about the spatial scale at which education affects growth?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Map the t-values.&lt;/strong> Instead of mapping the raw coefficients, map the local t-statistics from &lt;code>mgwr_results.tvalues[:, 1]&lt;/code>. How does this map compare to the significance map based on corrected t-values?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Compare with ESDA.&lt;/strong> Run a Moran&amp;rsquo;s I test on the MGWR residuals. Is there remaining spatial autocorrelation? If not, MGWR has successfully captured the spatial structure. If yes, what might be missing?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="12-references">12. References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://doi.org/10.1080/24694452.2017.1352480" target="_blank" rel="noopener">Fotheringham, A. S., Yang, W., and Kang, W. (2017). Multiscale Geographically Weighted Regression (MGWR). &lt;em>Annals of the American Association of Geographers&lt;/em>, 107(6), 1247&amp;ndash;1265.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.21105/joss.01750" target="_blank" rel="noopener">Oshan, T. M., Li, Z., Kang, W., Wolf, L. J., and Fotheringham, A. S. (2019). mgwr: A Python Implementation of Multiscale Geographically Weighted Regression. &lt;em>JOSS&lt;/em>, 4(42), 1750.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1111/j.1538-4632.1996.tb00936.x" target="_blank" rel="noopener">Brunsdon, C., Fotheringham, A. S., and Charlton, M. E. (1996). Geographically Weighted Regression: A Method for Exploring Spatial Nonstationarity. &lt;em>Geographical Analysis&lt;/em>, 28(4), 281&amp;ndash;298.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.wiley.com/en-us/Geographically&amp;#43;Weighted&amp;#43;Regression-p-9780471496168" target="_blank" rel="noopener">Fotheringham, A. S., Brunsdon, C., and Charlton, M. (2002). &lt;em>Geographically Weighted Regression: The Analysis of Spatially Varying Relationships&lt;/em>. Wiley.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://carlos-mendez.org/publication/20241219-ae/" target="_blank" rel="noopener">Mendez, C. and Jiang, Q. (2024). Spatial Heterogeneity Modeling for Regional Economic Analysis: A Computational Approach Using Python and Cloud Computing. Working Paper, Nagoya University.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://mgwr.readthedocs.io/" target="_blank" rel="noopener">mgwr documentation&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>Introduction to PCA Analysis for Building Development Indicators</title><link>https://carlos-mendez.org/post/python_pca/</link><pubDate>Sat, 21 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_pca/</guid><description>&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>In development economics, we rarely measure progress with just one number. To understand a country&amp;rsquo;s health system, you might look at life expectancy, infant mortality, hospital beds per capita, and disease prevalence. But how do you rank 50 countries when you have multiple metrics measured in different units &amp;mdash; years, rates, and raw counts? You cannot simply add them together. You need a single, elegant &amp;ldquo;Development Index.&amp;rdquo;&lt;/p>
&lt;p>&lt;strong>Principal Component Analysis (PCA)&lt;/strong> is a statistical technique used for data compression. It takes a dataset with many correlated variables and condenses it into a single composite index while retaining as much of the original information as possible. Think of PCA as finding the hallway in a building that gives you the longest unobstructed view &amp;mdash; the direction where the data is most spread out, and therefore most informative. For visual introductions to the core idea, see &lt;a href="https://youtu.be/_6UjscCJrYE" target="_blank" rel="noopener">Principal Component Analysis (PCA) Explained Simply&lt;/a> and &lt;a href="https://youtu.be/nEvKduLXFvk" target="_blank" rel="noopener">Visualizing Principal Component Analysis (PCA)&lt;/a>. For a hands-on interactive demonstration, try the &lt;a href="https://numiqo.com/lab/pca" target="_blank" rel="noopener">Numiqo PCA Lab&lt;/a>.&lt;/p>
&lt;p>This tutorial builds a simplified Health Index using only two indicators &amp;mdash; Life Expectancy (years) and Infant Mortality (deaths per 1,000 live births) &amp;mdash; for 50 simulated countries. By using simulated data with a known structure, we can verify that PCA recovers the true underlying pattern. The same six-step pipeline scales naturally to 10, 20, or 100 indicators.&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Understand why polarity adjustment and standardization are prerequisites for PCA&lt;/li>
&lt;li>Compute the covariance matrix and interpret its entries as variable overlap&lt;/li>
&lt;li>Perform eigen-decomposition to extract principal component weights and variance proportions&lt;/li>
&lt;li>Construct a composite index by projecting standardized data onto the first principal component&lt;/li>
&lt;li>Verify manual PCA results against scikit-learn&amp;rsquo;s PCA implementation&lt;/li>
&lt;/ul>
&lt;h2 id="2-the-pca-pipeline">2. The PCA pipeline&lt;/h2>
&lt;p>Before diving into the math, it helps to see the full pipeline at a glance. Each of the six steps builds on the previous one and cannot be skipped.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
A[&amp;quot;&amp;lt;b&amp;gt;Step 1&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Polarity&amp;lt;br/&amp;gt;Adjustment&amp;quot;] --&amp;gt; B[&amp;quot;&amp;lt;b&amp;gt;Step 2&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Standardization&amp;lt;br/&amp;gt;(Z-scores)&amp;quot;]
B --&amp;gt; C[&amp;quot;&amp;lt;b&amp;gt;Step 3&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Covariance&amp;lt;br/&amp;gt;Matrix&amp;quot;]
C --&amp;gt; D[&amp;quot;&amp;lt;b&amp;gt;Step 4&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Eigen-&amp;lt;br/&amp;gt;Decomposition&amp;quot;]
D --&amp;gt; E[&amp;quot;&amp;lt;b&amp;gt;Step 5&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Scoring&amp;lt;br/&amp;gt;(PC1)&amp;quot;]
E --&amp;gt; F[&amp;quot;&amp;lt;b&amp;gt;Step 6&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Normalization&amp;lt;br/&amp;gt;(0-1)&amp;quot;]
style A fill:#d97757,stroke:#141413,color:#fff
style B fill:#6a9bcc,stroke:#141413,color:#fff
style C fill:#6a9bcc,stroke:#141413,color:#fff
style D fill:#00d4c8,stroke:#141413,color:#fff
style E fill:#00d4c8,stroke:#141413,color:#fff
style F fill:#1a3a8a,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>The pipeline transforms raw indicators into a single number that captures the dominant pattern of variation. We start by aligning indicator directions (Step 1), removing unit differences (Step 2), measuring variable overlap (Step 3), finding the optimal weights (Step 4), computing scores (Step 5), and finally rescaling for human readability (Step 6).&lt;/p>
&lt;h2 id="3-setup-and-imports">3. Setup and imports&lt;/h2>
&lt;p>The analysis relies on &lt;a href="https://numpy.org/" target="_blank" rel="noopener">NumPy&lt;/a> for linear algebra, &lt;a href="https://pandas.pydata.org/" target="_blank" rel="noopener">pandas&lt;/a> for data management, &lt;a href="https://matplotlib.org/" target="_blank" rel="noopener">matplotlib&lt;/a> for visualization, and &lt;a href="https://scikit-learn.org/" target="_blank" rel="noopener">scikit-learn&lt;/a> for verification. The &lt;code>RANDOM_SEED&lt;/code> ensures every reader gets identical results.&lt;/p>
&lt;pre>&lt;code class="language-python">import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
# Reproducibility
RANDOM_SEED = 42
# Site color palette
STEEL_BLUE = &amp;quot;#6a9bcc&amp;quot;
WARM_ORANGE = &amp;quot;#d97757&amp;quot;
NEAR_BLACK = &amp;quot;#141413&amp;quot;
TEAL = &amp;quot;#00d4c8&amp;quot;
&lt;/code>&lt;/pre>
&lt;details>
&lt;summary>Dark theme figure styling (click to expand)&lt;/summary>
&lt;pre>&lt;code class="language-python"># Dark theme palette (consistent with site navbar/dark sections)
DARK_NAVY = &amp;quot;#0f1729&amp;quot;
GRID_LINE = &amp;quot;#1f2b5e&amp;quot;
LIGHT_TEXT = &amp;quot;#c8d0e0&amp;quot;
WHITE_TEXT = &amp;quot;#e8ecf2&amp;quot;
# Plot defaults — minimal, spine-free, dark background
plt.rcParams.update({
&amp;quot;figure.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.edgecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.linewidth&amp;quot;: 0,
&amp;quot;axes.labelcolor&amp;quot;: LIGHT_TEXT,
&amp;quot;axes.titlecolor&amp;quot;: WHITE_TEXT,
&amp;quot;axes.spines.top&amp;quot;: False,
&amp;quot;axes.spines.right&amp;quot;: False,
&amp;quot;axes.spines.left&amp;quot;: False,
&amp;quot;axes.spines.bottom&amp;quot;: False,
&amp;quot;axes.grid&amp;quot;: True,
&amp;quot;grid.color&amp;quot;: GRID_LINE,
&amp;quot;grid.linewidth&amp;quot;: 0.6,
&amp;quot;grid.alpha&amp;quot;: 0.8,
&amp;quot;xtick.color&amp;quot;: LIGHT_TEXT,
&amp;quot;ytick.color&amp;quot;: LIGHT_TEXT,
&amp;quot;xtick.major.size&amp;quot;: 0,
&amp;quot;ytick.major.size&amp;quot;: 0,
&amp;quot;text.color&amp;quot;: WHITE_TEXT,
&amp;quot;font.size&amp;quot;: 12,
&amp;quot;legend.frameon&amp;quot;: False,
&amp;quot;legend.fontsize&amp;quot;: 11,
&amp;quot;legend.labelcolor&amp;quot;: LIGHT_TEXT,
&amp;quot;figure.edgecolor&amp;quot;: DARK_NAVY,
&amp;quot;savefig.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;savefig.edgecolor&amp;quot;: DARK_NAVY,
})
&lt;/code>&lt;/pre>
&lt;/details>
&lt;h2 id="4-simulating-health-data">4. Simulating health data&lt;/h2>
&lt;p>We generate data for 50 countries driven by a single latent factor &amp;mdash; &lt;code>base_health&lt;/code> &amp;mdash; drawn from a uniform distribution. This factor drives both life expectancy (positively) and infant mortality (negatively), mimicking the real-world pattern where healthier countries perform well across multiple indicators simultaneously. Using simulated data lets us verify that PCA recovers this known single-factor structure.&lt;/p>
&lt;pre>&lt;code class="language-python">def simulate_health_data(n=50, seed=42):
&amp;quot;&amp;quot;&amp;quot;Simulate health indicators for n countries.
True DGP:
base_health ~ Uniform(0, 1) -- latent health capacity
life_exp = 55 + 30 * base_health + N(0, 2) -- range ~55-85
infant_mort = 60 - 55 * base_health + N(0, 3) -- range ~2-60
&amp;quot;&amp;quot;&amp;quot;
rng = np.random.default_rng(seed)
base_health = rng.uniform(0, 1, n)
life_exp = 55 + 30 * base_health + rng.normal(0, 2, n)
infant_mort = 60 - 55 * base_health + rng.normal(0, 3, n)
countries = [f&amp;quot;Country_{i+1:02d}&amp;quot; for i in range(n)]
return pd.DataFrame({
&amp;quot;country&amp;quot;: countries,
&amp;quot;life_exp&amp;quot;: np.round(life_exp, 1),
&amp;quot;infant_mort&amp;quot;: np.round(infant_mort, 1),
})
df = simulate_health_data(n=50, seed=RANDOM_SEED)
# Save raw data to CSV (used later in the scikit-learn pipeline)
df.to_csv(&amp;quot;health_data.csv&amp;quot;, index=False)
print(f&amp;quot;Dataset shape: {df.shape}&amp;quot;)
print(f&amp;quot;\nFirst 5 rows:&amp;quot;)
print(df.head().to_string(index=False))
print(f&amp;quot;\nDescriptive statistics:&amp;quot;)
print(df[[&amp;quot;life_exp&amp;quot;, &amp;quot;infant_mort&amp;quot;]].describe().round(2).to_string())
print(&amp;quot;\nSaved: health_data.csv&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Dataset shape: (50, 3)
First 5 rows:
country life_exp infant_mort
Country_01 79.6 18.6
Country_02 68.3 33.1
Country_03 81.3 11.6
Country_04 77.2 25.5
Country_05 54.9 53.8
Descriptive statistics:
life_exp infant_mort
count 50.00 50.00
mean 70.72 30.30
std 8.62 15.57
min 54.90 3.50
25% 63.45 17.28
50% 71.25 30.25
75% 78.90 42.05
max 84.70 58.70
Saved: health_data.csv
&lt;/code>&lt;/pre>
&lt;p>All 50 countries loaded with two health indicators. Life expectancy ranges from 54.9 to 84.7 years with a mean of 70.72, while infant mortality ranges from 3.5 to 58.7 per 1,000 live births with a mean of 30.30. Notice the directional conflict: life expectancy is a &amp;ldquo;positive&amp;rdquo; indicator (higher means better health), while infant mortality is a &amp;ldquo;negative&amp;rdquo; indicator (higher means worse health). This conflict is precisely what Step 1 will resolve.&lt;/p>
&lt;h2 id="5-exploring-the-raw-data">5. Exploring the raw data&lt;/h2>
&lt;p>Before transforming the data, let us visualize the raw relationship between the two indicators. The Pearson correlation coefficient ($r$) measures the strength and direction of the linear relationship between two variables, ranging from $-1$ (perfect negative) to $+1$ (perfect positive). If the two indicators are strongly correlated, PCA will be able to compress them effectively into a single index.&lt;/p>
&lt;pre>&lt;code class="language-python">raw_corr = df[&amp;quot;life_exp&amp;quot;].corr(df[&amp;quot;infant_mort&amp;quot;])
print(f&amp;quot;Pearson correlation (LE vs IM): {raw_corr:.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Pearson correlation (LE vs IM): -0.9595
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 6))
fig.patch.set_linewidth(0)
ax.scatter(df[&amp;quot;life_exp&amp;quot;], df[&amp;quot;infant_mort&amp;quot;],
color=STEEL_BLUE, edgecolors=DARK_NAVY, s=60, zorder=3)
# Label extreme countries
sorted_df = df.sort_values(&amp;quot;life_exp&amp;quot;)
label_idx = list(sorted_df.head(5).index) + list(sorted_df.tail(5).index)
for i in label_idx:
ax.annotate(df.loc[i, &amp;quot;country&amp;quot;],
(df.loc[i, &amp;quot;life_exp&amp;quot;], df.loc[i, &amp;quot;infant_mort&amp;quot;]),
fontsize=7, color=LIGHT_TEXT, xytext=(5, 5),
textcoords=&amp;quot;offset points&amp;quot;)
ax.set_xlabel(&amp;quot;Life Expectancy (years)&amp;quot;)
ax.set_ylabel(&amp;quot;Infant Mortality (per 1,000 live births)&amp;quot;)
ax.set_title(&amp;quot;Raw health indicators: Life Expectancy vs. Infant Mortality&amp;quot;)
ax.annotate(f&amp;quot;r = {raw_corr:.2f}&amp;quot;, xy=(0.95, 0.95), xycoords=&amp;quot;axes fraction&amp;quot;,
fontsize=12, color=WARM_ORANGE, fontweight=&amp;quot;bold&amp;quot;,
va=&amp;quot;top&amp;quot;, ha=&amp;quot;right&amp;quot;)
plt.savefig(&amp;quot;pca_raw_scatter.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca_raw_scatter.png" alt="Raw health indicators: Life Expectancy vs. Infant Mortality for 50 simulated countries.">&lt;/p>
&lt;p>The Pearson correlation is $r = -0.96$, confirming a very strong negative relationship. Countries with high life expectancy almost always have low infant mortality, and vice versa. This means the two indicators are telling essentially the same story about health &amp;mdash; just in opposite directions. This high redundancy is exactly what PCA will exploit to compress two dimensions into one.&lt;/p>
&lt;h2 id="6-step-1-polarity-adjustment-----aligning-the-health-goals">6. Step 1: Polarity adjustment &amp;mdash; aligning the health goals&lt;/h2>
&lt;p>&lt;strong>What it is:&lt;/strong> Before any math is applied, we must ensure our indicators share the same logical direction. We mathematically invert indicators where &amp;ldquo;higher&amp;rdquo; means &amp;ldquo;worse&amp;rdquo; so that all variables move in the same positive direction. For our negative indicator (Infant Mortality, or $IM$), we calculate an adjusted value:&lt;/p>
&lt;p>$$IM_i^{*} = -1 \times IM_i$$&lt;/p>
&lt;p>In words, this says: for each country $i$, multiply its infant mortality rate by negative one. After this transformation, a large positive value of $IM^{&lt;em>}$ means low infant mortality &amp;mdash; a good outcome. Here $IM_i$ corresponds to the &lt;code>infant_mort&lt;/code> column, and $IM_i^{&lt;/em>}$ will be stored as &lt;code>infant_mort_adj&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The application:&lt;/strong> Country_01 has an infant mortality rate of 18.6 deaths per 1,000 live births. Applying the formula: $IM^{*} = -1 \times 18.6 = -18.6$. The raw value of 18.6 becomes $-18.6$ after polarity adjustment. The negative sign encodes &amp;ldquo;18.6 units of infant survival&amp;rdquo; &amp;mdash; a positive health signal that can now be combined with Life Expectancy because both variables point in the same direction.&lt;/p>
&lt;p>&lt;strong>The Intuition:&lt;/strong> Life Expectancy ($LE$) is a &amp;ldquo;positive&amp;rdquo; indicator: higher numbers mean better health. Infant Mortality is a &amp;ldquo;negative&amp;rdquo; indicator: higher numbers mean worse health. If we feed these into an index as they are, the final score will be contradictory. Imagine comparing exam scores where one professor grades 0&amp;ndash;100 (higher is better) and another grades on demerits 0&amp;ndash;100 (lower is better). Before averaging, you must flip the demerit scale.&lt;/p>
&lt;p>&lt;strong>The Necessity:&lt;/strong> We must flip the negative indicator so that &amp;ldquo;up&amp;rdquo; always means &amp;ldquo;better.&amp;rdquo; By multiplying by $-1$, instead of measuring &amp;ldquo;Infant Mortality,&amp;rdquo; we are effectively measuring &amp;ldquo;Infant Survival.&amp;rdquo; Now, for both variables, a higher number universally indicates a stronger health system.&lt;/p>
&lt;pre>&lt;code class="language-python">df[&amp;quot;infant_mort_adj&amp;quot;] = -1 * df[&amp;quot;infant_mort&amp;quot;]
adj_corr = df[&amp;quot;life_exp&amp;quot;].corr(df[&amp;quot;infant_mort_adj&amp;quot;])
print(f&amp;quot;Correlation after polarity adjustment (LE vs -IM): {adj_corr:.4f}&amp;quot;)
print(f&amp;quot;\nFirst 5 rows with adjusted IM:&amp;quot;)
print(df[[&amp;quot;country&amp;quot;, &amp;quot;life_exp&amp;quot;, &amp;quot;infant_mort&amp;quot;, &amp;quot;infant_mort_adj&amp;quot;]].head().to_string(index=False))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Correlation after polarity adjustment (LE vs -IM): 0.9595
First 5 rows with adjusted IM:
country life_exp infant_mort infant_mort_adj
Country_01 79.6 18.6 -18.6
Country_02 68.3 33.1 -33.1
Country_03 81.3 11.6 -11.6
Country_04 77.2 25.5 -25.5
Country_05 54.9 53.8 -53.8
&lt;/code>&lt;/pre>
&lt;p>The correlation has flipped from $-0.96$ to $+0.96$. Both indicators now point in the same direction: higher values mean better health. The magnitude of the correlation is unchanged &amp;mdash; the relationship is identical, just properly aligned. With this alignment in place, we can proceed to standardize the variables.&lt;/p>
&lt;h2 id="7-step-2-standardization-----comparing-apples-to-apples">7. Step 2: Standardization &amp;mdash; comparing apples to apples&lt;/h2>
&lt;p>&lt;strong>What it is:&lt;/strong> We transform our raw data into Z-scores. For each value, we subtract the sample mean ($\mu$) and divide by the standard deviation ($\sigma$):&lt;/p>
&lt;p>$$Z_{ij} = \frac{X_{ij} - \bar{X}_j}{\sigma_j}$$&lt;/p>
&lt;p>In words, this says: for country $i$ and variable $j$, subtract the variable&amp;rsquo;s mean $\bar{X}_j$ and divide by its standard deviation $\sigma_j$. The result is a unitless score that tells us how many standard deviations above or below average the country is. Here $X_{ij}$ is the raw value (e.g., &lt;code>life_exp&lt;/code> or &lt;code>infant_mort_adj&lt;/code>), $\bar{X}_j$ is computed by &lt;code>np.mean()&lt;/code>, and $\sigma_j$ is computed by &lt;code>np.std(ddof=0)&lt;/code>.&lt;/p>
&lt;p>&lt;strong>The application:&lt;/strong> Country_01 has Life Expectancy = 79.6 and adjusted Infant Mortality = $-18.6$. Applying the formula: $Z_{LE} = (79.6 - 70.72) / 8.53 = 8.88 / 8.53 = 1.0402$ and $Z_{IM} = (-18.6 - (-30.30)) / 15.42 = 11.70 / 15.42 = 0.7587$. Country_01 is 1.04 standard deviations above average in life expectancy and 0.76 standard deviations above average in infant survival. Both positive Z-scores confirm it is a healthier-than-average country on both indicators. These two numbers are now directly comparable &amp;mdash; 1.04 and 0.76 are both measured in the same unit (standard deviations), even though the original variables were in years and rates.&lt;/p>
&lt;p>&lt;strong>The Intuition:&lt;/strong> Life Expectancy is measured in years (range 54.9&amp;ndash;84.7). Infant Mortality is measured as a rate per 1,000 (range 3.5&amp;ndash;58.7). If we mix these directly, the index will naturally be dominated by Infant Mortality simply because its values have a wider physical spread.&lt;/p>
&lt;p>&lt;strong>The Necessity:&lt;/strong> We standardize both variables to have a mean of $0$ and a standard deviation of $1$. We are no longer looking at &amp;ldquo;years&amp;rdquo; or &amp;ldquo;rates.&amp;rdquo; We are looking at &amp;ldquo;standard deviations from the global average.&amp;rdquo; Both indicators now have equal footing.&lt;/p>
&lt;pre>&lt;code class="language-python"># Manual standardization
le_mean = df[&amp;quot;life_exp&amp;quot;].mean()
le_std = df[&amp;quot;life_exp&amp;quot;].std(ddof=0)
im_mean = df[&amp;quot;infant_mort_adj&amp;quot;].mean()
im_std = df[&amp;quot;infant_mort_adj&amp;quot;].std(ddof=0)
df[&amp;quot;z_le&amp;quot;] = (df[&amp;quot;life_exp&amp;quot;] - le_mean) / le_std
df[&amp;quot;z_im&amp;quot;] = (df[&amp;quot;infant_mort_adj&amp;quot;] - im_mean) / im_std
print(f&amp;quot;Life Expectancy -- mean: {le_mean:.2f}, std: {le_std:.2f}&amp;quot;)
print(f&amp;quot;Infant Mort (adj) -- mean: {im_mean:.2f}, std: {im_std:.2f}&amp;quot;)
print(f&amp;quot;\nZ-score statistics:&amp;quot;)
print(f&amp;quot; z_le mean: {df['z_le'].mean():.6f}, std: {df['z_le'].std(ddof=0):.6f}&amp;quot;)
print(f&amp;quot; z_im mean: {df['z_im'].mean():.6f}, std: {df['z_im'].std(ddof=0):.6f}&amp;quot;)
# Verify with sklearn
scaler = StandardScaler()
Z_sklearn = scaler.fit_transform(df[[&amp;quot;life_exp&amp;quot;, &amp;quot;infant_mort_adj&amp;quot;]])
max_diff = np.max(np.abs(Z_sklearn - df[[&amp;quot;z_le&amp;quot;, &amp;quot;z_im&amp;quot;]].values))
print(f&amp;quot;\nMax difference from sklearn StandardScaler: {max_diff:.2e}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Life Expectancy -- mean: 70.72, std: 8.53
Infant Mort (adj) -- mean: -30.30, std: 15.42
Z-score statistics:
z_le mean: 0.000000, std: 1.000000
z_im mean: 0.000000, std: 1.000000
Max difference from sklearn StandardScaler: 0.00e+00
&lt;/code>&lt;/pre>
&lt;p>Both Z-scores now have a mean of exactly 0 and a standard deviation of exactly 1, confirmed by the zero-difference check against &lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.StandardScaler.html" target="_blank" rel="noopener">StandardScaler()&lt;/a>. Note that &lt;code>describe()&lt;/code> in Section 4 reported &lt;code>std = 8.62&lt;/code> for life expectancy, while here we get &lt;code>8.53&lt;/code>. The difference is the denominator: pandas' &lt;code>describe()&lt;/code> divides by $n - 1$ (sample standard deviation, &lt;code>ddof=1&lt;/code>), while &lt;code>StandardScaler&lt;/code> and our manual formula divide by $n$ (population standard deviation, &lt;code>ddof=0&lt;/code>). We use &lt;code>ddof=0&lt;/code> because PCA treats the dataset as the full population being analyzed, not a sample from a larger population. A country that is 2 standard deviations above average in life expectancy is now directly comparable to one that is 2 standard deviations above average in (adjusted) infant mortality. The unit problem is solved, and we can now measure how the two variables move together.&lt;/p>
&lt;h2 id="8-step-3-the-covariance-matrix-----mapping-the-overlap">8. Step 3: The covariance matrix &amp;mdash; mapping the overlap&lt;/h2>
&lt;p>&lt;strong>What it is:&lt;/strong> We calculate the covariance matrix to measure how the two standardized variables move together. For two variables, this forms a $2 \times 2$ matrix ($\Sigma$). Because our data is standardized, the covariance between them is simply their correlation ($r$):&lt;/p>
&lt;p>$$\Sigma = \frac{1}{n} Z^T Z = \begin{pmatrix} 1 &amp;amp; r \\ r &amp;amp; 1 \end{pmatrix}$$&lt;/p>
&lt;p>In words, this says: the covariance matrix $\Sigma$ of standardized data has 1s on the diagonal (each variable has unit variance after standardization) and the correlation $r$ on the off-diagonal. With two variables, PCA only needs to decompose this single $2 \times 2$ matrix.&lt;/p>
&lt;p>&lt;strong>The application:&lt;/strong> Plugging in our standardized data, the resulting matrix has diagonal entries of 1.0000 (guaranteed by standardization &amp;mdash; each variable has unit variance) and an off-diagonal of 0.9595 (the correlation $r$). This off-diagonal value means that when a country&amp;rsquo;s standardized life expectancy increases by 1 standard deviation, its standardized infant survival tends to increase by 0.96 standard deviations as well. The two variables move almost in lockstep, confirming heavy redundancy that PCA can exploit.&lt;/p>
&lt;p>&lt;strong>The Intuition:&lt;/strong> In the real world, these two indicators are heavily correlated. A country with high life expectancy almost certainly has high infant survival. They are essentially telling us the same story about the country&amp;rsquo;s healthcare system.&lt;/p>
&lt;p>&lt;strong>The Necessity:&lt;/strong> The covariance matrix measures exactly how strong this overlap is. It tells the PCA algorithm mathematically, &amp;ldquo;These two variables share a high amount of redundant information. You can safely compress them into one variable without losing the big picture.&amp;rdquo;&lt;/p>
&lt;pre>&lt;code class="language-python">Z = df[[&amp;quot;z_le&amp;quot;, &amp;quot;z_im&amp;quot;]].values
cov_matrix = np.cov(Z.T, ddof=0)
print(f&amp;quot;Covariance matrix (2x2):&amp;quot;)
print(f&amp;quot; [{cov_matrix[0, 0]:.4f} {cov_matrix[0, 1]:.4f}]&amp;quot;)
print(f&amp;quot; [{cov_matrix[1, 0]:.4f} {cov_matrix[1, 1]:.4f}]&amp;quot;)
print(f&amp;quot;\nOff-diagonal (correlation): {cov_matrix[0, 1]:.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Covariance matrix (2x2):
[1.0000 0.9595]
[0.9595 1.0000]
Off-diagonal (correlation): 0.9595
&lt;/code>&lt;/pre>
&lt;p>The diagonal entries are exactly 1.0 (unit variance, as expected after standardization) and the off-diagonal is 0.9595 &amp;mdash; the same correlation we computed earlier. This means 96% of the movement in one variable is mirrored by the other. The covariance matrix has now quantified the overlap, and eigen-decomposition will use this information to find the optimal compression axis.&lt;/p>
&lt;h2 id="9-step-4-eigen-decomposition-----finding-the-optimal-direction">9. Step 4: Eigen-decomposition &amp;mdash; finding the optimal direction&lt;/h2>
&lt;p>This is the mathematical core, where we find our new, compressed index. It introduces two new concepts &amp;mdash; &lt;strong>eigenvectors&lt;/strong> and &lt;strong>eigenvalues&lt;/strong> &amp;mdash; that are central to how PCA works.&lt;/p>
&lt;p>&lt;strong>What it is:&lt;/strong> We decompose the covariance matrix $\Sigma$ into two outputs by solving the equation $\Sigma \mathbf{v} = \lambda \mathbf{v}$:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>An &lt;strong>eigenvector&lt;/strong> ($\mathbf{v}$) is a direction in the data space. For our two health indicators, each eigenvector is a pair of numbers $[w_1, w_2]$ that defines a direction &amp;mdash; like a compass heading through the scatter plot of countries. PCA finds the direction along which the data is most spread out. The components of this eigenvector become the &lt;strong>weights&lt;/strong> for combining our indicators into a single index. A $2 \times 2$ matrix always produces exactly two eigenvectors, perpendicular to each other.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>An &lt;strong>eigenvalue&lt;/strong> ($\lambda$) is a number that tells us how much variance &amp;mdash; how much &amp;ldquo;spread&amp;rdquo; &amp;mdash; the data has along its corresponding eigenvector direction. A large eigenvalue means the countries are widely dispersed in that direction (lots of information), while a small eigenvalue means they are tightly clustered (little information). The eigenvalues always sum to the total number of variables (in our case, 2).&lt;/p>
&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Why are they useful for PCA?&lt;/strong> Together, eigenvectors and eigenvalues answer two questions at once. The eigenvector with the &lt;strong>largest&lt;/strong> eigenvalue identifies the single best direction to project our data &amp;mdash; the direction that captures the most variation across countries. Its components tell us exactly how much weight to give each indicator in our composite index. The ratio of the largest eigenvalue to the total tells us what percentage of the original information our single-number index retains.&lt;/p>
&lt;p>&lt;strong>The application:&lt;/strong> For a $2 \times 2$ correlation matrix, the eigenvalues have an elegant closed-form solution: $\lambda_1 = 1 + r$ and $\lambda_2 = 1 - r$. With our correlation of $r = 0.9595$: $\lambda_1 = 1 + 0.9595 = 1.9595$ and $\lambda_2 = 1 - 0.9595 = 0.0405$. The variance explained by PC1 is $1.9595 / 2.0000 = 97.97\%$. This reveals a direct link between correlation strength and PCA compression power. Because $r = 0.96$, the first eigenvalue absorbs nearly all the variance, leaving only $\lambda_2 = 0.04$ for PC2. The higher the correlation between our health indicators, the more variance PC1 captures. At the extreme, if the correlation were zero, both eigenvalues would equal 1.0 and PCA would offer no compression advantage at all.&lt;/p>
&lt;p>&lt;strong>The Intuition:&lt;/strong> Imagine plotting all 50 countries on a 2D graph &amp;mdash; Standardized Life Expectancy on the X-axis, Standardized Infant Survival on the Y-axis. The countries form a narrow, elongated cloud stretching diagonally from the lower-left (unhealthy countries) to the upper-right (healthy countries). The first eigenvector is a straight line drawn through the long axis of this cloud &amp;mdash; the direction where countries differ the most. Its eigenvalue measures how stretched the cloud is along that line. If you stood at the center of the cloud and looked down the first eigenvector, you would see maximum separation between countries. Look down the second eigenvector (perpendicular), and the countries would appear tightly bunched &amp;mdash; almost no useful information in that direction. This is why we keep the first eigenvector and discard the second: it captures nearly all the meaningful variation.&lt;/p>
&lt;p>&lt;strong>The Necessity:&lt;/strong> Eigen-decomposition removes human bias. Instead of randomly guessing how much weight to give Life Expectancy versus Infant Mortality, the math calculates the absolute optimal weights to capture the maximum amount of overlapping information. The algorithm finds the single direction that best summarizes 50 countries' health performance &amp;mdash; no subjective judgment required.&lt;/p>
&lt;pre>&lt;code class="language-python">eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix)
# Sort in descending order (eigh returns ascending)
idx = np.argsort(eigenvalues)[::-1]
eigenvalues = eigenvalues[idx]
eigenvectors = eigenvectors[:, idx]
# Sign convention: first weight positive
if eigenvectors[0, 0] &amp;lt; 0:
eigenvectors[:, 0] *= -1
var_explained = eigenvalues / eigenvalues.sum() * 100
print(f&amp;quot;Eigenvalues: [{eigenvalues[0]:.4f}, {eigenvalues[1]:.4f}]&amp;quot;)
print(f&amp;quot;Sum of eigenvalues: {eigenvalues.sum():.4f}&amp;quot;)
print(f&amp;quot;\nEigenvector (PC1): [{eigenvectors[0, 0]:.4f}, {eigenvectors[1, 0]:.4f}]&amp;quot;)
print(f&amp;quot;Eigenvector (PC2): [{eigenvectors[0, 1]:.4f}, {eigenvectors[1, 1]:.4f}]&amp;quot;)
print(f&amp;quot;\nVariance explained:&amp;quot;)
print(f&amp;quot; PC1: {var_explained[0]:.2f}%&amp;quot;)
print(f&amp;quot; PC2: {var_explained[1]:.2f}%&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Eigenvalues: [1.9595, 0.0405]
Sum of eigenvalues: 2.0000
Eigenvector (PC1): [0.7071, 0.7071]
Eigenvector (PC2): [0.7071, -0.7071]
Variance explained:
PC1: 97.97%
PC2: 2.03%
&lt;/code>&lt;/pre>
&lt;p>The first eigenvalue is 1.9595 and the second is just 0.0405, summing to 2.0 (the number of variables). PC1 captures 97.97% of all variance in the data &amp;mdash; nearly everything. The eigenvector weights are both 0.7071 ($\approx 1/\sqrt{2}$), meaning both variables contribute equally to PC1. This equal weighting is not a coincidence &amp;mdash; it is a mathematical certainty whenever PCA is applied to exactly two standardized variables. A $2 \times 2$ correlation matrix always has eigenvectors $[1/\sqrt{2}, \; 1/\sqrt{2}]$ and $[1/\sqrt{2}, \; -1/\sqrt{2}]$, regardless of how strong the correlation is. With three or more variables, the weights would generally differ, giving more influence to variables that contribute unique information.&lt;/p>
&lt;h3 id="91-visualizing-the-principal-components">9.1 Visualizing the principal components&lt;/h3>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 8))
fig.patch.set_linewidth(0)
ax.scatter(Z[:, 0], Z[:, 1], color=STEEL_BLUE, edgecolors=DARK_NAVY,
s=60, zorder=3, alpha=0.8)
# Eigenvector arrows scaled by sqrt(eigenvalue) so length reflects variance
vis = 1.5 # visibility multiplier
scale_pc1 = np.sqrt(eigenvalues[0]) * vis
scale_pc2 = np.sqrt(eigenvalues[1]) * vis
ax.annotate(&amp;quot;&amp;quot;, xy=(eigenvectors[0, 0] * scale_pc1, eigenvectors[1, 0] * scale_pc1),
xytext=(0, 0),
arrowprops=dict(arrowstyle=&amp;quot;-|&amp;gt;&amp;quot;, color=WARM_ORANGE, lw=2.5))
ax.annotate(&amp;quot;&amp;quot;, xy=(eigenvectors[0, 1] * scale_pc2, eigenvectors[1, 1] * scale_pc2),
xytext=(0, 0),
arrowprops=dict(arrowstyle=&amp;quot;-|&amp;gt;&amp;quot;, color=TEAL, lw=2.0))
ax.text(eigenvectors[0, 0] * scale_pc1 + 0.15, eigenvectors[1, 0] * scale_pc1 + 0.15,
f&amp;quot;PC1 ({var_explained[0]:.1f}%)&amp;quot;, color=WARM_ORANGE, fontsize=12,
fontweight=&amp;quot;bold&amp;quot;)
ax.text(eigenvectors[0, 1] * scale_pc2 + 0.15, eigenvectors[1, 1] * scale_pc2 - 0.15,
f&amp;quot;PC2 ({var_explained[1]:.1f}%)&amp;quot;, color=TEAL, fontsize=12,
fontweight=&amp;quot;bold&amp;quot;)
ax.axhline(0, color=GRID_LINE, linewidth=0.8, zorder=1)
ax.axvline(0, color=GRID_LINE, linewidth=0.8, zorder=1)
ax.set_xlabel(&amp;quot;Standardized Life Expectancy (Z-score)&amp;quot;)
ax.set_ylabel(&amp;quot;Standardized Infant Survival (Z-score)&amp;quot;)
ax.set_title(&amp;quot;Standardized data with principal component directions&amp;quot;)
ax.set_aspect(&amp;quot;equal&amp;quot;)
plt.savefig(&amp;quot;pca_standardized_eigenvectors.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca_standardized_eigenvectors.png" alt="Standardized data with PC1 and PC2 eigenvector arrows overlaid.">&lt;/p>
&lt;p>The orange PC1 arrow points along the diagonal &amp;mdash; the direction of maximum spread through the narrow, elongated data cloud. Because both weights are positive and equal (0.7071 each), PC1 essentially averages the two standardized indicators. The teal PC2 arrow is perpendicular and captures only the small residual variation (2.03%) not explained by PC1.&lt;/p>
&lt;h3 id="92-variance-explained">9.2 Variance explained&lt;/h3>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(6, 4))
fig.patch.set_linewidth(0)
bars = ax.bar([&amp;quot;PC1&amp;quot;, &amp;quot;PC2&amp;quot;], var_explained,
color=[WARM_ORANGE, STEEL_BLUE],
edgecolor=DARK_NAVY, width=0.5)
for bar, val in zip(bars, var_explained):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 1,
f&amp;quot;{val:.1f}%&amp;quot;, ha=&amp;quot;center&amp;quot;, va=&amp;quot;bottom&amp;quot;, fontsize=13,
fontweight=&amp;quot;bold&amp;quot;, color=WHITE_TEXT)
ax.set_ylabel(&amp;quot;Variance Explained (%)&amp;quot;)
ax.set_title(&amp;quot;Variance explained by each principal component&amp;quot;)
ax.set_ylim(0, 110)
plt.savefig(&amp;quot;pca_variance_explained.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca_variance_explained.png" alt="Bar chart showing PC1 captures 98.0% and PC2 captures 2.0% of total variance.">&lt;/p>
&lt;p>PC1 alone captures 98.0% of all variation, meaning a single number retains almost all the information in the original two variables. The remaining 2.0% on PC2 is mostly noise from the simulation. This extreme dominance confirms that the two health indicators are largely redundant &amp;mdash; an ideal scenario for PCA compression. With the optimal weights in hand, we can now compute each country&amp;rsquo;s score.&lt;/p>
&lt;h2 id="10-step-5-scoring-----building-the-index">10. Step 5: Scoring &amp;mdash; building the index&lt;/h2>
&lt;p>Step 4 produced the eigenvector $[0.7071, \; 0.7071]$. These two numbers are the weights $w_1$ and $w_2$ &amp;mdash; the recipe for building our index. The first component ($w_1 = 0.7071$) multiplies standardized Life Expectancy, and the second ($w_2 = 0.7071$) multiplies standardized Infant Survival. The eigenvector itself IS the formula for combining the variables.&lt;/p>
&lt;p>&lt;strong>What it is:&lt;/strong> We multiply each country&amp;rsquo;s standardized data by the weights from our eigenvector to calculate their final Principal Component 1 ($PC1$) score:&lt;/p>
&lt;p>$$PC1_i = (w_1 \times Z_{i,LE}) + (w_2 \times Z_{i,IM})$$&lt;/p>
&lt;p>In words, this says: for country $i$, the PC1 score is $w_1$ times its standardized life expectancy plus $w_2$ times its standardized (adjusted) infant mortality. Here $w_1$ and $w_2$ are the eigenvector components from Step 4, $Z_{i,LE}$ is &lt;code>z_le&lt;/code>, and $Z_{i,IM}$ is &lt;code>z_im&lt;/code>.&lt;/p>
&lt;p>&lt;strong>Why are the weights equal?&lt;/strong> As explained in Step 4, equal weights ($w_1 = w_2 = 1/\sqrt{2}$) are a mathematical certainty in the two-variable standardized case &amp;mdash; they hold for any correlation value $r$. Because both weights are identical, the PC1 score is equivalent to a simple average of the two Z-scores (scaled by $\sqrt{2}$). In practice, this means PCA adds no weighting advantage over a naive average when you have exactly two standardized indicators. The real power of PCA emerges with three or more variables, where the algorithm discovers unequal weights that reflect each variable&amp;rsquo;s unique contribution.&lt;/p>
&lt;p>&lt;strong>The application:&lt;/strong> Country_01&amp;rsquo;s Z-scores from Step 2 were $Z_{LE} = 1.0402$ and $Z_{IM} = 0.7587$. Applying the formula: $PC1 = 0.7071 \times 1.0402 + 0.7071 \times 0.7587 = 0.7355 + 0.5365 = 1.2720$. Country_01&amp;rsquo;s PC1 score of 1.27 is positive and well above the mean of 0, placing it in the healthier half of the sample. The contribution from life expectancy (0.7355) is slightly larger than from infant survival (0.5365), reflecting the fact that Country_01 is further above average in life expectancy ($Z = 1.04$) than in infant survival ($Z = 0.76$).&lt;/p>
&lt;p>&lt;strong>The Intuition:&lt;/strong> We take every country&amp;rsquo;s dot on our 2D graph and project it squarely onto that single diagonal line. The position of the country along that line is its new, single Health Score.&lt;/p>
&lt;p>&lt;strong>The Necessity:&lt;/strong> We have successfully collapsed a 2D matrix into a 1D number line. Two variables have officially become one composite index.&lt;/p>
&lt;pre>&lt;code class="language-python">w1 = eigenvectors[0, 0]
w2 = eigenvectors[1, 0]
df[&amp;quot;pc1&amp;quot;] = w1 * df[&amp;quot;z_le&amp;quot;] + w2 * df[&amp;quot;z_im&amp;quot;]
print(f&amp;quot;Eigenvector weights: w1 = {w1:.4f}, w2 = {w2:.4f}&amp;quot;)
print(f&amp;quot;\nPC1 score statistics:&amp;quot;)
print(f&amp;quot; Mean: {df['pc1'].mean():.4f}&amp;quot;)
print(f&amp;quot; Std: {df['pc1'].std(ddof=0):.4f}&amp;quot;)
print(f&amp;quot; Min: {df['pc1'].min():.4f}&amp;quot;)
print(f&amp;quot; Max: {df['pc1'].max():.4f}&amp;quot;)
print(f&amp;quot;\nTop 5 countries (highest PC1):&amp;quot;)
print(df.nlargest(5, &amp;quot;pc1&amp;quot;)[[&amp;quot;country&amp;quot;, &amp;quot;life_exp&amp;quot;, &amp;quot;infant_mort&amp;quot;, &amp;quot;pc1&amp;quot;]]
.to_string(index=False))
print(f&amp;quot;\nBottom 5 countries (lowest PC1):&amp;quot;)
print(df.nsmallest(5, &amp;quot;pc1&amp;quot;)[[&amp;quot;country&amp;quot;, &amp;quot;life_exp&amp;quot;, &amp;quot;infant_mort&amp;quot;, &amp;quot;pc1&amp;quot;]]
.to_string(index=False))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Eigenvector weights: w1 = 0.7071, w2 = 0.7071
PC1 score statistics:
Mean: 0.0000
Std: 1.3998
Min: -2.3892
Max: 2.3734
Top 5 countries (highest PC1):
country life_exp infant_mort pc1
Country_12 84.7 3.8 2.373421
Country_32 83.4 4.0 2.256521
Country_23 81.6 3.5 2.130292
Country_06 83.6 8.6 2.062127
Country_03 81.3 11.6 1.733944
Bottom 5 countries (lowest PC1):
country life_exp infant_mort pc1
Country_05 54.9 53.8 -2.389155
Country_28 57.7 58.7 -2.381854
Country_29 58.8 55.9 -2.162285
Country_18 58.5 54.9 -2.141282
Country_50 57.2 51.9 -2.111422
&lt;/code>&lt;/pre>
&lt;p>PC1 scores range from $-2.39$ (Country_05) to $+2.37$ (Country_12). The top-scoring countries combine high life expectancy (81&amp;ndash;85 years) with very low infant mortality (3.5&amp;ndash;8.6 per 1,000), while the bottom-scoring countries show the opposite pattern (54.9&amp;ndash;58.8 years, 48&amp;ndash;59 per 1,000). The mean of 0.0 confirms that PC1 is centered, as expected from standardized inputs.&lt;/p>
&lt;pre>&lt;code class="language-python">df_sorted = df.sort_values(&amp;quot;pc1&amp;quot;, ascending=True)
fig, ax = plt.subplots(figsize=(10, 14))
fig.patch.set_linewidth(0)
colors = [TEAL if v &amp;gt;= 0 else WARM_ORANGE for v in df_sorted[&amp;quot;pc1&amp;quot;]]
ax.barh(range(len(df_sorted)), df_sorted[&amp;quot;pc1&amp;quot;], color=colors,
edgecolor=DARK_NAVY, height=0.7)
ax.set_yticks(range(len(df_sorted)))
ax.set_yticklabels(df_sorted[&amp;quot;country&amp;quot;], fontsize=8)
ax.axvline(0, color=LIGHT_TEXT, linewidth=0.8, zorder=1)
ax.set_xlabel(&amp;quot;PC1 Score&amp;quot;)
ax.set_title(&amp;quot;PC1 scores: countries ranked by health performance&amp;quot;)
plt.savefig(&amp;quot;pca_pc1_scores.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca_pc1_scores.png" alt="Horizontal bar chart of 50 countries ranked by PC1 score.">&lt;/p>
&lt;p>The bar chart reveals a roughly symmetric distribution of PC1 scores around zero, with the healthiest countries (teal bars) on the right and the least healthy (orange bars) on the left. However, the raw PC1 scores include negative numbers &amp;mdash; a format that is hard to communicate in policy reports. The next step normalizes these scores to a 0&amp;ndash;1 scale.&lt;/p>
&lt;h2 id="11-step-6-normalization-----making-it-human-readable">11. Step 6: Normalization &amp;mdash; making it human-readable&lt;/h2>
&lt;p>&lt;strong>What it is:&lt;/strong> We apply Min-Max scaling to compress the $PC1$ scores into a range between 0 and 1. Let $\min(PC1)$ be the lowest score in the sample, and $\max(PC1)$ be the highest:&lt;/p>
&lt;p>$$HI_i = \frac{PC1_i - PC1_{min}}{PC1_{max} - PC1_{min}}$$&lt;/p>
&lt;p>In words, this says: subtract the minimum PC1 score and divide by the range. The country with the lowest PC1 score gets $HI = 0$ and the country with the highest gets $HI = 1$.&lt;/p>
&lt;p>&lt;strong>The application:&lt;/strong> Country_01&amp;rsquo;s PC1 score from Step 5 is 1.2720, while the sample minimum is $-2.3892$ and the maximum is $2.3734$. Applying the formula: $HI = (1.2720 - (-2.3892)) / (2.3734 - (-2.3892)) = 3.6612 / 4.7626 = 0.7687$. Country_01&amp;rsquo;s Health Index of 0.77 means it performs better than roughly 77% of the scale defined by the worst-performing country (0.00) and the best-performing country (1.00). A policymaker can immediately understand this number without knowing anything about Z-scores or eigenvectors.&lt;/p>
&lt;p>&lt;strong>The Intuition:&lt;/strong> Because we standardized the data earlier, our $PC1$ scores have negative numbers (e.g., $-2.39$). You cannot easily publish a report saying a country has a health score of negative 2.39 &amp;mdash; it confuses policymakers and the public.&lt;/p>
&lt;p>&lt;strong>The Necessity:&lt;/strong> Normalization forces the absolute lowest scoring country to equal $0$, and the highest scoring country to equal $1$. Everyone else scales proportionally in between. The result is a highly rigorous, purely data-driven index that is instantly understandable.&lt;/p>
&lt;pre>&lt;code class="language-python">pc1_min = df[&amp;quot;pc1&amp;quot;].min()
pc1_max = df[&amp;quot;pc1&amp;quot;].max()
df[&amp;quot;health_index&amp;quot;] = (df[&amp;quot;pc1&amp;quot;] - pc1_min) / (pc1_max - pc1_min)
print(f&amp;quot;PC1 range: [{pc1_min:.4f}, {pc1_max:.4f}]&amp;quot;)
print(f&amp;quot;\nHealth Index statistics:&amp;quot;)
print(f&amp;quot; Mean: {df['health_index'].mean():.4f}&amp;quot;)
print(f&amp;quot; Median: {df['health_index'].median():.4f}&amp;quot;)
print(f&amp;quot; Std: {df['health_index'].std(ddof=0):.4f}&amp;quot;)
print(f&amp;quot;\nTop 10 countries:&amp;quot;)
print(df.nlargest(10, &amp;quot;health_index&amp;quot;)[
[&amp;quot;country&amp;quot;, &amp;quot;life_exp&amp;quot;, &amp;quot;infant_mort&amp;quot;, &amp;quot;health_index&amp;quot;]
].to_string(index=False))
print(f&amp;quot;\nBottom 10 countries:&amp;quot;)
print(df.nsmallest(10, &amp;quot;health_index&amp;quot;)[
[&amp;quot;country&amp;quot;, &amp;quot;life_exp&amp;quot;, &amp;quot;infant_mort&amp;quot;, &amp;quot;health_index&amp;quot;]
].to_string(index=False))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">PC1 range: [-2.3892, 2.3734]
Health Index statistics:
Mean: 0.5017
Median: 0.5182
Std: 0.2939
Top 10 countries:
country life_exp infant_mort health_index
Country_12 84.7 3.8 1.000000
Country_32 83.4 4.0 0.975454
Country_23 81.6 3.5 0.948950
Country_06 83.6 8.6 0.934637
Country_03 81.3 11.6 0.865729
Country_45 79.1 7.8 0.864043
Country_24 79.5 11.4 0.836335
Country_19 79.1 15.2 0.792782
Country_14 79.0 15.5 0.788153
Country_46 79.0 16.5 0.778523
Bottom 10 countries:
country life_exp infant_mort health_index
Country_05 54.9 53.8 0.000000
Country_28 57.7 58.7 0.001533
Country_29 58.8 55.9 0.047636
Country_18 58.5 54.9 0.052046
Country_50 57.2 51.9 0.058316
Country_37 56.5 48.4 0.079840
Country_09 58.3 50.1 0.094789
Country_26 61.8 53.4 0.123910
Country_36 59.9 48.9 0.134184
Country_39 60.9 48.5 0.155436
&lt;/code>&lt;/pre>
&lt;p>The Health Index has a mean of 0.50 and a median of 0.52, indicating a roughly symmetric distribution. Country_12 leads with a perfect score of 1.00 (life expectancy 84.7 years, infant mortality 3.8), while Country_05 anchors the bottom at 0.00 (54.9 years, 53.8 per 1,000). The gap between the top 10 (all above 0.78) and the bottom 10 (all below 0.16) reveals a stark divide in health outcomes across the sample.&lt;/p>
&lt;pre>&lt;code class="language-python">df_sorted_hi = df.sort_values(&amp;quot;health_index&amp;quot;, ascending=True)
fig, ax = plt.subplots(figsize=(10, 14))
fig.patch.set_linewidth(0)
# Gradient from warm orange (low) to teal (high)
cmap_colors = []
for val in df_sorted_hi[&amp;quot;health_index&amp;quot;]:
r = int(0xd9 + val * (0x00 - 0xd9))
g = int(0x77 + val * (0xd4 - 0x77))
b = int(0x57 + val * (0xc8 - 0x57))
cmap_colors.append(f&amp;quot;#{r:02x}{g:02x}{b:02x}&amp;quot;)
ax.barh(range(len(df_sorted_hi)), df_sorted_hi[&amp;quot;health_index&amp;quot;],
color=cmap_colors, edgecolor=DARK_NAVY, height=0.7)
ax.set_yticks(range(len(df_sorted_hi)))
ax.set_yticklabels(df_sorted_hi[&amp;quot;country&amp;quot;], fontsize=8)
ax.set_xlabel(&amp;quot;Health Index (0 = worst, 1 = best)&amp;quot;)
ax.set_title(&amp;quot;Health Index: countries ranked from 0 (worst) to 1 (best)&amp;quot;)
ax.set_xlim(0, 1.05)
plt.savefig(&amp;quot;pca_health_index.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca_health_index.png" alt="Health Index bar chart for 50 countries with gradient coloring from orange to teal.">&lt;/p>
&lt;p>The gradient-colored bar chart makes the health divide visually immediate. Countries cluster into three rough groups: a high-performing cluster above 0.75 (warm teal), a middle group between 0.25 and 0.75, and a struggling cluster below 0.25 (warm orange). The bottom 10 countries all have Health Index values below 0.16, suggesting systemic health challenges that span both longevity and infant survival. Country_05 and Country_28 appear to have no bar at all &amp;mdash; this is not missing data. Country_05 has a Health Index of exactly 0.00 because it is the worst performer in the sample and Min-Max normalization maps the minimum to zero by definition. Country_28 has an index of just 0.0015, so close to zero that its bar is invisible at this scale. Both countries have low life expectancy (54.9 and 57.7 years) combined with high infant mortality (53.8 and 58.7 per 1,000), placing them at the extreme low end of the health spectrum.&lt;/p>
&lt;h2 id="12-replicating-the-analysis-with-scikit-learn">12. Replicating the analysis with scikit-learn&lt;/h2>
&lt;p>Now that we understand every step, scikit-learn can do the entire pipeline &amp;mdash; from raw CSV to final Health Index &amp;mdash; in a single, compact script. This section presents the automated pipeline and then compares its results against our manual implementation.&lt;/p>
&lt;h3 id="121-a-pca-pipeline-with-scikit-learn">12.1 A PCA pipeline with scikit-learn&lt;/h3>
&lt;p>The code block below is designed to be &lt;strong>reusable&lt;/strong>: by changing only the CSV file path, the column names, and the list of negative indicators, you can apply this same pipeline to any dataset.&lt;/p>
&lt;pre>&lt;code class="language-python"># ── Full PCA pipeline with scikit-learn ──────────────────────────
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
# ── Configuration (change these for your own dataset) ────────────
CSV_FILE = &amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/master/content/post/python_pca/health_data.csv&amp;quot;
ID_COL = &amp;quot;country&amp;quot; # Row identifier column
POSITIVE_COLS = [&amp;quot;life_exp&amp;quot;] # Higher = better
NEGATIVE_COLS = [&amp;quot;infant_mort&amp;quot;] # Higher = worse (will be flipped)
# Step 1: Load raw data from CSV
df_sk = pd.read_csv(CSV_FILE)
print(f&amp;quot;Loaded: {df_sk.shape[0]} rows, {df_sk.shape[1]} columns&amp;quot;)
# Step 2: Polarity adjustment — flip negative indicators
# Multiplying by -1 so that &amp;quot;higher = better&amp;quot; for all variables
for col in NEGATIVE_COLS:
df_sk[col + &amp;quot;_adj&amp;quot;] = -1 * df_sk[col]
adj_cols = POSITIVE_COLS + [col + &amp;quot;_adj&amp;quot; for col in NEGATIVE_COLS]
# Step 3: Standardization — Z-scores (mean=0, std=1)
# StandardScaler centers and scales each column independently
scaler = StandardScaler()
Z_sk = scaler.fit_transform(df_sk[adj_cols])
# Step 4: PCA — fit to find eigenvectors and eigenvalues
# n_components=1 because we want a single composite index
pca_sk = PCA(n_components=1)
pca_sk.fit(Z_sk)
# Step 5: Transform — project data onto the first principal component
df_sk[&amp;quot;pc1&amp;quot;] = pca_sk.transform(Z_sk)[:, 0]
# Step 6: Normalization — Min-Max scaling to 0-1
df_sk[&amp;quot;pc1_index&amp;quot;] = (
(df_sk[&amp;quot;pc1&amp;quot;] - df_sk[&amp;quot;pc1&amp;quot;].min())
/ (df_sk[&amp;quot;pc1&amp;quot;].max() - df_sk[&amp;quot;pc1&amp;quot;].min())
)
# Export results
df_sk.to_csv(&amp;quot;pc1_index_results.csv&amp;quot;, index=False)
# Summary
indicator_cols = POSITIVE_COLS + NEGATIVE_COLS
print(f&amp;quot;\nPC1 weights: {pca_sk.components_[0].round(4)}&amp;quot;)
print(f&amp;quot;Variance explained: {pca_sk.explained_variance_ratio_.round(4)}&amp;quot;)
print(f&amp;quot;\nTop 5:&amp;quot;)
print(df_sk.nlargest(5, &amp;quot;pc1_index&amp;quot;)[
[ID_COL] + indicator_cols + [&amp;quot;pc1_index&amp;quot;]
].to_string(index=False))
print(f&amp;quot;\nBottom 5:&amp;quot;)
print(df_sk.nsmallest(5, &amp;quot;pc1_index&amp;quot;)[
[ID_COL] + indicator_cols + [&amp;quot;pc1_index&amp;quot;]
].to_string(index=False))
print(f&amp;quot;\nSaved: pc1_index_results.csv&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Loaded: 50 rows, 3 columns
PC1 weights: [0.7071 0.7071]
Variance explained: [0.9797]
Top 5:
country life_exp infant_mort pc1_index
Country_12 84.7 3.8 1.000000
Country_32 83.4 4.0 0.975454
Country_23 81.6 3.5 0.948950
Country_06 83.6 8.6 0.934637
Country_03 81.3 11.6 0.865729
Bottom 5:
country life_exp infant_mort pc1_index
Country_05 54.9 53.8 0.000000
Country_28 57.7 58.7 0.001533
Country_29 58.8 55.9 0.047636
Country_18 58.5 54.9 0.052046
Country_50 57.2 51.9 0.058316
Saved: pc1_index_results.csv
&lt;/code>&lt;/pre>
&lt;p>The entire six-step manual pipeline collapses into roughly 15 lines of sklearn code. The configuration block at the top (&lt;code>CSV_FILE&lt;/code>, &lt;code>POSITIVE_COLS&lt;/code>, &lt;code>NEGATIVE_COLS&lt;/code>) makes the script reusable: to build a different composite index, simply point it to a new CSV and specify which columns are positive and which are negative. The rankings match our manual results exactly &amp;mdash; Country_12 leads at 1.00 and Country_05 anchors the bottom at 0.00.&lt;/p>
&lt;h3 id="122-manual-vs-scikit-learn-comparison">12.2 Manual vs. scikit-learn comparison&lt;/h3>
&lt;p>Now that we have both sets of PC1 scores &amp;mdash; one from our six manual steps, one from the sklearn pipeline &amp;mdash; we can compare them directly. One subtlety: eigenvectors are defined up to a sign flip, so sklearn may return scores with the opposite sign. We check for this and flip if needed.&lt;/p>
&lt;pre>&lt;code class="language-python">sklearn_pc1 = df_sk[&amp;quot;pc1&amp;quot;].values
# Handle sign ambiguity: eigenvectors can point in either direction
sign_corr = np.corrcoef(df[&amp;quot;pc1&amp;quot;], sklearn_pc1)[0, 1]
if sign_corr &amp;lt; 0:
sklearn_pc1 = -sklearn_pc1
print(&amp;quot;Note: sklearn returned opposite sign (normal). Flipped for comparison.&amp;quot;)
max_diff = np.max(np.abs(sklearn_pc1 - df[&amp;quot;pc1&amp;quot;].values))
corr_val = np.corrcoef(df[&amp;quot;pc1&amp;quot;], sklearn_pc1)[0, 1]
print(f&amp;quot;Max absolute difference in PC1 scores: {max_diff:.2e}&amp;quot;)
print(f&amp;quot;Correlation between manual and sklearn: {corr_val:.6f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Max absolute difference in PC1 scores: 1.33e-15
Correlation between manual and sklearn: 1.000000
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(6, 6))
fig.patch.set_linewidth(0)
ax.scatter(df[&amp;quot;pc1&amp;quot;], sklearn_pc1, color=STEEL_BLUE, edgecolors=DARK_NAVY,
s=60, zorder=3)
lim_min = min(df[&amp;quot;pc1&amp;quot;].min(), sklearn_pc1.min()) - 0.2
lim_max = max(df[&amp;quot;pc1&amp;quot;].max(), sklearn_pc1.max()) + 0.2
ax.plot([lim_min, lim_max], [lim_min, lim_max], color=WARM_ORANGE,
linewidth=2, linestyle=&amp;quot;--&amp;quot;, label=&amp;quot;Perfect agreement&amp;quot;, zorder=2)
ax.set_xlabel(&amp;quot;Manual PC1 Score&amp;quot;)
ax.set_ylabel(&amp;quot;scikit-learn PC1 Score&amp;quot;)
ax.set_title(&amp;quot;Manual vs. scikit-learn PCA: verification&amp;quot;)
ax.legend(loc=&amp;quot;upper left&amp;quot;)
ax.set_aspect(&amp;quot;equal&amp;quot;)
plt.savefig(&amp;quot;pca_sklearn_comparison.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca_sklearn_comparison.png" alt="Scatter plot showing perfect agreement between manual and sklearn PCA scores.">&lt;/p>
&lt;p>The maximum absolute difference between manual and sklearn PC1 scores is $1.33 \times 10^{-15}$ &amp;mdash; essentially machine-precision zero. The correlation is 1.000000, confirming perfect agreement. All 50 points fall exactly on the dashed 45-degree line. This validates that our step-by-step manual implementation produces identical results to the optimized library.&lt;/p>
&lt;h2 id="13-summary-results">13. Summary results&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Step&lt;/th>
&lt;th>Input&lt;/th>
&lt;th>Output&lt;/th>
&lt;th>Key Result&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Polarity&lt;/td>
&lt;td>IM (raw)&lt;/td>
&lt;td>IM* = -IM&lt;/td>
&lt;td>Correlation: -0.96 to +0.96&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Standardization&lt;/td>
&lt;td>LE, IM*&lt;/td>
&lt;td>Z_LE, Z_IM&lt;/td>
&lt;td>Mean=0, SD=1 for both&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Covariance&lt;/td>
&lt;td>Z matrix&lt;/td>
&lt;td>2x2 matrix&lt;/td>
&lt;td>Off-diagonal r = 0.96&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Eigen-decomposition&lt;/td>
&lt;td>Cov matrix&lt;/td>
&lt;td>eigenvalues, eigenvectors&lt;/td>
&lt;td>PC1 captures 98.0%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Scoring&lt;/td>
&lt;td>Z * eigvec&lt;/td>
&lt;td>PC1 scores&lt;/td>
&lt;td>Range: [-2.39, 2.37]&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Normalization&lt;/td>
&lt;td>PC1&lt;/td>
&lt;td>Health Index&lt;/td>
&lt;td>Range: [0.00, 1.00]&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="14-discussion">14. Discussion&lt;/h2>
&lt;p>The answer to our opening question is clear: &lt;strong>yes, PCA successfully reduces two correlated health indicators into a single Health Index.&lt;/strong> With a correlation of 0.96 between life expectancy and adjusted infant mortality, PC1 captures 97.97% of all variation &amp;mdash; meaning our single-number index retains virtually all the information from both original variables.&lt;/p>
&lt;p>The approximately equal eigenvector weights ($w_1 = w_2 = 0.707$) reveal that PCA produced an index nearly identical to a simple average of Z-scores. This is not always the case. With less correlated indicators or more than two variables, the weights would diverge, giving more influence to the indicators that contribute unique information. In high-dimensional settings with 15 or 20 indicators, PCA&amp;rsquo;s ability to discover these unequal weights becomes far more valuable than any manual weighting scheme. For an applied example, &lt;a href="https://carlos-mendez.org/publication/20210318-economia/" target="_blank" rel="noopener">Mendez and Gonzales (2021)&lt;/a> use PCA to classify 339 Bolivian municipalities according to human capital constraints &amp;mdash; combining malnutrition, language barriers, dropout rates, and education inequality into composite indices that reveal distinct geographic clusters of deprivation.&lt;/p>
&lt;p>A policymaker looking at these results could immediately identify that the bottom 10 countries (Health Index below 0.16) suffer from both low life expectancy and high infant mortality, indicating systemic health system weaknesses rather than isolated problems. These countries would be natural candidates for comprehensive health investment packages rather than single-issue interventions.&lt;/p>
&lt;p>It is crucial to understand that this index is a measure of &lt;strong>relative performance&lt;/strong> within the specific sample. A score of 1.0 does not mean a country has achieved perfect health &amp;mdash; it simply means that country is the best performer among the 50 analyzed. Adding or removing countries from the sample changes every score because both the standardization parameters (mean and standard deviation) and the Min-Max bounds depend on which countries are included. If a new country with extremely high life expectancy joins the sample, every existing country&amp;rsquo;s Z-scores shift downward, altering all PC1 scores and the final index.&lt;/p>
&lt;p>Using a PCA-based health index to compare against a PCA-based education index is also problematic. A health index score of 0.77 and an education index score of 0.77 may look equivalent, but they are not directly comparable. Each index has its own eigenvectors, eigenvalues, and standardization parameters derived from entirely different variables with different correlation structures. The numbers live on different scales &amp;mdash; 0.77 in health means &amp;ldquo;77% of the way between the worst and best health performers,&amp;rdquo; while 0.77 in education means the same relative position but within a completely different set of indicators. Combining or averaging PCA indices across domains requires additional methodological choices (such as those used in the UNDP Human Development Index).&lt;/p>
&lt;p>Using our PCA-based health index to study changes over time introduces further challenges. If you compute the index separately for each year, both the eigenvector weights and the Z-score parameters (means, standard deviations) can shift from year to year, making scores from different periods non-comparable. A country&amp;rsquo;s index could improve not because its health system got better, but because the sample&amp;rsquo;s average got worse. One potential solution is a &lt;strong>pooled PCA approach&lt;/strong> &amp;mdash; standardizing across all years simultaneously and computing a single set of eigenvectors from the pooled covariance matrix. However, this requires assuming that the correlation structure between indicators remains constant over time, which may not hold if the relationship between life expectancy and infant mortality evolves as countries develop. For an example of PCA applied to social progress indicators across countries and multiple years, see &lt;a href="https://doi.org/10.1093/oep/gpac022" target="_blank" rel="noopener">Peiro-Palomino, Picazo-Tadeo, and Rios (2023)&lt;/a>.&lt;/p>
&lt;h2 id="15-summary-and-next-steps">15. Summary and next steps&lt;/h2>
&lt;p>&lt;strong>Key takeaways:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Method insight:&lt;/strong> PCA is most effective when indicators are highly correlated. With $r = 0.96$, PC1 captured 98.0% of variance. With weakly correlated indicators, PCA would require multiple components, reducing the simplicity advantage. Always check the correlation structure before choosing PCA for index construction.&lt;/li>
&lt;li>&lt;strong>Data insight:&lt;/strong> The equal eigenvector weights (both 0.707) mean PCA produced an index nearly identical to a simple Z-score average in this two-variable case. The real power of PCA emerges when variables contribute unequally and you need the algorithm to discover the optimal weighting.&lt;/li>
&lt;li>&lt;strong>Limitation:&lt;/strong> With only two variables, PCA offers modest dimensionality reduction (2 to 1). The technique&amp;rsquo;s full value emerges with many indicators (e.g., 15 SDG variables reduced to 3&amp;ndash;4 components). Also, the Health Index is relative &amp;mdash; adding or removing countries changes every score because of the Min-Max normalization.&lt;/li>
&lt;li>&lt;strong>Next step:&lt;/strong> Extend to multi-variable PCA with real data (e.g., SDG indicators covering education, income, and health). Explore how many components to retain using the scree plot and cumulative variance threshold (commonly 80&amp;ndash;90%), and consider factor analysis for latent variable interpretation.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Limitations of this analysis:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>The data is simulated. Real WHO data would include outliers, missing values, and non-normal distributions that require additional preprocessing.&lt;/li>
&lt;li>Two-variable PCA is a pedagogical simplification. Real composite indices (like the UNDP Human Development Index) use more indicators and often apply domain-specific weighting decisions alongside statistical methods.&lt;/li>
&lt;li>Min-Max normalization is sensitive to outliers. A single extreme country can compress the range for everyone else. Robust alternatives include percentile ranking or winsorization.&lt;/li>
&lt;/ul>
&lt;h2 id="16-exercises">16. Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Add a third indicator.&lt;/strong> Extend the data generating process with a third variable (e.g., &lt;code>healthcare_spending = 200 + 800 * base_health + noise&lt;/code>). Run the same pipeline with three variables. How does the variance explained by PC1 change? Do the eigenvector weights shift from equal?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Test outlier sensitivity.&lt;/strong> Modify one country to have extreme values (e.g., &lt;code>life_exp = 40&lt;/code>, &lt;code>infant_mort = 100&lt;/code>). How does Min-Max normalization affect the rankings of other countries? Try replacing Min-Max with percentile-based normalization and compare.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Apply to real data.&lt;/strong> Download Life Expectancy and Infant Mortality data from the &lt;a href="https://www.who.int/data/gho" target="_blank" rel="noopener">WHO Global Health Observatory&lt;/a>. Apply the six-step pipeline to real countries. Compare your PCA-based Health Index ranking with the UNDP Human Development Index and discuss any discrepancies.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="17-references">17. References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://doi.org/10.1098/rsta.2015.0202" target="_blank" rel="noopener">Jolliffe, I. T. and Cadima, J. (2016). Principal Component Analysis: A Review and Recent Developments. &lt;em>Philosophical Transactions of the Royal Society A&lt;/em>, 374(2065).&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1080/14786440109462720" target="_blank" rel="noopener">Pearson, K. (1901). On Lines and Planes of Closest Fit to Systems of Points in Space. &lt;em>The London, Edinburgh, and Dublin Philosophical Magazine&lt;/em>, 2(11), 559&amp;ndash;572.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html" target="_blank" rel="noopener">scikit-learn &amp;ndash; PCA Documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.StandardScaler.html" target="_blank" rel="noopener">scikit-learn &amp;ndash; StandardScaler Documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://hdr.undp.org/data-center/human-development-index" target="_blank" rel="noopener">UNDP (2024). Human Development Index &amp;ndash; Technical Notes.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.who.int/data/gho" target="_blank" rel="noopener">WHO &amp;ndash; Global Health Observatory Data Repository&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://carlos-mendez.org/publication/20210318-economia/" target="_blank" rel="noopener">Mendez, C. and Gonzales, E. (2021). Human Capital Constraints, Spatial Dependence, and Regionalization in Bolivia. &lt;em>Economia&lt;/em>, 44(87).&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://youtu.be/_6UjscCJrYE" target="_blank" rel="noopener">Principal Component Analysis (PCA) Explained Simply (YouTube)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://youtu.be/nEvKduLXFvk" target="_blank" rel="noopener">Visualizing Principal Component Analysis (PCA) (YouTube)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://numiqo.com/lab/pca" target="_blank" rel="noopener">Numiqo &amp;ndash; PCA Interactive Lab&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1093/oep/gpac022" target="_blank" rel="noopener">Peiro-Palomino, J., Picazo-Tadeo, A. J., and Rios, V. (2023). Social Progress around the World: Trends and Convergence. &lt;em>Oxford Economic Papers&lt;/em>, 75(2), 281&amp;ndash;306.&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>Pooled PCA for Building Development Indicators Across Time</title><link>https://carlos-mendez.org/post/python_pca2/</link><pubDate>Sat, 21 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_pca2/</guid><description>&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>In the &lt;a href="https://carlos-mendez.org/post/python_pca/">Introduction to PCA Analysis for Building Development Indicators&lt;/a>, we built a Health Index from two indicators using a six-step pipeline. That tutorial&amp;rsquo;s Discussion section raised a critical warning:&lt;/p>
&lt;blockquote>
&lt;p>If you compute the index separately for each year, both the eigenvector weights and the Z-score parameters (means, standard deviations) can shift from year to year, making scores from different periods non-comparable. A country&amp;rsquo;s index could improve not because its health system got better, but because the sample&amp;rsquo;s average got worse.&lt;/p>
&lt;/blockquote>
&lt;p>This sequel addresses that problem head-on with real data. We use the &lt;a href="https://globaldatalab.org/shdi/" target="_blank" rel="noopener">Subnational Human Development Index&lt;/a> from the Global Data Lab, which provides Education, Health, and Income sub-indices for 153 sub-national regions across 12 South American countries in 2013 and 2019. When we track development over time, we need the yardstick to remain fixed. If the ruler itself changes between measurements, we cannot tell whether the object grew or the ruler shrank. &lt;strong>Pooled PCA&lt;/strong> solves this by standardizing and computing weights from all periods simultaneously, producing a single fixed yardstick that makes scores directly comparable across time.&lt;/p>
&lt;p>The real data reveals a nuanced story: education and health improved on average across South America between 2013 and 2019, but income &lt;strong>declined&lt;/strong>. This mixed signal makes the choice between pooled and per-period PCA consequential &amp;mdash; the two methods disagree on the direction of change for 16 out of 153 regions.&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Understand why per-period PCA produces non-comparable scores across time&lt;/li>
&lt;li>Implement pooled standardization using cross-period means and standard deviations&lt;/li>
&lt;li>Compute pooled eigenvectors from stacked data to obtain stable weights&lt;/li>
&lt;li>Apply pooled normalization with cross-period min/max bounds&lt;/li>
&lt;li>Contrast pooled vs per-period PCA using rank stability and direction-of-change analysis&lt;/li>
&lt;/ul>
&lt;h2 id="2-the-pooled-pca-pipeline">2. The pooled PCA pipeline&lt;/h2>
&lt;p>The pooled pipeline extends the &lt;a href="https://carlos-mendez.org/post/python_pca/#2-the-pca-pipeline">six-step pipeline from the previous tutorial&lt;/a> by adding a stacking step at the beginning and replacing per-period parameters with pooled parameters at the standardization, covariance, and normalization steps.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
S[&amp;quot;&amp;lt;b&amp;gt;Step 0&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Stack&amp;lt;br/&amp;gt;Periods&amp;quot;] --&amp;gt; A[&amp;quot;&amp;lt;b&amp;gt;Step 1&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Polarity&amp;lt;br/&amp;gt;Adjustment&amp;quot;]
A --&amp;gt; B[&amp;quot;&amp;lt;b&amp;gt;Step 2&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Pooled&amp;lt;br/&amp;gt;Standardization&amp;quot;]
B --&amp;gt; C[&amp;quot;&amp;lt;b&amp;gt;Step 3&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Pooled&amp;lt;br/&amp;gt;Covariance&amp;quot;]
C --&amp;gt; D[&amp;quot;&amp;lt;b&amp;gt;Step 4&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Pooled Eigen-&amp;lt;br/&amp;gt;Decomposition&amp;quot;]
D --&amp;gt; E[&amp;quot;&amp;lt;b&amp;gt;Step 5&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Scoring&amp;lt;br/&amp;gt;(PC1)&amp;quot;]
E --&amp;gt; F[&amp;quot;&amp;lt;b&amp;gt;Step 6&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Pooled&amp;lt;br/&amp;gt;Normalization&amp;quot;]
style S fill:#141413,stroke:#6a9bcc,color:#fff
style A fill:#d97757,stroke:#141413,color:#fff
style B fill:#6a9bcc,stroke:#141413,color:#fff
style C fill:#6a9bcc,stroke:#141413,color:#fff
style D fill:#00d4c8,stroke:#141413,color:#fff
style E fill:#00d4c8,stroke:#141413,color:#fff
style F fill:#1a3a8a,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>The key insight is that Steps 2, 3, and 6 &amp;mdash; labeled &amp;ldquo;Pooled&amp;rdquo; &amp;mdash; compute their parameters from the stacked data (all periods combined) rather than from each period separately. This single change ensures that a region&amp;rsquo;s Z-score in 2013 is measured against the same baseline as its Z-score in 2019, that the eigenvector weights are fixed across time, and that the 0&amp;ndash;1 normalization uses a common scale.&lt;/p>
&lt;h2 id="3-setup-and-imports">3. Setup and imports&lt;/h2>
&lt;p>The analysis uses the same libraries as the &lt;a href="https://carlos-mendez.org/post/python_pca/">previous tutorial&lt;/a>: NumPy for linear algebra, pandas for data management, matplotlib for visualization, and scikit-learn for verification.&lt;/p>
&lt;pre>&lt;code class="language-python">import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
# Reproducibility
RANDOM_SEED = 42
# Site color palette
STEEL_BLUE = &amp;quot;#6a9bcc&amp;quot;
WARM_ORANGE = &amp;quot;#d97757&amp;quot;
NEAR_BLACK = &amp;quot;#141413&amp;quot;
TEAL = &amp;quot;#00d4c8&amp;quot;
&lt;/code>&lt;/pre>
&lt;details>
&lt;summary>Dark theme figure styling (click to expand)&lt;/summary>
&lt;pre>&lt;code class="language-python"># Dark theme palette (consistent with site navbar/dark sections)
DARK_NAVY = &amp;quot;#0f1729&amp;quot;
GRID_LINE = &amp;quot;#1f2b5e&amp;quot;
LIGHT_TEXT = &amp;quot;#c8d0e0&amp;quot;
WHITE_TEXT = &amp;quot;#e8ecf2&amp;quot;
# Plot defaults — minimal, spine-free, dark background
plt.rcParams.update({
&amp;quot;figure.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.edgecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.linewidth&amp;quot;: 0,
&amp;quot;axes.labelcolor&amp;quot;: LIGHT_TEXT,
&amp;quot;axes.titlecolor&amp;quot;: WHITE_TEXT,
&amp;quot;axes.spines.top&amp;quot;: False,
&amp;quot;axes.spines.right&amp;quot;: False,
&amp;quot;axes.spines.left&amp;quot;: False,
&amp;quot;axes.spines.bottom&amp;quot;: False,
&amp;quot;axes.grid&amp;quot;: True,
&amp;quot;grid.color&amp;quot;: GRID_LINE,
&amp;quot;grid.linewidth&amp;quot;: 0.6,
&amp;quot;grid.alpha&amp;quot;: 0.8,
&amp;quot;xtick.color&amp;quot;: LIGHT_TEXT,
&amp;quot;ytick.color&amp;quot;: LIGHT_TEXT,
&amp;quot;xtick.major.size&amp;quot;: 0,
&amp;quot;ytick.major.size&amp;quot;: 0,
&amp;quot;text.color&amp;quot;: WHITE_TEXT,
&amp;quot;font.size&amp;quot;: 12,
&amp;quot;legend.frameon&amp;quot;: False,
&amp;quot;legend.fontsize&amp;quot;: 11,
&amp;quot;legend.labelcolor&amp;quot;: LIGHT_TEXT,
&amp;quot;figure.edgecolor&amp;quot;: DARK_NAVY,
&amp;quot;savefig.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;savefig.edgecolor&amp;quot;: DARK_NAVY,
})
&lt;/code>&lt;/pre>
&lt;/details>
&lt;h2 id="4-loading-the-subnational-hdi-data">4. Loading the Subnational HDI data&lt;/h2>
&lt;p>The dataset is a subsample from the &lt;a href="https://globaldatalab.org/shdi/" target="_blank" rel="noopener">Subnational Human Development Database&lt;/a> constructed by &lt;a href="https://doi.org/10.1038/sdata.2019.38" target="_blank" rel="noopener">Smits and Permanyer (2019)&lt;/a>, which provides sub-national development indicators for countries worldwide. We use the South American subset with three HDI component indices for 2013 and 2019. The original data is in wide format (one row per region, with year-specific columns), so we reshape it into a long panel format suitable for pooled PCA.&lt;/p>
&lt;pre>&lt;code class="language-python">DATA_URL = &amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/master/content/post/python_pca2/data.csv&amp;quot;
raw = pd.read_csv(DATA_URL)
print(f&amp;quot;Raw dataset: {raw.shape[0]} regions, {raw.shape[1]} columns&amp;quot;)
print(f&amp;quot;Countries: {raw['country'].nunique()}&amp;quot;)
# Reshape wide → long
rows = []
for _, r in raw.iterrows():
for year in [2013, 2019]:
rows.append({
&amp;quot;GDLcode&amp;quot;: r[&amp;quot;GDLcode&amp;quot;],
&amp;quot;region&amp;quot;: r[&amp;quot;region&amp;quot;],
&amp;quot;country&amp;quot;: r[&amp;quot;country&amp;quot;],
&amp;quot;period&amp;quot;: f&amp;quot;Y{year}&amp;quot;,
&amp;quot;education&amp;quot;: round(r[f&amp;quot;edindex{year}&amp;quot;], 4),
&amp;quot;health&amp;quot;: round(r[f&amp;quot;healthindex{year}&amp;quot;], 4),
&amp;quot;income&amp;quot;: round(r[f&amp;quot;incindex{year}&amp;quot;], 4),
&amp;quot;shdi_official&amp;quot;: round(r[f&amp;quot;shdi{year}&amp;quot;], 4),
&amp;quot;pop&amp;quot;: round(r[f&amp;quot;pop{year}&amp;quot;], 1),
})
df = pd.DataFrame(rows)
&lt;/code>&lt;/pre>
&lt;p>To make regions instantly identifiable in figures and tables, we create a &lt;code>region_country&lt;/code> label that combines a shortened region name with a three-letter country abbreviation. This avoids ambiguity &amp;mdash; for example, &amp;ldquo;Cordoba&amp;rdquo; exists in both Argentina and Colombia.&lt;/p>
&lt;pre>&lt;code class="language-python"># Create informative label: shortened region + country abbreviation
COUNTRY_ABBR = {
&amp;quot;Argentina&amp;quot;: &amp;quot;ARG&amp;quot;, &amp;quot;Bolivia&amp;quot;: &amp;quot;BOL&amp;quot;, &amp;quot;Brazil&amp;quot;: &amp;quot;BRA&amp;quot;,
&amp;quot;Chile&amp;quot;: &amp;quot;CHL&amp;quot;, &amp;quot;Colombia&amp;quot;: &amp;quot;COL&amp;quot;, &amp;quot;Ecuador&amp;quot;: &amp;quot;ECU&amp;quot;,
&amp;quot;Guyana&amp;quot;: &amp;quot;GUY&amp;quot;, &amp;quot;Paraguay&amp;quot;: &amp;quot;PRY&amp;quot;, &amp;quot;Peru&amp;quot;: &amp;quot;PER&amp;quot;,
&amp;quot;Suriname&amp;quot;: &amp;quot;SUR&amp;quot;, &amp;quot;Uruguay&amp;quot;: &amp;quot;URY&amp;quot;, &amp;quot;Venezuela&amp;quot;: &amp;quot;VEN&amp;quot;,
}
def make_label(region, country, max_len=25):
&amp;quot;&amp;quot;&amp;quot;Shorten region name and append country abbreviation.&amp;quot;&amp;quot;&amp;quot;
abbr = COUNTRY_ABBR.get(country, country[:3].upper())
short = region[:max_len].rstrip(&amp;quot;, &amp;quot;) if len(region) &amp;gt; max_len else region
return f&amp;quot;{short} ({abbr})&amp;quot;
df[&amp;quot;region_country&amp;quot;] = df.apply(
lambda r: make_label(r[&amp;quot;region&amp;quot;], r[&amp;quot;country&amp;quot;]), axis=1
)
df.to_csv(&amp;quot;data_long.csv&amp;quot;, index=False)
INDICATORS = [&amp;quot;education&amp;quot;, &amp;quot;health&amp;quot;, &amp;quot;income&amp;quot;]
print(f&amp;quot;\nPanel dataset: {df.shape[0]} rows (= {raw.shape[0]} regions x 2 periods)&amp;quot;)
print(f&amp;quot;\nFirst 6 rows:&amp;quot;)
print(df[[&amp;quot;region_country&amp;quot;, &amp;quot;period&amp;quot;, &amp;quot;education&amp;quot;, &amp;quot;health&amp;quot;, &amp;quot;income&amp;quot;]].head(6).to_string(index=False))
print(f&amp;quot;\nRegions per country:&amp;quot;)
print(raw[&amp;quot;country&amp;quot;].value_counts().sort_index().to_string())
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Panel dataset: 306 rows (= 153 regions x 2 periods)
First 6 rows:
region_country period education health income
City of Buenos Aires (ARG) Y2013 0.926 0.858 0.850
City of Buenos Aires (ARG) Y2019 0.946 0.872 0.832
Rest of Buenos Aires (ARG) Y2013 0.797 0.858 0.820
Rest of Buenos Aires (ARG) Y2019 0.830 0.872 0.802
Catamarca, La Rioja, San (ARG) Y2013 0.822 0.858 0.828
Catamarca, La Rioja, San (ARG) Y2019 0.856 0.872 0.810
Regions per country:
country
Argentina 11
Bolivia 9
Brazil 27
Chile 13
Colombia 33
Ecuador 3
Guyana 10
Paraguay 5
Peru 6
Suriname 5
Uruguay 7
Venezuela 24
&lt;/code>&lt;/pre>
&lt;p>The panel contains 306 rows (153 regions $\times$ 2 periods) covering 12 South American countries. Colombia contributes the most regions (33), followed by Brazil (27) and Venezuela (24), while Ecuador has only 3. The &lt;code>region_country&lt;/code> label &amp;mdash; such as &amp;ldquo;City of Buenos Aires (ARG)&amp;rdquo; or &amp;ldquo;Potosi (BOL)&amp;rdquo; &amp;mdash; will make every region immediately identifiable in the analysis that follows.&lt;/p>
&lt;pre>&lt;code class="language-python">print(f&amp;quot;Period means:&amp;quot;)
print(df.groupby(&amp;quot;period&amp;quot;)[INDICATORS].mean().round(4).to_string())
p1_means = df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2013&amp;quot;][INDICATORS].mean()
p2_means = df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2019&amp;quot;][INDICATORS].mean()
changes = p2_means - p1_means
print(f&amp;quot;\nMean changes (2019 - 2013):&amp;quot;)
print(f&amp;quot; Education: {changes['education']:+.4f}&amp;quot;)
print(f&amp;quot; Health: {changes['health']:+.4f}&amp;quot;)
print(f&amp;quot; Income: {changes['income']:+.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Period means:
education health income
period
Y2013 0.6674 0.8370 0.7355
Y2019 0.6899 0.8504 0.7153
Mean changes (2019 - 2013):
Education: +0.0225
Health: +0.0134
Income: -0.0202
&lt;/code>&lt;/pre>
&lt;p>The period means reveal a mixed development story: education rose from 0.667 to 0.690 (+0.023) and health from 0.837 to 0.850 (+0.013), but income &lt;strong>declined&lt;/strong> from 0.736 to 0.715 ($-0.020$). This income decline across much of South America between 2013 and 2019 &amp;mdash; driven by commodity price drops and economic slowdowns &amp;mdash; is a real signal that our PCA-based index must capture correctly. Note that all three indicators are positive-direction (higher means better), so no polarity adjustment is needed.&lt;/p>
&lt;h2 id="5-exploring-the-raw-data">5. Exploring the raw data&lt;/h2>
&lt;p>Before running any PCA, let us examine the country-level patterns, the correlation structure, and the period-to-period shift.&lt;/p>
&lt;pre>&lt;code class="language-python"># Country-level means by period
print(f&amp;quot;Country-level means by period:&amp;quot;)
country_means = (df.groupby([&amp;quot;country&amp;quot;, &amp;quot;period&amp;quot;])[INDICATORS]
.mean().round(3).unstack(&amp;quot;period&amp;quot;))
country_means.columns = [f&amp;quot;{col[0]}_{col[1]}&amp;quot; for col in country_means.columns]
print(country_means.to_string())
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Country-level means by period:
education_Y2013 education_Y2019 health_Y2013 health_Y2019 income_Y2013 income_Y2019
country
Argentina 0.823 0.852 0.858 0.872 0.827 0.809
Bolivia 0.652 0.689 0.778 0.809 0.633 0.665
Brazil 0.659 0.684 0.838 0.859 0.745 0.732
Chile 0.732 0.781 0.911 0.925 0.806 0.814
Colombia 0.615 0.654 0.858 0.876 0.707 0.720
Ecuador 0.688 0.691 0.857 0.877 0.707 0.699
Guyana 0.568 0.574 0.747 0.764 0.599 0.622
Paraguay 0.612 0.624 0.820 0.835 0.698 0.717
Peru 0.671 0.713 0.845 0.866 0.688 0.703
Suriname 0.584 0.627 0.791 0.804 0.757 0.725
Uruguay 0.694 0.722 0.878 0.891 0.790 0.799
Venezuela 0.708 0.682 0.813 0.801 0.782 0.630
&lt;/code>&lt;/pre>
&lt;p>The country-level means reveal stark development gaps across South America. Chile leads in health (0.911&amp;ndash;0.925) and is strong across all dimensions. Argentina leads in education (0.823&amp;ndash;0.852) with high income. At the other end, Guyana has the lowest education (0.568&amp;ndash;0.574) and Bolivia the lowest health (0.778&amp;ndash;0.809). Most countries improved on all three indicators between 2013 and 2019, but two stand out for &lt;strong>income decline&lt;/strong>: Venezuela&amp;rsquo;s income collapsed from 0.782 to 0.630 ($-0.152$), reflecting its severe economic crisis, and Argentina&amp;rsquo;s income also fell from 0.827 to 0.809. These divergent trajectories across countries are precisely why a fixed yardstick (pooled PCA) is essential for temporal comparison.&lt;/p>
&lt;pre>&lt;code class="language-python">corr_matrix = df[INDICATORS].corr().round(4)
print(f&amp;quot;Pooled correlation matrix:&amp;quot;)
print(corr_matrix.to_string())
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Pooled correlation matrix:
education health income
education 1.0000 0.4392 0.6808
health 0.4392 1.0000 0.6303
income 0.6808 0.6303 1.0000
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca2_correlation_heatmap.png" alt="Pooled correlation heatmap of the three HDI sub-indices.">&lt;/p>
&lt;p>The correlations are moderate to strong but far from the near-perfect values we saw in the &lt;a href="https://carlos-mendez.org/post/python_pca/">previous tutorial&amp;rsquo;s&lt;/a> simulated data ($r &amp;gt; 0.93$). Education and Income show the strongest correlation (0.68), followed by Health and Income (0.63), with Education and Health the weakest (0.44). These lower correlations mean PCA will capture less variance in PC1 &amp;mdash; the three indicators carry more independent information than in the simulated case, reflecting the genuine complexity of human development. The weak Education-Health link (0.44) suggests that a region can have high literacy but mediocre life expectancy (or vice versa) &amp;mdash; education and health are partly independent dimensions of development.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 6))
fig.patch.set_linewidth(0)
p1 = df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2013&amp;quot;]
p2 = df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2019&amp;quot;]
ax.scatter(p1[&amp;quot;education&amp;quot;], p1[&amp;quot;income&amp;quot;], color=STEEL_BLUE,
edgecolors=DARK_NAVY, s=40, zorder=3, alpha=0.7, label=&amp;quot;2013&amp;quot;)
ax.scatter(p2[&amp;quot;education&amp;quot;], p2[&amp;quot;income&amp;quot;], color=WARM_ORANGE,
edgecolors=DARK_NAVY, s=40, zorder=3, alpha=0.7, label=&amp;quot;2019&amp;quot;)
# Centroid arrows
c1_edu, c1_inc = p1[&amp;quot;education&amp;quot;].mean(), p1[&amp;quot;income&amp;quot;].mean()
c2_edu, c2_inc = p2[&amp;quot;education&amp;quot;].mean(), p2[&amp;quot;income&amp;quot;].mean()
ax.annotate(&amp;quot;&amp;quot;, xy=(c2_edu, c2_inc), xytext=(c1_edu, c1_inc),
arrowprops=dict(arrowstyle=&amp;quot;-|&amp;gt;&amp;quot;, color=TEAL, lw=2.5))
ax.set_xlabel(&amp;quot;Education Index&amp;quot;)
ax.set_ylabel(&amp;quot;Income Index&amp;quot;)
ax.set_title(&amp;quot;Education vs. Income by period (153 South American regions)&amp;quot;)
ax.legend(loc=&amp;quot;lower right&amp;quot;)
plt.savefig(&amp;quot;pca2_period_shift_scatter.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca2_period_shift_scatter.png" alt="Scatter plot of Education vs Income colored by period, showing education rising but income declining.">&lt;/p>
&lt;p>The scatter plot reveals a striking pattern: between 2013 (steel blue) and 2019 (orange), the cloud shifted &lt;strong>right&lt;/strong> (education improved) but &lt;strong>downward&lt;/strong> (income declined). The teal arrow connecting the two period centroids captures this asymmetric shift. This is a real-world complication that simple simulated data would not produce &amp;mdash; per-period PCA will handle this mixed signal differently from pooled PCA.&lt;/p>
&lt;h2 id="6-the-problem-per-period-pca">6. The problem: per-period PCA&lt;/h2>
&lt;p>To understand why pooled PCA is necessary, let us first see what goes wrong with the naive approach. We run the full six-step pipeline separately for each period &amp;mdash; standardizing with period-specific means, computing period-specific eigenvectors, and normalizing with period-specific bounds.&lt;/p>
&lt;p>&lt;strong>Per-period standardization&lt;/strong> uses different baselines for each period:&lt;/p>
&lt;p>$$Z_{ij}^{(t)} = \frac{X_{ij,t} - \bar{X}_j^{(t)}}{\sigma_j^{(t)}}$$&lt;/p>
&lt;p>In words, this says: standardize using only the data from period $t$. The mean and standard deviation change between periods, so the yardstick shifts.&lt;/p>
&lt;p>&lt;strong>Per-period normalization&lt;/strong> uses different bounds for each period:&lt;/p>
&lt;p>$$HDI_i^{(t)} = \frac{PC1_i^{(t)} - PC1_{min}^{(t)}}{PC1_{max}^{(t)} - PC1_{min}^{(t)}}$$&lt;/p>
&lt;p>In words, this says: the worst region in each period gets 0 and the best gets 1, but the scale resets every period.&lt;/p>
&lt;pre>&lt;code class="language-python">def run_single_period_pca(df_period, indicators):
&amp;quot;&amp;quot;&amp;quot;Run the full PCA pipeline on a single-period DataFrame.&amp;quot;&amp;quot;&amp;quot;
X = df_period[indicators].values
means = X.mean(axis=0)
stds = X.std(axis=0, ddof=0)
Z = (X - means) / stds
cov = np.cov(Z.T, ddof=0)
eigenvalues, eigenvectors = np.linalg.eigh(cov)
idx = np.argsort(eigenvalues)[::-1]
eigenvalues = eigenvalues[idx]
eigenvectors = eigenvectors[:, idx]
if eigenvectors[0, 0] &amp;lt; 0:
eigenvectors[:, 0] *= -1
pc1 = Z @ eigenvectors[:, 0]
hdi = (pc1 - pc1.min()) / (pc1.max() - pc1.min())
return {&amp;quot;pc1&amp;quot;: pc1, &amp;quot;hdi&amp;quot;: hdi, &amp;quot;weights&amp;quot;: eigenvectors[:, 0],
&amp;quot;eigenvalues&amp;quot;: eigenvalues,
&amp;quot;var_explained&amp;quot;: eigenvalues / eigenvalues.sum() * 100,
&amp;quot;means&amp;quot;: means, &amp;quot;stds&amp;quot;: stds}
pp_p1 = run_single_period_pca(df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2013&amp;quot;], INDICATORS)
pp_p2 = run_single_period_pca(df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2019&amp;quot;], INDICATORS)
print(f&amp;quot;Per-period eigenvector weights (PC1):&amp;quot;)
print(f&amp;quot; 2013: [{pp_p1['weights'][0]:.4f}, {pp_p1['weights'][1]:.4f}, {pp_p1['weights'][2]:.4f}]&amp;quot;)
print(f&amp;quot; 2019: [{pp_p2['weights'][0]:.4f}, {pp_p2['weights'][1]:.4f}, {pp_p2['weights'][2]:.4f}]&amp;quot;)
print(f&amp;quot; Shift: [{pp_p2['weights'][0] - pp_p1['weights'][0]:+.4f}, &amp;quot;
f&amp;quot;{pp_p2['weights'][1] - pp_p1['weights'][1]:+.4f}, &amp;quot;
f&amp;quot;{pp_p2['weights'][2] - pp_p1['weights'][2]:+.4f}]&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Per-period eigenvector weights (PC1):
2013: [0.5832, 0.5100, 0.6322]
2019: [0.5405, 0.5657, 0.6228]
Shift: [-0.0427, +0.0556, -0.0095]
&lt;/code>&lt;/pre>
&lt;p>The eigenvector weights shift substantially between periods. Education&amp;rsquo;s weight drops from 0.583 to 0.541 ($-0.043$), while Health&amp;rsquo;s weight jumps from 0.510 to 0.566 ($+0.056$). This means the index formula itself changes &amp;mdash; a region&amp;rsquo;s 2013 HDI and 2019 HDI are computed with different recipes, making temporal comparison unreliable. Under per-period PCA, &lt;strong>43 out of 153 regions appear to decline&lt;/strong> in HDI despite the overall improvement in education and health. The per-period approach erases the mixed global signal by re-centering every period to a mean of zero.&lt;/p>
&lt;p>&lt;img src="pca2_perperiod_weights.png" alt="Grouped bar chart showing per-period eigenvector weights shifting between 2013 and 2019.">&lt;/p>
&lt;p>To visualize how individual regions shift in rank under per-period PCA, we store each period&amp;rsquo;s HDI scores and compute ranks.&lt;/p>
&lt;pre>&lt;code class="language-python">df_p1 = df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2013&amp;quot;].copy()
df_p2 = df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2019&amp;quot;].copy()
df_p1[&amp;quot;pp_hdi&amp;quot;] = pp_p1[&amp;quot;hdi&amp;quot;]
df_p2[&amp;quot;pp_hdi&amp;quot;] = pp_p2[&amp;quot;hdi&amp;quot;]
df_p1[&amp;quot;pp_rank&amp;quot;] = df_p1[&amp;quot;pp_hdi&amp;quot;].rank(ascending=False).astype(int)
df_p2[&amp;quot;pp_rank&amp;quot;] = df_p2[&amp;quot;pp_hdi&amp;quot;].rank(ascending=False).astype(int)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 10))
fig.patch.set_linewidth(0)
rank_change = df_p2[&amp;quot;pp_rank&amp;quot;].values - df_p1[&amp;quot;pp_rank&amp;quot;].values
abs_change = np.abs(rank_change)
top_changers_idx = np.argsort(abs_change)[-10:]
for i in top_changers_idx:
r1 = df_p1.iloc[i][&amp;quot;pp_rank&amp;quot;]
r2 = df_p2.iloc[i][&amp;quot;pp_rank&amp;quot;]
label = df_p1.iloc[i][&amp;quot;region_country&amp;quot;]
color = TEAL if r2 &amp;lt; r1 else WARM_ORANGE
ax.plot([0, 1], [r1, r2], color=color, linewidth=2, alpha=0.8)
ax.text(-0.05, r1, f&amp;quot;{label} (#{int(r1)})&amp;quot;, ha=&amp;quot;right&amp;quot;, va=&amp;quot;center&amp;quot;,
fontsize=7, color=LIGHT_TEXT)
ax.text(1.05, r2, f&amp;quot;{label} (#{int(r2)})&amp;quot;, ha=&amp;quot;left&amp;quot;, va=&amp;quot;center&amp;quot;,
fontsize=7, color=LIGHT_TEXT)
ax.set_xlim(-0.6, 1.6)
ax.set_ylim(160, -5)
ax.set_xticks([0, 1])
ax.set_xticklabels([&amp;quot;2013 Rank&amp;quot;, &amp;quot;2019 Rank&amp;quot;], fontsize=13)
ax.set_ylabel(&amp;quot;Rank (1 = best)&amp;quot;)
ax.set_title(&amp;quot;Per-period PCA: rank shifts for 10 regions\n(teal = improved, orange = declined)&amp;quot;)
plt.savefig(&amp;quot;pca2_perperiod_rank_shift.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca2_perperiod_rank_shift.png" alt="Slopegraph showing 10 regions with the largest rank shifts under per-period PCA.">&lt;/p>
&lt;p>&lt;strong>The running example:&lt;/strong> City of Buenos Aires &amp;mdash; Argentina&amp;rsquo;s capital and one of the most developed regions in South America &amp;mdash; has a per-period HDI of 1.000 in 2013 (ranked #1) and 0.960 in 2019 &amp;mdash; a &lt;strong>decline of -0.04&lt;/strong>. But we know Buenos Aires improved in education (0.926 $\to$ 0.946) and health (0.858 $\to$ 0.872), with only a modest income decline (0.850 $\to$ 0.832). Is Buenos Aires really declining, or is the shifting yardstick hiding a more nuanced story?&lt;/p>
&lt;h2 id="7-pooled-step-1-stacking-the-data">7. Pooled Step 1: Stacking the data&lt;/h2>
&lt;p>The first step of pooled PCA is to stack all periods into a single dataset. From PCA&amp;rsquo;s perspective, we have 306 observations (153 regions $\times$ 2 periods), not two separate groups. The &lt;code>period&lt;/code> column is metadata that we carry through for analysis, but it does not enter the PCA computation.&lt;/p>
&lt;pre>&lt;code class="language-python">print(f&amp;quot;Stacked dataset: {df.shape[0]} rows, {df.shape[1]} columns&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Stacked dataset: 306 rows, 9 columns
&lt;/code>&lt;/pre>
&lt;p>The stacked dataset has 306 rows. PCA will treat each row equally regardless of which period it belongs to, producing a single set of standardization parameters and a single set of eigenvector weights.&lt;/p>
&lt;h2 id="8-pooled-step-2-pooled-standardization">8. Pooled Step 2: Pooled standardization&lt;/h2>
&lt;p>&lt;strong>What it is:&lt;/strong> We compute the mean and standard deviation from the entire stacked dataset (all 306 rows) and use these pooled parameters to standardize every observation:&lt;/p>
&lt;p>$$Z_{ij,t}^{pooled} = \frac{X_{ij,t} - \bar{X}_j^{pooled}}{\sigma_j^{pooled}}$$&lt;/p>
&lt;p>In words, this says: for region $i$, indicator $j$, at time $t$, subtract the pooled mean $\bar{X}_j^{pooled}$ (computed across all regions and all periods) and divide by the pooled standard deviation $\sigma_j^{pooled}$.&lt;/p>
&lt;p>&lt;strong>The application:&lt;/strong> City of Buenos Aires has education = 0.926 in 2013 and 0.946 in 2019. The pooled mean for education is 0.679 and the pooled standard deviation is 0.081. Under per-period standardization, 2013 uses mean = 0.667 and 2019 uses mean = 0.690 &amp;mdash; a shifting baseline. Under pooled standardization, both periods use the same mean = 0.679. The increase from 0.926 to 0.946 maps to a genuine increase in pooled Z-score.&lt;/p>
&lt;p>&lt;strong>The intuition:&lt;/strong> Imagine measuring children&amp;rsquo;s heights at age 5 and age 10. Per-period standardization compares each child only to their same-age peers: a tall 5-year-old gets a high Z-score, and a tall 10-year-old gets a high Z-score, but you cannot tell how much each child grew because the reference group changed. Pooled standardization measures everyone against the same ruler &amp;mdash; the combined height distribution &amp;mdash; so the Z-score increase from age 5 to age 10 directly reflects actual growth.&lt;/p>
&lt;p>&lt;strong>The necessity:&lt;/strong> Without pooled standardization, the income decline (from 0.736 to 0.715 on average) would be hidden. Per-period Z-scores re-center income to zero each period, erasing the decline. Pooled Z-scores preserve it: the 2019 income Z-scores average slightly below zero, correctly reflecting the real economic setback.&lt;/p>
&lt;pre>&lt;code class="language-python">X_all = df[INDICATORS].values # 306 rows
pooled_means = X_all.mean(axis=0)
pooled_stds = X_all.std(axis=0, ddof=0)
Z_pooled = (X_all - pooled_means) / pooled_stds
print(f&amp;quot;Pooled standardization parameters:&amp;quot;)
print(f&amp;quot; Means: [{pooled_means[0]:.4f}, {pooled_means[1]:.4f}, {pooled_means[2]:.4f}]&amp;quot;)
print(f&amp;quot; Stds: [{pooled_stds[0]:.4f}, {pooled_stds[1]:.4f}, {pooled_stds[2]:.4f}]&amp;quot;)
scaler = StandardScaler()
Z_sklearn = scaler.fit_transform(X_all)
max_diff = np.max(np.abs(Z_sklearn - Z_pooled))
print(f&amp;quot;\nMax difference from sklearn StandardScaler: {max_diff:.2e}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Pooled standardization parameters:
Means: [0.6786, 0.8437, 0.7254]
Stds: [0.0814, 0.0472, 0.0749]
Max difference from sklearn StandardScaler: 0.00e+00
&lt;/code>&lt;/pre>
&lt;p>The pooled means sit between the period-specific means (e.g., education: 0.667 in 2013, 0.690 in 2019, 0.679 pooled). The standard deviations are similar across periods because the within-period spread is much larger than the between-period level shift. The zero-difference check against &lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.StandardScaler.html" target="_blank" rel="noopener">StandardScaler()&lt;/a> confirms our manual computation is correct.&lt;/p>
&lt;h2 id="9-pooled-step-3-covariance-matrix">9. Pooled Step 3: Covariance matrix&lt;/h2>
&lt;p>We compute the $3 \times 3$ covariance matrix from the pooled standardized data (all 306 rows):&lt;/p>
&lt;p>$$\Sigma^{pooled} = \frac{1}{nT} Z^{pooled^T} Z^{pooled}$$&lt;/p>
&lt;p>In words, this says: the pooled covariance matrix measures how the three standardized indicators co-move across all region-period observations.&lt;/p>
&lt;pre>&lt;code class="language-python">cov_pooled = np.cov(Z_pooled.T, ddof=0)
print(f&amp;quot;Pooled covariance matrix (3x3):&amp;quot;)
for i in range(3):
row = &amp;quot; [&amp;quot; + &amp;quot; &amp;quot;.join(f&amp;quot;{cov_pooled[i, j]:.4f}&amp;quot; for j in range(3)) + &amp;quot;]&amp;quot;
print(row)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Pooled covariance matrix (3x3):
[1.0000 0.4392 0.6808]
[0.4392 1.0000 0.6303]
[0.6808 0.6303 1.0000]
&lt;/code>&lt;/pre>
&lt;p>The off-diagonals range from 0.44 (Education-Health) to 0.68 (Education-Income). These are substantially lower than the 0.93&amp;ndash;0.95 values in the &lt;a href="https://carlos-mendez.org/post/python_pca/#8-step-3-the-covariance-matrix----mapping-the-overlap">simulated data from the previous tutorial&lt;/a>, reflecting the genuine complexity of human development. Education and Health are only moderately correlated because they measure different dimensions &amp;mdash; a region can have high literacy but mediocre life expectancy (or vice versa). This means PC1 will capture less total variance, and the eigenvector weights will be more unequal.&lt;/p>
&lt;h2 id="10-pooled-step-4-eigen-decomposition">10. Pooled Step 4: Eigen-decomposition&lt;/h2>
&lt;p>We decompose the pooled covariance matrix to find the direction of maximum spread:&lt;/p>
&lt;p>$$\Sigma^{pooled} \mathbf{v}_k = \lambda_k \mathbf{v}_k$$&lt;/p>
&lt;p>The PC1 score for each region-period is:&lt;/p>
&lt;p>$$PC1_{i,t} = w_1 , Z_{i,edu,t}^{pooled} + w_2 , Z_{i,health,t}^{pooled} + w_3 , Z_{i,income,t}^{pooled}$$&lt;/p>
&lt;p>In words, this says: each region&amp;rsquo;s PC1 score is a weighted sum of its three pooled-standardized indicators, using the single set of pooled weights $[w_1, w_2, w_3]$.&lt;/p>
&lt;pre>&lt;code class="language-python">eigenvalues, eigenvectors = np.linalg.eigh(cov_pooled)
idx = np.argsort(eigenvalues)[::-1]
eigenvalues = eigenvalues[idx]
eigenvectors = eigenvectors[:, idx]
if eigenvectors[0, 0] &amp;lt; 0:
eigenvectors[:, 0] *= -1
var_explained = eigenvalues / eigenvalues.sum() * 100
print(f&amp;quot;Pooled eigenvalues: [{eigenvalues[0]:.4f}, {eigenvalues[1]:.4f}, {eigenvalues[2]:.4f}]&amp;quot;)
print(f&amp;quot;\nPooled eigenvector (PC1): [{eigenvectors[0, 0]:.4f}, {eigenvectors[1, 0]:.4f}, {eigenvectors[2, 0]:.4f}]&amp;quot;)
print(f&amp;quot;\nVariance explained:&amp;quot;)
print(f&amp;quot; PC1: {var_explained[0]:.2f}%&amp;quot;)
print(f&amp;quot; PC2: {var_explained[1]:.2f}%&amp;quot;)
print(f&amp;quot; PC3: {var_explained[2]:.2f}%&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Pooled eigenvalues: [2.1726, 0.5631, 0.2643]
Pooled eigenvector (PC1): [0.5642, 0.5448, 0.6204]
Variance explained:
PC1: 72.42%
PC2: 18.77%
PC3: 8.81%
&lt;/code>&lt;/pre>
&lt;p>PC1 captures 72.42% of all variance &amp;mdash; substantially less than the 96% in the simulated tutorial, but still a strong majority. The eigenvector weights are $[0.5642, 0.5448, 0.6204]$, revealing that &lt;strong>Income carries the highest weight&lt;/strong> (0.620), followed by Education (0.564), with Health contributing least (0.545). This unequal weighting reflects the real-world correlation structure: Income is more strongly correlated with the other two indicators, so it contributes more unique information to the composite index. Unlike the two-variable case from the &lt;a href="https://carlos-mendez.org/post/python_pca/#9-step-4-eigen-decomposition----finding-the-optimal-direction">previous tutorial&lt;/a> where equal weights were a mathematical certainty, three variables allow PCA to discover data-driven weights. Crucially, these weights are &lt;strong>fixed&lt;/strong> &amp;mdash; the same weights apply to 2013 and 2019 because they were computed from the pooled data.&lt;/p>
&lt;p>&lt;img src="pca2_pooled_variance_explained.png" alt="Bar chart showing PC1 captures 72.4%, PC2 captures 18.8%, and PC3 captures 8.8%.">&lt;/p>
&lt;p>The variance explained chart shows PC1 dominating but with meaningful contributions from PC2 (18.8%) and PC3 (8.8%). The fact that PC2 and PC3 are not negligible means some development dimensions are not captured by a single index. For instance, PC2 might separate regions with high education but low income from those with the opposite pattern. For this tutorial, we focus on PC1 as the composite HDI, but researchers working with this data should consider whether retaining PC2 adds meaningful insight.&lt;/p>
&lt;h2 id="11-pooled-step-5-scoring">11. Pooled Step 5: Scoring&lt;/h2>
&lt;p>We project all 306 rows onto PC1 using the fixed pooled weights.&lt;/p>
&lt;pre>&lt;code class="language-python">w = eigenvectors[:, 0]
df[&amp;quot;pc1&amp;quot;] = Z_pooled @ w
pc1_p1 = df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2013&amp;quot;][&amp;quot;pc1&amp;quot;]
pc1_p2 = df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2019&amp;quot;][&amp;quot;pc1&amp;quot;]
print(f&amp;quot;Pooled PC1 score statistics:&amp;quot;)
print(f&amp;quot; 2013 mean: {pc1_p1.mean():.4f}&amp;quot;)
print(f&amp;quot; 2019 mean: {pc1_p2.mean():.4f}&amp;quot;)
print(f&amp;quot; Shift: {pc1_p2.mean() - pc1_p1.mean():+.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Pooled PC1 score statistics:
2013 mean: -0.0720
2019 mean: 0.0720
Shift: +0.1439
&lt;/code>&lt;/pre>
&lt;p>The 2013 mean PC1 score is $-0.072$ (below the grand mean) and the 2019 mean is $+0.072$ (above the grand mean). The shift of $+0.144$ represents pooled PCA&amp;rsquo;s measure of net development progress across South America. This is a modest positive shift, reflecting the trade-off between education/health gains and income decline. Under per-period PCA, this shift would be exactly zero by construction &amp;mdash; the net progress would be invisible.&lt;/p>
&lt;h2 id="12-pooled-step-6-normalization">12. Pooled Step 6: Normalization&lt;/h2>
&lt;p>We apply Min-Max normalization using the pooled bounds &amp;mdash; the minimum and maximum PC1 scores across all 306 observations:&lt;/p>
&lt;p>$$HDI_{i,t} = \frac{PC1_{i,t} - PC1_{min}^{pooled}}{PC1_{max}^{pooled} - PC1_{min}^{pooled}}$$&lt;/p>
&lt;pre>&lt;code class="language-python">pc1_min = df[&amp;quot;pc1&amp;quot;].min()
pc1_max = df[&amp;quot;pc1&amp;quot;].max()
df[&amp;quot;hdi&amp;quot;] = (df[&amp;quot;pc1&amp;quot;] - pc1_min) / (pc1_max - pc1_min)
print(f&amp;quot;\nPooled HDI — 2019 top 5:&amp;quot;)
print(df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2019&amp;quot;].nlargest(5, &amp;quot;hdi&amp;quot;)[
[&amp;quot;region_country&amp;quot;, &amp;quot;education&amp;quot;, &amp;quot;health&amp;quot;, &amp;quot;income&amp;quot;, &amp;quot;hdi&amp;quot;]
].to_string(index=False))
print(f&amp;quot;\nPooled HDI — 2013 bottom 5:&amp;quot;)
print(df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2013&amp;quot;].nsmallest(5, &amp;quot;hdi&amp;quot;)[
[&amp;quot;region_country&amp;quot;, &amp;quot;education&amp;quot;, &amp;quot;health&amp;quot;, &amp;quot;income&amp;quot;, &amp;quot;hdi&amp;quot;]
].to_string(index=False))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Pooled HDI — 2019 top 5:
region_country education health income hdi
Region Metropolitana (CHL) 0.877 0.929 0.844 1.000000
Tarapaca (incl Arica and (CHL) 0.888 0.937 0.823 0.999348
City of Buenos Aires (ARG) 0.946 0.872 0.832 0.965232
Antofagasta (CHL) 0.894 0.896 0.838 0.961010
Valparaiso (former Aconca (CHL) 0.842 0.931 0.831 0.959202
Pooled HDI — 2013 bottom 5:
region_country education health income hdi
Potaro-Siparuni (GUY) 0.522 0.735 0.443 0.000000
Barima-Waini (GUY) 0.483 0.745 0.534 0.074601
Potosi (BOL) 0.564 0.666 0.578 0.076345
Upper Takutu-Upper Essequ (GUY) 0.567 0.751 0.470 0.089799
Brokopondo and Sipaliwini (SUR) 0.382 0.774 0.602 0.099207
&lt;/code>&lt;/pre>
&lt;p>The top 5 in 2019 are dominated by Chilean regions (Region Metropolitana, Tarapaca, Antofagasta, Valparaiso) plus Buenos Aires. Chile&amp;rsquo;s strong performance across all three indicators &amp;mdash; particularly Health (0.90&amp;ndash;0.94) &amp;mdash; places its regions at the top. The bottom 5 in 2013 are remote regions of Guyana (Potaro-Siparuni, Barima-Waini), Bolivia (Potosi), and Suriname (Brokopondo), characterized by low education and income despite moderate health outcomes. The Potaro-Siparuni region of Guyana anchors the bottom at HDI = 0.00 (education 0.522, health 0.735, income 0.443).&lt;/p>
&lt;p>&lt;strong>City of Buenos Aires&lt;/strong> has pooled HDI of 0.946 in 2013 and 0.965 in 2019 &amp;mdash; an improvement of $+0.019$. Under per-period PCA, the same region showed a decline of $-0.040$. Pooled PCA correctly reveals that Buenos Aires improved modestly while being overtaken by Chilean regions that improved faster.&lt;/p>
&lt;p>&lt;img src="pca2_pooled_hdi_bars.png" alt="Paired horizontal bar chart showing top and bottom 15 regions with 2013 and 2019 HDI.">&lt;/p>
&lt;p>The paired bar chart shows the pooled HDI for the top and bottom 15 regions. In the top group, orange (2019) bars consistently extend further than steel blue (2013) bars, reflecting genuine improvement. In the bottom group, the pattern is more mixed &amp;mdash; some of the least developed regions in 2013 made substantial gains by 2019, while others barely moved. The dashed separator line divides the bottom 15 (below) from the top 15 (above).&lt;/p>
&lt;h2 id="13-the-contrast-pooled-vs-per-period-pca">13. The contrast: pooled vs per-period PCA&lt;/h2>
&lt;p>We now have two sets of HDI values for every region-period: one from per-period PCA and one from pooled PCA. To compare them, we build a wide table with each region&amp;rsquo;s pooled and per-period HDI change side by side.&lt;/p>
&lt;pre>&lt;code class="language-python">from scipy.stats import spearmanr
# Separate pooled HDI by period
df_pooled_p1 = df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2013&amp;quot;].copy()
df_pooled_p2 = df[df[&amp;quot;period&amp;quot;] == &amp;quot;Y2019&amp;quot;].copy()
# Build comparison table: pooled vs per-period changes
compare = df_pooled_p1[[&amp;quot;region&amp;quot;, &amp;quot;country&amp;quot;, &amp;quot;region_country&amp;quot;, &amp;quot;hdi&amp;quot;]].rename(
columns={&amp;quot;hdi&amp;quot;: &amp;quot;hdi_p1&amp;quot;}
).merge(
df_pooled_p2[[&amp;quot;region&amp;quot;, &amp;quot;country&amp;quot;, &amp;quot;hdi&amp;quot;]].rename(columns={&amp;quot;hdi&amp;quot;: &amp;quot;hdi_p2&amp;quot;}),
on=[&amp;quot;region&amp;quot;, &amp;quot;country&amp;quot;]
)
compare[&amp;quot;hdi_change&amp;quot;] = compare[&amp;quot;hdi_p2&amp;quot;] - compare[&amp;quot;hdi_p1&amp;quot;]
compare[&amp;quot;pp_change&amp;quot;] = df_p2[&amp;quot;pp_hdi&amp;quot;].values - df_p1[&amp;quot;pp_hdi&amp;quot;].values
compare[&amp;quot;method_diff&amp;quot;] = compare[&amp;quot;hdi_change&amp;quot;] - compare[&amp;quot;pp_change&amp;quot;]
# Direction disagreement
disagree = ((compare[&amp;quot;hdi_change&amp;quot;] &amp;gt; 0) &amp;amp; (compare[&amp;quot;pp_change&amp;quot;] &amp;lt; 0)) | \
((compare[&amp;quot;hdi_change&amp;quot;] &amp;lt; 0) &amp;amp; (compare[&amp;quot;pp_change&amp;quot;] &amp;gt; 0))
# Spearman rank correlation
rho_change, _ = spearmanr(compare[&amp;quot;hdi_change&amp;quot;], compare[&amp;quot;pp_change&amp;quot;])
# Running example: City of Buenos Aires
ba = compare[compare[&amp;quot;region_country&amp;quot;].str.contains(&amp;quot;Buenos Aires&amp;quot;)].iloc[0]
ba_pp_p1 = df_p1[df_p1[&amp;quot;region_country&amp;quot;].str.contains(&amp;quot;Buenos Aires&amp;quot;)][&amp;quot;pp_hdi&amp;quot;].values[0]
ba_pp_p2 = df_p2[df_p2[&amp;quot;region_country&amp;quot;].str.contains(&amp;quot;Buenos Aires&amp;quot;)][&amp;quot;pp_hdi&amp;quot;].values[0]
print(f&amp;quot;City of Buenos Aires:&amp;quot;)
print(f&amp;quot; Per-period: 2013={ba_pp_p1:.4f}, 2019={ba_pp_p2:.4f}, Change={ba_pp_p2 - ba_pp_p1:+.4f}&amp;quot;)
print(f&amp;quot; Pooled: 2013={ba['hdi_p1']:.4f}, 2019={ba['hdi_p2']:.4f}, Change={ba['hdi_change']:+.4f}&amp;quot;)
print(f&amp;quot;\nRegions where methods disagree on direction: {disagree.sum()} / {len(compare)}&amp;quot;)
print(f&amp;quot;\nSpearman rank correlation (HDI change): rho = {rho_change:.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">City of Buenos Aires:
Per-period: 2013=1.0000, 2019=0.9604, Change=-0.0396
Pooled: 2013=0.9464, 2019=0.9652, Change=+0.0189
Regions where methods disagree on direction: 16 / 153
Spearman rank correlation (HDI change): rho = 0.9818
&lt;/code>&lt;/pre>
&lt;p>For City of Buenos Aires, per-period PCA shows a decline of $-0.04$ while pooled PCA shows an improvement of $+0.02$. The two methods disagree on the direction of change for &lt;strong>16 out of 153 regions&lt;/strong> &amp;mdash; about 10% of the sample. The Spearman rank correlation for improvement rankings is 0.982, meaning the two methods largely agree on who improved most, but the direction disagreements for specific regions could lead to different policy conclusions.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(7, 7))
fig.patch.set_linewidth(0)
ax.scatter(compare[&amp;quot;hdi_change&amp;quot;], compare[&amp;quot;pp_change&amp;quot;],
color=STEEL_BLUE, edgecolors=DARK_NAVY, s=40, zorder=3, alpha=0.7)
lim_min = min(compare[&amp;quot;hdi_change&amp;quot;].min(), compare[&amp;quot;pp_change&amp;quot;].min()) - 0.02
lim_max = max(compare[&amp;quot;hdi_change&amp;quot;].max(), compare[&amp;quot;pp_change&amp;quot;].max()) + 0.02
ax.plot([lim_min, lim_max], [lim_min, lim_max], color=WARM_ORANGE,
linewidth=2, linestyle=&amp;quot;--&amp;quot;, label=&amp;quot;Perfect agreement&amp;quot;, zorder=2)
ax.axhline(0, color=GRID_LINE, linewidth=0.8, zorder=1)
ax.axvline(0, color=GRID_LINE, linewidth=0.8, zorder=1)
# Label extreme outliers
top_outliers = compare.nlargest(3, &amp;quot;method_diff&amp;quot;)
bot_outliers = compare.nsmallest(3, &amp;quot;method_diff&amp;quot;)
for _, row in pd.concat([top_outliers, bot_outliers]).iterrows():
ax.annotate(row[&amp;quot;region_country&amp;quot;], (row[&amp;quot;hdi_change&amp;quot;], row[&amp;quot;pp_change&amp;quot;]),
fontsize=6, color=TEAL, xytext=(5, 5),
textcoords=&amp;quot;offset points&amp;quot;)
ax.set_xlabel(&amp;quot;Pooled HDI change (2019 - 2013)&amp;quot;)
ax.set_ylabel(&amp;quot;Per-period HDI change (2019 - 2013)&amp;quot;)
ax.set_title(&amp;quot;Pooled vs. per-period PCA: HDI change comparison&amp;quot;)
ax.legend(loc=&amp;quot;upper left&amp;quot;)
ax.set_aspect(&amp;quot;equal&amp;quot;)
plt.savefig(&amp;quot;pca2_pooled_vs_perperiod_change.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca2_pooled_vs_perperiod_change.png" alt="Scatter plot of pooled vs per-period HDI change with 45-degree agreement line.">&lt;/p>
&lt;p>The scatter plot places pooled HDI change on the horizontal axis and per-period HDI change on the vertical axis. If both methods agreed perfectly, all points would fall on the dashed 45-degree line. The cloud sits systematically below the line for most regions &amp;mdash; per-period PCA tends to understate improvement (or overstate decline) relative to pooled PCA, because per-period standardization erases the net positive shift in education and health.&lt;/p>
&lt;pre>&lt;code class="language-python">compare[&amp;quot;pooled_change_rank&amp;quot;] = compare[&amp;quot;hdi_change&amp;quot;].rank(ascending=False).astype(int)
compare[&amp;quot;pp_change_rank&amp;quot;] = compare[&amp;quot;pp_change&amp;quot;].rank(ascending=False).astype(int)
compare[&amp;quot;change_rank_diff&amp;quot;] = np.abs(compare[&amp;quot;pooled_change_rank&amp;quot;] - compare[&amp;quot;pp_change_rank&amp;quot;])
fig, ax = plt.subplots(figsize=(8, 10))
fig.patch.set_linewidth(0)
top_change_rank_diff = compare.nlargest(10, &amp;quot;change_rank_diff&amp;quot;)
for _, row in top_change_rank_diff.iterrows():
r_pooled = row[&amp;quot;pooled_change_rank&amp;quot;]
r_pp = row[&amp;quot;pp_change_rank&amp;quot;]
label = row[&amp;quot;region_country&amp;quot;]
color = TEAL if r_pooled &amp;lt; r_pp else WARM_ORANGE
ax.plot([0, 1], [r_pooled, r_pp], color=color, linewidth=2, alpha=0.8)
ax.text(-0.05, r_pooled, f&amp;quot;{label} (#{int(r_pooled)})&amp;quot;, ha=&amp;quot;right&amp;quot;,
va=&amp;quot;center&amp;quot;, fontsize=7, color=LIGHT_TEXT)
ax.text(1.05, r_pp, f&amp;quot;{label} (#{int(r_pp)})&amp;quot;, ha=&amp;quot;left&amp;quot;,
va=&amp;quot;center&amp;quot;, fontsize=7, color=LIGHT_TEXT)
ax.set_xlim(-0.6, 1.6)
ax.set_ylim(160, -5)
ax.set_xticks([0, 1])
ax.set_xticklabels([&amp;quot;Pooled Improvement Rank&amp;quot;, &amp;quot;Per-period Improvement Rank&amp;quot;], fontsize=11)
ax.set_ylabel(&amp;quot;Rank (1 = most improved)&amp;quot;)
ax.set_title(&amp;quot;Who improved the most? Pooled vs. per-period rankings\n(teal = ranked higher by pooled, orange = ranked lower)&amp;quot;)
plt.savefig(&amp;quot;pca2_rank_comparison_bump.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca2_rank_comparison_bump.png" alt="Bump chart comparing improvement rankings under pooled vs per-period PCA.">&lt;/p>
&lt;p>The bump chart compares who improved the most under each method. The crossing lines show where the two methods re-order regions' improvement rankings. Regions that pooled PCA ranks as top improvers may be ranked lower by per-period PCA if their gains were partly masked by the shifting baseline.&lt;/p>
&lt;h2 id="14-validation-against-the-official-shdi">14. Validation against the official SHDI&lt;/h2>
&lt;p>The Global Data Lab computes an official Subnational HDI (SHDI) using a geometric mean methodology similar to the UNDP&amp;rsquo;s approach. We can validate our PCA-based index by comparing both the pooled and per-period approaches against this official benchmark. If pooled PCA better tracks the established methodology, it provides further evidence that the pooled approach is superior for temporal analysis.&lt;/p>
&lt;pre>&lt;code class="language-python"># Add per-period HDI to main DataFrame for comparison
df[&amp;quot;pp_hdi&amp;quot;] = pd.concat([df_p1[&amp;quot;pp_hdi&amp;quot;], df_p2[&amp;quot;pp_hdi&amp;quot;]]).sort_index().values
# Pooled PCA vs official SHDI
corr_pooled = df[&amp;quot;hdi&amp;quot;].corr(df[&amp;quot;shdi_official&amp;quot;])
r2_pooled = corr_pooled ** 2
# Per-period PCA vs official SHDI
corr_pp = df[&amp;quot;pp_hdi&amp;quot;].corr(df[&amp;quot;shdi_official&amp;quot;])
r2_pp = corr_pp ** 2
print(f&amp;quot;Pooled PCA vs official SHDI:&amp;quot;)
print(f&amp;quot; Pearson r: {corr_pooled:.4f}&amp;quot;)
print(f&amp;quot; R-squared: {r2_pooled:.4f}&amp;quot;)
print(f&amp;quot;\nPer-period PCA vs official SHDI:&amp;quot;)
print(f&amp;quot; Pearson r: {corr_pp:.4f}&amp;quot;)
print(f&amp;quot; R-squared: {r2_pp:.4f}&amp;quot;)
print(f&amp;quot;\nR-squared difference (pooled - per-period): {r2_pooled - r2_pp:+.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Pooled PCA vs official SHDI:
Pearson r: 0.9911
R-squared: 0.9823
Per-period PCA vs official SHDI:
Pearson r: 0.9874
R-squared: 0.9750
R-squared difference (pooled - per-period): +0.0073
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.patch.set_linewidth(0)
p1_mask = df[&amp;quot;period&amp;quot;] == &amp;quot;Y2013&amp;quot;
p2_mask = df[&amp;quot;period&amp;quot;] == &amp;quot;Y2019&amp;quot;
# Panel A: Pooled PCA vs SHDI
ax = axes[0]
ax.scatter(df.loc[p1_mask, &amp;quot;shdi_official&amp;quot;], df.loc[p1_mask, &amp;quot;hdi&amp;quot;],
color=STEEL_BLUE, edgecolors=DARK_NAVY, s=30, alpha=0.7, zorder=3, label=&amp;quot;2013&amp;quot;)
ax.scatter(df.loc[p2_mask, &amp;quot;shdi_official&amp;quot;], df.loc[p2_mask, &amp;quot;hdi&amp;quot;],
color=WARM_ORANGE, edgecolors=DARK_NAVY, s=30, alpha=0.7, zorder=3, label=&amp;quot;2019&amp;quot;)
ax.set_xlabel(&amp;quot;Official SHDI&amp;quot;)
ax.set_ylabel(&amp;quot;Pooled PCA HDI&amp;quot;)
ax.set_title(f&amp;quot;Pooled PCA (R² = {r2_pooled:.4f})&amp;quot;)
ax.legend(loc=&amp;quot;upper left&amp;quot;, fontsize=9)
# Panel B: Per-period PCA vs SHDI
ax = axes[1]
ax.scatter(df.loc[p1_mask, &amp;quot;shdi_official&amp;quot;], df.loc[p1_mask, &amp;quot;pp_hdi&amp;quot;],
color=STEEL_BLUE, edgecolors=DARK_NAVY, s=30, alpha=0.7, zorder=3, label=&amp;quot;2013&amp;quot;)
ax.scatter(df.loc[p2_mask, &amp;quot;shdi_official&amp;quot;], df.loc[p2_mask, &amp;quot;pp_hdi&amp;quot;],
color=WARM_ORANGE, edgecolors=DARK_NAVY, s=30, alpha=0.7, zorder=3, label=&amp;quot;2019&amp;quot;)
ax.set_xlabel(&amp;quot;Official SHDI&amp;quot;)
ax.set_ylabel(&amp;quot;Per-period PCA HDI&amp;quot;)
ax.set_title(f&amp;quot;Per-period PCA (R² = {r2_pp:.4f})&amp;quot;)
ax.legend(loc=&amp;quot;upper left&amp;quot;, fontsize=9)
fig.suptitle(&amp;quot;Validation: which PCA method tracks the official SHDI better?&amp;quot;,
fontsize=14, y=1.02)
plt.tight_layout()
plt.savefig(&amp;quot;pca2_validation_vs_shdi.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca2_validation_vs_shdi.png" alt="Side-by-side scatter plots comparing pooled PCA and per-period PCA against the official SHDI.">&lt;/p>
&lt;p>&lt;strong>Pooled PCA achieves $R^2 = 0.9823$, outperforming per-period PCA at $R^2 = 0.9750$.&lt;/strong> The difference of +0.0073 may seem small in absolute terms, but it is consistent and meaningful: pooled PCA explains 0.73 percentage points more of the variance in the official SHDI. The left panel shows pooled PCA points tightly clustered along the fit line with both periods intermixed seamlessly &amp;mdash; exactly what we want for a temporally comparable index. The right panel shows per-period PCA with a slightly wider scatter, reflecting the distortion introduced by re-centering each period to its own baseline. The fact that the official SHDI (which uses a fixed geometric mean formula across years) correlates more strongly with pooled PCA than with per-period PCA validates the pooled approach: when the goal is temporal comparability, fitting on stacked data is the right choice.&lt;/p>
&lt;h3 id="validating-the-dynamics-changes-over-time">Validating the dynamics: changes over time&lt;/h3>
&lt;p>The level comparison above tests cross-sectional fit &amp;mdash; do the PCA-based indices rank regions correctly at a point in time? But the core promise of pooled PCA is capturing &lt;strong>dynamics&lt;/strong> &amp;mdash; changes over time. We now test whether the change in PCA-based HDI tracks the change in official SHDI.&lt;/p>
&lt;pre>&lt;code class="language-python"># Compute official SHDI change per region
shdi_wide = (df.loc[p1_mask, [&amp;quot;region&amp;quot;, &amp;quot;country&amp;quot;, &amp;quot;shdi_official&amp;quot;]]
.rename(columns={&amp;quot;shdi_official&amp;quot;: &amp;quot;shdi_p1&amp;quot;}))
shdi_wide = shdi_wide.merge(
df.loc[p2_mask, [&amp;quot;region&amp;quot;, &amp;quot;country&amp;quot;, &amp;quot;shdi_official&amp;quot;]]
.rename(columns={&amp;quot;shdi_official&amp;quot;: &amp;quot;shdi_p2&amp;quot;}),
on=[&amp;quot;region&amp;quot;, &amp;quot;country&amp;quot;]
)
shdi_wide[&amp;quot;shdi_change&amp;quot;] = shdi_wide[&amp;quot;shdi_p2&amp;quot;] - shdi_wide[&amp;quot;shdi_p1&amp;quot;]
# Merge with comparison table
compare_val = compare.merge(shdi_wide[[&amp;quot;region&amp;quot;, &amp;quot;country&amp;quot;, &amp;quot;shdi_change&amp;quot;]],
on=[&amp;quot;region&amp;quot;, &amp;quot;country&amp;quot;])
# R² for changes
corr_pooled_change = compare_val[&amp;quot;hdi_change&amp;quot;].corr(compare_val[&amp;quot;shdi_change&amp;quot;])
r2_pooled_change = corr_pooled_change ** 2
corr_pp_change = compare_val[&amp;quot;pp_change&amp;quot;].corr(compare_val[&amp;quot;shdi_change&amp;quot;])
r2_pp_change = corr_pp_change ** 2
print(f&amp;quot;Pooled PCA change vs official SHDI change:&amp;quot;)
print(f&amp;quot; Pearson r: {corr_pooled_change:.4f}&amp;quot;)
print(f&amp;quot; R-squared: {r2_pooled_change:.4f}&amp;quot;)
print(f&amp;quot;\nPer-period PCA change vs official SHDI change:&amp;quot;)
print(f&amp;quot; Pearson r: {corr_pp_change:.4f}&amp;quot;)
print(f&amp;quot; R-squared: {r2_pp_change:.4f}&amp;quot;)
print(f&amp;quot;\nR-squared difference (pooled - per-period): {r2_pooled_change - r2_pp_change:+.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Pooled PCA change vs official SHDI change:
Pearson r: 0.9982
R-squared: 0.9964
Per-period PCA change vs official SHDI change:
Pearson r: 0.9957
R-squared: 0.9913
R-squared difference (pooled - per-period): +0.0051
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, axes = plt.subplots(1, 2, figsize=(14, 6))
fig.patch.set_linewidth(0)
# Panel A: Pooled PCA change vs SHDI change
ax = axes[0]
ax.scatter(compare_val[&amp;quot;shdi_change&amp;quot;], compare_val[&amp;quot;hdi_change&amp;quot;],
color=STEEL_BLUE, edgecolors=DARK_NAVY, s=40, alpha=0.7, zorder=3)
ax.axhline(0, color=GRID_LINE, linewidth=0.8, zorder=1)
ax.axvline(0, color=GRID_LINE, linewidth=0.8, zorder=1)
ax.set_xlabel(&amp;quot;Official SHDI change (2019 - 2013)&amp;quot;)
ax.set_ylabel(&amp;quot;Pooled PCA HDI change&amp;quot;)
ax.set_title(f&amp;quot;Pooled PCA (R² = {r2_pooled_change:.4f})&amp;quot;)
# Panel B: Per-period PCA change vs SHDI change
ax = axes[1]
ax.scatter(compare_val[&amp;quot;shdi_change&amp;quot;], compare_val[&amp;quot;pp_change&amp;quot;],
color=STEEL_BLUE, edgecolors=DARK_NAVY, s=40, alpha=0.7, zorder=3)
ax.axhline(0, color=GRID_LINE, linewidth=0.8, zorder=1)
ax.axvline(0, color=GRID_LINE, linewidth=0.8, zorder=1)
ax.set_xlabel(&amp;quot;Official SHDI change (2019 - 2013)&amp;quot;)
ax.set_ylabel(&amp;quot;Per-period PCA HDI change&amp;quot;)
ax.set_title(f&amp;quot;Per-period PCA (R² = {r2_pp_change:.4f})&amp;quot;)
fig.suptitle(&amp;quot;Validation: which PCA method better captures development dynamics?&amp;quot;,
fontsize=14, y=1.02)
plt.tight_layout()
plt.savefig(&amp;quot;pca2_validation_changes.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca2_validation_changes.png" alt="Side-by-side scatter plots comparing pooled and per-period PCA HDI changes against official SHDI changes.">&lt;/p>
&lt;p>The change validation is even more compelling than the level validation. &lt;strong>Pooled PCA change achieves $R^2 = 0.9964$, outperforming per-period PCA change at $R^2 = 0.9913$.&lt;/strong> Both methods track the official SHDI dynamics remarkably well ($r &amp;gt; 0.99$), but pooled PCA is the tighter fit. The left panel shows pooled PCA changes falling almost exactly on the regression line, with virtually no scatter. The right panel shows per-period PCA changes with slightly more dispersion, reflecting the noise introduced by re-centering each period&amp;rsquo;s baseline. Taken together, the level validation ($R^2$: 0.9823 vs 0.9750) and the change validation ($R^2$: 0.9964 vs 0.9913) consistently favor pooled PCA &amp;mdash; it better reproduces both the cross-sectional rankings and the temporal dynamics of the official Subnational Human Development Index.&lt;/p>
&lt;h2 id="15-replicating-with-scikit-learn">15. Replicating with scikit-learn&lt;/h2>
&lt;p>The pooled PCA pipeline with scikit-learn is nearly identical to the &lt;a href="https://carlos-mendez.org/post/python_pca/#12-replicating-the-analysis-with-scikit-learn">single-period pipeline from the previous tutorial&lt;/a>. The key insight is that sklearn&amp;rsquo;s &lt;code>fit_transform&lt;/code> on the stacked data IS pooled PCA &amp;mdash; no special panel-data library is needed.&lt;/p>
&lt;pre>&lt;code class="language-python">import numpy as np
import pandas as pd
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
# ── Configuration (change these for your own dataset) ────────────
CSV_URL = &amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/master/content/post/python_pca2/data_long.csv&amp;quot;
ID_COL = &amp;quot;region&amp;quot;
PERIOD_COL = &amp;quot;period&amp;quot;
POSITIVE_COLS = [&amp;quot;education&amp;quot;, &amp;quot;health&amp;quot;, &amp;quot;income&amp;quot;]
NEGATIVE_COLS = []
# Step 0: Load long-format panel data
df_sk = pd.read_csv(CSV_URL)
print(f&amp;quot;Loaded: {df_sk.shape[0]} rows, {df_sk.shape[1]} columns&amp;quot;)
# Step 1: Polarity adjustment
for col in NEGATIVE_COLS:
df_sk[col + &amp;quot;_adj&amp;quot;] = -1 * df_sk[col]
adj_cols = POSITIVE_COLS + [col + &amp;quot;_adj&amp;quot; for col in NEGATIVE_COLS]
# Step 2: POOLED standardization (fit on ALL periods)
scaler = StandardScaler()
Z_sk = scaler.fit_transform(df_sk[adj_cols])
# Step 3-4: POOLED PCA (fit on ALL periods)
pca_sk = PCA(n_components=1)
df_sk[&amp;quot;pc1&amp;quot;] = pca_sk.fit_transform(Z_sk)[:, 0]
# Step 5-6: POOLED normalization (min/max across ALL periods)
df_sk[&amp;quot;pc1_index&amp;quot;] = (
(df_sk[&amp;quot;pc1&amp;quot;] - df_sk[&amp;quot;pc1&amp;quot;].min())
/ (df_sk[&amp;quot;pc1&amp;quot;].max() - df_sk[&amp;quot;pc1&amp;quot;].min())
)
df_sk.to_csv(&amp;quot;pc1_index_results.csv&amp;quot;, index=False)
print(f&amp;quot;\nPC1 weights: {pca_sk.components_[0].round(4)}&amp;quot;)
print(f&amp;quot;Variance explained: {pca_sk.explained_variance_ratio_.round(4)}&amp;quot;)
print(f&amp;quot;\nSaved: pc1_index_results.csv&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Loaded: 306 rows, 10 columns
PC1 weights: [0.5642 0.5448 0.6204]
Variance explained: [0.7242]
Saved: pc1_index_results.csv
&lt;/code>&lt;/pre>
&lt;p>The sklearn pipeline produces identical weights ($[0.5642, 0.5448, 0.6204]$) and variance explained (72.42%), with a maximum absolute difference of $2.00 \times 10^{-15}$ from our manual implementation.&lt;/p>
&lt;h2 id="16-application-space-time-analyses">16. Application: Space-time analyses&lt;/h2>
&lt;p>With a temporally comparable pooled PCA index in hand, we can now analyze development dynamics across South America. This section demonstrates two types of space-time analysis: mapping how the spatial distribution of development shifted between 2013 and 2019, and measuring how spatial inequality changed over the same period.&lt;/p>
&lt;h3 id="spatial-distribution-dynamics">Spatial distribution dynamics&lt;/h3>
&lt;p>Choropleth maps provide an intuitive way to visualize where development improved, stagnated, or declined. The key methodological choice is to compute the color breaks from the &lt;strong>initial period&lt;/strong> (2013) using the &lt;a href="https://pysal.org/mapclassify/generated/mapclassify.FisherJenks.html" target="_blank" rel="noopener">Fisher-Jenks natural breaks algorithm&lt;/a> and hold those breaks &lt;strong>constant&lt;/strong> in the 2019 map. This ensures that a color change between maps reflects a genuine shift in HDI, not a shifting classification scheme. If we re-computed breaks for each period, regions could change color simply because the overall distribution shifted, not because they individually improved.&lt;/p>
&lt;pre>&lt;code class="language-python">import geopandas as gpd
import mapclassify
import contextily as cx
# Load GeoJSON boundaries and merge pooled HDI using GDLcode
GEO_URL = &amp;quot;https://raw.githubusercontent.com/cmg777/starter-academic-v501/master/content/post/python_pca2/data.geojson&amp;quot;
gdf = gpd.read_file(GEO_URL)
hdi_2013 = df_pooled_p1[[&amp;quot;GDLcode&amp;quot;, &amp;quot;hdi&amp;quot;]].rename(columns={&amp;quot;hdi&amp;quot;: &amp;quot;hdi_2013&amp;quot;})
hdi_2019 = df_pooled_p2[[&amp;quot;GDLcode&amp;quot;, &amp;quot;hdi&amp;quot;]].rename(columns={&amp;quot;hdi&amp;quot;: &amp;quot;hdi_2019&amp;quot;})
gdf = gdf.merge(hdi_2013, on=&amp;quot;GDLcode&amp;quot;)
gdf = gdf.merge(hdi_2019, on=&amp;quot;GDLcode&amp;quot;)
# Reproject to Web Mercator for basemap
gdf_3857 = gdf.to_crs(epsg=3857)
# Fisher-Jenks breaks from 2013 (5 classes)
fj = mapclassify.FisherJenks(gdf_3857[&amp;quot;hdi_2013&amp;quot;].values, k=5)
breaks = fj.bins.tolist()
# Extend upper break to cover 2019 max
max_val = max(gdf_3857[&amp;quot;hdi_2013&amp;quot;].max(), gdf_3857[&amp;quot;hdi_2019&amp;quot;].max())
if max_val &amp;gt; breaks[-1]:
breaks[-1] = float(round(max_val + 0.001, 3))
# Apply adjusted breaks to 2019 (must come AFTER break extension)
fj_2019 = mapclassify.UserDefined(gdf_3857[&amp;quot;hdi_2019&amp;quot;].values, bins=breaks)
# Class transitions
classes_2013 = fj.yb
classes_2019 = fj_2019.yb
improved = (classes_2019 &amp;gt; classes_2013).sum()
stayed = (classes_2019 == classes_2013).sum()
declined = (classes_2019 &amp;lt; classes_2013).sum()
print(f&amp;quot;Fisher-Jenks breaks (from 2013): {[round(b, 3) for b in breaks]}&amp;quot;)
print(f&amp;quot;\nClass transitions (2013 → 2019):&amp;quot;)
print(f&amp;quot; Improved (moved up): {improved}&amp;quot;)
print(f&amp;quot; Stayed same: {stayed}&amp;quot;)
print(f&amp;quot; Declined (moved down): {declined}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Fisher-Jenks breaks (from 2013): [0.167, 0.449, 0.581, 0.73, 1.001]
Class transitions (2013 → 2019):
Improved (moved up): 40
Stayed same: 88
Declined (moved down): 25
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python"># Class labels
class_labels = []
lower = 0.0
for b in breaks:
class_labels.append(f&amp;quot;{lower:.2f} – {b:.2f}&amp;quot;)
lower = b
fig, axes = plt.subplots(1, 2, figsize=(16, 12))
fig.patch.set_facecolor(DARK_NAVY)
fig.patch.set_linewidth(0)
from matplotlib.patches import Patch
cmap = plt.cm.coolwarm
norm = plt.Normalize(vmin=0, vmax=len(breaks) - 1)
for ax, year_col, title, year_fj in [
(axes[0], &amp;quot;hdi_2013&amp;quot;, &amp;quot;Pooled PCA HDI — 2013&amp;quot;, fj),
(axes[1], &amp;quot;hdi_2019&amp;quot;, &amp;quot;Pooled PCA HDI — 2019&amp;quot;, fj_2019),
]:
# Classify and assign colors manually
year_classes = year_fj.yb
colors = [cmap(norm(c)) for c in year_classes]
gdf_3857.plot(
ax=ax, color=colors,
edgecolor=DARK_NAVY, linewidth=0.3,
)
cx.add_basemap(ax, source=cx.providers.CartoDB.DarkMatter, zoom=4, attribution=&amp;quot;&amp;quot;)
ax.set_title(title, fontsize=14, color=WHITE_TEXT, pad=10)
ax.set_axis_off()
# Build legend manually with correct counts
counts = np.bincount(year_fj.yb, minlength=len(breaks))
handles = []
for i, (cl, c) in enumerate(zip(class_labels, counts)):
handles.append(Patch(facecolor=cmap(norm(i)), edgecolor=DARK_NAVY,
label=f&amp;quot;{cl} (n={c})&amp;quot;))
leg = ax.legend(handles=handles, title=&amp;quot;HDI Class&amp;quot;, loc=&amp;quot;lower right&amp;quot;,
fontsize=16, title_fontsize=17)
leg.set_frame_on(True)
leg.get_frame().set_facecolor(&amp;quot;#1a1a2e&amp;quot;)
leg.get_frame().set_edgecolor(LIGHT_TEXT)
leg.get_frame().set_alpha(0.9)
leg.get_frame().set_linewidth(1.5)
for text in leg.get_texts():
text.set_color(WHITE_TEXT)
leg.get_title().set_color(WHITE_TEXT)
fig.suptitle(&amp;quot;Spatial distribution dynamics: Pooled PCA HDI\n&amp;quot;
&amp;quot;(Fisher-Jenks breaks from 2013 held constant)&amp;quot;,
fontsize=15, color=WHITE_TEXT, y=0.95)
plt.tight_layout(rect=[0, 0, 1, 0.93])
plt.savefig(&amp;quot;pca2_choropleth_hdi.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca2_choropleth_hdi.png" alt="Side-by-side choropleth maps of pooled PCA HDI for 2013 and 2019 with fixed Fisher-Jenks breaks.">&lt;/p>
&lt;p>The choropleth maps reveal clear geographic patterns in South American development. The Southern Cone (Chile, Argentina, Uruguay) and southern Brazil appear in the highest HDI classes (teal tones), while the Amazon basin, interior Guyana, and parts of Bolivia occupy the lowest classes (orange tones). Between 2013 and 2019, &lt;strong>40 regions moved up&lt;/strong> at least one Fisher-Jenks class, &lt;strong>88 stayed in the same class&lt;/strong>, and &lt;strong>25 declined&lt;/strong>. The upward mobility is concentrated in the Andean countries (Peru, Bolivia, Colombia) where education gains shifted regions from the second to the third class. The declines are predominantly in Venezuelan states, visible as regions shifting from mid-range blues to warmer colors &amp;mdash; a direct cartographic reflection of Venezuela&amp;rsquo;s economic crisis. The fact that both maps use the same classification breaks makes these color changes directly interpretable: any region that changed color genuinely crossed a development threshold.&lt;/p>
&lt;h3 id="spatial-inequality-dynamics">Spatial inequality dynamics&lt;/h3>
&lt;p>The &lt;strong>Gini index&lt;/strong> measures inequality in the distribution of a variable across a population, ranging from 0 (perfect equality &amp;mdash; every region has the same value) to 1 (perfect inequality &amp;mdash; all development concentrated in a single region). Think of it as a single number that summarizes how unevenly a resource or outcome is distributed. By computing the Gini index for each indicator in each period, we can track whether development is converging (Gini falling &amp;mdash; regions becoming more similar) or diverging (Gini rising &amp;mdash; gaps widening).&lt;/p>
&lt;p>We use the &lt;a href="https://pysal.org/inequality/generated/inequality.gini.Gini.html" target="_blank" rel="noopener">Gini&lt;/a> class from PySAL&amp;rsquo;s &lt;a href="https://pysal.org/inequality/" target="_blank" rel="noopener">inequality&lt;/a> library, which provides a robust implementation of the Gini coefficient. The &lt;code>Gini(values).g&lt;/code> attribute returns the computed coefficient.&lt;/p>
&lt;pre>&lt;code class="language-python">from inequality.gini import Gini
# Compute Gini for each indicator and pooled HDI, per period
gini_rows = []
for period_label in [&amp;quot;Y2013&amp;quot;, &amp;quot;Y2019&amp;quot;]:
mask = df[&amp;quot;period&amp;quot;] == period_label
row = {&amp;quot;period&amp;quot;: period_label}
for col in INDICATORS + [&amp;quot;hdi&amp;quot;]:
row[col] = round(Gini(df.loc[mask, col].values).g, 4)
gini_rows.append(row)
gini_df = pd.DataFrame(gini_rows).set_index(&amp;quot;period&amp;quot;)
# Add change row
change_row = gini_df.loc[&amp;quot;Y2019&amp;quot;] - gini_df.loc[&amp;quot;Y2013&amp;quot;]
change_row.name = &amp;quot;Change&amp;quot;
gini_df = pd.concat([gini_df, change_row.to_frame().T])
print(f&amp;quot;Gini index by indicator and period:&amp;quot;)
print(gini_df.to_string())
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Gini index by indicator and period:
education health income hdi
Y2013 0.0655 0.0295 0.0549 0.1712
Y2019 0.0639 0.0318 0.0585 0.1795
Change -0.0016 0.0023 0.0036 0.0083
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 5))
fig.patch.set_linewidth(0)
labels = [&amp;quot;Education&amp;quot;, &amp;quot;Health&amp;quot;, &amp;quot;Income&amp;quot;, &amp;quot;Pooled HDI&amp;quot;]
cols = INDICATORS + [&amp;quot;hdi&amp;quot;]
vals_2013 = [gini_df.loc[&amp;quot;Y2013&amp;quot;, c] for c in cols]
vals_2019 = [gini_df.loc[&amp;quot;Y2019&amp;quot;, c] for c in cols]
x = np.arange(len(labels))
width = 0.3
bars1 = ax.bar(x - width/2, vals_2013, width, color=STEEL_BLUE,
edgecolor=DARK_NAVY, label=&amp;quot;2013&amp;quot;)
bars2 = ax.bar(x + width/2, vals_2019, width, color=WARM_ORANGE,
edgecolor=DARK_NAVY, label=&amp;quot;2019&amp;quot;)
for bar in bars1:
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.002,
f&amp;quot;{bar.get_height():.4f}&amp;quot;, ha=&amp;quot;center&amp;quot;, va=&amp;quot;bottom&amp;quot;,
fontsize=9, color=LIGHT_TEXT)
for bar in bars2:
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 0.002,
f&amp;quot;{bar.get_height():.4f}&amp;quot;, ha=&amp;quot;center&amp;quot;, va=&amp;quot;bottom&amp;quot;,
fontsize=9, color=LIGHT_TEXT)
ax.set_xticks(x)
ax.set_xticklabels(labels, fontsize=12)
ax.set_ylabel(&amp;quot;Gini Index&amp;quot;)
ax.set_title(&amp;quot;Spatial inequality dynamics: Gini index by indicator (2013 vs 2019)&amp;quot;)
ax.legend()
ax.set_ylim(0, ax.get_ylim()[1] * 1.15)
plt.savefig(&amp;quot;pca2_gini_dynamics.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca2_gini_dynamics.png" alt="Grouped bar chart showing Gini index for each indicator in 2013 and 2019.">&lt;/p>
&lt;p>The Gini analysis reveals a nuanced inequality story across South America&amp;rsquo;s sub-national regions. &lt;strong>Education is the only dimension that converged&lt;/strong> between 2013 and 2019 &amp;mdash; its Gini fell from 0.0655 to 0.0639 ($-0.0016$), meaning regions became slightly more equal in educational attainment. Health and income both &lt;strong>diverged&lt;/strong>: health inequality rose from 0.0295 to 0.0318 ($+0.0023$) and income inequality from 0.0549 to 0.0585 ($+0.0036$). The composite pooled PCA HDI shows an overall increase in inequality from 0.1712 to 0.1795 ($+0.0083$), driven primarily by the income and health dimensions. This tells a policy-relevant story: while South America made progress in reducing educational gaps across regions, the income decline was unevenly distributed &amp;mdash; some regions (particularly Venezuelan states) experienced far steeper economic setbacks than others, widening the income gap. The fact that overall HDI inequality increased despite educational convergence underscores that development progress is not uniform across dimensions, and a composite index like the pooled PCA HDI captures these cross-cutting dynamics in a single measure.&lt;/p>
&lt;h3 id="population-weighted-inequality">Population-weighted inequality&lt;/h3>
&lt;p>The unweighted Gini treats every region equally &amp;mdash; Potaro-Siparuni (population 10,000) carries the same weight as São Paulo (population 44 million). For policy analysis, we often care more about how many &lt;em>people&lt;/em> experience inequality, not how many &lt;em>regions&lt;/em>. A population-weighted Gini accounts for this by giving larger regions proportionally more influence. Since PySAL&amp;rsquo;s &lt;code>Gini&lt;/code> class does not support population weights, we implement the weighted Gini using the trapezoidal Lorenz curve approach.&lt;/p>
&lt;pre>&lt;code class="language-python">def weighted_gini(values, weights):
&amp;quot;&amp;quot;&amp;quot;Compute the population-weighted Gini index using the Lorenz curve.
Parameters
----------
values : array-like — indicator values (e.g., HDI per region)
weights : array-like — population weights (e.g., region population)
Returns
-------
float — weighted Gini coefficient in [0, 1]
&amp;quot;&amp;quot;&amp;quot;
v = np.asarray(values, dtype=float)
w = np.asarray(weights, dtype=float)
order = np.argsort(v)
v, w = v[order], w[order]
# Cumulative population and value shares
cum_w = np.cumsum(w) / np.sum(w)
cum_vw = np.cumsum(v * w) / np.sum(v * w)
# Prepend zero for trapezoidal integration
cum_w = np.concatenate(([0], cum_w))
cum_vw = np.concatenate(([0], cum_vw))
# Area under Lorenz curve
B = np.sum((cum_w[1:] - cum_w[:-1]) * (cum_vw[1:] + cum_vw[:-1]) / 2)
return 1 - 2 * B
# Compute population-weighted Gini
wgini_rows = []
for period_label in [&amp;quot;Y2013&amp;quot;, &amp;quot;Y2019&amp;quot;]:
mask = df[&amp;quot;period&amp;quot;] == period_label
row = {&amp;quot;period&amp;quot;: period_label}
for col in INDICATORS + [&amp;quot;hdi&amp;quot;]:
row[col] = round(weighted_gini(
df.loc[mask, col].values, df.loc[mask, &amp;quot;pop&amp;quot;].values
), 4)
wgini_rows.append(row)
wgini_df = pd.DataFrame(wgini_rows).set_index(&amp;quot;period&amp;quot;)
wchange_row = wgini_df.loc[&amp;quot;Y2019&amp;quot;] - wgini_df.loc[&amp;quot;Y2013&amp;quot;]
wchange_row.name = &amp;quot;Change&amp;quot;
wgini_df = pd.concat([wgini_df, wchange_row.to_frame().T])
print(f&amp;quot;Population-weighted Gini index:&amp;quot;)
print(wgini_df.to_string())
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Population-weighted Gini index:
education health income hdi
Y2013 0.0525 0.0174 0.0359 0.1113
Y2019 0.0521 0.0186 0.0387 0.1156
Change -0.0004 0.0012 0.0028 0.0043
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, axes = plt.subplots(1, 2, figsize=(14, 5), sharey=True)
fig.patch.set_linewidth(0)
labels = [&amp;quot;Education&amp;quot;, &amp;quot;Health&amp;quot;, &amp;quot;Income&amp;quot;, &amp;quot;Pooled HDI&amp;quot;]
cols = INDICATORS + [&amp;quot;hdi&amp;quot;]
x = np.arange(len(labels))
width = 0.3
# Panel A: Unweighted
ax = axes[0]
uw_13 = [gini_df.loc[&amp;quot;Y2013&amp;quot;, c] for c in cols]
uw_19 = [gini_df.loc[&amp;quot;Y2019&amp;quot;, c] for c in cols]
ax.bar(x - width/2, uw_13, width, color=STEEL_BLUE, edgecolor=DARK_NAVY, label=&amp;quot;2013&amp;quot;)
ax.bar(x + width/2, uw_19, width, color=WARM_ORANGE, edgecolor=DARK_NAVY, label=&amp;quot;2019&amp;quot;)
for i, (v13, v19) in enumerate(zip(uw_13, uw_19)):
ax.text(i - width/2, v13 + 0.002, f&amp;quot;{v13:.4f}&amp;quot;, ha=&amp;quot;center&amp;quot;, va=&amp;quot;bottom&amp;quot;,
fontsize=8, color=LIGHT_TEXT)
ax.text(i + width/2, v19 + 0.002, f&amp;quot;{v19:.4f}&amp;quot;, ha=&amp;quot;center&amp;quot;, va=&amp;quot;bottom&amp;quot;,
fontsize=8, color=LIGHT_TEXT)
ax.set_xticks(x)
ax.set_xticklabels(labels, fontsize=11)
ax.set_ylabel(&amp;quot;Gini Index&amp;quot;)
ax.set_title(&amp;quot;Unweighted Gini&amp;quot;)
ax.legend(fontsize=9)
# Panel B: Population-weighted
ax = axes[1]
pw_13 = [wgini_df.loc[&amp;quot;Y2013&amp;quot;, c] for c in cols]
pw_19 = [wgini_df.loc[&amp;quot;Y2019&amp;quot;, c] for c in cols]
ax.bar(x - width/2, pw_13, width, color=STEEL_BLUE, edgecolor=DARK_NAVY, label=&amp;quot;2013&amp;quot;)
ax.bar(x + width/2, pw_19, width, color=WARM_ORANGE, edgecolor=DARK_NAVY, label=&amp;quot;2019&amp;quot;)
for i, (v13, v19) in enumerate(zip(pw_13, pw_19)):
ax.text(i - width/2, v13 + 0.002, f&amp;quot;{v13:.4f}&amp;quot;, ha=&amp;quot;center&amp;quot;, va=&amp;quot;bottom&amp;quot;,
fontsize=8, color=LIGHT_TEXT)
ax.text(i + width/2, v19 + 0.002, f&amp;quot;{v19:.4f}&amp;quot;, ha=&amp;quot;center&amp;quot;, va=&amp;quot;bottom&amp;quot;,
fontsize=8, color=LIGHT_TEXT)
ax.set_xticks(x)
ax.set_xticklabels(labels, fontsize=11)
ax.set_title(&amp;quot;Population-weighted Gini&amp;quot;)
ax.legend(fontsize=9)
fig.suptitle(&amp;quot;Spatial inequality: unweighted vs. population-weighted Gini&amp;quot;,
fontsize=14, y=1.02)
plt.tight_layout()
plt.savefig(&amp;quot;pca2_gini_weighted_comparison.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;,
facecolor=DARK_NAVY, edgecolor=DARK_NAVY, pad_inches=0)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="pca2_gini_weighted_comparison.png" alt="Side-by-side comparison of unweighted and population-weighted Gini indices.">&lt;/p>
&lt;p>The population-weighted Gini values are &lt;strong>substantially lower&lt;/strong> than their unweighted counterparts across all indicators and both periods. For example, the pooled HDI Gini drops from 0.1712 (unweighted) to 0.1113 (weighted) in 2013 &amp;mdash; a 35% reduction. This gap means that large-population regions (São Paulo, Buenos Aires, Bogota, Santiago) tend to cluster near the middle of the development distribution, while the extreme values (both high and low) are found in smaller regions. When we weight by population, the outlier regions matter less, and inequality appears lower because most South Americans live in moderately developed areas. The direction of change, however, is consistent: both weighted and unweighted Gini show education converging ($-0.0004$ weighted vs $-0.0016$ unweighted) while income ($+0.0028$ vs $+0.0036$) and overall HDI ($+0.0043$ vs $+0.0083$) diverge. The divergence is smaller in population-weighted terms, suggesting that the widening gaps are driven more by sparsely populated peripheral regions than by the major urban centers where most people live.&lt;/p>
&lt;h2 id="17-summary-results">17. Summary results&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Step&lt;/th>
&lt;th>Input&lt;/th>
&lt;th>Output&lt;/th>
&lt;th>Key Result&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Stack&lt;/td>
&lt;td>2 periods $\times$ 153 regions&lt;/td>
&lt;td>306-row DataFrame&lt;/td>
&lt;td>Panel format ready&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Polarity&lt;/td>
&lt;td>Raw indicators&lt;/td>
&lt;td>Aligned indicators&lt;/td>
&lt;td>All positive (no flip needed)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pooled Standardization&lt;/td>
&lt;td>306 rows&lt;/td>
&lt;td>Z-scores (pooled)&lt;/td>
&lt;td>Fixed baseline across periods&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pooled Covariance&lt;/td>
&lt;td>Z matrix&lt;/td>
&lt;td>3$\times$3 matrix&lt;/td>
&lt;td>Off-diagonals 0.44&amp;ndash;0.68&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pooled Eigen-decomposition&lt;/td>
&lt;td>Cov matrix&lt;/td>
&lt;td>eigenvalues, eigenvectors&lt;/td>
&lt;td>PC1 captures 72.4%&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Scoring&lt;/td>
&lt;td>Z $\times$ eigvec&lt;/td>
&lt;td>PC1 scores&lt;/td>
&lt;td>2019 mean &amp;gt; 2013 mean (+0.14)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Pooled Normalization&lt;/td>
&lt;td>PC1&lt;/td>
&lt;td>HDI (0&amp;ndash;1)&lt;/td>
&lt;td>Comparable across periods&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="18-discussion">18. Discussion&lt;/h2>
&lt;p>&lt;strong>Pooled PCA successfully builds a composite development index that is directly comparable across time periods.&lt;/strong> By standardizing with pooled means, computing a single set of eigenvector weights from stacked data, and normalizing with pooled min/max bounds, the index preserves genuine temporal dynamics. The net development shift of +0.14 PC1 units (reflecting education and health gains partially offset by income decline) is captured by pooled PCA but would be invisible under per-period PCA.&lt;/p>
&lt;p>The real South American data revealed that Income carries the highest eigenvector weight (0.620), meaning PCA gives Income more influence than Education (0.564) or Health (0.545) in the composite index. This data-driven weighting differs from the UNDP&amp;rsquo;s equal-weight geometric mean approach, yet the two methods agree closely ($r = 0.991$). The similarity arises because all three indicators are positively correlated and driven by the same broad development processes. The differences emerge in regions with unbalanced profiles &amp;mdash; for example, regions with very high health but low education may rank differently under PCA versus the geometric mean.&lt;/p>
&lt;p>The per-period approach disagrees with pooled PCA on the direction of change for 16 regions (10% of the sample). In each of these 16 cases, per-period PCA shows a decline while pooled PCA shows an improvement &amp;mdash; the shifting baseline erases genuine but modest gains. A policymaker using per-period PCA might conclude these regions are &amp;ldquo;falling behind&amp;rdquo; when in reality they made progress, just less than the shifting average.&lt;/p>
&lt;p>The income decline across South America between 2013 and 2019 makes the pooled approach particularly important. Per-period standardization would hide this real economic setback by re-centering income to zero each period. Pooled standardization preserves it, allowing researchers to see that income genuinely declined while education and health improved. This mixed signal is precisely the kind of nuance that development analysis must capture.&lt;/p>
&lt;h2 id="19-summary-and-next-steps">19. Summary and next steps&lt;/h2>
&lt;p>&lt;strong>Key takeaways:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Method insight:&lt;/strong> Pooled PCA produces temporally comparable composite indices by fitting standardization and eigen-decomposition on stacked data. The two methods disagree on the direction of HDI change for 16 out of 153 South American regions. The Spearman rank correlation for improvement rankings is 0.982 &amp;mdash; high but not perfect, with consequential differences for specific regions.&lt;/li>
&lt;li>&lt;strong>Data insight:&lt;/strong> Income carries the highest PC1 weight (0.620) despite education having a wider range. PC1 captures 72.4% of variance &amp;mdash; lower than the 96% in simulated data, reflecting the genuine complexity of real development indicators. The PCA-based HDI correlates at $r = 0.991$ with the official SHDI, validating the approach.&lt;/li>
&lt;li>&lt;strong>Limitation:&lt;/strong> PC1 captures only 72% of variance, meaning 28% of development variation is lost in the compression. PC2 (19%) might capture meaningful patterns (e.g., health vs income trade-offs). Also, the pooled approach assumes a stable correlation structure between 2013 and 2019 &amp;mdash; a strong assumption over a 6-year period that included significant economic volatility in the region.&lt;/li>
&lt;li>&lt;strong>Next step:&lt;/strong> Extend the analysis to more time periods (2000&amp;ndash;2019) using the full Global Data Lab time series. Explore PC2 interpretation for policy-relevant sub-dimensions. Consider factor analysis for more flexible loading structures, and compare results across different world regions.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Limitations of this analysis:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>The data covers only South America. Development patterns in Sub-Saharan Africa or South Asia may produce different correlation structures and eigenvector weights.&lt;/li>
&lt;li>Two periods (2013 and 2019) is the minimum for temporal analysis. More periods would strengthen the pooled estimates and allow testing the constant-correlation assumption.&lt;/li>
&lt;li>The PCA-based index is relative to this specific sample. Adding or removing regions changes every score.&lt;/li>
&lt;li>Min-Max normalization is sensitive to outliers. The Potaro-Siparuni region of Guyana anchors the bottom and compresses the range for everyone else.&lt;/li>
&lt;/ul>
&lt;h2 id="20-exercises">20. Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Explore PC2.&lt;/strong> The second principal component captures 18.8% of variance. Compute PC2 scores and plot them against PC1. What development pattern does PC2 capture? Which regions score high on PC1 but low on PC2 (or vice versa)?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Test the constant-correlation assumption.&lt;/strong> Compute the correlation matrices separately for 2013 and 2019. How much do they differ? If the Income-Education correlation changed substantially, what would that imply for the validity of pooled PCA?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Compare with the UNDP methodology.&lt;/strong> The official SHDI uses a geometric mean: $SHDI = (Education \times Health \times Income)^{1/3}$. Compute this for all regions and compare the ranking with your PCA-based ranking. Where do the two methods disagree most, and why?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="21-references">21. References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://carlos-mendez.org/post/python_pca/">Mendez, C. (2026). Introduction to PCA Analysis for Building Development Indicators.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1038/sdata.2019.38" target="_blank" rel="noopener">Smits, J. and Permanyer, I. (2019). The Subnational Human Development Database. &lt;em>Scientific Data&lt;/em>, 6, 190038.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://globaldatalab.org/shdi/" target="_blank" rel="noopener">Global Data Lab &amp;ndash; Subnational Human Development Index&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1098/rsta.2015.0202" target="_blank" rel="noopener">Jolliffe, I. T. and Cadima, J. (2016). Principal Component Analysis: A Review and Recent Developments. &lt;em>Philosophical Transactions of the Royal Society A&lt;/em>, 374(2065).&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1093/oep/gpac022" target="_blank" rel="noopener">Peiro-Palomino, J., Picazo-Tadeo, A. J., and Rios, V. (2023). Social Progress around the World: Trends and Convergence. &lt;em>Oxford Economic Papers&lt;/em>, 75(2), 281&amp;ndash;306.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://hdr.undp.org/data-center/human-development-index" target="_blank" rel="noopener">UNDP (2024). Human Development Index &amp;ndash; Technical Notes.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.decomposition.PCA.html" target="_blank" rel="noopener">scikit-learn &amp;ndash; PCA Documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.preprocessing.StandardScaler.html" target="_blank" rel="noopener">scikit-learn &amp;ndash; StandardScaler Documentation&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://carlos-mendez.org/publication/20210318-economia/" target="_blank" rel="noopener">Mendez, C. and Gonzales, E. (2021). Human Capital Constraints, Spatial Dependence, and Regionalization in Bolivia. &lt;em>Economia&lt;/em>, 44(87).&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>The FWL Theorem: Making Multivariate Regressions Intuitive</title><link>https://carlos-mendez.org/post/python_fwl/</link><pubDate>Sat, 14 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_fwl/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>Including multiple variables in a regression raises a natural question: what does it actually mean to &amp;ldquo;control for&amp;rdquo; a confounder? The output is a coefficient, but a multivariate regression cannot be plotted on a simple two-dimensional scatter plot. This makes it hard to build intuition about what the regression is doing behind the scenes.&lt;/p>
&lt;p>The &lt;strong>Frisch-Waugh-Lovell (FWL) theorem&lt;/strong> answers this question. It shows that any coefficient from a multivariate regression can be recovered from a simple univariate regression &amp;mdash; after removing the influence of all other variables through a procedure called &lt;em>partialling-out&lt;/em> (also known as &lt;em>residualization&lt;/em> or &lt;em>orthogonalization&lt;/em>). Think of it as stripping away the noise from other variables so that only the signal of interest remains.&lt;/p>
&lt;p>This tutorial is inspired by &lt;a href="https://towardsdatascience.com/the-fwl-theorem-or-how-to-make-all-regressions-intuitive-59f801eb3299/" target="_blank" rel="noopener">Courthoud (2022)&lt;/a>, and applies the FWL theorem to a simulated retail scenario. A chain of stores distributes discount coupons and wants to know whether the coupons increase sales. The catch: neighborhood income affects both coupon usage and sales, creating a confounding relationship that makes the naive analysis misleading. The analysis uses FWL to untangle these effects, verifies the theorem step by step, and visualizes the conditional relationship that multivariate regression captures but hides from view.&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Understand the Frisch-Waugh-Lovell theorem and why it matters for causal inference&lt;/li>
&lt;li>Implement the partialling-out procedure using OLS residuals&lt;/li>
&lt;li>Visualize conditional relationships that multivariate regressions capture but cannot directly plot&lt;/li>
&lt;li>Compare naive and conditional estimates to see how omitted variable bias distorts results&lt;/li>
&lt;li>Connect FWL to modern applications such as Double Machine Learning&lt;/li>
&lt;/ul>
&lt;h2 id="the-causal-structure">The causal structure&lt;/h2>
&lt;p>Before looking at data, it helps to understand the causal relationships among the variables. A &lt;strong>Directed Acyclic Graph (DAG)&lt;/strong> &amp;mdash; a diagram where arrows indicate direct causal effects &amp;mdash; makes these assumptions explicit.&lt;/p>
&lt;p>In this retail scenario, three variables interact:&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
I[&amp;quot;&amp;lt;b&amp;gt;Income&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;(confounder)&amp;quot;] --&amp;gt;|&amp;quot;Higher income&amp;lt;br/&amp;gt;→ fewer coupons&amp;quot;| C[&amp;quot;&amp;lt;b&amp;gt;Coupons&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;(treatment)&amp;quot;]
I --&amp;gt;|&amp;quot;Higher income&amp;lt;br/&amp;gt;→ more spending&amp;quot;| S[&amp;quot;&amp;lt;b&amp;gt;Sales&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;(outcome)&amp;quot;]
C --&amp;gt;|&amp;quot;True causal&amp;lt;br/&amp;gt;effect: +0.2&amp;quot;| S
style I fill:#d97757,stroke:#141413,color:#fff
style C fill:#6a9bcc,stroke:#141413,color:#fff
style S fill:#00d4c8,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>Income acts as a &lt;strong>confounder&lt;/strong> &amp;mdash; a variable that influences both the treatment (coupon usage) and the outcome (sales). Wealthier neighborhoods use fewer coupons but spend more, creating a &lt;em>backdoor path&lt;/em> from coupons to sales through income. Ignoring income allows this backdoor path to generate a spurious negative association between coupons and sales, masking the true positive effect.&lt;/p>
&lt;p>To recover the genuine causal effect, the analysis must &lt;strong>block&lt;/strong> this backdoor path by conditioning on income. The FWL theorem provides an elegant way to do this and to visualize the result.&lt;/p>
&lt;h2 id="setup-and-imports">Setup and imports&lt;/h2>
&lt;p>The following code loads all necessary libraries. The analysis relies on &lt;a href="https://www.statsmodels.org/stable/index.html" target="_blank" rel="noopener">statsmodels&lt;/a> for OLS regression, &lt;a href="https://seaborn.pydata.org/" target="_blank" rel="noopener">seaborn&lt;/a> for regression plots, and &lt;a href="https://matplotlib.org/" target="_blank" rel="noopener">matplotlib&lt;/a> for figure customization. The &lt;code>RANDOM_SEED&lt;/code> ensures that every reader gets identical results.&lt;/p>
&lt;pre>&lt;code class="language-python">import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import statsmodels.formula.api as smf
# Reproducibility
RANDOM_SEED = 42
np.random.seed(RANDOM_SEED)
# Site color palette
STEEL_BLUE = &amp;quot;#6a9bcc&amp;quot;
WARM_ORANGE = &amp;quot;#d97757&amp;quot;
NEAR_BLACK = &amp;quot;#141413&amp;quot;
TEAL = &amp;quot;#00d4c8&amp;quot;
&lt;/code>&lt;/pre>
&lt;blockquote>
&lt;p>&lt;strong>Note on figure styling:&lt;/strong> The figures in this post use a dark theme for visual consistency with the site. The companion &lt;code>script.py&lt;/code> includes the full styling code. To reproduce the dark-themed figures, add the following to your setup:&lt;/p>
&lt;details>&lt;summary>Dark theme settings (click to expand)&lt;/summary>
&lt;pre>&lt;code class="language-python">DARK_NAVY = &amp;quot;#0f1729&amp;quot;
GRID_LINE = &amp;quot;#1f2b5e&amp;quot;
LIGHT_TEXT = &amp;quot;#c8d0e0&amp;quot;
WHITE_TEXT = &amp;quot;#e8ecf2&amp;quot;
plt.rcParams.update({
&amp;quot;figure.facecolor&amp;quot;: DARK_NAVY, &amp;quot;axes.facecolor&amp;quot;: DARK_NAVY,
&amp;quot;axes.edgecolor&amp;quot;: DARK_NAVY, &amp;quot;axes.linewidth&amp;quot;: 0,
&amp;quot;axes.labelcolor&amp;quot;: LIGHT_TEXT, &amp;quot;axes.titlecolor&amp;quot;: WHITE_TEXT,
&amp;quot;axes.spines.top&amp;quot;: False, &amp;quot;axes.spines.right&amp;quot;: False,
&amp;quot;axes.spines.left&amp;quot;: False, &amp;quot;axes.spines.bottom&amp;quot;: False,
&amp;quot;axes.grid&amp;quot;: True, &amp;quot;grid.color&amp;quot;: GRID_LINE,
&amp;quot;grid.linewidth&amp;quot;: 0.6, &amp;quot;grid.alpha&amp;quot;: 0.8,
&amp;quot;xtick.color&amp;quot;: LIGHT_TEXT, &amp;quot;ytick.color&amp;quot;: LIGHT_TEXT,
&amp;quot;text.color&amp;quot;: WHITE_TEXT, &amp;quot;font.size&amp;quot;: 12,
&amp;quot;legend.frameon&amp;quot;: False, &amp;quot;legend.labelcolor&amp;quot;: LIGHT_TEXT,
&amp;quot;savefig.facecolor&amp;quot;: DARK_NAVY, &amp;quot;savefig.edgecolor&amp;quot;: DARK_NAVY,
})
&lt;/code>&lt;/pre>
&lt;/details>
&lt;/blockquote>
&lt;h2 id="data-simulation">Data simulation&lt;/h2>
&lt;p>Rather than importing data from an external source, this section builds a transparent data generating process (DGP) so that the &lt;strong>true causal effect&lt;/strong> is known in advance and the methods can be verified against it. Think of it as running a controlled experiment in a computer: set the rules, generate the data, and then check whether the statistical tools find the right answer.&lt;/p>
&lt;p>The DGP encodes the causal structure from the DAG above:&lt;/p>
&lt;ul>
&lt;li>&lt;code>income&lt;/code> is drawn from a normal distribution centered at \$50K&lt;/li>
&lt;li>&lt;code>coupons&lt;/code> depends negatively on income (wealthier customers use fewer coupons) plus random noise&lt;/li>
&lt;li>&lt;code>sales&lt;/code> depends positively on both coupons (+0.2) and income (+0.3), plus a day-of-week effect and random noise&lt;/li>
&lt;/ul>
&lt;p>The true causal effect of coupons on sales is &lt;strong>exactly +0.2&lt;/strong> &amp;mdash; this is the &lt;strong>Average Treatment Effect (ATE)&lt;/strong>, the average impact of coupons on sales across all stores. In concrete terms, every 1 percentage point increase in coupon usage causes a \$200 increase in daily sales (measured in thousands).&lt;/p>
&lt;pre>&lt;code class="language-python">def simulate_store_data(n=50, seed=42):
&amp;quot;&amp;quot;&amp;quot;Simulate retail store data with confounding by income.&amp;quot;&amp;quot;&amp;quot;
rng = np.random.default_rng(seed)
income = rng.normal(50, 10, n)
dayofweek = rng.integers(1, 8, n)
coupons = 60 - 0.5 * income + rng.normal(0, 5, n)
sales = (10 + 0.2 * coupons + 0.3 * income
+ 0.5 * dayofweek + rng.normal(0, 3, n))
return pd.DataFrame({
&amp;quot;sales&amp;quot;: np.round(sales, 2),
&amp;quot;coupons&amp;quot;: np.round(coupons, 2),
&amp;quot;income&amp;quot;: np.round(income, 2),
&amp;quot;dayofweek&amp;quot;: dayofweek,
})
N = 50
df = simulate_store_data(n=N, seed=RANDOM_SEED)
print(&amp;quot;Dataset shape:&amp;quot;, df.shape)
print(df.head())
print(df.describe().round(2))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Dataset shape: (50, 4)
sales coupons income dayofweek
0 37.37 36.93 53.05 6
1 36.88 38.06 39.60 6
2 33.09 32.04 57.50 6
3 35.09 33.43 59.41 5
4 27.01 43.21 30.49 4
sales coupons income dayofweek
count 50.00 50.00 50.00 50.00
mean 33.61 33.84 50.91 3.92
std 3.96 4.89 7.68 1.88
min 25.76 23.26 30.49 1.00
25% 31.30 31.53 45.78 2.00
50% 33.24 33.25 51.74 4.00
75% 36.00 36.89 56.42 5.75
max 44.38 43.79 71.42 7.00
&lt;/code>&lt;/pre>
&lt;p>The dataset contains 50 stores with average daily sales of \$33,610, average coupon usage of 33.84%, and average neighborhood income of \$50,910. Sales range from \$25,760 to \$44,380, reflecting meaningful variation across stores. Coupon usage spans from 23% to 44%, and income ranges from \$30,490 to \$71,420. This variation provides enough signal to estimate the relationships of interest.&lt;/p>
&lt;h2 id="the-naive-relationship">The naive relationship&lt;/h2>
&lt;p>The simplest approach is to regress sales directly on coupon usage, ignoring income entirely. This is what a rushed analyst might do &amp;mdash; just look at whether stores with more coupon usage have higher or lower sales.&lt;/p>
&lt;pre>&lt;code class="language-python">sns.regplot(x=&amp;quot;coupons&amp;quot;, y=&amp;quot;sales&amp;quot;, data=df, ci=False,
scatter_kws={&amp;quot;color&amp;quot;: STEEL_BLUE, &amp;quot;alpha&amp;quot;: 0.7, &amp;quot;edgecolors&amp;quot;: &amp;quot;white&amp;quot;, &amp;quot;s&amp;quot;: 60},
line_kws={&amp;quot;color&amp;quot;: WARM_ORANGE, &amp;quot;linewidth&amp;quot;: 2, &amp;quot;label&amp;quot;: &amp;quot;Linear fit&amp;quot;})
plt.legend()
plt.xlabel(&amp;quot;Coupon usage (%)&amp;quot;)
plt.ylabel(&amp;quot;Daily sales (thousands $)&amp;quot;)
plt.title(&amp;quot;Naive relationship: Sales vs. coupon usage&amp;quot;)
plt.savefig(&amp;quot;fwl_naive_regression.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="fwl_naive_regression.png" alt="Scatter plot showing a negative relationship between coupon usage and sales, with a downward-sloping regression line.">
&lt;em>Naive regression: the downward slope suggests coupons reduce sales, but this is driven by confounding from income.&lt;/em>&lt;/p>
&lt;pre>&lt;code class="language-python">naive_model = smf.ols(&amp;quot;sales ~ coupons&amp;quot;, df).fit()
print(naive_model.summary().tables[1])
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>==============================================================================
coef std err t P&amp;gt;|t| [0.025 0.975]
------------------------------------------------------------------------------
Intercept 37.1906 3.960 9.390 0.000 29.228 45.154
coupons -0.1059 0.116 -0.914 0.365 -0.339 0.127
==============================================================================
&lt;/code>&lt;/pre>
&lt;p>The naive regression suggests that coupons have a &lt;strong>negative&lt;/strong> effect on sales: each additional percentage point of coupon usage is associated with \$106 less in daily sales. However, this coefficient is not statistically significant (p = 0.365), and the 95% confidence interval [-0.339, 0.127] spans both negative and positive values. More importantly, the true effect is +0.2, so this estimate is not just imprecise &amp;mdash; it points in the wrong direction. The confounder (income) is pulling the estimate downward because wealthier neighborhoods use fewer coupons but spend more.&lt;/p>
&lt;h2 id="controlling-for-income">Controlling for income&lt;/h2>
&lt;p>To block the backdoor path through income, the next step includes it as a control variable in the regression. This is the standard approach in applied work: add the confounder to the right-hand side of the regression equation.&lt;/p>
&lt;pre>&lt;code class="language-python">full_model = smf.ols(&amp;quot;sales ~ coupons + income&amp;quot;, df).fit()
print(full_model.summary().tables[1])
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>==============================================================================
coef std err t P&amp;gt;|t| [0.025 0.975]
------------------------------------------------------------------------------
Intercept 5.0278 7.181 0.700 0.487 -9.418 19.474
coupons 0.2673 0.120 2.222 0.031 0.025 0.509
income 0.3836 0.076 5.015 0.000 0.230 0.537
==============================================================================
&lt;/code>&lt;/pre>
&lt;p>Controlling for income reverses the picture entirely. The coefficient on coupons is now &lt;strong>+0.2673&lt;/strong> (p = 0.031), indicating that each additional percentage point of coupon usage increases daily sales by about \$267. This is close to the true effect of +0.2, and the 95% confidence interval [0.025, 0.509] no longer includes zero. Income itself has a strong positive effect of +0.3836 (p &amp;lt; 0.001), confirming that wealthier neighborhoods spend more. By conditioning on income, the backdoor path is blocked and the estimate moves much closer to the true causal effect.&lt;/p>
&lt;p>But what is the regression actually &lt;em>doing&lt;/em> when it &amp;ldquo;controls for&amp;rdquo; income? This is where the FWL theorem provides a clear answer.&lt;/p>
&lt;h2 id="the-fwl-theorem">The FWL theorem&lt;/h2>
&lt;p>The Frisch-Waugh-Lovell theorem, first published by Ragnar Frisch and Frederick Waugh in 1933 and later given an elegant proof by Michael Lovell in 1963, provides a precise algebraic decomposition of what multivariate regression does under the hood.&lt;/p>
&lt;p>Consider a linear model with two sets of regressors:&lt;/p>
&lt;p>$$y_i = \beta_1 x_{i,1} + \beta_2 x_{i,2} + \varepsilon_i$$&lt;/p>
&lt;p>In words, this equation says that the outcome $y$ (sales) equals the effect $\beta_1$ of the variable of interest $x_1$ (coupons), plus the effect $\beta_2$ of the control variable $x_2$ (income), plus an error term $\varepsilon$. In this analysis, $y$ corresponds to the &lt;code>sales&lt;/code> column, $x_1$ to &lt;code>coupons&lt;/code>, and $x_2$ to &lt;code>income&lt;/code>.&lt;/p>
&lt;p>The FWL theorem states that the &lt;strong>Ordinary Least Squares (OLS)&lt;/strong> estimator &amp;mdash; the standard method for fitting a regression line by minimizing squared prediction errors &amp;mdash; $\hat{\beta}_1$ from this multivariate regression is &lt;strong>identical&lt;/strong> to the estimator obtained from a simpler procedure:&lt;/p>
&lt;p>$$\hat{\beta}_1^{FWL} = \frac{\text{Cov}(\tilde{y}, \, \tilde{x}_1)}{\text{Var}(\tilde{x}_1)}$$&lt;/p>
&lt;p>where $\tilde{x}_1$ is the residual from regressing $x_1$ on $x_2$, and $\tilde{y}$ is the residual from regressing $y$ on $x_2$.&lt;/p>
&lt;p>In words, this says: to estimate the effect of coupons while controlling for income, we can (1) remove income&amp;rsquo;s influence from coupons, (2) remove income&amp;rsquo;s influence from sales, and (3) regress the cleaned sales on the cleaned coupons. The resulting coefficient is &lt;strong>exactly&lt;/strong> the same as the one from the full multivariate regression.&lt;/p>
&lt;p>This procedure is called &lt;strong>partialling-out&lt;/strong> because it removes the variation explained by the control variables, keeping only the residual variation &amp;mdash; the part that is &lt;em>orthogonal&lt;/em> to (independent of) income. The three equivalent estimators are:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Full OLS:&lt;/strong> Regress $y$ on $x_1$ and $x_2$ jointly&lt;/li>
&lt;li>&lt;strong>Partial FWL:&lt;/strong> Regress $y$ on $\tilde{x}_1$ (residuals of $x_1$ on $x_2$)&lt;/li>
&lt;li>&lt;strong>Full FWL:&lt;/strong> Regress $\tilde{y}$ on $\tilde{x}_1$ (residuals of both variables on $x_2$)&lt;/li>
&lt;/ol>
&lt;p>All three produce the same $\hat{\beta}_1$. The full FWL (option 3) also gives the correct standard errors.&lt;/p>
&lt;h2 id="verifying-fwl-step-by-step">Verifying FWL step by step&lt;/h2>
&lt;p>Let us verify each step of the theorem using the simulated data.&lt;/p>
&lt;h3 id="step-1-residualize-coupons-only">Step 1: Residualize coupons only&lt;/h3>
&lt;p>First, we regress coupons on income and extract the residuals $\tilde{x}_1$. These residuals represent the variation in coupon usage that &lt;strong>cannot&lt;/strong> be explained by income &amp;mdash; the &amp;ldquo;purified&amp;rdquo; coupon signal. Then we regress sales on these residuals. Because residuals always average to zero by construction (they are &lt;em>mean-zero&lt;/em>), we drop the intercept from this regression.&lt;/p>
&lt;pre>&lt;code class="language-python"># Residualize coupons with respect to income
df[&amp;quot;coupons_tilde&amp;quot;] = smf.ols(&amp;quot;coupons ~ income&amp;quot;, df).fit().resid
# Regress sales on residualized coupons (no intercept)
fwl_step1 = smf.ols(&amp;quot;sales ~ coupons_tilde - 1&amp;quot;, df).fit()
print(fwl_step1.summary().tables[1])
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>=================================================================================
coef std err t P&amp;gt;|t| [0.025 0.975]
---------------------------------------------------------------------------------
coupons_tilde 0.2673 1.271 0.210 0.834 -2.288 2.822
=================================================================================
&lt;/code>&lt;/pre>
&lt;p>The coefficient is &lt;strong>exactly 0.2673&lt;/strong> &amp;mdash; identical to the full regression. However, the standard error has exploded from 0.120 to 1.271, making the estimate appear insignificant (p = 0.834). This happens because income was only partialled out from coupons but not from sales. The remaining variation in sales due to income inflates the residual variance of the regression, producing artificially large standard errors.&lt;/p>
&lt;h3 id="step-2-residualize-both-variables">Step 2: Residualize both variables&lt;/h3>
&lt;p>To fix the standard errors, we also residualize sales with respect to income. Now both variables have had income&amp;rsquo;s influence removed.&lt;/p>
&lt;pre>&lt;code class="language-python"># Residualize sales with respect to income
df[&amp;quot;sales_tilde&amp;quot;] = smf.ols(&amp;quot;sales ~ income&amp;quot;, df).fit().resid
# Regress residualized sales on residualized coupons (no intercept)
fwl_step2 = smf.ols(&amp;quot;sales_tilde ~ coupons_tilde - 1&amp;quot;, df).fit()
print(fwl_step2.summary().tables[1])
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>=================================================================================
coef std err t P&amp;gt;|t| [0.025 0.975]
---------------------------------------------------------------------------------
coupons_tilde 0.2673 0.118 2.269 0.028 0.031 0.504
=================================================================================
&lt;/code>&lt;/pre>
&lt;p>The coefficient remains &lt;strong>exactly 0.2673&lt;/strong>, and now the standard error (0.118) and p-value (0.028) are nearly identical to the full regression (SE = 0.120, p = 0.031). The slight difference in standard errors comes from a degrees-of-freedom adjustment &amp;mdash; the full regression uses up an extra degree of freedom to estimate the income coefficient (leaving fewer data points for estimating uncertainty), while this univariate regression does not. The substantive conclusion is the same: coupons have a significant positive effect on sales after partialling out income.&lt;/p>
&lt;h2 id="visualizing-partialling-out">Visualizing partialling-out&lt;/h2>
&lt;p>What does partialling-out actually look like? Regressing coupons on income produces fitted values that form a line through the data. The &lt;strong>residuals&lt;/strong> &amp;mdash; the vertical distances between each point and this line &amp;mdash; represent the coupon variation that income cannot explain.&lt;/p>
&lt;pre>&lt;code class="language-python">df[&amp;quot;coupons_hat&amp;quot;] = smf.ols(&amp;quot;coupons ~ income&amp;quot;, df).fit().predict()
fig, ax = plt.subplots(figsize=(8, 6))
ax.scatter(df[&amp;quot;income&amp;quot;], df[&amp;quot;coupons&amp;quot;], color=STEEL_BLUE, alpha=0.7,
edgecolors=&amp;quot;white&amp;quot;, s=60, label=&amp;quot;Stores&amp;quot;)
sns.regplot(x=&amp;quot;income&amp;quot;, y=&amp;quot;coupons&amp;quot;, data=df, ci=False, scatter=False,
line_kws={&amp;quot;color&amp;quot;: WARM_ORANGE, &amp;quot;linewidth&amp;quot;: 2, &amp;quot;label&amp;quot;: &amp;quot;Linear fit&amp;quot;}, ax=ax)
ax.vlines(df[&amp;quot;income&amp;quot;],
np.minimum(df[&amp;quot;coupons&amp;quot;], df[&amp;quot;coupons_hat&amp;quot;]),
np.maximum(df[&amp;quot;coupons&amp;quot;], df[&amp;quot;coupons_hat&amp;quot;]),
linestyle=&amp;quot;--&amp;quot;, color=NEAR_BLACK, alpha=0.5, linewidth=1,
label=&amp;quot;Residuals&amp;quot;)
ax.set_xlabel(&amp;quot;Neighborhood income (thousands $)&amp;quot;)
ax.set_ylabel(&amp;quot;Coupon usage (%)&amp;quot;)
ax.set_title(&amp;quot;Partialling-out: removing income's effect on coupons&amp;quot;)
ax.legend()
plt.savefig(&amp;quot;fwl_residuals_income.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="fwl_residuals_income.png" alt="Scatter plot of coupon usage versus income with a downward-sloping fitted line and vertical dashed lines showing residuals for each store.">
&lt;em>Partialling-out: the dashed lines are the residuals &amp;mdash; the coupon variation that income cannot explain.&lt;/em>&lt;/p>
&lt;p>The downward-sloping fitted line confirms that higher-income neighborhoods use fewer coupons. The vertical dashed lines are the residuals &amp;mdash; the part of coupon usage that income does not predict. Some stores use more coupons than their neighborhood income would suggest (positive residuals), and others use fewer (negative residuals). Partialling out income keeps only these residuals, effectively asking: &amp;ldquo;Among stores with similar income levels, which ones have unusually high or low coupon usage?&amp;rdquo;&lt;/p>
&lt;h2 id="the-conditional-relationship-revealed">The conditional relationship revealed&lt;/h2>
&lt;p>It is now possible to plot the relationship that the multivariate regression captures but cannot directly display: residualized sales against residualized coupons. Both variables have had income&amp;rsquo;s influence removed, so any remaining relationship is the &lt;strong>conditional&lt;/strong> effect of coupons on sales &amp;mdash; the effect after accounting for income differences.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 6))
ax.scatter(df[&amp;quot;coupons_tilde&amp;quot;], df[&amp;quot;sales_tilde&amp;quot;], color=STEEL_BLUE,
alpha=0.7, edgecolors=&amp;quot;white&amp;quot;, s=60, label=&amp;quot;Stores (residualized)&amp;quot;)
sns.regplot(x=&amp;quot;coupons_tilde&amp;quot;, y=&amp;quot;sales_tilde&amp;quot;, data=df, ci=False, scatter=False,
line_kws={&amp;quot;color&amp;quot;: WARM_ORANGE, &amp;quot;linewidth&amp;quot;: 2, &amp;quot;label&amp;quot;: &amp;quot;Linear fit&amp;quot;}, ax=ax)
ax.set_xlabel(&amp;quot;Residual coupon usage&amp;quot;)
ax.set_ylabel(&amp;quot;Residual sales&amp;quot;)
ax.set_title(&amp;quot;Conditional relationship after partialling-out income&amp;quot;)
ax.legend()
plt.savefig(&amp;quot;fwl_partialled_out.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="fwl_partialled_out.png" alt="Scatter plot showing a positive relationship between residualized coupon usage and residualized sales, with an upward-sloping regression line.">
&lt;em>After removing income&amp;rsquo;s influence from both variables, the true positive effect of coupons on sales emerges.&lt;/em>&lt;/p>
&lt;p>The positive slope is now clearly visible. Stripping away the confounding influence of income reveals that stores where coupon usage is higher than expected (given their neighborhood income) tend to also have sales that are higher than expected. The slope of this line is exactly 0.2673 &amp;mdash; the same coefficient produced by the full multivariate regression.&lt;/p>
&lt;h2 id="scaling-for-interpretability">Scaling for interpretability&lt;/h2>
&lt;p>One drawback of the partialled-out plot is that both axes show residuals centered around zero, which makes the magnitudes hard to interpret. A negative coupon value of -5 does not mean the store has -5% coupon usage &amp;mdash; it means coupon usage is 5 percentage points below what income alone would predict.&lt;/p>
&lt;p>Adding the sample mean back to each residualized variable fixes this. The shift moves the axes without changing the slope.&lt;/p>
&lt;pre>&lt;code class="language-python">df[&amp;quot;coupons_tilde_scaled&amp;quot;] = df[&amp;quot;coupons_tilde&amp;quot;] + df[&amp;quot;coupons&amp;quot;].mean()
df[&amp;quot;sales_tilde_scaled&amp;quot;] = df[&amp;quot;sales_tilde&amp;quot;] + df[&amp;quot;sales&amp;quot;].mean()
# Verify the coefficient is unchanged
scaled_model = smf.ols(&amp;quot;sales_tilde_scaled ~ coupons_tilde_scaled&amp;quot;, df).fit()
print(scaled_model.summary().tables[1])
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>========================================================================================
coef std err t P&amp;gt;|t| [0.025 0.975]
----------------------------------------------------------------------------------------
Intercept 24.5585 4.053 6.059 0.000 16.409 32.708
coupons_tilde_scaled 0.2673 0.119 2.246 0.029 0.028 0.507
========================================================================================
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 6))
ax.scatter(df[&amp;quot;coupons_tilde_scaled&amp;quot;], df[&amp;quot;sales_tilde_scaled&amp;quot;],
color=STEEL_BLUE, alpha=0.7, edgecolors=&amp;quot;white&amp;quot;, s=60,
label=&amp;quot;Stores (residualized + scaled)&amp;quot;)
sns.regplot(x=&amp;quot;coupons_tilde_scaled&amp;quot;, y=&amp;quot;sales_tilde_scaled&amp;quot;, data=df,
ci=False, scatter=False,
line_kws={&amp;quot;color&amp;quot;: WARM_ORANGE, &amp;quot;linewidth&amp;quot;: 2, &amp;quot;label&amp;quot;: &amp;quot;Linear fit&amp;quot;}, ax=ax)
ax.set_xlabel(&amp;quot;Coupon usage (%, residualized + mean)&amp;quot;)
ax.set_ylabel(&amp;quot;Daily sales (thousands $, residualized + mean)&amp;quot;)
ax.set_title(&amp;quot;Scaled residuals: interpretable magnitudes&amp;quot;)
ax.legend()
plt.savefig(&amp;quot;fwl_scaled_residuals.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="fwl_scaled_residuals.png" alt="Scatter plot of scaled residualized sales versus scaled residualized coupon usage, with axes now showing values in the original units centered around their means.">
&lt;em>Adding the sample means back to the residuals restores interpretable units without changing the slope.&lt;/em>&lt;/p>
&lt;p>The coefficient remains exactly 0.2673 (p = 0.029), confirming that adding the means back does not alter the estimated relationship. Now the axes are in interpretable units: coupon usage around 34% and daily sales around \$33,600. This scaled version is ideal for presentations and reports where the audience needs to understand both the direction and the magnitude of the conditional relationship at a glance.&lt;/p>
&lt;h2 id="extending-to-multiple-controls">Extending to multiple controls&lt;/h2>
&lt;p>The FWL theorem works with &lt;strong>any number&lt;/strong> of control variables, not just one. To demonstrate, the next step adds &lt;code>dayofweek&lt;/code> as a second control alongside income. The theorem says both controls can be partialled out simultaneously and the same coefficient on coupons will emerge.&lt;/p>
&lt;pre>&lt;code class="language-python"># Full regression with both controls
full_model_2 = smf.ols(&amp;quot;sales ~ coupons + income + dayofweek&amp;quot;, df).fit()
print(full_model_2.summary().tables[1])
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>==============================================================================
coef std err t P&amp;gt;|t| [0.025 0.975]
------------------------------------------------------------------------------
Intercept 3.9825 7.172 0.555 0.581 -10.454 18.419
coupons 0.2706 0.119 2.266 0.028 0.030 0.511
income 0.3774 0.076 4.961 0.000 0.224 0.531
dayofweek 0.3195 0.245 1.306 0.198 -0.173 0.812
==============================================================================
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python"># FWL: partial out both income and dayofweek
df[&amp;quot;coupons_tilde_2&amp;quot;] = smf.ols(&amp;quot;coupons ~ income + dayofweek&amp;quot;, df).fit().resid
df[&amp;quot;sales_tilde_2&amp;quot;] = smf.ols(&amp;quot;sales ~ income + dayofweek&amp;quot;, df).fit().resid
fwl_multi = smf.ols(&amp;quot;sales_tilde_2 ~ coupons_tilde_2 - 1&amp;quot;, df).fit()
print(fwl_multi.summary().tables[1])
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>===================================================================================
coef std err t P&amp;gt;|t| [0.025 0.975]
-----------------------------------------------------------------------------------
coupons_tilde_2 0.2706 0.116 2.338 0.023 0.038 0.503
===================================================================================
&lt;/code>&lt;/pre>
&lt;p>With both controls, the full regression gives a coupon coefficient of 0.2706 (p = 0.028). The FWL procedure &amp;mdash; partialling out income and day of week from both sales and coupons &amp;mdash; yields the &lt;strong>identical&lt;/strong> coefficient of 0.2706 (p = 0.023). The day-of-week effect itself (0.3195, p = 0.198) is not statistically significant in this sample, but including it slightly sharpens the coupon estimate from 0.2673 to 0.2706 by absorbing additional residual variance. This confirms that FWL scales to any number of controls.&lt;/p>
&lt;h2 id="naive-vs-conditional-the-full-picture">Naive vs. conditional: the full picture&lt;/h2>
&lt;p>To appreciate how much the FWL procedure changes the conclusions, the next figure places the naive and conditional relationships side by side. The left panel shows the raw data; the right panel shows the same data after partialling out income.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, axes = plt.subplots(1, 2, figsize=(14, 6))
# Left: naive relationship
axes[0].scatter(df[&amp;quot;coupons&amp;quot;], df[&amp;quot;sales&amp;quot;], color=STEEL_BLUE, alpha=0.7,
edgecolors=&amp;quot;white&amp;quot;, s=60)
sns.regplot(x=&amp;quot;coupons&amp;quot;, y=&amp;quot;sales&amp;quot;, data=df, ci=False, scatter=False,
line_kws={&amp;quot;color&amp;quot;: WARM_ORANGE, &amp;quot;linewidth&amp;quot;: 2}, ax=axes[0])
axes[0].set_xlabel(&amp;quot;Coupon usage (%)&amp;quot;)
axes[0].set_ylabel(&amp;quot;Daily sales (thousands $)&amp;quot;)
axes[0].set_title(&amp;quot;Naive (no controls)&amp;quot;)
# Right: after partialling-out income
axes[1].scatter(df[&amp;quot;coupons_tilde_scaled&amp;quot;], df[&amp;quot;sales_tilde_scaled&amp;quot;],
color=TEAL, alpha=0.7, edgecolors=&amp;quot;white&amp;quot;, s=60)
sns.regplot(x=&amp;quot;coupons_tilde_scaled&amp;quot;, y=&amp;quot;sales_tilde_scaled&amp;quot;, data=df,
ci=False, scatter=False,
line_kws={&amp;quot;color&amp;quot;: WARM_ORANGE, &amp;quot;linewidth&amp;quot;: 2}, ax=axes[1])
axes[1].set_xlabel(&amp;quot;Coupon usage (%, after partialling-out)&amp;quot;)
axes[1].set_ylabel(&amp;quot;Daily sales (thousands $, after partialling-out)&amp;quot;)
axes[1].set_title(&amp;quot;After partialling-out income (FWL)&amp;quot;)
plt.suptitle(&amp;quot;The FWL theorem reveals the true relationship&amp;quot;,
fontsize=14, fontweight=&amp;quot;bold&amp;quot;, y=1.02)
plt.tight_layout()
plt.savefig(&amp;quot;fwl_comparison.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="fwl_comparison.png" alt="Two-panel figure comparing the naive negative relationship between sales and coupons on the left with the positive conditional relationship after partialling-out income on the right.">
&lt;em>Simpson&amp;rsquo;s paradox resolved: the naive negative slope (left) reverses to a positive slope (right) after partialling out income.&lt;/em>&lt;/p>
&lt;p>The contrast is striking. On the left, the naive analysis suggests a negative relationship (slope = -0.106) &amp;mdash; coupons appear to hurt sales. On the right, after removing income&amp;rsquo;s confounding influence, the true positive relationship emerges (slope = +0.267). This is a textbook example of &lt;strong>Simpson&amp;rsquo;s paradox&lt;/strong>: a trend that appears in aggregate data reverses when the data is properly conditioned on a relevant variable.&lt;/p>
&lt;h2 id="summary-of-results">Summary of results&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Coupons coefficient&lt;/th>
&lt;th>Std. error&lt;/th>
&lt;th>p-value&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Naive OLS (no controls)&lt;/td>
&lt;td>-0.1059&lt;/td>
&lt;td>0.116&lt;/td>
&lt;td>0.365&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Full OLS (+ income)&lt;/td>
&lt;td>+0.2673&lt;/td>
&lt;td>0.120&lt;/td>
&lt;td>0.031&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FWL Step 1 (residualize X only)&lt;/td>
&lt;td>+0.2673&lt;/td>
&lt;td>1.271&lt;/td>
&lt;td>0.834&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FWL Step 2 (residualize both)&lt;/td>
&lt;td>+0.2673&lt;/td>
&lt;td>0.118&lt;/td>
&lt;td>0.028&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Full OLS (+ income + day)&lt;/td>
&lt;td>+0.2706&lt;/td>
&lt;td>0.119&lt;/td>
&lt;td>0.028&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>FWL (+ income + day)&lt;/td>
&lt;td>+0.2706&lt;/td>
&lt;td>0.116&lt;/td>
&lt;td>0.023&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>All FWL variants produce the same coefficient as the corresponding full regression, confirming the theorem. The coefficient of +0.267 is close to the true DGP value of +0.200, with the difference attributable to finite-sample noise in 50 observations.&lt;/p>
&lt;h2 id="applications-of-the-fwl-theorem">Applications of the FWL theorem&lt;/h2>
&lt;p>The FWL theorem is not just a mathematical curiosity &amp;mdash; it has practical applications across several domains.&lt;/p>
&lt;h3 id="data-visualization">Data visualization&lt;/h3>
&lt;p>As shown above, FWL makes it possible to plot the conditional relationship between two variables after controlling for confounders. This is invaluable when presenting regression results to non-technical audiences who understand scatter plots but not regression tables with multiple coefficients.&lt;/p>
&lt;h3 id="computational-efficiency">Computational efficiency&lt;/h3>
&lt;p>When a regression includes &lt;strong>high-dimensional fixed effects&lt;/strong> &amp;mdash; for example, year, industry, and country dummies that could add hundreds of columns &amp;mdash; computing the full regression becomes expensive. The FWL theorem allows software to partial out these fixed effects first, reducing the problem to a much smaller regression. Widely-used packages that exploit this strategy include:&lt;/p>
&lt;ul>
&lt;li>&lt;a href="https://scorreia.com/software/reghdfe/" target="_blank" rel="noopener">reghdfe&lt;/a> in Stata&lt;/li>
&lt;li>&lt;a href="https://cran.r-project.org/web/packages/fixest/index.html" target="_blank" rel="noopener">fixest&lt;/a> in R&lt;/li>
&lt;li>&lt;a href="https://pyfixest.org/pyfixest.html" target="_blank" rel="noopener">pyfixest&lt;/a> in Python &amp;mdash; a fast, user-friendly package for fixed-effects regression (including multi-way clustering and interaction effects), inspired by fixest&amp;rsquo;s R API&lt;/li>
&lt;li>&lt;a href="https://pyhdfe.readthedocs.io/en/stable/index.html" target="_blank" rel="noopener">pyhdfe&lt;/a> in Python&lt;/li>
&lt;/ul>
&lt;h3 id="machine-learning-and-causal-inference">Machine learning and causal inference&lt;/h3>
&lt;p>Perhaps the most impactful modern application is &lt;strong>Double Machine Learning (DML)&lt;/strong>, developed by Chernozhukov, Chetverikov, Demirer, Duflo, Hansen, Newey, and Robins (2018). DML extends the FWL logic by replacing the OLS regressions in the partialling-out step with &lt;strong>flexible machine learning models&lt;/strong> (random forests, lasso, neural networks). This allows the control variables to have complex, nonlinear effects on both the treatment and the outcome &amp;mdash; while still recovering a valid causal estimate of the treatment effect.&lt;/p>
&lt;p>If you want to see DML in action, check out the companion tutorial on &lt;a href="https://carlos-mendez.org/post/python_doubleml/">Introduction to Causal Inference: Double Machine Learning&lt;/a>, which applies the partialling-out estimator to a real randomized experiment.&lt;/p>
&lt;h2 id="discussion">Discussion&lt;/h2>
&lt;p>This tutorial set out to answer a simple question: what does it mean to &amp;ldquo;control for&amp;rdquo; a variable in regression, and how can the result be visualized? The FWL theorem provides a definitive answer. Controlling for income in a regression of sales on coupons is equivalent to removing income&amp;rsquo;s influence from both variables and then regressing the residuals.&lt;/p>
&lt;p>In the simulated retail scenario, failing to control for income produced a misleading negative coefficient of -0.106, suggesting coupons reduce sales. After partialling out income, the coefficient reversed to +0.267 (p = 0.031), revealing that coupons genuinely increase sales by about \$267 per percentage point. This estimate is close to the true data-generating parameter of +0.200, with the gap attributable to sampling variability in just 50 stores.&lt;/p>
&lt;p>For a practitioner &amp;mdash; say, the marketing director of the retail chain &amp;mdash; the takeaway is clear. An analysis that ignored neighborhood income would conclude the coupon program was counterproductive. The FWL-based analysis shows it works, and provides a plot that makes this case visually compelling. The theorem bridges the gap between the numbers in a regression table and the intuitive two-variable scatter plot.&lt;/p>
&lt;h2 id="summary-and-next-steps">Summary and next steps&lt;/h2>
&lt;p>&lt;strong>Key takeaways:&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Sign reversal.&lt;/strong> The naive coupon coefficient was -0.106 (negative, not significant). After controlling for income, it became +0.267 (positive, p = 0.031). Ignoring confounders can reverse not just the magnitude but the direction of an estimated effect.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Exact equivalence.&lt;/strong> The FWL procedure produced a coefficient of 0.2673 &amp;mdash; identical to the full multivariate regression down to four decimal places &amp;mdash; whether partialling out one control (income) or two (income + day of week). The theorem is not an approximation; it is an algebraic identity.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Visualization power.&lt;/strong> FWL reduces any multivariate regression to a univariate one, enabling scatter plots that display conditional relationships. This is especially valuable for communicating results to non-technical stakeholders.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Foundation for DML.&lt;/strong> FWL underpins modern causal inference methods like Double Machine Learning, where flexible ML learners replace OLS in the partialling-out step. Understanding FWL is a prerequisite for understanding DML.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Linearity assumption matters.&lt;/strong> The FWL procedure relies on OLS residualization, which assumes linear relationships between the controls and both the treatment and outcome. If income affects coupons or sales nonlinearly, OLS residuals will not fully remove the confounding &amp;mdash; motivating methods like DML that replace OLS with flexible learners.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Limitations:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>The data is simulated with a known linear DGP. In real data, the DGP is unknown and may be nonlinear, requiring methods like DML rather than plain OLS.&lt;/li>
&lt;li>The FWL theorem assumes a correctly specified linear model. If the relationship between income and coupons (or sales) is nonlinear, OLS residualization will not fully remove the confounding.&lt;/li>
&lt;li>With only 50 observations, the estimates have wide confidence intervals. Larger samples would sharpen the estimates.&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Next steps:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>See &lt;a href="https://carlos-mendez.org/post/python_doubleml/">Double Machine Learning&lt;/a> to learn how FWL extends to nonlinear settings.&lt;/li>
&lt;li>See &lt;a href="https://carlos-mendez.org/post/python_dowhy/">Introduction to Causal Inference: The DoWhy Approach&lt;/a> for a full causal inference workflow with real data.&lt;/li>
&lt;/ul>
&lt;h2 id="exercises">Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Sample size sensitivity.&lt;/strong> Change &lt;code>N&lt;/code> from 50 to 500 in the &lt;code>simulate_store_data()&lt;/code> function. How do the naive and FWL coefficients change? How do the standard errors shrink? Is the naive estimate still misleading with a larger sample?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Nonlinear confounding.&lt;/strong> Modify the DGP so that income affects coupons nonlinearly: &lt;code>coupons = 60 - 0.01 * income**2 + noise&lt;/code>. Does the FWL procedure (with linear OLS residualization) still recover the true coefficient? Why or why not?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Real data application.&lt;/strong> Pick a dataset with a known confounder (e.g., the wage-education-ability relationship) and apply the FWL procedure. Visualize the naive and conditional relationships side by side.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://towardsdatascience.com/the-fwl-theorem-or-how-to-make-all-regressions-intuitive-59f801eb3299/" target="_blank" rel="noopener">Courthoud, M. (2022). Understanding the Frisch-Waugh-Lovell Theorem. &lt;em>Towards Data Science&lt;/em>.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.jstor.org/stable/1907330" target="_blank" rel="noopener">Frisch, R. and Waugh, F. V. (1933). Partial Time Regressions as Compared with Individual Trends. &lt;em>Econometrica&lt;/em>, 1(4), 387&amp;ndash;401.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.tandfonline.com/doi/abs/10.1080/01621459.1963.10480682" target="_blank" rel="noopener">Lovell, M. C. (1963). Seasonal Adjustment of Economic Time Series and Multiple Regression Analysis. &lt;em>Journal of the American Statistical Association&lt;/em>, 58(304), 993&amp;ndash;1010.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://academic.oup.com/ectj/article/21/1/C1/5056401" target="_blank" rel="noopener">Chernozhukov, V., Chetverikov, D., Demirer, M., Duflo, E., Hansen, C., Newey, W., and Robins, J. (2018). Double/Debiased Machine Learning for Treatment and Structural Parameters. &lt;em>The Econometrics Journal&lt;/em>, 21(1), C1&amp;ndash;C68.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://academic.oup.com/restud/article-abstract/81/2/608/1523757" target="_blank" rel="noopener">Belloni, A., Chernozhukov, V., and Hansen, C. (2014). Inference on Treatment Effects after Selection among High-Dimensional Controls. &lt;em>Review of Economic Studies&lt;/em>, 81(2), 608&amp;ndash;650.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://pyfixest.org/pyfixest.html" target="_blank" rel="noopener">pyfixest &amp;mdash; Fast Estimation of Fixed-Effects Models in Python&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.statsmodels.org/stable/index.html" target="_blank" rel="noopener">statsmodels &amp;mdash; Statistical Modeling in Python&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>Introduction to Partial Identification: Bounding Causal Effects Under Unmeasured Confounding</title><link>https://carlos-mendez.org/post/python_partial_identification/</link><pubDate>Fri, 13 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_partial_identification/</guid><description>&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>Does a job training program actually help workers find jobs, or could an unmeasured factor &amp;ndash; like prior work experience &amp;ndash; explain the entire observed association? In standard causal inference with methods like Double Machine Learning or DoWhy, we assume that all confounders are observed. But what if that assumption fails? Rather than abandoning causal analysis entirely, &lt;strong>partial identification&lt;/strong> offers an honest alternative: instead of estimating a single number, we compute &lt;em>bounds&lt;/em> &amp;ndash; a range of values that the true causal effect must lie within, given only minimal assumptions.&lt;/p>
&lt;p>Think of it this way. If someone tells you that $x + y = 10$ and $y = 6$, you know $x = 4$ exactly &amp;ndash; that is &lt;strong>point identification&lt;/strong>. But if they only tell you that $y$ is somewhere between 4 and 7, you can still say $x$ is between 3 and 6. You have not pinned down $x$ exactly, but you have ruled out many values. That is &lt;strong>partial identification&lt;/strong>: credible uncertainty over incredible certainty.&lt;/p>
&lt;p>In this tutorial we simulate an observational study where an unmeasured confounder biases the naive estimate, then compute &lt;strong>Manski bounds&lt;/strong> (the widest possible bounds under minimal assumptions), &lt;strong>entropy-based bounds&lt;/strong> (tighter bounds using information-theoretic constraints), and &lt;strong>Tian-Pearl bounds&lt;/strong> for the Probability of Necessity and Sufficiency. We use the &lt;a href="https://pypi.org/project/causalboundingengine/" target="_blank" rel="noopener">CausalBoundingEngine&lt;/a> Python package, which provides a unified framework for multiple bounding methods.&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Understand why unmeasured confounders invalidate point identification and when partial identification is the appropriate response&lt;/li>
&lt;li>Implement Manski (worst-case) bounds for the Average Treatment Effect using the algebra of observable probabilities&lt;/li>
&lt;li>Compute Tian-Pearl bounds for the Probability of Necessity and Sufficiency (PNS)&lt;/li>
&lt;li>Compare multiple bounding methods to see how additional assumptions tighten bounds&lt;/li>
&lt;li>Assess whether bounds are informative enough for practical decision-making&lt;/li>
&lt;/ul>
&lt;h2 id="the-identification-problem">The Identification Problem&lt;/h2>
&lt;h3 id="point-identification-vs-partial-identification">Point identification vs. partial identification&lt;/h3>
&lt;p>Most causal inference methods produce a single estimate of the treatment effect &amp;ndash; a &lt;strong>point estimate&lt;/strong>. This requires strong assumptions. For example, regression adjustment assumes that all variables affecting both treatment and outcome are included in the model. Double Machine Learning assumes &lt;em>conditional ignorability&lt;/em> &amp;ndash; that treatment is as good as randomly assigned once we condition on observed covariates. These assumptions are untestable: we can never verify from the data alone that no important variable was left out.&lt;/p>
&lt;p>&lt;strong>Partial identification&lt;/strong> relaxes these assumptions. Instead of requiring &amp;ldquo;no unmeasured confounders,&amp;rdquo; it asks: &amp;ldquo;What can we learn about the causal effect using only the data we observe, without assuming confounders away?&amp;rdquo; The answer is a range of values &amp;ndash; called the &lt;strong>identified set&lt;/strong> or &lt;strong>bounds&lt;/strong> &amp;ndash; consistent with the data and the weaker assumptions. Any value outside this range can be rejected; any value inside it remains plausible.&lt;/p>
&lt;p>The key estimand we target is the &lt;strong>Average Treatment Effect (ATE)&lt;/strong>:&lt;/p>
&lt;p>$$\text{ATE} = E[Y(1)] - E[Y(0)]$$&lt;/p>
&lt;p>In words, the ATE is the difference between the average outcome if everyone were treated and the average outcome if no one were treated. Here $Y(1)$ is the potential outcome under treatment (getting a job if trained) and $Y(0)$ is the potential outcome without treatment (getting a job without training). We never observe both potential outcomes for the same person &amp;ndash; this is the &lt;strong>fundamental problem of causal inference&lt;/strong> &amp;ndash; so we must rely on assumptions to bridge the gap between what we observe and what we want to know.&lt;/p>
&lt;h3 id="the-confounded-scenario">The confounded scenario&lt;/h3>
&lt;p>In our case study, a job training program ($X$) may cause workers to find jobs ($Y$), but prior work experience ($U$) also affects both who enrolls in training and who gets hired. The causal diagram below shows these relationships &amp;ndash; each arrow represents a direct causal influence from one variable to another. Because $U$ is unmeasured, we cannot block the &lt;strong>backdoor path&lt;/strong> $X \leftarrow U \rightarrow Y$ &amp;ndash; an indirect route from treatment to outcome through a common cause that creates a spurious association. The &lt;strong>backdoor criterion&lt;/strong> says that if we could condition on all variables along such paths, we could identify the causal effect. Since $U$ is unobserved, the criterion fails and standard causal methods will produce biased estimates.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
U[&amp;quot;U&amp;lt;br/&amp;gt;(Prior Experience)&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;Unmeasured&amp;lt;/i&amp;gt;&amp;quot;] --&amp;gt;|&amp;quot;affects enrollment&amp;quot;| X[&amp;quot;X&amp;lt;br/&amp;gt;(Job Training)&amp;quot;]
U --&amp;gt;|&amp;quot;affects hiring&amp;quot;| Y[&amp;quot;Y&amp;lt;br/&amp;gt;(Got a Job)&amp;quot;]
X --&amp;gt;|&amp;quot;causal effect&amp;lt;br/&amp;gt;(what we want)&amp;quot;| Y
style U fill:#999999,stroke:#141413,color:#fff,stroke-dasharray: 5 5
style X fill:#6a9bcc,stroke:#141413,color:#fff
style Y fill:#d97757,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>The dashed border on $U$ signals it is unmeasured. Because we cannot condition on $U$, the backdoor criterion fails and &lt;strong>point identification is impossible&lt;/strong>. This is precisely when partial identification becomes valuable: we can still bound the causal effect using only the observable joint distribution of $X$ and $Y$. The next section sets up our simulated data so we can see exactly how this works.&lt;/p>
&lt;h2 id="setup-and-imports">Setup and Imports&lt;/h2>
&lt;p>We use &lt;a href="https://pypi.org/project/causalboundingengine/" target="_blank" rel="noopener">CausalBoundingEngine&lt;/a>, a Python package that provides a unified interface for applying and comparing multiple causal bounding methods. Install it with &lt;code>pip install causalboundingengine&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-python">import numpy as np
import matplotlib.pyplot as plt
import time
from causalboundingengine.scenarios import BinaryConf
# Reproducibility
RANDOM_SEED = 42
np.random.seed(RANDOM_SEED)
# Configuration
N = 1000 # Number of simulated workers
# Site color palette
STEEL_BLUE = &amp;quot;#6a9bcc&amp;quot;
WARM_ORANGE = &amp;quot;#d97757&amp;quot;
NEAR_BLACK = &amp;quot;#141413&amp;quot;
TEAL = &amp;quot;#00d4c8&amp;quot;
HEADING_BLUE = &amp;quot;#1a3a8a&amp;quot;
&lt;/code>&lt;/pre>
&lt;h2 id="data-simulation">Data Simulation&lt;/h2>
&lt;p>We simulate an observational study where 1,000 workers either receive job training ($X = 1$) or not ($X = 0$), and we observe whether they get a job within six months ($Y = 1$) or not ($Y = 0$). An unmeasured confounder &amp;ndash; prior work experience ($U$) &amp;ndash; affects both who enrolls in training and who gets hired, creating genuine confounding. The data-generating process has two parts. First, treatment assignment depends on the confounder:&lt;/p>
&lt;p>$$P(X_i = 1) = 0.3 + 0.4 \, U_i$$&lt;/p>
&lt;p>Workers with prior experience ($U = 1$) have a 70% chance of enrolling in training, while inexperienced workers ($U = 0$) have only a 30% chance. This creates confounding: the treated group is enriched with experienced workers who would have found jobs regardless. Second, the outcome depends on training, experience, and their interaction:&lt;/p>
&lt;p>$$P(Y_i = 1) = \text{clip}\big(0.2 + 0.3 \, X_i + 0.4 \, U_i - 0.1 \, X_i U_i, \; 0, \; 1\big)$$&lt;/p>
&lt;p>In words, the probability of getting a job depends on training (a positive effect of 0.3), prior experience (a positive effect of 0.4), and a small negative interaction (workers with prior experience benefit slightly less from training). We reveal these equations so we can compute the &lt;em>true&lt;/em> ATE and verify that our bounds contain it.&lt;/p>
&lt;pre>&lt;code class="language-python"># Unmeasured confounder: prior work experience (30% prevalence)
U = np.random.binomial(1, 0.3, N)
# Treatment: enrollment depends on experience (confounded assignment)
X_prob = 0.3 + 0.4 * U # P(X=1|U=0)=0.3, P(X=1|U=1)=0.7
X = np.random.binomial(1, X_prob, N)
# Outcome probability depends on X, U, and their interaction
Y_prob = np.clip(0.2 + 0.3 * X + 0.4 * U - 0.1 * X * U, 0, 1)
Y = np.random.binomial(1, Y_prob) # Outcome: got a job
# Summary statistics
print(f&amp;quot;Dataset: {N} simulated workers&amp;quot;)
print(f&amp;quot;Treatment (X): {X.sum()} trained ({X.mean():.1%})&amp;quot;)
print(f&amp;quot;Outcome (Y): {Y.sum()} got a job ({Y.mean():.1%})&amp;quot;)
# Contingency table
n_00 = ((X == 0) &amp;amp; (Y == 0)).sum()
n_01 = ((X == 0) &amp;amp; (Y == 1)).sum()
n_10 = ((X == 1) &amp;amp; (Y == 0)).sum()
n_11 = ((X == 1) &amp;amp; (Y == 1)).sum()
print(f&amp;quot;\nContingency Table:&amp;quot;)
print(f&amp;quot;{'':&amp;gt;15} {'Y=0':&amp;gt;8} {'Y=1':&amp;gt;8} {'Total':&amp;gt;8}&amp;quot;)
print(f&amp;quot;{'X=0 (Control)':&amp;gt;15} {n_00:&amp;gt;8} {n_01:&amp;gt;8} {n_00+n_01:&amp;gt;8}&amp;quot;)
print(f&amp;quot;{'X=1 (Trained)':&amp;gt;15} {n_10:&amp;gt;8} {n_11:&amp;gt;8} {n_10+n_11:&amp;gt;8}&amp;quot;)
print(f&amp;quot;{'Total':&amp;gt;15} {n_00+n_10:&amp;gt;8} {n_01+n_11:&amp;gt;8} {N:&amp;gt;8}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Dataset: 1000 simulated workers
Treatment (X): 401 trained (40.1%)
Outcome (Y): 407 got a job (40.7%)
Contingency Table:
Y=0 Y=1 Total
X=0 (Control) 447 152 599
X=1 (Trained) 146 255 401
Total 593 407 1000
&lt;/code>&lt;/pre>
&lt;p>Our simulated dataset has 1,000 workers with an imbalanced treatment split: only 401 received training while 599 did not. This imbalance itself is a signature of confounding &amp;ndash; experienced workers (who are more likely to get hired anyway) disproportionately enroll in training. Overall, 40.7% of workers found jobs. The contingency table reveals that 255 out of 401 trained workers got jobs (63.6%) compared to 152 out of 599 untrained workers (25.4%). This raw difference of 38.2 percentage points overstates the true causal effect because the treated group is enriched with experienced workers.&lt;/p>
&lt;h2 id="exploratory-data-analysis">Exploratory Data Analysis&lt;/h2>
&lt;p>Before computing bounds, we visualize the observed conditional probabilities &amp;ndash; the job rates for trained and untrained workers. This is what we can directly observe in the data.&lt;/p>
&lt;pre>&lt;code class="language-python">P_Y1_X1 = Y[X == 1].mean() # P(Y=1 | X=1)
P_Y1_X0 = Y[X == 0].mean() # P(Y=1 | X=0)
naive_ate = P_Y1_X1 - P_Y1_X0
print(f&amp;quot;P(Y=1 | X=1) = {P_Y1_X1:.4f} (trained workers who got jobs)&amp;quot;)
print(f&amp;quot;P(Y=1 | X=0) = {P_Y1_X0:.4f} (untrained workers who got jobs)&amp;quot;)
fig, ax = plt.subplots(figsize=(7, 5))
groups = [&amp;quot;No Training\n(X = 0)&amp;quot;, &amp;quot;Training\n(X = 1)&amp;quot;]
probs = [P_Y1_X0, P_Y1_X1]
colors = [STEEL_BLUE, WARM_ORANGE]
bars = ax.bar(groups, probs, color=colors, width=0.5,
edgecolor=NEAR_BLACK, linewidth=0.8)
for bar, prob in zip(bars, probs):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.01,
f&amp;quot;{prob:.1%}&amp;quot;, ha=&amp;quot;center&amp;quot;, va=&amp;quot;bottom&amp;quot;, fontsize=13,
fontweight=&amp;quot;bold&amp;quot;, color=NEAR_BLACK)
# Annotate the naive ATE gap between bars
ax.annotate(&amp;quot;&amp;quot;, xy=(1, P_Y1_X1), xytext=(0, P_Y1_X0),
arrowprops=dict(arrowstyle=&amp;quot;&amp;lt;-&amp;gt;&amp;quot;, color=NEAR_BLACK, lw=1.5))
ax.text(0.5, (P_Y1_X1 + P_Y1_X0) / 2, f&amp;quot;Naive ATE = {naive_ate:.2%}&amp;quot;,
ha=&amp;quot;center&amp;quot;, va=&amp;quot;bottom&amp;quot;, fontsize=11, color=NEAR_BLACK,
bbox=dict(boxstyle=&amp;quot;round,pad=0.3&amp;quot;, facecolor=&amp;quot;white&amp;quot;,
edgecolor=NEAR_BLACK, alpha=0.8))
ax.set_ylabel(&amp;quot;P(Got a Job | Treatment)&amp;quot;, fontsize=12)
ax.set_title(&amp;quot;Observed Job Rates by Training Status&amp;quot;, fontsize=14, color=HEADING_BLUE)
ax.set_ylim(0, 0.75)
ax.spines[&amp;quot;top&amp;quot;].set_visible(False)
ax.spines[&amp;quot;right&amp;quot;].set_visible(False)
plt.savefig(&amp;quot;partial_id_observed_probs.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>P(Y=1 | X=1) = 0.6359 (trained workers who got jobs)
P(Y=1 | X=0) = 0.2538 (untrained workers who got jobs)
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="partial_id_observed_probs.png" alt="Observed job rates by training status showing 25.4% for untrained and 63.6% for trained workers.">&lt;/p>
&lt;p>Trained workers find jobs at more than twice the rate of untrained workers: 63.6% versus 25.4%, a gap of 38.2 percentage points. However, this raw comparison confounds the causal effect of training with the influence of prior experience. Because experienced workers are more likely to both enroll in training (70% vs. 30% enrollment rate) and get hired, the treated group is systematically different from the control group. To separate causation from confounding, we need to go beyond this naive comparison.&lt;/p>
&lt;h2 id="baseline----the-naive-estimate">Baseline &amp;ndash; The Naive Estimate&lt;/h2>
&lt;p>The simplest estimate of the causal effect is the &lt;strong>naive difference in means&lt;/strong>: we subtract the job rate of untrained workers from the job rate of trained workers. If there were no confounders, this would equal the true ATE. With confounders, it is biased.&lt;/p>
&lt;pre>&lt;code class="language-python"># True ATE from known DGP (since we simulated the data)
# E[Y(1)] = P(U=0) * P(Y=1|X=1,U=0) + P(U=1) * P(Y=1|X=1,U=1)
# = 0.7 * 0.5 + 0.3 * 0.8 = 0.59
# E[Y(0)] = P(U=0) * P(Y=1|X=0,U=0) + P(U=1) * P(Y=1|X=0,U=1)
# = 0.7 * 0.2 + 0.3 * 0.6 = 0.32
E_Y1_true = 0.7 * 0.5 + 0.3 * 0.8 # = 0.59
E_Y0_true = 0.7 * 0.2 + 0.3 * 0.6 # = 0.32
true_ate = E_Y1_true - E_Y0_true # = 0.27
print(f&amp;quot;Naive ATE (difference in means): {naive_ate:.4f}&amp;quot;)
print(f&amp;quot;True ATE (from known DGP): {true_ate:.4f}&amp;quot;)
print(f&amp;quot;Bias (Naive - True): {naive_ate - true_ate:+.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Naive ATE (difference in means): 0.3822
True ATE (from known DGP): 0.2700
Bias (Naive - True): +0.1122
&lt;/code>&lt;/pre>
&lt;p>The naive estimate of 0.3822 overshoots the true ATE of 0.27 by 11.2 percentage points &amp;ndash; a substantial upward bias. This happens because experienced workers ($U = 1$) are more likely to both enroll in training and find jobs, inflating the apparent benefit of training. Without observing $U$, we have no way to know the magnitude or even the direction of this bias from the data alone. This motivates partial identification &amp;ndash; we can at least bound the true effect.&lt;/p>
&lt;h2 id="manski-bounds">Manski Bounds&lt;/h2>
&lt;h3 id="what-are-manski-bounds">What are Manski bounds?&lt;/h3>
&lt;p>&lt;strong>Manski bounds&lt;/strong> (also called &amp;ldquo;no-assumptions bounds&amp;rdquo;) are the widest possible bounds on the ATE that use only the observed data and no additional assumptions beyond the &lt;strong>law of total probability&lt;/strong> (the rule that the probability of an event equals the sum of its probabilities across all subgroups, weighted by subgroup size). The idea is simple: for the group we do not observe under a given treatment, we consider the worst-case scenario. What if all untreated workers would have gotten jobs if trained? What if none would have?&lt;/p>
&lt;p>Think of Manski bounds like a courtroom verdict based only on eyewitness testimony. The witnesses tell you what they saw &amp;ndash; the outcomes for treated and untreated groups. But for the people not in the courtroom (the counterfactual outcomes we never observe), you assume the worst and the best to bracket the truth.&lt;/p>
&lt;p>Formally, the law of total probability gives us:&lt;/p>
&lt;p>$$E[Y(1)] = E[Y|X=1] \cdot P(X=1) + E[Y(1)|X=0] \cdot P(X=0)$$&lt;/p>
&lt;p>We observe $E[Y|X=1]$ and $P(X=1)$, but $E[Y(1)|X=0]$ &amp;ndash; the average outcome of untrained workers &lt;em>had they been trained&lt;/em> &amp;ndash; is unobservable. Since $Y$ is binary, this unknown quantity lies between 0 and 1. The same logic applies to $E[Y(0)]$. Substituting worst-case and best-case values:&lt;/p>
&lt;p>$$E[Y(1)] \in \big[E[Y|X=1] \cdot P(X=1), \; E[Y|X=1] \cdot P(X=1) + P(X=0)\big]$$&lt;/p>
&lt;p>$$E[Y(0)] \in \big[E[Y|X=0] \cdot P(X=0), \; E[Y|X=0] \cdot P(X=0) + P(X=1)\big]$$&lt;/p>
&lt;p>The ATE bounds are then the lowest possible $E[Y(1)]$ minus the highest possible $E[Y(0)]$ (lower bound) and vice versa (upper bound).&lt;/p>
&lt;h3 id="manual-computation">Manual computation&lt;/h3>
&lt;p>We walk through the Manski bounds computation step by step using the observed probabilities, so the reader can see exactly how each number arises.&lt;/p>
&lt;pre>&lt;code class="language-python">P_X1 = X.mean()
P_X0 = 1 - P_X1
# Bound E[Y(1)]: observed part + worst/best case for unobserved
E_Y1_lower = P_Y1_X1 * P_X1 + 0 * P_X0 # worst case: no untrained would benefit
E_Y1_upper = P_Y1_X1 * P_X1 + 1 * P_X0 # best case: all untrained would benefit
# Bound E[Y(0)]: observed part + worst/best case for unobserved
E_Y0_lower = P_Y1_X0 * P_X0 + 0 * P_X1 # worst case
E_Y0_upper = P_Y1_X0 * P_X0 + 1 * P_X1 # best case
# ATE bounds: min difference vs max difference
ATE_lower = E_Y1_lower - E_Y0_upper
ATE_upper = E_Y1_upper - E_Y0_lower
print(f&amp;quot;Step 1: Observed probabilities&amp;quot;)
print(f&amp;quot; P(Y=1|X=1) = {P_Y1_X1:.4f}&amp;quot;)
print(f&amp;quot; P(Y=1|X=0) = {P_Y1_X0:.4f}&amp;quot;)
print(f&amp;quot; P(X=1) = {P_X1:.4f}, P(X=0) = {P_X0:.4f}&amp;quot;)
print(f&amp;quot;\nStep 2: Bound potential outcome means&amp;quot;)
print(f&amp;quot; E[Y(1)] in [{E_Y1_lower:.4f}, {E_Y1_upper:.4f}]&amp;quot;)
print(f&amp;quot; E[Y(0)] in [{E_Y0_lower:.4f}, {E_Y0_upper:.4f}]&amp;quot;)
print(f&amp;quot;\nStep 3: Compute ATE bounds&amp;quot;)
print(f&amp;quot; ATE_lower = {E_Y1_lower:.4f} - {E_Y0_upper:.4f} = {ATE_lower:.4f}&amp;quot;)
print(f&amp;quot; ATE_upper = {E_Y1_upper:.4f} - {E_Y0_lower:.4f} = {ATE_upper:.4f}&amp;quot;)
print(f&amp;quot;\n Manski Bounds: [{ATE_lower:.4f}, {ATE_upper:.4f}]&amp;quot;)
print(f&amp;quot; Width: {ATE_upper - ATE_lower:.4f}&amp;quot;)
print(f&amp;quot; Contains true ATE ({true_ate})? {ATE_lower &amp;lt;= true_ate &amp;lt;= ATE_upper}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Step 1: Observed probabilities
P(Y=1|X=1) = 0.6359
P(Y=1|X=0) = 0.2538
P(X=1) = 0.4010, P(X=0) = 0.5990
Step 2: Bound potential outcome means
E[Y(1)] in [0.2550, 0.8540]
E[Y(0)] in [0.1520, 0.5530]
Step 3: Compute ATE bounds
ATE_lower = 0.2550 - 0.5530 = -0.2980
ATE_upper = 0.8540 - 0.1520 = 0.7020
Manski Bounds: [-0.2980, 0.7020]
Width: 1.0000
Contains true ATE (0.27)? True
&lt;/code>&lt;/pre>
&lt;p>The Manski bounds place the true ATE between -0.298 and 0.702 &amp;ndash; a width of exactly 1.0. This means we cannot even determine the &lt;em>sign&lt;/em> of the causal effect under no assumptions: the bounds span zero, so the training program might help, hurt, or have no effect at all. While this seems discouraging, these bounds are guaranteed to contain the true effect (0.27) and are &lt;strong>sharp&lt;/strong> (meaning no tighter bounds exist under these assumptions). They establish the baseline that any tighter method must improve upon.&lt;/p>
&lt;h3 id="verification-with-causalboundingengine">Verification with CausalBoundingEngine&lt;/h3>
&lt;p>We use &lt;a href="https://pypi.org/project/causalboundingengine/" target="_blank" rel="noopener">BinaryConf()&lt;/a> to initialize the confounded scenario. This class takes the observed treatment and outcome arrays and provides methods for computing various bounds. The &lt;a href="https://pypi.org/project/causalboundingengine/" target="_blank" rel="noopener">manski()&lt;/a> method computes the classical no-assumptions bounds for the ATE.&lt;/p>
&lt;pre>&lt;code class="language-python"># Initialize the confounded binary scenario
scenario = BinaryConf(X, Y)
# Compute Manski bounds using the package
start_time = time.time()
manski_bounds = scenario.ATE.manski()
manski_time = time.time() - start_time
print(f&amp;quot;Manski Bounds (ATE): [{manski_bounds[0]:.4f}, {manski_bounds[1]:.4f}]&amp;quot;)
print(f&amp;quot;Width: {manski_bounds[1] - manski_bounds[0]:.4f}&amp;quot;)
print(f&amp;quot;Contains true ATE? {manski_bounds[0] &amp;lt;= true_ate &amp;lt;= manski_bounds[1]}&amp;quot;)
print(f&amp;quot;Computation Time: {manski_time:.6f} seconds&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Manski Bounds (ATE): [-0.2980, 0.7020]
Width: 1.0000
Contains true ATE? True
Computation Time: 0.000112 seconds
&lt;/code>&lt;/pre>
&lt;p>The package confirms our manual computation exactly: [-0.2980, 0.7020] with a width of 1.0. The computation takes less than a millisecond because Manski bounds have a closed-form solution &amp;ndash; no optimization is needed. This verification gives us confidence in both our understanding of the math and the package implementation. But can we do better with stronger assumptions? The next section explores methods that trade stronger assumptions for tighter bounds.&lt;/p>
&lt;h2 id="beyond-manski----tighter-bounds">Beyond Manski &amp;ndash; Tighter Bounds&lt;/h2>
&lt;p>Manski bounds assume nothing beyond the data. But additional structural assumptions &amp;ndash; even mild ones &amp;ndash; can dramatically narrow the identified set. CausalBoundingEngine provides several methods that leverage different assumptions to tighten bounds.&lt;/p>
&lt;h3 id="autobound-linear-programming">Autobound (linear programming)&lt;/h3>
&lt;p>The &lt;a href="https://pypi.org/project/causalboundingengine/" target="_blank" rel="noopener">autobound()&lt;/a> method uses &lt;strong>linear programming&lt;/strong> (an optimization technique that finds the best value within constraints defined by linear equations) to compute the tightest possible bounds given the constraints implied by the observed distribution. Think of it as an optimization problem: find the narrowest interval that is consistent with every probability constraint the data imposes.&lt;/p>
&lt;pre>&lt;code class="language-python">start_time = time.time()
autobound_ate = scenario.ATE.autobound()
autobound_time = time.time() - start_time
print(f&amp;quot;Autobound (ATE): [{autobound_ate[0]:.4f}, {autobound_ate[1]:.4f}]&amp;quot;)
print(f&amp;quot;Width: {autobound_ate[1] - autobound_ate[0]:.4f}&amp;quot;)
print(f&amp;quot;Contains true? {autobound_ate[0] &amp;lt;= true_ate &amp;lt;= autobound_ate[1]}&amp;quot;)
print(f&amp;quot;Computation Time: {autobound_time:.6f} seconds&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Autobound (ATE): [-0.2980, 0.7020]
Width: 1.0000
Contains true? True
Computation Time: 0.301253 seconds
&lt;/code>&lt;/pre>
&lt;p>The autobound method returns the same bounds as Manski: [-0.2980, 0.7020]. This is an important result, not a failure. It confirms that the Manski bounds are already sharp &amp;ndash; they are the tightest possible bounds for the ATE in a binary confounded scenario without additional assumptions. No linear programming trick can improve upon them because the worst-case distributions that achieve the extreme bounds are actually valid probability distributions. The autobound takes longer (0.30 seconds vs. instantaneous) because it solves an optimization problem to arrive at the same answer.&lt;/p>
&lt;h3 id="entropy-bounds">Entropy bounds&lt;/h3>
&lt;p>The &lt;a href="https://pypi.org/project/causalboundingengine/" target="_blank" rel="noopener">entropybounds()&lt;/a> method adds an &lt;strong>information-theoretic constraint&lt;/strong>: it limits how much the unmeasured confounder can distort the joint distribution by bounding the entropy of the latent variable. Formally, the constraint requires that the conditional entropy of the unmeasured variable given the observed data is bounded:&lt;/p>
&lt;p>$$H(U | X, Y) \leq \theta$$&lt;/p>
&lt;p>where $H$ denotes Shannon entropy &amp;ndash; a measure of uncertainty, like the unpredictability of a coin flip. A fair coin has maximum entropy because each flip is maximally surprising; a two-headed coin has zero entropy because the outcome is certain. The key parameter &lt;code>theta&lt;/code> caps how &amp;ldquo;surprising&amp;rdquo; the hidden confounder is allowed to be. Smaller values impose stricter constraints and produce tighter bounds: low theta means the confounder can only redistribute probability mass in limited ways.&lt;/p>
&lt;pre>&lt;code class="language-python">start_time = time.time()
entropy_ate = scenario.ATE.entropybounds(theta=0.1)
entropy_time = time.time() - start_time
print(f&amp;quot;Entropy Bounds (ATE, theta=0.1): [{entropy_ate[0]:.4f}, {entropy_ate[1]:.4f}]&amp;quot;)
print(f&amp;quot;Width: {entropy_ate[1] - entropy_ate[0]:.4f}&amp;quot;)
print(f&amp;quot;Contains true? {entropy_ate[0] &amp;lt;= true_ate &amp;lt;= entropy_ate[1]}&amp;quot;)
print(f&amp;quot;Computation Time: {entropy_time:.6f} seconds&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Entropy Bounds (ATE, theta=0.1): [-0.2279, 0.4540]
Width: 0.6819
Contains true? True
Computation Time: 0.027686 seconds
&lt;/code>&lt;/pre>
&lt;p>With &lt;code>theta = 0.1&lt;/code>, the entropy bounds narrow the ATE to [-0.2279, 0.4540] &amp;ndash; a width of 0.68, which is 32% narrower than the Manski bounds. The bounds still cross zero, so we cannot definitively conclude the sign of the effect, but the range is considerably more informative. The entropy constraint says: &amp;ldquo;the unmeasured confounder is allowed to create some distortion, but not unlimited distortion.&amp;rdquo; This is a middle ground between no assumptions (Manski) and full identification (assuming no confounders at all).&lt;/p>
&lt;h2 id="probability-of-necessity-and-sufficiency-pns">Probability of Necessity and Sufficiency (PNS)&lt;/h2>
&lt;p>Beyond the ATE, partial identification can address a deeper causal question: for how many workers did training &lt;strong>both&lt;/strong> cause them to get a job &lt;strong>and&lt;/strong> was essential for getting that job? This is the &lt;strong>Probability of Necessity and Sufficiency (PNS)&lt;/strong>.&lt;/p>
&lt;p>$$\text{PNS} = P(Y_{X=1} = 1 \, \cap \, Y_{X=0} = 0)$$&lt;/p>
&lt;p>In words, PNS is the probability that a worker would get a job if trained ($Y_{X=1} = 1$) and would &lt;em>not&lt;/em> get a job if untrained ($Y_{X=0} = 0$). Unlike the ATE, which averages over the population, the PNS captures individual-level causation. It matters for legal and medical decisions: a court might ask whether a specific intervention was &lt;em>necessary&lt;/em> for the outcome, not just whether it helps on average.&lt;/p>
&lt;p>We compute PNS bounds using &lt;a href="https://pypi.org/project/causalboundingengine/" target="_blank" rel="noopener">tianpearl()&lt;/a>, which implements the sharp closed-form bounds from Tian and Pearl (2000). These bounds use observational data to constrain the three probabilities of causation: necessity (PN), sufficiency (PS), and their conjunction (PNS).&lt;/p>
&lt;pre>&lt;code class="language-python"># Tian-Pearl bounds for PNS
start_time = time.time()
tianpearl_pns = scenario.PNS.tianpearl()
tianpearl_time = time.time() - start_time
print(f&amp;quot;Tian-Pearl Bounds (PNS): [{tianpearl_pns[0]:.4f}, {tianpearl_pns[1]:.4f}]&amp;quot;)
print(f&amp;quot;Width: {tianpearl_pns[1] - tianpearl_pns[0]:.4f}&amp;quot;)
print(f&amp;quot;Computation Time: {tianpearl_time:.6f} seconds&amp;quot;)
# Compare with autobound and entropy
autobound_pns = scenario.PNS.autobound()
entropy_pns = scenario.PNS.entropybounds(theta=0.1)
print(f&amp;quot;\nAutobound (PNS): [{autobound_pns[0]:.4f}, {autobound_pns[1]:.4f}]&amp;quot;)
print(f&amp;quot;Width: {autobound_pns[1] - autobound_pns[0]:.4f}&amp;quot;)
print(f&amp;quot;\nEntropy Bounds (PNS): [{entropy_pns[0]:.4f}, {entropy_pns[1]:.4f}]&amp;quot;)
print(f&amp;quot;Width: {entropy_pns[1] - entropy_pns[0]:.4f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Tian-Pearl Bounds (PNS): [0.0000, 0.7020]
Width: 0.7020
Computation Time: 0.000057 seconds
Autobound (PNS): [-0.0000, 0.7020]
Width: 0.7020
Entropy Bounds (PNS): [0.0000, 0.8394]
Width: 0.8394
&lt;/code>&lt;/pre>
&lt;p>The Tian-Pearl bounds place the PNS between 0.00 and 0.702. The lower bound of zero means we cannot rule out the possibility that training is &lt;em>never&lt;/em> individually necessary and sufficient. Some workers might always get jobs regardless, others might never get jobs regardless, and the observed difference could arise from group-level patterns rather than individual-level causation. The upper bound of 0.702 means at most 70.2% of workers experienced training as both necessary and sufficient for employment. The autobound confirms these are already sharp (0.7020 width). Interestingly, the entropy bounds are &lt;em>wider&lt;/em> for PNS (0.8394) than Tian-Pearl &amp;ndash; the entropy constraint is less effective for &lt;strong>counterfactual&lt;/strong> queries (questions about what &lt;em>would have happened&lt;/em> under a different treatment) than for the ATE.&lt;/p>
&lt;h2 id="comparing-all-bounds">Comparing All Bounds&lt;/h2>
&lt;h3 id="when-does-it-help-to-identify-or-decide">When does it help to identify or decide?&lt;/h3>
&lt;p>A decision-maker can draw conclusions from partial identification in two ways. If the entire bound interval is positive, we conclude the treatment helps (on average) even without observing the confounder. If the interval spans zero, we cannot determine the sign &amp;ndash; honesty about this uncertainty is a strength, not a weakness.&lt;/p>
&lt;p>The following flowchart summarizes when to use partial identification versus point identification methods:&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
Q[&amp;quot;Are all confounders&amp;lt;br/&amp;gt;observed?&amp;quot;] --&amp;gt;|&amp;quot;Yes&amp;quot;| PI[&amp;quot;&amp;lt;b&amp;gt;Point Identification&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;DoWhy, DoubleML&amp;quot;]
Q --&amp;gt;|&amp;quot;No&amp;quot;| IV[&amp;quot;Is there an&amp;lt;br/&amp;gt;instrument?&amp;quot;]
IV --&amp;gt;|&amp;quot;Yes&amp;quot;| IVPI[&amp;quot;&amp;lt;b&amp;gt;Point Identification&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;via Instrumental Variables&amp;lt;br/&amp;gt;(IV / 2SLS)&amp;quot;]
IV --&amp;gt;|&amp;quot;No&amp;quot;| PART[&amp;quot;&amp;lt;b&amp;gt;Partial Identification&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Compute bounds&amp;quot;]
style Q fill:#141413,stroke:#141413,color:#fff
style PI fill:#6a9bcc,stroke:#141413,color:#fff
style IV fill:#141413,stroke:#141413,color:#fff
style IVPI fill:#6a9bcc,stroke:#141413,color:#fff
style PART fill:#d97757,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;h3 id="ate-bounds-comparison">ATE bounds comparison&lt;/h3>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(9, 5))
methods_ate = [
(&amp;quot;Entropy (theta=0.1)&amp;quot;, entropy_ate, TEAL),
(&amp;quot;Autobound (LP)&amp;quot;, autobound_ate, WARM_ORANGE),
(&amp;quot;Manski (No Assumptions)&amp;quot;, manski_bounds, STEEL_BLUE),
]
for i, (label, bounds, color) in enumerate(methods_ate):
width = bounds[1] - bounds[0]
ax.barh(i, width, left=bounds[0], height=0.5, color=color,
edgecolor=NEAR_BLACK, linewidth=0.8, alpha=0.85)
ax.text(bounds[1] + 0.01, i, f&amp;quot;[{bounds[0]:.3f}, {bounds[1]:.3f}]&amp;quot;,
va=&amp;quot;center&amp;quot;, fontsize=9, color=NEAR_BLACK)
ax.axvline(x=true_ate, color=NEAR_BLACK, linestyle=&amp;quot;--&amp;quot;, linewidth=2,
label=f&amp;quot;True ATE = {true_ate:.2f}&amp;quot;)
ax.axvline(x=naive_ate, color=&amp;quot;#999999&amp;quot;, linestyle=&amp;quot;:&amp;quot;, linewidth=1.5,
label=f&amp;quot;Naive estimate = {naive_ate:.4f}&amp;quot;)
ax.set_yticks([0, 1, 2])
ax.set_yticklabels([&amp;quot;Entropy (theta=0.1)&amp;quot;, &amp;quot;Autobound (LP)&amp;quot;,
&amp;quot;Manski (No Assumptions)&amp;quot;], fontsize=11)
ax.set_xlabel(&amp;quot;Average Treatment Effect (ATE)&amp;quot;, fontsize=12)
ax.set_title(&amp;quot;Comparing Causal Bounds on the ATE&amp;quot;, fontsize=14, color=HEADING_BLUE)
ax.legend(loc=&amp;quot;upper center&amp;quot;, bbox_to_anchor=(0.5, -0.12), fontsize=10, ncol=2)
ax.spines[&amp;quot;top&amp;quot;].set_visible(False)
ax.spines[&amp;quot;right&amp;quot;].set_visible(False)
plt.savefig(&amp;quot;partial_id_bounds_comparison.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="partial_id_bounds_comparison.png" alt="Horizontal interval chart comparing Manski, Autobound, and Entropy bounds on the ATE, with the true ATE marked as a dashed vertical line.">&lt;/p>
&lt;p>All three methods contain the true ATE (0.27), but they differ in width. Manski and Autobound both produce identical bounds of [-0.298, 0.702] with width 1.0, confirming the Manski bounds are already sharp. The entropy bounds with theta = 0.1 narrow the interval to [-0.228, 0.454] (width 0.68), a 32% improvement. The naive estimate (0.3822, gray dotted line) lies noticeably to the right of the true ATE (0.27, black dashed line), illustrating the upward bias caused by confounding &amp;ndash; experienced workers disproportionately enroll in training and find jobs.&lt;/p>
&lt;h3 id="summary-table">Summary table&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Estimand&lt;/th>
&lt;th>Lower&lt;/th>
&lt;th>Upper&lt;/th>
&lt;th>Width&lt;/th>
&lt;th>Contains True ATE?&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Manski&lt;/td>
&lt;td>ATE&lt;/td>
&lt;td>-0.2980&lt;/td>
&lt;td>0.7020&lt;/td>
&lt;td>1.0000&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Autobound (LP)&lt;/td>
&lt;td>ATE&lt;/td>
&lt;td>-0.2980&lt;/td>
&lt;td>0.7020&lt;/td>
&lt;td>1.0000&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Entropy (theta = 0.1)&lt;/td>
&lt;td>ATE&lt;/td>
&lt;td>-0.2279&lt;/td>
&lt;td>0.4540&lt;/td>
&lt;td>0.6819&lt;/td>
&lt;td>Yes&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Tian-Pearl&lt;/td>
&lt;td>PNS&lt;/td>
&lt;td>0.0000&lt;/td>
&lt;td>0.7020&lt;/td>
&lt;td>0.7020&lt;/td>
&lt;td>&amp;ndash;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Autobound (LP)&lt;/td>
&lt;td>PNS&lt;/td>
&lt;td>0.0000&lt;/td>
&lt;td>0.7020&lt;/td>
&lt;td>0.7020&lt;/td>
&lt;td>&amp;ndash;&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Entropy (theta = 0.1)&lt;/td>
&lt;td>PNS&lt;/td>
&lt;td>0.0000&lt;/td>
&lt;td>0.8394&lt;/td>
&lt;td>0.8394&lt;/td>
&lt;td>&amp;ndash;&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="pns-bounds-comparison">PNS bounds comparison&lt;/h3>
&lt;p>We now visualize the PNS bounds from all three methods side by side, just as we did for the ATE above. This comparison reveals which bounding approach is most effective for counterfactual queries about individual-level causation.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(9, 4.5))
methods_pns = [
(&amp;quot;Entropy (theta=0.1)&amp;quot;, entropy_pns, TEAL),
(&amp;quot;Autobound (LP)&amp;quot;, autobound_pns, WARM_ORANGE),
(&amp;quot;Tian-Pearl (Closed Form)&amp;quot;, tianpearl_pns, STEEL_BLUE),
]
for i, (label, bounds, color) in enumerate(methods_pns):
width = bounds[1] - bounds[0]
ax.barh(i, width, left=bounds[0], height=0.5, color=color,
edgecolor=NEAR_BLACK, linewidth=0.8, alpha=0.85)
ax.text(bounds[1] + 0.01, i, f&amp;quot;[{bounds[0]:.3f}, {bounds[1]:.3f}]&amp;quot;,
va=&amp;quot;center&amp;quot;, fontsize=9, color=NEAR_BLACK)
ax.set_yticks([0, 1, 2])
ax.set_yticklabels([&amp;quot;Entropy (theta=0.1)&amp;quot;, &amp;quot;Autobound (LP)&amp;quot;,
&amp;quot;Tian-Pearl (Closed Form)&amp;quot;], fontsize=11)
ax.set_xlabel(&amp;quot;Probability of Necessity &amp;amp; Sufficiency (PNS)&amp;quot;, fontsize=12)
ax.set_title(&amp;quot;Comparing Causal Bounds on the PNS&amp;quot;, fontsize=14, color=HEADING_BLUE)
ax.spines[&amp;quot;top&amp;quot;].set_visible(False)
ax.spines[&amp;quot;right&amp;quot;].set_visible(False)
plt.savefig(&amp;quot;partial_id_pns_bounds.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="partial_id_pns_bounds.png" alt="Horizontal interval chart comparing Tian-Pearl, Autobound, and Entropy bounds on the PNS.">&lt;/p>
&lt;p>For the PNS, the Tian-Pearl and Autobound methods produce identical sharp bounds of [0.000, 0.702]. The entropy method yields wider bounds [0.000, 0.839] &amp;ndash; the entropy constraint is less effective here because PNS is a counterfactual quantity that depends on the joint distribution of potential outcomes, which is harder to constrain with information-theoretic tools. All methods agree that the lower bound is zero, meaning we cannot rule out that training is never individually necessary and sufficient.&lt;/p>
&lt;h2 id="validation----coverage-simulation">Validation &amp;ndash; Coverage Simulation&lt;/h2>
&lt;p>A critical property of valid bounds is &lt;strong>coverage&lt;/strong>: they must contain the true parameter value. Since we control the data-generating process, we can verify this by repeating the simulation 100 times with different random seeds and checking whether each set of bounds contains the true ATE of 0.27.&lt;/p>
&lt;pre>&lt;code class="language-python">n_sims = 100
coverage = {&amp;quot;Manski&amp;quot;: 0, &amp;quot;Autobound&amp;quot;: 0, &amp;quot;Entropy&amp;quot;: 0}
for sim in range(n_sims):
np.random.seed(sim)
U_s = np.random.binomial(1, 0.3, N)
X_prob_s = 0.3 + 0.4 * U_s
X_s = np.random.binomial(1, X_prob_s, N)
Y_prob_s = np.clip(0.2 + 0.3 * X_s + 0.4 * U_s - 0.1 * X_s * U_s, 0, 1)
Y_s = np.random.binomial(1, Y_prob_s)
sc = BinaryConf(X_s, Y_s)
m = sc.ATE.manski()
a = sc.ATE.autobound()
e = sc.ATE.entropybounds(theta=0.1)
if m[0] &amp;lt;= true_ate &amp;lt;= m[1]: coverage[&amp;quot;Manski&amp;quot;] += 1
if a[0] &amp;lt;= true_ate &amp;lt;= a[1]: coverage[&amp;quot;Autobound&amp;quot;] += 1
if e[0] &amp;lt;= true_ate &amp;lt;= e[1]: coverage[&amp;quot;Entropy&amp;quot;] += 1
for method, count in coverage.items():
print(f&amp;quot; {method} coverage: {count}/{n_sims} ({count/n_sims:.0%})&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code> Manski coverage: 100/100 (100%)
Autobound coverage: 100/100 (100%)
Entropy coverage: 100/100 (100%)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(7, 4.5))
methods = [&amp;quot;Manski&amp;quot;, &amp;quot;Autobound&amp;quot;, &amp;quot;Entropy\n(\u03b8 = 0.1)&amp;quot;]
coverages = [coverage[k] / n_sims * 100 for k in coverage]
colors = [STEEL_BLUE, WARM_ORANGE, TEAL]
bars = ax.bar(methods, coverages, color=colors, width=0.5,
edgecolor=NEAR_BLACK, linewidth=0.8)
for bar, cov in zip(bars, coverages):
ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + 0.5,
f&amp;quot;{cov:.0f}%&amp;quot;, ha=&amp;quot;center&amp;quot;, va=&amp;quot;bottom&amp;quot;, fontsize=13,
fontweight=&amp;quot;bold&amp;quot;, color=NEAR_BLACK)
ax.axhline(y=100, color=NEAR_BLACK, linestyle=&amp;quot;--&amp;quot;, linewidth=1, alpha=0.5)
ax.set_ylabel(&amp;quot;Coverage Rate (%)&amp;quot;, fontsize=12)
ax.set_title(&amp;quot;Do Bounds Contain the True ATE?\n(100 Simulations)&amp;quot;, fontsize=14, color=HEADING_BLUE)
ax.set_ylim(0, 110)
ax.spines[&amp;quot;top&amp;quot;].set_visible(False)
ax.spines[&amp;quot;right&amp;quot;].set_visible(False)
plt.savefig(&amp;quot;partial_id_coverage.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="partial_id_coverage.png" alt="Bar chart showing 100% coverage rates for all three bounding methods across 100 simulations.">&lt;/p>
&lt;p>All three methods achieve 100% coverage across 100 simulations &amp;ndash; the true ATE of 0.27 falls within the computed bounds in every single draw. This is not surprising for Manski and Autobound, which make no assumptions beyond the data. For entropy bounds, the 100% coverage suggests that &lt;code>theta = 0.1&lt;/code> is a conservative enough constraint that it does not exclude the true value. In practice, choosing theta requires domain knowledge: too small and the bounds may not cover the truth; too large and the bounds approach the uninformative Manski width.&lt;/p>
&lt;h2 id="sensitivity----how-sample-size-affects-bounds">Sensitivity &amp;ndash; How Sample Size Affects Bounds&lt;/h2>
&lt;p>A common misconception is that collecting more data will narrow partial identification bounds. This is generally &lt;strong>not true&lt;/strong>. Unlike confidence intervals, which shrink with more observations, identification bounds reflect fundamental uncertainty about what we do not observe &amp;ndash; the unmeasured confounder. More data gives us more precise estimates of the observed probabilities, but does not reduce the range of possible confounding.&lt;/p>
&lt;pre>&lt;code class="language-python">sample_sizes = [100, 250, 500, 1000, 2500, 5000]
n_reps = 30
manski_widths = {n: [] for n in sample_sizes}
entropy_widths = {n: [] for n in sample_sizes}
for n in sample_sizes:
for rep in range(n_reps):
np.random.seed(rep + 1000)
U_s = np.random.binomial(1, 0.3, n)
X_prob_s = 0.3 + 0.4 * U_s
X_s = np.random.binomial(1, X_prob_s, n)
Y_prob_s = np.clip(0.2 + 0.3 * X_s + 0.4 * U_s - 0.1 * X_s * U_s, 0, 1)
Y_s = np.random.binomial(1, Y_prob_s)
sc = BinaryConf(X_s, Y_s)
m = sc.ATE.manski()
e = sc.ATE.entropybounds(theta=0.1)
manski_widths[n].append(m[1] - m[0])
entropy_widths[n].append(e[1] - e[0])
for n in sample_sizes:
print(f&amp;quot;N={n:&amp;gt;5}: Manski width = {np.mean(manski_widths[n]):.4f} &amp;quot;
f&amp;quot;(+/- {np.std(manski_widths[n]):.4f}), &amp;quot;
f&amp;quot;Entropy width = {np.mean(entropy_widths[n]):.4f} &amp;quot;
f&amp;quot;(+/- {np.std(entropy_widths[n]):.4f})&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>N= 100: Manski width = 1.0000 (+/- 0.0000), Entropy width = 0.6733 (+/- 0.0139)
N= 250: Manski width = 1.0000 (+/- 0.0000), Entropy width = 0.6733 (+/- 0.0100)
N= 500: Manski width = 1.0000 (+/- 0.0000), Entropy width = 0.6753 (+/- 0.0084)
N= 1000: Manski width = 1.0000 (+/- 0.0000), Entropy width = 0.6772 (+/- 0.0055)
N= 2500: Manski width = 1.0000 (+/- 0.0000), Entropy width = 0.6751 (+/- 0.0032)
N= 5000: Manski width = 1.0000 (+/- 0.0000), Entropy width = 0.6753 (+/- 0.0027)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(8, 5))
manski_means = [np.mean(manski_widths[n]) for n in sample_sizes]
manski_stds = [np.std(manski_widths[n]) for n in sample_sizes]
entropy_means = [np.mean(entropy_widths[n]) for n in sample_sizes]
entropy_stds = [np.std(entropy_widths[n]) for n in sample_sizes]
ax.plot(sample_sizes, manski_means, &amp;quot;o-&amp;quot;, color=STEEL_BLUE, linewidth=2,
markersize=7, label=&amp;quot;Manski Bounds&amp;quot;, zorder=3)
ax.fill_between(sample_sizes,
[m - s for m, s in zip(manski_means, manski_stds)],
[m + s for m, s in zip(manski_means, manski_stds)],
color=STEEL_BLUE, alpha=0.15)
ax.plot(sample_sizes, entropy_means, &amp;quot;s-&amp;quot;, color=TEAL, linewidth=2,
markersize=7, label=&amp;quot;Entropy Bounds (\u03b8 = 0.1)&amp;quot;, zorder=3)
ax.fill_between(sample_sizes,
[m - s for m, s in zip(entropy_means, entropy_stds)],
[m + s for m, s in zip(entropy_means, entropy_stds)],
color=TEAL, alpha=0.15)
ax.set_xlabel(&amp;quot;Sample Size (N)&amp;quot;, fontsize=12)
ax.set_ylabel(&amp;quot;Bound Width (Upper - Lower)&amp;quot;, fontsize=12)
ax.set_title(&amp;quot;Bound Width vs. Sample Size&amp;quot;, fontsize=14, color=HEADING_BLUE)
ax.legend(loc=&amp;quot;center right&amp;quot;, fontsize=11)
ax.spines[&amp;quot;top&amp;quot;].set_visible(False)
ax.spines[&amp;quot;right&amp;quot;].set_visible(False)
ax.set_xscale(&amp;quot;log&amp;quot;)
ax.set_xticks(sample_sizes)
ax.set_xticklabels([str(n) for n in sample_sizes])
plt.savefig(&amp;quot;partial_id_sample_size.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="partial_id_sample_size.png" alt="Line plot showing Manski bound width stays constant at 1.0 across sample sizes, while Entropy bound width stays near 0.68 with decreasing variance.">&lt;/p>
&lt;p>The Manski bound width remains exactly 1.0 regardless of sample size &amp;ndash; from N = 100 to N = 5,000, the width does not budge. This is because Manski bounds are &lt;strong>identification bounds&lt;/strong>, not statistical estimates: they reflect what we fundamentally cannot learn without observing the confounder, not sampling noise. The entropy bounds similarly stabilize around 0.68 across all sample sizes, with only their variance decreasing (from +/-0.014 at N = 100 to +/-0.003 at N = 5,000). The practical implication is clear: to narrow these bounds, you need stronger assumptions or additional data &lt;em>about the confounder&lt;/em> &amp;ndash; not just more observations of the same variables.&lt;/p>
&lt;h2 id="discussion">Discussion&lt;/h2>
&lt;p>We began by asking whether a job training program helps workers find jobs when a key confounder &amp;ndash; prior work experience &amp;ndash; is unmeasured. The naive difference in means (0.3822) suggests training increases job probability by about 38 percentage points, but this estimate is upward biased by 11.2 percentage points because experienced workers disproportionately enroll in training.&lt;/p>
&lt;p>Partial identification provides an honest answer. The Manski bounds place the true ATE between -0.298 and 0.702: training might reduce job probability by as much as 30 percentage points or increase it by as much as 70 percentage points. This interval is wide enough to span zero, so we cannot conclude even the direction of the effect under minimal assumptions. The entropy bounds (theta = 0.1) narrow this to [-0.228, 0.454], a 32% reduction, but still include zero.&lt;/p>
&lt;p>&lt;strong>So what does this mean for a policymaker?&lt;/strong> If you are deciding whether to fund the training program, the Manski bounds alone are not informative enough. You need either additional data (an instrument, panel data, or direct measurement of the confounder) or stronger assumptions to narrow the bounds. However, the bounds are valuable for ruling out extreme claims: the ATE cannot exceed 0.702, so any claim of a 75-percentage-point benefit is inconsistent with the data. Partial identification does not give you the answer, but it tells you honestly what the data can and cannot say.&lt;/p>
&lt;p>This framework complements the point-identification methods covered in previous tutorials. &lt;a href="https://carlos-mendez.org/post/python_doubleml/">Double Machine Learning&lt;/a> and &lt;a href="https://carlos-mendez.org/post/python_dowhy/">DoWhy&lt;/a> assume all confounders are observed and produce precise estimates. Partial identification drops that assumption and produces bounds instead. The choice depends on whether the &amp;ldquo;no unmeasured confounders&amp;rdquo; assumption is credible in your application.&lt;/p>
&lt;h2 id="summary-and-next-steps">Summary and Next Steps&lt;/h2>
&lt;p>&lt;strong>Key takeaways:&lt;/strong>&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Method insight:&lt;/strong> Manski bounds require only observational data and the law of total probability &amp;ndash; no parametric assumptions, no exclusion restrictions. The price is width: a full 1.0 on the probability scale for binary outcomes. These bounds are already sharp (autobound confirms this), establishing the fundamental limit of what data alone can tell us.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Data insight:&lt;/strong> The naive estimate (0.3822) overshoots the true ATE (0.27) by 11.2 percentage points because experienced workers disproportionately enroll in training. This upward bias illustrates why raw comparisons are misleading in observational studies. The Manski bounds honestly bracket this uncertainty by admitting the effect could range from -0.298 to 0.702.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Limitation:&lt;/strong> When bounds span zero &amp;ndash; as ours do for both Manski and entropy methods &amp;ndash; we cannot determine even the sign of the treatment effect. This is an honest result, not a failure. It means the data, without additional structure, genuinely cannot distinguish a helpful program from a harmful one.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Next step:&lt;/strong> To narrow bounds, add structural information. An instrumental variable (use &lt;code>BinaryIV&lt;/code> scenario in CausalBoundingEngine) can dramatically tighten bounds. Monotonicity assumptions (treatment can only help, never hurt) halve the Manski width. Alternatively, sensitivity analysis methods like Cinelli and Hazlett&amp;rsquo;s partial $R^2$ approach let you ask: &amp;ldquo;How strong would the confounder need to be to explain away the observed effect?&amp;rdquo;&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>&lt;strong>Limitations:&lt;/strong> This tutorial uses binary variables only &amp;ndash; real applications often involve continuous outcomes and treatments, which require different bounding approaches. The simulated data lets us verify coverage but does not capture the messy complexities of real observational studies. The entropy bounds require choosing a theta parameter, and we have not provided guidance on calibrating this choice from domain knowledge.&lt;/p>
&lt;h2 id="exercises">Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Increase confounder strength:&lt;/strong> Change the confounder&amp;rsquo;s effect on the outcome from 0.4 to 0.8 in the data-generating process. How do the Manski bounds change? Does the naive estimate become more biased?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Add an instrumental variable:&lt;/strong> Create a variable $Z$ that affects $X$ but not $Y$ directly (e.g., $Z$ is a randomly mailed training invitation). Use the &lt;code>BinaryIV&lt;/code> scenario in CausalBoundingEngine. Do the IV-based bounds tighten compared to Manski?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Real-world application:&lt;/strong> Find a published observational study in your field (economics, epidemiology, or social science). Identify what the unmeasured confounders might be. How wide would the Manski bounds be given the observed treatment and outcome rates? Would the study&amp;rsquo;s conclusions survive under partial identification?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://www.jstor.org/stable/2006592" target="_blank" rel="noopener">Manski, C. F. (1990). Nonparametric Bounds on Treatment Effects. American Economic Review Papers and Proceedings, 80(2), 319-323.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1023/A:1018912507879" target="_blank" rel="noopener">Tian, J. &amp;amp; Pearl, J. (2000). Probabilities of Causation: Bounds and Identification. Annals of Mathematics and Artificial Intelligence, 28, 287-313.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2508.13607" target="_blank" rel="noopener">Maringgele, T. (2025). Bounding Causal Effects and Counterfactuals. arXiv:2508.13607.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://pypi.org/project/causalboundingengine/" target="_blank" rel="noopener">CausalBoundingEngine &amp;ndash; Python Package for Causal Bounding Methods.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://theeffectbook.net/ch-PartialIdentification.html" target="_blank" rel="noopener">Huntington-Klein, N. (2021). The Effect: An Introduction to Research Design and Causality, Chapter 21: Partial Identification.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1007/b97478" target="_blank" rel="noopener">Manski, C. F. (2003). Partial Identification of Probability Distributions. Springer.&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>Introduction to Causal Inference: The DoWhy Approach with the Lalonde Dataset</title><link>https://carlos-mendez.org/post/python_dowhy/</link><pubDate>Thu, 12 Mar 2026 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_dowhy/</guid><description>&lt;div style="background:#0e1545; border-radius:12px; padding:8px;">
&lt;iframe style="border-radius:8px" src="https://open.spotify.com/embed/episode/7h6S9YzEroATdQabvJxi1W?utm_source=generator&amp;theme=0" width="100%" height="152" frameBorder="0" allowfullscreen="" allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture" loading="lazy">&lt;/iframe>
&lt;/div>
&lt;h2 id="overview">Overview&lt;/h2>
&lt;p>Does a job training program actually cause participants to earn more, or do people who enroll in training simply differ from those who do not? This is the central challenge of &lt;strong>causal inference&lt;/strong>: distinguishing genuine treatment effects from confounding differences between groups. A simple comparison of average earnings between participants and non-participants can be misleading if the two groups differ in age, education, or prior employment history.&lt;/p>
&lt;p>&lt;strong>&lt;a href="https://www.pywhy.org/dowhy/" target="_blank" rel="noopener">DoWhy&lt;/a>&lt;/strong> is a Python library that provides a principled, end-to-end framework for causal inference. It organizes the analysis into four explicit steps &amp;mdash; &lt;strong>Model, Identify, Estimate, Refute&lt;/strong> &amp;mdash; each of which forces the analyst to state and test causal assumptions rather than hiding them inside a black-box estimator. In this tutorial, we apply DoWhy to the &lt;strong>&lt;a href="https://www.jstor.org/stable/1806062" target="_blank" rel="noopener">Lalonde dataset&lt;/a>&lt;/strong>, a classic dataset from the National Supported Work (NSW) Demonstration program, to estimate how much the job training program increased participants' earnings in 1978.&lt;/p>
&lt;p>&lt;strong>Learning objectives:&lt;/strong>&lt;/p>
&lt;ul>
&lt;li>Understand DoWhy&amp;rsquo;s four-step causal inference workflow (Model, Identify, Estimate, Refute)&lt;/li>
&lt;li>Define a causal graph that encodes domain knowledge about confounders&lt;/li>
&lt;li>Identify causal estimands from the graph using the backdoor criterion&lt;/li>
&lt;li>Estimate causal effects using multiple methods (regression adjustment, IPW, doubly robust, propensity score stratification, propensity score matching)&lt;/li>
&lt;li>Assess robustness of estimates using refutation tests&lt;/li>
&lt;/ul>
&lt;h2 id="dowhys-four-step-framework">DoWhy&amp;rsquo;s four-step framework&lt;/h2>
&lt;p>Most statistical software lets you jump straight from data to estimates, skipping the hard work of stating assumptions and testing whether the results are trustworthy. DoWhy takes a different approach: it organizes every causal analysis into four explicit steps, each answering a distinct question.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
A[&amp;quot;&amp;lt;b&amp;gt;1. Model&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Define causal&amp;lt;br/&amp;gt;assumptions&amp;quot;] --&amp;gt; B[&amp;quot;&amp;lt;b&amp;gt;2. Identify&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Find the right&amp;lt;br/&amp;gt;formula&amp;quot;]
B --&amp;gt; C[&amp;quot;&amp;lt;b&amp;gt;3. Estimate&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Compute the&amp;lt;br/&amp;gt;causal effect&amp;quot;]
C --&amp;gt; D[&amp;quot;&amp;lt;b&amp;gt;4. Refute&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Stress-test&amp;lt;br/&amp;gt;the result&amp;quot;]
style A fill:#6a9bcc,stroke:#141413,color:#fff
style B fill:#d97757,stroke:#141413,color:#fff
style C fill:#00d4c8,stroke:#141413,color:#fff
style D fill:#8b5cf6,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>Each step answers a specific question and builds on the previous one:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Model&lt;/strong> &amp;mdash; &lt;em>&amp;ldquo;What are the causal relationships?&amp;quot;&lt;/em> Encode your domain knowledge as a causal graph (a DAG). This is where you declare which variables cause which, making your assumptions explicit and debatable rather than hidden inside a regression.&lt;/li>
&lt;li>&lt;strong>Identify&lt;/strong> &amp;mdash; &lt;em>&amp;ldquo;Can we estimate the effect from data?&amp;quot;&lt;/em> Given the graph, DoWhy uses graph theory to determine whether the causal effect is identifiable &amp;mdash; meaning it can be computed from observed data alone &amp;mdash; and returns the mathematical formula (the &lt;em>estimand&lt;/em>) needed to do so.&lt;/li>
&lt;li>&lt;strong>Estimate&lt;/strong> &amp;mdash; &lt;em>&amp;ldquo;What is the causal effect?&amp;quot;&lt;/em> Apply one or more statistical methods to compute the actual numeric estimate. DoWhy supports multiple estimators so you can check whether different methods agree.&lt;/li>
&lt;li>&lt;strong>Refute&lt;/strong> &amp;mdash; &lt;em>&amp;ldquo;Should we trust the estimate?&amp;quot;&lt;/em> Run automated falsification tests that probe whether the result could be a statistical artifact, whether it is sensitive to unobserved confounders, and whether it is stable across subsamples.&lt;/li>
&lt;/ul>
&lt;p>The ordering is deliberate. You cannot estimate a causal effect without first identifying the correct formula, and you cannot identify the formula without first specifying your causal assumptions. This sequential discipline is DoWhy&amp;rsquo;s key contribution: it prevents the common mistake of running a regression and calling the coefficient &amp;ldquo;causal&amp;rdquo; without ever checking whether the adjustment set is correct or whether the result survives basic robustness checks.&lt;/p>
&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 dowhy # https://pypi.org/project/dowhy/
&lt;/code>&lt;/pre>
&lt;p>The following code imports all necessary libraries and sets configuration variables. We define the outcome, treatment, and covariate columns that will be used throughout the analysis.&lt;/p>
&lt;pre>&lt;code class="language-python">import warnings
warnings.filterwarnings(&amp;quot;ignore&amp;quot;)
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.linear_model import LogisticRegression, LinearRegression as SklearnLR
from dowhy import CausalModel
from dowhy.datasets import lalonde_dataset
# Reproducibility
RANDOM_SEED = 42
np.random.seed(RANDOM_SEED)
# Configuration
OUTCOME = &amp;quot;re78&amp;quot;
OUTCOME_LABEL = &amp;quot;Earnings in 1978 (USD)&amp;quot;
TREATMENT = &amp;quot;treat&amp;quot;
TREATMENT_LABEL = &amp;quot;Job Training (treat)&amp;quot;
COVARIATES = [&amp;quot;age&amp;quot;, &amp;quot;educ&amp;quot;, &amp;quot;black&amp;quot;, &amp;quot;hisp&amp;quot;, &amp;quot;married&amp;quot;, &amp;quot;nodegr&amp;quot;, &amp;quot;re74&amp;quot;, &amp;quot;re75&amp;quot;]
&lt;/code>&lt;/pre>
&lt;h2 id="data-loading-the-lalonde-dataset">Data loading: The Lalonde Dataset&lt;/h2>
&lt;p>The Lalonde dataset comes from the &lt;strong>National Supported Work (NSW) Demonstration&lt;/strong>, a randomized employment program conducted in the 1970s in the United States. Eligible applicants &amp;mdash; mostly disadvantaged workers with limited employment histories &amp;mdash; were randomly assigned to receive job training (treatment) or not (control). The dataset records each participant&amp;rsquo;s demographics, prior earnings, and post-program earnings in 1978. It has become a benchmark for testing causal inference methods because the random assignment provides a credible ground truth against which observational estimators can be compared.&lt;/p>
&lt;p>DoWhy includes the Lalonde dataset directly, so we can load it with the &lt;a href="https://www.pywhy.org/dowhy/v0.14/example_notebooks/lalonde_pandas_api.html" target="_blank" rel="noopener">&lt;code>lalonde_dataset()&lt;/code>&lt;/a> function.&lt;/p>
&lt;pre>&lt;code class="language-python">df = lalonde_dataset()
# Convert boolean treatment to integer for DoWhy compatibility
df[TREATMENT] = df[TREATMENT].astype(int)
print(f&amp;quot;Dataset shape: {df.shape}&amp;quot;)
print(f&amp;quot;\nTreatment groups:&amp;quot;)
print(df[TREATMENT].value_counts().sort_index().rename({0: &amp;quot;Control&amp;quot;, 1: &amp;quot;Training&amp;quot;}))
print(f&amp;quot;\nOutcome ({OUTCOME}) summary:&amp;quot;)
print(df[OUTCOME].describe().round(2))
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Dataset shape: (445, 12)
Treatment groups:
treat
Control 260
Training 185
Name: count, dtype: int64
Outcome (re78) summary:
count 445.00
mean 5300.76
std 6631.49
min 0.00
25% 0.00
50% 3701.81
75% 8124.72
max 60307.93
Name: re78, dtype: float64
&lt;/code>&lt;/pre>
&lt;p>The dataset contains 445 participants with 12 variables. The treatment is split into 185 individuals who received job training and 260 controls who did not. The outcome variable, real earnings in 1978 (&lt;code>re78&lt;/code>), has a mean of \$5,301 but enormous variation (standard deviation of \$6,631), ranging from \$0 to \$60,308. The median (\$3,702) is well below the mean, indicating a right-skewed distribution &amp;mdash; many participants earned little or nothing while a few earned substantially more.&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 any causal modeling, we compare the raw earnings distributions between training and control groups. If the training program had an effect, we expect to see higher average earnings in the training group &amp;mdash; but we cannot yet tell whether any difference is truly caused by the program or driven by pre-existing differences between the groups.&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;Training&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():,.0f})&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;dowhy_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="dowhy_outcome_by_treatment.png" alt="Distribution of 1978 earnings by treatment group. The training group shows a higher mean.">&lt;/p>
&lt;p>Both distributions are heavily right-skewed, with a large spike near zero reflecting participants who had no earnings. The training group has a higher mean (\$6,349) compared to the control group (\$4,555), a raw difference of about \$1,794. However, both distributions overlap substantially, and the spike at zero is present in both groups, indicating that many participants struggled to find employment regardless of training.&lt;/p>
&lt;h3 id="covariate-balance">Covariate balance&lt;/h3>
&lt;p>In a randomized experiment, we expect the covariates to be balanced across treatment and control groups. Under randomization, the naive difference-in-means is &lt;strong>unbiased&lt;/strong> for the ATE in expectation &amp;mdash; but with a finite sample of 445 observations, chance imbalances can still arise and reduce the precision of the estimate. Checking covariate balance helps us assess whether such imbalances exist and whether covariate adjustment could improve efficiency. We first examine the categorical covariates as proportions, then use Standardized Mean Differences to assess balance across all covariates on a common scale.&lt;/p>
&lt;h4 id="categorical-covariates">Categorical covariates&lt;/h4>
&lt;p>The four binary covariates &amp;mdash; &lt;code>black&lt;/code>, &lt;code>hisp&lt;/code>, &lt;code>married&lt;/code>, and &lt;code>nodegr&lt;/code> (no high school degree) &amp;mdash; indicate demographic group membership. Comparing their proportions across treatment and control groups reveals whether random assignment produced balanced groups on these characteristics.&lt;/p>
&lt;pre>&lt;code class="language-python">categorical_vars = [&amp;quot;black&amp;quot;, &amp;quot;hisp&amp;quot;, &amp;quot;married&amp;quot;, &amp;quot;nodegr&amp;quot;]
cat_means = df.groupby(TREATMENT)[categorical_vars].mean()
fig, ax = plt.subplots(figsize=(8, 5))
x = np.arange(len(categorical_vars))
width = 0.35
ax.bar(x - width / 2, cat_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, cat_means.loc[1], width, label=&amp;quot;Training&amp;quot;,
color=&amp;quot;#d97757&amp;quot;, edgecolor=&amp;quot;white&amp;quot;)
ax.set_xticks(x)
ax.set_xticklabels(categorical_vars, rotation=45, ha=&amp;quot;right&amp;quot;)
ax.set_ylabel(&amp;quot;Proportion&amp;quot;)
ax.set_ylim(0, 1)
ax.set_title(&amp;quot;Covariate Balance: Categorical Variables&amp;quot;)
ax.legend()
plt.savefig(&amp;quot;dowhy_covariate_balance_categorical.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="dowhy_covariate_balance_categorical.png" alt="Proportions of categorical covariates for control and training groups. Both groups show similar demographic composition.">&lt;/p>
&lt;p>The categorical covariates are well balanced across treatment and control groups, consistent with random assignment. The sample is predominantly Black (83%) and has a high rate of lacking a high school diploma (78%), reflecting the disadvantaged population targeted by the NSW program. Hispanic and married proportions are low in both groups (roughly 6% and 16%, respectively), with no meaningful differences between treatment arms.&lt;/p>
&lt;h4 id="covariate-balance-standardized-mean-differences">Covariate balance: Standardized Mean Differences&lt;/h4>
&lt;p>Comparing raw group means can be misleading when covariates are measured on different scales. Suppose the control group earns \$500 more in prior earnings (&lt;code>re74&lt;/code>) than the training group, and is also 1 year older on average. Which imbalance is larger? The raw numbers cannot answer this question &amp;mdash; \$500 sounds like a lot, but prior earnings vary by thousands of dollars across individuals, so a \$500 gap may be trivial relative to the spread. A 1-year age difference sounds small, but if most participants are clustered around age 25, that gap may represent a meaningful shift in the distribution.&lt;/p>
&lt;p>The &lt;strong>Standardized Mean Difference (SMD)&lt;/strong> resolves this by asking: &lt;em>how many standard deviations apart are the treatment and control groups on each covariate?&lt;/em> For each variable, we compute the difference in group means and divide by the pooled standard deviation. This converts every covariate &amp;mdash; whether binary, measured in years, or measured in dollars &amp;mdash; to the same unitless scale, making imbalances directly comparable:&lt;/p>
&lt;p>$$\text{SMD} = \frac{\bar{X}_{treated} - \bar{X}_{control}}{\sqrt{(s^2_{treated} + s^2_{control}) \,/\, 2}}$$&lt;/p>
&lt;p>An absolute SMD below 0.1 is the conventional threshold for &amp;ldquo;good balance&amp;rdquo; (&lt;a href="https://doi.org/10.1002/sim.3697" target="_blank" rel="noopener">Austin, 2011&lt;/a>). Values above 0.1 signal that the groups differ by more than one-tenth of a standard deviation on that variable &amp;mdash; enough to potentially confound the treatment effect estimate. A &lt;a href="https://doi.org/10.1002/sim.3697" target="_blank" rel="noopener">&lt;strong>Love plot&lt;/strong>&lt;/a> displays the absolute SMD for all covariates as horizontal bars, with a dashed line at the 0.1 threshold. Bars in steel blue fall below the threshold (balanced), while bars in warm orange exceed it (imbalanced).&lt;/p>
&lt;pre>&lt;code class="language-python"># Standardized Mean Difference (SMD) for all covariates
treated = df[df[TREATMENT] == 1]
control = df[df[TREATMENT] == 0]
smd_values = {}
for var in COVARIATES:
diff = treated[var].mean() - control[var].mean()
pooled_sd = np.sqrt((treated[var].std()**2 + control[var].std()**2) / 2)
smd_values[var] = diff / pooled_sd
smd_df = pd.DataFrame({&amp;quot;variable&amp;quot;: list(smd_values.keys()),
&amp;quot;smd&amp;quot;: list(smd_values.values())})
smd_df[&amp;quot;abs_smd&amp;quot;] = smd_df[&amp;quot;smd&amp;quot;].abs()
smd_df = smd_df.sort_values(&amp;quot;abs_smd&amp;quot;)
fig, ax = plt.subplots(figsize=(8, 5))
colors = [&amp;quot;#6a9bcc&amp;quot; if v &amp;lt; 0.1 else &amp;quot;#d97757&amp;quot; for v in smd_df[&amp;quot;abs_smd&amp;quot;]]
ax.barh(smd_df[&amp;quot;variable&amp;quot;], smd_df[&amp;quot;abs_smd&amp;quot;], color=colors,
edgecolor=&amp;quot;white&amp;quot;, height=0.6)
ax.axvline(0.1, color=&amp;quot;#141413&amp;quot;, linewidth=1, linestyle=&amp;quot;--&amp;quot;, label=&amp;quot;SMD = 0.1 threshold&amp;quot;)
ax.set_xlabel(&amp;quot;Absolute Standardized Mean Difference&amp;quot;)
ax.set_title(&amp;quot;Covariate Balance: Love Plot (All Covariates)&amp;quot;)
ax.legend(loc=&amp;quot;lower right&amp;quot;)
plt.savefig(&amp;quot;dowhy_covariate_balance_smd.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="dowhy_covariate_balance_smd.png" alt="Love plot showing standardized mean differences for all eight covariates. Most fall below the 0.1 threshold, indicating good balance.">&lt;/p>
&lt;p>The Love plot reveals a more nuanced picture than raw mean comparisons would suggest. Prior earnings (&lt;code>re74&lt;/code> and &lt;code>re75&lt;/code>) &amp;mdash; which appeared imbalanced when comparing raw means in the thousands &amp;mdash; are actually well balanced on the standardized scale (SMD &amp;lt; 0.1), because their large variances absorb the mean differences. In contrast, &lt;code>nodegr&lt;/code> shows the largest imbalance (SMD ~0.31), followed by &lt;code>hisp&lt;/code> (~0.18) and &lt;code>educ&lt;/code> (~0.14). These imbalances, despite random assignment, reflect the small sample size and the disadvantaged population targeted by NSW. Although the naive difference-in-means remains unbiased under randomization, adjusting for these chance imbalances can &lt;strong>improve the precision&lt;/strong> of the treatment effect estimate &amp;mdash; a well-known result in the experimental design literature (&lt;a href="https://doi.org/10.1214/12-AOAS583" target="_blank" rel="noopener">Lin, 2013&lt;/a>; &lt;a href="https://doi.org/10.1214/08-AOAS171" target="_blank" rel="noopener">Freedman, 2008&lt;/a>).&lt;/p>
&lt;h2 id="the-causal-inference-problem">The causal inference problem&lt;/h2>
&lt;h3 id="ate-vs-att-two-different-causal-questions">ATE vs ATT: Two different causal questions&lt;/h3>
&lt;p>Before estimating the treatment effect, we need to be precise about &lt;em>which&lt;/em> causal question we are asking. There are two distinct estimands, each answering a different policy-relevant question:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Average Treatment Effect (ATE)&lt;/strong> &amp;mdash; &lt;em>&amp;ldquo;What would happen if we assigned treatment to a random person from the entire population?&amp;quot;&lt;/em> The ATE averages the treatment effect over &lt;strong>everyone&lt;/strong> &amp;mdash; both the treated and the untreated:&lt;/li>
&lt;/ul>
&lt;p>$$\text{ATE} = E[Y(1) - Y(0)]$$&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Average Treatment Effect on the Treated (ATT)&lt;/strong> &amp;mdash; &lt;em>&amp;ldquo;What was the effect of treatment for those who actually received it?&amp;quot;&lt;/em> The ATT averages the treatment effect only over the &lt;strong>treated&lt;/strong> subpopulation:&lt;/li>
&lt;/ul>
&lt;p>$$\text{ATT} = E[Y(1) - Y(0) \mid T = 1]$$&lt;/p>
&lt;p>The distinction matters because the people who receive treatment may differ systematically from those who do not. If the training program helps disadvantaged workers the most, and disadvantaged workers are more likely to enroll, then the ATT (the effect on those who enrolled) will be larger than the ATE (the effect if we enrolled everyone at random). Conversely, if the program is most effective for workers who are &lt;em>least&lt;/em> likely to enroll, the ATE could exceed the ATT.&lt;/p>
&lt;p>&lt;strong>In this tutorial, we estimate the ATE&lt;/strong> &amp;mdash; the average effect of the NSW job training program across the entire study population. This is the natural estimand for a randomized experiment where we want to evaluate the program&amp;rsquo;s overall impact. Four of our five estimation methods (regression adjustment, IPW, AIPW, and propensity score stratification) target the ATE directly. The exception is &lt;strong>propensity score matching&lt;/strong>, which discards unmatched control units and therefore shifts the estimand toward the ATT &amp;mdash; we flag this distinction when we discuss the matching results.&lt;/p>
&lt;h3 id="why-simple-comparisons-can-mislead">Why simple comparisons can mislead&lt;/h3>
&lt;p>A naive approach to estimating the treatment effect is to compute the difference in mean outcomes between the training and control groups. This gives us the &lt;strong>Average Treatment Effect (ATE)&lt;/strong>:&lt;/p>
&lt;p>$$\text{ATE}_{naive} = \bar{Y}_{treated} - \bar{Y}_{control}$$&lt;/p>
&lt;p>While this is a natural starting point and is &lt;strong>unbiased in expectation&lt;/strong> under randomization, it can be imprecise when finite-sample covariate imbalances exist. Adjusting for covariates that predict the outcome can sharpen the estimate. In observational studies, the problem is more severe &amp;mdash; without adjustment, the naive estimator can be genuinely biased by confounding.&lt;/p>
&lt;pre>&lt;code class="language-python">mean_treated = df[df[TREATMENT] == 1][OUTCOME].mean()
mean_control = df[df[TREATMENT] == 0][OUTCOME].mean()
naive_ate = mean_treated - mean_control
print(f&amp;quot;Mean earnings (Training): ${mean_treated:,.2f}&amp;quot;)
print(f&amp;quot;Mean earnings (Control): ${mean_control:,.2f}&amp;quot;)
print(f&amp;quot;Naive ATE (difference): ${naive_ate:,.2f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Mean earnings (Training): $6,349.14
Mean earnings (Control): $4,554.80
Naive ATE (difference): $1,794.34
&lt;/code>&lt;/pre>
&lt;p>The naive estimate suggests that training increases earnings by \$1,794 on average. Under randomization, this estimate is unbiased in expectation, but the finite-sample covariate imbalances we observed earlier (particularly in &lt;code>nodegr&lt;/code>, &lt;code>hisp&lt;/code>, and &lt;code>educ&lt;/code>) mean that covariate adjustment can sharpen the estimate and account for chance differences between groups. This is where DoWhy&amp;rsquo;s structured framework helps &amp;mdash; it forces us to explicitly model our causal assumptions, identify the correct estimand, apply rigorous estimation methods, and test whether the results hold up under scrutiny.&lt;/p>
&lt;h2 id="step-1-model-----define-the-causal-graph">Step 1: Model &amp;mdash; Define the causal graph&lt;/h2>
&lt;p>The first step in DoWhy&amp;rsquo;s framework is to encode our &lt;strong>domain knowledge&lt;/strong> as a causal graph &amp;mdash; a Directed Acyclic Graph (DAG) that specifies which variables cause which. In our case, the covariates (age, education, race, prior earnings, etc.) are &lt;strong>common causes&lt;/strong> of both treatment assignment and the outcome. Even in a randomized experiment, these covariates predict the outcome and adjusting for them improves precision, so we include them in the model. This also makes the tutorial directly applicable to observational settings where these variables are genuine confounders.&lt;/p>
&lt;h3 id="what-is-a-dag">What is a DAG?&lt;/h3>
&lt;p>A &lt;strong>Directed Acyclic Graph&lt;/strong> is the formal language of causal inference. Each word in the name carries meaning:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Directed&lt;/strong> &amp;mdash; every edge is an arrow pointing from cause to effect. If age affects earnings, we draw an arrow from &lt;code>age&lt;/code> to &lt;code>re78&lt;/code>, never the reverse.&lt;/li>
&lt;li>&lt;strong>Acyclic&lt;/strong> &amp;mdash; there are no feedback loops. You cannot follow the arrows and return to where you started. This rules out simultaneous causation (e.g., &amp;ldquo;A causes B and B causes A at the same time&amp;rdquo;), which requires more advanced models.&lt;/li>
&lt;li>&lt;strong>Graph&lt;/strong> &amp;mdash; variables are &lt;strong>nodes&lt;/strong> (circles or squares) and causal relationships are &lt;strong>edges&lt;/strong> (arrows). The full picture is a map of which variables drive which.&lt;/li>
&lt;/ul>
&lt;p>The DAG is not a statistical model &amp;mdash; it encodes &lt;em>qualitative&lt;/em> assumptions about the data-generating process before we look at a single number. Its power lies in what it tells us about which variables to adjust for and which to leave alone.&lt;/p>
&lt;h3 id="types-of-variables-in-a-causal-graph">Types of variables in a causal graph&lt;/h3>
&lt;p>Not all variables play the same role. Understanding the three fundamental types is essential for deciding what to control for:&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
C[&amp;quot;&amp;lt;b&amp;gt;Confounder&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;(e.g., prior earnings)&amp;quot;] --&amp;gt;|&amp;quot;affects&amp;quot;| T[&amp;quot;Treatment&amp;quot;]
C --&amp;gt;|&amp;quot;affects&amp;quot;| Y[&amp;quot;Outcome&amp;quot;]
T -.-&amp;gt;|&amp;quot;causal effect&amp;quot;| Y
style C fill:#00d4c8,stroke:#141413,color:#fff
style T fill:#6a9bcc,stroke:#141413,color:#fff
style Y fill:#d97757,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;ul>
&lt;li>&lt;strong>Confounders&lt;/strong> (common causes) &amp;mdash; A variable that affects &lt;em>both&lt;/em> the treatment and the outcome. For example, prior earnings (&lt;code>re74&lt;/code>) may influence whether someone enrolls in training &lt;em>and&lt;/em> how much they earn later. Confounders create a spurious association between treatment and outcome. &lt;strong>You must adjust for confounders&lt;/strong> to isolate the causal effect.&lt;/li>
&lt;/ul>
&lt;pre>&lt;code class="language-mermaid">graph LR
T[&amp;quot;Treatment&amp;quot;] --&amp;gt;|&amp;quot;causes&amp;quot;| M[&amp;quot;&amp;lt;b&amp;gt;Mediator&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;(e.g., skills)&amp;quot;]
M --&amp;gt;|&amp;quot;causes&amp;quot;| Y[&amp;quot;Outcome&amp;quot;]
style T fill:#6a9bcc,stroke:#141413,color:#fff
style M fill:#00d4c8,stroke:#141413,color:#fff
style Y fill:#d97757,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;ul>
&lt;li>&lt;strong>Mediators&lt;/strong> &amp;mdash; A variable that lies &lt;em>on&lt;/em> the causal path from treatment to outcome. For example, if job training increases skills, and skills increase earnings, then &lt;code>skills&lt;/code> is a mediator. &lt;strong>You should NOT adjust for mediators&lt;/strong> &amp;mdash; doing so would block the very causal pathway you are trying to measure, attenuating or eliminating the estimated effect.&lt;/li>
&lt;/ul>
&lt;pre>&lt;code class="language-mermaid">graph TD
T[&amp;quot;Treatment&amp;quot;] --&amp;gt;|&amp;quot;affects&amp;quot;| Col[&amp;quot;&amp;lt;b&amp;gt;Collider&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;(e.g., in_survey)&amp;quot;]
Y[&amp;quot;Outcome&amp;quot;] --&amp;gt;|&amp;quot;affects&amp;quot;| Col
T -.-&amp;gt;|&amp;quot;causal effect&amp;quot;| Y
style T fill:#6a9bcc,stroke:#141413,color:#fff
style Col fill:#00d4c8,stroke:#141413,color:#fff
style Y fill:#d97757,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;ul>
&lt;li>&lt;strong>Colliders&lt;/strong> &amp;mdash; A variable that is &lt;em>caused by&lt;/em> both the treatment and the outcome (or by variables on both sides). For example, if both training and high earnings make someone likely to appear in a follow-up survey, then &lt;code>in_survey&lt;/code> is a collider. &lt;strong>You should NOT condition on colliders&lt;/strong> &amp;mdash; doing so can create a spurious association between treatment and outcome even where none exists (a phenomenon called &lt;em>collider bias&lt;/em> or &lt;em>selection bias&lt;/em>).&lt;/li>
&lt;/ul>
&lt;p>In the Lalonde dataset, all eight covariates (age, education, race, marital status, degree status, and prior earnings) are measured &lt;em>before&lt;/em> treatment assignment, so they can only be confounders &amp;mdash; they cannot be mediators or colliders. This makes the graph straightforward: every covariate points to both &lt;code>treat&lt;/code> and &lt;code>re78&lt;/code>.&lt;/p>
&lt;p>The causal structure we assume is:&lt;/p>
&lt;ul>
&lt;li>Each covariate (age, educ, black, hisp, married, nodegr, re74, re75) affects both treatment assignment and earnings&lt;/li>
&lt;li>Treatment (&lt;code>treat&lt;/code>) affects the outcome (&lt;code>re78&lt;/code>)&lt;/li>
&lt;li>No covariate is itself caused by the treatment (pre-treatment variables)&lt;/li>
&lt;/ul>
&lt;p>We now create the &lt;a href="https://www.pywhy.org/dowhy/v0.11.1/dowhy.html#dowhy.causal_model.CausalModel" target="_blank" rel="noopener">&lt;code>CausalModel&lt;/code>&lt;/a> in DoWhy, specifying the treatment, outcome, and common causes. The model object stores the data, the causal graph, and metadata that DoWhy will use in subsequent steps to determine the correct adjustment strategy.&lt;/p>
&lt;pre>&lt;code class="language-python">model = CausalModel(
data=df,
treatment=TREATMENT,
outcome=OUTCOME,
common_causes=COVARIATES,
)
print(&amp;quot;CausalModel created successfully.&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>CausalModel created successfully.
&lt;/code>&lt;/pre>
&lt;p>DoWhy can visualize the causal graph it constructed using the &lt;a href="https://www.pywhy.org/dowhy/v0.11.1/dowhy.html#dowhy.causal_model.CausalModel.view_model" target="_blank" rel="noopener">&lt;code>view_model()&lt;/code>&lt;/a> method, which uses Graphviz to render the DAG automatically from the model&amp;rsquo;s internal graph representation:&lt;/p>
&lt;pre>&lt;code class="language-python"># Visualize the causal graph using DoWhy's built-in method
model.view_model(layout=&amp;quot;dot&amp;quot;)
from IPython.display import Image, display
display(Image(filename=&amp;quot;causal_model.png&amp;quot;))
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="dowhy_causal_graph.png" alt="Causal graph generated by DoWhy showing confounders as common causes of both treatment and outcome.">&lt;/p>
&lt;p>The DAG makes our assumptions explicit: the eight covariates are common causes that affect both treatment assignment (&lt;code>treat&lt;/code>) and earnings (&lt;code>re78&lt;/code>). The arrows encode the direction of causation &amp;mdash; each confounder points to both &lt;code>treat&lt;/code> and &lt;code>re78&lt;/code>, and &lt;code>treat&lt;/code> points to &lt;code>re78&lt;/code> (the causal effect we want to estimate). By stating these assumptions as a graph, DoWhy can automatically determine which variables need to be adjusted for and which estimation strategies are valid.&lt;/p>
&lt;h2 id="step-2-identify-----find-the-causal-estimand">Step 2: Identify &amp;mdash; Find the causal estimand&lt;/h2>
&lt;p>With the causal graph defined, DoWhy&amp;rsquo;s &lt;a href="https://www.pywhy.org/dowhy/v0.11.1/dowhy.html#dowhy.causal_model.CausalModel.identify_effect" target="_blank" rel="noopener">&lt;code>identify_effect()&lt;/code>&lt;/a> method uses graph theory to &lt;strong>identify&lt;/strong> the causal estimand &amp;mdash; the mathematical expression that, if computed correctly, equals the true causal effect. This step determines &lt;em>whether&lt;/em> the effect is identifiable from the data given our assumptions, and &lt;em>what&lt;/em> variables we need to condition on.&lt;/p>
&lt;h3 id="what-does-identification-mean">What does &amp;ldquo;identification&amp;rdquo; mean?&lt;/h3>
&lt;p>In causal inference, &lt;strong>identification&lt;/strong> answers a deceptively simple question: &lt;em>can we compute the causal effect from the data we have, without running a new experiment?&lt;/em> The answer is not always yes. Consider a scenario where an unmeasured variable (say, &amp;ldquo;motivation&amp;rdquo;) affects both whether someone enrolls in training and how much they earn afterward. No amount of data on age, education, and prior earnings can untangle the causal effect of training from the confounding effect of motivation &amp;mdash; the causal effect is &lt;strong>not identified&lt;/strong> without observing motivation.&lt;/p>
&lt;p>Identification is the bridge between &lt;em>causal assumptions&lt;/em> (encoded in the graph) and &lt;em>statistical computation&lt;/em> (what we can actually calculate from data). If the effect is identified, the identification step produces an &lt;strong>estimand&lt;/strong> &amp;mdash; a precise mathematical formula that tells us exactly which conditional expectations or reweightings to compute. If the effect is not identified, no estimation method can produce a credible causal estimate, no matter how sophisticated.&lt;/p>
&lt;h3 id="identification-strategies">Identification strategies&lt;/h3>
&lt;p>DoWhy checks three main strategies, each applicable in different causal structures:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;a href="https://www.pywhy.org/dowhy/v0.14/user_guide/causal_tasks/estimating_causal_effects/effect_estimation_with_backdoor.html" target="_blank" rel="noopener">Backdoor criterion&lt;/a>&lt;/strong> &amp;mdash; The most common strategy. It applies when we can observe all confounders between treatment and outcome. By conditioning on these confounders, we &amp;ldquo;block&amp;rdquo; all backdoor paths &amp;mdash; non-causal pathways that create spurious associations. In the Lalonde example, conditioning on the eight covariates satisfies the backdoor criterion because they are the only common causes of &lt;code>treat&lt;/code> and &lt;code>re78&lt;/code>.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.pywhy.org/dowhy/v0.14/user_guide/causal_tasks/estimating_causal_effects/effect_estimation_with_natural_experiments.html" target="_blank" rel="noopener">Instrumental variables (IV)&lt;/a>&lt;/strong> &amp;mdash; Useful when some confounders are &lt;em>unobserved&lt;/em>. An instrument is a variable that affects treatment but has &lt;em>no direct effect&lt;/em> on the outcome except through the treatment itself. For example, draft lottery numbers have been used as instruments for military service: the lottery affects whether someone serves (treatment) but has no direct effect on later earnings (outcome) except through the service itself. IV estimation requires strong assumptions but can identify causal effects when backdoor adjustment is impossible.&lt;/li>
&lt;li>&lt;strong>&lt;a href="https://www.pywhy.org/dowhy/v0.14/user_guide/causal_tasks/estimating_causal_effects/index.html" target="_blank" rel="noopener">Front-door criterion&lt;/a>&lt;/strong> &amp;mdash; Applies when there is a &lt;strong>mediator&lt;/strong> that fully transmits the treatment effect and is itself unconfounded with the outcome. This strategy is rare in practice but theoretically important: it can identify causal effects even in the presence of unmeasured confounders between treatment and outcome, as long as the mediator pathway is clean.&lt;/li>
&lt;/ul>
&lt;p>A key advantage of DoWhy is that &lt;strong>it automates the identification step&lt;/strong>. Given the causal graph, DoWhy algorithmically checks which strategies are valid and returns the correct estimand. This prevents a common and dangerous mistake in applied work: manually choosing which variables to &amp;ldquo;control for&amp;rdquo; without formally checking whether the chosen adjustment set actually satisfies the conditions for causal identification.&lt;/p>
&lt;pre>&lt;code class="language-python">identified_estimand = model.identify_effect(proceed_when_unidentifiable=True)
print(identified_estimand)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Estimand type: EstimandType.NONPARAMETRIC_ATE
### Estimand : 1
Estimand name: backdoor
Estimand expression:
d
────────(E[re78|educ,black,age,hisp,re75,married,re74,nodegr])
d[treat]
Estimand assumption 1, Unconfoundedness: If U→{treat} and U→re78
then P(re78|treat,educ,black,age,hisp,re75,married,re74,nodegr,U)
= P(re78|treat,educ,black,age,hisp,re75,married,re74,nodegr)
&lt;/code>&lt;/pre>
&lt;p>DoWhy identifies the &lt;strong>backdoor estimand&lt;/strong> as the primary identification strategy, expressing the causal effect as the derivative of the conditional expectation of earnings with respect to treatment, conditioning on all eight covariates. The critical assumption is &lt;strong>unconfoundedness&lt;/strong> &amp;mdash; there are no unmeasured confounders beyond the ones we specified. DoWhy also checks for instrumental variable and front-door estimands but finds none applicable, which is expected given our graph structure.&lt;/p>
&lt;h2 id="step-3-estimate-----compute-the-causal-effect">Step 3: Estimate &amp;mdash; Compute the causal effect&lt;/h2>
&lt;p>With the estimand identified, we now use &lt;a href="https://www.pywhy.org/dowhy/v0.11.1/dowhy.html#dowhy.causal_model.CausalModel.estimate_effect" target="_blank" rel="noopener">&lt;code>estimate_effect()&lt;/code>&lt;/a> to compute the actual causal effect estimate. DoWhy supports multiple estimation methods, each with different assumptions and properties. We compare five approaches to see how robust the estimate is across methods.&lt;/p>
&lt;p>Causal estimation methods fall into &lt;strong>three broad paradigms&lt;/strong>, distinguished by what they model:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Outcome modeling&lt;/strong> (Regression Adjustment) &amp;mdash; directly models the relationship $E[Y \mid X, T]$ between covariates, treatment, and outcome. Its validity depends on correctly specifying this outcome model.&lt;/li>
&lt;li>&lt;strong>Treatment modeling&lt;/strong> (IPW, PS Stratification, PS Matching) &amp;mdash; models the treatment assignment mechanism $P(T \mid X)$ (the propensity score) and uses it to remove confounding. All three methods rely exclusively on the propensity score &amp;mdash; they differ in &lt;em>how&lt;/em> they use it (reweighting, grouping, or pairing observations) but none of them model the outcome. Their validity depends on correctly specifying the propensity score model.&lt;/li>
&lt;li>&lt;strong>Doubly robust&lt;/strong> (AIPW) &amp;mdash; the only true hybrid. It explicitly combines an outcome model $E[Y \mid X, T]$ with a propensity score model $P(T \mid X)$, and is consistent if &lt;em>either&lt;/em> model is correctly specified. This &amp;ldquo;double protection&amp;rdquo; is why it is called doubly robust.&lt;/li>
&lt;/ol>
&lt;p>The following diagram shows how these paradigms relate to the five methods we will apply:&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
Root[&amp;quot;&amp;lt;b&amp;gt;Estimation Methods&amp;lt;/b&amp;gt;&amp;quot;] --&amp;gt; OM[&amp;quot;&amp;lt;b&amp;gt;Outcome Modeling&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;Models E[Y | X, T]&amp;lt;/i&amp;gt;&amp;quot;]
Root --&amp;gt; TM[&amp;quot;&amp;lt;b&amp;gt;Treatment Modeling&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;Models P(T | X)&amp;lt;/i&amp;gt;&amp;quot;]
Root --&amp;gt; DR_cat[&amp;quot;&amp;lt;b&amp;gt;Doubly Robust&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;Models both E[Y | X, T]&amp;lt;br/&amp;gt;and P(T | X)&amp;lt;/i&amp;gt;&amp;quot;]
OM --&amp;gt; RA[&amp;quot;Regression&amp;lt;br/&amp;gt;Adjustment&amp;quot;]
TM --&amp;gt; IPW[&amp;quot;Inverse Probability&amp;lt;br/&amp;gt;Weighting&amp;quot;]
TM --&amp;gt; PSS[&amp;quot;PS&amp;lt;br/&amp;gt;Stratification&amp;quot;]
TM --&amp;gt; PSM[&amp;quot;PS&amp;lt;br/&amp;gt;Matching&amp;quot;]
DR_cat --&amp;gt; DR[&amp;quot;AIPW&amp;quot;]
style Root fill:#141413,stroke:#141413,color:#fff
style OM fill:#6a9bcc,stroke:#141413,color:#fff
style TM fill:#d97757,stroke:#141413,color:#fff
style DR_cat fill:#00d4c8,stroke:#141413,color:#fff
style RA fill:#6a9bcc,stroke:#141413,color:#fff
style IPW fill:#d97757,stroke:#141413,color:#fff
style PSS fill:#d97757,stroke:#141413,color:#fff
style PSM fill:#d97757,stroke:#141413,color:#fff
style DR fill:#00d4c8,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>Understanding these paradigms helps clarify why different methods can give somewhat different estimates and why comparing across paradigms is a powerful robustness check. The key trade-offs are:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>What each paradigm models&lt;/strong>: Outcome modeling specifies how covariates relate to earnings ($E[Y \mid X, T]$). Treatment modeling specifies how covariates relate to treatment assignment ($P(T \mid X)$) &amp;mdash; all three PS methods use this same propensity score but differ in how they apply it. Doubly robust specifies both models simultaneously.&lt;/li>
&lt;li>&lt;strong>What each paradigm assumes&lt;/strong>: Regression adjustment requires the outcome model to be correctly specified. All three propensity score methods (IPW, stratification, matching) require the propensity score model to be correctly specified. Doubly robust only requires &lt;em>one&lt;/em> of the two to be correct.&lt;/li>
&lt;li>&lt;strong>Bias-variance characteristics&lt;/strong>: Regression adjustment tends to be low-variance but can be biased if the outcome-covariate relationship is nonlinear. IPW can have high variance when propensity scores are extreme (near 0 or 1). Stratification and matching use the propensity score more conservatively &amp;mdash; by grouping or pairing rather than directly reweighting &amp;mdash; which can reduce variance relative to IPW. Doubly robust balances both concerns but is more complex to implement.&lt;/li>
&lt;/ul>
&lt;p>The three treatment modeling methods differ in &lt;em>how&lt;/em> they use the propensity score to create balanced comparisons:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>IPW&lt;/strong> reweights every observation by the inverse of its propensity score, creating a pseudo-population where treatment is independent of covariates. It uses the full sample but can be unstable when propensity scores are near 0 or 1.&lt;/li>
&lt;li>&lt;strong>PS Stratification&lt;/strong> divides observations into groups (strata) with similar propensity scores, then computes simple mean differences within each stratum. By comparing treated and control units within the same stratum, it approximates a block-randomized experiment.&lt;/li>
&lt;li>&lt;strong>PS Matching&lt;/strong> pairs each treated unit with the control unit that has the most similar propensity score, then computes mean differences within matched pairs. It discards unmatched observations, focusing on the closest comparisons at the cost of reduced sample size.&lt;/li>
&lt;/ul>
&lt;p>None of these methods model the outcome &amp;mdash; they all achieve confounding adjustment purely through the propensity score. If outcome modeling and treatment modeling agree, we can be more confident that neither model is badly misspecified.&lt;/p>
&lt;h3 id="method-1-regression-adjustment">Method 1: Regression Adjustment&lt;/h3>
&lt;p>Regression adjustment is grounded in the &lt;strong>potential outcomes framework&lt;/strong>: each individual has two potential outcomes &amp;mdash; $Y(1)$ if treated and $Y(0)$ if not &amp;mdash; and the causal effect is their difference. Since we only observe one outcome per person, regression adjustment estimates both potential outcomes by modeling $E[Y \mid X, T]$, the conditional expectation of the outcome given covariates and treatment status. The treatment effect is the coefficient on the treatment indicator, which captures the difference in expected outcomes between treated and control units &lt;strong>at the same covariate values&lt;/strong> &amp;mdash; effectively comparing apples to apples.&lt;/p>
&lt;p>The key assumption is that the outcome model must be &lt;strong>correctly specified&lt;/strong>. If the true relationship between covariates and the outcome is nonlinear or includes interactions, a simple linear model will produce biased estimates. In econometrics, this approach is closely related to the &lt;strong>&lt;a href="https://en.wikipedia.org/wiki/Frisch%E2%80%93Waugh%E2%80%93Lovell_theorem" target="_blank" rel="noopener">Frisch-Waugh-Lovell theorem&lt;/a>&lt;/strong>, which shows that the treatment coefficient in a multiple regression is identical to what you would get by first partialing out the covariates from both the treatment and the outcome, then regressing the residuals on each other. This makes regression adjustment the simplest and most transparent baseline estimator.&lt;/p>
&lt;p>We use DoWhy&amp;rsquo;s &lt;a href="https://www.pywhy.org/dowhy/v0.14/user_guide/causal_tasks/estimating_causal_effects/effect_estimation_with_backdoor.html" target="_blank" rel="noopener">&lt;code>backdoor.linear_regression&lt;/code>&lt;/a> method:&lt;/p>
&lt;pre>&lt;code class="language-python">estimate_ra = model.estimate_effect(
identified_estimand,
method_name=&amp;quot;backdoor.linear_regression&amp;quot;,
confidence_intervals=True,
)
print(f&amp;quot;Estimated ATE (Regression Adjustment): ${estimate_ra.value:,.2f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Estimated ATE (Regression Adjustment): $1,676.34
&lt;/code>&lt;/pre>
&lt;p>The regression adjustment estimate is \$1,676, slightly lower than the naive difference of \$1,794. The reduction from \$1,794 to \$1,676 reflects the covariate adjustment &amp;mdash; by accounting for finite-sample imbalances in age, education, race, and prior earnings, the estimated treatment effect shrinks by about \$118. In this randomized setting, the adjustment primarily improves precision rather than removing bias, but the same technique is essential in observational studies where confounding is a genuine concern.&lt;/p>
&lt;h3 id="method-2-inverse-probability-weighting-ipw">Method 2: Inverse Probability Weighting (IPW)&lt;/h3>
&lt;p>IPW takes a fundamentally different approach from regression adjustment. Instead of modeling the outcome, it models the &lt;strong>treatment assignment mechanism&lt;/strong>. The central concept is the &lt;strong>propensity score&lt;/strong>, $e(X) = P(T = 1 \mid X)$ &amp;mdash; the probability that a unit receives treatment given its observed covariates. A person with a propensity score of 0.8 has an 80% chance of being treated based on their characteristics; a person with a score of 0.2 has only a 20% chance.&lt;/p>
&lt;p>The key intuition behind inverse weighting is that &lt;strong>units who are unlikely to receive the treatment they actually received carry more information&lt;/strong> about the causal effect. Consider a treated individual with a low propensity score (say 0.1) &amp;mdash; this person was unlikely to be treated, yet was treated. Their outcome is especially informative because they are &amp;ldquo;similar&amp;rdquo; to the control group in all observable respects. IPW upweights such surprising cases by assigning them a weight of $1/e(X) = 10$, while a treated person with $e(X) = 0.9$ receives a weight of only $1/0.9 \approx 1.1$. This reweighting creates a &amp;ldquo;pseudo-population&amp;rdquo; in which treatment assignment is independent of the observed confounders, mimicking what a randomized experiment would look like.&lt;/p>
&lt;p>A critical contrast with regression adjustment: IPW makes &lt;strong>no assumptions about how covariates relate to the outcome&lt;/strong> &amp;mdash; it only requires that the propensity score model is correctly specified. However, IPW has a key vulnerability: when propensity scores are extreme (near 0 or 1), the inverse weights become very large, producing &lt;strong>unstable estimates with high variance&lt;/strong>. This is why practitioners often use weight trimming or stabilized weights in practice.&lt;/p>
&lt;p>The IPW estimator is:&lt;/p>
&lt;p>$$\hat{\tau}_{IPW} = \frac{1}{n} \sum_{i=1}^{n} \left[ \frac{T_i Y_i}{\hat{e}(X_i)} - \frac{(1 - T_i) Y_i}{1 - \hat{e}(X_i)} \right]$$&lt;/p>
&lt;p>where $\hat{e}(X_i)$ is the estimated propensity score for individual $i$.&lt;/p>
&lt;p>We use DoWhy&amp;rsquo;s &lt;a href="https://www.pywhy.org/dowhy/v0.14/user_guide/causal_tasks/estimating_causal_effects/effect_estimation_with_backdoor.html" target="_blank" rel="noopener">&lt;code>backdoor.propensity_score_weighting&lt;/code>&lt;/a> method, which implements the &lt;a href="https://doi.org/10.1080/01621459.1952.10483446" target="_blank" rel="noopener">Horvitz-Thompson&lt;/a> inverse probability estimator:&lt;/p>
&lt;pre>&lt;code class="language-python">estimate_ipw = model.estimate_effect(
identified_estimand,
method_name=&amp;quot;backdoor.propensity_score_weighting&amp;quot;,
method_params={&amp;quot;weighting_scheme&amp;quot;: &amp;quot;ips_weight&amp;quot;},
)
print(f&amp;quot;Estimated ATE (IPW): ${estimate_ipw.value:,.2f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Estimated ATE (IPW): $1,559.47
&lt;/code>&lt;/pre>
&lt;p>The IPW estimate of \$1,559 is the lowest among all methods. IPW is sensitive to extreme propensity scores &amp;mdash; when some individuals have very high or very low probabilities of treatment, their weights become large and can dominate the estimate. In this dataset, the estimated propensity scores are reasonably well-behaved (the NSW was a randomized experiment), so the IPW estimate remains in the plausible range. The difference from the regression adjustment (\$1,676 vs \$1,559) reflects the fact that IPW makes no assumptions about the outcome model, relying entirely on correct specification of the propensity score model.&lt;/p>
&lt;h3 id="method-3-doubly-robust-aipw">Method 3: Doubly Robust (AIPW)&lt;/h3>
&lt;p>The &lt;strong>doubly robust&lt;/strong> estimator &amp;mdash; also called &lt;strong>Augmented Inverse Probability Weighting (AIPW)&lt;/strong> &amp;mdash; combines both regression adjustment and IPW into a single estimator. The key advantage is that the estimate is consistent if &lt;em>either&lt;/em> the outcome model &lt;em>or&lt;/em> the propensity score model is correctly specified (hence &amp;ldquo;doubly robust&amp;rdquo;). This provides an important safeguard against model misspecification.&lt;/p>
&lt;p>The intuition is straightforward: AIPW starts with the regression adjustment estimate ($\hat{\mu}_1(X) - \hat{\mu}_0(X)$, the difference in predicted outcomes under treatment and control) and then &lt;strong>adds a correction term&lt;/strong> based on the IPW-weighted prediction errors. If the outcome model is perfectly specified, the prediction errors $Y - \hat{\mu}(X)$ are pure noise and the correction averages to zero &amp;mdash; the regression adjustment alone does the work. If the outcome model is misspecified but the propensity score model is correct, the IPW-weighted correction term exactly compensates for the bias in the outcome predictions. This is why the estimator only needs &lt;strong>one&lt;/strong> of the two models to be correct &amp;mdash; whichever model is right &amp;ldquo;rescues&amp;rdquo; the other.&lt;/p>
&lt;p>Beyond its robustness property, AIPW achieves the &lt;strong>semiparametric efficiency bound&lt;/strong> when both models are correctly specified, meaning no other estimator that makes the same assumptions can have lower variance. This makes it a natural default choice in modern causal inference.&lt;/p>
&lt;p>The AIPW estimator is:&lt;/p>
&lt;p>$$\hat{\tau}_{DR} = \frac{1}{n} \sum_{i=1}^{n} \left[ \hat{\mu}_1(X_i) - \hat{\mu}_0(X_i) + \frac{T_i (Y_i - \hat{\mu}_1(X_i))}{\hat{e}(X_i)} - \frac{(1 - T_i)(Y_i - \hat{\mu}_0(X_i))}{1 - \hat{e}(X_i)} \right]$$&lt;/p>
&lt;p>where $\hat{\mu}_1(X_i)$ and $\hat{\mu}_0(X_i)$ are the predicted outcomes under treatment and control, and $\hat{e}(X_i)$ is the propensity score.&lt;/p>
&lt;p>We implement the AIPW estimator manually rather than using DoWhy&amp;rsquo;s built-in &lt;a href="https://www.pywhy.org/dowhy/v0.14/user_guide/causal_tasks/estimating_causal_effects/index.html" target="_blank" rel="noopener">&lt;code>backdoor.doubly_robust&lt;/code>&lt;/a> method, which has a known compatibility issue with recent scikit-learn versions. The manual implementation uses &lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LogisticRegression.html" target="_blank" rel="noopener">&lt;code>LogisticRegression&lt;/code>&lt;/a> for the propensity score model and &lt;a href="https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.LinearRegression.html" target="_blank" rel="noopener">&lt;code>LinearRegression&lt;/code>&lt;/a> for the outcome model, making the estimator&amp;rsquo;s two-component structure fully transparent.&lt;/p>
&lt;pre>&lt;code class="language-python"># Doubly Robust (AIPW) — manual implementation
ps_model = LogisticRegression(max_iter=1000, random_state=42)
ps_model.fit(df[COVARIATES], df[TREATMENT])
ps = ps_model.predict_proba(df[COVARIATES])[:, 1]
outcome_model_1 = SklearnLR().fit(df[df[TREATMENT] == 1][COVARIATES], df[df[TREATMENT] == 1][OUTCOME])
outcome_model_0 = SklearnLR().fit(df[df[TREATMENT] == 0][COVARIATES], df[df[TREATMENT] == 0][OUTCOME])
mu1 = outcome_model_1.predict(df[COVARIATES])
mu0 = outcome_model_0.predict(df[COVARIATES])
T = df[TREATMENT].values
Y = df[OUTCOME].values
dr_ate = np.mean(
(mu1 - mu0)
+ T * (Y - mu1) / ps
- (1 - T) * (Y - mu0) / (1 - ps)
)
print(f&amp;quot;Estimated ATE (Doubly Robust): ${dr_ate:,.2f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Estimated ATE (Doubly Robust): $1,620.04
&lt;/code>&lt;/pre>
&lt;p>The doubly robust estimate of \$1,620 falls between the regression adjustment (\$1,676) and IPW (\$1,559) estimates. This reflects how the AIPW estimator works: it uses the outcome model as its primary estimate and adds an IPW-weighted correction based on the prediction residuals. The fact that it is close to both individual estimates suggests that neither model is severely misspecified. In practice, the doubly robust estimator is often the preferred choice because it provides insurance against misspecification of either component model.&lt;/p>
&lt;h3 id="method-4-propensity-score-stratification">Method 4: Propensity Score Stratification&lt;/h3>
&lt;p>Propensity score stratification builds on a powerful result from &lt;a href="https://doi.org/10.1093/biomet/70.1.41" target="_blank" rel="noopener">Rosenbaum and Rubin (1983)&lt;/a>: &lt;strong>conditioning on the scalar propensity score is sufficient to remove all confounding from observed covariates&lt;/strong>, even though the score compresses multiple covariates into a single number. This means that within a group of individuals who all have similar propensity scores, treatment assignment is effectively random with respect to the observed confounders &amp;mdash; just as in a randomized experiment.&lt;/p>
&lt;p>Stratification is a &lt;strong>discrete approximation&lt;/strong> to this idea. Instead of conditioning on the exact propensity score (which would require infinite data), we bin observations into a small number of strata &amp;mdash; typically 5 quintiles. Within each stratum, treated and control individuals have similar propensity scores and are therefore more comparable, so the within-stratum treatment effect is less confounded. The overall ATE is a weighted average of these stratum-specific effects. A classic result from &lt;a href="https://doi.org/10.2307/2528036" target="_blank" rel="noopener">Cochran (1968)&lt;/a> shows that &lt;strong>5 strata typically remove over 90% of the bias&lt;/strong> from observed confounders, making this a surprisingly effective yet simple approach.&lt;/p>
&lt;p>There is a practical trade-off in choosing the number of strata: more strata produce finer covariate balance within each group, reducing bias, but also leave fewer observations per stratum, increasing variance. Five strata is the conventional choice, balancing these considerations well.&lt;/p>
&lt;p>We use DoWhy&amp;rsquo;s &lt;a href="https://www.pywhy.org/dowhy/v0.14/user_guide/causal_tasks/estimating_causal_effects/effect_estimation_with_backdoor.html" target="_blank" rel="noopener">&lt;code>backdoor.propensity_score_stratification&lt;/code>&lt;/a> method:&lt;/p>
&lt;pre>&lt;code class="language-python">estimate_ps_strat = model.estimate_effect(
identified_estimand,
method_name=&amp;quot;backdoor.propensity_score_stratification&amp;quot;,
method_params={&amp;quot;num_strata&amp;quot;: 5, &amp;quot;clipping_threshold&amp;quot;: 5},
)
print(f&amp;quot;Estimated ATE (PS Stratification): ${estimate_ps_strat.value:,.2f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Estimated ATE (PS Stratification): $1,617.07
&lt;/code>&lt;/pre>
&lt;p>Propensity score stratification with 5 strata estimates the ATE at \$1,617, very close to the doubly robust estimate (\$1,620). The stratification approach is more flexible than regression adjustment because it does not impose a functional form on the outcome-covariate relationship. The estimate is in the same ballpark as the other adjusted results, which is reassuring &amp;mdash; multiple methods agree that the training effect is in the \$1,550&amp;ndash;\$1,700 range.&lt;/p>
&lt;h3 id="method-5-propensity-score-matching">Method 5: Propensity Score Matching&lt;/h3>
&lt;p>Propensity score matching constructs a comparison group by finding, for each treated individual, the control individual(s) with the most similar propensity score. The treatment effect is then estimated by comparing outcomes within these matched pairs. This is conceptually the most intuitive approach &amp;mdash; it directly mimics what we would see if we could compare individuals who are identical except for their treatment status.&lt;/p>
&lt;p>An important subtlety is that matching typically &lt;strong>discards unmatched control units&lt;/strong> &amp;mdash; those with no treated counterpart nearby in propensity score space. This means the estimand shifts from the &lt;strong>Average Treatment Effect (ATE)&lt;/strong> toward the &lt;strong>Average Treatment Effect on the Treated (ATT)&lt;/strong>, which answers a slightly different question: &amp;ldquo;What was the effect of treatment for those who were actually treated?&amp;rdquo; rather than &amp;ldquo;What would the effect be if we treated everyone?&amp;rdquo;&lt;/p>
&lt;p>Several practical choices affect matching quality. &lt;strong>With-replacement&lt;/strong> matching allows each control to be matched to multiple treated units, reducing bias but increasing variance. &lt;strong>1:k matching&lt;/strong> uses $k$ nearest controls per treated unit, averaging out noise but potentially introducing worse matches. &lt;strong>Caliper restrictions&lt;/strong> discard matches where the propensity score difference exceeds a threshold, preventing poor matches at the cost of losing some treated observations. These choices create a fundamental &lt;strong>bias-variance trade-off&lt;/strong>: tighter matching criteria reduce bias from imperfect comparisons but may discard many observations, increasing the variance of the estimate.&lt;/p>
&lt;p>We use DoWhy&amp;rsquo;s &lt;a href="https://www.pywhy.org/dowhy/v0.14/user_guide/causal_tasks/estimating_causal_effects/effect_estimation_with_backdoor.html" target="_blank" rel="noopener">&lt;code>backdoor.propensity_score_matching&lt;/code>&lt;/a> method:&lt;/p>
&lt;pre>&lt;code class="language-python">estimate_ps_match = model.estimate_effect(
identified_estimand,
method_name=&amp;quot;backdoor.propensity_score_matching&amp;quot;,
)
print(f&amp;quot;Estimated ATE (PS Matching): ${estimate_ps_match.value:,.2f}&amp;quot;)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Estimated ATE (PS Matching): $1,735.69
&lt;/code>&lt;/pre>
&lt;p>Propensity score matching estimates the effect at \$1,736, the highest of the five adjusted estimates and closest to the naive difference. Matching tends to give slightly different results because it uses only the closest comparisons rather than the full sample. As noted above, this estimate is closer to the &lt;strong>ATT&lt;/strong> than the ATE, so it answers a slightly different question than the other four methods &amp;mdash; readers should keep this distinction in mind when comparing across estimators. The fact that all five methods produce estimates between \$1,559 and \$1,736 provides strong evidence that the treatment effect is real and robust to the choice of estimation method.&lt;/p>
&lt;h2 id="step-4-refute-----test-robustness">Step 4: Refute &amp;mdash; Test robustness&lt;/h2>
&lt;p>The final and perhaps most valuable step in DoWhy&amp;rsquo;s framework is &lt;strong>refutation&lt;/strong> &amp;mdash; systematically testing whether the estimated causal effect is robust to violations of our assumptions. DoWhy&amp;rsquo;s &lt;a href="https://www.pywhy.org/dowhy/v0.11.1/dowhy.html#dowhy.causal_model.CausalModel.refute_estimate" target="_blank" rel="noopener">&lt;code>refute_estimate()&lt;/code>&lt;/a> method provides several built-in refutation tests, each probing a different potential weakness.&lt;/p>
&lt;h3 id="why-refutation-matters">Why refutation matters&lt;/h3>
&lt;p>Most causal inference workflows stop after estimation: you run a regression, get a coefficient, and report it as the causal effect. DoWhy&amp;rsquo;s refutation step is its key innovation &amp;mdash; it provides &lt;strong>automated falsification tests&lt;/strong> that probe whether the estimate could be an artifact of the model, the data, or violated assumptions. This is the causal inference equivalent of &amp;ldquo;stress testing&amp;rdquo;: if the estimate survives multiple attempts to break it, we can be more confident that it reflects a genuine causal relationship.&lt;/p>
&lt;p>DoWhy&amp;rsquo;s refutation tests fall into three categories, each targeting a different potential weakness:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Placebo tests&lt;/strong> &amp;mdash; &lt;em>&amp;ldquo;If the treatment doesn&amp;rsquo;t matter, does the effect disappear?&amp;quot;&lt;/em> These tests replace the real treatment with a fake (randomly permuted) treatment. If the estimated effect drops to near zero, the original result is tied to the actual treatment rather than being a statistical artifact of the model or data structure.&lt;/li>
&lt;li>&lt;strong>Sensitivity tests&lt;/strong> &amp;mdash; &lt;em>&amp;ldquo;If we missed a confounder, does the estimate change?&amp;quot;&lt;/em> These tests add a randomly generated variable as an additional confounder. If the estimate barely changes, it suggests the result is not fragile &amp;mdash; adding one more covariate does not destabilize it. This provides indirect evidence (though not proof) that unobserved confounders may not be a major concern.&lt;/li>
&lt;li>&lt;strong>Stability tests&lt;/strong> &amp;mdash; &lt;em>&amp;ldquo;If we use different data, does the estimate hold?&amp;quot;&lt;/em> These tests re-estimate the effect on random subsets of the data. If the estimate fluctuates wildly, it may depend on a few influential observations rather than reflecting a stable population-level effect.&lt;/li>
&lt;/ul>
&lt;p>An important caveat: &lt;strong>passing all refutation tests does not prove causation&lt;/strong>. The tests can only detect certain types of problems &amp;mdash; they cannot rule out every possible source of bias. However, &lt;strong>failing any test is a strong signal that something is wrong&lt;/strong> and warrants further investigation before drawing causal conclusions.&lt;/p>
&lt;h3 id="placebo-treatment-test">Placebo Treatment Test&lt;/h3>
&lt;p>The &lt;a href="https://www.pywhy.org/dowhy/v0.14/user_guide/refuting_causal_estimates/refuting_effect_estimates/placebo_treatment.html" target="_blank" rel="noopener">placebo test&lt;/a> replaces the actual treatment with a randomly permuted version. If our estimate is truly capturing a causal effect, this fake treatment should produce an effect near zero. A large p-value indicates that the placebo effect is not significantly different from zero, confirming that the real treatment drives the original estimate.&lt;/p>
&lt;pre>&lt;code class="language-python">refute_placebo = model.refute_estimate(
identified_estimand,
estimate_ra,
method_name=&amp;quot;placebo_treatment_refuter&amp;quot;,
placebo_type=&amp;quot;permute&amp;quot;,
num_simulations=100,
)
print(refute_placebo)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Refute: Use a Placebo Treatment
Estimated effect:1676.3426437675835
New effect:61.821946542496946
p value:0.92
&lt;/code>&lt;/pre>
&lt;p>The placebo treatment test produces a new effect of approximately \$62, which is close to zero and dramatically smaller than the original estimate of \$1,676. The high p-value (0.92) indicates that the original estimate is well above what we would expect from a random treatment assignment. This is strong evidence that the estimated effect is not an artifact of the model or data structure.&lt;/p>
&lt;h3 id="random-common-cause-test">Random Common Cause Test&lt;/h3>
&lt;p>The &lt;a href="https://www.pywhy.org/dowhy/v0.14/user_guide/refuting_causal_estimates/refuting_effect_estimates/random_common_cause.html" target="_blank" rel="noopener">random common cause test&lt;/a> adds a randomly generated confounder to the model and checks whether the estimate changes. If our model is correctly specified and the estimate is robust, adding a random variable should not significantly alter the result.&lt;/p>
&lt;pre>&lt;code class="language-python">refute_random = model.refute_estimate(
identified_estimand,
estimate_ra,
method_name=&amp;quot;random_common_cause&amp;quot;,
num_simulations=100,
)
print(refute_random)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Refute: Add a random common cause
Estimated effect:1676.3426437675835
New effect:1675.606781672203
p value:0.9
&lt;/code>&lt;/pre>
&lt;p>Adding a random common cause barely changes the estimate: from \$1,676 to \$1,676 &amp;mdash; a difference of less than \$1. The high p-value (0.90) confirms that the original estimate is stable when an additional (irrelevant) confounder is introduced. This suggests that the model is not overly sensitive to the specific set of confounders included.&lt;/p>
&lt;h3 id="data-subset-test">Data Subset Test&lt;/h3>
&lt;p>The &lt;a href="https://www.pywhy.org/dowhy/v0.14/user_guide/refuting_causal_estimates/refuting_effect_estimates/data_subsample.html" target="_blank" rel="noopener">data subset test&lt;/a> re-estimates the effect on random 80% subsamples of the data. If the estimate is robust, it should remain similar across different subsets. Large fluctuations would suggest that the result depends on a few influential observations.&lt;/p>
&lt;pre>&lt;code class="language-python">refute_subset = model.refute_estimate(
identified_estimand,
estimate_ra,
method_name=&amp;quot;data_subset_refuter&amp;quot;,
subset_fraction=0.8,
num_simulations=100,
)
print(refute_subset)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code>Refute: Use a subset of data
Estimated effect:1676.3426437675835
New effect:1727.583871150809
p value:0.8
&lt;/code>&lt;/pre>
&lt;p>The data subset refuter produces a mean effect of \$1,728 across 100 random subsamples, close to the full-sample estimate of \$1,676. The high p-value (0.80) indicates that the estimate is stable across subsets and does not depend on a handful of outlier observations. The slight increase in the subsample estimate (\$1,728 vs \$1,676) reflects normal sampling variability.&lt;/p>
&lt;h2 id="comparing-all-estimates">Comparing all estimates&lt;/h2>
&lt;p>To visualize how all estimation approaches compare, we plot the ATE estimates side by side. Consistent estimates across different methods strengthen confidence in the causal conclusion.&lt;/p>
&lt;pre>&lt;code class="language-python">fig, ax = plt.subplots(figsize=(9, 6))
methods = [&amp;quot;Naive\n(Diff. in Means)&amp;quot;, &amp;quot;Regression\nAdjustment&amp;quot;, &amp;quot;IPW&amp;quot;,
&amp;quot;Doubly Robust\n(AIPW)&amp;quot;, &amp;quot;PS\nStratification&amp;quot;, &amp;quot;PS\nMatching&amp;quot;]
estimates = [naive_ate, estimate_ra.value, estimate_ipw.value,
dr_ate, estimate_ps_strat.value, estimate_ps_match.value]
colors = [&amp;quot;#999999&amp;quot;, &amp;quot;#6a9bcc&amp;quot;, &amp;quot;#d97757&amp;quot;, &amp;quot;#00d4c8&amp;quot;, &amp;quot;#e8956a&amp;quot;, &amp;quot;#c4623d&amp;quot;]
bars = ax.barh(methods, estimates, color=colors, edgecolor=&amp;quot;white&amp;quot;, height=0.6)
for bar, val in zip(bars, estimates):
ax.text(val + 50, bar.get_y() + bar.get_height() / 2,
f&amp;quot;${val:,.0f}&amp;quot;, va=&amp;quot;center&amp;quot;, fontsize=10, color=&amp;quot;#141413&amp;quot;)
ax.axvline(0, color=&amp;quot;black&amp;quot;, linewidth=0.5, linestyle=&amp;quot;--&amp;quot;)
ax.set_xlabel(&amp;quot;Estimated Average Treatment Effect (USD)&amp;quot;)
ax.set_title(&amp;quot;Causal Effect Estimates: NSW Job Training on 1978 Earnings&amp;quot;)
plt.savefig(&amp;quot;dowhy_estimate_comparison.png&amp;quot;, dpi=300, bbox_inches=&amp;quot;tight&amp;quot;)
plt.show()
&lt;/code>&lt;/pre>
&lt;p>&lt;img src="dowhy_estimate_comparison.png" alt="Comparison of ATE estimates across six methods.">&lt;/p>
&lt;p>All six methods produce positive estimates between \$1,559 and \$1,794, indicating that the NSW job training program increased participants' 1978 earnings by roughly \$1,550&amp;ndash;\$1,800. The five adjusted methods cluster between \$1,559 and \$1,736, suggesting that about \$58&amp;ndash;\$235 of the naive estimate was due to finite-sample covariate imbalances rather than the treatment. The convergence across fundamentally different estimation strategies &amp;mdash; outcome modeling (regression adjustment), treatment modeling (IPW, stratification, matching), and doubly robust (AIPW) &amp;mdash; is strong evidence that the effect is real.&lt;/p>
&lt;h2 id="summary-table">Summary table&lt;/h2>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Estimated ATE&lt;/th>
&lt;th>Notes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Naive (Difference in Means)&lt;/td>
&lt;td>\$1,794&lt;/td>
&lt;td>No covariate adjustment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Regression Adjustment&lt;/td>
&lt;td>\$1,676&lt;/td>
&lt;td>Models outcome, assumes linearity&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>IPW&lt;/td>
&lt;td>\$1,559&lt;/td>
&lt;td>Models treatment assignment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Doubly Robust (AIPW)&lt;/td>
&lt;td>\$1,620&lt;/td>
&lt;td>Models both outcome and treatment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Propensity Score Stratification&lt;/td>
&lt;td>\$1,617&lt;/td>
&lt;td>5 strata, flexible&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Propensity Score Matching&lt;/td>
&lt;td>\$1,736&lt;/td>
&lt;td>Nearest-neighbor matching (closer to ATT)&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Refutation Test&lt;/th>
&lt;th>New Effect&lt;/th>
&lt;th>p-value&lt;/th>
&lt;th>Interpretation&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Placebo Treatment&lt;/td>
&lt;td>\$62&lt;/td>
&lt;td>0.92&lt;/td>
&lt;td>Effect vanishes with fake treatment&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Random Common Cause&lt;/td>
&lt;td>\$1,676&lt;/td>
&lt;td>0.90&lt;/td>
&lt;td>Stable with added confounder&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Data Subset (80%)&lt;/td>
&lt;td>\$1,728&lt;/td>
&lt;td>0.80&lt;/td>
&lt;td>Stable across subsamples&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The summary confirms a consistent causal effect across methods: the NSW job training program increased 1978 earnings by approximately \$1,550&amp;ndash;\$1,800. All five adjusted methods and all three refutation tests support the validity of the estimate. The placebo test is particularly convincing &amp;mdash; when the real treatment is replaced by random noise, the effect drops from \$1,676 to just \$62, confirming that the observed effect is tied to the actual treatment and not a statistical artifact. The doubly robust estimate (\$1,620) provides the most credible point estimate because it is consistent under misspecification of either the outcome model or the propensity score model.&lt;/p>
&lt;h2 id="discussion">Discussion&lt;/h2>
&lt;p>The Lalonde dataset provides a compelling case study for DoWhy&amp;rsquo;s four-step framework. Each step serves a distinct purpose: the &lt;strong>Model&lt;/strong> step forces us to articulate our causal assumptions as a graph, the &lt;strong>Identify&lt;/strong> step uses graph theory to determine the correct adjustment formula, the &lt;strong>Estimate&lt;/strong> step applies statistical methods to compute the effect, and the &lt;strong>Refute&lt;/strong> step probes whether the result withstands scrutiny.&lt;/p>
&lt;p>The estimated ATE ranges from \$1,559 (IPW) to \$1,736 (PS matching), with the doubly robust estimate at \$1,620 providing a credible middle ground. On a base of \$4,555 for the control group, this represents roughly a 34&amp;ndash;38% increase in earnings &amp;mdash; a substantial effect for a disadvantaged population with very low baseline earnings. The three estimation paradigms &amp;mdash; outcome modeling (regression adjustment), treatment modeling (IPW, stratification, matching), and doubly robust (AIPW) &amp;mdash; each bring different strengths, and their convergence strengthens the causal conclusion.&lt;/p>
&lt;p>The key strength of DoWhy over ad-hoc statistical approaches is transparency. The causal graph makes assumptions visible and debatable. The identification step formally checks whether the effect is estimable. Multiple estimation methods let us assess robustness. And refutation tests provide automated sanity checks that would otherwise require expert judgment.&lt;/p>
&lt;h2 id="limitations-and-next-steps">Limitations and next steps&lt;/h2>
&lt;p>This analysis demonstrates DoWhy&amp;rsquo;s workflow on a well-understood dataset, but several limitations apply:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Small sample size&lt;/strong>: With only 445 observations, estimates have high variance and the propensity score methods may suffer from poor overlap in some regions of the covariate space&lt;/li>
&lt;li>&lt;strong>Unconfoundedness assumption&lt;/strong>: The backdoor criterion requires that all confounders are observed. If there are unmeasured factors affecting both training enrollment and earnings, our estimates would be biased&lt;/li>
&lt;li>&lt;strong>Linear outcome model&lt;/strong>: The regression adjustment and doubly robust estimates assume a linear relationship between covariates and earnings, which may be too restrictive for the highly skewed outcome distribution&lt;/li>
&lt;li>&lt;strong>Experimental data&lt;/strong>: The NSW was a randomized experiment, making it the easiest setting for causal inference. DoWhy&amp;rsquo;s advantages are more pronounced in observational studies where confounding is more severe&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Next steps&lt;/strong> could include:&lt;/p>
&lt;ul>
&lt;li>Apply DoWhy to an observational version of the Lalonde dataset (e.g., the PSID or CPS comparison groups) where confounding is much stronger&lt;/li>
&lt;li>Explore DoWhy&amp;rsquo;s instrumental variable and front-door estimators for settings where the backdoor criterion fails&lt;/li>
&lt;li>Investigate heterogeneous treatment effects &amp;mdash; does training help some subgroups more than others?&lt;/li>
&lt;li>Use nonparametric outcome models (e.g., random forests) in the doubly robust estimator for more flexible modeling&lt;/li>
&lt;li>Compare DoWhy&amp;rsquo;s estimates with Double Machine Learning (DoubleML) for a side-by-side comparison of frameworks&lt;/li>
&lt;/ul>
&lt;h2 id="takeaways">Takeaways&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>DoWhy&amp;rsquo;s four-step workflow&lt;/strong> (Model, Identify, Estimate, Refute) makes causal assumptions explicit and testable, rather than hiding them inside a black-box estimator.&lt;/li>
&lt;li>&lt;strong>The NSW job training program increased 1978 earnings by approximately \$1,550&amp;ndash;\$1,800&lt;/strong>, a 34&amp;ndash;38% gain over the control group mean of \$4,555.&lt;/li>
&lt;li>&lt;strong>Five estimation methods&lt;/strong> &amp;mdash; regression adjustment, IPW, doubly robust, PS stratification, and PS matching &amp;mdash; all produce positive, consistent estimates, strengthening confidence in the causal conclusion.&lt;/li>
&lt;li>&lt;strong>The doubly robust (AIPW) estimator&lt;/strong> (\$1,620) is the most credible single estimate because it remains consistent if either the outcome model or the propensity score model is misspecified.&lt;/li>
&lt;li>&lt;strong>IPW and regression adjustment represent two complementary paradigms&lt;/strong>: modeling treatment assignment (\$1,559) vs. modeling the outcome (\$1,676). Their divergence quantifies sensitivity to modeling choices.&lt;/li>
&lt;li>&lt;strong>Refutation tests confirm robustness&lt;/strong> &amp;mdash; the placebo test reduced the effect from \$1,676 to just \$62, ruling out statistical artifacts.&lt;/li>
&lt;li>&lt;strong>Causal graphs encode domain knowledge as testable assumptions&lt;/strong>; the backdoor criterion then determines which variables must be conditioned on for valid causal estimation.&lt;/li>
&lt;li>&lt;strong>Next step&lt;/strong>: apply DoWhy to an observational comparison group (e.g., PSID or CPS) where confounding is stronger and the choice of estimator matters more.&lt;/li>
&lt;/ul>
&lt;h2 id="exercises">Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Change the number of strata.&lt;/strong> Re-run the propensity score stratification with &lt;code>num_strata=10&lt;/code> and &lt;code>num_strata=20&lt;/code>. How does the ATE estimate change? What are the tradeoffs of using more vs. fewer strata with a sample of only 445 observations?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Add an additional refutation test.&lt;/strong> DoWhy supports a &lt;code>bootstrap_refuter&lt;/code> that re-estimates the effect on bootstrap samples. Implement this refuter and compare its results to the data subset refuter. Are the conclusions similar?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Estimate effects for subgroups.&lt;/strong> Split the dataset by &lt;code>black&lt;/code> (race indicator) and estimate the ATE separately for each subgroup using DoWhy. Does the job training program have a different effect for Black vs. non-Black participants? What might explain any differences you observe?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://www.pywhy.org/dowhy/" target="_blank" rel="noopener">DoWhy &amp;mdash; Python Library for Causal Inference (PyWhy)&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://www.jstor.org/stable/1806062" target="_blank" rel="noopener">LaLonde, R. (1986). Evaluating the Econometric Evaluations of Training Programs. American Economic Review, 76(4), 604&amp;ndash;620.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1080/01621459.1999.10473858" target="_blank" rel="noopener">Dehejia, R. &amp;amp; Wahba, S. (1999). Causal Effects in Nonexperimental Studies: Reevaluating the Evaluation of Training Programs. JASA, 94(448), 1053&amp;ndash;1062.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://arxiv.org/abs/2011.04216" target="_blank" rel="noopener">Sharma, A. &amp;amp; Kiciman, E. (2020). DoWhy: An End-to-End Library for Causal Inference. arXiv:2011.04216.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1080/01621459.1952.10483446" target="_blank" rel="noopener">Horvitz, D. G. &amp;amp; Thompson, D. J. (1952). A Generalization of Sampling Without Replacement from a Finite Universe. JASA, 47(260), 663&amp;ndash;685.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1080/01621459.1994.10476818" target="_blank" rel="noopener">Robins, J. M., Rotnitzky, A. &amp;amp; Zhao, L. P. (1994). Estimation of Regression Coefficients When Some Regressors Are Not Always Observed. JASA, 89(427), 846&amp;ndash;866.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1093/biomet/70.1.41" target="_blank" rel="noopener">Rosenbaum, P. R. &amp;amp; Rubin, D. B. (1983). The Central Role of the Propensity Score in Observational Studies for Causal Effects. Biometrika, 70(1), 41&amp;ndash;55.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.2307/2528036" target="_blank" rel="noopener">Cochran, W. G. (1968). The Effectiveness of Adjustment by Subclassification in Removing Bias in Observational Studies. Biometrics, 24(2), 295&amp;ndash;313.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://medium.com/@chrisjames.nita/causal-inference-with-python-introduction-to-dowhy-ff5799e48985" target="_blank" rel="noopener">Nita, C. J. Causal Inference with Python &amp;mdash; Introduction to DoWhy. Medium.&lt;/a>&lt;/li>
&lt;/ol></description></item><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></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></description></item><item><title>Exploratory Spatial Data Analysis (ESDA)</title><link>https://carlos-mendez.org/post/python_esda/</link><pubDate>Fri, 01 Mar 2024 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_esda/</guid><description>&lt;h1 id="exploratory-spatial-data-analysis-esda-of-regional-development">Exploratory Spatial Data Analysis (ESDA) of Regional Development&lt;/h1>
&lt;p>This &lt;a href="https://esda101-bolivia339.streamlit.app/" target="_blank" rel="noopener">interactive application&lt;/a> enables users to explore municipal development indicators across Bolivia. In particular, it offers:&lt;/p>
&lt;ul>
&lt;li>🗺️ Geographical data visualizations&lt;/li>
&lt;li>📈 Distribution and comparative analysis tools&lt;/li>
&lt;li>💾 Downloadable datasets&lt;/li>
&lt;li>🧮 Access to a cloud-based computational notebook on &lt;a href="https://colab.research.google.com/drive/1JHf8wPxSxBdKKhXaKQZUzhEpVznKGiep?usp=sharing" target="_blank" rel="noopener">Google Colab&lt;/a>&lt;/li>
&lt;/ul>
&lt;iframe
src="https://cmg777.github.io/open-results/files/mapBolivia339imds.html"
width="100%"
height="576"
frameborder="0"
loading="lazy"
style="border:none;">
&lt;/iframe>
&lt;blockquote>
&lt;p>⚠️ This application is open source and still work in progress. Source code is available at: &lt;a href="https://github.com/cmg777/streamlit_esda101" target="_blank" rel="noopener">github.com/cmg777/streamlit_esda101&lt;/a>&lt;/p>
&lt;/blockquote>
&lt;hr>
&lt;h2 id="-data-sources-and-credits">📚 Data Sources and Credits&lt;/h2>
&lt;ul>
&lt;li>Primary data source: &lt;a href="https://sdsnbolivia.org/Atlas/" target="_blank" rel="noopener">Municipal Atlas of the SDGs in Bolivia 2020.&lt;/a>&lt;/li>
&lt;li>Additional indicators for multiple years were sourced from the &lt;a href="https://www.aiddata.org/geoquery" target="_blank" rel="noopener">GeoQuery project.&lt;/a>&lt;/li>
&lt;li>Administrative boundaries from the &lt;a href="https://www.geoboundaries.org/" target="_blank" rel="noopener">GeoBoundaries database&lt;/a>&lt;/li>
&lt;li>Streamlit web app and computational notebook by &lt;a href="https://carlos-mendez.org" target="_blank" rel="noopener">Carlos Mendez.&lt;/a>&lt;/li>
&lt;li>Erick Gonzales and Pedro Leoni also colaborated in the organization of the data and the creation of the initial geospatial database&lt;/li>
&lt;/ul>
&lt;p>&lt;strong>Citation&lt;/strong>:&lt;br>
Mendez, C. (2025, March 24). &lt;em>Regional Development Indicators of Bolivia: A Dashboard for Exploratory Analysis&lt;/em> (Version 0.0.2) [Computer software]. Zenodo. &lt;a href="https://doi.org/10.5281/zenodo.15074864" target="_blank" rel="noopener">https://doi.org/10.5281/zenodo.15074864&lt;/a>&lt;/p>
&lt;hr>
&lt;h2 id="-context-and-motivation">🌐 Context and Motivation&lt;/h2>
&lt;p>Adopted in 2015, the &lt;strong>2030 Agenda for Sustainable Development&lt;/strong> established 17 Sustainable Development Goals. While global metrics offer useful benchmarks, they often overlook subnational disparities—particularly in heterogeneous countries such as Bolivia.&lt;/p>
&lt;ul>
&lt;li>🇧🇴 Bolivia ranks &lt;strong>79/166&lt;/strong> on the 2020 SDG Index (score: 69.3)&lt;/li>
&lt;li>🏘️ The &lt;em>&lt;a href="http://atlas.sdsnbolivia.org" target="_blank" rel="noopener">Municipal Atlas of the SDGs in Bolivia 2020&lt;/a>&lt;/em> reveals &lt;strong>intra-national disparities&lt;/strong> comparable to &lt;strong>global inter-country variation&lt;/strong>&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="-development-index-índice-municipal-de-desarrollo-sostenible-imds">📊 Development Index: Índice Municipal de Desarrollo Sostenible (IMDS)&lt;/h2>
&lt;p>The &lt;strong>Municipal Sustainable Development Index (IMDS)&lt;/strong> summarizes municipal performance using 62 indicators across 15 Sustainable Development Goals. However, systematic and reliable information on goals 12 and 14 were not available at the municipal level.&lt;/p>
&lt;h3 id="-methodological-criteria">🎯 Methodological Criteria&lt;/h3>
&lt;ul>
&lt;li>✅ Relevance to local Sustainable Development Goal targets&lt;/li>
&lt;li>📥 Data availability from official or trusted sources&lt;/li>
&lt;li>🌐 Full municipal coverage (339 municipalities)&lt;/li>
&lt;li>🕒 Data mostly from 2012–2019&lt;/li>
&lt;li>🧮 Low redundancy between indicators&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="-indicators-by-sustainable-development-goal">🗃️ Indicators by Sustainable Development Goal&lt;/h2>
&lt;h3 id="-goal-1-no-poverty">🧱 Goal 1: No Poverty&lt;/h3>
&lt;ul>
&lt;li>Energy poverty rate (2012, INE)&lt;/li>
&lt;li>Multidimensional Poverty Index (2013, UDAPE)&lt;/li>
&lt;li>Unmet Basic Needs (2012, INE)&lt;/li>
&lt;li>Access to basic services: water, sanitation, electricity (2012, INE)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-2-zero-hunger">🌾 Goal 2: Zero Hunger&lt;/h3>
&lt;ul>
&lt;li>Chronic malnutrition in children under five (2016, Ministry of Health)&lt;/li>
&lt;li>Obesity prevalence in women (2016, Ministry of Health)&lt;/li>
&lt;li>Average agricultural unit size (2013, Agricultural Census)&lt;/li>
&lt;li>Tractor density per 1,000 farms (2013, Agricultural Census)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-3-good-health-and-well-being">🏥 Goal 3: Good Health and Well-being&lt;/h3>
&lt;ul>
&lt;li>Infant and under-five mortality rates (2016, Ministry of Health)&lt;/li>
&lt;li>Institutional birth coverage (2016, Ministry of Health)&lt;/li>
&lt;li>Incidence of Chagas, HIV, malaria, tuberculosis, dengue (2016, Ministry of Health)&lt;/li>
&lt;li>Adolescent fertility rate (2016, Ministry of Health)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-4-quality-education">📚 Goal 4: Quality Education&lt;/h3>
&lt;ul>
&lt;li>Secondary school dropout rates, by gender (2016, Ministry of Education)&lt;/li>
&lt;li>Adult literacy rate (2012, INE)&lt;/li>
&lt;li>Share of population with higher education (2012, INE)&lt;/li>
&lt;li>Share of qualified teachers, initial and secondary levels (2016, Ministry of Education)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-5-gender-equality">⚖️ Goal 5: Gender Equality&lt;/h3>
&lt;ul>
&lt;li>Gender parity in education, labor participation, and poverty (2012–2016, INE and UDAPE)&lt;/li>
&lt;li>&lt;em>Note: Data on gender-based violence not available at municipal level&lt;/em>&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-6-clean-water-and-sanitation">💧 Goal 6: Clean Water and Sanitation&lt;/h3>
&lt;ul>
&lt;li>Access to potable water (2012, INE)&lt;/li>
&lt;li>Access to sanitation services (2012, INE)&lt;/li>
&lt;li>Proportion of treated wastewater (2015, Ministry of Environment)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-7-affordable-and-clean-energy">⚡ Goal 7: Affordable and Clean Energy&lt;/h3>
&lt;ul>
&lt;li>Electricity coverage (2012, INE)&lt;/li>
&lt;li>Per capita electricity consumption (2015, Ministry of Energy)&lt;/li>
&lt;li>Use of clean cooking energy (2015, Ministry of Hydrocarbons)&lt;/li>
&lt;li>CO₂ emissions per capita, energy-related (2015, international satellite data)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-8-decent-work-and-economic-growth">💼 Goal 8: Decent Work and Economic Growth&lt;/h3>
&lt;ul>
&lt;li>Share of non-functioning electricity meters (proxy for informality/unemployment) (2015, Ministry of Energy)&lt;/li>
&lt;li>Labor force participation rate (2012, INE)&lt;/li>
&lt;li>Youth not in education, employment, or training (NEET rate) (2015, Ministry of Labor)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-9-industry-innovation-and-infrastructure">🏗️ Goal 9: Industry, Innovation, and Infrastructure&lt;/h3>
&lt;ul>
&lt;li>Internet access in households (2012, INE)&lt;/li>
&lt;li>Mobile signal coverage (2015, telecommunications data)&lt;/li>
&lt;li>Availability of urban infrastructure (2015, Ministry of Public Works)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-10-reduced-inequality">⚖️ Goal 10: Reduced Inequality&lt;/h3>
&lt;ul>
&lt;li>Proxy measures: municipal differences in poverty and participation rates (2012–2016, INE and UDAPE)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-11-sustainable-cities-and-communities">🏘️ Goal 11: Sustainable Cities and Communities&lt;/h3>
&lt;ul>
&lt;li>Urban housing adequacy (2012, INE)&lt;/li>
&lt;li>Access to collective transportation (2015, Ministry of Transport)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-13-climate-action">🌍 Goal 13: Climate Action&lt;/h3>
&lt;ul>
&lt;li>Natural disaster resilience index (2015, Ministry of Environment)&lt;/li>
&lt;li>CO₂ emissions and forest degradation (2015, satellite data)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-15-life-on-land">🌳 Goal 15: Life on Land&lt;/h3>
&lt;ul>
&lt;li>Deforestation rates (2015, satellite data)&lt;/li>
&lt;li>Biodiversity loss indicators (2015, Ministry of Environment)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-16-peace-justice-and-strong-institutions">🕊️ Goal 16: Peace, Justice, and Strong Institutions&lt;/h3>
&lt;ul>
&lt;li>Birth registration coverage (2012, INE)&lt;/li>
&lt;li>Crime and homicide rates (2015, Ministry of Government)&lt;/li>
&lt;li>Corruption perceptions (2015, civil society organizations)&lt;/li>
&lt;/ul>
&lt;h3 id="-goal-17-partnerships-for-the-goals">🤝 Goal 17: Partnerships for the Goals&lt;/h3>
&lt;ul>
&lt;li>Municipal fiscal capacity (2015, Ministry of Economy)&lt;/li>
&lt;li>Public investment per capita (2015, Ministry of Economy)&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="-limitations-and-future-work">⚠️ Limitations and Future Work&lt;/h2>
&lt;ul>
&lt;li>No disaggregated data for Indigenous Territories (TIOC)&lt;/li>
&lt;li>Many indicators based on 2012 Census; updates pending&lt;/li>
&lt;li>Limited information for Goals 12 and 14 at municipal level&lt;/li>
&lt;li>No indicators for educational quality (due to lack of standardized testing)&lt;/li>
&lt;li>Gender violence data unavailable at municipal scale&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="-access">🔗 Access&lt;/h2>
&lt;ul>
&lt;li>&lt;strong>Original website&lt;/strong>: &lt;a href="http://atlas.sdsnbolivia.org" target="_blank" rel="noopener">atlas.sdsnbolivia.org&lt;/a>&lt;/li>
&lt;li>&lt;strong>Original Publication&lt;/strong>: &lt;a href="http://www.sdsnbolivia.org/Atlas" target="_blank" rel="noopener">sdsnbolivia.org/Atlas&lt;/a>&lt;/li>
&lt;li>&lt;strong>Source Code of the Web App&lt;/strong>: &lt;a href="https://github.com/cmg777/streamlit_esda101" target="_blank" rel="noopener">github.com/cmg777/streamlit_esda101&lt;/a>&lt;/li>
&lt;li>&lt;strong>Computational Notebook&lt;/strong>: &lt;a href="https://colab.research.google.com/drive/1JHf8wPxSxBdKKhXaKQZUzhEpVznKGiep?usp=sharing" target="_blank" rel="noopener">Google Colab&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>Studying spatial heterogeneity</title><link>https://carlos-mendez.org/post/python_gwr_mgwr/</link><pubDate>Sat, 23 Dec 2023 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_gwr_mgwr/</guid><description>&lt;h1 id="a-geocomputational-notebook-to-compute-gwr-and-mgwr">&lt;strong>A geocomputational notebook to compute GWR and MGWR&lt;/strong>&lt;/h1>
&lt;p>.&lt;/p></description></item><item><title>Construct and export spatial connectivity structures (W)</title><link>https://carlos-mendez.org/post/python_how_to_build_w/</link><pubDate>Sat, 02 Dec 2023 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_how_to_build_w/</guid><description>&lt;p>.&lt;/p></description></item><item><title>Cross-Sectional Spatial Regression in Stata: Crime in Columbus Neighborhoods</title><link>https://carlos-mendez.org/post/stata_sp_regression_cross_section/</link><pubDate>Fri, 01 Dec 2023 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/stata_sp_regression_cross_section/</guid><description>&lt;h2 id="1-overview">1. Overview&lt;/h2>
&lt;p>Crime does not stop at neighborhood boundaries. A neighborhood&amp;rsquo;s crime rate may depend not only on its own socioeconomic conditions but also on conditions in adjacent areas &amp;mdash; through spatial displacement (criminals move to easier targets nearby), diffusion (criminal networks operate across borders), and shared exposure to common risk factors. Standard regression models that treat each neighborhood as an independent observation miss these &lt;strong>spatial spillovers&lt;/strong>, potentially producing biased estimates of how income and housing values affect crime.&lt;/p>
&lt;p>This tutorial introduces the &lt;strong>complete taxonomy of cross-sectional spatial regression models&lt;/strong> &amp;mdash; from a simple OLS baseline through the most general GNS (General Nesting Spatial) specification. Using the classic Columbus crime dataset, we progressively estimate eight models: OLS, SAR, SEM, SLX, SDM, SDEM, SAC, and GNS. Each model captures spatial dependence through a different combination of three channels: the spatial lag of the dependent variable ($\rho Wy$), the spatial lag of the explanatory variables ($WX\theta$), and the spatial lag of the error term ($\lambda Wu$). We use &lt;strong>specification tests&lt;/strong> from the SDM to determine which simpler model the data supports, and compare all models using log-likelihoods and direct/indirect effect decompositions, following Elhorst (2014, Chapter 2).&lt;/p>
&lt;p>The Columbus crime dataset contains 49 neighborhoods in Columbus, Ohio, with data on residential burglaries and vehicle thefts per 1,000 households (CRIME), household income in \$1,000 (INC), and housing value in \$1,000 (HOVAL). The spatial weight matrix is a Queen contiguity matrix &amp;mdash; two neighborhoods are neighbors if they share a common border or vertex &amp;mdash; row-standardized so that the spatial lag of a variable equals the weighted average among a neighborhood&amp;rsquo;s neighbors. All estimation uses Stata&amp;rsquo;s official &lt;code>spregress&lt;/code> command (available since Stata 15), which implements maximum likelihood estimation for the full family of cross-sectional spatial models.&lt;/p>
&lt;blockquote>
&lt;p>Mendez, C. (2021). &lt;em>Spatial econometrics for cross-sectional data in Stata.&lt;/em> DOI: &lt;a href="https://doi.org/10.5281/zenodo.5151076" target="_blank" rel="noopener">10.5281/zenodo.5151076&lt;/a>&lt;/p>
&lt;/blockquote>
&lt;h3 id="learning-objectives">Learning objectives&lt;/h3>
&lt;ul>
&lt;li>Construct and load a Queen contiguity spatial weight matrix in Stata using &lt;code>spmatrix fromdata&lt;/code>&lt;/li>
&lt;li>Compute spatial lags of explanatory variables ($WX$) manually using Mata&lt;/li>
&lt;li>Test for spatial autocorrelation using Moran&amp;rsquo;s I and LM tests&lt;/li>
&lt;li>Estimate the full taxonomy of spatial models (SAR, SEM, SLX, SDM, SDEM, SAC, GNS) using &lt;code>spregress&lt;/code>&lt;/li>
&lt;li>Decompose coefficient estimates into direct, indirect (spillover), and total effects using &lt;code>estat impact&lt;/code>&lt;/li>
&lt;li>Use specification tests to determine whether the SDM simplifies to SAR, SLX, or SEM&lt;/li>
&lt;li>Compare models and identify the SDM and SDEM as preferred specifications following Elhorst (2014)&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="2-the-spatial-model-taxonomy">2. The spatial model taxonomy&lt;/h2>
&lt;p>The eight models in this tutorial form a nested hierarchy. At the top sits the &lt;strong>GNS&lt;/strong> (General Nesting Spatial) model, which includes all three spatial channels simultaneously. Each intermediate model imposes one or more restrictions, and OLS sits at the bottom with no spatial terms at all. Understanding this nesting structure is essential for model selection &amp;mdash; we estimate from the general to the specific, using statistical tests to determine whether restrictions are warranted.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
GNS[&amp;quot;&amp;lt;b&amp;gt;GNS&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;y = ρWy + Xβ + WXθ + u&amp;lt;br/&amp;gt;u = λWu + ε&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;Most general&amp;lt;/i&amp;gt;&amp;quot;]
SDM[&amp;quot;&amp;lt;b&amp;gt;SDM&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;y = ρWy + Xβ + WXθ + ε&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;λ = 0&amp;lt;/i&amp;gt;&amp;quot;]
SDEM[&amp;quot;&amp;lt;b&amp;gt;SDEM&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;y = Xβ + WXθ + u&amp;lt;br/&amp;gt;u = λWu + ε&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;ρ = 0&amp;lt;/i&amp;gt;&amp;quot;]
SAC[&amp;quot;&amp;lt;b&amp;gt;SAC&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;y = ρWy + Xβ + u&amp;lt;br/&amp;gt;u = λWu + ε&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;θ = 0&amp;lt;/i&amp;gt;&amp;quot;]
SAR[&amp;quot;&amp;lt;b&amp;gt;SAR&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;y = ρWy + Xβ + ε&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;λ = 0, θ = 0&amp;lt;/i&amp;gt;&amp;quot;]
SEM[&amp;quot;&amp;lt;b&amp;gt;SEM&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;y = Xβ + u&amp;lt;br/&amp;gt;u = λWu + ε&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;ρ = 0, θ = 0&amp;lt;/i&amp;gt;&amp;quot;]
SLX[&amp;quot;&amp;lt;b&amp;gt;SLX&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;y = Xβ + WXθ + ε&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;ρ = 0, λ = 0&amp;lt;/i&amp;gt;&amp;quot;]
OLS[&amp;quot;&amp;lt;b&amp;gt;OLS&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;y = Xβ + ε&amp;lt;br/&amp;gt;&amp;lt;i&amp;gt;ρ = 0, θ = 0, λ = 0&amp;lt;/i&amp;gt;&amp;quot;]
GNS --&amp;gt; SDM
GNS --&amp;gt; SDEM
GNS --&amp;gt; SAC
SDM --&amp;gt; SAR
SDM --&amp;gt; SLX
SDEM --&amp;gt; SLX
SDEM --&amp;gt; SEM
SAC --&amp;gt; SAR
SAC --&amp;gt; SEM
SAR --&amp;gt; OLS
SEM --&amp;gt; OLS
SLX --&amp;gt; OLS
style GNS fill:#141413,stroke:#d97757,color:#fff
style SDM fill:#00d4c8,stroke:#141413,color:#141413
style SDEM fill:#6a9bcc,stroke:#141413,color:#fff
style SAC fill:#6a9bcc,stroke:#141413,color:#fff
style SAR fill:#d97757,stroke:#141413,color:#fff
style SEM fill:#d97757,stroke:#141413,color:#fff
style SLX fill:#d97757,stroke:#141413,color:#fff
style OLS fill:#141413,stroke:#6a9bcc,color:#fff
&lt;/code>&lt;/pre>
&lt;p>The diagram shows three spatial channels and their corresponding parameters: $\rho$ (spatial lag of $y$), $\theta$ (spatial lag of $X$), and $\lambda$ (spatial lag of the error). Setting any of these to zero yields a nested model. The SDM is often the starting point for model selection because it nests the three most common models &amp;mdash; SAR, SLX, and SEM &amp;mdash; and the restrictions can be tested with standard Wald tests.&lt;/p>
&lt;hr>
&lt;h2 id="3-setup-and-data-loading">3. Setup and data loading&lt;/h2>
&lt;p>Before running any spatial models, we need the &lt;code>estout&lt;/code> package for table output and the &lt;code>spatwmat&lt;/code>/&lt;code>spatdiag&lt;/code> packages for LM diagnostic tests. If you have not installed them, uncomment the &lt;code>ssc install&lt;/code> and &lt;code>net install&lt;/code> lines below.&lt;/p>
&lt;pre>&lt;code class="language-stata">clear all
macro drop _all
set more off
* Install packages (uncomment if needed)
*ssc install estout, replace
*net install st0085_2, from(http://www.stata-journal.com/software/sj14-2)
&lt;/code>&lt;/pre>
&lt;h3 id="31-spatial-weight-matrix">3.1 Spatial weight matrix&lt;/h3>
&lt;p>The spatial weight matrix &lt;strong>W&lt;/strong> defines the neighborhood structure among the 49 Columbus neighborhoods. We use a Queen contiguity matrix where two neighborhoods are neighbors if they share a common border or vertex. The matrix is stored in a &lt;code>.dta&lt;/code> file and converted to an &lt;code>spmatrix&lt;/code> object with row-standardization &amp;mdash; meaning that each row sums to one, so the spatial lag of a variable equals the &lt;strong>weighted average&lt;/strong> among a neighborhood&amp;rsquo;s neighbors.&lt;/p>
&lt;pre>&lt;code class="language-stata">* Load Queen contiguity W matrix
use &amp;quot;https://github.com/quarcs-lab/data-open/raw/master/Columbus/columbus/Wqueen_fromStata_spmat.dta&amp;quot;, clear
gen id = _n
order id, first
spset id
spmatrix fromdata W = v*, normalize(row) replace
spmatrix summarize W
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Spatial-weighting matrix W
Dimensions: 49 x 49
Stored type: dense
Normalization: row
Summary statistics
-------------------------------------------
Min Mean Max N
-------------------------------------------
Nonzero .0625 .2049 .5000 236
All .0000 .0042 .5000 2401
-------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The &lt;code>spmatrix fromdata&lt;/code> command reads the columns of the loaded dataset and stores them as a spatial weight matrix object named &lt;code>W&lt;/code>. The &lt;code>normalize(row)&lt;/code> option applies row-standardization, and &lt;code>replace&lt;/code> overwrites any existing matrix with the same name. The matrix has 236 nonzero entries out of 2,401 total cells, meaning the average neighborhood has approximately $236 / 49 \approx 4.8$ neighbors.&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>Note:&lt;/strong> The companion &lt;code>analysis.do&lt;/code> file uses the longer name &lt;code>WqueenS_fromStata15&lt;/code> for the spatial weight matrix to match the original Colab notebook. In this tutorial, we use the shorter name &lt;code>W&lt;/code> for readability. Both names are interchangeable &amp;mdash; only the name passed to &lt;code>spmatrix fromdata&lt;/code> matters.&lt;/p>
&lt;/blockquote>
&lt;h3 id="32-generating-spatial-lags-of-x">3.2 Generating spatial lags of X&lt;/h3>
&lt;p>Before loading the crime data, we pre-compute the spatial lags of the explanatory variables ($W \cdot INC$ and $W \cdot HOVAL$) using Mata. These spatial lags represent each neighborhood&amp;rsquo;s &lt;strong>neighbors' average&lt;/strong> income and housing value, and will be used as explicit regressors in the SLX, SDM, SDEM, and GNS models.&lt;/p>
&lt;pre>&lt;code class="language-stata">* Load data and generate spatial lags of X manually
use &amp;quot;https://github.com/quarcs-lab/data-open/raw/master/Columbus/columbus/columbusDbase.dta&amp;quot;, clear
spset id
label var CRIME &amp;quot;Crime&amp;quot;
label var INC &amp;quot;Income&amp;quot;
label var HOVAL &amp;quot;House value&amp;quot;
* Compute W*X using Mata (bypasses spregress ivarlag)
mata: spmatrix_matafromsp(W_mata, id_vec, &amp;quot;W&amp;quot;)
mata: st_view(inc=., ., &amp;quot;INC&amp;quot;)
mata: st_view(hoval=., ., &amp;quot;HOVAL&amp;quot;)
gen double W_INC = .
gen double W_HOVAL = .
mata: st_store(., &amp;quot;W_INC&amp;quot;, W_mata * inc)
mata: st_store(., &amp;quot;W_HOVAL&amp;quot;, W_mata * hoval)
label var W_INC &amp;quot;W * Income&amp;quot;
label var W_HOVAL &amp;quot;W * House value&amp;quot;
&lt;/code>&lt;/pre>
&lt;blockquote>
&lt;p>&lt;strong>Why compute W*X manually?&lt;/strong> Stata&amp;rsquo;s &lt;code>spregress&lt;/code> command provides the &lt;code>ivarlag()&lt;/code> option to include spatial lags of explanatory variables. However, this option may produce incorrect coefficient signs in some Stata versions. Computing $WX$ explicitly using Mata and including the result as a regular regressor is more transparent and produces results consistent with Elhorst (2014) and PySAL&amp;rsquo;s &lt;code>spreg&lt;/code> package.&lt;/p>
&lt;/blockquote>
&lt;h3 id="33-summary-statistics">3.3 Summary statistics&lt;/h3>
&lt;pre>&lt;code class="language-stata">summarize CRIME INC HOVAL
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Variable | Obs Mean Std. dev. Min Max
-------------+---------------------------------------------------------
CRIME | 49 35.1288 16.5647 .1783 68.8920
INC | 49 14.3765 5.7575 3.7240 27.8966
HOVAL | 49 38.4362 18.4661 5.0000 96.4000
&lt;/code>&lt;/pre>
&lt;h3 id="34-variables">3.4 Variables&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Variable&lt;/th>
&lt;th>Description&lt;/th>
&lt;th>Mean&lt;/th>
&lt;th>Std. Dev.&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>CRIME&lt;/code>&lt;/td>
&lt;td>Residential burglaries and vehicle thefts per 1,000 households&lt;/td>
&lt;td>35.13&lt;/td>
&lt;td>16.56&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>INC&lt;/code>&lt;/td>
&lt;td>Household income (\$1,000)&lt;/td>
&lt;td>14.38&lt;/td>
&lt;td>5.76&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>HOVAL&lt;/code>&lt;/td>
&lt;td>Housing value (\$1,000)&lt;/td>
&lt;td>38.44&lt;/td>
&lt;td>18.47&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Mean crime is 35.13 incidents per 1,000 households, with substantial variation across neighborhoods (standard deviation of 16.56, ranging from near zero to 68.89). Mean household income is \$14,380 and mean housing value is \$38,440. The wide range of both income (\$3,724 to \$27,897) and housing value (\$5,000 to \$96,400) reflects the considerable socioeconomic heterogeneity across Columbus neighborhoods, providing sufficient variation to estimate the effects of these variables on crime.&lt;/p>
&lt;hr>
&lt;h2 id="4-ols-baseline-and-spatial-diagnostics">4. OLS baseline and spatial diagnostics&lt;/h2>
&lt;h3 id="41-ols-regression">4.1 OLS regression&lt;/h3>
&lt;p>Before introducing any spatial structure, we estimate a standard OLS regression of crime on income and housing value. This provides a non-spatial benchmark against which all subsequent models will be compared.&lt;/p>
&lt;pre>&lt;code class="language-stata">regress CRIME INC HOVAL
eststo OLS
estat ic
mat s = r(S)
quietly estadd scalar AIC = s[1,5]
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Source | SS df MS Number of obs = 49
-------------+---------------------------------- F(2, 46) = 28.39
Model | 5765.1588 2 2882.5794 Prob &amp;gt; F = 0.0000
Residual | 4670.9753 46 101.5429 R-squared = 0.5524
-------------+---------------------------------- Adj R-squared = 0.5330
Total | 10436.1341 48 217.4194 Root MSE = 10.0769
------------------------------------------------------------------------------
CRIME | Coefficient Std. err. t P&amp;gt;|t| [95% conf. interval]
-------------+----------------------------------------------------------------
INC | -1.5973 .3341 -4.78 0.000 -2.2699 -.9247
HOVAL | -0.2739 .1032 -2.65 0.011 -0.4817 -.0661
_cons | 68.6190 4.7355 14.49 0.000 59.0876 78.1504
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>OLS estimates that each additional \$1,000 in household income is associated with a reduction of &lt;strong>1.60 crimes&lt;/strong> per 1,000 households, and each additional \$1,000 in housing value is associated with a reduction of &lt;strong>0.27 crimes&lt;/strong>. Both coefficients are statistically significant, and the model explains about &lt;strong>55%&lt;/strong> of the variation in crime rates across neighborhoods (R-squared = 0.552). The intercept of 68.62 represents the predicted crime rate for a hypothetical neighborhood with zero income and zero housing value. However, OLS assumes that crime in one neighborhood is independent of conditions in adjacent neighborhoods &amp;mdash; an assumption we now test directly.&lt;/p>
&lt;h3 id="42-morans-i-test">4.2 Moran&amp;rsquo;s I test&lt;/h3>
&lt;p>Moran&amp;rsquo;s I is the most widely used test for spatial autocorrelation. Applied to OLS residuals, it tests whether the residuals in nearby neighborhoods are more similar (positive spatial autocorrelation) or more dissimilar (negative spatial autocorrelation) than expected under spatial independence. The test statistic is:&lt;/p>
&lt;p>$$I = \frac{N}{S_0} \cdot \frac{e' W e}{e' e}$$&lt;/p>
&lt;p>where $e$ is the vector of OLS residuals, $W$ is the row-standardized spatial weight matrix, $N$ is the number of observations, and $S_0$ is the sum of all elements of $W$. Under the null hypothesis of no spatial autocorrelation, $I$ follows an approximately standard normal distribution after standardization.&lt;/p>
&lt;pre>&lt;code class="language-stata">regress CRIME INC HOVAL
estat moran, errorlag(W)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Moran test for spatial autocorrelation in the error
H0: Error is i.i.d.
I = 0.2222
E(I) = -0.0208
Mean = -0.0208
Sd(I) = 0.0856
z = 2.8391
p-value = 0.0045
&lt;/code>&lt;/pre>
&lt;p>Moran&amp;rsquo;s I is &lt;strong>0.222&lt;/strong> with a z-statistic of &lt;strong>2.84&lt;/strong> (p = 0.005), providing strong evidence of &lt;strong>positive spatial autocorrelation&lt;/strong> in the OLS residuals. Neighborhoods with high unexplained crime tend to cluster near other neighborhoods with high unexplained crime, and vice versa. This violates the OLS assumption of independent errors and motivates the use of spatial regression models. The positive sign of Moran&amp;rsquo;s I is consistent with crime diffusion &amp;mdash; criminal activity in one neighborhood spills over into adjacent areas.&lt;/p>
&lt;h3 id="43-lm-tests-for-spatial-specification">4.3 LM tests for spatial specification&lt;/h3>
&lt;p>While Moran&amp;rsquo;s I confirms the presence of spatial autocorrelation, it does not indicate the &lt;strong>form&lt;/strong> of the spatial dependence. The Lagrange Multiplier (LM) tests proposed by Anselin (1988) test separately for the spatial lag ($\rho Wy$) and spatial error ($\lambda Wu$) specifications. The robust versions of these tests remain valid even when the alternative specification is also present.&lt;/p>
&lt;pre>&lt;code class="language-stata">* Create compatible W matrix for spatdiag
spatwmat using &amp;quot;https://github.com/quarcs-lab/data-open/raw/master/Columbus/columbus/Wqueen_fromStata_spmat.dta&amp;quot;, ///
name(Wcompat) eigenval(eWcompat) standardize
quietly regress CRIME INC HOVAL
spatdiag, weights(Wcompat)
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Spatial error:
Moran's I = 0.2055 Prob = 0.0068
Lagrange multiplier = 5.3282 Prob = 0.0210
Robust LM = 2.1901 Prob = 0.1389
Spatial lag:
Lagrange multiplier = 3.3954 Prob = 0.0654
Robust LM = 0.2572 Prob = 0.6121
&lt;/code>&lt;/pre>
&lt;p>The standard LM test for the spatial error ($\lambda$) is significant at the 5% level (LM = &lt;strong>5.33&lt;/strong>, p = 0.021), while the standard LM test for the spatial lag ($\rho$) is marginally significant at the 10% level (LM = &lt;strong>3.40&lt;/strong>, p = 0.065). The robust tests provide further guidance: the robust LM-error is &lt;strong>2.19&lt;/strong> (p = 0.139) and the robust LM-lag is only &lt;strong>0.26&lt;/strong> (p = 0.612).&lt;/p>
&lt;p>Following the Anselin (2005) decision rule &amp;mdash; compare the standard LM tests first, then use the robust tests to break ties &amp;mdash; the evidence favors the &lt;strong>SEM&lt;/strong> specification. The standard LM-error is larger and more significant than the standard LM-lag, and the robust LM-error remains larger than the robust LM-lag. The decision tree below summarizes this logic. However, as we will see, the full model taxonomy reveals a more nuanced picture.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph TD
MI[&amp;quot;&amp;lt;b&amp;gt;Moran's I&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;I = 0.222, p = 0.005&amp;lt;br/&amp;gt;Significant&amp;quot;]
LM[&amp;quot;&amp;lt;b&amp;gt;Standard LM Tests&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;LM-error = 5.33 (p = 0.021)&amp;lt;br/&amp;gt;LM-lag = 3.40 (p = 0.065)&amp;quot;]
RLM[&amp;quot;&amp;lt;b&amp;gt;Robust LM Tests&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Robust LM-error = 2.19&amp;lt;br/&amp;gt;Robust LM-lag = 0.26&amp;quot;]
SEM_d[&amp;quot;&amp;lt;b&amp;gt;SEM Preferred&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Error specification&amp;lt;br/&amp;gt;dominates&amp;quot;]
MI --&amp;gt;|&amp;quot;Spatial dependence?&amp;quot;| LM
LM --&amp;gt;|&amp;quot;Both significant?&amp;quot;| RLM
RLM --&amp;gt;|&amp;quot;Error &amp;gt; Lag&amp;quot;| SEM_d
style MI fill:#6a9bcc,stroke:#141413,color:#fff
style LM fill:#d97757,stroke:#141413,color:#fff
style RLM fill:#00d4c8,stroke:#141413,color:#141413
style SEM_d fill:#141413,stroke:#d97757,color:#fff
&lt;/code>&lt;/pre>
&lt;hr>
&lt;h2 id="5-first-generation-spatial-models">5. First-generation spatial models&lt;/h2>
&lt;h3 id="51-sar-spatial-autoregressive--spatial-lag">5.1 SAR (Spatial Autoregressive / Spatial Lag)&lt;/h3>
&lt;p>The SAR model adds a spatial lag of the dependent variable to the OLS specification. It assumes that crime in a neighborhood depends directly on the crime rate in adjacent neighborhoods &amp;mdash; a &amp;ldquo;contagion&amp;rdquo; or &amp;ldquo;diffusion&amp;rdquo; channel where high crime in one area breeds crime in neighboring areas.&lt;/p>
&lt;p>$$y = \rho W y + X \beta + \varepsilon$$&lt;/p>
&lt;p>The parameter $\rho$ measures the strength of this spatial feedback. Because $Wy$ is endogenous (it depends on $y$, which depends on $\varepsilon$), OLS estimation would be inconsistent. We use maximum likelihood estimation via &lt;code>spregress&lt;/code>.&lt;/p>
&lt;pre>&lt;code class="language-stata">spregress CRIME INC HOVAL, ml dvarlag(W)
eststo SAR
estat ic
mat s = r(S)
quietly estadd scalar AIC = s[1,5]
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Spatial autoregressive model Number of obs = 49
Maximum likelihood estimates Wald chi2(2) = 54.83
Prob &amp;gt; chi2 = 0.0000
Log-likelihood = -184.926 Pseudo R2 = 0.5830
------------------------------------------------------------------------------
CRIME | Coefficient Std. err. z P&amp;gt;|z| [95% conf. interval]
-------------+----------------------------------------------------------------
CRIME |
INC | -1.0312 .3359 -3.07 0.002 -1.6897 -.3728
HOVAL | -0.2654 .0922 -2.88 0.004 -0.4461 -.0847
_cons | 45.0719 7.8406 5.75 0.000 29.7046 60.4392
-------------+----------------------------------------------------------------
W |
CRIME | 0.4283 .1228 3.49 0.000 0.1875 0.6690
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The spatial autoregressive parameter $\rho$ is &lt;strong>0.428&lt;/strong> (z = 3.49, p &amp;lt; 0.001), indicating substantial positive spatial dependence. After accounting for the spatial lag, the own income coefficient drops to &lt;strong>-1.03&lt;/strong> (from -1.60 in OLS), while the housing value coefficient remains similar at &lt;strong>-0.27&lt;/strong>. The reduction in the income coefficient suggests that part of what OLS attributed to income was actually capturing spatial spillover effects that are now absorbed by $\rho$.&lt;/p>
&lt;p>However, the raw coefficients in the SAR model do not have the same interpretation as OLS coefficients because the spatial lag creates a &lt;strong>feedback loop&lt;/strong>: a change in income in one neighborhood affects its crime, which affects its neighbors' crime, which feeds back to the original neighborhood. The proper interpretation requires decomposing effects into direct, indirect, and total components.&lt;/p>
&lt;pre>&lt;code class="language-stata">estat impact
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Coefficient Std. err. z P&amp;gt;|z|
-------------------------------------------------------------------
INC
Direct | -1.1024 .3486 -3.16 0.002
Indirect | -0.7594 .3712 -2.05 0.041
Total | -1.8618 .5803 -3.21 0.001
-------------------------------------------------------------------
HOVAL
Direct | -0.2838 .0983 -2.89 0.004
Indirect | -0.1954 .1123 -1.74 0.082
Total | -0.4792 .1722 -2.78 0.005
-------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The &lt;strong>direct effect&lt;/strong> of income is -1.10, meaning that a \$1,000 increase in a neighborhood&amp;rsquo;s own income reduces its crime by 1.10 incidents per 1,000 households. The &lt;strong>indirect (spillover) effect&lt;/strong> is -0.76 and statistically significant (p = 0.041), meaning that when all neighboring neighborhoods experience a \$1,000 income increase, the focal neighborhood&amp;rsquo;s crime drops by an additional 0.76 incidents through the spatial feedback channel. The &lt;strong>total effect&lt;/strong> of income is -1.86, larger than the OLS estimate of -1.60, revealing that OLS understates the total impact of income on crime. However, a key limitation of the SAR is that the ratio between the indirect and direct effect is the same for every variable ($\delta / (1 - \delta) \approx 0.75$), which may be overly restrictive.&lt;/p>
&lt;h3 id="52-sem-spatial-error-model">5.2 SEM (Spatial Error Model)&lt;/h3>
&lt;p>The SEM assumes that spatial dependence operates through the error term rather than through a direct contagion channel. Spatially correlated unobservable factors &amp;mdash; such as local policing strategies, community organizations, or land use patterns &amp;mdash; generate correlated residuals across adjacent neighborhoods.&lt;/p>
&lt;p>$$y = X \beta + u, \quad u = \lambda W u + \varepsilon$$&lt;/p>
&lt;p>The parameter $\lambda$ measures the degree of spatial autocorrelation in the error term. Unlike the SAR, the SEM does not produce indirect (spillover) effects &amp;mdash; the spatial dependence is treated as a nuisance rather than a substantive economic channel.&lt;/p>
&lt;pre>&lt;code class="language-stata">spregress CRIME INC HOVAL, ml errorlag(W)
eststo SEM
estat ic
mat s = r(S)
quietly estadd scalar AIC = s[1,5]
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Spatial error model Number of obs = 49
Maximum likelihood estimates Wald chi2(2) = 50.51
Prob &amp;gt; chi2 = 0.0000
Log-likelihood = -184.379 Pseudo R2 = 0.5877
------------------------------------------------------------------------------
CRIME | Coefficient Std. err. z P&amp;gt;|z| [95% conf. interval]
-------------+----------------------------------------------------------------
CRIME |
INC | -0.9376 .3393 -2.76 0.006 -1.6027 -.2726
HOVAL | -0.3023 .0909 -3.32 0.001 -0.4805 -.1241
_cons | 59.6228 5.4722 10.90 0.000 48.8975 70.3481
-------------+----------------------------------------------------------------
W |
lambda | 0.5623 .1330 4.23 0.000 0.3017 0.8230
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The spatial error parameter $\lambda$ is &lt;strong>0.562&lt;/strong> (z = 4.23, p &amp;lt; 0.001), confirming substantial spatial autocorrelation in the unobservables. The income coefficient is &lt;strong>-0.94&lt;/strong>, further attenuated from the OLS estimate, and the housing value coefficient is &lt;strong>-0.30&lt;/strong>, slightly larger in magnitude than OLS. The log-likelihood of -184.38 is higher than OLS (-187.38), confirming the spatial error structure improves fit.&lt;/p>
&lt;pre>&lt;code class="language-stata">estat impact
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Coefficient Std. err. z P&amp;gt;|z|
-------------------------------------------------------------------
INC
Direct | -0.9376 .3393 -2.76 0.006
Indirect | 0.0000 . . .
Total | -0.9376 .3393 -2.76 0.006
-------------------------------------------------------------------
HOVAL
Direct | -0.3023 .0909 -3.32 0.001
Indirect | 0.0000 . . .
Total | -0.3023 .0909 -3.32 0.001
-------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>As expected, the SEM produces &lt;strong>zero indirect effects&lt;/strong> by construction. In the SEM, spatial dependence is a nuisance in the error term, not a substantive spillover channel. The direct and total effects are identical. If one believes that crime spillovers are substantively important &amp;mdash; for example, through displacement or diffusion &amp;mdash; the SEM&amp;rsquo;s assumption that all spatial dependence is in the errors is overly restrictive. As we will see in Sections 6 and 8, models that include $WX\theta$ terms reveal a significant negative spillover of neighbors' income on crime, which the SEM cannot detect.&lt;/p>
&lt;hr>
&lt;h2 id="6-models-with-spatial-lags-of-x">6. Models with spatial lags of X&lt;/h2>
&lt;h3 id="61-slx-spatial-lag-of-x">6.1 SLX (Spatial Lag of X)&lt;/h3>
&lt;p>The SLX model includes spatial lags of the explanatory variables but no spatial lag of $y$ and no spatial error. It captures &lt;strong>local spillovers&lt;/strong> &amp;mdash; the idea that a neighborhood&amp;rsquo;s crime depends on its neighbors' income and housing values &amp;mdash; without the global feedback mechanism of the SAR.&lt;/p>
&lt;p>$$y = X \beta + W X \theta + \varepsilon$$&lt;/p>
&lt;p>The $\theta$ coefficients measure the direct impact of neighbors' characteristics on the focal neighborhood&amp;rsquo;s crime. Unlike the SAR, the SLX does not generate a spatial multiplier &amp;mdash; the spillover effects are localized to immediate neighbors. Since the SLX has no spatial autoregressive or error component, it can be estimated by OLS with the pre-computed $W \cdot INC$ and $W \cdot HOVAL$ variables as additional regressors.&lt;/p>
&lt;pre>&lt;code class="language-stata">regress CRIME INC HOVAL W_INC W_HOVAL
eststo SLX
estat ic
mat s = r(S)
quietly estadd scalar AIC = s[1,5]
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Source | SS df MS Number of obs = 49
-------------+---------------------------------- F(4, 44) = 17.24
Model | 6373.4060 4 1593.35150 Prob &amp;gt; F = 0.0000
Residual | 4062.7281 44 92.33473 R-squared = 0.6105
-------------+---------------------------------- Adj R-squared = 0.5751
Total | 10436.1341 48 217.4194 Root MSE = 9.6090
------------------------------------------------------------------------------
CRIME | Coefficient Std. err. t P&amp;gt;|t| [95% conf. interval]
-------------+----------------------------------------------------------------
INC | -1.0974 .3738 -2.94 0.005 -1.8509 -.3438
HOVAL | -0.2944 .1017 -2.90 0.006 -0.4993 -.0895
W_INC | -1.3987 .5601 -2.50 0.016 -2.5275 -.2700
W_HOVAL | 0.2148 .2079 1.03 0.307 -0.2045 0.6342
_cons | 74.5534 6.7156 11.10 0.000 61.0167 88.0901
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The spatial lag of income ($W \cdot INC$) is &lt;strong>-1.40&lt;/strong> and statistically significant (t = -2.50, p = 0.016), meaning that higher average income among a neighborhood&amp;rsquo;s neighbors is associated with &lt;strong>lower&lt;/strong> crime in the focal neighborhood. This is economically intuitive: neighborhoods surrounded by wealthier areas benefit from reduced crime, possibly through better public services, lower criminal opportunity, or social spillovers. The spatial lag of housing value ($W \cdot HOVAL$) is &lt;strong>+0.21&lt;/strong> but statistically insignificant (p = 0.307). The own-variable coefficients are INC at &lt;strong>-1.10&lt;/strong> and HOVAL at &lt;strong>-0.29&lt;/strong>, both highly significant. The log-likelihood of -184.0 is higher than OLS (-187.4), and the LR-test of the SLX versus OLS is 6.8 with 2 df (critical value 5.99), meaning the OLS model needs to be rejected in favor of the SLX.&lt;/p>
&lt;p>The direct and indirect effects in the SLX correspond directly to $\beta$ and $\theta$ because there is no spatial multiplier:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Direct&lt;/th>
&lt;th>Indirect&lt;/th>
&lt;th>Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>INC&lt;/strong>&lt;/td>
&lt;td>-1.10***&lt;/td>
&lt;td>-1.40**&lt;/td>
&lt;td>-2.50***&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>HOVAL&lt;/strong>&lt;/td>
&lt;td>-0.29***&lt;/td>
&lt;td>+0.21&lt;/td>
&lt;td>-0.08&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The total effect of income is &lt;strong>-2.50&lt;/strong>, much larger than the OLS estimate of -1.60, revealing that a substantial portion of the income effect operates through the neighbors' income channel. For housing value, the positive but insignificant indirect effect partially offsets the negative direct effect, suggesting that the crime-reducing effect of housing value is primarily a within-neighborhood phenomenon.&lt;/p>
&lt;h3 id="62-sdm-spatial-durbin-model">6.2 SDM (Spatial Durbin Model)&lt;/h3>
&lt;p>The SDM combines the spatial lag of $y$ from the SAR with the spatial lags of $X$ from the SLX. It is the most popular &amp;ldquo;general purpose&amp;rdquo; spatial model because it nests SAR, SLX, and SEM as special cases, enabling formal specification testing.&lt;/p>
&lt;p>$$y = \rho W y + X \beta + W X \theta + \varepsilon$$&lt;/p>
&lt;p>The SDM captures spillovers through two channels: a &lt;strong>global feedback&lt;/strong> channel ($\rho Wy$, where shocks propagate through the entire network) and a &lt;strong>local&lt;/strong> channel ($WX\theta$, where neighbors' characteristics directly affect local outcomes). We include $W \cdot INC$ and $W \cdot HOVAL$ as regular regressors alongside the spatial lag of crime.&lt;/p>
&lt;pre>&lt;code class="language-stata">spregress CRIME INC HOVAL W_INC W_HOVAL, ml dvarlag(W)
eststo SDM
estat ic
mat s = r(S)
quietly estadd scalar AIC = s[1,5]
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Spatial Durbin model Number of obs = 49
Maximum likelihood estimates Wald chi2(4) = 56.79
Prob &amp;gt; chi2 = 0.0000
Log-likelihood = -181.639 Pseudo R2 = 0.6037
------------------------------------------------------------------------------
CRIME | Coefficient Std. err. z P&amp;gt;|z| [95% conf. interval]
-------------+----------------------------------------------------------------
CRIME |
INC | -0.9199 .3347 -2.75 0.006 -1.5758 -.2639
HOVAL | -0.2971 .0904 -3.29 0.001 -0.4742 -.1200
W_INC | -0.5839 .5742 -1.02 0.309 -1.7094 0.5415
W_HOVAL | 0.2577 .1872 1.38 0.169 -0.1092 0.6247
-------------+----------------------------------------------------------------
W |
CRIME | 0.4035 .1613 2.50 0.012 0.0873 0.7197
_cons | 44.3200 13.0455 3.40 0.001 18.7512 69.8888
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The spatial autoregressive parameter $\rho$ is &lt;strong>0.404&lt;/strong> (z = 2.50, p = 0.012), close to the SAR estimate. The own income coefficient is &lt;strong>-0.92&lt;/strong> and housing value is &lt;strong>-0.30&lt;/strong>. The spatial lag of income ($W \cdot INC = -0.58$) is negative but individually insignificant (p = 0.309), while the spatial lag of housing value ($W \cdot HOVAL = +0.26$) is positive and also insignificant (p = 0.169). Although the $\theta$ terms are individually insignificant, their joint significance is tested formally via the specification tests in Section 7.&lt;/p>
&lt;pre>&lt;code class="language-stata">estat impact
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Coefficient Std. err. z P&amp;gt;|z|
-------------------------------------------------------------------
INC
Direct | -1.0250 .3350 -3.06 0.002
Indirect | -1.4959 .8060 -1.86 0.064
Total | -2.5209 .8820 -2.86 0.004
-------------------------------------------------------------------
HOVAL
Direct | -0.2820 .0900 -3.13 0.002
Indirect | 0.2158 .2990 0.72 0.470
Total | -0.0661 .3050 -0.22 0.828
-------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The direct effect of income is &lt;strong>-1.03&lt;/strong>, similar to the SAR. The indirect (spillover) effect of income is &lt;strong>-1.50&lt;/strong> and marginally significant (p = 0.064), much larger than in the SAR (-0.76), because the SDM accounts for both the spatial feedback channel ($\rho$) and the direct effect of neighbors' income ($\theta_{INC}$). The total effect of income is &lt;strong>-2.52&lt;/strong>, substantially larger than the SAR&amp;rsquo;s -1.86. For housing value, the indirect effect is &lt;strong>+0.22&lt;/strong> (insignificant), suggesting that neighbors' housing values do not generate meaningful crime spillovers once the global feedback is accounted for.&lt;/p>
&lt;hr>
&lt;h2 id="7-specification-tests-from-sdm">7. Specification tests from SDM&lt;/h2>
&lt;p>The SDM nests SAR, SLX, and SEM as special cases. Before accepting the full SDM, we test whether the data supports simplifying to one of these more parsimonious specifications. We re-estimate the SDM and apply three tests. We use both &lt;strong>Wald tests&lt;/strong> (from the Stata estimation) and &lt;strong>LR tests&lt;/strong> (comparing log-likelihoods across models), following Elhorst (2014, Section 2.9).&lt;/p>
&lt;pre>&lt;code class="language-stata">quietly spregress CRIME INC HOVAL W_INC W_HOVAL, ml dvarlag(W)
&lt;/code>&lt;/pre>
&lt;h3 id="71-reduce-to-slx-test-rho--0">7.1 Reduce to SLX? (test $\rho = 0$)&lt;/h3>
&lt;p>The SLX model restricts $\rho = 0$ &amp;mdash; there is no spatial autoregressive feedback. Under SLX, neighbors' characteristics affect local crime directly, but there is no contagion through the spatial lag of crime itself.&lt;/p>
&lt;pre>&lt;code class="language-stata">* Wald test: Reduce to SLX? (NO if p &amp;lt; 0.05)
test ([W]CRIME = 0)
&lt;/code>&lt;/pre>
&lt;p>The test &lt;strong>rejects&lt;/strong> the SLX restriction at the 1% level. The spatial autoregressive parameter $\rho$ is significantly different from zero, meaning that the global feedback channel is an important feature of the data. The LR test confirms this: $-2(\text{LogL}_{SLX} - \text{LogL}_{SDM}) \approx 7.4$ with 1 df (critical value 3.84). Dropping $\rho$ would misspecify the model.&lt;/p>
&lt;h3 id="72-reduce-to-sar-test-theta--0">7.2 Reduce to SAR? (test $\theta = 0$)&lt;/h3>
&lt;p>The SAR model restricts $\theta = 0$ &amp;mdash; the spatial lags of the explanatory variables are zero. Under SAR, only neighbors' crime levels matter, not their incomes or housing values directly.&lt;/p>
&lt;pre>&lt;code class="language-stata">* Wald test: Reduce to SAR? (NO if p &amp;lt; 0.05)
test ([CRIME]W_INC = 0) ([CRIME]W_HOVAL = 0)
&lt;/code>&lt;/pre>
&lt;p>The test &lt;strong>fails to reject&lt;/strong> the SAR restriction. The spatial lags of income and housing value are jointly insignificant, suggesting that the SAR specification may be adequate. The LR test also fails to reject: $-2(\text{LogL}_{SAR} - \text{LogL}_{SDM}) \approx 2.0$ with 2 df (critical value 5.99). However, this does not mean the $\theta$ terms are unimportant &amp;mdash; it may simply reflect insufficient power with only 49 observations.&lt;/p>
&lt;h3 id="73-reduce-to-sem-common-factor-restriction">7.3 Reduce to SEM? (common factor restriction)&lt;/h3>
&lt;p>The SEM imposes the common factor restriction $\theta + \rho \beta = 0$. Under this restriction, the apparent spatial lag effects are entirely attributable to spatially correlated errors rather than substantive spillovers.&lt;/p>
&lt;pre>&lt;code class="language-stata">* Wald test: Reduce to SEM? (NO if p &amp;lt; 0.05)
testnl ([CRIME]W_INC = -[W]CRIME * [CRIME]INC) ([CRIME]W_HOVAL = -[W]CRIME * [CRIME]HOVAL)
&lt;/code>&lt;/pre>
&lt;p>The test &lt;strong>fails to reject&lt;/strong> the SEM common factor restriction. The LR test yields $-2(\text{LogL}_{SEM} - \text{LogL}_{SDM}) \approx 4.0$ with 2 df (critical value 5.99), confirming the SEM is not rejected. This means that the spatial dependence in the Columbus data could be interpreted as arising from spatially correlated unobservables rather than substantive crime spillovers.&lt;/p>
&lt;h3 id="74-sdm-vs-slx-the-key-comparison">7.4 SDM vs. SLX: the key comparison&lt;/h3>
&lt;p>The SDM clearly outperforms the SLX. The SLX is estimated by OLS (no spatial lag of $y$), while the SDM adds $\rho Wy$ which is highly significant ($\rho = 0.40$, z = 2.50). This spatial feedback term substantially improves the fit. The SLX alone, despite its significant $W \cdot INC$ coefficient, fails to capture the global spatial feedback that the $\rho$ parameter provides.&lt;/p>
&lt;h3 id="75-summary-of-specification-tests">7.5 Summary of specification tests&lt;/h3>
&lt;pre>&lt;code class="language-mermaid">graph TD
SDM[&amp;quot;&amp;lt;b&amp;gt;Spatial Durbin Model (SDM)&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;Starting point&amp;quot;]
SLX[&amp;quot;&amp;lt;b&amp;gt;SLX&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;ρ = 0&amp;lt;br/&amp;gt;Rejected&amp;quot;]
SAR[&amp;quot;&amp;lt;b&amp;gt;SAR&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;θ = 0&amp;lt;br/&amp;gt;Not rejected&amp;quot;]
SEM[&amp;quot;&amp;lt;b&amp;gt;SEM&amp;lt;/b&amp;gt;&amp;lt;br/&amp;gt;θ + ρβ = 0&amp;lt;br/&amp;gt;Not rejected&amp;quot;]
SDM --&amp;gt;|&amp;quot;LR ≈ 7.4, 1 df&amp;quot;| SLX
SDM --&amp;gt;|&amp;quot;LR ≈ 2.0, 2 df&amp;quot;| SAR
SDM --&amp;gt;|&amp;quot;LR ≈ 4.0, 2 df&amp;quot;| SEM
style SDM fill:#00d4c8,stroke:#141413,color:#141413
style SLX fill:#d97757,stroke:#141413,color:#fff
style SAR fill:#6a9bcc,stroke:#141413,color:#fff
style SEM fill:#6a9bcc,stroke:#141413,color:#fff
&lt;/code>&lt;/pre>
&lt;p>The specification tests tell a nuanced story. Both the SAR restriction ($\theta = 0$) and the SEM common factor restriction ($\theta + \rho\beta = 0$) cannot be rejected at the 5% level. Only the SLX restriction ($\rho = 0$) is rejected, confirming that the spatial autoregressive parameter $\rho$ is essential. This leaves both SAR and SEM as statistically adequate simplifications. However, as Elhorst (2014) points out, the SAR&amp;rsquo;s constraint that the ratio between the indirect and direct effect is the same for every variable is economically restrictive. An alternative path is to consider the &lt;strong>SDEM&lt;/strong>, which also nests SLX and SEM (see Section 8.1).&lt;/p>
&lt;hr>
&lt;h2 id="8-extended-spatial-models">8. Extended spatial models&lt;/h2>
&lt;h3 id="81-sdem-spatial-durbin-error-model">8.1 SDEM (Spatial Durbin Error Model)&lt;/h3>
&lt;p>The SDEM combines the spatial lags of X from the SLX with the spatial error structure of the SEM. It captures &lt;strong>local spillovers&lt;/strong> through $WX\theta$ and &lt;strong>spatially correlated unobservables&lt;/strong> through $\lambda Wu$, but does not include the global feedback mechanism of $\rho Wy$.&lt;/p>
&lt;p>$$y = X \beta + W X \theta + u, \quad u = \lambda W u + \varepsilon$$&lt;/p>
&lt;p>The SDEM is sometimes preferred over the SDM when one believes that spillovers are local (limited to immediate neighbors) rather than global (propagating through the entire network). Like the SDM, the SDEM nests both the SLX ($\lambda = 0$) and the SEM ($\theta = 0$).&lt;/p>
&lt;pre>&lt;code class="language-stata">spregress CRIME INC HOVAL W_INC W_HOVAL, ml errorlag(W)
eststo SDEM
estat ic
mat s = r(S)
quietly estadd scalar AIC = s[1,5]
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">Spatial Durbin error model Number of obs = 49
Maximum likelihood estimates Wald chi2(4) = 66.92
Prob &amp;gt; chi2 = 0.0000
Log-likelihood = -181.779 Pseudo R2 = 0.5988
------------------------------------------------------------------------------
CRIME | Coefficient Std. err. z P&amp;gt;|z| [95% conf. interval]
-------------+----------------------------------------------------------------
CRIME |
INC | -1.0523 .3213 -3.28 0.001 -1.6821 -.4225
HOVAL | -0.2782 .0911 -3.05 0.002 -0.4568 -.0996
W_INC | -1.2049 .5736 -2.10 0.036 -2.3292 -.0806
W_HOVAL | 0.1312 .2072 0.63 0.527 -0.2749 0.5374
-------------+----------------------------------------------------------------
W |
lambda | 0.4036 .1635 2.47 0.014 0.0832 0.7241
_cons | 73.6451 8.7239 8.44 0.000 56.5465 90.7437
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The spatial error parameter $\lambda$ is &lt;strong>0.404&lt;/strong> (z = 2.47, p = 0.014), confirming that spatially correlated unobservables are important. Crucially, the spatial lag of income $W \cdot INC$ is &lt;strong>-1.20&lt;/strong> and statistically significant (z = -2.10, p = 0.036). This is a key result: even after controlling for spatially correlated errors, neighbors' average income significantly reduces a neighborhood&amp;rsquo;s crime rate. The spatial lag of housing value ($W \cdot HOVAL = +0.13$) remains insignificant (p = 0.527).&lt;/p>
&lt;p>In the SDEM, the indirect effects correspond directly to the $\theta$ coefficients because there is no spatial multiplier (no $\rho Wy$ term):&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Direct&lt;/th>
&lt;th>Indirect&lt;/th>
&lt;th>Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>INC&lt;/strong>&lt;/td>
&lt;td>-1.05***&lt;/td>
&lt;td>-1.20**&lt;/td>
&lt;td>-2.26***&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>HOVAL&lt;/strong>&lt;/td>
&lt;td>-0.28***&lt;/td>
&lt;td>+0.13&lt;/td>
&lt;td>-0.15&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The indirect effect of income is &lt;strong>-1.20&lt;/strong> (significant at 5%), indicating that a \$1,000 increase in neighbors' average income reduces crime in the focal neighborhood by 1.20 incidents per 1,000 households. This is a substantively important local spillover: neighborhoods benefit from having wealthier neighbors through reduced crime. The total effect of income is &lt;strong>-2.26&lt;/strong>, even larger than the OLS estimate of -1.60, because OLS ignores the neighbors' income channel entirely.&lt;/p>
&lt;h3 id="82-sac--sarar">8.2 SAC / SARAR&lt;/h3>
&lt;p>The SAC (also called SARAR) model includes both a spatial lag of the dependent variable and a spatial error term, but no spatial lags of $X$. It separates two forms of spatial dependence: substantive spillovers through $\rho Wy$ and nuisance dependence through $\lambda Wu$.&lt;/p>
&lt;p>$$y = \rho W y + X \beta + u, \quad u = \lambda W u + \varepsilon$$&lt;/p>
&lt;pre>&lt;code class="language-stata">spregress CRIME INC HOVAL, ml dvarlag(W) errorlag(W)
eststo SAC
estat ic
mat s = r(S)
quietly estadd scalar AIC = s[1,5]
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">SAC model Number of obs = 49
Wald chi2(2) = 54.77
Log-likelihood = -182.581 Prob &amp;gt; chi2 = 0.0000
------------------------------------------------------------------------------
CRIME | Coefficient Std. err. z P&amp;gt;|z| [95% conf. interval]
-------------+----------------------------------------------------------------
CRIME |
INC | -1.0260 .3268 -3.14 0.002 -1.6666 -.3854
HOVAL | -0.2820 .0900 -3.13 0.002 -0.4584 -.1056
_cons | 47.8000 9.8900 4.83 0.000 28.4159 67.1841
-------------+----------------------------------------------------------------
W |
CRIME | 0.4780 .1622 2.95 0.003 0.1601 0.7959
lambda | 0.1660 .2969 0.56 0.576 -0.4158 0.7478
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>In the SAC model, $\rho$ is &lt;strong>0.478&lt;/strong> (z = 2.95, p = 0.003) and $\lambda$ is &lt;strong>0.166&lt;/strong> (z = 0.56, p = 0.576). When both are included, $\rho$ remains significant but $\lambda$ becomes insignificant, suggesting that the spatial lag model (SAR) dominates the spatial error structure. The coefficient of $\rho$ in the SAC (0.478) is close to the SAR value (0.428), and $\lambda$ in the SAC (0.166) is much smaller than in the SEM (0.562). The LR test of SAC versus SAR is approximately 0.3 with 1 df, and SAC versus SEM is approximately 2.3 with 1 df &amp;mdash; neither reaches the 5% critical value of 3.84, making it difficult to choose among these three models. However, since $\rho$ is significant while $\lambda$ is not, the SAR is the more parsimonious choice.&lt;/p>
&lt;pre>&lt;code class="language-stata">estat impact
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text"> Coefficient Std. err. z P&amp;gt;|z|
-------------------------------------------------------------------
INC
Direct | -1.0630 .3250 -3.27 0.001
Indirect | -0.5600 .3390 -1.65 0.099
Total | -1.6230 .5500 -2.95 0.003
-------------------------------------------------------------------
HOVAL
Direct | -0.2920 .0910 -3.21 0.001
Indirect | -0.1540 .0980 -1.57 0.116
Total | -0.4460 .1580 -2.82 0.005
-------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>The SAC&amp;rsquo;s effect decomposition falls between the SAR and SEM. The direct effect of income (-1.06) is similar to the SAR (-1.10), and the indirect effects are somewhat attenuated because the spatial error term absorbs a portion of the spatial dependence. One key limitation of the SAC (shared with the SAR) is that the ratio between the indirect and direct effect is the same for every explanatory variable, because spillovers operate only through the spatial multiplier $(I - \rho W)^{-1}$. This constraint is economically restrictive &amp;mdash; there is no reason to expect that income and housing value should have proportionally equal spillover intensities.&lt;/p>
&lt;h3 id="83-gns-general-nesting-spatial">8.3 GNS (General Nesting Spatial)&lt;/h3>
&lt;p>The GNS model includes all three spatial channels simultaneously: the spatial lag of $y$, the spatial lags of $X$, and the spatial error. It is the most general specification in the taxonomy.&lt;/p>
&lt;p>$$y = \rho W y + X \beta + W X \theta + u, \quad u = \lambda W u + \varepsilon$$&lt;/p>
&lt;pre>&lt;code class="language-stata">spregress CRIME INC HOVAL W_INC W_HOVAL, ml dvarlag(W) errorlag(W)
eststo GNS
estat ic
mat s = r(S)
quietly estadd scalar AIC = s[1,5]
&lt;/code>&lt;/pre>
&lt;pre>&lt;code class="language-text">General nesting spatial model Number of obs = 49
Wald chi2(4) = 55.64
Log-likelihood = -179.689 Prob &amp;gt; chi2 = 0.0000
------------------------------------------------------------------------------
CRIME | Coefficient Std. err. z P&amp;gt;|z| [95% conf. interval]
-------------+----------------------------------------------------------------
CRIME |
INC | -0.9510 .4397 -2.16 0.031 -1.8129 -.0891
HOVAL | -0.2860 .0997 -2.87 0.004 -0.4813 -.0907
W_INC | -0.6930 1.6896 -0.41 0.682 -4.0046 2.6186
W_HOVAL | 0.2080 .2849 0.73 0.465 -0.3504 0.7664
-------------+----------------------------------------------------------------
W |
CRIME | 0.3150 .9553 0.33 0.742 -1.5574 2.1874
lambda | 0.1540 1.0267 0.15 0.881 -1.8583 2.1663
_cons | 50.9000 14.2800 3.56 0.000 22.9115 78.8885
------------------------------------------------------------------------------
&lt;/code>&lt;/pre>
&lt;p>In the GNS model, $\rho$ is &lt;strong>0.315&lt;/strong> (p = 0.742), $\lambda$ is &lt;strong>0.154&lt;/strong> (p = 0.881), and the spatial lags of income and housing value are both insignificant. With seven spatial parameters competing to explain the same 49 observations, the model is &lt;strong>overparameterized&lt;/strong>. As Gibbons and Overman (2012) explain, interaction effects among the dependent variable and interaction effects among the error terms are only &lt;strong>weakly identified&lt;/strong> separately. Combining both (as in the GNS) compounds this problem &amp;mdash; significance levels of all variables tend to collapse. The log-likelihood barely improves over the SDM or SDEM, and the AIC is higher, confirming that the additional complexity does not improve fit.&lt;/p>
&lt;p>The GNS&amp;rsquo;s effect decomposition is correspondingly imprecise:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>Direct&lt;/th>
&lt;th>Indirect&lt;/th>
&lt;th>Total&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>INC&lt;/strong>&lt;/td>
&lt;td>-1.03***&lt;/td>
&lt;td>-1.37&lt;/td>
&lt;td>-2.40&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>HOVAL&lt;/strong>&lt;/td>
&lt;td>-0.28***&lt;/td>
&lt;td>+0.16&lt;/td>
&lt;td>-0.11&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The direct effects remain significant and stable (consistent with all other models), but the indirect effects have very large standard errors. The GNS confirms what the specification tests already suggested &amp;mdash; the data does not support the most general specification, and a more parsimonious model is needed.&lt;/p>
&lt;hr>
&lt;h2 id="9-model-comparison">9. Model comparison&lt;/h2>
&lt;h3 id="91-coefficient-comparison">9.1 Coefficient comparison&lt;/h3>
&lt;p>We compare all eight models side by side, focusing on the key coefficients and model fit. Values are based on ML estimation; t-values in parentheses.&lt;/p>
&lt;pre>&lt;code class="language-stata">esttab OLS SAR SEM SLX SDM SDEM SAC GNS, ///
label stats(AIC) mtitle(&amp;quot;OLS&amp;quot; &amp;quot;SAR&amp;quot; &amp;quot;SEM&amp;quot; &amp;quot;SLX&amp;quot; &amp;quot;SDM&amp;quot; &amp;quot;SDEM&amp;quot; &amp;quot;SAC&amp;quot; &amp;quot;GNS&amp;quot;)
&lt;/code>&lt;/pre>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>OLS&lt;/th>
&lt;th>SAR&lt;/th>
&lt;th>SEM&lt;/th>
&lt;th>SLX&lt;/th>
&lt;th>SDM&lt;/th>
&lt;th>SDEM&lt;/th>
&lt;th>SAC&lt;/th>
&lt;th>GNS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>INC&lt;/td>
&lt;td>-1.60***&lt;/td>
&lt;td>-1.03***&lt;/td>
&lt;td>-0.94***&lt;/td>
&lt;td>-1.10***&lt;/td>
&lt;td>-0.92***&lt;/td>
&lt;td>-1.05***&lt;/td>
&lt;td>-1.03***&lt;/td>
&lt;td>-0.95**&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>HOVAL&lt;/td>
&lt;td>-0.27***&lt;/td>
&lt;td>-0.27***&lt;/td>
&lt;td>-0.30***&lt;/td>
&lt;td>-0.29***&lt;/td>
&lt;td>-0.30***&lt;/td>
&lt;td>-0.28***&lt;/td>
&lt;td>-0.28***&lt;/td>
&lt;td>-0.29***&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\rho$ (W*y)&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>0.43***&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>0.40**&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>0.48***&lt;/td>
&lt;td>0.32&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>$\lambda$ (W*e)&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>0.56***&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>0.40**&lt;/td>
&lt;td>0.17&lt;/td>
&lt;td>0.15&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>W*INC&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>-1.40**&lt;/td>
&lt;td>-0.58&lt;/td>
&lt;td>-1.20**&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>-0.69&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>W*HOVAL&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>+0.21&lt;/td>
&lt;td>+0.26&lt;/td>
&lt;td>+0.13&lt;/td>
&lt;td>&amp;mdash;&lt;/td>
&lt;td>+0.21&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Several patterns emerge. First, the income coefficient is &lt;strong>consistently negative&lt;/strong> across all models, ranging from -0.92 (SDM) to -1.60 (OLS). The spatial models generally produce smaller income coefficients than OLS, suggesting that part of the OLS income effect was capturing omitted spatial structure. Second, the housing value coefficient is &lt;strong>remarkably stable&lt;/strong> across all models, ranging from -0.27 to -0.30 &amp;mdash; this variable is insensitive to the spatial specification choice. Third, and crucially, the spatial lag of income ($W \cdot INC$) is &lt;strong>negative and significant&lt;/strong> in the SLX (-1.40, t = -2.50) and the SDEM (-1.20, z = -2.10), meaning that neighbors' income is a substantive predictor of crime. The SLX, SDM, SDEM, and GNS models all agree that $W \cdot INC$ is negative and $W \cdot HOVAL$ is positive, producing a consistent pattern of spatial spillover estimates regardless of which other spatial channels are included.&lt;/p>
&lt;h3 id="92-direct-and-indirect-effects-comparison">9.2 Direct and indirect effects comparison&lt;/h3>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>&lt;/th>
&lt;th>OLS&lt;/th>
&lt;th>SAR&lt;/th>
&lt;th>SEM&lt;/th>
&lt;th>SLX&lt;/th>
&lt;th>SDM&lt;/th>
&lt;th>SDEM&lt;/th>
&lt;th>SAC&lt;/th>
&lt;th>GNS&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>INC&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Direct&lt;/td>
&lt;td>-1.60***&lt;/td>
&lt;td>-1.10***&lt;/td>
&lt;td>-0.94***&lt;/td>
&lt;td>-1.10***&lt;/td>
&lt;td>-1.03***&lt;/td>
&lt;td>-1.05***&lt;/td>
&lt;td>-1.06***&lt;/td>
&lt;td>-1.03***&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Indirect&lt;/td>
&lt;td>0&lt;/td>
&lt;td>-0.76**&lt;/td>
&lt;td>0&lt;/td>
&lt;td>-1.40**&lt;/td>
&lt;td>-1.50*&lt;/td>
&lt;td>-1.20**&lt;/td>
&lt;td>-0.56&lt;/td>
&lt;td>-1.37&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Total&lt;/td>
&lt;td>-1.60***&lt;/td>
&lt;td>-1.86***&lt;/td>
&lt;td>-0.94***&lt;/td>
&lt;td>-2.50***&lt;/td>
&lt;td>-2.52***&lt;/td>
&lt;td>-2.26***&lt;/td>
&lt;td>-1.62***&lt;/td>
&lt;td>-2.40&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>HOVAL&lt;/strong>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;td>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Direct&lt;/td>
&lt;td>-0.27***&lt;/td>
&lt;td>-0.28***&lt;/td>
&lt;td>-0.30***&lt;/td>
&lt;td>-0.29***&lt;/td>
&lt;td>-0.28***&lt;/td>
&lt;td>-0.28***&lt;/td>
&lt;td>-0.29***&lt;/td>
&lt;td>-0.28***&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Indirect&lt;/td>
&lt;td>0&lt;/td>
&lt;td>-0.20*&lt;/td>
&lt;td>0&lt;/td>
&lt;td>+0.21&lt;/td>
&lt;td>+0.22&lt;/td>
&lt;td>+0.13&lt;/td>
&lt;td>-0.15&lt;/td>
&lt;td>+0.16&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Total&lt;/td>
&lt;td>-0.27***&lt;/td>
&lt;td>-0.48***&lt;/td>
&lt;td>-0.30***&lt;/td>
&lt;td>-0.08&lt;/td>
&lt;td>-0.07&lt;/td>
&lt;td>-0.15&lt;/td>
&lt;td>-0.45***&lt;/td>
&lt;td>-0.11&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The direct effects of income and housing value are broadly consistent across models: approximately -0.94 to -1.60 for income and -0.27 to -0.30 for housing value. The &lt;strong>indirect effects&lt;/strong> reveal the most important differences:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>The OLS, SEM, and SAR models produce no or wrong spillover effects.&lt;/strong> OLS has zero spillovers by construction. The SEM&amp;rsquo;s spillovers are zero by construction. The SAR constrains the ratio between indirect and direct effects to be equal for every variable, which forces the housing value spillover to be negative (-0.20) even though the SLX, SDM, SDEM, and GNS all suggest it is positive.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>The SLX, SDM, SDEM, and GNS models agree on the pattern&lt;/strong>: income spillovers are large and negative (-1.20 to -1.50), while housing value spillovers are small and positive (+0.13 to +0.22) and insignificant. This consistency across different model specifications strengthens the case that the income spillover is a robust finding.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>The total effect of income is substantially larger in models with $\theta$ terms&lt;/strong> (-2.26 to -2.52) than in models without them (-0.94 to -1.86). This reveals that the standard SAR/SEM models substantially underestimate the full impact of income on crime by ignoring the local spillover channel.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="10-discussion">10. Discussion&lt;/h2>
&lt;p>The Columbus crime dataset illustrates a recurring challenge in spatial econometrics: choosing among models that capture spatial dependence through different channels. Following Elhorst (2014, Section 2.9), the evidence points toward the &lt;strong>SDM&lt;/strong> and &lt;strong>SDEM&lt;/strong> as the preferred specifications, though neither the SAR nor SEM can be formally rejected.&lt;/p>
&lt;p>&lt;strong>Why not SAR, SEM, or SAC?&lt;/strong> The specification tests fail to reject both the SAR restriction ($\theta = 0$) and the SEM common factor restriction ($\theta + \rho\beta = 0$), which might suggest these simpler models are adequate. However, as Elhorst (2014) emphasizes, these models have structural limitations. The SAR and SAC constrain the ratio between the indirect and direct effect to be &lt;strong>the same for every explanatory variable&lt;/strong> &amp;mdash; a consequence of spillovers operating solely through the spatial multiplier $(I - \rho W)^{-1}\beta_k$. In the Columbus data, this forces the housing value spillover to be negative (proportional to the direct effect), even though the SLX, SDM, SDEM, and GNS models all estimate it as positive. The SEM, on the other hand, produces &lt;strong>zero spillover effects&lt;/strong> by construction, which may be too restrictive if one believes that crime is genuinely affected by conditions in neighboring areas.&lt;/p>
&lt;p>&lt;strong>Why SDM and SDEM?&lt;/strong> Both models allow the indirect effect to differ freely across explanatory variables. In both, the spillover effect of income is negative and significant (SDM: -1.50, marginally significant; SDEM: -1.20, significant at 5%), while the spillover effect of housing value is positive but insignificant. This flexibility produces economically sensible results: neighborhoods surrounded by higher-income areas experience less crime (consistent with crime displacement and opportunity theory), but neighbors' housing values have no significant independent effect on crime.&lt;/p>
&lt;p>&lt;strong>The SDM-SDEM dilemma.&lt;/strong> Whether it is the SDM or the SDEM model that better describes the data is difficult to say, since these two models are &lt;strong>non-nested&lt;/strong> (the SDM has $\rho$ but no $\lambda$; the SDEM has $\lambda$ but no $\rho$). The GNS, which nests both, is overparameterized and produces insignificant estimates for all spatial parameters. Both models produce comparable spillover effects in terms of magnitude and significance. As Elhorst (2014) notes, this is worrying because the two models have &lt;strong>different interpretations&lt;/strong>: the SDM implies that crime spillovers propagate globally through the network, while the SDEM implies they are local (limited to immediate neighbors) with the remaining spatial pattern driven by unobserved common factors.&lt;/p>
&lt;p>&lt;strong>Policy implications.&lt;/strong> A \$1,000 increase in household income reduces crime by approximately 1.0 incident per 1,000 households directly and an additional 1.2&amp;ndash;1.5 incidents indirectly through the spatial spillover channel, for a total effect of 2.3&amp;ndash;2.5. This means that policies to increase income in the poorest neighborhoods generate positive externalities for neighboring areas that are even larger than the within-neighborhood effect. The total income effect in the SDM/SDEM (-2.3 to -2.5) is &lt;strong>40&amp;ndash;55% larger&lt;/strong> than the OLS estimate (-1.60), revealing the magnitude of the bias from ignoring spatial spillovers.&lt;/p>
&lt;p>This tutorial complements the companion post on &lt;a href="https://carlos-mendez.org/post/stata_sp_regression_panel/">spatial panel regression&lt;/a>, which demonstrates the same model taxonomy in a panel data setting using cigarette demand across US states. The panel setting offers additional advantages &amp;mdash; fixed effects to control for unobserved heterogeneity and dynamic extensions to separate temporal from spatial dynamics &amp;mdash; but requires repeated observations over time. The cross-sectional framework presented here is appropriate when only a single snapshot of spatial data is available, which is common in urban economics, criminology, and regional science.&lt;/p>
&lt;hr>
&lt;h2 id="11-summary-and-next-steps">11. Summary and next steps&lt;/h2>
&lt;p>This tutorial covered the complete taxonomy of cross-sectional spatial regression models in Stata &amp;mdash; from OLS diagnostics through the most general GNS specification. The key takeaways are:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Spatial autocorrelation is significant.&lt;/strong> Moran&amp;rsquo;s I of 0.222 (p = 0.005) confirms that OLS residuals are positively spatially autocorrelated, and the LM tests favor the spatial error specification.&lt;/li>
&lt;li>&lt;strong>The SDM and SDEM are the preferred models.&lt;/strong> Both models allow the indirect effects to differ across explanatory variables, and both identify a significant negative spillover effect of income. The SAR, SEM, and SLX restrictions from the SDM cannot be formally rejected, but the SAR and SAC impose an economically restrictive constraint (equal spillover-to-direct ratios for all variables), while the SEM produces zero spillovers by construction.&lt;/li>
&lt;li>&lt;strong>Direct effects are robust to spatial specification.&lt;/strong> The direct effect of income ranges from -1.03 to -1.10 across the four models with $\theta$ terms (SLX, SDM, SDEM, GNS), and the direct effect of housing value ranges from -0.28 to -0.29 &amp;mdash; substantially more stable than the indirect effects.&lt;/li>
&lt;li>&lt;strong>Neighbors' income significantly reduces crime.&lt;/strong> The indirect effect of income is -1.20 (SDEM) to -1.50 (SDM), comparable to or larger than the direct effect. The total income effect in the SDM/SDEM (-2.3 to -2.5) is &lt;strong>40&amp;ndash;55% larger&lt;/strong> than the OLS estimate (-1.60), revealing substantial bias from ignoring spatial spillovers.&lt;/li>
&lt;li>&lt;strong>The GNS is overparameterized.&lt;/strong> When all three spatial channels ($\rho$, $\theta$, $\lambda$) are included simultaneously, all become insignificant. The difficulty of separately identifying endogenous interaction effects and error interaction effects is a fundamental limitation of the cross-sectional setting.&lt;/li>
&lt;/ul>
&lt;p>For further study, consider the companion tutorial on &lt;a href="https://carlos-mendez.org/post/stata_sp_regression_panel/">spatial panel regression&lt;/a>, which extends these methods to panel data with fixed effects and dynamic specifications. For Python implementations, the PySAL &lt;code>spreg&lt;/code> package provides analogous spatial regression tools.&lt;/p>
&lt;hr>
&lt;h2 id="12-exercises">12. Exercises&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Alternative weight matrix.&lt;/strong> Replace the Queen contiguity matrix with a k-nearest neighbors matrix (e.g., $k = 4$ or $k = 6$). Re-estimate the SAR and SEM models and compare the spatial parameter estimates ($\rho$ and $\lambda$). Does the choice of weight matrix change the substantive conclusions about spatial dependence in crime?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Single explanatory variable.&lt;/strong> Re-estimate all eight models using only INC (dropping HOVAL). How do the spatial parameter estimates and the AIC rankings change? Does the Wald test from the SDM still fail to reject the SAR and SEM restrictions?&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Rook vs. Queen contiguity.&lt;/strong> Construct a Rook contiguity matrix (neighbors share a common edge, not just a vertex) and re-estimate the SDM. Compare the Wald specification test results to those obtained with Queen contiguity. Are the conclusions about which spatial model is appropriate sensitive to the contiguity definition?&lt;/p>
&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="references">References&lt;/h2>
&lt;ol>
&lt;li>&lt;a href="https://doi.org/10.1007/978-94-015-7799-1" target="_blank" rel="noopener">Anselin, L. (1988). &lt;em>Spatial Econometrics: Methods and Models&lt;/em>. Kluwer Academic Publishers.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.1201/9781420064254" target="_blank" rel="noopener">LeSage, J. P. &amp;amp; Pace, R. K. (2009). &lt;em>Introduction to Spatial Econometrics&lt;/em>. Chapman &amp;amp; Hall/CRC.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://link.springer.com/book/10.1007/978-3-642-40340-8" target="_blank" rel="noopener">Elhorst, J. P. (2014). &lt;em>Spatial Econometrics: From Cross-Sectional Data to Spatial Panels&lt;/em>. Springer.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://geodacenter.github.io/documentation.html" target="_blank" rel="noopener">Anselin, L. (2005). &lt;em>Exploring Spatial Data with GeoDa: A Workbook&lt;/em>. Center for Spatially Integrated Social Science.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://doi.org/10.5281/zenodo.5151076" target="_blank" rel="noopener">Mendez, C. (2021). &lt;em>Spatial econometrics for cross-sectional data in Stata&lt;/em>. DOI: 10.5281/zenodo.5151076.&lt;/a>&lt;/li>
&lt;li>&lt;a href="https://geodacenter.github.io/data-and-lab/columbus/" target="_blank" rel="noopener">Columbus crime dataset &amp;mdash; GeoDa Center Data and Lab.&lt;/a>&lt;/li>
&lt;/ol></description></item><item><title>Spatial inequality dynamics</title><link>https://carlos-mendez.org/post/python_gds_spatial_inequality/</link><pubDate>Sun, 27 Aug 2023 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_gds_spatial_inequality/</guid><description/></item><item><title>Introduction to spatial data science</title><link>https://carlos-mendez.org/post/python_intro_spatial_data_science/</link><pubDate>Mon, 01 Apr 2019 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/python_intro_spatial_data_science/</guid><description>&lt;p>Introduction to spatial data science with Python&lt;/p></description></item><item><title>Use marginal predictions</title><link>https://carlos-mendez.org/post/stata_marginal_predictions/</link><pubDate>Mon, 01 Apr 2019 00:00:00 +0000</pubDate><guid>https://carlos-mendez.org/post/stata_marginal_predictions/</guid><description>&lt;p>TBA&lt;/p></description></item></channel></rss>