Ranking & Shortlisting Candidate Sites

A composite suitability score is only useful once it becomes an ordered, deduplicated, constraint-satisfying shortlist that a real-estate committee can act on — and getting from one to the other is where most site-selection pipelines quietly go wrong.

Scoring answers “how good is each site in isolation.” Ranking answers a harder question: “which handful of sites should we actually pursue, given that some are near-duplicates, some cannibalize each other, and the capital budget only funds a few.” This stage of the Suitability Scoring & Site Ranking Models workflow consumes the scored candidate layer produced by building weighted site suitability scores and emits a ranked shortlist GeoDataFrame that feeds directly into stakeholder reporting. A ranking that ignores portfolio interactions will happily recommend three stores in the same trade area, each stealing the others’ sales.

Concept: from cardinal scores to an ordinal shortlist

A suitability score SiS_i is a cardinal quantity — a weighted blend of reachable population, demographic fit, competitive saturation, and site economics. Ranking collapses those cardinal values into an ordinal sequence, and shortlisting truncates that sequence subject to constraints. The subtlety is that raw scores are rarely comparable across regions: a score of 0.72 in a dense metro and 0.72 in a rural market do not represent the same opportunity, because the underlying distributions differ. Normalizing to a rank restores comparability.

The normalized rank of candidate ii maps its ordinal position ρi\rho_i (where ρi=1\rho_i = 1 is the best-scoring site of NN) onto the unit interval:

r~i=1ρi1N1,r~i[0,1]\tilde{r}_i = 1 - \frac{\rho_i - 1}{N - 1}, \qquad \tilde{r}_i \in [0, 1]

so the top site scores 1.0, the worst 0.0, and ties share an averaged rank. Cardinal scores decide ρi\rho_i; the normalized rank is what you report and threshold on, because it is robust to the scale and skew of the raw score distribution.

Ranking on suitability alone, though, treats every candidate as if it captures its trade area in a vacuum. In reality, expected demand depends on the competitive gravity field — the probability a customer patronizes the candidate rather than an incumbent. Folding a Huff-model capture probability into the ranking input turns a static score into an expected-demand estimate. A Huff-adjusted rank score discounts the suitability score by the share of demand the site is actually expected to win:

Si=Si(1+γPˉi),Pˉi=jwjPijjwjS_i^{\ast} = S_i \cdot \bigl(1 + \gamma \, \bar{P}_i\bigr), \qquad \bar{P}_i = \frac{\sum_j w_j P_{ij}}{\sum_j w_j}

where Pˉi\bar{P}_i is the demand-weighted mean capture probability across origin zones jj, wjw_j the demand weight of each zone, and γ\gamma a tunable emphasis on captured demand versus raw suitability. Set γ=0\gamma = 0 and you rank on suitability only; raise it and expected demand dominates.

From scored candidates to a ranked shortlist Scored candidate sites pass through a minimum-score threshold, a spatial near-duplicate dedup, a Huff-adjusted rank, and a portfolio constraint that removes cannibalizing pairs, leaving a small ranked shortlist. Candidate counts shrink at each stage. From scored candidates to a ranked shortlist Scored candidate sites composite S_i 420 Threshold min score gate drop tail 190 Dedup near-duplicate keep best of cluster 168 Rank Huff-adjusted tie-break 168 ordered Shortlist portfolio filter no cannibalizing pair 12 Counts illustrate attrition: a 420-site candidate pool narrows to a 12-site ranked shortlist under score, dedup, and portfolio constraints.

Configuration parameters

Every knob that changes which sites survive to the shortlist is a documented parameter, not a magic number buried in code. The table below lists the controls that materially reshape the output, with types, ranges, and defaults that suit a typical multi-market expansion cycle.

Parameter Type Valid range Default Effect on shortlist
min_score float 0.01.0 0.55 Drops candidates below a suitability floor before ranking
top_n int 1N 15 Maximum sites returned after all constraints
dedup_radius_m float 505000 800 Sites within this distance are treated as near-duplicates
max_cannibalization float 0.01.0 0.20 Rejects a pair whose expected sales-transfer exceeds this share
min_pair_distance_m float 50020000 4000 Hard floor on spacing between two shortlisted sites
gamma (Huff emphasis) float 0.02.0 0.5 Weight on captured-demand probability in the rank score
budget_cap float > 0 per cycle Total capex ceiling the shortlist must respect
tie_breaker string population, capture, capex capture Ordering rule when composite scores are equal

Treat any value outside these ranges as a deliberate, reviewed exception. In particular, min_pair_distance_m and max_cannibalization encode the same portfolio intent two ways — a distance floor is a cheap proxy, while the cannibalization share is the accurate but expensive check — and production runs usually apply both.

Step-by-step Python: a ranked, deduplicated shortlist

The pipeline below takes a scored candidate GeoDataFrame and returns a ranked shortlist. It asserts a CRS before every geometric operation, reprojects to the equal-area EPSG:5070 for all distance math, deduplicates near-identical candidates, computes a Huff-adjusted rank, and then runs a greedy portfolio selection that refuses any pair violating the spacing or cannibalization constraints. The suitability scores and Huff capture probabilities are assumed already attached as columns by the upstream scoring and gravity stages.

python
import geopandas as gpd
import numpy as np
import pandas as pd
from pyproj import CRS

STORAGE_CRS = CRS.from_epsg(4326)     # WGS 84 interchange
EQUAL_AREA_CRS = CRS.from_epsg(5070)  # NAD83 / Conus Albers, for distances (m)


def _to_equal_area(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    # Distance and area math is only valid in a projected CRS.
    assert gdf.crs is not None, "candidates have no CRS; refusing to proceed"
    return gdf.to_crs(EQUAL_AREA_CRS)


def dedup_near_duplicates(gdf: gpd.GeoDataFrame,
                          radius_m: float,
                          score_col: str = "score") -> gpd.GeoDataFrame:
    """Collapse candidates within radius_m into one, keeping the top scorer."""
    proj = _to_equal_area(gdf).reset_index(drop=True)
    # Self-join on a buffer to find spatial clusters of duplicates.
    buffered = proj.copy()
    buffered["geometry"] = proj.geometry.buffer(radius_m / 2.0)
    pairs = gpd.sjoin(proj[["geometry", score_col]],
                      buffered[["geometry"]],
                      predicate="intersects", how="inner")
    # Union-find style grouping via connected components on the pair index.
    keep = set(proj.index)
    for left, right in zip(pairs.index, pairs["index_right"]):
        if left == right or left not in keep or right not in keep:
            continue
        # Keep whichever of the colliding pair has the higher score.
        loser = right if proj.at[left, score_col] >= proj.at[right, score_col] else left
        keep.discard(loser)
    return gdf.loc[sorted(keep)].copy()


def rank_candidates(gdf: gpd.GeoDataFrame,
                    score_col: str = "score",
                    capture_col: str = "mean_capture",
                    gamma: float = 0.5,
                    tie_breaker: str = "capture") -> gpd.GeoDataFrame:
    """Attach a Huff-adjusted rank score, a normalized rank, and ordinal rank."""
    out = gdf.copy()
    # Missing capture probability defaults to 0 -> no demand credit, never NaN.
    capture = out[capture_col].fillna(0.0)
    out["rank_score"] = out[score_col] * (1.0 + gamma * capture)
    # Sort by rank score, breaking ties on the configured secondary key.
    out = out.sort_values(["rank_score", tie_breaker],
                          ascending=[False, False], kind="mergesort")
    n = len(out)
    out["rank"] = np.arange(1, n + 1)
    out["norm_rank"] = 1.0 - (out["rank"] - 1) / max(n - 1, 1)
    return out.reset_index(drop=True)


def select_portfolio(ranked: gpd.GeoDataFrame,
                     top_n: int = 15,
                     min_pair_distance_m: float = 4000.0,
                     transfer_fn=None,
                     max_cannibalization: float = 0.20) -> gpd.GeoDataFrame:
    """Greedily accept sites in rank order, rejecting cannibalizing pairs."""
    proj = _to_equal_area(ranked)
    chosen: list[int] = []
    for idx in proj.index:                       # index is rank order (0 = best)
        cand = proj.geometry.iloc[idx]
        ok = True
        for c in chosen:
            dist = cand.distance(proj.geometry.iloc[c])
            if dist < min_pair_distance_m:
                ok = False
                break
            if transfer_fn is not None:
                transfer = transfer_fn(proj.iloc[idx], proj.iloc[c], dist)
                if transfer > max_cannibalization:
                    ok = False
                    break
        if ok:
            chosen.append(idx)
        if len(chosen) >= top_n:
            break
    return ranked.iloc[chosen].reset_index(drop=True)


def build_shortlist(candidates: gpd.GeoDataFrame, cfg: dict) -> gpd.GeoDataFrame:
    assert candidates.crs is not None, "input candidates missing CRS"
    survivors = candidates[candidates["score"] >= cfg["min_score"]].copy()
    survivors = dedup_near_duplicates(survivors, cfg["dedup_radius_m"])
    ranked = rank_candidates(survivors, gamma=cfg["gamma"],
                             tie_breaker=cfg["tie_breaker"])
    shortlist = select_portfolio(
        ranked,
        top_n=cfg["top_n"],
        min_pair_distance_m=cfg["min_pair_distance_m"],
        transfer_fn=cfg.get("transfer_fn"),
        max_cannibalization=cfg["max_cannibalization"],
    )
    assert shortlist.crs == candidates.crs, "CRS drifted through the pipeline"
    return shortlist

The transfer_fn hook is where the sales-transfer model plugs in: given two candidate rows and their separation, it returns the expected fraction of one site’s demand captured by the other. Passing None falls back to the cheaper distance-only spacing rule. Note that the greedy accept-in-rank-order strategy is not globally optimal — it is a fast, defensible heuristic; for tightly capital-constrained cycles you can swap select_portfolio for an integer-program solver that maximizes summed rank_score subject to the same pairwise and budget constraints.

Edge cases and failure modes

Ranking is deceptively fragile because every failure produces a plausible-looking ordered list rather than an error.

  • Ties in composite score. Floating-point scores rarely tie exactly, but binned or rounded scores do. rank_candidates uses a stable mergesort and an explicit tie_breaker column so ties resolve deterministically instead of depending on input row order. Never rely on the default sort for reproducibility.
  • Near-duplicate candidates. Two parcels 200 m apart on the same arterial are effectively one opportunity; ranking both wastes a shortlist slot and misleads the committee. dedup_radius_m collapses them, keeping the higher scorer. Set the radius from real parcel geometry, not intuition.
  • Missing criteria. A candidate lacking a demographic or capture value must not silently rank last or throw. Impute a neutral value or exclude the row explicitly, and record which criteria were missing — the same discipline applied when imputing missing census block group data. Here, absent capture probability defaults to zero demand credit rather than NaN.
  • Over-constrained portfolio. If min_pair_distance_m is large and candidates cluster, select_portfolio may return fewer than top_n sites. That is correct behavior, not a bug — surfacing “only 6 sites satisfy the spacing rule” is more honest than padding the list with cannibalizing pairs.
  • CRS drift. Every distance operation reprojects to EPSG:5070; the final assertion confirms the returned frame still carries the original CRS. A silent reprojection upstream is the classic cause of a shortlist whose spacing rule was enforced in degrees.

Performance and scaling

For a few hundred candidates the greedy selection’s pairwise inner loop is trivial. It is O(km)O(k \cdot m) where kk is the accepted count and mm the survivor count, so it stays cheap as long as top_n is small. The dedup self-join is the heavier operation; on tens of thousands of candidates, build a spatial index (GeoPandas does this automatically for sjoin) and, if the candidate layer lives in a database, push the near-duplicate detection down to PostGIS with a ST_DWithin self-join rather than materializing every pair in Python — the indexing conventions are covered in setting up PostGIS for retail analytics.

The Huff capture probabilities that feed mean_capture are the expensive upstream input; compute them once per candidate against the demographic layer and cache them, rather than recomputing inside the ranking loop. Ranking itself should never call the gravity model.

Validation and QA gates

No shortlist reaches a stakeholder report until it clears these hard gates:

  1. Monotonic rank. rank is a strict 1..N sequence with no gaps, and norm_rank is monotonically decreasing. A break signals a sort that silently dropped or duplicated rows.
  2. Spacing invariant. Every pair in the returned shortlist is at least min_pair_distance_m apart, measured in EPSG:5070. Re-verify after selection rather than trusting the loop.
  3. Cannibalization ceiling. For every shortlisted pair, expected sales transfer is below max_cannibalization. This is the gate that catches a mis-specified transfer_fn.
  4. Budget feasibility. The summed capex of the shortlist does not exceed budget_cap.
  5. CRS integrity. The output CRS equals the input CRS, and no geometry is empty or invalid.
python
def validate_shortlist(shortlist: gpd.GeoDataFrame, cfg: dict) -> bool:
    proj = shortlist.to_crs(EQUAL_AREA_CRS)
    ranks = shortlist["rank"].to_numpy()
    assert np.array_equal(ranks, np.arange(1, len(ranks) + 1)), "rank gaps"
    for i in range(len(proj)):
        for j in range(i + 1, len(proj)):
            d = proj.geometry.iloc[i].distance(proj.geometry.iloc[j])
            assert d >= cfg["min_pair_distance_m"], f"pair too close: {d:.0f} m"
    assert shortlist["capex"].sum() <= cfg["budget_cap"], "budget exceeded"
    assert shortlist.crs is not None, "shortlist lost its CRS"
    return True

The robustness of the ranking to the weights that produced SiS_i is its own validation concern: a shortlist that reshuffles completely when a single weight moves by a few points is not defensible. Quantifying that fragility is the subject of sensitivity analysis for site ranking weights, which should run before any shortlist is presented as final.

Integration notes

The ranked shortlist is the handoff to the delivery layer. Persist it with its rank, norm_rank, rank_score, and the criteria that drove each site so the reasoning is auditable, then feed it to automated reporting and GIS export, which renders committee-ready PDFs, XLSX workbooks, and BI-tool layers from exactly this frame. Keep the shortlist geometry in EPSG:4326 for interchange and let the export stage reproject as needed.

Upstream, the quality of the ranking is bounded by the quality of its inputs: the reachable population comes from drive-time contours built in Isochrone Generation & Network Analysis, and the demographic fit comes from Demographic Data Integration & Spatial Joins. A defensible shortlist is only as trustworthy as those layers and the weighting scheme that combined them.

Frequently Asked Questions

Should I rank on raw score or normalized rank?

Rank on the cardinal score to establish ordinal position, then report and threshold on the normalized rank. Raw scores are not comparable across regions with different score distributions, so a fixed cutoff on the raw score admits too many sites from one market and too few from another. The normalized rank is distribution-robust, which is what a cross-market committee needs.

How do I stop the shortlist from recommending sites that cannibalize each other?

Enforce a portfolio constraint during selection, not after ranking. The greedy select_portfolio accepts sites in rank order and rejects any candidate whose expected sales transfer with an already-chosen site exceeds max_cannibalization, or that sits closer than min_pair_distance_m. Ranking each site in isolation and taking the top-N ignores these interactions entirely.

What is the role of the Huff probability in ranking?

The Huff model estimates the probability that a customer patronizes the candidate rather than a competitor, given store attractiveness and distance decay. Folding the demand-weighted mean capture probability into the rank score turns a static suitability number into an expected-demand estimate, so a well-located but heavily contested site is ranked below an equally suitable site that actually wins its trade area.

What happens when two candidates tie on score?

Exact ties are resolved by the configured tie_breaker — expected capture, reachable population, or capex — applied through a stable sort so the result is reproducible run to run. Never leave tie resolution to the input row order; that makes the shortlist non-deterministic and impossible to audit.

← Back to Suitability Scoring & Site Ranking Models