Building type is drawn from the right marginal distribution — sixty percent residential, thirty
commercial, ten industrial — and the map looks like static. Real cities have residential
districts, not residential confetti.
Part of Attribute Correlation Modeling: that page covers correlations between attributes at a location. This one is correlation of a single attribute across locations, which needs a different mechanism and is usually the more visible of the two when it is missing.
Drawing each feature’s category independently from a marginal distribution reproduces that
distribution exactly. It also guarantees that neighbouring features are independent, which is the
one property the real data does not have.
The gap shows up in a statistic almost nobody computes on synthetic output and everybody notices
by eye: the join count, the number of adjacent pairs sharing a category. Under independent
draws with category proportion p, the expected share of same-category joins is simply Σpᵢ², and
in real spatial data it is far higher — often twice that, sometimes more. The generator is not
slightly off; it is producing a structurally different object that happens to have the right
histogram.
The consequence is not cosmetic. Anything downstream that aggregates over neighbourhoods —
catchment analysis, district-level summaries, any model with a spatial lag term — will behave
differently on confetti than on clustered data, and the difference is systematic rather than
noisy.
Same marginal distribution, three generators: independent draws sit at the theoretical floor while the observed data clusters far above it.
defjoin_counts(labels:dict, adjacency:dict)->dict:"""Share of adjacent pairs sharing a category, and per-category breakdown."""
same =0
total =0
per_cat:dict={}for a, neighbours in adjacency.items():for b in neighbours:if a >= b:continue# count each undirected pair once
total +=1if labels[a]== labels[b]:
same +=1
per_cat[labels[a]]= per_cat.get(labels[a],0)+1return{"same_share": same / total,"per_category": per_cat,"pairs": total}defindependence_baseline(proportions:dict)->float:"""What the same-category share would be under independent draws."""returnsum(p * p for p in proportions.values())
Run both on the reference data. The ratio between them is the calibration target, and it is the
single number the generator has to hit — a same-share of 0.58 against a baseline of 0.46 is a
clustering ratio of 1.26, and that is what the generated output should reproduce.
Per-category breakdown matters too. Industrial land clusters far more strongly than retail in
most cities, and a generator tuned to a single global ratio will over-cluster the diffuse
categories while under-clustering the concentrated ones.
The reliable construction is indirect. Rather than drawing categories, draw a smooth continuous
field with the right spatial correlation and cut it into categories at quantiles chosen to hit
the marginal.
deflatent_field(coords, range_m:float, rng)->list[float]:"""A Gaussian field smoothed to a target correlation range."""
raw =[rng.normal()for _ in coords]
out =[]for i,(x, y)inenumerate(coords):
num = den =0.0for j,(u, v)inenumerate(coords):
d2 =(x - u)**2+(y - v)**2if d2 >(3* range_m)**2:continue# the kernel is negligible past 3 ranges
w = math.exp(-d2 /(2* range_m * range_m))
num += w * raw[j]
den += w
out.append(num / math.sqrt(den))# keep unit variancereturn out
The range_m parameter is the whole control surface: it is the distance over which categories
stay similar, it maps directly onto the observed clustering ratio, and it is the only thing that
needs calibrating.
defassign_categories(field:list[float], proportions:dict)->list[str]:"""Cut the latent field at quantiles so the marginal comes out exactly right."""
order =sorted(range(len(field)), key=lambda i: field[i])
labels =[None]*len(field)
cursor =0for cat, share in proportions.items():
take =round(share *len(field))for i in order[cursor:cursor + take]:
labels[i]= cat
cursor += take
for i in order[cursor:]:# rounding remainder
labels[i]=list(proportions)[-1]return labels
Cutting by rank rather than by a fixed threshold makes the marginal exact by construction, which
means the calibration loop only has one free parameter left. That separation — marginal exact,
clustering tuned — is what keeps the fit tractable.
defcalibrate_range(coords, adjacency, proportions, target_ratio, rng,
lo=50.0, hi=5000.0, tol=0.01)->float:"""Bisect on the correlation range until the join-count ratio matches."""
baseline = independence_baseline(proportions)for _ inrange(24):
mid = math.sqrt(lo * hi)# geometric bisection: range spans decades
labels = assign_categories(latent_field(coords, mid, rng), proportions)
ratio = join_counts(dict(enumerate(labels)), adjacency)["same_share"]/ baseline
ifabs(ratio - target_ratio)< tol:return mid
lo, hi =(mid, hi)if ratio < target_ratio else(lo, mid)return math.sqrt(lo * hi)
Geometric rather than arithmetic bisection, because a plausible correlation range spans two or
three orders of magnitude and arithmetic bisection wastes most of its iterations at the top of
the interval.
Clustering ratio as a function of the latent correlation range, with the bisection path to a target ratio marked.
deftest_marginal_is_exact(labels, proportions, tol=0.005):
got = tally_shares(labels)for cat, want in proportions.items():assertabs(got[cat]- want)< tol,f"{cat}: {got[cat]:.3f} vs {want:.3f}"deftest_clustering_matches_target(labels, adjacency, proportions, target, tol=0.05):
ratio =(join_counts(labels, adjacency)["same_share"]/ independence_baseline(proportions))assertabs(ratio - target)< tol,f"clustering ratio {ratio:.3f} vs target {target:.3f}"deftest_per_category_clustering(labels, adjacency, observed_per_cat, tol=0.1):"""A global match can hide two categories that are wrong in opposite directions."""
got = join_counts(labels, adjacency)["per_category"]for cat, want in observed_per_cat.items():assertabs(got[cat]- want)/ want < tol,f"{cat} clusters wrongly"deftest_not_degenerate(labels, adjacency):"""Too much clustering is also a failure: three giant blobs is not a city."""assert count_connected_components(labels, adjacency)>20
The last test is the one people leave out. Calibration pressure runs in one direction — the
symptom was too little clustering — and it is entirely possible to overshoot into a map with
three enormous single-category regions, which reproduces the join-count target and looks nothing
like the reference.
Anisotropic clustering. Land use follows corridors — along rivers, along transport lines — so
an isotropic kernel under-clusters along the corridor and over-clusters across it. Replacing the
distance term with an anisotropic metric is a two-line change and is worth it wherever the
underlying geography has strong directionality.
The same generated map measured three ways: the adjacency definition changes the number by more than most calibration tolerances allow.
Categories with hard spatial constraints. Industrial zoning that cannot occur inside a
protected area is a constraint, not a tendency, and it belongs as a mask applied to the ranking
rather than as pressure on the latent field. Trying to express a hard constraint through
correlation produces a generator that violates it occasionally, which is the worst outcome.
The adjacency definition changes the number. Join counts computed over queen contiguity, rook
contiguity or k-nearest neighbours give different values for the same map. The generated and
reference figures have to use the same definition, and that definition belongs in the contract
alongside the ratio itself.
Very large feature counts. The naïve field construction is quadratic. Past a few tens of
thousands of features it needs a spatial index or a grid-based approximation — the same
chunking argument as everywhere else, with the extra requirement that the kernel support must not exceed the chunk overlap or the correlation will break at chunk boundaries.
The other standard answer is a Markov random field — a Potts model, sampled with Gibbs or
Swendsen-Wang, where the energy penalises adjacent cells with different categories. It is the
more principled construction and it is usually the wrong practical choice here.
The marginal is not controllable. In the latent-field construction the marginal is exact by
construction, because the categories are assigned by rank. In a Potts model the marginal emerges
from the interaction strength and the external field together, so hitting a target proportion
means fitting a second parameter, and the two parameters interact — raising the interaction
strength to increase clustering also shifts the proportions, and the fit becomes
two-dimensional for no gain.
Convergence is not observable. A Gibbs sampler on a Potts model has to reach equilibrium
before the output means anything, and near the critical interaction strength — which is exactly
where realistic clustering levels sit — mixing is slow and there is no reliable stopping rule. A
run that has not converged produces plausible-looking output, which is the worst property a
generator can have.
Reproducibility is harder. The latent-field construction is a fixed sequence of arithmetic
from a seed. A Gibbs sampler’s output depends on the iteration count, the sweep order and the
initialisation, all of which have to be pinned and recorded to get a byte-identical release.
The Potts model earns its place when the interaction itself is the object of study, or when the
category structure has genuinely local rules that a smooth latent field cannot express — a
category that can only occur adjacent to another, for instance. For reproducing an observed
clustering level at a target marginal, the latent field is less elegant, more controllable, and
finishes in one pass.