How to Estimate Market Share with a Huff Model in Python
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 168,323 residents of Chittenden County, 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,231 between them.
- The two methods disagreed about 31.6% of the market.
- 75.5% 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,987 expected customers (1.2%), and the most any existing store lost was 199.
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.
Step-by-step solution
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: 168,323. 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,231 expected customers between them. One Shaw's of 6,448 mยฒ was nearest for 101 residents and expected 9,953 customers; Shelburne Supermarket's share was 4.0 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.5% 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.4% 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.6 and 90% within 18.4.
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,428 people in the county โ within 8 minutes of a new one: 2,250 of them. A median-sized store there took 1,987 expected customers. In a Huff model the market is closed, so the existing stores lost exactly 1,987 between them, led by a Hannaford (โ199, 2% of its customers), Shelburne Supermarket (โ190, 6%) and a Shaw's (โ150, 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,048 at ฮฒ = 1, 1,987 at ฮฒ = 2 and 2,478 at ฮฒ = 3, and at ฮฒ = 3 Shelburne Supermarket's loss rose to 11% of its customers. A decision that changes across that range needs data to calibrate ฮฒ before it is made.
Code examples
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,377 15,654 8.5% 9.3%
Hannaford 5,775 11,100 6,259 6.6% 3.7%
Hannaford 4,619 11,036 10,246 6.6% 6.1%
Hannaford 5,224 10,185 6,137 6.1% 3.6%
Shaw's 6,448 9,953 101 5.9% 0.1%
stores that are nobody's nearest: 7, with 8,231 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.6 min
90% of expected customers within 18.4 min
blocks where it is the likelier-than-not choice: 42, holding 16.4% 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,987 customers (1.2%); taken from others: 1,987
Hannaford: -199 (2% of its customers)
Shelburne Supermarket: -190 (6% of its customers)
Shaw's: -150 (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 14.1% of its expected customers.
Explanation
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.
Edge cases or notes
- 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,231 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,987 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,048, 1,987 and 2,478 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.