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.

Gross sales, transferred sales, and the net number that decides the case A new store forecast at 4.2 million in gross sales draws 1.1 million from an existing sister store and 0.4 million from a second one, leaving 2.7 million of genuinely new sales. The committee decision rests on the net figure, while the site score and the forecast both quote the gross. The forecast says 4.2M. The board is buying 2.7M. New store · gross $4.2M what the forecast quotes drawn from store 118 $1.1M · 9% of its sales drawn from store 204 $0.4M · 3% of its sales transferred $1.5M Net new sales = 4.2M − 1.5M = $2.7M the figure the payback model must use, and the one a gross-only forecast quietly overstates by 56% Transfer is not automatically a reason to decline. Defending a corridor against a rival opening, relieving a capacity-constrained store, or shortening drive times for existing customers can all justify it — but only if the transfer is quantified rather than discovered in the following year's comparable sales. Percentages are of each donor store's own sales, which is what its manager will ask about.

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.

One origin zone, before and after the new store opens Before opening, a block group sends 46 per cent of its demand to store 118, 21 per cent to store 204 and 33 per cent to rivals. After the new store enters the choice set it takes 38 per cent, and the loss is distributed in proportion to each prior share, so the sister stores give up more than the rivals do. The new store does not take share evenly — it takes it in proportion before store 118 · 46% 204 · 21% rivals · 33% after 118 · 28.5% 204 · 13% rivals · 20.5% new store · 38% Each prior share is scaled by the same factor, because the denominator of the choice model grew while every numerator stayed put. The store with the largest prior share therefore surrenders the most demand. This is why transfer concentrates on your strongest nearby store rather than your weakest, and why an estimate built from overlap area alone gets the direction of the effect right and the magnitude wrong. Bar widths are shares of one origin zone's demand; the same arithmetic runs for every zone in the catchment.

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.

Back-testing the transfer model on openings you already have For four past openings the model predicted transfers of 9, 4, 14 and 6 per cent from the nearest sister store, and the observed post-opening declines, adjusted for market trend, were 8, 5, 11 and 7 per cent. The consistent over-prediction on the largest case is the signal worth chasing. The model is only credible on openings it did not see past opening predicted transfer observed, trend-adjusted gap Northgate, 2024 9% 8% +1 Riverside, 2024 4% 5% −1 Fairview, 2025 14% 11% +3 Eastmark, 2025 6% 7% −1 Three of four land within a point. The fourth over-predicts by three, and it is the closest pair of stores in the set — evidence the decay is too flat at short distances rather than that the model is wrong everywhere. Trend adjustment subtracts the market's own comparable-sales movement over the same period.

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.

Frequently Asked Questions

Is a transfer estimate ever a reason to approve a site rather than reject one?

Frequently. A site that transfers heavily from a store operating at capacity converts queue-limited demand into servable demand, and a site that transfers from a store a rival is about to attack defends revenue that was going to move anyway. What the estimate buys is the ability to say which of those situations you are in. The number itself is neutral; presenting it without the context — capacity, competitive threat, lease timing — is what makes it read as an argument against expansion.

How long after opening can the estimate be checked?

Give it two to four quarters. The first weeks are an opening surge that draws curiosity trips from far outside the catchment and tells you almost nothing about the steady state, and the first full quarter still carries promotional distortion. Comparing the donor store’s sales against its own pre-opening trend and against the wider market over the same window separates transfer from seasonality, which is the comparison the model can actually be scored on.

What if the two stores serve different missions?

Then the shared catchment overstates the transfer, and the model needs a similarity term rather than a distance one. A small-format convenience store and a full-line supermarket occupying the same neighbourhood compete for a fraction of each other’s baskets, not all of them. Scaling the estimated transfer by a format-similarity factor, calibrated from the basket overlap you can observe in loyalty data, keeps the geometry doing what it is good at while letting merchandising decide how substitutable the two stores really are.

Should transfer be modelled between our stores and a rival’s?

The same arithmetic applies, but the interpretation changes: demand drawn from a competitor is the upside case and it should be reported separately, never netted against internal transfer. Mixing the two produces a single number that hides the question the committee is asking, which is how much of the forecast is genuinely new business for the company. Report gross sales, internal transfer and competitor draw as three lines, and let the payback model consume the one it needs.

← Back to Competitor Mapping & Cannibalization Analysis