Suitability Scoring & Site Ranking Models

Suitability scoring is the stage where every upstream spatial layer converges into a single number a real-estate committee can act on: a defensible, auditable rank for each candidate storefront. This section is the reference for Python teams building the scoring and ranking engine that consumes drive-time catchments, demographic joins and competitor footprints, and emits a shortlist that survives due diligence.

Everything earlier in the pipeline — the data foundations, the isochrone polygons, the demographic spatial joins — produces inputs. Scoring is where those inputs become a decision. Get the weighting, normalization or decay wrong here and every prior layer’s precision is wasted, because the committee ranks on the composite, not on the raw geometry. The discipline therefore demands a rigorous grasp of multi-criteria decision analysis, gravity models, and the validation gates that keep a ranking reproducible when the weights are questioned six months later.

Conceptual Foundations: From Layers to a Single Score

A candidate site is a feature vector. Each criterion — reachable population, median household income fit, competitive saturation, rent per square foot, parking, co-tenancy — is one dimension, measured in its own units and on its own scale. Scoring is the function that collapses that vector into a scalar while preserving the analyst’s intent about which dimensions matter and in which direction.

The canonical form is a weighted sum over normalized criteria. For candidate site ii and criterion jj, with normalized value xij[0,1]x_{ij} \in [0,1] and weight wjw_j where jwj=1\sum_j w_j = 1:

Si=jwjxij,xij=vijminjmaxjminjS_i = \sum_{j} w_j \, x_{ij}, \qquad x_{ij} = \frac{v_{ij} - \min_j}{\max_j - \min_j}

The subtlety hides in three places. First, directionality: a benefit criterion (population) rewards high values, but a cost criterion (rent, competitor proximity) must be inverted before it enters the sum, or the score punishes good sites. Second, normalization: raw counts and dollar amounts live on incomparable scales, so mixing them without normalizing lets whichever criterion has the largest numeric range silently dominate. Third, decay: population reachable in thirty minutes is not worth the same as population reachable in five, so accessibility criteria carry a distance-decay weighting rather than a flat count. Each of these is treated as its own engineering concern below, because each fails in its own way.

Suitability scoring pipeline Candidate sites gather criteria from upstream layers, each criterion is normalized to a common zero-to-one scale, weighted by importance, and summed into a composite score that produces a ranked shortlist. Suitability scoring pipeline Criteria reach · demand · rent competition Normalize 0–1 scale invert cost criteria Weight w · sum to 1 decay on reach Composite weighted sum score per site Rank shortlist top-N

The stages above map onto distinct failure surfaces. Criteria assembly is a join problem sensitive to CRS alignment; normalization is where scale bugs hide; weighting is where organizational intent enters and must be auditable; and ranking is where ties, near-duplicates and cannibalization must be resolved before a shortlist is trustworthy. The clusters beneath this section each own one of these concerns: building weighted site suitability scores formalizes the scoring matrix, competitor mapping and cannibalization analysis supplies the competition penalty, ranking and shortlisting candidate sites resolves the final order, and automated reporting and GIS export delivers the result to stakeholders.

Scoring Architecture: A Decoupled Assembly-to-Rank Flow

A production scoring engine is not a single script — it is a decoupled flow where criterion assembly, normalization, weighting and ranking are separate, individually testable stages. Coupling them means a change to one weight forces recomputation of every join, and a bug in normalization silently corrupts the composite with no place to inspect the intermediate. The pattern mirrors the four-layer discipline established across the architecture foundations: each stage reads validated inputs, writes a validated artifact, and can be re-run independently.

Decoupled suitability scoring architecture An orchestrator drives four decoupled stages — criterion assembly from isochrone, demographic and competitor layers, normalization, weighted scoring, and ranking — writing a scored candidate table to storage, with a validation gate rejecting any site that fails weight, CRS or geometry checks. Decoupled suitability scoring architecture Orchestrator Airflow / Prefect DAG · idempotent per candidate · weight config versioned · bounded retries 1 · Assemble isochrone reach demographics · rent competitor index 2 · Normalize min-max / z-score invert cost criteria clip outliers 3 · Score weighted sum distance decay competition penalty 4 · Rank sort · tie-break dedup catchments top-N shortlist Validation gate weights sum to 1 · no NaN · CRS asserted · score in [0,1] · monotonicity a failed gate quarantines the site, it never reaches the shortlist Scored candidate table GeoParquet · site × criterion × score · weight_version tag validated, versioned scores persisted for reporting and export Solid arrows = data flow · dashed = orchestration & validation · each stage is an independent, restartable job

Persisting the intermediate scored table as partitioned GeoParquet — one row per site-criterion pair, tagged with the weight-configuration version — is what makes the whole flow auditable. When a committee asks “why did site 14 rank above site 9?”, the answer is a query, not a rerun. That same versioning discipline lets the orchestration layer skip recomputation when only the presentation changes.

Assembling the Criteria Matrix

The scoring matrix begins as a spatial join. Each candidate site carries a trade area — usually a drive-time isochrone polygon — and every criterion is an aggregate over that polygon or a point attribute of the site itself. Reachable population comes from a spatial join against census block group demographics; competitive saturation comes from the competitor proximity index; rent and co-tenancy are site attributes joined from a property table.

The one rule that governs this assembly is that no area or distance is computed in a geographic CRS. Reachable population summed over an isochrone requires the catchment and the demographic layer to share a projected, equal-area system before the join runs.

python
import geopandas as gpd
from pyproj import CRS

EQUAL_AREA_CRS = CRS.from_epsg(5070)   # North America Albers — areas & distances

def assemble_criteria(sites: gpd.GeoDataFrame,
                      catchments: gpd.GeoDataFrame,
                      blockgroups: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    # Every layer must be reprojected to the same equal-area CRS before joins.
    for name, gdf in (("sites", sites), ("catchments", catchments),
                      ("blockgroups", blockgroups)):
        assert gdf.crs is not None, f"{name} has no CRS; refusing to proceed"
    catchments = catchments.to_crs(EQUAL_AREA_CRS)
    blockgroups = blockgroups.to_crs(EQUAL_AREA_CRS)

    # Reachable population per candidate catchment (intersecting block groups).
    joined = gpd.sjoin(blockgroups, catchments, predicate="intersects", how="inner")
    reach = joined.groupby("site_id")["population"].sum().rename("reach_pop")

    out = sites.set_index("site_id").join(reach)
    out["reach_pop"] = out["reach_pop"].fillna(0)
    return out.reset_index()

This produces one row per candidate with raw, un-normalized criteria. The mechanics of apportioning population when a catchment only partially covers a block group — which materially changes the reach figure — are covered in area-weighted interpolation for partial block group overlap.

Normalization, Weighting, and Distance Decay

Raw criteria are incomparable until normalized. A benefit criterion is rescaled so that larger is better; a cost criterion is inverted so that smaller is better. The normalization of mixed-scale attributes is where most scoring bugs originate, because a single un-inverted cost criterion produces a ranking that looks plausible and is exactly wrong.

Weighting encodes organizational intent and must be explicit and versioned. The weights table below is a starting point for a grocery-anchored format; the weighted scoring cluster discusses deriving them from analytic hierarchy process or regression against known-performing stores.

Criterion Direction Typical weight Normalization Notes
Reachable population benefit 0.30 min-max decayed by travel-time band
Demographic fit benefit 0.25 z-score → 0–1 target-audience weighted
Competitive saturation cost 0.20 inverted min-max from proximity index
Rent / occupancy cost cost 0.15 inverted min-max per square foot
Co-tenancy / anchor benefit 0.10 ordinal → 0–1 categorical mapped to scale

Reachable population is never a flat count. A shopper thirty minutes away contributes less expected demand than one five minutes away, so the accessibility criterion applies a distance-decay weight per travel-time band bb:

Ri=bPi,bfd(tb),fd(t)=eλtR_i = \sum_{b} P_{i,b} \, f_d(t_b), \qquad f_d(t) = e^{-\lambda t}

where Pi,bP_{i,b} is the population in band bb and λ\lambda is the decay rate. Choosing λ\lambda is an empirical exercise, not a guess — calibrating distance-decay functions for trade areas fits it against observed patronage. The composite score then combines the decayed reach with the other normalized criteria under the weight vector.

python
import numpy as np
import pandas as pd

def composite_score(matrix: pd.DataFrame, weights: dict[str, float]) -> pd.Series:
    # Weights are a versioned contract: they must sum to 1 and cover every column.
    assert abs(sum(weights.values()) - 1.0) < 1e-9, "weights must sum to 1"
    assert set(weights) <= set(matrix.columns), "unknown criterion in weights"

    cols = list(weights)
    w = np.array([weights[c] for c in cols])
    x = matrix[cols].to_numpy(dtype=float)
    assert not np.isnan(x).any(), "NaN in normalized matrix; fix upstream"
    assert (x >= 0).all() and (x <= 1).all(), "criteria not in [0,1]"

    return pd.Series(x @ w, index=matrix.index, name="score")

Gravity Models: Beyond Additive Scores

An additive weighted sum answers “how good is this site in isolation?” It does not answer “how much demand will this site actually capture, given the competitors already in the market?” For that, the field turns to gravity models. The Huff model estimates the probability that a customer at origin ii patronizes store jj as a function of the store’s attractiveness AjA_j and the travel cost dijd_{ij}:

Pij=AjdijβkAkdikβP_{ij} = \frac{A_j \, d_{ij}^{-\beta}}{\sum_k A_k \, d_{ik}^{-\beta}}

The denominator sums over every competing store kk, which is precisely why a gravity model, unlike an additive score, captures competition endogenously: adding a rival to the market lowers PijP_{ij} everywhere without any hand-tuned penalty. Multiplying PijP_{ij} by the demand at each origin and summing gives expected captured demand — a revenue-proportional quantity that feeds both the composite score and the cannibalization estimate. When a new site is near existing sister stores, the same probabilities quantify sales transfer between nearby stores, so the shortlist can penalize self-competition explicitly.

Automation and Orchestration

Scoring one candidate portfolio by hand is a notebook exercise; scoring every quarter as demographics refresh, weights are retuned and new competitors open is an engineering problem. The scoring engine runs as a stage in the broader refresh DAG documented in orchestrating spatial pipelines with Airflow. Idempotency is keyed on a deterministic hash of the inputs plus the weight-configuration version, so a rerun after a partial failure recomputes only what changed.

python
import hashlib

def score_key(site_id, catchment_version, demo_version, weight_version) -> str:
    raw = f"{site_id}:{catchment_version}:{demo_version}:{weight_version}"
    return hashlib.sha256(raw.encode()).hexdigest()[:16]

def needs_rescore(site, exists_fn) -> bool:
    key = score_key(site.id, site.catchment_version,
                    site.demo_version, site.weight_version)
    return not exists_fn(key)   # skip scores already persisted with this config

Including weight_version in the key matters: when the committee retunes weights, every score becomes stale and the version bump forces a clean recompute rather than serving a ranking built on the old contract. This is the same idempotency discipline applied to contour generation and to idempotent Airflow DAGs for geospatial refresh.

Scaling and Performance

A national screen evaluates tens of thousands of candidate sites, each against hundreds of intersecting block groups. The binding constraint is the spatial join, not the arithmetic: the weighted sum over a normalized matrix is trivially vectorized in NumPy, but the sjoin that assembles reach is quadratic without a spatial index. Reproject once, build the index once, and join in region-partitioned batches rather than nationally in a single pass. The GiST index tuning that accelerates the join in PostGIS applies equally when the assembly runs in-database.

Where the same demographic and competitor layers are reused across many scoring runs, cache the assembled criteria matrix keyed by input version, exactly as the routing layer caches contours. Recomputing normalization and weighting from a cached matrix is milliseconds; re-running the joins is minutes. Partitioning the scored output by region keeps the reporting queries — “rank all candidates in the Southeast” — reading only the relevant row groups.

Validation and QA Gates

No score reaches a committee without passing automated validation, because a plausible-but-wrong ranking is more dangerous than an obvious error. Scoring fails in predictable ways: an un-inverted cost criterion, a weight vector that no longer sums to one after an edit, a NaN from a site with no intersecting demographics, or a CRS slip that inflates reachable area. Each has a deterministic check, and the pipeline refuses to emit a ranking that fails any of them.

The minimum validation gate set:

  • Weight integrity — the active weight vector sums to 1.0 within tolerance and names only known criteria.
  • CRS assertion — every layer carries the expected projected EPSG code before any area or distance is computed.
  • Range validity — every normalized criterion lies in [0,1][0,1]; a value outside it signals a normalization bug.
  • Completeness — no site carries a NaN criterion; sites with missing inputs are quarantined, not silently zero-filled.
  • Monotonicity — holding other criteria fixed, increasing a benefit criterion never lowers a site’s score.
  • Rank stability — a small perturbation of the weights does not reorder the top of the shortlist, tested by sensitivity analysis for site ranking weights.
python
def validate_scores(matrix, weights, scores):
    assert abs(sum(weights.values()) - 1.0) < 1e-9, "weights do not sum to 1"
    x = matrix[list(weights)].to_numpy(dtype=float)
    assert not np.isnan(x).any(), "NaN criterion; quarantine the site"
    assert (x >= 0).all() and (x <= 1).all(), "criterion outside [0,1]"
    assert scores.between(0, 1).all(), "composite score outside [0,1]"
    return True

Cross-checking a ranking against observed store performance follows the same discipline used in validating spatial join accuracy with ground truth: a model that cannot reproduce the rank order of stores you already operate should not be trusted to rank the ones you do not.

Aligning Rankings with Capital Deployment

A ranked shortlist is an input to an investment committee, not a verdict. The ranking and shortlisting stage resolves ties, removes near-duplicate sites whose catchments overlap, and applies portfolio constraints so that no two shortlisted candidates cannibalize each other. Only then does the output become a deliverable, and delivery is its own discipline: reproducible PDF committee packs, XLSX for finance, and GeoJSON or GeoPackage for the BI layer, all handled in automated reporting and GIS export.

The score is defensible only if every number on the committee pack traces back through a versioned, validated pipeline to a raw geometry. That traceability — weight version, input version, validation record — is what turns a spatial model into a capital decision an analyst can stand behind when the site opens and the sales come in.

Frequently Asked Questions

Should I use an additive weighted score or a gravity model?

Use both, for different questions. An additive weighted score is transparent and easy to defend criterion-by-criterion, which committees value. A gravity model captures competition and cannibalization endogenously, which an additive penalty approximates only crudely. In practice the composite score carries the gravity-model’s expected-captured-demand as one of its weighted criteria, so you get the interpretability of the additive form and the competitive realism of the Huff model.

How do I choose the criterion weights?

Start from expert judgment encoded explicitly, then validate against stores you already operate: if the weights cannot reproduce the rank order of known winners and losers, retune them. Regression of observed performance on normalized criteria gives data-driven weights; the analytic hierarchy process gives structured expert weights. Whichever you use, version the weight vector and run a sensitivity analysis so you know whether the ranking is robust or balanced on a knife-edge.

Why must normalization happen before weighting?

Because raw criteria live on wildly different scales — population in the hundreds of thousands, rent in the tens of dollars, distance in kilometres. A weighted sum over un-normalized values is dominated by whichever criterion has the largest numeric range, regardless of its assigned weight. Normalizing every criterion to a common [0,1][0,1] scale first is what makes the weights mean what they say.

What is the most common scoring bug?

A cost criterion that was never inverted. Competitor proximity, rent and travel time are all “smaller is better,” but they enter the matrix as raw magnitudes where larger is numerically bigger. If you forget to invert them, the score rewards expensive, saturated, hard-to-reach sites — and the ranking looks superficially reasonable, which is why the range and monotonicity gates exist to catch it.

How do I keep a ranking auditable months later?

Persist the full scored matrix — one row per site-criterion pair — tagged with the weight-configuration version and the input data versions, in partitioned storage. When someone asks why one site outranked another, the answer becomes a query against a versioned artifact rather than a rerun of a changed pipeline. Traceability from committee pack back to raw geometry is the deliverable, not just the final number.

Conclusion

Suitability scoring is where spatial precision becomes a business decision, and it earns that role only through discipline. By decoupling assembly, normalization, weighting and ranking into independently testable stages, asserting a projected CRS before every area calculation, versioning the weight contract, and gating every output on weight integrity and monotonicity, location intelligence teams produce rankings that are reproducible and auditable. Blend the transparency of an additive weighted score with the competitive realism of a gravity model, validate against the stores you already run, and every rank on the committee pack traces cleanly back to the geometry that produced it.

← Back to Location Intelligence & Retail Site Selection Automation