Skip to content

Commit f8b97f3

Browse files
committed
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
1 parent 8290244 commit f8b97f3

4 files changed

Lines changed: 85 additions & 12 deletions

File tree

app/ai-service/api/v1/fraud.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from fastapi import APIRouter, HTTPException
88

99
from schemas.fraud import FraudDetectionRequest, FraudDetectionResponse
10-
from services.fraud_detection import detect_fraud
10+
from services.fraud_detection import detect_fraud, MODEL_VERSION
1111

1212
logger = logging.getLogger(__name__)
1313

@@ -28,6 +28,7 @@ async def detect_fraud_endpoint(request: FraudDetectionRequest) -> FraudDetectio
2828
return FraudDetectionResponse(
2929
results=results,
3030
flagged_count=sum(r.is_flagged for r in results),
31+
model_version=MODEL_VERSION,
3132
)
3233
except Exception as exc:
3334
logger.error("Fraud detection failed: %s", exc)

app/ai-service/schemas/fraud.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ class ClaimFraudResult(BaseModel):
2525
class FraudDetectionResponse(BaseModel):
2626
results: List[ClaimFraudResult]
2727
flagged_count: int
28+
model_version: Optional[str] = None

app/ai-service/services/fraud_detection.py

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from typing import List
1010

1111
import numpy as np
12-
from sklearn.preprocessing import LabelEncoder
12+
from sklearn.preprocessing import LabelEncoder, StandardScaler
1313
from sklearn.neighbors import LocalOutlierFactor
1414

1515
from schemas.fraud import ClaimMetadata, ClaimFraudResult
@@ -19,9 +19,14 @@
1919
# Claims with LOF score above this threshold are flagged
2020
_OUTLIER_THRESHOLD = -1.5
2121

22+
# Version of the scoring pipeline — surfaced in the response so downstream
23+
# consumers can detect silent model drift. Bump when the feature
24+
# representation or decision rule changes.
25+
MODEL_VERSION = "fraud-v1.1"
26+
2227

2328
def _vectorize(claims: List[ClaimMetadata]) -> np.ndarray:
24-
"""Convert claim metadata into a numeric feature matrix."""
29+
"""Convert claim metadata into a normalised numeric feature matrix."""
2530
ip_enc = LabelEncoder()
2631
hash_enc = LabelEncoder()
2732
loc_enc = LabelEncoder()
@@ -35,12 +40,18 @@ def _vectorize(claims: List[ClaimMetadata]) -> np.ndarray:
3540
hash_enc.fit(hashes)
3641
loc_enc.fit(locs)
3742

38-
return np.column_stack([
39-
ip_enc.transform(ips),
40-
hash_enc.transform(hashes),
41-
loc_enc.transform(locs),
42-
amounts,
43-
]).astype(float)
43+
raw = np.column_stack([
44+
ip_enc.transform(ips).astype(float),
45+
hash_enc.transform(hashes).astype(float),
46+
loc_enc.transform(locs).astype(float),
47+
np.array(amounts, dtype=float),
48+
])
49+
50+
# Standardise every column so Euclidean distance in LOF is not
51+
# dominated by whichever column has the largest range (e.g. raw
52+
# token amounts vs. small integer codes).
53+
scaler = StandardScaler()
54+
return scaler.fit_transform(raw)
4455

4556

4657
def detect_fraud(claims: List[ClaimMetadata]) -> List[ClaimFraudResult]:
@@ -55,17 +66,21 @@ def detect_fraud(claims: List[ClaimMetadata]) -> List[ClaimFraudResult]:
5566
return [ClaimFraudResult(claim_id=claims[0].claim_id, fraud_risk_score=0.0, is_flagged=False)]
5667

5768
X = _vectorize(claims)
58-
69+
5970
# Add tiny random noise to prevent identical point degeneracy and zero-distance division issues
6071
np.random.seed(42)
6172
X_noise = X + np.random.normal(0, 1e-5, X.shape)
6273

63-
n_neighbors = min(20, max(2, len(claims) // 2))
74+
# n_neighbors must be strictly less than n_samples for LOF.
75+
# For very small batches (2-3 claims) use n_neighbors = 1 so LOF
76+
# still produces a meaningful local density estimate.
77+
n_samples = len(claims)
78+
n_neighbors = min(20, max(1, n_samples - 1))
6479
lof = LocalOutlierFactor(n_neighbors=n_neighbors, contamination="auto")
6580
lof.fit_predict(X_noise)
6681
raw_scores: np.ndarray = lof.negative_outlier_factor_ # negative; more negative = more anomalous
6782

68-
# Normalise to [0, 1]: most anomalous 1, most normal 0
83+
# Normalise to [0, 1]: most anomalous -> 1, most normal -> 0
6984
min_s, max_s = raw_scores.min(), raw_scores.max()
7085
if max_s == min_s:
7186
normalised = np.zeros(len(raw_scores))

app/ai-service/tests/test_fraud_detection.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,30 @@ def test_outlier_gets_higher_score(self):
5757
results = {r["claim_id"]: r["fraud_risk_score"] for r in resp.json()["results"]}
5858
assert results["outlier"] > results["c0"]
5959

60+
def test_two_claim_batch_does_not_crash(self):
61+
"""Two-claim batch must not raise ValueError from LOF."""
62+
payload = {"claims": [
63+
{"claim_id": "a", "ip_address": "1.2.3.4", "amount": 100.0},
64+
{"claim_id": "b", "ip_address": "5.6.7.8", "amount": 200.0},
65+
]}
66+
resp = client.post("/v1/fraud/detect", json=payload)
67+
assert resp.status_code == 200
68+
assert len(resp.json()["results"]) == 2
69+
70+
def test_three_claim_batch_does_not_crash(self):
71+
"""Three-claim batch must not raise ValueError from LOF."""
72+
payload = {"claims": _make_claims(3)}
73+
resp = client.post("/v1/fraud/detect", json=payload)
74+
assert resp.status_code == 200
75+
assert len(resp.json()["results"]) == 3
76+
77+
def test_model_version_in_response(self):
78+
payload = {"claims": _make_claims(3)}
79+
resp = client.post("/v1/fraud/detect", json=payload)
80+
data = resp.json()
81+
assert "model_version" in data
82+
assert data["model_version"] is not None
83+
6084

6185
class TestFraudDetectionService:
6286
def test_single_claim(self):
@@ -70,3 +94,35 @@ def test_scores_in_range(self):
7094
results = detect_fraud(claims)
7195
for r in results:
7296
assert 0.0 <= r.fraud_risk_score <= 1.0
97+
98+
def test_two_claims_no_crash(self):
99+
claims = [
100+
ClaimMetadata(claim_id="a", ip_address="1.1.1.1", amount=10.0),
101+
ClaimMetadata(claim_id="b", ip_address="2.2.2.2", amount=20.0),
102+
]
103+
results = detect_fraud(claims)
104+
assert len(results) == 2
105+
for r in results:
106+
assert 0.0 <= r.fraud_risk_score <= 1.0
107+
108+
def test_three_claims_no_crash(self):
109+
claims = [
110+
ClaimMetadata(claim_id="a", ip_address="1.1.1.1", amount=10.0),
111+
ClaimMetadata(claim_id="b", ip_address="2.2.2.2", amount=20.0),
112+
ClaimMetadata(claim_id="c", ip_address="3.3.3.3", amount=30.0),
113+
]
114+
results = detect_fraud(claims)
115+
assert len(results) == 3
116+
for r in results:
117+
assert 0.0 <= r.fraud_risk_score <= 1.0
118+
119+
def test_outlier_flagged_in_batch(self):
120+
"""A constructed outlier should be flagged, homogeneous batch should not."""
121+
claims = [
122+
ClaimMetadata(claim_id=f"n{i}", ip_address="1.1.1.1", amount=100.0)
123+
for i in range(8)
124+
]
125+
claims.append(ClaimMetadata(claim_id="outlier", ip_address="99.99.99.99", amount=99999.0))
126+
results = detect_fraud(claims)
127+
by_id = {r.claim_id: r for r in results}
128+
assert by_id["outlier"].fraud_risk_score > by_id["n0"].fraud_risk_score

0 commit comments

Comments
 (0)