Correcting Panel Bias in Mobility Datasets
This page solves one exact task: converting raw panel visit counts into estimates scaled to the population, by weighting each origin zone according to how well the panel covers it and then post-stratifying on the demographics that drive both device ownership and shopping behaviour.
This is where most of the accuracy in a mobility pipeline is won. An uncorrected panel over-represents younger, urban, higher-income households — precisely the segments whose shopping patterns differ most from the population average — so an uncorrected trade area is a picture of a subset presented as a picture of a market.
Prerequisites
- Panel device counts per origin zone, supplied by the vendor or derivable from the aggregates.
- Population denominators per zone from ACS or equivalent estimates, matched to the same geography and vintage.
- Demographic composition per zone for post-stratification — age bands, income bands, household size.
- Python packages:
pandasandnumpy. Install withpip install pandas numpy. - The parent context. Mobility and foot traffic data integration explains why levels need correcting and shares do not.
Configuration and execution parameters
| Parameter | Value for this task | Notes |
|---|---|---|
coverage_basis |
devices ÷ adult population | Per zone, per month |
min_devices_per_zone |
30 | Below this the weight is unstable |
weight_cap |
5× the median weight | Prevents one sparse zone dominating |
post_strata |
age × income band | Two dimensions is usually enough |
smoothing |
shrink toward the market mean | For zones near the device floor |
recompute |
monthly | Panels move; a stale weight is a silent bias |
report_effective_n |
yes | The honest sample size after weighting |
weight_cap is the parameter that separates a working correction from an unstable one. A zone with twelve panel devices and four thousand adults produces a weight in the hundreds, and one visit from that zone then contributes as much as an entire neighbourhood. Capping the weight biases the estimate slightly and stops a single observation from dominating a market — a trade almost always worth making.
Annotated implementation
The correction is two stages: a per-zone weight that scales devices to population, and a post-stratification adjustment that fixes the residual demographic imbalance within zones.
from __future__ import annotations
import numpy as np
import pandas as pd
MIN_DEVICES = 30
WEIGHT_CAP_MULTIPLE = 5.0
def zone_weights(panel: pd.DataFrame, population: pd.DataFrame) -> pd.DataFrame:
"""Weight = adults per panel device, capped and smoothed.
panel: zone_id, devices, month
population: zone_id, adults
"""
df = panel.merge(population, on="zone_id", how="inner")
df["raw_weight"] = df["adults"] / df["devices"].clip(lower=1)
# Zones below the device floor get the market median rather than their own
# unstable ratio: one device in a zone of 4,000 adults is not a measurement.
market_median = df.loc[df["devices"] >= MIN_DEVICES, "raw_weight"].median()
df["weight"] = np.where(df["devices"] >= MIN_DEVICES,
df["raw_weight"], market_median)
cap = WEIGHT_CAP_MULTIPLE * market_median
df["capped"] = df["weight"] > cap
df["weight"] = df["weight"].clip(upper=cap)
return df[["zone_id", "month", "devices", "adults", "weight", "capped"]]
def post_stratify(visits: pd.DataFrame, weights: pd.DataFrame,
strata: pd.DataFrame) -> pd.DataFrame:
"""Adjust so the weighted visitor mix matches the population mix by stratum.
strata: zone_id, stratum, population_share
"""
v = visits.merge(weights[["zone_id", "month", "weight"]],
on=["zone_id", "month"], how="inner")
v["weighted_visits"] = v["visits"] * v["weight"]
joined = v.merge(strata, on="zone_id", how="left")
observed = (joined.groupby(["store_id", "stratum"])["weighted_visits"].sum()
/ joined.groupby("store_id")["weighted_visits"].transform("sum").iloc[0])
target = strata.groupby("stratum")["population_share"].mean()
factor = (target / observed).replace([np.inf, -np.inf], np.nan).fillna(1.0)
joined["strat_factor"] = joined["stratum"].map(factor).clip(0.5, 2.0)
joined["adjusted_visits"] = joined["weighted_visits"] * joined["strat_factor"]
return joined
def effective_sample_size(weights: pd.Series) -> float:
"""Kish's effective n — the honest sample size once weights are unequal."""
return float(weights.sum() ** 2 / (weights ** 2).sum())
The effective sample size is the diagnostic worth reporting alongside every corrected figure. A store whose weighted estimate rests on 900 raw device-visits with highly unequal weights may have an effective sample of 180, and that number tells a reader far more about how much to trust the estimate than the raw count does.
Failure modes and debugging
Weighting a zone with almost no devices. The uncapped weight explodes, and one visitor from that zone becomes hundreds of estimated visitors. The device floor and the cap exist for this, and the count of capped zones belongs in the output — a market where a third of zones are capped is a market where the panel is too thin to support this analysis.
Correcting with a mismatched population vintage. Weights built from last year’s population estimates and this year’s devices drift in the zones where the population grew fastest, which are usually the ones a retailer is most interested in. Pin both to the same vintage and recompute when either changes.
Post-stratifying on too many dimensions. Age by income by household size by tenure produces strata with a handful of devices each, and the resulting factors are noise. Two dimensions with three or four bands each is usually the practical ceiling.
Correcting shares that did not need it. Applying zone weights to a quantity that is already a ratio within a zone double-counts the correction. Weights fix the comparison between zones; they do not belong inside a within-zone proportion.
Verification
- Compare corrected composition against loyalty data for stores where both exist. This is the only external check available, and agreement within a few points is a strong signal the weights are sensible.
- Report the effective sample size for every store-level estimate. A figure a tenth of the raw count means the weights are doing a great deal of work and the estimate is correspondingly fragile.
- Track the share of capped zones by market. Rising caps mean a thinning panel, which invalidates level comparisons across the period.
- Re-run the correction with the cap removed on a sample and compare. A result that moves substantially is one where a handful of sparse zones are driving the estimate.
Frequently Asked Questions
Should the vendor’s own weights be used instead?
Use them as a starting point and validate them the same way. Vendors weight to national or regional benchmarks, which is appropriate for their general audience and not necessarily for a retailer whose markets are a specific subset. Comparing vendor-weighted output against your loyalty mix, as above, tells you quickly whether their correction suffices; where it does, using it saves effort and is more defensible than a home-made alternative.
How often do weights need recomputing?
Monthly, alongside the data. Panels change composition continuously, and a weight computed once and reused is a bias that grows silently. The computation is cheap — a join and a division — so the only reason it is ever skipped is that nobody built it into the pipeline.
What if population denominators are unavailable at the origin geography?
Aggregate up until they are. Weighting at tract level with reliable denominators beats weighting at block-group level with interpolated ones, because the interpolation error enters every weight and propagates to every estimate. The loss of resolution is visible and bounded; the error from bad denominators is neither.
Does correction fix the level, or only the mix?
Both, but with different confidence. The mix — which zones and which demographics — is corrected well, because that is what the weights are constructed to fix. The absolute level still depends on the vendor’s expansion assumptions and on visit-detection thresholds, so it should be anchored against transactions before it is used as a demand figure. The safe rule remains: trust the corrected shares, calibrate the corrected levels.
Can the correction be validated without loyalty data?
Partially. Two internal checks are available to anyone: the corrected visitor mix for a store should resemble the demographic mix of its own catchment more closely than the uncorrected one does, and the corrected level should scale sensibly with store size within a format. Neither is proof, and both catch a correction that has gone badly wrong. The external check against transaction or loyalty data remains the only one that speaks to accuracy rather than plausibility, which is why it is worth arranging even for a subset of stores.
Does correction remove the need for care about levels?
No, and treating it as if it does is the commonest way a corrected pipeline still misleads. Weighting fixes the composition of the sample; it cannot fix a vendor’s expansion factor, a visit-detection threshold that suits a different format, or a place polygon that includes the shop next door. The corrected level is a better estimate in the same units as before — which are still not transactions — so the calibration step remains necessary and the honest label on a corrected figure is “estimated visits, panel-derived” rather than “visits”.
Should the weights be published with the estimates?
Yes, at least in summary. A consumer of a corrected figure needs to know the median weight, the share of capped zones and the effective sample size to interpret it, and those three numbers travel easily as columns. Publishing the full per-zone weight table alongside is better still, because it lets a sceptical analyst reproduce the correction rather than take it on trust — which is exactly the scrutiny a panel-derived number should be able to withstand.
Related
- Mobility & Foot Traffic Data Integration — where correction sits in the pipeline.
- Validating Foot Traffic Estimates Against Point-of-Sale Counts — anchoring the level.
- Weighting Demographic Variables for Target Audiences — the related discipline on census attributes.
← Back to Mobility & Foot Traffic Data Integration