Fraud can hide in connections that look harmless one transaction at a time. A graph neural network (GNN) scores activity using both its attributes and its relationships—for example, transactions linked through a shared user or device. The goal is to surface suspicious patterns that an isolated-transaction model may miss. section 9.0 section 9.4.2
A GNN learns representations by passing information between connected nodes. In a simple two-layer model, each layer transforms a transaction’s features, aggregates transformed features from its neighbors, and combines the two. The resulting representation carries local context into a Normal/Fraud classifier. Unlike community detection, which finds densely connected groups, this approach learns features for a prediction task. section 9.4 section 9.4.2
A simplified transaction-fraud workflow is:
- Create one node per transaction, using amount and transaction-time features; connect transactions that share a user ID or device ID. section 9.4.2
- Pass the feature matrix and adjacency matrix through two neighborhood-aggregation layers and a two-class classifier. section 9.4.2
- If fraud labels exist, train with cross-entropy loss; otherwise, run inference. Convert class scores to fraud probabilities and inspect the five highest-ranked transactions. section 9.4.2
Listing 9.7 Defining the GNN model
import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np
import pandas as pd
from sklearn.preprocessing import StandardScaler
from scipy.sparse import csr_matrix
# Simple GNN Model
class FraudGNN(nn.Module):
def __init__(self, input_dim, hidden_dim=32):
super(FraudGNN, self).__init__()
self.conv1 = nn.Linear(input_dim, hidden_dim)
self.conv2 = nn.Linear(hidden_dim, hidden_dim)
self.classifier = nn.Linear(hidden_dim, 2) # Normal/Fraud
def forward(self, x, adj):
# First GNN layer: Aggregate neighbor information
h1 = torch.relu(self.conv1(x))
h1_agg = torch.mm(adj, h1) # Average neighbors' information
h1 = h1 + h1_agg
# Second GNN layer
h2 = torch.relu(self.conv2(h1))
h2_agg = torch.mm(adj, h2)
h2 = h2 + h2_agg
# Classification
out = self.classifier(h2)
return out
Inspect the PyTorch model to see the feature transformation, adjacency-matrix aggregation, and combination of a node’s own and neighboring information. section 9.4.2
Listing 9.8 Function to create graph data (create_fast_graph)
def create_fast_graph(df):
"""Fast graph generation using vectorized operations"""
print(" Creating node features...")
n = len(df)
times = pd.to_datetime(df['transaction_time'])
features = np.column_stack([
df['amount'].values / 1000,
times.dt.hour.values / 24.0,
times.dt.weekday.values / 6.0
])
print(" Computing connections...")
row_indices = []
col_indices = []
for user_id in df['user_id'].unique(): #A
user_mask = df['user_id'] == user_id
user_indices = np.where(user_mask)[0]
if len(user_indices) > 1:
for i in user_indices:
for j in user_indices:
if i != j:
row_indices.append(i)
col_indices.append(j)
for device_id in df['device_id'].unique(): #1
device_mask = df['device_id'] == device_id
device_indices = np.where(device_mask)[0]
if len(device_indices) > 1:
for i in device_indices:
for j in device_indices:
if i != j:
row_indices.append(i)
col_indices.append(j)
print(" Creating sparse matrix...")
if row_indices:
data = np.ones(len(row_indices))
adj_sparse = csr_matrix((data, (row_indices, col_indices)), shape=(n, n))
row_sum = np.array(adj_sparse.sum(axis=1)).flatten()
row_sum[row_sum == 0] = 1
adj_sparse = adj_sparse.multiply(1.0 / row_sum.reshape(-1, 1)) # Normalized sparse matrix
if n < 1000: # Original logic to convert to dense for small graphs
adj_matrix = adj_sparse.toarray()
else:
adj_matrix = adj_sparse # Keep sparse for larger graphs
else:
adj_matrix = np.eye(n)
return features, adj_matrix
Inspect how tabular transactions become node features and connections based on shared identifiers. section 9.4.2
Listing 9.10 Defining the orchestration function (2/3): Model training logic
# --- Continuation of run_fast_gnn_demo function ---
# 4. Train if labels exist, otherwise inference only
if 'is_fraud' in df.columns:
print("\n Training GNN...")
y = torch.LongTensor(df['is_fraud'].values) # Using original df['is_fraud'].values
optimizer = torch.optim.Adam(model.parameters(), lr=0.01, weight_decay=1e-4)
criterion = nn.CrossEntropyLoss()
best_loss = float('inf')
patience = 0
for epoch in range(100):
model.train() # Set model to training mode
optimizer.zero_grad()
outputs = model(X, A)
loss = criterion(outputs, y)
loss.backward()
optimizer.step()
if epoch % 20 == 0: # Original condition for printing
acc = (outputs.argmax(1) == y).float().mean()
print(f" Epoch {epoch}: Loss={loss.item():.3f}, Acc={acc.item():.3f}") # Using .item() for scalar loss
if loss.item() < best_loss: # Early stopping based on training loss
best_loss = loss.item()
patience = 0
else:
patience += 1
if patience > 3 and epoch > 40:
print(f" Early stopping at epoch {epoch}")
break
# Removed "GNN training finished." from here to match original single print at the end of function
else: # This else block was part of the original code for the 'is_fraud' in df.columns check
print("\n GNN inference mode...")
# The function continues in Listing 9.11 with results analysis and return...
Inspect the labeled-data training path, including the optimizer, loss function, and epoch loop. section 9.4.2
The PyTorch workflow is an educational bridge from graph construction to graph learning, not a production fraud system. Large, changing financial graphs call for scalable training approaches such as neighbor sampling, attention to class imbalance, and explainability. GNN signals belong in a multilayered fraud defense rather than replacing other detection methods or investigator judgment. section 9.4.2 section 9.6
Beyond fraud: investment discovery
Two investors hold the same asset; one reads a niche report that the other has not searched for. Their shared holding supplies a graph-based signal for recommending the report, extending discovery beyond matching users and content as independent vectors. section 13.3.3




