Spaces:
Sleeping
Sleeping
Deploy MzansiScore API Space
Browse files- Dockerfile +15 -0
- README.md +54 -10
- __init__.py +0 -0
- __pycache__/__init__.cpython-313.pyc +0 -0
- __pycache__/features_runtime.cpython-313.pyc +0 -0
- __pycache__/main.cpython-313.pyc +0 -0
- __pycache__/models.cpython-313.pyc +0 -0
- __pycache__/scorer.cpython-313.pyc +0 -0
- features_runtime.py +80 -0
- main.py +80 -0
- models.py +68 -0
- requirements.txt +13 -0
- scorer.py +287 -0
- tests/__pycache__/test_main.cpython-313-pytest-9.0.2.pyc +0 -0
- tests/__pycache__/test_scorer.cpython-313-pytest-9.0.2.pyc +0 -0
- tests/test_main.py +57 -0
- tests/test_scorer.py +44 -0
Dockerfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
ENV MZANSISCORE_MODEL_SOURCE=hub
|
| 6 |
+
ENV MZANSISCORE_MODEL_REPO=Pieter182/mzansiscore-credit-risk
|
| 7 |
+
|
| 8 |
+
COPY requirements.txt requirements.txt
|
| 9 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 10 |
+
|
| 11 |
+
COPY . .
|
| 12 |
+
|
| 13 |
+
EXPOSE 7860
|
| 14 |
+
|
| 15 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,54 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: docker
|
| 7 |
-
|
| 8 |
-
---
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: MzansiScore Credit Risk API
|
| 3 |
+
emoji: 📉
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# MzansiScore Credit Risk API
|
| 11 |
+
|
| 12 |
+
FastAPI backend for scoring South African credit applications using the trained MzansiScore model artifacts.
|
| 13 |
+
|
| 14 |
+
## Endpoints
|
| 15 |
+
|
| 16 |
+
- `GET /v1/health`
|
| 17 |
+
- `GET /v1/model/info`
|
| 18 |
+
- `POST /v1/score`
|
| 19 |
+
|
| 20 |
+
## Local run
|
| 21 |
+
|
| 22 |
+
```powershell
|
| 23 |
+
pip install -r api/requirements.txt
|
| 24 |
+
uvicorn api.main:app --reload --port 7860
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
If you are inside the `api/` folder itself, run:
|
| 28 |
+
|
| 29 |
+
```powershell
|
| 30 |
+
pip install -r requirements.txt
|
| 31 |
+
uvicorn main:app --reload --port 7860
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
## Model artifact source
|
| 35 |
+
|
| 36 |
+
The API supports both local artifacts and HuggingFace Hub downloads.
|
| 37 |
+
|
| 38 |
+
- Default HF model repo: `Pieter182/mzansiscore-credit-risk`
|
| 39 |
+
- Optional env var: `MZANSISCORE_MODEL_REPO`
|
| 40 |
+
- Optional env var: `MZANSISCORE_MODEL_SOURCE=hub` to force Hub downloads
|
| 41 |
+
- Optional env var: `HF_TOKEN` for private repos or authenticated access
|
| 42 |
+
|
| 43 |
+
The Docker image is configured to prefer Hub artifacts by default:
|
| 44 |
+
|
| 45 |
+
- `MZANSISCORE_MODEL_SOURCE=hub`
|
| 46 |
+
- `MZANSISCORE_MODEL_REPO=Pieter182/mzansiscore-credit-risk`
|
| 47 |
+
|
| 48 |
+
## HuggingFace Docker Space
|
| 49 |
+
|
| 50 |
+
This folder is now self-contained for deployment as its own HuggingFace Docker Space repo.
|
| 51 |
+
|
| 52 |
+
- push the contents of `api/` as the Space repository root
|
| 53 |
+
- `Dockerfile` now builds from that root directly
|
| 54 |
+
- runtime feature engineering is bundled via `features_runtime.py`
|
__init__.py
ADDED
|
File without changes
|
__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (140 Bytes). View file
|
|
|
__pycache__/features_runtime.cpython-313.pyc
ADDED
|
Binary file (6.02 kB). View file
|
|
|
__pycache__/main.cpython-313.pyc
ADDED
|
Binary file (3.48 kB). View file
|
|
|
__pycache__/models.cpython-313.pyc
ADDED
|
Binary file (3.92 kB). View file
|
|
|
__pycache__/scorer.cpython-313.pyc
ADDED
|
Binary file (15.5 kB). View file
|
|
|
features_runtime.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pandas as pd
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def build_features(df):
|
| 6 |
+
df = df.copy()
|
| 7 |
+
|
| 8 |
+
df['living_to_income'] = df['living_expenses'] / df['net_income'].replace(0, np.nan)
|
| 9 |
+
df['discretionary_ratio'] = df['discretionary_income'] / df['net_income'].replace(0, np.nan)
|
| 10 |
+
df['gross_to_net_ratio'] = df['net_income'] / df['gross_income'].replace(0, np.nan)
|
| 11 |
+
|
| 12 |
+
df['score_norm'] = df['credit_score'] / 850
|
| 13 |
+
df['score_risk'] = 1 - df['score_norm']
|
| 14 |
+
df['combined_risk'] = df['score_risk'] + df['debt_service_ratio']
|
| 15 |
+
|
| 16 |
+
df['food_ratio'] = df['food_expense'] / df['net_income'].replace(0, np.nan)
|
| 17 |
+
df['housing_ratio'] = df['accommodation_expense'] / df['net_income'].replace(0, np.nan)
|
| 18 |
+
df['transport_ratio'] = df['transport_expense'] / df['net_income'].replace(0, np.nan)
|
| 19 |
+
|
| 20 |
+
if 'credit_bureau_monthly_debt' in df.columns:
|
| 21 |
+
df['bureau_debt_to_income'] = df['credit_bureau_monthly_debt'] / df['net_income'].replace(0, np.nan)
|
| 22 |
+
|
| 23 |
+
if 'affordability_surplus' in df.columns:
|
| 24 |
+
df['affordability_surplus_ratio'] = df['affordability_surplus'] / df['net_income'].replace(0, np.nan)
|
| 25 |
+
|
| 26 |
+
if 'affordability_expense_basis' in df.columns:
|
| 27 |
+
df['affordability_basis_ratio'] = df['affordability_expense_basis'] / df['net_income'].replace(0, np.nan)
|
| 28 |
+
|
| 29 |
+
if {'affordability_expense_basis', 'living_expenses'}.issubset(df.columns):
|
| 30 |
+
df['expense_gap_ratio'] = (
|
| 31 |
+
(df['affordability_expense_basis'] - df['living_expenses']) /
|
| 32 |
+
df['net_income'].replace(0, np.nan)
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
if {'minimum_living_expense', 'living_expenses'}.issubset(df.columns):
|
| 36 |
+
df['norm_to_declared_ratio'] = df['minimum_living_expense'] / df['living_expenses'].replace(0, np.nan)
|
| 37 |
+
|
| 38 |
+
if 'predicted_monthly_income' in df.columns:
|
| 39 |
+
df['income_prediction_gap'] = (
|
| 40 |
+
(df['predicted_monthly_income'] - df['gross_income']) /
|
| 41 |
+
df['gross_income'].replace(0, np.nan)
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
if 'household_gross_income' in df.columns:
|
| 45 |
+
df['household_to_income_ratio'] = df['household_gross_income'] / df['gross_income'].replace(0, np.nan)
|
| 46 |
+
|
| 47 |
+
if {'cpa_commitments', 'net_income'}.issubset(df.columns):
|
| 48 |
+
df['cpa_to_income_ratio'] = df['cpa_commitments'] / df['net_income'].replace(0, np.nan)
|
| 49 |
+
|
| 50 |
+
if {'nlr_commitments', 'net_income'}.issubset(df.columns):
|
| 51 |
+
df['nlr_to_income_ratio'] = df['nlr_commitments'] / df['net_income'].replace(0, np.nan)
|
| 52 |
+
|
| 53 |
+
if {'contactability_index', 'bureau_utilisation'}.issubset(df.columns):
|
| 54 |
+
df['contactability_risk_gap'] = (100 - df['contactability_index']) / 100 + df['bureau_utilisation']
|
| 55 |
+
|
| 56 |
+
if {'monthly_instalment', 'net_income'}.issubset(df.columns):
|
| 57 |
+
df['instalment_to_income'] = df['monthly_instalment'] / df['net_income'].replace(0, np.nan)
|
| 58 |
+
|
| 59 |
+
if {'total_debt_outstanding_zar', 'gross_income'}.issubset(df.columns):
|
| 60 |
+
df['debt_to_income_multiple'] = df['total_debt_outstanding_zar'] / df['gross_income'].replace(0, np.nan)
|
| 61 |
+
|
| 62 |
+
if {'asset_total_zar', 'gross_income'}.issubset(df.columns):
|
| 63 |
+
df['assets_to_income_multiple'] = df['asset_total_zar'] / df['gross_income'].replace(0, np.nan)
|
| 64 |
+
|
| 65 |
+
if {'avg_bank_balance_30d_zar', 'net_income'}.issubset(df.columns):
|
| 66 |
+
df['avg_balance_to_income'] = df['avg_bank_balance_30d_zar'] / df['net_income'].replace(0, np.nan)
|
| 67 |
+
|
| 68 |
+
if {'min_bank_balance_30d_zar', 'net_income'}.issubset(df.columns):
|
| 69 |
+
df['min_balance_to_income'] = df['min_bank_balance_30d_zar'] / df['net_income'].replace(0, np.nan)
|
| 70 |
+
|
| 71 |
+
if {'credit_history_months'}.issubset(df.columns):
|
| 72 |
+
df['credit_history_years'] = df['credit_history_months'] / 12
|
| 73 |
+
|
| 74 |
+
df['income_band'] = pd.cut(
|
| 75 |
+
df['gross_income'],
|
| 76 |
+
bins=[0, 8000, 15000, 25000, 45000, 80000, np.inf],
|
| 77 |
+
labels=['very_low', 'low', 'lower_mid', 'middle', 'upper_mid', 'high'],
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
return df
|
main.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from contextlib import asynccontextmanager
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI, HTTPException
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
|
| 6 |
+
try:
|
| 7 |
+
from api.models import HealthResponse, ModelInfoResponse, ScoreRequest, ScoreResponse
|
| 8 |
+
from api.scorer import ModelScorer
|
| 9 |
+
except ImportError:
|
| 10 |
+
from models import HealthResponse, ModelInfoResponse, ScoreRequest, ScoreResponse
|
| 11 |
+
from scorer import ModelScorer
|
| 12 |
+
|
| 13 |
+
scorer: ModelScorer | None = None
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@asynccontextmanager
|
| 17 |
+
async def lifespan(app: FastAPI):
|
| 18 |
+
global scorer
|
| 19 |
+
scorer = ModelScorer()
|
| 20 |
+
yield
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
app = FastAPI(
|
| 24 |
+
title='MzansiScore Credit Risk API',
|
| 25 |
+
version='0.1.0',
|
| 26 |
+
description='FastAPI scorer for the MzansiScore SA credit risk model.',
|
| 27 |
+
lifespan=lifespan,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
app.add_middleware(
|
| 31 |
+
CORSMiddleware,
|
| 32 |
+
allow_origins=['*'],
|
| 33 |
+
allow_credentials=True,
|
| 34 |
+
allow_methods=['*'],
|
| 35 |
+
allow_headers=['*'],
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@app.get('/')
|
| 40 |
+
def root() -> dict[str, str | bool | None]:
|
| 41 |
+
return {
|
| 42 |
+
'service': 'MzansiScore Credit Risk API',
|
| 43 |
+
'status': 'ok',
|
| 44 |
+
'model_loaded': scorer is not None,
|
| 45 |
+
'best_model': None if scorer is None else scorer.meta.get('best_model'),
|
| 46 |
+
'artifact_source': None if scorer is None else scorer.artifact_source,
|
| 47 |
+
'model_repo': None if scorer is None else scorer.repo_id,
|
| 48 |
+
'docs': '/docs',
|
| 49 |
+
'health': '/v1/health',
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@app.get('/v1/health', response_model=HealthResponse)
|
| 54 |
+
def health() -> HealthResponse:
|
| 55 |
+
return HealthResponse(
|
| 56 |
+
status='ok',
|
| 57 |
+
model_loaded=scorer is not None,
|
| 58 |
+
best_model=None if scorer is None else scorer.meta.get('best_model'),
|
| 59 |
+
artifact_source=None if scorer is None else scorer.artifact_source,
|
| 60 |
+
model_repo=None if scorer is None else scorer.repo_id,
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@app.get('/v1/model/info', response_model=ModelInfoResponse)
|
| 65 |
+
def model_info() -> ModelInfoResponse:
|
| 66 |
+
if scorer is None:
|
| 67 |
+
raise HTTPException(status_code=503, detail='Model not loaded')
|
| 68 |
+
return ModelInfoResponse(
|
| 69 |
+
**scorer.model_info(),
|
| 70 |
+
artifact_source=scorer.artifact_source,
|
| 71 |
+
model_repo=scorer.repo_id,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@app.post('/v1/score', response_model=ScoreResponse)
|
| 76 |
+
def score_application(request: ScoreRequest) -> ScoreResponse:
|
| 77 |
+
if scorer is None:
|
| 78 |
+
raise HTTPException(status_code=503, detail='Model not loaded')
|
| 79 |
+
result = scorer.score(request.to_payload())
|
| 80 |
+
return ScoreResponse(**result)
|
models.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ScoreRequest(BaseModel):
|
| 7 |
+
model_config = ConfigDict(extra='allow')
|
| 8 |
+
|
| 9 |
+
gross_income: float = Field(..., gt=0)
|
| 10 |
+
net_income: float = Field(..., gt=0)
|
| 11 |
+
living_expenses: float = Field(..., ge=0)
|
| 12 |
+
credit_score: int = Field(..., ge=250, le=850)
|
| 13 |
+
age: int = Field(..., ge=18, le=100)
|
| 14 |
+
|
| 15 |
+
discretionary_income: float | None = None
|
| 16 |
+
debt_service_ratio: float | None = Field(default=None, ge=0)
|
| 17 |
+
credit_bureau_monthly_debt: float | None = Field(default=None, ge=0)
|
| 18 |
+
monthly_instalment: float | None = Field(default=None, ge=0)
|
| 19 |
+
loan_amount_requested: float | None = Field(default=None, ge=0)
|
| 20 |
+
loan_term_months: int | None = Field(default=None, ge=1)
|
| 21 |
+
|
| 22 |
+
province: str | None = None
|
| 23 |
+
gender: str | None = None
|
| 24 |
+
marital_status: str | None = None
|
| 25 |
+
employment_type: str | None = None
|
| 26 |
+
education_level: str | None = None
|
| 27 |
+
dependants: int | None = Field(default=None, ge=0)
|
| 28 |
+
residence_type: str | None = None
|
| 29 |
+
loan_product_type: str | None = None
|
| 30 |
+
salary_frequency: str | None = None
|
| 31 |
+
|
| 32 |
+
affordability_expense_basis: float | None = None
|
| 33 |
+
affordability_surplus: float | None = None
|
| 34 |
+
affordability_margin: float | None = None
|
| 35 |
+
nca_affordability_pass: int | None = Field(default=None, ge=0, le=1)
|
| 36 |
+
|
| 37 |
+
def to_payload(self) -> dict[str, Any]:
|
| 38 |
+
return self.model_dump(exclude_none=True)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class ScoreResponse(BaseModel):
|
| 42 |
+
decision_id: str
|
| 43 |
+
risk_score: int
|
| 44 |
+
risk_band: str
|
| 45 |
+
recommendation: str
|
| 46 |
+
default_probability: float
|
| 47 |
+
nca_pass: int
|
| 48 |
+
policy_reason: str
|
| 49 |
+
shap_top3: list[dict[str, float | str]]
|
| 50 |
+
processing_time_ms: float
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class HealthResponse(BaseModel):
|
| 54 |
+
status: str
|
| 55 |
+
model_loaded: bool
|
| 56 |
+
best_model: str | None = None
|
| 57 |
+
artifact_source: str | None = None
|
| 58 |
+
model_repo: str | None = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class ModelInfoResponse(BaseModel):
|
| 62 |
+
best_model: str
|
| 63 |
+
holdout_auc: float
|
| 64 |
+
holdout_brier: float
|
| 65 |
+
runtime_human: str | None = None
|
| 66 |
+
top_features: list[dict[str, float | str]]
|
| 67 |
+
artifact_source: str | None = None
|
| 68 |
+
model_repo: str | None = None
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.115
|
| 2 |
+
uvicorn[standard]>=0.30
|
| 3 |
+
pydantic>=2.8
|
| 4 |
+
pandas>=2.2
|
| 5 |
+
numpy>=1.26
|
| 6 |
+
joblib>=1.4
|
| 7 |
+
scikit-learn>=1.5
|
| 8 |
+
xgboost>=2.0
|
| 9 |
+
lightgbm>=4.3
|
| 10 |
+
shap>=0.46
|
| 11 |
+
huggingface_hub>=0.30
|
| 12 |
+
pytest>=8.3
|
| 13 |
+
httpx>=0.27
|
scorer.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
import sys
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import joblib
|
| 10 |
+
import numpy as np
|
| 11 |
+
import pandas as pd
|
| 12 |
+
import shap
|
| 13 |
+
from huggingface_hub import hf_hub_download
|
| 14 |
+
|
| 15 |
+
ROOT_DIR = Path(__file__).resolve().parents[1]
|
| 16 |
+
ML_SRC_DIR = ROOT_DIR / 'ml' / 'src'
|
| 17 |
+
ML_DATA_DIR = ROOT_DIR / 'ml' / 'data'
|
| 18 |
+
HF_MODEL_REPO = os.getenv('MZANSISCORE_MODEL_REPO', 'Pieter182/mzansiscore-credit-risk')
|
| 19 |
+
HF_TOKEN = os.getenv('HF_TOKEN') or os.getenv('HUGGINGFACE_HUB_TOKEN')
|
| 20 |
+
ARTIFACT_FILES = [
|
| 21 |
+
'best_tree_model.joblib',
|
| 22 |
+
'feature_cols.joblib',
|
| 23 |
+
'label_encoders.joblib',
|
| 24 |
+
'model_meta.json',
|
| 25 |
+
]
|
| 26 |
+
STATIC_DEFAULTS: dict[str, Any] = {
|
| 27 |
+
'province': 'Gauteng',
|
| 28 |
+
'gender': 'Male',
|
| 29 |
+
'marital_status': 'Single',
|
| 30 |
+
'employment_type': 'permanent',
|
| 31 |
+
'education_level': 'matric',
|
| 32 |
+
'residence_type': 'rented_formal',
|
| 33 |
+
'loan_product_type': 'personal_loan',
|
| 34 |
+
'salary_frequency': 'Monthly',
|
| 35 |
+
'dependants': 0,
|
| 36 |
+
'gross_income': 25000.0,
|
| 37 |
+
'net_income': 19000.0,
|
| 38 |
+
'living_expenses': 7000.0,
|
| 39 |
+
'discretionary_income': 6500.0,
|
| 40 |
+
'debt_service_ratio': 0.28,
|
| 41 |
+
'credit_bureau_monthly_debt': 2500.0,
|
| 42 |
+
'monthly_instalment': 2800.0,
|
| 43 |
+
'loan_amount_requested': 50000.0,
|
| 44 |
+
'loan_term_months': 24,
|
| 45 |
+
'age': 34,
|
| 46 |
+
'credit_score': 640,
|
| 47 |
+
'food_expense': 1800.0,
|
| 48 |
+
'transport_expense': 1200.0,
|
| 49 |
+
'accommodation_expense': 2800.0,
|
| 50 |
+
'utilities_expense': 900.0,
|
| 51 |
+
'medical_expense': 500.0,
|
| 52 |
+
'minimum_living_expense': 7200.0,
|
| 53 |
+
'affordability_expense_basis': 7200.0,
|
| 54 |
+
'affordability_surplus': 6500.0,
|
| 55 |
+
'affordability_margin': 0.34,
|
| 56 |
+
'predicted_monthly_income': 25500.0,
|
| 57 |
+
'household_gross_income': 32000.0,
|
| 58 |
+
'cpa_commitments': 700.0,
|
| 59 |
+
'nlr_commitments': 600.0,
|
| 60 |
+
'contactability_index': 82.0,
|
| 61 |
+
'bureau_utilisation': 0.33,
|
| 62 |
+
'total_debt_outstanding_zar': 42000.0,
|
| 63 |
+
'asset_total_zar': 110000.0,
|
| 64 |
+
'avg_bank_balance_30d_zar': 8400.0,
|
| 65 |
+
'min_bank_balance_30d_zar': 2200.0,
|
| 66 |
+
'credit_history_months': 48,
|
| 67 |
+
'nca_affordability_pass': 1,
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
if str(ML_SRC_DIR) not in sys.path:
|
| 71 |
+
sys.path.append(str(ML_SRC_DIR))
|
| 72 |
+
|
| 73 |
+
try:
|
| 74 |
+
from api.features_runtime import build_features # type: ignore
|
| 75 |
+
except ImportError:
|
| 76 |
+
try:
|
| 77 |
+
from features_runtime import build_features # type: ignore
|
| 78 |
+
except ImportError:
|
| 79 |
+
from features import build_features # type: ignore # noqa: E402
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class ModelScorer:
|
| 83 |
+
def __init__(self, repo_id: str | None = None, token: str | None = None) -> None:
|
| 84 |
+
self.repo_id = repo_id or HF_MODEL_REPO
|
| 85 |
+
self.token = token or HF_TOKEN
|
| 86 |
+
self.artifact_source = 'unknown'
|
| 87 |
+
self.artifact_dir = self._resolve_artifact_dir()
|
| 88 |
+
|
| 89 |
+
self.best_model = joblib.load(self.artifact_dir / 'best_tree_model.joblib')
|
| 90 |
+
self.feature_cols = joblib.load(self.artifact_dir / 'feature_cols.joblib')
|
| 91 |
+
self.label_encoders = joblib.load(self.artifact_dir / 'label_encoders.joblib')
|
| 92 |
+
with open(self.artifact_dir / 'model_meta.json', 'r', encoding='utf-8') as handle:
|
| 93 |
+
self.meta = json.load(handle)
|
| 94 |
+
|
| 95 |
+
self.defaults = self._build_defaults()
|
| 96 |
+
self.explainer = shap.TreeExplainer(self.best_model)
|
| 97 |
+
|
| 98 |
+
def _resolve_artifact_dir(self) -> Path:
|
| 99 |
+
local_ready = all((ML_DATA_DIR / name).exists() for name in ARTIFACT_FILES)
|
| 100 |
+
prefer_hub = os.getenv('MZANSISCORE_MODEL_SOURCE', '').lower() == 'hub' or not local_ready
|
| 101 |
+
|
| 102 |
+
if prefer_hub and self.repo_id:
|
| 103 |
+
try:
|
| 104 |
+
self.artifact_source = 'hub'
|
| 105 |
+
return self._download_artifacts_from_hub()
|
| 106 |
+
except Exception:
|
| 107 |
+
if not local_ready:
|
| 108 |
+
raise
|
| 109 |
+
|
| 110 |
+
if local_ready:
|
| 111 |
+
self.artifact_source = 'local'
|
| 112 |
+
return ML_DATA_DIR
|
| 113 |
+
|
| 114 |
+
if self.repo_id:
|
| 115 |
+
self.artifact_source = 'hub'
|
| 116 |
+
return self._download_artifacts_from_hub()
|
| 117 |
+
|
| 118 |
+
raise FileNotFoundError('No local model artifacts found and no HuggingFace repo configured.')
|
| 119 |
+
|
| 120 |
+
def _download_artifacts_from_hub(self) -> Path:
|
| 121 |
+
downloaded_paths = [
|
| 122 |
+
Path(
|
| 123 |
+
hf_hub_download(
|
| 124 |
+
repo_id=self.repo_id,
|
| 125 |
+
filename=filename,
|
| 126 |
+
repo_type='model',
|
| 127 |
+
token=self.token,
|
| 128 |
+
),
|
| 129 |
+
)
|
| 130 |
+
for filename in ARTIFACT_FILES
|
| 131 |
+
]
|
| 132 |
+
return downloaded_paths[0].parent
|
| 133 |
+
|
| 134 |
+
def _build_defaults(self) -> dict[str, Any]:
|
| 135 |
+
dataset_path = ML_DATA_DIR / 'sa_credit_dataset.csv'
|
| 136 |
+
if not dataset_path.exists():
|
| 137 |
+
return dict(STATIC_DEFAULTS)
|
| 138 |
+
|
| 139 |
+
df = pd.read_csv(dataset_path)
|
| 140 |
+
defaults: dict[str, Any] = dict(STATIC_DEFAULTS)
|
| 141 |
+
|
| 142 |
+
for col in df.columns:
|
| 143 |
+
series = df[col]
|
| 144 |
+
if pd.api.types.is_numeric_dtype(series):
|
| 145 |
+
defaults[col] = float(series.median()) if not pd.isna(series.median()) else 0.0
|
| 146 |
+
else:
|
| 147 |
+
mode = series.mode(dropna=True)
|
| 148 |
+
defaults[col] = str(mode.iloc[0]) if not mode.empty else ''
|
| 149 |
+
|
| 150 |
+
defaults.setdefault('province', 'Gauteng')
|
| 151 |
+
defaults.setdefault('gender', 'Male')
|
| 152 |
+
defaults.setdefault('marital_status', 'Single')
|
| 153 |
+
defaults.setdefault('employment_type', 'permanent')
|
| 154 |
+
defaults.setdefault('education_level', 'matric')
|
| 155 |
+
defaults.setdefault('residence_type', 'rented_formal')
|
| 156 |
+
defaults.setdefault('loan_product_type', 'personal_loan')
|
| 157 |
+
defaults.setdefault('salary_frequency', 'Monthly')
|
| 158 |
+
defaults.setdefault('dependants', 0)
|
| 159 |
+
return defaults
|
| 160 |
+
|
| 161 |
+
def _merge_payload(self, payload: dict[str, Any]) -> dict[str, Any]:
|
| 162 |
+
row = dict(self.defaults)
|
| 163 |
+
row.update(payload)
|
| 164 |
+
|
| 165 |
+
if row.get('discretionary_income') is None:
|
| 166 |
+
row['discretionary_income'] = (
|
| 167 |
+
float(row.get('net_income', 0))
|
| 168 |
+
- float(row.get('living_expenses', 0))
|
| 169 |
+
- float(row.get('credit_bureau_monthly_debt', 0))
|
| 170 |
+
- float(row.get('monthly_instalment', 0))
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
if row.get('affordability_expense_basis') is None:
|
| 174 |
+
row['affordability_expense_basis'] = max(
|
| 175 |
+
float(row.get('living_expenses', 0)),
|
| 176 |
+
float(row.get('minimum_living_expense', row.get('living_expenses', 0))),
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
if row.get('affordability_surplus') is None:
|
| 180 |
+
row['affordability_surplus'] = (
|
| 181 |
+
float(row.get('net_income', 0))
|
| 182 |
+
- float(row.get('affordability_expense_basis', 0))
|
| 183 |
+
- float(row.get('credit_bureau_monthly_debt', 0))
|
| 184 |
+
- float(row.get('monthly_instalment', 0))
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
if row.get('affordability_margin') is None:
|
| 188 |
+
row['affordability_margin'] = float(row['affordability_surplus']) / max(float(row.get('net_income', 1)), 1.0)
|
| 189 |
+
|
| 190 |
+
if row.get('debt_service_ratio') is None:
|
| 191 |
+
row['debt_service_ratio'] = (
|
| 192 |
+
float(row.get('credit_bureau_monthly_debt', 0)) + float(row.get('monthly_instalment', 0))
|
| 193 |
+
) / max(float(row.get('net_income', 1)), 1.0)
|
| 194 |
+
|
| 195 |
+
if row.get('nca_affordability_pass') is None:
|
| 196 |
+
row['nca_affordability_pass'] = int(
|
| 197 |
+
float(row.get('affordability_surplus', 0)) > 800
|
| 198 |
+
and float(row.get('affordability_margin', 0)) > 0.02
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
return row
|
| 202 |
+
|
| 203 |
+
def _encode_for_tree(self, df: pd.DataFrame) -> pd.DataFrame:
|
| 204 |
+
model_df = df.copy()
|
| 205 |
+
for col in self.feature_cols:
|
| 206 |
+
if col not in model_df.columns:
|
| 207 |
+
model_df[col] = np.nan
|
| 208 |
+
|
| 209 |
+
model_df = model_df[self.feature_cols].copy()
|
| 210 |
+
|
| 211 |
+
for col, encoder in self.label_encoders.items():
|
| 212 |
+
if col not in model_df.columns:
|
| 213 |
+
continue
|
| 214 |
+
values = model_df[col].astype(str).fillna('MISSING')
|
| 215 |
+
known = set(encoder.classes_)
|
| 216 |
+
values = values.where(values.isin(known), 'UNKNOWN')
|
| 217 |
+
model_df[col] = encoder.transform(values)
|
| 218 |
+
|
| 219 |
+
return model_df.fillna(-999)
|
| 220 |
+
|
| 221 |
+
def _build_shap_top3(self, encoded_df: pd.DataFrame) -> list[dict[str, Any]]:
|
| 222 |
+
shap_values = self.explainer.shap_values(encoded_df)
|
| 223 |
+
if isinstance(shap_values, list):
|
| 224 |
+
shap_values = shap_values[0]
|
| 225 |
+
row_vals = np.asarray(shap_values)[0]
|
| 226 |
+
top_idx = np.argsort(np.abs(row_vals))[-3:][::-1]
|
| 227 |
+
return [
|
| 228 |
+
{
|
| 229 |
+
'feature': self.feature_cols[int(idx)],
|
| 230 |
+
'shap_value': round(float(row_vals[int(idx)]), 4),
|
| 231 |
+
}
|
| 232 |
+
for idx in top_idx
|
| 233 |
+
]
|
| 234 |
+
|
| 235 |
+
def score(self, payload: dict[str, Any]) -> dict[str, Any]:
|
| 236 |
+
start = time.perf_counter()
|
| 237 |
+
merged = self._merge_payload(payload)
|
| 238 |
+
raw_df = pd.DataFrame([merged])
|
| 239 |
+
feature_df = build_features(raw_df)
|
| 240 |
+
encoded_df = self._encode_for_tree(feature_df)
|
| 241 |
+
|
| 242 |
+
default_probability = float(self.best_model.predict_proba(encoded_df)[0, 1])
|
| 243 |
+
nca_pass = int(merged['nca_affordability_pass'])
|
| 244 |
+
|
| 245 |
+
if not nca_pass:
|
| 246 |
+
risk_band = 'DECLINE'
|
| 247 |
+
recommendation = 'DECLINE'
|
| 248 |
+
policy_reason = 'Affordability rule failed'
|
| 249 |
+
elif default_probability < 0.10:
|
| 250 |
+
risk_band = 'LOW'
|
| 251 |
+
recommendation = 'APPROVE'
|
| 252 |
+
policy_reason = 'Affordability pass'
|
| 253 |
+
elif default_probability < 0.25:
|
| 254 |
+
risk_band = 'MEDIUM'
|
| 255 |
+
recommendation = 'REVIEW'
|
| 256 |
+
policy_reason = 'Affordability pass'
|
| 257 |
+
elif default_probability < 0.50:
|
| 258 |
+
risk_band = 'HIGH'
|
| 259 |
+
recommendation = 'REVIEW'
|
| 260 |
+
policy_reason = 'Affordability pass'
|
| 261 |
+
else:
|
| 262 |
+
risk_band = 'DECLINE'
|
| 263 |
+
recommendation = 'DECLINE'
|
| 264 |
+
policy_reason = 'High predicted default probability'
|
| 265 |
+
|
| 266 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 2)
|
| 267 |
+
return {
|
| 268 |
+
'decision_id': f"MZS-{uuid.uuid4().hex[:8].upper()}",
|
| 269 |
+
'risk_score': int(round((1 - default_probability) * 999)),
|
| 270 |
+
'risk_band': risk_band,
|
| 271 |
+
'recommendation': recommendation,
|
| 272 |
+
'default_probability': round(default_probability, 4),
|
| 273 |
+
'nca_pass': nca_pass,
|
| 274 |
+
'policy_reason': policy_reason,
|
| 275 |
+
'shap_top3': self._build_shap_top3(encoded_df),
|
| 276 |
+
'processing_time_ms': elapsed_ms,
|
| 277 |
+
}
|
| 278 |
+
|
| 279 |
+
def model_info(self) -> dict[str, Any]:
|
| 280 |
+
calibrated = self.meta['results'].get('XGBoost_cal', self.meta['results']['XGBoost'])
|
| 281 |
+
return {
|
| 282 |
+
'best_model': self.meta['best_model'],
|
| 283 |
+
'holdout_auc': calibrated['holdout_auc'],
|
| 284 |
+
'holdout_brier': calibrated['holdout_brier'],
|
| 285 |
+
'runtime_human': self.meta.get('runtime_human'),
|
| 286 |
+
'top_features': self.meta.get('top_features', [])[:5],
|
| 287 |
+
}
|
tests/__pycache__/test_main.cpython-313-pytest-9.0.2.pyc
ADDED
|
Binary file (10.9 kB). View file
|
|
|
tests/__pycache__/test_scorer.cpython-313-pytest-9.0.2.pyc
ADDED
|
Binary file (7.24 kB). View file
|
|
|
tests/test_main.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
|
| 3 |
+
from api.main import app
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
SAMPLE_PAYLOAD = {
|
| 7 |
+
'gross_income': 25000,
|
| 8 |
+
'net_income': 19000,
|
| 9 |
+
'living_expenses': 7000,
|
| 10 |
+
'credit_score': 640,
|
| 11 |
+
'age': 34,
|
| 12 |
+
'monthly_instalment': 2800,
|
| 13 |
+
'credit_bureau_monthly_debt': 2500,
|
| 14 |
+
'loan_amount_requested': 50000,
|
| 15 |
+
'loan_term_months': 24,
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_health_endpoint():
|
| 20 |
+
with TestClient(app) as client:
|
| 21 |
+
response = client.get('/v1/health')
|
| 22 |
+
assert response.status_code == 200
|
| 23 |
+
data = response.json()
|
| 24 |
+
assert data['status'] == 'ok'
|
| 25 |
+
assert data['model_loaded'] is True
|
| 26 |
+
assert data['artifact_source'] in {'local', 'hub'}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_root_endpoint():
|
| 30 |
+
with TestClient(app) as client:
|
| 31 |
+
response = client.get('/')
|
| 32 |
+
assert response.status_code == 200
|
| 33 |
+
data = response.json()
|
| 34 |
+
assert data['service'] == 'MzansiScore Credit Risk API'
|
| 35 |
+
assert data['health'] == '/v1/health'
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_model_info_endpoint():
|
| 39 |
+
with TestClient(app) as client:
|
| 40 |
+
response = client.get('/v1/model/info')
|
| 41 |
+
assert response.status_code == 200
|
| 42 |
+
data = response.json()
|
| 43 |
+
assert 'best_model' in data
|
| 44 |
+
assert 'holdout_auc' in data
|
| 45 |
+
assert 'top_features' in data
|
| 46 |
+
assert data['artifact_source'] in {'local', 'hub'}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def test_score_endpoint():
|
| 50 |
+
with TestClient(app) as client:
|
| 51 |
+
response = client.post('/v1/score', json=SAMPLE_PAYLOAD)
|
| 52 |
+
assert response.status_code == 200
|
| 53 |
+
data = response.json()
|
| 54 |
+
assert data['decision_id'].startswith('MZS-')
|
| 55 |
+
assert data['risk_band'] in {'LOW', 'MEDIUM', 'HIGH', 'DECLINE'}
|
| 56 |
+
assert data['recommendation'] in {'APPROVE', 'REVIEW', 'DECLINE'}
|
| 57 |
+
assert len(data['shap_top3']) == 3
|
tests/test_scorer.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from api.scorer import ModelScorer
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
SAMPLE_PAYLOAD = {
|
| 5 |
+
'gross_income': 25000,
|
| 6 |
+
'net_income': 19000,
|
| 7 |
+
'living_expenses': 7000,
|
| 8 |
+
'credit_score': 640,
|
| 9 |
+
'age': 34,
|
| 10 |
+
'monthly_instalment': 2800,
|
| 11 |
+
'credit_bureau_monthly_debt': 2500,
|
| 12 |
+
'loan_amount_requested': 50000,
|
| 13 |
+
'loan_term_months': 24,
|
| 14 |
+
'province': 'Gauteng',
|
| 15 |
+
'employment_type': 'permanent',
|
| 16 |
+
'education_level': 'diploma',
|
| 17 |
+
'dependants': 1,
|
| 18 |
+
'residence_type': 'rented_formal',
|
| 19 |
+
'loan_product_type': 'personal_loan',
|
| 20 |
+
'salary_frequency': 'Monthly',
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_model_scorer_returns_expected_payload_shape():
|
| 25 |
+
scorer = ModelScorer()
|
| 26 |
+
result = scorer.score(SAMPLE_PAYLOAD)
|
| 27 |
+
|
| 28 |
+
assert result['decision_id'].startswith('MZS-')
|
| 29 |
+
assert 0 <= result['default_probability'] <= 1
|
| 30 |
+
assert 0 <= result['risk_score'] <= 999
|
| 31 |
+
assert result['risk_band'] in {'LOW', 'MEDIUM', 'HIGH', 'DECLINE'}
|
| 32 |
+
assert result['recommendation'] in {'APPROVE', 'REVIEW', 'DECLINE'}
|
| 33 |
+
assert result['nca_pass'] in {0, 1}
|
| 34 |
+
assert len(result['shap_top3']) == 3
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_model_info_contains_core_metrics():
|
| 38 |
+
scorer = ModelScorer()
|
| 39 |
+
info = scorer.model_info()
|
| 40 |
+
|
| 41 |
+
assert info['best_model']
|
| 42 |
+
assert info['holdout_auc'] > 0.5
|
| 43 |
+
assert info['holdout_brier'] >= 0
|
| 44 |
+
assert isinstance(info['top_features'], list)
|