Validation¶
Agreement against a reference LCZ map, in the style of the lczexplore package.
Per-class agreement and a confusion matrix, never a single accuracy number; plus the breakdowns
that turn the height caveat into a measured quantity - agreement by height_completeness
band, and the two confusion axes reported apart: the height axis (1<->2<->3, 4<->5<->6) and the
compactness axis (1<->4, 2<->5, 3<->6).
ranges adds the complementary instrument: agreement says whether a label was right, and
parameter_ranges says whether the parameter behind it could ever have reached the published
prototype it should have matched. uncertainty adds the third: how much of a difference between
two agreement figures is signal, given that the units are a correlated sheet rather than
independent draws.
Three references, not one, and they are three different kinds of object. reference_lcz reads the
Demuzere global map, which is a model output carrying its own error; labelled_lcz reads
hand-labelled So2Sat LCZ42 / DFC2017 patches, which are ground truth on a uniform grid;
wudapt_lcz reads WUDAPT LCZ Generator training areas, which are hand-drawn too but irregular,
overlapping, spread over four decades, and available for every city rather than fifty-one.
Where So2Sat exists it is primary and the agreement between it and lcz_v3 is the ceiling on any
score against the map, and that distinction is easy to lose. WUDAPT does
not give a second such ceiling: the LCZ Generator's training areas are the training data behind
the Demuzere map, so the two are not independent and their agreement is inflated by construction.
All three return the same three columns, so agreement() consumes any of them without knowing
which — which is exactly why every report records reference_file. A table that does not name the
file it used is indistinguishable from one that used a different reference, and this project has
found that mistake twice.
Agreement between a run's labels and a reference set, reported in the style of the lczexplore
package — per-class figures and a sparse confusion matrix, never a single accuracy number.
Terms used throughout this page are defined in the glossary: ceiling, overall accuracy, built-class agreement and the two confusion axes.
Validate against labelled ground truth, not against another model
lcz_v3.tif — the Demuzere global LCZ map, derived from satellite imagery — is an estimate
carrying its own error. Measuring against it compares two models and reports the disagreement
as lczkit's error. Where hand-drawn polygons exist — So2Sat LCZ42, a labelled benchmark set
over 51 cities, and WUDAPT, the community-contributed World Urban Database and Access Portal
Tools — those are the reference and lcz_v3 is a secondary comparator.
reference_file is recorded on every report: "the reference" names a role, and the three
that can fill it disagree by up to 18 points, so a figure that does not say which file produced
it cannot be read.
Three things any per-city figure needs stated beside it:
- Its ceiling. How well
lcz_v3itself agrees with the hand-drawn polygons on the same cells. That bounds what any map can score againstlcz_v3, and it varies enormously: 22.8% in Mumbai, 83.2% in Rio. - Built-class agreement, separately, with the natural-class share alongside. An overall figure dominated by trivially-classified water says nothing about the classifier.
- The label reproducibility. Two independent expert label sets over the same ground agree at a median 79.7% across twenty-eight cities, ranging 26.3% (Cairo — below what a constant predictor scores there, 52.1%) to 97.7% (Istanbul). Where two references disagree, no classifier can agree with both, so this is a floor under every residual reported here.
Never report '% of ceiling'
Vancouver scores 41.8% against a 36.7% ceiling — 114%. The comparator is another estimator, not an upper bound. Report raw agreement and ceiling side by side, or their difference.
lczkit.validation.agreement
¶
Agreement against a reference LCZ map, reported the way the LCZ literature reports it.
Comparability matters more than a headline figure: per-class agreement and a confusion matrix,
in the style of the lczexplore package, not a single accuracy number. A single number hides the
thing a reader of an LCZ map actually needs to know, which is which classes are being confused
for which - Bernard et al. (2024) Sect. 3.2 read their own results exactly this way, noting that
their worst agreements sit on classes covering a negligible share of the area.
Four breakdowns beyond the standard ones are required here:
- built-class agreement, separately from overall, with the natural-class share stated beside it. An overall figure carried by water is not a statement about the classifier;
- agreement by
height_completenessband, so a city where tier-1 heights are near-absent can be judged rather than disclaimed; - the height axis, 1<->2<->3 and 4<->5<->6, holding compactness fixed. This is where error concentrates when heights come from an areal product, since such a product cannot resolve the <10 m / 10-25 m / >25 m bands within a heterogeneous unit. It is the axis that pairs with the stratification above; and
- the compactness axis, 1<->4, 2<->5, 3<->6, holding height fixed. This is the diagnostic for building footprint coverage and for whether the spatial unit is the right size to hold an LCZ patch at all.
The two are different instruments and are reported separately. An earlier revision of the spec named the second set the height axis, which inverted what a reader would conclude from it: a disagreement between LCZ 2 and LCZ 5 says nothing about heights, both being midrise.
Agreement is area-weighted throughout. On a regular grid that is the same as counting units; on
enclosures it is not, and weighting by count there would let a thousand courtyards outvote a
district. The axes are the exception, and deliberately carry both weightings: every axis figure
published between Phases 6.7 and 11 is count-based, so share_of_disagreement keeps that definition
and share_of_disagreement_area is reported beside it. Redefining the field in place would have
silently moved every stored arm-B number while leaving arm A untouched, which is the hardest kind of
change to notice.
Raw axis shares are not comparable across cities, which is easy to get wrong.
Their denominator is all disagreement, so a city whose reference carries a large natural
share dilutes both axes, while an all-built fixture dilutes neither; and which pairs can fire at all
depends on which classes the reference happens to contain. axis_summary() below reports the raw
share alongside two corrections for exactly this - see its docstring.
AXIS_ELIGIBLE_CODES
module-attribute
¶
AXIS_ELIGIBLE_CODES: frozenset[int] = frozenset(code for pair in HEIGHT_AXIS_PAIRS + COMPACTNESS_AXIS_PAIRS for code in pair)
LCZ 1-6. The only reference classes that can place a unit on either axis.
Both axis definitions are built from the compact (1-3) and open (4-6) types, so a unit whose reference is LCZ 7-10 or a natural class contributes to the denominator of a raw axis share while being unable to contribute to its numerator.
ClassAgreement
¶
Bases: BaseModel
Agreement for one reference class.
agreement
instance-attribute
¶
area_agree_m2 / area_reference_m2, or 0.0 where the class is absent from the
reference. This is producer's accuracy (recall): of the ground the reference calls this
class, how much did lczkit agree on.
n_predicted
class-attribute
instance-attribute
¶
Units lczkit assigns to this class. The denominator of user_accuracy.
user_accuracy
class-attribute
instance-attribute
¶
area_agree_m2 / area_predicted_m2, or 0.0 where the class is never predicted.
The complement of agreement, and the reason both are needed: producer's accuracy alone
cannot see over-prediction. A classifier that labels every cell LCZ 5 scores 100% producer's
accuracy on LCZ 5 while being useless, and nothing in this report would have said so.
Demuzere et al. (2021) report the pair through F1 for exactly this reason.
f1
class-attribute
instance-attribute
¶
Harmonic mean of user_accuracy and agreement, 0.0 where either is zero.
The class-wise metric Demuzere et al. (2021), Sect. 2.4 use, following Verdonck et al. (2017): "the F1 metric, which is a harmonic mean of the user's and producer's accuracy". Reported so lczkit's per-class figures are directly comparable to published LCZ maps.
ConfusionCell
¶
Bases: BaseModel
One (reference, predicted) pair with a non-zero count.
Stratum
¶
Bases: BaseModel
Agreement within one band of a stratifying variable.
AxisConfusion
¶
Bases: BaseModel
One pair of classes differing along a single axis, counted in both directions.
share_of_disagreement
instance-attribute
¶
n_total over all disagreeing units. If one axis is the limiting factor, its pairs should
hold a disproportionate share of the errors - which is the whole reason for reporting the two
axes apart rather than pooled.
share_of_disagreement_area
class-attribute
instance-attribute
¶
The same share, area-weighted. Identical to share_of_disagreement on a regular grid, where
every unit is one cell; different on enclosures, where units vary in size by orders of
magnitude.
AxisSummary
¶
Bases: BaseModel
One axis pooled over its pairs, with the denominators that make cities comparable.
Three shares of the same numerator, in increasing order of correction:
share_of_disagreementis the raw figure Phases 6.7-11 published. Its denominator is all disagreement, so it is deflated by whatever built<->natural confusion a city happens to carry and cannot be compared across cities with different class composition;share_of_axis_eligiblerestricts the denominator to units whose reference is LCZ 1-6, the only classes that can land on either axis. Transparent, and enough to remove the natural-share dilution;liftdivides byexpected_share, what the axis would hold if misclassification ignored the axes entirely. This is the one that removes class composition: a reference carrying only LCZ 2 and 5 affords the compactness pair both its directions while giving each height pair only one, so the two axes are not on equal footing before it is applied.
lift is the figure to compare across cities. 1.0 means the axis holds exactly the share its
reference affords it; above 1.0 means error concentrates there beyond what composition explains.
expected_share
instance-attribute
¶
Share the axis would hold under a null that keeps each unit's reference class and the observed distribution of wrong labels, but breaks the association between them.
lift
instance-attribute
¶
share_of_disagreement / expected_share, or 0.0 where the null affords the axis nothing.
AgreementReport
¶
Bases: BaseModel
The full comparison against one reference map.
n_compared
instance-attribute
¶
Units that survived the coverage filter and carry both a prediction and a reference.
overall_agreement
instance-attribute
¶
Area-weighted share of compared units whose label matches the reference.
built_agreement
class-attribute
instance-attribute
¶
Agreement over the units the reference calls built (LCZ 1-10).
Reported separately from overall_agreement always, because an overall figure
can be dominated by trivially-classified natural cover and then says nothing about the
classifier: Rotterdam's headline 42.5% was 266 water cells agreeing at 95.9% while LCZ 8 sat at
0.0% over 224. The denominator is the reference's family, not the run's - grouping by what the
run predicted would let a classifier improve its built score by predicting water.
natural_agreement
class-attribute
instance-attribute
¶
Agreement over the units the reference calls natural (LCZ A-G, codes 11-17).
natural_share
class-attribute
instance-attribute
¶
Area share of the compared units the reference calls natural. Stated alongside the headline so a figure carried by water is recognisable as one.
weighted_agreement
class-attribute
instance-attribute
¶
OA_w: overall accuracy with partial credit for a near-miss, per Bechtel et al. (2020).
Every other agreement figure here treats calling a compact midrise cell "open midrise" as
exactly as wrong as calling it "water". For a scheme whose classes lie on a near-continuum of
built form that is plainly false, and it is the reason this module reports the two confusion
axes apart in the first place. OA_w weights the confusion matrix by class similarity, so an
adjacent-class error scores most of a correct one and a cross-family error scores little.
A generalisation of overall_agreement rather than a rival to it: plain OA is the same sum with
a similarity matrix of ones on the diagonal and zeros off it, so the two coincide exactly when
the matrix is the identity. Reported beside it, never instead of it — and the LCZ literature
reports both, which is what makes lczkit's numbers comparable to a published map's.
Count-weighted, matching the confusion matrix it is computed from. overall_agreement is
area-weighted; on a grid the two coincide, on enclosures they do not.
built_natural_agreement
class-attribute
instance-attribute
¶
OA_bu: agreement on the built-versus-natural distinction alone, ignoring which built or
which natural class.
Demuzere et al. (2021) Sect. 2.4 and Demuzere et al. (2022) Sect. 2.4 both report it beside
OA and OA_u, and it separates two failures this report otherwise conflates: a city where lczkit
finds the built fabric and misjudges its form, and one where it does not find the built fabric
at all. overall_agreement charges both the same. It is also the one accuracy figure here that
a height-blind pipeline can still score well on, which is the point when tier-1 coverage is 1%.
height_axis
class-attribute
instance-attribute
¶
height_axis: list[AxisConfusion] = Field(default_factory=list)
1<->2<->3 and 4<->5<->6: compactness fixed, height band varies.
compactness_axis
class-attribute
instance-attribute
¶
compactness_axis: list[AxisConfusion] = Field(default_factory=list)
1<->4, 2<->5, 3<->6: height fixed, building surface fraction varies.
compactness_axis_summary
class-attribute
instance-attribute
¶
compactness_axis_summary: AxisSummary | None = None
The two axes pooled, with the normalised denominators. Compare cities on lift, never on
share_of_disagreement, which is not comparable across cities whose references carry
different classes.
n_disagree_axis_eligible
class-attribute
instance-attribute
¶
Disagreeing units whose reference is LCZ 1-6. The denominator of share_of_axis_eligible.
agreement
¶
agreement(predicted: Series, reference: Series, area_m2: Series, *, coverage: Series | None = None, height_completeness: Series | None = None, config: ValidationConfig | None = None, reference_file: str | None = None) -> AgreementReport
Compare predicted against reference, both indexed by unit_id.
Units missing either label, or whose reference coverage falls below the configured minimum, are excluded and counted separately rather than silently dropped - a run comparing a tenth of its units against the reference is a different claim from one comparing all of them.
Source code in src/lczkit/validation/agreement.py
280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 | |
weighted_agreement
¶
weighted_agreement(confusion: Sequence[ConfusionCell], weights: Mapping[tuple[int, int], float] | None = None) -> float
OA_w — Bechtel et al. (2020) Eq. 1: sum(w_ij * c_ij) / N.
w is the similarity matrix from docs/references/tables/lcz_class_similarity.md, one on
the diagonal. Passing the complement would invert the measure without raising, which is why the
default is not a parameter a caller has to get right and why similarity._check() refuses a
matrix whose diagonal is not one.
Computed from the stored confusion list, so a manifest written before this existed can be re-scored without re-running the pipeline.
Source code in src/lczkit/validation/agreement.py
axis_pairs
¶
axis_pairs(confusion: Sequence[ConfusionCell], pairs: tuple[tuple[int, int], ...]) -> list[AxisConfusion]
One axis' pairs and their share of all disagreement, counted in both directions.
Derived from the confusion matrix rather than from the compared units, so that re-analysing a stored run cannot drift from what the run itself reported: the confusion list is what a run persists, and it holds exactly the counts and areas this needs.
Source code in src/lczkit/validation/agreement.py
axis_summary
¶
axis_summary(confusion: Sequence[ConfusionCell], pairs: tuple[tuple[int, int], ...], *, axis: str) -> AxisSummary
Pool one axis over its pairs and normalise it for the reference's class composition.
The null behind expected_share keeps each disagreeing unit's reference class and the
observed distribution of wrong labels, but breaks the association between them: a unit whose
reference is r takes a wrong label drawn from the run's own error distribution, conditioned on
not being r. Formally, for axis X with partner sets P,
E[X] = sum_r (n_r / N) * (sum_{p in P(r)} q_p) / (1 - q_r)
with n_r the disagreeing units the reference calls r, N the disagreeing units in total,
and q the distribution of predicted labels across them.
Keeping q from the run rather than assuming it uniform matters: a classifier that
over-predicts LCZ 5 raises the chance of landing on 2<->5 for reasons that have nothing to do
with compactness being the limiting axis, and the null has to be able to absorb that or lift
would just rediscover the prediction histogram.
Source code in src/lczkit/validation/agreement.py
Confusion axes¶
Two different ways a label can be wrong, and they diagnose different things, so they must not be conflated:
- Height axis — confusions between 1↔2↔3 and between 4↔5↔6. Compactness is fixed and the
height band varies, so this tracks the quality of the height data. Read it beside
height_completeness. - Compactness axis — confusions between 1↔4, 2↔5 and 3↔6. Height is fixed and building surface fraction varies, so this tracks how completely the footprints were captured and how the units were drawn.
Only pair-normalised lift against a composition-preserving null is reported. The raw share cannot compare the two: the height axis affords six pairs to compactness's three, so a null that never looks at the data awards height 3.9× more error on affordance alone.
Weighted accuracy¶
Plain overall accuracy (OA) counts a unit right only if its label matches the reference
exactly. Weighted overall accuracy (OA_w) gives partial credit according to how similar the
two classes are, using the class-similarity matrix of Bechtel, Demuzere & Stewart (2020) — so
calling a compact midrise an open midrise scores most of a point, while calling it water scores
near zero.
OA_w above OA therefore says the map is landing in neighbouring classes rather than at
random. That is a real and useful thing to know, and it is not an accuracy figure. It is
reported beside OA and never instead of it.
lczkit.validation.similarity
¶
Bechtel et al. (2020) class-similarity weights, and the weighted accuracy they define.
Why this is not just another accuracy number. Overall accuracy treats every mistake as equally
wrong: calling a compact midrise cell "open midrise" costs exactly what calling it "water" costs.
For LCZ that is plainly false — the scheme's classes lie on a near-continuum of built form, and the
whole point of reporting the two confusion axes separately is that adjacent-class error means
something different from cross-family error. OA_w is the LCZ community's answer, and reporting it
is what makes this package's per-class figures comparable to published LCZ maps.
Bechtel et al. frame it as a generalisation of overall accuracy rather than a different measure.
Plain OA is already the Hadamard product of the confusion matrix with a weight matrix carrying ones
on the diagonal and zeros off it — itself a similarity metric, in which every class is fully similar
to itself and fully dissimilar to everything else. Relaxing those off-diagonal zeros to real
similarities buys partial credit for a near-miss. So OA_w reduces to OA exactly when the matrix
is the identity, which is the first thing test_validation_similarity.py asserts.
The weights live in docs/references/tables/lcz_class_similarity.md, not here. A published
number is read from a committed transcription rather than reproduced from memory, and this matrix
has 289 cells, of which a wrong one would be invisible. The table is parsed
at import and the parsed values are the constants; there is no second copy to drift.
That file holds two matrices with identical headers — the similarity one and its complement.
OA_w uses the similarity matrix. Substituting the other inverts the measure without raising:
a perfect map scores 0.00, a map confusing LCZ 1 with LCZ G scores 1.00, and every cross-city
comparison in a paper ranks backwards. Hence the parsing is by section heading and the direction is
asserted by a test rather than trusted to a comment.
BECHTEL_2020
module-attribute
¶
Bechtel, Demuzere & Stewart (2020), Remote Sensing 12(11), 1769. Defines OA_w and the class
similarity matrix it weights the confusion matrix with.
SIMILARITY_TABLE
module-attribute
¶
SIMILARITY_TABLE = Path(__file__).resolve().parents[3] / 'docs' / 'references' / 'tables' / 'lcz_class_similarity.md'
The committed transcription. Repo content rather than data, so it needs no DATA_DIR — the same
exception tests/fixtures/ relies on, and for the same reason: a clean checkout must be able to
reproduce a classification.
SIMILARITY
module-attribute
¶
SIMILARITY: dict[tuple[int, int], float] = _parse(SIMILARITY_TABLE, SIMILARITY_HEADING)
(reference code, predicted code) -> similarity in [0, 1]. One on the diagonal.
References¶
lczkit.validation.labelled
¶
Labelled LCZ ground truth (So2Sat LCZ42 / DFC2017), reduced to one label per spatial unit.
lcz_v3.tif is an estimate carrying its own error, so measuring against it compares two models
and reports the disagreement as lczkit's. Where hand-labelled LCZ
polygons exist they are the primary reference and lcz_v3 is a secondary comparator; the
agreement between the two, on the same cells, is the ceiling on what lczkit can score against
lcz_v3 at all.
Why the label is anchored on the patch centre rather than overlaid areally. So2Sat patches are 320 m squares sampled on a 100 m stride, so they overlap each other about sevenfold - measured on the Berlin fixture bbox: 473 patches, 48.4 km2 of patch area over a 7.1 km2 union, 16,560 overlapping pairs. An areal overlay would therefore count the same ground up to nine times, under labels that need not agree, and the resulting "majority" would be a property of the sampling density rather than of the city. Anchoring each label on the patch centre removes the double counting entirely and is exact at the scale lczkit validates on: on the Berlin fixture 438 patch centres fall into 438 distinct cells with no cell receiving two labels.
The 1:1 property holds, but not for the reason first recorded here. The patch centres are not
aligned to the local UTM origin: measured on the Berlin fixture in EPSG:32633 they sit on an exact
100 m stride at a fixed phase offset of (40.0, 70.0) m from the GridUnits cell corners. That
offset is what makes the property robust - being far from both 0 and 50 m, no centre can land on a
cell boundary and no two centres can share a cell. An offset near either value would degrade, which
is precisely what LabelMatch exists to expose, so it is reported per run rather than assumed.
What the centre rule does not control is support. The label describes a 320 x 320 m patch, 10.24 ha, and is attributed to one 1 ha cell whose centre is systematically ~22 m from the patch centre. A 100 m cell inside a compact-midrise patch can legitimately be a courtyard. That is an irreducible floor under any agreement figure measured this way, and it is the same patch-versus-cell mismatch the published parameter ranges show - the labels are patch-scale objects too.
The reduction is deliberately not raster-based. reference_lcz reads a categorical raster through
LocalRasterSource because the Demuzere map is one; these patches are vector polygons whose
value lies in their exact placement, and rasterising them to reuse that path would reintroduce the
overlap problem in a less visible form.
Returns the same three columns as lczkit.validation.reference.reference_lcz, so agreement()
consumes either without knowing which it was given.
LABEL_COLUMN
module-attribute
¶
So2Sat LCZ42's own class column. Integer 1-17, matching Demuzere's coding for 1-10 and A-G, so
no translation is needed - asserted by tests/test_validation_labelled.py.
SO2SAT_CITATION
module-attribute
¶
Zhu et al. (2020), IEEE GRSM 8(3), 76-89. So2Sat LCZ42.
LabelMatch
dataclass
¶
LabelMatch(n_patches: int, n_centres_matched: int, n_centres_unmatched: int, n_centres_ambiguous: int, n_units_labelled: int, n_units_multi_label: int)
How the patch centres landed on the units, so a misaligned city is visible in the output.
The 1:1 mapping above holds because a So2Sat patch grid and a 100 m GridUnits grid share the
UTM origin. Nothing guarantees that for every city or every unit strategy, and a silent
degradation - centres falling on cell boundaries, or several centres per unit disagreeing -
would look exactly like a well-measured run. These counts make it look like what it is.
n_centres_matched
instance-attribute
¶
Centres falling strictly inside exactly one unit.
n_centres_unmatched
instance-attribute
¶
Centres outside every unit, or on a shared boundary and so inside none of them.
n_centres_ambiguous
instance-attribute
¶
Centres matching more than one unit. Zero for any valid partition; non-zero means the units overlap, and those centres are discarded rather than counted twice.
n_units_multi_label
instance-attribute
¶
Units receiving centres of more than one class, and so decided by a majority rather than by a single label. Zero on a 100 m grid; non-zero on enclosures, which are larger than a patch.
labelled_lcz
¶
labelled_lcz(units: GeoDataFrame, patches: GeoDataFrame, *, class_column: str = LABEL_COLUMN) -> tuple[DataFrame, LabelMatch]
The ground-truth class per unit_id, and a record of how the labels were matched.
Returns three columns, matching reference_lcz so the two are interchangeable downstream:
reference_lcz- the majority class among the patch centres inside the unit, or null where no centre falls in it. NullableInt8, never a sentinel.reference_coverage- 1.0 where the unit carries at least one centre, else 0.0. Labelled patches are a sample, not a map: a unit either has a label or has none, and reporting a fractional coverage would invitemin_reference_coverageto filter on a number that means something different from the one it was written for.reference_majority_fraction- share of the unit's centres holding the winning class. 1.0 wherever a unit holds one centre, which on a 100 m grid is every labelled unit.
Neither input is mutated.
Source code in src/lczkit/validation/labelled.py
86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 | |
lczkit.validation.reference
¶
The reference LCZ map, reduced to one label per spatial unit.
The comparator map is the Demuzere global LCZ map, compared on the 100 m grid. The
reduction that needs is areal class fractions per unit followed by the majority - which is
exactly what LocalRasterSource already does for land cover, down to the CRS handling, the
covering window, the exact cell weighting and the nodata policy. So the reference map is described
as a LandCoverDatasetConfig and read through that source rather than growing a second
exactextract path that could drift from the first.
The one adaptation is that nodata is assigned to its own class instead of being excluded, so the fractions still say how much of each unit the reference map reaches. Without that a unit lying half outside the map would report a confident majority computed from a corner of itself, and the agreement statistics would quietly include it.
"The 100 m grid" describes lczkit's units, not the reference.
lcz_v3.tif is distributed in EPSG:4326 at 0.000898315 degrees, which is ~100 m north-south and
100 m east-west only at the equator: at Berlin's latitude the cells are 60.9 m wide, at
Rotterdam 61.7 m, at Hong Kong 92.5 m. The lczkit unit is a 100 m square in a projected CRS, so the
two grids share neither an origin nor an east-west cell size, and each unit takes the areal-majority
label over roughly one-and-a-half reference cells across by one down.
Nothing here is approximated by that - units are reprojected into the raster's CRS rather than
warping a categorical raster, and exactextract weights each cell by its exact covered fraction.
But the majority is a reduction, and reference_majority_fraction reports how decisively it was
won. A unit whose winner holds 0.34 of the observed area is close to a coin toss between three
classes and is currently weighted the same as one holding 1.0.
NODATA_CLASS
module-attribute
¶
Class name the reference dataset config assigns unmapped/nodata cells to.
reference_lcz
¶
reference_lcz(units: GeoDataFrame, path: Path, config: LandCoverDatasetConfig, *, max_raster_cells: int = 200000000) -> DataFrame
The majority reference class per unit_id, with how well it is supported.
Returns three columns:
reference_lcz- the integer code covering most of the unit, or null where the reference map covers none of it. NullableInt8, never a sentinel: "the reference does not reach here" and "the reference says class 0" must not look the same.reference_coverage- fraction of the unit the reference map actually observed.reference_majority_fraction- fraction of the observed part the majority class holds. A unit split evenly between two classes agrees with whichever it is compared against about half the time, and that is worth knowing before reading a confusion matrix.
Neither input is mutated.
Source code in src/lczkit/validation/reference.py
lczkit.validation.wudapt
¶
WUDAPT LCZ training areas, reduced to one label per spatial unit.
The third reference, and the only one that reaches every city this package has been run on. It is
the secondary reference, and the first where So2Sat has too few labels for a region of interest.
Measured over sixteen study windows it carries 119-2374 polygons each, 11-18 classes, and
63-996 km2 of labelled ground against
So2Sat's 7.1 km2 union on Berlin - one to two orders of magnitude more labelled area, at
neighbourhood scale. (lczkit.cities now holds twenty; the four added afterwards are not in that
range, and every figure quoted in this module is over the original sixteen.)
Why the reduction is areal here and not centroid-anchored. labelled_lcz anchors each So2Sat
label on its patch centre, and that rule is justified by a property WUDAPT does not have: So2Sat
patches are uniform 320 m squares on a 100 m stride, so a centroid is an exact, non-double-counting
stand-in for the patch. WUDAPT polygons are hand-drawn and span 0 m2 to 18 680 km2, with a median
of 4.8 ha across the whole file. A centroid rule would let a 4.8 ha polygon and a 1000 km2 polygon
each label exactly one unit, which is not a sampling scheme but a discarding of almost all of the
reference. So the reduction is an areal overlay, and reference_coverage is genuinely fractional
here - unlike labelled_lcz, where it is deliberately binary.
The overlaps are real and are resolved before the overlay, not during it. Contributions come
from different submissions in different years - representative_date spans 1983 to 2025 - and they
overlap each other: in a 15 x 12 km Kowloon window, 803 polygons form 3330 overlapping pairs of
which 560 carry different classes. Overlaying that directly would count the same ground several
times under labels that contradict each other, and the "majority" would measure contributor
enthusiasm rather than the city. resolve_overlaps gives each piece of ground to exactly one
polygon by an explicit priority, and reports how much ground was contested and how much was merely
duplicated - the contributors' own disagreement rate is a number worth having rather than hiding.
Two things about this file that will mislead a reader who trusts it.
- The stored
areacolumn is not area in any usable unit: it is km2 computed in Web Mercator, so it is inflated by 1/cos^2(latitude) - the median ratio to true area is 1 004 995 against Mollweide's 744 899. Nothing here reads it;test_validation_wudapt.pyasserts that. classruns 1 to 19, not 1 to 17. 633 polygons globally carry codes 18 and 19, which are outside the Demuzere/So2Sat coding this package uses everywhere else. They are dropped and counted, never mapped onto a neighbouring class.
WUDAPT is not independent of lcz_v3. The LCZ Generator's training areas are the training
data behind the Demuzere global map, so an agreement figure between the two is inflated by
construction and is not a ceiling in the sense So2Sat gives one. Report it only with that stated
beside it.
Returns the same three columns as reference_lcz and labelled_lcz, so agreement() consumes
any of the three without knowing which it was given.
WUDAPT_SOURCE_DIR_NAME
module-attribute
¶
input/<name>/ holding the LCZ Generator training-area export.
WUDAPT_CITATION
module-attribute
¶
Bechtel et al. (2015), IJGI 4(1), 199-219 - the WUDAPT Level 0 protocol these areas follow.
The LCZ Generator itself is Demuzere, Kittner & Bechtel (2021), 10.3389/fenvs.2021.637455.
CLASS_COLUMN
module-attribute
¶
WUDAPT's own class column. Integer, and 1-17 agrees with Demuzere's coding - but see
UNSUPPORTED_CLASSES, because it does not stop at 17.
UNSUPPORTED_CLASSES
module-attribute
¶
Codes present in the file and absent from lczkit.classify.labels.CODES.
633 polygons of 630 311 globally. They are dropped rather than folded into a neighbouring class: this package has no definition for them, and inventing one would put a label into the reference that no contributor ever drew.
QC_COLUMNS
module-attribute
¶
The LCZ Generator's three submission quality-control flags, stored as strings.
PRIORITY_COLUMNS
module-attribute
¶
Columns resolve_overlaps reads to rank contested ground. Absent columns are skipped.
READ_COLUMNS
module-attribute
¶
READ_COLUMNS: tuple[str, ...] = (CLASS_COLUMN, *QC_COLUMNS, 'submission_id', 'submission_date', 'representative_date', 'city', 'oa', 'license')
Everything the loader needs. Reading 40 columns over 630 311 features to use nine of them is the difference between a fixture build that takes seconds and one that takes minutes.
OVERLAP_EPS_M2
module-attribute
¶
Intersection area below which two resolved polygons are treated as not overlapping.
shapely.difference leaves coordinate-noise slivers along the cut, so the topological overlaps
predicate still fires on pairs whose shared area is around 1e-8 m² — a hundredth of a square
micrometre, measured on the Berlin fixture. The resolution is exact in area (labelled +
duplicate + conflict reproduces the raw sum to the full 13 595 047.0 m² on that fixture); it is
the predicate that is not a statement about area. Tests assert on area against this tolerance.
A module constant and not config, for the same reason eps_m is one: a floating-point tolerance
is not a domain threshold.
WudaptSelection
dataclass
¶
WudaptSelection(n_read: int, n_kept: int, n_dropped_invalid: int, n_dropped_unsupported_class: int, n_dropped_qc: int, n_dropped_accuracy: int, n_dropped_area: int, qc_pass_fraction: float, n_overlapping_pairs: int, n_conflicting_pairs: int, duplicate_area_m2: float, conflict_area_m2: float, labelled_area_m2: float, date_min: str | None, date_max: str | None, licences: tuple[str, ...] = (), cities: tuple[str, ...] = ())
What prepare_wudapt kept, what it dropped, and what the contributors disagreed about.
Every drop is counted rather than being visible only as a smaller frame, for the same reason the cleaning report counts what it removes: a reference that quietly loses half its polygons to a quality gate looks exactly like one that never had them.
n_dropped_invalid
instance-attribute
¶
Non-polygonal or unrepairable geometry. About 1.0% of the file self-intersects.
n_dropped_unsupported_class
instance-attribute
¶
Codes in UNSUPPORTED_CLASSES, or null.
n_dropped_qc
instance-attribute
¶
Failed the quality gate. Zero when WudaptConfig.require_qc is off, which is the default -
the gate costs 51.8% of the file, so it is offered rather than imposed.
n_dropped_accuracy
instance-attribute
¶
Below WudaptConfig.min_oa. oa is a property of the submission, not the polygon.
n_dropped_area
instance-attribute
¶
Outside WudaptConfig.min_area_m2 / max_area_m2.
qc_pass_fraction
instance-attribute
¶
Share of the read polygons passing all three QC flags, reported whether or not the gate is on, so the cost of turning it on is visible without a second run.
n_overlapping_pairs
instance-attribute
¶
Pairs of kept polygons whose interiors overlap, in one direction each.
n_conflicting_pairs
instance-attribute
¶
Of those, the pairs carrying different classes - contributors disagreeing about ground, as opposed to re-drawing it.
duplicate_area_m2
instance-attribute
¶
Ground a polygon yielded to a higher-priority polygon of the same class. Redundancy.
conflict_area_m2
instance-attribute
¶
Ground a polygon yielded to a higher-priority polygon of a different class. This is the reference disagreeing with itself, and it belongs in the write-up beside any figure measured against it.
labelled_area_m2
instance-attribute
¶
Area of the resolved, non-overlapping polygons. The reference's actual support.
date_max
instance-attribute
¶
Range of representative_date among the kept polygons. A reference spanning four decades is
not a snapshot, and a city that redeveloped inside that span will disagree with any single map
for reasons that are not the map's error.
licences
class-attribute
instance-attribute
¶
Distinct license values among the kept polygons, read from the data rather than assumed.
Most of the file is CC BY-NC-SA 4.0 or CC BY-SA - non-commercial in the first case.
That constrains the data, not this MIT package, and nothing here redistributes it; it is
recorded so a run's manifest states the terms of what it was scored against.
cities
class-attribute
instance-attribute
¶
Distinct city strings among the kept polygons. Free text and not normalised upstream -
Wuhan and wuhan are different submissions - so this is provenance, never a join key.
WudaptMatch
dataclass
¶
WudaptMatch(n_polygons: int, n_units: int, n_units_labelled: int, n_units_multi_label: int, unit_area_m2: float, labelled_area_m2: float, mean_coverage: float, class_counts: dict[int, int] = dict())
How the resolved polygons landed on the units.
The analogue of LabelMatch, and it exists for the same reason: a reference that reaches a
tenth of the study area produces exactly the same column set as one that reaches all of it.
n_units_labelled
instance-attribute
¶
Units receiving any labelled ground at all, before min_reference_coverage filters them.
n_units_multi_label
instance-attribute
¶
Units whose labelled ground carries more than one class, and so are decided by an areal majority. Expected to be common on patch-scale units and rare on a 100 m grid.
labelled_area_m2
instance-attribute
¶
Labelled ground falling inside the units. Below WudaptSelection.labelled_area_m2 whenever
the reference extends past the study window, which it usually does.
mean_coverage
instance-attribute
¶
Area-weighted mean reference_coverage over the labelled units.
class_counts
class-attribute
instance-attribute
¶
Units per assigned reference class, so a window carrying two classes is visible as such.
OverlapReport
dataclass
¶
OverlapReport(n_overlapping_pairs: int, n_conflicting_pairs: int, duplicate_area_m2: float, conflict_area_m2: float)
What resolve_overlaps had to arbitrate.
The three areas satisfy raw = labelled + duplicate + conflict exactly, which is the
invariant worth checking: every square metre a contributor drew is either kept, redundant, or
contested, and nothing is silently lost.
n_overlapping_pairs
instance-attribute
¶
Ordered pairs, counted once per lower-priority polygon per higher-priority polygon it genuinely shares area with. Polygons that merely touch are excluded.
n_conflicting_pairs
instance-attribute
¶
Of those, the pairs whose classes differ.
duplicate_area_m2
instance-attribute
¶
Ground yielded to a higher-priority polygon of the same class, summed over polygons — so ground claimed by k agreeing polygons contributes k-1 times. It measures redundant drawing effort, not an area of the city.
conflict_area_m2
instance-attribute
¶
Ground yielded to a higher-priority polygon of a different class, summed the same way.
Attributed to duplicate_area_m2 first where a polygon lost the same ground to both an
agreeing and a disagreeing claimant, so this is the conservative reading of the disagreement.
read_wudapt
¶
read_wudapt(path: Path, bbox: BBox, *, layer: str | None = None, columns: tuple[str, ...] = READ_COLUMNS) -> GeoDataFrame
The raw polygons intersecting bbox, in the file's own CRS (EPSG:4326).
Reads only columns and only the bbox: the file is 720 MB and 630 311 features, and every
caller wants a city-sized window of it.
layer=None resolves to the first geometry-bearing layer rather than being passed through.
The published export carries a second layer, layer_styles, which is a QGIS style table with
no geometry; letting the driver pick would work today and read the style table the day the
layer order changes.
Source code in src/lczkit/validation/wudapt.py
prepare_wudapt
¶
prepare_wudapt(polygons: GeoDataFrame, *, crs: CRS, config: WudaptConfig | None = None) -> tuple[GeoDataFrame, WudaptSelection]
Clean, filter and de-overlap polygons, returning them in crs with a record of the cost.
The returned frame carries class and a non-overlapping geometry: every piece of ground
belongs to exactly one polygon, so an areal overlay onto units cannot double-count it.
polygons is not mutated.
Source code in src/lczkit/validation/wudapt.py
262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 | |
priority_order
¶
Index of polygons, best claim to contested ground first.
Most recent representative_date, then the higher submission accuracy, then the smaller
polygon, then the index. The last two are what make it deterministic; the first two are the
judgement:
- Recency wins because a WUDAPT polygon describes the city at
representative_date, the dates span 1983-2025, and lczkit is classifying a current Overture release. Where two contributors disagree about the same ground, the newer one is describing the city the package is looking at. - Accuracy breaks the tie because
oais the LCZ Generator's own cross-validated score for the submission the polygon came from. It is a property of the submission, not the polygon, so it is a weak signal - which is exactly why it ranks below recency rather than above it. - Smaller wins last, on the reasoning that a contributor who drew a 2 ha polygon inside someone else's 200 ha one was being more specific about that ground.
A missing date or accuracy sorts last rather than dropping the polygon: it costs the polygon its claim on contested ground, not its label on ground nobody else drew.
Source code in src/lczkit/validation/wudapt.py
resolve_overlaps
¶
resolve_overlaps(polygons: GeoDataFrame, *, class_column: str = CLASS_COLUMN) -> tuple[GeoDataFrame, OverlapReport]
Give every piece of ground to exactly one polygon, and report what that cost.
Each polygon keeps itself minus every higher-priority polygon. Subtracting the higher-priority polygons' original geometries rather than their remainders is equivalent and cheaper: ground a higher-priority polygon itself lost went to something higher still, so it is claimed either way and never returns to a lower-priority claimant.
Polygons reduced to nothing are dropped. Their label is not lost - it agreed with, or was overruled by, a polygon that kept the same ground.
The result is exact in area but not in topology: the cuts leave coordinate-noise slivers, so
overlaps still fires on some pairs at around 1e-8 m2. See OVERLAP_EPS_M2.
Source code in src/lczkit/validation/wudapt.py
wudapt_lcz
¶
wudapt_lcz(units: GeoDataFrame, polygons: GeoDataFrame, *, class_column: str = CLASS_COLUMN) -> tuple[DataFrame, WudaptMatch]
The reference class per unit_id by areal majority, and how the labels landed.
Returns the three columns reference_lcz and labelled_lcz return, with the same meanings:
reference_lcz- the class holding most of the unit's labelled area, or null where no polygon reaches it. NullableInt8, never a sentinel.reference_coverage- fraction of the unit any polygon covers. Genuinely fractional, unlikelabelled_lcz's binary flag, because WUDAPT polygons tile ground rather than sampling it.reference_majority_fraction- the winner's share of the labelled part.
polygons must already be non-overlapping - pass what prepare_wudapt returns. Overlapping
input is not detected here (the check costs as much as the fix); it would inflate every
coverage above 1.0 and let one contributor outvote a city.
Neither input is mutated.
Source code in src/lczkit/validation/wudapt.py
518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 | |
load_wudapt
¶
load_wudapt(path: Path, bbox: BBox, *, crs: CRS, config: WudaptConfig | None = None) -> tuple[GeoDataFrame, WudaptSelection]
read_wudapt then prepare_wudapt: the whole file route in one call.
Split into two functions underneath so tests can exercise the cleaning against a committed
fixture frame without DATA_DIR being set.
Source code in src/lczkit/validation/wudapt.py
Uncertainty¶
Confidence intervals come from a spatial-block bootstrap — resampling contiguous blocks of cells rather than individual cells. So2Sat patches are 320 m across on a 100 m grid, so neighbouring cells frequently carry the same label from the same patch: a city's labelled cells are one correlated sheet, and resampling them individually would report an interval far too narrow.
lczkit.validation.uncertainty
¶
Confidence intervals for the agreement figures, by spatial block bootstrap.
Why an interval at all. Every headline this package reports - overall_agreement,
built_agreement, both axis lifts - is a point estimate, and the sixteen-city work compares them
across cities, differences them between arms, and orders the next lever by which is larger. None of
that is readable without knowing how much of the difference is noise. axis_reconciliation.py
compares max_over_median_lift against a hard < 5.0 as though it were a test statistic.
Why blocks rather than units. The units are not independent draws. So2Sat patches are 320 m squares sampled on a 100 m stride, so neighbouring patches overlap about sevenfold and the labelled cells of a city are one contiguous, strongly autocorrelated sheet. Resampling cells independently would treat 9 627 near-duplicate observations as 9 627 independent ones and return an interval far too narrow - the classic effective-sample-size error. Resampling contiguous blocks keeps the local correlation inside the resampling unit, which is what a block bootstrap is for.
The block size is a judgement, not a measurement: it has to be larger than the correlation length and small enough to leave many blocks. The default is 1 km, comfortably above the 320 m patch width that generates most of the dependence, and it is reported in the output so a reader can see what it was rather than infer it.
What this does not do. It quantifies sampling variability under the labels this run was scored against. It says nothing about the label support mismatch (a 320 m patch label attributed to a 1 ha cell), which is a bias rather than a variance and does not shrink with more cells.
Interval
¶
Bases: BaseModel
A point estimate with a percentile bootstrap interval around it.
width
property
¶
upper - lower, in the units of the figure — the interval's span, not a half-width.
BootstrapReport
¶
Bases: BaseModel
Intervals for the figures the project actually compares across cities and arms.
n_blocks
instance-attribute
¶
Blocks resampled with replacement. This, not the unit count, is the effective sample size - and the gap between the two is the whole reason this module exists.
spatial_blocks
¶
Assign each unit to a square block of side block_size_m, keyed by unit_id.
Blocks are anchored on the projected CRS origin rather than on the data's own bounding box, for
the reason GridUnits uses the same anchor: two runs over overlapping extents then agree about
which block a unit is in, so their intervals are computed over comparable partitions.
Uses a representative point rather than a centroid so a concave enclosure lands in a block it actually occupies.
Source code in src/lczkit/validation/uncertainty.py
bootstrap_agreement
¶
bootstrap_agreement(predicted: Series, reference: Series, area_m2: Series, blocks: Series, *, coverage: Series | None = None, config: ValidationConfig | None = None, n_resamples: int = 200, confidence: float = 0.95, seed: int = 0, block_size_m: float = float('nan')) -> BootstrapReport
Percentile bootstrap over spatial blocks, resampled with replacement.
blocks is a unit_id-indexed block label, from spatial_blocks(). Every input is aligned on
unit_id first, so a caller cannot silently pair a prediction with another unit's block.
seed is fixed by default: an interval that moves between two runs of the same data is not a
property of the data, and this package treats reproducibility as a feature rather than an
afterthought. block_size_m is carried through for the record only - the blocks themselves
arrive already formed, so nothing here depends on it being right, but a reported interval whose
block size a reader has to guess is not much of a record.
Source code in src/lczkit/validation/uncertainty.py
106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | |
Parameter ranges¶
lczkit.validation.ranges
¶
Where a computed parameter actually falls, against the range Stewart & Oke published for it.
Agreement says whether a label was right. This says why it was wrong: if a parameter is systematically outside the published band for the class a unit genuinely belongs to, no weighting or threshold downstream can recover the label, because the prototype the unit should match is unreachable in that dimension.
Group by the reference class, not by the assigned one. Grouping by the assigned class asks whether the labelling is self-consistent, which it is almost by construction - the classifier placed those units near that prototype, so their parameter lands near its range. Grouping by the reference class asks whether the parameter reaches the published range on units of known type, which is the question. The two are easy to confuse and give opposite answers: on the Berlin fixture, building surface fraction grouped by assigned class sits inside LCZ 2's published 40-70%, while grouped by reference class it sits at 0.31.
"Reference" names a role, not a file, so the file has to travel with the numbers. Two different
references can fill that role - lcz_v3, which is another model's estimate, and hand-labelled
So2Sat patches, which are the primary reference wherever they exist - and they do not agree.
Substituting one for the other has inverted a diagnosis outright (compactness 25.8% against 55.2%
on the same cells), and grouping by lcz_v3 silently uses 91 158 Berlin cells where 9 627 carry
labels. reference_file is therefore required to mean
anything by a RangeReport: a table that does not name what it grouped by cannot be compared with
another one, and the failure is invisible because both look like "the reference class".
Everything is area-weighted, for the reason agreement is: on enclosures, weighting by count lets
a thousand street-margin slivers outvote a district.
ClassRange
¶
Bases: BaseModel
One class: where the units it should contain actually sit, against its published range.
published_max
instance-attribute
¶
The prototype interval in the column's own unit. None is an open end - a class unbounded
on that side, which no value can fall outside.
median
instance-attribute
¶
Area-weighted median of the parameter over this class' units.
share_in_range
instance-attribute
¶
Area share of this class' units whose parameter lies inside the published interval. The headline number: 1.0 means the published prototype is reachable for every unit that should match it, 0.0 means it is reachable for none of them.
RangeReport
¶
Bases: BaseModel
One parameter, compared against its published range across every class present.
source
instance-attribute
¶
STEWART_OKE_2012 or lczkit - whether the range being tested against is published at
all. Testing a measurement against a range this package invented proves nothing about the
measurement, so the provenance travels with the numbers.
grouped_by
instance-attribute
¶
"reference", "ground_truth" or "assigned". See the module docstring: these answer
different questions. "reference" and "ground_truth" are both "the class this unit really
is" - they differ in who says so, which is why reference_file is not optional in practice.
reference_file
class-attribute
instance-attribute
¶
The file the grouping labels came from. None only where the caller genuinely has no file
to name, as for "assigned", whose labels this package produced itself.
parameter_ranges
¶
parameter_ranges(values: Series, labels: Series, area_m2: Series, *, column: str, grouped_by: str = 'reference', reference_file: str | None = None) -> RangeReport
Compare values against the published range of column, grouped by labels.
All three series are indexed by unit_id. Units with a null value or a null label are
excluded - a unit the reference map does not reach cannot tell us anything about whether the
parameter reaches a range - and classes absent from labels are omitted rather than reported
as empty, since a class no unit belongs to has nothing to say about the parameter.
Pass reference_file wherever the labels came from one: two reports that do not name their
reference cannot be told apart, and this module's caller grouped by the wrong one undetected
for four phases.
Source code in src/lczkit/validation/ranges.py
weighted_quantile
¶
The q-quantile of values weighted by weights, or None for an empty input.
The lower of the two interpolation candidates rather than a blend: the quantity is reported beside a published interval, and an interpolated value is not one any unit actually has.