Estimating Fare Evasion Rates from Gate Tap Gaps
This guide works one station end to end: take the gate-entry count from the faregate sensor and the validated paid-tap count from the AFC ledger over the same period, correct the raw gap for sensor bias and legitimate non-tap passage, convert the residual unpaid entries into a Decimal revenue-leakage figure, and attach a confidence interval so the number survives scrutiny. It is the single-platform mechanics behind the Fare Evasion Analytics topic, within Fraud Detection & Revenue Protection. The counts are assumed already deduplicated and UTC-aligned; this page owns only the arithmetic from gap to sized, bounded leakage.
Gap-to-leakage flow
The estimate is a short pipeline with exactly one guard: if the corrected entry count does not exceed paid taps, there is no measurable leakage and the residual is a data fault, not evasion. The flow below traces the counts down to a bounded leakage figure:
Step 1 — Correct the raw entry count
The raw gap is never the evasion count. A beam sensor undercounts tailgaters, so the true entry count is higher than the sensor reports; and some entries are legitimately non-tapping — staff, accessibility escorts, free-scheme holders on a swing gate — so not every entry owes a fare. Apply both corrections before differencing. Reliability above 1.0 is valid for an optical counter that overcounts luggage; the function handles both directions.
from __future__ import annotations
import logging
from decimal import Decimal, ROUND_HALF_UP
logger = logging.getLogger("transit.gate_gap")
class GapEstimationError(Exception):
"""Raised when the counts cannot yield a meaningful evasion estimate."""
def corrected_entries(
raw_entries: int,
sensor_reliability: Decimal, # 1 - beta; 0.95 means the sensor sees 95% of entries
non_tap_fraction: Decimal, # alpha; fraction of true entries that legitimately do not tap
) -> Decimal:
"""Scale raw sensor entries up for undercount, then keep only fare-liable entries."""
if raw_entries < 0:
raise GapEstimationError("Entry count cannot be negative.")
if sensor_reliability <= 0:
raise GapEstimationError("Sensor reliability must be positive.")
if not (Decimal("0") <= non_tap_fraction < Decimal("1")):
raise GapEstimationError("Non-tap fraction must be in [0, 1).")
true_entries = Decimal(raw_entries) / sensor_reliability
fare_liable = true_entries * (Decimal("1") - non_tap_fraction)
logger.debug("raw=%d true=%.1f fare_liable=%.1f", raw_entries, true_entries, fare_liable)
return fare_liable
Step 2 — Rate and Decimal leakage
The evasion rate is the fraction of fare-liable entries that never produced a paid tap; the leakage is the count of those unpaid entries priced at the segment’s average fare. Both stay in Decimal so the figure is reproducible to the cent, and the unpaid count is clamped at zero — a station where paid taps exceed corrected entries is a data fault, surfaced by the guard, not negative evasion.
def evasion_rate(paid_taps: int, fare_liable: Decimal) -> Decimal:
"""Fraction of fare-liable entries with no matching paid tap, clamped to [0, 1]."""
if fare_liable <= 0:
raise GapEstimationError("Fare-liable entries resolved to zero or less.")
rate = Decimal("1") - (Decimal(paid_taps) / fare_liable)
if rate < 0:
logger.warning("Negative raw rate %.4f clamped to 0 (paid exceeds corrected entries).", rate)
return Decimal("0")
return rate.quantize(Decimal("0.0001"))
def leakage_cents(paid_taps: int, fare_liable: Decimal, avg_fare_cents: Decimal) -> Decimal:
"""Priced leakage in minor units: unpaid fare-liable entries times the average fare."""
if avg_fare_cents <= 0:
raise GapEstimationError("Average fare must be positive.")
unpaid = fare_liable - Decimal(paid_taps)
if unpaid <= 0:
return Decimal("0")
return (unpaid * avg_fare_cents).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
Step 3 — Attach a confidence interval
A single-period point estimate without bounds invites false precision. Treat each of the fare-liable entries as a Bernoulli trial of evasion with probability ; the standard error of that proportion is
and the leakage band is the rate band times times the average fare. The rate SE is a dimensionless proportion, so it is computed with math, but every figure that carries money units is converted back to Decimal before it leaves the function.
import math
from dataclasses import dataclass
@dataclass(frozen=True)
class GapEstimate:
station_id: str
fare_liable_entries: int
paid_taps: int
evasion_rate: Decimal
leakage_cents: Decimal
leakage_low_cents: Decimal
leakage_high_cents: Decimal
low_confidence: bool
def estimate_station(
station_id: str,
raw_entries: int,
paid_taps: int,
avg_fare_cents: Decimal,
sensor_reliability: Decimal,
non_tap_fraction: Decimal,
z: float = 1.96, # ~95% normal-approximation interval
min_entries: int = 200,
) -> GapEstimate:
"""Estimate evasion rate, Decimal leakage and a confidence band for one station-period."""
fare_liable = corrected_entries(raw_entries, sensor_reliability, non_tap_fraction)
rate = evasion_rate(paid_taps, fare_liable)
leak = leakage_cents(paid_taps, fare_liable, avg_fare_cents)
n = float(fare_liable)
r = float(rate)
se = math.sqrt(max(r * (1.0 - r), 0.0) / n) if n > 0 else 0.0
rate_low = Decimal(str(max(r - z * se, 0.0)))
rate_high = Decimal(str(min(r + z * se, 1.0)))
liable_dec = fare_liable
leak_low = (rate_low * liable_dec * avg_fare_cents).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
leak_high = (rate_high * liable_dec * avg_fare_cents).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
low_conf = raw_entries < min_entries
if low_conf:
logger.warning("Low-confidence estimate for %s: only %d raw entries.", station_id, raw_entries)
return GapEstimate(
station_id=station_id,
fare_liable_entries=int(fare_liable.to_integral_value(ROUND_HALF_UP)),
paid_taps=paid_taps,
evasion_rate=rate,
leakage_cents=leak,
leakage_low_cents=leak_low,
leakage_high_cents=leak_high,
low_confidence=low_conf,
)
Validation & Test Cases
The normal case is a busy gated station with a modest, believable evasion rate; the edge case shows why the sensor correction matters — the same raw gap yields a very different leakage once a known undercount is applied.
from decimal import Decimal
# Normal case: 10,000 raw entries, 9,200 paid taps, $2.90 average fare.
# Sensor sees 97% of entries; 2% of true entries legitimately do not tap.
est = estimate_station(
station_id="ST_CENTRAL",
raw_entries=10_000,
paid_taps=9_200,
avg_fare_cents=Decimal("290"),
sensor_reliability=Decimal("0.97"),
non_tap_fraction=Decimal("0.02"),
)
# Corrected fare-liable entries ~= 10000/0.97*0.98 = 10103; unpaid ~= 903.
assert est.evasion_rate > Decimal("0.08")
assert est.evasion_rate < Decimal("0.10")
assert est.leakage_cents > Decimal("250000") # > $2,500 leaked in the period
assert est.leakage_low_cents < est.leakage_cents < est.leakage_high_cents
assert est.low_confidence is False
# Edge case: severe sensor undercount. The beam only catches 80% of entries,
# so the true entry count is far higher and the gap widens sharply.
undercount = estimate_station(
station_id="ST_CENTRAL",
raw_entries=10_000,
paid_taps=9_200,
avg_fare_cents=Decimal("290"),
sensor_reliability=Decimal("0.80"),
non_tap_fraction=Decimal("0.02"),
)
# Corrected entries ~= 10000/0.80*0.98 = 12250; unpaid ~= 3050 -> much larger leakage.
assert undercount.evasion_rate > est.evasion_rate
assert undercount.leakage_cents > est.leakage_cents * 3
# Guard case: paid taps exceed corrected entries -> zero leakage, not negative.
fault = estimate_station(
station_id="ST_QUIET",
raw_entries=500,
paid_taps=520,
avg_fare_cents=Decimal("290"),
sensor_reliability=Decimal("1.00"),
non_tap_fraction=Decimal("0.02"),
)
assert fault.evasion_rate == Decimal("0.0000")
assert fault.leakage_cents == Decimal("0")
assert fault.low_confidence is True
The first block returns a rate near 9% and a leakage figure bracketed by its interval. The undercount block, differing only in sensor_reliability, more than triples the leakage — which is the whole point of publishing the sensor assumption alongside the number. The guard block shows the clamp: more paid taps than corrected entries is a fault, reported as zero leakage and flagged low-confidence rather than a nonsensical negative.
Edge Cases
- Reliability drift over the period. If a sensor’s firmware changed mid-period, no single
sensor_reliabilityis correct. Split the period at the calibration boundary and estimate each half separately. - Very small samples. A near-empty station makes large and the interval wider than the point estimate; the
low_confidenceflag exists so these never anchor a headline figure. - Concentrated legitimate non-tap. A one-off event (a station open day, an accessibility drill) spikes non-tap passage far above the calibrated ; annotate and exclude such periods rather than reporting them as an evasion collapse.
- Rounding at the cent. Keep the unpaid-count multiplication in
Decimaland quantize once at the end; quantizing the rate and the count separately compounds rounding error across a month.
Integration Note
This single-station routine is the unit the Fare Evasion Analytics topic runs across every station, direction, and hour, then sums in Decimal to a network leakage figure. Its closest operational sibling is Tap Pattern Anomaly Detection: the gap estimate tells enforcement where leakage concentrates, and the anomaly scorer identifies which media to inspect once officers are deployed to that platform and window. Feed both the same settled tap ledger so the aggregate gap and the individual flags are measured against one source of truth.
FAQ
Why not just report the raw gap between entries and paid taps as evasion?
20% of tailgaters understates true entries and hides evasion, while staff and accessibility passages inflate the gap without any fare owed. Correcting for both with sensor_reliability and non_tap_fraction is what separates a defensible estimate from a headline that collapses under audit.
Is a normal-approximation interval good enough for the confidence band?
low_confidence flag fires below min_entries. If you need tighter guarantees near the boundaries, swap the band for a Wilson score interval; the surrounding pipeline does not change.
The interval only reflects sampling noise — what about the sensor calibration being wrong?
sensor_reliability and non_tap_fraction is modelled separately at the topic level by re-running the estimate across the plausible range of those factors. Report both bands, or a combined one, so a reader sees that a mis-calibrated sensor can move the figure more than sampling noise does.
Related
- Fare Evasion Analytics — the parent topic that runs this method across every segment and sums the leakage.
- Tap Pattern Anomaly Detection — the real-time signal that turns a hot segment into an actionable inspection target.
- Revenue Reconciliation & Settlement — produces the settled paid-tap ledger this estimate differences against.
- Threshold Tuning Frameworks — where the confidence and entry-count thresholds are versioned.
Part of Fare Evasion Analytics, within Fraud Detection & Revenue Protection.