Zonal statistics s6
- PostgreSQL with PostGIS
- Python
- 2 hours
Prerequisites: Hot and cold spots (s5), Rasters (g7)
Do some zones hold more records than their size predicts, and what is left once recording effort is accounted for? Records and area by ecoregion in SQL and in GeoPandas, a chi-square test, a ratio normalized for effort, and the same comparison by the classes of a global habitat map, on Costa Rica's records. Everything runs in your own PostgreSQL with PostGIS and the Python environment, on the lab tables (the setup page).
Concept
Point patterns, s2, to Hot and cold spots, s5, asked where records cluster; this module asks whether some zones hold more records than they should, and what "should" can mean.
- Zonal statistics summarize what falls inside each zone of a layer: a count, a sum, an area or a mean per zone. A zone is any polygon, or every raster cell with one value. Here the zones are Costa Rica's ecoregions, cut to the country's outline, and then the classes of a habitat map. For points in polygons the summary is a spatial join and a
GROUP BY(Spatial joins, g3); for a raster inside polygons it is Rasters, g7'sST_ValueCount. - Like with like. A count alone says little, because a big zone holds more records than a small one. Density divides by area: records per 1,000 km². The record-to-area ratio divides a zone's share of the records by its share of the area: 1 when records are spread by area alone, above 1 when the zone holds more than its size predicts. Both shares must cover the same place: the zones cut to the outline, and only the records inside that outline.
- Normalizing for effort. A ratio above 1 still mixes the animals with the people who looked for them (Bias in records, s1). The records of every species in a sample are a rough measure of where people recorded at all, the target group idea: records of related species, collected the same way, stand in for effort. A species' effort-normalized ratio in a zone is its share of the zone's records divided by its share of all the country's records: 1 when the species turns up in the zone as often as recording in general predicts. It assumes the other species were recorded the same way, which s1 showed this sample's weren't.
- The chi-square goodness-of-fit test asks whether counts follow an expectation, here records in proportion to area:
χ² = Σ (O − E)² / Eover the zones, with O the observed and E the expected count, on (zones − 1) degrees of freedom. With hundreds of records it rejects almost any simple expectation. A rejection says the records aren't spread by area; it doesn't say why, and nothing about which habitat an animal prefers. - Estimated areas. The habitat classes' areas come from a map, not a survey (the lab data, g7): Jung et al.'s 1 km cells each carry the fraction of each class, and
country_habitatssums fraction × cell area over the cells whose centre is inside the outline.occurrence_habitatsgives each record the class with the largest fraction in its 1 km cell: the map's class at that place, not the habitat the animal used.
Worked example
Costa Rica's ecoregions as zones: area, records (those GBIF codes CR that lie inside the outline, g3), records per 1,000 km², the record-to-area ratio, and the jaguar's effort-normalized ratio:
Checked 2026-11-02 on PostgreSQL 16.15, PostGIS 3.4.2, GEOS 3.12.1, GDAL 3.8.4; Python 3.12.3 with requirements.txt sha256 d5bbe28024cc.
s6-zones · PostgreSQL with PostGIS
WITH country AS (
SELECT geom FROM country_shapes WHERE country_code = 'CR'
), zones AS (
SELECT e.eco_id, e.name, ST_Intersection(e.geom, country.geom) AS geom
FROM ecoregions AS e, country
WHERE ST_Intersects(e.geom, country.geom)
), records AS (
SELECT o.id, o.species_id, o.geom
FROM occurrences AS o, country
WHERE o.country_code = 'CR' AND ST_Contains(country.geom, o.geom)
), per_zone AS (
SELECT z.eco_id, z.name, ST_Area(z.geom::geography) / 1e6 AS km2,
count(r.id) AS records,
count(r.id) FILTER (WHERE r.species_id = (SELECT id FROM species WHERE scientific_name = 'Panthera onca')) AS jaguar
FROM zones AS z
LEFT JOIN records AS r ON ST_Contains(z.geom, r.geom)
GROUP BY z.eco_id, z.name, z.geom
)
SELECT eco_id, name, round(km2::numeric) AS km2, records, jaguar,
round((1000 * records / km2)::numeric, 1) AS per_1000_km2,
round(((records / sum(records) OVER ()) / (km2 / sum(km2) OVER ()))::numeric, 2) AS record_ratio,
round(((jaguar::numeric / nullif(records, 0)) / (sum(jaguar) OVER () / sum(records) OVER ()))::numeric, 2) AS jaguar_ratio
FROM per_zone
ORDER BY km2 DESC, eco_id;
-- result: s6-zones · 7 rows · sha256 72c259664d1d eco_id | name | km2 | records | jaguar | per_1000_km2 | record_ratio | jaguar_ratio 470 | Isthmian-Atlantic moist forests | 17288 | 215 | 0 | 12.4 | 1.23 | 0.00 471 | Isthmian-Pacific moist forests | 9012 | 96 | 0 | 10.7 | 1.05 | 0.00 458 | Costa Rican seasonal moist forests | 8400 | 107 | 0 | 12.7 | 1.26 | 0.00 506 | Talamancan montane forests | 8311 | 13 | 0 | 1.6 | 0.15 | 0.00 527 | Central American dry forests | 6062 | 62 | 61 | 10.2 | 1.01 | 7.29 … 2 more rows
In s6.sql, with the blocks it runs after.
Read it one CTE at a time. zones cuts each ecoregion that touches the outline to the outline (ST_Intersection, Distance and overlay, g4). records keeps the records coded CR that lie inside the outline. per_zone joins records to zones with ST_Contains and counts them, with LEFT JOIN and count(r.id) so a zone with no record gets 0, not no row; the jaguar's records are counted with FILTER. ST_Area on geography measures on the WGS 84 ellipsoid, in m². The last query divides each zone's share by the column totals, sum(…) OVER (); nullif keeps a zone with no record from dividing by zero.
591 of Costa Rica's 619 records lie inside its outline (g3 found the rest). By area alone the records are uneven: the Talamancan montane forests, 8,311 km² of mountains, hold 13 records (ratio 0.15), the three lowland moist forests 1.05 to 1.26. The jaguar's column looks different again: 61 of the 62 records in the Central American dry forests and all 7 in the Southern Mesoamerican Pacific mangroves are jaguars, 7.29 and 7.41 times its share of the records in zones, and it has none in the other five.
Lab
The same zones in GeoPandas: an overlay of the ecoregions with the outline, then a spatial join of the records to the pieces, with areas in an equal-area projection (EPSG:6933, the global EASE-Grid 2.0), and a chi-square test of records against area.
from_wkbreads the shapes PostGIS sends as well-known binary (Loading and validating shapes, g2):s6-geopandas · Python
Runs after: py-connect
import geopandas as gpd import shapely from scipy.stats import chisquare with connect() as conn: outline = conn.execute("SELECT ST_AsBinary(geom) FROM country_shapes WHERE country_code = 'CR'").fetchone()[0] eco = conn.execute( """SELECT e.eco_id, e.name, ST_AsBinary(e.geom) FROM ecoregions AS e JOIN country_shapes AS c ON c.country_code = 'CR' AND ST_Intersects(e.geom, c.geom) ORDER BY e.eco_id""" ).fetchall() recs = conn.execute("SELECT id, longitude, latitude FROM occurrences WHERE country_code = 'CR' ORDER BY id").fetchall() country = gpd.GeoDataFrame(geometry=[shapely.from_wkb(bytes(outline))], crs=4326) zones = gpd.GeoDataFrame({"eco_id": [e[0] for e in eco], "name": [e[1] for e in eco]}, geometry=shapely.from_wkb([bytes(e[2]) for e in eco]), crs=4326) pieces = gpd.overlay(zones, country, how="intersection", keep_geom_type=True) points = gpd.GeoDataFrame({"id": [r[0] for r in recs]}, geometry=gpd.points_from_xy([r[1] for r in recs], [r[2] for r in recs]), crs=4326) inside = points[points.within(country.geometry.iloc[0])] counts = gpd.sjoin(inside, pieces, predicate="within").groupby("eco_id").size() pieces["km2"] = pieces.to_crs(6933).area / 1e6 pieces["records"] = pieces["eco_id"].map(counts).fillna(0).astype(int) pieces = pieces.sort_values(["km2", "eco_id"], ascending=[False, True]) print(f"records coded CR: {len(points)} inside the outline: {len(inside)} in a zone: {pieces['records'].sum()}") print("eco_id | km2 | records") for z in pieces.itertuples(): print(f"{z.eco_id} | {z.km2:.0f} | {z.records}") expected = pieces["records"].sum() * pieces["km2"] / pieces["km2"].sum() test = chisquare(pieces["records"], expected) print(f"chi-square: {test.statistic:.1f} df: {len(pieces) - 1} p: {test.pvalue:.2g}")-- result: s6-geopandas · python · sha256 1c182b76d465 records coded CR: 619 inside the outline: 591 in a zone: 504 eco_id | km2 | records 470 | 17288 | 215 471 | 9012 | 96 458 | 8400 | 107 506 | 8311 | 13 527 | 6061 | 62 617 | 636 | 7 613 | 198 | 4 chi-square: 77.4 df: 6 p: 1.2e-14
The course's test checks that every zone
s6-zonesshows has the same records here, and an area within 1 km²: geography's ellipsoid and EPSG:6933's projection differ by less than that (6,062 against 6,061 km² for the dry forests). "In a zone" is 504, but 3 records lie where two pieces overlap (g4) and are counted in both, so 501 records are in a zone and 90 in the gaps the simplified ecoregions leave inside the outline; both engines count a record once per piece that contains it. The chi-square test rejects "records follow area": 77.4 on 6 degrees of freedom, p 1.2 × 10⁻¹⁴.Records by habitat class against the classes' estimated areas, for the whole country. Each record takes its class from
occurrence_habitats; each class's area iscountry_habitats' estimate:s6-habitat · PostgreSQL with PostGIS
WITH records AS ( SELECT oh.habitat_code, count(*) AS records FROM occurrences AS o JOIN occurrence_habitats AS oh ON oh.occurrence_id = o.id WHERE o.country_code = 'CR' GROUP BY oh.habitat_code ) SELECT ch.habitat_code, hc.name, ch.est_area_km2, coalesce(r.records, 0) AS records, round((coalesce(r.records, 0) / sum(coalesce(r.records, 0)) OVER ()) / (ch.est_area_km2 / sum(ch.est_area_km2) OVER ()), 2) AS record_ratio FROM country_habitats AS ch LEFT JOIN records AS r ON r.habitat_code = ch.habitat_code LEFT JOIN habitat_classes AS hc ON hc.code = ch.habitat_code WHERE ch.country_code = 'CR' ORDER BY ch.est_area_km2 DESC, ch.habitat_code;-- result: s6-habitat · 34 rows · sha256 3494dd1c6e04 habitat_code | name | est_area_km2 | records | record_ratio 1401 | Arable Land | 16108.04 | 236 | 1.21 109 | Forest – Subtropical/tropical moist montane | 13095.13 | 31 | 0.20 106 | Forest – Subtropical/tropical moist lowland | 7108.44 | 128 | 1.49 201 | Savanna - Dry | 3528.48 | 35 | 0.82 105 | Forest – Subtropical/tropical dry | 2236.93 | 42 | 1.55 … 29 more rows
In s6.sql, with the blocks it runs after.
Arable land, 31 % of the estimated area, holds 236 of the 619 records (ratio 1.21); 216 of them are red-eyed tree frogs, recorded in 1 km cells that are mostly farmland: the cell's class, not the pond the frog sat in. Moist montane forest, a quarter of the area, holds 31 (0.20), and plantations 2 on 2,107 km² (0.08). The highest ratios belong to small classes at the coast: Marine Neritic, the sea over the continental shelf, has 59 records on 514 km² (9.50), and its coral reefs 7 on 29 km² (20.01). The 1:50m outline takes in some coastal water, and a 1 km cell that is mostly sea gets a marine class even when the record was made on the shore; 54 of the 59 lie inside the outline, up to 3.3 km from its edge, and 5 outside it. Such a ratio measures where the map's coastline, the outline and the records' coordinates disagree, not where frogs, sloths and jaguars live. The block also counts the 28 records outside the outline against areas inside it: the exercise fixes that.
Result
Costa Rica's 591 records inside the outline are spread unevenly over its ecoregions: the montane forests hold about a seventh of what their area predicts and the lowland forests a little more, and the chi-square test rejects records in proportion to area (77.4, 6 degrees of freedom, p 1.2 × 10⁻¹⁴). PostGIS and GeoPandas count the same records in every zone, with areas within 1 km². The jaguar's records sit almost only in the dry forests and the Pacific mangroves, 7.29 and 7.41 times its share. By habitat class, arable land holds 1.21 times its share of the records and the coastal marine classes 9.50 and 20.01. All of these are ratios of recording: they say where people recorded, and where one collar dataset's animals walked, not which forest a jaguar prefers.
Exercise
s6-habitat counts every record coded CR, including the 28 outside the outline, against areas inside it. Compare like with like: keep only the records inside the outline, as s6-zones does, and normalize the jaguar for effort by habitat class, as s6-zones does by ecoregion. For each class with a record: its records, the jaguar's records, the number of datasets the jaguar's records come from, and the jaguar's effort-normalized ratio. In which classes is the jaguar recorded, and from how many datasets?
Show answer
s6-ex · PostgreSQL with PostGIS
WITH country AS (
SELECT geom FROM country_shapes WHERE country_code = 'CR'
), records AS (
SELECT oh.habitat_code, o.dataset_key,
o.species_id = (SELECT id FROM species WHERE scientific_name = 'Panthera onca') AS jaguar
FROM occurrences AS o
JOIN country ON ST_Contains(country.geom, o.geom)
JOIN occurrence_habitats AS oh ON oh.occurrence_id = o.id
WHERE o.country_code = 'CR'
), per_class AS (
SELECT habitat_code, count(*) AS records,
count(*) FILTER (WHERE jaguar) AS jaguar,
count(DISTINCT dataset_key) FILTER (WHERE jaguar) AS jaguar_datasets
FROM records
GROUP BY habitat_code
)
SELECT p.habitat_code, hc.name, p.records, p.jaguar, p.jaguar_datasets,
round((p.jaguar::numeric / p.records) / (sum(p.jaguar) OVER () / sum(p.records) OVER ()), 2) AS jaguar_ratio
FROM per_class AS p
JOIN habitat_classes AS hc ON hc.code = p.habitat_code
ORDER BY p.jaguar DESC, p.records DESC, p.habitat_code;
-- result: s6-ex · 14 rows · sha256 fb2d0c58861d habitat_code | name | records | jaguar | jaguar_datasets | jaguar_ratio 105 | Forest – Subtropical/tropical dry | 42 | 42 | 1 | 6.29 201 | Savanna - Dry | 35 | 35 | 1 | 6.29 900 | Marine Neritic | 54 | 12 | 1 | 1.40 1401 | Arable Land | 236 | 5 | 1 | 0.13 106 | Forest – Subtropical/tropical moist lowland | 108 | 0 | 0 | 0.00 … 9 more rows
In s6.sql, with the blocks it runs after.
Check
The outline drops 28 records: Marine Neritic goes from 59 to 54, its coral reefs from 7 to 4, and moist lowland forest from 128 to 108. The jaguar's 94 records are all inside the outline and fall in four classes. Every record in dry forest (42) and in dry savanna (35) is a jaguar, 6.29 times its share of 94 in 591; it has 12 of the 54 in Marine Neritic (1.40) and 5 of the 236 on arable land (0.13), and none anywhere else. jaguar_datasets is 1 in each: all 94 come from the Jaguar Movement Database, GPS collars worn by jaguars tracked in the dry north-west (s1). Normalizing for effort removes the other species' recording, not the collar dataset's: a ratio of one species from one dataset says where the collared animals walked, in a habitat map's classes at 1 km, not which habitat jaguars prefer. count(DISTINCT dataset_key) FILTER (WHERE jaguar) counts the datasets of one kind of row inside a group, as s5's exercise counted rows.
Dialects
- PostgreSQL:
round(x, 2)needs anumeric(there is noround(double precision, integer)), hence the casts. An integer divided by an integer is an integer, sojaguar / recordswould be 0 everywhere:jaguar::numericmakes it a fraction.ST_Area(geom::geography)measures on the ellipsoid;ST_Area(geom)on a 4326 geometry gives square degrees, which shrink towards the poles. - DuckDB: the
spatialextension (not in this course's browser) hasST_Intersection,ST_IntersectsandST_Contains, andST_Area_Spheroidfor the area on WGS 84's ellipsoid, latitude first (Coordinate reference systems, g1)./divides in floating point, so no cast is needed. It has no chi-square function; compute the statistic in SQL and the p-value in Python. - SQL Server: the
geographytype hasSTIntersection,STContainsandSTArea()(in m²); a polygon must be valid and wound with its interior on the left (MakeValid()andReorientObject()fix the two most common refusals). Integer division as in PostgreSQL:CAST(jaguar AS decimal(10, 4)) / records. No statistical distribution functions: the p-value comes from Python. - GeoPandas:
gpd.overlay(…, how="intersection")cuts one layer by another (keep_geom_type=Truedrops the lines and points an intersection can leave);gpd.sjoin(points, polygons, predicate="within")returns one row per point and polygon that match, so a point in two overlapping polygons appears twice, as in SQL.to_crs(6933)projects for areas;scipy.stats.chisquare(observed, expected)needs both to sum to the same total. - ArcGIS Pro: Pairwise Intersect (
arcpy.analysis.PairwiseIntersect) cuts the ecoregions to the outline. Summarize Within (arcpy.analysis.SummarizeWithin) is the zonal count of points: Input Polygons the pieces; Input Summary Features the records inside the outline; Keep all input polygons checked (theLEFT JOIN); Add shape summary attributes checked; Group Field the species, which writes a table of each species' count in each zone, the inputs of the effort-normalized ratio. Calculate Geometry Attributes with Area (geodesic) in square kilometres givesST_Areaon geography's answer. For a raster in zones, Spatial Analyst's Tabulate Area (arcpy.sa.TabulateArea) gives each class's area in each zone (g7's method 1), and Zonal Statistics as Table (arcpy.sa.ZonalStatisticsAsTable) the mean, sum or majority of a value raster. Pro has no goodness-of-fit tool; runscipy.stats.chisquareon the table in Pro's Python window (Pro's environment includes SciPy).