loanguard / src /models /autoencoder.py
adwitiyashukla's picture
update repo
44cd54e
Raw
History Blame Contribute Delete
5.56 kB
"""Reconstruction-error autoencoder for unsupervised fraud detection.
We train a denoising autoencoder on (mostly) clean applications.
At inference time, applications with high reconstruction error are
flagged - including novel fraud patterns the supervised models
weren't trained on.
Loss surface is intentionally simple: MSE on normalised features.
For an MVP this beats fancier VAEs and is much easier to debug.
"""
from __future__ import annotations
from typing import Any
import numpy as np
import pandas as pd
import torch
import torch.nn as nn
from torch.utils.data import DataLoader, TensorDataset
from sklearn.preprocessing import StandardScaler
from ..utils.logging import get_logger
from .base import FraudModel
log = get_logger(__name__)
class _AENet(nn.Module):
def __init__(self, input_dim: int, encoder_dims: list[int], dropout: float):
super().__init__()
# Build encoder
enc_layers: list[nn.Module] = []
prev = input_dim
for h in encoder_dims:
enc_layers += [nn.Linear(prev, h), nn.ReLU(), nn.Dropout(dropout)]
prev = h
self.encoder = nn.Sequential(*enc_layers)
# Symmetric decoder back to input_dim
dec_layers: list[nn.Module] = []
for h in reversed(encoder_dims[:-1]):
dec_layers += [nn.Linear(prev, h), nn.ReLU(), nn.Dropout(dropout)]
prev = h
dec_layers += [nn.Linear(prev, input_dim)]
self.decoder = nn.Sequential(*dec_layers)
def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.decoder(self.encoder(x))
class AutoencoderFraudModel(FraudModel):
name = "autoencoder"
DEFAULT_PARAMS = {
"encoder_dims": [128, 64, 32],
"dropout": 0.2,
"batch_size": 512,
"epochs": 30,
"learning_rate": 1e-3,
"early_stopping_patience": 5,
"noise_std": 0.05,
}
def __init__(self, params: dict | None = None, device: str | None = None):
self.params = {**self.DEFAULT_PARAMS, **(params or {})}
self.device = torch.device(
device or ("cuda" if torch.cuda.is_available() else "cpu")
)
self.scaler = StandardScaler()
self.net: _AENet | None = None
self.score_min_ = 0.0
self.score_max_ = 1.0
self._trained = False
def fit(self, X: pd.DataFrame, y: pd.Series | None = None, **kwargs) -> "AutoencoderFraudModel":
# If labels available, train only on negatives ("clean" data) - semi-supervised.
if y is not None:
mask = y.values == 0
X_train = X.loc[mask]
log.info(
f"[AE] training on {mask.sum():,} negative samples "
f"(skipping {(~mask).sum():,} positives)"
)
else:
X_train = X
X_arr = self.scaler.fit_transform(X_train.values).astype(np.float32)
input_dim = X_arr.shape[1]
self.net = _AENet(
input_dim=input_dim,
encoder_dims=self.params["encoder_dims"],
dropout=self.params["dropout"],
).to(self.device)
optim = torch.optim.Adam(self.net.parameters(), lr=self.params["learning_rate"])
loss_fn = nn.MSELoss()
ds = TensorDataset(torch.from_numpy(X_arr))
loader = DataLoader(ds, batch_size=self.params["batch_size"], shuffle=True)
best_loss = float("inf")
patience = 0
for epoch in range(self.params["epochs"]):
self.net.train()
epoch_loss = 0.0
for (batch,) in loader:
batch = batch.to(self.device)
noisy = batch + torch.randn_like(batch) * self.params["noise_std"]
recon = self.net(noisy)
loss = loss_fn(recon, batch)
optim.zero_grad()
loss.backward()
optim.step()
epoch_loss += loss.item() * len(batch)
epoch_loss /= len(ds)
if epoch_loss < best_loss - 1e-5:
best_loss = epoch_loss
patience = 0
else:
patience += 1
if patience >= self.params["early_stopping_patience"]:
log.info(f"[AE] early stopping at epoch {epoch}, best_loss={best_loss:.5f}")
break
# Compute reconstruction-error range for normalisation
self.net.eval()
with torch.no_grad():
recon = self.net(torch.from_numpy(X_arr).to(self.device))
err = ((recon - torch.from_numpy(X_arr).to(self.device)) ** 2).mean(dim=1).cpu().numpy()
self.score_min_ = float(np.percentile(err, 1))
self.score_max_ = float(np.percentile(err, 99))
self._trained = True
log.info(
f"[AE] trained. recon_err range [{self.score_min_:.4f}, {self.score_max_:.4f}]"
)
return self
def predict_proba(self, X: pd.DataFrame) -> np.ndarray:
if not self._trained or self.net is None:
raise RuntimeError("Model not trained")
self.net.eval()
X_arr = self.scaler.transform(X.values).astype(np.float32)
with torch.no_grad():
t = torch.from_numpy(X_arr).to(self.device)
recon = self.net(t)
err = ((recon - t) ** 2).mean(dim=1).cpu().numpy()
span = max(self.score_max_ - self.score_min_, 1e-9)
prob = (err - self.score_min_) / span
return np.clip(prob, 0.0, 1.0)
def get_params(self) -> dict[str, Any]:
return {"params": self.params}