<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Principal Component Analysis (PCA) | Carlos Mendez</title><link>https://carlos-mendez.org/category/principal-component-analysis-pca/</link><atom:link href="https://carlos-mendez.org/category/principal-component-analysis-pca/index.xml" rel="self" type="application/rss+xml"/><description>Principal Component Analysis (PCA)</description><generator>Wowchemy (https://wowchemy.com)</generator><language>en-us</language><copyright>Carlos Mendez</copyright><lastBuildDate>Sat, 21 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>Principal Component Analysis (PCA)</title><link>https://carlos-mendez.org/category/principal-component-analysis-pca/</link></image><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>
&lt;h4 id="acknowledgements">Acknowledgements&lt;/h4>
&lt;p>AI tools (Claude Code, Gemini, NotebookLM) were used to make the contents of this post more accessible to students. Nevertheless, the content in this post may still have errors. Caution is needed when applying the contents of this post to true research projects.&lt;/p></description></item><item><title>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>
&lt;h4 id="acknowledgements">Acknowledgements&lt;/h4>
&lt;p>AI tools (Claude Code, Gemini, NotebookLM) were used to make the contents of this post more accessible to students. Nevertheless, the content in this post may still have errors. Caution is needed when applying the contents of this post to true research projects.&lt;/p></description></item></channel></rss>