Chapter 8 Project 6: Real-Time Fraud Detection

Fraud detection demands millisecond-level latency, extreme class imbalance (often <0.1% fraud), and adaptability to evolving attack patterns. In this chapter, we build a hybrid system combining isolation forests for anomaly detection with XGBoost for supervised classification, backed by Redis for real-time velocity features.

8.1 Business Context

A payment processor needs to score every transaction in real time:

  • Approve: Process immediately (< 50ms)
  • Review: Send to manual review queue (50-500ms)
  • Block: Decline transaction (> 500ms or high confidence fraud)

The system must minimize false positives (legitimate transactions blocked) while catching sophisticated fraud patterns.

8.2 Architecture Overview

Transaction ──► Velocity Features (Redis) ──┐
                                            ├──► Isolation Forest ──► XGBoost ──► Decision
Static Features (DB) ────────────────────────┘

8.3 Real-Time Velocity Features with Redis

Velocity features capture behavioral anomalies: is this user making unusually many transactions? Is this card being used across multiple countries?

library(redis)
library(jsonlite)

redis_con <- redux::hiredis(host = "redis-cluster", port = 6379)

compute_velocity_features_r <- function(transaction) {
  user_id <- transaction$user_id
  card_hash <- transaction$card_fingerprint

  # User velocity (last 1 hour)
  user_key <- paste0("user:", user_id, ":txns")
  user_txns <- redis_con$ZRANGEBYSCORE(
    user_key,
    as.character(as.numeric(Sys.time()) - 3600),
    as.character(as.numeric(Sys.time()))
  )

  # Card velocity (last 24 hours)
  card_key <- paste0("card:", card_hash, ":txns")
  card_txns <- redis_con$ZRANGEBYSCORE(
    card_key,
    as.character(as.numeric(Sys.time()) - 86400),
    as.character(as.numeric(Sys.time()))
  )

  # Parse transaction amounts from JSON
  user_amounts <- sapply(user_txns, function(t) fromJSON(t)$amount, USE.NAMES = FALSE)
  card_amounts <- sapply(card_txns, function(t) fromJSON(t)$amount, USE.NAMES = FALSE)

  # Historical baselines
  user_avg <- as.numeric(redis_con$GET(paste0("user:", user_id, ":avg_amount")))
  user_max <- as.numeric(redis_con$GET(paste0("user:", user_id, ":max_amount")))

  list(
    user_txn_count_1h = length(user_txns),
    user_txn_amount_1h = sum(user_amounts, na.rm = TRUE),
    card_txn_count_24h = length(card_txns),
    card_txn_amount_24h = sum(card_amounts, na.rm = TRUE),
    amount_vs_user_avg = transaction$amount / max(user_avg, 1),
    amount_vs_user_max = transaction$amount / max(user_max, 1),
    time_since_last_txn_min = ifelse(
      length(user_txns) > 0,
      (as.numeric(Sys.time()) - 
         as.numeric(redis_con$ZSCORE(user_key, tail(user_txns, 1)))) / 60,
      9999
    )
  )
}

Important: Redis sorted sets (ZADD, ZRANGEBYSCORE) provide O(log n) insertion and range queries, making them ideal for time-windowed aggregations. Set TTLs (time-to-live) on keys to prevent unbounded memory growth.

8.4 Isolation Forest for Anomaly Detection

Isolation forests isolate anomalies by randomly selecting features and split values. Anomalies require fewer splits to isolate.

library(solitude)

fit_isolation_forest <- function(train_data) {
  if_model <- isolationForest$new(
    sample_size = nrow(train_data),
    num_trees = 200
  )
  if_model$fit(train_data)
  if_model
}

8.5 XGBoost with Anomaly Score

We enhance the feature set with the isolation forest anomaly score, creating a powerful hybrid model.

fit_fraud_xgboost <- function(train_data, labels) {
  # Add isolation forest anomaly score
  if_model <- fit_isolation_forest(train_data)
  if_scores <- if_model$predict(train_data)$anomaly_score

  train_enhanced <- train_data %>%
    mutate(iso_score = if_scores)

  dtrain <- xgb.DMatrix(
    data = as.matrix(train_enhanced),
    label = labels
  )

  params <- list(
    objective = "binary:logistic",
    eval_metric = "aucpr",
    eta = 0.1,
    max_depth = 6,
    subsample = 0.8,
    colsample_bytree = 0.8,
    scale_pos_weight = 50  # Fraud is ~50x rarer than legitimate
  )

  xgb_cv <- xgb.cv(
    params = params,
    data = dtrain,
    nrounds = 300,
    nfold = 5,
    early_stopping_rounds = 20,
    print_every_n = 50
  )

  xgb.train(
    params = params,
    data = dtrain,
    nrounds = xgb_cv$best_iteration
  )
}

8.6 Real-Time Scoring Function

score_transaction <- function(transaction, xgb_model, if_model, threshold = 0.7) {
  # Compute velocity features
  velocity <- compute_velocity_features_r(transaction)
  transaction_enhanced <- c(transaction, velocity)

  # Isolation forest anomaly score
  if_score <- if_model$predict(
    as.data.frame(transaction_enhanced)
  )$anomaly_score

  # XGBoost fraud probability
  features <- as.matrix(as.data.frame(transaction_enhanced))
  fraud_prob <- predict(xgb_model, features)

  # Decision logic
  action <- case_when(
    fraud_prob > 0.95 ~ "block",
    fraud_prob > threshold ~ "review",
    TRUE ~ "approve"
  )

  list(
    transaction_id = transaction$transaction_id,
    fraud_probability = round(fraud_prob, 4),
    action = action,
    iso_score = round(if_score, 4),
    timestamp = format(Sys.time(), "%Y-%m-%d %H:%M:%S")
  )
}

8.7 Performance Optimization

8.7.1 Pre-warming Redis

Load historical baselines into Redis at startup:

warm_redis_baselines <- function(con) {
  tbl(con, "transactions") %>%
    filter(transaction_date >= Sys.Date() - 90) %>%
    group_by(user_id) %>%
    summarise(
      avg_amount = mean(amount),
      max_amount = max(amount),
      .groups = "drop"
    ) %>%
    collect() %>%
    pwalk(function(user_id, avg_amount, max_amount) {
      redis_con$SET(paste0("user:", user_id, ":avg_amount"), avg_amount)
      redis_con$SET(paste0("user:", user_id, ":max_amount"), max_amount)
    })
}

8.7.2 Model Serialization

Use readr::write_rds() with compression for fast loading:

# Save
write_rds(list(xgb = xgb_model, if = if_model), "fraud_model.rds", compress = "xz")

# Load (fast startup)
models <- read_rds("fraud_model.rds")

8.8 Monitoring & Alerting

Track model performance in real time:

log_fraud_decision <- function(score_result, actual_label) {
  log_info("Transaction {score_result$transaction_id}: 
            action={score_result$action}, 
            prob={score_result$fraud_probability}, 
            actual={actual_label}")

  # Push to monitoring system
  redis_con$LPUSH("fraud:log", jsonlite::toJSON(score_result))
}

8.9 Exercises

  1. Implement a device fingerprinting feature that flags transactions from new devices.
  2. Add a geo-velocity check: flag transactions impossible given time/distance from previous transaction.
  3. Build a feedback loop that retrains the model weekly on new labeled data.
  4. Implement a circuit breaker pattern: if Redis is down, fall back to database queries with a timeout.

Next, we’ll build an automated reporting pipeline that orchestrates ETL, transformations, and report generation.