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 with a trade-area population and a set of competitors falling inside that trade area, each competitor carrying a weight (a proxy for its selling capacity — floor area, format, or brand pull), the saturation index is:
The exponential term 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 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 and another store’s catchment , the overlap index is the intersection area normalized by the candidate’s own area:
An near 1 means almost the entire candidate catchment is also served by store ; 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.
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.
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 counted as material overlap |
decay_beta |
float | 0.05–0.60 /km | 0.20 /km | Distance-decay rate for competitor weighting |
competitor_weight |
mapping | — | chain 1.0, independent 0.6 | Per-competitor selling-capacity proxy |
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 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.
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.
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.
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_weightmapping 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_pointsgrid-snap collapses points withindedup_distance_msharing 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 > 0guard 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 and reports a phantom penalty.
Performance and scaling
Overlap is an all-pairs geometric problem, and a naive nested loop over candidates and stores is 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:
- 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.
- Overlap bounds. Each lies in ; a value above 1 means the intersection was computed against the wrong denominator or a non-simple geometry, and the run aborts.
- 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_mor badly duplicated source data. - Coverage confidence. Sites in markets flagged as incomplete are excluded from ranking, not scored as green-field.
- Penalty monotonicity. Increasing own-store overlap never decreases the cannibalization penalty — a quick property test that catches sign errors in the scoring integration.
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 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:
where is the set of sister stores with material overlap and (the cannibalization_gamma parameter) controls how aggressively the penalty bites. Competitive saturation enters as a separate negative-weighted attribute inside 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.
Related
- Building Weighted Site Suitability Scores — the composite score the competition penalty discounts.
- Ranking and Shortlisting Candidate Sites — where adjusted scores become a defensible short list.
- Measuring Sales Transfer Between Nearby Stores — turning overlap into an expected revenue transfer.
- Building a Competitor Proximity Index in PostGIS — distance-to-nearest and count-within-radius at portfolio scale.
- Isochrone Generation & Network Analysis — the drive-time catchments this analysis overlays.