How many people and which assets are exposed to a given hazard, before any event, as a baseline for preparedness and planning.
Overview¶
This notebook fetches data from several sources to calculate the baseline risk exposure of Lebanon’s population to various hazards. It uses OCHA’s Common Operational Datasets for administrative boundaries, INFORM subnational risk scores for hazard exposure, and WorldPop population estimates.
Code and prose have been hidden by default to focus on the map and stat outputs, but you can click on the toggles titled “Source” and “Notebook Cell” to see the underlying logic and data processing steps.
Notebook Cell
Data Prep¶
Notebook Cell
Boundaries¶
Let’s fetch data for Lebanon, since it has precise, up-to-date INFORM subnational risk scores (see below).
OCHA’s Common Operational Datasets (COD-AB) are published as cloud-native
GeoParquet by the Humanitarian Data Exchange on
sourceadm3, ~1,600 municipalities) lines up
with the municipality granularity used by the INFORM risk index below.
The catalog is organized as a STAC tree (per the Portolan SDI spec this dataset is built with): root catalog → country → version → admin-level collection → asset. Each collection resolves to a predictable path, so we build the file’s URL directly from a base URL and a country/version/admin-level suffix, fetch it, and keep only the columns we need.
Inform Subnational Risk Index¶
INFORM Subnational Risk is published for roughly 30 countries at various spatial resolution (admin 1 - 3), potentially ad-hoc.
For example, the most recent data for Lebanon appears to be from 2024.
For the purposes of this notebook, the key variables provided by INFORM appear to be:
NATURAL
Flood (riverine flood)
Storm (tropical cyclone)
Earthquake & Tsunami
Forest Fire (wildfire)
VULNERABILITY
INFRASTRUCTURE
LACK OF COPING CAPACITY
RISK
Matching boundaries to the risk index¶
INFORM’s admin3 codes are Lebanon’s national cadastre codes (per the workbook’s own Table of Contents), not OCHA P-codes, so the two datasets aren’t interoperable via their code columns. We join on normalized district + municipality names instead. Both sides also carry placeholder rows for disputed/unclaimed cadastral zones ("Litige" in INFORM, "Conflict" in OCHA) that share a generic label rather than a real name, so those are excluded from the match rather than joined blindly.
Name-matching is not ideal, but works well here because OCHA’s own admin3 level for
Lebanon is the cadastre too, not a coincidence: its STAC collection metadata labels admin_3_name as "Cadaster (Manatek Ikaria)", and its caveats field documents that Lebanon’s admin3 P-codes are purely numeric and follow a different sequence than admin1/2 — an OCHA-acknowledged P-code nesting conflict from the post-2014 governorate restructuring, independent of the INFORM/OCHA mismatch above.
Hazard Types and Risk Threshold¶
Population Exposure¶
WorldPop publishes constrained population estimates as ~100m GeoTIFFs, projected for future years including 2026. The file isn’t cloud optimized, but is small enough (~3.7MB) to fetch it fully.
We sum population per pixel within each admin3 polygon (zonal statistics) to get total population per municipality.
Population at Risk by Hazard¶
INFORM classifies its 0-10 risk scores into five bands using hierarchical cluster analysis: Very Low (0.0–2.1), Low (2.2–3.1), Medium (3.2–4.8), High (4.9–6.7), Very High (6.8–10.0) — see the INFORM concept & methodology report. Each hazard sub-indicator (Flood, Storm, Earthquake & Tsunami, Forest Fire) uses the same 0-10 scale as the overall RISK score.
These bands are published for the global country-level index; INFORM’s subnational methodology doesn’t spell out per-indicator thresholds explicitly, so treating the “High” cutoff (> 4.9) as the “at risk” threshold is a reasonable but non-official choice, applied uniformly across all five hazards. The map and stats below both use this same fixed threshold, so both work identically with or without a running kernel — toggling a layer or reading a stat never needs to recompute anything live.
Notebook Cell
import io
import geopandas as gpd
import pandas as pd
import requests
boundaries_url = "https://data.source.coop/hdx/cod-ab"
adm3_path = "lbn/latest/adm3/original.parquet"
response = requests.get(f"{boundaries_url}/{adm3_path}")
lebanon_admin3_gdf = gpd.read_parquet(io.BytesIO(response.content))
# Keep only selected columns
lebanon_admin3_gdf = lebanon_admin3_gdf[["adm1_name", "adm2_name", "adm3_name", "adm3_pcode", "geometry"]]
inform_lebanon_url = "https://drmkc.jrc.ec.europa.eu/inform-index/Portals/0/InfoRM/2024/Subnational/Lebanon/20241010%20-%20INFORM_LEBANON_2024_v1.xlsx"
inform_lebanon_df = pd.read_excel(
inform_lebanon_url, sheet_name="INFORM Lebanon 2024", header=1
).dropna(subset=["MUNICIPALITY"])
inform = inform_lebanon_df[inform_lebanon_df["MUNICIPALITY"] != "Litige"].copy()
inform["key"] = (inform["DISTRICT"] + inform["MUNICIPALITY"]).str.lower().str.replace(r"[^a-z0-9]", "", regex=True)
ocha = lebanon_admin3_gdf[lebanon_admin3_gdf["adm3_name"] != "Litige"].copy()
ocha["key"] = (ocha["adm2_name"] + ocha["adm3_name"]).str.lower().str.replace(r"[^a-z0-9]", "", regex=True)
hazard_cols = ["Flood", "Storm", "Earthquake & Tsunami", "Forest Fire", "RISK"]
risk_boundaries_gdf = ocha[["key", "geometry"]].merge(
inform[["key", "GOVERNORATE", "DISTRICT", "MUNICIPALITY"] + hazard_cols], on="key"
)
print(f"Matched {len(risk_boundaries_gdf)} of {len(inform)} municipalities ({len(risk_boundaries_gdf) / len(inform):.1%})")
hazard_options = {
"Overall Risk": "RISK",
"Riverine Flood": "Flood",
"Tropical Cyclone": "Storm",
"Earthquake": "Earthquake & Tsunami",
"Wildfire": "Forest Fire",
}
# Focusing on risk scores greater than or equal to 4.9, which is the threshold for "high" risk in INFORM's scoring system.
RISK_THRESHOLD = 4.9
from rasterstats import zonal_stats
import rasterio
pop_url = "https://data.worldpop.org/GIS/Population/Global_2015_2030/R2025A/2026/LBN/v1/100m/constrained/lbn_pop_2026_CN_100m_R2025A_v1.tif"
response = requests.get(pop_url)
with rasterio.MemoryFile(response.content) as memfile:
with memfile.open() as src:
stats = zonal_stats(
risk_boundaries_gdf,
src.read(1),
affine=src.transform,
stats="sum",
nodata=src.nodata,
)
risk_population_gdf = risk_boundaries_gdf.copy()
risk_population_gdf["population"] = [round(s["sum"] or 0) for s in stats]
risk_population_gdf[["MUNICIPALITY", "population"]].sort_values("population", ascending=False).head()
Matched 1545 of 1545 municipalities (100.0%)
Population at Risk by Hazard¶
Source
import matplotlib
from lonboard import Map, PolygonLayer
from lonboard.colormap import apply_continuous_cmap
from manywidgets import Column, Row
from manywidgets.lonboard import LayerToggle
pop_min = risk_population_gdf["population"].min()
pop_max = risk_population_gdf["population"].max()
risk_layers = {}
for label, col in hazard_options.items():
at_risk_gdf = risk_population_gdf[risk_population_gdf[col] > RISK_THRESHOLD]
normalized = (at_risk_gdf["population"] - pop_min) / (pop_max - pop_min)
colors = apply_continuous_cmap(normalized.to_numpy(), matplotlib.colormaps["YlOrRd"])
risk_layers[label] = PolygonLayer.from_geopandas(
at_risk_gdf,
get_fill_color=colors,
get_line_color=[80, 80, 80],
line_width_min_pixels=0.5,
opacity=0.8,
visible=(label == "Overall Risk"),
)
hazard_toggles = [
LayerToggle(layer=risk_layers[label], value=(label == "Overall Risk"), label=label)
for label in hazard_options
]
risk_map = Map(
list(risk_layers.values()),
view_state={"longitude": 35.86, "latitude": 33.95, "zoom": 7.5},
height=500,
)
Column(Row(*hazard_toggles, gap="8px"), risk_map, gap="8px")Source
from manywidgets import Column, Row, Stat
def stat_for(label, col):
at_risk_gdf = risk_population_gdf[risk_population_gdf[col] > RISK_THRESHOLD]
return Stat(
label=label,
value=f"{round(at_risk_gdf['population'].sum()):,.0f}",
unit=f"across {len(at_risk_gdf)} municipalities",
)
hazard_stats = [stat_for(label, col) for label, col in hazard_options.items()]
Column(
Row(*hazard_stats[:3], gap="8px"),
Row(*hazard_stats[3:], gap="8px"),
gap="8px",
)