Case Study // Hazard & risk analysis
California Wildfire Risk — Scoring & Validation
I took 147 years of California fire history — 22,810 CAL FIRE perimeters back to 1878 — and reduced it to a single 0–100 risk score for any point in the state, built from three things you can measure: how close the nearest past fire was, how many have burned nearby, and how recently.
The weights on those three signals are a stated domain assumption, not a trained result. That distinction is the whole point of this page: the score is worth something only if the places it calls high-risk are the places that actually burn, and the validation section lays out exactly how I am testing that against years the model never saw.
CAL FIRE perimeters (1878–2024)NASA FIRMS VIIRS detectionsPostGIS spatial SQLPython · GeoPandas · ShapelyEPSG:3310 California Albers
22,810
CAL FIRE PERIMETERS
1878–2024
HISTORICAL RECORD
// The question
Did the places it called high-risk actually burn?
A risk score is easy to produce and easy to believe. The hard part — and the part that decides whether anyone should act on it — is whether the number survives contact with what happened next. Everything on this page is machinery for answering that one sentence honestly.
Two numbers answer it. Precision: of the places I flagged high-risk, what fraction actually burned. Recall: of the places that actually burned, what fraction I caught. They trade against each other, and which one you favour depends on whether a miss means a missed fire or a false alarm means a wasted inspection.
Why not accuracywarning
Wildfire is a rare event at grid-cell resolution — only a small percentage of cells burn in any given window. A model that predicted “nothing will ever burn” would score in the high nineties on accuracy and be worthless. Reporting the base rate first, then precision and recall, is the only honest framing for a problem this imbalanced.
// Context
Why this exists
I lived in Santa Monica during the January 2025 Los Angeles wildfires. This project started as a personal response to that and became the analysis I most wanted to be able to defend: take a hazard dataset the state publishes openly, turn it into a decision-shaped number, then be rigorous about what that number is and is not worth.
The work sits in the same shape as what hazard and catastrophe risk teams do — score locations, hand the score to someone who has to decide something, and be able to say out loud how confident you are and where the model breaks.
// The data
Sources, projection, and why the projection matters
Two sources. CAL FIRE historical fire perimeters — 22,810 polygons spanning 1878 to 2024, the authoritative state record of where fire has been. And NASA FIRMS VIIRS thermal detections from the January 2025 LA fires, which are satellite hotspot points rather than mapped perimeters, and which carry their own confidence field precisely because not every detection is a wildfire.
Everything is stored and measured in EPSG:3310 (California Albers), an equal-area projection, and transformed to EPSG:4326 only on the way out. That is not a formality. Distances and areas computed in raw latitude/longitude are wrong — a degree of longitude is about 111 km at the equator but roughly 88 km at California's latitude — so a 20 km buffer drawn in degrees is not 20 km, and is not even the same distance north to south. Every proximity and density number here depends on that choice being made at ingest rather than patched later.
source
CAL FIRE perimeters + NASA FIRMS detections
Published open data, loaded with their native geometry preserved.
arrow_downward
EPSG:3310
PostGIS, stored equal-area
Every spatial predicate — ST_Distance, ST_DWithin, ST_Intersects — runs in metres, not degrees.
arrow_downward
scoring
Three factors, normalized and weighted
A pure function of the query results, with no database dependency, so the model can be tested on its own.
arrow_downward
EPSG:4326
GeoJSON out
Reprojected at the boundary only, so results drop straight into QGIS, Leaflet, or a notebook.
// The model
Three signals, and the assumption inside them
The score combines three spatial factors, each normalized to 0–100 and then weighted. It always returns the full factor breakdown alongside the headline number, so anyone reading a score can see which signal drove it instead of taking it on trust.
| Factor | Weight | What it measures | Sub-score |
|---|
| Proximity | 0.5 | Distance to the nearest historical fire (ST_Distance). | 0 km → 100, ≥ 50 km → 0 |
| Density | 0.3 | Count of fires within 20 km (ST_DWithin + COUNT). | saturates at 20 fires → 100 |
| Recency | 0.2 | Years since the most recent nearby fire. | this year → 100, ≥ 50 yrs → 0 |
The weights are a heuristic, and it is worth being precise about what that means. Nobody fit 0.5 / 0.3 / 0.2 to data. They encode a domain assumption — that recent, close fire activity is the strongest available signal — and they are stated plainly rather than buried, because a number a reviewer cannot interrogate is not worth much. The principled next step is to fit them against ground-truth burn outcomes with a logistic regression and let the data decide. Because the scoring logic is a pure function decoupled from the queries, swapping trained weights in is a contained change.
// Validation
How I am testing it against years it never saw
Study in progressResults publishing October 2026
The heuristic was built with every perimeter in front of it. Testing it against that same data would guarantee a flattering answer, so the honest version is a temporal split: pretend I only knew about fires through 2023, then check the score against what actually burned in 2024–25. Not a random split — a time split, because in this problem you are always predicting forward.
1Split the perimeters by year
Everything through 2023 becomes the training set the score is computed from. 2024 and 2025 are held back entirely.
2Build a fixed scoring grid
A 2 km hexagon grid over the study area in equal-area EPSG:3310, so every cell is one comparable observation. Without a common grid there is a prediction and an outcome but nothing to line them up against.
3Score every cell on training data only
The existing three-factor logic, run per hexagon against pre-2024 fires. This is the prediction.
4Label what actually burned
A spatial join marks each cell 1 if any 2024–25 perimeter touched it. This is ground truth.
5Report the base rate first
The fraction of cells that burned at all. It sets the bar everything else is judged against, and it is why accuracy is not reported.
6Confusion matrix, precision, recall, PR curve
At a chosen threshold, plus average precision across all thresholds — and a one-sentence defence of the threshold I picked.
7Calibration curve
Bin the cells by predicted score and plot predicted against observed burn fraction. This decides whether the score can be read as a probability or only as a ranking. Most hand-built heuristics fail this, and saying so plainly is the point.
8Moran's I on the residuals
Queen-contiguity weights over the hex grid, then test whether the errors cluster in space. If they do, the model is systematically missing something geographic — and neighbouring cells are not independent, which means the precision and recall figures are more optimistic than they look.
9Characterize the misses
Pull the cells that burned but scored low and group them by county and elevation band. Knowing the conditions under which the model is wrong is worth more than the headline metric.
// This section gets replaced by the results — confusion matrix, calibration plot, failure modes — when the study lands.
// Limitations
What this score cannot tell you
Stated up front rather than in a footnote, because these are the things that would lead someone to act on the number wrongly.
It is a fire-history score, not a fuel or weather model
Every input is a past fire perimeter. Nothing in it sees fuel load, slope, aspect, vegetation type, wind corridors, or ignition sources. A place that has never burned but is primed to burn scores low — a real failure mode, not a rounding error.
Past fire is a double-edged signal
Proximity and density assume fire history predicts fire future. In fuel-limited terrain the opposite can hold: a recent burn leaves little left to carry fire for several years. The recency weight treats a recent fire as raising risk, and that assumption is exactly what validation has to test.
Perimeter coverage is uneven across 147 years
Early-record fires are mapped less completely and less precisely than modern ones. Density counts therefore lean toward recent decades, inflating scores in areas that are well surveyed rather than areas that burn most.
The score ranks; it may not be a probability
A 0–100 output invites reading 80 as an 80% chance. Until the calibration curve exists there is no basis for that reading, so the score should be treated as an ordering of places and nothing more.
// Underneath
The analysis the score is built on
Six notebooks sit under the scoring logic, each answering one question about the data before it was trusted — whether reprojection was quietly changing distances, how perimeters intersect populated places, whether terrain could be brought in as a factor at all.
Does the projection change the answer?
CRS transformations and centroid distances to California cities
Which populated places sit inside the footprint?
Buffering perimeters against populated-place points
Can terrain be brought in as a factor?
Elevation and slope from raster DEMs with Rasterio
What did January 2025 actually look like?
Interactive Leafmap of the LA fires and FIRMS detections
Will the queries hold at full scale?
PostGIS load of the full perimeter and FIRMS tables, indexed
What is in the record at all?
Distribution of the perimeter set — choropleths, the ten largest fires
// Technical environment
How it runs
The analysis is served through a FastAPI application over PostgreSQL/PostGIS, with the scoring logic isolated as a pure function and unit-tested without a database. The whole stack comes up locally with one docker-compose up, and GitHub Actions runs the suite against a seeded PostGIS service container on every push. The public explorer is a static site that recomputes the same formula client-side against a bundled GeoJSON snapshot, so the map answers instantly with no server round-trip.
FastAPIPostgreSQL + PostGISSQLAlchemyRasterioMapLibre GLDockerGitHub Actionspytest
Check the score yourself
Click anywhere on the live map for a score and its full factor breakdown, or read the notebooks behind it.