Fraud and AML data with labelled truth
A transaction-monitoring model is only as good as the labels it is scored on, and real labels are late, partial and argued over. Grown synthetic graphs have the opposite problem: patterns appear by accident, so a detector's false positives may be the data's fault. Here every suspicious pattern is declared, and the rest of the graph cannot contain one.
Who: risk, AML and fraud ML teams; graph analytics engineers. Time: about 3 seconds for 320,000 rows.
The declaration#
# misata.yaml
name: transfer_network
seed: 21
tables:
accounts:
rows: 20000
columns:
account_id: {type: int, unique: true, min: 1, max: 20000}
kind: {type: categorical, choices: [personal, business], probabilities: [0.85, 0.15]}
country: {type: categorical, choices: [US, GB, DE, AE, SG], probabilities: [0.45, 0.2, 0.15, 0.1, 0.1]}
opened_on: {type: date, start: "2019-01-01", end: "2025-06-30"}
transfers:
rows: 300000
columns:
transfer_id: {type: int, unique: true, min: 1, max: 100000000}
src: {type: int, min: 1, max: 20000}
dst: {type: int, min: 1, max: 20000}
amount: {type: float, distribution: lognormal, mean: 900, std: 2500, min: 1, max: 250000, decimals: 2}
sent_at: {type: datetime, start: "2025-07-01", end: "2025-10-01"}
dag_edges:
- name: background
table: transfers
node_table: accounts
node_key: account_id
from_column: src
to_column: dst
graph_motifs:
- name: laundering
table: transfers
node_table: accounts
node_key: account_id
from_column: src
to_column: dst
rate: 0.01
shares: {cycle: 0.4, fan_in: 0.25, fan_out: 0.2, scatter_gather: 0.15}
benign_rate: 0.01
benign_shares: {cycle: 0.5, fan_in: 0.5}
flag_column: is_suspiciousdag_edgeslays the 300,000 transfers out as a graph with no cycles.graph_motifsthen rewrites 1% of them into flagged motifs (rings, fan-ins, fan-outs and scatter-gathers in the declared mix), and another 1% into benign rings and fan-ins of the same shapes, the hard negatives.- Each motif edge carries its shape in
motif, its case id inmotif_case, andis_suspiciousis true for the flagged ones only.
Run it#
pip install "misata[parquet,duckdb]"
misata explain misata.yaml
misata generate -c misata.yaml -o out --format parquet --attestThe check#
python out/verify.pyok 2 file(s) match their attested sha256
ok 10 of 10 invariant(s) hold| Invariant | Expected | What it proves |
|---|---|---|
transfers.src->dst.acyclic | 0 | no cycle among the 294,000 edges outside a declared case |
transfers.laundering.cycle.motif_edges | 2,700 | 1,200 flagged ring edges plus 1,500 benign ones |
transfers.laundering.fan_in.motif_edges | 2,250 | 750 flagged plus 1,500 benign |
transfers.laundering.fan_out.motif_edges | 600 | flagged only |
transfers.laundering.scatter_gather.motif_edges | 450 | flagged only |
transfers.laundering.flagged_edges | 3,000 | exactly 1% of transfers are labelled suspicious |
- Flagged
- Benign
Show the numbers
| Shape | Flagged | Benign |
|---|---|---|
| cycle | 1,200 | 1,500 |
| fan_in | 750 | 1,500 |
| fan_out | 600 | 0 |
| scatter_gather | 450 | 0 |
Source: the invariants above (motif_edges per shape)
The acyclic check is Kahn's algorithm written as a DuckDB recursive query: it removes nodes with no incoming edges round by round and counts what is left. It finishes in under half a second on this graph, and it fails on a single planted cycle, so a reader who does not trust the generator can confirm that every cycle in the file belongs to a case.
What it is for#
Because the background is acyclic, a perfect cycle finder flags exactly the 2,700 ring edges, and only 1,200 of them are suspicious: 44% precision from topology alone. That is the point of declaring hard negatives. A detector is scored on telling a laundering ring from an innocent loop, and every miss and every false alarm traces to a case id.
The transfers table is an edge list, so it loads into any graph tool as it
is: src and dst are account ids, and accounts holds the node
attributes.
Related#
- Lexicons, motifs and joint margins:
graph_motifsin full - Anomaly injection: outliers and labelled anomalies on single columns
- Attest a dataset: how each count becomes a SQL invariant
- Fintech domain: accounts, transactions and fraud flags from a sentence
Files#
The recipe folder runs as it is. Its files on GitHub:
Prefer generating data visually?
Misata Studio lets you turn natural language prompts into relational schemas with exact outcome invariants, live preview tables, and one-click database seeding without writing code.

