DEV Community

Cover image for From Poisson to Hawkes: Modeling Fraud Bursts in Finance
Akan
Akan

Posted on

From Poisson to Hawkes: Modeling Fraud Bursts in Finance

Have you ever thought about taking a pure mathematical idea and stress-testing it against the messy reality of finance?

In this tutorial, I dive into fraud detection through the lens of stochastic processes - modeling transaction queues not just as numbers, but as signals of human (and sometimes malicious) behavior. Imagine a payment system where normal transactions trickle in steadily, then suddenly an attack injects bursts of activity. The challenge isn’t spotting one suspicious transaction; it’s recognizing the rhythm of the queue itself.

To tackle this, I compare two classic approaches:

  1. Hawkes processes: self-exciting point processes where one event sparks a cascade of follow-ups.
  2. Markov-Modulated Poisson Processes (MMPP): regime-switching models that capture the shift between "business as usual" and "attack mode."

Stochastic Processes

The fraud signal lives in the arrival-rate structure, not the individual transaction. By reframing fraud as a stochastic queueing problem, we get a visceral, intuitive way to see attacks unfold - and a rigorous mathematical framework to detect them.

Let's walk through how it's actually built, stage by stage.

  1. Generating an attack that looks real

Real labeled fraud data is close to impossible to get your hands on, and even harder to get ground truth on the exact moment an attack starts. So the first step is simulation - but simulation with a purpose: generate two competing hypotheses for how a burst happens, and secretly keep the answer key.

A Hawkes process says events excite future events. The conditional intensity - how "hot" the process is at any moment - is:

lambda(t) = mu + sum( alpha * exp(-beta * (t - t_i)) for every past event t_i )

Implemented via Ogata's thinning algorithm:

def hawkes_stream(mu, alpha, beta, duration, rng, attack_windows=None,
                   attack_alpha_multiplier=4.0):
    events = []
    t = 0.0
    while t < duration:
        lam_upper = mu + sum(alpha * np.exp(-beta * (t - te)) for te in events[-200:]) + alpha
        t += rng.exponential(1.0 / max(lam_upper, 1e-6))
        lam_true = mu + sum(alpha_at(t) * np.exp(-beta * (t - te)) for te in events[-200:])
        if rng.uniform(0, 1) <= lam_true / lam_upper:
            events.append(t)
Enter fullscreen mode Exit fullscreen mode

An MMPP instead assumes a hidden two-state Markov chain - normal and attack - each driving its own Poisson rate:

def mmpp_stream(mu_normal, mu_attack, p_normal_to_attack, p_attack_to_normal,
                 duration, rng, dt=1.0):
    state = 0
    for i in range(int(duration / dt)):
        rate = mu_attack if state == 1 else mu_normal
        n_events = rng.poisson(rate * dt)
        ...
        if state == 0 and rng.uniform(0, 1) < p_normal_to_attack:
            state = 1
        elif state == 1 and rng.uniform(0, 1) < p_attack_to_normal:
            state = 0
Enter fullscreen mode Exit fullscreen mode

Both generators log a ground-truth label alongside every timestamp - the only reason this project can grade itself honestly later.

One real gotcha worth sharing: a Hawkes process is only stable when alpha/beta stays below 1 (subcritical). Push it above 1, even briefly during a simulated "attack" burst, and the process becomes explosive - I hit this early on and generated 5,000+ events in a 60-second window instead of the expected ~100. Cheap lesson, worth knowing before you tune your own.

  1. Watching what a burst does to a real system

A burst isn't just a statistical curiosity - it does something physical to a payment gateway: the authorization queue backs up. I modeled the gateway as an M/M/c queue using SimPy:

class GatewayQueueSim:
    def _transaction(self, env, resource, arrival_time):
        queue_enter = env.now
        with resource.request() as req:
            yield req
            wait = env.now - queue_enter
            self.wait_times.append(wait)
            yield env.timeout(self.rng.exponential(1.0 / self.service_rate))
Enter fullscreen mode Exit fullscreen mode

Feed a Hawkes-generated burst through this and you see it directly: queue length spikes from near-zero to double digits, wait times jump 5-10x, right at the attack window. This is the cheapest fraud signal in the whole project - no model fitting required, just watching the line get long.

  1. Fitting two models, blind

This is the part worth being precise about: the detectors never see the ground-truth labels, and never see the true mu/alpha/beta that generated the data. They only receive raw timestamps and have to estimate parameters from scratch.

Hawkes detector - maximum likelihood estimation on the observed timestamps:

def hawkes_log_likelihood(params, event_times, T):
    mu, alpha, beta = params
    R, log_lik, prev_t = 0.0, 0.0, 0.0
    for i, t in enumerate(event_times):
        if i > 0:
            R = np.exp(-beta * (t - prev_t)) * (1 + R)
        log_lik += np.log(max(mu + alpha * R, 1e-12))
        prev_t = t
    compensator = mu * T + sum((alpha/beta) * (1 - np.exp(-beta*(T-t))) for t in event_times)
    return -(log_lik - compensator)
Enter fullscreen mode Exit fullscreen mode

scipy.optimize.minimize nudges mu, alpha, beta until the observed arrival pattern becomes as likely as possible under the model. Once fit, the detector recomputes the conditional intensity at every point in time and flags anything above a threshold multiple of baseline.

MMPP detector - bins events into per-second counts and fits a Hidden Markov Model via Baum-Welch (EM):

def fit_mmpp(event_times, T, dt=1.0, n_states=2):
    counts = bin_events(event_times, T, dt)
    model = GaussianHMM(n_components=n_states, covariance_type="diag", n_iter=200)
    model.fit(counts)
    states = model.predict(counts)          # Viterbi decode
    attack_state = int(np.argmax(model.means_.flatten()))
    flags = states == attack_state
Enter fullscreen mode Exit fullscreen mode

In testing, this recovered rates of 0.97 and 8.09 against true generating values of 1.0 and 8.0 - a clean fit, no cheating involved.

  1. Scoring them honestly - including against each other's assumptions

A comparison only means something if you also test each detector on data it wasn't designed for. I cross-tested both: Hawkes detector against MMPP-generated data, and vice versa.

def evaluate_detector(name, fit_fn, event_times, event_labels, T, dt=1.0):
    ground_truth = _events_to_bin_labels(event_times, event_labels, T, dt)
    result = fit_fn(event_times, T=T)
    precision, recall, f1 = _precision_recall_f1(result["flags"], ground_truth)
    latency = _detection_latency(result["flags"], ground_truth, result["grid"])
    return {"model": name, "precision": precision, "recall": recall, "f1": f1,
            "detection_latency_sec": latency, "aic": result["aic"], "bic": result["bic"]}
Enter fullscreen mode Exit fullscreen mode

Real results from one run: MMPP scored F1 ≈ 0.92 on its own generating process, while Hawkes trailed on the same data - each model's assumptions genuinely matter, and neither wins universally. On a separate seed, Hawkes actually scored F1 = 0.000 - flagged nothing at all. That's not a bug; it's the honest fragility of a threshold-based detector on certain random draws, and it's exactly the kind of finding a rigged demo would hide.

  1. Beyond timing - catching the amount pattern too

Bursty timing isn't the only tell. Card-testing attacks classically show a tiny "is this card alive" probe followed by a near-limit strike. Rather than hardcode what counts as suspiciously small or large, thresholds are derived per account from that account's own history, using an O(1)-memory streaming quantile estimator (the P² algorithm):

class ProbeStrikeDetector:
    def observe(self, account_id, t, amount):
        profile = self.profiles[account_id]
        if profile.n_seen >= self.min_history:
            low, high = profile.low_q.value(), profile.high_q.value()
            if amount >= high:
                for probe_t, probe_amt in reversed(profile.recent):
                    if t - probe_t > self.strike_window_sec:
                        break
                    if probe_amt <= low:
                        return {"account_id": account_id, "probe_amount": probe_amt,
                                "strike_amount": amount, "gap_sec": t - probe_t}
        profile.low_q.update(amount)
        profile.high_q.update(amount)
Enter fullscreen mode Exit fullscreen mode

In testing, this correctly flagged a 5-naira probe followed 40 seconds later by a 999,999 strike - while also surfacing an honest false positive early on, when an account's thresholds were still stabilizing from limited history. A real cold-start problem, not something I'm hiding from the write-up.

  1. Making it survive contact with a real pipeline

None of the above matters if it only runs once, on my machine. The whole thing is orchestrated as a ZenML pipeline - ingest, train, evaluate/track, and a quality gate that can fail the run outright:

@step
def quality_gate_step(comparison: pd.DataFrame) -> None:
    floors = {"hawkes": 0.10, "mmpp": 0.80}
    for _, row in comparison.iterrows():
        if row["f1"] < floors.get(row["model"], 0.0):
            raise ValueError(f"Quality gate failed: {row['model']} F1={row['f1']:.3f}")
Enter fullscreen mode Exit fullscreen mode

That gate is wired into GitHub Actions, so a future change that quietly degrades either model's detection quality fails the build - not a warning buried in logs, an actual red X on the commit.

Failed Run

What this actually proves - and what it doesn't

Worth being direct about this rather than overselling it: this proves I can build a reproducible pipeline with real parameter estimation and an enforced quality bar. It does not prove either model would catch real-world fraud - the floors are a first pass I set myself, everything runs on synthetic data, and the pipeline is only ever tested against one fixed scenario in CI. What I'd actually stand behind is the discipline: estimate parameters honestly, test assumptions against data they weren't built for, and gate on the result. That discipline travels to real data. The specific numbers here don't, yet.

Successful Run

GitHub Repo: https://github.com/AkanimohOD19A/fraudulent-queuing-stochastic

Header Image: Photo by Hal Gatewood on Unsplash

Top comments (0)