How to train a fraud classifier in Keras

Card-payment screening depends on ranking a small number of suspicious transactions among far more legitimate ones. A Keras binary classifier can learn that ranking from numeric transaction features while keeping the final review decision separate from the model's raw probability score.

The synthetic transaction table avoids customer records and makes the run reproducible. A Normalization layer learns its statistics from the training split, while a fixed holdout split supplies the PR-AUC and threshold measurements used to judge the trained model.

Inverse-frequency class weights make fraud rows contribute more strongly to the training loss. The separate 0.35 threshold represents a manual-review cutoff, so its precision and recall must be evaluated on holdout data rather than treated as a universal production value.

Steps to train a Keras fraud classifier:

  1. Create train_fraud_classifier.py with the Keras backend and deterministic random sources.
    train_fraud_classifier.py
    import os
    from pathlib import Path
     
    os.environ.setdefault("KERAS_BACKEND", "jax")
     
    import keras
    import numpy as np
     
     
    keras.utils.set_random_seed(7)
    rng = np.random.default_rng(42)

    The KERAS_BACKEND assignment must happen before import keras. Keras and the selected backend must already be installed in the project environment.
    Related: How to install Keras with pip
    Related: How to set the Keras backend

  2. Add the synthetic transaction features below the random generator.
    rows = 6000
    amount = rng.lognormal(mean=3.2, sigma=0.9, size=rows).astype("float32")
    hour = rng.integers(0, 24, size=rows).astype("float32")
    country_risk = rng.beta(1.2, 8.0, size=rows).astype("float32")
    merchant_risk = rng.beta(1.4, 7.0, size=rows).astype("float32")
    device_age_days = rng.exponential(scale=20.0, size=rows).astype("float32")
    chargeback_count = rng.poisson(0.12, size=rows).astype("float32")
    night_purchase = ((hour < 6) | (hour > 22)).astype("float32")

    Production transaction data requires defined access controls, retention limits, and leakage checks before it replaces the synthetic table.

  3. Add the synthetic training arrays below the feature generator.
    amount_log = np.log1p(amount)
    amount_z = (amount_log - amount_log.mean()) / amount_log.std()
    fraud_logit = (
        -5.5
        + 1.2 * amount_z
        + 6.4 * country_risk
        + 5.7 * merchant_risk
        + 1.2 * night_purchase
        + 1.6 * (device_age_days < 2)
        + 1.5 * (chargeback_count > 0)
    )
    fraud_probability = 1.0 / (1.0 + np.exp(-fraud_logit))
    target = rng.binomial(1, fraud_probability).astype("float32")
     
    features = np.column_stack(
        [
            amount_log,
            hour / 23.0,
            country_risk,
            merchant_risk,
            np.log1p(device_age_days),
            chargeback_count,
            night_purchase,
        ]
    ).astype("float32")
  4. Add the holdout split and inverse-frequency class weights below the input matrix.
    order = rng.permutation(rows)
    train_size = int(rows * 0.8)
    train_index = order[:train_size]
    validation_index = order[train_size:]
    x_train = features[train_index]
    y_train = target[train_index]
    x_validation = features[validation_index]
    y_validation = target[validation_index]
     
    negative = float((y_train == 0).sum())
    positive = float((y_train == 1).sum())
    total = negative + positive
    class_weight = {
        0: total / (2.0 * negative),
        1: total / (2.0 * positive),
    }

    The class weights affect the training loss, not the validation metrics or the later review threshold.

  5. Add the adapted normalizer and dense classifier below the class weights.
    normalizer = keras.layers.Normalization()
    normalizer.adapt(x_train)
     
    model = keras.Sequential(
        [
            keras.layers.Input(shape=(x_train.shape[1],)),
            normalizer,
            keras.layers.Dense(32, activation="relu"),
            keras.layers.Dropout(0.2),
            keras.layers.Dense(16, activation="relu"),
            keras.layers.Dense(1, activation="sigmoid"),
        ]
    )

    Adapting the normalizer only on x_train prevents holdout statistics from leaking into model preparation.

  6. Add the training configuration below the model definition.
    model.compile(
        optimizer=keras.optimizers.Adam(learning_rate=0.005),
        loss=keras.losses.BinaryCrossentropy(),
        metrics=[
            keras.metrics.AUC(curve="PR", name="pr_auc"),
            keras.metrics.Precision(name="precision"),
            keras.metrics.Recall(name="recall"),
        ],
        jit_compile="auto",
    )
     
    history = model.fit(
        x_train,
        y_train,
        validation_data=(x_validation, y_validation),
        epochs=8,
        batch_size=128,
        class_weight=class_weight,
        verbose=0,
    )

    Precision and Recall use the default 0.5 threshold here. The next section measures the lower review threshold separately.
    Related: How to compile a model in Keras

  7. Append the holdout threshold, saved-model reload, and score-order check below the training call.
    validation_probability = model.predict(
        x_validation,
        batch_size=256,
        verbose=0,
    ).ravel()
    threshold = 0.35
    validation_prediction = validation_probability >= threshold
    actual_positive = y_validation == 1
    actual_negative = y_validation == 0
    true_positive = int(np.logical_and(validation_prediction, actual_positive).sum())
    false_positive = int(np.logical_and(validation_prediction, actual_negative).sum())
    false_negative = int(np.logical_and(~validation_prediction, actual_positive).sum())
    true_negative = int(np.logical_and(~validation_prediction, actual_negative).sum())
    threshold_precision = true_positive / max(true_positive + false_positive, 1)
    threshold_recall = true_positive / max(true_positive + false_negative, 1)
     
    model_path = Path("fraud_classifier.keras")
    model.save(model_path, overwrite=True)
    loaded_model = keras.saving.load_model(model_path)
    sample_transactions = np.array(
        [
            [np.log1p(38.20), 14 / 23.0, 0.04, 0.08, np.log1p(120.0), 0.0, 0.0],
            [np.log1p(620.00), 2 / 23.0, 0.54, 0.61, np.log1p(0.5), 2.0, 1.0],
        ],
        dtype="float32",
    )
    sample_scores = loaded_model.predict(sample_transactions, verbose=0).ravel()
     
    if not model_path.is_file():
        raise RuntimeError("The saved Keras model is missing")
    if sample_scores[1] <= sample_scores[0]:
        raise RuntimeError("The suspicious sample did not receive the higher score")
     
    print(f"backend: {keras.config.backend()}")
    print(f"train rows: {len(x_train)}")
    print(f"validation rows: {len(x_validation)}")
    print(f"training fraud rate: {y_train.mean():.3f}")
    print(f"class weights: legit={class_weight[0]:.2f}, fraud={class_weight[1]:.2f}")
    print(f"epochs completed: {len(history.history['loss'])}")
    print(f"val pr_auc: {history.history['val_pr_auc'][-1]:.3f}")
    print(f"val precision@0.5: {history.history['val_precision'][-1]:.3f}")
    print(f"val recall@0.5: {history.history['val_recall'][-1]:.3f}")
    print(
        f"threshold 0.35 counts: tp={true_positive}, "
        f"fp={false_positive}, fn={false_negative}, tn={true_negative}"
    )
    print(
        "threshold 0.35 metrics: "
        f"precision={threshold_precision:.3f}, recall={threshold_recall:.3f}"
    )
    print(f"saved model: {model_path.name}")
    print(
        "sample fraud scores: "
        f"routine={sample_scores[0]:.3f}, suspicious={sample_scores[1]:.3f}"
    )
    print("reload score order: suspicious > routine")

    The .keras file stores the Normalization layer with the network. Scoring data must still use the same seven-feature order.
    Related: How to save and load a Keras model
    Related: How to run batch prediction in Keras

  8. Run the completed fraud-classifier training script.
    $ python train_fraud_classifier.py
    backend: jax
    train rows: 4800
    validation rows: 1200
    training fraud rate: 0.115
    class weights: legit=0.56, fraud=4.35
    epochs completed: 8
    val pr_auc: 0.425
    val precision@0.5: 0.301
    val recall@0.5: 0.743
    threshold 0.35 counts: tp=118, fp=349, fn=22, tn=711
    threshold 0.35 metrics: precision=0.253, recall=0.843
    saved model: fraud_classifier.keras
    sample fraud scores: routine=0.050, suspicious=1.000
    reload score order: suspicious > routine

    The score-order line appears only after the saved model reloads and assigns the higher score to the suspicious sample. A production threshold should reflect representative holdout data and the review queue's false-positive capacity.