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, and pandas. Install with pip 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 AA (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 zz patronizes store ii as its attractiveness discounted by travel friction, normalized across all stores the customer could reach:

Piz=AiαdizβjAjαdjzβP_{iz} = \frac{A_i^{\alpha} \, d_{iz}^{-\beta}}{\sum_{j} A_j^{\alpha} \, d_{jz}^{-\beta}}

Sales transfer is the change in incumbent capture caused by introducing the new store nn. Before the opening, an incumbent ii captures demand DzD_z in each zone with probability Piz0P^{0}_{iz} (the sum over stores excludes nn). After the opening, nn enters the denominator and every incumbent’s probability drops to Piz1P^{1}_{iz}. The expected revenue transferred from incumbent ii to the new store is the demand-weighted probability it loses:

Tni=zAnAiDz(Piz0Piz1)T_{n \to i} = \sum_{z \in A_n \cap A_i} D_z \bigl(P^{0}_{iz} - P^{1}_{iz}\bigr)

Summed over all incumbents, iTni\sum_i T_{n \to i} is the total cannibalized revenue. Divided by the new store’s projected sales RnR_n, it is the cannibalization rate:

κn=iTniRn,0κn1\kappa_n = \frac{\sum_i T_{n \to i}}{R_n}, \qquad 0 \le \kappa_n \le 1

A κn\kappa_n 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 [0,1][0, 1] 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 OniO_{ni} — the share of the new catchment also served by incumbent ii. 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 TniT_{n \to i} 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, κn\kappa_n 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 α\alpha in the Huff numerator
beta 1.8 Distance-decay exponent β\beta; higher = customers stay closer
demand_field "spend" Per-zone demand column (DzD_z); population is an acceptable proxy
min_distance_m 150 Distance floor so dβd^{-\beta} never explodes at a store’s own location
attractiveness_field "gla" Gross leasable area or format score used as AiA_i

The min_distance_m floor matters: the Huff term dβd^{-\beta} 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.

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, 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 dβd^{-\beta} 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 dβd^{-\beta} 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:

  1. 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.
  2. Distance monotonicity: hold everything else fixed and push the candidate progressively farther from an incumbent; TniT_{n \to i} must fall monotonically. A sanity sweep over three or four distances catches sign and exponent errors.
  3. Demand conservation: the total transfer across incumbents cannot exceed the demand captured by the new store inside the contested zone. Compare iTni\sum_i T_{n \to i} against zDzPnz1\sum_z D_z P^{1}_{nz}.
  4. Zero-overlap check: a candidate whose catchment does not intersect any incumbent must return κn=0\kappa_n = 0; if it does not, the containment predicate is using the wrong geometry.
python
# 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 κn\kappa_n 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.

← Back to Competitor Mapping & Cannibalization Analysis