How to Estimate Market Share with a Huff Model in Python
On this page
Problem statement
Assigning every household to its nearest store predicts that a small store next to a big one gets nothing, and that a new store captures every household for which it becomes the nearest. Neither is how people shop. The Huff model predicts probabilities instead: each household splits its custom across stores according to their size and travel time, and each store's expected customers are the sum over households.
Measured for 39 supermarkets and the 167,770 residents of Chittenden County with a road route to them, Vermont, with floor area as attractiveness and a distance-decay exponent of 2:
- Nearest-store allocation gave 7 supermarkets no customers at all; the Huff model gave them 8,277 between them.
- The two methods disagreed about 31.6% of the market.
- 75.9% of residents had no supermarket they were more likely than not to use.
- A new median-sized store at the best under-served site took 1,996 expected customers (1.2%), and the most any existing store lost was 202.
Quick answer
import numpy as np
U = floor_m2[None, :] * np.maximum(minutes, 0.5) ** -2.0 # homes x stores
P = U / U.sum(axis=1, keepdims=True) # each row sums to 1
expected = P.T @ population # expected customers per store
share = expected / population.sum()
floor_m2 holds one attractiveness value per store with no gaps, minutes the homes × stores travel times, and population the people in each home unit. The half-minute floor stops a zero travel time from becoming infinite utility.
What you get — from The data used on this page
2 duplicate features dropped
39 supermarkets; 2,239 blocks, 167,770 people (dropped 2 blocks, 553 people)
13,598 people over 8 min from a supermarket; block 500070035013024 brings 2,336 of them within 8 min
Attractiveness, floored times, exponents, expected customers
1. Choose an attractiveness measure
Floor area is the usual proxy when sales are unknown. OpenStreetMap had a building outline for 24 of the 39 supermarkets; the other 15 were points and were given the median footprint, 1,870 m². Say how gaps were filled — the fill rule alone moved the largest store's share between 5.9% and 11.2% (see fixing a gravity model that sends everyone to the biggest store).
2. Build travel times with a floor
Compute drive times from every populated block to every store and floor them at half a minute: seven block–store pairs had a time of exactly zero, because the block and the store snapped to the same road node.
3. Choose the exponents
With α = 1 and β = 2, the model is proportional to size and falls with the square of travel time. Without observed shopping data these are assumptions, so carry a range — β of 1 to 3 below — into every result. With survey or loyalty-card data, calibrate them (see gravity and Huff models explained).
4. Compute probabilities and expected customers
Each row of the probability matrix sums to one, so expected customers across all stores add up to the population: 167,770. Divide by the population for market share.
5. Compare with nearest-store allocation
Nearest-store allocation is the Huff model with an infinite distance exponent, and the difference between them is what the model adds. Seven supermarkets were nobody's nearest store but had 8,277 expected customers between them. One Shaw's of 6,448 m² was nearest for 101 residents and expected 9,940 customers; Shelburne Supermarket's share was 4.1 percentage points lower under Huff than under nearest-store allocation. Summed over all stores, 31.6% of the market moved.
6. Describe trade areas as distributions
Probability maps have no hard edges. The median resident's likeliest supermarket had a probability of 0.30, and 75.9% of residents had no store above 0.5. For the store with the largest expected share, a 6,499 m² Shaw's, blocks where it was the likelier-than-not choice held only 16.3% of its expected customers. Its trade area is better described by travel time: half of its expected customers lived within 5.8 minutes, three-quarters within 10.7 and 90% within 18.5.
7. Simulate a new store
Add a column for the proposed site and recompute. The candidate here was the populated block that put the most residents who are currently more than 8 minutes from a supermarket — 13,598 people in the county — within 8 minutes of a new one: 2,336 of them. A median-sized store there took 1,996 expected customers. In a Huff model the market is closed, so the existing stores lost exactly 1,996 between them, led by a Hannaford (−202, 2% of its customers), Shelburne Supermarket (−185, 7%) and a Shaw's (−152, 2%). Nearest-store allocation would have given the new store 3,118 customers taken from just 3 stores.
8. Report a range, not a point
The new store's expected customers were 2,036 at β = 1, about 2,000 at β = 2 and 2,503 at β = 3, and at β = 3 Shelburne Supermarket's loss rose to 12% of its customers — and the small Shaw's, 14% at β = 2, lost 46%. A decision that changes across that range needs data to calibrate β before it is made.
Expected customers and what a new store takes
The data used on this page
Chittenden County, Vermont. Households are the 2020 Census: the TIGER/Line 2020 blocks for Vermont, each populated block as a representative point with its POP20. Stores are OpenStreetMap shop=supermarket within 10 km of the county, with the area of the store's own mapped outline as floor area and NaN where the store is only a point. Drive times come from OpenStreetMap's drive network for the county plus 10.5 km, with OSMnx's imputed free-flow speeds, road-only between nearest nodes; two blocks (553 people) with no road route are dropped. Overpass via the maps.mail.ru mirror while overpass-api.de was refusing connections. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.
import io
import os
import requests
import urllib.request
import zipfile
import geopandas as gpd
import numpy as np
import osmnx as ox
import scipy.sparse as sp
import shapely
from scipy.sparse.csgraph import dijkstra
# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-huff-model-market-share-python-2026-10-10.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.overpass_rate_limit = False
ox.settings.http_user_agent = "spatialworkflow.io guide examples (https://www.spatialworkflow.io/)"
CRS = 32145 # NAD83 / Vermont, metres
if not os.path.exists("tl_2020_50_tabblock20.zip"):
urllib.request.urlretrieve("https://www2.census.gov/geo/tiger/TIGER2020/TABBLOCK20/"
"tl_2020_50_tabblock20.zip", "tl_2020_50_tabblock20.zip")
vt_blocks = gpd.read_file("tl_2020_50_tabblock20.zip").to_crs(CRS)
county = vt_blocks[vt_blocks.COUNTYFP20 == "007"].union_all() # Chittenden
homes = vt_blocks[(vt_blocks.COUNTYFP20 == "007") & (vt_blocks.POP20 > 0)].copy()
homes["geometry"] = homes.geometry.representative_point()
homes = homes.reset_index(drop=True)
reach = gpd.GeoSeries([county.buffer(10_500)], crs=CRS).to_crs(4326).iloc[0]
stores = ox.features_from_polygon(reach, tags={"shop": "supermarket"}).to_crs(CRS)
stores = stores[shapely.distance(stores.geometry.representative_point().values, county) <= 10_000]
stores = stores.sort_index().reset_index()
footprint = stores.geometry.area.where(stores.geom_type.isin(["Polygon", "MultiPolygon"]))
stores["geometry"] = stores.geometry.representative_point()
# one store mapped twice (two outlines, or a point and an outline) is one store: keep the outline
keep = []
for i in footprint.notna().sort_values(ascending=False, kind="stable").index:
if not any(stores.name[i] == stores.name[j] and stores.geometry[i].distance(stores.geometry[j]) < 50 for j in keep):
keep.append(i)
keep = sorted(keep)
print(f"{len(stores) - len(keep)} duplicate features dropped")
stores, footprint = stores.loc[keep].reset_index(drop=True), footprint.loc[keep].reset_index(drop=True)
G = ox.add_edge_travel_times(ox.add_edge_speeds(ox.graph_from_polygon(reach, network_type="drive")))
G = ox.project_graph(G, to_crs=CRS)
index = {n: i for i, n in enumerate(G.nodes)}
secs = {}
for u, v, d in G.edges(data=True): # shortest of any parallel edges
secs[(index[u], index[v])] = min(secs.get((index[u], index[v]), np.inf), d["travel_time"])
rows, cols = zip(*secs)
R = sp.csr_array((list(secs.values()), (rows, cols)), shape=(len(index), len(index))).T.tocsr()
s_idx = [index[n] for n in ox.distance.nearest_nodes(G, stores.geometry.x, stores.geometry.y)]
h_idx = np.array([index[n] for n in ox.distance.nearest_nodes(G, homes.geometry.x, homes.geometry.y)])
T_all = dijkstra(R, directed=True, indices=s_idx)[:, h_idx].T / 60 # home x store, minutes
ok = np.isfinite(T_all).all(axis=1)
minutes, population = T_all[ok], homes.loc[ok, "POP20"].to_numpy(float)
stores["floor_m2"] = footprint # NaN where the store is only a point
print(f"{len(stores)} supermarkets; {ok.sum():,} blocks, {population.sum():,.0f} people "
f"(dropped {(~ok).sum()} blocks, {homes.POP20[~ok].sum():,} people)")
homes = homes[ok].reset_index(drop=True)
# the candidate site: the populated block that brings the most people now over 8 minutes from a
# supermarket to within 8 minutes of a new one
C = dijkstra(R, directed=True, indices=h_idx[ok])[:, h_idx[ok]].T / 60 # home x candidate block
far = minutes.min(axis=1) > 8
gain = (population[far, None] * (C[far] <= 8)).sum(axis=0)
site = int(np.argmax(gain))
minutes_to_site = C[:, site]
print(f"{population[far].sum():,.0f} people over 8 min from a supermarket; block {homes.GEOID20[site]} "
f"brings {gain[site]:,.0f} of them within 8 min")
2 duplicate features dropped
39 supermarkets; 2,239 blocks, 167,770 people (dropped 2 blocks, 553 people)
13,598 people over 8 min from a supermarket; block 500070035013024 brings 2,336 of them within 8 min
Example 1 — expected customers per store
import numpy as np
import pandas as pd
def huff_probabilities(attractiveness, minutes, beta=2.0, alpha=1.0, floor=0.5):
utility = attractiveness[None, :] ** alpha * np.maximum(minutes, floor) ** -beta
return utility / utility.sum(axis=1, keepdims=True)
def store_table(stores, minutes, population, beta=2.0):
A = stores.floor_m2.fillna(stores.floor_m2.median()).to_numpy()
P = huff_probabilities(A, minutes, beta)
nearest = np.bincount(np.maximum(minutes, 0.5).argmin(axis=1), weights=population, minlength=len(stores))
table = pd.DataFrame({"store": stores.name.to_numpy(), "floor_m2": A, "huff": P.T @ population, "nearest": nearest})
table["huff_share"] = table.huff / population.sum()
table["nearest_share"] = table.nearest / population.sum()
return table.sort_values("huff", ascending=False), P
table, P = store_table(stores, minutes, population)
print(table.head(5).to_string(index=False, formatters={
"floor_m2": "{:,.0f}".format, "huff": "{:,.0f}".format, "nearest": "{:,.0f}".format,
"huff_share": "{:.1%}".format, "nearest_share": "{:.1%}".format}))
print(f"stores that are nobody's nearest: {(table.nearest == 0).sum()}, "
f"with {table.huff[table.nearest == 0].sum():,.0f} expected customers")
store floor_m2 huff nearest huff_share nearest_share
Shaw's 6,499 14,326 15,654 8.5% 9.3%
Hannaford 5,775 11,105 6,259 6.6% 3.7%
Hannaford 4,619 11,032 10,246 6.6% 6.1%
Hannaford 5,224 10,180 6,137 6.1% 3.7%
Shaw's 6,448 9,940 101 5.9% 0.1%
stores that are nobody's nearest: 7, with 8,277 expected customers
stores has a floor_m2 column with gaps where no footprint exists, and minutes has one column per store in the same order.
Example 2 — a trade area in travel time
def trade_area(P, minutes, population, store):
customers = P[:, store] * population
order = np.argsort(minutes[:, store])
cum = np.cumsum(customers[order]) / customers.sum()
for share in (0.5, 0.75, 0.9):
print(f"{share:.0%} of expected customers within {minutes[order, store][np.searchsorted(cum, share)]:.1f} min")
majority = P[:, store] >= 0.5
print(f"blocks where it is the likelier-than-not choice: {majority.sum()}, "
f"holding {customers[majority].sum() / customers.sum():.1%} of its expected customers")
largest = int(np.argmax(P.T @ population))
trade_area(P, minutes, population, largest)
50% of expected customers within 5.8 min
75% of expected customers within 10.7 min
90% of expected customers within 18.5 min
blocks where it is the likelier-than-not choice: 41, holding 16.3% of its expected customers
Example 3 — what a new store takes from the others
def new_store_impact(A, minutes, population, new_minutes, new_floor_m2, names, beta=2.0):
before = huff_probabilities(A, minutes, beta).T @ population
after = huff_probabilities(np.append(A, new_floor_m2), np.column_stack([minutes, new_minutes]), beta).T @ population
loss = before - after[:-1]
print(f"new store: {after[-1]:,.0f} customers ({after[-1] / population.sum():.1%}); taken from others: {loss.sum():,.0f}")
for i in np.argsort(-loss)[:3]:
print(f" {names[i]}: -{loss[i]:,.0f} ({loss[i] / before[i]:.0%} of its customers)")
return loss / before
A = stores.floor_m2.fillna(stores.floor_m2.median()).to_numpy()
lost = new_store_impact(A, minutes, population, minutes_to_site, stores.floor_m2.median(), stores.name.to_numpy())
print(f"stores losing more than 10% of their customers: {(lost > 0.10).sum()}")
new store: 1,997 customers (1.2%); taken from others: 1,997
Hannaford: -202 (2% of its customers)
Shelburne Supermarket: -185 (7% of its customers)
Shaw's: -152 (2% of its customers)
stores losing more than 10% of their customers: 1
minutes_to_site holds the drive time from every block to the candidate site. The one store losing more than 10% was a small Shaw's with no mapped footprint, which lost 13.9% of its expected customers.
Example 4 — the comparisons quoted above
nearest_share = table.set_index(table.index).nearest_share
moved = (table.huff_share - table.nearest_share).abs().sum() / 2
print(f"market moved between nearest-store and Huff allocation: {moved:.1%}")
likeliest = P.max(axis=1)
order = np.argsort(likeliest)
median = likeliest[order][np.searchsorted(np.cumsum(population[order]) / population.sum(), .5)]
print(f"median resident's likeliest store: probability {median:.2f}; "
f"residents with no store above 0.5: {population[likeliest < 0.5].sum() / population.sum():.1%}")
diff = (table.huff_share - table.nearest_share)
print(f"largest drop from nearest to Huff: {table.store[diff.idxmin()]}, {100 * diff.min():.1f} points")
# the new store under nearest-store allocation, and across a range of beta
before = np.bincount(np.maximum(minutes, 0.5).argmin(axis=1), minlength=len(stores))
with_site = np.column_stack([minutes, minutes_to_site])
winner = np.maximum(with_site, 0.5).argmin(axis=1)
taken = population[winner == len(stores)]
losers = np.unique(np.maximum(minutes, 0.5).argmin(axis=1)[winner == len(stores)])
print(f"nearest-store allocation: new store {taken.sum():,.0f} customers, from {len(losers)} stores")
names = stores.name.to_numpy()
for beta in (1.0, 2.0, 3.0):
b = huff_probabilities(A, minutes, beta).T @ population
a = huff_probabilities(np.append(A, stores.floor_m2.median()), with_site, beta).T @ population
loss = (b - a[:-1]) / b
worst, shelburne = int(np.argmax(loss)), int(np.flatnonzero(names == "Shelburne Supermarket")[0])
print(f"beta {beta}: new store {a[-1]:,.0f}; Shelburne Supermarket loses {loss[shelburne]:.1%}; "
f"biggest proportional loss {names[worst]} {loss[worst]:.1%}")
market moved between nearest-store and Huff allocation: 31.6%
median resident's likeliest store: probability 0.30; residents with no store above 0.5: 75.9%
largest drop from nearest to Huff: Shelburne Supermarket, -4.1 points
nearest-store allocation: new store 3,118 customers, from 3 stores
beta 1.0: new store 2,036; Shelburne Supermarket loses 2.2%; biggest proportional loss Shelburne Supermarket 2.2%
beta 2.0: new store 1,997; Shelburne Supermarket loses 7.0%; biggest proportional loss Shaw's 13.9%
beta 3.0: new store 2,503; Shelburne Supermarket loses 11.8%; biggest proportional loss Shaw's 46.0%
Probabilities instead of assignment
Why probabilities beat assignment for shares
Nearest-store allocation gives each household's whole custom to one store, so a store's total depends entirely on where the boundaries between stores fall. Small shifts in travel time move whole blocks. Probabilities change smoothly, and they let a large store draw custom from well beyond the area where it is nearest — which is what floor area is standing in for.
Why the market is closed
The probabilities for each household sum to one before and after a new store opens, so the new store's customers are exactly the existing stores' losses. The model cannot represent people shopping more often, or shoppers arriving from outside the study area. If the new store is expected to grow the market, that has to be modelled separately.
Why a new store's impact spreads thinly
Every store keeps a non-zero probability for every household, so a new store takes a little from many competitors instead of everything from one. At β = 2 the largest single loss was 199 customers. Nearest-store allocation predicted more customers for the new store, 3,118, and took all of them from the 3 stores whose nearest areas it cut into.
What the model does not know
Brand loyalty, price, parking, trip chaining from work, and capacity. A store's attractiveness is a single number, and every household applies the same exponents. The model is a structured assumption about behaviour, strongest when calibrated against observed visits.
Stores and customers beyond the boundary
- Stores outside the study area take custom from it; include them, as here with supermarkets within 10 km.
- Customers from outside the study area are missing unless you load them; boundary stores' totals are understated.
- Zero travel times need a floor or an additive constant; a constant makes the result depend on units.
- Missing attractiveness needs a stated fill rule.
- The power form is unit-free. Minutes and seconds give identical probabilities; exponential decay does not.
- Market shares are of people, not sales. Weight by spending per head if it varies.
- A closed-market model cannot show market growth from a new store.
Internal links
- Gravity and Huff models explained: predicting where people go — the model and its parameters
- Fixing a gravity model that sends everyone to the biggest store — exponents, units and fills gone wrong
- How to build Voronoi service areas around facilities in Python — nearest-store areas for comparison
- How to measure distance to the nearest facility for every home — the travel times
- How to measure network distance for many origin–destination pairs — building the matrix
- Fixing accessibility scores that are wrong near the study area edge — stores and shoppers beyond the boundary
- How to run a weighted site suitability analysis in Python — finding candidate sites
- Location analysis explained: catchments, accessibility and site selection — where market-share models fit
FAQ
How do I estimate market share with a Huff model?
Compute each store's attractiveness times travel time raised to −β for every household, divide by the household's total, and sum the resulting probabilities weighted by population. For 39 supermarkets and β = 2, the largest share was 8.5%.
How is a Huff model different from nearest-store allocation?
It splits each household's custom across stores instead of giving it all to the nearest. The two disagreed about 31.6% of Chittenden County's market, and 7 stores that were nobody's nearest had 8,277 expected customers.
How do I model the impact of a new store?
Add the proposed site as another column with its attractiveness and travel times, recompute, and compare each store's expected customers. A median-sized supermarket at an under-served site took 1,996 customers, spread across many stores.
What distance decay exponent should I use?
Calibrate it from observed visits if you can. Without data, test a range: the new store's customers were 2,036, about 2,000 and 2,503 for β of 1, 2 and 3.
Where does a Huff trade area end?
Nowhere sharply. For the largest supermarket, 90% of expected customers lived within 18.4 minutes, and blocks where it was the majority choice held only 16.4% of its customers.
Can a Huff model show a new store growing the market?
No. Each household's probabilities sum to one, so everything a new store gains is lost by existing stores; market growth needs a separate assumption.