Measuring Sales Transfer Between Nearby Stores
This page solves one exact task: estimating how much of a new store’s revenue is transferred from existing sister stores — the cannibalization percentage — by combining catchment overlap with Huff-style capture probabilities, so a candidate’s incremental value is scored honestly rather than its gross value.
Overlap area alone tells you two catchments share territory; it does not tell you how much money moves. A new store that overlaps a sister store’s catchment by 40% might transfer 40% of the shared zone’s spend, or 15%, depending on relative store attractiveness and how customers in the contested area split their trips. Converting geometry into an expected transfer requires a probabilistic capture model, and that is what this task builds: a pairwise transfer estimate between a candidate and every incumbent, bounded to a sane range and validated against distance.
Prerequisites
Before running this task you need:
- Python packages:
geopandas,shapely,numpy, andpandas. Install withpip install geopandas shapely numpy pandas. - Candidate and incumbent catchments as polygons with a defined CRS, ideally the drive-time contours from Isochrone Generation & Network Analysis. A radial buffer works as a fallback but overstates overlap across barriers.
- A demand surface — spend or population per small area (block group or grid cell) inside the contested zone, produced by a point-in-polygon join against demographics.
- Store attractiveness values (a size or format proxy) for the candidate and each incumbent, the same weights used in the Huff model probabilities applied elsewhere in the ranking stage.
- The parent context. This page assumes you have read Competitor Mapping & Cannibalization Analysis, which defines the overlap index and the catchment geometry reused here.
The transfer formula
The Huff model gives the probability that a customer in demand zone patronizes store as its attractiveness discounted by travel friction, normalized across all stores the customer could reach:
Sales transfer is the change in incumbent capture caused by introducing the new store . Before the opening, an incumbent captures demand in each zone with probability (the sum over stores excludes ). After the opening, enters the denominator and every incumbent’s probability drops to . The expected revenue transferred from incumbent to the new store is the demand-weighted probability it loses:
Summed over all incumbents, is the total cannibalized revenue. Divided by the new store’s projected sales , it is the cannibalization rate:
A of 0.30 means roughly a third of the new store’s revenue is relocated from the existing chain rather than won incrementally. That is the number a capital committee actually cares about, and it is bounded to by construction because transferred sales cannot exceed the store’s own sales.
Why overlap alone is not enough
The parent analysis produces an overlap ratio — the share of the new catchment also served by incumbent . It is a fast, purely geometric screen, but it silently assumes every contested customer splits evenly between the two stores. Real customers do not: they weigh store size, format, and travel friction, so a large new store in a contested zone captures more than its area share and a small one captures less. The Huff formulation replaces that flat assumption with a demand-weighted probability, which is why can differ substantially from what the raw overlap suggests. The expected transfer relates to overlap through the capture asymmetry: when the new store and the incumbent are of equal attractiveness and equidistant, collapses back toward the overlap-implied split, and it diverges as their relative pull grows. Treat overlap as the coarse filter that decides which incumbents to model, and the transfer estimate as the number that actually enters the investment case.
Configuration and execution parameters
| Parameter | Value for this task | Notes |
|---|---|---|
equal_area_crs |
EPSG:5070 |
Conus Albers; all distance and area math runs here |
alpha |
1.0 |
Attractiveness exponent in the Huff numerator |
beta |
1.8 |
Distance-decay exponent ; higher = customers stay closer |
demand_field |
"spend" |
Per-zone demand column (); population is an acceptable proxy |
min_distance_m |
150 |
Distance floor so never explodes at a store’s own location |
attractiveness_field |
"gla" |
Gross leasable area or format score used as |
The min_distance_m floor matters: the Huff term diverges as distance approaches zero, so a demand zone sitting on top of a store would otherwise receive infinite probability. Clamping the distance to a small positive floor keeps the probabilities finite and the transfer bounded.
Annotated implementation
The function below computes pairwise transfer between one candidate and a set of incumbents over a shared demand surface. It reprojects everything to EPSG:5070 up front, asserts the CRS before any distance is measured, applies the distance floor, and returns a per-incumbent transfer table plus the aggregate cannibalization rate.
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, metres
def _to_metric(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
assert gdf.crs is not None, "layer has no CRS; refusing to proceed"
if CRS.from_user_input(gdf.crs) != EQUAL_AREA_CRS:
gdf = gdf.to_crs(EQUAL_AREA_CRS)
assert CRS.from_user_input(gdf.crs) == EQUAL_AREA_CRS
return gdf
def sales_transfer(
candidate: dict, # {"point": Point, "A": float, "catchment": polygon}
incumbents: gpd.GeoDataFrame, # rows: geometry=store point, A, catchment
demand: gpd.GeoDataFrame, # rows: geometry=zone centroid, spend
projected_sales: float, # R_n, new store's own projected revenue
alpha: float = 1.0,
beta: float = 1.8,
min_distance_m: float = 150.0,
) -> tuple[pd.DataFrame, float]:
"""Expected transfer T_{n->i} per incumbent and the cannibalization rate."""
incumbents = _to_metric(incumbents)
demand = _to_metric(demand)
assert incumbents.crs == demand.crs, "CRS mismatch before distance math"
# Restrict demand to the candidate's catchment: only contested zones matter.
cand_catch = gpd.GeoSeries([candidate["catchment"]], crs=EQUAL_AREA_CRS).iloc[0]
zones = demand[demand.geometry.within(cand_catch)].copy()
if zones.empty:
return pd.DataFrame(columns=["store_id", "transfer"]), 0.0
zpts = zones.geometry
D = zones["spend"].to_numpy() # per-zone demand D_z
# Attractiveness / distance terms for the NEW store at every zone.
d_n = np.maximum(zpts.distance(candidate["point"]).to_numpy(), min_distance_m)
u_n = candidate["A"] ** alpha * d_n ** (-beta)
# Same terms for each incumbent; stack into a (zones x stores) matrix.
u_inc, keep = [], []
for _, row in incumbents.iterrows():
d_i = np.maximum(zpts.distance(row.geometry).to_numpy(), min_distance_m)
u_inc.append(row["A"] ** alpha * d_i ** (-beta))
keep.append(row["store_id"])
U = np.vstack(u_inc) # shape (n_stores, n_zones)
denom0 = U.sum(axis=0) # before: incumbents only
denom1 = denom0 + u_n # after: new store added
# Probability each incumbent loses per zone, weighted by demand.
p0 = U / denom0
p1 = U / denom1
transfer_per_store = ((p0 - p1) * D).sum(axis=1) # T_{n->i}
table = pd.DataFrame({"store_id": keep, "transfer": transfer_per_store})
kappa = float(transfer_per_store.sum() / projected_sales) if projected_sales else 0.0
return table.sort_values("transfer", ascending=False), min(max(kappa, 0.0), 1.0)
The matrix formulation is what keeps this tractable: rather than looping zone-by-zone, the attractiveness terms are assembled into a stores-by-zones array, and the before/after denominators are a single column sum with and without the new store’s contribution. The p0 - p1 difference is always non-negative because adding a term to the denominator can only shrink each existing probability, which is why the transfer is guaranteed non-negative before the demand weighting.
Failure modes and debugging
| Symptom | Cause | Fix |
|---|---|---|
transfer values exceed projected sales |
Missing distance floor lets blow up near a store | Apply min_distance_m; verify no zone centroid sits exactly on a store point. |
| Cannibalization rate near 1.0 for a distant site | Demand surface not clipped to the candidate catchment | Restrict demand to zones within the candidate catchment before the math. |
| Transfer identical across incumbents | Attractiveness field all equal or all missing | Populate A with real GLA or format scores; a constant reduces Huff to distance-only. |
| Distances look tiny or huge | Layers still in EPSG:4326 (degrees) | Reproject to EPSG:5070 and assert the CRS before measuring. |
| Negative transfer | Sign error or a store double-counted in both networks | Exclude the candidate from the incumbent set; p0 - p1 should never be negative. |
The single most common mistake is measuring distance in EPSG:4326. A degree of longitude is ~85 km at 40° N and 0 km at the pole, so a term computed on raw coordinates is not just wrong in magnitude, it is wrong inconsistently across latitude. The _to_metric reprojection and CRS assertion exist precisely to make that mistake impossible.
Verification
Confirm the output before trusting the cannibalization rate downstream:
- Boundedness:
assert 0.0 <= kappa <= 1.0. A rate outside this range means the transfer exceeded the store’s own sales — almost always a missing distance floor or an unclipped demand surface. - Distance monotonicity: hold everything else fixed and push the candidate progressively farther from an incumbent; must fall monotonically. A sanity sweep over three or four distances catches sign and exponent errors.
- Demand conservation: the total transfer across incumbents cannot exceed the demand captured by the new store inside the contested zone. Compare against .
- Zero-overlap check: a candidate whose catchment does not intersect any incumbent must return ; if it does not, the containment predicate is using the wrong geometry.
# Distance-monotonicity sanity sweep
from shapely.affinity import translate
rates = []
for shift_m in (0, 2000, 5000, 10000):
moved = dict(candidate, point=translate(candidate["point"], xoff=shift_m))
_, k = sales_transfer(moved, incumbents, demand, projected_sales=R_n)
rates.append(k)
assert rates == sorted(rates, reverse=True), "transfer must fall with distance"
A falling sequence of rates as the candidate moves away from the incumbent network is the strongest single check that the model behaves — it confirms the distance decay, the normalization, and the CRS handling are all pulling in the right direction. Feed the resulting back into the cannibalization penalty defined in the parent page, and cache the Huff probability terms if the same incumbents are scored against many candidates, following the caching strategies for repeated network queries.
Related
- Applying Huff Model Probabilities to Store Catchments — the capture-probability model this transfer estimate builds on.
- Caching Strategies for Repeated Network Queries — reuse Huff terms across many candidate evaluations.