Appendix B — Population Data Validation: WorldPop vs. ONEI
The trigger framework uses gridded population to estimate exposure inside the ZMA. Before relying on those exposure numbers, this appendix sanity-checks the WorldPop raster we use against an independent official tabulation from ONEI (Oficina Nacional de Estadística e Información de Cuba). The two are not fully independent — both descend from Cuba’s 2012 census — but the comparison still diagnoses (i) whether province-level totals are coherent across products, (ii) where they disagree, and (iii) the order of magnitude of structural uncertainty on absolute counts.
B.0.1 Inputs
Role
Source
Polygons
Field Maps edge-matched humanitarian admin1 (fieldmaps/edge-matched/humanitarian/intl/adm1/CUB.parquet, Blob container global)
The xlsx is published via ONEI’s Observatorio de Igualdad de Género. Despite the “2024” filename, rows are tagged year = 2025 (this is the 2025-published projection for the 2024 reference year).
B.0.2 Zonal sums
Code
import ioimport unicodedataimport geopandas as gpdimport numpy as npimport ocha_stratus as stratusimport pandas as pdfrom exactextract import exact_extractfrom src.constants import PROJECT_PREFIXdef norm_name(s: str) ->str:"""Accent-fold + lowercase for joining province names across sources."""return ( unicodedata.normalize("NFKD", str(s)) .encode("ascii", "ignore") .decode("ascii") .strip() .lower() )adm1_raw = stratus.load_blob_data("fieldmaps/edge-matched/humanitarian/intl/adm1/CUB.parquet", container_name="global", stage="dev",)adm1 = gpd.read_parquet(io.BytesIO(adm1_raw))da = stratus.open_blob_cog("worldpop/pop_count/global_pop_2026_CN_1km_R2025A_UA_v1.tif", container_name="raster", stage="dev",)if"band"in da.dims and da.sizes.get("band", 1) ==1: da = da.squeeze("band", drop=True)ifstr(adm1.crs) !=str(da.rio.crs): adm1 = adm1.to_crs(da.rio.crs)# exactextract handles partial-pixel coverage; sum is area-weightedstats = exact_extract( da, adm1, ops=["sum"], output="pandas", include_cols=["adm1_name"])sum_col =next( c for c in stats.columns if c =="sum"or c.endswith("_sum"))stats = stats.rename(columns={sum_col: "pop_worldpop2026"})stats["name_key"] = stats["adm1_name"].map(norm_name)
total_wp = merged["pop_worldpop2026"].sum()total_off = merged["pop_official"].sum()print(f"WorldPop 2026 (constrained, sum of adm1): {total_wp:>12,.0f}")print(f"ONEI 2024 projection (sum of adm1): {total_off:>12,.0f}")print(f"Difference: {total_wp - total_off:>+,.0f} ({100*(total_wp/total_off -1):+.2f}%)")
WorldPop 2026 (constrained, sum of adm1): 10,848,785
ONEI 2024 projection (sum of adm1): 9,748,007
Difference: +1,100,778 (+11.29%)
B.0.3 Same shape, different totals
The +11% national gap could mean either (a) the two datasets disagree on the spatial distribution of people across provinces, or (b) the spatial distribution is similar and only the national total differs. To tell them apart, compare each province’s share of the national total in both datasets.
Shares match within ~1 percentage point across all 16 provinces. The Pearson correlation between provincial totals is essentially 1:
Code
r = np.corrcoef(merged["pop_worldpop2026"], merged["pop_official"])[0, 1]print(f"Pearson r (admin1 totals): {r:.4f}")
Pearson r (admin1 totals): 0.9970
B.0.4 Decomposing the per-province difference
Per-province pct_diff ranges from +4% (Guantánamo) to +18% (La Habana). That is not the +11.3% you would get from a pure national rescale. Both components matter and they compose multiplicatively:
The rescale is +11.3% for every province (UN WPP 2024 national total vs. ONEI’s 2025 internal projection). The reshape varies from −6.6% (Guantánamo) to +6.0% (La Habana), with a clear east–west tilt:
Direction
Provinces (reshape sign)
Western (gainers)
La Habana, Artemisa, Cienfuegos, Camagüey, Mayabeque, Villa Clara
Eastern (losers)
Guantánamo, Granma, Santiago de Cuba, Holguín, Las Tunas
B.0.5 Rank check
For applications that rank provinces by exposure, the question is whether the ordering is preserved. 13 of 16 provinces have identical rank between WorldPop and ONEI; the three that swap are a single trio in the middle of the distribution:
The only meaningful rank swap is Artemisa rising 2 places (9th → 11th in ONEI, but 9th in WorldPop), pushing Las Tunas and Guantánamo down one slot each. That is the same east-west reshape made visible on the rank scale.
B.0.6 Cross-checking at admin2
If the shared-lineage story is right, we should see the same close agreement at admin2 (municipio). Both sources publish at this scale: CODAB has 168 admin2 polygons, ONEI’s CUB_ADM2_POP_2024.xlsx has 168 rows.
The pcode systems do not match (CODAB uses CU0201-style, ONEI’s adm2 pcode column is NaN), so the join is on (adm1_name, adm2_name) after Unicode normalisation. The compound key also disambiguates two municipios called “San Luis” (one in Pinar del Río, one in Santiago de Cuba).
A handful of municipio names have typos or spelling variants on one side (e.g. ONEI’s "aguada de pasajajeros" vs. CODAB’s "aguada de pasajeros"; ONEI’s "la habana del este" vs. CODAB’s "habana del este"). Rather than build a manual fix-up map and risk silently mis-matching, we apply a stricter quality filter: keep an admin1 only if every one of its admin2 names matches exactly between CODAB and ONEI under basic Unicode normalisation. If even one municipio in a province has uncertain name alignment, drop the whole province — because if names are uncertain, boundary alignment between the two sources is uncertain too.
Code
def norm_basic(s: str) ->str:return ( unicodedata.normalize("NFKD", str(s)) .encode("ascii", "ignore") .decode("ascii") .strip() .lower() )adm2 = stratus.load_shp_from_blob( blob_name=f"{PROJECT_PREFIX}/raw/codab/cub.shp.zip", shapefile="cub_adm2.shp", stage="dev",)adm2["a1_k"] = adm2["ADM1_ES"].map(norm_basic)adm2["a2_k"] = adm2["ADM2_ES"].map(norm_basic)xlsx2_bytes = stratus.load_blob_data(f"{PROJECT_PREFIX}/raw/unfpa/CUB_ADM2_POP_2024.xlsx", container_name="projects", stage="dev",)official2 = ( pd.read_excel(io.BytesIO(xlsx2_bytes), sheet_name="CUB_ADM2_POP_2024")[ ["ADM1_NAME", "ADM2_NAME", "T_TL"] ] .rename(columns={"T_TL": "pop_official"}) .assign( a1_k=lambda d: d["ADM1_NAME"].map(norm_basic), a2_k=lambda d: d["ADM2_NAME"].map(norm_basic), ))# Keep admin1s where the set of adm2 names is identical in both sourcesdef is_clean(a1_key: str) ->bool: g =set(adm2.loc[adm2["a1_k"] == a1_key, "a2_k"]) o =set(official2.loc[official2["a1_k"] == a1_key, "a2_k"])returnbool(g) and g == oclean_a1 =sorted( {a1 for a1 inset(adm2["a1_k"]) |set(official2["a1_k"]) if is_clean(a1)})dropped_a1 =sorted( {a1 for a1 inset(adm2["a1_k"]) |set(official2["a1_k"]) ifnot is_clean(a1)})print(f"clean admin1s ({len(clean_a1)}/16): {clean_a1}")print(f"dropped admin1s ({len(dropped_a1)}/16): {dropped_a1}")
clean admin1s (9/16): ['artemisa', 'granma', 'isla de la juventud', 'las tunas', 'matanzas', 'mayabeque', 'pinar del rio', 'sancti spiritus', 'villa clara']
dropped admin1s (7/16): ['camaguey', 'ciego de avila', 'cienfuegos', 'guantanamo', 'holguin', 'la habana', 'santiago de cuba']
Code
adm2_clean = adm2[adm2["a1_k"].isin(clean_a1)].merge( official2.loc[official2["a1_k"].isin(clean_a1), ["a1_k", "a2_k", "pop_official"]], on=["a1_k", "a2_k"], validate="one_to_one",)ifstr(adm2_clean.crs) !=str(da.rio.crs): adm2_clean = adm2_clean.to_crs(da.rio.crs)stats2 = exact_extract( da, adm2_clean, ops=["sum"], output="pandas", include_cols=["ADM2_PCODE", "ADM2_ES", "ADM1_ES", "pop_official"],)sum2_col =next( c for c in stats2.columns if c =="sum"or c.endswith("_sum"))stats2 = stats2.rename(columns={sum2_col: "pop_worldpop2026"})stats2["pct_diff"] = (100* (stats2["pop_worldpop2026"] / stats2["pop_official"] -1))print(f"adm2 polygons in clean subset: {len(stats2)} / 168")print(f"WP total: {stats2['pop_worldpop2026'].sum():>11,.0f} "f"ONEI total: {stats2['pop_official'].sum():>11,.0f} "f"diff: {100*(stats2['pop_worldpop2026'].sum()/stats2['pop_official'].sum()-1):+.2f}%")r2 = np.corrcoef(stats2["pop_worldpop2026"], stats2["pop_official"])[0, 1]rho2 = np.corrcoef( stats2["pop_worldpop2026"].rank(), stats2["pop_official"].rank())[0, 1]print(f"Pearson r: {r2:.4f} (vs 0.997 at adm1)")print(f"Spearman rho: {rho2:.4f} (vs 0.991 at adm1)")
adm2 polygons in clean subset: 89 / 168
WP total: 4,743,825 ONEI total: 4,284,194 diff: +10.73%
Pearson r: 0.9786 (vs 0.997 at adm1)
Spearman rho: 0.9105 (vs 0.991 at adm1)
After filtering, 9 of 16 admin1s qualify, leaving 89 municipios. A naive Pearson r is 0.98, but that statistic is dominated by the largest municipios (each province’s capital) and hides what is happening to the small ones. The more honest test is whether the per-adm2 pct_diff distribution within each clean province clusters tightly around that province’s adm1-level pct_diff (which we already know — it sits on the +11.3% rescale plus the east-west reshape).
The median per-adm2 pct_diff closely tracks the adm1 pct_diff in each province (e.g. Mayabeque adm1 = +13.6%, adm2 median = +13.7%; Sancti Spíritus adm1 = +11.3%, adm2 median = +11.4%). There is no systematic within-province bias — the per-province mass is in roughly the right place.
But the spread is large. Villa Clara’s individual municipios range from −66% to +93% (std 37 pp); Pinar del Río and Artemisa span ~80 pp; Matanzas and Mayabeque ~50–60 pp. Even in the cleanest provinces, per-municipio agreement is far worse than per-province.
This is consistent with the dasymetric preservation property of popRF: it preserves the total inside each WorldPop-input admin unit but redistributes population within that unit according to the random forest density × built-settlement mask. When CODAB’s adm2 polygons happen to match WorldPop’s internal nested adm2 boundaries, the per-municipio sums recover ONEI’s values. When they don’t, the within- unit allocation determines the per-CODAB-adm2 result, and that allocation depends on RF/settlement signal rather than on the actual ONEI municipal census.
To make the within-province scatter explicit, the residual of each adm2’s pct_diff from its province’s pct_diff should be centred near zero with most weight inside ±10–15 pp if dasymetric preservation behaved cleanly. In practice the residuals reach ±80 pp:
Code
stats2 = stats2.merge( adm1_summary["adm1_pct"].rename("province_pct"), left_on="ADM1_ES", right_index=True,)stats2["residual_pp"] = stats2["pct_diff"] - stats2["province_pct"]print("=== Top 5 residuals (WP allocates more than its province average) ===")print( stats2.nlargest(5, "residual_pp")[ ["ADM2_ES", "ADM1_ES", "pop_worldpop2026", "pop_official", "residual_pp"] ] .round(0) .to_string(index=False))print("=== Bottom 5 residuals (WP allocates less than its province average) ===")print( stats2.nsmallest(5, "residual_pp")[ ["ADM2_ES", "ADM1_ES", "pop_worldpop2026", "pop_official", "residual_pp"] ] .round(0) .to_string(index=False))
=== Top 5 residuals (WP allocates more than its province average) ===
ADM2_ES ADM1_ES pop_worldpop2026 pop_official residual_pp
Caibarién Villa Clara 67771.0 35191 79.0
San Juan y Martínez Pinar del Río 69201.0 38194 70.0
Bauta Artemisa 77968.0 43887 63.0
Cifuentes Villa Clara 37301.0 23243 47.0
Remedios Villa Clara 56242.0 37250 38.0
=== Bottom 5 residuals (WP allocates less than its province average) ===
ADM2_ES ADM1_ES pop_worldpop2026 pop_official residual_pp
Placetas Villa Clara 19009.0 56514 -80.0
La Sierpe Sancti Spíritus 11326.0 15685 -39.0
Jovellanos Matanzas 42232.0 51241 -28.0
Cauto Cristo Granma 15678.0 19921 -27.0
Nueva Paz Mayabeque 18007.0 20211 -24.0
So even after the strict admin1 filter:
Province-level totals (adm1) are preserved. Per-adm2 medians track per-adm1 pct_diff closely, so there is no systematic within-province bias.
Individual municipio counts are not reliable. The within-province spread is ±20–80 pp. The raster is a province-level estimator, not a municipio-level one — even in provinces where names and boundaries align cleanly.
The 7 dropped provinces include exactly the densest urban areas (La Habana, Santiago de Cuba, Cienfuegos). We can say nothing empirically validated about adm2 accuracy there; a polygon-level ONEI–CODAB crosswalk would be needed for a true test.
B.0.7 Methodology context
ONEI projection. The xlsx is ONEI’s projection from the 2012 Censo de Población y Viviendas (Cuba’s last completed census) using a deterministic cohort-component method via CEPDE under a UNFPA collaboration. The xlsx’s “Methods” cell does not specify the technique explicitly. The known methodological weakness, documented by independent demographers (notably Albizu-Campos, CEDEM/UH), is in emigration accounting:
Since 2013, ONEI does not classify as emigrants persons who hold a valid Cuban passport and re-enter the country at least once every 24 months, regardless of where they actually live.
The external migration balance is reportedly dominated by US arrivals data, undercounting flows to Spain, Mexico, Russia, Uruguay, etc.
Independent reconstruction from electoral rolls + birth/death + destination data places end-2024 population at roughly 8.0M vs. ONEI’s 9.75M — a ~1.7M (~18%) gap that ONEI’s published projection does not see.
WorldPop pipeline. The R2025A raster is produced by random-forest dasymetric disaggregation (Stevens et al. 2007), implemented via the popRF R package. For Cuba specifically (WorldPop’s census-sources sheet, row 52):
Stage
Input for Cuba
Timepoint 1
ONEI 2012 census, 168 municipios
Timepoint 2
ONEI Anuario Estadístico 2022, 170 municipios
Annual interpolation
Modelled per municipio, 2010–2030
National constraint
UN WPP 2024 Revision, January-1st estimates
Settlement mask
GHSL BUILT-S/V + Google Open Buildings v3 + Microsoft Building Footprints + World Settlement Footprint
Both WorldPop timepoints are ONEI products, and the UN WPP 2024 national control itself draws on ONEI. So WorldPop and our reference xlsx share the same 2012-census-rooted lineage and the same emigration blind-spot. R2025A is marked as an alpha release by WorldPop, with refinements expected.
B.0.8 What this means for the trigger framework
At admin1, rank- and share-based comparisons are robust. 13/16 ranks match exactly, all 16 shares match within 1.1 pp, and r ≈ 0.997. Exposure rankings used for provincial prioritisation are insensitive to the choice between WorldPop and ONEI.
At admin2, the raster is not a reliable per-municipio estimator — not even in provinces where names and boundaries align cleanly. After restricting to the 9 admin1s with perfect adm2-name matches between CODAB and ONEI (89 municipios), the per-adm2 pct_diffmedian tracks each province’s adm1 pct_diff closely, but the spread is ±20–80 percentage points within province. The dasymetric preservation property holds only at WorldPop’s internal nested input units (~161 for Cuba), not at CODAB’s published 168 — so within-province allocation is driven by the random-forest density
built-settlement mask, not by ONEI’s municipal census. For municipio-level exposure numbers, use ONEI’s adm2 directly rather than zonal sums from this raster.
Absolute counts are not robust at any level. Both datasets descend from the 2012 census via ONEI projections that do not reflect post-2020 emigration. Independent estimates suggest the true end-2024 population is ~15–18% lower than either number. If absolute “people affected” figures are needed, they should be flagged with that uncertainty.
The +11% gap between WorldPop and the xlsx is not evidence about real population. It is mostly a difference between UN WPP 2024 (WorldPop’s national control) and ONEI’s later 2025 projection (the xlsx). The ~6% east-west reshape on top is consistent with WorldPop’s 2022 input pre-dating ONEI’s 2025 redistribution of share away from eastern Cuba.
B.0.9 References
WorldPop. Global Demographic Data: Public Release R2025A V1, September
Stevens, F. R., Gaughan, A. E., Linard, C. & Tatem, A. J. (2015). Disaggregating census data for population mapping using random forests with remotely-sensed and ancillary data. PLoS ONE 10:e0107042.
UN DESA, Population Division (2024). World Population Prospects 2024: Methodology. UN DESA/POP/2024/DC/NO.10.