Designing a Fraud Detection System
Fraud detection is one of the hardest ML system design problems: extreme class imbalance, adversarial inputs, real-time constraints, and the cost of false positives. Here is how to approach it.
Fraud detection is an adversarial ML problem. Unlike churn prediction, your adversary adapts to your model. This makes it one of the most challenging and interesting ML system designs.
Problem Framing
Key constraints that shape the design:
- Extreme class imbalance - fraud rates are typically 0.1–1% of transactions
- Real-time requirements - a payment decision often needs to be made in <100ms
- Adversarial dynamics - fraudsters study your rules and model to evade detection
- Asymmetric costs - false negative (missed fraud) costs money; false positive (blocking legitimate user) costs trust and revenue
- Feedback delay - a fraud label may not arrive for days or weeks (chargebacks)
System Architecture
Transaction request
│
▼
┌────────────────────────────────────────────────────────────────┐
│ Feature Extraction (< 10ms) │
│ - Transaction features: amount, merchant, location, time │
│ - User history features: avg spend, velocity, device │
│ - Graph features: network connections to known fraud │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ Rule Engine (< 5ms) │
│ - Hard blocks: known stolen cards, sanctioned countries │
│ - Velocity rules: >5 transactions in 1 minute │
│ - High-confidence heuristics that do not require ML │
└────────────────────────────────────────────────────────────────┘
│ passes rules
▼
┌────────────────────────────────────────────────────────────────┐
│ ML Model Layer (< 50ms) │
│ - Fast model: gradient boosted tree (GBT) for <10ms scoring │
│ - Deep model: neural net for higher accuracy (async or sync) │
│ - Ensemble score: weighted combination │
└────────────────────────────────────────────────────────────────┘
│
▼
┌────────────────────────────────────────────────────────────────┐
│ Decision Engine │
│ - score < 0.3 → ALLOW │
│ - 0.3 ≤ score < 0.7 → CHALLENGE (step-up auth, CAPTCHA) │
│ - score ≥ 0.7 → BLOCK │
└────────────────────────────────────────────────────────────────┘
Feature Engineering for Fraud
The most predictive features are velocity features and device/behavioral signals:
pythondef extract_fraud_features(transaction: dict, user_history: list, device: dict) -> dict: now = transaction['timestamp'] past_1h = [t for t in user_history if now - t['timestamp'] < 3600] past_24h = [t for t in user_history if now - t['timestamp'] < 86400] past_30d = [t for t in user_history if now - t['timestamp'] < 86400 * 30] return { # Transaction features "amount": transaction['amount'], "amount_log": np.log1p(transaction['amount']), "merchant_category": transaction['merchant_category'], "is_international": transaction['country'] != user_history[-1]['country'] if user_history else 0, # Velocity features - most discriminative "txn_count_1h": len(past_1h), "txn_count_24h": len(past_24h), "amount_sum_1h": sum(t['amount'] for t in past_1h), "distinct_merchants_24h": len(set(t['merchant_id'] for t in past_24h)), "distinct_countries_24h": len(set(t['country'] for t in past_24h)), # Deviation from baseline "amount_vs_avg_30d": transaction['amount'] / (np.mean([t['amount'] for t in past_30d]) + 1e-6), "hour_of_day": pd.Timestamp(now, unit='s').hour, "is_unusual_hour": 1 if pd.Timestamp(now, unit='s').hour < 6 else 0, # Device and behavioral signals "is_new_device": int(device['device_id'] not in user_history_devices), "device_type": device['type'], "ip_country_mismatch": int(device['ip_country'] != transaction['country']), "is_vpn": device.get('is_vpn', 0), }
Handling Class Imbalance
With 0.5% fraud rate, a model that predicts "not fraud" 100% of the time has 99.5% accuracy. You need strategies specifically for imbalance:
pythonfrom sklearn.ensemble import GradientBoostingClassifier from imblearn.over_sampling import SMOTE import lightgbm as lgb # Strategy 1: Scale positive weight in the model model_gbt = lgb.LGBMClassifier( scale_pos_weight=99, # ratio of negative to positive class n_estimators=500, learning_rate=0.05, max_depth=8 ) # Strategy 2: SMOTE (Synthetic Minority Over-sampling) smote = SMOTE(sampling_strategy=0.1, random_state=42) X_resampled, y_resampled = smote.fit_resample(X_train, y_train) # Strategy 3: Threshold optimization (most practical) # Instead of using 0.5 as the decision threshold, optimize for F-beta score from sklearn.metrics import fbeta_score import numpy as np y_proba = model.predict_proba(X_val)[:, 1] thresholds = np.arange(0.1, 0.9, 0.01) # beta=2 weights recall twice as much as precision (catching fraud matters more) best_threshold = max( thresholds, key=lambda t: fbeta_score(y_val, y_proba > t, beta=2) ) print(f"Optimal threshold: {best_threshold:.2f}")
Evaluation Metrics for Fraud
Never use accuracy. The right metrics:
pythonfrom sklearn.metrics import (precision_recall_curve, average_precision_score, roc_auc_score, confusion_matrix) # AUC-PR is better than AUC-ROC for imbalanced classes auc_pr = average_precision_score(y_val, y_proba) print(f"AUC-PR: {auc_pr:.4f}") # At your operating threshold, what are the business numbers? y_pred = (y_proba > best_threshold).astype(int) tn, fp, fn, tp = confusion_matrix(y_val, y_pred).ravel() print(f"Catch rate (recall): {tp/(tp+fn):.2%}") # % of fraud caught print(f"False positive rate: {fp/(fp+tn):.2%}") # % of legit txns blocked print(f"Precision: {tp/(tp+fp):.2%}") # % of blocks that were fraud # Business cost model fraud_amount = sum(transaction_amounts[y_val == 1]) caught_fraud_amount = sum(transaction_amounts[(y_val == 1) & (y_pred == 1)]) false_block_cost = fp * 5 # assume $5 cost per false block (user friction) net_value = caught_fraud_amount - false_block_cost print(f"Net value: ${net_value:,.2f}")
Graph Features: The Fraud Ring Detector
Individual transaction features miss organized fraud rings. Graph features detect shared infrastructure:
python# Build a bipartite graph: users ↔ devices, cards, IP addresses import networkx as nx G = nx.Graph() # Add edges from transaction logs for txn in transactions: G.add_edge(f"user:{txn['user_id']}", f"device:{txn['device_id']}") G.add_edge(f"user:{txn['user_id']}", f"card:{txn['card_id']}") G.add_edge(f"user:{txn['user_id']}", f"ip:{txn['ip_address']}") # Label propagation: if a known fraud node connects to new users, those users are risky from networkx.algorithms.community import label_propagation_communities # Count connections to known fraud accounts def fraud_network_features(user_id: str, known_fraud_users: set) -> dict: user_node = f"user:{user_id}" neighbors = set(G.neighbors(user_node)) # 2-hop neighbors two_hop = set() for n in neighbors: two_hop.update(G.neighbors(n)) fraud_neighbors_1h = sum(1 for n in neighbors if n.replace("user:", "") in known_fraud_users) fraud_neighbors_2h = sum(1 for n in two_hop if n.replace("user:", "") in known_fraud_users) return { "fraud_neighbors_1hop": fraud_neighbors_1h, "fraud_neighbors_2hop": fraud_neighbors_2h, "total_network_size": len(neighbors) + len(two_hop) }
Adversarial Robustness
Fraudsters probe your system. Common attacks:
- Velocity evasion: Keep transactions just below your velocity thresholds
- Threshold probing: Make small purchases to find your decision boundary
- Feature poisoning: Use legitimate patterns (normal amounts, known merchants) until they have high enough account age
Countermeasures:
- Add noise to your decision boundary (never expose exact thresholds via API)
- Use behavioral sequences, not just point-in-time snapshots
- Retrain frequently on recent fraud - fraud patterns evolve monthly
- Maintain a separate model for detecting systematic probing behavior
Online Feature Computation
Low-latency velocity features require precomputed aggregates in Redis:
pythonimport redis import time r = redis.Redis() def increment_velocity_counters(user_id: str, amount: float): """Update counters atomically when a transaction is processed.""" now = int(time.time()) pipe = r.pipeline() # Use sorted sets with timestamp as score pipe.zadd(f"txns:{user_id}", {str(now): now}) pipe.zadd(f"amounts:{user_id}", {f"{amount}:{now}": now}) # Clean up entries older than 30 days pipe.zremrangebyscore(f"txns:{user_id}", 0, now - 86400 * 30) pipe.execute() def get_velocity_features(user_id: str) -> dict: now = int(time.time()) # Count transactions in each window (O(log n) per query) txns_1h = r.zcount(f"txns:{user_id}", now - 3600, now) txns_24h = r.zcount(f"txns:{user_id}", now - 86400, now) return { "txn_count_1h": txns_1h, "txn_count_24h": txns_24h, }
Redis sorted sets give you O(log n) range queries, making real-time velocity computation feasible at scale.
Common Mistakes
Optimizing for accuracy on heavily imbalanced fraud data. Fraud datasets are typically 0.1-1% positive. A classifier that always predicts "not fraud" achieves 99%+ accuracy while being completely useless. Always evaluate fraud models on precision, recall, and F1 at your operating threshold - or better, plot the full precision-recall curve and select a threshold based on the business cost of false positives versus false negatives.
Not accounting for adversarial adaptation. Unlike most ML problems, fraud detection has adversaries who actively learn and adapt to the model's decision boundary. A rule or pattern that catches fraud today will be changed by fraudsters once they learn it triggers detection. Build explicit model refresh cadences and monitor for sudden drops in precision as a signal that the adversary has adapted.
Missing temporal leakage in feature computation. Features computed from future data (e.g., "average transaction value for this user over the next 7 days") will produce excellent offline metrics but fail completely in production. Every feature must be computable from data available at prediction time. This requires point-in-time correct feature construction that is easy to get wrong in training pipelines using aggregate window functions.
What to Practice Next
- Design the feature store schema for a fraud detection system: for each feature, document its computation window, the event timestamp it is anchored to, and how you would verify it is point-in-time correct.
- Plot the precision-recall curve for a classifier on an imbalanced fraud dataset; select a threshold that achieves at least 80% precision and report the resulting recall.
- Simulate adversarial adaptation by retraining a fraud model after removing the top-5 most predictive features; measure how much performance degrades and identify what new signals you would add.
Stay in the loop
Get new ML/AI lessons in your inbox.
No account needed. We will send curriculum updates, launch notes, and practical learning resources.
Related Posts
More postsDesigning a Search and Ranking System
Search is one of the highest-leverage ML problems. A well-designed ranking system doubles engagement; a poor one loses users in seconds. Walk through the full architecture from query to result.
Designing a Real-Time Recommendation System
A recommendation system that feels real-time but stays fast at scale requires careful architecture. Walk through the full design: candidate generation, ranking, serving, and feedback loops.
ML System Design for LLM-Powered Customer Support
Replacing a rules-based support bot with an LLM-powered assistant sounds simple. The production system is not. Walk through the full design: RAG, guardrails, escalation, and cost control.