| title | SENTINEL AML API |
|---|---|
| emoji | π‘οΈ |
| colorFrom | blue |
| colorTo | indigo |
| sdk | docker |
| app_port | 7860 |
| pinned | false |
SENTINEL flags accounts involved in money laundering, names the typology, and shows an investigator why - with real SHAP attribution over the model that produced the score.
Everything runs offline from the CSVs in SynthDataGen/. No database to provision.
pip install -r requirements.txt
# Train both models (~1 min on CPU). Writes to artifacts/.
python -m models.anomaly
python -m models.classifier
# API on :8000 (first request warms the models, ~25s)
uvicorn backend.main:app --reload
# UI on :5200
cd frontend && npm install && npm run devPort note. Vite's default 5173 falls inside a Windows reserved range on some machines (
netsh interface ipv4 show excludedportrange protocol=tcp). The dev script uses 5200; the API allows both origins.
SynthDataGen/*.csv
β
ββ models/dataset.py in-memory graph (10,363 nodes / 50,917 edges)
β + the 9 aggregate account features
β
ββ models/graph_features.py 26 behavioural features per account
β (channel mix, pass-through ratio, burstiness,
β counterparty spread, reciprocity)
β
ββ models/classifier.py β risk score + typology [PRIMARY]
ββ models/anomaly.py β novelty score [SECONDARY]
ββ models/predictor.py β inference + SHAP, consumed by backend/
A gradient-boosted tree (HistGradientBoostingClassifier) over the 26 behavioural
features, predicting NONE | MULE | SMURFING | LAYERING. The displayed risk score is
1 β P(NONE).
Held-out (25% stratified split, 2,591 accounts):
| Class | Precision | Recall | F1 | Support |
|---|---|---|---|---|
| NONE | 1.00 | 1.00 | 1.00 | 2507 |
| MULE | 0.82 | 0.90 | 0.86 | 10 |
| SMURFING | 0.82 | 0.82 | 0.82 | 44 |
| LAYERING | 0.93 | 0.90 | 0.92 | 30 |
Macro-F1 0.90, accuracy 0.993. As a binary detector the risk score reaches ROC-AUC 0.99, PR-AUC 0.97, precision@top-1% = 1.00.
MULE's support is only 10 accounts in the test split, so its per-class numbers are noisy - read them with that in mind.
The autoencoder (9β6β3β6β9) from the original design, retrained on features that match the current data. It is not the detector, and the code says so.
Trained to convergence, reconstruction error is a weak global ranker: ROC-AUC 0.49. Autoencoders reconstruct anomalies about as well as everything else once they have the capacity. What it is good for is the tail - the top 1% of reconstruction error is 10.2Γ enriched for known-illicit accounts, which is how an unlabelled typology would first surface. It ships as a "novelty" column, not a risk score.
An earlier under-trained checkpoint scored 0.65 AUC. Shipping that checkpoint would have been tuning on the evaluation metric, so it was not shipped.
Book-level intelligence, computed across all 51,144 transactions rather than scored per account. It is the context an investigator reads a single case against.
| Panel | Finding |
|---|---|
When it happens (/statistics/clock) |
Illicit activity is 9.2Γ over-represented at midnight and ~8Γ from 01:00-03:00. Reported as lift over the licit baseline, not raw volume - there are 45Γ more licit transactions, so their diurnal shape would otherwise swamp the signal. |
How it moves (/statistics/channels) |
Each typology has a clean channel fingerprint. Layering and smurfing are 100% transfer; a mule is defined by the cash-out, so it inverts - 97% of mule transactions leave as ATM cash or card spend. |
What the model weighs (/statistics/drivers) |
Global SHAP importance. Money Throughput moves the score 4.2Γ more than any other feature - the model's single strongest lever. |
How much it moves (/statistics/amounts) |
Median illicit transaction βΉ8.3K against βΉ91 for licit - a 91Γ gap. Over half the retail book sits under βΉ100, where laundering essentially never appears. |
Two candidate panels were cut for failing the evidence bar, which is the point of listing them: shared-IP collusion clusters (no IP in the book is used by more than one account, so there is nothing to cluster) and laundering rings (314 of the 379 flagged accounts fall in a single connected component - an artefact of the generator, not a finding). The amount panel likewise stops short of calling the pattern "structuring": illicit amounts do cluster where retail traffic does not, but the generator encodes no reporting threshold, so reading intent into it would be a story about the data rather than a fact in it.
Every other screen reports on the book the classifier was fitted to, so a reader has no way to tell a model that learned laundering from one that memorised 10,000 rows. SHAP over a frozen dataset explains a result nobody can challenge.
So /simulate lets the reader write the book. They choose the size, the crime mix and the
seed; the server generates it, scores it with the already-trained classifier, and grades
the predictions against a ground truth that did not exist when the request arrived. Nothing
is retrained. It runs in ~600ms and holds ~300MB, inside the 512MB tier.
On a book it has never seen (3,000 accounts, 15,000 transactions, seed 7):
| Caught (recall) | 96% - 222 of 232 criminals |
| Correct (precision) | 91% - 23 false alarms |
| ROC-AUC | 0.997 |
| Mule / Layering / Smurfing recall | 100% / 100% / 93% |
The numbers are free to come out badly, and they do: smurfing is the weak typology, and on a book with zero crime the model still flags ~0.7% of honest accounts. Both are rendered as loudly as the wins - a scoreboard that can only go up is not evidence.
The generator mirrors SynthDataGen/generate_data.py's three topologies exactly (fan-in
smurfing, multi-hop layering, cash-out mules terminating at merchants). If it drifted, the
model would be graded on a crime it was never trained to recognise and a poor score would
mean nothing. It generates only the columns the model reads - no names, PANs or IPs - which
is why it needs no Faker and adds nothing to the runtime requirements.
shap.TreeExplainer over the classifier - exact, not sampled, a few ms per account.
Show the working. A SHAP bar saying "Pass-Through Ratio contributed +2.1" is a number
about a number. So the simulator renders each derived feature as its actual formula from
models/graph_features.py with the account's real values substituted - min(βΉ9.1L, βΉ9.2L) / max(βΉ9.1L, βΉ9.2L) = 0.996 - above the raw ledger it was computed from. The arithmetic is
checkable by hand. An explanation you cannot check is just a second thing to trust.
Attribution is taken against the NONE class and negated: a feature that pushes the
model away from NONE is exactly a feature that raises risk. So the chart explains the
number on screen rather than some correlated proxy. Values are in classifier margin
(log-odds) units and are signed - factors that lower risk render on the cool pole
of a diverging axis.
The API and the UI deploy separately.
Vercel βββββββββββββββΊ Render (or any host)
React UI CORS FastAPI + committed models
Why the models are committed. The runtime artifacts - pattern_classifier.joblib,
novelty.csv, and the two metrics files - live in artifacts/ and are checked in.
So the deploy needs no training step, and, crucially, the serving process never
imports torch: novelty is a percentile over a static dataset, precomputed at train
time. That drops the resident set from ~600MB to ~265MB, which is what lets it run on
a 512MB free instance. Regenerate the artifacts with the training deps:
pip install -r requirements-train.txt
python -m models.anomaly && python -m models.classifierAPI - Render (native Python). render.yaml pins the config:
| Setting | Value |
|---|---|
| Build | pip install -r requirements.txt (torch-free, fast) |
| Start | uvicorn backend.main:app --host 0.0.0.0 --port $PORT |
| Health check | /health |
Env var ALLOWED_ORIGINS |
your UI origin, e.g. https://xai-aml.vercel.app |
A manually-created service does not auto-adopt render.yaml; set the two commands
under Settings or re-create it via New β Blueprint. CORS reads ALLOWED_ORIGINS
and allows any *.vercel.app by regex; local dev origins are always allowed. The free
instance sleeps after ~15 minutes idle; waking it costs ~50s of container boot, which is
why the axios timeout is 180s (see Cold starts below).
A Dockerfile is also provided (for HF Spaces or Render-as-Docker): same idea, it
installs the runtime requirements and copies the committed artifacts - no torch, no
training. .github/workflows/deploy-hf-space.yml mirrors main to a HF Space if you
set HF_TOKEN (secret) and HF_SPACE (variable); it no-ops until then.
Keeping it warm - and what actually works. .github/workflows/keep-warm.yml asks
for a /health ping every 10 minutes. GitHub does not honour that, and it is worth
being precise about how badly: over one 15-hour window the schedule should have fired 89
times and fired 8, with gaps up to 3.5 hours. GitHub deprioritises high-frequency
cron on shared runners, and no interval you write fixes it - asking for */5 gets you
throttled harder, not less. Against a 15-minute sleep threshold, a job that lands every
~2 hours keeps nothing warm. The workflow was measured, not trusted, and it does not do
the job it was written to do.
What does work:
workflow_dispatch. A hand-triggered run is not throttled - it fires in seconds and the instance is warm ~75s later. Kick it before a demo, or just open the link yourself two minutes early. This is the reliable path and it is the one to use.- An external uptime pinger (cron-job.org, UptimeRobot, Better Stack - all free, all
honour 1-5 minute intervals). Point one at
/healthand the instance genuinely never sleeps. This is the only hands-off fix; it lives outside this repo because it needs an account, not a commit.
The scheduled cron is left enabled because a ping that lands is still a ping, and
workflow_dispatch shares the file. It is a bonus, not the mechanism. The mechanism for
a cold visitor is the next paragraph: the UI is built to survive the wait.
UI - Vercel. Set the project root to frontend/. No environment variable is
required: a production build defaults to the deployed API above. Point it elsewhere
by setting VITE_API_URL (Vite inlines it at build time), which takes precedence.
The default matters. Previously a production build with no VITE_API_URL fell back
to 127.0.0.1:8000 - aiming every visitor's browser at their own machine - and the
only symptom was all six panels erroring at once. The deployed bundle shipped that
way. Production now falls back to the real API instead.
Cold starts are visible, not hidden: the free instance sleeps, and the first request pays ~50s of container wake plus ~25s of model warmup (graph build + SHAP explainer). The axios timeout is 180s - a 60s timeout aborted the first load of the day before the API could answer - and the dashboard shows a "waking the server" note after 6s rather than a spinner that reads as a hang.
vercel.json rewrites all paths to index.html; without it a refresh on
/network/ACC1234 404s, because the router is a BrowserRouter.
Labels are per-leg, not per-endpoint. A mule's cash-out leg terminates at a merchant.
Crediting both endpoints with the MULE label taught the model that "receives card spend"
means "launderer" - 363 of the 403 accounts it called mules were merchants, and three of
them ranked in the top 15 most-suspicious. Cash-out legs (ATM withdrawal, card purchase)
now credit the source only. Merchants stay on the graph as counterparties but never enter
the investigable population.
Nothing in the feature set reads is_illicit or illicit_pattern_type. Those are
labels. They are used for training, for evaluation, and to mark known-bad edges red in the
graph - never as model inputs.
The frontend formats; the backend doesn't. Metrics travel as {value, unit}. When the
API pre-formatted currency with Python's f"{x:,.0f}", one card read βΉ755,771 while the
tile beside it read βΉ7.4Cr.
Charts follow the data's job. Magnitude β sequential single hue. Polarity (SHAP) β diverging, warm/cool poles, neutral midpoint. Identity (typology) β a fixed categorical slot per typology, so filtering never repaints the survivors. Every chart has a table-view twin, and the palette was validated for colour-vision deficiency rather than eyeballed.
backend/main.py FastAPI app - serves from the in-memory graph
models/dataset.py CSV β graph + features
models/graph_features.py behavioural features + ground-truth labels
models/classifier.py typology classifier (train + evaluate)
models/anomaly.py autoencoder novelty (train + evaluate)
models/predictor.py inference API + SHAP
frontend/src/lib/ formatting + data-fetching hook
frontend/src/components/ UI, charts under charts/, primitives under ui/
SynthDataGen/ generator + accounts.csv + transactions.csv
The following were deleted - none were on any runtime path:
models/feature_engineering.py,models/train_gcn.py- importedneo4j/dglmodels/train_autoencoder.py- superseded bymodels/anomaly.pyautoencoder.pth,gcn.pth,scaler.pkl,account_features.csvat the repo root - fit on a 50,000-account matrix that matched no file in the repo. Recomputing those features for the 10,000 real accounts reproduced the CSV's amount columns 0% of the time. The live artifacts are trained intoartifacts/at build time.frontend/public/geoBoundaries-IND-ADM3.topojson(12.7 MB) - not referenced; the choropleth loadsindia-states.json.
The GCN was loaded on import and never called; models/classifier.py replaces it. The
Aura instance in .env no longer resolves, and neo4j/ stays gitignored.