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.
Related: How to set the Keras backend
Related: How to compile a model in Keras
Steps to train a Keras fraud classifier:
- 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 - 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.
- 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")
- 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.
- 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.
- 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 - 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 - 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.
Mohd Shakir Zakaria is a cloud architect with deep roots in software development and open-source advocacy. Certified in AWS, Red Hat, VMware, ITIL, and Linux, he specializes in designing and managing robust cloud and on-premises infrastructures.