Gravity and Huff Models Explained: Predicting Where People Go
Problem statement
A catchment says who is near a store. It does not say where people actually shop, because people do not always use the nearest store: a larger supermarket a few minutes further away draws custom past a smaller one next door. Gravity models describe that trade-off, and the Huff model is the version most used for retail: each household spreads its probability of using each store according to the store's attractiveness and the time it takes to get there.
The model has two parameters that people set by habit — a distance-decay exponent and an attractiveness exponent — and they behave very differently. Measured with 39 supermarkets in and around Chittenden County, Vermont, attractiveness taken as building footprint, and drive times from 2,241 populated census blocks:
- The distance exponent barely changed market shares. The largest store's share of the county's shoppers stayed between 6.7% and 9.3% for every exponent from 0 to 5.
- It completely changed the map of which store each household most likely uses. The largest store was the likeliest store for 100% of residents with no distance decay and for 12.7% with an exponent of 2.
- The attractiveness exponent moved shares directly. Squaring floor area raised the largest store's share from 8.5% to 13.2%.
Quick answer
The Huff probability that households in area i use store j:
P(i, j) = A_j^α × T_ij^−β / Σ_k A_k^α × T_ik^−β
A is attractiveness (floor area, turnover, number of checkouts), T is travel time or distance, α weights attractiveness and β sets how fast the pull of a store fades with distance. A store's expected share of customers is the population-weighted sum of its probabilities.
import numpy as np
U = A[None, :] ** alpha * np.maximum(T, 0.5) ** -beta # utility, homes x stores (minutes)
P = U / U.sum(axis=1, keepdims=True) # each row sums to 1
share = (P * population[:, None]).sum(axis=0) / population.sum()
Calibrate β and α against observed customer data where you have it. Where you do not, report the parameters, and show how the results you care about change across a plausible range.
Step-by-step solution
1. Choose an attractiveness measure
Attractiveness stands in for everything that makes a store worth the trip. Floor area is the usual open proxy; here it came from OpenStreetMap building footprints, available for 24 of 39 supermarkets, with a median of 1,870 m² and a maximum of 6,499 m². Stores mapped only as points need a value, and how you fill the gap is itself a modelling choice (step 6).
The units do not matter. Footprints in square feet gave probabilities identical to those in square metres, to 16 decimal places, because a constant factor cancels in the ratio.
2. Build a travel-time matrix
Rows are origins — homes or census blocks — and columns are stores. Network drive times are better than straight lines, for the same reasons as in any accessibility analysis. Include stores beyond the study area, since residents near the boundary shop across it.
3. Floor the travel times
Seven origin–store pairs had a travel time of exactly zero, because a block and a store snapped to the same network node. Raised to a negative power, zero becomes infinity, and those seven rows came out as NaN. A floor — half a minute here — keeps the model defined without changing anything else measurably.
4. Understand what β controls
With β = 0, distance plays no part: every home splits its custom in proportion to floor area, so every store's share equals its share of floor area — 6.7% for the largest — and the largest store is every household's single likeliest store.
As β grows, nearby stores take over each home's probabilities:
β largest store's share top 3 stores' share homes whose likeliest store is the largest
0.0 6.7% 20.2% 100.0%
0.5 7.8% 22.4% 27.4%
1.0 8.4% 22.8% 14.4%
2.0 8.5% 21.7% 12.7%
5.0 9.3% 23.2% 11.3%
The shares hardly move because stores are spread around the county: a large exponent concentrates each home on its local store, and the county's homes are split among local stores roughly as they were among large ones. The household map is what β really sets.
5. Understand what α controls
α changes how much size matters relative to distance, and the effect on shares is direct:
α largest store's share top 3 stores' share
0.5 6.2% 18.7%
1.0 8.5% 21.7%
2.0 13.2% 32.4%
With β = 2, doubling α concentrated a third of all shopping in three stores. If the question is how much a new large store would take from existing ones, α matters more than β.
6. Decide how to fill missing attractiveness
Filling the 15 footprint-less stores with the median footprint gave the largest store 8.5%. Filling them with the maximum footprint gave it 5.9%; filling them with 1 m² gave it 11.2% and pushed the top three to 29.1%. Missing data in A is not a detail. Prefer a real measure for every store, or at least report the fill rule.
7. Use the right form of distance decay for your units
The power form, T^−β, is scale-free: travel times in minutes or seconds gave identical probabilities. The exponential form, exp(−λT), is not. λ = 0.2 per minute applied to times in hours made the largest store the likeliest store for 59.1% of residents instead of 18.4%. Any additive term, such as (T + 1)^−β, also depends on units.
Code examples
Example 1 — Huff probabilities with the safeguards
import numpy as np
def huff_probabilities(A, T, alpha=1.0, beta=2.0, floor=0.5):
"""Probability of each origin using each store; rows sum to 1. T in any unit, floored."""
A = np.asarray(A, dtype=float)
if np.isnan(A).any():
raise ValueError(f"{int(np.isnan(A).sum())} stores have no attractiveness value")
T = np.maximum(np.asarray(T, dtype=float), floor)
utility = A[None, :] ** alpha * T ** -beta
return utility / utility.sum(axis=1, keepdims=True)
Refusing missing attractiveness forces the fill rule into the open, and the floor removes the zero-time NaN rows. floor must be in the same unit as T.
Example 2 — shares and the likeliest store together
def huff_summary(P, population, attractiveness, label):
"""Expected shares, plus how often the largest store is each area's likeliest."""
share = (P * population[:, None]).sum(axis=0) / population.sum()
largest = int(np.argmax(attractiveness))
top3 = np.sort(share)[-3:].sum()
likeliest_is_largest = population[P.argmax(axis=1) == largest].sum() / population.sum()
print(f"{label:12} largest {share[largest]:6.1%} | top-3 {top3:6.1%} | "
f"likeliest is largest {likeliest_is_largest:6.1%}")
return share
for beta in (0.0, 0.5, 1.0, 2.0, 5.0):
huff_summary(huff_probabilities(A, T, beta=beta), pop, A, f"beta {beta}")
beta 0.0 largest 6.7% | top-3 20.2% | likeliest is largest 100.0%
beta 0.5 largest 7.8% | top-3 22.4% | likeliest is largest 27.4%
beta 1.0 largest 8.4% | top-3 22.8% | likeliest is largest 14.4%
beta 2.0 largest 8.5% | top-3 21.7% | likeliest is largest 12.7%
beta 5.0 largest 9.3% | top-3 23.2% | likeliest is largest 11.3%
Report both columns. A model can have sensible shares and an absurd household map, or the reverse.
Example 3 — calibrating β against observed shares
from scipy.optimize import minimize_scalar
def calibrate_beta(A, T, population, observed_share, alpha=1.0, bounds=(0.0, 5.0)):
"""The beta whose predicted store shares are closest to observed shares (sum of squares)."""
def loss(beta):
P = huff_probabilities(A, T, alpha=alpha, beta=beta)
predicted = (P * population[:, None]).sum(axis=0) / population.sum()
return float(((predicted - observed_share) ** 2).sum())
result = minimize_scalar(loss, bounds=bounds, method="bounded")
return result.x, result.fun
observed_share comes from loyalty-card data, a survey or turnover figures, in the same store order as A. As a test, shares generated by the model itself with β = 2 were fitted back to exactly 2.000 — but the loss was only 4.7 × 10⁻⁴ at β = 1.5 and 3.7 × 10⁻⁴ at β = 2.5. That shallow minimum is what share insensitivity looks like: noise in real share data can move the fitted β a long way, and data on where individual households shop pins it down far better.
Explanation
Why gravity
Newton's law of gravitation made the analogy: attraction grows with mass and falls with distance. Reilly applied it to retail trade between towns in the 1930s, and Huff turned it into probabilities in the 1960s, so that one household can use several stores with different likelihoods. The form survives because it is simple, gives every store a non-zero share, and has two parameters with a clear meaning.
Why shares and household maps respond differently to β
A store's share sums probabilities over every household. With weak decay, each household spreads its custom by store size, so large stores get a bit of everyone. With strong decay, each household concentrates on nearby stores, so every store gets most of its local households. Across a county with stores in every town, those two pictures add up to similar totals. For each household, though, the likeliest store switches from the largest to the nearest. Any analysis at the household or neighbourhood level depends heavily on β.
Why α is the lever for size
α raises attractiveness to a power before comparing stores. At α = 2, a store with twice the floor area has four times the pull, and large stores capture custom well beyond their neighbourhoods. That is why impact studies for a proposed large store are more sensitive to α than to β, and why α is the parameter most worth calibrating.
Why power decay is safer than exponential decay
A power function of time is scale-invariant: multiplying every time by 60 multiplies every utility by the same factor, which cancels. An exponential of time is not; λ has units of inverse time, and applying a per-minute λ to seconds or hours makes decay 60 times stronger or weaker. Exponential decay is perfectly good when λ is stated with its unit and fitted to data; it is dangerous when copied from a paper that used a different unit.
Edge cases or notes
- A store with zero travel time breaks the power form. Floor the times.
- Missing attractiveness must be filled deliberately. Median, maximum and 1 m² fills gave the largest store 8.5%, 5.9% and 11.2%.
- Attractiveness units cancel; decay units only cancel for the power form.
- Stores beyond the boundary take custom. Leaving them out inflates every in-area store's share.
- Probabilities are not visits. Multiply by spending or trip rates to get a volume.
- Uncalibrated parameters are assumptions. β = 2 is a convention, not a fact about any town.
- The model has no capacity. A store predicted to take 30% of a county's shoppers does not get busier; add a constraint if congestion matters.
Internal links
- How to estimate market share with a Huff model in Python — the full workflow
- Fixing a gravity model that sends everyone to the biggest store — when the parameters go wrong
- Accessibility measures explained: nearest, cumulative and gravity — distance decay used for access
- Catchment areas explained: buffers, isochrones and Voronoi compared — the deterministic alternative
- Location analysis explained: catchments, accessibility and site selection — where gravity models fit
- How to measure network distance for many origin–destination pairs — the travel-time matrix
- Location-allocation explained: p-median, coverage and what each optimises — choosing sites once demand is known
- Spatial interpolation explained — another use of inverse-distance weighting
FAQ
What is the Huff model?
A probabilistic retail model in which each household's chance of using a store rises with the store's attractiveness and falls with travel time, normalised so that the probabilities across all stores sum to one.
What does the distance-decay exponent β do?
It sets how quickly a store's pull fades with distance. For Chittenden County supermarkets it changed each household's likeliest store dramatically — the largest store was likeliest for 100% of residents at β = 0 and 12.7% at β = 2 — while store market shares stayed within 6.7–9.3%.
What does the attractiveness exponent α do?
It sets how much size matters. Raising α from 1 to 2 increased the largest store's share from 8.5% to 13.2%.
What value of β should I use?
Calibrate it against observed shopping data. Without data, β around 1–2 for drive times is common in practice, but report the value and test how sensitive your conclusions are to it.
Does it matter whether travel time is in minutes or seconds?
Not for the power form, where the units cancel. For exponential decay it matters a great deal: a per-minute λ applied to hours made the largest store the likeliest for 59.1% of residents.
What should I use for store attractiveness?
Whatever best predicts patronage and exists for every store: floor area, turnover, checkouts or product range. Fill missing values deliberately, because the fill rule changed the largest store's share by nearly half.