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 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 maps its ordinal position (where is the best-scoring site of ) onto the unit interval:
so the top site scores 1.0, the worst 0.0, and ties share an averaged rank. Cardinal scores decide ; 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:
where is the demand-weighted mean capture probability across origin zones , the demand weight of each zone, and a tunable emphasis on captured demand versus raw suitability. Set and you rank on suitability only; raise it and expected demand dominates.
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.0–1.0 |
0.55 |
Drops candidates below a suitability floor before ranking |
top_n |
int | 1–N |
15 |
Maximum sites returned after all constraints |
dedup_radius_m |
float | 50–5000 |
800 |
Sites within this distance are treated as near-duplicates |
max_cannibalization |
float | 0.0–1.0 |
0.20 |
Rejects a pair whose expected sales-transfer exceeds this share |
min_pair_distance_m |
float | 500–20000 |
4000 |
Hard floor on spacing between two shortlisted sites |
gamma (Huff emphasis) |
float | 0.0–2.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.
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_candidatesuses a stablemergesortand an explicittie_breakercolumn 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_mcollapses 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_mis large and candidates cluster,select_portfoliomay return fewer thantop_nsites. 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 where is the accepted count and 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:
- Monotonic rank.
rankis a strict1..Nsequence with no gaps, andnorm_rankis monotonically decreasing. A break signals a sort that silently dropped or duplicated rows. - Spacing invariant. Every pair in the returned shortlist is at least
min_pair_distance_mapart, measured in EPSG:5070. Re-verify after selection rather than trusting the loop. - Cannibalization ceiling. For every shortlisted pair, expected sales transfer is below
max_cannibalization. This is the gate that catches a mis-specifiedtransfer_fn. - Budget feasibility. The summed capex of the shortlist does not exceed
budget_cap. - CRS integrity. The output CRS equals the input CRS, and no geometry is empty or invalid.
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 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.
Related
- Building Weighted Site Suitability Scores — produces the composite score this stage ranks.
- Competitor Mapping & Cannibalization Analysis — supplies the sales-transfer model behind the portfolio constraint.
- Automated Reporting & GIS Export — renders the ranked shortlist into committee deliverables.
- Applying Huff Model Probabilities to Store Catchments — computes the capture probabilities used in the rank score.
- Sensitivity Analysis for Site Ranking Weights — tests how robust the ranking is to weight changes.