A fuel card is swiped at 2:14 in the morning, at a truck stop 2,400 miles from where the same card bought diesel the day before, for 300 gallons at a single pump. Fraud?
Two of those three facts are ordinary. A long-haul driver keeps odd hours and covers ground; 2,400 miles in a day is a team or a relay rather than an impossibility, and 300 gallons is a normal fill for a rig with two saddle tanks. The third fact, a single pump putting 300 gallons into a tank that holds 240, is the one that matters. A generic card-fraud model cannot pull the three apart, because it does not know what a truck is. It sees an unusual place, an unusual hour, and a large amount, and it either shrugs or flags them all.
This project, fuel-card-fraud-monitoring, is about the part a generic fraud model misses: the semantics of fuel. It is part of a short series about fraud signals that are not what they first appear, after a card-fraud ring that turned out to be an artefact of the data and a money mule the evidence did not support (https://github.com/gbadedata/mule-network-detection).
This one carries two traps, and they pull in opposite directions. One is the driver who looks guilty and is not. The other is the fraud that looks innocent and is not.
Fuel is not retail
Retail card-fraud models live in a world of merchants, amounts, and velocities, and they are good at it. A fuel card lives somewhere else, under physical constraints a retail model never learns:
- A tank has a size. A 240-gallon fill into a 240-gallon tank is fine. A 400-gallon fill is fuel going somewhere other than the truck.
- Diesel trucks burn diesel. A fuel card buying premium unleaded, or merchandise, or a cash-equivalent, is worth a second look.
- Trucks get a knowable range of miles per gallon. Between two fills the odometer and the gallons imply an economy, and two miles per gallon on a full tank means the fuel did not go into this truck.
- Trucks run corridors. A driver has a home base and a radius they work within, and their fuelling traces a lane across the map.
None of that is in a generic model, and all of it is in this one. The features are the physics: gallons against tank capacity, fuel-type match, implied miles per gallon from the odometer, speed between consecutive fills, distance from the card's own corridor. Each is computed strictly from what was known before the swipe, so nothing from the future leaks into
the score.
The driver who looks guilty
Here is the first trap. Suppose you flag a swipe as off-route when it is far from the card's home base. It reads as reasonable, and it is wrong, because a long-haul driver is always far from base. That is the job. Flag on absolute distance and every long-haul card lights up, the queue fills with drivers doing ordinary work, and the real off-route fraud is buried under them.
The fix is to measure deviation against the card's own history, not against an absolute:
# running centroid of the card's prior fuelling sites (strictly before this swipe) off_route = haversine(lat, lon, mean_lat_prior, mean_lon_prior)
# divide by the card's own prior roaming radius, so a driver who always ranges far
# is not mistaken for one that has suddenly gone off-route off_route_ratio = off_route / (prior_mean_off + 100)
A driver who always ranges 900 miles from their centre has a large off-route distance and a small ratio: normal for them. A driver who has always stayed within 150 miles and today sits 600 miles out has a modest distance by long-haul standards but a large ratio: that is the anomaly. The model leans on the ratio, and that is why a card that roams for a living is not punished for roaming.
Two layers, on purpose
The decision at the pump has to be instant and explainable. When a card is declined at the island, someone has to be able to say why, in words, that minute. A gradient-boosted probability is not that. So the system has two layers doing two jobs.
The first is a rules engine: a handful of deterministic checks with plain-language reasons. Impossible travel between two fills. Gallons over the tank. Wrong fuel. An economy below a floor. These fire in real time, they are auditable, and they carry the pump decision with a reason a person can read: "1,138 gallons exceeds the 300-gallon tank; implied 0.8 mpg on the fill."
But rules alone are blunt in both directions. They flag 5.4% of legitimate swipes, because a fixed threshold catches ordinary variation along with fraud, and they miss the fraud that stays under every threshold. So a model rides on top as the ranker, ordering a review queue by expected loss.
One design choice is worth stating, because the obvious move is the wrong one. You could blend the rules and the model into a single score. Doing that drags the ranking down, because the rules carry their false positives into the blend. The rules earn their place as the real-time guardrail and the explanation; the model earns its place as the ranker. Keeping them separate makes each one better.
The fraud that looks innocent
Here is the second trap, and it is the reason the model exists. Consider a swipe built to stay under every rule:
# evasive: every signal kept just under its threshold, anomalous only together
gallons = tank * uniform(0.92, 1.12) # near the tank, not over it
odometer = prev + gallons * uniform(3.0, 4.3) # a low economy, but above the floor
emit(..., hour=overnight, entry="manual", amount=above_card_average)
Nothing here trips a rule. The fill is close to the tank but not over it. The economy is poor but not impossible. It is hand-keyed and overnight and above the card's usual spend, but none of those alone is fraud, and roughly one legitimate swipe in twelve is hand-keyed. Each signal sits in the safe zone. Only the combination is wrong.
The rules catch about four percent of this. The model catches close to ninety, because a gradient-boosted model reads the interaction of features that no single threshold can. That gap is the entire case for the second layer. Across the test set, by typology:
| Typology | Rules alone | Model |
|---|---|---|
| Impossible travel | 100% | 100% |
| Tank overflow | 100% | 95% |
| Wrong or non-fuel | 100% | 90% |
| Implausible economy | 100% | 100% |
| Rapid repeat | 100% | 100% |
| Off route | 92% | 79% |
| Evasive | 4% | 87% |
The rules own the clear typologies, which is what a real-time guardrail should do. The model owns the one that hides in combination, and it does so while flagging only 0.3% of legitimate swipes, against the rules' 5.4%.
What it adds up to
On an out-of-time test of about 7,000 swipes at a 2.6% fraud rate, the model ranks fraud far above the rules alone: average precision near 0.99 on the synthetic test, against 0.75 for the rules as a ranker and 0.03 for no-skill. Two operating views matter more than the single number.
As a review queue ranked by expected loss, working the top 2% of swipes recovers 90% of the fraud value, and the top 5% recovers all of it. A team with a fixed amount of review time spends it where the money is.
As a real-time gate, declining on a hard rule or a high model probability catches 96% of fraud while declining under 1% of legitimate swipes. Adding a step-up, a prompt for a second factor, on the middle band reaches 98% of fraud while touching 1% of legitimate traffic. Most fraud stopped at the pump, almost no honest driver inconvenienced.
What this is, and what it is not
The numbers come from a synthetic fuelling panel, and it is worth being plain about that. No fuel-card programme publishes labelled fraud, so the generator has to carry the realism: real hubs and distances, drivers with home bases and radii and tanks, corridors that fills trace across the map, and a rate of hand-keyed entry high enough that entry mode is not a giveaway.
The typologies, evasive included, are injected against that backdrop. Exact figures move a little with library versions, and real fuel fraud adapts as controls tighten in a way a static generator does not.
What carries over is the shape of it. Fuel-card fraud is a domain problem before it is a modelling problem. The useful signals are physical, and a model that does not know what a truck is will both miss the fraud that hides and punish the driver who is only doing the job.
Encode the semantics, measure each swipe against the card's own history, keep a transparent guardrail for the real-time call, and let the model rank what is left.
The code, the synthetic panel, the rules engine, the model, and the investigation queries are all in the repository.
Top comments (0)