Hazard, exposure and vulnerability explained: what "risk" means in a GIS
On this page
Problem statement
"Risk map" is used for three different things. A map of where a hazard can occur β floodplains, fault lines, burn probability β is a hazard map. A map of what is there to be harmed β people, buildings, crops, roads β is an exposure map. A map of how badly those things are harmed by a given intensity is vulnerability. Risk is the combination, and a map labelled "risk" that shows only the first of the three will be read as if it showed all three.
Keeping them apart is not pedantry: they come from different sources, have different units, and change on different timescales. A floodplain moves when the river moves. Exposure changes when somebody builds a house. Vulnerability changes when that house is raised a metre.
Quick answer
Compute each term separately, then combine, and keep the intermediate layers:
import geopandas as gpd, numpy as np, json
blocks = gpd.read_parquet("blocks_ca_north.parquet").to_crs(3310) # equal-area: California Albers
fire = gpd.read_file("park_perimeter.geojson")
fire = fire[fire.poly_GISAcres > 1000].to_crs(blocks.crs) # the 2024 Park Fire perimeter
exposed = blocks[blocks.intersects(fire.union_all())]
print(f"hazard: {fire.area.sum() / 1e6:,.0f} kmΒ² burned")
print(f"exposure: {len(exposed):,} blocks, {int(exposed.POP20.sum()):,} residents, "
f"{int(exposed.HOUSING20.sum()):,} housing units intersect the perimeter")
print(f"risk: neither number above is risk until vulnerability is applied")
hazard: 1,744 kmΒ² burned
exposure: 320 blocks, 2,761 residents, 1,381 housing units intersect the perimeter
risk: neither number above is risk until vulnerability is applied
The third line is the point. Population inside a hazard footprint is exposure; turning it into expected harm needs a vulnerability function, and that function is usually the weakest link.
Hazard with intensity and probability, exposure, vulnerability
1. Define the hazard, with an intensity and a probability
"Flood" is not a hazard specification. "Water depth of 1.2 m with a 1% annual chance" is. A hazard layer needs both what happens and how often, as the return-period guide sets out.
2. Define exposure as countable things
People, dwellings, kilometres of road, hectares of crop, replacement value. Counts you can check beat indices you cannot.
3. Find a vulnerability relationship
A damage function maps intensity to loss: depth to percentage of building value, ground shaking to collapse probability, burn severity to tree mortality. These come from engineering studies and post-event surveys, not from GIS.
4. Combine at a consistent unit
Per building, per block, per grid cell. Mixing a raster hazard with polygon exposure means a decision about how to apportion, and that decision belongs in the metadata.
5. Keep the layers separate in the output
Publish hazard, exposure and the combination. A single risk score hides which term drove it, and readers will want to know whether an area is risky because the hazard is severe or because a lot of people live there.
6. Say which hazard scenario the map is for
A 1-in-100-year map and a 1-in-500-year map look the same and mean different things.
Exposure to the Park Fire, three ways
The data used on this page
Three real inputs for the 2024 Park Fire in northern California. The perimeter from the WFIGS interagency fire perimeters service (park_perimeter.geojson). The 2020 Census blocks of California with their POP20 and HOUSING20 counts, from the TIGER/Line block file (382 MB), kept for the bounding box of the main perimeter plus a 0.25Β° margin (blocks_ca_north.parquet) β only blocks touching the perimeter enter any result, so the margin does not change a number. And the burn severity from How to map burn severity with dNBR: its two Sentinel-2 scenes (2 July and 10 September 2024) on its 20 m window, saved as the uncorrected dNBR (dnbr.npy) and the pre-fire NBR (pre_nbr.npy).
import os
import geopandas as gpd
import numpy as np
import pystac_client
import rasterio
import requests
from rasterio.enums import Resampling
from rasterio.transform import from_origin
from rasterio.vrt import WarpedVRT
from rasterio.warp import transform_bounds
from shapely.geometry import box, shape
WFIGS = ("https://services3.arcgis.com/T4QMspbfLg3qTGWY/arcgis/rest/services/"
"WFIGS_Interagency_Perimeters/FeatureServer/0/query")
if not os.path.exists("park_perimeter.geojson"):
reply = requests.get(WFIGS, params={"where": "poly_IncidentName='PARK' AND attr_POOState='US-CA'",
"outFields": "*", "f": "geojson", "outSR": 4326}, timeout=300)
reply.raise_for_status()
open("park_perimeter.geojson", "w").write(reply.text)
if not os.path.exists("blocks_ca_north.parquet"):
fire = gpd.read_file("park_perimeter.geojson")
x0, y0, x1, y1 = fire[fire.poly_GISAcres > 1000].total_bounds # WFIGS has two small PARK fires too
blocks = gpd.read_file("https://www2.census.gov/geo/tiger/TIGER2020/TABBLOCK20/tl_2020_06_tabblock20.zip",
bbox=(x0 - 0.25, y0 - 0.25, x1 + 0.25, y1 + 0.25))
blocks[["GEOID20", "POP20", "HOUSING20", "ALAND20", "geometry"]].to_parquet("blocks_ca_north.parquet")
if not os.path.exists("dnbr.npy"):
BBOX = [-122.00, 40.00, -121.74, 40.22]
left, bottom, right, top = transform_bounds(4326, "EPSG:32610", *BBOX)
GRID = {"crs": "EPSG:32610", "transform": from_origin(left, top, 20, 20),
"width": int((right - left) // 20), "height": int((top - bottom) // 20)}
es = pystac_client.Client.open("https://earth-search.aws.element84.com/v1")
def scene(dates):
items = [i for i in es.search(collections=["sentinel-2-c1-l2a"], bbox=BBOX, datetime=dates,
query={"eo:cloud_cover": {"lt": 5}}).item_collection()
if shape(i.geometry).contains(box(*BBOX))]
return sorted(items, key=lambda i: i.datetime)[0]
def band(item, name):
with rasterio.open(item.assets[name].href) as src, WarpedVRT(
src, crs=GRID["crs"], transform=GRID["transform"], width=GRID["width"],
height=GRID["height"], resampling=Resampling.bilinear) as vrt:
a = vrt.read(1).astype("float32")
return np.where(a > 0, a * 0.0001, np.nan)
def nbr(item):
return (band(item, "nir08") - band(item, "swir22")) / (band(item, "nir08") + band(item, "swir22"))
pre, post = scene("2024-07-01/2024-07-10"), scene("2024-09-08/2024-09-15")
np.save("pre_nbr.npy", nbr(pre))
np.save("dnbr.npy", (nbr(pre) - nbr(post)) * 1000)
print(len(gpd.read_parquet("blocks_ca_north.parquet")), "blocks;", np.load("dnbr.npy").shape)
8735 blocks; (1235, 1123)
Example 1 β exposure inside the perimeter, done three ways
from shapely.ops import unary_union
peri = fire.union_all()
inter = blocks[blocks.intersects(peri)].copy()
inter["share"] = inter.geometry.intersection(peri).area / inter.geometry.area
by_touch = inter.POP20.sum()
by_centroid = blocks[blocks.representative_point().within(peri)].POP20.sum()
by_share = (inter.POP20 * inter.share).sum()
print(f"blocks touching the perimeter: {int(by_touch):,} residents")
print(f"blocks whose centre is inside: {int(by_centroid):,}")
print(f"area-weighted share of population: {by_share:,.0f}")
blocks touching the perimeter: 2,761 residents
blocks whose centre is inside: 1,677
area-weighted share of population: 1,640
Three defensible methods, three different answers. The area-weighted one assumes population is spread evenly inside each block, which in rural blocks it is not β see the dasymetric guide.
Example 2 β the hazard layer has an intensity, not just a footprint
import rasterio
from rasterio.features import geometry_mask
dnbr = np.load("dnbr.npy") # from the burn severity guide, 20 m pixels
classes = {"low": (99, 269), "moderate-low": (269, 439),
"moderate-high": (439, 659), "high": (659, 2000)}
for name, (lo, hi) in classes.items():
px = int(np.nansum((dnbr >= lo) & (dnbr < hi)))
print(f"{name:14} {px * 400 / 1e6:6.1f} kmΒ² within the study window")
low 323.9 kmΒ² within the study window
moderate-low 107.3 kmΒ² within the study window
moderate-high 52.4 kmΒ² within the study window
high 1.1 kmΒ² within the study window
Example 3 β where the three terms come from
sources = {
"hazard": "fire perimeter (WFIGS), burn severity from Sentinel-2",
"exposure": "US census blocks 2020: population and housing counts",
"vulnerability": "not in either dataset β needs a damage function",
}
for term, src in sources.items():
print(f"{term:14} {src}")
hazard fire perimeter (WFIGS), burn severity from Sentinel-2
exposure US census blocks 2020: population and housing counts
vulnerability not in either dataset β needs a damage function
Why the three terms must stay separate
Why the three terms must stay separate
They answer different questions and are updated by different people. A hazard layer is a physical model; exposure is a census or a building register; vulnerability is empirical damage data. Multiplying them into one number early makes it impossible to update one term when better data arrives, and impossible to explain the result.
Why exposure is where most of the uncertainty is on small areas
Hazard footprints are usually mapped at tens of metres. Exposure is aggregated to census units that can be kilometres across in rural areas, so "who is inside" depends on how a partly covered unit is treated. In the Park Fire example the three apportionment rules differ by 40% over the same perimeter: 2,761 residents against 1,640.
Why "risk" without vulnerability is a category error
Two identical houses in the same floodplain, one on stilts, face the same hazard and the same exposure and very different risk. Without a vulnerability function, a risk map is an exposure map with a more alarming title β and it will still be used to make decisions.
Multi-hazard, indirect losses and time of day
- Multi-hazard areas need each hazard separately; they do not add.
- Indirect losses β business interruption, displacement β are not in the exposure layer.
- Time of day changes exposure: a school is not the same population at 03:00 and 11:00.
- Global exposure data such as GHSL population or WorldPop replaces census blocks outside the US.
- Vulnerability functions exist for floods and earthquakes; for wildfire they are scarcer.
- Expected annual loss integrates over all probabilities, not just one scenario.
Internal links
- Return periods and annual exceedance probability explained β the probability half of hazard
- How to count buildings and people exposed to a hazard zone β exposure in practice
- Burn severity explained β one hazard intensity measure
- Flood hazard maps explained β depth, extent and scenario
- How to redistribute population with dasymetric mapping β better exposure inside big blocks
- Exposure counts are double-counted or missed β the apportionment trap
- Heat exposure explained β hazard, exposure and vulnerability for heat
FAQ
What is the difference between hazard and risk?
Hazard is the physical event and its probability. Risk is what that event does to what is there, through a vulnerability relationship.
Is a floodplain map a risk map?
No. It is a hazard map. It becomes a risk map only when exposure and vulnerability are added.
Where does vulnerability data come from?
Engineering studies and post-event damage surveys, not from spatial data.
How should I count people in a partly affected census unit?
State the rule. Area-weighted apportionment is common; dasymetric redistribution is better.
Can I add two hazards together?
Not directly. Model each hazard's risk and combine losses, not intensities.
What is expected annual loss?
Loss integrated over all hazard probabilities, rather than for one scenario.