Back to portfolio
World Bank ยท World Development Indicators ยท Case study
๐ŸŽ’ Do Richer Countries Keep More Children in School?
Wealth, public spending and out-of-school rates across 217 economies, 2000โ€“2025
๐Ÿ‘ค Mihail Deligrozev ๐Ÿ pandas ยท matplotlib ยท SciPy ๐Ÿ“… Data updated Oct 2026

๐Ÿงญ Executive summary

โˆ’0.59
Rank correlation of GDP per capita with adolescents out of school, 2015 (n = 62)
20ร—
Median out-of-school rate for adolescents in the poorest vs richest quarter of countries (20.3% vs 1.0%)
โ‰ˆ 0
Link between spending effort and out-of-school rates once GDP is held constant
35% โ†’ 42%
Adolescents out of school in low-income countries, 2013 to 2024

Question. Are children and adolescents more likely to be out of school in poorer countries, and does higher government spending per student help?

Data. A fresh World Bank DataBank export of five World Development Indicators for 217 economies, 1960โ€“2025 (last updated 8 October 2026).

Answer. Wealth is strongly linked to keeping children in school (H1 supported, in every year tested and in a 136-country sample from 2021). Spending effort, measured as spending per student relative to GDP per capita, shows a weak raw link that disappears once wealth is held constant (H2 not supported on this measure).

Data-quality catches. The export has .. for missing values and footer lines, mixes 5 aggregates with the 217 economies, and its "metadata" file is the data again plus a definitions block. Spending data ends in 2018, which limits any joint test to 2018 or earlier.

Originally Assignment 1 (Data Collection and Wrangling) of EPA-1316 Introduction to Urban Data Science, TU Delft. Rebuilt as a standalone case study on a fresh WDI export (last updated 8 October 2026).

๐Ÿ“‘ Contents

# Section What happens
1 โš™๏ธ Setup Libraries, chart style, helper functions
2 ๐Ÿ“ฅ Load and profile Read the DataBank export, check its structure and quality
3 ๐ŸŒ The bigger picture Out-of-school trends by income group, 2000โ€“2024
4 ๐Ÿงช Hypotheses and year choice H1 and H2, picking a cross-section year
5 ๐Ÿงน Missing data and selection bias What listwise deletion costs us
6 ๐Ÿ“Š Testing the hypotheses Rank correlations, CIs, quartiles, partial correlation
7 ๐Ÿ›ฐ๏ธ H1 with recent data Wealth vs out-of-school in the latest well-covered year
8 ๐Ÿ” Robustness Same tests across years and with a latest-value window
9 ๐Ÿ Findings and limitations Answers, caveats, next steps
10 ๐Ÿ’พ Save outputs Clean table and figures

โš™๏ธ 1. Setup

๐Ÿ’ก Why this step
All charts share one style and one colour order (blue, orange, aqua), and every chart is numbered and saved as a PNG, so the findings can point back to Figure N. Small statistics helpers (bootstrap confidence intervals, partial rank correlation) live here so the analysis cells stay short.
Code
import warnings
from pathlib import Path

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.ticker import FuncFormatter
from scipy.stats import spearmanr, mannwhitneyu, pearsonr, t as t_dist

warnings.filterwarnings("ignore")
pd.set_option("display.width", 160)
pd.set_option("display.max_columns", 12)
pd.set_option("display.float_format", "{:,.2f}".format)

# ๐ŸŽจ palette and chart style
BLUE, ORANGE, AQUA, VIOLET = "#2a78d6", "#eb6834", "#1baf7a", "#6250d6"
INK, MUTED, GRID, SURFACE = "#0b0b0b", "#52514e", "#e5e4df", "#fcfcfb"
plt.rcParams.update({
    "figure.dpi": 110, "figure.facecolor": SURFACE, "axes.facecolor": SURFACE,
    "axes.edgecolor": GRID, "axes.labelcolor": MUTED, "axes.titlecolor": INK,
    "axes.titlesize": 11, "axes.titleweight": "bold", "axes.titlelocation": "left",
    "axes.spines.top": False, "axes.spines.right": False,
    "axes.grid": True, "grid.color": GRID, "grid.linewidth": .8,
    "xtick.color": MUTED, "ytick.color": MUTED, "font.size": 10,
    "legend.frameon": False, "lines.linewidth": 2,
})

# ๐Ÿ“ paths: works locally (./data) and on Kaggle (/kaggle/input/...)
def find_file(pattern):
    for base in [Path("data"), Path("."), Path("/kaggle/input")]:
        hits = sorted(base.rglob(pattern)) if base.exists() else []
        if hits:
            return hits[0]
    raise FileNotFoundError(pattern)

OUT_DIR = Path("/kaggle/working") if Path("/kaggle/working").exists() else Path("output")
FIG_DIR = OUT_DIR / "figures"
FIG_DIR.mkdir(parents=True, exist_ok=True)

# ๐Ÿ–ผ๏ธ numbered figures, saved as PNG
_FIG = {"n": 0}
def figure_title(fig, text):
    _FIG["n"] += 1
    fig.suptitle(f"Figure {_FIG['n']} ยท {text}", x=0.01, ha="left", fontsize=13, fontweight="bold", color=INK)
    return _FIG["n"]

def save(fig, name):
    fig.savefig(FIG_DIR / f"fig{_FIG['n']:02d}_{name}.png", dpi=150, bbox_inches="tight", facecolor=SURFACE)
    plt.show()

usd = FuncFormatter(lambda v, _: f"${v:,.0f}")

# ๐Ÿ“ statistics helpers
def fmt_p(p):
    return "< 0.001" if p < 0.001 else f"{p:.3f}"

def spearman_ci(x, y, n_boot=2000, seed=42):
    """Spearman rho with a percentile bootstrap 95% CI."""
    x, y = np.asarray(x), np.asarray(y)
    rho, p = spearmanr(x, y)
    rng = np.random.default_rng(seed)
    idx = rng.integers(0, len(x), (n_boot, len(x)))
    boots = [spearmanr(x[i], y[i])[0] for i in idx]
    lo, hi = np.nanpercentile(boots, [2.5, 97.5])
    return rho, p, lo, hi

def partial_spearman(x, y, z):
    """Rank correlation of x and y after removing the linear effect of rank(z) from both."""
    rx, ry, rz = (pd.Series(v).rank().to_numpy() for v in (x, y, z))
    res = lambda a: a - np.polyval(np.polyfit(rz, a, 1), rz)
    r, _ = pearsonr(res(rx), res(ry))
    n = len(rx)
    p = 2 * t_dist.sf(abs(r) * np.sqrt((n - 3) / (1 - r**2)), n - 3)
    return r, p

print("โœ… setup done ยท figures go to", FIG_DIR)
โœ… setup done ยท figures go to output/figures

๐Ÿ“ฅ 2. Load and profile the data

The data is a World Bank DataBank export of five World Development Indicators for 217 economies plus 5 regional and income aggregates, 1960 to 2025. Each row is one country ร— indicator, and each year is a column. The export comes as two files:

File Contents
โ€ฆ_Data.csv The values, with .. for missing and a short footer (source, last updated)
โ€ฆ_Series - Metadata.csv The same values again, followed by a block with each indicator's definition and licence
๐Ÿ’ก Why this step
A DataBank export is not a clean table. Missing values are written as .. (pandas reads them as text unless told otherwise), the year columns are named 2015 [YR2015], and five footer lines sit below the data. Fixing these at load time avoids silent errors later, such as a year column stored as strings.
Code
DATA_FILE = find_file("*_Data.csv")
META_FILE = find_file("*Metadata.csv")

raw = pd.read_csv(DATA_FILE, na_values="..")
footer = raw[raw["Series Code"].isna()]
df = raw.dropna(subset=["Series Code"]).copy()
df.columns = [c.split(" [")[0] for c in df.columns]          # "2015 [YR2015]" -> "2015"
YEARS = [c for c in df.columns if c.isdigit()]
ID_COLS = [c for c in df.columns if not c.isdigit()]

print(f"Raw rows: {len(raw):,}  ->  data rows: {len(df):,}  (dropped {len(footer)} footer/blank lines)")
print("Footer text:", [s for s in footer["Country Name"].dropna()])
print(f"Year columns: {YEARS[0]} to {YEARS[-1]} ({len(YEARS)} years), all numeric: {all(df[y].dtype.kind == 'f' for y in YEARS)}")
Raw rows: 1,115  ->  data rows: 1,110  (dropped 5 footer/blank lines)
Footer text: ['Data from database: World Development Indicators', 'Last Updated: 10/08/2026']
Year columns: 1960 to 2025 (66 years), all numeric: True
Code
# The metadata file repeats the data, then has a definitions block starting at a row whose first cell is "Code"
meta_raw = pd.read_csv(META_FILE, header=None, dtype=str, na_values="..")
start = meta_raw.index[meta_raw[0].eq("Code")][0]
DEFINITIONS = (meta_raw.iloc[start + 1:, :4]
               .set_axis(meta_raw.iloc[start, :4].tolist(), axis=1)
               .set_index("Code"))

# Integrity check: is the data part of the metadata file identical to the data file?
meta_data = pd.read_csv(META_FILE, na_values="..", nrows=start - 1).dropna(subset=["Series Code"])
meta_data.columns = [c.split(" [")[0] for c in meta_data.columns]
same = meta_data[YEARS].reset_index(drop=True).equals(df[YEARS].reset_index(drop=True))
print("Data block of the metadata file identical to the data file:", same)

DEFINITIONS[["Indicator Name", "Short definition"]].style.set_properties(**{"text-align": "left", "white-space": "normal"})
Data block of the metadata file identical to the data file: True
  Indicator Name Short definition
Code    
SE.SEC.UNER.LO.ZS Adolescents out of school (% of lower secondary school age) Adolescents out of school are the percentage of lower secondary school age adolescents who are not enrolled in school.
SE.PRM.UNER.ZS Children out of school (% of primary school age) Children out of school are the percentage of primary-school-age children who are not enrolled in primary or secondary school. Children in the official primary age group that are in preprimary education should be considered out of school.
SE.XPD.PRIM.PC.ZS Government expenditure per student, primary (% of GDP per capita) Government expenditure per student is the average general government expenditure (current, capital, and transfers) per student in the given level of education, expressed as a percentage of GDP per capita.
SE.XPD.SECO.PC.ZS Government expenditure per student, secondary (% of GDP per capita) Government expenditure per student is the average general government expenditure (current, capital, and transfers) per student in the given level of education, expressed as a percentage of GDP per capita.
NY.GDP.PCAP.CD GDP per capita (current US$) Gross domestic product is the total income earned through the production of goods and services in an economic territory during an accounting period. It can be measured in three different ways: using either the expenditure approach, the income approach, or the production approach. The core indicator has been divided by the general population to achieve a per capita estimate.This indicator is expressed in current prices, meaning no adjustment has been made to account for price changes over time. This indicator is expressed in United States dollars.

Countries versus aggregates

DataBank mixes real economies with aggregates (income groups and regions). They overlap with the countries they contain, so leaving them in would count the same children twice.

Code
AGGREGATES = {
    "LIC": "Low income", "LMC": "Lower middle income", "LMY": "Low & middle income",
    "LDC": "Least developed countries (UN)", "MNA": "Middle East, North Africa, Afghanistan & Pakistan (excl. high income)",
}
names = df.drop_duplicates("Country Code").set_index("Country Code")["Country Name"]
countries = df[~df["Country Code"].isin(AGGREGATES)]
aggregates = df[df["Country Code"].isin(AGGREGATES)]

profile = pd.Series({
    "Rows (country ร— indicator)": f"{len(df):,}",
    "Indicators": df["Series Code"].nunique(),
    "Economies": countries["Country Code"].nunique(),
    "Aggregates": aggregates["Country Code"].nunique(),
    "Year columns": f"{YEARS[0]}โ€“{YEARS[-1]}",
    "Share of cells with a value (economies)": f"{countries[YEARS].notna().mean().mean():.1%}",
}, name="value").to_frame()
display(profile)
names.loc[list(AGGREGATES)].to_frame("Aggregate name")
value
Rows (country ร— indicator) 1,110
Indicators 5
Economies 217
Aggregates 5
Year columns 1960โ€“2025
Share of cells with a value (economies) 31.6%
Aggregate name
Country Code
LIC Low income
LMC Lower middle income
LMY Low & middle income
LDC Least developed countries: UN classification
MNA Middle East, North Africa, Afghanistan & Pakis...
๐Ÿ”Ž Insight
The data is sparse: only about a third of the economy ร— indicator ร— year cells hold a value, so choosing a year matters. The five aggregates are all income or regional groups and are removed from every country-level analysis below. The second file's data block is identical to the main file, so it adds only the indicator definitions.

Which indicator is reported when?

๐Ÿ’ก Why this step
A cross-section only works if all five indicators are reported for the same countries in the same year. A heatmap of how many of the 217 economies report each indicator per year shows at a glance which years are usable.
Code
VARIABLES = {
    "SE.PRM.UNER.ZS":    "Children out of School",
    "SE.SEC.UNER.LO.ZS": "Adolescents out of School",
    "SE.XPD.PRIM.PC.ZS": "Govt Primary Expenditure",
    "SE.XPD.SECO.PC.ZS": "Govt Secondary Expenditure",
    "NY.GDP.PCAP.CD":    "GDP Per Capita",
}
SHOW_YEARS = [y for y in YEARS if int(y) >= 1990]
cov = (countries.set_index("Series Code")[SHOW_YEARS].notna()
       .groupby(level=0).sum().loc[list(VARIABLES)].rename(index=VARIABLES))

fig, ax = plt.subplots(figsize=(12, 3.4))
im = ax.imshow(cov.values, aspect="auto", cmap="Blues", vmin=0, vmax=217)
ax.set_yticks(range(len(cov)), cov.index)
ax.set_xticks(range(0, len(SHOW_YEARS), 5), SHOW_YEARS[::5])
ax.grid(False)
for i, row in enumerate(cov.values):           # label every 5th year so the numbers are readable
    for j in range(0, len(SHOW_YEARS), 5):
        ax.text(j, i, row[j], ha="center", va="center", fontsize=8, color="white" if row[j] > 120 else INK)
fig.colorbar(im, ax=ax, label="economies reporting (of 217)", fraction=.025, pad=.01)
figure_title(fig, "Spending data stops in 2018; out-of-school rates run to 2024")
fig.tight_layout()
save(fig, "coverage_heatmap")
๐Ÿ”Ž Insight
Out-of-school rates are well reported from around 2000 to 2024 (130โ€“150 economies a year), and GDP per capita is almost complete. Spending per student is the bottleneck: it peaks at about 100 economies around 2010โ€“2013, falls sharply after 2016 and is empty from 2019 onwards in this export. Any test that needs spending must use 2018 or earlier, while a test of wealth alone can use recent years (section 7).

๐ŸŒ 3. The bigger picture: out-of-school trends by income group

๐Ÿ’ก Why this step
The aggregates must be kept out of the country analysis, but they are useful on their own: the World Bank computes them with population weights, so they show how the problem has evolved for low- and lower-middle-income countries as a whole. This gives context before zooming into one year.
Code
trend = (aggregates[aggregates["Country Code"].isin(["LIC", "LMC"])
                    & aggregates["Series Code"].isin(["SE.PRM.UNER.ZS", "SE.SEC.UNER.LO.ZS"])]
         .set_index(["Country Code", "Series Code"])[[y for y in YEARS if 2000 <= int(y) <= 2024]])
yrs = trend.columns.astype(int)

fig, axes = plt.subplots(1, 2, figsize=(12, 4.2), sharey=True)
for ax, code in zip(axes, ["SE.PRM.UNER.ZS", "SE.SEC.UNER.LO.ZS"]):
    for grp, color in [("LIC", ORANGE), ("LMC", BLUE)]:
        s = trend.loc[(grp, code)]
        ax.plot(yrs, s.values, color=color)
        ax.annotate(f"{AGGREGATES[grp]}\n{s.iloc[-1]:.0f}% in 2024", (yrs[-1], s.iloc[-1]),
                    xytext=(6, 0), textcoords="offset points", va="center", fontsize=8.5, color=INK)
        lo_year = s.idxmin()
        if grp == "LIC" and code == "SE.SEC.UNER.LO.ZS":
            ax.scatter(int(lo_year), s.min(), s=40, color=color, edgecolor=SURFACE, lw=2, zorder=3)
            ax.annotate(f"low point {lo_year}: {s.min():.1f}%", (int(lo_year), s.min()),
                        xytext=(0, -16), textcoords="offset points", ha="center", fontsize=8, color=MUTED)
    ax.set_title(VARIABLES[code] + " (% of age group)")
    ax.set_xlim(2000, 2031)
    ax.set_ylim(0, 55)
figure_title(fig, "Progress stalled in low-income countries after the early 2010s")
fig.tight_layout()
save(fig, "income_group_trends")

trend.round(1).loc[:, ["2000", "2010", "2015", "2020", "2024"]].rename(index=AGGREGATES | VARIABLES)
2000 2010 2015 2020 2024
Country Code Series Code
Low income Adolescents out of School 51.30 36.60 36.10 39.10 42.00
Children out of School 44.60 27.80 25.60 25.30 24.40
Lower middle income Adolescents out of School 24.20 15.10 12.70 12.50 11.90
Children out of School 18.50 11.20 9.70 8.70 8.30
๐Ÿ”Ž Insight
Since 2000 the share of primary-age children out of school in low-income countries fell from 45% to about 25%, but it has been flat since around 2014. For adolescents the picture is worse: the low-income rate bottomed out at 35.5% in 2013 and has risen to 42% in 2024, while lower-middle-income countries kept improving slowly (24% โ†’ 12%). The gap between the two groups is not closing.
โš ๏ธ Caution
These are aggregates published by the World Bank, partly built from imputed country values. They describe the groups, not any single country.

๐Ÿงช 4. Hypotheses and choosing a year

I want to understand why children and adolescents are out of school, and whether national wealth and public spending explain it.

Role Variable Code
Outcome Children out of school (% of primary school age) SE.PRM.UNER.ZS
Outcome Adolescents out of school (% of lower secondary school age) SE.SEC.UNER.LO.ZS
Explanatory Government expenditure per student, primary (% of GDP per capita) SE.XPD.PRIM.PC.ZS
Explanatory Government expenditure per student, secondary (% of GDP per capita) SE.XPD.SECO.PC.ZS
Explanatory GDP per capita (current US$) NY.GDP.PCAP.CD
๐Ÿ’ก Why this step
Testing both hypotheses together needs a year in which as many economies as possible report all five indicators. I count complete economies per year and pick by a stated rule, rather than by eye, so the choice is reproducible.
Code
def wide_for(year, frame=countries):
    w = frame.pivot(index="Country Code", columns="Series Code", values=year)
    return w.rename(columns=VARIABLES)[list(VARIABLES.values())]

rows = []
for year in map(str, range(2008, 2019)):
    w = wide_for(year)
    rows.append({"Year": year, **w.notna().sum().to_dict(), "Complete (all 5)": len(w.dropna())})
year_coverage = pd.DataFrame(rows).set_index("Year")

MIN_N = 60
YEAR = max(y for y, n in year_coverage["Complete (all 5)"].items() if n >= MIN_N)
print(f"Rule: most recent year with at least {MIN_N} complete economies  ->  YEAR = {YEAR}")
year_coverage.style.highlight_max("Complete (all 5)", color="#cfe2f8").apply(
    lambda r: ["font-weight:bold" if r.name == YEAR else "" for _ in r], axis=1)
Rule: most recent year with at least 60 complete economies  ->  YEAR = 2015
  Children out of School Adolescents out of School Govt Primary Expenditure Govt Secondary Expenditure GDP Per Capita Complete (all 5)
Year            
2008 128 86 88 77 211 46
2009 127 93 92 79 213 53
2010 125 103 101 90 213 56
2011 137 110 104 95 214 63
2012 141 112 95 81 212 54
2013 132 120 101 94 212 66
2014 142 131 93 84 213 62
2015 145 135 89 83 212 62
2016 144 132 82 77 211 57
2017 144 132 39 36 211 28
2018 140 132 24 23 211 18
๐Ÿ”Ž Insight
2013 has the most complete economies (66), but the rule picks 2015 (62 economies): the most recent year before spending coverage collapses. 2014 also has 62, and the robustness section repeats every test for 2008โ€“2016, so the choice does not drive the result.

๐Ÿงน 5. Missing data and selection bias

Reshaping the chosen year from long to wide gives one row per economy and one column per indicator.

๐Ÿ’ก Why this step
Out-of-school rates and spending per student come from national education statistics, which not every country reports every year. GDP comes from national accounts, which almost every economy publishes. The outcomes should not be imputed (that would invent the thing I am trying to explain), so I use listwise deletion. Its price is a smaller sample, so I check who drops out.
Code
wide = wide_for(YEAR)
missing = pd.DataFrame({"Missing": wide.isna().sum(), "% missing": wide.isna().mean() * 100})
print(f"{YEAR}: {wide.shape[0]} economies ร— {wide.shape[1]} indicators")
missing
2015: 217 economies ร— 5 indicators
Missing % missing
Series Code
Children out of School 72 33.18
Adolescents out of School 82 37.79
Govt Primary Expenditure 128 58.99
Govt Secondary Expenditure 134 61.75
GDP Per Capita 5 2.30
Code
clean = wide.dropna().sort_values("Children out of School", ascending=False)
dropped = wide.drop(clean.index)
gdp_kept, gdp_drop = clean["GDP Per Capita"], dropped["GDP Per Capita"].dropna()
u, p_mw = mannwhitneyu(gdp_kept, gdp_drop)

pd.DataFrame({
    "Economies": [len(clean), len(dropped)],
    "with GDP data": [gdp_kept.size, gdp_drop.size],
    "Median GDP per capita (US$)": [gdp_kept.median(), gdp_drop.median()],
}, index=["Kept (all 5)", "Dropped"]).style.format({"Median GDP per capita (US$)": "${:,.0f}"}).set_caption(
    f"Mann-Whitney U test on GDP per capita, kept vs dropped: p = {fmt_p(p_mw)}")
Mann-Whitney U test on GDP per capita, kept vs dropped: p = < 0.001
  Economies with GDP data Median GDP per capita (US$)
Kept (all 5) 62 62 $13,378
Dropped 155 150 $4,552
Code
fig, ax = plt.subplots(figsize=(11, 3.6))
rng = np.random.default_rng(1)
for i, (label, s, color) in enumerate([("Dropped", gdp_drop, ORANGE), ("Kept (all 5)", gdp_kept, BLUE)]):
    jitter = i + rng.uniform(-.18, .18, len(s))
    ax.scatter(s, jitter, s=22, alpha=.55, color=color, edgecolor=SURFACE, lw=.6)
    ax.plot([s.median()] * 2, [i - .3, i + .3], color=INK, lw=2.5)
    ax.annotate(f"median ${s.median():,.0f}  (n = {len(s)})", (s.median(), i + .32), fontsize=8.5, color=INK, ha="center")
ax.set_xscale("log")
ax.xaxis.set_major_formatter(usd)
ax.set_yticks([0, 1], ["Dropped", "Kept (all 5)"])
ax.set_ylim(-.6, 1.7)
ax.set_xlabel(f"GDP per capita {YEAR} (US$, log scale)")
ax.grid(axis="y", visible=False)
figure_title(fig, "Who survives listwise deletion?")
fig.tight_layout()
save(fig, "selection_bias")
โš ๏ธ Caution
Only 62 of 217 economies report all five indicators in 2015, and they are about three times richer (median GDP per capita $13,378 vs $4,552 for those dropped; Mann-Whitney p < 0.001). Many of the poorest countries drop out (Burundi, Central African Republic, Madagascar, Somalia, DR Congo, Malawi, Afghanistanโ€ฆ), exactly where out-of-school rates are highest. Conclusions apply to countries that report all five indicators, not to the whole world, and the wealth effect is probably understated because the poor tail is thin.

๐Ÿ“Š 6. Testing the hypotheses

๐Ÿ’ก Why this step
The variables are heavily skewed: GDP per capita spans more than two orders of magnitude and most out-of-school rates are close to zero. Spearman rank correlation is not distorted by those outliers. With about 60 countries a single coefficient can be noisy, so each one gets a bootstrap 95% confidence interval (2,000 resamples).
Code
TESTS = [
    ("GDP Per Capita",             "Children out of School",    "H1"),
    ("GDP Per Capita",             "Adolescents out of School", "H1"),
    ("Govt Primary Expenditure",   "Children out of School",    "H2"),
    ("Govt Secondary Expenditure", "Adolescents out of School", "H2"),
]
def run_tests(data):
    out = []
    for x, y, h in TESTS:
        rho, p, lo, hi = spearman_ci(data[x], data[y])
        out.append({"Hypothesis": h, "Explanatory": x, "Outcome": y, "rho": rho,
                    "CI low": lo, "CI high": hi, "p-value": fmt_p(p), "n": len(data)})
    return pd.DataFrame(out)

results = run_tests(clean)
results
Hypothesis Explanatory Outcome rho CI low CI high p-value n
0 H1 GDP Per Capita Children out of School -0.53 -0.74 -0.28 < 0.001 62
1 H1 GDP Per Capita Adolescents out of School -0.59 -0.77 -0.35 < 0.001 62
2 H2 Govt Primary Expenditure Children out of School -0.32 -0.55 -0.06 0.011 62
3 H2 Govt Secondary Expenditure Adolescents out of School -0.03 -0.29 0.25 0.843 62
Code
fig, ax = plt.subplots(figsize=(10, 3.6))
labels = [f"{h}: {x.replace('Govt ', '').replace(' Expenditure', ' spending')} โ†’ {y.split(' out')[0]}"
          for h, x, y in zip(results["Hypothesis"], results["Explanatory"], results["Outcome"])]
ys = np.arange(len(results))[::-1]
for yy, (_, r) in zip(ys, results.iterrows()):
    color = BLUE if r["Hypothesis"] == "H1" else ORANGE
    ax.plot([r["CI low"], r["CI high"]], [yy, yy], color=color, lw=3, solid_capstyle="round")
    ax.scatter(r["rho"], yy, s=70, color=color, edgecolor=SURFACE, lw=2, zorder=3)
    ax.annotate(f"{r['rho']:.2f}", (r["rho"], yy), xytext=(0, 9), textcoords="offset points", ha="center", fontsize=8.5)
ax.axvline(0, color=MUTED, lw=1, ls="--")
ax.set_yticks(ys, labels)
ax.set_xlim(-1, .5)
ax.set_xlabel("Spearman rho with bootstrap 95% CI")
ax.grid(axis="y", visible=False)
figure_title(fig, f"Wealth separates countries far more than spending effort ({YEAR}, n = {len(clean)})")
fig.tight_layout()
save(fig, "forest_spearman")
๐Ÿ”Ž Insight
Both wealth correlations are moderate to strong and their confidence intervals stay well away from zero. Primary spending effort has a weaker negative link (rho = โˆ’0.32, CI โˆ’0.55 to โˆ’0.06) and secondary spending effort has none (rho = โˆ’0.03, CI crosses zero). The scatter plots below show why: the countries with most children out of school (Mali, Niger, Burkina Faso, Cรดte d'Ivoire, Mauritania) are all below $2,000 per capita, but their spending effort sits in the same 10โ€“25% range as rich countries.

What the relationships look like

Code
fig, axes = plt.subplots(2, 2, figsize=(12, 8.5))
label_codes = list(clean.index[:5]) + list(clean.sort_values("GDP Per Capita").index[-2:])
for ax, (x, y, h) in zip(axes.ravel(), TESTS):
    color = BLUE if h == "H1" else ORANGE
    rho, p = spearmanr(clean[x], clean[y])
    ax.scatter(clean[x], clean[y], s=34, alpha=.8, color=color, edgecolor=SURFACE, lw=.8)
    if x == "GDP Per Capita":
        ax.set_xscale("log")
        ax.xaxis.set_major_formatter(usd)
    for code in label_codes:
        ax.annotate(code, (clean.loc[code, x], clean.loc[code, y]), xytext=(4, 3),
                    textcoords="offset points", fontsize=7.5, color=MUTED)
    ax.set_xlabel(x + (" (US$, log scale)" if x == "GDP Per Capita" else " (% of GDP per capita)"))
    ax.set_ylabel(y + " (%)")
    ax.set_title(f"{h} ยท rho = {rho:.2f}, p {'< 0.001' if p < .001 else '= ' + format(p, '.3f')}")
figure_title(fig, f"Out-of-school rates against wealth and spending effort, {YEAR}")
fig.tight_layout()
save(fig, "scatter_panels")

From a coefficient to something readable: GDP quartiles

๐Ÿ’ก Why this step
A rho of โˆ’0.6 is abstract. Splitting the countries into four equal groups by GDP per capita and comparing the median out-of-school rate in each group shows the size of the gap in percentage points.
Code
clean["GDP quartile"] = pd.qcut(clean["GDP Per Capita"], 4, labels=["Q1 poorest", "Q2", "Q3", "Q4 richest"])
quart = clean.groupby("GDP quartile", observed=True).agg(
    countries=("GDP Per Capita", "size"),
    gdp_range=("GDP Per Capita", lambda s: f"${s.min():,.0f}โ€“${s.max():,.0f}"),
    children=("Children out of School", "median"),
    adolescents=("Adolescents out of School", "median"),
    primary_effort=("Govt Primary Expenditure", "median"),
)

fig, ax = plt.subplots(figsize=(10, 4))
xpos, w = np.arange(len(quart)), .36
for off, col, color, lab in [(-w / 2 - .01, "children", BLUE, "Children (primary age)"),
                             (w / 2 + .01, "adolescents", AQUA, "Adolescents (lower secondary age)")]:
    bars = ax.bar(xpos + off, quart[col], w, color=color, label=lab)
    ax.bar_label(bars, fmt="%.1f%%", fontsize=8.5, padding=2, color=INK)
ax.set_xticks(xpos, [f"{q}\n{r}".replace("$", r"\$") for q, r in zip(quart.index, quart["gdp_range"])])
ax.set_ylabel("median % out of school")
ax.legend(loc="upper right")
ax.grid(axis="x", visible=False)
figure_title(fig, "The poorest quarter of countries has by far the most children out of school")
fig.tight_layout()
save(fig, "gdp_quartiles")
quart
countries gdp_range children adolescents primary_effort
GDP quartile
Q1 poorest 16 $486โ€“$5,820 10.34 20.27 14.96
Q2 15 $5,976โ€“$13,322 3.40 4.58 15.11
Q3 15 $13,434โ€“$30,640 1.95 2.59 20.00
Q4 richest 16 $36,138โ€“$105,462 0.87 0.98 20.51
๐Ÿ”Ž Insight
In the poorest quarter of countries (GDP per capita below about $5,800) the median adolescent out-of-school rate is 20.3%; in the richest quarter it is 1.0%. The drop is steep between Q1 and Q2 and flat after that, so the wealth effect is concentrated among the poorest countries. Median primary spending effort is lower in the two poorer quartiles (about 15% of GDP per capita) than in the two richer ones (about 20%), which is why spending effort and out-of-school rates correlate at all.

Does spending matter once wealth is held constant?

๐Ÿ’ก Why this step
The two spending indicators are a share of GDP per capita: they measure effort, not money. H2 may be about absolute money, which I can rebuild as % of GDP per capita ร— GDP per capita. But absolute spending moves almost one-for-one with wealth, so the honest test is a partial rank correlation: the link between spending and out-of-school rates after removing what GDP per capita explains in both.
Code
clean["Primary spend per student (US$)"] = clean["Govt Primary Expenditure"] / 100 * clean["GDP Per Capita"]
clean["Secondary spend per student (US$)"] = clean["Govt Secondary Expenditure"] / 100 * clean["GDP Per Capita"]

rows = []
for spend, outcome in [("Govt Primary Expenditure", "Children out of School"),
                       ("Govt Secondary Expenditure", "Adolescents out of School"),
                       ("Primary spend per student (US$)", "Children out of School"),
                       ("Secondary spend per student (US$)", "Adolescents out of School")]:
    rho, p = spearmanr(clean[spend], clean[outcome])
    pr, pp = partial_spearman(clean[spend], clean[outcome], clean["GDP Per Capita"])
    rows.append({"Spending measure": spend, "Outcome": outcome,
                 "rho": rho, "p": fmt_p(p), "partial rho | GDP": pr, "partial p": fmt_p(pp)})
rows.append({"Spending measure": "Primary spend per student (US$)", "Outcome": "GDP Per Capita",
             "rho": spearmanr(clean["Primary spend per student (US$)"], clean["GDP Per Capita"])[0],
             "p": "", "partial rho | GDP": np.nan, "partial p": ""})
pd.DataFrame(rows)
Spending measure Outcome rho p partial rho | GDP partial p
0 Govt Primary Expenditure Children out of School -0.32 0.011 -0.18 0.175
1 Govt Secondary Expenditure Adolescents out of School -0.03 0.843 0.11 0.379
2 Primary spend per student (US$) Children out of School -0.56 < 0.001 -0.22 0.083
3 Secondary spend per student (US$) Adolescents out of School -0.55 < 0.001 0.10 0.428
4 Primary spend per student (US$) GDP Per Capita 0.97 NaN
๐Ÿ”Ž Insight
Measured as effort, spending has a weak link with children out of school (rho = โˆ’0.32) and none with adolescents. Measured in dollars, the link looks strong (rho โ‰ˆ โˆ’0.55), but dollar spending per student is almost a restatement of wealth (rho = 0.97 with GDP per capita). Holding GDP constant, none of the four spending measures has a significant link left (partial rho โˆ’0.22 to 0.11, all p > 0.08). On this data, spending does not explain out-of-school rates beyond what national wealth already explains.

๐Ÿ›ฐ๏ธ 7. H1 with recent data

๐Ÿ’ก Why this step
Spending data ends in 2018, but out-of-school rates and GDP run to 2024. H1 needs only those two, so it can be tested on a much larger and more recent sample. This also checks whether the 2015 result still holds a decade later.
Code
h1_cols = ["GDP Per Capita", "Children out of School", "Adolescents out of School"]
h1_cov = pd.Series({y: len(wide_for(y)[h1_cols].dropna()) for y in map(str, range(2018, 2025))}, name="complete economies")
RECENT = h1_cov.idxmax()
recent = wide_for(RECENT)[h1_cols].dropna()
print(f"Best-covered recent year for H1: {RECENT} (n = {len(recent)})")
display(h1_cov.to_frame().T)

recent_results = pd.DataFrame([
    dict(zip(["rho", "p", "CI low", "CI high"], spearman_ci(recent["GDP Per Capita"], recent[y]))) | {"Outcome": y}
    for y in h1_cols[1:]
]).set_index("Outcome")
recent_results["p"] = recent_results["p"].map(fmt_p)
recent_results
Best-covered recent year for H1: 2021 (n = 136)
2018 2019 2020 2021 2022 2023 2024
complete economies 125 129 131 136 135 132 105
rho p CI low CI high
Outcome
Children out of School -0.45 < 0.001 -0.58 -0.30
Adolescents out of School -0.71 < 0.001 -0.79 -0.61
Code
fig, ax = plt.subplots(figsize=(11, 5))
for y, color in [("Adolescents out of School", AQUA), ("Children out of School", BLUE)]:
    ax.scatter(recent["GDP Per Capita"], recent[y], s=26, alpha=.7, color=color, edgecolor=SURFACE, lw=.6,
               label=y.replace(" out of School", f" (rho = {spearmanr(recent['GDP Per Capita'], recent[y])[0]:.2f})"))
# rolling median of the adolescent rate along log GDP, to show the shape
srt = recent.sort_values("GDP Per Capita")
roll = srt["Adolescents out of School"].rolling(21, center=True, min_periods=11).median()
ax.plot(srt["GDP Per Capita"], roll, color=INK, lw=2, label="Adolescents, rolling median (21 countries)")
for code in srt.sort_values("Adolescents out of School").index[-5:]:
    ax.annotate(f"{code}", (recent.loc[code, "GDP Per Capita"], recent.loc[code, "Adolescents out of School"]),
                xytext=(4, 2), textcoords="offset points", fontsize=7.5, color=MUTED)
ax.set_xscale("log")
ax.xaxis.set_major_formatter(usd)
ax.set_xlabel(f"GDP per capita {RECENT} (US$, log scale)")
ax.set_ylabel("% out of school")
ax.legend(loc="upper right")
figure_title(fig, f"The wealth gradient still holds in {RECENT}, with a much larger sample (n = {len(recent)})")
fig.tight_layout()
save(fig, "recent_h1")
๐Ÿ”Ž Insight
With 136 economies in 2021 the wealth gradient is clearer than in 2015: rho = โˆ’0.71 for adolescents (CI โˆ’0.79 to โˆ’0.61) and โˆ’0.45 for children. The rolling median shows a threshold rather than a straight line: adolescent rates stay around 30% below roughly $2,000 per capita, drop steeply between $2,000 and $4,000, and are close to zero above $20,000. The highest rates are in Niger, Chad, Syria, Burkina Faso and Tanzania.

๐Ÿ” 8. Robustness

๐Ÿ’ก Why this step
The headline numbers come from one year. If they are real, they should look similar in neighbouring years, and when each country's most recent value within a window is used instead of a single year. Both checks use the same four tests.
Code
by_year = []
for year in map(str, range(2008, 2018)):
    w = wide_for(year).dropna()
    if len(w) < 40:
        continue
    r = run_tests(w)
    r["Year"] = int(year)
    by_year.append(r)
by_year = pd.concat(by_year)

fig, axes = plt.subplots(1, 4, figsize=(14, 3.8), sharey=True)
for ax, (x, y, h) in zip(axes, TESTS):
    s = by_year[(by_year["Explanatory"] == x) & (by_year["Outcome"] == y)]
    color = BLUE if h == "H1" else ORANGE
    ax.fill_between(s["Year"], s["CI low"], s["CI high"], color=color, alpha=.15, lw=0)
    ax.plot(s["Year"], s["rho"], color=color, marker="o", ms=5, mec=SURFACE)
    ax.axhline(0, color=MUTED, lw=1, ls="--")
    ax.set_title(f"{h}: {x.split(' ')[1] if h == 'H2' else 'GDP'} โ†’ {y.split(' ')[0]}", fontsize=10)
    ax.set_xticks(s["Year"][::2])
axes[0].set_ylabel("Spearman rho (95% CI)")
figure_title(fig, "The wealth link is stable across years; the spending-effort link stays weak")
fig.tight_layout()
save(fig, "robustness_years")
by_year.pivot_table(index="Year", columns=["Hypothesis", "Outcome", "Explanatory"], values="rho").round(2).assign(
    n=by_year.groupby("Year")["n"].first())
Hypothesis H1 H2 n
Outcome Adolescents out of School Children out of School Adolescents out of School Children out of School
Explanatory GDP Per Capita GDP Per Capita Govt Secondary Expenditure Govt Primary Expenditure
Year
2008 -0.72 -0.62 -0.23 -0.25 46
2009 -0.85 -0.61 -0.26 -0.41 53
2010 -0.79 -0.60 -0.16 -0.24 56
2011 -0.76 -0.65 -0.17 -0.24 63
2012 -0.86 -0.67 -0.16 -0.23 54
2013 -0.81 -0.65 -0.27 -0.22 66
2014 -0.66 -0.64 -0.18 -0.23 62
2015 -0.59 -0.53 -0.03 -0.32 62
2016 -0.70 -0.54 -0.12 -0.11 57
Code
# Latest value per country and indicator within 2014โ€“2018
WINDOW = [str(y) for y in range(2014, 2019)]
latest = (countries.set_index(["Country Code", "Series Code"])[WINDOW]
          .apply(lambda r: r.dropna().iloc[-1] if r.notna().any() else np.nan, axis=1)
          .unstack().rename(columns=VARIABLES)[list(VARIABLES.values())].dropna())
print(f"Latest value in {WINDOW[0]}โ€“{WINDOW[-1]}: n = {len(latest)} economies (vs {len(clean)} in {YEAR} alone)")
window_results = run_tests(latest)
window_results["partial rho | GDP"] = [
    partial_spearman(latest[x], latest[y], latest["GDP Per Capita"])[0] if h == "H2" else np.nan for x, y, h in TESTS]
window_results["partial p"] = [
    fmt_p(partial_spearman(latest[x], latest[y], latest["GDP Per Capita"])[1]) if h == "H2" else "" for x, y, h in TESTS]
window_results
Latest value in 2014โ€“2018: n = 103 economies (vs 62 in 2015 alone)
Hypothesis Explanatory Outcome rho CI low CI high p-value n partial rho | GDP partial p
0 H1 GDP Per Capita Children out of School -0.61 -0.74 -0.45 < 0.001 103 NaN
1 H1 GDP Per Capita Adolescents out of School -0.75 -0.84 -0.62 < 0.001 103 NaN
2 H2 Govt Primary Expenditure Children out of School -0.34 -0.50 -0.16 < 0.001 103 -0.03 0.794
3 H2 Govt Secondary Expenditure Adolescents out of School -0.35 -0.52 -0.17 < 0.001 103 -0.06 0.537
๐Ÿ”Ž Insight
H1 holds in every year from 2008 to 2016 (rho โˆ’0.53 to โˆ’0.86, CIs never near zero), and is strongest for adolescents. H2 is weak and unstable: the primary-effort CI includes zero in most years. Using each country's latest value in 2014โ€“2018 raises the sample to 103 economies; spending effort then correlates at about โˆ’0.35 for both outcomes, but the partial correlation given GDP drops to โˆ’0.03 and โˆ’0.06 (p = 0.79 and 0.54). The bigger sample confirms rather than overturns the 2015 result.

๐Ÿ 9. Findings and limitations

What the data says

  1. H1 is supported. Higher GDP per capita goes with lower out-of-school rates for both age groups in every year from 2008 to 2016 (Figure 8), in 2015 (rho = โˆ’0.53 for children and โˆ’0.59 for adolescents, n = 62; Figure 4) and in 2021 with a much larger sample (rho = โˆ’0.45 and โˆ’0.71, n = 136; Figure 7). The poorest quarter of countries has a median of 20% of adolescents out of school against 1% in the richest quarter (Figure 6).
  2. H2 is not supported. Spending per student as a share of GDP per capita shows at most a weak raw link, and no link at all once wealth is held constant, both in 2015 (n = 62) and with the latest 2014โ€“2018 values (n = 103). Dollar spending per student is almost the same variable as GDP per capita (rho = 0.97), so it cannot separate money from wealth.
  3. The problem is concentrated and, for adolescents, getting worse. The relationship looks like a threshold: rates are high below about $2,000 per capita and close to zero above $20,000. In low-income countries the share of adolescents out of school has risen from 35.5% (2013) to 42% (2024) (Figure 2).

Limitations

Next steps

๐Ÿ’พ 10. Save outputs

Code
import shutil
out_cols = list(VARIABLES.values()) + ["Primary spend per student (US$)", "Secondary spend per student (US$)", "GDP quartile"]
clean[out_cols].to_csv(OUT_DIR / f"school_dropout_{YEAR}_clean.csv")
recent.to_csv(OUT_DIR / f"school_dropout_{RECENT}_h1.csv")
zip_path = shutil.make_archive(str(OUT_DIR / "figures"), "zip", FIG_DIR)
print("Saved:")
for f in sorted(OUT_DIR.glob("*.csv")) + [Path(zip_path)]:
    print("  ", f)
print(f"   {len(list(FIG_DIR.glob('*.png')))} figures in {FIG_DIR}")
Saved:
   output/school_dropout_2015_clean.csv
   output/school_dropout_2021_h1.csv
   /home/claude/nb/run/output/figures.zip
   8 figures in output/figures

Data: World Bank, World Development Indicators (DataBank export, last updated 8 October 2026), CC BY 4.0.