Competitor Mapping & Cannibalization Analysis

A candidate site that looks strong on demographics can still destroy value if it saturates an already-contested market or steals sales from a sister store two miles away — competitor mapping and cannibalization analysis is the discipline that quantifies both risks before capital is committed.

This stage of the Suitability Scoring & Site Ranking Models workflow takes a portfolio of candidate coordinates plus a competitor and own-store network and produces two numbers per site: a competitive saturation index and an expected catchment-overlap penalty. Those numbers are not decoration — they enter the suitability score directly, discounting sites whose raw accessibility and demographic fit overstate their true incremental revenue. Get the geocoding, the projection, or the overlap accounting wrong here and the ranking model will recommend opening stores that cannibalize the existing chain.

Concept: saturation, overlap, and the cannibalization problem

Three distinct spatial questions hide inside “how competitive is this site.” The first is saturation: how much competing selling capacity already serves the demand inside the candidate’s trade area. The second is overlap: how much of the candidate’s catchment is shared with other stores — competitor or own — so that its demand is contested rather than captive. The third, and the one that most often surprises planners, is cannibalization: the portion of a new store’s sales that is transferred from existing sister stores rather than won incrementally from the market. A new location can pass every demographic screen and still be a poor investment because most of its revenue is simply relocated from a store the chain already owns.

Saturation is a supply-versus-demand ratio. For a candidate site ii with a trade-area population PiP_i and a set of competitors CiC_i falling inside that trade area, each competitor jj carrying a weight wjw_j (a proxy for its selling capacity — floor area, format, or brand pull), the saturation index is:

SIi=jCiwjeβdijPi/1000SI_i = \frac{\sum_{j \in C_i} w_j \, e^{-\beta d_{ij}}}{P_i / 1000}

The exponential term eβdije^{-\beta d_{ij}} is a distance-decay factor that discounts competitors farther from the site, using the same distance-decay calibration that governs demand attenuation elsewhere in the scoring model. A high SIiSI_i means competing capacity per thousand residents is already dense — the market is saturated and a new entrant fights for scraps.

Overlap is a geometric ratio between two catchment polygons. For the candidate catchment AiA_i and another store’s catchment AkA_k, the overlap index is the intersection area normalized by the candidate’s own area:

Oik=area(AiAk)area(Ai),0Oik1O_{ik} = \frac{\operatorname{area}(A_i \cap A_k)}{\operatorname{area}(A_i)}, \qquad 0 \le O_{ik} \le 1

An OikO_{ik} near 1 means almost the entire candidate catchment is also served by store kk; near 0 means the two barely touch. Summed and capped across the own-store network, overlap becomes the cannibalization penalty that discounts the site’s incremental value. The distinction between competitor overlap (competitive pressure) and own-store overlap (cannibalization) is critical: the first reduces expected capture, the second reduces net new revenue to the chain even when the store performs well on its own.

Overlapping store catchments and the cannibalization zone An existing store catchment and a new candidate site catchment are drawn as two overlapping circles. Their intersection is a lens-shaped overlap zone representing contested demand where the new store transfers sales from the incumbent. An arc from the existing store to the candidate marks the expected sales transfer, and a callout labels the overlap zone as cannibalized demand. Catchment overlap and store cannibalization two catchments sharing a contested demand zone Existing store New candidate site store A · incumbent store B · candidate expected sales transfer Overlap zone — contested, cannibalized demand

Architecture overview

The analysis is a linear pipeline: geocode and normalize both networks into a single projected reference frame, build a trade-area catchment per candidate, spatially join competitors and own stores into each catchment, compute the saturation and overlap indices, then emit a competition penalty for the scoring stage. Every geometric operation runs in an equal-area projection so that areas, distances, and buffers are metric rather than degree-based.

Competitor mapping and cannibalization pipeline Competitor and own-store networks are geocoded and normalized to a projected CRS, candidate catchments are built, competitors and sister stores are joined into each catchment, saturation and overlap indices are computed, and a competition penalty is emitted for the scoring stage. Competitor mapping and cannibalization pipeline Geocode competitors · own dedup · CRS Build catchments isochrone / buffer per candidate Spatial join competitors in catchment Indices saturation overlap ratio Penalty competition term into site score

Catchments here are the same drive-time contours produced upstream in Isochrone Generation & Network Analysis; a radial buffer is an acceptable fallback only when routing is unavailable, because it ignores the barriers that make two nominally close stores serve genuinely different customers. Whatever the catchment source, the overlap math below is identical.

Geocoding and normalizing the two networks

Before any index can be computed, both the competitor network and the own-store network have to become a single, projected, deduplicated point layer. This is where most real errors originate, because the two networks almost never arrive in the same shape. Own stores usually come from an internal master with clean parcel-level coordinates and a stable identifier; competitors come from a POI feed, a purchased list, or a manual survey, with inconsistent addresses, mixed geocode quality, and no shared key. Normalizing them means resolving three things: a common CRS, a common attribute schema, and a common deduplication rule.

CRS comes first and is non-negotiable. Geocoders emit WGS84 (EPSG:4326), which is fine for storage and interchange but useless for the metric work ahead — every distance, buffer, and area in this analysis runs in the equal-area EPSG:5070 frame. Reproject once at ingestion and assert the result, so no downstream function has to guess. Attribute normalization means mapping every competitor onto a brand, a format class, and a numeric weight that proxies selling capacity; without that weight the pipeline cannot tell a big-box anchor from a corner independent. Deduplication is the third step, applied after projection so the merge radius is in metres, collapsing the same physical store that appears in two source lists into one weighted point. Only once all three are resolved does the point layer become a trustworthy input to the saturation and overlap math.

A defensive normalizer therefore reprojects, validates coordinates against the analysis extent, tags provenance, and deduplicates — in that order — and refuses to emit a layer that still carries a geographic CRS. Coordinate hygiene here reuses exactly the discipline established in data validation rules for store coordinates, applied to the competitor feed rather than the internal master.

Configuration parameters

The table lists the settings that materially change the saturation and overlap outputs. Treat the retail defaults as calibrated starting points to be validated against observed transfer, not universal constants.

Parameter Type Valid range Retail default Effect on output
buffer_radius_m float 800–8000 m 3200 m Fallback catchment radius when no isochrone is available
catchment_source string isochrone, buffer isochrone Network contour vs. straight-line ring
overlap_threshold float 0.0–1.0 0.15 Minimum OikO_{ik} counted as material overlap
decay_beta float 0.05–0.60 /km 0.20 /km Distance-decay rate β\beta for competitor weighting
competitor_weight mapping chain 1.0, independent 0.6 Per-competitor selling-capacity proxy wjw_j
dedup_distance_m float 10–150 m 50 m Merge radius for duplicate competitor points
cannibalization_gamma float 0.0–1.0 0.50 Penalty strength γ\gamma applied to mean own-store overlap
equal_area_crs EPSG projected 5070 Albers Equal Area for all area/length math

The overlap_threshold deserves emphasis: two catchments almost always share a sliver of area at their edges, and counting a 2% overlap as cannibalization inflates the penalty across the whole portfolio. Only overlap above the threshold is treated as material contested demand.

Step-by-step Python implementation

The workflow below geocodes both networks into a single GeoDataFrame, reprojects to EPSG:5070 for all metric work, deduplicates competitor points, and computes competitor density and catchment overlap. Every geometric operation asserts its CRS before running — there is no bare latitude/longitude arithmetic anywhere.

python
import geopandas as gpd
import numpy as np
from pyproj import CRS

STORAGE_CRS = CRS.from_epsg(4326)     # WGS84 for interchange / geocoded input
EQUAL_AREA_CRS = CRS.from_epsg(5070)  # NAD83 / Conus Albers, metres

def to_metric(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Reproject to an equal-area CRS, refusing to guess a missing projection."""
    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 dedup_points(gdf: gpd.GeoDataFrame, dist_m: float = 50.0) -> gpd.GeoDataFrame:
    """Collapse near-duplicate competitor rows (same store double-listed)."""
    gdf = to_metric(gdf).copy()
    # Snap to a grid of dist_m and keep one point per cell + brand.
    gx = (gdf.geometry.x / dist_m).round().astype(int)
    gy = (gdf.geometry.y / dist_m).round().astype(int)
    gdf["_cell"] = list(zip(gx, gy, gdf["brand"].fillna("_")))
    deduped = gdf.drop_duplicates("_cell").drop(columns="_cell")
    return deduped.reset_index(drop=True)

With clean, projected inputs, competitor density inside a catchment is a spatial join followed by a distance-decayed, capacity-weighted sum. The catchment and the competitor layer must already share the equal-area CRS before the predicate runs.

python
def competitor_saturation(
    catchment: gpd.GeoDataFrame,   # single-row candidate catchment
    competitors: gpd.GeoDataFrame,
    site_point,                    # shapely Point in EQUAL_AREA_CRS
    population: float,
    beta_per_km: float = 0.20,
) -> float:
    """Distance-decayed competitor capacity per 1,000 residents (index SI_i)."""
    catchment = to_metric(catchment)
    competitors = to_metric(competitors)
    assert catchment.crs == competitors.crs, "CRS mismatch before spatial join"

    inside = gpd.sjoin(competitors, catchment, predicate="within", how="inner")
    if inside.empty or population <= 0:
        return 0.0

    # Distances are metric because we are in EPSG:5070.
    dist_km = inside.geometry.distance(site_point) / 1000.0
    weighted = (inside["weight"].to_numpy()
                * np.exp(-beta_per_km * dist_km.to_numpy()))
    return float(weighted.sum() / (population / 1000.0))

Catchment overlap between the candidate and every store in a reference network — competitor or own — is a pairwise intersection-area ratio. Because the whole computation lives in an equal-area projection, .area returns square metres directly and the ratio is dimensionless.

python
def overlap_indices(
    candidate: gpd.GeoDataFrame,   # single-row candidate catchment
    others: gpd.GeoDataFrame,      # other catchments (own or competitor)
    threshold: float = 0.15,
) -> gpd.GeoDataFrame:
    """Return O_ik = area(A_i ∩ A_k) / area(A_i) for each other catchment."""
    candidate = to_metric(candidate)
    others = to_metric(others)
    assert candidate.crs == others.crs, "CRS mismatch before overlay"

    cand_geom = candidate.geometry.iloc[0]
    cand_area = cand_geom.area
    assert cand_area > 0, "degenerate candidate catchment"

    inter = others.geometry.intersection(cand_geom)
    o_ik = inter.area / cand_area
    out = others.copy()
    out["overlap"] = o_ik.to_numpy()
    out["material"] = out["overlap"] >= threshold
    return out[out["material"]].sort_values("overlap", ascending=False)

The mean overlap against the own-store subset is what becomes the cannibalization penalty. Keeping competitor overlap and own-store overlap in separate columns is deliberate: they discount different things and must never be summed into one figure.

Edge cases and failure modes

Competitor data is the messiest input in the whole site-selection stack, and most failures here are silent — the pipeline produces a plausible number that is quietly wrong.

  • Missing or stale competitor data. A gap in coverage reads as low saturation and inflates the site score. Attach a coverage confidence flag per market and refuse to score sites in regions where the competitor layer is known incomplete, rather than treating absence as evidence of an open market.
  • Chain vs. independent conflation. A single national-chain box and a small independent both count as “one competitor” unless weighted. Use the competitor_weight mapping so a large-format chain contributes more capacity than a convenience independent; the raw count is nearly meaningless without it.
  • Duplicate geocodes. The same competitor appears twice — once from a POI feed, once from a manual list — doubling its weight. The dedup_points grid-snap collapses points within dedup_distance_m sharing a brand before any density math runs.
  • Geocoding drift. A rooftop-vs-centroid discrepancy of a few hundred metres can push a competitor just inside or outside a catchment boundary, flipping its inclusion. Validate competitor coordinates with the same rigor applied to store coordinates and prefer parcel-level geocodes near catchment edges.
  • Degenerate catchments. An empty or zero-area contour (an off-network origin) makes the overlap denominator zero. The assert cand_area > 0 guard fails loudly rather than emitting a division error downstream.
  • Self-overlap. When scoring an expansion into an existing market, the candidate must be excluded from the own-store set, or it overlaps itself at O=1O = 1 and reports a phantom penalty.

Performance and scaling

Overlap is an all-pairs geometric problem, and a naive nested loop over nn candidates and mm stores is O(nm)O(nm) intersection tests, each expensive. The fix is a spatial index: build an R-tree on the store catchments and query only the candidates whose bounding boxes actually intersect. GeoPandas exposes this through sjoin and .sindex, which prune the vast majority of non-overlapping pairs before any exact intersection runs.

For portfolios in the thousands of candidates against a national competitor network, precompute the proximity relationships in the database rather than in Python — the count-within-radius and nearest-competitor distances are exactly the kind of query a GiST-indexed PostGIS table answers in milliseconds, as detailed in building a competitor proximity index in PostGIS. Reserve the in-Python overlay for the final short list, where exact intersection geometry actually matters.

Validation and QA gates

No competition penalty reaches the scoring model until it clears these hard gates:

  1. CRS assertion. Every catchment, competitor, and own-store layer is confirmed to be in EPSG:5070 before any area, distance, or overlap calculation. A degree-based area silently corrupts every saturation ratio.
  2. Overlap bounds. Each OikO_{ik} lies in [0,1][0, 1]; a value above 1 means the intersection was computed against the wrong denominator or a non-simple geometry, and the run aborts.
  3. Dedup sanity. The deduplicated competitor count is within a plausible band of the raw count; a 40% collapse signals either a too-large dedup_distance_m or badly duplicated source data.
  4. Coverage confidence. Sites in markets flagged as incomplete are excluded from ranking, not scored as green-field.
  5. Penalty monotonicity. Increasing own-store overlap never decreases the cannibalization penalty — a quick property test that catches sign errors in the scoring integration.
python
def validate_overlap(gdf: gpd.GeoDataFrame) -> bool:
    assert CRS.from_user_input(gdf.crs) == EQUAL_AREA_CRS, "overlap not in equal-area CRS"
    assert (gdf["overlap"].between(0.0, 1.0)).all(), "overlap outside [0, 1]"
    return True

Integration notes: the competition penalty in the site score

The output of this stage is a single competition term folded into the composite suitability score. The raw score SiS_i produced by the weighted suitability model — which combines accessibility, demographics, and site attributes after normalizing mixed-scale attributes — is discounted by the mean material overlap against the own-store network:

Siadj=Si(1γOˉi),Oˉi=1KikKiOikS_i^{\text{adj}} = S_i \cdot \bigl(1 - \gamma \, \bar{O}_i\bigr), \qquad \bar{O}_i = \frac{1}{|K_i|} \sum_{k \in K_i} O_{ik}

where KiK_i is the set of sister stores with material overlap and γ\gamma (the cannibalization_gamma parameter) controls how aggressively the penalty bites. Competitive saturation enters as a separate negative-weighted attribute inside SiS_i itself, so the two effects — external competition and internal cannibalization — stay analytically distinct. The adjusted score is what flows into ranking and shortlisting candidate sites, and the overlap figures themselves become a column in the automated site-selection reporting so reviewers see why a demographically attractive site was ranked down.

The overlap fraction is a first-order approximation of cannibalization; converting it into an expected revenue transfer requires a probabilistic capture model, covered in measuring sales transfer between nearby stores. Both depend on the same catchment geometry and the same point-in-polygon join that assigns demand to the contested zone.

Frequently Asked Questions

How do I tell competitive pressure apart from cannibalization?

Keep the two networks separate throughout the pipeline. Overlap with a competitor catchment reduces how much demand the candidate can capture — it is external pressure. Overlap with an own store reduces the chain’s net new revenue even if the candidate captures its share — it is internal cannibalization. Compute both as overlap ratios but never sum them; they discount different quantities and feed the score through different terms.

What buffer radius should I use when routing is unavailable?

Prefer a drive-time isochrone; fall back to a buffer only when the routing engine has no coverage. A 3200 m ring is a reasonable default for suburban general retail, but the honest answer is to calibrate the radius to the format’s observed customer travel — grocery pulls a tighter ring than a category-killer big box. A straight-line buffer systematically overstates overlap where rivers or highways separate stores, so widen the overlap_threshold to compensate when buffers are unavoidable.

Why must every area calculation happen in EPSG:5070?

Areas and distances computed in EPSG:4326 are in degrees, which vary with latitude and make overlap ratios meaningless. Reprojecting to an equal-area system such as EPSG:5070 (Conus Albers) makes .area return square metres and .distance return metres, so the saturation and overlap indices are physically comparable across the whole portfolio. Asserting the CRS in code turns a silent error into a loud one.

How should missing competitor data be handled?

Never treat absence as evidence of an open market. Maintain a per-market coverage confidence flag and exclude sites in known-incomplete regions from ranking rather than scoring them as green-field. A gap in the competitor feed reads as low saturation and will push a bad site to the top of the list if left unguarded.

← Back to Suitability Scoring & Site Ranking Models