Applying Huff Model Probabilities to Store Catchments

This page solves one task: computing the Huff-model probability that a customer at each demographic origin patronizes each candidate store, then rolling those probabilities up into expected captured demand that a ranking model can consume.

The Huff model is the workhorse gravity formulation of retail catchment analysis. It replaces the fiction of hard trade-area boundaries with a continuous probability field: a customer is more likely to shop at a store that is larger, closer, or both, and that pull decays with distance. The output — a probability per origin-store pair — is exactly the capture input that ranking and shortlisting candidate sites folds into its Huff-adjusted rank score.

The model

The probability that a customer at origin ii patronizes store jj is that store’s attractiveness-over-distance pull, divided by the summed pull of every store the customer could choose:

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

Here AjA_j is the attractiveness of store jj (commonly floor area, but any positive utility proxy works), dijd_{ij} is the distance from origin ii to store jj, and β>0\beta > 0 is the distance-decay exponent that controls how sharply pull falls with distance. The denominator sums over the full competitive set kk, which is what makes each PijP_{ij} a proper probability: for any origin ii, the probabilities over all stores sum to exactly 1.

Expected captured demand for store jj is then the probability-weighted sum of demand across origins:

Dj=iPijCiD_j = \sum_{i} P_{ij} \, C_i

where CiC_i is the buying power at origin ii — population, households, or dollar demand. The decay exponent β\beta is the parameter that most changes the result; a grocery trip has a steeper decay (customers will not drive far for milk) than a furniture showroom. Calibrating it belongs with the broader work of calibrating distance-decay functions for trade areas; this page takes β\beta as a given input.

Two modeling choices shape the result as much as β\beta does. The first is the attractiveness proxy AjA_j: floor area is the conventional default because larger stores carry more assortment, but any positive utility measure works — anchor-tenant count, parking capacity, or a blended index. Because attractiveness enters linearly in both numerator and denominator, only the ratio of one store’s attractiveness to its competitors’ matters, so consistent units across the store layer are what count, not absolute magnitude. The second is the competitive set: the denominator must include every store a customer at origin ii would realistically consider. Truncating that set to a fixed radius is a common shortcut, but it biases probabilities upward near the radius edge, so prefer a distance cutoff generous enough that omitted stores contribute negligible pull.

A useful way to read DjD_j is as an expected market share: dividing a store’s captured demand by total demand in the study area gives the share of buying power the gravity field assigns to it. That share, computed for a candidate against the incumbent competitive field, is precisely the quantity that separates a well-located but contested site from one that actually wins its trade area.

The denominator is the whole choice set, not the nearest store A block group sits four, seven and twelve minutes from three stores whose selling areas are 3,200, 5,800 and 4,100 square metres. With beta at 1.8 the utilities are 262, 200 and 46, so the shares are 51, 39 and 9 per cent. Dropping the farthest store from the choice set would inflate the other two by a tenth. Every store in reach changes every other store's share origin zone 1,840 households block group 0421.02 store A · 4 min · 3,200 m² utility 262 store B · 7 min · 5,800 m² utility 200 store C · 12 min · 4,100 m² utility 46 51% 39% 9% Utility here is selling area divided by travel time raised to β = 1.8; the shares are each utility over their sum. Leave store C out of the choice set and A and B rise to 57% and 43% — a tenth of the catchment invented by an incomplete competitor table rather than by anything in the market.

Prerequisites

  • Python packages: geopandas, pandas, numpy, shapely, and pyproj. Install with pip install geopandas pandas numpy shapely pyproj.
  • A store layer as a GeoDataFrame of points with an attractiveness column (floor area in m² is the usual proxy). Include both candidate and incumbent competitor stores — omitting competitors inflates every capture probability.
  • A demographic origin layer of block-group centroids with a demand column (population or households), joined from ACS via how to join ACS 5-year estimates to custom trade area polygons. Use centroids, not polygons, so distance is point-to-point.
  • A calibrated decay exponent β\beta. Typical retail values run 1.5–2.2; start at 2.0 if you have no local calibration.

Configuration and execution parameters

Parameter Value / type Notes
beta float, 1.02.5 Distance-decay exponent; higher = customers travel less far
attractiveness_col string Store utility proxy, e.g. floor_area_m2; must be strictly positive
demand_col string Origin buying power, e.g. population
dist_crs EPSG:5070 Equal-area CRS so distances are in meters, not degrees
min_distance_m float, 50500 Distance floor so an origin on top of a store does not blow up dβd^{-\beta}

The distance floor matters: raw dijβd_{ij}^{-\beta} diverges as d0d \to 0, so an origin centroid coincident with a store produces an infinite pull. Clamping dijd_{ij} to a small positive minimum keeps the ratio finite and well-behaved.

Annotated implementation

The function computes a full origin-by-store probability matrix, then aggregates expected demand per store. All distance math happens in EPSG:5070, and the CRS is asserted before any geometric operation.

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

EQUAL_AREA_CRS = CRS.from_epsg(5070)   # NAD83 / Conus Albers, meters


def huff_probabilities(
    origins: gpd.GeoDataFrame,
    stores: gpd.GeoDataFrame,
    beta: float = 2.0,
    attractiveness_col: str = "floor_area_m2",
    demand_col: str = "population",
    min_distance_m: float = 100.0,
) -> tuple[np.ndarray, pd.DataFrame]:
    """
    Compute Huff patronage probabilities for every origin-store pair.

    Returns:
        P     : (n_origins, n_stores) probability matrix; each row sums to 1.
        demand: per-store expected captured demand, indexed like `stores`.
    """
    # Distances are only meaningful in a projected, equal-area CRS.
    assert origins.crs is not None, "origins missing CRS"
    assert stores.crs is not None, "stores missing CRS"
    o = origins.to_crs(EQUAL_AREA_CRS)
    s = stores.to_crs(EQUAL_AREA_CRS)

    if (s[attractiveness_col] <= 0).any():
        raise ValueError("attractiveness must be strictly positive")

    # Coordinate arrays for a vectorized pairwise distance matrix.
    ox = o.geometry.x.to_numpy()[:, None]   # (n_origins, 1)
    oy = o.geometry.y.to_numpy()[:, None]
    sx = s.geometry.x.to_numpy()[None, :]   # (1, n_stores)
    sy = s.geometry.y.to_numpy()[None, :]

    dist = np.hypot(ox - sx, oy - sy)       # meters, shape (n_origins, n_stores)
    dist = np.maximum(dist, min_distance_m)  # clamp so d^-beta stays finite

    attract = s[attractiveness_col].to_numpy()[None, :]
    pull = attract * np.power(dist, -beta)   # A_j * d_ij^-beta

    # Normalize each origin row so probabilities sum to 1 across all stores.
    row_totals = pull.sum(axis=1, keepdims=True)
    P = pull / row_totals

    # Expected captured demand: P_ij weighted by origin demand, summed over i.
    demand_i = o[demand_col].to_numpy()[:, None]
    captured = (P * demand_i).sum(axis=0)    # shape (n_stores,)
    demand = pd.DataFrame({
        "store_id": stores.index,
        "expected_demand": captured,
    }).set_index("store_id")

    return P, demand

A candidate site’s mean capture probability — the scalar the ranking stage consumes — is the demand-weighted average of its column in P:

python
def mean_capture_for_store(P, origins, store_col_idx, demand_col="population"):
    """Demand-weighted mean patronage probability for one store column."""
    w = origins[demand_col].to_numpy()
    return float(np.average(P[:, store_col_idx], weights=w))
The same three stores under three values of the distance exponent With beta at 1.0 the nearest store takes 43 per cent and the farthest still takes 18. At 1.8 the split is 51, 39 and 9. At 2.6 the nearest store takes 60 per cent and the farthest almost nothing. Beta decides how much geography matters relative to store size, and it must be fitted, not assumed. β decides whether size or proximity wins β = 1.0 · distance barely penalised A · 43% B · 39% C · 18% β = 1.8 · fitted on loyalty trips A · 51% B · 39% C · 9% β = 2.6 · distance dominates A · 60% B · 36% 4% A cannibalization estimate built on β = 1.0 and one built on β = 2.6 disagree about which of your own stores loses the sales — which is the number the affected store manager will contest.

Failure modes and debugging

Symptom Cause Fix
Probabilities per origin do not sum to 1 Silent NaN from a zero or missing attractiveness Enforce strictly positive attractiveness_col; the code raises on non-positive values.
Absurdly high capture for one store Origin centroid coincident with the store, so dβd^{-\beta} \to \infty The min_distance_m clamp bounds it; raise the floor if centroids are coarse.
Distances look tiny or huge Layers left in EPSG:4326, so hypot is measuring degrees Reproject to EPSG:5070 first; the assertions catch a missing CRS, not a wrong one.
Candidate capture near zero Competitor set includes a dominant nearby incumbent Correct behavior — that is the site losing the gravity contest; verify the competitor layer is right.
Expected demand exceeds total population Demand double-counted across overlapping origins Use non-overlapping block-group centroids, each counted once.

Omitting competitor stores is the most consequential error: because the denominator sums over the full choice set, dropping incumbents makes every candidate look like it captures the whole market. The competitor footprint that populates the store layer is the subject of competitor mapping and cannibalization analysis, and the sales a new site pulls from existing ones follows in measuring sales transfer between nearby stores.

Aggregating probabilities into expected demand Four origin zones contribute demand in proportion to their households and their probability of choosing the candidate store: 1,840 households at 51 per cent, 2,310 at 34, 960 at 22 and 3,150 at 8. The captured total is 2,157 households, and the probability-weighted catchment it implies is smaller than the polygon that drew it. The catchment is not a polygon — it is a weighted sum origin zone households P(choose candidate) expected households 0421.02 1,840 0.51 938 0421.03 2,310 0.34 785 0422.01 960 0.22 211 0430.04 3,150 0.08 252 captured 8,260 in reach 2,157 A 15-minute polygon around this site reports 8,260 households. The choice model says 2,157 of them are actually yours — and the far zone, which the polygon counts in full, contributes barely a tenth.

Verification

Confirm the model is well-formed before trusting its output:

  1. Row-stochastic check: every origin row of P sums to 1 within floating-point tolerance.
  2. Bounded probabilities: all entries lie in [0, 1].
  3. Demand conservation: summed expected demand across stores equals summed origin demand.
python
assert np.allclose(P.sum(axis=1), 1.0), "rows must sum to 1"
assert (P >= 0).all() and (P <= 1).all(), "probabilities out of range"
total_in = origins["population"].sum()
total_out = demand["expected_demand"].sum()
assert np.isclose(total_in, total_out), "demand not conserved"

The conservation check is the strongest single test: because the probabilities partition each origin’s demand across the full store set, every unit of buying power must land somewhere. If the totals disagree, an origin has zero total pull (all distances or attractiveness invalid) and its row silently dropped out of the normalization.

Frequently Asked Questions

What should attractiveness be, if not selling area?

Selling area is the standard proxy because it is available for every store including competitors, but it is a proxy for assortment breadth and trip utility rather than a cause of them. Where richer data exists — category count, anchor tenancy, parking, opening hours — a composite attractiveness fitted against observed patronage outperforms area alone, particularly across formats. What matters is consistency: the same attractiveness definition must be used for your stores and your rivals, or the model quietly advantages whichever side has the better data.

Do the probabilities need to sum to one for every origin zone?

Within the modelled choice set, yes — that is what the normalization does, and a zone whose probabilities do not sum to one signals a store missing from the denominator or a distance that failed to compute. Across the whole market, though, they should not be read as “all demand is spent”: a share of trips leaves for online, for a store outside the modelled area, or for no trip at all. Handling that leakage with an explicit outside option keeps the captured-demand figure honest rather than inflating every store’s estimate.

How far out should origin zones be included?

Far enough that the marginal zone contributes almost nothing — typically the point where the probability falls below one or two per cent — and not so far that millions of trivially small contributions dominate the runtime. Truncating too close biases the estimate downward and, worse, biases it unevenly: a site on the edge of the study area loses zones that a central site keeps. Extending the origin set beyond the candidate pool’s own extent and then trimming by probability avoids that edge effect.

Why does the model prefer travel time over straight-line distance?

Because a choice model is a behavioural claim, and shoppers experience minutes rather than metres. Substituting straight-line distance builds the road network’s effect into the fitted exponent, so a parameter calibrated in a grid-plan market transfers badly to one cut by a river or a rail corridor. Feeding the model a routed travel-time matrix keeps the geography where it belongs and leaves the exponent describing shopper behaviour, which is the part you actually want to reuse.

← Back to Ranking & Shortlisting Candidate Sites