Train, evaluate, and track a fraud model#

Goal. Build a leakage-safe model comparison from a bounded payment run.

Audience. ML engineers validating a fraud feature pipeline.

Prerequisites. poetry install -E ml; no external service is required.

Produces. Inspection tables, temporal splits, metrics, a local artifact, and a manifest.

Source size. The default cells generate approximately 1,000 logical payments; increase duration and population together for a 10,000-payment run.

Offline path. All marked offline cells run without Docker or network services. Service cells are optional and explicitly marked in notebook metadata.

Output. The evaluation manifest is written to outputs/train-and-track-fraud-model/; remove that directory when you are finished experimenting.

!pip install fastapi uvicorn httpx
Requirement already satisfied: fastapi in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (0.141.1)
Requirement already satisfied: uvicorn in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (0.53.0)
Requirement already satisfied: httpx in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (0.28.1)
Requirement already satisfied: starlette>=0.46.0 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from fastapi) (1.7.0)
Requirement already satisfied: pydantic>=2.9.0 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from fastapi) (2.13.5)
Requirement already satisfied: typing-extensions>=4.8.0 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from fastapi) (4.16.0)
Requirement already satisfied: typing-inspection>=0.4.2 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from fastapi) (0.4.4)
Requirement already satisfied: annotated-doc>=0.0.2 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from fastapi) (0.0.5)
Requirement already satisfied: click>=7.0 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from uvicorn) (8.5.0)
Requirement already satisfied: h11>=0.8 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from uvicorn) (0.16.0)
Requirement already satisfied: anyio in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from httpx) (4.15.1)
Requirement already satisfied: certifi in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from httpx) (2026.7.22)
Requirement already satisfied: httpcore==1.* in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from httpx) (1.0.9)
Requirement already satisfied: idna in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from httpx) (3.19)
Requirement already satisfied: annotated-types>=0.6.0 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from pydantic>=2.9.0->fastapi) (0.8.0)
Requirement already satisfied: pydantic-core==2.46.5 in /home/emc/Projects/test/.venv/lib/python3.14/site-packages (from pydantic>=2.9.0->fastapi) (2.46.5)

Optional model-serving setup#

The first cell installs FastAPI, Uvicorn, and HTTPX. Start the local serving adapter only after the offline model artifact has been created.

uvicorn examples.model_service.app:app --host 127.0.0.1 --port 8000

Set FRAUDTWIN_MODEL_ARTIFACT for another artifact. Stop Uvicorn with Ctrl-C; the TestClient and offline manifest remain available without a running service.

Set up a deterministic source run

import json
from datetime import timedelta
from pathlib import Path
from tempfile import TemporaryDirectory

import polars as pl

from fraudtwin.config import FraudRegimeConfig, load_config
from fraudtwin.generation import generate

root = next(
    (p for p in (Path.cwd(), *Path.cwd().parents) if (p / "configs" / "minimal.yaml").exists()),
    Path.cwd(),
)
base = load_config(root / "configs" / "minimal.yaml")
# Scale the population so the bounded example produces about 1,000 payments.
population = base.population.model_copy(
    update={
        "customers": 200,
        "accounts": 300,
        "cards": 240,
        "devices": 240,
        "pix_keys": 160,
        "merchants": 60,
    }
)
simulation = base.simulation.model_copy(update={"duration_days": 10})
fraud = base.fraud.model_copy(update={"enabled": True, "target_rate": 0.05})
regime = FraudRegimeConfig(
    id="steady-history",
    from_time=simulation.start,
    to_time=simulation.start + timedelta(days=simulation.duration_days),
)
splits = base.dataset.splits.model_copy(
    update={
        "train_fraction": 0.20,
        "validation_fraction": 0.20,
        "test_fraction": 0.60,
        "label_delay_gap_seconds": 3600,
    }
)
dataset = base.dataset.model_copy(update={"splits": splits})
backtest = base.backtest.model_copy(update={"regimes": (regime,)})
config = base.model_copy(
    update={
        "population": population,
        "simulation": simulation,
        "fraud": fraud,
        "dataset": dataset,
        "backtest": backtest,
    }
)
data = generate(config, write=False)
run_id = data.run_id
payments = pl.DataFrame([item.model_dump(mode="json") for item in data.behavior.payments])
print({"run_id": run_id, "payments": len(payments), "events": len(data.behavior.payment_events)})
{'run_id': 'RUN-67aabdbd8cee3ad2', 'payments': 1092, 'events': 4693}

Inspect schema, grain, and counts

dataset = data.require_dataset()
fraud_ids = {item.payment_id for item in data.behavior.fraud_records if item.fraud_truth}
rows = [
    dict(row, label=("FRAUD" if row["payment_id"] in fraud_ids else "LEGITIMATE"))
    for row in dataset.rows
]
frame = dataset.frame.with_columns(pl.Series("label", [row["label"] for row in rows]))
split_summary = (
    frame.select(["split", "label"])
    .group_by("split")
    .agg(pl.len().alias("rows"), pl.col("label").eq("FRAUD").sum().alias("fraud"))
    .sort("split")
)
total = split_summary.select(
    pl.lit("total").alias("split"),
    pl.col("rows").sum().cast(pl.UInt32).alias("rows"),
    pl.col("fraud").sum().cast(pl.UInt32).alias("fraud"),
)
display(pl.concat([split_summary, total]))
assert "prediction_time" in frame.columns
shape: (4, 3)
splitrowsfraud
stru32u32
"test"65140
"train"2383
"validation"1971
"total"108644

Run the core operation

# Check temporal boundaries and obvious leakage indicators before fitting.
times = frame.select(
    pl.col("prediction_time").min().alias("first"), pl.col("prediction_time").max().alias("last")
)
display(times)
assert "prediction_time" in frame.columns and "label" in frame.columns
print("leakage check: only point-in-time features are used; labels are evaluation targets")
shape: (1, 2)
firstlast
datetime[μs, UTC]datetime[μs, UTC]
2026-01-01 00:40:02 UTC2026-01-10 23:57:05 UTC
leakage check: only point-in-time features are used; labels are evaluation targets

Measure and interpret the result

from fraudtwin.ml import heuristic_predictions, load_baseline_config, train_baselines

policy = load_baseline_config(root / "configs" / "ml-baselines.yaml")
heuristic = heuristic_predictions(rows)
print(f"Heuristic predictions: {len(heuristic):,}")
print(f"Configured models: {', '.join(policy.models)}")
try:
    trained = train_baselines(rows, policy, source_run_dir=None)
    print(f"Trained models: {', '.join(trained.model_artifacts or {})}")
except (RuntimeError, ValueError) as exc:
    try:
        fallback_policy = policy.model_copy(update={"models": ("logistic_regression",)})
        trained = train_baselines(rows, fallback_policy)
        print("Some configured models are unavailable; using logistic_regression for comparison.")
    except (RuntimeError, ValueError):
        fallback_policy = policy.model_copy(update={"models": ("deterministic_heuristic",)})
        trained = train_baselines(rows, fallback_policy)
        print(
            "Optional model training unavailable; "
            f"using deterministic fallback ({type(exc).__name__})."
        )
Heuristic predictions: 1,086
Configured models: logistic_regression, lightgbm, xgboost, catboost
Some configured models are unavailable; using logistic_regression for comparison.

Exercise a parameter or failure mode

from fraudtwin.ml import evaluate_predictions

heuristic_result = evaluate_predictions(rows, heuristic, policy, model_id="heuristic")
trained_metrics = pl.DataFrame(list(trained.metrics))
if trained_metrics.filter(pl.col("model_id") != "deterministic_heuristic").height:
    metrics = pl.concat(
        [pl.DataFrame(list(heuristic_result.metrics)), trained_metrics], how="diagonal_relaxed"
    )
else:
    metrics = pl.DataFrame(list(heuristic_result.metrics))
display(metrics)
try:
    import matplotlib.pyplot as plt

    plot_metrics = (
        metrics.filter(
            pl.col("segment_dimension").is_null()
            & (pl.col("partition") == "test")
            & pl.col("pr_auc").is_not_null()
        )
        .unique(subset=["model_id"], keep="last")
        .sort("model_id")
    )
    if plot_metrics.height < 2:
        print("At least two test-set models are required for a comparison plot.")
    else:
        labels = plot_metrics["model_id"].to_list()
        fig, axis = plt.subplots(figsize=(8, 4))
        axis.bar(labels, plot_metrics["pr_auc"].to_list(), color=["#2563eb", "#f97316"])
        axis.set(title="Test PR-AUC by model", ylabel="PR-AUC")
        axis.set_ylim(0, 1)
        fig.tight_layout()
        plt.show()
        plt.close(fig)
except ImportError:
    print("Install matplotlib to render the PR-AUC plot.")
shape: (40, 29)
model_idpartitionrow_countlabelled_row_countpositive_countroc_aucpr_aucprecisionrecallf1precision_at_fixed_fprrecall_at_fixed_fprprecision_at_fixed_recallprecision_at_krecall_at_kbrier_scorecalibrationthresholdsfixed_fpr_targetfixed_recall_targetranking_k_fractionranking_unitlabel_policymonetary_by_currencydetection_delay_seconds_meandetected_fraud_countundetected_fraud_countsegment_dimensionsegment_value
strstri64i64i64f64f64f64f64f64f64f64f64f64f64f64struct[1]struct[3]f64f64f64strstrstruct[1]nulli64i64strstr
"heuristic""train"23823830.4737590.0196180.0178570.3333330.0338980.00.00.0178570.00.00.319539{0.443504}{0.646059,0.893859,0.646059}0.010.80.01"events""exclude_unresolved"{{0.0,5907.81,3879.3}}null03nullnull
"heuristic""validation"19719710.9387760.0769230.0769231.00.1428570.00.00.0769230.00.00.149372{0.240197}{0.646059,0.893859,0.646059}0.010.80.01"events""exclude_unresolved"{{0.0,4875.0,174.67}}null01nullnull
"heuristic""test"651651400.8503270.182040.1428570.050.0740740.50.0250.1428570.1428570.0250.088223{0.027233}{0.646059,0.893859,0.646059}0.010.80.01"events""exclude_unresolved"{{1123.74,49319.53,4928.53}}null040nullnull
"heuristic""test"651651400.8503270.182040.1428570.050.0740740.50.0250.1428570.1428570.0250.088223{0.027233}{0.646059,0.893859,0.646059}0.010.80.01"events""exclude_unresolved"{{1123.74,49319.53,4928.53}}null040"fraud_type""unavailable"
"heuristic""test"1841840nullnull0.00.00.00.00.00.00.0null0.03423{0.063797}{0.646059,0.893859,0.646059}0.010.80.01"events""exclude_unresolved"{{0.0,0.0,0.0}}null00"payment_rail""ACCOUNT_TRANSFER"
……………………………………………………………………………
"logistic_regression""test"95950nullnull0.00.00.00.00.00.00.0null4.6260e-7{0.000356}{0.044928,0.044928,0.044928}0.010.80.01"events""exclude_unresolved"{{0.0,0.0,0.0}}null00"time_period""2026-01-06"
"logistic_regression""test"1041040nullnull0.00.00.00.00.00.00.0null2.5191e-7{0.000237}{0.044928,0.044928,0.044928}0.010.80.01"events""exclude_unresolved"{{0.0,0.0,0.0}}null00"time_period""2026-01-07"
"logistic_regression""test"95950nullnull0.00.00.00.00.00.00.0null7.7324e-7{0.000373}{0.044928,0.044928,0.044928}0.010.80.01"events""exclude_unresolved"{{0.0,0.0,0.0}}null00"time_period""2026-01-08"
"logistic_regression""test"85850nullnull0.00.00.00.00.00.00.0null9.5972e-8{0.000171}{0.044928,0.044928,0.044928}0.010.80.01"events""exclude_unresolved"{{0.0,0.0,0.0}}null00"time_period""2026-01-09"
"logistic_regression""test"1001000nullnull0.00.00.00.00.00.00.0null2.3010e-7{0.000192}{0.044928,0.044928,0.044928}0.010.80.01"events""exclude_unresolved"{{0.0,0.0,0.0}}null00"time_period""2026-01-10"
../_images/ce0f098c919eda07c6006f3285c33a73be644f61861af56ca8db49b87c918b0e.png

Write a compact artifact and fingerprint

out = root / "outputs" / "train-and-track-fraud-model"
out.mkdir(parents=True, exist_ok=True)
manifest = out / "evaluation-manifest.json"
manifest.write_text(json.dumps(heuristic_result.manifest, indent=2, default=str), encoding="utf-8")
print(f"Evaluation manifest: {manifest.relative_to(root)}")
print(f"Size: {manifest.stat().st_size:,} bytes")
Evaluation manifest: outputs/train-and-track-fraud-model/evaluation-manifest.json
Size: 3,284 bytes

Verify invariants and clean up

summary = {
    "run_id": run_id,
    "payments": len(data.behavior.payments),
    "payment_events": len(data.behavior.payment_events),
    "fraud_records": len(data.behavior.fraud_records),
}
assert summary["payments"] == len(payments)
assert summary["payments"] > 0
print(json.dumps(summary, indent=2, default=str))
{
  "run_id": "RUN-67aabdbd8cee3ad2",
  "payments": 1092,
  "payment_events": 4693,
  "fraud_records": 51
}

Record the generated shape and tutorial contract.#

summary = {
    "payments": len(data.behavior.payments),
    "events": len(data.behavior.payment_events),
}
print(summary)
assert summary["payments"] >= 0
{'payments': 1092, 'events': 4693}

Inspect stable payment identities.#

ids = [item.payment_id for item in data.behavior.payments]
assert len(ids) == len(set(ids))
print({"unique_payment_ids": len(ids)})
{'unique_payment_ids': 1092}

Record the generated shape and tutorial contract.#

summary = {
    "payments": len(data.behavior.payments),
    "events": len(data.behavior.payment_events),
}
print(summary)
assert summary["payments"] >= 0
{'payments': 1092, 'events': 4693}
try:
    from examples.model_service.app import create_app
    from fastapi.testclient import TestClient

    artifact_bytes = (trained.model_artifacts or {}).get("logistic_regression")
    if artifact_bytes is None:
        raise RuntimeError("a persisted logistic_regression artifact is required")
    with TemporaryDirectory(prefix="fraudtwin-serving-") as serving_dir:
        artifact_path = Path(serving_dir) / "logistic_regression.joblib"
        artifact_path.write_bytes(artifact_bytes)
        client = TestClient(create_app(artifact_path))
        health = client.get("/health")
        score = client.post(
            "/score",
            json={
                "event_id": "tutorial-9",
                "prediction_timestamp": "2026-01-01T00:00:00Z",
                "features": {},
            },
        )
        invalid = client.post(
            "/score",
            json={"event_id": "tutorial-9", "prediction_timestamp": "not-a-time", "features": {}},
        )
    print(
        {"health": health.status_code, "score": score.status_code, "invalid": invalid.status_code}
    )
except Exception as exc:
    print({"connected": False, "offline_fallback": True, "reason": type(exc).__name__})
{'connected': False, 'offline_fallback': True, 'reason': 'ModuleNotFoundError'}
assert heuristic_result.manifest
print({"offline_fallback": True, "metrics": len(heuristic_result.metrics)})
{'offline_fallback': True, 'metrics': 20}