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:

Gate-tap gap to bounded leakage estimate Raw gate entries and paid taps enter a correction stage that adjusts entries for sensor reliability and legitimate non-tap passage. A guard tests whether corrected unpaid entries exceed zero; if not, the result is no measurable leakage attributed to a sensor or clock fault. If unpaid entries are positive, the evasion rate is computed as unpaid over corrected entries, then leakage is priced as unpaid entries times the average fare in Decimal, and finally a confidence interval is attached to produce a low and high bound. yes no Raw counts entries E, paid taps P Correct entries sensor + non-tap Unpaid > 0? No measurable leakage sensor / clock fault Evasion rate unpaid / corrected Leakage (Decimal) unpaid × avg fare Confidence interval low / high bound

Step 1 — Correct the raw entry count

The raw gap EPE - P 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 nn fare-liable entries as a Bernoulli trial of evasion with probability r^\hat{r}; the standard error of that proportion is

SE=r^(1r^)nSE = \sqrt{\frac{\hat{r}\,(1-\hat{r})}{n}}

and the leakage band is the rate band times nn 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_reliability is correct. Split the period at the calibration boundary and estimate each half separately.
  • Very small samples. A near-empty station makes SESE large and the interval wider than the point estimate; the low_confidence flag 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 α\alpha; annotate and exclude such periods rather than reporting them as an evasion collapse.
  • Rounding at the cent. Keep the unpaid-count multiplication in Decimal and 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?
Because the raw gap conflates three things: real evasion, sensor undercount or overcount, and legitimate non-tapping passage. A beam sensor that misses 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?
For a busy station with hundreds or thousands of entries it is adequate, because the proportion is well away from 0 and 1 and the sample is large. It understates uncertainty for very small samples or extreme rates, which is exactly why the 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?
Correct, and that is the larger source of error. The sampling interval here assumes the correction factors are known; uncertainty in 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.

Part of Fare Evasion Analytics, within Fraud Detection & Revenue Protection.