Orbweaver

Finding coordinated abuse rings

The one that feels the whole web. Promotion-abuse rings are invisible order by order — the fraud lives in the connections between accounts. Orbweaver finds them in the graph, and reports what it costs to be wrong about them.

0.7292share of a ring worth reviewing
0.2242base rate among labelled accounts
0.371real customers per fraudster caught
44%rings with a case open the night before
₹67,900stopped by one analyst, one hour a night
Every number on this page is produced by make reproduce; none is typed in by hand — this page is quoted, not retyped, from README.md. Rupee figures use stated assumptions, because the dataset ships no monetary amounts, so they rank options against each other and mean nothing absolute. Built for the Razorpay AI Buildathon, Track 02 — my submission to their buildathon, not a Razorpay product.

In one minute

A group running many accounts through one delivery-app promotion looks fine order by order; the fraud only exists in the connections between the accounts, which a detector scoring one transaction at a time cannot see. Pruning to suspicious accounts, then peeling for dense structure, catches them at 0.7292 ring precision against a base rate of 0.2242 — 3.252× chance — at a measured cost of 0.371 real customers swept into a ring for every fraudster it catches.

The finding I would defend hardest: dense is not the same as fraudulent, and it replicates on every unrelated dataset I have tried it on. Unpruned, the same extractor lands below chance here (0.31×) and at exactly zero on YelpChi - 25 rings, 1,914 accounts, 0 of them fraudulent. Pruned first, the identical code reaches 14.3× on Amazon reviewers and 6.9× on YelpChi reviews - three platforms, two of them nothing like promotion abuse, saying the same thing (why this matters).

Live console · Full results · What broke

The problem

The problem. A delivery app gives ₹100 off your first order. A group runs fifty accounts between them and takes ₹5,000. Every order looks normal on its own — the fraud only exists in the connections: a shared address, a shared device, one UPI ID paying for all of them.

Why nothing on the shelf catches it. Thirdwatch scores "the probability of the order being fraudulent" — per order. Vulcan runs a transformer over ~3,000 signals — per transaction. A ring is invisible to both, because a ring is not a property of any order. I checked rather than assumed: as of 2 September 2026, every Sprint 2026 fraud launch is scoped to a transaction, a chargeback or an identity. Nothing graph-level exists.

The approach. Score accounts once, prune to the suspicious region, then let a densest-subgraph algorithm with a proved bound decide ring membership — so "why is this account in this ring?" is checkable arithmetic, not a model's opinion. Each ring ships as a case file with evidence, rupees at stake, and the cost of being wrong.

Keywords: fraud-detection graph-algorithms densest-subgraph anomaly-detection payments fintech xgboost python reproducible-research

Why this dataset

PPA is the only public, labelled promotion-abuse-ring dataset I could find — not the best one, the only one — and I never modified it, because a detector's numbers are only honest if the ground truth they are checked against is real. The full case for why this is still the right dataset to have started from, what its own paper claims that the release does not support, and how the hostel test and three unrelated datasets test the method past PPA, is in docs/why-this-data.md.

Key features

FeatureWhat it does
Multi-relation graphEdges weighted by entity rarity × measured fraud lift, fitted on training accounts only
Prune, then peelScore cut-off first, then greedy peeling with a ½-approximation bound
Evidence per ringShared entities, coverage, platform-wide rarity, day concentration, ₹ at stake
Cost always attachedEvery precision number ships with real customers swept in per fraudster caught
Capacity-aware policyExact knapsack over reviewer minutes → review / auto-hold / ignore, with both numbers
Anchored nightly replayStable case ids so tonight's ring is recognisably tomorrow's case
Live analyst consoleFastAPI + HTMX: queue, case files, offers, account lookup, replay. No build step
Reproduciblemake reproduce regenerates every number, table and figure. Nothing typed by hand
Honest failure log36 dated entries — what broke, what I believed, why it was wrong

Tech stack

LayerTechnology
LanguagePython 3.11+ (developed on 3.13)
Graph & algorithmspython-igraph, NumPy · greedy peeling + Fast Belief Propagation, hand-written
MLXGBoost (isotonic-calibrated) · GraphSAGE (reported alternative) · scikit-learn
Datapandas, PyArrow/Parquet, pydantic-validated YAML config
ConsoleFastAPI + HTMX, server-rendered — no npm, no bundler
ReportingMatplotlib; Markdown + HTML generated by eval/report.py
Testing / CIpytest · GitHub Actions on every push
HostingVercel (console) · GitHub Pages (docs & evidence)

How it works

Replayed one night at a time for the nightly numbers: rings are anchored around fixed accounts so a case found tonight is recognisably the same case tomorrow, not a fresh one every morning.

  1. Build the graph — accounts linked by shared entities, each edge weighted by how rare that entity is and how much that sharing predicts fraud.
  2. Score accounts — gradient boosting over 39 engineered features. The one learned step in the pipeline.
  3. Prune, then peel — the cut-off removes ordinary accounts; densest-subgraph decides membership on what remains.
  4. Attach evidence — what they share, how rare, when they acted, rupees at stake, cost of being wrong.
  5. Price the response — capacity-aware policy recommends an action under a stated reviewer budget.

Data and ML pipeline

StageWhat happensWhy it is done this way
SourcesPPA (only public labelled promotion-abuse ring dataset) + Amazon, YelpChi, IEEE-CIS as transfer checksOne dataset proves nothing about a method. Why this data →
CleaningSchema validation; entity capping via N_max. No relabelling, no augmentation, no dropped outliersA labelled evaluation is only worth something if the labels were set by someone with no stake in the result
FeaturesGraph edges as alpha_r / log(2 + users(e)); 39 account featuresalpha_r is measured fraud–fraud lift per relation, fitted on training accounts only
TrainingXGBoost + isotonic calibration. Strictly temporal split — week 1 trains, week 2 testsRandom node splits leak. tests/test_split_no_leak.py fails the build if one appears
AlternativesGraphSAGE (mini-batch, CPU-deterministic) and Fast Belief Propagation, run at every label budgetReported beside XGBoost whichever way they fall — and one of them wins
EvaluationRing precision vs base rate · FP cost · held-out AUPRC · hostel test · adversarial fragmentationPrecision alone is not a deployable number

Where a model is trusted, and where it is not

StageLearned?
Graph construction, edge weightingNo — arithmetic over measured per-relation lift
Account scoringYes — XGBoost, the only learned component
Ring extractionNo — deterministic peeling with a proved bound
Evidence, ₹ at stake, FP costNo — counting and stated arithmetic
The decision to actNo — a human reads the case file

A model scores accounts. It does not decide who is in a ring. No language model sits anywhere in the detection or decision path. → docs/design-decisions.md

Every number this project produces

All 13 investigations, including the four that came back negative, generated by make reproduce from the run in data/processed/.

Graph35,701,750 edges over the accounts active in the scoring window
Ring precision0.7292 against a base rate of 0.2242 — 3.252×
Cost of that0.371 real customers placed in a ring per fraudster caught
Without the score cut-off0.0696 — 0.31×, i.e. worse than picking at random
Account scorerAUPRC 0.3796 on held-out accounts, 1.693× random
Three relations I cannot rebuildworth +0.122 precision and +269 fraud accounts on the authors' own graph
Hostel test2 of 2,446 legitimate co-located groups touched (0.08%)
The relation only a platform can seeworth +0.024 to +0.038 ring precision at equal review capacity (250-500 accounts)
Time to detection, replaying night by nightmedian 4 of 4 nights; 33.4% of a ring's spend still ahead of it when it is found
Ranking rings by confidencethe mean member score wins at 200 rings (0.6739) — a trained ring model gets 0.5989, density 0.5814
Yesterday's rings as a feature+0.0011 AUPRC — it reaches 0.15% of held-out accounts. /check answers in 0.01 ms at the median
Behaviour edges against fragmentation+0.0237 ring precision when the ring is split into threes, -0.0023 when it is split into twenties
The same method on a payment processor's graph0.5079 precision, 18.138× its base rate, at 0.969 good cards per fraudulent one caught
What one analyst an hour a night stops₹67,900 of promotion value against ₹200 for working the queue in order, for ₹16,040 of legitimate value harmed (assumed rupees)
Telling a crowd from a ring by when it formedburst-weighted ring precision -0.0149 on PPA; on IEEE-CIS the apartment-cluster weakness is unchanged at 4 of 7 touched at every resolution tried
Which offers are being farmedtop 50 offers by size (325,494 accounts) cover 7.1% of all labelled fraud, 19.7x the 0.36% ring recall ceiling
How many confirmed cases before this worksprune-then-peel first beats the base rate at 1,146 confirmed accounts (0.5% of the training pool)
Spreading what few labels there areFast Belief Propagation, no fitted model: 0.4615 held-out AUPRC at full labels, 0.9886 ring precision pruning on its beliefs alone
A ring you can find again tomorrow44% of final rings had a case open the night before (global peeling: 4%); 0.7167 precision against 0.7292 for the cost of a case id

The findings, including the ones that did not work

#FindingOutcome
1Dense is not the same as fraudulent. On the raw graph the densest subgraphs are large ordinary communities — people who happened to use the same promotion — and ring precision comes out below the base rate. Filtering to suspicious accounts first, then looking for dense structure inside that region, is what makes the output useful. It replicates on two unrelated datasets: run unchanged on Amazon reviewers and YelpChi reviews, the unpruned extractor again lands below base rate — on YelpChi at exactly zero, 25 rings and 1,914 accounts without a single fraudster — while pruning first reaches 14.3× and 6.9×Held up — the one I would defend hardest
2The relation that dominates the graph carries the weakest signal. Promotion edges are 70% of the graph at 1.76× fraud lift; location edges are 16% at 3.71×. Weighting relations by measured evidential value, fitted on training accounts only, follows directlyHeld up — drives the edge weighting
3The link only a platform can see is worth measuring, not assuming. On YelpChi, removing the one relation that spans businesses costs two to four points of ring precision at equal review capacity. I had argued the aggregator's advantage with simulated edges before this; now it is a measurement on real labels, and the simulated version is only a sensitivity checkHeld up — a measurement replaced a guess
4One night of data is worth nothing, and rings do not survive the night. Replaying a night at a time, a single night puts the queue at chance, and it takes four nights to reach the headline. Worse, no ring found on the last night had a recognisable predecessor — a case could not be tracked at all. Anchoring the extraction fixed it: 44% of final rings now have a case open the night before against 0% for the global extractor, at a cost of 0.0125 precisionFailed, then fixed by anchoring
5The crudest baseline beat the model I built to replace it. A ring-level confidence model lost to simply ranking by the mean score of a ring's members, at every depth. The reason is visible in the training data: 90.6% of candidate rings are already fraudulent, so there is almost nothing for a ring-level model to separateNegative result
6Ring history does not transfer to the next window. Feeding "was this account in a ring last window" back into the account score moves held-out AUPRC by +0.0011. The ceiling was set before the model ran: the feature is non-zero for 0.15% of held-out accounts. Rings are window-specific objects — the accounts recur, the groupings do notNegative result
7Behaviour edges raise the price of fragmentation without defeating it. Splitting a ring into cells of three takes precision from 0.73 to 0.45. Mutual nearest-neighbour edges in behaviour space — which an attacker cannot cut by severing shared entities — recover +0.0237 of that, and nothing at all at cells of twentyPartial — fragmentation still works
8The method transfers to a payment processor's graph, and its weakest point moves with it. On IEEE-CIS the same pipeline reaches 0.5079 ring precision at 18.1× base rate. But the apartment-building analogue of the hostel test touches 4 of 7 clusters, against 2 of 2,446 here, because the billing address is at once the most informative relation and the thing that legitimately ties every card in a building togetherTransfers, at a cost
9The analyst is not the bottleneck I built for. Pricing review against auto-hold under a fixed budget, the fraud stopped does not change between thirty analyst-minutes a night and two hundred and forty — auto-holding already stops it. What analyst time buys is a 39% fall in legitimate value harmed. On these assumptions the reviewer is a false-positive control rather than a detectorHeld up — not what I expected
10Telling a crowd from a ring by when it formed did not pay off, and the fair test explains why. Burstiness weighting costs 0.0149 precision on PPA with no clean per-relation story. IEEE-CIS was built to test this properly, with second-resolution timestamps PPA cannot offer, and returned as clean a null as this project has produced: the same 4 of 7 apartment clusters at every resolution from one hour to one day. The billing address is not informative despite being shared by a legitimate building — it is informative because of itNegative result — a clean null
11Which offers are farmed splits into a precision ranking and a coverage ranking — and they are not the same offers. A leakage score built from no label beats base rate by 2.5–3.8× at top 25 and 50. But leakage ranks small, concentrated offers first, capping how much fraud it can touch: fifty offers by leakage cover 0.04% of labelled fraud, against 7.1% — 19.7× the ring's own recall ceiling — ranked by raw size, at the cost of reviewing 325,494 accounts rather than a few hundredHeld up — a capacity decision, not a technical one
12A team with almost no confirmed labels is not starting from nothing. Prune-then-peel already beats base rate at the smallest fraction tested — 1,146 confirmed accounts, 0.5% of the training pool. Held-out AUPRC keeps climbing all the way to 100%; no plateau appears in the range testedHeld up
13A method with no fitted model at all beat both learned scorers, and not for the reason I expected. Fast Belief Propagation — one sparse linear system, a proven convergence condition, priors from confirmed labels only — reaches 0.4615 held-out AUPRC against XGBoost's 0.3796 and GraphSAGE's 0.3819, and pruning on its beliefs alone lifts ring precision to 0.9886 with three times the recall at the same review cost. The hypothesis was that propagation wins when labels are scarce; the data says the opposite — it trails both learned scorers from 0.5% through 20% of the pool and only crosses over at 50%. Propagation needs seeds to spread fromHeld up — hypothesis wrong, result right

How much the model's opinion actually matters

At the score cut-off this project uses (τ = 0.5), this is ring precision as λ moves the peeling objective from pure structure (λ = 0) toward weighing the account scorer's opinion more heavily. The same numbers are in the table above and, alongside every tau, in docs/results.md.

View as a table
λring precisionreal customers per catch
0.00.66460.505
0.50.66580.502
1.00.70210.424
2.00.70140.426
5.00.72920.371

What these numbers do not prove

LimitDetail
Not Indian dataPPA is Chinese food delivery. The method is data-agnostic; the numbers are not. The hostel test is the closest proxy — real validation needs Indian data
Transfer runs are weakerAmazon/YelpChi ship no timestamps (account-disjoint, not forward in time) and do ship node features PPA lacks — their higher numbers say more about the datasets
The release ≠ the paperTest week only: 3,267,961 accounts, 10,012,449 edges — not 5.69M/29M. Three of eight relations are empty in the order files. Measured here
₹ rests on assumptionsPPA ships no monetary amounts. The ₹ columns rank options against each other; they mean nothing absolute
Ring recall is low by designRings surface a few hundred accounts for review, not the population. A queue asks what share of what it looks at is worth looking at
Baselines aren't like-for-likePublished baselines have no account holdout and count unlabelled as negative. Both conventions are reported

Figures

All 16 of them, regenerated by make report; the exact captions in docs/results.md.

Ring precision as a ring is fragmented into smaller cells, with and without behavioural edges
Precision falls sharply as cells shrink; behavioural edges recover some of it at cells of three and recover nothing at cells of twenty - fragmentation remains the evasion that works.
Ring precision against false-positive cost across score cut-offs
Each point is one score cut-off (tau); the headline operating point is marked. Precision rises steeply then flattens as the cut-off tightens, while cost falls - the marked point is where that trade-off was judged worth making, not the only defensible choice.
Fraud-fraud lift by relation on the IEEE-CIS payment-processor graph
The same lift-versus-share pattern as PPA, on a different dataset. address_distance is the same billing address and distance band; device the same device; email_recipient the same recipient e-mail domain; browser the same browser build; email_payer the same payer e-mail domain, the one relation no better than chance.
Held-out AUPRC and ring precision against the number of confirmed labels used
Even the smallest label budget tested (1,146 accounts) already beats the base rate, and AUPRC keeps climbing all the way to full label availability with no plateau visible in the range tested.
Ring precision with and without burst-weighted edges, by relation and by dataset
Burst-weighting costs precision on PPA with no clean per-relation story, and leaves the IEEE-CIS apartment-cluster weakness exactly unchanged - a negative result, shown rather than omitted.
Ring precision and node AUPRC with and without the one relation only a platform can see
Removing the cross-business relation costs both ring precision and account-scoring AUPRC - real, measured evidence for what an aggregator's view is worth, not an assumption.
Precision at k and cumulative fraud coverage for two offer rankings
Ranking offers by leakage is precise but narrow - it covers very little of all labelled fraud, because it puts small offers first. Ranking by raw size covers far more at a much larger review cost. Neither ranking dominates; which to use is a capacity decision.
What each review policy stops against what it costs, by reviewer budget; and one analyst's cumulative catch across four nights
Left: the capacity-aware policy holds fraud stopped roughly flat while cutting legitimate harm as the budget grows - more analyst time buys fewer wrongly-harmed customers, not more fraud caught. Right: an analyst working two hours a night keeps catching new fraud value every night of the replay, not just the first.
Precision at depth for three ways of ranking rings
Ranking rings by their members' mean account score beats both a trained ring-confidence model and raw density, at every depth tested - the simplest ranking wins here, not the most sophisticated one.
Fraud-fraud lift by relation, against each relation's share of all edges
The relation carrying the most edges (promotion) has the weakest evidential lift; the rarest relations are the strongest evidence per edge. Edge count and evidential value are not the same thing.
Predicted versus realised precision, by confidence decile
Points near the diagonal mean the ring-confidence model's stated probability is trustworthy at that decile; points off it show where the model is over- or under-confident.
Held-out AUPRC with and without last window's ring membership as a feature
The lift is small (+0.0011) because the feature is only ever non-zero for 0.15% of held-out accounts - most accounts were in no ring last window, so there is nothing for the feature to carry forward for them.
Share of final-night rings with a predecessor the night before, anchored against global extraction
Anchoring the extraction around fixed accounts is what makes a case trackable from one night to the next - global peeling recomposes the whole graph every night and loses almost every case in the process.
Ring precision across the full cut-off (tau) by structure-weight (lambda) grid
Precision depends far more on the score cut-off than on how much the peeling objective weighs the model's opinion - most of the grid's variation runs along the tau axis, not the lambda axis.
Held-out AUPRC against label budget for three scorers: XGBoost, Fast Belief Propagation, GraphSAGE
Belief propagation trails both learned scorers until roughly half the training pool is labelled, then overtakes both - the crossover is real and the hypothesis going in predicted the opposite order.
Ring precision and days-to-detection, replaying the window night by night
One night of data lands at the base rate; it takes four nights of replay to reach the precision this project reports as its headline number, which is the real cost of not anchoring cases across nights.

What broke

The five that mattered, out of FAILURES.md's full, dated log — the file I would read first if someone handed me this repository.

  • Dense groups were innocent — the densest subgraphs kept turning out to be ordinary clusters rather than fraud rings, and that single result changed how ranking works in this project. Read it →
  • A model trained on strangers — two files used two different id spaces, and the silent leak between them let a model score well on accounts it had never truly seen. Read it →
  • A rewrite the tests missed — a fresh clone produced a number my own machine no longer did, because the rewrite was checked by its tests but published by a file the rewrite never touched. Read it →
  • The hostel test came back clean — time alone did not separate the hostel from the ring even where the data gave it every chance to, and the fair test built for exactly that weakness returned a clean null. Read it →
  • Propagation ran backwards — belief propagation lost when labels were scarce and only won once they were not, the opposite of the stated hypothesis, with the real mechanism not the one written down beforehand. Read it →

Research foundation

WorkWhat it contributed here
PromoGuardian / PPA, IEEE S&P 2026 · arXiv · dataThe labelled dataset everything is measured against. No code or checkpoint reused
Charikar, APPROX 2000The ½-approximation the peeling objective relies on
Hooi et al., FRAUDAR, KDD 2016Camouflage-resistant weighting; the bound for the node-prior objective
Bahmani, Kumar & Vassilvitskii, VLDB 2012Batch peeling — how rings get extracted at 35.7M-edge scale
Khuller & Saha, ICALP 2009The NP-hardness result motivating approximation over exact search
Xu, Ma, Fang et al., SIGMOD 2023The empirical bound cited for greedy peeling under a size ceiling
Dai et al., Anchored Densest Subgraph, SIGMOD 2022The anchored formulation that makes a case survive the night
Greene, Doyle & Cunningham, ASONAM 2010Life-cycle events and the Jaccard threshold for case identity
Koutra et al., ECML-PKDD 2011Fast Belief Propagation, implemented as specified — and it beat the learned scorer
Beutel et al., CopyCatch, WWW 2013The lockstep-in-time argument the burstiness arm tests
Tang et al., GADBench, NeurIPS 2023Why this starts with gradient boosting; two of the transfer datasets
Dou et al., CARE-GNN, CIKM 2020The Amazon and YelpChi releases used unchanged as a transfer check
Razorpay, ThirdwatchThe per-order framing this complements rather than replaces

Repository map

DocRead it if…
docs/results.mdYou want every number and all 16 figures
docs/why-this-data.mdYou're asking "why this dataset, and is it trustworthy"
docs/architecture.mdYou want the five stages in depth
docs/design-decisions.mdYou want the ML boundary, and why it sits there
docs/data.mdYou want the raw release measured file by file
docs/threat-model.mdYou want what it catches, misses, and how to evade it
FAILURES.mdYou want the honest log. Start here
ETHICS.mdYou want the scope boundary in six lines

Run it yourself

A clone, six packages and one command — no dataset download needed, because the repository carries the computed results as a bundle.

git clone https://github.com/adarshcod30/Orbweaver
cd Orbweaver
pip install -r requirements-demo.txt
make console      # http://127.0.0.1:8000

The bundle is 1.69 MB, so the live console and a fresh clone both run with no dataset present at all. The full pipeline from raw data is make reproduce.