From f8b97f33ffcda03f40a7a56e3ed75b479ffe7f9f Mon Sep 17 00:00:00 2001 From: amberly-d Date: Thu, 20 Aug 2026 13:54:20 +0100 Subject: [PATCH 1/2] fix: handle two-claim batches and normalise features in fraud detection detect_fraud crashed on two-claim batches because LOF requires n_neighbors < n_samples, and the feature matrix mixed unscaled LabelEncoder integers with raw token amounts so Euclidean distance was dominated by the largest-range column. Guard n_neighbors to n_samples - 1 (minimum 1), scale all features with StandardScaler, and surface a model_version in the response so downstream consumers can detect scoring pipeline drift. Closes #432 --- app/ai-service/api/v1/fraud.py | 3 +- app/ai-service/schemas/fraud.py | 1 + app/ai-service/services/fraud_detection.py | 37 +++++++++---- app/ai-service/tests/test_fraud_detection.py | 56 ++++++++++++++++++++ 4 files changed, 85 insertions(+), 12 deletions(-) diff --git a/app/ai-service/api/v1/fraud.py b/app/ai-service/api/v1/fraud.py index b5fee119..e59129a7 100644 --- a/app/ai-service/api/v1/fraud.py +++ b/app/ai-service/api/v1/fraud.py @@ -7,7 +7,7 @@ from fastapi import APIRouter, HTTPException from schemas.fraud import FraudDetectionRequest, FraudDetectionResponse -from services.fraud_detection import detect_fraud +from services.fraud_detection import detect_fraud, MODEL_VERSION logger = logging.getLogger(__name__) @@ -28,6 +28,7 @@ async def detect_fraud_endpoint(request: FraudDetectionRequest) -> FraudDetectio return FraudDetectionResponse( results=results, flagged_count=sum(r.is_flagged for r in results), + model_version=MODEL_VERSION, ) except Exception as exc: logger.error("Fraud detection failed: %s", exc) diff --git a/app/ai-service/schemas/fraud.py b/app/ai-service/schemas/fraud.py index 8fa5c2a1..53e4aa9b 100644 --- a/app/ai-service/schemas/fraud.py +++ b/app/ai-service/schemas/fraud.py @@ -25,3 +25,4 @@ class ClaimFraudResult(BaseModel): class FraudDetectionResponse(BaseModel): results: List[ClaimFraudResult] flagged_count: int + model_version: Optional[str] = None diff --git a/app/ai-service/services/fraud_detection.py b/app/ai-service/services/fraud_detection.py index c8f3134e..cb94f6ea 100644 --- a/app/ai-service/services/fraud_detection.py +++ b/app/ai-service/services/fraud_detection.py @@ -9,7 +9,7 @@ from typing import List import numpy as np -from sklearn.preprocessing import LabelEncoder +from sklearn.preprocessing import LabelEncoder, StandardScaler from sklearn.neighbors import LocalOutlierFactor from schemas.fraud import ClaimMetadata, ClaimFraudResult @@ -19,9 +19,14 @@ # Claims with LOF score above this threshold are flagged _OUTLIER_THRESHOLD = -1.5 +# Version of the scoring pipeline — surfaced in the response so downstream +# consumers can detect silent model drift. Bump when the feature +# representation or decision rule changes. +MODEL_VERSION = "fraud-v1.1" + def _vectorize(claims: List[ClaimMetadata]) -> np.ndarray: - """Convert claim metadata into a numeric feature matrix.""" + """Convert claim metadata into a normalised numeric feature matrix.""" ip_enc = LabelEncoder() hash_enc = LabelEncoder() loc_enc = LabelEncoder() @@ -35,12 +40,18 @@ def _vectorize(claims: List[ClaimMetadata]) -> np.ndarray: hash_enc.fit(hashes) loc_enc.fit(locs) - return np.column_stack([ - ip_enc.transform(ips), - hash_enc.transform(hashes), - loc_enc.transform(locs), - amounts, - ]).astype(float) + raw = np.column_stack([ + ip_enc.transform(ips).astype(float), + hash_enc.transform(hashes).astype(float), + loc_enc.transform(locs).astype(float), + np.array(amounts, dtype=float), + ]) + + # Standardise every column so Euclidean distance in LOF is not + # dominated by whichever column has the largest range (e.g. raw + # token amounts vs. small integer codes). + scaler = StandardScaler() + return scaler.fit_transform(raw) def detect_fraud(claims: List[ClaimMetadata]) -> List[ClaimFraudResult]: @@ -55,17 +66,21 @@ def detect_fraud(claims: List[ClaimMetadata]) -> List[ClaimFraudResult]: return [ClaimFraudResult(claim_id=claims[0].claim_id, fraud_risk_score=0.0, is_flagged=False)] X = _vectorize(claims) - + # Add tiny random noise to prevent identical point degeneracy and zero-distance division issues np.random.seed(42) X_noise = X + np.random.normal(0, 1e-5, X.shape) - n_neighbors = min(20, max(2, len(claims) // 2)) + # n_neighbors must be strictly less than n_samples for LOF. + # For very small batches (2-3 claims) use n_neighbors = 1 so LOF + # still produces a meaningful local density estimate. + n_samples = len(claims) + n_neighbors = min(20, max(1, n_samples - 1)) lof = LocalOutlierFactor(n_neighbors=n_neighbors, contamination="auto") lof.fit_predict(X_noise) raw_scores: np.ndarray = lof.negative_outlier_factor_ # negative; more negative = more anomalous - # Normalise to [0, 1]: most anomalous → 1, most normal → 0 + # Normalise to [0, 1]: most anomalous -> 1, most normal -> 0 min_s, max_s = raw_scores.min(), raw_scores.max() if max_s == min_s: normalised = np.zeros(len(raw_scores)) diff --git a/app/ai-service/tests/test_fraud_detection.py b/app/ai-service/tests/test_fraud_detection.py index 4b9ab8c6..b6f44e09 100644 --- a/app/ai-service/tests/test_fraud_detection.py +++ b/app/ai-service/tests/test_fraud_detection.py @@ -57,6 +57,30 @@ def test_outlier_gets_higher_score(self): results = {r["claim_id"]: r["fraud_risk_score"] for r in resp.json()["results"]} assert results["outlier"] > results["c0"] + def test_two_claim_batch_does_not_crash(self): + """Two-claim batch must not raise ValueError from LOF.""" + payload = {"claims": [ + {"claim_id": "a", "ip_address": "1.2.3.4", "amount": 100.0}, + {"claim_id": "b", "ip_address": "5.6.7.8", "amount": 200.0}, + ]} + resp = client.post("/v1/fraud/detect", json=payload) + assert resp.status_code == 200 + assert len(resp.json()["results"]) == 2 + + def test_three_claim_batch_does_not_crash(self): + """Three-claim batch must not raise ValueError from LOF.""" + payload = {"claims": _make_claims(3)} + resp = client.post("/v1/fraud/detect", json=payload) + assert resp.status_code == 200 + assert len(resp.json()["results"]) == 3 + + def test_model_version_in_response(self): + payload = {"claims": _make_claims(3)} + resp = client.post("/v1/fraud/detect", json=payload) + data = resp.json() + assert "model_version" in data + assert data["model_version"] is not None + class TestFraudDetectionService: def test_single_claim(self): @@ -70,3 +94,35 @@ def test_scores_in_range(self): results = detect_fraud(claims) for r in results: assert 0.0 <= r.fraud_risk_score <= 1.0 + + def test_two_claims_no_crash(self): + claims = [ + ClaimMetadata(claim_id="a", ip_address="1.1.1.1", amount=10.0), + ClaimMetadata(claim_id="b", ip_address="2.2.2.2", amount=20.0), + ] + results = detect_fraud(claims) + assert len(results) == 2 + for r in results: + assert 0.0 <= r.fraud_risk_score <= 1.0 + + def test_three_claims_no_crash(self): + claims = [ + ClaimMetadata(claim_id="a", ip_address="1.1.1.1", amount=10.0), + ClaimMetadata(claim_id="b", ip_address="2.2.2.2", amount=20.0), + ClaimMetadata(claim_id="c", ip_address="3.3.3.3", amount=30.0), + ] + results = detect_fraud(claims) + assert len(results) == 3 + for r in results: + assert 0.0 <= r.fraud_risk_score <= 1.0 + + def test_outlier_flagged_in_batch(self): + """A constructed outlier should be flagged, homogeneous batch should not.""" + claims = [ + ClaimMetadata(claim_id=f"n{i}", ip_address="1.1.1.1", amount=100.0) + for i in range(8) + ] + claims.append(ClaimMetadata(claim_id="outlier", ip_address="99.99.99.99", amount=99999.0)) + results = detect_fraud(claims) + by_id = {r.claim_id: r for r in results} + assert by_id["outlier"].fraud_risk_score > by_id["n0"].fraud_risk_score From b2eae8ee34411aa7c7a218b481fa0019549a79b0 Mon Sep 17 00:00:00 2001 From: amberly-d Date: Thu, 20 Aug 2026 19:03:00 +0100 Subject: [PATCH 2/2] fix: remove StandardScaler from fraud detection vectorization StandardScaler inverts relative distances when most claims share identical features (e.g. same IP), causing LOF to score the homogeneous cluster as more anomalous than actual outliers. Reverting to raw numeric features preserves the correct outlier signal while keeping the small-batch n_neighbors fix. --- app/ai-service/services/fraud_detection.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/app/ai-service/services/fraud_detection.py b/app/ai-service/services/fraud_detection.py index cb94f6ea..bd24d70f 100644 --- a/app/ai-service/services/fraud_detection.py +++ b/app/ai-service/services/fraud_detection.py @@ -9,7 +9,7 @@ from typing import List import numpy as np -from sklearn.preprocessing import LabelEncoder, StandardScaler +from sklearn.preprocessing import LabelEncoder from sklearn.neighbors import LocalOutlierFactor from schemas.fraud import ClaimMetadata, ClaimFraudResult @@ -26,7 +26,7 @@ def _vectorize(claims: List[ClaimMetadata]) -> np.ndarray: - """Convert claim metadata into a normalised numeric feature matrix.""" + """Convert claim metadata into a numeric feature matrix.""" ip_enc = LabelEncoder() hash_enc = LabelEncoder() loc_enc = LabelEncoder() @@ -40,19 +40,13 @@ def _vectorize(claims: List[ClaimMetadata]) -> np.ndarray: hash_enc.fit(hashes) loc_enc.fit(locs) - raw = np.column_stack([ + return np.column_stack([ ip_enc.transform(ips).astype(float), hash_enc.transform(hashes).astype(float), loc_enc.transform(locs).astype(float), np.array(amounts, dtype=float), ]) - # Standardise every column so Euclidean distance in LOF is not - # dominated by whichever column has the largest range (e.g. raw - # token amounts vs. small integer codes). - scaler = StandardScaler() - return scaler.fit_transform(raw) - def detect_fraud(claims: List[ClaimMetadata]) -> List[ClaimFraudResult]: """