How to Build a Land Cover Change Matrix in Python

On this page

Problem statement

Two land cover maps of the same place, years apart, hold more than two sets of class totals. Comparing totals says that grassland shrank and developed land grew. A change matrix, also called a transition or cross-tabulation matrix, says what became what: how much grassland became houses, how much forest became pasture, and how much stayed put. It separates net change from the gross gains and losses that cancel inside it. It is also where the rules of the product show up: some transitions a map is built never to record.

This page builds the matrix for Williamson County, Texas, between NLCD 2001 and NLCD 2021, at the level of eight broad groups and of all sixteen classes.

Quick answer

Mask both years to the same area, flatten them in the same order, and cross-tabulate. Multiply counts by the pixel area:

valid = (Y2001 > 0) & (Y2021 > 0)
before = pd.Series(Y2001[valid]).map(GROUP).rename("2001")
after = pd.Series(Y2021[valid]).map(GROUP).rename("2021")
matrix = pd.crosstab(before, after) * PIXEL_KM2
print(matrix.round(0).astype(int).to_string())
print(f"changed: {(before != after).mean():.1%} of the county, {(before != after).sum() * PIXEL_KM2:,.0f} kmΒ²")
2021         Agriculture  Barren  Developed  Forest  Grassland  Shrub  Water  Wetland
2001                                                                                 
Agriculture          877       1         18       2          8     15      0        0
Barren                 0      19          1       0          1      0      0        0
Developed              0       0        332       0          0      0      0        0
Forest                 1       3         51     426         10      7      0        0
Grassland             31       9        109       2        737      4      2        0
Shrub                  3       1         11       5          1    141      0        0
Water                  0       0          0       0          0      0     29        1
Wetland                0       0          1       0          0      0      0       64
changed: 10.3% of the county, 301 kmΒ²
Map of Williamson County, Texas, showing NLCD change from 2001 to 2021: 332 square kilometres already developed in 2001 in grey around Round Rock, Cedar Park, Georgetown and Taylor; 191 square kilometres that became developed by 2021 in red, mostly around the edges of those towns in the south-west; 110 square kilometres of other change in amber scattered across the farmland of the east; and 2,293 square kilometres unchanged. Map of Williamson County, Texas, showing NLCD change from 2001 to 2021: 332 square kilometres already developed in 2001 in grey around Round Rock, Cedar Park, Georgetown and Taylor; 191 square kilometres that became developed by 2021 in red, mostly around the edges of those towns in the south-west; 110 square kilometres of other change in amber scattered across the farmland of the east; and 2,293 square kilometres unchanged.
What changed in twenty years: new development rings the existing towns, while other changes are scattered across the farmland.

Gross against net, ranked transitions, the product's rules

1. Separate gross from net

The diagonal is persistence. A row's off-diagonal cells are that class's losses; a column's are its gains:

persist = pd.Series(np.diag(matrix), index=matrix.index)
summary = pd.DataFrame({"2001": matrix.sum(axis=1), "2021": matrix.sum(axis=0),
                        "gain": matrix.sum(axis=0) - persist, "loss": matrix.sum(axis=1) - persist})
summary["net"] = summary.gain - summary.loss
summary["swap"] = 2 * np.minimum(summary.gain, summary.loss)          # change that cancels out
print(summary.round(1).sort_values("net").to_string())
              2001   2021   gain   loss    net  swap
Grassland    895.2  757.8   20.4  157.8 -137.4  40.9
Forest       499.0  435.5    9.3   72.9  -63.5  18.7
Agriculture  921.1  912.0   35.4   44.5   -9.2  70.8
Wetland       65.4   65.1    1.4    1.6   -0.3   2.7
Water         30.9   32.4    3.1    1.6    1.5   3.2
Shrub        161.3  167.0   26.1   20.4    5.7  40.7
Barren        21.6   33.3   14.4    2.7   11.7   5.3
Developed    331.8  523.2  191.4    0.0  191.4   0.0

Grassland and forest show the largest net losses and developed land the largest net gain. Agriculture's small net change hides gains and losses of tens of square kilometres each, swapping location rather than shrinking.

2. Rank the transitions

pairs = matrix.stack()
pairs = pairs[[a != b for a, b in pairs.index]].sort_values(ascending=False)
print(pairs.head(6).round(1).to_string())
print(f"top transition's share of all change: {pairs.iloc[0] / pairs.sum():.0%}")
2001         2021       
Grassland    Developed      109.1
Forest       Developed       51.3
Grassland    Agriculture     31.2
Agriculture  Developed       17.9
             Shrub           14.5
Shrub        Developed       10.7
top transition's share of all change: 36%
Diverging bar chart of gross gains and losses by land cover group from 2001 to 2021: developed gained 191 square kilometres and lost almost nothing; grassland lost 158 and gained 20, net minus 137; forest lost 73 and gained 9, net minus 64; agriculture lost 45 and gained 35, net minus 9; shrub, barren, water and wetland changed little. Diverging bar chart of gross gains and losses by land cover group from 2001 to 2021: developed gained 191 square kilometres and lost almost nothing; grassland lost 158 and gained 20, net minus 137; forest lost 73 and gained 9, net minus 64; agriculture lost 45 and gained 35, net minus 9; shrub, barren, water and wetland changed little.
Net change hides gross change: agriculture moved tens of square kilometres in both directions for a net loss of 9.

3. Look inside the groups

Grouping hides change between classes of the same group. Developed land has four intensity classes, and land that was already developed can still intensify:

full = pd.crosstab(pd.Series(Y2001[valid], name="2001"), pd.Series(Y2021[valid], name="2021")) * PIXEL_KM2
dev = [21, 22, 23, 24]
within = full.loc[dev, dev].to_numpy().sum() - np.trace(full.loc[dev, dev].to_numpy())
print(f"change within developed classes: {within:.1f} kmΒ² (invisible in the grouped matrix)")
print(full.loc[dev, dev].round(1).to_string())
change within developed classes: 28.9 kmΒ² (invisible in the grouped matrix)
2021     21    22    23    24
2001                         
21    133.0   5.2  14.2   2.6
22      0.0  80.0   3.7   2.8
23      0.0   0.0  67.9   0.4
24      0.0   0.0   0.0  21.9

4. Check the product's rules

In the grouped matrix, developed land lost almost nothing:

lost = matrix.loc["Developed"].drop("Developed").sum()
print(f"developed in 2001, not developed in 2021: {lost:.2f} kmΒ², {lost / PIXEL_KM2:.0f} pixels of {valid.sum():,}")
down = np.tril(full.loc[dev, dev].to_numpy(), -1).sum()        # rows are 2001: below the diagonal = lower intensity in 2021
print(f"developed pixels that moved to a lower intensity: {down:.2f} kmΒ²")
developed in 2001, not developed in 2021: 0.01 kmΒ², 8 pixels of 3,251,319
developed pixels that moved to a lower intensity: 0.00 kmΒ²

That is a rule of NLCD's change detection, not a fact about Williamson County: once developed, a pixel stays developed (8 pixels in 3.25 million are the exception) and never moves to a lower intensity. Demolitions and redevelopment to parkland exist but are not mapped. Every product has rules like this, and a change matrix is where to find them.

Where each 2001 class went

The data used on this page

NLCD 2001 and 2021 (2021 release, 30 m) for Williamson County, Texas, from MRLC's WCS (see how to download NLCD), masked with the 2023 Census county outline. Both years share the same grid, so pixels correspond one to one.

import time
from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
import rasterio.mask
import requests

Path("data").mkdir(exist_ok=True)
WCS = "https://www.mrlc.gov/geoserver/mrlc_download/wcs"
if not Path("data/cb_2023_us_county_20m.zip").exists():
    Path("data/cb_2023_us_county_20m.zip").write_bytes(requests.get("https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_20m.zip", timeout=300).content)
COUNTY = gpd.read_file("data/cb_2023_us_county_20m.zip").query("GEOID == '48491'").to_crs(5070)


def nlcd(year):
    path = Path(f"data/nlcd_{year}.tif")
    if not path.exists():
        x0, y0, x1, y1 = COUNTY.total_bounds
        for attempt in range(4):
            r = requests.get(WCS, params={"service": "WCS", "version": "2.0.1", "request": "GetCoverage",
                                          "coverageId": f"mrlc_download__NLCD_{year}_Land_Cover_L48",
                                          "subset": [f"X({x0},{x1})", f"Y({y0},{y1})"], "format": "image/geotiff"}, timeout=300)
            if r.status_code < 500:
                break
            time.sleep(15 * (attempt + 1))
        r.raise_for_status()
        path.write_bytes(r.content)
    with rasterio.open(path) as src:
        a, tr = rasterio.mask.mask(src, COUNTY.geometry, crop=True, nodata=0)
    return a[0], tr


Y2001, TRANSFORM = nlcd(2001)
Y2021, _ = nlcd(2021)
PIXEL_KM2 = 30 * 30 / 1e6
GROUP = {11: "Water", 21: "Developed", 22: "Developed", 23: "Developed", 24: "Developed", 31: "Barren",
         41: "Forest", 42: "Forest", 43: "Forest", 52: "Shrub", 71: "Grassland", 81: "Agriculture", 82: "Agriculture",
         90: "Wetland", 95: "Wetland"}
print(f"grids {Y2001.shape} and {Y2021.shape}, same transform: {TRANSFORM.almost_equals(_)}")
grids (1884, 2845) and (1884, 2845), same transform: True

Example 1: where each 2001 class went, in per cent

rows = matrix.div(matrix.sum(axis=1), axis=0) * 100
print(rows.loc[["Grassland", "Forest", "Agriculture"], ["Developed", "Grassland", "Forest", "Agriculture", "Shrub"]].round(1).to_string())
2021         Developed  Grassland  Forest  Agriculture  Shrub
2001                                                         
Grassland         12.2       82.4     0.2          3.5    0.5
Forest            10.3        2.0    85.4          0.2    1.4
Agriculture        1.9        0.9     0.2         95.2    1.6

The categorical version of subtracting two rasters

A change matrix is the categorical version of subtracting two rasters. Subtraction is meaningless for class codes (82 minus 71 is not a land cover), so instead each pixel's pair of classes is counted. Row totals are the first map, column totals the second, the diagonal is what stayed, and every off-diagonal cell is one kind of change with its area. Gains, losses, net change and swap all come from those sums. Swap is the part of a class's change that cancels out: land gained in one place and lost in another.

Where Williamson County land of three 2001 classes was in 2021, as a share of each 2001 class. each 2001 class, twenty years later→ Developed→ Grassland→ Forest→ AgricultureGrassland 200112.2%82.4% stayed0.2%3.5%Forest 200110.3%2.0%85.4% stayed0.2%Agriculture 20011.9%0.9%0.2%95.2% stayedRows of the change matrix divided by their 2001 totals; NLCD 2001 and 2021 spatialworkflow.io
Where each 2001 class went.

Two cautions apply to any matrix. Map errors appear as change: two classifications of an unchanged pixel can disagree, and those disagreements add up to change that never happened. Products built for change, like NLCD's consistent series, reduce this by detecting change first and relabelling only changed pixels, and they encode rules such as "developed never reverts". Comparing two unrelated products cell by cell gives a matrix full of differences in definition and method.

Same grid, masks and grouping

Land cover change matrix problems and fixes. change matrix problemsSymptomFixsmall net, big real changereport gains, losses and swapdeveloped never shrinksa product rule: read the methodintensification invisiblelook inside grouped classeschange that never happenedsame product series, same gridWilliamson County, Texas, NLCD 2001–2021 spatialworkflow.io
Change matrix problems and fixes.
  • Same grid: both maps must share the grid; reprojecting one with anything but nearest neighbour invents classes.
  • Masks: use the same mask for both years, and drop pixels that are nodata in either.
  • Area: multiply counts by the true pixel area; in a geographic CRS that varies with latitude.
  • Grouping: decide the groups before looking at the results, and report change within groups if it matters.
  • Small changes: single-pixel changes are often noise; a minimum mapping unit or a majority filter removes speckle before counting.

FAQ

What is a land cover change matrix?

A cross-tabulation of two land cover maps of the same area: rows are classes in the first year, columns in the second, and each cell the area that went from one to the other.

How do I make a transition matrix from two rasters in Python?

Read both rasters on the same grid, mask nodata, flatten them, and use pd.crosstab; multiply the counts by the pixel area.

What is the difference between net and gross change?

Net change is the difference between a class's totals; gross change is its gains plus its losses. A class can change a lot gross with almost no net change.

Why does NLCD show no loss of developed land?

The NLCD change methodology does not allow developed pixels to revert to other classes or to lower intensity. It is a rule of the product.