Quantifying Leakage with Spatial Control Areas
Leakage is the part of a carbon project’s claim that cannot be measured directly, only inferred. The question — how much of the deforestation this project prevented simply moved somewhere else — is counterfactual, and the answer depends entirely on the credibility of the comparison you construct. This guide implements that comparison, within permanence, reversal and leakage monitoring in the spatial modeling and carbon stock validation stack, and it treats control selection as the primary engineering task rather than a preliminary to it.
The estimator itself is simple — a difference-in-differences on deforestation rates — and takes twenty lines. The work is everything around it: choosing controls that were genuinely similar to the project before it started, proving that similarity with balance diagnostics, defining a leakage belt whose width is justified rather than assumed, and freezing all of it before the first monitoring period so the choices cannot drift toward the answer you want.
Root Cause Analysis
Leakage estimates fail for three reasons, and only one of them is statistical.
The first is role confusion between the belt and the controls. The leakage belt is where displaced activity is expected to land — the treated area for leakage purposes. The controls supply the counterfactual rate the belt is compared against. Using the belt as a control subtracts the effect from itself and produces an estimate near zero, which is why a suspiciously small leakage figure should always prompt a check of which polygon played which role. The three geometries — project, belt, controls — must be distinct, and the controls must be far enough away that project activity does not plausibly reach them.
The second is matching on the wrong things, or on things measured after treatment. Controls must be matched on the drivers of deforestation as they stood before the project: slope, elevation, distance to road and to market, tenure class, protected status, and the historical deforestation rate over a pre-project window. Matching on a post-treatment variable — current forest cover, for instance — conditions on an outcome and biases the estimate in an unpredictable direction. Matching on convenience — administrative boundaries, a neighbouring reserve — usually produces controls with systematically lower accessibility than the project, which understates the counterfactual rate and therefore overstates the project’s benefit while understating leakage.
The third is assuming parallel trends without testing them. Difference-in-differences is unbiased only if, absent the project, the belt and controls would have followed parallel paths. That assumption is testable on pre-project data: if belt and control deforestation rates diverged before the project existed, they will not become parallel because you need them to be. Testing it is cheap and almost never done, and its absence is the first thing a competent verifier probes.
Diagnostic Pipeline / Pre-Flight Validation
The pre-flight computes balance diagnostics and the parallel-trends test, and refuses to proceed when either fails. Standardised mean difference is the standard balance statistic; below 0.1 in absolute value is the conventional threshold, and any covariate above 0.25 makes the match indefensible regardless of what the others do.
from dataclasses import dataclass
import numpy as np
import pandas as pd
import structlog
log = structlog.get_logger()
SMD_GOOD = 0.10
SMD_FAIL = 0.25
PRE_TREND_P_GATE = 0.10 # a pre-trend interaction this significant invalidates DiD
@dataclass(frozen=True)
class MatchQuality:
control_set_id: str
n_treated: int
n_control: int
worst_covariate: str
worst_smd: float
pre_trend_p: float
usable: bool
reason: str | None
def standardised_mean_difference(treated: np.ndarray, control: np.ndarray) -> float:
"""SMD: mean difference in pooled-standard-deviation units. Scale-free, so it
is comparable across covariates measured in metres, percent, and categories."""
pooled = np.sqrt((treated.var(ddof=1) + control.var(ddof=1)) / 2.0)
return float((treated.mean() - control.mean()) / max(pooled, 1e-9))
def check_balance(units: pd.DataFrame, covariates: list[str],
control_set_id: str) -> tuple[dict[str, float], str, float]:
"""Balance on every matched covariate, reported individually.
A mean SMD hides the covariate that is wrong; verifiers ask for the worst one,
so that is what this returns.
"""
treated = units[units["group"] == "belt"]
control = units[units["group"] == "control"]
smds = {c: standardised_mean_difference(treated[c].to_numpy(), control[c].to_numpy())
for c in covariates}
worst = max(smds, key=lambda c: abs(smds[c]))
log.info("leakage.balance", control_set_id=control_set_id,
smds={k: round(v, 3) for k, v in smds.items()},
worst_covariate=worst, worst_smd=round(smds[worst], 3))
return smds, worst, smds[worst]
def parallel_trends_test(panel: pd.DataFrame) -> float:
"""Regress pre-project rate on group x time. A significant interaction means
the two series were already diverging, so DiD attributes to the project a
difference that predates it."""
import statsmodels.formula.api as smf
pre = panel[panel["period"] < 0].copy()
pre["is_belt"] = (pre["group"] == "belt").astype(int)
model = smf.ols("rate ~ is_belt * period", data=pre).fit()
p = float(model.pvalues.get("is_belt:period", 1.0))
log.info("leakage.pre_trends", interaction_p=round(p, 4),
n_pre_periods=int(pre["period"].nunique()))
return p
def preflight(units: pd.DataFrame, panel: pd.DataFrame, covariates: list[str],
control_set_id: str) -> MatchQuality:
smds, worst, worst_smd = check_balance(units, covariates, control_set_id)
p = parallel_trends_test(panel)
reason = None
if abs(worst_smd) > SMD_FAIL:
reason = f"covariate_imbalance:{worst}"
elif p < PRE_TREND_P_GATE:
reason = "pre_trends_not_parallel"
elif abs(worst_smd) > SMD_GOOD:
log.warning("leakage.balance.marginal", covariate=worst,
smd=round(worst_smd, 3), note="report the imbalance explicitly")
quality = MatchQuality(
control_set_id=control_set_id,
n_treated=int((units["group"] == "belt").sum()),
n_control=int((units["group"] == "control").sum()),
worst_covariate=worst, worst_smd=round(worst_smd, 3),
pre_trend_p=round(p, 4), usable=reason is None, reason=reason,
)
if reason:
log.error("leakage.preflight.failed", **quality.__dict__)
return quality
Deterministic Transformation Logic
With a validated control set, the estimator runs each period. It computes the difference-in-differences on rates, converts the excess rate to a tonnage using the belt’s area and the applicable carbon density, floors the result at zero, and reports a bootstrap confidence interval alongside the raw uncorrected estimate.
import numpy as np
import pandas as pd
import structlog
log = structlog.get_logger()
BOOTSTRAP_DRAWS = 2000
RNG_SEED = 20260811 # fixed: a leakage figure must be byte-reproducible
def difference_in_differences(panel: pd.DataFrame) -> dict:
"""Excess deforestation rate in the belt relative to matched controls.
Rates, not counts: belt and control areas differ, and comparing counts would
measure the sampling design rather than the landscape.
"""
def mean_rate(group: str, post: bool) -> float:
sel = (panel["group"] == group) & ((panel["period"] >= 0) == post)
return float(panel.loc[sel, "rate"].mean())
belt_delta = mean_rate("belt", True) - mean_rate("belt", False)
ctrl_delta = mean_rate("control", True) - mean_rate("control", False)
excess = belt_delta - ctrl_delta
log.info("leakage.did", belt_delta=round(belt_delta, 5),
control_delta=round(ctrl_delta, 5), excess=round(excess, 5))
return {"belt_delta": belt_delta, "control_delta": ctrl_delta, "excess": excess}
def bootstrap_interval(panel: pd.DataFrame, draws: int = BOOTSTRAP_DRAWS) -> tuple[float, float]:
"""Resample UNITS, not observations, so the interval respects the fact that a
unit's periods are correlated with each other."""
rng = np.random.default_rng(RNG_SEED)
units = panel["unit_id"].unique()
estimates = np.empty(draws)
for i in range(draws):
sample = rng.choice(units, size=len(units), replace=True)
resampled = pd.concat([panel[panel["unit_id"] == u] for u in sample])
estimates[i] = difference_in_differences(resampled)["excess"]
return float(np.percentile(estimates, 2.5)), float(np.percentile(estimates, 97.5))
def leakage_tonnage(
panel: pd.DataFrame, belt_area_ha: float, carbon_density_tco2e_ha: float,
control_set_id: str, period: str, years: float,
) -> dict:
"""Excess rate -> tonnes, floored at zero, with the raw estimate preserved."""
did = difference_in_differences(panel)
ci_low, ci_high = bootstrap_interval(panel)
raw_tco2e = did["excess"] * belt_area_ha * carbon_density_tco2e_ha * years
reported = max(0.0, raw_tco2e)
significant = not (ci_low <= 0.0 <= ci_high)
if raw_tco2e < 0:
# Publish it, do not credit it. A project does not earn tonnes because its
# neighbours happened to clear less than the controls this period.
log.info("leakage.negative_estimate", raw_tco2e=round(raw_tco2e, 1),
note="reported as zero; raw value retained for transparency")
result = {
"period": period,
"control_set_id": control_set_id,
"excess_rate": round(did["excess"], 5),
"excess_rate_ci": (round(ci_low, 5), round(ci_high, 5)),
"raw_tco2e": round(raw_tco2e, 1),
"reported_tco2e": round(reported, 1),
"significant": significant,
"belt_area_ha": belt_area_ha,
"carbon_density_tco2e_ha": carbon_density_tco2e_ha,
"estimator": "difference-in-differences/matched-controls/v1",
"bootstrap_draws": BOOTSTRAP_DRAWS,
"rng_seed": RNG_SEED,
}
log.info("leakage.period.complete", **{k: v for k, v in result.items()
if k != "excess_rate_ci"})
return result
Two details are load-bearing. The bootstrap resamples units, not observations, because a unit’s periods are correlated and treating them as independent produces an interval that is far too narrow — a common and flattering error. And the RNG seed is fixed and recorded, so the interval is byte-reproducible; a confidence interval that changes on re-run is not evidence.
Compliance Gating & Audit Trail Generation
The leakage record must carry enough for a verifier to re-run the estimate without re-deciding anything. That means the control set identifier and version, the belt definition with the justification for its width, the covariates matched on and their balance statistics, the parallel-trends test result, the estimator name and version, the bootstrap seed and draw count, and both the raw and reported tonnages. Store it under the schema contract in the MRV data schema reference and chain it through MRV data lineage and provenance tracking.
The belt width is where methodologies differ most, and where an unjustified choice is most visible. Most frameworks expect the width to reflect the displacement distance of the specific drivers — smallholder agricultural expansion displaces over a few kilometres, commercial logging over tens — supported by evidence from the local context rather than a default. Record the evidence, because a belt chosen to make the number small is the first thing a reviewer will suspect if the justification is missing. The requirements as they differ across frameworks are compared in Verra VM0047 vs Gold Standard GIS requirements.
One gate is worth enforcing in code rather than in review: the control set must be immutable after validation. Implement it as a content hash over the control unit identifiers, stored with the project record and asserted at the start of every monitoring run. If someone changes the control set, the run fails rather than quietly producing a better number.
Production Integration
- Assemble the candidate pool at validation from units outside both the project and the belt, with pre-project covariates only.
- Match with a caliper, discarding treated units with no acceptable match rather than forcing a poor one — and record how many were discarded, since a large fraction narrows what the estimate covers.
- Run the pre-flight: per-covariate balance and the parallel-trends test. Fail loudly, and prefer widening the candidate pool over relaxing the thresholds.
- Freeze the matched set with an identifier, a version, and a content hash. This is the step teams skip and later regret.
- Estimate each period with the frozen set, bootstrapping over units with a fixed seed.
- Report raw and floored tonnage with the interval, and deduct the floored figure from net issuance.
For batch operation, the bootstrap dominates runtime; precompute per-unit period aggregates so each draw is a group-by over a small table rather than a re-read of the rasters. The change rates themselves come from the same composites used by temporal aggregation for land-use change, and must be computed identically for belt and controls — a different cloud threshold on the two sets is a silent, systematic bias.
Frequently Asked Questions
How wide should the leakage belt be?
Wide enough to contain the displacement distance of the drivers actually operating, and no wider. Smallholder agricultural expansion typically displaces a few kilometres; commercial logging and ranching displace much further, sometimes beyond any belt, which is why market leakage is handled separately by discount factors rather than spatially. Justify the width with local evidence — historical displacement patterns, road networks, land-tenure structure — and record the justification. A belt chosen without justification is treated as chosen to suit the result.
What if I cannot find well-matched controls?
Then say so and narrow the claim. Options in order of preference: widen the candidate pool geographically while keeping the covariate constraints; relax to coarser matching on the strongest drivers only, and report the resulting imbalance; or, where neither works, use a conservative default leakage deduction from the methodology instead of a spatial estimate. What is not acceptable is proceeding with unbalanced controls and reporting the number as if it were an estimate.
Should leakage be estimated per period or cumulatively?
Per period, then accumulated — because the belt and control rates both change over time and a single cumulative comparison hides that. Per-period estimation also lets you detect the characteristic pattern of displacement dying away as the belt’s accessible forest is exhausted, which is real and materially reduces later-period leakage. Report the per-period series alongside the cumulative figure.
Does difference-in-differences handle spatial autocorrelation?
Not by itself, and ignoring it makes the confidence interval far too narrow. Neighbouring units share drivers and shocks, so treating them as independent observations overstates the effective sample size. Resampling units in the bootstrap helps; clustering at a coarser spatial level — watershed, administrative unit — helps more where the correlation length is long. Report which clustering you used, because it changes the interval substantially and a verifier will want to know.
What kinds of leakage does a spatial estimator miss entirely?
Two, and both matter. Market leakage — where reduced timber or agricultural supply from the project raises prices and induces production somewhere far outside any belt, potentially on another continent — is invisible to a spatial comparison by construction, and methodologies handle it with fixed discount factors derived from economic modelling rather than from your imagery. Activity shifting by mobile actors who relocate well beyond the belt is similarly out of reach; a logging operation that moves two hundred kilometres will not appear in any defensible belt. Report the spatial estimate for what it is — displacement into the monitored belt — and apply the methodology’s market-leakage factor on top of it rather than in place of it. Presenting a spatial figure as total leakage is a substantive misstatement even when the arithmetic is impeccable.
How do I keep the belt and control rates comparable over time?
By computing them through exactly the same code path with exactly the same parameters, and asserting that in the pipeline rather than trusting it. The failure is subtle: a cloud-cover threshold, a compositing window, or a minimum-mapping-unit that differs between the two sets introduces a systematic difference in measured rate that has nothing to do with land use. Because belts are usually closer to infrastructure and controls are often further into the interior, their cloud and observation statistics genuinely differ, so a threshold that discards more observations in one set than the other quietly biases the comparison. Compute both from one function call over a combined unit table, record the parameters on the result, and assert that the observation counts per unit are within a stated ratio — a divergence there is a signal in its own right.
Does leakage decline over time, and should the estimate reflect that?
It usually does, and the estimate should reflect it because the per-period series is what reveals it. Displacement concentrates in the belt’s accessible forest, and once that is exhausted the belt’s excess rate falls back toward the control rate — a pattern visible in most long-running avoided-deforestation projects. Reporting a single leakage rate applied uniformly across the crediting period therefore overstates later-period leakage and understates early-period leakage, and a verifier comparing your figure against the observable series will notice. Estimate per period, report the series, and let the accumulation be the sum rather than a projection.
Can I use the same controls for leakage and for the baseline?
It is usually a mistake even though it is tempting. The baseline counterfactual asks what would have happened inside the project area; the leakage counterfactual asks what would have happened in the belt. The two areas differ systematically — belts are typically more accessible than project interiors — so a control set matched to one is generally unmatched to the other. Build and validate two sets, and check balance separately for each.
Related guides
- Permanence, Reversal & Leakage Monitoring — the parent topic and the three post-issuance obligations.
- Detecting Carbon Reversals from Satellite Time Series — the sibling obligation, measured inside the boundary.
- Forest Carbon Baseline & Additionality Modeling — the other counterfactual, and why it needs its own control set.
- Verra VM0047 vs Gold Standard GIS Requirements — how the frameworks differ on belts and controls.