How to map local zoning codes to standardized taxonomies

You have field-aligned dozens of municipal layers — every source now exposes a single zoning_code column — and you still cannot run a regional analysis, because the values in that column are local dialect. R-1 in one city is low-density single-family; R-1 in the county next door is a planned-unit residential overlay. C-2 is “general commercial” in one ordinance and “heavy commercial” in another. A flat dictionary keyed on the raw string either collides these into one wrong class or explodes into hundreds of singletons. This page answers one narrow question: how do you translate a jurisdiction’s local zoning code strings into a single standardized taxonomy deterministically, scoped per jurisdiction, with a confidence score on every decision, so a code that means different things in two cities never silently maps to the same class? It is the resolution-engine core of zoning taxonomy mapping, and it assumes the upstream attribute normalization has already settled which column to read.

Diagnosis: why a flat lookup corrupts cross-jurisdiction data jump to heading

A naive TAXONOMY[code] dictionary fails in three distinct ways, and you want to recognize each before writing any resolution logic.

  1. Hard KeyError on an unseen variant. The lookup ingested R-1 cleanly last quarter, then a new annexation publishes RES-1A and the access throws:

    Traceback (most recent call last):
      File "/opt/pipeline/taxonomy.py", line 88, in map_code
        return CANONICAL[raw.strip().upper()]
    KeyError: 'RES-1A'
    
  2. Silent cross-jurisdiction collapse. No exception fires, but two cities both use C-2 for incompatible classes. A global, un-scoped lookup maps both to the same canonical value, and the error only surfaces later as a feasibility model that approves heavy industry next to homes. This is the most expensive failure because nothing logs it.

  3. Confidence-blind defaulting. A pipeline that force-maps everything it cannot match to a single fallback class inflates one category with garbage. Without a per-record confidence score, you cannot tell a clean exact match from a desperate fuzzy guess, so you cannot defend any number downstream.

Before refactoring, profile the divergence so the win is measurable. Count distinct raw codes per jurisdiction and how many already resolve against your current taxonomy:

import geopandas as gpd
from pathlib import Path

for src in Path("aligned").glob("*.gpkg"):
    gdf = gpd.read_file(src, engine="pyogrio")
    by_juris = gdf.groupby("jurisdiction")["zoning_code"].nunique()
    print(f"{src.name:28} distinct codes per jurisdiction:\n{by_juris.to_string()}")

If the same raw code appears under two jurisdiction values with different intended meanings, you have a scoping problem, not a spelling problem — and it must be solved with a jurisdiction-keyed override layer, not a bigger global dictionary.

Tiered, jurisdiction-scoped resolution cascade for one zoning code One raw zoning_code string enters and is sanitized to a stable token by collapsing case, delimiters, and whitespace; an empty result short-circuits to QUARANTINE_NULL at confidence 0.0. The token then falls through four tiers in strict order, each more permissive and less confident than the one above. Tier 1 is a jurisdiction-scoped override keyed on the (jurisdiction, code) pair, which wins first and prevents cross-jurisdiction collisions, emitting a canonical class at confidence 1.0 with method override. A miss falls to Tier 2, an exact global match against the canonical taxonomy at confidence 1.0 method exact. A miss falls to Tier 3, regex token extraction such as R1A to R1 at confidence 0.9 method regex. A miss falls to Tier 4, a fuzzy match gated at a fuzz.ratio of 88, emitting confidence equal to score over 100 with method fuzzy. Anything below the threshold falls to the quarantine sink, tagged for review and never silently defaulted. raw zoning_code one per-record string sanitize_code() collapse case · delimiters · whitespace Tier 1 — jurisdiction override (jurisdiction, code) — wins first Tier 2 — exact global match CANONICAL_TAXONOMY[code] Tier 3 — regex token extract R1A → R1 · RES1 → R1 Tier 4 — fuzzy match fuzz.ratio gated ≥ 88 Quarantine sink tagged for review · never defaulted miss miss miss below threshold empty → QUARANTINE_NULL · 0.0 hit hit hit hit canonical class confidence 1.0 · method = override canonical class confidence 1.0 · method = exact canonical class confidence 0.9 · method = regex canonical class confidence = score/100 · method = fuzzy

Architecture: a jurisdiction-scoped, tiered resolver jump to heading

Model the mapping as a strict cascade where every tier is more permissive and less confident than the one above it, and every tier is scoped by jurisdiction first.

  • Canonical taxonomy. One flat target vocabulary aligned to a public standard such as the Land Based Classification Standards (LBCS), e.g. RESIDENTIAL_SINGLE_FAMILY, COMMERCIAL_GENERAL, MIXED_USE. This is shared with everything that consumes mapped zoning downstream.
  • Jurisdiction override table. A per-(jurisdiction, raw_code) table that wins before any global rule. This is the only place a local C-2 gets its city-specific meaning, and it is the mechanism that prevents cross-jurisdiction collisions.
  • Tiered global resolver. Exact match → regex token extraction → fuzzy match gated by a confidence threshold. Each tier emits both a canonical class and a numeric confidence so routing decisions are explicit.
  • Quarantine sink. Anything below the confidence threshold is tagged, never defaulted, and routed for review.

Keep the resolver stateless and the tables version-controlled data so the pass is idempotent and a new jurisdiction is a config change. The override and taxonomy tables are the same artifacts that schema validation & data quality checks validate against, and they presuppose that geometries have already been reprojected via CRS alignment strategies so attribute and spatial layers stay consistent.

Step-by-step implementation jump to heading

Each step isolates exactly one concern. Compose them in the orchestrator at the end.

Step 1 — Pin the canonical taxonomy and jurisdiction overrides jump to heading

Keep both as data, not code. The override table is keyed by jurisdiction so identical raw codes resolve to different canonical classes per city.

# Standardized target vocabulary (aligned to LBCS-style classes).
CANONICAL_TAXONOMY = {
    "R1": "RESIDENTIAL_SINGLE_FAMILY",
    "R2": "RESIDENTIAL_MULTI_FAMILY",
    "C1": "COMMERCIAL_NEIGHBORHOOD",
    "C2": "COMMERCIAL_GENERAL",
    "I1": "INDUSTRIAL_LIGHT",
    "MU": "MIXED_USE",
    "AG": "AGRICULTURAL_CONSERVATION",
}

# (jurisdiction, sanitized_raw_code) -> canonical class. Wins before global rules.
JURISDICTION_OVERRIDES = {
    ("RIVERTON", "C2"): "COMMERCIAL_HEAVY",      # local C-2 means heavy commercial
    ("RIVERTON", "R1"): "RESIDENTIAL_PUD",       # local R-1 is a PUD overlay
    ("LAKEVIEW", "MUD"): "MIXED_USE",            # MU-D collapses to MIXED_USE here
}

Step 2 — Sanitize the raw code to a stable token jump to heading

Collapse the cosmetic variance — case, whitespace, delimiters, stray punctuation — so identical codes converge before any lookup. Sanitization must be deterministic and lossless enough to keep the original for the audit trail.

import re
import pandas as pd

def sanitize_code(raw) -> str:
    """Collapse case, whitespace, and delimiter variance to a stable token."""
    if pd.isna(raw):
        return ""
    cleaned = re.sub(r"[^A-Za-z0-9]", "", str(raw))
    return cleaned.strip().upper()

Step 3 — Resolve through the tiered cascade with confidence jump to heading

Run override, exact, regex, then fuzzy in strict order. Each tier returns the canonical class, a confidence score, and the method used, so nothing downstream has to guess how a value was derived.

from rapidfuzz import process, fuzz

ZONE_PATTERN = re.compile(r"^(?P<base>[A-Z]{1,3})(?P<num>\d+)")

def resolve_code(jurisdiction: str, raw: str, threshold: int = 88) -> dict:
    """Map one raw code to canonical with an explicit confidence and method."""
    clean = sanitize_code(raw)
    if not clean:
        return {"canonical": "QUARANTINE_NULL", "confidence": 0.0, "method": "null"}

    # Tier 1: jurisdiction override (authoritative, prevents collisions).
    override = JURISDICTION_OVERRIDES.get((jurisdiction.upper(), clean))
    if override:
        return {"canonical": override, "confidence": 1.0, "method": "override"}

    # Tier 2: exact global match.
    if clean in CANONICAL_TAXONOMY:
        return {"canonical": CANONICAL_TAXONOMY[clean], "confidence": 1.0, "method": "exact"}

    # Tier 3: regex token extraction, e.g. "R1A" / "RES1" -> "R1".
    match = ZONE_PATTERN.match(clean)
    if match:
        key = f"{match.group('base')[0]}{match.group('num')}"
        if key in CANONICAL_TAXONOMY:
            return {"canonical": CANONICAL_TAXONOMY[key], "confidence": 0.9, "method": "regex"}

    # Tier 4: fuzzy match, gated by threshold. Confidence scales with the score.
    best, score, _ = process.extractOne(clean, CANONICAL_TAXONOMY.keys(), scorer=fuzz.ratio)
    if score >= threshold:
        return {"canonical": CANONICAL_TAXONOMY[best], "confidence": round(score / 100, 3), "method": "fuzzy"}

    return {"canonical": f"QUARANTINE_{clean}", "confidence": 0.0, "method": "unmatched"}

Step 4 — Apply across the frame and preserve lineage jump to heading

Map the resolver over a GeoDataFrame, keeping the original raw value alongside the canonical class so every decision is reconstructable. Never overwrite the source column in place.

def map_taxonomy(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Attach canonical class, confidence, and method without dropping lineage."""
    gdf = gdf.copy()
    gdf["raw_zoning_code"] = gdf["zoning_code"]  # preserve for audit
    resolved = [
        resolve_code(row.get("jurisdiction", "UNKNOWN"), row["zoning_code"])
        for _, row in gdf.iterrows()
    ]
    gdf["canonical_code"] = [r["canonical"] for r in resolved]
    gdf["map_confidence"] = [r["confidence"] for r in resolved]
    gdf["map_method"] = [r["method"] for r in resolved]
    return gdf

Step 5 — Emit a mapping manifest with confidence distribution jump to heading

Capture a per-run manifest the moment mapping completes. It records the method mix, the quarantine rate, and a deterministic checksum — exactly the compliance artifact a due-diligence review reads.

import hashlib

def build_manifest(gdf: gpd.GeoDataFrame, run_id: str) -> dict:
    """Per-run mapping manifest with method mix and quarantine rate."""
    quarantined = int(gdf["canonical_code"].str.startswith("QUARANTINE_").sum())
    payload = gdf[["raw_zoning_code", "canonical_code"]].to_json(orient="records")
    return {
        "run_id": run_id,
        "records_total": len(gdf),
        "records_quarantined": quarantined,
        "quarantine_rate": round(quarantined / max(len(gdf), 1), 4),
        "method_distribution": gdf["map_method"].value_counts().to_dict(),
        "checksum": hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16],
    }

Verification & testing jump to heading

Confirm the resolver actually standardized the codes rather than hiding the divergence.

  • No cross-jurisdiction collision. Assert that for any raw code shared by two jurisdictions with an override, the canonical results differ: gdf.groupby("raw_zoning_code")["canonical_code"].nunique() should be greater than one wherever an override applies. A flat one means the override layer was skipped.
  • Cardinality is bounded. gdf["canonical_code"].nunique() should not exceed the taxonomy size plus the distinct quarantine tokens. A higher count means a variant escaped sanitize_code.
  • Confidence floor holds. assert (gdf.loc[gdf.map_method == "fuzzy", "map_confidence"] >= 0.88).all() — anything below threshold must have been quarantined, not mapped.
  • Lineage is intact. raw_zoning_code must be non-null on every row so any mapping can be reconstructed and challenged.
  • Round-trip determinism. Re-running the same input must reproduce an identical manifest checksum. Drift means non-deterministic row order; sort before mapping.

A quick reference for how the tiers resolve real municipal variants:

Jurisdiction Raw code After sanitize Canonical class Confidence Method
RIVERTON C-2 C2 COMMERCIAL_HEAVY 1.0 override
LAKEVIEW C-2 C2 COMMERCIAL_GENERAL 1.0 exact
LAKEVIEW R-1A R1A RESIDENTIAL_SINGLE_FAMILY 0.9 regex
LAKEVIEW Resdential RESDENTIAL QUARANTINE_RESDENTIAL 0.0 unmatched
LAKEVIEW `` (empty) `` QUARANTINE_NULL 0.0 null

Failure recovery jump to heading

When a code fails to resolve cleanly mid-run, keep the rest of the pipeline moving and stay reproducible.

  • Quarantine, never default. Records whose canonical_code starts with QUARANTINE_ write to a review_required.parquet partition with their raw code, jurisdiction, and confidence, so a human or a later taxonomy update reclassifies them without re-ingesting the whole feed. This is the same quarantine discipline used across fallback routing logic.
  • Promote a quarantined code to an override. When review confirms a quarantined or low-confidence code’s true meaning, add a (jurisdiction, code) entry to JURISDICTION_OVERRIDES and replay only that source — cheap, because the manifest already records exactly which jurisdiction drifted.
  • Alert on quarantine spikes. Track quarantine_rate per jurisdiction across runs; a sudden jump for one city is the signature of an unannounced ordinance update, which is exactly the signal you want surfaced rather than absorbed. Wire the alert through your error handling & retry logic.
  • Halt the source, not the run. A jurisdiction whose quarantine rate exceeds a hard ceiling (say 15%) emits a status: review_required manifest and is held out of the regional merge; sibling jurisdictions continue. Never let one city’s reclassification abort the batch.

Frequently asked questions jump to heading

Why scope the lookup by jurisdiction instead of one global taxonomy table?

Because identical raw codes legitimately mean different things across municipalities — C-2 is general commercial in one ordinance and heavy commercial in another. A single global table must pick one, silently misclassifying the other city’s parcels. A jurisdiction-keyed override layer resolves the code in its local context first, then falls back to global rules only for codes that are genuinely unambiguous.

What confidence threshold should gate the fuzzy tier?

Start at 88 on a fuzz.ratio scale and tune per data. Below roughly 85 you begin auto-accepting transpositions that change meaning (R1 vs RI). The point of the threshold is that anything under it is quarantined for review rather than mapped, so a conservative value costs you a slightly larger review queue, not silent corruption.

Should I store the raw code after mapping?

Always. Keep raw_zoning_code on every record. Standardized analytics read canonical_code, but compliance, audit, and any future re-mapping against an updated taxonomy depend on the original string being preserved. Discarding it makes a misclassification permanent and undiscoverable.

How do I onboard a new municipality without a code change?

Add its rows to the version-controlled JURISDICTION_OVERRIDES and, if it introduces genuinely new classes, to CANONICAL_TAXONOMY. Because the resolver is stateless and table-driven, onboarding is a reviewable config diff with full history — not a deploy — and you can see exactly when and why a mapping entered the system during a compliance review.