Estimating Visit Frequency Decay from Foot Traffic Panels

This page solves one exact task: fitting the relationship between travel time and visit frequency from observed panel data, and separating it into the two effects it actually contains — how many households in a zone visit at all, and how often the visitors among them return.

That separation is the substance of the page. A single decay curve blends both effects and hides the one that matters commercially: a zone twenty minutes away may contribute few customers who shop weekly, or many who shop twice a year, and those two markets need completely different treatment in a suitability score and in a media plan.

Prerequisites

  • Corrected visit aggregates by origin zone and period, from correcting panel bias in mobility datasets.
  • A travel-time matrix from each origin zone to the store, from the routing layer.
  • Visitor counts as well as visit counts — the vendor’s distinct-device figure per zone, which is what makes the two effects separable.
  • Python packages: pandas, numpy, scipy for the fit. Install with pip install pandas numpy scipy.

Configuration and execution parameters

Parameter Value for this task Notes
time_bins 0–5, 5–10, 10–15, 15–25, 25–40 min Wider at the tail where data thins
min_visitors_per_bin 50 Below this the bin is reported, not fitted
penetration_basis visitors ÷ households in the zone The first effect
frequency_basis visits ÷ visitors The second effect
period 3 months Long enough for frequency to be meaningful
functional_form exponential, then power Fit both, choose on held-out error
report_both_curves yes The blended curve alone hides the mechanism

Splitting the measurement into penetration and frequency costs nothing — both come from figures the vendor already provides — and it is the difference between a curve that describes what happens and one that explains it.

Two effects hiding inside one decay curve Household penetration falls steeply with travel time, from 34 per cent within five minutes to 3 per cent beyond twenty-five. Visit frequency among those who do visit falls far more gently, from 3.4 visits a month to 1.6. Most of the decay is people deciding whether to come at all, not how often. Distance decides whether they come, far more than how often travel time band household penetration visits per visitor blended index 0–5 min 34% 3.4 1.00 5–10 min 21% 2.9 0.53 10–15 min 12% 2.4 0.25 15–25 min 6% 2.0 0.10 25–40 min 3% 1.6 0.04 Penetration falls elevenfold across the range; frequency falls barely twofold. A marketing programme aimed at the far band should be recruiting new shoppers, not encouraging existing ones to return.

Annotated implementation

The fit is applied to each effect separately, on bins with enough observations to support it.

python
from __future__ import annotations

import numpy as np
import pandas as pd
from scipy.optimize import curve_fit

MIN_VISITORS = 50


def exponential(t: np.ndarray, a: float, lam: float) -> np.ndarray:
    return a * np.exp(-lam * t)


def power_law(t: np.ndarray, a: float, beta: float) -> np.ndarray:
    return a * np.power(1.0 + t / 10.0, -beta)


def split_effects(visits: pd.DataFrame, households: pd.DataFrame,
                  travel: pd.DataFrame) -> pd.DataFrame:
    """Penetration and frequency, per origin zone, from the same aggregates."""
    df = (visits.merge(households, on="zone_id")
                .merge(travel, on="zone_id"))
    df["penetration"] = df["visitors"] / df["households"]
    df["frequency"] = df["visits"] / df["visitors"].clip(lower=1)
    df["fittable"] = df["visitors"] >= MIN_VISITORS
    return df


def fit_curve(df: pd.DataFrame, column: str) -> dict:
    """Fit both families on the fittable rows; choose on held-out error."""
    data = df.loc[df["fittable"]]
    t, y = data["travel_minutes"].to_numpy(), data[column].to_numpy()

    # Simple holdout: every third row, ordered by travel time, is held out so
    # the split spans the range rather than sampling one end of it.
    order = np.argsort(t)
    holdout = order[::3]
    train = np.setdiff1d(order, holdout)

    results = {}
    for name, fn in (("exponential", exponential), ("power", power_law)):
        params, _ = curve_fit(fn, t[train], y[train], maxfev=10_000)
        pred = fn(t[holdout], *params)
        results[name] = {
            "params": [round(float(p), 4) for p in params],
            "holdout_mae": float(np.abs(pred - y[holdout]).mean()),
        }
    results["chosen"] = min(("exponential", "power"),
                            key=lambda k: results[k]["holdout_mae"])
    return results

Holding out every third row by travel-time order rather than at random matters more than it looks. A random holdout in a dataset dominated by nearby zones tests the fit almost entirely on the near bands, which is where every functional form agrees; spreading the holdout across the range tests the tail, which is where they differ and where the decay parameter is actually decided.

Failure modes and debugging

Fitting on zones with a handful of visitors. A zone with four visitors produces a frequency figure of 1.0 or 3.0 depending on one person’s habits. The visitor floor keeps those rows out of the fit while still reporting them, which is the right treatment: they are data, they are not evidence about the curve.

Confusing visits with visitors. The two columns look interchangeable and are not. Dividing visits by households conflates the effects and produces a curve that is neither penetration nor frequency, and no amount of careful fitting recovers the distinction afterwards.

Ignoring the competitive field. A zone’s penetration is low both because it is far away and because there is a rival between it and your store. Fitting decay without any competition term attributes the rival’s effect to distance, which then transfers badly to a market with a different competitive structure — the same argument that motivates a gravity model over a pure distance decay.

Fitting one curve for all formats. Convenience and destination formats have genuinely different decay, and a pooled fit describes neither. Fit per format, and where a format has too few stores, borrow explicitly and record the borrowing.

Fitted decay differs by format more than by market Convenience stores fit a penetration decay of 0.21 per minute, supermarkets 0.11, and destination retail 0.05. Within each format the spread across markets is far smaller than the differences between formats, which is the argument for fitting per format rather than per market. Format decides the curve; market shifts it slightly convenience λ = 0.21 supermarket λ = 0.11 destination retail λ = 0.05 spread across markets within a format: ±0.02 · spread across formats: 0.16 A single pooled parameter would be wrong for every format and right for none, and the error would systematically favour whichever format the estate happens to have most of. Penetration decay, exponential form, fitted on three months of corrected panel visits.

Verification

  • Check the fit against a held-out band, not against the data it was fitted on. Every reasonable family fits the near bands well.
  • Compare the fitted decay with one derived from loyalty data where both exist. Agreement is strong evidence; disagreement usually points at the panel’s home-zone attribution rather than at the fit.
  • Confirm the two effects multiply back to the observed visits per household within tolerance. If they do not, a denominator is mismatched somewhere.
  • Re-fit with a competition term on a sample of stores and see how much the distance parameter moves. A large move means the pure decay was absorbing competitive structure.
Choosing the functional form on held-out bands On in-sample fit the three forms are nearly indistinguishable, with R-squared of 0.96, 0.97 and 0.97. On held-out bands the exponential form gives a mean absolute error of 0.021, the power law 0.014 and the Gaussian 0.038 — so the choice that looked arbitrary in sample is clear out of sample. In-sample they agree; out of sample they do not form in-sample R² held-out MAE verdict exponential 0.96 0.021 good power law 0.97 0.014 chosen Gaussian 0.97 0.038 over-fitted Report both columns in the calibration record. The in-sample figure is what a reviewer will ask for and the held-out figure is what decided the answer. Re-run the choice when a year of new data arrives; the best form can change as the tail fills in.

Frequently Asked Questions

Does this replace the decay calibrated from loyalty data?

It complements it and covers different ground. Loyalty data is unbiased about your own customers and silent about everyone else; a panel sees the whole market including the households who never joined the scheme, at the cost of sampling bias. Fitting both and comparing is the strongest position: agreement raises confidence in both, and disagreement usually localises to a specific segment — often older or lower-income households, where panel coverage is thinnest and loyalty membership highest.

How does frequency decay interact with basket size?

Directly and in the opposite direction, which is why frequency alone can mislead. Customers travelling further tend to visit less often and spend more per visit, so a zone contributing few, infrequent visitors can still contribute meaningful revenue. Where basket data exists by origin — usually from loyalty transactions — the honest demand estimate multiplies penetration, frequency and basket rather than stopping at the first two.

What period should frequency be measured over?

Three months for most retail formats: long enough that a monthly shopper appears as a repeat visitor rather than a one-off, short enough not to blur a seasonal shift. Weekly frequency is noise at zone level, and annual frequency hides the seasonality that the site’s assortment depends on. Whatever period is chosen belongs in the column name, since “frequency” without a period is uninterpretable.

How should the fitted curve be stored and used?

As a small, versioned artifact — family, parameters, fitting period, format, market, and the held-out error — read by the scoring pipeline rather than hard-coded into it. A decay parameter embedded in a scoring script is invisible to review and impossible to attribute when a ranking moves; the same parameter in a dated record is a line in the manifest of every scored run. This is the same versioning discipline that the network build identifier applies to the road graph, and for the same reason.

What if the panel shows almost no decay?

Check the place match before believing it. A flat decay curve is far more often a symptom than a finding: a vendor place that covers a whole retail park attracts visitors from everywhere and produces a curve that barely falls, because the “store” being measured is really a destination centre. Genuine flat decay does occur for true destination formats, and it is distinguishable by the fact that the near bands are also low — a flat curve that starts high is a matching problem, and a flat curve that starts low is a destination.

Should the decay be re-fitted per store or per format?

Per format, with per-store deviation reported rather than fitted. A store-level fit has too few observations in the far bands to be stable, and it produces a parameter that mostly describes that store’s particular competitive surroundings. Fitting per format and then flagging stores whose observed decay departs materially from their format’s curve gives both a usable parameter and a short list of locations behaving unusually — which is more useful than a hundred noisy per-store curves.

What does the curve say about marketing rather than site selection?

Quite a lot, and it is often the first place the analysis pays for itself. Because penetration falls far faster than frequency, the far bands of a catchment contain households who have not been recruited rather than households who shop rarely — so a promotion aimed there is an acquisition programme, while the same promotion in the near bands is a frequency programme. Those need different offers, different channels and different measures of success, and the split falls straight out of a curve that was fitted for another purpose entirely.

← Back to Mobility & Foot Traffic Data Integration