Skip to content

Fraud Detection

Find transfers that may be fraudulent by looking at payment details and the flow of money between accounts. Then prioritize cases for investigators by both the chance of fraud and the amount at stake.

Goals
DetectPredictPrioritize
Reasoning types
GraphRules-basedPredictivePrescriptive
Experience level
Advanced
Browse files

What this template is for

Payments teams often receive more fraud alerts than investigators can review, and transaction attributes alone can miss suspicious account-network behavior. This template combines network signals with a fraud classifier, then selects the investigations that maximize expected loss averted within a fixed investigator-hours budget. Adapt its transaction data, fraud signals, predictive model, and review constraints for your workflow.

Explore the model

Explore the model

Accounts send and receive transactions. The same connections support account-level graph signals, transaction-level fraud predictions, and the budget-constrained investigation queue.

AccountTransactionsent byreceived by
AccountTransactionsent byreceived by
Account

A customer or merchant account that sends or receives transfers. Its activity and connections to other accounts help the model assess whether a related transfer may be fraudulent.

Properties

NameType
account_id (identity)String
account_type_prefixString

Relationships

sent by
Transaction is sent by Account
received by
Transaction is received by Account
Preview source dataaccounts.csv · 32661 rows; showing 4

Customer and merchant account identifiers and type prefixes.

View data/paysim_mini/accounts.csv

account_idaccount_type_prefix
C658247527C
C1812418129C
C1544350298C
C662184778C
Transaction

A transfer from one account to another. The model uses its amount and account balances, along with each account’s activity and connections, to estimate whether the transfer may be fraudulent.

Properties

NameType
transaction_id (identity)Integer
stepInteger
step_tsDateTime
trans_typeString
amountFloat
name_origString
old_balance_origFloat
new_balance_origFloat
name_destString
old_balance_destFloat
new_balance_destFloat
is_flagged_fraudInteger
audit_costFloat

Relationships

sent by
Transaction is sent by Account
received by
Transaction is received by Account
Preview source datatransactions.csv · 16426 rows; showing 4

Transaction amounts, balances, endpoints, timestamps, and heuristic flags.

View data/paysim_mini/transactions.csv

transaction_idstepstep_tstrans_typeamountname_origold_balance_orignew_balance_origname_destold_balance_destnew_balance_destis_flagged_fraud
56822953972020-01-17 13:00:00CASH_IN76550.74C6582475271096252.931172803.66C4926705732208784.022132233.280
32565492512020-01-11 11:00:00PAYMENT12617.11C1812418129339181.87326564.76M19244230590.00.00
1059490982020-01-05 02:00:00CASH_OUT8055.06C15443502988055.060.0C9124053480.08055.060
61614055502020-01-23 22:00:00TRANSFER342309.91C662184778342309.910.0C17405030200.00.00
Transaction is sent by Account

Connects each transfer to the account that originated it.

Fields

FieldValue
Namesent by
FromTransaction
ToAccount
Preview source datatransactions.csv · 16426 rows; showing 4

Transaction amounts, balances, endpoints, timestamps, and heuristic flags.

View data/paysim_mini/transactions.csv

transaction_idstepstep_tstrans_typeamountname_origold_balance_orignew_balance_origname_destold_balance_destnew_balance_destis_flagged_fraud
56822953972020-01-17 13:00:00CASH_IN76550.74C6582475271096252.931172803.66C4926705732208784.022132233.280
32565492512020-01-11 11:00:00PAYMENT12617.11C1812418129339181.87326564.76M19244230590.00.00
1059490982020-01-05 02:00:00CASH_OUT8055.06C15443502988055.060.0C9124053480.08055.060
61614055502020-01-23 22:00:00TRANSFER342309.91C662184778342309.910.0C17405030200.00.00
Transaction is received by Account

Connects each transfer to the account that received it.

Fields

FieldValue
Namereceived by
FromTransaction
ToAccount
Preview source datatransactions.csv · 16426 rows; showing 4

Transaction amounts, balances, endpoints, timestamps, and heuristic flags.

View data/paysim_mini/transactions.csv

transaction_idstepstep_tstrans_typeamountname_origold_balance_orignew_balance_origname_destold_balance_destnew_balance_destis_flagged_fraud
56822953972020-01-17 13:00:00CASH_IN76550.74C6582475271096252.931172803.66C4926705732208784.022132233.280
32565492512020-01-11 11:00:00PAYMENT12617.11C1812418129339181.87326564.76M19244230590.00.00
1059490982020-01-05 02:00:00CASH_OUT8055.06C15443502988055.060.0C9124053480.08055.060
61614055502020-01-23 22:00:00TRANSFER342309.91C662184778342309.910.0C17405030200.00.00
accounts.csv

Customer and merchant account identifiers and type prefixes.

Populates

Model item
Account

Source

Path
data/paysim_mini/accounts.csv
Kind
Local CSV

Preview: 32661 rows; showing 4

account_idaccount_type_prefix
C658247527C
C1812418129C
C1544350298C
C662184778C
transactions.csv

Transaction amounts, balances, endpoints, timestamps, and heuristic flags.

Populates

Model item
Transaction
Model item
sent by
Model item
received by

Source

Path
data/paysim_mini/transactions.csv
Kind
Local CSV

Preview: 16426 rows; showing 4

transaction_idstepstep_tstrans_typeamountname_origold_balance_orignew_balance_origname_destold_balance_destnew_balance_destis_flagged_fraud
56822953972020-01-17 13:00:00CASH_IN76550.74C6582475271096252.931172803.66C4926705732208784.022132233.280
32565492512020-01-11 11:00:00PAYMENT12617.11C1812418129339181.87326564.76M19244230590.00.00
1059490982020-01-05 02:00:00CASH_OUT8055.06C15443502988055.060.0C9124053480.08055.060
61614055502020-01-23 22:00:00TRANSFER342309.91C662184778342309.910.0C17405030200.00.00
train.csv

Labeled transactions used to train the fraud classifier.

Populates

Model item
Transaction

Source

Path
data/paysim_mini/train.csv
Kind
Local CSV

Preview: 11498 rows; showing 4

transaction_idstepstep_tsis_fraud
265712020-01-01 01:00:000
230212020-01-01 01:00:001
232912020-01-01 01:00:000
72512020-01-01 01:00:001
val.csv

Labeled transactions used to validate the fraud classifier.

Populates

Model item
Transaction

Source

Path
data/paysim_mini/val.csv
Kind
Local CSV

Preview: 2463 rows; showing 4

transaction_idstepstep_tsis_fraud
55637543892020-01-17 05:00:001
55637503892020-01-17 05:00:001
55637593892020-01-17 05:00:001
55637533892020-01-17 05:00:001
test.csv

Unlabeled transactions scored for investigation.

Populates

Model item
Transaction

Source

Path
data/paysim_mini/test.csv
Kind
Local CSV

Preview: 2465 rows; showing 4

transaction_idstepstep_ts
61482275462020-01-23 18:00:00
61454065462020-01-23 18:00:00
61521005462020-01-23 18:00:00
61518445462020-01-23 18:00:00

Download and run the template

Before you start, install Python 3.10 or later and get access to a Snowflake account with the RAI Native App. Graph, Predictive, and Prescriptive reasoning are in Public Preview; ask your RelationalAI support representative to enable Prescriptive reasoning. Preview features are for evaluation and testing, not production applications. The local demo uses bundled CSVs and runs on CPU without an external dataset or GPU. The template pins relationalai[gnn]==1.27.1 in pyproject.toml.

Ask an administrator to run this SQL before you start. It creates the writable experiment schema used by fraud_detection_local.py and grants the RAI Native App access:

CREATE DATABASE IF NOT EXISTS FRAUD_DETECTION;
CREATE SCHEMA IF NOT EXISTS FRAUD_DETECTION.EXPERIMENTS;
GRANT USAGE ON DATABASE FRAUD_DETECTION TO APPLICATION RELATIONALAI;
GRANT USAGE ON SCHEMA FRAUD_DETECTION.EXPERIMENTS TO APPLICATION RELATIONALAI;
GRANT CREATE EXPERIMENT ON SCHEMA FRAUD_DETECTION.EXPERIMENTS TO APPLICATION RELATIONALAI;
GRANT CREATE MODEL ON SCHEMA FRAUD_DETECTION.EXPERIMENTS TO APPLICATION RELATIONALAI;

Use this sequence to run the bundled example:

  1. Download the template

    Download the ZIP, unzip it, and enter the template directory:

    Terminal window
    unzip fraud-detection.zip
    cd fraud-detection
  2. Create a Python environment

    Create and activate a virtual environment, then update pip:

    Terminal window
    python -m venv .venv
    source .venv/bin/activate
    python -m pip install --upgrade pip
  3. Install the template

    Install the dependencies pinned in pyproject.toml:

    Terminal window
    python -m pip install .
  4. Configure your project

    Use the configuration builder in Start building with PyRel to create raiconfig.yaml in the template directory and verify your connection.

    Add this setting to raiconfig.yaml before running the template:

    data:
    ensure_change_tracking: true
  5. Run the template

    Run the bundled script from the template directory:

    Terminal window
    python fraud_detection_local.py

    The run reports the classifier's ROC-AUC score, ranked alerts, and the audit schedule that fits the investigator budget. The bundled PaySim sample overrepresents fraud for CPU training, so its scores don't estimate real-world detection accuracy. The sample comes from Edgar Lopez-Rojas's PaySim synthetic mobile-money dataset under CC BY-SA 4.0. See data/paysim_mini/LICENSE.txt for attribution and citation details.

See how it works

The Model Explorer shows the Account and Transaction concepts and the relationships between them. The steps below show how the template loads account and transaction data, derives fraud signals, scores transfers, and selects cases for investigation. Use the excerpts to locate the input mappings, fraud signals, and review limits to adapt for your own project.

Hover over dotted-underlined code terms to highlight their source. You can also focus or tap each term.

Connect transactions to the account network

Python’s read_csv function reads the two CSV data sources into DataFrames named accounts_df and transactions_df. The source loader also estimates the time needed to review each transfer from its amount. Account.new(...) and Transaction.new(...) map the imported records to instances of the Account and Transaction concepts shown in the Model Explorer.

Transaction.sender(...) and Transaction.receiver(...) define relationships between each transfer and its sending and receiving accounts, which the next steps use to trace money moving between accounts.

model/source.py (lines 31-53)
accounts_df = read_csv(data_dir / "accounts.csv")
transactions_df = read_csv(data_dir / "transactions.csv", parse_dates=["step_ts"])
transactions_df["audit_cost"] = np.where(
transactions_df["amount"] > large_amount_threshold,
large_audit_cost_hours,
small_audit_cost_hours,
)
train_df = read_csv(data_dir / "train.csv", parse_dates=["step_ts"])
val_df = read_csv(data_dir / "val.csv", parse_dates=["step_ts"])
test_df = read_csv(data_dir / "test.csv", parse_dates=["step_ts"])
model.define(Account.new(model.data(accounts_df).to_schema()))
model.define(Transaction.new(model.data(transactions_df).to_schema()))
model.define(TrainTable.new(model.data(train_df).to_schema()))
model.define(ValTable.new(model.data(val_df).to_schema()))
model.define(TestTable.new(model.data(test_df).to_schema()))
model.define(Transaction.sender(Transaction, Account)).where(
Transaction.name_orig == Account.account_id
)
model.define(Transaction.receiver(Transaction, Account)).where(
Transaction.name_dest == Account.account_id
)
model/source.py (lines 31-53)
accounts_df = read_csv(data_dir / "accounts.csv")
transactions_df = read_csv(data_dir / "transactions.csv", parse_dates=["step_ts"])
transactions_df["audit_cost"] = np.where(
transactions_df["amount"] > large_amount_threshold,
large_audit_cost_hours,
small_audit_cost_hours,
)
train_df = read_csv(data_dir / "train.csv", parse_dates=["step_ts"])
val_df = read_csv(data_dir / "val.csv", parse_dates=["step_ts"])
test_df = read_csv(data_dir / "test.csv", parse_dates=["step_ts"])
model.define(Account.new(model.data(accounts_df).to_schema()))
model.define(Transaction.new(model.data(transactions_df).to_schema()))
model.define(TrainTable.new(model.data(train_df).to_schema()))
model.define(ValTable.new(model.data(val_df).to_schema()))
model.define(TestTable.new(model.data(test_df).to_schema()))
model.define(Transaction.sender(Transaction, Account)).where(
Transaction.name_orig == Account.account_id
)
model.define(Transaction.receiver(Transaction, Account)).where(
Transaction.name_dest == Account.account_id
)

Build the funds-flow graph and rank accounts

Graph(...) creates a network of accounts linked by transfers. acct_graph.Edge.new(...) connects a sender to a receiver in the direction money moves.

acct_graph.pagerank() runs a graph algorithm that scores each account based on which other accounts send it money, giving more weight to connections from higher-scoring senders. Account.pagerank stores the score as an account property, which the fraud classifier uses alongside payment details to estimate whether a transfer may be fraudulent.

fraud_detection_local.py (lines 117-139)
acct_graph = Graph(
model, directed=True, weighted=False,
node_concept=Account, aggregator="sum",
)
_sender = Account.ref()
_receiver = Account.ref()
_txn_ref = Transaction.ref()
model.define(acct_graph.Edge.new(src=_sender, dst=_receiver)).where(
_txn_ref.sender(_sender),
_txn_ref.receiver(_receiver),
)
# --------------------------------------------------
# Stage 1: Graph -- account centrality via PageRank
# --------------------------------------------------
# PageRank returns a binary Relationship (account, score). Bind it to an
# explicit Account.pagerank Property so the GNN table materialiser sees a
# named column. (See machine_maintenance.py for the same pattern.)
pagerank_rel = acct_graph.pagerank()
Account.pagerank = model.Property(f"{Account} has {Float:pagerank}")
_a_pr = Account.ref()
_score_pr = Float.ref()
model.define(_a_pr.pagerank(_score_pr)).where(pagerank_rel(_a_pr, _score_pr))
fraud_detection_local.py (lines 117-139)
acct_graph = Graph(
model, directed=True, weighted=False,
node_concept=Account, aggregator="sum",
)
_sender = Account.ref()
_receiver = Account.ref()
_txn_ref = Transaction.ref()
model.define(acct_graph.Edge.new(src=_sender, dst=_receiver)).where(
_txn_ref.sender(_sender),
_txn_ref.receiver(_receiver),
)
# --------------------------------------------------
# Stage 1: Graph -- account centrality via PageRank
# --------------------------------------------------
# PageRank returns a binary Relationship (account, score). Bind it to an
# explicit Account.pagerank Property so the GNN table materialiser sees a
# named column. (See machine_maintenance.py for the same pattern.)
pagerank_rel = acct_graph.pagerank()
Account.pagerank = model.Property(f"{Account} has {Float:pagerank}")
_a_pr = Account.ref()
_score_pr = Float.ref()
model.define(_a_pr.pagerank(_score_pr)).where(pagerank_rel(_a_pr, _score_pr))

Count transfers sent by each account

How many transfers an account sends can help the classifier assess a transfer. Account.activity_count defines an account property to hold that count.

count(Transaction).per(Account) counts transfers by account, and the .where(...) condition matches each transfer’s sender ID to an account ID so that only outgoing transfers contribute. The classifier uses the result with the network score from step 2 and payment details to estimate whether a transfer may be fraudulent.

fraud_detection_local.py (lines 150-154)
Account.activity_count = model.Property(
f"{Account} has {Integer:activity_count}")
model.define(Account.activity_count(
count(Transaction).per(Account)
)).where(Transaction.name_orig == Account.account_id)
fraud_detection_local.py (lines 150-154)
Account.activity_count = model.Property(
f"{Account} has {Integer:activity_count}")
model.define(Account.activity_count(
count(Transaction).per(Account)
)).where(Transaction.name_orig == Account.account_id)

Train a fraud classifier and score transfers

GNN(...) sets up a graph neural network to classify transfers from their payment details, the account signals from steps 2 and 3, and a separate network linking transfers to accounts. gnn.fit() trains on transfers labeled fraudulent or legitimate, then evaluates the model on validation transfers.

gnn.predictions(domain=Test) assigns a predicted fraud probability to each transaction in the test set. The next step combines that probability with the source data’s fraud flag to help prioritize cases for review.

fraud_detection_local.py (lines 196-207)
gnn = GNN(
exp_database="FRAUD_DETECTION", exp_schema="EXPERIMENTS",
graph=gnn_graph, property_transformer=pt,
train=Train, validation=Val,
task_type="binary_classification", eval_metric="roc_auc",
has_time_column=True, stream_logs=STREAM_LOGS, seed=SEED,
device="cpu", n_epochs=10, lr=0.005,
temporal_strategy="last",
)
gnn.fit()
_report(gnn, "fraud_gnn")
Transaction.predictions = gnn.predictions(domain=Test)
fraud_detection_local.py (lines 196-207)
gnn = GNN(
exp_database="FRAUD_DETECTION", exp_schema="EXPERIMENTS",
graph=gnn_graph, property_transformer=pt,
train=Train, validation=Val,
task_type="binary_classification", eval_metric="roc_auc",
has_time_column=True, stream_logs=STREAM_LOGS, seed=SEED,
device="cpu", n_epochs=10, lr=0.005,
temporal_strategy="last",
)
gnn.fit()
_report(gnn, "fraud_gnn")
Transaction.predictions = gnn.predictions(domain=Test)

Combine two signals into a fraud alert score

Transaction.alert_score defines a model property that combines the source data’s existing fraud flag with the classifier’s predicted fraud probability for each transfer in the test set. ALPHA_FLAG controls how much the flag contributes, and Transaction.predictions.probs supplies the remaining share.

The combined score helps prioritize reviews, but it does not prove that a transfer is fraudulent. The next step uses the score alongside transfer amount and available investigator time to choose cases for review.

fraud_detection_local.py (lines 213-218)
Transaction.alert_score = model.Property(
f"{Transaction} has {Float:alert_score}")
model.define(Transaction.alert_score(
ALPHA_FLAG * Transaction.is_flagged_fraud
+ (1 - ALPHA_FLAG) * Transaction.predictions.probs
)).where(Transaction.predictions)
fraud_detection_local.py (lines 213-218)
Transaction.alert_score = model.Property(
f"{Transaction} has {Float:alert_score}")
model.define(Transaction.alert_score(
ALPHA_FLAG * Transaction.is_flagged_fraud
+ (1 - ALPHA_FLAG) * Transaction.predictions.probs
)).where(Transaction.predictions)

Choose the highest-value investigations within capacity

problem.solve_for(...) defines a decision variable: a yes-or-no choice to investigate each transfer with an alert score. Two constraints narrow those choices: an investigator-hours budget caps total review time, and a receiver-account cap limits how many selected transfers go to the same account.

problem.maximize(...) seeks the set of reviews with the most estimated loss averted, calculated from each selected transfer’s alert score and amount. problem.solve(...) asks the solver to find a choice that satisfies both constraints instead of simply ranking transfers by score.

fraud_detection_local.py (lines 257-293)
problem.solve_for(
Transaction.x_audit(select_ref),
type="bin",
name=["audit", Transaction.transaction_id],
where=[Transaction.alert_score(alert_bind)],
)
# Constraint: total investigator hours <= budget
# sum over audited transactions of audit_cost <= AUDIT_BUDGET_HOURS
problem.satisfy(model.where(
Txn_ref.x_audit(select_ref),
).require(
sum(Txn_ref, Txn_ref.audit_cost * select_ref) <= AUDIT_BUDGET_HOURS
))
# Constraint: per-receiver-account cap (at most PER_ACCOUNT_CAP audits per receiver)
Acct_ref = Account.ref()
Txn_rc = Transaction.ref()
select_rc = Float.ref()
problem.satisfy(model.where(
Txn_rc.x_audit(select_rc),
Txn_rc.name_dest == Acct_ref.account_id,
).require(
sum(Txn_rc, select_rc).per(Acct_ref) <= PER_ACCOUNT_CAP
))
# Objective: maximize expected loss averted
# E[loss averted | audit] = alert_score * transaction_amount
# Ranking by alert_score alone is suboptimal here: a high-score $5M transfer
# consumes 5 hours but yields less $/hour than two medium-score $500K at 1hr.
sel_obj = Float.ref()
Txn_obj = Transaction.ref()
problem.maximize(sum(
Txn_obj.alert_score * Txn_obj.amount * sel_obj
).where(Txn_obj.x_audit(sel_obj)))
problem.solve("highs", time_limit_sec=120)
fraud_detection_local.py (lines 257-293)
problem.solve_for(
Transaction.x_audit(select_ref),
type="bin",
name=["audit", Transaction.transaction_id],
where=[Transaction.alert_score(alert_bind)],
)
# Constraint: total investigator hours <= budget
# sum over audited transactions of audit_cost <= AUDIT_BUDGET_HOURS
problem.satisfy(model.where(
Txn_ref.x_audit(select_ref),
).require(
sum(Txn_ref, Txn_ref.audit_cost * select_ref) <= AUDIT_BUDGET_HOURS
))
# Constraint: per-receiver-account cap (at most PER_ACCOUNT_CAP audits per receiver)
Acct_ref = Account.ref()
Txn_rc = Transaction.ref()
select_rc = Float.ref()
problem.satisfy(model.where(
Txn_rc.x_audit(select_rc),
Txn_rc.name_dest == Acct_ref.account_id,
).require(
sum(Txn_rc, select_rc).per(Acct_ref) <= PER_ACCOUNT_CAP
))
# Objective: maximize expected loss averted
# E[loss averted | audit] = alert_score * transaction_amount
# Ranking by alert_score alone is suboptimal here: a high-score $5M transfer
# consumes 5 hours but yields less $/hour than two medium-score $500K at 1hr.
sel_obj = Float.ref()
Txn_obj = Transaction.ref()
problem.maximize(sum(
Txn_obj.alert_score * Txn_obj.amount * sel_obj
).where(Txn_obj.x_audit(sel_obj)))
problem.solve("highs", time_limit_sec=120)