Skip to navigation

Money Laundering Detection

View as Markdown

Money laundering moves illicit funds through a network of accounts and transactions to disguise where they came from. This example looks ahead from each account and predicts whether it will be reported for money laundering in the coming weeks, so an investigations team can review the riskiest accounts before the funds move on. Kumo Engine learns this from each account’s own attributes and from the money it has sent and received across the transaction network in the graph, which is the pattern a plain rule on any single account misses.

Data and relationships

The example assumes a prepared Kumo Engine graph, a matching snapshot, and these tables:

TableRequired columnsPurpose
accountsaccount_id as the primary keyThe accounts being scored, with attributes like account type or opening date. A static entity table with no time column.
transactionstransaction_id, timestampEach movement of funds, with an optional amount. timestamp is the time column. A transaction reaches accounts through the inputs and outputs tables, not a direct foreign key.
inputsinput_id, transaction_id, account_id, timestampThe sending side of each transaction: one row links a transaction to the account the funds came from. timestamp is the time column.
outputsoutput_id, transaction_id, account_id, timestampThe receiving side of each transaction: one row links a transaction to the account the funds went to. timestamp is the time column.
reportsreport_id, account_id, timestampA money-laundering report filed against an account, such as a suspicious-activity report. timestamp records when the report was filed and is the time column. This is the event the queries below predict.

Connect inputs.transaction_id and outputs.transaction_id to transactions.transaction_id, connect inputs.account_id, outputs.account_id, and reports.account_id to accounts.account_id, and mark each table’s primary key and its timestamp time column. A transaction touches an account only through the inputs (sender) and outputs (receiver) tables, so there is no direct foreign key from transactions to accounts, and those two bridge tables carry the money flow in both directions. The reports table does have a direct foreign key to accounts, which is what lets the queries below count an account’s future reports. The transaction network is the signal the model learns from, even though the query text only counts reports: the funds an account has sent through inputs and received through outputs shape its prediction.

Score each account for money-laundering risk

This query looks ahead from each account and predicts whether it will be reported for money laundering in the next 30 days. COUNT(reports.*, 0, 30, days) counts the reports filed against the account over the 30 days that follow the scoring time, > 0 turns that into a yes or no label, and FOR EACH accounts.account_id produces one prediction per account. The forward window is anchored at the scoring time you run the query, not at each account’s own history, and it is the half-open interval (scoring time, scoring time + 30 days] shared across every account scored in the same run. Every transaction and any earlier report is dated before that anchor, so the model is trained and scored using only what was known before the window it looks ahead into. Count the reports with COUNT(reports.*, 0, 30, days) > 0 rather than predicting a raw report count, which Kumo Engine reads as a numeric target and scores as a regression.

from kumo.engine import PredictiveQuery
query_text = (
"PREDICT COUNT(reports.*, 0, 30, days) > 0 "
"FOR EACH accounts.account_id"
)
query = PredictiveQuery(graph, query_text)
query.validate()
print(query.get_task_type()) # binary_classification

Focus on accounts that just moved funds

Scoring every account on a fixed schedule works for a periodic sweep, but a monitor that reacts as money moves wants to review an account the moment funds arrive. This query makes the same 30-day prediction and adds a WHERE filter that keeps an account in the training and scoring population only when it received funds in the 7 days up to the scoring time. COUNT(outputs.*, -7, 0, days) > 0 counts the account’s incoming transactions over the 7 days before the anchor, so the model concentrates on accounts that are currently active. The prediction target is unchanged, so this still answers whether the account will be reported in the next 30 days.

query_text = (
"PREDICT COUNT(reports.*, 0, 30, days) > 0 "
"FOR EACH accounts.account_id "
"WHERE COUNT(outputs.*, -7, 0, days) > 0"
)
query = PredictiveQuery(graph, query_text)
query.validate()
print(query.get_task_type()) # binary_classification

Reading the predictions

Both queries return a probability that an account will be reported for money laundering within the next 30 days. An investigations team can rank accounts by that probability and open a review, file a suspicious-activity report, or place a temporary hold on the highest-risk accounts before the funds are layered away. The first query scores every account, which suits a periodic sweep of the whole book; the second narrows scoring to accounts that just received funds, which fits a monitor that reacts as money moves.

These are estimates from past behavior, not proof of wrongdoing, and every flagged account still needs a human investigation. The 30-day window is a starting point: widen or narrow the number of days to match how quickly your team can act, and shift the window forward, for example COUNT(reports.*, 10, 30, days) > 0, to separate accounts that need review this week from those to keep on a longer watchlist. The forward window is anchored at the time you run the query, so the model always uses only the transaction and report history recorded before that point and never sees the outcome it predicts. Refresh the predictions as new transactions arrive so each decision reflects the account’s latest activity.